From da3a5553e0778d5b2f3c037d4d902a775bae1c9e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:59:26 -0600 Subject: [PATCH 01/10] fix(discover): seed artist follow state in the Recommendations rails slim_from_artist hardcoded following: false, so reco artist cards never reflected the current follow state at build time (the pinned carousel already seeded from fav_cache). Seed from fav_cache::is_artist_favorite and extend the shell-entry favorite-artists warm walk to the three ExternalRecoState artist models, in place (no model swap, no flicker). Live toggles were already propagated by search::mark_artist_followed. km --- crates/qbz/src/external_reco.rs | 6 +++++- crates/qbz/src/main.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/qbz/src/external_reco.rs b/crates/qbz/src/external_reco.rs index 79117f442..10871205a 100644 --- a/crates/qbz/src/external_reco.rs +++ b/crates/qbz/src/external_reco.rs @@ -601,7 +601,11 @@ fn slim_from_artist(a: &ArtistReco) -> SlimItem { rank: "".into(), artwork_url: a.image_url.clone().into(), artwork: slint::Image::default(), - following: false, + // Live follow state from the login-seeded fav cache (the + // pinned_section precedent) — a hardcoded `false` mislabeled + // already-followed artists. Kept live afterwards by + // search::mark_artist_followed on every toggle. + following: crate::fav_cache::is_artist_favorite(a.qobuz_artist_id), } } fn slim_from_track(t: &TrackReco) -> SlimItem { diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 3bda0b7b3..ec8675606 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -494,6 +494,30 @@ async fn enter_shell( } } } + // External-reco artist rows (Discover > Recommendations): + // same in-place re-seed — the rows may already be painted + // from the results blob before this warm lands (their + // build-time fav_cache seed was stale/empty then). + let reco = w.global::(); + for model in [ + reco.get_rec_artists_common(), + reco.get_rec_artists_recent(), + reco.get_top_artists(), + ] { + for i in 0..model.row_count() { + if let Some(mut it) = model.row_data(i) { + let following = it + .id + .parse::() + .map(|id| fav_cache::is_artist_favorite(id)) + .unwrap_or(false); + if it.following != following { + it.following = following; + model.set_row_data(i, it); + } + } + } + } }); } Err(e) => log::warn!("[qbz-slint] favorite artists warm failed: {e}"), 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 02/10] 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); + } +} From 465f8627ebbb427bd731bfe9bc9615c3f9554ffa Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:08:38 -0600 Subject: [PATCH 03/10] feat(discover): filter, cross-rail dedup, and overflow backfill for reco rails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two Recommended-Artist rails showed artists the user already follows or blacklisted, and the same artist could appear in both rails at once. Builders now retain the full validated pool (rotated, not truncated at DISPLAY_CAP); the pools ride the results blob as before, so old blobs simply have no overflow until they expire. A new pure compose step in qbz-external-reco (unit-tested: exclusion, within/cross-rail dedup with common-wins ordering, visible/overflow split order) runs at the single paint choke point in external_reco.rs — covering BOTH the fresh build and the cached-blob paint. Exclusions fold followed (fav_cache) + blacklisted + reco-dismissed ids, each source independently fail-open. The past-cap survivors are retained as overflow for the Not-interested live backfill (no extra network). km --- crates/qbz-external-reco/src/carousels.rs | 138 +++++++++++++++++++++- crates/qbz-external-reco/src/lib.rs | 1 + crates/qbz/src/external_reco.rs | 80 ++++++++++--- crates/qbz/src/fav_cache.rs | 6 + 4 files changed, 207 insertions(+), 18 deletions(-) diff --git a/crates/qbz-external-reco/src/carousels.rs b/crates/qbz-external-reco/src/carousels.rs index 00882b05a..284752128 100644 --- a/crates/qbz-external-reco/src/carousels.rs +++ b/crates/qbz-external-reco/src/carousels.rs @@ -22,6 +22,9 @@ use crate::validate::{ use crate::{RecoCatalog, RecoInputs}; const DISPLAY_CAP: usize = 20; +/// Public alias for the paint layer (it composes the visible rows after +/// filtering/dedup, so it needs the canonical per-rail visible cap). +pub const ARTIST_DISPLAY_CAP: usize = DISPLAY_CAP; const PLAYLIST_CAP: usize = 30; const VALIDATE_CONCURRENCY: usize = 6; const ARTIST_SEEDS: usize = 6; @@ -239,7 +242,13 @@ async fn similar_artist_row( } let candidates: Vec = round_robin(groups).into_iter().take(45).collect(); let pool = validate_artist_pool(inputs.catalog, inputs.cache, candidates).await; - rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP) + // Rotate for daily variety but do NOT truncate at DISPLAY_CAP: the paint + // layer composes the visible rows AFTER followed/blacklisted/dismissed + // filtering + cross-rail dedup (compose_artist_rails) and keeps the + // remaining validated candidates as backfill overflow — they ride the + // results blob, so replacements need no extra network. + let take = pool.len(); + rotate_take(pool, inputs.rotation_seed, take) } /// "More like the artists you love" — your COMMON taste (overall top). @@ -258,6 +267,59 @@ pub async fn build_rec_artists_recent( similar_artist_row(inputs, history, "1month").await } +// ── Display composition (filter + cross-rail dedup + backfill split) ──────── + +/// One artist rail after display composition: the visible rows (the first +/// `cap` survivors) plus the retained overflow (the remaining survivors, in +/// pool order), which the paint layer keeps for live backfill after a +/// "not interested" dismissal. +#[derive(Debug, Clone, Default)] +pub struct ArtistRailComposition { + pub visible: Vec, + pub overflow: Vec, +} + +/// Compose the two Recommended-Artist rails for display: +/// +/// - drop every candidate whose Qobuz id is in `excluded` (the frontend folds +/// followed / blacklisted / dismissed ids into that set; fail-soft = pass +/// an empty set), +/// - dedup WITHIN and ACROSS rails: an id shows in at most one rail — the +/// COMMON rail is composed first and wins — and overflow ids are claimed +/// too, so a common-overflow id cannot resurface in recent's visible, +/// - the first `cap` survivors per rail are visible; the rest is overflow. +pub fn compose_artist_rails( + common_pool: Vec, + recent_pool: Vec, + excluded: &HashSet, + cap: usize, +) -> (ArtistRailComposition, ArtistRailComposition) { + let mut seen: HashSet = HashSet::new(); + let common = compose_one_rail(common_pool, excluded, &mut seen, cap); + let recent = compose_one_rail(recent_pool, excluded, &mut seen, cap); + (common, recent) +} + +fn compose_one_rail( + pool: Vec, + excluded: &HashSet, + seen: &mut HashSet, + cap: usize, +) -> ArtistRailComposition { + let mut out = ArtistRailComposition::default(); + for reco in pool { + if excluded.contains(&reco.qobuz_artist_id) || !seen.insert(reco.qobuz_artist_id) { + continue; + } + if out.visible.len() < cap { + out.visible.push(reco); + } else { + out.overflow.push(reco); + } + } + out +} + // ── Recommended Albums (Last.fm: your artists' top albums, not scrobbled) ─── pub async fn build_rec_albums(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec { @@ -637,3 +699,77 @@ pub async fn build_editorial(inputs: &RecoInputs<'_>) -> (Vec, Vec ArtistReco { + ArtistReco { + qobuz_artist_id: id, + name: format!("Artist {id}"), + image_url: String::new(), + subtitle: String::new(), + source: RecoSource::LastFm, + } + } + + fn ids(recos: &[ArtistReco]) -> Vec { + recos.iter().map(|r| r.qobuz_artist_id).collect() + } + + #[test] + fn compose_excludes_ids_from_visible_and_overflow() { + let common = vec![reco(1), reco(2), reco(3), reco(4)]; + let recent = vec![reco(5), reco(6)]; + let excluded = HashSet::from([2, 4, 5]); + let (c, r) = compose_artist_rails(common, recent, &excluded, 2); + assert_eq!(ids(&c.visible), vec![1, 3]); + assert!(c.overflow.is_empty(), "excluded ids leave no overflow"); + assert_eq!(ids(&r.visible), vec![6]); + } + + #[test] + fn compose_dedups_cross_rail_common_wins() { + let common = vec![reco(1), reco(2)]; + let recent = vec![reco(2), reco(3)]; + let (c, r) = compose_artist_rails(common, recent, &HashSet::new(), 20); + assert_eq!(ids(&c.visible), vec![1, 2]); + assert_eq!(ids(&r.visible), vec![3], "id 2 appears only in common"); + } + + #[test] + fn compose_dedups_within_rail() { + let common = vec![reco(1), reco(1), reco(2)]; + let (c, _r) = compose_artist_rails(common, Vec::new(), &HashSet::new(), 20); + assert_eq!(ids(&c.visible), vec![1, 2]); + } + + #[test] + fn compose_splits_visible_and_overflow_in_order() { + let common: Vec = (1..=5).map(reco).collect(); + let excluded = HashSet::from([1]); + let (c, _r) = compose_artist_rails(common, Vec::new(), &excluded, 3); + // The exclusion punches a hole in the first window; the next + // candidates move up into the visible rows, the rest is overflow — + // both in pool order (the backfill contract). + assert_eq!(ids(&c.visible), vec![2, 3, 4]); + assert_eq!(ids(&c.overflow), vec![5]); + } + + #[test] + fn compose_cross_rail_dedup_also_covers_overflow() { + // An id sitting in common's OVERFLOW must not resurface in recent's + // visible rows (it is still claimed for potential backfill). + let common: Vec = (1..=3).map(reco).collect(); + let recent = vec![reco(3), reco(4)]; + let (c, r) = compose_artist_rails(common, recent, &HashSet::new(), 2); + assert_eq!(ids(&c.visible), vec![1, 2]); + assert_eq!(ids(&c.overflow), vec![3]); + assert_eq!(ids(&r.visible), vec![4], "id 3 is claimed by common"); + assert!(r.overflow.is_empty()); + } +} diff --git a/crates/qbz-external-reco/src/lib.rs b/crates/qbz-external-reco/src/lib.rs index bd47ba77f..f179d1643 100644 --- a/crates/qbz-external-reco/src/lib.rs +++ b/crates/qbz-external-reco/src/lib.rs @@ -27,6 +27,7 @@ use qbz_integrations::{LastFmClient, ListenBrainzClient, MusicBrainzClient}; use qbz_models::{Album, Artist, Track}; pub use cache::RecoCache; +pub use carousels::{compose_artist_rails, ArtistRailComposition, ARTIST_DISPLAY_CAP}; pub use types::{ AlbumReco, ArtistReco, ExternalCarousels, ExtHistory, LocalHistory, RecoSource, TrackReco, }; diff --git a/crates/qbz/src/external_reco.rs b/crates/qbz/src/external_reco.rs index 10871205a..6e885e7a9 100644 --- a/crates/qbz/src/external_reco.rs +++ b/crates/qbz/src/external_reco.rs @@ -9,6 +9,7 @@ //! Weekly Exploration/Jams (ListenBrainz), Deep-cut albums, and a Qobuz editorial //! cold-start fallback. +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -18,9 +19,9 @@ use qbz_app::shell::AppRuntime; use qbz_external_reco::{ build_deep_cut_albums, build_editorial, build_fresh_releases, build_rec_albums, build_rec_artists_common, build_rec_artists_recent, build_similar_albums_seeded, - build_weekly_exploration, build_weekly_jams, gather_history, is_cold_start, AlbumReco, - ArtistReco, ExternalCarousels, LastFmHandle, ListenBrainzHandle, LocalHistory, RecoCache, - RecoCatalog, RecoInputs, TrackReco, + build_weekly_exploration, build_weekly_jams, compose_artist_rails, gather_history, + is_cold_start, AlbumReco, ArtistReco, ExternalCarousels, LastFmHandle, ListenBrainzHandle, + LocalHistory, RecoCache, RecoCatalog, RecoInputs, TrackReco, ARTIST_DISPLAY_CAP, }; use qbz_integrations::{LastFmClient, ListenBrainzClient, MusicBrainzClient}; use qbz_models::{Album, Artist, Track}; @@ -32,6 +33,15 @@ use crate::{AlbumCardItem, AppWindow, DiscoverSection, ExternalRecoState, SlimIt static CACHE_DIR: Mutex> = Mutex::new(None); +/// Retained per-rail overflow (validated candidates past the visible cap) for +/// the two Recommended-Artist rails — (common, recent). Kept for live +/// backfill after a "not interested" dismissal, so a replacement card needs +/// no extra network. Rewritten on every rail paint (fresh build AND cached +/// blob paint; old blobs carry no overflow, so they simply don't backfill +/// until they expire). +static ARTIST_OVERFLOW: Mutex<(Vec, Vec)> = + Mutex::new((Vec::new(), Vec::new())); + pub fn init_for_user(base_dir: &Path) { if let Ok(mut g) = CACHE_DIR.lock() { *g = Some(base_dir.to_path_buf()); @@ -390,19 +400,21 @@ fn spawn( let history = gather_history(&inputs).await; let col = &collector; // Progressive: each branch paints its row AND collects it for the cache. - let b_common = async { - let r = build_rec_artists_common(&inputs, &history).await; + // The two artist rails build in parallel but paint TOGETHER through + // apply_artist_rails (the shared filter/dedup/backfill choke point — + // cross-rail dedup needs both pools). The collector stores the FULL + // validated pools (visible + overflow), so the results blob carries + // the backfill candidates too. + let b_artists = async { + let (common_pool, recent_pool) = tokio::join!( + build_rec_artists_common(&inputs, &history), + build_rec_artists_recent(&inputs, &history), + ); if let Ok(mut g) = col.lock() { - g.rec_artists_common = r.clone(); + g.rec_artists_common = common_pool.clone(); + g.rec_artists_recent = recent_pool.clone(); } - apply_artists(&weak, &image_cache, r, ArtistRow::RecArtistsCommon); - }; - let b_recent = async { - let r = build_rec_artists_recent(&inputs, &history).await; - if let Ok(mut g) = col.lock() { - g.rec_artists_recent = r.clone(); - } - apply_artists(&weak, &image_cache, r, ArtistRow::RecArtistsRecent); + apply_artist_rails(&weak, &image_cache, common_pool, recent_pool); }; let b_albums = async { let r = build_rec_albums(&inputs, &history).await; @@ -439,7 +451,7 @@ fn spawn( } apply_albums(&weak, &image_cache, r, AlbumRow::DeepCuts); }; - tokio::join!(b_common, b_recent, b_albums, b_fresh, b_explore, b_jams, b_deep); + tokio::join!(b_artists, b_albums, b_fresh, b_explore, b_jams, b_deep); } // 3. Store the built result for instant future opens (48h TTL). GUARD @@ -522,8 +534,7 @@ fn clear_all_pending(w: &AppWindow) { /// their own per-week cache by `build_and_apply_weeklies`, so the blob can never /// pin a stale/empty weekly for the 48h window. fn apply_all(weak: &slint::Weak, cache: &ImageCache, r: ExternalCarousels) { - apply_artists(weak, cache, r.rec_artists_common, ArtistRow::RecArtistsCommon); - apply_artists(weak, cache, r.rec_artists_recent, ArtistRow::RecArtistsRecent); + apply_artist_rails(weak, cache, r.rec_artists_common, r.rec_artists_recent); apply_albums(weak, cache, r.rec_albums, AlbumRow::RecAlbums); apply_albums(weak, cache, r.fresh_releases, AlbumRow::FreshReleases); apply_albums(weak, cache, r.deep_cut_albums, AlbumRow::DeepCuts); @@ -531,6 +542,41 @@ fn apply_all(weak: &slint::Weak, cache: &ImageCache, r: ExternalCarou apply_artists(weak, cache, r.top_artists, ArtistRow::TopArtists); } +/// Fold the three per-user exclusion sources — followed artists, the app-wide +/// blacklist, and the reco-scoped "not interested" dismissals — into one id +/// set for the rail composition. Every source is independently fail-open (an +/// unbound/unreadable store simply contributes nothing). +fn artist_exclusions() -> HashSet { + let mut ids = crate::fav_cache::all_artists(); + ids.extend(crate::artist_blacklist::ids_snapshot()); + ids.extend(crate::reco_dismiss::ids_snapshot()); + ids +} + +/// THE paint choke point for the two Recommended-Artist rails — the fresh +/// build AND the cached-blob paint both funnel through here. Composes the +/// visible rows from the validated pools AFTER exclusion filtering + +/// cross-rail dedup (common wins), takes the first ARTIST_DISPLAY_CAP +/// survivors per rail, and retains the rest as backfill overflow for the +/// "not interested" live replacement (compose_artist_rails does the split; +/// the full pools ride the results blob, so a cached paint backfills too). +fn apply_artist_rails( + weak: &slint::Weak, + cache: &ImageCache, + common_pool: Vec, + recent_pool: Vec, +) { + let excluded = artist_exclusions(); + let (common, recent) = + compose_artist_rails(common_pool, recent_pool, &excluded, ARTIST_DISPLAY_CAP); + if let Ok(mut g) = ARTIST_OVERFLOW.lock() { + g.0 = common.overflow; + g.1 = recent.overflow; + } + apply_artists(weak, cache, common.visible, ArtistRow::RecArtistsCommon); + apply_artists(weak, cache, recent.visible, ArtistRow::RecArtistsRecent); +} + /// Build + paint the two Weekly rows from their own per-week cache (cheap on a /// hit; one ListenBrainz `createdfor` call + a SQLite read). Used on the /// instant-paint path so the weeklies follow ListenBrainz's weekly cadence diff --git a/crates/qbz/src/fav_cache.rs b/crates/qbz/src/fav_cache.rs index 51aef9f04..4dede5d36 100644 --- a/crates/qbz/src/fav_cache.rs +++ b/crates/qbz/src/fav_cache.rs @@ -272,6 +272,12 @@ pub fn is_artist_favorite(artist_id: u64) -> bool { .unwrap_or(false) } +/// Snapshot of the full followed-artist id set — the reco paint filter's +/// "already following" exclusion input (mirrors `all()` for tracks). +pub fn all_artists() -> HashSet { + FAV_ARTISTS.read().map(|g| g.clone()).unwrap_or_default() +} + /// Replace the followed-artist set with a freshly-fetched id list and mirror /// it to the per-user store (full replace — same lifecycle as the album set). /// Blocking disk write; call off the UI thread. From a181fb7aa9a005db93123f74a34772ee64676d1b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:11:58 -0600 Subject: [PATCH 04/10] feat(discover): Not interested menu action with live removal + backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArtistGridCard's context menu gains a 4th item (Not interested, thumbs-down icon; popup height bumped for the extra row) that emits ("artist", id, "not-interested") through the existing media-action chain. The new central arm persists the dismissal to the reco_dismiss store and, when the artist is in a reco rail, removes the card live and backfills the freed slot from the rail's retained overflow (already Qobuz-validated — no network; the pop re-checks live exclusions and cross-rail visibility). Dismissed from a non-reco surface, the display name is resolved via get_artist before persisting, so the management list and toast still read well. km --- .../qbz-ui/ui/discover/ArtistGridCard.slint | 11 +- crates/qbz/src/external_reco.rs | 109 ++++++++++++++++++ crates/qbz/src/main.rs | 64 ++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/crates/qbz-ui/ui/discover/ArtistGridCard.slint b/crates/qbz-ui/ui/discover/ArtistGridCard.slint index 130135152..02831f30c 100644 --- a/crates/qbz-ui/ui/discover/ArtistGridCard.slint +++ b/crates/qbz-ui/ui/discover/ArtistGridCard.slint @@ -203,7 +203,7 @@ export component ArtistGridCard inherits Rectangle { x: 21px; y: 143px; width: 196px; - height: 3 * 33px + 10px; + height: 4 * 33px + 10px; ContextMenu { ContextMenuItem { icon: @image-url("../assets/icons/user.svg"); @@ -228,6 +228,15 @@ export component ArtistGridCard inherits Rectangle { root.media-action(root.follow-kind, root.artist.id, "follow"); } } + // Reco-scoped dismissal (NOT the blacklist): the artist + // leaves the Recommendations rails only. + ContextMenuItem { + icon: @image-url("../assets/icons/thumbs-down.svg"); + label: @tr("Not interested"); + clicked => { + root.media-action("artist", root.artist.id, "not-interested"); + } + } } } } diff --git a/crates/qbz/src/external_reco.rs b/crates/qbz/src/external_reco.rs index 6e885e7a9..4072cd09d 100644 --- a/crates/qbz/src/external_reco.rs +++ b/crates/qbz/src/external_reco.rs @@ -577,6 +577,115 @@ fn apply_artist_rails( apply_artists(weak, cache, recent.visible, ArtistRow::RecArtistsRecent); } +/// Pop the first retained-overflow candidate for `which` rail that still +/// passes the LIVE exclusions (a follow/blacklist may have landed since the +/// paint) and is not already visible in either rail. Non-passing entries stay +/// pooled (they may become eligible again, e.g. after an un-follow). +fn pop_backfill( + which: ArtistRow, + visible: &HashSet, + excluded: &HashSet, +) -> Option { + let mut g = ARTIST_OVERFLOW.lock().ok()?; + let pool = match which { + ArtistRow::RecArtistsCommon => &mut g.0, + ArtistRow::RecArtistsRecent => &mut g.1, + ArtistRow::TopArtists => return None, + }; + let idx = pool.iter().position(|r| { + !excluded.contains(&r.qobuz_artist_id) + && !visible.contains(&r.qobuz_artist_id.to_string()) + })?; + Some(pool.remove(idx)) +} + +/// Live "Not interested": drop the artist card from the Recommended-Artist +/// rail that carries it and backfill the freed slot from that rail's retained +/// overflow — already Qobuz-validated, so no network. Runs on the Slint event +/// loop. Returns the (name, image_url) snapshot of the dismissed card (for the +/// dismissal store + toast), or None when the artist is not currently in a +/// reco rail (e.g. dismissed from a search/home card). +pub fn apply_artist_dismissal( + w: &AppWindow, + cache: &ImageCache, + artist_id: &str, +) -> Option<(String, String)> { + let s = w.global::(); + let common_model = s.get_rec_artists_common(); + let recent_model = s.get_rec_artists_recent(); + let common_rows: Vec = (0..common_model.row_count()) + .filter_map(|i| common_model.row_data(i)) + .collect(); + let recent_rows: Vec = (0..recent_model.row_count()) + .filter_map(|i| recent_model.row_data(i)) + .collect(); + let in_common = common_rows.iter().any(|it| it.id.as_str() == artist_id); + let in_recent = recent_rows.iter().any(|it| it.id.as_str() == artist_id); + if !in_common && !in_recent { + return None; + } + let snapshot = common_rows + .iter() + .chain(recent_rows.iter()) + .find(|it| it.id.as_str() == artist_id) + .map(|it| (it.title.to_string(), it.artwork_url.to_string())); + + let excluded = artist_exclusions(); + // Rebuild the affected models from the kept rows — their already-decoded + // `artwork` images ride along, so only the backfilled row needs a job. + let mut common_new: Vec = common_rows + .into_iter() + .filter(|it| it.id.as_str() != artist_id) + .collect(); + let mut recent_new: Vec = recent_rows + .into_iter() + .filter(|it| it.id.as_str() != artist_id) + .collect(); + // Visible ids across BOTH rails (post-removal): the backfill candidate + // must not duplicate the other rail. + let mut visible: HashSet = common_new + .iter() + .chain(recent_new.iter()) + .map(|it| it.id.to_string()) + .collect(); + + let mut jobs: Vec = Vec::new(); + if in_common { + if let Some(reco) = pop_backfill(ArtistRow::RecArtistsCommon, &visible, &excluded) { + visible.insert(reco.qobuz_artist_id.to_string()); + if !reco.image_url.is_empty() { + jobs.push(ArtworkJob { + url: reco.image_url.clone(), + target: ArtworkTarget::ExtRecoRecArtistCommon { + index: common_new.len(), + }, + }); + } + common_new.push(slim_from_artist(&reco)); + } + s.set_rec_artists_common(ModelRc::new(VecModel::from(common_new))); + } + if in_recent { + if let Some(reco) = pop_backfill(ArtistRow::RecArtistsRecent, &visible, &excluded) { + visible.insert(reco.qobuz_artist_id.to_string()); + if !reco.image_url.is_empty() { + jobs.push(ArtworkJob { + url: reco.image_url.clone(), + target: ArtworkTarget::ExtRecoRecArtistRecent { + index: recent_new.len(), + }, + }); + } + recent_new.push(slim_from_artist(&reco)); + } + s.set_rec_artists_recent(ModelRc::new(VecModel::from(recent_new))); + } + if !jobs.is_empty() { + crate::artwork::spawn_loads(jobs, w.as_weak(), cache.clone()); + } + snapshot +} + /// Build + paint the two Weekly rows from their own per-week cache (cheap on a /// hit; one ListenBrainz `createdfor` call + a SQLite read). Used on the /// instant-paint path so the weeklies follow ListenBrainz's weekly cadence diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 47674c213..30f1346fc 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -12557,6 +12557,70 @@ fn main() -> Result<(), Box> { }); } } + // "Not interested" (reco-scoped dismissal — NOT the app-wide + // blacklist): persist the dismissal, drop the card from the + // Recommendations rails live, and backfill the freed slot from + // the retained overflow. The artist stays visible everywhere + // else (search/home/label pages); future paints exclude it via + // the §B filter. + ("artist", "not-interested") => { + if let Some(w) = weak.upgrade() { + let snapshot = + crate::external_reco::apply_artist_dismissal(&w, &image_cache, &id); + match snapshot { + Some((name, image)) => { + if let Ok(aid) = id.parse::() { + crate::reco_dismiss::dismiss(aid, &name, &image); + } + crate::toast::info_weak( + &weak, + qbz_i18n::t_args( + "{} won't appear in Recommendations anymore", + &[&name], + ), + ); + } + None => { + // Dismissed from a non-reco surface (search / + // home / pinned card): nothing to remove live + // — resolve the display name, then persist. + let runtime = runtime.clone(); + let weak = weak.clone(); + let artist_id = id.clone(); + handle.spawn(async move { + let Ok(aid) = artist_id.parse::() else { + return; + }; + let (name, image) = runtime + .core() + .get_artist(aid) + .await + .map(|a| { + ( + a.name, + a.image + .and_then(|i| i.best().cloned()) + .unwrap_or_default(), + ) + }) + .unwrap_or_default(); + crate::reco_dismiss::dismiss(aid, &name, &image); + let msg = if name.is_empty() { + qbz_i18n::t("Artist dismissed from Recommendations") + } else { + qbz_i18n::t_args( + "{} won't appear in Recommendations anymore", + &[&name], + ) + }; + let _ = weak.upgrade_in_event_loop(move |w| { + crate::toast::info(&w, msg); + }); + }); + } + } + } + } // === Label landing actions =============================== ("label", "follow") => { // Toggle the label favorite, optimistically flipping the From 6adb33fcefcf02ca421f81208601917e89e00d23 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:18:59 -0600 Subject: [PATCH 05/10] feat(settings): Recommendations tab in the Blacklist Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage the reco-scoped Not-interested dismissals: a third tab in Settings > Blacklist lists dismissed artists (search-filtered like the other axes, count badge, empty + no-results states) with a per-row undo that removes the entry from the reco_dismiss store — the artist becomes eligible for the Recommendations rails again on their next paint. Row click reuses the existing artist-select navigation. No bulk Clear-All on this tab (undo is per-row); the modal stays scoped to the blacklist axes. km --- .../ui/settings/BlacklistManagerView.slint | 218 +++++++++++++++++- crates/qbz-ui/ui/state.slint | 20 +- crates/qbz/src/blacklist_manager.rs | 57 ++++- crates/qbz/src/main.rs | 11 + 4 files changed, 296 insertions(+), 10 deletions(-) diff --git a/crates/qbz-ui/ui/settings/BlacklistManagerView.slint b/crates/qbz-ui/ui/settings/BlacklistManagerView.slint index fd27d5c9a..dc4b53237 100644 --- a/crates/qbz-ui/ui/settings/BlacklistManagerView.slint +++ b/crates/qbz-ui/ui/settings/BlacklistManagerView.slint @@ -245,6 +245,96 @@ component AlbumRow inherits Rectangle { } } +// One reco-dismissed artist row (the Recommendations tab). Lighter than +// ArtistRow: the dismissal store carries no timestamp/notes, and the avatar +// is the generic user glyph (NOT blind-eye — this is not the blacklist). +component DismissedRow inherits Rectangle { + in property artist-name; + property hovered: ta.has-hover || rm-ta.has-hover; + callback select(); + callback remove(); + callback hover-changed(bool); + height: 64px; + border-radius: Radius.md; + background: root.hovered ? Theme.surface-elevated : Theme.surface-card; + + changed hovered => { + root.hover-changed(self.hovered); + } + + HorizontalLayout { + padding-left: 16px; + padding-right: 8px; + spacing: 12px; + + // Clickable artist-info region — opens the artist page. + Rectangle { + horizontal-stretch: 1; + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.select(); + } + } + HorizontalLayout { + spacing: 12px; + VerticalLayout { + alignment: center; + Rectangle { + width: 44px; + height: 44px; + border-radius: 22px; + background: Theme.surface-elevated; + QbzIcon { + source: @image-url("../assets/icons/user.svg"); + width: 20px; + height: 20px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: Theme.text-muted; + } + } + } + VerticalLayout { + alignment: center; + Text { + text: root.artist-name; + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + overflow: elide; + } + } + } + } + + // Per-row undo (optimistic, no confirm — mirrors the blacklist rows). + VerticalLayout { + alignment: center; + Rectangle { + width: 36px; + height: 36px; + border-radius: Radius.sm; + background: rm-ta.has-hover ? Theme.danger-bg : transparent; + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 18px; + height: 18px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: rm-ta.has-hover ? Theme.danger : Theme.text-muted; + } + rm-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.remove(); + } + } + } + } + } +} + export component BlacklistManagerView inherits Rectangle { background: ShellState.app-background-active ? transparent : Theme.surface-main; @@ -347,6 +437,28 @@ export component BlacklistManagerView inherits Rectangle { } } } + // Recommendations tab (reco-scoped "Not interested" dismissals). + Rectangle { + width: reco-lbl.preferred-width + 28px; + border-radius: Radius.sm; + background: BlacklistState.active-tab == 2 + ? Theme.surface-card + : (reco-ta.has-hover ? Theme.surface-hover : transparent); + reco-lbl := Text { + text: @tr("Recommendations"); + color: BlacklistState.active-tab == 2 ? Theme.text-primary : Theme.text-muted; + font-size: Typography.legal; + font-weight: Typography.medium; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + reco-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + BlacklistActions.set-tab(2); + } + } + } } } } @@ -356,7 +468,9 @@ export component BlacklistManagerView inherits Rectangle { vertical-stretch: 0; text: BlacklistState.active-tab == 0 ? @tr("Blacklisted artists are hidden from search results, radio, suggestions, and similar artists.") - : @tr("Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed."); + : BlacklistState.active-tab == 1 + ? @tr("Blocked albums are hidden from search, discovery, and listings — even when their artist is allowed.") + : @tr("Dismissed artists only leave your Recommendations — they still appear in search and everywhere else."); color: Theme.text-muted; font-size: Typography.body; wrap: word-wrap; @@ -473,7 +587,9 @@ export component BlacklistManagerView inherits Rectangle { } // Clear-All — only when the active tab has something to clear. - if (BlacklistState.active-tab == 0 ? BlacklistState.count : BlacklistState.album-count) > 0: Rectangle { + // The Recommendations tab has no bulk clear (undo is per-row). + if (BlacklistState.active-tab == 0 ? BlacklistState.count + : BlacklistState.active-tab == 1 ? BlacklistState.album-count : 0) > 0: Rectangle { width: clear-all-row.preferred-width + 28px; height: 34px; border-radius: Radius.sm; @@ -519,7 +635,9 @@ export component BlacklistManagerView inherits Rectangle { Text { text: BlacklistState.active-tab == 0 ? @tr("{} artists", BlacklistState.count) - : @tr("{} albums", BlacklistState.album-count); + : BlacklistState.active-tab == 1 + ? @tr("{} albums", BlacklistState.album-count) + : @tr("{} artists", BlacklistState.dismissed-count); color: Theme.text-muted; font-size: Typography.legal; horizontal-alignment: right; @@ -774,6 +892,100 @@ export component BlacklistManagerView inherits Rectangle { hover-active: root.list-hover-rows > 0; } } + + // --- Recommendations tab branches (reco-scoped dismissals) ----- + // 2c) Empty (nothing dismissed). + if BlacklistState.active-tab == 2 && !BlacklistState.loading && BlacklistState.dismissed-count == 0: VerticalLayout { + alignment: center; + spacing: 12px; + HorizontalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/thumbs-down.svg"); + width: 48px; + height: 48px; + tint: Theme.text-muted; + opacity: 0.3; + } + } + HorizontalLayout { + alignment: center; + Text { + text: @tr("No dismissed artists"); + color: Theme.text-muted; + font-size: Typography.heading; + } + } + HorizontalLayout { + alignment: center; + Text { + text: @tr("To stop seeing an artist in Recommendations, open its card menu and choose Not interested."); + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + } + } + } + + // 3c) No search results (dismissed). + if BlacklistState.active-tab == 2 && !BlacklistState.loading && BlacklistState.dismissed-count > 0 && BlacklistState.dismissed-items.length == 0: VerticalLayout { + alignment: center; + spacing: 12px; + HorizontalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/search.svg"); + width: 48px; + height: 48px; + tint: Theme.text-muted; + opacity: 0.3; + } + } + HorizontalLayout { + alignment: center; + Text { + text: @tr("No results for \"{}\"", BlacklistState.search-query); + color: Theme.text-muted; + font-size: Typography.heading; + } + } + } + + // 4c) The dismissed-artists list (per-row undo). + if BlacklistState.active-tab == 2 && !BlacklistState.loading && BlacklistState.dismissed-items.length > 0: Rectangle { + dismissed-flick := Flickable { + width: 100%; + height: 100%; + viewport-width: self.width; + viewport-height: dismissed-content.preferred-height; + dismissed-content := VerticalLayout { + width: parent.width - 18px; + spacing: 4px; + for item in BlacklistState.dismissed-items: DismissedRow { + artist-name: item.artist-name; + select => { + BlacklistActions.artist-select(item.artist-id); + } + remove => { + BlacklistActions.remove-dismissed(item.artist-id); + } + hover-changed(h) => { + root.list-hover-rows = h ? root.list-hover-rows + 1 : Math.max(0, root.list-hover-rows - 1); + } + } + } + } + ListScrollbar { + x: parent.width - self.width - 4px; + width: 14px; + height: 100%; + viewport-height: dismissed-flick.viewport-height; + visible-height: dismissed-flick.height; + viewport-y <=> dismissed-flick.viewport-y; + auto-hide: true; + hover-active: root.list-hover-rows > 0; + } + } } } diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 888bb7a96..a87e4d38a 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -2462,6 +2462,16 @@ export struct BlacklistedAlbumItem { has-notes: bool, } +// One reco-dismissed artist ("Not interested"). Reco-SCOPED — NOT the +// blacklist: the artist only leaves the Recommendations rails. The store +// carries no timestamp/notes, so the row is just the name (+ a generic +// avatar); removal is the undo. +export struct DismissedArtistItem { + // Numeric Qobuz artist id (same int pass-through as BlacklistedArtistItem). + artist-id: int, + artist-name: string, +} + export global BlacklistState { // The CURRENTLY-VISIBLE rows (already search-filtered by the controller). in property <[BlacklistedArtistItem]> items: []; @@ -2481,7 +2491,11 @@ export global BlacklistState { in property <[BlacklistedAlbumItem]> album-items: []; // FULL blocked-album count (drives the Albums tab badge + empty state). in property album-count: 0; - // Active manager tab: 0 = Artists, 1 = Albums. + // --- Reco-dismissal axis ("Not interested" — the Recommendations tab) --- + in property <[DismissedArtistItem]> dismissed-items: []; + // FULL dismissed count (drives the tab badge + empty state). + in property dismissed-count: 0; + // Active manager tab: 0 = Artists, 1 = Albums, 2 = Recommendations. in property active-tab: 0; } @@ -2498,12 +2512,14 @@ export global BlacklistActions { callback search-changed(string /* query */); // --- Album axis --- - callback set-tab(int /* 0=artists, 1=albums */); + callback set-tab(int /* 0=artists, 1=albums, 2=recommendations */); callback album-select(string /* album id */); callback remove-album(string /* album id */); callback clear-all-albums(); // Block an album from a context menu (id + display fields for the manager). callback block-album(string /* id */, string /* title */, string /* artist */, string /* cover-url */); + // --- Reco-dismissal axis --- + callback remove-dismissed(int /* artist id */); } // === My QBZ — Mixtapes & Collections (read-only index grids) ============ diff --git a/crates/qbz/src/blacklist_manager.rs b/crates/qbz/src/blacklist_manager.rs index 630e0ad1d..07a72fde7 100644 --- a/crates/qbz/src/blacklist_manager.rs +++ b/crates/qbz/src/blacklist_manager.rs @@ -3,7 +3,9 @@ //! `BlacklistState`, applies search-as-you-type filtering controller-side, and //! runs the toggle / remove / clear mutations against the //! `crate::artist_blacklist` wrapper (the same fail-open singleton the artist -//! toggle in T9 mutates). +//! toggle in T9 mutates). A third "Recommendations" tab (active-tab 2) lists +//! the reco-SCOPED "Not interested" dismissals from `crate::reco_dismiss` — +//! NOT the blacklist — with a per-row undo. //! //! Mirrors `crate::offline_manager`'s shape: a `load` entry point invoked on //! navigation + an action set wired in `main.rs`. There is no change-notify on @@ -29,7 +31,7 @@ use chrono::{DateTime, Utc}; use slint::{ComponentHandle, ModelRc, SharedString, VecModel}; use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache}; -use crate::{AppWindow, BlacklistState, BlacklistedAlbumItem, BlacklistedArtistItem}; +use crate::{AppWindow, BlacklistState, BlacklistedAlbumItem, BlacklistedArtistItem, DismissedArtistItem}; /// Shared image cache for resolving blocked-album cover thumbnails (the artist /// tab has no covers; the album tab does). Set once during startup wiring. @@ -137,16 +139,41 @@ fn build_album_items() -> (Vec, i32, Vec) { (items, count, jobs) } -/// Push the filtered items + full count + enabled flag + query into Slint (both -/// axes). Album covers resolve asynchronously via the shared image cache. +/// Build the visible (filtered) dismissed-artist items — the "Not interested" +/// reco-scoped list — from the store snapshot, applying the current query +/// (name match, same rule as the artist axis). `count` carries the FULL list +/// length for the tab badge + empty/no-results split. +fn build_dismissed_items() -> (Vec, i32) { + let all = crate::reco_dismiss::list(); + let count = all.len() as i32; + let needle = current_query().trim().to_lowercase(); + + let items: Vec = all + .into_iter() + .filter(|a| needle.is_empty() || a.name.to_lowercase().contains(&needle)) + .map(|a| DismissedArtistItem { + // Same int pass-through as the blacklist artist id. + artist_id: a.artist_id as i32, + artist_name: a.name.into(), + }) + .collect(); + + (items, count) +} + +/// Push the filtered items + full count + enabled flag + query into Slint (all +/// three axes). Album covers resolve asynchronously via the shared image cache. fn push(w: &AppWindow) { let (items, count) = build_items(); let (album_items, album_count, jobs) = build_album_items(); + let (dismissed_items, dismissed_count) = build_dismissed_items(); let st = w.global::(); st.set_items(ModelRc::new(VecModel::from(items))); st.set_count(count); st.set_album_items(ModelRc::new(VecModel::from(album_items))); st.set_album_count(album_count); + st.set_dismissed_items(ModelRc::new(VecModel::from(dismissed_items))); + st.set_dismissed_count(dismissed_count); st.set_enabled(crate::artist_blacklist::is_enabled()); st.set_search_query(SharedString::from(current_query())); // Kick off cover loads (best-effort; needs the cache + a weak handle). @@ -236,7 +263,7 @@ pub fn clear_all(w: &AppWindow) { // --- Album axis actions ------------------------------------------------- -/// Switch the manager's active tab (0 = Artists, 1 = Albums). +/// Switch the manager's active tab (0 = Artists, 1 = Albums, 2 = Recommendations). pub fn set_tab(w: &AppWindow, tab: i32) { w.global::().set_active_tab(tab); } @@ -303,3 +330,23 @@ pub fn clear_all_albums(w: &AppWindow) { } } } + + +// --- Reco-dismissal axis actions ----------------------------------------- + +/// Undo one "Not interested" dismissal (optimistic — the re-push drops the row +/// immediately) + toast. The artist becomes eligible for the Recommendations +/// rails again on their next paint (the §B filter reads the store). +pub fn remove_dismissed(w: &AppWindow, artist_id: i32) { + // Capture the name before removing, for the toast (falls back to the + // generic "Artist" for rows persisted without a resolved name). + let name = crate::reco_dismiss::list() + .into_iter() + .find(|a| a.artist_id == artist_id as u64) + .map(|a| a.name) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| qbz_i18n::t("Artist")); + crate::reco_dismiss::remove(artist_id as u64); + push(w); + crate::toast::success(w, qbz_i18n::t_args("{} restored to Recommendations", &[&name])); +} diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 30f1346fc..47322171b 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -16576,6 +16576,17 @@ fn main() -> Result<(), Box> { } }); } + // --- Reco-dismissal callbacks (the Recommendations tab) --- + { + let weak = window.as_weak(); + window + .global::() + .on_remove_dismissed(move |id| { + if let Some(w) = weak.upgrade() { + blacklist_manager::remove_dismissed(&w, id); + } + }); + } { let weak = window.as_weak(); let bl_runtime_b = app_runtime.clone(); From 2b0cf33d2e59fb4ae97361fe4c2c77098e2ae15d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:25:01 -0600 Subject: [PATCH 06/10] i18n(discover): Not interested + Recommendations-tab strings (8 locales) New msgids hand-added to all 8 qbz-ui catalogs: the tab description, the empty-state heading + hint, and the dismiss/undo toasts. "Not interested" and "Recommendations" reuse the pre-existing msgids (PlaylistView / Discover). en msgstr = msgid; es/de/fr/pt/ru/ja/nl translated. msgfmt --check clean on all 8. km --- .../translations/de/LC_MESSAGES/qbz-ui.po | 20 ++++++++++++++++ .../translations/en/LC_MESSAGES/qbz-ui.po | 23 +++++++++++++++++++ .../translations/es/LC_MESSAGES/qbz-ui.po | 20 ++++++++++++++++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 20 ++++++++++++++++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 20 ++++++++++++++++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 20 ++++++++++++++++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 20 ++++++++++++++++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 20 ++++++++++++++++ 8 files changed, 163 insertions(+) diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index 9595de793..75a405712 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -4778,3 +4778,23 @@ msgstr "Meistgespielte Alben" # Most Played Albums View-all — per-card play count msgid "{} plays" msgstr "{} Wiedergaben" + +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Abgelehnte Künstler verschwinden nur aus deinen Empfehlungen — sie erscheinen weiterhin in der Suche und überall sonst." + +msgid "No dismissed artists" +msgstr "Keine abgelehnten Künstler" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Um einen Künstler nicht mehr in Empfehlungen zu sehen, öffne das Menü seiner Karte und wähle Nicht interessiert." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} erscheint nicht mehr in Empfehlungen" + +msgid "Artist dismissed from Recommendations" +msgstr "Künstler aus Empfehlungen abgelehnt" + +msgid "{} restored to Recommendations" +msgstr "{} wieder in Empfehlungen aufgenommen" diff --git a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po index 741dae8c6..4cd7303f7 100644 --- a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po @@ -571,3 +571,26 @@ msgstr "Most Played Albums" # Most Played Albums View-all — per-card play count msgid "{} plays" msgstr "{} plays" + +# Discover Recommendations — Not interested (reco-scoped dismissals) + +msgid "Not interested" +msgstr "Not interested" + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." + +msgid "No dismissed artists" +msgstr "No dismissed artists" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} won't appear in Recommendations anymore" + +msgid "Artist dismissed from Recommendations" +msgstr "Artist dismissed from Recommendations" + +msgid "{} restored to Recommendations" +msgstr "{} restored to Recommendations" diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index 5cecb4fc3..b0a7f3178 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -4808,3 +4808,23 @@ msgstr "Álbumes más escuchados" # Most Played Albums View-all — per-card play count msgid "{} plays" msgstr "{} reproducciones" + +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Los artistas descartados solo desaparecen de tus Recomendaciones — siguen apareciendo en la búsqueda y en el resto de la app." + +msgid "No dismissed artists" +msgstr "No hay artistas descartados" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Para dejar de ver un artista en Recomendaciones, abre el menú de su tarjeta y elige No me interesa." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} ya no aparecerá en Recomendaciones" + +msgid "Artist dismissed from Recommendations" +msgstr "Artista descartado de Recomendaciones" + +msgid "{} restored to Recommendations" +msgstr "{} restaurado en Recomendaciones" diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index c888efb39..013d0669b 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4778,3 +4778,23 @@ msgstr "Albums les plus écoutés" # Most Played Albums View-all — per-card play count msgid "{} plays" msgstr "{} lectures" + +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Les artistes écartés disparaissent uniquement de vos Recommandations — ils restent visibles dans la recherche et partout ailleurs." + +msgid "No dismissed artists" +msgstr "Aucun artiste écarté" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Pour ne plus voir un artiste dans Recommandations, ouvrez le menu de sa carte et choisissez Pas intéressé." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} n'apparaîtra plus dans Recommandations" + +msgid "Artist dismissed from Recommendations" +msgstr "Artiste écarté des Recommandations" + +msgid "{} restored to Recommendations" +msgstr "{} rétabli dans Recommandations" diff --git a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po index 88323159f..0e2c2d223 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -4766,3 +4766,23 @@ msgstr "最も再生したアルバム" # Most Played Albums View-all — per-card play count msgid "{} plays" msgstr "{} 回再生" + +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "除外したアーティストは「おすすめ」からのみ消えます — 検索やその他の場所には引き続き表示されます。" + +msgid "No dismissed artists" +msgstr "除外したアーティストはいません" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "おすすめにアーティストを表示しないようにするには、そのカードのメニューを開いて「興味なし」を選んでください。" + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} はおすすめに表示されなくなります" + +msgid "Artist dismissed from Recommendations" +msgstr "アーティストをおすすめから除外しました" + +msgid "{} restored to Recommendations" +msgstr "{} をおすすめに復元しました" diff --git a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po index 06ea48401..f64ae3e6f 100644 --- a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po @@ -4790,3 +4790,23 @@ msgstr "Meest afgespeelde albums" # Most Played Albums View-all — per-card play count msgid "{} plays" msgstr "{} keer afgespeeld" + +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Afgewezen artiesten verdwijnen alleen uit je Aanbevelingen — ze blijven zichtbaar in zoekresultaten en overal elders." + +msgid "No dismissed artists" +msgstr "Geen afgewezen artiesten" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Om een artiest niet meer in Aanbevelingen te zien, open je het kaartmenu en kies je Niet geïnteresseerd." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} verschijnt niet meer in Aanbevelingen" + +msgid "Artist dismissed from Recommendations" +msgstr "Artiest afgewezen voor Aanbevelingen" + +msgid "{} restored to Recommendations" +msgstr "{} hersteld in Aanbevelingen" diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index 3b8d5d9f7..f7934d236 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4778,3 +4778,23 @@ msgstr "Álbuns mais tocados" # Most Played Albums View-all — per-card play count msgid "{} plays" msgstr "{} reproduções" + +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Artistas dispensados só saem das suas Recomendações — continuam aparecendo na busca e em todo o resto." + +msgid "No dismissed artists" +msgstr "Nenhum artista dispensado" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Para deixar de ver um artista em Recomendações, abra o menu do seu cartão e escolha Não tenho interesse." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} não aparecerá mais em Recomendações" + +msgid "Artist dismissed from Recommendations" +msgstr "Artista dispensado das Recomendações" + +msgid "{} restored to Recommendations" +msgstr "{} restaurado em Recomendações" diff --git a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po index 3fc9a4cb5..821002034 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -4792,3 +4792,23 @@ msgstr "Самые прослушиваемые альбомы" # Most Played Albums View-all — per-card play count msgid "{} plays" msgstr "{} воспроизведений" + +# Discover Recommendations — reco-scoped dismissals (Not interested management) + +msgid "Dismissed artists only leave your Recommendations — they still appear in search and everywhere else." +msgstr "Отклонённые исполнители исчезают только из Рекомендаций — они по-прежнему видны в поиске и повсюду остальном." + +msgid "No dismissed artists" +msgstr "Нет отклонённых исполнителей" + +msgid "To stop seeing an artist in Recommendations, open its card menu and choose Not interested." +msgstr "Чтобы больше не видеть исполнителя в Рекомендациях, откройте меню его карточки и выберите Не интересно." + +msgid "{} won't appear in Recommendations anymore" +msgstr "{} больше не появится в Рекомендациях" + +msgid "Artist dismissed from Recommendations" +msgstr "Исполнитель отклонён из Рекомендаций" + +msgid "{} restored to Recommendations" +msgstr "{} возвращён в Рекомендации" From c8e37f937dc429e21aea285b855aafab7aabcea5 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:31:00 -0600 Subject: [PATCH 07/10] refactor(discover): borrow-free upsert in the reco-dismiss store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit position() + index instead of an iter_mut().find() borrow held across a match arm — no NLL subtlety, and the lifecycle test passes against the verbatim file (scratch-crate run, since the qbz crate itself is not built here). km --- crates/qbz/src/reco_dismiss.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/qbz/src/reco_dismiss.rs b/crates/qbz/src/reco_dismiss.rs index 0585e1305..0c5b0e5e3 100644 --- a/crates/qbz/src/reco_dismiss.rs +++ b/crates/qbz/src/reco_dismiss.rs @@ -126,8 +126,9 @@ pub fn dismiss(artist_id: u64, name: &str, image_url: &str) { return; } let mut store = load_store(); - match store.artists.iter_mut().find(|a| a.artist_id == artist_id) { - Some(existing) => { + match store.artists.iter().position(|a| a.artist_id == artist_id) { + Some(idx) => { + let existing = &mut store.artists[idx]; if existing.name.is_empty() && !name.is_empty() { existing.name = name.to_string(); } From fd8f8f40646d6a15381b2aad57fe5ff282b05660 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:55:47 -0600 Subject: [PATCH 08/10] fix(deep-link): navigate Qobuz links passed via argv on cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xdg-open https://open.qobuz.com/album/ raised/focused QBZ but never navigated: main() never read argv, so the desktop files' Exec=qbz %u URL was silently dropped (Tauri parity gap — the old app scanned argv on cold start and in its single-instance callback). New deep_link module: capture_argv() at the top of main() stashes the first Qobuz-link argv entry (legacy is_qobuz_link prefixes) BEFORE the single-instance guard runs — a second launch must read its own argv before deciding to raise+exit. enter_shell binds a shell context (runtime, window weak, tokio handle, image cache; cleared on logout) and drains the pending URL at its very end, after the startup-page/view restore, so the restore can't re-root over the deep link. Session is active and the AppWindow exists there, so no readiness sleep hack. Dispatch is the existing Ctrl+L machinery (link_resolver::resolve -> apply_resolved_link); failures surface as toasts. At the login screen or in offline mode the URL stays pending for the next successful online enter_shell. km --- crates/qbz/src/deep_link.rs | 235 ++++++++++++++++++++++++++++++++++++ crates/qbz/src/main.rs | 29 +++++ 2 files changed, 264 insertions(+) create mode 100644 crates/qbz/src/deep_link.rs diff --git a/crates/qbz/src/deep_link.rs b/crates/qbz/src/deep_link.rs new file mode 100644 index 000000000..468775b29 --- /dev/null +++ b/crates/qbz/src/deep_link.rs @@ -0,0 +1,235 @@ +//! XDG / launcher deep links — `xdg-open https://open.qobuz.com/album/` +//! with the desktop files' `Exec=qbz %u` (Tauri parity: the old app scanned +//! argv in the single-instance plugin callback and on cold start; the Slint +//! rebuild kept the raise-half but dropped the URL-half, so a deep link +//! raised/focused the window without ever navigating). +//! +//! Two entry paths, one pending slot: +//! +//! - **Cold start:** `capture_argv()` at the top of `main()` stashes the +//! first Qobuz-link argv entry BEFORE the single-instance guard runs (a +//! second launch must read its own argv before deciding to raise+exit). +//! Drained at the END of `enter_shell` — after the startup-page/view +//! restore, so the restore can't re-root over the deep link. At that point +//! the session is active and the AppWindow exists, so NO sleep hack (the +//! Tauri 1500 ms delay is not ported). Sitting at the login screen (no +//! session) the URL simply stays pending until the next successful +//! `enter_shell`. Offline entry (`enter_shell_offline`) never binds the +//! shell context, so the URL rides until an online shell — navigation +//! needs the API (same limitation as the Tauri era). +//! - **Warm start:** the second launch forwards the URL over the +//! single-instance D-Bus interface (`OpenUrl`, see `single_instance.rs`), +//! which presents the running instance and drains through the same path. +//! +//! The dispatch itself is the EXISTING Ctrl+L machinery: +//! `link_resolver::resolve` → `apply_resolved_link` → `navigate_*`. + +use std::sync::{Arc, Mutex}; + +use qbz_app::shell::AppRuntime; + +use crate::adapter::SlintAdapter; +use crate::artwork; +use crate::AppWindow; + +/// The first Qobuz link seen, waiting for a shell to navigate it. A warm +/// `OpenUrl` overwrites: the newest user intent wins. +static PENDING: Mutex> = Mutex::new(None); + +/// Everything `dispatch` needs, bound once per shell entry (`enter_shell`) +/// and cleared on logout so "context set" means "a session is active and +/// navigation can succeed". The warm D-Bus path has no other way to reach +/// these — they only exist as locals in `main()`. +#[derive(Clone)] +struct ShellCtx { + runtime: Arc>, + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: artwork::ImageCache, +} + +static SHELL_CTX: Mutex> = Mutex::new(None); + +/// Whether a string looks like a Qobuz link (custom scheme or web URL) — +/// 1:1 the legacy Tauri `is_qobuz_link` prefixes +/// (`qbz-worktrees/legacy-tauri` `src-tauri/src/lib.rs:594`). +pub fn is_qobuz_link(arg: &str) -> bool { + arg.starts_with("qobuzapp://") + || arg.starts_with("https://play.qobuz.com/") + || arg.starts_with("http://play.qobuz.com/") + || arg.starts_with("https://open.qobuz.com/") + || arg.starts_with("http://open.qobuz.com/") +} + +/// The first Qobuz link in an argument list, if any (pure — unit-tested). +fn select_link(args: &[String]) -> Option { + args.iter().find(|a| is_qobuz_link(a)).cloned() +} + +/// Scan the process argv for a Qobuz link and stash it pending. Call at the +/// top of `main()`, BEFORE `single_instance::acquire_or_raise`: when another +/// instance owns the bus name the guard forwards the stashed URL to it, and +/// when we are the primary it rides until the `enter_shell` drain. +pub fn capture_argv() { + let args: Vec = std::env::args().skip(1).collect(); + if let Some(url) = select_link(&args) { + log::info!( + "[qbz-slint] deep link: captured from argv: {}", + url.split('?').next().unwrap_or(&url) + ); + stash(url); + } +} + +/// Stash a URL pending (cold argv capture and the warm D-Bus `OpenUrl`). +pub fn stash(url: String) { + if let Ok(mut guard) = PENDING.lock() { + *guard = Some(url); + } +} + +/// Take the pending URL, leaving the slot empty. +pub fn take_pending() -> Option { + PENDING.lock().ok().and_then(|mut guard| guard.take()) +} + +/// Bind the shell context at `enter_shell` — the gate that lets +/// `drain_pending` dispatch (session active, AppWindow alive). +pub fn bind_shell_ctx( + runtime: Arc>, + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: artwork::ImageCache, +) { + if let Ok(mut guard) = SHELL_CTX.lock() { + *guard = Some(ShellCtx { + runtime, + weak, + handle, + image_cache, + }); + } +} + +/// Clear the shell context on logout: back at the login screen a pending +/// URL must wait for the next `enter_shell`, not fire into a dead session. +pub fn clear_shell_ctx() { + if let Ok(mut guard) = SHELL_CTX.lock() { + *guard = None; + } +} + +/// Dispatch the pending URL through the existing Ctrl+L resolve flow, but +/// only when a shell is up. No-op otherwise — the URL stays pending for the +/// next successful `enter_shell`. Safe to call from any thread (the zbus +/// executor included): the resolve spawns on the stored tokio handle and +/// the navigation hops to the Slint event loop. +pub fn drain_pending() { + let ctx = SHELL_CTX.lock().ok().and_then(|guard| guard.clone()); + let Some(ctx) = ctx else { return }; + let Some(url) = take_pending() else { return }; + dispatch(url, ctx); +} + +/// Mirror of the Ctrl+L `LinkResolverActions::on_submit` flow in `main.rs`, +/// minus the modal state: resolve off the UI thread, then apply the +/// navigation on the event loop. Failures surface as a toast (there is no +/// modal to hold an error here). +fn dispatch(url: String, ctx: ShellCtx) { + let ShellCtx { + runtime, + weak, + handle, + image_cache, + } = ctx; + log::info!( + "[qbz-slint] deep link: resolving {}", + url.split('?').next().unwrap_or(&url) + ); + handle.clone().spawn(async move { + let result = crate::link_resolver::resolve(runtime.clone(), url).await; + let _ = weak.upgrade_in_event_loop(move |w| match result { + Ok(qbz_music_link::MusicLinkResult::Resolved { link, .. }) => { + crate::apply_resolved_link(link, &runtime, &w.as_weak(), &handle, &image_cache); + } + Ok(qbz_music_link::MusicLinkResult::PlaylistDetected { provider }) => { + // Unreachable for the native Qobuz shapes the argv/D-Bus + // matcher accepts (cross-platform provider playlists only). + log::info!("[qbz-slint] deep link: {provider} playlist — nothing to navigate to"); + } + Ok(qbz_music_link::MusicLinkResult::NotOnQobuz { .. }) => { + crate::toast::error( + &w, + qbz_i18n::t("This content is not available on Qobuz"), + ); + } + Err(e) => { + log::warn!("[qbz-slint] deep link: resolve failed: {e}"); + crate::toast::error(&w, qbz_i18n::t("Could not resolve that link")); + } + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_open_qobuz_album_https_and_http() { + assert!(is_qobuz_link("https://open.qobuz.com/album/kq3s910v1qufc")); + assert!(is_qobuz_link("http://open.qobuz.com/album/kq3s910v1qufc")); + } + + #[test] + fn matches_play_qobuz_https_and_http() { + assert!(is_qobuz_link("https://play.qobuz.com/album/kq3s910v1qufc")); + assert!(is_qobuz_link("http://play.qobuz.com/track/123456")); + } + + #[test] + fn matches_qobuzapp_scheme() { + assert!(is_qobuz_link("qobuzapp://album/kq3s910v1qufc")); + assert!(is_qobuz_link("qobuzapp://artist/123")); + } + + #[test] + fn ignores_non_link_args() { + assert!(!is_qobuz_link("--verbose")); + assert!(!is_qobuz_link("/home/user/music.flac")); + assert!(!is_qobuz_link("https://example.com/album/kq3s910v1qufc")); + assert!(!is_qobuz_link("https://open.qobuz.com.evil.test/album/x")); + // The dead `qbz://` scheme resolves nowhere and stays unmatched. + assert!(!is_qobuz_link("qbz://album/kq3s910v1qufc")); + assert!(!is_qobuz_link("")); + } + + #[test] + fn select_link_takes_first_match() { + let args = vec![ + "--flag".to_string(), + "https://open.qobuz.com/album/first".to_string(), + "qobuzapp://album/second".to_string(), + ]; + assert_eq!( + select_link(&args), + Some("https://open.qobuz.com/album/first".to_string()) + ); + } + + #[test] + fn select_link_returns_none_without_match() { + let args = vec!["--flag".to_string(), "cover.jpg".to_string()]; + assert_eq!(select_link(&args), None); + } + + /// Single stateful test: PENDING is process-global and cargo runs tests + /// on threads, so the round-trip + overwrite checks stay sequential here. + #[test] + fn pending_drains_once_and_newest_wins() { + stash("qobuzapp://album/old".to_string()); + stash("qobuzapp://album/new".to_string()); + assert_eq!(take_pending(), Some("qobuzapp://album/new".to_string())); + assert_eq!(take_pending(), None); + } +} diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 47322171b..c05088347 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -40,6 +40,7 @@ mod glibc_compat; mod log_viewer; mod sleep_timer; pub use qbz_text_utils::{dates, strip_html}; +mod deep_link; mod discover_browse; mod discord_rpc; mod discover_prefs; @@ -352,6 +353,17 @@ async fn enter_shell( ) { let tray = init_shell_for_user(&runtime, &weak, session.user_id); + // Deep links (argv capture / warm D-Bus OpenUrl) may now drain: the + // session is active and the AppWindow exists. Bound here at the top; the + // pending URL itself is dispatched at the very END of this function so + // the startup-page/view restore below can't re-root over the deep link. + deep_link::bind_shell_ctx( + runtime.clone(), + weak.clone(), + tokio::runtime::Handle::current(), + image_cache.clone(), + ); + let _ = weak.upgrade_in_event_loop(move |w| { let state = w.global::(); state.set_user_name(session.display_name.into()); @@ -660,6 +672,14 @@ async fn enter_shell( let _ = weak.upgrade_in_event_loop(move |w| { favorites::apply_counts(&w, counts); }); + + // XDG deep link: drain a pending Qobuz URL LAST — after the startup-page + // / view-restore block above, so the restore can't re-root over the deep + // link (the navigation lands on top of whatever was restored). Session + // active, AppWindow alive: no readiness sleep needed. Nothing pending => + // no-op. (Offline entries never reach here — enter_shell_offline keeps + // the URL pending; navigation needs the API.) + deep_link::drain_pending(); } /// Offline session entry — "Start offline" on the login screen (spec §4.1). @@ -8073,6 +8093,12 @@ fn main() -> Result<(), Box> { std::env::var("SLINT_SCALE_FACTOR").unwrap_or_default() ); } + + // XDG deep link (Exec=qbz %u): capture a Qobuz link from argv BEFORE the + // single-instance guard below — a second launch must read its own argv + // before deciding to raise the primary and exit (the guard forwards the + // stashed URL over D-Bus; as primary it drains at the end of enter_shell). + deep_link::capture_argv(); // Artwork decode targets grow with the preset so covers stay sharp at // Large/XL (and shrink RAM at Small). Set before any artwork job runs. crate::artwork::set_ui_scale_factor(ui_scale_factor); @@ -10566,6 +10592,9 @@ fn main() -> Result<(), Box> { window.on_logout(move || { // Drop any Discord "now listening" activity on logout. discord_rpc::clear(&handle); + // Back at the login screen a pending deep link must wait for the + // next enter_shell, not fire into the torn-down session. + deep_link::clear_shell_ctx(); // Clear the per-user purchases cache + download-status store so the // next account never sees the previous user's purchased items or // in-flight download statuses (cross-account data leak). From 537cabbb995414fc5056dd6df6d6367f6223ba2a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:56:00 -0600 Subject: [PATCH 09/10] fix(deep-link): forward Qobuz links to the running instance over D-Bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second launch carrying a deep link exited after a bare Present(), taking the URL with it — focus worked, navigation didn't. Add OpenUrl(url) to the SingleInstance D-Bus interface: the primary stashes the URL, presents itself, and dispatches through the same pending/drain path as a cold-start link (stays pending while no shell is up). acquire_or_raise calls OpenUrl when argv carried a link and falls back to bare Present() when the method is missing on an older primary (version skew), then to MPRIS Raise as before; no-link launches keep plain Present(). km --- crates/qbz/src/single_instance.rs | 66 ++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/crates/qbz/src/single_instance.rs b/crates/qbz/src/single_instance.rs index b06ace406..22a8f47ce 100644 --- a/crates/qbz/src/single_instance.rs +++ b/crates/qbz/src/single_instance.rs @@ -6,14 +6,18 @@ //! The first instance takes ownership of the well-known session-bus name //! `com.blitzfc.qbz` (Flatpak auto-grants owning the app-id name — no //! finish-args change needed) and exports a `com.blitzfc.qbz.SingleInstance` -//! interface with a `Present()` method. A second launch sees the name taken, -//! calls `Present()` — which raises whichever window is current (the mini -//! when the miniplayer is open, else the main window) and works from process -//! start, login screen included — and exits. If the primary predates the -//! interface (≤2.0.x) the call errors and the second launch falls back to -//! the MPRIS `Raise` method, which only exists after session entry. Any -//! D-Bus problem — no session bus, weird sandbox — falls through as "we are -//! primary": the guard must never block startup. +//! interface with `Present()` and `OpenUrl(url)` methods. A second launch +//! sees the name taken and calls `OpenUrl(url)` when its own argv carried a +//! Qobuz deep link (the primary presents itself AND navigates — Tauri +//! parity, the piece #618 didn't port) or `Present()` otherwise — which +//! raises whichever window is current (the mini when the miniplayer is +//! open, else the main window) and works from process start, login screen +//! included — and exits. If the primary predates the interface (≤2.0.x) the +//! call errors and the second launch falls back to the MPRIS `Raise` +//! method, which only exists after session entry. `OpenUrl` failing on an +//! older primary (interface present, method missing) falls back to bare +//! `Present()`. Any D-Bus problem — no session bus, weird sandbox — falls +//! through as "we are primary": the guard must never block startup. //! //! Blocking zbus API on purpose: this runs once on the main thread before //! the UI exists, and the async-io executor self-drives the connection @@ -54,16 +58,35 @@ static PENDING_PRESENT: AtomicBool = AtomicBool::new(false); /// primary sits at the login window must still raise it). struct SingleInstanceIface; +/// Shared Present path for the iface methods: raise whichever window is +/// current, or remember the request until `bind_window` (simultaneous cold +/// starts: the DoNotQueue loser can call in while the winner is still +/// initializing). +fn present_or_defer() { + match MAIN_WEAK.get() { + Some(weak) => crate::tray::present(weak), + None => PENDING_PRESENT.store(true, Ordering::SeqCst), + } +} + #[zbus::interface(name = "com.blitzfc.qbz.SingleInstance")] impl SingleInstanceIface { /// Raise whichever window is current (mini when the miniplayer is open, /// else the main window). Runs on a zbus executor thread — never touch /// Slint state here; `tray::present` routes through the event loop. fn present(&self) { - match MAIN_WEAK.get() { - Some(weak) => crate::tray::present(weak), - None => PENDING_PRESENT.store(true, Ordering::SeqCst), - } + present_or_defer(); + } + + /// A second launch carrying a Qobuz deep link forwards it here: stash + /// the URL, present ourselves, and dispatch it through the running + /// instance's Ctrl+L link-resolver flow (`deep_link::drain_pending`). + /// With no shell up (sitting at the login screen, or offline) the URL + /// stays pending for the next successful `enter_shell`. + fn open_url(&self, url: &str) { + crate::deep_link::stash(url.to_string()); + present_or_defer(); + crate::deep_link::drain_pending(); } } @@ -110,9 +133,22 @@ fn probe() -> zbus::Result { // runs. Ask it to present itself; both calls are best-effort — the // duplicate still must not start. RequestNameReply::Exists | RequestNameReply::InQueue => { - let presented = conn - .call_method(Some(BUS_NAME), OBJECT_PATH, Some(IFACE_NAME), "Present", &()) - .is_ok(); + // Carrying a deep link from argv (captured at process start)? + // Forward it: OpenUrl makes the primary present itself AND + // navigate. An older primary without the method errors → the + // bare-Present ladder below (version-skew tolerance); the URL + // is then lost with this exiting process, same as a failed emit + // in the Tauri era. + let forwarded = match crate::deep_link::take_pending() { + Some(url) => conn + .call_method(Some(BUS_NAME), OBJECT_PATH, Some(IFACE_NAME), "OpenUrl", &url) + .is_ok(), + None => false, + }; + let presented = forwarded + || conn + .call_method(Some(BUS_NAME), OBJECT_PATH, Some(IFACE_NAME), "Present", &()) + .is_ok(); if !presented { // Older primary (≤2.0.x) without the SingleInstance interface: // fall back to MPRIS Raise. Full MPRIS name = From 299a7c3237f1e22a8d5bb830693f1f4576551a5a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:56:14 -0600 Subject: [PATCH 10/10] chore(packaging): drop dead x-scheme-handler/qbz registration qbz:// resolves nowhere in the link resolver, so the registration advertised a scheme the app never handles. Keep x-scheme-handler/qobuzapp (handled by the resolver) in all 7 desktop files. km --- crates/qbz/packaging/qbz.desktop | 2 +- flatpak/com.blitzfc.qbz.desktop | 2 +- packaging/arch/com.blitzfc.qbz.desktop | 2 +- packaging/aur/qbz.desktop | 2 +- packaging/flatpak/com.blitzfc.qbz.desktop | 2 +- packaging/linux/qbz.desktop | 2 +- packaging/snap/snap/gui/qbz.desktop | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/qbz/packaging/qbz.desktop b/crates/qbz/packaging/qbz.desktop index 7e2dfbaeb..ee8b81049 100644 --- a/crates/qbz/packaging/qbz.desktop +++ b/crates/qbz/packaging/qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/flatpak/com.blitzfc.qbz.desktop b/flatpak/com.blitzfc.qbz.desktop index c0e8f61c4..93bf12922 100644 --- a/flatpak/com.blitzfc.qbz.desktop +++ b/flatpak/com.blitzfc.qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/arch/com.blitzfc.qbz.desktop b/packaging/arch/com.blitzfc.qbz.desktop index c0e8f61c4..93bf12922 100644 --- a/packaging/arch/com.blitzfc.qbz.desktop +++ b/packaging/arch/com.blitzfc.qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/aur/qbz.desktop b/packaging/aur/qbz.desktop index 87aefe75e..80b93c913 100644 --- a/packaging/aur/qbz.desktop +++ b/packaging/aur/qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/flatpak/com.blitzfc.qbz.desktop b/packaging/flatpak/com.blitzfc.qbz.desktop index c0e8f61c4..93bf12922 100644 --- a/packaging/flatpak/com.blitzfc.qbz.desktop +++ b/packaging/flatpak/com.blitzfc.qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/linux/qbz.desktop b/packaging/linux/qbz.desktop index 45b8b30fb..f8ca2203a 100644 --- a/packaging/linux/qbz.desktop +++ b/packaging/linux/qbz.desktop @@ -9,4 +9,4 @@ Categories=AudioVideo;Audio;Music;Player; Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; StartupWMClass=com.blitzfc.qbz StartupNotify=true -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp; diff --git a/packaging/snap/snap/gui/qbz.desktop b/packaging/snap/snap/gui/qbz.desktop index 577fad69f..04c40d4c3 100644 --- a/packaging/snap/snap/gui/qbz.desktop +++ b/packaging/snap/snap/gui/qbz.desktop @@ -9,4 +9,4 @@ Terminal=false StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; -MimeType=x-scheme-handler/qbz;x-scheme-handler/qobuzapp; +MimeType=x-scheme-handler/qobuzapp;