sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane

This commit is contained in:
KeelrLeek committed 2026-07-31 18:21:04 -05:00
1 parent 606250c361
commit eba29824c9
12 files changed
+1595 -281

No files matched your search

+173 -4
View File
@@ -1,7 +1,7 @@
//! Music library: SQLite-backed albums and tracks. All methods are
//! synchronous — call from a blocking context.
use rusqlite::Connection;
use rusqlite::{Connection, OptionalExtension};
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
@@ -70,6 +70,37 @@ pub struct PlayStats {
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>,
}
@@ -129,10 +160,50 @@ impl Library {
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);
",
)?;
// 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", []);
@@ -194,15 +265,113 @@ impl Library {
// first accumulates listened time and tops up its seconds while it plays,
// so a skipped track never writes a row and per-play counts stay honest.
pub fn record_play(&self, artist: &str, title: &str, now: i64) -> Result<i64> {
/// `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) VALUES (?1, ?2, ?3, 0)",
rusqlite::params![artist, title, now],
"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. The catalog arm excludes rows that already
// have a library album so a local track with catalog identity cannot
// appear twice. 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(
"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 = p.album_id
WHERE p.album_id IS NOT NULL AND 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
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(