Files
trackpull/docs/monochrome.md
T
2026-08-16 23:29:15 +02:00

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 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 (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 <MPD> — extract <BaseURL> 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)