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
This commit is contained in:
vicrodh committed 2026-07-22 22:02:25 -06:00
1 parent da3a5553e0
commit 2c162b6925
3 files changed
+243

No files matched your search

+6
View File
@@ -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();
+4
View File
@@ -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).
+233
View File
@@ -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<DismissedArtist>,
}
/// The bound per-user file. `None` outside an active session (pure fail-open
/// window), matching the `fav_cache` lifecycle.
static STORE_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Bind the per-user store path from `<dir>/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<PathBuf> {
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<u64> {
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<DismissedArtist> {
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);
}
}