# Monochrome API Architecture ## How It Works Monochrome is a web frontend that proxies audio from Tidal/Qobuz through distributed API instances. It does NOT host audio itself. > **⚠️ Ecosystem overhaul (2026).** The Monochrome project moved off the open "hifi-api" model this package targets. The official app now uses a **Turnstile-gated "Unified Playback API"** (`music-api.geeked.wtf`, resolving Amazon Music + Tidal) whose download endpoint returns `HTTP 428 turnstile_required` — a Cloudflare CAPTCHA a headless client cannot solve — and the official docs state self-hosters cannot stream. This package therefore relies on the remaining **open, hifi-api-style community instances** (Lucida/QQDL, Kinoplus, samidy), which still speak `/search/` and `/track/`. See the gotcha below. ## Instance Discovery 1. **Live list**: `discover_instances()` fetches the official [`INSTANCES.md`](https://github.com/monochrome-music/monochrome/blob/main/INSTANCES.md) (`INSTANCES_MD_URL`) and parses backtick-wrapped URLs from its **"## API Instances"** section. (The old uptime-monitor worker `tidal-uptime.jiffy-puffs-1j.workers.dev` is dead — 404 — and has been removed.) 2. **Hardcoded fallbacks** (`FALLBACK_INSTANCES`, used only if the INSTANCES.md fetch fails; kept in sync with the API-instance section manually): - `https://monochrome-api.samidy.com` (official API — **serves search but 403-gates downloads**) - `https://wolf.qqdl.site`, `https://maus.qqdl.site`, `https://vogel.qqdl.site`, `https://katze.qqdl.site`, `https://hund.qqdl.site` (Lucida/QQDL, Cloudflare-fronted) - `https://tidal.kinoplus.online` (Kinoplus) ## API Endpoints (on any instance) ### Stream/download: `GET /track/?id={trackId}&quality={quality}` - **Response envelope**: `{"version": "2.x", "data": { ... }}` - **Inside `data`**: `manifest` (base64), `audioQuality`, `trackId`, replay gain fields - **`manifest` decodes to**: JSON `{"mimeType":"audio/flac","codecs":"flac","urls":["https://..."]}` - Alternative: `OriginalTrackUrl` field (direct URL, skip manifest) ### Metadata: `GET /info/?id={trackId}` - Same envelope wrapping - Returns: title, duration, artist, artists[], album (with cover UUID), trackNumber, volumeNumber, copyright, isrc, streamStartDate, bpm, key, explicit, etc. ### Search: `GET /search/?s={query}` (tracks), `?a=` (artists), `?al=` (albums), `?p=` (playlists) ### Album: `GET /album/?id={albumId}&offset={n}&limit=500` ### Qobuz alternative: `https://qobuz.squid.wtf/api` (⚠️ currently dead — squid.wtf stopped hosting; kept as a code path in case it returns) - Search: `/get-music?q={query}` - Stream: `/download-music?track_id={id}&quality={qobuzQuality}` - Quality mapping: 27=MP3_320, 7=FLAC, 6=HiRes96/24, 5=HiRes192/24 - Track IDs prefixed with `q:` in the frontend (e.g. `q:12345`) ## Quality Levels (Tidal instances) | Quality param | What you get | Works? | |-------------------|-----------------------|--------| | HI_RES_LOSSLESS | Best available (FLAC) | Yes | | HI_RES | FLAC | Yes | | LOSSLESS | 16-bit/44.1kHz FLAC | Yes | | HIGH | AAC 320kbps | Yes | | LOW | AAC 96kbps | Yes | | MP3_320 | N/A | **404** — not a valid API quality | ## Manifest Decoding (3 types) 1. **JSON** (most common): `{"mimeType":"audio/flac","urls":["https://lgf.audio.tidal.com/..."]}` — use `urls[0]` 2. **DASH XML**: Contains `` — extract `` if present, otherwise needs dash.js (unsupported in CLI) 3. **Raw URL**: Just a URL string in the decoded base64 ## Cover Art - Source: Tidal CDN - URL pattern: `https://resources.tidal.com/images/{cover_uuid_with_slashes}/{size}x{size}.jpg` - The album `cover` field is a UUID like `d8170d28-d09b-400a-ae83-6c9dea002b4d` - Replace `-` with `/` to form the path: `d8170d28/d09b/400a/ae83/6c9dea002b4d` - Common sizes: 80, 160, 320, 640, 1280 ## Search Response Structure - **Endpoint**: `GET /search/?s={query}` - **Response envelope**: `{"version": "2.x", "data": {"limit": 25, "offset": 0, "totalNumberOfItems": N, "items": [...]}}` - **Each item** in `items[]`: - `id` (Tidal track ID), `title`, `duration`, `trackNumber`, `volumeNumber` - `artist`: `{"id": N, "name": "...", "picture": "uuid"}` - `artists`: array of artist objects (same shape) - `album`: `{"id": N, "title": "...", "cover": "uuid"}` - `isrc`, `copyright`, `bpm`, `key`, `explicit`, `audioQuality`, `popularity` - **Important**: results are inside `data.items[]`, not `data` directly ## Frontend Retry Logic - Randomize instance order - Try each instance up to `instances.length * 2` times - 429 (rate limit): 500ms delay, next instance - 401/5xx: next instance - Network error: 200ms delay, next instance ## Spotify URL Converter (spotify_to_ids.py) ### How It Works 1. **Parse Spotify URL** — regex extracts type (`track`/`album`/`playlist`) and ID 2. **Scrape metadata** — fetches `https://open.spotify.com/embed/{type}/{id}`, extracts `__NEXT_DATA__` JSON from HTML 3. **Extract tracks** — navigates `props.pageProps.state.data.entity` for title/artist - Track: `name` + `artists[0].name` (or `subtitle`) - Album/Playlist: `trackList[]` array with `title` + `subtitle` per track 4. **Fallback** — if embed scraping fails, uses oEmbed API (`/oembed?url=...`) for single tracks (title only, no artist separation) 5. **Search Monochrome** — `GET {instance}/search/?s={artist}+{title}`, unwrap envelope, get `data.items[]` 6. **Fuzzy match** — normalize strings (strip feat/remaster/punctuation), Jaccard token overlap, weighted 60% title + 40% artist ### Code Structure (spotify_to_ids.py) - `parse_spotify_url(url)` — regex URL parsing → `(type, id)` - `fetch_spotify_embed(sp_type, sp_id)` — scrape embed page `__NEXT_DATA__` JSON - `fetch_spotify_oembed(sp_type, sp_id)` — oEmbed API fallback - `extract_tracks(embed_data, sp_type, sp_id)` — navigate JSON → list of `{title, artist}` - `normalize(text)` — lowercase, strip feat/remaster/punctuation - `similarity(a, b)` — Jaccard token overlap ratio - `find_best_match(results, title, artist, threshold)` — weighted scoring of search results - `search_monochrome(instances, query)` — search API with envelope unwrapping - Shared utilities copied from download.py: `fetch()`, `fetch_json()`, `discover_instances()` ### Key Gotchas 1. Spotify embed page structure (`__NEXT_DATA__`) is fragile — may break if Spotify redesigns 2. oEmbed fallback only works for single tracks, not albums/playlists 3. Remixes and live versions often fail to match (different titles on Spotify vs Tidal) 4. 0.5s delay between searches to avoid Monochrome 429 rate limits 5. All progress/errors go to stderr; only track IDs go to stdout (for piping)