Files
trackpull/CLAUDE.md
2026-08-16 23:29:15 +02:00

74 lines
5.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.