feat(slint): you-may-also-like discovery section with sticky dismiss

Closes the network sidebar with the fifth section — tag-based "You may
also like" discovery, with a per-tag dismiss store so a thumbs-down
sticks across artist pages that share the same primary genre tag.

Backend (`qbz-integrations` + `qbz-core`):
  - DiscoveryArtist and DiscoveryResponse DTOs added to
    qbz-integrations::musicbrainz::models.
  - QbzCore::musicbrainz_discover_artists ports
    v2_get_discovery_artists end to end (sans the RecoState local
    "play count > 2" filter, which has no equivalent in the Slint
    shell yet): primary-tag search, exclude seed + similar +
    dismissed, deterministic shuffle keyed on seed_mbid, validate
    each candidate on Qobuz, fall back to the secondary tag if we
    have fewer than 5 matches.
  - normalize_artist_name re-exported at the qbz-core crate root so
    the dismiss handler in qbz-slint can use the same key shape that
    the discovery pipeline filters on.
  - Deterministic shuffle implemented with a simple xorshift PRNG
    seeded from a stable hash of seed_mbid — keeps qbz-core free of
    the rand dep.

Slint controller (`crates/qbz-slint/src/`):
  - discovery_dismiss.rs — small JSON store at
    `<data-dir>/qbz/discovery_dismiss.json` keyed by lowercased tag
    -> list of normalized artist names. dismissed_for_tag is passed
    into the discovery call as the filter source; dismiss() is
    invoked by the thumbs-down handler.
  - artist::load_mb_discovery / apply_mb_discovery /
    remove_discovery_artist plumb the pipeline result through the
    standard load-off-thread / apply-on-UI split. Discovery state
    includes primary_tag so the dismiss handler can persist under
    the right key.
  - main.rs chains discovery after relationships in the same MB
    enrichment task. NetworkSidebarActions.discovery-dismissed (now
    carrying mbid + name) normalizes the name, persists the
    dismiss, and removes the row from the visible list.

Slint UI (`ui/state.slint`, `ui/artist/ArtistPageView.slint`):
  - discovery-tag property added to NetworkSidebarState.
  - YOU MAY ALSO LIKE section after Relationships — one row per
    candidate (SidebarLink + a 24x24 thumbs-down button on the
    right). Clicking the link navigates via the artist-clicked
    callback when a Qobuz id is present.
  - thumbs-down.svg icon added to ui/assets/icons (Lucide
    ThumbsDown).

cc
This commit is contained in:
vicrodh committed 2026-05-20 10:39:53 -06:00
1 parent 72083adb7f
commit 165674d8a8
9 files changed
+529 -16

No files matched your search

+91 -3
View File
@@ -12,14 +12,15 @@ use qbz_core::FrontendAdapter;
use qbz_models::{
PageArtistRelease, PageArtistResponse, PageArtistTrack, PageArtistTrackAlbum,
};
use slint::{ComponentHandle, ModelRc, VecModel};
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::album::TrackData;
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::home::CardData;
use crate::{
AlbumCardItem, AlbumTrackItem, AppWindow, ArtistState, DiscoverSection, LabelEntry,
MbOriginData, MbRelationship, MbRelationshipsData, NetworkSidebarState, SimilarEntry,
AlbumCardItem, AlbumTrackItem, AppWindow, ArtistState, DiscoverSection, DiscoveryArtist,
LabelEntry, MbOriginData, MbRelationship, MbRelationshipsData, NetworkSidebarState,
SimilarEntry,
};
/// Plain, `Send` artist data produced on the worker thread.
@@ -814,6 +815,93 @@ fn format_period(begin: Option<&str>, end: Option<&str>) -> String {
}
}
// ----- MB discovery -----------------------------------------------------
/// Plain, `Send` payload for the Discovery section. `primary_tag` is
/// kept alongside so the dismiss callback can look up the right key in
/// the dismiss store.
pub struct MbDiscoveryData {
pub primary_tag: String,
pub artists: Vec<MbDiscoveryRow>,
}
#[derive(Clone)]
pub struct MbDiscoveryRow {
pub mbid: String,
pub name: String,
pub qobuz_id: String,
}
/// Load discovery candidates for `seed_mbid` (the artist's MB id) using
/// `similar_names` to suppress already-shown rows and the local
/// discovery_dismiss store to suppress thumbs-downed rows.
pub async fn load_mb_discovery<A>(
runtime: &Arc<AppRuntime<A>>,
seed_mbid: &str,
seed_name: &str,
similar_names: Vec<String>,
) -> Result<MbDiscoveryData, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let response = runtime
.core()
.musicbrainz_discover_artists(
seed_mbid,
seed_name,
&similar_names,
&|tag| crate::discovery_dismiss::dismissed_for_tag(tag),
)
.await
.map_err(|e| e.to_string())?;
let artists = response
.artists
.into_iter()
.map(|a| MbDiscoveryRow {
mbid: a.mbid,
name: a.name,
qobuz_id: a.qobuz_id.map(|id| id.to_string()).unwrap_or_default(),
})
.collect();
Ok(MbDiscoveryData {
primary_tag: response.primary_tag,
artists,
})
}
/// Apply discovery candidates to NetworkSidebarState. Runs on the
/// Slint event loop. `primary_tag` is stored on the sidebar state for
/// the dismiss callback to read.
pub fn apply_mb_discovery(window: &AppWindow, data: MbDiscoveryData) {
let state = window.global::<NetworkSidebarState>();
state.set_discovery_tag(data.primary_tag.into());
let rows: Vec<DiscoveryArtist> = data
.artists
.into_iter()
.map(|r| DiscoveryArtist {
mbid: r.mbid.into(),
name: r.name.into(),
qobuz_id: r.qobuz_id.into(),
})
.collect();
state.set_discovery_artists(ModelRc::new(VecModel::from(rows)));
state.set_discovery_loading(false);
}
/// Remove a dismissed row from the visible Discovery list. The dismiss
/// store persistence is handled by the caller before this is invoked.
pub fn remove_discovery_artist(window: &AppWindow, mbid: &str) {
let state = window.global::<NetworkSidebarState>();
let model = state.get_discovery_artists();
let kept: Vec<DiscoveryArtist> = (0..model.row_count())
.filter_map(|i| model.row_data(i))
.filter(|row| row.mbid.as_str() != mbid)
.collect();
state.set_discovery_artists(ModelRc::new(VecModel::from(kept)));
}
/// Apply MB relationships to NetworkSidebarState. Runs on the Slint
/// event loop.
pub fn apply_mb_relationships(window: &AppWindow, data: MbRelationshipsRowData) {
+88
View File
@@ -0,0 +1,88 @@
//! Per-tag discovery dismiss store.
//!
//! The "You may also like" sidebar section lets the user thumbs-down a
//! candidate. The rejection is sticky for that genre tag, so the same
//! tag never re-suggests the artist on future runs. A single JSON file
//! at the shared QBZ data path holds the dismissals — small enough for
//! a full read on each discovery call, no SQLite needed at this layer.
//!
//! Names are stored normalized (trim + lowercase + collapse whitespace)
//! so casing/whitespace differences from MB and Qobuz collide on the
//! same key.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Map of normalized-lowercased tag -> list of normalized artist names
/// the user has dismissed for that tag.
#[derive(Default, Serialize, Deserialize)]
struct DismissStore {
#[serde(flatten)]
by_tag: HashMap<String, Vec<String>>,
}
fn store_path() -> Option<PathBuf> {
Some(
dirs::data_dir()?
.join("qbz")
.join("discovery_dismiss.json"),
)
}
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] discovery-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] discovery-dismiss write failed: {e}");
}
}
Err(e) => log::warn!("[qbz-slint] discovery-dismiss serialize failed: {e}"),
}
}
/// Return the set of dismissed normalized names for `tag` (which is
/// expected lowercase). Used as a filter input by the discovery
/// pipeline.
pub fn dismissed_for_tag(tag: &str) -> HashSet<String> {
let store = load_store();
store
.by_tag
.get(tag)
.cloned()
.unwrap_or_default()
.into_iter()
.collect()
}
/// Record a dismissal. The caller is expected to pass the tag already
/// lowercased and the name already normalized via
/// `qbz_core::normalize_artist_name`.
pub fn dismiss(tag: &str, normalized_name: &str) {
let mut store = load_store();
let entry = store.by_tag.entry(tag.to_string()).or_default();
if !entry.iter().any(|n| n == normalized_name) {
entry.push(normalized_name.to_string());
}
write_store(&store);
}
+53 -8
View File
@@ -20,6 +20,7 @@ mod artist;
mod artwork;
mod auth;
mod commands;
mod discovery_dismiss;
mod home;
mod nav;
mod network_sidebar_prefs;
@@ -231,6 +232,11 @@ fn navigate_artist(
let artwork_url = data.artwork_url.clone();
let jobs = artist::artwork_jobs(&data);
let artist_name = data.name.clone();
let similar_names_for_discovery: Vec<String> = data
.similar_artists
.iter()
.map(|s| s.name.clone())
.collect();
let _ = weak.upgrade_in_event_loop(move |w| {
artist::apply_artist(&w, data);
w.global::<ArtistState>().set_loading(false);
@@ -240,9 +246,9 @@ fn navigate_artist(
// Network sidebar — kick the MB enrichment off in
// parallel with artwork. Origin shows a loading state
// until the resolve + metadata calls return; on success
// the resolved mbid is used to fetch relationships in a
// second round-trip (the V2 cache, when wired, will
// make this single-shot for repeat visits).
// the resolved mbid is used to fetch relationships and
// discovery candidates in sequence (the V2 cache, when
// wired, will collapse repeat visits to a single shot).
let runtime_mb = runtime.clone();
let weak_mb = weak.clone();
tokio::spawn(async move {
@@ -250,6 +256,7 @@ fn navigate_artist(
let state = w.global::<NetworkSidebarState>();
state.set_origin_loading(true);
state.set_relationships_loading(true);
state.set_discovery_loading(true);
});
match artist::load_mb_metadata(&runtime_mb, &artist_name).await {
Ok(Some(meta)) => {
@@ -271,6 +278,27 @@ fn navigate_artist(
});
}
}
match artist::load_mb_discovery(
&runtime_mb,
&mbid,
&artist_name,
similar_names_for_discovery,
)
.await
{
Ok(disc) => {
let _ = weak_mb.upgrade_in_event_loop(move |w| {
artist::apply_mb_discovery(&w, disc);
});
}
Err(e) => {
log::warn!("[qbz-slint] MB discovery failed: {e}");
let _ = weak_mb.upgrade_in_event_loop(|w| {
w.global::<NetworkSidebarState>()
.set_discovery_loading(false);
});
}
}
}
Ok(None) => {
let _ = weak_mb.upgrade_in_event_loop(|w| {
@@ -1204,11 +1232,28 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"[qbz-slint] network sidebar: musician clicked name={name} role={role}"
);
});
window
.global::<NetworkSidebarActions>()
.on_discovery_dismissed(|mbid| {
log::info!("[qbz-slint] network sidebar: discovery dismissed mbid={mbid}");
});
// discovery-dismissed — persist the rejection under the current
// tag, then remove the row from the visible list.
{
let weak = window.as_weak();
window
.global::<NetworkSidebarActions>()
.on_discovery_dismissed(move |mbid, name| {
if let Some(w) = weak.upgrade() {
let tag = w
.global::<NetworkSidebarState>()
.get_discovery_tag()
.to_string()
.to_lowercase();
if !tag.is_empty() {
let normalized =
qbz_core::normalize_artist_name(name.as_str());
discovery_dismiss::dismiss(&tag, &normalized);
}
artist::remove_discovery_artist(&w, mbid.as_str());
}
});
}
window.on_close_app(|| {
log::info!("[qbz-slint] closing");
@@ -981,6 +981,88 @@ export component ArtistPageView inherits Rectangle {
}
}
}
// --- Discovery: "You may also like" --------------
if NetworkSidebarState.mb-available
&& (NetworkSidebarState.discovery-loading
|| NetworkSidebarState.discovery-artists.length > 0):
VerticalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 12px;
padding-bottom: 12px;
spacing: 4px;
Text {
text: @tr("YOU MAY ALSO LIKE");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
}
if NetworkSidebarState.discovery-loading
&& NetworkSidebarState.discovery-artists.length == 0: Text {
text: @tr("Loading…");
color: Theme.text-muted;
font-size: 12px;
}
// Each row: SidebarLink filling the row plus a
// small thumbs-down button anchored to the
// right. The link click navigates to the
// Qobuz artist; the thumbs-down emits the
// dismiss callback so the row drops out and
// never reappears for this tag.
for disc in NetworkSidebarState.discovery-artists:
HorizontalLayout {
spacing: 2px;
SidebarLink {
horizontal-stretch: 1;
label: disc.name;
icon: @image-url("../assets/icons/user.svg");
tooltip: disc.name;
clicked => {
if (disc.qobuz-id != "") {
NetworkSidebarActions.artist-clicked(
disc.qobuz-id);
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 0;
Rectangle {
width: 24px;
height: 24px;
border-radius: 4px;
background: dismiss-ta.has-hover
? Theme.surface-elevated
: transparent;
QbzIcon {
source: @image-url(
"../assets/icons/thumbs-down.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: dismiss-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
}
dismiss-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
NetworkSidebarActions
.discovery-dismissed(
disc.mbid, disc.name);
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 14V2" />
<path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
</svg>

After

Width:  |  Height:  |  Size: 375 B

+9 -2
View File
@@ -322,9 +322,14 @@ export global NetworkSidebarState {
in property <bool> relationships-loading: false;
in property <MbRelationshipsData> relationships;
// Discovery (MB tag + LB similar) section — populated in phase 8.
// Discovery (MB tag-based) section — populated in phase 8.
in property <bool> discovery-loading: false;
in property <[DiscoveryArtist]> discovery-artists: [];
// Primary tag the current discovery batch was seeded with —
// delivered as the first argument of the discovery-dismissed
// callback so the Rust side can persist the dismiss under the
// right key.
in property <string> discovery-tag;
}
// Callbacks the sidebar emits. Each delivers the minimum payload the
@@ -342,7 +347,9 @@ export global NetworkSidebarActions {
// Relationship row clicked — opens MusicianPage with the role.
callback musician-clicked(string /* name */, string /* role */);
// Discovery dismiss — removes the row and remembers the rejection.
callback discovery-dismissed(string /* mbid */);
// Carries both the MBID (model key for removal) and the name
// (persisted into the per-tag dismiss store after normalization).
callback discovery-dismissed(string /* mbid */, string /* name */);
// Sidebar toggled — Rust persists the new open state.
callback toggle();
}