5.1 KiB
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/. Start with 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
jobsmust holdjobs_lock. The in-memoryjobs: 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/jobsmerges in-memory + DB. job["process"](the Popen handle) is stripped before serialization byjob_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_requesthook (require_login). Adding a public route means adding it to that whitelist. Admin routes callrequire_admin()and return 403. - Monochrome package uses only the standard library (urllib, ssl, json). Keep it dependency-free —
requirements.txtis 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 — importingdbalone 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 andrmtrees any user download dir whose newest file is older than the cutoff. It is genuinely destructive to files — be careful changingjob_expiry_dayssemantics. - The Unified fallback writes into the same subfolder Monochrome created (
fail_info["subfolder"]) and forcesoutput_format: mp3. /api/artworkscrapesentity.visualIdentity.image[]from the Spotify embed page, then rewrites the CDN filename's first 16 hex chars to theab67616d000082c1size 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_320is not a valid Tidal quality param (404s) — it is only meaningful as a Qobuz mapping / post-conversion target.
Development
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 COPYs 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. 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.