How a request is handled¶
app/main.py builds the app. It includes each router, puts the auth guards in front of them, and mounts frontend/ at / last. The mount has to come last because it would otherwise catch /v1/… and /docs. It's AppFiles (app/api/caching.py): Starlette's StaticFiles plus Cache-Control: no-cache on every file and every 304. Their addresses stay the same across deploys, so without it browsers and the app's web view guessed a freshness from Last-Modified and ran old JavaScript for hours. Unchanged files cost one 304. On startup, the lifespan hook starts the AirPlay metadata reader. On shutdown, it stops mpv.
flowchart LR
R["Request"] --> G["Guard (auth.py)<br/>require_user / admin / ai"]
G --> RT["Route (app/api/*.py)<br/>validates input with schemas/"]
RT --> REPO["repositories/<br/>queries"]
RT --> SV["services/<br/>scans, mixes, mpv, Claude…"]
REPO --> M["models/ (SQLAlchemy)"]
SV --> M
M --> PG[("Postgres")]
RT -- "commit, then return a schema" --> OUT["JSON response"]
- Routes (
app/api/) are thin. They parse input into Pydantic models fromapp/schemas/, call a repository or service, commit, and return a schema such asTrackOut. Pydantic turns that into JSON. - Sessions.
get_db()inapp/db/session.pygives each request its own SQLAlchemy session. Repositoriesflushbut never commit; the route owns the transaction. Code running off the request (mpv's thread recording a play, background scans) opens its ownSessionLocal(). - Repositories (
app/repositories/) are reusable queries: paged track lists with sorting and search, playlists, plays. - mpv commands go over a Unix socket in batches of 200, reading each batch's replies before sending the next (
MpvIpc.commands). Sending thousands at once used to deadlock, because mpv stops reading while its replies go unread. A play request can now carry up to 10,000 songs. - Services (
app/services/) hold the real logic: - the library scanner;
- smart playlists and mixes;
- mix rules;
- AI playlists (calls to Anthropic);
- identity;
- Lidarr;
- metadata lookups;
- mpv control (
player.pydrives an mpv subprocess over its JSON IPC socket, and mpv's playlist is the source of truth for the queue). - Scans run on a background thread, one at a time (
scan_jobs.py). The API returns202at once, and the UI pollsGET …/scanfor progress. - Settings are layered. In order of priority: environment variables, then
data/settings.json(what Settings → Server saves), then.env, then defaults. The settings the UI may edit are listed inapp/core/editable_settings.py. - Migrations are Alembic, in
alembic/versions/. They run automatically when the container starts.
Shapes you'll see everywhere¶
// A paged list (tracks, artists, albums, playlists, playlist entries)
{ "items": [ … ], "total": 1234, "limit": 50, "offset": 100 }
// A track (TrackOut). No file path: clients play by id
{ "id": 42, "title": "…", "artist": { "id": 3, "name": "…" },
"album": { "id": 9, "title": "…", "artwork_url": "/v1/library/artwork/<sha256>/full", "artwork_thumb_url": "…/thumb" },
"genre": { "id": 2, "name": "Rock" }, "disc_number": 1, "track_number": 4, "year": 1997,
"duration": 241.3, "codec": "flac", "bitrate": 900000, "sample_rate": 44100, "channels": 2,
"play_count": 12, "last_played_at": "2026-10-20T19:02:11Z" }
// An error. api.js throws ApiError(status, detail) with this message
{ "detail": "Only an admin can do that." }
// A 422 (request body rejected) lists what's wrong; api.js shows the first message
{ "detail": [ { "loc": ["body", "track_ids"], "msg": "List should have at most 10000 items…" } ] }