77 lines
5.9 KiB
Markdown
77 lines
5.9 KiB
Markdown
# 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: units–tens 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.
|
||
|
||
```bash
|
||
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.py` — `get_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.
|