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

@@ -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();
}