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:
9 files changed
+529
-16
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 |
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user