feat(slint, core): per-artist play history feeds the discovery filter

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 <data-dir>/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<u64>,
    HashSet<String>) 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
This commit is contained in:
vicrodh committed 2026-05-20 11:06:38 -06:00
1 parent 8b526049bb
commit 669785ae01
7 files changed
+191 -2

No files matched your search

+1
View File
@@ -5163,6 +5163,7 @@ dependencies = [
"qbz-integrations", "qbz-integrations",
"qbz-models", "qbz-models",
"reqwest", "reqwest",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"slint", "slint",
+22 -2
View File
@@ -1584,6 +1584,12 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
seed_name: &str, seed_name: &str,
similar_names: &[String], similar_names: &[String],
dismissed_per_tag: &(dyn Fn(&str) -> std::collections::HashSet<String> + Send + Sync), dismissed_per_tag: &(dyn Fn(&str) -> std::collections::HashSet<String> + Send + Sync),
known_artists: &(dyn Fn() -> (
std::collections::HashSet<u64>,
std::collections::HashSet<String>,
)
+ Send
+ Sync),
) -> Result<DiscoveryResponse, CoreError> { ) -> Result<DiscoveryResponse, CoreError> {
use std::collections::HashSet; use std::collections::HashSet;
@@ -1624,6 +1630,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let similar_norm: HashSet<String> = let similar_norm: HashSet<String> =
similar_names.iter().map(|n| normalize_artist_name(n)).collect(); similar_names.iter().map(|n| normalize_artist_name(n)).collect();
let dismissed_primary = dismissed_per_tag(&primary_tag.to_lowercase()); 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(); let mut candidates: Vec<(String, String)> = Vec::new();
for artist in &mb_results.artists { for artist in &mb_results.artists {
@@ -1632,6 +1639,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|| artist.id.eq_ignore_ascii_case(seed_mbid) || artist.id.eq_ignore_ascii_case(seed_mbid)
|| similar_norm.contains(&n) || similar_norm.contains(&n)
|| dismissed_primary.contains(&n) || dismissed_primary.contains(&n)
|| known_names.contains(&n)
{ {
continue; continue;
} }
@@ -1643,7 +1651,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let max_results = 8; let max_results = 8;
let min_results = 5; let min_results = 5;
let mut results = self let mut results = self
.validate_discovery_on_qobuz(&candidates, max_results) .validate_discovery_on_qobuz(&candidates, max_results, &known_ids)
.await; .await;
if results.len() < min_results && seed_tags.len() > 1 { if results.len() < min_results && seed_tags.len() > 1 {
@@ -1664,6 +1672,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|| similar_norm.contains(&n) || similar_norm.contains(&n)
|| dismissed_primary.contains(&n) || dismissed_primary.contains(&n)
|| dismissed_secondary.contains(&n) || dismissed_secondary.contains(&n)
|| known_names.contains(&n)
|| existing_mbids.contains(&a.id) || existing_mbids.contains(&a.id)
{ {
continue; continue;
@@ -1673,7 +1682,11 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
shuffle_with_seed(&mut secondary_candidates, seed_mbid, Some(&secondary_tag)); shuffle_with_seed(&mut secondary_candidates, seed_mbid, Some(&secondary_tag));
let remaining = max_results.saturating_sub(results.len()); let remaining = max_results.saturating_sub(results.len());
let mut more = self let mut more = self
.validate_discovery_on_qobuz(&secondary_candidates, remaining) .validate_discovery_on_qobuz(
&secondary_candidates,
remaining,
&known_ids,
)
.await; .await;
results.append(&mut more); results.append(&mut more);
} }
@@ -1689,6 +1702,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
&self, &self,
candidates: &[(String, String)], candidates: &[(String, String)],
max: usize, max: usize,
known_ids: &std::collections::HashSet<u64>,
) -> Vec<DiscoveryArtist> { ) -> Vec<DiscoveryArtist> {
let mut out: Vec<DiscoveryArtist> = Vec::new(); let mut out: Vec<DiscoveryArtist> = Vec::new();
for (mbid, name) in candidates { for (mbid, name) in candidates {
@@ -1704,6 +1718,12 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
if normalize_artist_name(&first.name) != normalize_artist_name(name) { if normalize_artist_name(&first.name) != normalize_artist_name(name) {
continue; 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 { out.push(DiscoveryArtist {
mbid: mbid.clone(), mbid: mbid.clone(),
name: first.name.clone(), name: first.name.clone(),
+1
View File
@@ -38,6 +38,7 @@ qbz-audio = { path = "../qbz-audio" }
qbz-core = { path = "../qbz-core" } qbz-core = { path = "../qbz-core" }
qbz-models = { path = "../qbz-models" } qbz-models = { path = "../qbz-models" }
qbz-integrations = { path = "../qbz-integrations" } qbz-integrations = { path = "../qbz-integrations" }
rusqlite = { version = "0.31", features = ["bundled"] }
qbz-cache = { path = "../qbz-cache" } qbz-cache = { path = "../qbz-cache" }
qbz-credentials = { path = "../qbz-credentials" } qbz-credentials = { path = "../qbz-credentials" }
+5
View File
@@ -847,6 +847,10 @@ pub async fn load_mb_discovery<A>(
where where
A: FrontendAdapter + Send + Sync + 'static, 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 let response = runtime
.core() .core()
.musicbrainz_discover_artists( .musicbrainz_discover_artists(
@@ -854,6 +858,7 @@ where
seed_name, seed_name,
&similar_names, &similar_names,
&|tag| crate::discovery_dismiss::dismissed_for_tag(tag), &|tag| crate::discovery_dismiss::dismissed_for_tag(tag),
&|| crate::play_history::known_artists(known_threshold),
) )
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
+1
View File
@@ -23,6 +23,7 @@ mod commands;
mod discovery_dismiss; mod discovery_dismiss;
mod home; mod home;
mod nav; mod nav;
mod play_history;
mod playback; mod playback;
mod queue; mod queue;
mod recently; mod recently;
+155
View File
@@ -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<Mutex<Option<Connection>>> = OnceLock::new();
fn db_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("play_history.db"))
}
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] 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, 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)
}
/// 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<u64>, HashSet<String>) {
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<u64> = HashSet::new();
let mut names: HashSet<String> = 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()
}
+6
View File
@@ -280,6 +280,12 @@ async fn record_recent(runtime: &Runtime) {
album_artist: track.artist.clone(), album_artist: track.artist.clone(),
album_artwork_url: artwork, 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, /// Play `album_id` from `start_index`: fetch the album, build the queue,