From 2c162b69256a7a29f82ee463d63f786e1ff2d8bf Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:02:25 -0600 Subject: [PATCH] feat(discover): per-user reco-dismissal store for Not interested Reco-scoped dismissal (NOT the app-wide blacklist): a dismissed artist only leaves the Recommendations rails and stays visible everywhere else. One small JSON store per user (the light discovery_dismiss precedent) bound at session activation next to fav_cache (login / restore / offline) and dropped on logout. Fail-open: unbound or corrupt file reads empty and mutations no-op. Unit-tested lifecycle (roundtrip, idempotent dismiss with snapshot backfill, re-bind persistence, undo, junk/unknown format tolerance). km --- crates/qbz/src/auth.rs | 6 + crates/qbz/src/main.rs | 4 + crates/qbz/src/reco_dismiss.rs | 233 +++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 crates/qbz/src/reco_dismiss.rs diff --git a/crates/qbz/src/auth.rs b/crates/qbz/src/auth.rs index 241229e3c..4a46e3c5a 100644 --- a/crates/qbz/src/auth.rs +++ b/crates/qbz/src/auth.rs @@ -132,6 +132,8 @@ where if let Some(dir) = crate::offline_mode::user_data_dir(user_id) { crate::offline_mode::init_for_user(&dir); crate::fav_cache::init_for_user(&dir); + // Reco-scoped "Not interested" dismissal store (reco rails only). + crate::reco_dismiss::init_for_user(&dir); // Recommendation event store (shared events.db with Tauri). Train // after init (off-thread) so the seeds reflect this session's events. crate::reco::init_for_user(&dir); @@ -268,6 +270,9 @@ where if let Some(dir) = crate::offline_mode::user_data_dir(user_id) { crate::offline_mode::init_for_user(&dir); crate::fav_cache::init_for_user(&dir); + // Reco-scoped "Not interested" dismissal store (reco rails + // only). + crate::reco_dismiss::init_for_user(&dir); // Recommendation event store (shared events.db with Tauri). // Train after init (off-thread) so the seeds reflect this // session's events. @@ -337,6 +342,7 @@ where crate::offline::deactivate().await; crate::offline_mode::teardown(); crate::fav_cache::teardown(); + crate::reco_dismiss::teardown(); crate::reco::teardown(); runtime.core().clear_artist_vectors().await; crate::discover_prefs::teardown(); diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index ec8675606..47674c213 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -122,6 +122,7 @@ mod plex_settings; mod playlist_picker; mod quality; mod reco; +mod reco_dismiss; mod recently; mod scrobble; mod scrobbler_settings; @@ -708,6 +709,9 @@ async fn enter_shell_offline( if let Some(dir) = crate::offline_mode::user_data_dir(user_id) { crate::offline_mode::init_for_user(&dir); crate::fav_cache::init_for_user(&dir); + // Reco-scoped "Not interested" dismissal store (Discover > + // Recommendations rails only — NOT the blacklist). + crate::reco_dismiss::init_for_user(&dir); // Recommendation event store (shared events.db with Tauri). crate::reco::init_for_user(&dir); // External-recommendations resolution cache (4th Discover tab). diff --git a/crates/qbz/src/reco_dismiss.rs b/crates/qbz/src/reco_dismiss.rs new file mode 100644 index 000000000..0585e1305 --- /dev/null +++ b/crates/qbz/src/reco_dismiss.rs @@ -0,0 +1,233 @@ +//! Per-user "Not interested" dismissal store for Discover > Recommendations. +//! +//! Reco-SCOPED dismissal — deliberately NOT the app-wide blacklist: a +//! dismissed artist only leaves the two Recommended-Artist rails ("More like +//! the artists you love" / "Based on what you've been into lately"); it stays +//! visible in search, home, and label pages. The paint choke point in +//! `crate::external_reco` folds [`ids_snapshot`] into its exclusion set, and +//! the Blacklist Manager's "Recommendations" tab lists / undoes entries. +//! +//! Shape follows the light `discovery_dismiss` precedent (one small JSON file, +//! a full read on each op — no SQLite, no change-notify) but bound PER-USER +//! like `fav_cache` / `artist_blacklist`: a process-global path set via +//! [`init_for_user`] / dropped by [`teardown`]. Fail-open everywhere: with no +//! session bound (or a corrupt file) reads are empty and mutations no-op. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +/// JSON file name inside the per-user data dir. +const FILE_NAME: &str = "reco_dismiss.json"; + +/// One dismissed artist. `image_url` is optional (used only if a future +/// surface wants a thumbnail; the manager tab renders a generic avatar). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DismissedArtist { + #[serde(default)] + pub artist_id: u64, + #[serde(default)] + pub name: String, + #[serde(default)] + pub image_url: String, +} + +#[derive(Default, Serialize, Deserialize)] +struct DismissStore { + #[serde(default)] + artists: Vec, +} + +/// The bound per-user file. `None` outside an active session (pure fail-open +/// window), matching the `fav_cache` lifecycle. +static STORE_PATH: Mutex> = Mutex::new(None); + +/// Bind the per-user store path from `/reco_dismiss.json`. Called on +/// every session activation — login, restore, AND offline entry — next to +/// `fav_cache::init_for_user`. +pub fn init_for_user(base_dir: &Path) { + if let Ok(mut guard) = STORE_PATH.lock() { + *guard = Some(base_dir.join(FILE_NAME)); + } +} + +/// Drop the binding on logout. Mirrors `fav_cache::teardown`. +pub fn teardown() { + if let Ok(mut guard) = STORE_PATH.lock() { + *guard = None; + } +} + +fn store_path() -> Option { + STORE_PATH.lock().ok().and_then(|g| g.clone()) +} + +/// Fail-open read: no binding, unreadable file, or unknown/corrupt format all +/// yield an empty store (a corrupted file never blocks recommendations — the +/// user simply re-dismisses). +fn load_store() -> DismissStore { + let Some(path) = store_path() else { + return DismissStore::default(); + }; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => DismissStore::default(), + } +} + +fn write_store(store: &DismissStore) { + let Some(path) = store_path() else { + return; + }; + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("[qbz-slint] reco-dismiss dir failed: {e}"); + return; + } + } + match serde_json::to_vec_pretty(store) { + Ok(json) => { + if let Err(e) = std::fs::write(&path, json) { + log::warn!("[qbz-slint] reco-dismiss write failed: {e}"); + } + } + Err(e) => log::warn!("[qbz-slint] reco-dismiss serialize failed: {e}"), + } +} + +/// Snapshot of the dismissed id set — the §B paint-filter input. Empty when +/// no session is bound. Id 0 (a corrupt row) never matches. +pub fn ids_snapshot() -> HashSet { + load_store() + .artists + .into_iter() + .map(|a| a.artist_id) + .filter(|id| *id != 0) + .collect() +} + +/// All dismissed artists in insertion order, for the manager tab. Empty on no +/// session or a corrupt file. +pub fn list() -> Vec { + load_store() + .artists + .into_iter() + .filter(|a| a.artist_id != 0) + .collect() +} + +/// Record a dismissal (idempotent). A re-dismiss with a richer snapshot +/// backfills a previously empty name/image (e.g. first dismissed offline, +/// where the name could not be resolved). +pub fn dismiss(artist_id: u64, name: &str, image_url: &str) { + if artist_id == 0 { + return; + } + let mut store = load_store(); + match store.artists.iter_mut().find(|a| a.artist_id == artist_id) { + Some(existing) => { + if existing.name.is_empty() && !name.is_empty() { + existing.name = name.to_string(); + } + if existing.image_url.is_empty() && !image_url.is_empty() { + existing.image_url = image_url.to_string(); + } + } + None => store.artists.push(DismissedArtist { + artist_id, + name: name.to_string(), + image_url: image_url.to_string(), + }), + } + write_store(&store); +} + +/// Remove a dismissal (the manager tab's undo). No-op when absent / unbound. +pub fn remove(artist_id: u64) { + let mut store = load_store(); + let before = store.artists.len(); + store.artists.retain(|a| a.artist_id != artist_id); + if store.artists.len() != before { + write_store(&store); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unique temp dir under the system temp root (no `tempfile` dev-dep on + /// qbz-slint). Created here, removed at the end of the test. + fn unique_temp_dir() -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("qbz-slint-reco-dismiss-test-{nanos}")); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + /// One combined test: the path singleton is process-global, so splitting + /// into parallel tests would let them clobber each other. Covers the full + /// lifecycle: bind, dismiss (idempotent + snapshot backfill), persistence + /// across a re-bind, remove, unknown-format tolerance, and the fail-open + /// unbound state. + #[test] + fn lifecycle_roundtrip() { + let dir = unique_temp_dir(); + + init_for_user(&dir); + assert!(ids_snapshot().is_empty(), "fresh store has no ids"); + assert!(list().is_empty(), "fresh store has no rows"); + + dismiss(42, "Artist X", "https://img/x.jpg"); + dismiss(7, "Artist Y", ""); + assert!(ids_snapshot().contains(&42)); + assert!(ids_snapshot().contains(&7)); + assert_eq!(list().len(), 2); + + // Idempotent: a re-dismiss does not duplicate, but backfills an empty + // snapshot field. + dismiss(42, "Artist X", "https://img/x.jpg"); + dismiss(7, "Artist Y", "https://img/y.jpg"); + assert_eq!(list().len(), 2, "no duplicate rows"); + assert_eq!(list()[1].image_url, "https://img/y.jpg", "image backfilled"); + + // Id 0 is rejected and never matches. + dismiss(0, "Nobody", ""); + assert!(!ids_snapshot().contains(&0)); + + // Persistence: re-binding the same dir loads the file back. + teardown(); + assert!(ids_snapshot().is_empty(), "fail-open after teardown"); + dismiss(9, "Lost", ""); // no-op while unbound; must not panic + init_for_user(&dir); + assert!(ids_snapshot().contains(&42), "rows survive a re-bind"); + assert!(!ids_snapshot().contains(&9), "unbound mutation did not persist"); + + // Undo. + remove(42); + assert!(!ids_snapshot().contains(&42)); + assert_eq!(list().len(), 1); + remove(42); // absent: no-op, no write needed + + // Unknown-format tolerance: junk in the file reads as an empty store. + std::fs::write(dir.join(FILE_NAME), b"{ not json !!").expect("write junk"); + assert!(ids_snapshot().is_empty(), "corrupt file fails open"); + assert!(list().is_empty()); + // A partially-unknown row shape degrades to defaults, not a crash. + std::fs::write( + dir.join(FILE_NAME), + br#"{"artists":[{"artist_id":5},{"artist_id":0,"name":"zero"}]}"#, + ) + .expect("write partial"); + assert!(ids_snapshot().contains(&5), "partial row still loads"); + assert!(!ids_snapshot().contains(&0), "id 0 row is dropped"); + + teardown(); + let _ = std::fs::remove_dir_all(&dir); + } +}