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