//! 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), /// A reorder that is not a permutation of the playlist's current rows — /// a stale caller, refused rather than silently dropping rows. #[error("playlist order does not match its rows")] OrderMismatch, } 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, /// Which disc of the release this track belongs to, 1-based. None for a /// single-disc album, which is nearly all of them. /// /// Without it a multi-disc release is unreadable: every disc numbers its /// tracks from 1, so eleven volumes of Analord filed as one album put /// eleven "track 1"s at the top of the list, in alphabetical order, and /// nothing anywhere said which volume any of them came from. pub disc_no: Option, 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, pub description: Option, /// "manual" for ordinary playlists, "setlist" for imported timestamped /// tracklists. A kind describes what the rows MEAN (a setlist row carries /// a position in a source recording), not where the list came from. pub kind: String, pub pinned: bool, /// Managed custom cover, when one has been chosen. The automatic mosaic is /// composed on demand from `art_seeds` and never stored here. pub cover_path: Option, pub created_at: i64, pub updated_at: i64, /// Bumped by every mutation. The frontend uses it to drop stale fetches /// and to key the composed mosaic cache. pub revision: i64, /// Sum of the rows' known durations, seconds. pub duration_secs: i64, /// Rows that cannot play right now: imported entries never matched to a /// local file. (Rows whose file has since vanished are found at read time /// by the command layer, not stored.) pub unresolved_count: u32, /// First four DISTINCT art paths in playlist order — the mosaic's seed. pub art_seeds: Vec, } /// One occurrence in a playlist. `id` is the row's own identity: the same /// recording may appear twice, and a reorder must never change which row a /// remove or highlight addresses. Position is layout, not identity. #[derive(Debug, Clone, Serialize)] pub struct PlaylistItem { pub id: i64, pub position: i64, pub title: String, pub artist: String, pub album: Option, pub duration_secs: Option, /// The playable local file, when resolution found one. A stream or /// unresolved row keeps its metadata and order with no path at all. pub path: Option, pub art_path: Option, /// "local" (a file on disk) | "stream" (full identity, plays through the /// resolver) | "unresolved" (import placeholder awaiting repair). pub kind: String, /// Sanitized provenance: the raw locator or pasted line this row came /// from. Repair tools read it; nothing ever executes it. pub source_line: Option, /// Setlist timestamp: where in the source recording this occurrence /// begins, milliseconds. pub start_ms: Option, pub note: Option, /// A stream row's identity anchor: the catalog track id (exact id, never /// a name match — the same idiom as plays.catalog_album_id). pub track_id: Option, /// A stream row's cover, which has no local file to be read from. pub art_url: Option, } /// Insert payload for one playlist row (`add_playlist_items` / `import_playlist`). #[derive(Debug, Clone, Default)] pub struct NewPlaylistItem { pub title: String, pub artist: String, pub album: Option, pub duration_secs: Option, pub path: Option, pub art_path: Option, /// "local" | "stream" | "unresolved"; empty lets the insert classify: /// a path is local, a full identity streams, anything weaker is a /// placeholder (see `pathless_kind`). pub kind: String, pub source_line: Option, pub start_ms: Option, pub note: Option, pub track_id: Option, pub art_url: Option, } /// Classify a row that names no local file. A full identity — an artist and /// a real title, never the DJ unknown-token "ID" — plays through the normal /// resolver exactly like a catalog track, so it is a first-class 'stream' /// row. Anything weaker stays an 'unresolved' placeholder for the repair /// tools. (The same gate the playlist page always used to decide whether a /// click streams or opens repair.) fn pathless_kind(artist: &str, title: &str) -> &'static str { let t = title.trim(); if !artist.trim().is_empty() && !t.is_empty() && !t.eq_ignore_ascii_case("id") && !t.eq_ignore_ascii_case("i.d.") { "stream" } else { "unresolved" } } /// One row of the whole-library matching index (playlist import resolution: /// the importer loads this once and matches parsed entries in memory, so the /// database lock is held for one read, never per entry). #[derive(Debug, Clone, Serialize)] pub struct TrackIndexRow { pub title: String, pub artist: String, pub album: String, pub duration_secs: Option, pub path: String, pub art_path: Option, } /// Legacy row shape (pre-occurrence-identity), kept for the migration copy. #[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 mut 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, disc_no INTEGER, 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) ); -- The occurrence table that replaced playlist_tracks. Every row has -- its OWN id: position is layout, never identity, so duplicates and -- reorders are safe and an import can keep unresolved placeholders -- (path NULL) in their source order. playlist_tracks stays behind, -- read once by the migration below and never written again. CREATE TABLE IF NOT EXISTS playlist_items ( id INTEGER PRIMARY KEY, playlist_id INTEGER NOT NULL, position INTEGER NOT NULL, title TEXT NOT NULL, artist TEXT NOT NULL DEFAULT '', album TEXT, duration_secs INTEGER, path TEXT, art_path TEXT, kind TEXT NOT NULL DEFAULT 'local', source_line TEXT, start_ms INTEGER, note TEXT, added_at INTEGER NOT NULL DEFAULT 0, track_id TEXT, art_url TEXT ); CREATE INDEX IF NOT EXISTS idx_playlist_items ON playlist_items(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 ); -- What this database knows about ITSELF. `library_root` is the -- folder its paths were last written against; when that changes, -- every stored path is re-based (see `rebase_to_root`), which is -- what lets the library travel between machines and drive letters. CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT 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", []); // Migrate databases created before a release could have more than one // disc. Existing rows stay NULL, which orders exactly as they did // before — a null disc sorts with every other null disc, so a // single-disc album is untouched and a multi-disc one is only fixed // once its files are read again. let _ = conn.execute("ALTER TABLE tracks ADD COLUMN disc_no INTEGER", []); // Playlist management upgrade (2026-08): metadata the original // three-column table never carried. let _ = conn.execute("ALTER TABLE playlists ADD COLUMN description TEXT", []); let _ = conn.execute( "ALTER TABLE playlists ADD COLUMN kind TEXT NOT NULL DEFAULT 'manual'", [], ); let _ = conn.execute( "ALTER TABLE playlists ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0", [], ); let _ = conn.execute("ALTER TABLE playlists ADD COLUMN cover_path TEXT", []); let _ = conn.execute( "ALTER TABLE playlists ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0", [], ); let _ = conn.execute( "ALTER TABLE playlists ADD COLUMN revision INTEGER NOT NULL DEFAULT 0", [], ); // Playlist rows split into local vs stream (2026-08-18): a row with a // full identity and no file is a STREAMING track, not an error state. // Its anchor is the catalog track id (exact id, never a name match — // the plays.catalog_album_id idiom) with the cover URL riding along, // since a stream row has no local file to read artwork from. let _ = conn.execute("ALTER TABLE playlist_items ADD COLUMN track_id TEXT", []); let _ = conn.execute("ALTER TABLE playlist_items ADD COLUMN art_url TEXT", []); // Reclassify rows stored before the split: anything 'unresolved' that // carries an artist and a real title already streamed on click — the // kind now says so. Idempotent, so it simply runs on every open. let _ = conn.execute( "UPDATE playlist_items SET kind = 'stream' WHERE kind = 'unresolved' AND TRIM(artist) <> '' AND TRIM(title) <> '' AND LOWER(TRIM(title)) NOT IN ('id', 'i.d.')", [], ); migrate_playlist_rows(&mut conn)?; 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(()) } /// One row per distinct album artist, with whatever portrait path is /// stored against it. The caller decides what counts as missing — the /// library has no view of the filesystem, and a path that is present in the /// database but gone from disk is exactly the case worth repairing. pub fn artist_portraits(&self) -> Result)>> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT artist, MAX(artist_art_path) FROM albums GROUP BY artist COLLATE NOCASE", )?; let rows = stmt .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? .collect::, _>>()?; Ok(rows) } 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, disc_no: Option, 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, disc_no, 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, ?12) ON CONFLICT(path) DO UPDATE SET album_id = excluded.album_id, disc_no = excluded.disc_no, 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, disc_no, 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()) } /// Album id → MusicBrainz release id, for the albums that have one. pub fn album_mbids(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare("SELECT id, mbid FROM albums WHERE mbid IS NOT NULL AND mbid != ''")?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; 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( // Disc first, and a disc that says nothing sorts BEFORE one that // does: a single-disc album is all nulls and orders exactly as it // always has, while a release whose discs are known plays 1 then 2 // rather than interleaving eleven track-1s. "SELECT id, album_id, disc_no, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path FROM tracks WHERE album_id = ?1 ORDER BY disc_no IS NOT NULL, disc_no, 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)?, disc_no: r.get(2)?, track_no: r.get(3)?, title: r.get(4)?, artist: r.get(5)?, duration_secs: r.get(6)?, bitrate_kbps: r.get(7)?, sample_rate: r.get(8)?, bit_depth: r.get(9)?, size: r.get::<_, Option>(10)?.map(|n| n as u64), path: r.get(11)?, }) })?; Ok(rows.filter_map(|r| r.ok()).collect()) } /// Drop specific track rows by id. Used when the import tool replaces an /// album's audio with another version: the rows for the copy that was /// replaced go with its files. The album row is deliberately left alone — /// the caller is mid-apply and has already registered the replacement. pub fn remove_tracks(&self, ids: &[i64]) -> Result { let conn = self.conn.lock().unwrap(); let mut removed = 0; for id in ids { removed += conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?; } Ok(removed) } /// 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 ---- // // Every mutation bumps the playlist's revision and updated_at inside the // same transaction as the change itself, so a revision can never describe // rows it did not see. Multi-row edits are single transactions: a failed // import or reorder leaves the playlist exactly as it was. pub fn create_playlist(&self, name: &str, kind: &str, now: i64) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO playlists (name, kind, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", rusqlite::params![name, kind, now], )?; Ok(conn.last_insert_rowid()) } pub fn rename_playlist(&self, id: i64, name: &str, now: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE playlists SET name = ?2, updated_at = ?3, revision = revision + 1 WHERE id = ?1", rusqlite::params![id, name, now], )?; Ok(()) } pub fn set_playlist_description(&self, id: i64, description: Option<&str>, now: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE playlists SET description = ?2, updated_at = ?3, revision = revision + 1 WHERE id = ?1", rusqlite::params![id, description, now], )?; Ok(()) } pub fn set_playlist_pinned(&self, id: i64, pinned: bool, now: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE playlists SET pinned = ?2, updated_at = ?3 WHERE id = ?1", rusqlite::params![id, pinned as i64, now], )?; Ok(()) } /// `cover_path` None clears the custom cover (the automatic mosaic takes /// over again — "custom wins, removing it restores the fallback"). pub fn set_playlist_cover(&self, id: i64, cover_path: Option<&str>, now: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE playlists SET cover_path = ?2, updated_at = ?3, revision = revision + 1 WHERE id = ?1", rusqlite::params![id, cover_path, now], )?; Ok(()) } pub fn delete_playlist(&self, id: i64) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute("DELETE FROM playlist_items WHERE playlist_id = ?1", [id])?; tx.execute("DELETE FROM playlist_tracks WHERE playlist_id = ?1", [id])?; tx.execute("DELETE FROM playlists WHERE id = ?1", [id])?; tx.commit()?; Ok(()) } /// All playlists with their aggregates, pinned first, then newest first. pub fn list_playlists(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT p.id, p.name, p.description, p.kind, p.pinned, p.cover_path, p.created_at, p.updated_at, p.revision, (SELECT COUNT(*) FROM playlist_items i WHERE i.playlist_id = p.id), (SELECT COALESCE(SUM(i.duration_secs), 0) FROM playlist_items i WHERE i.playlist_id = p.id), (SELECT COUNT(*) FROM playlist_items i WHERE i.playlist_id = p.id AND i.kind = 'unresolved') FROM playlists p ORDER BY p.pinned DESC, p.created_at DESC", )?; let mut lists: Vec = stmt .query_map([], |r| { Ok(Playlist { id: r.get(0)?, name: r.get(1)?, description: r.get(2)?, kind: r.get(3)?, pinned: r.get::<_, i64>(4)? != 0, cover_path: r.get(5)?, created_at: r.get(6)?, updated_at: r.get(7)?, revision: r.get(8)?, track_count: r.get::<_, i64>(9)? as u32, duration_secs: r.get(10)?, unresolved_count: r.get::<_, i64>(11)? as u32, art_seeds: Vec::new(), }) })? .collect::>()?; // Mosaic seeds: the first four DISTINCT art paths in playlist order. // One pass over all rows rather than a query per playlist — a sidebar // refresh runs this, and a playlist is a few hundred rows at most. let mut seed_stmt = conn.prepare( "SELECT playlist_id, art_path FROM playlist_items WHERE art_path IS NOT NULL ORDER BY playlist_id, position", )?; let mut seeds: std::collections::HashMap> = std::collections::HashMap::new(); let rows = seed_stmt.query_map([], |r| { Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)) })?; for row in rows.flatten() { let (pid, art) = row; let list = seeds.entry(pid).or_default(); if list.len() < 4 && !list.contains(&art) { list.push(art); } } for playlist in &mut lists { if let Some(list) = seeds.remove(&playlist.id) { playlist.art_seeds = list; } } Ok(lists) } pub fn playlist_meta(&self, id: i64) -> Result> { Ok(self.list_playlists()?.into_iter().find(|p| p.id == id)) } pub fn playlist_items(&self, playlist_id: i64) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, position, title, artist, album, duration_secs, path, art_path, kind, source_line, start_ms, note, track_id, art_url FROM playlist_items WHERE playlist_id = ?1 ORDER BY position", )?; let rows = stmt.query_map([playlist_id], |r| { Ok(PlaylistItem { id: r.get(0)?, position: r.get(1)?, title: r.get(2)?, artist: r.get(3)?, album: r.get(4)?, duration_secs: r.get(5)?, path: r.get(6)?, art_path: r.get(7)?, kind: r.get(8)?, source_line: r.get(9)?, start_ms: r.get(10)?, note: r.get(11)?, track_id: r.get(12)?, art_url: r.get(13)?, }) })?; rows.collect::>>().map_err(Into::into) } /// Append (or insert at `at`) a batch of rows in ONE transaction. A batch /// that fails leaves nothing behind — the old per-row loop could commit /// half an album. Returns how many rows were added. pub fn add_playlist_items( &self, playlist_id: i64, items: &[NewPlaylistItem], at: Option, now: i64, ) -> Result { if items.is_empty() { return Ok(0); } let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; // The playlist must exist: playlist_items has no enforced FK, so an // add racing a delete would otherwise write permanent invisible rows. let exists: i64 = tx.query_row( "SELECT COUNT(*) FROM playlists WHERE id = ?1", [playlist_id], |r| r.get(0), )?; if exists == 0 { return Err(LibraryError::Sqlite(rusqlite::Error::QueryReturnedNoRows)); } let count: i64 = tx.query_row( "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?1", [playlist_id], |r| r.get(0), )?; let insert_at = at.unwrap_or(count).clamp(0, count); if insert_at < count { // Shift the tail out of the way. Offset by a large stride first so // the (playlist, position) pairs never collide mid-update. tx.execute( "UPDATE playlist_items SET position = position + 1000000 WHERE playlist_id = ?1 AND position >= ?2", rusqlite::params![playlist_id, insert_at], )?; tx.execute( "UPDATE playlist_items SET position = position - 1000000 + ?3 WHERE playlist_id = ?1 AND position >= 1000000 + ?2", rusqlite::params![playlist_id, insert_at, items.len() as i64], )?; } { let mut ins = tx.prepare( "INSERT INTO playlist_items (playlist_id, position, title, artist, album, duration_secs, path, art_path, kind, source_line, start_ms, note, added_at, track_id, art_url) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", )?; for (offset, item) in items.iter().enumerate() { let kind = if item.kind.is_empty() { if item.path.is_some() { "local" } else { pathless_kind(&item.artist, &item.title) } } else { &item.kind }; ins.execute(rusqlite::params![ playlist_id, insert_at + offset as i64, item.title, item.artist, item.album, item.duration_secs, item.path, item.art_path, kind, item.source_line, item.start_ms, item.note, now, item.track_id, item.art_url, ])?; } } Self::touch_playlist(&tx, playlist_id, now)?; tx.commit()?; Ok(items.len() as u32) } /// Remove rows by their own ids and close the position gaps, atomically. pub fn remove_playlist_items(&self, playlist_id: i64, item_ids: &[i64], now: i64) -> Result { if item_ids.is_empty() { return Ok(0); } let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; let mut removed = 0u32; for id in item_ids { removed += tx.execute( "DELETE FROM playlist_items WHERE playlist_id = ?1 AND id = ?2", rusqlite::params![playlist_id, id], )? as u32; } Self::compact_positions(&tx, playlist_id)?; Self::touch_playlist(&tx, playlist_id, now)?; tx.commit()?; Ok(removed) } /// Persist a complete new order, given every item id in its new sequence. /// Refuses an order that is not a permutation of the playlist's rows — /// a stale frontend must not silently drop or duplicate anything. pub fn set_playlist_order(&self, playlist_id: i64, ordered_ids: &[i64], now: i64) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; let existing: Vec = tx .prepare("SELECT id FROM playlist_items WHERE playlist_id = ?1")? .query_map([playlist_id], |r| r.get(0))? .collect::>()?; let mut want: Vec = ordered_ids.to_vec(); let mut have = existing.clone(); want.sort_unstable(); have.sort_unstable(); if want != have { return Err(LibraryError::OrderMismatch); } // Two passes through a disjoint range so old and new positions never // collide under the (playlist, position) index. tx.execute( "UPDATE playlist_items SET position = position + 1000000 WHERE playlist_id = ?1", [playlist_id], )?; { let mut set = tx.prepare( "UPDATE playlist_items SET position = ?3 WHERE playlist_id = ?1 AND id = ?2", )?; for (position, id) in ordered_ids.iter().enumerate() { set.execute(rusqlite::params![playlist_id, id, position as i64])?; } } Self::touch_playlist(&tx, playlist_id, now)?; tx.commit()?; Ok(()) } /// Repair one row: attach a playable local file (or correct its display /// metadata). Passing `path` Some flips the row to "local". `track_id` /// and `art_url` enrich a stream row's catalog anchor and cover. #[allow(clippy::too_many_arguments)] pub fn relink_playlist_item( &self, playlist_id: i64, item_id: i64, title: Option<&str>, artist: Option<&str>, album: Option<&str>, duration_secs: Option, path: Option<&str>, art_path: Option<&str>, track_id: Option<&str>, art_url: Option<&str>, now: i64, ) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute( "UPDATE playlist_items SET title = COALESCE(?3, title), artist = COALESCE(?4, artist), album = COALESCE(?5, album), duration_secs = COALESCE(?6, duration_secs), path = COALESCE(?7, path), art_path = COALESCE(?8, art_path), track_id = COALESCE(?9, track_id), art_url = COALESCE(?10, art_url), kind = CASE WHEN ?7 IS NOT NULL THEN 'local' ELSE kind END WHERE playlist_id = ?1 AND id = ?2", rusqlite::params![playlist_id, item_id, title, artist, album, duration_secs, path, art_path, track_id, art_url], )?; Self::touch_playlist(&tx, playlist_id, now)?; tx.commit()?; Ok(()) } /// Copy a playlist — metadata (except pin state) and every row, in order. pub fn duplicate_playlist(&self, id: i64, new_name: &str, now: i64) -> Result { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; let inserted = tx.execute( "INSERT INTO playlists (name, description, kind, created_at, updated_at) SELECT ?2, description, kind, ?3, ?3 FROM playlists WHERE id = ?1", rusqlite::params![id, new_name, now], )?; if inserted == 0 { // The source vanished (raced a delete). Without this check, // last_insert_rowid would hand back some EARLIER insert's id. return Err(LibraryError::Sqlite(rusqlite::Error::QueryReturnedNoRows)); } let new_id = tx.last_insert_rowid(); tx.execute( "INSERT INTO playlist_items (playlist_id, position, title, artist, album, duration_secs, path, art_path, kind, source_line, start_ms, note, added_at, track_id, art_url) SELECT ?2, position, title, artist, album, duration_secs, path, art_path, kind, source_line, start_ms, note, ?3, track_id, art_url FROM playlist_items WHERE playlist_id = ?1", rusqlite::params![id, new_id, now], )?; tx.commit()?; Ok(new_id) } /// One transactional import: the playlist and all its ordered rows land /// together or not at all. pub fn import_playlist( &self, name: &str, description: Option<&str>, kind: &str, items: &[NewPlaylistItem], now: i64, ) -> Result { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute( "INSERT INTO playlists (name, description, kind, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?4)", rusqlite::params![name, description, kind, now], )?; let playlist_id = tx.last_insert_rowid(); { let mut ins = tx.prepare( "INSERT INTO playlist_items (playlist_id, position, title, artist, album, duration_secs, path, art_path, kind, source_line, start_ms, note, added_at, track_id, art_url) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", )?; for (position, item) in items.iter().enumerate() { let kind = if item.kind.is_empty() { if item.path.is_some() { "local" } else { pathless_kind(&item.artist, &item.title) } } else { &item.kind }; ins.execute(rusqlite::params![ playlist_id, position as i64, item.title, item.artist, item.album, item.duration_secs, item.path, item.art_path, kind, item.source_line, item.start_ms, item.note, now, item.track_id, item.art_url, ])?; } } tx.commit()?; Ok(playlist_id) } /// Every track with its album context, for in-memory import matching. pub fn track_index(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT t.title, t.artist, a.title, t.duration_secs, t.path, a.art_path FROM tracks t JOIN albums a ON a.id = t.album_id", )?; let rows = stmt.query_map([], |r| { Ok(TrackIndexRow { title: r.get(0)?, artist: r.get(1)?, album: r.get(2)?, duration_secs: r.get(3)?, path: r.get(4)?, art_path: r.get(5)?, }) })?; rows.collect::>>().map_err(Into::into) } fn touch_playlist(conn: &Connection, id: i64, now: i64) -> rusqlite::Result<()> { conn.execute( "UPDATE playlists SET updated_at = ?2, revision = revision + 1 WHERE id = ?1", rusqlite::params![id, now], )?; Ok(()) } /// Rewrite positions 0..n preserving current order. fn compact_positions(conn: &Connection, playlist_id: i64) -> rusqlite::Result<()> { let ids: Vec = conn .prepare( "SELECT id FROM playlist_items WHERE playlist_id = ?1 ORDER BY position", )? .query_map([playlist_id], |r| r.get(0))? .collect::>()?; conn.execute( "UPDATE playlist_items SET position = position + 1000000 WHERE playlist_id = ?1", [playlist_id], )?; let mut set = conn.prepare( "UPDATE playlist_items SET position = ?3 WHERE playlist_id = ?1 AND id = ?2", )?; for (position, id) in ids.iter().enumerate() { set.execute(rusqlite::params![playlist_id, id, position as i64])?; } 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) } /// Every column in the schema that holds a filesystem path. /// `(table, column)` — kept in one place so a new path column cannot be /// silently left behind when the library moves. const PATH_COLUMNS: &'static [(&'static str, &'static str)] = &[ ("tracks", "path"), ("albums", "art_path"), ("albums", "artist_art_path"), ("playlist_tracks", "path"), ("playlist_tracks", "art_path"), ("playlist_items", "path"), ("playlist_items", "art_path"), ("playlists", "cover_path"), ]; /// Repoint every stored path from one folder to another. /// /// This is what makes the library portable. Paths are stored absolute — /// converting the whole schema to relative would mean touching some /// twenty-five read and write sites, and a single missed READ site is a /// silent corruption. Rewriting the prefix once, when the folder actually /// changes, gets the same portability with one place to be wrong. /// /// Windows path comparison is case-insensitive, and only a match at a /// component boundary counts — `…\SoulSeek Music 2` is not inside /// `…\SoulSeek Music`. pub fn rewrite_path_prefix(&self, from: &Path, to: &Path) -> Result { let norm = |p: &Path| { p.to_string_lossy() .trim_end_matches(['/', '\\']) .replace('/', "\\") }; let (from, to) = (norm(from), norm(to)); if from.is_empty() || from.eq_ignore_ascii_case(&to) { return Ok(0); } let conn = self.conn.lock().unwrap(); let mut changed = 0; for (table, column) in Self::PATH_COLUMNS { // LIKE is case-insensitive for ASCII in SQLite, which is the // behaviour wanted here; the escape keeps a literal % or _ in a // folder name from turning into a wildcard. let sql = format!( "UPDATE {table} SET {column} = ?2 || substr({column}, ?3) WHERE {column} LIKE ?1 ESCAPE '\\'" ); // Backslashes are doubled FIRST — escaping `%`/`_` afterwards adds // escape backslashes that must not themselves be doubled. The tail // is an escaped separator followed by a real wildcard (`\\` then // `%`); writing `\%` there would ask for a literal percent sign and // match nothing at all. let pattern = format!( "{}\\\\%", from.replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") ); // substr is 1-based and the prefix keeps its trailing separator // on the new side, so the cut starts one past the old prefix. changed += conn.execute( &sql, rusqlite::params![pattern, to, (from.chars().count() + 1) as i64], )?; } Ok(changed) } /// Read a remembered value (the library root this database was last /// opened against, and anything else worth surviving a restart). pub fn meta(&self, key: &str) -> Result> { let conn = self.conn.lock().unwrap(); Ok(conn .query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0)) .optional()?) } pub fn set_meta(&self, key: &str, value: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", rusqlite::params![key, value], )?; Ok(()) } /// Point the library at `root`, rewriting stored paths if it moved. /// /// A database that has never recorded a root simply learns the current one /// — its paths are already correct for wherever it is being opened from. /// After that, any change (a new drive letter, a folder carried to another /// machine, a renamed library) re-bases every path on first open. pub fn rebase_to_root(&self, root: &Path) -> Result { let current = root.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); let previous = self.meta("library_root")?; let moved = match previous.as_deref() { Some(old) if !old.eq_ignore_ascii_case(¤t) => { self.rewrite_path_prefix(Path::new(old), root)? } _ => 0, }; self.set_meta("library_root", ¤t)?; Ok(moved) } /// Drop everything that does not live under `root` — the library folder. /// /// The library folder is the library. Everything that legitimately enters /// it is organized INTO that folder (`save_to_library` and the download /// pipeline both do), so a row whose file sits somewhere else belongs to a /// library that is no longer the one being shown. /// /// Changing the folder used to leave those rows behind for good: the /// scanner started reading the new folder while `prune_missing` kept every /// old row alive on the grounds that its file still existed. The library /// became the UNION of every folder that had ever been the library folder, /// and emptying the current one could not empty it — six albums from a /// previous folder on another drive outlived a wipe of the whole library. /// /// This removes rows only. Files on disk are never touched, so pointing the /// setting back at the old folder re-indexes it from scratch. pub fn prune_outside_root(&self, root: &Path) -> 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 !is_under(Path::new(&path), root) { conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?; removed += 1; } } if removed > 0 { conn.execute( "DELETE FROM albums WHERE id NOT IN (SELECT album_id FROM tracks WHERE album_id IS NOT NULL)", [], )?; } Ok(removed) } /// Re-anchor album covers to the folder the album's own tracks live in. /// /// Every writer of `art_path` derives it from the album's own folder, so /// the stored value is right when it is written and stays right until the /// FILES move out from under it — a re-import into a new layout, a library /// reorganized by hand, a drive that comes back at a different letter. /// Nothing re-asks after a move: `upsert_album` COALESCEs art (it will not /// overwrite a value it already has) and the startup scan only processes /// files it does not already know. So a stale path survives indefinitely, /// and because every album folder names its cover `cover.jpg`, a stale /// path does not read as broken — it reads as ANOTHER ALBUM'S COVER, which /// renders perfectly and is simply the wrong picture. /// /// The invariant this restores: an album's cover lives either in one of /// that album's own track folders, or in the shared art cache. A path /// pointing anywhere else is somebody else's artwork. /// /// Deliberately conservative — it only ever moves a cover to a file that /// EXISTS, and never clears one. A missing file is left alone: with the /// music drive unplugged every path is missing at once, and that is not /// the moment to rewrite the library's idea of its own artwork. pub fn reanchor_album_art(&self, art_dir: &Path) -> Result { let conn = self.conn.lock().unwrap(); let rows: Vec<(i64, Option, String)> = conn .prepare( "SELECT a.id, a.art_path, t.path FROM albums a JOIN tracks t ON t.album_id = a.id ORDER BY a.id, t.track_no IS NULL, t.track_no, t.title", )? .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? .filter_map(|r| r.ok()) .collect(); // Group by album, preserving track order so "the album's folder" means // the first track's folder for a normal album and still sees every // folder of a multi-disc spread. let mut albums: Vec<(i64, Option, Vec)> = Vec::new(); for (id, art, track) in rows { let dir = match Path::new(&track).parent() { Some(d) => d.to_path_buf(), None => continue, }; match albums.last_mut() { Some((last, _, dirs)) if *last == id => { if !dirs.contains(&dir) { dirs.push(dir); } } _ => albums.push((id, art, vec![dir])), } } let mut moved = 0; for (id, art_path, dirs) in albums { let Some(own) = dirs.iter().find_map(|d| existing_cover(d)) else { continue; }; let keep = match art_path.as_deref() { None => false, Some(p) => { let file = Path::new(p); // A path that is not on disk is not evidence of anything — // see the note above about an absent drive. !file.is_file() || file.parent().is_some_and(|parent| { dirs.iter().any(|d| same_dir(parent, d)) || same_dir(parent, art_dir) }) } }; if keep || art_path.as_deref() == Some(own.as_str()) { continue; } conn.execute( "UPDATE albums SET art_path = ?2 WHERE id = ?1", rusqlite::params![id, own], )?; moved += 1; } Ok(moved) } /// 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 { // The SAME table the scanner indexes by. This list used to be its // own, shorter one — it knew nothing of `.aiff`, `.aif` or `.ape`, // so every AIFF album the scanner added was deleted here on the // next startup, with the album row following it out. if !audio_formats::is_audio_path(Path::new(&path)) { 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) } } /// One-time copy of legacy `playlist_tracks` rows into `playlist_items`. /// /// Runs inside a single transaction and marks itself done in `meta`, so a /// half-finished copy can never be observed and a second open never doubles /// the rows. The legacy table is left in place, read-only — it is the backup /// until this migration has been trusted for a while. /// /// Order is preserved exactly (including gaps left by the old /// remove-by-position, which never compacted), and duplicates survive: each /// legacy row becomes its own occurrence with a fresh id. fn migrate_playlist_rows(conn: &mut Connection) -> Result<()> { let done: Option = conn .query_row( "SELECT value FROM meta WHERE key = 'playlist_items_migrated'", [], |r| r.get(0), ) .optional()?; if done.is_some() { return Ok(()); } let tx = conn.transaction()?; // A database that already has item rows but no marker predates nothing — // it was created by this code; just stamp it. let already: i64 = tx.query_row("SELECT COUNT(*) FROM playlist_items", [], |r| r.get(0))?; if already == 0 { tx.execute( "INSERT INTO playlist_items (playlist_id, position, title, artist, album, duration_secs, path, art_path, kind, added_at) SELECT playlist_id, (SELECT COUNT(*) FROM playlist_tracks q WHERE q.playlist_id = t.playlist_id AND q.position < t.position), title, artist, NULL, duration_secs, path, art_path, 'local', 0 FROM playlist_tracks t ORDER BY playlist_id, position", [], )?; let copied = tx.query_row("SELECT COUNT(*) FROM playlist_items", [], |r| r.get::<_, i64>(0))?; let legacy: i64 = tx.query_row("SELECT COUNT(*) FROM playlist_tracks", [], |r| r.get(0))?; if copied != legacy { // Refuse to stamp a copy that lost rows; the transaction unwinds. return Err(LibraryError::OrderMismatch); } } tx.execute( "INSERT INTO meta (key, value) VALUES ('playlist_items_migrated', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value", [], )?; tx.commit()?; Ok(()) } 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()?; // A file name is only an identity when it names THIS album. Every album // folder calls its cover `cover.jpg`, so matching on that name alone would // repoint the whole library at one shared file the moment the music drive // is unplugged — every path missing at once is precisely when this runs. // The art cache names its entries after the artist and album; those are // the names worth recovering. if is_generic_cover_name(&name.to_string_lossy()) { return None; } let candidate = art_dir.join(name); candidate .is_file() .then(|| candidate.to_string_lossy().into_owned()) } /// `cover.jpg` / `folder.jpg` and friends: the conventional names an album /// folder gives its own artwork, which say nothing about WHICH album. fn is_generic_cover_name(name: &str) -> bool { let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name); matches!( stem.to_ascii_lowercase().as_str(), "cover" | "folder" | "front" | "album" | "albumart" | "thumb" | "artwork" ) } /// The album folder's own cover, under the two names every writer here uses. fn existing_cover(dir: &Path) -> Option { ["cover.jpg", "folder.jpg"].into_iter().find_map(|name| { let p = dir.join(name); p.is_file().then(|| p.to_string_lossy().into_owned()) }) } /// Is `path` inside `root`? Compared as normalized text rather than by walking /// the filesystem: this has to answer for a drive that is not plugged in. /// /// The boundary check matters — `…\SoulSeek Music 2` must not read as inside /// `…\SoulSeek Music`, which a bare `starts_with` on strings would say it is. fn is_under(path: &Path, root: &Path) -> bool { let norm = |p: &Path| { p.to_string_lossy() .trim_end_matches(['/', '\\']) .to_lowercase() .replace('/', "\\") }; let (p, r) = (norm(path), norm(root)); if r.is_empty() { return true; } p.strip_prefix(&r).is_some_and(|rest| rest.starts_with('\\')) } /// Directory equality for stored paths. Windows paths compare case-insensitively /// and a trailing separator must not make a folder differ from itself. fn same_dir(a: &Path, b: &Path) -> bool { let norm = |p: &Path| { p.to_string_lossy() .trim_end_matches(['/', '\\']) .to_lowercase() .replace('/', "\\") }; norm(a) == norm(b) } #[cfg(test)] mod tests { use super::*; /// Build a real album folder holding audio and its own `cover.jpg`. fn album_folder(root: &Path, name: &str) -> std::path::PathBuf { let dir = root.join(name); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("01.mp3"), b"audio").unwrap(); std::fs::write(dir.join("cover.jpg"), name.as_bytes()).unwrap(); dir } /// The point of the whole portability change: carry the drive to another /// machine, where it mounts at a different letter under a different user, /// and the library still finds every file. The art cache travels inside /// the library folder, so it re-bases with everything else. #[test] fn the_library_travels_to_another_machine() { let dir = std::env::temp_dir().join("ssplus-lib-rebase"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let lib = Library::open(&dir.join("t.db")).unwrap(); let work = Path::new("D:\\Music\\SoulSeek Music"); let home = Path::new("F:\\Media\\Library"); let id = lib .upsert_album( "AJJ", "Knife Man", None, Some("D:\\Music\\SoulSeek Music\\.soulstream\\art\\ajj - knife man.jpg"), Some("D:\\Music\\SoulSeek Music\\.soulstream\\art\\artist-ajj.jpg"), None, 100, ) .unwrap(); lib.upsert_track( id, None, Some(1), "Sad Songs", "AJJ", None, None, None, None, None, "D:\\Music\\SoulSeek Music\\AJJ\\2011 - Knife Man\\01.mp3", 100, ) .unwrap(); // First open just learns where it is — nothing to re-base yet. assert_eq!(lib.rebase_to_root(work).unwrap(), 0); // Now the drive is somewhere else entirely. assert_eq!(lib.rebase_to_root(home).unwrap(), 3, "track + cover + portrait"); let album = lib.get_album(id).unwrap().unwrap().0; assert_eq!( album.art_path.as_deref(), Some("F:\\Media\\Library\\.soulstream\\art\\ajj - knife man.jpg") ); assert_eq!( album.artist_art_path.as_deref(), Some("F:\\Media\\Library\\.soulstream\\art\\artist-ajj.jpg") ); assert_eq!( lib.album_tracks(id).unwrap()[0].path, "F:\\Media\\Library\\AJJ\\2011 - Knife Man\\01.mp3" ); // Opening again at the same place changes nothing. assert_eq!(lib.rebase_to_root(home).unwrap(), 0); } /// A prefix rewrite must not cut at the wrong place. The sibling folder /// shares a name prefix, and a `%` or `_` in a folder name is a LIKE /// wildcard unless it is escaped — either mistake silently mangles paths. #[test] fn rebasing_respects_folder_boundaries_and_wildcards() { let dir = std::env::temp_dir().join("ssplus-lib-rebase-edges"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let lib = Library::open(&dir.join("t.db")).unwrap(); let add = |artist: &str, path: &str| { let id = lib.upsert_album(artist, "a", None, None, None, None, 100).unwrap(); lib.upsert_track(id, None, Some(1), "t", artist, None, None, None, None, None, path, 100) .unwrap(); id }; let inside = add("in", "F:\\Music\\Lib\\a\\01.mp3"); let sibling = add("sib", "F:\\Music\\Lib 2\\a\\01.mp3"); let wild = add("wild", "F:\\Music\\Lib\\100%_live\\01.mp3"); assert_eq!(lib.rewrite_path_prefix(Path::new("F:\\Music\\Lib"), Path::new("X:\\New")).unwrap(), 2); assert_eq!(lib.album_tracks(inside).unwrap()[0].path, "X:\\New\\a\\01.mp3"); assert_eq!(lib.album_tracks(wild).unwrap()[0].path, "X:\\New\\100%_live\\01.mp3"); assert_eq!( lib.album_tracks(sibling).unwrap()[0].path, "F:\\Music\\Lib 2\\a\\01.mp3", "a folder that merely starts with the old name is a different folder" ); // A folder name containing a LIKE wildcard is matched literally. assert_eq!( lib.rewrite_path_prefix(Path::new("X:\\New\\100%_live"), Path::new("X:\\New\\live")).unwrap(), 1 ); assert_eq!(lib.album_tracks(wild).unwrap()[0].path, "X:\\New\\live\\01.mp3"); } /// Emptying the library folder left six albums standing, because they were /// indexed while a DIFFERENT folder was the library and their files still /// existed at the old location. `prune_missing` saw files on disk and kept /// them; the library was the union of every folder ever configured. /// /// Also pins the boundary: a sibling folder whose name merely STARTS with /// the root's name is outside it. #[test] fn a_folder_that_is_no_longer_the_library_does_not_stay_in_it() { let dir = std::env::temp_dir().join("ssplus-lib-outside-root"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let db = dir.join("t.db"); let lib = Library::open(&db).unwrap(); let add = |artist: &str, title: &str, path: &str| { let id = lib .upsert_album(artist, title, None, None, None, None, 100) .unwrap(); lib.upsert_track( id, None, Some(1), "t", artist, None, None, None, None, None, path, 100, ) .unwrap(); id }; let old = add("Ween", "Quebec", "C:\\Users\\Kyle\\Music\\SoulSeek+\\Ween\\2014 - Quebec\\01.flac"); let now = add("AJJ", "Knife Man", "F:\\Music\\SoulSeek Music\\AJJ\\2011 - Knife Man\\01.mp3"); // Case and separator style are not identity, and a sibling that shares // a name prefix is a different folder. let cased = add("GUM", "Ill Times", "f:/music/soulseek music/GUM/2025 - Ill Times/01.flac"); let sibling = add("POLYSICS", "Now", "F:\\Music\\SoulSeek Music 2\\POLYSICS\\01.flac"); let root = Path::new("F:\\Music\\SoulSeek Music"); assert_eq!(lib.prune_outside_root(root).unwrap(), 2, "the old folder and the sibling"); let left: Vec = lib.list_albums().unwrap().into_iter().map(|a| a.id).collect(); assert!(left.contains(&now) && left.contains(&cased)); assert!(!left.contains(&old) && !left.contains(&sibling)); assert_eq!(left.len(), 2, "albums left with no tracks must go too"); // Idempotent, and an unplugged drive is still 'inside' the root: this // is a path question, not a disk question. assert_eq!(lib.prune_outside_root(root).unwrap(), 0); } /// The bug this shipped with: 30 albums showing one other album's cover. /// Every album folder names its artwork `cover.jpg`, so a stale path is /// not a broken image — it is a perfectly rendered WRONG picture, and /// nothing in the write path ever re-asks once a value is stored. /// /// Also pins the two paths that must NOT be touched: a cover that lives in /// the shared art cache is legitimate, and one already inside the album's /// own folder is correct by definition. #[test] fn art_is_reanchored_to_the_albums_own_folder_not_a_neighbours() { let root = std::env::temp_dir().join("ssplus-lib-reanchor"); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).unwrap(); let art_dir = root.join("artcache"); std::fs::create_dir_all(&art_dir).unwrap(); let db = root.join("t.db"); let lib = Library::open(&db).unwrap(); let painting = album_folder(&root, "2016 - Painting With"); let prester = album_folder(&root, "2021 - Prester John"); let cached = art_dir.join("ween - quebec.jpg"); std::fs::write(&cached, b"art").unwrap(); let quebec = album_folder(&root, "2014 - Quebec"); let add = |artist: &str, title: &str, art: Option<&str>, dir: &Path| { let id = lib .upsert_album(artist, title, None, art, None, None, 100) .unwrap(); lib.upsert_track( id, None, Some(1), "t", artist, None, None, None, None, None, dir.join("01.mp3").to_str().unwrap(), 100, ) .unwrap(); id }; // The damage: this album's tracks are its own, its cover is next door. let wrong = add( "Animal Collective", "Painting With", Some(prester.join("cover.jpg").to_str().unwrap()), &painting, ); // Correct already — must be left exactly as it is. let right = add( "Animal Collective", "Prester John", Some(prester.join("cover.jpg").to_str().unwrap()), &prester, ); // A legitimate art-cache entry — not this album's folder, still right. let cache = add("Ween", "Quebec", Some(cached.to_str().unwrap()), &quebec); // No art at all, but its folder has a cover sitting there unused. let none = add("Animal Collective", "Feels", None, &album_folder(&root, "2005 - Feels")); let moved = lib.reanchor_album_art(&art_dir).unwrap(); assert_eq!(moved, 2, "only the foreign cover and the empty one move"); let art_of = |id: i64| lib.get_album(id).unwrap().unwrap().0.art_path.unwrap(); assert_eq!(art_of(wrong), painting.join("cover.jpg").to_str().unwrap()); assert_eq!(art_of(right), prester.join("cover.jpg").to_str().unwrap()); assert_eq!(art_of(cache), cached.to_str().unwrap()); assert!(art_of(none).contains("2005 - Feels")); // Idempotent: a second pass has nothing left to say. assert_eq!(lib.reanchor_album_art(&art_dir).unwrap(), 0); } /// The music drive comes home from work and is not plugged in yet. Every /// path in the library is missing at once — which is the WORST moment to /// rewrite what the library believes about its own artwork. #[test] fn an_absent_drive_never_rewrites_artwork() { let root = std::env::temp_dir().join("ssplus-lib-reanchor-absent"); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).unwrap(); let db = root.join("t.db"); let lib = Library::open(&db).unwrap(); let id = lib .upsert_album("Ween", "Quebec", None, Some("X:\\gone\\cover.jpg"), None, None, 100) .unwrap(); lib.upsert_track( id, None, Some(1), "t", "Ween", None, None, None, None, None, "X:\\gone\\01.mp3", 100, ) .unwrap(); assert_eq!(lib.reanchor_album_art(&root.join("artcache")).unwrap(), 0); assert_eq!( lib.get_album(id).unwrap().unwrap().0.art_path.unwrap(), "X:\\gone\\cover.jpg" ); } /// `cover.jpg` names an album folder's artwork, never WHICH album — so it /// must never be the key that recovers a path from the shared cache. It /// would hand every album in the library the same picture. #[test] fn a_generic_cover_name_is_not_an_identity() { let dir = std::env::temp_dir().join("ssplus-lib-generic-name"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("cover.jpg"), b"shared").unwrap(); std::fs::write(dir.join("ween - quebec.jpg"), b"mine").unwrap(); assert_eq!(cached_art_path("X:\\gone\\cover.jpg", &dir), None); assert_eq!(cached_art_path("X:\\gone\\Folder.JPG", &dir), None); assert!(cached_art_path("X:\\gone\\ween - quebec.jpg", &dir).is_some()); } /// 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, None, 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"); } /// A pre-upgrade database: playlists + position-keyed playlist_tracks, /// including a position GAP (the old remove never compacted) and a /// duplicate path (intentional repetition). The upgrade must carry order, /// gaps-as-order, and both duplicate occurrences — and must run once. #[test] fn legacy_playlist_rows_become_occurrences() { let dir = std::env::temp_dir().join("ssplus-lib-playlist-migrate"); std::fs::create_dir_all(&dir).unwrap(); let db = dir.join("t.db"); let _ = std::fs::remove_file(&db); { let conn = Connection::open(&db).unwrap(); conn.execute_batch( "CREATE TABLE playlists (id INTEGER PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER NOT NULL); CREATE TABLE playlist_tracks ( playlist_id INTEGER NOT NULL, 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)); INSERT INTO playlists VALUES (1, 'Mix', 100); INSERT INTO playlist_tracks VALUES (1, 0, 'Alpha', 'A', 200, 'C:\\m\\alpha.mp3', NULL), (1, 2, 'Beta', 'B', 180, 'C:\\m\\beta.mp3', 'C:\\m\\b.jpg'), (1, 5, 'Alpha', 'A', 200, 'C:\\m\\alpha.mp3', NULL);", ) .unwrap(); } let lib = Library::open(&db).unwrap(); let items = lib.playlist_items(1).unwrap(); assert_eq!(items.len(), 3); assert_eq!( items.iter().map(|i| i.position).collect::>(), vec![0, 1, 2], "gapped legacy positions become contiguous order" ); assert_eq!(items[0].title, "Alpha"); assert_eq!(items[1].title, "Beta"); assert_eq!(items[2].title, "Alpha"); assert_ne!(items[0].id, items[2].id, "duplicates are distinct occurrences"); // Re-opening must not double the rows. drop(lib); let lib = Library::open(&db).unwrap(); assert_eq!(lib.playlist_items(1).unwrap().len(), 3); } #[test] fn playlist_editing_is_occurrence_based() { let dir = std::env::temp_dir().join("ssplus-lib-playlist-edit"); 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 id = lib.create_playlist("Mix", "manual", 100).unwrap(); let row = |title: &str, path: Option<&str>, art: Option<&str>| NewPlaylistItem { title: title.into(), artist: "Artist".into(), duration_secs: Some(100), path: path.map(String::from), art_path: art.map(String::from), ..Default::default() }; // Two copies of the same file, on purpose, plus a pathless row with a // full identity (a streaming track, first-class) and a genuinely // unknown one (the DJ token "ID" stays a placeholder for repair). lib.add_playlist_items( id, &[ row("One", Some("C:\\m\\one.mp3"), Some("C:\\m\\a.jpg")), row("Two", Some("C:\\m\\two.mp3"), Some("C:\\m\\b.jpg")), row("One", Some("C:\\m\\one.mp3"), Some("C:\\m\\a.jpg")), row("Ghost", None, None), ], None, 110, ) .unwrap(); lib.add_playlist_items( id, &[NewPlaylistItem { title: "ID".into(), artist: "Artist".into(), ..Default::default() }], None, 111, ) .unwrap(); let items = lib.playlist_items(id).unwrap(); assert_eq!(items.len(), 5); assert_eq!( items[3].kind, "stream", "a pathless row with full identity streams — never dropped, never an error" ); assert_eq!( items[4].kind, "unresolved", "the DJ unknown-token stays a placeholder for repair" ); lib.remove_playlist_items(id, &[items[4].id], 112).unwrap(); let items = lib.playlist_items(id).unwrap(); assert_eq!(items.len(), 4); // Reorder: move the SECOND "One" first; the two copies stay distinct. let ids: Vec = items.iter().map(|i| i.id).collect(); lib.set_playlist_order(id, &[ids[2], ids[0], ids[1], ids[3]], 120).unwrap(); let items = lib.playlist_items(id).unwrap(); assert_eq!(items[0].id, ids[2]); assert_eq!(items[1].id, ids[0]); // A stale order (missing a row) is refused outright. assert!(lib.set_playlist_order(id, &[ids[0], ids[1]], 121).is_err()); // Remove exactly one of the duplicates, by id; the other survives. lib.remove_playlist_items(id, &[ids[0]], 130).unwrap(); let items = lib.playlist_items(id).unwrap(); assert_eq!(items.len(), 3); assert!(items.iter().any(|i| i.id == ids[2] && i.title == "One")); assert_eq!( items.iter().map(|i| i.position).collect::>(), vec![0, 1, 2], "removal compacts positions" ); // Insert at the top. lib.add_playlist_items(id, &[row("Zero", Some("C:\\m\\zero.mp3"), None)], Some(0), 140) .unwrap(); let items = lib.playlist_items(id).unwrap(); assert_eq!(items[0].title, "Zero"); assert_eq!(items.len(), 4); // Aggregates: unresolved count, duration sum, distinct art seeds. // "Ghost" is a stream row (full identity), so nothing is unresolved. let meta = lib.playlist_meta(id).unwrap().unwrap(); assert_eq!(meta.track_count, 4); assert_eq!(meta.unresolved_count, 0); assert_eq!(meta.duration_secs, 400, "all four rows carry 100s"); assert_eq!(meta.art_seeds, vec!["C:\\m\\a.jpg".to_string(), "C:\\m\\b.jpg".into()]); assert!(meta.revision > 0); // Duplicate the playlist: same rows, same order, its own identity. let copy = lib.duplicate_playlist(id, "Mix copy", 150).unwrap(); let copied = lib.playlist_items(copy).unwrap(); assert_eq!(copied.len(), 4); assert_eq!(copied[0].title, "Zero"); assert_ne!(copy, id); // Transactional import: rows and playlist land together. let imported = lib .import_playlist( "Imported set", Some("From a pasted tracklist"), "setlist", &[ NewPlaylistItem { title: "Opener".into(), artist: "DJ".into(), start_ms: Some(0), source_line: Some("00:00 DJ - Opener".into()), ..Default::default() }, NewPlaylistItem { title: "Closer".into(), artist: "DJ".into(), start_ms: Some(3_600_000), path: Some("C:\\m\\closer.mp3".into()), ..Default::default() }, ], 160, ) .unwrap(); let rows = lib.playlist_items(imported).unwrap(); assert_eq!(rows.len(), 2); assert_eq!(rows[0].kind, "stream", "full identity, no file: streams"); assert_eq!(rows[1].kind, "local"); assert_eq!(rows[1].start_ms, Some(3_600_000)); let meta = lib.playlist_meta(imported).unwrap().unwrap(); assert_eq!(meta.kind, "setlist"); // Repair: the unresolved opener finds its file. lib.relink_playlist_item( imported, rows[0].id, None, None, None, Some(240), Some("C:\\m\\opener.mp3"), None, None, None, 170, ) .unwrap(); let rows = lib.playlist_items(imported).unwrap(); assert_eq!(rows[0].kind, "local"); assert_eq!(rows[0].path.as_deref(), Some("C:\\m\\opener.mp3")); assert_eq!(rows[0].source_line.as_deref(), Some("00:00 DJ - Opener")); } #[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, None, 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, None, 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()); // The art cache names entries after the artist and album — those names // identify a record and are what a data-directory migration can // recover. (A fixture named `cover.jpg` would not: see // `a_generic_cover_name_is_not_an_identity`.) let art_dir = dir.join("art"); std::fs::create_dir_all(&art_dir).unwrap(); let cover = art_dir.join("stale artist - stale album.jpg"); let artist = art_dir.join("artist-stale_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\\stale artist - stale album.jpg"), Some("C:\\old-app-data\\art\\artist-stale_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()) ); } }