Kyle picked this off an interactive prototype (research/library/) over a long evening of passes, and the settings he landed on are baked in rather than left as choices: records no catalogue knows about are ordinary citizens with no marks at all, artwork is generated from a record's own name when there is none, the albums you do not have are one collapsed line that opens into full-size dimmed cards, an artist's shelf splits by release type, the toolbar is a floating card, the index rail is always on, the List view's column header is welded to that card, the entrance is a wave of records, and cards are the artist page's own 150px. The artists accordion is gone. In its place: three views (Shelves, Grid, List), six groupings (Artist, Decade, Format, Type, Added, None), thirteen sorts that are independent of the grouping, a live filter that moves every number in the header, and a stat row that is derived rather than written down. A library page has to say what the files ARE, and library_albums only carries id/artist/title/year/art/count/added. So the library crate gained album_stats: one pass over tracks per call, aggregating length, bytes, average bitrate, bit depth, sample rate and the dominant file extension per album, plus the newest play that met a seconds floor the caller passes (the same 45s the recents list uses — under it a play is a skip, and a page that sorts by "never played first" must not count skips as listening). Aggregated in Rust rather than SQL because 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. Filing a record as an album, an EP, a single, a live set, a compilation, a soundtrack, a remix record or bonus material is not a new opinion: localReleaseType.js carries CatalogArtist's own title heuristics verbatim — the regexes are the tuned artifact — reduced to the branches whose evidence a LOCAL record can carry. A file on disk has no MusicBrainz release group, no editorial lead sentence and usually no record_type, so the MB and description-hint branches are absent because their inputs cannot exist. Under it sits the floor the prototype settled on: when the classifier lands on "other" and the row carries no provider type, the two facts a file always has decide — three tracks or under ten minutes is a single, seven or under twenty-five an EP — with the session/demo check re-run inside it so a five-track practice tape stays bonus material. It is a copy, and it is marked as one at every function; unifying it with the artist page is follow-up work, not something to smuggle into this. Clicking a record opens CatalogArtist's inline expander, class for class, with its numbers unchanged: the 460ms slide whose height is read live per frame and kept monotonic (a captured height animates to a stale number once the tracklist lands), the staggered waterfall that fades each part to its OWN resting opacity, per-row entrances at min(i*16, 180), and a same-row switch that keeps both panels alive while the stage's height eases between them. What differs is the content, because a library knows different facts: where the artist page shows Last.fm plays and listeners, the rail shows size on disk and bitrate, where it offers Save an owned record offers its folder, and each track carries what its file is. Three things about the chrome are measured rather than typed, and each one is a bug the prototype found first. The bar's height is read off the bar, because a typed 50px parks anything pinned below it behind a bar whose controls have wrapped; the bar's own minimum is a constant, because publishing a measurement into the property that decides it is a ratchet. The index rail's 34px gutter is reserved whenever the rail is on, drawn or not, so no click on Group can change the width of the content column. And the table's column template lives on the page rather than on the rows, because a 1fr column floors at min-content and RECORD is wider than an ellipsized title, so identical declarations on two elements resolve to different widths; columns drop by tier from the page's measured width so a record's name is never squeezed out. The entrance is timed by POSITION, not by index. Indexing the document spends the whole stagger on sections nobody is looking at — 38 shelves means every visible one hits the cap and they all arrive in the same frame, which has no relative motion in it and reads as no animation at all. Delay comes from how far down and slightly across the pane a thing sits, normalized to the topmost thing that is both moving and on screen, and the delays are pulled forward over the next few frames as content-visibility geometry lands (only forward, except while a thing is still hidden, where a correction is invisible). The hero, the toolbar and the column header never move: they are the page's furniture, and dealing them in animated the whole window instead of the part that changed. A rail click corrects until its target stops moving. content-visibility makes the height of everything unpainted an estimate, so one scroll to a section overshoots — measured on the prototype at up to 1143px, with the page's own height moving 2260px during the jump. Two bugs fixed on the way, both the same mistake: `>>` on an unsigned 32-bit hash goes negative above 2^31, so a generated track name indexed past its word list and a generated cover's second hue produced an invalid gradient. Both are `>>>`. Deliberately not here. There is no Import button because there is no backend command to import a folder — Rescan re-reads the library and its stats, and a real importer is a backend feature. The "N of M released" count and the missing-albums line appear only for artists you FOLLOW: a follow carries an anchored artist id, and matching a local artist to a provider by name is exactly what the identity rules forbid, so an unfollowed shelf says what it holds and nothing more. Verified against the running app on real files: 7 albums, 72 tracks, 1.8 GB on disk off the new command, and Maladroit opening to 13 tracks, 33:37, 279 MB, 1019k, FLAC 16/44.1 with per-track rates, its notch under the card it belongs to and its row landing 10px under the pinned bar. Verified headless against the mock library at scale: all eighteen view-by-grouping combinations at one content width, the toolbar pinned with zero drift, the joined header's columns matching the rows on both edges, the expander opening from a card and from a row, morphing in place within a row and closing without leaving its reserve behind, and the wave moving 74 things with 34 distinct arrival times across 391ms while the chrome stayed still.
1182 lines
45 KiB
Rust
1182 lines
45 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),
|
||
}
|
||
|
||
pub type Result<T> = std::result::Result<T, LibraryError>;
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Album {
|
||
pub id: i64,
|
||
pub artist: String,
|
||
pub title: String,
|
||
pub year: Option<u32>,
|
||
pub art_path: Option<String>,
|
||
pub artist_art_path: Option<String>,
|
||
pub track_count: u32,
|
||
pub added_at: i64,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Track {
|
||
pub id: i64,
|
||
pub album_id: i64,
|
||
pub track_no: Option<u32>,
|
||
pub title: String,
|
||
pub artist: String,
|
||
pub duration_secs: Option<u32>,
|
||
pub bitrate_kbps: Option<u32>,
|
||
pub sample_rate: Option<u32>,
|
||
pub bit_depth: Option<u32>,
|
||
pub size: Option<u64>,
|
||
pub path: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Playlist {
|
||
pub id: i64,
|
||
pub name: String,
|
||
pub track_count: u32,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct PlaylistTrack {
|
||
pub position: i64,
|
||
pub title: String,
|
||
pub artist: String,
|
||
pub duration_secs: Option<u32>,
|
||
pub path: String,
|
||
pub art_path: Option<String>,
|
||
}
|
||
|
||
/// 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 conn = Connection::open(db_path)?;
|
||
conn.execute_batch(
|
||
"
|
||
PRAGMA journal_mode = WAL;
|
||
CREATE TABLE IF NOT EXISTS albums (
|
||
id INTEGER PRIMARY KEY,
|
||
artist TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
year INTEGER,
|
||
art_path TEXT,
|
||
artist_art_path TEXT,
|
||
mbid TEXT,
|
||
added_at INTEGER NOT NULL,
|
||
UNIQUE(artist COLLATE NOCASE, title COLLATE NOCASE)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS tracks (
|
||
id INTEGER PRIMARY KEY,
|
||
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||
track_no INTEGER,
|
||
title TEXT NOT NULL,
|
||
artist TEXT NOT NULL,
|
||
duration_secs INTEGER,
|
||
bitrate_kbps INTEGER,
|
||
sample_rate INTEGER,
|
||
bit_depth INTEGER,
|
||
size INTEGER,
|
||
path TEXT NOT NULL UNIQUE,
|
||
added_at INTEGER NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album_id);
|
||
CREATE TABLE IF NOT EXISTS playlists (
|
||
id INTEGER PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
created_at INTEGER NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS playlist_tracks (
|
||
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||
position INTEGER NOT NULL,
|
||
title TEXT NOT NULL,
|
||
artist TEXT NOT NULL,
|
||
duration_secs INTEGER,
|
||
path TEXT NOT NULL,
|
||
art_path TEXT,
|
||
PRIMARY KEY (playlist_id, position)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS plays (
|
||
id INTEGER PRIMARY KEY,
|
||
artist TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
started_at INTEGER NOT NULL,
|
||
seconds INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE);
|
||
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
|
||
);
|
||
",
|
||
)?;
|
||
// 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", []);
|
||
Ok(Library {
|
||
conn: Mutex::new(conn),
|
||
})
|
||
}
|
||
|
||
/// Insert or fetch the album, updating year/art/mbid when newly known.
|
||
pub fn upsert_album(
|
||
&self,
|
||
artist: &str,
|
||
title: &str,
|
||
year: Option<u32>,
|
||
art_path: Option<&str>,
|
||
artist_art_path: Option<&str>,
|
||
mbid: Option<&str>,
|
||
now: i64,
|
||
) -> Result<i64> {
|
||
let conn = self.conn.lock().unwrap();
|
||
conn.execute(
|
||
"INSERT INTO albums (artist, title, year, art_path, artist_art_path, mbid, added_at)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||
ON CONFLICT(artist COLLATE NOCASE, title COLLATE NOCASE) DO UPDATE SET
|
||
year = COALESCE(albums.year, excluded.year),
|
||
art_path = COALESCE(albums.art_path, excluded.art_path),
|
||
artist_art_path = COALESCE(albums.artist_art_path, excluded.artist_art_path),
|
||
mbid = COALESCE(albums.mbid, excluded.mbid)",
|
||
rusqlite::params![artist, title, year, art_path, artist_art_path, mbid, now],
|
||
)?;
|
||
let id = conn.query_row(
|
||
"SELECT id FROM albums WHERE artist = ?1 COLLATE NOCASE AND title = ?2 COLLATE NOCASE",
|
||
rusqlite::params![artist, title],
|
||
|r| r.get(0),
|
||
)?;
|
||
Ok(id)
|
||
}
|
||
|
||
pub fn set_album_art(&self, album_id: i64, art_path: &str) -> Result<()> {
|
||
let conn = self.conn.lock().unwrap();
|
||
conn.execute(
|
||
"UPDATE albums SET art_path = ?2 WHERE id = ?1",
|
||
rusqlite::params![album_id, art_path],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn set_artist_art(&self, artist: &str, art_path: &str) -> Result<usize> {
|
||
let conn = self.conn.lock().unwrap();
|
||
Ok(conn.execute(
|
||
"UPDATE albums SET artist_art_path = ?2 WHERE artist = ?1 COLLATE NOCASE",
|
||
rusqlite::params![artist, art_path],
|
||
)?)
|
||
}
|
||
|
||
// ---- play history ----
|
||
// One row per listening session. The frontend opens a row when a track
|
||
// first accumulates listened time and tops up its seconds while it plays,
|
||
// so a skipped track never writes a row and per-play counts stay honest.
|
||
|
||
/// `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,
|
||
track_no: Option<u32>,
|
||
title: &str,
|
||
artist: &str,
|
||
duration_secs: Option<u32>,
|
||
bitrate_kbps: Option<u32>,
|
||
sample_rate: Option<u32>,
|
||
bit_depth: Option<u32>,
|
||
size: Option<u64>,
|
||
path: &str,
|
||
now: i64,
|
||
) -> Result<i64> {
|
||
let conn = self.conn.lock().unwrap();
|
||
conn.execute(
|
||
"INSERT INTO tracks (album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, added_at)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||
ON CONFLICT(path) DO UPDATE SET
|
||
album_id = excluded.album_id,
|
||
track_no = excluded.track_no,
|
||
title = excluded.title,
|
||
artist = excluded.artist,
|
||
duration_secs = excluded.duration_secs,
|
||
bitrate_kbps = excluded.bitrate_kbps,
|
||
sample_rate = excluded.sample_rate,
|
||
bit_depth = excluded.bit_depth,
|
||
size = excluded.size",
|
||
rusqlite::params![album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, now],
|
||
)?;
|
||
let id = conn.query_row(
|
||
"SELECT id FROM tracks WHERE path = ?1",
|
||
rusqlite::params![path],
|
||
|r| r.get(0),
|
||
)?;
|
||
Ok(id)
|
||
}
|
||
|
||
pub fn has_track_path(&self, path: &str) -> Result<bool> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let n: i64 = conn.query_row(
|
||
"SELECT COUNT(*) FROM tracks WHERE path = ?1",
|
||
rusqlite::params![path],
|
||
|r| r.get(0),
|
||
)?;
|
||
Ok(n > 0)
|
||
}
|
||
|
||
/// Whether this song is already represented in the library, independent
|
||
/// of its on-disk path. Downloads are organized with a " (2)" suffix to
|
||
/// avoid overwriting files, so a path-only check cannot prevent a second
|
||
/// save of the same track from becoming a duplicate library row.
|
||
pub fn has_track(&self, artist: &str, album: &str, title: &str) -> Result<bool> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let n: i64 = conn.query_row(
|
||
"SELECT COUNT(*)
|
||
FROM tracks t
|
||
JOIN albums a ON a.id = t.album_id
|
||
WHERE a.artist = ?1 COLLATE NOCASE
|
||
AND a.title = ?2 COLLATE NOCASE
|
||
AND t.title = ?3 COLLATE NOCASE",
|
||
rusqlite::params![artist, album, title],
|
||
|r| r.get(0),
|
||
)?;
|
||
Ok(n > 0)
|
||
}
|
||
|
||
pub fn get_album(&self, album_id: i64) -> Result<Option<(Album, Option<String>)>> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let row = conn
|
||
.query_row(
|
||
"SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at, a.mbid,
|
||
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id)
|
||
FROM albums a WHERE a.id = ?1",
|
||
[album_id],
|
||
|r| {
|
||
Ok((
|
||
Album {
|
||
id: r.get(0)?,
|
||
artist: r.get(1)?,
|
||
title: r.get(2)?,
|
||
year: r.get(3)?,
|
||
art_path: r.get(4)?,
|
||
artist_art_path: r.get(5)?,
|
||
added_at: r.get(6)?,
|
||
track_count: r.get::<_, i64>(8)? as u32,
|
||
},
|
||
r.get::<_, Option<String>>(7)?,
|
||
))
|
||
},
|
||
)
|
||
.map(Some)
|
||
.or_else(|e| match e {
|
||
rusqlite::Error::QueryReturnedNoRows => Ok(None),
|
||
other => Err(other),
|
||
})?;
|
||
Ok(row)
|
||
}
|
||
|
||
pub fn list_albums(&self) -> Result<Vec<Album>> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let mut stmt = conn.prepare(
|
||
"SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at,
|
||
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id)
|
||
FROM albums a
|
||
ORDER BY a.added_at DESC, a.artist, a.title",
|
||
)?;
|
||
let rows = stmt.query_map([], |r| {
|
||
Ok(Album {
|
||
id: r.get(0)?,
|
||
artist: r.get(1)?,
|
||
title: r.get(2)?,
|
||
year: r.get(3)?,
|
||
art_path: r.get(4)?,
|
||
artist_art_path: r.get(5)?,
|
||
added_at: r.get(6)?,
|
||
track_count: r.get::<_, i64>(7)? as u32,
|
||
})
|
||
})?;
|
||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||
}
|
||
|
||
/// 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(
|
||
"SELECT id, album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path
|
||
FROM tracks WHERE album_id = ?1
|
||
ORDER BY track_no IS NULL, track_no, title",
|
||
)?;
|
||
let rows = stmt.query_map([album_id], |r| {
|
||
Ok(Track {
|
||
id: r.get(0)?,
|
||
album_id: r.get(1)?,
|
||
track_no: r.get(2)?,
|
||
title: r.get(3)?,
|
||
artist: r.get(4)?,
|
||
duration_secs: r.get(5)?,
|
||
bitrate_kbps: r.get(6)?,
|
||
sample_rate: r.get(7)?,
|
||
bit_depth: r.get(8)?,
|
||
size: r.get::<_, Option<i64>>(9)?.map(|n| n as u64),
|
||
path: r.get(10)?,
|
||
})
|
||
})?;
|
||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||
}
|
||
|
||
/// Paths of tracks missing the audio-props backfill (size is the sentinel:
|
||
/// always available from the filesystem, so a non-null size means scanned).
|
||
pub fn tracks_missing_props(&self) -> Result<Vec<(i64, String)>> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let mut stmt = conn.prepare("SELECT id, path FROM tracks WHERE size IS NULL")?;
|
||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||
}
|
||
|
||
/// Backfill audio props for one track (found via tracks_missing_props).
|
||
pub fn set_track_props(
|
||
&self,
|
||
id: i64,
|
||
sample_rate: Option<u32>,
|
||
bit_depth: Option<u32>,
|
||
size: Option<u64>,
|
||
) -> Result<()> {
|
||
let conn = self.conn.lock().unwrap();
|
||
conn.execute(
|
||
"UPDATE tracks SET sample_rate = ?2, bit_depth = ?3, size = ?4 WHERE id = ?1",
|
||
rusqlite::params![id, sample_rate, bit_depth, size.map(|n| n as i64)],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
// ---- 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 ----
|
||
|
||
pub fn create_playlist(&self, name: &str, now: i64) -> Result<i64> {
|
||
let conn = self.conn.lock().unwrap();
|
||
conn.execute(
|
||
"INSERT INTO playlists (name, created_at) VALUES (?1, ?2)",
|
||
rusqlite::params![name, now],
|
||
)?;
|
||
Ok(conn.last_insert_rowid())
|
||
}
|
||
|
||
pub fn rename_playlist(&self, id: i64, name: &str) -> Result<()> {
|
||
let conn = self.conn.lock().unwrap();
|
||
conn.execute(
|
||
"UPDATE playlists SET name = ?2 WHERE id = ?1",
|
||
rusqlite::params![id, name],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn delete_playlist(&self, id: i64) -> Result<()> {
|
||
let conn = self.conn.lock().unwrap();
|
||
conn.execute("DELETE FROM playlist_tracks WHERE playlist_id = ?1", [id])?;
|
||
conn.execute("DELETE FROM playlists WHERE id = ?1", [id])?;
|
||
Ok(())
|
||
}
|
||
|
||
/// (id, name, track_count), newest first.
|
||
pub fn list_playlists(&self) -> Result<Vec<Playlist>> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let mut stmt = conn.prepare(
|
||
"SELECT p.id, p.name,
|
||
(SELECT COUNT(*) FROM playlist_tracks t WHERE t.playlist_id = p.id)
|
||
FROM playlists p ORDER BY p.created_at DESC",
|
||
)?;
|
||
let rows = stmt.query_map([], |r| {
|
||
Ok(Playlist {
|
||
id: r.get(0)?,
|
||
name: r.get(1)?,
|
||
track_count: r.get::<_, i64>(2)? as u32,
|
||
})
|
||
})?;
|
||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||
}
|
||
|
||
pub fn add_to_playlist(&self, playlist_id: i64, t: &PlaylistTrack) -> Result<()> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let next: i64 = conn.query_row(
|
||
"SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1",
|
||
[playlist_id],
|
||
|r| r.get(0),
|
||
)?;
|
||
conn.execute(
|
||
"INSERT INTO playlist_tracks
|
||
(playlist_id, position, title, artist, duration_secs, path, art_path)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||
rusqlite::params![
|
||
playlist_id,
|
||
next,
|
||
t.title,
|
||
t.artist,
|
||
t.duration_secs,
|
||
t.path,
|
||
t.art_path
|
||
],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn playlist_tracks(&self, playlist_id: i64) -> Result<Vec<PlaylistTrack>> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let mut stmt = conn.prepare(
|
||
"SELECT position, title, artist, duration_secs, path, art_path
|
||
FROM playlist_tracks WHERE playlist_id = ?1 ORDER BY position",
|
||
)?;
|
||
let rows = stmt.query_map([playlist_id], |r| {
|
||
Ok(PlaylistTrack {
|
||
position: r.get(0)?,
|
||
title: r.get(1)?,
|
||
artist: r.get(2)?,
|
||
duration_secs: r.get(3)?,
|
||
path: r.get(4)?,
|
||
art_path: r.get(5)?,
|
||
})
|
||
})?;
|
||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||
}
|
||
|
||
pub fn remove_from_playlist(&self, playlist_id: i64, position: i64) -> Result<()> {
|
||
let conn = self.conn.lock().unwrap();
|
||
conn.execute(
|
||
"DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND position = ?2",
|
||
rusqlite::params![playlist_id, position],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Drop tracks whose files vanished; returns how many were removed.
|
||
pub fn prune_missing(&self) -> Result<usize> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let paths: Vec<(i64, String)> = conn
|
||
.prepare("SELECT id, path FROM tracks")?
|
||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||
.filter_map(|r| r.ok())
|
||
.collect();
|
||
let mut removed = 0;
|
||
for (id, path) in paths {
|
||
if !Path::new(&path).exists() {
|
||
conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
|
||
removed += 1;
|
||
}
|
||
}
|
||
conn.execute(
|
||
"DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)",
|
||
[],
|
||
)?;
|
||
Ok(removed)
|
||
}
|
||
|
||
/// Repoint stale artwork references to an identically named file in the
|
||
/// current app's art cache. This covers app-identifier/data-directory
|
||
/// migrations without requiring artwork to be downloaded again.
|
||
pub fn repair_art_paths(&self, art_dir: &Path) -> Result<usize> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let rows: Vec<(i64, Option<String>, Option<String>)> = conn
|
||
.prepare("SELECT id, art_path, artist_art_path FROM albums")?
|
||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||
.filter_map(|r| r.ok())
|
||
.collect();
|
||
let mut repaired = 0;
|
||
|
||
for (id, art_path, artist_art_path) in rows {
|
||
if let Some(path) = art_path.and_then(|p| cached_art_path(&p, art_dir)) {
|
||
conn.execute(
|
||
"UPDATE albums SET art_path = ?2 WHERE id = ?1",
|
||
rusqlite::params![id, path],
|
||
)?;
|
||
repaired += 1;
|
||
}
|
||
if let Some(path) = artist_art_path.and_then(|p| cached_art_path(&p, art_dir)) {
|
||
conn.execute(
|
||
"UPDATE albums SET artist_art_path = ?2 WHERE id = ?1",
|
||
rusqlite::params![id, path],
|
||
)?;
|
||
repaired += 1;
|
||
}
|
||
}
|
||
Ok(repaired)
|
||
}
|
||
|
||
/// Remove rows accidentally created for non-audio download extras (cover
|
||
/// images, playlists, text files, etc.). The library should only ever
|
||
/// expose playable audio tracks.
|
||
pub fn prune_non_audio(&self) -> Result<usize> {
|
||
let conn = self.conn.lock().unwrap();
|
||
let rows: Vec<(i64, String)> = conn
|
||
.prepare("SELECT id, path FROM tracks")?
|
||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||
.filter_map(|r| r.ok())
|
||
.collect();
|
||
let mut removed = 0;
|
||
for (id, path) in rows {
|
||
let audio = Path::new(&path)
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.map(|e| {
|
||
matches!(
|
||
e.to_ascii_lowercase().as_str(),
|
||
"flac" | "mp3" | "m4a" | "ogg" | "opus" | "wav" | "aac" | "wma"
|
||
)
|
||
})
|
||
.unwrap_or(false);
|
||
if !audio {
|
||
conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
|
||
removed += 1;
|
||
}
|
||
}
|
||
conn.execute(
|
||
"DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)",
|
||
[],
|
||
)?;
|
||
Ok(removed)
|
||
}
|
||
}
|
||
|
||
fn cached_art_path(path: &str, art_dir: &Path) -> Option<String> {
|
||
if Path::new(path).is_file() {
|
||
return None;
|
||
}
|
||
let name = Path::new(path).file_name()?;
|
||
let candidate = art_dir.join(name);
|
||
candidate
|
||
.is_file()
|
||
.then(|| candidate.to_string_lossy().into_owned())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// 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, 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");
|
||
}
|
||
|
||
#[test]
|
||
fn roundtrip() {
|
||
let dir = std::env::temp_dir().join("ssplus-lib-test");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let db = dir.join("t.db");
|
||
let _ = std::fs::remove_file(&db);
|
||
|
||
let lib = Library::open(&db).unwrap();
|
||
let album = lib
|
||
.upsert_album(
|
||
"Radiohead",
|
||
"OK Computer",
|
||
Some(1997),
|
||
None,
|
||
None,
|
||
None,
|
||
100,
|
||
)
|
||
.unwrap();
|
||
// Same album, case-insensitive → same id, art fills in.
|
||
let again = lib
|
||
.upsert_album(
|
||
"radiohead",
|
||
"ok computer",
|
||
None,
|
||
Some("art.jpg"),
|
||
None,
|
||
None,
|
||
200,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(album, again);
|
||
|
||
lib.upsert_track(
|
||
album,
|
||
Some(1),
|
||
"Airbag",
|
||
"Radiohead",
|
||
Some(287),
|
||
Some(1024),
|
||
Some(44100),
|
||
Some(16),
|
||
Some(30_000_000),
|
||
"C:\\x\\01.flac",
|
||
100,
|
||
)
|
||
.unwrap();
|
||
lib.upsert_track(
|
||
album,
|
||
Some(2),
|
||
"Paranoid Android",
|
||
"Radiohead",
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
"C:\\x\\02.flac",
|
||
100,
|
||
)
|
||
.unwrap();
|
||
|
||
let albums = lib.list_albums().unwrap();
|
||
assert_eq!(albums.len(), 1);
|
||
assert_eq!(albums[0].track_count, 2);
|
||
assert_eq!(albums[0].art_path.as_deref(), Some("art.jpg"));
|
||
|
||
let tracks = lib.album_tracks(album).unwrap();
|
||
assert_eq!(tracks[0].title, "Airbag");
|
||
assert!(lib.has_track_path("C:\\x\\01.flac").unwrap());
|
||
assert!(lib.has_track("radiohead", "ok computer", "airbag").unwrap());
|
||
assert!(!lib
|
||
.has_track("Radiohead", "OK Computer", "Karma Police")
|
||
.unwrap());
|
||
|
||
let art_dir = dir.join("art");
|
||
std::fs::create_dir_all(&art_dir).unwrap();
|
||
let cover = art_dir.join("cover.jpg");
|
||
let artist = art_dir.join("artist.jpg");
|
||
std::fs::write(&cover, []).unwrap();
|
||
std::fs::write(&artist, []).unwrap();
|
||
let stale = lib
|
||
.upsert_album(
|
||
"Stale Artist",
|
||
"Stale Album",
|
||
None,
|
||
Some("C:\\old-app-data\\art\\cover.jpg"),
|
||
Some("C:\\old-app-data\\art\\artist.jpg"),
|
||
None,
|
||
100,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(lib.repair_art_paths(&art_dir).unwrap(), 2);
|
||
let repaired = lib.get_album(stale).unwrap().unwrap().0;
|
||
assert_eq!(
|
||
repaired.art_path.as_deref(),
|
||
Some(cover.to_string_lossy().as_ref())
|
||
);
|
||
assert_eq!(
|
||
repaired.artist_art_path.as_deref(),
|
||
Some(artist.to_string_lossy().as_ref())
|
||
);
|
||
}
|
||
}
|