Files
SoulStream/crates/library/src/lib.rs
T

2591 lines
106 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, 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<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,
/// 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<u32>,
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,
pub description: Option<String>,
/// "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<String>,
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<String>,
}
/// 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<String>,
pub duration_secs: Option<u32>,
/// 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<String>,
pub art_path: Option<String>,
/// "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<String>,
/// Setlist timestamp: where in the source recording this occurrence
/// begins, milliseconds.
pub start_ms: Option<i64>,
pub note: Option<String>,
/// 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<String>,
/// A stream row's cover, which has no local file to be read from.
pub art_url: Option<String>,
}
/// 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<String>,
pub duration_secs: Option<u32>,
pub path: Option<String>,
pub art_path: Option<String>,
/// "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<String>,
pub start_ms: Option<i64>,
pub note: Option<String>,
pub track_id: Option<String>,
pub art_url: Option<String>,
}
/// 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<u32>,
pub path: String,
pub art_path: Option<String>,
}
/// 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<u32>,
pub path: String,
pub art_path: Option<String>,
}
/// 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<u32>,
pub bit_depth: Option<u32>,
pub sample_rate: Option<u32>,
/// File extensions present, dominant first ("flac", "mp3", ...).
pub formats: Vec<String>,
/// Newest play that met the caller's seconds floor. None = never played.
pub last_played_at: Option<i64>,
}
/// 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<String>,
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<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,
}
/// 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<i64>,
pub catalog_album_id: Option<String>,
pub artist: String,
pub title: String,
/// Local cover, a file path (library rows).
pub art_path: Option<String>,
/// Remote cover URL (catalog rows).
pub art_url: Option<String>,
/// 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<Connection>,
}
impl Library {
pub fn open(db_path: &Path) -> Result<Library> {
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<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(())
}
/// 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<Vec<(String, Option<String>)>> {
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::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
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.
/// `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<i64> {
let conn = self.conn.lock().unwrap();
let album_id: Option<i64> = 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<i64> {
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<usize> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute("DELETE FROM plays", [])?)
}
pub fn recent_albums(&self, limit: u32, min_secs: u32) -> Result<Vec<RecentAlbum>> {
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<String>>(3)?.unwrap_or_default(),
title: r.get::<_, Option<String>>(4)?.unwrap_or_default(),
art_path: r.get(5)?,
art_url: r.get(6)?,
last_played_at: r.get(7)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>().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<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,
disc_no: Option<u32>,
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, 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<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())
}
/// Album id → MusicBrainz release id, for the albums that have one.
pub fn album_mbids(&self) -> Result<std::collections::BTreeMap<i64, String>> {
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<Vec<AlbumStats>> {
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<u32>,
rate: Option<u32>,
exts: Vec<(String, u32)>,
}
let mut by_album: std::collections::HashMap<i64, Acc> = std::collections::HashMap::new();
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, Option<i64>>(2)?,
r.get::<_, Option<i64>>(3)?,
r.get::<_, Option<u32>>(4)?,
r.get::<_, Option<u32>>(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<i64, i64> = 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<Vec<Track>> {
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<i64>>(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<usize> {
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<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(())
}
// ---- 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<Vec<FollowedArtist>> {
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<i64> {
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<Vec<Playlist>> {
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<Playlist> = 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::<rusqlite::Result<_>>()?;
// 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<i64, Vec<String>> = 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<Option<Playlist>> {
Ok(self.list_playlists()?.into_iter().find(|p| p.id == id))
}
pub fn playlist_items(&self, playlist_id: i64) -> Result<Vec<PlaylistItem>> {
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::<rusqlite::Result<Vec<_>>>().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<i64>,
now: i64,
) -> Result<u32> {
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<u32> {
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<i64> = tx
.prepare("SELECT id FROM playlist_items WHERE playlist_id = ?1")?
.query_map([playlist_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
let mut want: Vec<i64> = 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<u32>,
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<i64> {
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<i64> {
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<Vec<TrackIndexRow>> {
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::<rusqlite::Result<Vec<_>>>().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<i64> = conn
.prepare(
"SELECT id FROM playlist_items WHERE playlist_id = ?1 ORDER BY position",
)?
.query_map([playlist_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
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<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)
}
/// 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<usize> {
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<Option<String>> {
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<usize> {
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(&current) => {
self.rewrite_path_prefix(Path::new(old), root)?
}
_ => 0,
};
self.set_meta("library_root", &current)?;
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<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 !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<usize> {
let conn = self.conn.lock().unwrap();
let rows: Vec<(i64, Option<String>, 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<String>, Vec<std::path::PathBuf>)> = 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<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 {
// 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<String> = 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<String> {
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<String> {
["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<i64> = 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<_>>(),
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<i64> = 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<_>>(),
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())
);
}
}