From 1319121a4fec97dec95c120aa851cb477c127007 Mon Sep 17 00:00:00 2001 From: Benjamin Hardy Date: Sun, 16 Aug 2026 23:29:15 +0200 Subject: [PATCH] Updated monochrome and votify --- CLAUDE.md | 3 +- Dockerfile | 6 ++- app.py | 31 ++++++++------- docs/docker-deployment.md | 2 +- docs/monochrome.md | 23 ++++++----- docs/votify.md | 45 ++++++++++++++++++--- monochrome/__init__.py | 84 ++++++++++++++++++++++++--------------- 7 files changed, 130 insertions(+), 64 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d7b6bfb..66eb553 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ app.py (Flask, served by Gunicorn: 1 worker / 4 threads) | Backend | Route | Runner in app.py | Mechanism | |---------|-------|------------------|-----------| -| Votify | `POST /api/download` | `run_download()` | `subprocess.Popen` on the `votify` CLI (votify-fix), needs `cookies.txt` | +| Votify | `POST /api/download` | `run_download()` | `subprocess.Popen` on the `votify` CLI (official glomatico/votify), needs `cookies.txt` | | Monochrome | `POST /api/monochrome/download` | `run_monochrome_download()` | in-process Python, proxies Tidal/Qobuz, no credentials | | Unified (default) | `POST /api/unified/download` | `run_unified_download()` | Monochrome at `MP3_320`, then spawns a **separate** Votify job for `fail_info["failed_urls"]` | @@ -55,6 +55,7 @@ Votify accepts a list of URLs; Monochrome and Unified take a **single** URL per - The Unified fallback writes into the *same* subfolder Monochrome created (`fail_info["subfolder"]`) and forces `output_format: mp3`. - `/api/artwork` scrapes `entity.visualIdentity.image[]` from the Spotify embed page, then rewrites the CDN filename's first 16 hex chars to the `ab67616d000082c1` size key to upscale to 2000×2000. Spotify embed structure (`__NEXT_DATA__`) is fragile and can break without notice. - Monochrome instances are third-party and go down regularly. `MP3_320` is **not** a valid Tidal quality param (404s) — it is only meaningful as a Qobuz mapping / post-conversion target. +- **Monochrome overhauled its ecosystem (2026).** The official app moved to a Turnstile-gated "Unified Playback API" (`music-api.geeked.wtf`) that a headless client can't use (download endpoint → `HTTP 428 turnstile_required`). This package deliberately stays on the *open, hifi-api-style* community instances (Lucida/QQDL, Kinoplus, samidy). `discover_instances()` now parses `INSTANCES_MD_URL` (the repo's `INSTANCES.md` "## API Instances" section) — the old uptime-monitor worker is dead (404). `monochrome-api.samidy.com` serves **search** but 403-gates downloads, so the download step depends on the QQDL/Kinoplus instances being reachable from the host. The `qobuz.squid.wtf` fallback is currently dead too. See [docs/monochrome.md](docs/monochrome.md). ## Development diff --git a/Dockerfile b/Dockerfile index b532756..ee04883 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,10 @@ RUN curl -L -o /tmp/bento4.zip https://www.bok.net/Bento4/binaries/Bento4-SDK-1- && chmod +x /usr/local/bin/mp4decrypt \ && rm -rf /tmp/bento4 /tmp/bento4.zip -# Install votify-fix -RUN pip install --no-cache-dir websocket-client git+https://github.com/GladistonXD/votify-fix.git +# Install votify (official, glomatico). The [librespot] extra is required for the +# default --session-type librespot. +RUN pip install --no-cache-dir websocket-client \ + "votify[librespot] @ git+https://github.com/glomatico/votify.git" # Install web app dependencies COPY requirements.txt /app/requirements.txt diff --git a/app.py b/app.py index b4abffb..464eb6a 100644 --- a/app.py +++ b/app.py @@ -170,16 +170,19 @@ def run_download(job_id: str, urls: list[str], options: dict, effective_output = output_path or str(user_dir) cmd = ["votify"] + # Ignore ~/.votify/config.ini so job behaviour depends only on these flags. + cmd.append("--no-config-file") cmd.extend(["--cookies-path", str(COOKIES_PATH)]) - cmd.extend(["--output-path", effective_output]) - cmd.extend(["--temp-path", str(TEMP_DIR)]) + cmd.extend(["--output", effective_output]) + cmd.extend(["--temp", str(TEMP_DIR)]) if WVD_PATH.exists(): cmd.extend(["--wvd-path", str(WVD_PATH)]) - cmd.extend(["--template-folder-album", "."]) - cmd.extend(["--template-folder-compilation", "."]) - cmd.extend(["--template-folder-episode", "."]) - cmd.extend(["--template-folder-music-video", "."]) + # Download flat into the output dir; post_process_votify_files() handles layout. + cmd.extend(["--album-folder-template", "."]) + cmd.extend(["--compilation-folder-template", "."]) + cmd.extend(["--podcast-folder-template", "."]) + cmd.extend(["--no-album-folder-template", "."]) quality = options.get("audio_quality", "aac-medium") if quality: @@ -187,7 +190,7 @@ def run_download(job_id: str, urls: list[str], options: dict, download_mode = options.get("download_mode", "ytdlp") if download_mode: - cmd.extend(["--download-mode", download_mode]) + cmd.extend(["--audio-download-mode", download_mode]) video_format = options.get("video_format", "mp4") if video_format: @@ -198,17 +201,17 @@ def run_download(job_id: str, urls: list[str], options: dict, cmd.extend(["--cover-size", cover_size]) if options.get("save_cover"): - cmd.append("--save-cover") + cmd.append("--save-cover-file") if options.get("save_playlist"): - cmd.append("--save-playlist") + cmd.append("--save-playlist-file") if options.get("overwrite"): cmd.append("--overwrite") if options.get("download_music_videos"): - cmd.append("--download-music-videos") + cmd.append("--prefer-video") if options.get("save_lrc"): - cmd.append("--lrc-only") + cmd.append("--synced-lyrics-only") if options.get("no_lrc"): - cmd.append("--no-lrc") + cmd.append("--no-synced-lyrics-file") truncate = options.get("truncate") if truncate: @@ -649,9 +652,9 @@ def get_artwork(): best = max(images, key=lambda img: img.get("maxWidth", 0)) url = best["url"] - # Upscale to 2000×2000 using the same CDN key technique as votify-fix. + # Upscale to 2000×2000 using the same CDN key technique as votify. # Spotify CDN filenames: first 16 hex chars = size key, remainder = image hash. - # Source: GladistonXD/votify-fix constants.py COVER_SIZE_X_KEY_MAPPING_SONG + # Source: glomatico/votify votify/interface/constants.py COVER_SIZE_ID_MAP_SONG EXTRA_LARGE_KEY = "ab67616d000082c1" parts = url.rsplit("/", 1) if len(parts) == 2 and len(parts[1]) >= 16: diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index d55b12e..1585371 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -65,7 +65,7 @@ Both directories are created automatically by Docker if they don't exist. **Python packages**: - From `requirements.txt`: Flask, gunicorn, mutagen, werkzeug, etc. - `websocket-client` — WebSocket support -- `votify-fix` — Spotify downloader (installed from GitHub: GladistonXD/votify-fix) +- `votify` — Spotify downloader (installed from GitHub: glomatico/votify, with the `[librespot]` extra) **Runtime command**: ``` diff --git a/docs/monochrome.md b/docs/monochrome.md index e8b087e..b4e4d2e 100644 --- a/docs/monochrome.md +++ b/docs/monochrome.md @@ -3,15 +3,20 @@ ## 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. **Uptime monitor**: `https://tidal-uptime.jiffy-puffs-1j.workers.dev/` — returns list of live instances -2. **Hardcoded fallbacks** (some may go down over time): - - `https://monochrome.tf` - - `https://triton.squid.wtf` - - `https://qqdl.site` - - `https://monochrome.samidy.com` - - `https://api.monochrome.tf` -3. More instances listed at: https://github.com/monochrome-music/monochrome/blob/main/INSTANCES.md +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) @@ -29,7 +34,7 @@ Monochrome is a web frontend that proxies audio from Tidal/Qobuz through distrib ### Album: `GET /album/?id={albumId}&offset={n}&limit=500` -### Qobuz alternative: `https://qobuz.squid.wtf/api` +### 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 diff --git a/docs/votify.md b/docs/votify.md index 40cd9b6..a10904d 100644 --- a/docs/votify.md +++ b/docs/votify.md @@ -2,7 +2,9 @@ ## Overview -Votify is the primary Spotify download backend. It invokes the `votify-fix` CLI tool (a third-party Python package) as a subprocess, streams its output to the job log, and post-processes the resulting files. +Votify is the primary Spotify download backend. It invokes the `votify` CLI tool (a third-party Python package) as a subprocess, streams its output to the job log, and post-processes the resulting files. + +Upstream is the official [glomatico/votify](https://github.com/glomatico/votify) project, installed from git in the Dockerfile. It previously used the `GladistonXD/votify-fix` fork; the fork's CLI flag names differ from upstream's, so `run_download()` was remapped when the swap was made (see [CLI flag mapping](#cli-flag-mapping)). --- @@ -24,6 +26,8 @@ Votify is the primary Spotify download backend. It invokes the `votify-fix` CLI Votify authenticates with Spotify using a `cookies.txt` file in Netscape format. This file must be uploaded by an admin via the Settings page before any downloads will succeed. +Upstream supports three session types (`--session-type`: `librespot`, `desktop`, `web`), defaulting to `librespot`. The app does not pass the flag, so the default applies — which is why the Dockerfile installs the `[librespot]` extra. Installing plain `votify` without the extra will fail at runtime under the default session type. + Path: `/config/cookies.txt` (configurable via `COOKIES_PATH` env var) A Widevine device certificate (`device.wvd`) may also be required depending on the content. It is uploaded separately via Settings. @@ -42,11 +46,40 @@ Path: `/config/device.wvd` (configurable via `WVD_PATH` env var) | `save_cover` | bool | Save cover art as a separate image file | | `save_playlist` | bool | Save playlist metadata file | | `overwrite` | bool | Re-download if file already exists | -| `download_music_videos` | bool | Include music video downloads | +| `download_music_videos` | bool | Prefer video streams where available | | `no_lrc` | bool | Skip LRC (lyrics) file generation | +| `save_lrc` | bool | Download *only* the synced-lyrics file | | `video_format` | `mp4`, `webm` | Format for music videos | | `cover_size` | `small`, `medium`, `large`, `extra-large` | Cover art resolution | -| `truncate` | int (optional) | Limit number of tracks to download | +| `truncate` | int (optional) | Max file/folder **name length** (not a track-count limit) | + +These are the app-level option names sent to `POST /api/download`; they are stable and unchanged by the upstream swap. The CLI flags they translate into are not — see below. + +Upstream also accepts `curl` for `--audio-download-mode` and the FLAC qualities `flac-flac`, `flac-mp4`, `flac-flac-24`, `flac-mp4-24` (premium accounts only). Neither is exposed in the UI; the values above are what the frontend offers. + +--- + +## CLI Flag Mapping + +`run_download()` builds the argv. Most flags were renamed between the old fork and upstream: + +| App option | Old (`votify-fix`) flag | Current (official) flag | +|------------|------------------------|-------------------------| +| output dir | `--output-path` | `--output` | +| temp dir | `--temp-path` | `--temp` | +| `download_mode` | `--download-mode` | `--audio-download-mode` | +| `save_cover` | `--save-cover` | `--save-cover-file` | +| `save_playlist` | `--save-playlist` | `--save-playlist-file` | +| `download_music_videos` | `--download-music-videos` | `--prefer-video` | +| `save_lrc` | `--lrc-only` | `--synced-lyrics-only` | +| `no_lrc` | `--no-lrc` | `--no-synced-lyrics-file` | +| folder flattening | `--template-folder-{album,compilation,episode,music-video}` | `--album-folder-template`, `--compilation-folder-template`, `--podcast-folder-template`, `--no-album-folder-template` | + +Unchanged: `--cookies-path`, `--wvd-path`, `--audio-quality`, `--video-format`, `--cover-size`, `--overwrite`, `--truncate`. + +All four folder templates are set to `.` so downloads land flat in the job's output directory; `post_process_votify_files()` then imposes the final layout. Upstream has no music-video folder template — `--no-album-folder-template` (used for albumless tracks) took that slot. + +`--no-config-file` is always passed so a stray `~/.votify/config.ini` cannot silently override these flags. --- @@ -78,7 +111,7 @@ After the subprocess exits, `post_process_votify_files()` runs: | Dependency | Purpose | |------------|---------| -| `votify-fix` (GitHub: GladistonXD/votify-fix) | Spotify download CLI | +| `votify` (GitHub: glomatico/votify, `[librespot]` extra) | Spotify download CLI | | `ffmpeg` | MP3 conversion | | `aria2c` | Optional download manager | | `yt-dlp` | Default download manager | @@ -91,6 +124,8 @@ After the subprocess exits, `post_process_votify_files()` runs: - Requires valid Spotify cookies (must be refreshed periodically when they expire). - DRM-protected content requires a Widevine device certificate. - Quality options are limited to what Votify and the Spotify API expose. +- Upstream warns that some users have had their Spotify accounts suspended for using it. +- **Interactive prompts can break jobs.** Upstream uses `inquirerpy` and will prompt on stdin for artist URLs (unless `--auto-media-option` is passed) and for `--video-format ask`. The subprocess has no usable stdin under Gunicorn, so such a job fails rather than completing. The UI only offers `mp4`/`webm`, so in practice this is reachable only via artist URLs or direct API calls. --- @@ -100,4 +135,4 @@ After the subprocess exits, `post_process_votify_files()` runs: |------|-----------| | [app.py](../app.py) | `run_download()`, `post_process_votify_files()`, route `/api/download` | | [utils.py](../utils.py) | `rename_from_metadata()`, `cleanup_empty_dirs()` | -| [Dockerfile](../Dockerfile) | Installation of votify-fix, ffmpeg, aria2, Bento4 | +| [Dockerfile](../Dockerfile) | Installation of votify, ffmpeg, aria2, Bento4 | diff --git a/monochrome/__init__.py b/monochrome/__init__.py index 243ce68..ecd542f 100644 --- a/monochrome/__init__.py +++ b/monochrome/__init__.py @@ -3,22 +3,32 @@ Monochrome - shared utilities for Tidal/Qobuz music downloading via Monochrome A """ import json +import re import ssl import sys import urllib.request import urllib.error -# Hardcoded fallback API instances +# Hardcoded fallback API instances (hifi-api compatible: /search/, /track/). +# Mirrors the "API Instances" section of the official INSTANCES.md; kept in sync +# manually as a last resort for when the live INSTANCES.md fetch fails. +# NOTE: the official Monochrome app has moved to a Turnstile-gated "Unified +# Playback API" (music-api.geeked.wtf) that a headless client cannot use. These +# are the remaining open, hifi-api-style instances. See docs for details. FALLBACK_INSTANCES = [ - "https://monochrome.tf", - "https://triton.squid.wtf", - "https://qqdl.site", - "https://monochrome.samidy.com", - "https://api.monochrome.tf", + "https://monochrome-api.samidy.com", + "https://wolf.qqdl.site", + "https://maus.qqdl.site", + "https://vogel.qqdl.site", + "https://katze.qqdl.site", + "https://hund.qqdl.site", + "https://tidal.kinoplus.online", ] QOBUZ_API = "https://qobuz.squid.wtf/api" -UPTIME_URL = "https://tidal-uptime.jiffy-puffs-1j.workers.dev/" +# Live API-instance list, parsed at runtime from the official repo. Replaces the +# old uptime-monitor worker, which is dead (404). +INSTANCES_MD_URL = "https://raw.githubusercontent.com/monochrome-music/monochrome/main/INSTANCES.md" # SSL context that doesn't verify certs (some instances have bad certs) SSL_CTX = ssl.create_default_context() @@ -39,36 +49,46 @@ def fetch_json(url, timeout=15, use_ssl_ctx=True): return json.loads(resp.read().decode()) +def parse_api_instances(markdown): + """Extract API-instance URLs from the INSTANCES.md markdown. + + API instances live under the "## API Instances" heading and are written as + backtick-wrapped URLs (e.g. `https://monochrome-api.samidy.com`). Other URLs + in that section (the Notes column, related links) use [text](url) markdown + form, so extracting only backtick-wrapped URLs keeps us to real API bases. + """ + section = markdown + heading = re.search(r'^#+\s*API Instances\s*$', markdown, re.MULTILINE) + if heading: + section = markdown[heading.end():] + # Stop at the next top-level (## ...) section. + nxt = re.search(r'^##\s+\S', section, re.MULTILINE) + if nxt: + section = section[:nxt.start()] + + urls = [] + for m in re.finditer(r'`(https?://[^`\s]+)`', section): + u = m.group(1).rstrip("/") + if u not in urls: + urls.append(u) + return urls + + def discover_instances(log=None): - """Get live API instances from uptime monitor, fall back to hardcoded list.""" + """Get live API instances from the official INSTANCES.md, fall back to the + hardcoded list. (The old uptime-monitor worker is dead — 404.)""" if log is None: log = print try: - data = fetch_json(UPTIME_URL, timeout=10) - if isinstance(data, dict): - urls = [] - for key, val in data.items(): - if isinstance(val, dict) and val.get("url"): - urls.append(val["url"].rstrip("/")) - elif isinstance(val, str) and val.startswith("http"): - urls.append(val.rstrip("/")) - if urls: - log(f"[*] Discovered {len(urls)} instances from uptime monitor") - return urls - elif isinstance(data, list): - urls = [] - for item in data: - if isinstance(item, str) and item.startswith("http"): - urls.append(item.rstrip("/")) - elif isinstance(item, dict): - u = item.get("url") or item.get("uri") or "" - if u.startswith("http"): - urls.append(u.rstrip("/")) - if urls: - log(f"[*] Discovered {len(urls)} instances from uptime monitor") - return urls + with fetch(INSTANCES_MD_URL, timeout=10, use_ssl_ctx=False) as resp: + markdown = resp.read().decode("utf-8", errors="replace") + urls = parse_api_instances(markdown) + if urls: + log(f"[*] Discovered {len(urls)} instances from INSTANCES.md") + return urls + log("[!] INSTANCES.md fetched but no API instances parsed") except Exception as e: - log(f"[!] Uptime discovery failed: {e}") + log(f"[!] Instance discovery failed: {e}") log(f"[*] Using {len(FALLBACK_INSTANCES)} fallback instances") return list(FALLBACK_INSTANCES)