Files
2026-06-03 10:40:00 +03:00

5.9 KiB
Raw Permalink Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

mcma-backend — a self-hosted, offline-first music service (FOSS, homelab scale: unitstens of users). Downloads music from external sources (yt-dlp etc.), stores files + metadata, streams to clients, serves a native REST API plus a Subsonic-compatible API. The server is a sync layer, not the sole source of truth — clients are expected to work offline, which shapes the data model (timestamps, event-logs) even before sync is built.

Status: Phase 1 (MVP). Step 1 (skeleton) is done; the data model, auth, sources, enrichment, streaming, and Subsonic layer follow. There is no in-repo plan document — the build order and deferred work live in Claude's project memory.

Commands

Uses uv (managed Python 3.14) for everything.

uv sync                              # install deps (runtime + dev group)
uv run uvicorn app.main:app --reload # run API (needs Postgres + Redis up)
uv run arq app.workers.arq_worker.WorkerSettings  # run background worker

uv run ruff check .                  # lint
uv run ruff format .                 # format
uv run mypy app                      # type-check (strict)
uv run pytest                        # all tests
uv run pytest tests/test_health.py::test_liveness_ok   # single test

uv run alembic revision --autogenerate -m "msg"  # new migration (after model changes)
uv run alembic upgrade head                       # apply migrations

docker build -t mcma-backend .       # build this service's image (repo is infra-free)

Orchestration is not in this repo. The workspace compose (../docker-compose.yml, shared with mcma-webui) wires the stack: docker compose up -d runs backing services only (db + redis) for local dev; docker compose --profile app up --build runs the full stack. The image takes every peer from env (DATABASE_URL, REDIS_URL, MEDIA_PATH, …) and hardcodes nothing.

Tests run in-process against the ASGI app (httpx + asgi-lifespan) — no server, no network. They do not require a running DB/Redis: dependency-backed checks degrade rather than fail.

Architecture — hexagonal (ports & adapters)

Dependencies point inward. The domain knows nothing about frameworks; outer layers are wired together only at the composition root.

  • app/domain/ — pure business core: entities, value objects, errors, and ports (Protocols). No framework imports (no FastAPI/SQLAlchemy/redis here, ever).
  • app/application/ — use cases / services. Depend on domain ports, never on concrete adapters.
  • app/infrastructure/ — driven adapters: ORM models + repositories (db/), Redis client (cache/), source backends, ML/HTTP clients. All vendor-specific code is confined here.
  • app/api/ — driving adapter (FastAPI): routers, schemas, deps.py (request-scoped wiring / composition root), middleware, error mapping.
  • app/core/ — cross-cutting: config, logging, security.
  • app/workers/ — arq background tasks (heavy I/O and CPU work).

Composition roots are app/main.py (create_app, lifespan) and app/api/deps.py (binds concrete adapters to ports per request). When adding a feature: define the port in domain, the service in application, the adapter in infrastructure, and wire it in deps.py.

Cross-cutting conventions

  • Errors: services raise framework-agnostic exceptions from app/domain/errors.py. app/api/errors.py is the only place that maps domain errors → HTTP status codes. Don't reference HTTP status from domain/application code.
  • Config: app/core/config.pyget_settings() is a cached singleton over pydantic-settings. Everything comes from env (or .env in dev); nothing is hardcoded. database_url must use the +asyncpg driver (validated).
  • Logging: app/core/logging.py — structlog, console in dev / JSON in prod. A correlation_id contextvar is bound per HTTP request (middleware) and should be bound per worker task; it auto-attaches to every log line.
  • DB sessions: API uses SessionDep (request-scoped, commit-on-success/rollback-on-error). Workers/scripts use session_scope() from app.infrastructure.db.
  • Migrations: Alembic is async and settings-driven — the URL is injected in alembic/env.py, never written to alembic.ini. Uncomment the models import in env.py once ORM models exist so autogenerate sees them.
  • Health: /health is liveness; /health/ready checks db + redis (required) + ml (optional). Every dependency check is wrapped in asyncio.timeout — a readiness probe must never hang.

Non-negotiable domain invariants

These exist for future sync + ML and are easy to violate by accident:

  • Likes are an append-only event-log, not a boolean. Current state = the latest event per (user, track). Never update a like row in place.
  • track.id is the stable content_id — a uuid that never changes; it's the basis of client-side caching. Don't regenerate it.
  • Dedup on both (source, source_id) and acoustid_fingerprint. Downloads/imports must be idempotent.
  • Graceful degradation is mandatory. ML, music sources, MusicBrainz/AcoustID are all optional. One being down must degrade a feature, never crash the system. The ML service is not a required dependency — radio must still work (worse) without it.
  • Never overwrite metadata_status = manual with automatic enrichment.
  • No heavy work in the request cycle. ffmpeg, fingerprinting, downloads — all go to arq workers.
  • Subsonic compatibility is a strategic goal: the Subsonic API is an adapter over services, not a reimplementation of logic.

Python 3.14 note

The project targets Python 3.14, which makes annotations lazy by default (PEP 649). from __future__ import annotations is therefore intentionally absent — do not add it back. All pins (pyproject requires-python, ruff target-version, mypy python_version, Dockerfile, .python-version) are on 3.14.