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

+22 -2
View File
@@ -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(),