//! Music library: SQLite-backed albums and tracks. All methods are //! synchronous — call from a blocking context. use rusqlite::{Connection, OptionalExtension}; 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, } /// Per-album aggregates of what the files carry — the Library page's numbers. /// Everything here is derived from the tracks table plus the plays table; /// nothing is stored, so a rescan or a new play is reflected on the next call. #[derive(Debug, Clone, Serialize)] pub struct AlbumStats { pub album_id: i64, /// Total running time of the album's tracks, seconds. pub secs: i64, /// Total size on disk, bytes. pub bytes: i64, pub avg_kbps: Option, pub bit_depth: Option, pub sample_rate: Option, /// File extensions present, dominant first ("flac", "mp3", ...). pub formats: Vec, /// Newest play that met the caller's seconds floor. None = never played. pub last_played_at: Option, } /// One followed artist. Name and portrait are stored alongside the id so the /// future home page can render "artists you follow" without paying a catalog /// request per artist — the row alone is enough to draw a card. #[derive(Debug, Clone, Serialize)] pub struct FollowedArtist { pub artist_id: u64, pub name: String, pub portrait: Option, pub followed_at: i64, } /// 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, } /// One album in the sidebar's recently-played list. /// /// Two kinds, because most listening here is STREAMED and never touches the /// local library. A streamed play is anchored on the catalog album id — an /// exact id, not a name — and carries its own remote cover. #[derive(Debug, Clone, Serialize)] pub struct RecentAlbum { /// "library" or "catalog" — decides which page a click opens. pub kind: String, pub album_id: Option, pub catalog_album_id: Option, pub artist: String, pub title: String, /// Local cover, a file path (library rows). pub art_path: Option, /// Remote cover URL (catalog rows). pub art_url: Option, /// Newest play of this album, so the frontend can group by day. pub last_played_at: i64, } /// Catalog identity for a streamed play. `album_id` is the anchor; the rest /// ride along because a streamed album has no local row to read them back from. #[derive(Debug, Clone, Copy, Default)] pub struct CatalogPlay<'a> { pub album_id: Option<&'a str>, pub title: Option<&'a str>, pub artist: Option<&'a str>, pub cover: Option<&'a str>, } 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); CREATE INDEX IF NOT EXISTS idx_plays_started ON plays(started_at DESC); CREATE TABLE IF NOT EXISTS followed_artists ( artist_id INTEGER PRIMARY KEY, name TEXT NOT NULL, portrait TEXT, followed_at INTEGER NOT NULL ); ", )?; // Migrate databases created before artist portraits were added. let _ = conn.execute("ALTER TABLE albums ADD COLUMN artist_art_path TEXT", []); // Migrate databases created before plays remembered which album they // belonged to. Rows written before this stay NULL and are simply absent // from the recents list — they cannot be recovered by matching on name, // and guessing an album from a title is exactly what the identity rules // forbid. let _ = conn.execute("ALTER TABLE plays ADD COLUMN album_id INTEGER", []); // Streamed listening never touches the local library, so it needs its // own anchor: the catalog album id. Exact id, never a name match. The // title/artist/cover ride along because a streamed album has no local // row to read them back from. let _ = conn.execute("ALTER TABLE plays ADD COLUMN catalog_album_id TEXT", []); let _ = conn.execute("ALTER TABLE plays ADD COLUMN cat_title TEXT", []); let _ = conn.execute("ALTER TABLE plays ADD COLUMN cat_artist TEXT", []); let _ = conn.execute("ALTER TABLE plays ADD COLUMN cat_cover TEXT", []); let _ = conn.execute( "CREATE INDEX IF NOT EXISTS idx_plays_catalog ON plays(catalog_album_id)", [], ); let _ = conn.execute( "CREATE INDEX IF NOT EXISTS idx_plays_album ON plays(album_id)", [], ); // Backfill history that predates the column — but ONLY where the // (artist, title) pair resolves to exactly ONE album in the library. // A title that appears on both an album and a compilation is genuinely // ambiguous, so it is left NULL rather than guessed at: a name is not // an identity, and a wrong cover is worse than no row. Streamed plays // have no library track at all and stay NULL forever, which is correct. let _ = conn.execute( "UPDATE plays SET album_id = ( SELECT t.album_id FROM tracks t WHERE t.title = plays.title COLLATE NOCASE AND t.artist = plays.artist COLLATE NOCASE GROUP BY t.title HAVING COUNT(DISTINCT t.album_id) = 1 ) WHERE album_id IS NULL", [], ); // 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. /// `path` is the track's file path, and it is the ONLY thing used to find /// the album: `tracks.path` is UNIQUE, so this is an exact anchor. Never /// resolve the album by artist/title — a name match is not an identity. /// A streamed track with no library row simply records album_id NULL. #[allow(clippy::too_many_arguments)] pub fn record_play( &self, artist: &str, title: &str, path: Option<&str>, cat: CatalogPlay<'_>, now: i64, ) -> Result { let conn = self.conn.lock().unwrap(); let album_id: Option = match path { Some(p) => conn .query_row( "SELECT album_id FROM tracks WHERE path = ?1", rusqlite::params![p], |r| r.get(0), ) .optional()?, None => None, }; conn.execute( "INSERT INTO plays (artist, title, started_at, seconds, album_id, catalog_album_id, cat_title, cat_artist, cat_cover) VALUES (?1, ?2, ?3, 0, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![ artist, title, now, album_id, cat.album_id, cat.title, cat.artist, cat.cover ], )?; Ok(conn.last_insert_rowid()) } /// The sidebar's recently-played list: one row per album, most recent first. /// /// `min_secs` is not optional in spirit. A play row is opened after ~1s of /// audio, so every skip writes one; without a floor this is a skip log, not /// a history. Measured on the live table: 183 grouped runs, 135 of which /// survive a 45-second floor. /// Count of play rows, so a destructive call can report what it will take. pub fn play_row_count(&self) -> Result { let conn = self.conn.lock().unwrap(); Ok(conn.query_row("SELECT COUNT(*) FROM plays", [], |r| r.get(0))?) } /// Wipe all listening history. Returns the number of rows removed. /// Only `plays` — albums, tracks and playlists are untouched. pub fn clear_play_history(&self) -> Result { let conn = self.conn.lock().unwrap(); Ok(conn.execute("DELETE FROM plays", [])?) } pub fn recent_albums(&self, limit: u32, min_secs: u32) -> Result> { let conn = self.conn.lock().unwrap(); // Two sources, one list — and one RECORD must stay one row even when // it was played both ways. A play carries `album_id` only when the // played file was a library track, so a STREAMED play of a record the // user also saved groups under its catalog id with album_id NULL, and // the old per-row exclusion let the same record appear twice (library // "Origin" over catalog "Origin", 2026-08-03). // // The bridge is `link`: any play that carried BOTH ids — a local track // played with catalog identity on screen — pins that catalog album to // that library album, by exact ids, never by name. The library arm // then absorbs the linked catalog plays (so streaming the record last // still bubbles it to the top), and the catalog arm drops them. An // album later deleted from the library falls out of `link` with it, // and its catalog plays stand on their own again. Within one // catalog_album_id the title/artist/cover are identical on every row, // so MAX() is just "pick one". let mut stmt = conn.prepare( "WITH link AS ( SELECT DISTINCT p.album_id, p.catalog_album_id FROM plays p JOIN albums a ON a.id = p.album_id WHERE p.catalog_album_id IS NOT NULL ) SELECT * FROM ( SELECT 'library' AS kind, a.id AS lib_id, NULL AS cat_id, a.artist AS artist, a.title AS title, a.art_path AS art_path, NULL AS art_url, MAX(p.started_at) AS last_at FROM plays p JOIN albums a ON a.id = COALESCE( p.album_id, (SELECT l.album_id FROM link l WHERE l.catalog_album_id = p.catalog_album_id LIMIT 1)) WHERE p.seconds >= ?2 GROUP BY a.id UNION ALL SELECT 'catalog', NULL, p.catalog_album_id, MAX(p.cat_artist), MAX(p.cat_title), NULL, MAX(p.cat_cover), MAX(p.started_at) FROM plays p WHERE p.catalog_album_id IS NOT NULL AND p.album_id IS NULL AND p.seconds >= ?2 AND NOT EXISTS (SELECT 1 FROM link l WHERE l.catalog_album_id = p.catalog_album_id) GROUP BY p.catalog_album_id ) ORDER BY last_at DESC LIMIT ?1", )?; let rows = stmt.query_map(rusqlite::params![limit, min_secs], |r| { Ok(RecentAlbum { kind: r.get(0)?, album_id: r.get(1)?, catalog_album_id: r.get(2)?, artist: r.get::<_, Option>(3)?.unwrap_or_default(), title: r.get::<_, Option>(4)?.unwrap_or_default(), art_path: r.get(5)?, art_url: r.get(6)?, last_played_at: r.get(7)?, }) })?; rows.collect::>>().map_err(Into::into) } 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()) } /// Per-album aggregates for the Library page: what the FILES add up to. /// One call for the whole library, because the page derives its stat row, /// its Format grouping and four of its sorts (length, size, bitrate, /// format) from these — asking per album would be N invokes on open. /// /// `min_secs` is the same play floor the recents list uses (the frontend /// passes its RECENTS_FLOOR_SECS): under it a play is a skip, and a page /// that sorts by "never played first" must not count skips as listening. pub fn album_stats(&self, min_secs: u32) -> Result> { let conn = self.conn.lock().unwrap(); // Aggregate in Rust off one pass over tracks rather than in SQL: // the dominant FORMAT needs the path's extension counted per album, // which SQLite cannot do without string gymnastics, and a library is // a few thousand rows — one iteration is nothing. let mut stmt = conn.prepare( "SELECT album_id, duration_secs, size, bitrate_kbps, bit_depth, sample_rate, path FROM tracks", )?; struct Acc { secs: i64, bytes: i64, kbps_sum: i64, kbps_n: i64, depth: Option, rate: Option, exts: Vec<(String, u32)>, } let mut by_album: std::collections::HashMap = std::collections::HashMap::new(); let rows = stmt.query_map([], |r| { Ok(( r.get::<_, i64>(0)?, r.get::<_, Option>(1)?, r.get::<_, Option>(2)?, r.get::<_, Option>(3)?, r.get::<_, Option>(4)?, r.get::<_, Option>(5)?, r.get::<_, String>(6)?, )) })?; for row in rows { let Ok((album_id, secs, size, kbps, depth, rate, path)) = row else { continue }; let acc = by_album.entry(album_id).or_insert(Acc { secs: 0, bytes: 0, kbps_sum: 0, kbps_n: 0, depth: None, rate: None, exts: Vec::new(), }); acc.secs += secs.unwrap_or(0); acc.bytes += size.unwrap_or(0); if let Some(k) = kbps { acc.kbps_sum += k; acc.kbps_n += 1; } acc.depth = acc.depth.max(depth); acc.rate = acc.rate.max(rate); let ext = std::path::Path::new(&path) .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); if !ext.is_empty() { match acc.exts.iter_mut().find(|(e, _)| *e == ext) { Some((_, n)) => *n += 1, None => acc.exts.push((ext, 1)), } } } // Newest qualifying play per album. Exact album_id anchor only — a play // written before the album_id column existed simply does not count, // exactly as the recents list treats it. let mut plays = conn.prepare( "SELECT album_id, MAX(started_at) FROM plays WHERE album_id IS NOT NULL AND seconds >= ?1 GROUP BY album_id", )?; let mut last_played: std::collections::HashMap = std::collections::HashMap::new(); let play_rows = plays.query_map([min_secs], |r| { Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)) })?; for row in play_rows { if let Ok((id, at)) = row { last_played.insert(id, at); } } Ok(by_album .into_iter() .map(|(album_id, mut acc)| { // Dominant format first, so `formats[0]` is the album's chip. acc.exts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); AlbumStats { album_id, secs: acc.secs, bytes: acc.bytes, avg_kbps: if acc.kbps_n > 0 { Some((acc.kbps_sum / acc.kbps_n) as u32) } else { None }, bit_depth: acc.depth, sample_rate: acc.rate, formats: acc.exts.into_iter().map(|(e, _)| e).collect(), last_played_at: last_played.get(&album_id).copied(), } }) .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(()) } // ---- followed artists ---- /// Idempotent: re-following refreshes the stored name/portrait (identity /// improves over time — a page visit can carry a better portrait) but /// keeps the original followed_at, so "following since" stays honest. pub fn follow_artist( &self, artist_id: u64, name: &str, portrait: Option<&str>, now: i64, ) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO followed_artists (artist_id, name, portrait, followed_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(artist_id) DO UPDATE SET name = ?2, portrait = COALESCE(?3, portrait)", rusqlite::params![artist_id as i64, name, portrait, now], )?; Ok(()) } pub fn unfollow_artist(&self, artist_id: u64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "DELETE FROM followed_artists WHERE artist_id = ?1", [artist_id as i64], )?; Ok(()) } /// Newest follow first — the order a home-page rail wants. pub fn followed_artists(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT artist_id, name, portrait, followed_at FROM followed_artists ORDER BY followed_at DESC", )?; let rows = stmt.query_map([], |r| { Ok(FollowedArtist { artist_id: r.get::<_, i64>(0)? as u64, name: r.get(1)?, portrait: r.get(2)?, followed_at: r.get(3)?, }) })?; Ok(rows.filter_map(|r| r.ok()).collect()) } // ---- 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::*; /// One record, played both as library files and as a stream, is ONE /// recents row. This is the "two Origins in the sidebar" bug: a saved /// album's streamed plays carry only the catalog id (album_id NULL), and /// the old query let them stand as a second row beside the library one. /// The dual-id play a local track records when the catalog page is on /// screen is the bridge that folds them together — and the merged row /// must carry the NEWEST play from either side, so a record streamed /// five minutes ago sorts above one saved-and-played an hour ago. #[test] fn recents_merge_streamed_plays_of_a_saved_album() { let dir = std::env::temp_dir().join("ssplus-lib-recents-merge"); 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("Vanilla", "Origin", Some(2015), Some("origin.jpg"), None, None, 100) .unwrap(); lib.upsert_track( album, Some(1), "Intro", "Vanilla", Some(120), None, None, None, None, "C:\\m\\origin\\01.mp3", 100, ) .unwrap(); let cat = CatalogPlay { album_id: Some("794088211"), title: Some("Origin"), artist: Some("Vanilla"), cover: Some("https://cdn/origin.jpg"), }; // A local track played with the catalog page on screen: BOTH ids. let p1 = lib .record_play("Vanilla", "Intro", Some("C:\\m\\origin\\01.mp3"), cat, 1000) .unwrap(); lib.add_play_seconds(p1, 60).unwrap(); // The same record STREAMED later: catalog id only, newer timestamp. let cat2 = CatalogPlay { album_id: Some("794088211"), title: Some("Origin"), artist: Some("Vanilla"), cover: Some("https://cdn/origin.jpg"), }; let p2 = lib.record_play("Vanilla", "Intro", None, cat2, 2000).unwrap(); lib.add_play_seconds(p2, 60).unwrap(); // An unrelated stream-only record keeps its own row. let other = CatalogPlay { album_id: Some("555"), title: Some("Elsewhere"), artist: Some("Someone"), cover: None, }; let p3 = lib.record_play("Someone", "Song", None, other, 1500).unwrap(); lib.add_play_seconds(p3, 60).unwrap(); let rows = lib.recent_albums(10, 45).unwrap(); let origins: Vec<_> = rows.iter().filter(|r| r.title == "Origin").collect(); assert_eq!(origins.len(), 1, "one record, one row: {rows:?}"); assert_eq!(origins[0].kind, "library"); assert_eq!(origins[0].album_id, Some(album)); // The streamed play is the newest — the merged row must carry it. assert_eq!(origins[0].last_played_at, 2000); assert!(rows.iter().any(|r| r.catalog_album_id.as_deref() == Some("555"))); // And the merged record sorts ABOVE the older unrelated one. assert_eq!(rows[0].title, "Origin"); } #[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()) ); } }