6.7 KiB
6.7 KiB
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 returnsHTTP 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
- Live list:
discover_instances()fetches the officialINSTANCES.md(INSTANCES_MD_URL) and parses backtick-wrapped URLs from its "## API Instances" section. (The old uptime-monitor workertidal-uptime.jiffy-puffs-1j.workers.devis dead — 404 — and has been removed.) - 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 manifestdecodes to: JSON{"mimeType":"audio/flac","codecs":"flac","urls":["https://..."]}- Alternative:
OriginalTrackUrlfield (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)
- JSON (most common):
{"mimeType":"audio/flac","urls":["https://lgf.audio.tidal.com/..."]}— useurls[0] - DASH XML: Contains
<MPD>— extract<BaseURL>if present, otherwise needs dash.js (unsupported in CLI) - 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
coverfield is a UUID liked8170d28-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,volumeNumberartist:{"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[], notdatadirectly
Frontend Retry Logic
- Randomize instance order
- Try each instance up to
instances.length * 2times - 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
- Parse Spotify URL — regex extracts type (
track/album/playlist) and ID - Scrape metadata — fetches
https://open.spotify.com/embed/{type}/{id}, extracts__NEXT_DATA__JSON from HTML - Extract tracks — navigates
props.pageProps.state.data.entityfor title/artist- Track:
name+artists[0].name(orsubtitle) - Album/Playlist:
trackList[]array withtitle+subtitleper track
- Track:
- Fallback — if embed scraping fails, uses oEmbed API (
/oembed?url=...) for single tracks (title only, no artist separation) - Search Monochrome —
GET {instance}/search/?s={artist}+{title}, unwrap envelope, getdata.items[] - 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__JSONfetch_spotify_oembed(sp_type, sp_id)— oEmbed API fallbackextract_tracks(embed_data, sp_type, sp_id)— navigate JSON → list of{title, artist}normalize(text)— lowercase, strip feat/remaster/punctuationsimilarity(a, b)— Jaccard token overlap ratiofind_best_match(results, title, artist, threshold)— weighted scoring of search resultssearch_monochrome(instances, query)— search API with envelope unwrapping- Shared utilities copied from download.py:
fetch(),fetch_json(),discover_instances()
Key Gotchas
- Spotify embed page structure (
__NEXT_DATA__) is fragile — may break if Spotify redesigns - oEmbed fallback only works for single tracks, not albums/playlists
- Remixes and live versions often fail to match (different titles on Spotify vs Tidal)
- 0.5s delay between searches to avoid Monochrome 429 rate limits
- All progress/errors go to stderr; only track IDs go to stdout (for piping)