Files
muzick/03-music-core-backend.md

7.3 KiB

music ("resonance") — core backend spec

FastAPI + sqlite. The streaming/library core. Recommendation/vibe engine is a separate spec (04). Music lives at /mnt/hdd1/media/Music. Dark/blue frontend is spec 05.

Working name: resonance. Rename freely.

Principles

  • Tag-indexed once into sqlite, queried from there. Tag parsing is heavier than stat(), so it only runs on indexing, not per request.
  • Incremental re-index: skip files whose mtime is unchanged since last index.
  • Metadata: prefer embedded tags (ID3 for mp3, Vorbis for flac/ogg, MP4 atoms for m4a). Fall back to folder/filename parsing when tags are missing/garbage.
  • Cover art: embedded first; fall back to cover.jpg|folder.jpg|front.jpg in the track's directory.

Libraries

  • mutagen — tag reading (mp3/flac/ogg/m4a/wav). Mature, pure-python.
  • Optional: Pillow to normalize/resize embedded cover art into a cache.

sqlite schema (music.db)

CREATE TABLE IF NOT EXISTS tracks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    path TEXT UNIQUE NOT NULL,
    title TEXT,
    artist TEXT,
    album_artist TEXT,
    album TEXT,
    track_no INTEGER,
    disc_no INTEGER,
    year INTEGER,
    genre TEXT,
    duration REAL,                 -- seconds
    bitrate INTEGER,
    sample_rate INTEGER,
    channels INTEGER,
    codec TEXT,                    -- mp3/flac/...
    feats TEXT,                    -- parsed featured artists, JSON array
    mtime REAL NOT NULL,           -- file mtime at index time
    size INTEGER,
    has_embedded_cover INTEGER DEFAULT 0,
    cover_path TEXT,               -- resolved external cover, if any
    mbid TEXT,                     -- musicbrainz recording id (spec 04)
    indexed_at TEXT NOT NULL,
    -- library state
    probation INTEGER DEFAULT 0,   -- 1 = recommended candidate not yet promoted (spec 04)
    source TEXT DEFAULT 'library', -- 'library' | 'recommendation'
    rec_source TEXT,               -- 'lastfm' | 'musicbrainz' (spec 04)
    added_at TEXT
);

CREATE TABLE IF NOT EXISTS albums (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    album_artist TEXT,
    year INTEGER,
    cover_path TEXT,
    mbid TEXT,
    UNIQUE(name, album_artist)
);

CREATE TABLE IF NOT EXISTS artists (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT UNIQUE NOT NULL,
    mbid TEXT,
    image_path TEXT,
    genres TEXT                    -- JSON array (spec 04 fills via lastfm/mb)
);

CREATE TABLE IF NOT EXISTS favorites (
    track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
    created_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    track_id INTEGER REFERENCES tracks(id) ON DELETE CASCADE,
    played_at TEXT NOT NULL,
    completed INTEGER DEFAULT 0    -- 1 if played to ~end (counts toward play_count)
);

CREATE TABLE IF NOT EXISTS play_counts (
    track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
    count INTEGER DEFAULT 0,
    last_played TEXT
);

CREATE TABLE IF NOT EXISTS prefs (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL
);

Indexes: tracks(artist), tracks(album), tracks(album_artist), tracks(genre), tracks(probation), history(played_at).

Indexing

  • index_library():
    • rglob music root for audio extensions.
    • For each file: if path in tracks and mtime unchanged → skip. Else parse tags via mutagen, upsert track row; upsert album + artist rows.
    • Cover resolution: if embedded art present, extract once to a cover cache dir on hdd2 (/mnt/hdd2/resonance/covers/<hash>.jpg), store cover_path. Else look for sidecar cover files in dir.
    • After full pass: delete track rows whose path no longer exists (and weren't promoted-from-recommendation pending — they still get removed if file's gone).
  • Trigger: on startup (background thread), and a manual POST /api/library/reindex.
  • Feats parsing: from title/artist via patterns — feat., ft., featuring, (with …), , in artist field — store normalized JSON in feats.

Endpoints — library

GET  /api/artists?sort=name|count           -> artists w/ track + album counts
GET  /api/artists/{id}                       -> artist + albums + tracks
GET  /api/albums?sort=name|year|artist       -> albums w/ track counts, cover
GET  /api/albums/{id}                        -> album + ordered tracks
GET  /api/tracks?sort=title|artist|added|plays&limit=&offset=  -> paginated
GET  /api/track/{id}                         -> full track meta
GET  /api/search?q=                          -> fuzzy across tracks/artists/albums
GET  /api/cover/{track_id|album_id}          -> image (from cover cache); 404->placeholder
  • Normalize (lowercase, strip diacritics for matching but keep originals for display — important for Cyrillic/mixed library).
  • Match across title, artist, album, album_artist. Rank: exact > prefix > substring > token subsequence. Cap results per category. SQLite + python ranking is fine at this library size; consider fts5 virtual table if it grows.

Endpoints — playback / streaming

GET  /api/stream/{track_id}                  -> audio stream, MUST support HTTP Range
GET  /api/lyrics/{track_id}                  -> synced lyrics (spec 04 provider chain)
  • /api/stream uses range requests so the player can seek. FileResponse handles this, but verify Accept-Ranges for the player's needs; if transcoding is added later, gate behind a ?transcode= param. v1 = direct file passthrough (no transcode).

Playback config (prefs)

prefs holds playback mode and options:

  • playback_mode: basic | gapless | crossfade | interstitial
  • crossfade_ms: int (when crossfade)
  • interstitial_track_id: track id to insert between songs (the "Thomas the Tank Engine between every song" mode). Cute, keep it. Gapless/crossfade are primarily client-side (Web Audio) concerns; backend just streams. Backend stores the prefs and serves the interstitial track like any other.

Endpoints — favorites / history / counts

POST   /api/favorite/{track_id}              -> toggle favorite
GET    /api/favorites                        -> favorited tracks
POST   /api/history                          body {track_id, completed} -> log play; if completed, bump play_counts
GET    /api/history?limit=                   -> recent plays
GET    /api/stats/top?by=plays&limit=        -> most played
GET    /api/prefs  /  PUT /api/prefs         -> player + app prefs (same pattern as kdrive)

Play accounting

  • Client logs a play to /api/history with completed=true when playback passes a threshold (e.g. ≥50% or last 10s reached). That increments play_counts. Scrubbed-away early plays log with completed=false (history but no count).

Global shuffle

GET /api/shuffle/all?limit=500&exclude_probation=false

Returns a shuffled list of track ids spanning the whole library (optionally include probation candidates mixed in — see spec 04). Frontend loads this as the queue. The "one button, everything shuffled" requirement.

Deletion (ties into spec 04 dislike flow)

DELETE /api/track/{track_id}    -> remove file from fs + all db rows (favorites/history/counts cascade)

Used by the dislike lifecycle's final step. Hard delete, irreversible. Path-safety: only delete within the music root.

Threading

Indexing + cover extraction run in a background thread on startup and on reindex. SQLite connections per-thread.