From 669785ae01d26feeecff0f4e6c580eea589aaad0 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 20 May 2026 11:06:38 -0600 Subject: [PATCH] feat(slint, core): per-artist play history feeds the discovery filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the minimum of src-tauri/src/reco_store needed to honour the "skip artists I already listen to" gate in the discovery pipeline. play_history (`crates/qbz-slint/src/play_history.rs`): - Tiny SQLite store at /qbz/play_history.db with WAL (ADR-002), holding play_events (artist_id + occurred_at, one row per audible track start) and artist_names (id -> name upsert). Lazy open, errors collapsed to log::warn so a missing DB just means an empty filter — same default Tauri lands on a fresh profile. - record_play(artist_id, name) wired into playback::record_recent so every audible track start increments the per-artist count. - known_artists(threshold) returns the (HashSet, HashSet) pair Tauri's filter expects — qobuz ids for suppressing validated matches, normalized names for suppressing MB candidates upstream. qbz-core::musicbrainz_discover_artists: - New `known_artists` closure parameter, called once per invocation. Threshold matches Tauri (>2 plays). - Filter applied at both stages: MB candidates dropped when their normalized name is in known_names; Qobuz matches dropped when their id is in known_ids (mirrors local_known_qobuz_ids check in v2_get_discovery_artists). The Tauri reco_store has many other features (favorites, top genres, home seeds) the Slint shell does not use yet — those stay out of this commit; only the play-count surface needed by the sidebar's "You may also like" filter is ported. cc --- crates/Cargo.lock | 1 + crates/qbz-core/src/core.rs | 24 ++++- crates/qbz-slint/Cargo.toml | 1 + crates/qbz-slint/src/artist.rs | 5 + crates/qbz-slint/src/main.rs | 1 + crates/qbz-slint/src/play_history.rs | 155 +++++++++++++++++++++++++++ crates/qbz-slint/src/playback.rs | 6 ++ 7 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 crates/qbz-slint/src/play_history.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 66e63b7b7..37bfb09f1 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -5163,6 +5163,7 @@ dependencies = [ "qbz-integrations", "qbz-models", "reqwest", + "rusqlite", "serde", "serde_json", "slint", diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 9834fa3cf..826e8967e 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -1584,6 +1584,12 @@ impl QbzCore { seed_name: &str, similar_names: &[String], dismissed_per_tag: &(dyn Fn(&str) -> std::collections::HashSet + Send + Sync), + known_artists: &(dyn Fn() -> ( + std::collections::HashSet, + std::collections::HashSet, + ) + + Send + + Sync), ) -> Result { use std::collections::HashSet; @@ -1624,6 +1630,7 @@ impl QbzCore { let similar_norm: HashSet = similar_names.iter().map(|n| normalize_artist_name(n)).collect(); let dismissed_primary = dismissed_per_tag(&primary_tag.to_lowercase()); + let (known_ids, known_names) = known_artists(); let mut candidates: Vec<(String, String)> = Vec::new(); for artist in &mb_results.artists { @@ -1632,6 +1639,7 @@ impl QbzCore { || artist.id.eq_ignore_ascii_case(seed_mbid) || similar_norm.contains(&n) || dismissed_primary.contains(&n) + || known_names.contains(&n) { continue; } @@ -1643,7 +1651,7 @@ impl QbzCore { let max_results = 8; let min_results = 5; let mut results = self - .validate_discovery_on_qobuz(&candidates, max_results) + .validate_discovery_on_qobuz(&candidates, max_results, &known_ids) .await; if results.len() < min_results && seed_tags.len() > 1 { @@ -1664,6 +1672,7 @@ impl QbzCore { || similar_norm.contains(&n) || dismissed_primary.contains(&n) || dismissed_secondary.contains(&n) + || known_names.contains(&n) || existing_mbids.contains(&a.id) { continue; @@ -1673,7 +1682,11 @@ impl QbzCore { shuffle_with_seed(&mut secondary_candidates, seed_mbid, Some(&secondary_tag)); let remaining = max_results.saturating_sub(results.len()); let mut more = self - .validate_discovery_on_qobuz(&secondary_candidates, remaining) + .validate_discovery_on_qobuz( + &secondary_candidates, + remaining, + &known_ids, + ) .await; results.append(&mut more); } @@ -1689,6 +1702,7 @@ impl QbzCore { &self, candidates: &[(String, String)], max: usize, + known_ids: &std::collections::HashSet, ) -> Vec { let mut out: Vec = Vec::new(); for (mbid, name) in candidates { @@ -1704,6 +1718,12 @@ impl QbzCore { if normalize_artist_name(&first.name) != normalize_artist_name(name) { continue; } + // Tauri's `!local_known_qobuz_ids.contains(&artist.id)` + // gate — never suggest an artist the user has already + // listened to >2 times. + if known_ids.contains(&first.id) { + continue; + } out.push(DiscoveryArtist { mbid: mbid.clone(), name: first.name.clone(), diff --git a/crates/qbz-slint/Cargo.toml b/crates/qbz-slint/Cargo.toml index fc22728d8..b35ba77f4 100644 --- a/crates/qbz-slint/Cargo.toml +++ b/crates/qbz-slint/Cargo.toml @@ -38,6 +38,7 @@ qbz-audio = { path = "../qbz-audio" } qbz-core = { path = "../qbz-core" } qbz-models = { path = "../qbz-models" } qbz-integrations = { path = "../qbz-integrations" } +rusqlite = { version = "0.31", features = ["bundled"] } qbz-cache = { path = "../qbz-cache" } qbz-credentials = { path = "../qbz-credentials" } diff --git a/crates/qbz-slint/src/artist.rs b/crates/qbz-slint/src/artist.rs index 1f382ec62..0c7d68203 100644 --- a/crates/qbz-slint/src/artist.rs +++ b/crates/qbz-slint/src/artist.rs @@ -847,6 +847,10 @@ pub async fn load_mb_discovery( where A: FrontendAdapter + Send + Sync + 'static, { + // Tauri's listen threshold: artists with strictly more than 2 + // plays count as "already known" and are excluded from + // suggestions. + let known_threshold: u32 = 2; let response = runtime .core() .musicbrainz_discover_artists( @@ -854,6 +858,7 @@ where seed_name, &similar_names, &|tag| crate::discovery_dismiss::dismissed_for_tag(tag), + &|| crate::play_history::known_artists(known_threshold), ) .await .map_err(|e| e.to_string())?; diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 74e644e78..1114537f5 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -23,6 +23,7 @@ mod commands; mod discovery_dismiss; mod home; mod nav; +mod play_history; mod playback; mod queue; mod recently; diff --git a/crates/qbz-slint/src/play_history.rs b/crates/qbz-slint/src/play_history.rs new file mode 100644 index 000000000..ac4ce590e --- /dev/null +++ b/crates/qbz-slint/src/play_history.rs @@ -0,0 +1,155 @@ +//! Local play history — the source of truth for the discovery +//! filter "skip artists I already know". +//! +//! Mirrors the minimum-needed shape of `src-tauri/src/reco_store` for +//! the artist-network sidebar: a `play_events` table that grows one +//! row per track-start, and a side `artist_names` table that maps +//! id -> name (updated on each play). The discovery pipeline reads +//! both at once and turns them into the (qobuz_ids, normalized_names) +//! pair that filters MB candidates and validated Qobuz matches. +//! +//! SQLite is opened lazily once, and every read/write swallows errors +//! into a `log::warn!`. A fresh user (no DB yet) yields empty sets, +//! which simply means no exclusion is applied — same default Tauri +//! lands on a first-run profile. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rusqlite::{params, Connection}; + +use qbz_core::normalize_artist_name; + +static DB: OnceLock>> = OnceLock::new(); + +fn db_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("play_history.db")) +} + +fn open_db() -> Option { + let path = db_path()?; + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("[qbz-slint] play_history dir create failed: {e}"); + return None; + } + } + let conn = match Connection::open(&path) { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz-slint] play_history open failed: {e}"); + return None; + } + }; + // ADR-002: WAL mode 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] play_history pragma failed: {e}"); + } + if let Err(e) = conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS play_events ( + artist_id INTEGER NOT NULL, + occurred_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS play_events_artist + ON play_events(artist_id); + + CREATE TABLE IF NOT EXISTS artist_names ( + artist_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + "#, + ) { + log::warn!("[qbz-slint] play_history schema failed: {e}"); + return None; + } + Some(conn) +} + +fn with_db(f: F) -> Option +where + F: FnOnce(&Connection) -> Option, +{ + let cell = DB.get_or_init(|| Mutex::new(open_db())); + let guard = cell.lock().ok()?; + let conn = guard.as_ref()?; + f(conn) +} + +/// Record a play. Called when a track starts audible playback so the +/// per-artist count converges on the user's listening reality. +#[allow(dead_code)] // wired by playback::record_recent +pub fn record_play(artist_id: u64, artist_name: &str) { + if artist_id == 0 || artist_name.is_empty() { + return; + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + with_db(|conn| { + if let Err(e) = conn.execute( + "INSERT INTO play_events (artist_id, occurred_at) VALUES (?, ?)", + params![artist_id as i64, now], + ) { + log::warn!("[qbz-slint] play_history insert event failed: {e}"); + } + if let Err(e) = conn.execute( + r#" + INSERT INTO artist_names (artist_id, name, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(artist_id) DO UPDATE SET + name = excluded.name, + updated_at = excluded.updated_at + "#, + params![artist_id as i64, artist_name, now], + ) { + log::warn!("[qbz-slint] play_history upsert name failed: {e}"); + } + Some(()) + }); +} + +/// Known artists with strictly more than `threshold` plays. Returns +/// the Qobuz id set (for filtering validated Qobuz results) and the +/// normalized-name set (for filtering MB candidates). Same two-axis +/// filter the Tauri discovery pipeline applies. +#[allow(dead_code)] // wired by artist::load_mb_discovery +pub fn known_artists(threshold: u32) -> (HashSet, HashSet) { + let pair = with_db(|conn| { + let mut stmt = conn + .prepare( + r#" + SELECT a.artist_id, a.name + FROM artist_names a + JOIN ( + SELECT artist_id, COUNT(*) AS play_count + FROM play_events + GROUP BY artist_id + HAVING play_count > ? + ) p ON p.artist_id = a.artist_id + "#, + ) + .ok()?; + let rows = stmt + .query_map(params![threshold], |row| -> rusqlite::Result<(u64, String)> { + let id: i64 = row.get(0)?; + let name: String = row.get(1)?; + Ok((id as u64, name)) + }) + .ok()?; + let mut ids: HashSet = HashSet::new(); + let mut names: HashSet = HashSet::new(); + for row in rows.flatten() { + ids.insert(row.0); + names.insert(normalize_artist_name(&row.1)); + } + Some((ids, names)) + }); + pair.unwrap_or_default() +} diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index df61db6ac..e07966f2d 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -280,6 +280,12 @@ async fn record_recent(runtime: &Runtime) { album_artist: track.artist.clone(), album_artwork_url: artwork, }); + // Per-artist play count — feeds the discovery filter "skip + // artists I already know" (HavingCount > threshold). artist_id + // is optional on QueueTrack; skip when absent. + if let Some(artist_id) = track.artist_id { + crate::play_history::record_play(artist_id, &track.artist); + } } /// Play `album_id` from `start_index`: fetch the album, build the queue,