From d136ef26c50c5e636c6b4b139fa2325ba71f7bf4 Mon Sep 17 00:00:00 2001 From: Benjamin Hardy Date: Sun, 16 Aug 2026 23:00:27 +0200 Subject: [PATCH] Created CLAUDE.md --- CLAUDE.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d7b6bfb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# 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 (votify-fix), 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. + +## 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.