2591 lines
106 KiB
Rust
2591 lines
106 KiB
Rust
//! 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(¤t) => {
|
||
self.rewrite_path_prefix(Path::new(old), root)?
|
||
}
|
||
_ => 0,
|
||
};
|
||
self.set_meta("library_root", ¤t)?;
|
||
Ok(moved)
|
||
}
|
||
|
||
/// Drop everything that does not live under `root` — the library folder.
|
||
///
|
||
/// The library folder is the library. Everything that legitimately enters
|
||
/// it is organized INTO that folder (`save_to_library` and the download
|
||
/// pipeline both do), so a row whose file sits somewhere else belongs to a
|
||
/// library that is no longer the one being shown.
|
||
///
|
||
/// Changing the folder used to leave those rows behind for good: the
|
||
/// scanner started reading the new folder while `prune_missing` kept every
|
||
/// old row alive on the grounds that its file still existed. The library
|
||
/// became the UNION of every folder that had ever been the library folder,
|
||
/// and emptying the current one could not empty it — six albums from a
|
||
/// previous folder on another drive outlived a wipe of the whole library.
|
||
///
|
||
/// This removes rows only. Files on disk are never touched, so pointing the
|
||
/// setting back at the old folder re-indexes it from scratch.
|
||
pub fn prune_outside_root(&self, root: &Path) -> Result<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())
|
||
);
|
||
}
|
||
}
|