Skip to content

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 from app/schemas/, call a repository or service, commit, and return a schema such as TrackOut. Pydantic turns that into JSON.
  • Sessions. get_db() in app/db/session.py gives each request its own SQLAlchemy session. Repositories flush but never commit; the route owns the transaction. Code running off the request (mpv's thread recording a play, background scans) opens its own SessionLocal().
  • 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.py drives 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 returns 202 at once, and the UI polls GET …/scan for 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 in app/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…" } ] }