//! Music library: SQLite-backed albums and tracks. All methods are //! synchronous — call from a blocking context. use rusqlite::Connection; use serde::Serialize; use std::path::Path; use std::sync::Mutex; #[derive(Debug, thiserror::Error)] pub enum LibraryError { #[error("sqlite: {0}")] Sqlite(#[from] rusqlite::Error), } pub type Result = std::result::Result; #[derive(Debug, Clone, Serialize)] pub struct Album { pub id: i64, pub artist: String, pub title: String, pub year: Option, pub art_path: Option, pub artist_art_path: Option, pub track_count: u32, pub added_at: i64, } #[derive(Debug, Clone, Serialize)] pub struct Track { pub id: i64, pub album_id: i64, pub track_no: Option, pub title: String, pub artist: String, pub duration_secs: Option, pub bitrate_kbps: Option, pub sample_rate: Option, pub bit_depth: Option, pub size: Option, pub path: String, } #[derive(Debug, Clone, Serialize)] pub struct Playlist { pub id: i64, pub name: String, pub track_count: u32, } #[derive(Debug, Clone, Serialize)] pub struct PlaylistTrack { pub position: i64, pub title: String, pub artist: String, pub duration_secs: Option, pub path: String, pub art_path: Option, } /// Cumulative listening stats for one artist (drives the artist-page chips). #[derive(Debug, Clone, Serialize)] pub struct PlayStats { pub seconds: u64, pub last_played_at: Option, /// Most played track, ranked by listened time (not session count, so ten /// instant skips never beat one album's worth of real listening). pub top_title: Option, /// Listening sessions of the top track — the "N×" the chip displays. pub top_count: u32, } pub struct Library { conn: Mutex, } impl Library { pub fn open(db_path: &Path) -> Result { let conn = Connection::open(db_path)?; conn.execute_batch( " PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS albums ( id INTEGER PRIMARY KEY, artist TEXT NOT NULL, title TEXT NOT NULL, year INTEGER, art_path TEXT, artist_art_path TEXT, mbid TEXT, added_at INTEGER NOT NULL, UNIQUE(artist COLLATE NOCASE, title COLLATE NOCASE) ); CREATE TABLE IF NOT EXISTS tracks ( id INTEGER PRIMARY KEY, album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE, track_no INTEGER, title TEXT NOT NULL, artist TEXT NOT NULL, duration_secs INTEGER, bitrate_kbps INTEGER, sample_rate INTEGER, bit_depth INTEGER, size INTEGER, path TEXT NOT NULL UNIQUE, added_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album_id); CREATE TABLE IF NOT EXISTS playlists ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS playlist_tracks ( playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, position INTEGER NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL, duration_secs INTEGER, path TEXT NOT NULL, art_path TEXT, PRIMARY KEY (playlist_id, position) ); CREATE TABLE IF NOT EXISTS plays ( id INTEGER PRIMARY KEY, artist TEXT NOT NULL, title TEXT NOT NULL, started_at INTEGER NOT NULL, seconds INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE); ", )?; // Migrate databases created before artist portraits were added. let _ = conn.execute("ALTER TABLE albums ADD COLUMN artist_art_path TEXT", []); // Migrate databases created before per-track audio props were stored. let _ = conn.execute("ALTER TABLE tracks ADD COLUMN sample_rate INTEGER", []); let _ = conn.execute("ALTER TABLE tracks ADD COLUMN bit_depth INTEGER", []); let _ = conn.execute("ALTER TABLE tracks ADD COLUMN size INTEGER", []); Ok(Library { conn: Mutex::new(conn), }) } /// Insert or fetch the album, updating year/art/mbid when newly known. pub fn upsert_album( &self, artist: &str, title: &str, year: Option, art_path: Option<&str>, artist_art_path: Option<&str>, mbid: Option<&str>, now: i64, ) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO albums (artist, title, year, art_path, artist_art_path, mbid, added_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(artist COLLATE NOCASE, title COLLATE NOCASE) DO UPDATE SET year = COALESCE(albums.year, excluded.year), art_path = COALESCE(albums.art_path, excluded.art_path), artist_art_path = COALESCE(albums.artist_art_path, excluded.artist_art_path), mbid = COALESCE(albums.mbid, excluded.mbid)", rusqlite::params![artist, title, year, art_path, artist_art_path, mbid, now], )?; let id = conn.query_row( "SELECT id FROM albums WHERE artist = ?1 COLLATE NOCASE AND title = ?2 COLLATE NOCASE", rusqlite::params![artist, title], |r| r.get(0), )?; Ok(id) } pub fn set_album_art(&self, album_id: i64, art_path: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE albums SET art_path = ?2 WHERE id = ?1", rusqlite::params![album_id, art_path], )?; Ok(()) } pub fn set_artist_art(&self, artist: &str, art_path: &str) -> Result { let conn = self.conn.lock().unwrap(); Ok(conn.execute( "UPDATE albums SET artist_art_path = ?2 WHERE artist = ?1 COLLATE NOCASE", rusqlite::params![artist, art_path], )?) } // ---- play history ---- // One row per listening session. The frontend opens a row when a track // first accumulates listened time and tops up its seconds while it plays, // so a skipped track never writes a row and per-play counts stay honest. pub fn record_play(&self, artist: &str, title: &str, now: i64) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO plays (artist, title, started_at, seconds) VALUES (?1, ?2, ?3, 0)", rusqlite::params![artist, title, now], )?; Ok(conn.last_insert_rowid()) } pub fn add_play_seconds(&self, play_id: i64, secs: u32) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE plays SET seconds = seconds + ?2 WHERE id = ?1", rusqlite::params![play_id, secs], )?; Ok(()) } pub fn artist_play_stats(&self, artist: &str) -> Result { let conn = self.conn.lock().unwrap(); let (seconds, last_played_at) = conn.query_row( "SELECT COALESCE(SUM(seconds), 0), MAX(started_at) FROM plays WHERE artist = ?1 COLLATE NOCASE", rusqlite::params![artist], |r| Ok((r.get::<_, i64>(0)?.max(0) as u64, r.get(1)?)), )?; // Sessions shorter than 30s are noise (previews, near-instant skips); // excluding them keeps the count aligned with the scrobbling norm. let top = conn .query_row( "SELECT title, COUNT(*) FROM plays WHERE artist = ?1 COLLATE NOCASE AND seconds >= 30 GROUP BY title COLLATE NOCASE ORDER BY SUM(seconds) DESC LIMIT 1", rusqlite::params![artist], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u32)), ) .map(Some) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), })?; let (top_title, top_count) = top.map_or((None, 0), |(t, c)| (Some(t), c)); Ok(PlayStats { seconds, last_played_at, top_title, top_count, }) } #[allow(clippy::too_many_arguments)] pub fn upsert_track( &self, album_id: i64, track_no: Option, title: &str, artist: &str, duration_secs: Option, bitrate_kbps: Option, sample_rate: Option, bit_depth: Option, size: Option, path: &str, now: i64, ) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO tracks (album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, added_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT(path) DO UPDATE SET album_id = excluded.album_id, track_no = excluded.track_no, title = excluded.title, artist = excluded.artist, duration_secs = excluded.duration_secs, bitrate_kbps = excluded.bitrate_kbps, sample_rate = excluded.sample_rate, bit_depth = excluded.bit_depth, size = excluded.size", rusqlite::params![album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, now], )?; let id = conn.query_row( "SELECT id FROM tracks WHERE path = ?1", rusqlite::params![path], |r| r.get(0), )?; Ok(id) } pub fn has_track_path(&self, path: &str) -> Result { let conn = self.conn.lock().unwrap(); let n: i64 = conn.query_row( "SELECT COUNT(*) FROM tracks WHERE path = ?1", rusqlite::params![path], |r| r.get(0), )?; Ok(n > 0) } /// Whether this song is already represented in the library, independent /// of its on-disk path. Downloads are organized with a " (2)" suffix to /// avoid overwriting files, so a path-only check cannot prevent a second /// save of the same track from becoming a duplicate library row. pub fn has_track(&self, artist: &str, album: &str, title: &str) -> Result { let conn = self.conn.lock().unwrap(); let n: i64 = conn.query_row( "SELECT COUNT(*) FROM tracks t JOIN albums a ON a.id = t.album_id WHERE a.artist = ?1 COLLATE NOCASE AND a.title = ?2 COLLATE NOCASE AND t.title = ?3 COLLATE NOCASE", rusqlite::params![artist, album, title], |r| r.get(0), )?; Ok(n > 0) } pub fn get_album(&self, album_id: i64) -> Result)>> { let conn = self.conn.lock().unwrap(); let row = conn .query_row( "SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at, a.mbid, (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id) FROM albums a WHERE a.id = ?1", [album_id], |r| { Ok(( Album { id: r.get(0)?, artist: r.get(1)?, title: r.get(2)?, year: r.get(3)?, art_path: r.get(4)?, artist_art_path: r.get(5)?, added_at: r.get(6)?, track_count: r.get::<_, i64>(8)? as u32, }, r.get::<_, Option>(7)?, )) }, ) .map(Some) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Ok(None), other => Err(other), })?; Ok(row) } pub fn list_albums(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at, (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id) FROM albums a ORDER BY a.added_at DESC, a.artist, a.title", )?; let rows = stmt.query_map([], |r| { Ok(Album { id: r.get(0)?, artist: r.get(1)?, title: r.get(2)?, year: r.get(3)?, art_path: r.get(4)?, artist_art_path: r.get(5)?, added_at: r.get(6)?, track_count: r.get::<_, i64>(7)? as u32, }) })?; Ok(rows.filter_map(|r| r.ok()).collect()) } pub fn album_tracks(&self, album_id: i64) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path FROM tracks WHERE album_id = ?1 ORDER BY track_no IS NULL, track_no, title", )?; let rows = stmt.query_map([album_id], |r| { Ok(Track { id: r.get(0)?, album_id: r.get(1)?, track_no: r.get(2)?, title: r.get(3)?, artist: r.get(4)?, duration_secs: r.get(5)?, bitrate_kbps: r.get(6)?, sample_rate: r.get(7)?, bit_depth: r.get(8)?, size: r.get::<_, Option>(9)?.map(|n| n as u64), path: r.get(10)?, }) })?; Ok(rows.filter_map(|r| r.ok()).collect()) } /// Paths of tracks missing the audio-props backfill (size is the sentinel: /// always available from the filesystem, so a non-null size means scanned). pub fn tracks_missing_props(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare("SELECT id, path FROM tracks WHERE size IS NULL")?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; Ok(rows.filter_map(|r| r.ok()).collect()) } /// Backfill audio props for one track (found via tracks_missing_props). pub fn set_track_props( &self, id: i64, sample_rate: Option, bit_depth: Option, size: Option, ) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE tracks SET sample_rate = ?2, bit_depth = ?3, size = ?4 WHERE id = ?1", rusqlite::params![id, sample_rate, bit_depth, size.map(|n| n as i64)], )?; Ok(()) } // ---- playlists ---- pub fn create_playlist(&self, name: &str, now: i64) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO playlists (name, created_at) VALUES (?1, ?2)", rusqlite::params![name, now], )?; Ok(conn.last_insert_rowid()) } pub fn rename_playlist(&self, id: i64, name: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE playlists SET name = ?2 WHERE id = ?1", rusqlite::params![id, name], )?; Ok(()) } pub fn delete_playlist(&self, id: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM playlist_tracks WHERE playlist_id = ?1", [id])?; conn.execute("DELETE FROM playlists WHERE id = ?1", [id])?; Ok(()) } /// (id, name, track_count), newest first. pub fn list_playlists(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT p.id, p.name, (SELECT COUNT(*) FROM playlist_tracks t WHERE t.playlist_id = p.id) FROM playlists p ORDER BY p.created_at DESC", )?; let rows = stmt.query_map([], |r| { Ok(Playlist { id: r.get(0)?, name: r.get(1)?, track_count: r.get::<_, i64>(2)? as u32, }) })?; Ok(rows.filter_map(|r| r.ok()).collect()) } pub fn add_to_playlist(&self, playlist_id: i64, t: &PlaylistTrack) -> Result<()> { let conn = self.conn.lock().unwrap(); let next: i64 = conn.query_row( "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1", [playlist_id], |r| r.get(0), )?; conn.execute( "INSERT INTO playlist_tracks (playlist_id, position, title, artist, duration_secs, path, art_path) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ playlist_id, next, t.title, t.artist, t.duration_secs, t.path, t.art_path ], )?; Ok(()) } pub fn playlist_tracks(&self, playlist_id: i64) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT position, title, artist, duration_secs, path, art_path FROM playlist_tracks WHERE playlist_id = ?1 ORDER BY position", )?; let rows = stmt.query_map([playlist_id], |r| { Ok(PlaylistTrack { position: r.get(0)?, title: r.get(1)?, artist: r.get(2)?, duration_secs: r.get(3)?, path: r.get(4)?, art_path: r.get(5)?, }) })?; Ok(rows.filter_map(|r| r.ok()).collect()) } pub fn remove_from_playlist(&self, playlist_id: i64, position: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND position = ?2", rusqlite::params![playlist_id, position], )?; Ok(()) } /// Drop tracks whose files vanished; returns how many were removed. pub fn prune_missing(&self) -> Result { let conn = self.conn.lock().unwrap(); let paths: Vec<(i64, String)> = conn .prepare("SELECT id, path FROM tracks")? .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? .filter_map(|r| r.ok()) .collect(); let mut removed = 0; for (id, path) in paths { if !Path::new(&path).exists() { conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?; removed += 1; } } conn.execute( "DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)", [], )?; Ok(removed) } /// Repoint stale artwork references to an identically named file in the /// current app's art cache. This covers app-identifier/data-directory /// migrations without requiring artwork to be downloaded again. pub fn repair_art_paths(&self, art_dir: &Path) -> Result { let conn = self.conn.lock().unwrap(); let rows: Vec<(i64, Option, Option)> = conn .prepare("SELECT id, art_path, artist_art_path FROM albums")? .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? .filter_map(|r| r.ok()) .collect(); let mut repaired = 0; for (id, art_path, artist_art_path) in rows { if let Some(path) = art_path.and_then(|p| cached_art_path(&p, art_dir)) { conn.execute( "UPDATE albums SET art_path = ?2 WHERE id = ?1", rusqlite::params![id, path], )?; repaired += 1; } if let Some(path) = artist_art_path.and_then(|p| cached_art_path(&p, art_dir)) { conn.execute( "UPDATE albums SET artist_art_path = ?2 WHERE id = ?1", rusqlite::params![id, path], )?; repaired += 1; } } Ok(repaired) } /// Remove rows accidentally created for non-audio download extras (cover /// images, playlists, text files, etc.). The library should only ever /// expose playable audio tracks. pub fn prune_non_audio(&self) -> Result { let conn = self.conn.lock().unwrap(); let rows: Vec<(i64, String)> = conn .prepare("SELECT id, path FROM tracks")? .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? .filter_map(|r| r.ok()) .collect(); let mut removed = 0; for (id, path) in rows { let audio = Path::new(&path) .extension() .and_then(|e| e.to_str()) .map(|e| { matches!( e.to_ascii_lowercase().as_str(), "flac" | "mp3" | "m4a" | "ogg" | "opus" | "wav" | "aac" | "wma" ) }) .unwrap_or(false); if !audio { conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?; removed += 1; } } conn.execute( "DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)", [], )?; Ok(removed) } } fn cached_art_path(path: &str, art_dir: &Path) -> Option { if Path::new(path).is_file() { return None; } let name = Path::new(path).file_name()?; let candidate = art_dir.join(name); candidate .is_file() .then(|| candidate.to_string_lossy().into_owned()) } #[cfg(test)] mod tests { use super::*; #[test] fn roundtrip() { let dir = std::env::temp_dir().join("ssplus-lib-test"); std::fs::create_dir_all(&dir).unwrap(); let db = dir.join("t.db"); let _ = std::fs::remove_file(&db); let lib = Library::open(&db).unwrap(); let album = lib .upsert_album( "Radiohead", "OK Computer", Some(1997), None, None, None, 100, ) .unwrap(); // Same album, case-insensitive → same id, art fills in. let again = lib .upsert_album( "radiohead", "ok computer", None, Some("art.jpg"), None, None, 200, ) .unwrap(); assert_eq!(album, again); lib.upsert_track( album, Some(1), "Airbag", "Radiohead", Some(287), Some(1024), Some(44100), Some(16), Some(30_000_000), "C:\\x\\01.flac", 100, ) .unwrap(); lib.upsert_track( album, Some(2), "Paranoid Android", "Radiohead", None, None, None, None, None, "C:\\x\\02.flac", 100, ) .unwrap(); let albums = lib.list_albums().unwrap(); assert_eq!(albums.len(), 1); assert_eq!(albums[0].track_count, 2); assert_eq!(albums[0].art_path.as_deref(), Some("art.jpg")); let tracks = lib.album_tracks(album).unwrap(); assert_eq!(tracks[0].title, "Airbag"); assert!(lib.has_track_path("C:\\x\\01.flac").unwrap()); assert!(lib.has_track("radiohead", "ok computer", "airbag").unwrap()); assert!(!lib .has_track("Radiohead", "OK Computer", "Karma Police") .unwrap()); let art_dir = dir.join("art"); std::fs::create_dir_all(&art_dir).unwrap(); let cover = art_dir.join("cover.jpg"); let artist = art_dir.join("artist.jpg"); std::fs::write(&cover, []).unwrap(); std::fs::write(&artist, []).unwrap(); let stale = lib .upsert_album( "Stale Artist", "Stale Album", None, Some("C:\\old-app-data\\art\\cover.jpg"), Some("C:\\old-app-data\\art\\artist.jpg"), None, 100, ) .unwrap(); assert_eq!(lib.repair_art_paths(&art_dir).unwrap(), 2); let repaired = lib.get_album(stale).unwrap().unwrap().0; assert_eq!( repaired.art_path.as_deref(), Some(cover.to_string_lossy().as_ref()) ); assert_eq!( repaired.artist_art_path.as_deref(), Some(artist.to_string_lossy().as_ref()) ); } }