forked from Keelrleek/SoulStream
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.
725 lines
25 KiB
Rust
725 lines
25 KiB
Rust
//! 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())
|
||
);
|
||
}
|
||
}
|