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:
7 files changed
+191
-2
No files matched your search
Generated
+1
@@ -5163,6 +5163,7 @@ dependencies = [
|
||||
"qbz-integrations",
|
||||
"qbz-models",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"slint",
|
||||
|
||||
@@ -1584,6 +1584,12 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
seed_name: &str,
|
||||
similar_names: &[String],
|
||||
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> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -1624,6 +1630,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
let similar_norm: HashSet<String> =
|
||||
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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
|| 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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
|| 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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
&self,
|
||||
candidates: &[(String, String)],
|
||||
max: usize,
|
||||
known_ids: &std::collections::HashSet<u64>,
|
||||
) -> Vec<DiscoveryArtist> {
|
||||
let mut out: Vec<DiscoveryArtist> = Vec::new();
|
||||
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) {
|
||||
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(),
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -847,6 +847,10 @@ pub async fn load_mb_discovery<A>(
|
||||
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())?;
|
||||
|
||||
@@ -23,6 +23,7 @@ mod commands;
|
||||
mod discovery_dismiss;
|
||||
mod home;
|
||||
mod nav;
|
||||
mod play_history;
|
||||
mod playback;
|
||||
mod queue;
|
||||
mod recently;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user