Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1319121a4f |
@@ -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
|
||||
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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**:
|
||||
```
|
||||
|
||||
+14
-9
@@ -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
|
||||
|
||||
+40
-5
@@ -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 |
|
||||
|
||||
+52
-32
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user