diff --git a/crates/qbz-app/src/settings/reco_store.rs b/crates/qbz-app/src/settings/reco_store.rs index 60d6f4f56..8cbd4880a 100644 --- a/crates/qbz-app/src/settings/reco_store.rs +++ b/crates/qbz-app/src/settings/reco_store.rs @@ -554,6 +554,37 @@ impl RecoStore { Ok(artists) } + /// Distinct artist ids the user "knows": played strictly more than + /// `play_threshold` times OR favorited at least once. Backs the discovery + /// "skip artists I already know" filter with a richer signal (plays + + /// favorites) than the play-only `play_history` store. + pub fn get_known_artist_ids( + &self, + play_threshold: u32, + ) -> Result, String> { + let mut stmt = self + .conn + .prepare( + r#" + SELECT artist_id + FROM reco_events + WHERE artist_id IS NOT NULL + GROUP BY artist_id + HAVING SUM(CASE WHEN event_type = 'play' THEN 1 ELSE 0 END) > ? + OR SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) > 0 + "#, + ) + .map_err(|e| format!("Failed to prepare known artists query: {}", e))?; + let rows = stmt + .query_map(params![play_threshold], |row| row.get::<_, u64>(0)) + .map_err(|e| format!("Failed to query known artists: {}", e))?; + let mut ids = std::collections::HashSet::new(); + for row in rows { + ids.insert(row.map_err(|e| format!("Failed to read known artist row: {}", e))?); + } + Ok(ids) + } + /// The user's most-played genres by event count (mirrors `get_top_genre_ids`). /// Returns `(genre_id, genre_name)` — name from `reco_album_meta` (empty if unknown). pub fn get_top_genres(&self, limit: u32) -> Result, String> { @@ -1277,4 +1308,32 @@ mod tests { assert_eq!(genres[1].0, 6); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn known_artists_includes_played_and_favorited() { + let dir = unique_test_dir("reco-known-artists"); + let store = RecoStore::new_at(&dir).unwrap(); + // artist 10: 3 plays (> threshold 2). artist 20: a single favorite, no + // plays. artist 30: 1 play, not favorited -> excluded. + store.log_play_event(1, Some("a".into()), Some(10), None).unwrap(); + store.log_play_event(2, Some("a".into()), Some(10), None).unwrap(); + store.log_play_event(3, Some("a".into()), Some(10), None).unwrap(); + store + .insert_event(&RecoEventInput { + event_type: RecoEventType::Favorite, + item_type: RecoItemType::Artist, + track_id: None, + album_id: None, + artist_id: Some(20), + playlist_id: None, + genre_id: None, + }) + .unwrap(); + store.log_play_event(4, Some("c".into()), Some(30), None).unwrap(); + let known = store.get_known_artist_ids(2).unwrap(); + assert!(known.contains(&10)); // 3 plays > 2 + assert!(known.contains(&20)); // favorited + assert!(!known.contains(&30)); // 1 play, not favorited + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/crates/qbz/src/artist.rs b/crates/qbz/src/artist.rs index f51eb8614..9262e367a 100644 --- a/crates/qbz/src/artist.rs +++ b/crates/qbz/src/artist.rs @@ -1540,7 +1540,17 @@ where seed_name, &similar_names, &|tag| crate::discovery_dismiss::dismissed_for_tag(tag), - &|| crate::play_history::known_artists(known_threshold), + &|| { + // play_history supplies the (id, name) known set; reco augments + // the id set with its richer signal (artists played >threshold + // OR favorited). Names stay from play_history -- reco_events has + // no artist names (schema frozen to match Tauri). + let (mut ids, names) = crate::play_history::known_artists(known_threshold); + if let Some(reco_ids) = crate::reco::known_artist_ids(known_threshold) { + ids.extend(reco_ids); + } + (ids, names) + }, ) .await .map_err(|e| e.to_string())?; diff --git a/crates/qbz/src/reco.rs b/crates/qbz/src/reco.rs index 1c9e09bd0..e12df8252 100644 --- a/crates/qbz/src/reco.rs +++ b/crates/qbz/src/reco.rs @@ -12,6 +12,7 @@ //! file is shared with Tauri (`/reco/events.db`), so a user's //! existing recommendation history carries across frontends. +use std::collections::HashSet; use std::path::Path; use std::sync::Mutex; @@ -230,6 +231,16 @@ pub fn backfill_album_genres(entries: Vec<(String, u64, String)>) { } } +/// Artist ids the user "knows" — played more than `play_threshold` times OR +/// favorited — to augment the discovery "skip artists I already know" filter +/// with the reco signal (plays + favorites, not plays alone). `None` when reco +/// is cold/disabled, so discovery falls back to the play_history set only. +pub fn known_artist_ids(play_threshold: u32) -> Option> { + let guard = RECO.lock().ok()?; + let store = guard.as_ref()?; + store.get_known_artist_ids(play_threshold).ok() +} + // --------------------------------------------------------------------------- // Training (W5) // ---------------------------------------------------------------------------