forked from Keelrleek/SoulStream
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.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "library"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
@@ -0,0 +1,724 @@
|
||||
//! 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user