Files
SoulStream/crates/library/src/lib.rs
T
KeelrLeek a667b4b28e Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
2026-07-22 17:57:00 -05:00

725 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Music library: SQLite-backed albums and tracks. All methods are
//! synchronous — call from a blocking context.
use rusqlite::Connection;
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
#[derive(Debug, thiserror::Error)]
pub enum LibraryError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
}
pub type Result<T> = std::result::Result<T, LibraryError>;
#[derive(Debug, Clone, Serialize)]
pub struct Album {
pub id: i64,
pub artist: String,
pub title: String,
pub year: Option<u32>,
pub art_path: Option<String>,
pub artist_art_path: Option<String>,
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<u32>,
pub title: String,
pub artist: String,
pub duration_secs: Option<u32>,
pub bitrate_kbps: Option<u32>,
pub sample_rate: Option<u32>,
pub bit_depth: Option<u32>,
pub size: Option<u64>,
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<u32>,
pub path: String,
pub art_path: Option<String>,
}
/// 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<i64>,
/// 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<String>,
/// Listening sessions of the top track — the "N×" the chip displays.
pub top_count: u32,
}
pub struct Library {
conn: Mutex<Connection>,
}
impl Library {
pub fn open(db_path: &Path) -> Result<Library> {
let conn = Connection::open(db_path)?;
conn.execute_batch(
"
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY,
artist TEXT NOT NULL,
title TEXT NOT NULL,
year INTEGER,
art_path TEXT,
artist_art_path TEXT,
mbid TEXT,
added_at INTEGER NOT NULL,
UNIQUE(artist COLLATE NOCASE, title COLLATE NOCASE)
);
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY,
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
track_no INTEGER,
title TEXT NOT NULL,
artist TEXT NOT NULL,
duration_secs INTEGER,
bitrate_kbps INTEGER,
sample_rate INTEGER,
bit_depth INTEGER,
size INTEGER,
path TEXT NOT NULL UNIQUE,
added_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album_id);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS playlist_tracks (
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
duration_secs INTEGER,
path TEXT NOT NULL,
art_path TEXT,
PRIMARY KEY (playlist_id, position)
);
CREATE TABLE IF NOT EXISTS plays (
id INTEGER PRIMARY KEY,
artist TEXT NOT NULL,
title TEXT NOT NULL,
started_at INTEGER NOT NULL,
seconds INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE);
",
)?;
// Migrate databases created before artist portraits were added.
let _ = conn.execute("ALTER TABLE albums ADD COLUMN artist_art_path TEXT", []);
// Migrate databases created before per-track audio props were stored.
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN sample_rate INTEGER", []);
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN bit_depth INTEGER", []);
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN size INTEGER", []);
Ok(Library {
conn: Mutex::new(conn),
})
}
/// Insert or fetch the album, updating year/art/mbid when newly known.
pub fn upsert_album(
&self,
artist: &str,
title: &str,
year: Option<u32>,
art_path: Option<&str>,
artist_art_path: Option<&str>,
mbid: Option<&str>,
now: i64,
) -> Result<i64> {
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<usize> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute(
"UPDATE albums SET artist_art_path = ?2 WHERE artist = ?1 COLLATE NOCASE",
rusqlite::params![artist, art_path],
)?)
}
// ---- play history ----
// One row per listening session. The frontend opens a row when a track
// first accumulates listened time and tops up its seconds while it plays,
// so a skipped track never writes a row and per-play counts stay honest.
pub fn record_play(&self, artist: &str, title: &str, now: i64) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO plays (artist, title, started_at, seconds) VALUES (?1, ?2, ?3, 0)",
rusqlite::params![artist, title, now],
)?;
Ok(conn.last_insert_rowid())
}
pub fn add_play_seconds(&self, play_id: i64, secs: u32) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE plays SET seconds = seconds + ?2 WHERE id = ?1",
rusqlite::params![play_id, secs],
)?;
Ok(())
}
pub fn artist_play_stats(&self, artist: &str) -> Result<PlayStats> {
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<u32>,
title: &str,
artist: &str,
duration_secs: Option<u32>,
bitrate_kbps: Option<u32>,
sample_rate: Option<u32>,
bit_depth: Option<u32>,
size: Option<u64>,
path: &str,
now: i64,
) -> Result<i64> {
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<bool> {
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<bool> {
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<Option<(Album, Option<String>)>> {
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<String>>(7)?,
))
},
)
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})?;
Ok(row)
}
pub fn list_albums(&self) -> Result<Vec<Album>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id)
FROM albums a
ORDER BY a.added_at DESC, a.artist, a.title",
)?;
let rows = stmt.query_map([], |r| {
Ok(Album {
id: r.get(0)?,
artist: r.get(1)?,
title: r.get(2)?,
year: r.get(3)?,
art_path: r.get(4)?,
artist_art_path: r.get(5)?,
added_at: r.get(6)?,
track_count: r.get::<_, i64>(7)? as u32,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn album_tracks(&self, album_id: i64) -> Result<Vec<Track>> {
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<i64>>(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<Vec<(i64, String)>> {
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<u32>,
bit_depth: Option<u32>,
size: Option<u64>,
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE tracks SET sample_rate = ?2, bit_depth = ?3, size = ?4 WHERE id = ?1",
rusqlite::params![id, sample_rate, bit_depth, size.map(|n| n as i64)],
)?;
Ok(())
}
// ---- playlists ----
pub fn create_playlist(&self, name: &str, now: i64) -> Result<i64> {
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<Vec<Playlist>> {
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<Vec<PlaylistTrack>> {
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<usize> {
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<usize> {
let conn = self.conn.lock().unwrap();
let rows: Vec<(i64, Option<String>, Option<String>)> = 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<usize> {
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<String> {
if Path::new(path).is_file() {
return None;
}
let name = Path::new(path).file_name()?;
let candidate = art_dir.join(name);
candidate
.is_file()
.then(|| candidate.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let dir = std::env::temp_dir().join("ssplus-lib-test");
std::fs::create_dir_all(&dir).unwrap();
let db = dir.join("t.db");
let _ = std::fs::remove_file(&db);
let lib = Library::open(&db).unwrap();
let album = lib
.upsert_album(
"Radiohead",
"OK Computer",
Some(1997),
None,
None,
None,
100,
)
.unwrap();
// Same album, case-insensitive → same id, art fills in.
let again = lib
.upsert_album(
"radiohead",
"ok computer",
None,
Some("art.jpg"),
None,
None,
200,
)
.unwrap();
assert_eq!(album, again);
lib.upsert_track(
album,
Some(1),
"Airbag",
"Radiohead",
Some(287),
Some(1024),
Some(44100),
Some(16),
Some(30_000_000),
"C:\\x\\01.flac",
100,
)
.unwrap();
lib.upsert_track(
album,
Some(2),
"Paranoid Android",
"Radiohead",
None,
None,
None,
None,
None,
"C:\\x\\02.flac",
100,
)
.unwrap();
let albums = lib.list_albums().unwrap();
assert_eq!(albums.len(), 1);
assert_eq!(albums[0].track_count, 2);
assert_eq!(albums[0].art_path.as_deref(), Some("art.jpg"));
let tracks = lib.album_tracks(album).unwrap();
assert_eq!(tracks[0].title, "Airbag");
assert!(lib.has_track_path("C:\\x\\01.flac").unwrap());
assert!(lib.has_track("radiohead", "ok computer", "airbag").unwrap());
assert!(!lib
.has_track("Radiohead", "OK Computer", "Karma Police")
.unwrap());
let art_dir = dir.join("art");
std::fs::create_dir_all(&art_dir).unwrap();
let cover = art_dir.join("cover.jpg");
let artist = art_dir.join("artist.jpg");
std::fs::write(&cover, []).unwrap();
std::fs::write(&artist, []).unwrap();
let stale = lib
.upsert_album(
"Stale Artist",
"Stale Album",
None,
Some("C:\\old-app-data\\art\\cover.jpg"),
Some("C:\\old-app-data\\art\\artist.jpg"),
None,
100,
)
.unwrap();
assert_eq!(lib.repair_art_paths(&art_dir).unwrap(), 2);
let repaired = lib.get_album(stale).unwrap().unwrap().0;
assert_eq!(
repaired.art_path.as_deref(),
Some(cover.to_string_lossy().as_ref())
);
assert_eq!(
repaired.artist_art_path.as_deref(),
Some(artist.to_string_lossy().as_ref())
);
}
}