Compare commits

..
Author SHA1 Message Date
patben8 1319121a4f Updated monochrome and votify 2026-08-16 23:29:15 +02:00
patben8 d136ef26c5 Created CLAUDE.md 2026-08-16 23:00:27 +02:00
patben8 bed1f4265a feat: implemented artwork fetching/downloading 2026-03-11 08:35:09 +01:00
patben8 343dfbaae1 fix: resolved apostrophe escape character issue 2026-03-09 23:05:46 +01:00
8 changed files with 293 additions and 66 deletions
+73
View File
@@ -0,0 +1,73 @@
# CLAUDE.md
Guidance for Claude Code when working in this repository.
## Project
**Trackpull** (repo dir still named `votify-docker`) — a self-hosted, multi-user Flask web app for downloading music from Spotify URLs. Dockerized, SQLite-backed, no frontend framework.
Full system documentation lives in [docs/](docs/). Start with [docs/onboarding.md](docs/onboarding.md).
## Architecture
```
templates/index.html (vanilla JS SPA, PWA)
│ fetch()
▼
app.py (Flask, served by Gunicorn: 1 worker / 4 threads)
├── db.py SQLite at /config/trackpull.db, thread-local connections
├── utils.py sanitize_filename, rename_from_metadata, cleanup_empty_dirs
└── monochrome/ Tidal/Qobuz download pipeline (pure Python, stdlib only)
├── __init__.py instance discovery, fetch/fetch_json, SSL ctx
├── api.py download_spotify_url() orchestrator
├── spotify_to_ids.py Spotify URL parse + embed scrape + fuzzy match
└── download.py stream URL, download, embed metadata, MP3 convert
```
### Three download backends
| Backend | Route | Runner in app.py | Mechanism |
|---------|-------|------------------|-----------|
| 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"]` |
Votify accepts a list of URLs; Monochrome and Unified take a **single** URL per job.
## Conventions & invariants
- **Everything mutating `jobs` must hold `jobs_lock`.** The in-memory `jobs: dict[str, dict]` is shared across Gunicorn threads. Read-modify-write patterns are already written this way — match them.
- **Job output is capped at 500 lines** (`[-500:]`) at every append site. Preserve the cap when touching logging.
- **Jobs are persisted only at terminal state** via `database.upsert_job()`. Live job state is in-memory and lost on restart. `GET /api/jobs` merges in-memory + DB.
- **`job["process"]`** (the Popen handle) is stripped before serialization by `job_to_dict()` and must never reach the DB or JSON.
- **Per-user isolation**: all files live under `/downloads/{user_id}/`. Every file route resolves paths through `_resolve_user_path()` / `_admin_resolve_path()`, which `.resolve()` the path and verify it is still inside the user dir. **Never bypass these helpers** when adding file routes.
- **Auth** is enforced by a single `@app.before_request` hook (`require_login`). Adding a public route means adding it to that whitelist. Admin routes call `require_admin()` and return 403.
- **Monochrome package uses only the standard library** (urllib, ssl, json). Keep it dependency-free — `requirements.txt` is intentionally tiny (flask, gunicorn, mutagen, werkzeug).
- Job IDs are `str(uuid.uuid4())[:8]`.
- Timestamps in the DB are **REAL unix floats**, not ISO strings.
## Key gotchas
- `db.init_db(DB_PATH)` is called explicitly from [app.py:41](app.py#L41) — importing `db` alone does not initialize the schema.
- `verify_password(user, password)` takes a **user dict**, not a username.
- `delete_jobs_older_than(cutoff)` takes a **unix timestamp**, not a number of days.
- The hourly `_purge_loop()` daemon deletes expired job rows **and** `rmtree`s any user download dir whose newest file is older than the cutoff. It is genuinely destructive to files — be careful changing `job_expiry_days` semantics.
- 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
```bash
cp .env.example .env # set ADMIN_USERNAME, ADMIN_PASSWORD, SECRET_KEY, PORT
docker compose up -d --build
```
There is no test suite and no linter config. Verify changes by rebuilding the container and exercising the UI.
Rebuilding is required for any Python or template change — the Dockerfile `COPY`s sources rather than mounting them. `/config` and `/downloads` persist on the host across rebuilds.
## Documentation policy
`docs/` is treated as the source of truth for humans and is expected to be kept current — see the maintenance note at the end of [docs/onboarding.md](docs/onboarding.md). **When you change a feature, update the matching document in the same change**: one document per system, update API endpoint and option tables when routes or parameters change, and record newly discovered third-party quirks as gotchas.
+4 -2
View File
@@ -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
+60 -12
View File
@@ -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:
@@ -615,6 +618,51 @@ def save_settings():
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Artwork routes
# ---------------------------------------------------------------------------
@app.route("/api/artwork", methods=["GET"])
def get_artwork():
from monochrome.spotify_to_ids import parse_spotify_url, fetch_spotify_embed
spotify_url = request.args.get("url", "").strip()
if not spotify_url:
return jsonify({"error": "No URL provided"}), 400
sp_type, sp_id = parse_spotify_url(spotify_url)
if not sp_type:
return jsonify({"error": "Invalid Spotify URL"}), 400
embed_data = fetch_spotify_embed(sp_type, sp_id)
if not embed_data:
return jsonify({"error": "Could not fetch Spotify metadata"}), 502
try:
entity = embed_data["props"]["pageProps"]["state"]["data"]["entity"]
except (KeyError, TypeError):
return jsonify({"error": "Unexpected Spotify response format"}), 502
# entity.visualIdentity.image[] — confirmed structure from Spotify embed page
# Each entry: {"url": "https://image-cdn-ak.spotifycdn.com/image/...", "maxWidth": N, "maxHeight": N}
images = (entity.get("visualIdentity") or {}).get("image", [])
if not images:
app.logger.warning("Artwork not found. Entity keys: %s", list(entity.keys()))
return jsonify({"error": "No artwork found for this URL"}), 404
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.
# Spotify CDN filenames: first 16 hex chars = size key, remainder = image hash.
# 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:
url = parts[0] + "/" + EXTRA_LARGE_KEY + parts[1][16:]
return jsonify({"image_url": url})
# ---------------------------------------------------------------------------
# Job routes
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+49 -5
View File
@@ -179,6 +179,7 @@
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button class="btn btn-sm btn-secondary" onclick="showPage('download')">Votify (Spotify)</button>
<button class="btn btn-sm btn-secondary" onclick="showPage('monochrome')">Monochrome (Tidal)</button>
<button class="btn btn-sm btn-secondary" onclick="showPage('artwork')">Artwork</button>
</div>
</div>
</div>
@@ -280,6 +281,19 @@
</div>
</div>
<!-- ARTWORK PAGE -->
<div id="page-artwork" class="page">
<div class="card">
<h2>Artwork</h2>
<div class="form-group">
<label for="artwork-url">Spotify URL (track, album, or playlist)</label>
<textarea id="artwork-url" placeholder="https://open.spotify.com/album/..." style="min-height:60px"></textarea>
</div>
<button class="btn" id="btn-artwork" onclick="fetchArtwork()">Get Artwork</button>
<div id="artwork-result" style="margin-top:20px"></div>
</div>
</div>
<!-- JOBS PAGE -->
<div id="page-jobs" class="page">
<div class="card">
@@ -448,8 +462,8 @@
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
// For hidden pages (download/monochrome), highlight the "Download" tab
const tabName = (name === 'download' || name === 'monochrome') ? 'unified' : name;
// For sub-pages (download/monochrome/artwork), highlight the "Download" tab
const tabName = (name === 'download' || name === 'monochrome' || name === 'artwork') ? 'unified' : name;
const matchingTab = document.querySelector(`.tab[onclick="showPage('${tabName}')"]`);
if (matchingTab) matchingTab.classList.add('active');
document.querySelectorAll('.bottom-tab').forEach(t => t.classList.toggle('active', t.dataset.page === tabName));
@@ -986,7 +1000,7 @@
<span class="file-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span class="file-name" onclick="loadFiles(this.dataset.p)" data-p="${safePath}">${escapeHtml(f.name)}</span>
<div class="file-actions">
<button class="btn-icon" onclick="window.location='/api/files/download-folder?path=${encodedPath}'" title="Download as ZIP">&#11015;</button>
<button class="btn-icon" onclick="window.location='/api/files/download-folder?path='+encodeURIComponent(this.dataset.p)" data-p="${safePath}" title="Download as ZIP">&#11015;</button>
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, true)" data-p="${safePath}" title="Delete folder">&#128465;</button>
</div>
</li>`;
@@ -994,7 +1008,7 @@
const size = formatSize(f.size);
return `<li class="file-item">
<span class="file-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>
<span class="file-name" onclick="window.location='/api/files/download?path=${encodedPath}'">${escapeHtml(f.name)}</span>
<span class="file-name" onclick="window.location='/api/files/download?path='+encodeURIComponent(this.dataset.p)" data-p="${safePath}">${escapeHtml(f.name)}</span>
<span class="file-size">${size}</span>
<div class="file-actions">
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, false)" data-p="${safePath}" title="Delete file">&#128465;</button>
@@ -1092,6 +1106,34 @@
}
}
async function fetchArtwork() {
const btn = document.getElementById('btn-artwork');
const url = document.getElementById('artwork-url').value.trim().split('\n')[0].trim();
const resultDiv = document.getElementById('artwork-result');
if (!url) { showToast('Enter a Spotify URL', 'error'); return; }
btn.disabled = true;
resultDiv.innerHTML = '<span style="color:var(--text2)">Fetching artwork...</span>';
try {
const res = await fetch('/api/artwork?url=' + encodeURIComponent(url));
const data = await res.json();
if (res.ok && data.image_url) {
const img = document.createElement('img');
img.src = data.image_url;
img.alt = 'Cover artwork';
img.style.cssText = 'max-width:100%;border-radius:var(--radius);display:block;';
resultDiv.innerHTML = '';
resultDiv.appendChild(img);
} else {
resultDiv.innerHTML = '<span style="color:var(--danger)">' + escapeHtml(data.error || 'No artwork found') + '</span>';
}
} catch (e) {
resultDiv.innerHTML = '<span style="color:var(--danger)">Failed to fetch artwork</span>';
}
btn.disabled = false;
}
function formatSize(bytes) {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
@@ -1137,9 +1179,11 @@
// Drop onto active page's textarea
const monoActive = document.getElementById('page-monochrome').classList.contains('active');
const votifyActive = document.getElementById('page-download').classList.contains('active');
const artworkActive = document.getElementById('page-artwork').classList.contains('active');
let taId = 'unified-urls';
if (monoActive) taId = 'mono-url';
else if (votifyActive) taId = 'urls';
else if (artworkActive) taId = 'artwork-url';
const ta = document.getElementById(taId);
ta.value = (ta.value.trim() ? ta.value.trim() + '\n' : '') + lines.join('\n');
ta.classList.remove('drop-flash');
@@ -1147,7 +1191,7 @@
ta.classList.add('drop-flash');
setTimeout(() => ta.classList.remove('drop-flash'), 650);
});
for (const id of ['urls', 'mono-url', 'unified-urls']) {
for (const id of ['urls', 'mono-url', 'unified-urls', 'artwork-url']) {
const ta = document.getElementById(id);
ta.addEventListener('dragover', () => ta.classList.add('drag-over'));
ta.addEventListener('dragleave', () => ta.classList.remove('drag-over'));