diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index e7acda1..ee3d1d8 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -1,39 +1,38 @@ -name: "Copilot Setup Steps" +name: 'Copilot Setup Steps' # Automatically run the setup steps when they are changed to allow for easy validation, and # allow manual testing through the repository's "Actions" tab on: - workflow_dispatch: - push: - paths: - - .github/workflows/copilot-setup-steps.yml - pull_request: - paths: - - .github/workflows/copilot-setup-steps.yml + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml jobs: - # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. - copilot-setup-steps: - runs-on: ubuntu-latest + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest - # Set the permissions to the lowest permissions possible needed for your steps. - # Copilot will be given its own token for its operations. - permissions: - # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. - # If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. - contents: read + # Set the permissions to the lowest permissions possible needed for your steps. + # Copilot will be given its own token for its operations. + permissions: + contents: read + workflows: write - steps: - - name: Checkout code - uses: actions/checkout@v6 + steps: + - name: Checkout code + uses: actions/checkout@v6 - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: latest + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest - - name: Install dependencies - run: bun install + - name: Install dependencies + run: bun install - - name: Install playwright dependencies - run: bun run install:playwright + - name: Install playwright dependencies + run: bun run install:playwright diff --git a/.github/workflows/editors-picks.yml b/.github/workflows/editors-picks.yml index 3745ac8..0ce8f3f 100644 --- a/.github/workflows/editors-picks.yml +++ b/.github/workflows/editors-picks.yml @@ -25,7 +25,7 @@ jobs: echo "Files changed in this commit:" echo "$CHANGED" if echo "$CHANGED" | grep -qE '^public/editors-picks\.json$|^public/editors-picks-old/'; then - echo "Detected changes to generated files in this commit — backing off to avoid overwriting manual edits." + echo "Detected changes to generated files in this commit - backing off to avoid overwriting manual edits." echo "skip=true" >> "$GITHUB_OUTPUT" else echo "skip=false" >> "$GITHUB_OUTPUT" @@ -37,6 +37,10 @@ jobs: with: python-version: '3.x' + - name: Install webp + if: steps.backoff.outputs.skip == 'false' + run: sudo apt-get update && sudo apt-get install -y webp + - name: Archive current editors picks if: steps.backoff.outputs.skip == 'false' run: | @@ -60,9 +64,19 @@ jobs: label = m.group(1).strip() break - # Copy current picks to archive + # Copy current picks to archive, replacing monochrome URLs with UUIDs with open("public/editors-picks.json") as f: current = json.load(f) + + # Replace cover URLs with original UUIDs + url_pattern = re.compile(r'^https://monochrome\.tf/editors-picks-images/([a-f0-9-]+)\.webp$') + for item in current: + for field in ['cover', 'picture', 'image']: + if field in item and item[field]: + match = url_pattern.match(item[field]) + if match: + item[field] = match.group(1) + with open(archive_path, "w") as f: json.dump(current, f, indent=4) @@ -89,7 +103,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add public/editors-picks.json public/editors-picks-old/ + git add public/editors-picks.json public/editors-picks-old/ public/editors-picks-images/ git diff --staged --quiet && echo "No changes to commit." && exit 0 git commit -m "chore: update editors picks" git push diff --git a/editors-picks-input.txt b/editors-picks-input.txt index 6d8a390..b365994 100644 --- a/editors-picks-input.txt +++ b/editors-picks-input.txt @@ -20,4 +20,5 @@ album:423471869 album:250986538 album:509761344 album:15621057 -album:103897783 \ No newline at end of file +album:103897783 +album:151728406 \ No newline at end of file diff --git a/gen-editors-picks.py b/gen-editors-picks.py index 95e102e..e6404e0 100644 --- a/gen-editors-picks.py +++ b/gen-editors-picks.py @@ -7,12 +7,17 @@ import re import sys import hashlib import time +import os +import subprocess +import shutil +import tempfile INPUT_FILE = "editors-picks-input.txt" +IMAGES_DIR = "public/editors-picks-images" COUNTRY = "US" -# Tidal internal token — replace when expired -TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MjQ2NzQ2LCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.ksUE4yhQ39IG7oHWk8DyJ91dwIoDVWGzvTAnpeDJ5p-_Gp0F_yO858xDO11AINBaahQCq0jlbqWqIaTqCTOjqg" +# Tidal internal token replace when expired +TIDAL_TOKEN = "eyJraWQiOiJ2OU1GbFhqWSIsImFsZyI6IkVTMjU2In0.eyJ0eXBlIjoibzJfYWNjZXNzIiwic2NvcGUiOiIiLCJnVmVyIjowLCJzVmVyIjowLCJjaWQiOjEzNTU3LCJhdCI6IklOVEVSTkFMIiwiZXhwIjoxNzc1MzY0MTQwLCJpc3MiOiJodHRwczovL2F1dGgudGlkYWwuY29tL3YxIn0.6ui6itHVQ-OXPF0F9mbf5KcKz1fKYJNsa1vBAj60upXpcN-DQG8JPKBlqJN6RuBEH8yhwYj2wh4YJ-TOOuO8DA" TIDAL_HEADERS = { "accept": "*/*", @@ -83,6 +88,61 @@ def fetch_podcast(feed_id): return podcast_get(f"/podcasts/byfeedid?id={feed_id}&pretty") +# ── Image processing ─────────────────────────────────────────────────────────── + +def clear_images_dir(): + if os.path.exists(IMAGES_DIR): + shutil.rmtree(IMAGES_DIR) + os.makedirs(IMAGES_DIR, exist_ok=True) + + +def is_uuid_cover(cover_value): + if not cover_value: + return False + return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', cover_value)) + + +def uuid_to_path_segments(uuid): + return uuid.replace('-', '/') + + +def download_and_process_cover(cover_uuid): + url = f"https://resources.tidal.com/images/{uuid_to_path_segments(cover_uuid)}/320x320.jpg" + + with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp: + tmp_path = tmp.name + + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req) as resp: + with open(tmp_path, 'wb') as f: + shutil.copyfileobj(resp, f) + + output_path = os.path.join(IMAGES_DIR, f"{cover_uuid}.webp") + + subprocess.run( + ['cwebp', '-q', '50', tmp_path, '-o', output_path], + check=True, + capture_output=True + ) + + return f"https://monochrome.tf/editors-picks-images/{cover_uuid}.webp" + except Exception as e: + print(f"Error processing cover {cover_uuid}: {e}", file=sys.stderr) + return None + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +def process_cover(cover_value): + if not cover_value: + return cover_value + if is_uuid_cover(cover_value): + return download_and_process_cover(cover_value) + return cover_value + + # ── Transformers ────────────────────────────────────────────────────────────── def transform_album(d): @@ -95,7 +155,7 @@ def transform_album(d): "name": d.get("artist", {}).get("name"), }, "releaseDate": d.get("releaseDate"), - "cover": d.get("cover"), + "cover": process_cover(d.get("cover")), "explicit": d.get("explicit"), "audioQuality": d.get("audioQuality"), "mediaMetadata": d.get("mediaMetadata"), @@ -107,7 +167,7 @@ def transform_artist(d): "type": "artist", "id": d.get("id"), "name": d.get("name"), - "picture": d.get("picture"), + "picture": process_cover(d.get("picture")), } @@ -124,7 +184,7 @@ def transform_track(d): "album": { "id": album.get("id"), "title": album.get("title"), - "cover": album.get("cover"), + "cover": process_cover(album.get("cover")), }, "duration": d.get("duration"), "explicit": d.get("explicit"), @@ -140,7 +200,7 @@ def transform_playlist(d): "type": "playlist", "id": d.get("uuid"), "title": d.get("title"), - "cover": cover, + "cover": process_cover(cover), "numberOfTracks": d.get("numberOfTracks", 0), } @@ -153,7 +213,7 @@ def transform_userplaylist(d): "type": "user-playlist", "id": d.get("uuid"), "name": d.get("title"), - "cover": cover, + "cover": process_cover(cover), "numberOfTracks": d.get("numberOfTracks", 0), "username": creator.get("name"), } @@ -198,6 +258,8 @@ def read_items(path): # ── Main ────────────────────────────────────────────────────────────────────── +clear_images_dir() + FETCHERS = { "album": (fetch_album, transform_album), "artist": (fetch_artist, transform_artist), @@ -212,7 +274,7 @@ picks = [] for item_type, item_id in items: if item_type not in FETCHERS: - print(f"Unknown type '{item_type}' for id {item_id!r} — skipping", file=sys.stderr) + print(f"Unknown type '{item_type}' for id {item_id!r} - skipping", file=sys.stderr) continue fetch_fn, transform_fn = FETCHERS[item_type] data = fetch_fn(item_id) diff --git a/index.html b/index.html index 8ba694e..d6b1c6e 100644 --- a/index.html +++ b/index.html @@ -3,24 +3,79 @@ - Monochrome Music + Monochrome + - + + + + + + + + + + + + + + + + + + + + + + + - - - - diff --git a/js/app.js b/js/app.js index 93022c1..74db58a 100644 --- a/js/app.js +++ b/js/app.js @@ -127,32 +127,36 @@ async function loadDownloadsModule() { } async function fetchcontributors() { - const response = await fetch('https://api.samidy.com/api/contributors'); - const data1 = await response.json(); + try { + const response = await fetch('https://api.samidy.com/api/contributors'); + if (!response.ok) return; + const data1 = await response.json(); - const data = data1.filter( - (user) => user.type !== 'Bot' && user.login !== 'edidealt' && user.login !== 'satanyahoo' - ); + const data = data1.filter( + (user) => user.type !== 'Bot' && user.login !== 'edidealt' && user.login !== 'satanyahoo' + ); - const edideaur = data.find((user) => user.login === 'edideaur'); - if (edideaur) { - edideaur.contributions += data1.find((u) => u.login === 'edidealt')?.contributions || 0; - edideaur.contributions += data1.find((u) => u.login === 'satanyahoo')?.contributions || 0; - } + const edideaur = data.find((user) => user.login === 'edideaur'); + if (edideaur) { + edideaur.contributions += data1.find((u) => u.login === 'edidealt')?.contributions || 0; + edideaur.contributions += data1.find((u) => u.login === 'satanyahoo')?.contributions || 0; + } - const con = document.querySelector('.about-contributors'); + const con = document.querySelector('.about-contributors'); + if (!con) return; - data.forEach((user) => { - const userDIV = document.createElement('div'); - userDIV.innerHTML = ` - - ${user.login} - ${user.login} - Contributions: ${user.contributions} - - `; - con.appendChild(userDIV); - }); + data.forEach((user) => { + const userDIV = document.createElement('div'); + userDIV.innerHTML = ` + + ${user.login} + ${user.login} + Contributions: ${user.contributions} + + `; + con.appendChild(userDIV); + }); + } catch (e) {} } async function loadMetadataModule() { @@ -500,7 +504,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize tracker initTracker().catch(console.error); - fetchcontributors(); + await fetchcontributors(); const castBtn = document.getElementById('cast-btn'); initializeCasting(audioPlayer, castBtn); diff --git a/js/lyrics.js b/js/lyrics.js index e59ffae..484c5c1 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -278,13 +278,13 @@ export class LyricsManager { // Load Kuroshiro from CDN if (!window.Kuroshiro) { - await this.loadScript('https://unpkg.com/kuroshiro@1.2.0/dist/kuroshiro.min.js'); + await this.loadScript('https://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshiro.min.js'); } // Load Kuromoji analyzer from CDN if (!window.KuromojiAnalyzer) { await this.loadScript( - 'https://unpkg.com/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js' + 'https://cdn.jsdelivr.net/npm/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js' ); } diff --git a/js/player.js b/js/player.js index f39bc67..5b779a0 100644 --- a/js/player.js +++ b/js/player.js @@ -487,7 +487,7 @@ export class Player { const timeRemaining = duration - currentTime; // Preload if we are in last 30 seconds of song - const shouldPreload = (duration > 0 && timeRemaining <= 30); + const shouldPreload = duration > 0 && timeRemaining <= 30; if (shouldPreload) { this._pendingPreload = false; @@ -537,7 +537,7 @@ export class Player { albumPeakAmplitude: trackData.info.albumPeakAmplitude, }; } - } catch(e) {} // Fail silently + } catch (_e) {} // Fail silently } this.preloadCache.set(track.id, streamInfo); @@ -546,30 +546,45 @@ export class Player { // Warm connection and pre-fetch if (!streamUrl.startsWith('blob:')) { if (streamUrl.includes('.mpd') || streamUrl.includes('.m3u8')) { - if (this.shakaInitialized && this.shakaPlayer && typeof this.shakaPlayer.preload === 'function') { + if ( + this.shakaInitialized && + this.shakaPlayer && + typeof this.shakaPlayer.preload === 'function' + ) { try { let preloadConfig = undefined; if (typeof this.shakaPlayer.getConfiguration === 'function') { preloadConfig = this.shakaPlayer.getConfiguration(); - const stats = typeof this.shakaPlayer.getStats === 'function' ? this.shakaPlayer.getStats() : null; + const stats = + typeof this.shakaPlayer.getStats === 'function' + ? this.shakaPlayer.getStats() + : null; if (stats && stats.estimatedBandwidth) { preloadConfig.abr.defaultBandwidthEstimate = stats.estimatedBandwidth; } - + // Lock the preload to the exact current audio codec to prevent ABR mismatch, // which forces the player to discard and re-fetch chunks on slow connections. preloadConfig.abr.enabled = false; try { - const variants = typeof this.shakaPlayer.getVariantTracks === 'function' ? this.shakaPlayer.getVariantTracks() : []; - const activeVariant = variants.find(v => v.active); + const variants = + typeof this.shakaPlayer.getVariantTracks === 'function' + ? this.shakaPlayer.getVariantTracks() + : []; + const activeVariant = variants.find((v) => v.active); if (activeVariant && activeVariant.audioCodec) { preloadConfig.preferredAudioCodecs = [activeVariant.audioCodec]; } - } catch (e) {} + } catch (_e) {} } - const preloadManager = await this.shakaPlayer.preload(streamUrl, null, null, preloadConfig); + const preloadManager = await this.shakaPlayer.preload( + streamUrl, + null, + null, + preloadConfig + ); streamInfo.preloadManager = preloadManager; - } catch (e) { + } catch (_e) { // Ignore preload errors, will just load fresh } } else { @@ -801,7 +816,7 @@ export class Player { this.hls.destroy(); this.hls = null; } - + // Retain the initialized Shaka player if we are remaining on the same HTMLMediaElement if (this.shakaInitialized && this.shakaPlayer) { if (this.shakaPlayer.getMediaElement() !== activeElement) { @@ -1015,14 +1030,17 @@ export class Player { } else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) { await this.shakaPlayer.attach(activeElement); - const loadTarget = track.type == 'video' && this.preloadCache.has(track.id) ? - (this.preloadCache.get(track.id).preloadManager || streamUrl) : streamUrl; + const loadTarget = + track.type == 'video' && this.preloadCache.has(track.id) + ? this.preloadCache.get(track.id).preloadManager || streamUrl + : streamUrl; try { await this.shakaPlayer.load(loadTarget); } catch (e) { - console.error("PreloadManager load Error:", e); if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl); - else throw e; + console.error('PreloadManager load Error:', e); + if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl); + else throw e; } this.shakaInitialized = true; @@ -1097,8 +1115,9 @@ export class Player { await this.shakaPlayer.load(loadTarget); } } catch (e) { - console.error("PreloadManager load Error:", e); if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl); - else throw e; + console.error('PreloadManager load Error:', e); + if (loadTarget !== streamUrl) await this.shakaPlayer.load(streamUrl); + else throw e; } this.shakaInitialized = true; @@ -1109,7 +1128,7 @@ export class Player { this.updateAdaptiveQualityBadge(); - // Instantly trigger playback rather than explicitly waiting for 'canplay' + // Instantly trigger playback rather than explicitly waiting for 'canplay' // which delays the event loop and natively adds gap/latency await this.safePlay(activeElement); } else { diff --git a/js/taglib.worker.ts b/js/taglib.worker.ts index 71a0183..cd1644d 100644 --- a/js/taglib.worker.ts +++ b/js/taglib.worker.ts @@ -145,7 +145,7 @@ export async function addMetadataToAudio(message: _AddMetadataMessage): Promise< if (explicit !== undefined) { if (isMp4) { - // rtng is a byte item — must be set directly on the Mp4Tag + // rtng is a byte item - must be set directly on the Mp4Tag const mp4Tag = underlying.tag(); mp4Tag.setItem('rtng', Mp4Item.fromByte(explicit ? 1 : 0)); } else { diff --git a/public/assets/banner-twitter.jpg b/public/assets/banner-twitter.jpg new file mode 100644 index 0000000..83a9ec3 Binary files /dev/null and b/public/assets/banner-twitter.jpg differ diff --git a/public/assets/banner.jpg b/public/assets/banner.jpg new file mode 100644 index 0000000..95f786d Binary files /dev/null and b/public/assets/banner.jpg differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e81a557 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,11 @@ +# robots.txt for https://monochrome.tf/ + +User-agent: * +Allow: / + +# Avoid indexing internal endpoints and auth flows. +Disallow: /functions/ +Disallow: /api/ +Disallow: /auth/ + +Sitemap: https://monochrome.tf/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..dcfdecf --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,43 @@ + + + + https://monochrome.tf/ + daily + 1.0 + + + https://monochrome.tf/search + daily + 0.8 + + + https://monochrome.tf/library + daily + 0.8 + + + https://monochrome.tf/recent + daily + 0.7 + + + https://monochrome.tf/podcasts + daily + 0.7 + + + https://monochrome.tf/unreleased + daily + 0.7 + + + https://monochrome.tf/parties + weekly + 0.6 + + + https://monochrome.tf/donate + monthly + 0.5 + + diff --git a/styles.css b/styles.css index ce49775..2d3d2e4 100644 --- a/styles.css +++ b/styles.css @@ -1763,6 +1763,10 @@ input[type='search']::-webkit-search-cancel-button { .settings-tab-content { display: none; + max-width: 100%; + min-width: 0; + overflow-x: auto; + overflow-y: visible; } .settings-tab-content.active { @@ -1770,14 +1774,6 @@ input[type='search']::-webkit-search-cancel-button { animation: fade-in-slide-up 0.4s var(--ease-out-back); } -/* Prevent settings tab content from overflowing on small displays (fixes #313) */ -.settings-tab-content { - max-width: 100%; - min-width: 0; - overflow-x: auto; - overflow-y: visible; -} - .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); @@ -4301,6 +4297,7 @@ input:checked + .slider::before { justify-content: center; gap: 1rem; margin-top: 1rem; + position: relative; } .fs-volume-btn { @@ -5910,10 +5907,6 @@ img[src=''] { color: white; } -#fullscreen-cover-overlay.is-video-mode .fullscreen-volume-container { - margin-top: 0.5rem; -} - #fullscreen-cover-overlay.ui-hidden .fullscreen-main-view, #fullscreen-cover-overlay.ui-hidden .fullscreen-controls, #fullscreen-cover-overlay.ui-hidden #fullscreen-next-track, @@ -9881,10 +9874,10 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn { .chat-msg { margin-bottom: 0.5rem; - animation: fadeIn 0.2s ease-out; + animation: fade-in 0.2s ease-out; } -@keyframes fadeIn { +@keyframes fade-in { from { opacity: 0; transform: translateY(5px);