# 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.