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.jpgin 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
pathin tracks andmtimeunchanged → 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), storecover_path. Else look for sidecar cover files in dir. - After full pass: delete track rows whose
pathno 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 infeats.
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
fts5virtual 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/streamuses range requests so the player can seek. FileResponse handles this, but verifyAccept-Rangesfor 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|interstitialcrossfade_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/historywithcompleted=truewhen playback passes a threshold (e.g. ≥50% or last 10s reached). That incrementsplay_counts. Scrubbed-away early plays log withcompleted=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.