feat(discover): Most Played Albums — play-count store, hook, section id
Data foundation for the new Most Played Albums rail. New qbz-app store album_play_history (SQLite, mirrors play_history): one album_play_event row per track-start + an album_meta side table; top_albums(n)/all_albums() rank by COUNT(*) GROUP BY album_id, tie-broken by most-recent play. Hooked in playback::record_recent next to the recently/artist stores, same album identity. Registers the mostPlayedAlbums section id in discover_prefs (Home + For You, default off). The Qobuz API exposes no most-played endpoint (verified vs the inferred OpenAPI), so the ranking is local. 15 unit tests green (store 5 + prefs 10). cc
This commit is contained in:
4 files changed
+374
-8
No files matched your search
@@ -0,0 +1,332 @@
|
||||
//! Local album play history — source of truth for the "Most Played Albums"
|
||||
//! rail + its View-all page.
|
||||
//!
|
||||
//! Mirrors [`crate::play_history`] (the per-artist store): an
|
||||
//! `album_play_events` table grows one row per track-start whose album is
|
||||
//! known, plus a side `album_meta` table (id -> title/artist/artwork/quality)
|
||||
//! refreshed on each play. "Most played" = `COUNT(*) GROUP BY album_id
|
||||
//! ORDER BY plays DESC`. Counting is PER TRACK-START, like play_history, so an
|
||||
//! album listened all the way through adds one per track.
|
||||
//!
|
||||
//! The Qobuz API exposes no most-played endpoint (verified against the
|
||||
//! inferred OpenAPI), so the ranking is derived locally from our own plays.
|
||||
//!
|
||||
//! SQLite is opened lazily; every read/write swallows errors into a
|
||||
//! `log::warn!`. A fresh user (no DB yet) yields an empty list, so the rail
|
||||
//! self-hides — same default the other #566 rails land on.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
static DB: OnceLock<Mutex<Option<Connection>>> = OnceLock::new();
|
||||
|
||||
fn db_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("album_play_history.db"))
|
||||
}
|
||||
|
||||
/// Create the tables + index on a fresh connection (shared by the lazy opener
|
||||
/// and the in-memory test connections).
|
||||
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS album_play_events (
|
||||
album_id TEXT NOT NULL,
|
||||
occurred_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS album_play_events_album
|
||||
ON album_play_events(album_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS album_meta (
|
||||
album_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
artist_id TEXT NOT NULL DEFAULT '',
|
||||
artwork_url TEXT NOT NULL DEFAULT '',
|
||||
quality_tier TEXT NOT NULL DEFAULT '',
|
||||
quality_label TEXT NOT NULL DEFAULT '',
|
||||
year TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
fn open_db() -> Option<Connection> {
|
||||
let path = db_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("[qbz-slint] album_play_history dir create failed: {e}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let conn = match Connection::open(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("[qbz-slint] album_play_history open failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// ADR-002: WAL for any SQLite store touched off the UI thread.
|
||||
if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") {
|
||||
log::warn!("[qbz-slint] album_play_history pragma failed: {e}");
|
||||
}
|
||||
if let Err(e) = init_schema(&conn) {
|
||||
log::warn!("[qbz-slint] album_play_history schema failed: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(conn)
|
||||
}
|
||||
|
||||
fn with_db<F, T>(f: F) -> Option<T>
|
||||
where
|
||||
F: FnOnce(&Connection) -> Option<T>,
|
||||
{
|
||||
let cell = DB.get_or_init(|| Mutex::new(open_db()));
|
||||
let guard = cell.lock().ok()?;
|
||||
let conn = guard.as_ref()?;
|
||||
f(conn)
|
||||
}
|
||||
|
||||
/// Album metadata captured at play time (refreshed on every play, so renames
|
||||
/// and artwork refreshes converge).
|
||||
pub struct AlbumPlayMeta<'a> {
|
||||
pub album_id: &'a str,
|
||||
pub title: &'a str,
|
||||
pub artist: &'a str,
|
||||
pub artist_id: &'a str,
|
||||
pub artwork_url: &'a str,
|
||||
pub quality_tier: &'a str,
|
||||
pub quality_label: &'a str,
|
||||
pub year: &'a str,
|
||||
pub source: &'a str,
|
||||
}
|
||||
|
||||
/// One ranked album for the "Most Played Albums" rail / View-all page.
|
||||
#[derive(Clone, Default, Debug, PartialEq)]
|
||||
pub struct AlbumPlayRow {
|
||||
pub album_id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub artist_id: String,
|
||||
pub artwork_url: String,
|
||||
pub quality_tier: String,
|
||||
pub quality_label: String,
|
||||
pub year: String,
|
||||
pub source: String,
|
||||
pub plays: u32,
|
||||
}
|
||||
|
||||
/// Insert one play event + upsert the album meta, at an explicit timestamp.
|
||||
/// Internal so tests can drive it against an in-memory connection.
|
||||
fn record_on(conn: &Connection, m: &AlbumPlayMeta, now: i64) {
|
||||
if let Err(e) = conn.execute(
|
||||
"INSERT INTO album_play_events (album_id, occurred_at) VALUES (?, ?)",
|
||||
params![m.album_id, now],
|
||||
) {
|
||||
log::warn!("[qbz-slint] album_play_history insert event failed: {e}");
|
||||
}
|
||||
if let Err(e) = conn.execute(
|
||||
r#"
|
||||
INSERT INTO album_meta
|
||||
(album_id, title, artist, artist_id, artwork_url,
|
||||
quality_tier, quality_label, year, source, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(album_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
artist = excluded.artist,
|
||||
artist_id = excluded.artist_id,
|
||||
artwork_url = excluded.artwork_url,
|
||||
quality_tier = excluded.quality_tier,
|
||||
quality_label = excluded.quality_label,
|
||||
year = excluded.year,
|
||||
source = excluded.source,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
params![
|
||||
m.album_id,
|
||||
m.title,
|
||||
m.artist,
|
||||
m.artist_id,
|
||||
m.artwork_url,
|
||||
m.quality_tier,
|
||||
m.quality_label,
|
||||
m.year,
|
||||
m.source,
|
||||
now
|
||||
],
|
||||
) {
|
||||
log::warn!("[qbz-slint] album_play_history upsert meta failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rank albums by play count (desc), tie-broken by most-recent play so ties
|
||||
/// are stable and intuitive. `limit` caps the carousel; `None` = full list.
|
||||
fn query_on(conn: &Connection, limit: Option<u32>) -> Vec<AlbumPlayRow> {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT m.album_id, m.title, m.artist, m.artist_id, m.artwork_url,
|
||||
m.quality_tier, m.quality_label, m.year, m.source, p.plays
|
||||
FROM album_meta m
|
||||
JOIN (
|
||||
SELECT album_id, COUNT(*) AS plays, MAX(occurred_at) AS last_at
|
||||
FROM album_play_events
|
||||
GROUP BY album_id
|
||||
) p ON p.album_id = m.album_id
|
||||
ORDER BY p.plays DESC, p.last_at DESC
|
||||
{}
|
||||
"#,
|
||||
limit.map(|n| format!("LIMIT {n}")).unwrap_or_default()
|
||||
);
|
||||
let out = (|| -> Option<Vec<AlbumPlayRow>> {
|
||||
let mut stmt = conn.prepare(&sql).ok()?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(AlbumPlayRow {
|
||||
album_id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
artist: row.get(2)?,
|
||||
artist_id: row.get(3)?,
|
||||
artwork_url: row.get(4)?,
|
||||
quality_tier: row.get(5)?,
|
||||
quality_label: row.get(6)?,
|
||||
year: row.get(7)?,
|
||||
source: row.get(8)?,
|
||||
plays: row.get::<_, i64>(9)? as u32,
|
||||
})
|
||||
})
|
||||
.ok()?;
|
||||
Some(rows.flatten().collect())
|
||||
})();
|
||||
out.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Record a play. Called from `playback::record_recent` when a track starts
|
||||
/// audible playback. No-op when the album id is empty (some local/Plex
|
||||
/// sources carry none — same guard as the recently-played rail).
|
||||
#[allow(dead_code)] // wired by playback::record_recent
|
||||
pub fn record_album_play(m: AlbumPlayMeta) {
|
||||
if m.album_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
with_db(|conn| {
|
||||
record_on(conn, &m, now);
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
|
||||
/// The top `limit` most-played albums (the carousel).
|
||||
#[allow(dead_code)] // wired by home/foryou
|
||||
pub fn top_albums(limit: u32) -> Vec<AlbumPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, Some(limit)))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every played album, ranked (the "View all" page).
|
||||
#[allow(dead_code)] // wired by the View-all loader
|
||||
pub fn all_albums() -> Vec<AlbumPlayRow> {
|
||||
with_db(|conn| Some(query_on(conn, None))).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta<'a>(id: &'a str, title: &'a str) -> AlbumPlayMeta<'a> {
|
||||
AlbumPlayMeta {
|
||||
album_id: id,
|
||||
title,
|
||||
artist: "Artist",
|
||||
artist_id: "7",
|
||||
artwork_url: "http://art",
|
||||
quality_tier: "hires",
|
||||
quality_label: "Hi-Res",
|
||||
year: "2024",
|
||||
source: "qobuz",
|
||||
}
|
||||
}
|
||||
|
||||
fn mem() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
init_schema(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranks_by_play_count_desc() {
|
||||
let c = mem();
|
||||
// A: 12 plays, B: 20 plays, C: 36 plays (per-track-start counting).
|
||||
for i in 0..12 {
|
||||
record_on(&c, &meta("A", "Album A"), 100 + i);
|
||||
}
|
||||
for i in 0..20 {
|
||||
record_on(&c, &meta("B", "Album B"), 200 + i);
|
||||
}
|
||||
for i in 0..36 {
|
||||
record_on(&c, &meta("C", "Album C"), 300 + i);
|
||||
}
|
||||
let rows = query_on(&c, None);
|
||||
assert_eq!(
|
||||
rows.iter().map(|r| (r.album_id.as_str(), r.plays)).collect::<Vec<_>>(),
|
||||
vec![("C", 36), ("B", 20), ("A", 12)]
|
||||
);
|
||||
// Meta round-trips.
|
||||
assert_eq!(rows[0].title, "Album C");
|
||||
assert_eq!(rows[0].artist, "Artist");
|
||||
assert_eq!(rows[0].quality_tier, "hires");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tie_break_prefers_more_recent_play() {
|
||||
let c = mem();
|
||||
// Both albums have 2 plays; B's last play is later -> B leads.
|
||||
record_on(&c, &meta("A", "A"), 10);
|
||||
record_on(&c, &meta("A", "A"), 11);
|
||||
record_on(&c, &meta("B", "B"), 20);
|
||||
record_on(&c, &meta("B", "B"), 21);
|
||||
let rows = query_on(&c, None);
|
||||
assert_eq!(rows.iter().map(|r| r.album_id.clone()).collect::<Vec<_>>(), vec!["B", "A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_caps_the_carousel() {
|
||||
let c = mem();
|
||||
for n in 0..5 {
|
||||
let id = format!("id{n}");
|
||||
// n+1 plays so ordering is deterministic (id4 highest).
|
||||
for i in 0..=n {
|
||||
record_on(&c, &meta(&id, "t"), 100 + n as i64 * 10 + i as i64);
|
||||
}
|
||||
}
|
||||
let top = query_on(&c, Some(3));
|
||||
assert_eq!(top.len(), 3);
|
||||
assert_eq!(top.iter().map(|r| r.album_id.clone()).collect::<Vec<_>>(), vec!["id4", "id3", "id2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_is_empty() {
|
||||
let c = mem();
|
||||
assert!(query_on(&c, None).is_empty());
|
||||
assert!(query_on(&c, Some(20)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_upsert_refreshes_on_replay() {
|
||||
let c = mem();
|
||||
record_on(&c, &meta("A", "Old Title"), 1);
|
||||
let mut m2 = meta("A", "New Title");
|
||||
m2.artwork_url = "http://new-art";
|
||||
record_on(&c, &m2, 2);
|
||||
let rows = query_on(&c, None);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].title, "New Title");
|
||||
assert_eq!(rows[0].artwork_url, "http://new-art");
|
||||
assert_eq!(rows[0].plays, 2);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,8 @@ impl DiscoveryTab {
|
||||
// Section ids
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The 19-member `DiscoverySectionId` union (`sectionPrefs.ts`). `editorPicks`
|
||||
/// The 20-member `DiscoverySectionId` union (`sectionPrefs.ts` + the local
|
||||
/// `mostPlayedAlbums` addition). `editorPicks`
|
||||
/// is BOTH a tab and a section id (the "Albums of the Week" section).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiscoverySectionId {
|
||||
@@ -81,6 +82,9 @@ pub enum DiscoverySectionId {
|
||||
EssentialsByGenre,
|
||||
ArtistsToFollow,
|
||||
ArtistSpotlight,
|
||||
/// "Most Played Albums" — top albums by local play count
|
||||
/// (`qbz_app::settings::album_play_history`). Home + For You, default off.
|
||||
MostPlayedAlbums,
|
||||
}
|
||||
|
||||
impl DiscoverySectionId {
|
||||
@@ -106,6 +110,7 @@ impl DiscoverySectionId {
|
||||
EssentialsByGenre => "essentialsByGenre",
|
||||
ArtistsToFollow => "artistsToFollow",
|
||||
ArtistSpotlight => "artistSpotlight",
|
||||
MostPlayedAlbums => "mostPlayedAlbums",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +136,7 @@ impl DiscoverySectionId {
|
||||
"essentialsByGenre" => EssentialsByGenre,
|
||||
"artistsToFollow" => ArtistsToFollow,
|
||||
"artistSpotlight" => ArtistSpotlight,
|
||||
"mostPlayedAlbums" => MostPlayedAlbums,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -169,10 +175,11 @@ pub struct DiscoverPrefs {
|
||||
pub fn default_prefs() -> DiscoverPrefs {
|
||||
use DiscoverySectionId::*;
|
||||
DiscoverPrefs {
|
||||
// home: first 7 ON, last 6 OFF (1:1 with Tauri sectionPrefs.ts:63-77).
|
||||
// All 13 ids render on Home since #566 completed the port: qobuzMixes
|
||||
// / releaseWatch / topArtists / favoriteAlbums were genuine Tauri-Home
|
||||
// sections whose Slint render arms + data pipelines were missing.
|
||||
// home: first 7 ON, the rest OFF. The first 13 are 1:1 with Tauri
|
||||
// sectionPrefs.ts:63-77 (all render since #566 completed the port:
|
||||
// qobuzMixes / releaseWatch / topArtists / favoriteAlbums were genuine
|
||||
// Tauri-Home sections whose Slint arms + pipelines were missing).
|
||||
// mostPlayedAlbums (#14) is a local addition — top albums by play count.
|
||||
home: vec![
|
||||
pref(NewReleases, true),
|
||||
pref(PressAwards, true),
|
||||
@@ -187,6 +194,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
||||
pref(Qobuzissimes, false),
|
||||
pref(TopArtists, false),
|
||||
pref(FavoriteAlbums, false),
|
||||
pref(MostPlayedAlbums, false),
|
||||
],
|
||||
// editorPicks: all ON.
|
||||
editor_picks: vec![
|
||||
@@ -212,6 +220,7 @@ pub fn default_prefs() -> DiscoverPrefs {
|
||||
pref(EssentialsByGenre, true),
|
||||
pref(ArtistsToFollow, true),
|
||||
pref(ArtistSpotlight, true),
|
||||
pref(MostPlayedAlbums, false),
|
||||
],
|
||||
show_recommendations: true,
|
||||
reco_cache_ttl_hours: 48,
|
||||
@@ -506,13 +515,15 @@ mod tests {
|
||||
#[test]
|
||||
fn defaults_match_spec_exactly() {
|
||||
let d = default_prefs();
|
||||
// home: 13 entries, first 7 ON (1:1 Tauri sectionPrefs.ts).
|
||||
// home: 14 entries, first 7 ON (first 13 = 1:1 Tauri sectionPrefs.ts,
|
||||
// + the local mostPlayedAlbums, default off).
|
||||
assert_eq!(
|
||||
ids(&d.home),
|
||||
vec![
|
||||
NewReleases, PressAwards, QobuzPlaylists, RecentlyPlayedAlbums,
|
||||
ContinueListening, IdealDiscography, MostStreamed, QobuzMixes,
|
||||
ReleaseWatch, EditorPicks, Qobuzissimes, TopArtists, FavoriteAlbums,
|
||||
MostPlayedAlbums,
|
||||
]
|
||||
);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::Home), 7);
|
||||
@@ -524,9 +535,11 @@ mod tests {
|
||||
vec![NewReleases, EditorPicks, Qobuzissimes, PressAwards, MostStreamed, IdealDiscography, QobuzPlaylists]
|
||||
);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::EditorPicks), 7);
|
||||
// forYou: 12 entries, all ON, qobuzMixes first.
|
||||
assert_eq!(d.for_you.len(), 12);
|
||||
// forYou: 13 entries, qobuzMixes first; the 12 Tauri ones ON,
|
||||
// mostPlayedAlbums (local addition) OFF.
|
||||
assert_eq!(d.for_you.len(), 13);
|
||||
assert_eq!(d.for_you[0].id, QobuzMixes);
|
||||
assert_eq!(d.for_you[12].id, MostPlayedAlbums);
|
||||
assert_eq!(d.enabled_count(DiscoveryTab::ForYou), 12);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod favorites_cache;
|
||||
pub mod graphics;
|
||||
pub mod playback;
|
||||
pub mod plex;
|
||||
pub mod album_play_history;
|
||||
pub mod reco_store;
|
||||
pub mod remote_control;
|
||||
pub mod scrobblers;
|
||||
|
||||
Reference in new issue
Block a user