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 |
|
| 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 |
|
| 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"]` |
|
| 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`.
|
- 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.
|
- `/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 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
|
## 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 \
|
&& chmod +x /usr/local/bin/mp4decrypt \
|
||||||
&& rm -rf /tmp/bento4 /tmp/bento4.zip
|
&& rm -rf /tmp/bento4 /tmp/bento4.zip
|
||||||
|
|
||||||
# Install votify-fix
|
# Install votify (official, glomatico). The [librespot] extra is required for the
|
||||||
RUN pip install --no-cache-dir websocket-client git+https://github.com/GladistonXD/votify-fix.git
|
# 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
|
# Install web app dependencies
|
||||||
COPY requirements.txt /app/requirements.txt
|
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)
|
effective_output = output_path or str(user_dir)
|
||||||
|
|
||||||
cmd = ["votify"]
|
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(["--cookies-path", str(COOKIES_PATH)])
|
||||||
cmd.extend(["--output-path", effective_output])
|
cmd.extend(["--output", effective_output])
|
||||||
cmd.extend(["--temp-path", str(TEMP_DIR)])
|
cmd.extend(["--temp", str(TEMP_DIR)])
|
||||||
if WVD_PATH.exists():
|
if WVD_PATH.exists():
|
||||||
cmd.extend(["--wvd-path", str(WVD_PATH)])
|
cmd.extend(["--wvd-path", str(WVD_PATH)])
|
||||||
|
|
||||||
cmd.extend(["--template-folder-album", "."])
|
# Download flat into the output dir; post_process_votify_files() handles layout.
|
||||||
cmd.extend(["--template-folder-compilation", "."])
|
cmd.extend(["--album-folder-template", "."])
|
||||||
cmd.extend(["--template-folder-episode", "."])
|
cmd.extend(["--compilation-folder-template", "."])
|
||||||
cmd.extend(["--template-folder-music-video", "."])
|
cmd.extend(["--podcast-folder-template", "."])
|
||||||
|
cmd.extend(["--no-album-folder-template", "."])
|
||||||
|
|
||||||
quality = options.get("audio_quality", "aac-medium")
|
quality = options.get("audio_quality", "aac-medium")
|
||||||
if quality:
|
if quality:
|
||||||
@@ -187,7 +190,7 @@ def run_download(job_id: str, urls: list[str], options: dict,
|
|||||||
|
|
||||||
download_mode = options.get("download_mode", "ytdlp")
|
download_mode = options.get("download_mode", "ytdlp")
|
||||||
if download_mode:
|
if download_mode:
|
||||||
cmd.extend(["--download-mode", download_mode])
|
cmd.extend(["--audio-download-mode", download_mode])
|
||||||
|
|
||||||
video_format = options.get("video_format", "mp4")
|
video_format = options.get("video_format", "mp4")
|
||||||
if video_format:
|
if video_format:
|
||||||
@@ -198,17 +201,17 @@ def run_download(job_id: str, urls: list[str], options: dict,
|
|||||||
cmd.extend(["--cover-size", cover_size])
|
cmd.extend(["--cover-size", cover_size])
|
||||||
|
|
||||||
if options.get("save_cover"):
|
if options.get("save_cover"):
|
||||||
cmd.append("--save-cover")
|
cmd.append("--save-cover-file")
|
||||||
if options.get("save_playlist"):
|
if options.get("save_playlist"):
|
||||||
cmd.append("--save-playlist")
|
cmd.append("--save-playlist-file")
|
||||||
if options.get("overwrite"):
|
if options.get("overwrite"):
|
||||||
cmd.append("--overwrite")
|
cmd.append("--overwrite")
|
||||||
if options.get("download_music_videos"):
|
if options.get("download_music_videos"):
|
||||||
cmd.append("--download-music-videos")
|
cmd.append("--prefer-video")
|
||||||
if options.get("save_lrc"):
|
if options.get("save_lrc"):
|
||||||
cmd.append("--lrc-only")
|
cmd.append("--synced-lyrics-only")
|
||||||
if options.get("no_lrc"):
|
if options.get("no_lrc"):
|
||||||
cmd.append("--no-lrc")
|
cmd.append("--no-synced-lyrics-file")
|
||||||
|
|
||||||
truncate = options.get("truncate")
|
truncate = options.get("truncate")
|
||||||
if truncate:
|
if truncate:
|
||||||
@@ -649,9 +652,9 @@ def get_artwork():
|
|||||||
best = max(images, key=lambda img: img.get("maxWidth", 0))
|
best = max(images, key=lambda img: img.get("maxWidth", 0))
|
||||||
url = best["url"]
|
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.
|
# 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"
|
EXTRA_LARGE_KEY = "ab67616d000082c1"
|
||||||
parts = url.rsplit("/", 1)
|
parts = url.rsplit("/", 1)
|
||||||
if len(parts) == 2 and len(parts[1]) >= 16:
|
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**:
|
**Python packages**:
|
||||||
- From `requirements.txt`: Flask, gunicorn, mutagen, werkzeug, etc.
|
- From `requirements.txt`: Flask, gunicorn, mutagen, werkzeug, etc.
|
||||||
- `websocket-client` — WebSocket support
|
- `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**:
|
**Runtime command**:
|
||||||
```
|
```
|
||||||
|
|||||||
+14
-9
@@ -3,15 +3,20 @@
|
|||||||
## How It Works
|
## How It Works
|
||||||
Monochrome is a web frontend that proxies audio from Tidal/Qobuz through distributed API instances. It does NOT host audio itself.
|
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
|
## Instance Discovery
|
||||||
1. **Uptime monitor**: `https://tidal-uptime.jiffy-puffs-1j.workers.dev/` — returns list of live instances
|
1. **Live list**: `discover_instances()` fetches the official
|
||||||
2. **Hardcoded fallbacks** (some may go down over time):
|
[`INSTANCES.md`](https://github.com/monochrome-music/monochrome/blob/main/INSTANCES.md)
|
||||||
- `https://monochrome.tf`
|
(`INSTANCES_MD_URL`) and parses backtick-wrapped URLs from its **"## API Instances"**
|
||||||
- `https://triton.squid.wtf`
|
section. (The old uptime-monitor worker `tidal-uptime.jiffy-puffs-1j.workers.dev` is
|
||||||
- `https://qqdl.site`
|
dead — 404 — and has been removed.)
|
||||||
- `https://monochrome.samidy.com`
|
2. **Hardcoded fallbacks** (`FALLBACK_INSTANCES`, used only if the INSTANCES.md fetch
|
||||||
- `https://api.monochrome.tf`
|
fails; kept in sync with the API-instance section manually):
|
||||||
3. More instances listed at: https://github.com/monochrome-music/monochrome/blob/main/INSTANCES.md
|
- `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)
|
## 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`
|
### 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}`
|
- Search: `/get-music?q={query}`
|
||||||
- Stream: `/download-music?track_id={id}&quality={qobuzQuality}`
|
- Stream: `/download-music?track_id={id}&quality={qobuzQuality}`
|
||||||
- Quality mapping: 27=MP3_320, 7=FLAC, 6=HiRes96/24, 5=HiRes192/24
|
- Quality mapping: 27=MP3_320, 7=FLAC, 6=HiRes96/24, 5=HiRes192/24
|
||||||
|
|||||||
+40
-5
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Overview
|
## 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.
|
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)
|
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.
|
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_cover` | bool | Save cover art as a separate image file |
|
||||||
| `save_playlist` | bool | Save playlist metadata file |
|
| `save_playlist` | bool | Save playlist metadata file |
|
||||||
| `overwrite` | bool | Re-download if file already exists |
|
| `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 |
|
| `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 |
|
| `video_format` | `mp4`, `webm` | Format for music videos |
|
||||||
| `cover_size` | `small`, `medium`, `large`, `extra-large` | Cover art resolution |
|
| `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 |
|
| Dependency | Purpose |
|
||||||
|------------|---------|
|
|------------|---------|
|
||||||
| `votify-fix` (GitHub: GladistonXD/votify-fix) | Spotify download CLI |
|
| `votify` (GitHub: glomatico/votify, `[librespot]` extra) | Spotify download CLI |
|
||||||
| `ffmpeg` | MP3 conversion |
|
| `ffmpeg` | MP3 conversion |
|
||||||
| `aria2c` | Optional download manager |
|
| `aria2c` | Optional download manager |
|
||||||
| `yt-dlp` | Default 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).
|
- Requires valid Spotify cookies (must be refreshed periodically when they expire).
|
||||||
- DRM-protected content requires a Widevine device certificate.
|
- DRM-protected content requires a Widevine device certificate.
|
||||||
- Quality options are limited to what Votify and the Spotify API expose.
|
- 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` |
|
| [app.py](../app.py) | `run_download()`, `post_process_votify_files()`, route `/api/download` |
|
||||||
| [utils.py](../utils.py) | `rename_from_metadata()`, `cleanup_empty_dirs()` |
|
| [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 json
|
||||||
|
import re
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
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 = [
|
FALLBACK_INSTANCES = [
|
||||||
"https://monochrome.tf",
|
"https://monochrome-api.samidy.com",
|
||||||
"https://triton.squid.wtf",
|
"https://wolf.qqdl.site",
|
||||||
"https://qqdl.site",
|
"https://maus.qqdl.site",
|
||||||
"https://monochrome.samidy.com",
|
"https://vogel.qqdl.site",
|
||||||
"https://api.monochrome.tf",
|
"https://katze.qqdl.site",
|
||||||
|
"https://hund.qqdl.site",
|
||||||
|
"https://tidal.kinoplus.online",
|
||||||
]
|
]
|
||||||
|
|
||||||
QOBUZ_API = "https://qobuz.squid.wtf/api"
|
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 context that doesn't verify certs (some instances have bad certs)
|
||||||
SSL_CTX = ssl.create_default_context()
|
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())
|
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):
|
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:
|
if log is None:
|
||||||
log = print
|
log = print
|
||||||
try:
|
try:
|
||||||
data = fetch_json(UPTIME_URL, timeout=10)
|
with fetch(INSTANCES_MD_URL, timeout=10, use_ssl_ctx=False) as resp:
|
||||||
if isinstance(data, dict):
|
markdown = resp.read().decode("utf-8", errors="replace")
|
||||||
urls = []
|
urls = parse_api_instances(markdown)
|
||||||
for key, val in data.items():
|
if urls:
|
||||||
if isinstance(val, dict) and val.get("url"):
|
log(f"[*] Discovered {len(urls)} instances from INSTANCES.md")
|
||||||
urls.append(val["url"].rstrip("/"))
|
return urls
|
||||||
elif isinstance(val, str) and val.startswith("http"):
|
log("[!] INSTANCES.md fetched but no API instances parsed")
|
||||||
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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"[!] Uptime discovery failed: {e}")
|
log(f"[!] Instance discovery failed: {e}")
|
||||||
|
|
||||||
log(f"[*] Using {len(FALLBACK_INSTANCES)} fallback instances")
|
log(f"[*] Using {len(FALLBACK_INSTANCES)} fallback instances")
|
||||||
return list(FALLBACK_INSTANCES)
|
return list(FALLBACK_INSTANCES)
|
||||||
|
|||||||
Reference in New Issue
Block a user