forked from Keelrleek/SoulStream
sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane
This commit is contained in:
1 parent
606250c361
commit
eba29824c9
12 files changed
+1595
-281
No files matched your search
+173
-4
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user