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-integrations",
|
||||||
"qbz-models",
|
"qbz-models",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"slint",
|
"slint",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
@@ -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())?;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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_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,
|
||||||
|
|||||||
Reference in new issue
Block a user