# 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) ```sql 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/.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 ``` ### Fuzzy search - 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.