Files
2026-08-16 23:00:27 +02:00

5.1 KiB
Raw Permalink Blame History

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 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 — 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 rmtrees 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

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.