diff --git a/crates/qbz-qt/qml/assets/brand/brand-discogs.svg b/crates/qbz-qt/qml/assets/brand/brand-discogs.svg
new file mode 100644
index 000000000..eaf07c847
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/brand/brand-discogs.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/brand/brand-lastfm.svg b/crates/qbz-qt/qml/assets/brand/brand-lastfm.svg
new file mode 100644
index 000000000..4028eb711
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/brand/brand-lastfm.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/brand/brand-musicbrainz.svg b/crates/qbz-qt/qml/assets/brand/brand-musicbrainz.svg
new file mode 100644
index 000000000..78d93555c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/brand/brand-musicbrainz.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml
index a7254dd57..85ecf7886 100644
--- a/crates/qbz-qt/qml/views/AlbumView.qml
+++ b/crates/qbz-qt/qml/views/AlbumView.qml
@@ -8,9 +8,9 @@
// same artist", "Listening suggestions").
//
// POC-NOTEs: multi-select + bulk bar, offline download column, booklet,
-// external links, custom-cover menu, album-info modal, the header
-// atmosphere (album-header-gradient pref) — out of scope (visible stubs
-// are inert). Text keeps the theme colors (header-light = false here).
+// custom-cover menu, album-info modal, the header atmosphere
+// (album-header-gradient pref) — out of scope (visible stubs are inert).
+// Text keeps the theme colors (header-light = false here).
import QtQuick
import QtQuick.Controls
@@ -152,6 +152,53 @@ Rectangle {
font.letterSpacing: 1
}
+ // One EXTERNAL LINKS brand icon (AlbumPageView.slint BrandLink): the bare
+ // brand SVG in its NATIVE colors — no tint pass, no visible label, the
+ // name lives in the hover tooltip (Feishin-style inline links).
+ component BrandLink: Rectangle {
+ property string iconSource: ""
+ property string name: ""
+ property string url: ""
+ width: 30
+ height: 30
+ radius: 6
+ color: brandArea.containsMouse ? theme.surfaceHover : "transparent"
+ Image {
+ anchors.centerIn: parent
+ source: iconSource
+ width: 18
+ height: 18
+ sourceSize: Qt.size(36, 36)
+ fillMode: Image.PreserveAspectFit
+ opacity: brandArea.containsMouse ? 1.0 : 0.85
+ Behavior on opacity { NumberAnimation { duration: 120 } }
+ }
+ MouseArea {
+ id: brandArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ // Deep link only — the browser does the work, nothing is fetched
+ // here and no integration has to be connected.
+ onClicked: if (url !== "") Qt.openUrlExternally(url)
+ // The Slint BrandLink carries the name in the shared tooltip
+ // bubble; the Qt port rides Qt's own ToolTip (LocalMultiSelectBar
+ // precedent).
+ ToolTip.visible: containsMouse && name !== ""
+ ToolTip.text: name
+ ToolTip.delay: 350
+ }
+ }
+
+ // Absolute qrc prefix for the brand SVGs — same rule as QbzIcon: a
+ // relative URL resolves against the CONSUMER's document depth.
+ readonly property string brandDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/brand/"
+
+ // Whether the right-hand album sidebar has anything to show at all.
+ readonly property bool hasSidebar: (header.label || "") !== ""
+ || awards.length > 0
+ || header.showExternalLinks === true
+
// ============================ the page ================================
Flickable {
id: pageFlick
@@ -379,7 +426,7 @@ Rectangle {
spacing: 32
Column {
- width: parent.width - ((header.label || "") !== "" || awards.length > 0 ? 232 : 0)
+ width: parent.width - (root.hasSidebar ? 232 : 0)
spacing: 0
// Loading.
@@ -598,9 +645,9 @@ Rectangle {
}
}
- // Label / awards sidebar (200px).
+ // Label / awards / external-links sidebar (200px).
Column {
- visible: (header.label || "") !== "" || awards.length > 0
+ visible: root.hasSidebar
width: 200
spacing: 24
@@ -634,6 +681,38 @@ Rectangle {
}
}
}
+
+ // EXTERNAL LINKS — Last.fm / Discogs / MusicBrainz deep
+ // links for this release. Present whenever the album has
+ // an artist and a title; they are ordinary web URLs, so
+ // they neither require nor touch a connected integration.
+ Column {
+ visible: header.showExternalLinks === true
+ width: parent.width
+ spacing: 8
+ SidebarHeading { text: QbzSession.tr("EXTERNAL LINKS", QbzSession.trRev) }
+ Row {
+ spacing: 8
+ BrandLink {
+ visible: (header.lastfmUrl || "") !== ""
+ iconSource: root.brandDir + "brand-lastfm.svg"
+ name: "Last.fm"
+ url: header.lastfmUrl || ""
+ }
+ BrandLink {
+ visible: (header.discogsUrl || "") !== ""
+ iconSource: root.brandDir + "brand-discogs.svg"
+ name: "Discogs"
+ url: header.discogsUrl || ""
+ }
+ BrandLink {
+ visible: (header.musicbrainzUrl || "") !== ""
+ iconSource: root.brandDir + "brand-musicbrainz.svg"
+ name: "MusicBrainz"
+ url: header.musicbrainzUrl || ""
+ }
+ }
+ }
}
}
diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml
index 673127e11..ae9f1a043 100644
--- a/crates/qbz-qt/qml/views/ArtistView.qml
+++ b/crates/qbz-qt/qml/views/ArtistView.qml
@@ -7,12 +7,18 @@
// (Albums / EPs & Singles / Live / … in the official order, sort menu,
// per-section Load more paged through the core), Appears On, Playlists,
// Other (collapsed), and the 300px Network sidebar (Network/Magazine
-// tabs, LABELS, SIMILAR ARTISTS).
+// tabs, ORIGIN, LABELS, SIMILAR ARTISTS, RELATIONSHIPS, YOU MAY ALSO LIKE,
+// and the Magazine story teasers).
//
-// POC-NOTEs: MusicBrainz sidebar sections (Origin/Relationships/
-// Discovery), the Magazine tab's stories, blacklist banner, artist Scene,
-// Share, Create Collection, radio engines (dropdown inert), multi-select,
-// the sticky behavior of the JUMP TO bar (it scrolls with the page).
+// The document arrives in passes: the Qobuz page first, then the Magazine
+// stories, then MusicBrainz Origin -> Relationships -> Discovery (see
+// artist_qt.rs). Each MB section renders its own "Loading…" line and, when
+// MusicBrainz is off in Settings or the artist has no confident MB match, is
+// simply ABSENT — never an error frame, and nothing is requested.
+//
+// POC-NOTEs: blacklist banner, artist Scene, Share, Create Collection, radio
+// engines (dropdown inert), multi-select, the sticky behavior of the JUMP TO
+// bar (it scrolls with the page).
import QtQuick
import QtQuick.Controls
@@ -42,6 +48,13 @@ Rectangle {
readonly property var labels: artist.labels || []
readonly property var similarArtists: artist.similarArtists || []
readonly property var playlists: artist.playlists || []
+ // MusicBrainz-driven sidebar payload (artist_qt.rs ArtistNetwork). Absent
+ // on the very first frame of a cold document — every read below is
+ // defaulted so a missing member can never throw.
+ readonly property var network: artist.network || ({})
+ readonly property var mbOrigin: network.origin || ({})
+ readonly property var mbRelationships: network.relationships || ({})
+ readonly property var stories: artist.stories || []
property var coverMap: ({})
property string activeJumpTab: "popular-tracks"
@@ -52,6 +65,45 @@ Rectangle {
property bool networkOpen: false
property string netTab: "network"
readonly property int preview: 5
+ // Sidebar lists are unbounded upstream (an orchestra can list 150
+ // members): show a slice, expand on demand — the delegates for the rest
+ // are never instantiated.
+ readonly property int sidebarPreview: 12
+ property bool membersExpanded: false
+ property bool groupsExpanded: false
+ property bool collabsExpanded: false
+ // Thumbs-downed discovery rows, by mbid. Session-only: the Slint app
+ // persists these in its `discovery_dismiss` store, which this POC does
+ // not open, so the rejection lasts as long as the process — it is NOT
+ // written anywhere and makes no claim to be.
+ property var dismissedDiscovery: ({})
+ // The artist the view state (tab choice, dismissals) belongs to. Compared
+ // on every republish so a mid-load pass never resets the user's choices.
+ property string loadedArtistId: ""
+
+ // Optimistic heart/pin state. The document is republished several times
+ // per page now (stories, then each MusicBrainz section), and every
+ // republish re-parses `artist` — a toggle written straight onto the parsed
+ // object would silently pop back. Overrides live here instead and win over
+ // whatever the document says, until the artist changes.
+ property var localToggles: ({})
+ function toggleState(key, fallback) {
+ return localToggles[key] !== undefined ? localToggles[key] : fallback === true
+ }
+ function setToggleState(key, value) {
+ var m = localToggles
+ m[key] = value
+ localToggles = Object.assign({}, m)
+ }
+
+ readonly property var discoveryRows: {
+ var out = []
+ var rows = network.discovery || []
+ for (var i = 0; i < rows.length; i++) {
+ if (!dismissedDiscovery[rows[i].mbid]) out.push(rows[i])
+ }
+ return out
+ }
// JUMP TO tabs from the present sections (ArtistState.jump-tabs).
readonly property var jumpTabs: {
@@ -98,17 +150,60 @@ Rectangle {
root.artistChanged()
}
}
- Component.onCompleted: dispatchCovers()
- onArtistChanged: dispatchCovers()
+ Component.onCompleted: {
+ syncArtistState()
+ dispatchCovers()
+ }
+ onArtistChanged: {
+ syncArtistState()
+ dispatchCovers()
+ }
// Cover dispatch keys off the raw document (artist.artUrl etc.), so
// re-fire when the parsed value actually changes (same stale race).
onTopTracksChanged: dispatchCovers()
onArtistTabChanged: if (artistTab === "library") dispatchLibCovers()
+
+ // The document is republished several times per page (stories, then each
+ // MusicBrainz section). Reset per-artist view state ONLY when the id
+ // actually changed, or an enrichment pass would yank the sidebar tab back
+ // under the user mid-read.
+ function syncArtistState() {
+ var id = artist.id || ""
+ if (id === loadedArtistId)
+ return
+ loadedArtistId = id
+ // Slint opens a fresh artist on Network, or on Magazine when
+ // MusicBrainz is off (an empty Network tab is worse than none).
+ netTab = (artist.network && artist.network.mbAvailable) ? "network" : "magazine"
+ dismissedDiscovery = ({})
+ localToggles = ({})
+ membersExpanded = false
+ groupsExpanded = false
+ collabsExpanded = false
+ dispatchedCovers = ({})
+ }
+
function dispatchLibCovers() {
- var urls = []
var items = libraryTab.libItems || []
+ var urls = []
for (var i = 0; i < items.length; i++) if (items[i].imageUrl) urls.push(items[i].imageUrl)
- if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
+ dispatchArtwork(urls)
+ }
+
+ // Already-requested artwork keys. With the progressive republish the
+ // dispatch runs once per pass, so re-sending the whole (potentially
+ // several-hundred-entry) URL list every time is pure waste — send only
+ // what is new.
+ property var dispatchedCovers: ({})
+ function dispatchArtwork(urls) {
+ var fresh = []
+ for (var i = 0; i < urls.length; i++) {
+ var u = urls[i]
+ if (!u || dispatchedCovers[u]) continue
+ dispatchedCovers[u] = true
+ fresh.push(u)
+ }
+ if (fresh.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(fresh))
}
function dispatchCovers() {
@@ -119,7 +214,9 @@ Rectangle {
for (i = 0; i < releaseSections.length; i++)
for (j = 0; j < releaseSections[i].cards.length; j++)
if (releaseSections[i].cards[j].artUrl) urls.push(releaseSections[i].cards[j].artUrl)
- if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
+ // Magazine story thumbnails ride the same pipeline (arc-cdn URLs).
+ for (i = 0; i < stories.length; i++) if (stories[i].artUrl) urls.push(stories[i].artUrl)
+ dispatchArtwork(urls)
}
function scrollToSection(id) {
@@ -293,8 +390,10 @@ Rectangle {
font.weight: theme.weightBold
horizontalAlignment: Text.AlignHCenter
}
- // Favorite (live).
+ // Favorite (live). Reads through the override map so the state
+ // survives a document republish (see root.localToggles).
Rectangle {
+ property bool favorite: root.toggleState("track:" + row.id, row.isFavorite)
width: 28
height: 28
radius: theme.radiusSm
@@ -302,10 +401,10 @@ Rectangle {
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
- name: row.isFavorite ? "heart-filled" : "heart"
+ name: parent.favorite ? "heart-filled" : "heart"
width: 16
height: 16
- tintName: row.isFavorite ? "favorite" : (favArea.containsMouse ? "primary" : "muted")
+ tintName: parent.favorite ? "favorite" : (favArea.containsMouse ? "primary" : "muted")
}
MouseArea {
id: favArea
@@ -313,7 +412,7 @@ Rectangle {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
- row.isFavorite = !row.isFavorite
+ root.setToggleState("track:" + row.id, !parent.favorite)
QbzLibrary.libraryToggleFavorite("track", row.id)
}
}
@@ -354,10 +453,14 @@ Rectangle {
}
}
- // Sidebar link row (SidebarLink).
+ // Sidebar link row (SidebarLink). `navigable` false = informational row:
+ // no pointer cursor, no hover promise it cannot keep (used by the MB
+ // Relationships rows, which have no destination in this port).
component SidebarLink: Rectangle {
property string label: ""
property string iconName: "user"
+ property string tooltip: ""
+ property bool navigable: true
signal clicked()
width: parent ? parent.width : 0
height: 28
@@ -388,8 +491,113 @@ Rectangle {
id: slArea
anchors.fill: parent
hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: parent.clicked()
+ cursorShape: navigable ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: if (navigable) parent.clicked()
+ ToolTip.visible: containsMouse && tooltip !== ""
+ ToolTip.text: tooltip
+ ToolTip.delay: 400
+ }
+ }
+
+ // Sidebar section heading (11px muted caps, letter-spaced).
+ component SidebarSectionHeading: Text {
+ color: theme.textMuted
+ font.pixelSize: 11
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 0.5
+ }
+
+ // Small 11px muted line — sub-group labels and the "Loading…"/empty
+ // states inside the sidebar sections.
+ component SidebarNote: Text {
+ color: theme.textMuted
+ font.pixelSize: 12
+ }
+
+ // One "KEY value" row of the MB Origin block.
+ component OriginRow: Item {
+ property string key: ""
+ property string value: ""
+ // The host section Column carries 14px of left+right padding, and a
+ // QML Positioner does NOT shrink its children for it — a right-aligned
+ // value bound to the bare parent.width would run past the sidebar
+ // edge. Subtract it here (this row only ever lives in that section).
+ width: parent ? parent.width - 28 : 0
+ height: 20
+ Text {
+ id: originKey
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: key
+ color: theme.textMuted
+ font.pixelSize: 11
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 0.5
+ }
+ Text {
+ anchors.right: parent.right
+ anchors.left: originKey.right
+ anchors.leftMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: value
+ color: theme.textPrimary
+ font.pixelSize: 13
+ horizontalAlignment: Text.AlignRight
+ elide: Text.ElideRight
+ }
+ }
+
+ // One MB relationship sub-group (Members & Former / Member Of /
+ // Collaborators) with a preview cap + expander.
+ component RelationshipGroup: Column {
+ id: relGroup
+ property string title: ""
+ property var rows: []
+ property string iconName: "user"
+ property bool expanded: false
+ signal toggled()
+ visible: rows.length > 0
+ // -28 = the host section Column's left+right padding (see OriginRow).
+ width: parent ? parent.width - 28 : 0
+ spacing: 2
+ topPadding: 2
+ SidebarNote {
+ text: relGroup.title
+ font.pixelSize: 11
+ }
+ Repeater {
+ model: relGroup.rows.length > root.sidebarPreview && !relGroup.expanded
+ ? relGroup.rows.slice(0, root.sidebarPreview)
+ : relGroup.rows
+ delegate: SidebarLink {
+ required property var modelData
+ label: modelData.name
+ tooltip: modelData.tooltip
+ iconName: relGroup.iconName
+ // POC-NOTE: the Slint row resolves the musician through
+ // MusicBrainz and lands on the Qobuz artist (or its
+ // MusicianPageView). Neither exists here, so the row is
+ // informational — see GLUE in the handoff report.
+ navigable: false
+ }
+ }
+ Text {
+ visible: relGroup.rows.length > root.sidebarPreview
+ leftPadding: 8
+ // Same msgid pair the page's other expanders use — no new
+ // catalog entries (all 8 locales already carry these).
+ text: relGroup.expanded
+ ? QbzSession.tr("View less", QbzSession.trRev)
+ : QbzSession.tr("Load more", QbzSession.trRev)
+ color: relMoreArea.containsMouse ? theme.textPrimary : theme.textSecondary
+ font.pixelSize: 12
+ MouseArea {
+ id: relMoreArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: relGroup.toggled()
+ }
}
}
@@ -588,7 +796,15 @@ Rectangle {
onClicked: {
var shell = root.parent
while (shell && shell.openTextModal === undefined) shell = shell.parent
- if (shell) shell.openTextModal(artist.name || "", artist.bio || "")
+ if (!shell) return
+ // The Slint modal renders the attribution
+ // ("Source: TiVo") as a small line under the
+ // body; the shared text modal has one body
+ // slot, so it rides at the end.
+ var body = artist.bio || ""
+ if ((artist.bioSource || "") !== "")
+ body += "\n\n" + QbzSession.tr("Source", QbzSession.trRev) + ": " + artist.bioSource
+ shell.openTextModal(artist.name || "", body)
}
}
}
@@ -599,11 +815,12 @@ Rectangle {
width: parent.width
spacing: 12
QbzCircleAction {
- name: artist.isFollowing ? "heart-filled" : "heart"
- active: artist.isFollowing === true
+ readonly property bool following: root.toggleState("artist", artist.isFollowing)
+ name: following ? "heart-filled" : "heart"
+ active: following
anchors.verticalCenter: parent.verticalCenter
onClicked: {
- artist.isFollowing = !artist.isFollowing
+ root.setToggleState("artist", !following)
QbzLibrary.libraryToggleFavorite("artist", artist.id)
}
}
@@ -1233,6 +1450,47 @@ Rectangle {
bottomPadding: 12
spacing: 0
+ // ORIGIN (MusicBrainz). Gated exactly like the Slint
+ // block: MB available AND (still loading OR the artist
+ // actually carries a life span / location). With MB off
+ // the whole block is absent — nothing was requested.
+ Column {
+ visible: network.mbAvailable === true
+ && (network.originLoading === true || mbOrigin.hasData === true)
+ width: parent.width
+ leftPadding: 14
+ rightPadding: 14
+ topPadding: 12
+ bottomPadding: 12
+ spacing: 6
+ SidebarNote {
+ visible: network.originLoading === true
+ text: QbzSession.tr("Loading origin…", QbzSession.trRev)
+ }
+ OriginRow {
+ visible: network.originLoading !== true && (mbOrigin.beginDate || "") !== ""
+ key: mbOrigin.isPerson ? QbzSession.tr("BORN", QbzSession.trRev)
+ : QbzSession.tr("FOUNDED", QbzSession.trRev)
+ value: mbOrigin.beginDate || ""
+ }
+ OriginRow {
+ visible: network.originLoading !== true && (mbOrigin.locationDisplay || "") !== ""
+ key: mbOrigin.isPerson ? QbzSession.tr("BORN IN", QbzSession.trRev)
+ : QbzSession.tr("FOUNDED IN", QbzSession.trRev)
+ // POC-NOTE: the Slint row is clickable and opens
+ // ArtistsByLocationView; that view has no port
+ // here, so the affordance is left out rather than
+ // rendered dead.
+ value: mbOrigin.locationDisplay || ""
+ }
+ OriginRow {
+ visible: network.originLoading !== true && (mbOrigin.endDate || "") !== ""
+ key: mbOrigin.isPerson ? QbzSession.tr("DIED", QbzSession.trRev)
+ : QbzSession.tr("DISBANDED", QbzSession.trRev)
+ value: mbOrigin.endDate || ""
+ }
+ }
+
// LABELS.
Column {
width: parent.width
@@ -1241,23 +1499,16 @@ Rectangle {
topPadding: 12
bottomPadding: 6
spacing: 4
- Text {
- text: QbzSession.tr("LABELS", QbzSession.trRev)
- color: theme.textMuted
- font.pixelSize: 11
- font.weight: theme.weightSemibold
- font.letterSpacing: 0.5
- }
- Text {
+ SidebarSectionHeading { text: QbzSession.tr("LABELS", QbzSession.trRev) }
+ SidebarNote {
visible: labels.length === 0
text: QbzSession.tr("No label info", QbzSession.trRev)
- color: theme.textMuted
- font.pixelSize: 12
}
Repeater {
model: labels
delegate: SidebarLink {
label: modelData.name
+ tooltip: modelData.name
iconName: "disc"
// POC-NOTE: no label view yet.
}
@@ -1272,45 +1523,222 @@ Rectangle {
topPadding: 12
bottomPadding: 6
spacing: 4
- Text {
- text: QbzSession.tr("SIMILAR ARTISTS", QbzSession.trRev)
- color: theme.textMuted
- font.pixelSize: 11
- font.weight: theme.weightSemibold
- font.letterSpacing: 0.5
- }
- Text {
+ SidebarSectionHeading { text: QbzSession.tr("SIMILAR ARTISTS", QbzSession.trRev) }
+ SidebarNote {
visible: similarArtists.length === 0 && QbzArtist.artistLoading
text: QbzSession.tr("Loading…", QbzSession.trRev)
- color: theme.textMuted
- font.pixelSize: 12
}
Repeater {
model: similarArtists
delegate: SidebarLink {
label: modelData.name
+ tooltip: modelData.name
iconName: "user"
onClicked: QbzArtist.openArtist(modelData.id)
}
}
}
+
+ // RELATIONSHIPS (MusicBrainz) — band members, the groups
+ // this artist belongs to, and studio collaborators.
+ Column {
+ visible: network.mbAvailable === true
+ && (network.relationshipsLoading === true
+ || mbRelationships.hasData === true)
+ width: parent.width
+ leftPadding: 14
+ rightPadding: 14
+ topPadding: 12
+ bottomPadding: 6
+ spacing: 6
+ SidebarSectionHeading { text: QbzSession.tr("RELATIONSHIPS", QbzSession.trRev) }
+ SidebarNote {
+ visible: network.relationshipsLoading === true
+ text: QbzSession.tr("Loading…", QbzSession.trRev)
+ }
+ RelationshipGroup {
+ visible: network.relationshipsLoading !== true
+ && (mbRelationships.members || []).length > 0
+ title: QbzSession.tr("Members & Former", QbzSession.trRev)
+ rows: mbRelationships.members || []
+ iconName: "user"
+ expanded: root.membersExpanded
+ onToggled: root.membersExpanded = !root.membersExpanded
+ }
+ RelationshipGroup {
+ visible: network.relationshipsLoading !== true
+ && (mbRelationships.groups || []).length > 0
+ title: QbzSession.tr("Member Of", QbzSession.trRev)
+ rows: mbRelationships.groups || []
+ iconName: "music"
+ expanded: root.groupsExpanded
+ onToggled: root.groupsExpanded = !root.groupsExpanded
+ }
+ RelationshipGroup {
+ visible: network.relationshipsLoading !== true
+ && (mbRelationships.collaborators || []).length > 0
+ title: QbzSession.tr("Collaborators", QbzSession.trRev)
+ rows: mbRelationships.collaborators || []
+ iconName: "user"
+ expanded: root.collabsExpanded
+ onToggled: root.collabsExpanded = !root.collabsExpanded
+ }
+ }
+
+ // YOU MAY ALSO LIKE (MusicBrainz tag discovery, validated
+ // against Qobuz by the core). Rows without a resolved
+ // Qobuz id stay informational instead of dead-clicking.
+ Column {
+ visible: network.mbAvailable === true
+ && (network.discoveryLoading === true || root.discoveryRows.length > 0)
+ width: parent.width
+ leftPadding: 14
+ rightPadding: 14
+ topPadding: 12
+ bottomPadding: 12
+ spacing: 4
+ SidebarSectionHeading { text: QbzSession.tr("YOU MAY ALSO LIKE", QbzSession.trRev) }
+ SidebarNote {
+ visible: network.discoveryLoading === true && root.discoveryRows.length === 0
+ text: QbzSession.tr("Loading…", QbzSession.trRev)
+ }
+ Repeater {
+ model: root.discoveryRows
+ delegate: Item {
+ required property var modelData
+ // -28 = the section Column's left+right
+ // padding (see OriginRow).
+ width: parent ? parent.width - 28 : 0
+ height: 28
+ SidebarLink {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ // Explicit width REPLACES the component's
+ // own `parent.width` binding (leaving it
+ // and anchoring both edges fights it).
+ width: parent.width - 26
+ label: modelData.name
+ tooltip: modelData.name
+ iconName: "user"
+ navigable: modelData.qobuzId !== ""
+ onClicked: QbzArtist.openArtist(modelData.qobuzId)
+ }
+ Rectangle {
+ id: dismissBtn
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: 24
+ height: 24
+ radius: 4
+ color: dismissArea.containsMouse ? theme.surfaceElevated : "transparent"
+ QbzIcon {
+ anchors.centerIn: parent
+ name: "thumbs-down"
+ width: 12
+ height: 12
+ tintName: dismissArea.containsMouse ? "primary" : "muted"
+ }
+ MouseArea {
+ id: dismissArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ // Session-only: drop the row now. The
+ // Slint app also persists it under the
+ // discovery tag; that store is not open
+ // in this port (see the handoff report).
+ onClicked: {
+ var d = root.dismissedDiscovery
+ d[modelData.mbid] = true
+ root.dismissedDiscovery = Object.assign({}, d)
+ }
+ }
+ }
+ }
+ }
+ }
}
}
- // Magazine tab body — POC-NOTE placeholder (the Stories CMS is
- // a separate subsystem).
- Item {
+ // Magazine tab body — Qobuz editorial story teasers (limit 2,
+ // like the official client). A story opens in the system browser.
+ Flickable {
visible: root.netTab === "magazine"
width: parent.width
height: parent.height - 45
- Text {
- anchors.centerIn: parent
- width: parent.width - 28
- text: "QBZ Qt POC — the Magazine feed is not wired yet"
- color: theme.textMuted
- font.pixelSize: 12
- horizontalAlignment: Text.AlignHCenter
- wrapMode: Text.WordWrap
+ clip: true
+ contentWidth: width
+ contentHeight: magBody.implicitHeight
+ boundsBehavior: Flickable.StopAtBounds
+
+ Column {
+ id: magBody
+ width: parent.width
+ padding: 12
+ spacing: 10
+
+ SidebarNote {
+ visible: artist.storiesLoading === true && stories.length === 0
+ text: QbzSession.tr("Loading…", QbzSession.trRev)
+ }
+ SidebarNote {
+ visible: artist.storiesLoading !== true && stories.length === 0
+ text: QbzSession.tr("No stories for this artist", QbzSession.trRev)
+ }
+
+ Repeater {
+ model: stories
+ delegate: Rectangle {
+ required property var modelData
+ width: magBody.width - 24
+ height: storyCol.implicitHeight
+ radius: 8
+ color: storyArea.containsMouse ? theme.surfaceHover : "transparent"
+ Column {
+ id: storyCol
+ width: parent.width
+ padding: 6
+ spacing: 6
+ // 1:1 square thumbnail, height tracks width.
+ Rectangle {
+ visible: (modelData.artUrl || "") !== ""
+ width: storyCol.width - 12
+ height: visible ? width : 0
+ radius: 6
+ color: theme.surfaceElevated
+ clip: true
+ RoundedImage {
+ anchors.fill: parent
+ source: root.coverMap[modelData.artUrl] || ""
+ radius: 6
+ }
+ }
+ Text {
+ width: storyCol.width - 12
+ text: modelData.title
+ color: theme.textPrimary
+ font.pixelSize: 13
+ font.weight: theme.weightSemibold
+ wrapMode: Text.WordWrap
+ }
+ Text {
+ visible: (modelData.author || "") !== ""
+ width: storyCol.width - 12
+ text: modelData.author
+ color: theme.textMuted
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+ }
+ MouseArea {
+ id: storyArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: if ((modelData.url || "") !== "") Qt.openUrlExternally(modelData.url)
+ }
+ }
+ }
}
}
}
@@ -1361,7 +1789,7 @@ Rectangle {
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "stub" },
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "stub" },
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "stub" },
- { "label": artist.isPinned ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": artist.isPinned ? "pin-filled" : "pin", "action": "pin" },
+ { "label": root.toggleState("artistPin", artist.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("artistPin", artist.isPinned) ? "pin-filled" : "pin", "action": "pin" },
]
delegate: Rectangle {
required property var modelData
@@ -1392,7 +1820,7 @@ Rectangle {
onClicked: {
overflowMenu.close()
if (modelData.action === "pin") {
- artist.isPinned = !artist.isPinned
+ root.setToggleState("artistPin", !root.toggleState("artistPin", artist.isPinned))
QbzLibrary.togglePin("artist", artist.id, artist.name, "", artist.artUrl)
}
}
diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs
index ce7b3051e..7d5f3b3a3 100644
--- a/crates/qbz-qt/src/album_qt.rs
+++ b/crates/qbz-qt/src/album_qt.rs
@@ -4,12 +4,13 @@
//! suggestions" via get_album_suggest). Publishes ONE JSON document.
//!
//! POC-NOTEs:
-//! - Booklet download/rasterize, external links (Last.fm/Discogs/MB),
-//! multi-select + bulk bar, DiscHeaderMenu, album-info modal, custom
-//! covers: out of scope (inert stubs where visible).
+//! - Booklet download/rasterize, multi-select + bulk bar, DiscHeaderMenu,
+//! album-info modal, custom covers: out of scope (inert stubs where visible).
//! - Blacklist filtering on carousels: skipped (store not open).
-//! - The album-header-gradient atmosphere and Last.fm suggestions row:
-//! not wired (appearance pref + Last.fm subsystem).
+//! - The album-header-gradient atmosphere: not wired (appearance pref).
+//! - The Last.fm "similar albums" second suggestions row needs the
+//! `qbz-external-reco` engine, which is not a dependency of this crate —
+//! see the GLUE note in the handoff report. NOT faked here.
use std::sync::Arc;
use std::time::Instant;
@@ -104,6 +105,18 @@ pub struct AlbumHeader {
pub is_favorite: bool,
#[serde(rename = "isPinned")]
pub is_pinned: bool,
+ /// EXTERNAL LINKS sidebar block — deep links into the three music
+ /// databases, built from artist + title. These are plain URLs handed to
+ /// the system browser on click: nothing is fetched, no account is needed
+ /// and no integration has to be connected for them to work.
+ #[serde(rename = "showExternalLinks")]
+ pub show_external_links: bool,
+ #[serde(rename = "lastfmUrl")]
+ pub lastfm_url: String,
+ #[serde(rename = "discogsUrl")]
+ pub discogs_url: String,
+ #[serde(rename = "musicbrainzUrl")]
+ pub musicbrainz_url: String,
}
#[derive(Default, Serialize)]
@@ -129,6 +142,38 @@ fn format_duration(secs: u32) -> String {
}
}
+/// Last.fm path segment: percent-encode, then render spaces as `+` (Last.fm's
+/// `/music/{artist}/{album}` paths use `+` for spaces). `urlencoding::encode`
+/// emits `%20` for spaces, so swap them — the remaining percent-escapes
+/// (e.g. `/`, `?`) stay path-safe. (album.rs `lastfm_segment`.)
+fn lastfm_segment(text: &str) -> String {
+ urlencoding::encode(text).replace("%20", "+")
+}
+
+/// The three external-database deep links for an album (album.rs
+/// `apply_album`'s external-links block). Returns `None` when either the
+/// artist or the title is missing — the sidebar block is then absent.
+fn external_links(artist: &str, title: &str) -> Option<(String, String, String)> {
+ if artist.is_empty() || title.is_empty() {
+ return None;
+ }
+ let lastfm = format!(
+ "https://www.last.fm/music/{}/{}",
+ lastfm_segment(artist),
+ lastfm_segment(title),
+ );
+ // `{artist}+{album}` query (spaces as `+`, each part percent-encoded).
+ let query = format!(
+ "{}+{}",
+ urlencoding::encode(artist),
+ urlencoding::encode(title)
+ );
+ let discogs = format!("https://www.discogs.com/search/?q={query}&type=release");
+ let musicbrainz =
+ format!("https://musicbrainz.org/search?query={query}&type=release&method=indexed");
+ Some((lastfm, discogs, musicbrainz))
+}
+
/// Word-boundary truncation (album.rs `truncate_words`).
pub(crate) fn truncate_words(text: &str, max: usize) -> String {
if text.chars().count() <= max {
@@ -322,7 +367,15 @@ pub async fn load_album(runtime: &Arc>, album_id: &st
.map(|container| container.items.iter().map(map_track).collect())
.unwrap_or_default();
+ // External links are built from the SAME artist/title the header shows.
+ let links = external_links(&artist, &title);
+ let (lastfm_url, discogs_url, musicbrainz_url) = links.clone().unwrap_or_default();
+
let header = AlbumHeader {
+ show_external_links: links.is_some(),
+ lastfm_url,
+ discogs_url,
+ musicbrainz_url,
is_favorite: crate::library_qt::with_library(|d| {
d.feed
.iter()
diff --git a/crates/qbz-qt/src/artist_qt.rs b/crates/qbz-qt/src/artist_qt.rs
index 26d9831c9..67c281b32 100644
--- a/crates/qbz-qt/src/artist_qt.rs
+++ b/crates/qbz-qt/src/artist_qt.rs
@@ -2,22 +2,46 @@
//! (`load_artist`/`map_artist`, `load_release_page`, the release-section
//! bucketing in the official order). Publishes ONE JSON document.
//!
+//! The page publishes PROGRESSIVELY: `load_artist_view` returns the Qobuz
+//! document immediately (main.rs sets it on the bridge), then a background
+//! task re-publishes the SAME document enriched with the Magazine stories and
+//! the MusicBrainz network-sidebar sections as each one resolves. The stashed
+//! copy is generation-guarded so a late reply for a previous artist is
+//! dropped instead of overwriting the current page.
+//!
+//! MusicBrainz is OPT-IN-RESPECTING: `network.mbAvailable` is seeded from
+//! `core.musicbrainz_is_enabled()` BEFORE anything is fetched — when the user
+//! has MB off, no resolve/metadata/relationship/discovery request is made at
+//! all and the Origin / Relationships / You-may-also-like sections are simply
+//! absent from the sidebar (never an error, never an empty frame).
+//!
//! POC-NOTEs:
-//! - MusicBrainz sidebar sections (Origin / Relationships / Discovery),
-//! the Magazine/Stories tab, blacklist banner/filter, "In library"
-//! PLAYLISTS sub-lists, jump-tab scroll-tracking: out of scope (the
-//! Network sidebar carries LABELS + SIMILAR ARTISTS, both wired).
+//! - Blacklist banner/filter, "In library" PLAYLISTS sub-lists, jump-tab
+//! scroll-tracking: out of scope.
//! - `is_following` seeds from the phase-5 library feed (the Slint app
//! resolves it from the favorites cache — same truth, different path).
+//! - Discovery runs with EMPTY `dismissed_per_tag` / `known_artists`
+//! callbacks: the Slint app feeds those from `discovery_dismiss` +
+//! `play_history` + `reco`, none of which this POC brings up. That is the
+//! same default a first-run Slint profile lands on (no exclusion applied),
+//! not a fabricated one.
+//! - MB Origin location is rendered as plain text: the clickable form opens
+//! ArtistsByLocationView, which this POC has no port of, so the affordance
+//! is left out rather than dead.
use std::collections::{BTreeMap, HashMap, HashSet};
-use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
use std::time::Instant;
+use cxx_qt_lib::QString;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
-use qbz_models::{PageArtistRelease, PageArtistResponse, PageArtistTrack, QueueTrack};
+use qbz_models::{
+ ArtistStoryItem, PageArtistRelease, PageArtistResponse, PageArtistTrack, QueueTrack,
+};
use serde::Serialize;
+use serde_json::json;
use crate::album_qt::{truncate_words, AlbumCardData, TrackRow};
use crate::home_qt;
@@ -71,6 +95,85 @@ pub struct ArtistPlaylist {
pub art_url: String,
}
+/// Magazine story teaser (Qobuz editorial — `/artist/getStory`). Not an
+/// integration: it is the same Qobuz session the rest of the page uses.
+#[derive(Clone, Default, Serialize)]
+pub struct StoryRow {
+ pub title: String,
+ pub author: String,
+ pub excerpt: String,
+ pub url: String,
+ #[serde(rename = "artUrl")]
+ pub art_url: String,
+}
+
+/// MusicBrainz Origin block (artist.rs `MbOrigin`). `hasData` is the gate the
+/// sidebar uses so an MB-matched artist with no life-span/location renders
+/// nothing at all instead of an empty heading.
+#[derive(Clone, Default, Serialize)]
+pub struct MbOriginJson {
+ #[serde(rename = "isPerson")]
+ pub is_person: bool,
+ #[serde(rename = "beginDate")]
+ pub begin_date: String,
+ #[serde(rename = "endDate")]
+ pub end_date: String,
+ #[serde(rename = "locationDisplay")]
+ pub location_display: String,
+ #[serde(rename = "hasData")]
+ pub has_data: bool,
+}
+
+/// One Members / Member-Of / Collaborators row (artist.rs `MbRelationshipRow`).
+#[derive(Clone, Default, Serialize)]
+pub struct MbRelationshipJson {
+ pub mbid: String,
+ pub name: String,
+ pub role: String,
+ pub tooltip: String,
+}
+
+#[derive(Clone, Default, Serialize)]
+pub struct MbRelationshipsJson {
+ pub members: Vec,
+ pub groups: Vec,
+ pub collaborators: Vec,
+ #[serde(rename = "hasData")]
+ pub has_data: bool,
+}
+
+/// One "You may also like" candidate. `qobuzId` is "" when the MB candidate
+/// never validated against Qobuz — those rows are informational only.
+#[derive(Clone, Default, Serialize)]
+pub struct MbDiscoveryJson {
+ pub mbid: String,
+ pub name: String,
+ #[serde(rename = "qobuzId")]
+ pub qobuz_id: String,
+}
+
+/// Everything the Network sidebar renders beyond LABELS / SIMILAR ARTISTS
+/// (which ride the Qobuz artist page). Mirrors Slint's `NetworkSidebarState`.
+#[derive(Clone, Default, Serialize)]
+pub struct ArtistNetwork {
+ /// MusicBrainz is enabled AND this artist resolved to an MBID. False =
+ /// every MB-driven section is ABSENT (opt-in rule: no error, no frame).
+ #[serde(rename = "mbAvailable")]
+ pub mb_available: bool,
+ pub mbid: String,
+ #[serde(rename = "originLoading")]
+ pub origin_loading: bool,
+ pub origin: MbOriginJson,
+ #[serde(rename = "relationshipsLoading")]
+ pub relationships_loading: bool,
+ pub relationships: MbRelationshipsJson,
+ #[serde(rename = "discoveryLoading")]
+ pub discovery_loading: bool,
+ #[serde(rename = "discoveryTag")]
+ pub discovery_tag: String,
+ pub discovery: Vec,
+}
+
#[derive(Default, Serialize)]
pub struct ArtistViewData {
pub id: String,
@@ -80,6 +183,9 @@ pub struct ArtistViewData {
pub bio_short: String,
#[serde(rename = "bioTruncated")]
pub bio_truncated: bool,
+ /// Biography attribution ("TiVo" etc); "" when Qobuz sends none.
+ #[serde(rename = "bioSource")]
+ pub bio_source: String,
#[serde(rename = "artUrl")]
pub artwork_url: String,
#[serde(rename = "isFollowing")]
@@ -100,6 +206,12 @@ pub struct ArtistViewData {
#[serde(rename = "similarArtists")]
pub similar_artists: Vec,
pub playlists: Vec,
+ /// Magazine tab. Empty + `storiesLoading` false = "No stories".
+ pub stories: Vec,
+ #[serde(rename = "storiesLoading")]
+ pub stories_loading: bool,
+ /// MusicBrainz-driven Network sidebar sections (filled progressively).
+ pub network: ArtistNetwork,
}
fn mmss(secs: u32) -> String {
@@ -213,11 +325,26 @@ pub async fn load_artist(
fn map_artist(page: PageArtistResponse) -> ArtistViewData {
let name = page.name.display;
- let bio = page
- .biography
- .and_then(|b| b.content)
- .map(|c| qbz_text_utils::strip_html::strip_html(&c))
- .unwrap_or_default();
+ // Biography: content (HTML-stripped) + the attribution name. Qobuz sends
+ // `biography.source` as a raw JSON value (sometimes a string, sometimes an
+ // object) — only the string form carries an attribution.
+ let (bio, bio_source) = match page.biography {
+ Some(biography) => {
+ let content = biography
+ .content
+ .map(|c| qbz_text_utils::strip_html::strip_html(&c))
+ .unwrap_or_default();
+ let source = biography
+ .source
+ .and_then(|v| {
+ v.as_str()
+ .map(qbz_text_utils::strip_html::decode_html_entities)
+ })
+ .unwrap_or_default();
+ (content, source)
+ }
+ None => (String::new(), String::new()),
+ };
let bio_short = truncate_words(&bio, 360);
let bio_truncated = bio_short != bio;
let artwork_url = page
@@ -391,6 +518,7 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData {
bio,
bio_short,
bio_truncated,
+ bio_source,
artwork_url,
is_following,
is_pinned: crate::sidebar_qt::is_pinned("artist", &page.id.to_string()),
@@ -402,6 +530,10 @@ fn map_artist(page: PageArtistResponse) -> ArtistViewData {
labels,
similar_artists,
playlists,
+ // Filled by the background enrichment pass (see load_artist_view).
+ stories: Vec::new(),
+ stories_loading: false,
+ network: ArtistNetwork::default(),
}
}
@@ -479,19 +611,88 @@ pub async fn load_release_page(
.await
.map_err(|e| e.to_string())?;
let has_more = resp.has_more;
- let cards = resp.items.iter().map(map_release).collect();
+ let cards: Vec = resp.items.iter().map(map_release).collect();
+ // Fold the page into the stashed document too. The view appends it to its
+ // own parsed copy when the signal lands, but an enrichment pass that
+ // republishes the document afterwards would otherwise reset the section
+ // back to page 1. Ids are deduped on both sides.
+ merge_release_page(artist_id, release_type, &cards, has_more);
Ok((cards, has_more))
}
-/// Fetch + publish (perf-marked like phase 5).
+/// Append `cards` to `release_type`'s bucket inside the stashed document
+/// (no republish — the caller's `releaseSectionReady` signal already paints
+/// them; this only keeps a LATER republish from losing them). Ignored when
+/// the stashed document is for a different artist (the user navigated away
+/// while the page was in flight).
+fn merge_release_page(
+ artist_id: &str,
+ release_type: &str,
+ cards: &[AlbumCardData],
+ has_more: bool,
+) {
+ let Ok(mut guard) = ARTIST_DOC.lock() else {
+ return;
+ };
+ let Some((_, doc)) = guard.as_mut() else {
+ return;
+ };
+ if doc.get("id").and_then(|v| v.as_str()) != Some(artist_id) {
+ return;
+ }
+ let Some(sections) = doc
+ .get_mut("releaseSections")
+ .and_then(|v| v.as_array_mut())
+ else {
+ return;
+ };
+ let Some(section) = sections.iter_mut().find(|s| {
+ s.get("releaseType").and_then(|v| v.as_str()) == Some(release_type)
+ }) else {
+ return;
+ };
+ section["hasMore"] = json!(has_more);
+ let Some(existing) = section.get_mut("cards").and_then(|v| v.as_array_mut()) else {
+ return;
+ };
+ let seen: HashSet = existing
+ .iter()
+ .filter_map(|c| c.get("id").and_then(|v| v.as_str()).map(str::to_string))
+ .collect();
+ for card in cards {
+ if seen.contains(&card.id) {
+ continue;
+ }
+ if let Ok(value) = serde_json::to_value(card) {
+ existing.push(value);
+ }
+ }
+}
+
+/// Fetch + publish (perf-marked like phase 5), then kick the progressive
+/// enrichment (Magazine stories + the MusicBrainz Network sidebar) off in the
+/// background. The returned JSON is what main.rs writes onto the bridge; every
+/// later pass re-publishes the SAME document through `publish_patch`.
pub async fn load_artist_view(
runtime: &Arc>,
artist_id: &str,
) -> Result {
let t = Instant::now();
- let data = load_artist(runtime, artist_id).await?;
+ let mut data = load_artist(runtime, artist_id).await?;
let sections: usize = data.release_sections.iter().map(|s| s.cards.len()).sum();
stash_top_queue(&data);
+
+ // Seed the sidebar's own loading flags BEFORE serializing so the sections
+ // paint their "Loading…" state with the first frame. `mb_available` is the
+ // user's MusicBrainz opt-in read straight from the core: false here means
+ // the enrichment task never even starts, so no MB request is issued.
+ let mb_on = runtime.core().musicbrainz_is_enabled().await;
+ data.network.mb_available = mb_on;
+ data.network.origin_loading = mb_on;
+ data.network.relationships_loading = mb_on;
+ data.network.discovery_loading = mb_on;
+ data.stories_loading = true;
+
let json = serde_json::to_string(&data).map_err(|e| e.to_string())?;
log::info!(
"[qbz-qt][perf] artist load: {:?} ({} top tracks, {} releases in {} sections)",
@@ -500,5 +701,487 @@ pub async fn load_artist_view(
sections,
data.release_sections.len(),
);
+
+ // Stash the document for the progressive passes, under a fresh generation.
+ let generation = ARTIST_GEN.fetch_add(1, Ordering::SeqCst) + 1;
+ match serde_json::from_str::(&json) {
+ Ok(doc) => {
+ if let Ok(mut guard) = ARTIST_DOC.lock() {
+ *guard = Some((generation, doc));
+ }
+ }
+ Err(e) => log::warn!("[qbz-qt] artist doc stash failed: {e}"),
+ }
+
+ let similar_names: Vec =
+ data.similar_artists.iter().map(|s| s.name.clone()).collect();
+ spawn_enrichment(
+ runtime.clone(),
+ generation,
+ artist_id.to_string(),
+ data.name.clone(),
+ similar_names,
+ mb_on,
+ );
+
Ok(json)
}
+
+// ==================== Progressive enrichment (stories + MusicBrainz) =======
+
+/// Monotonic id for the artist page currently on screen. Every background
+/// pass carries the generation it was started for and is dropped when the
+/// user has navigated on — a slow MB reply can never repaint a later artist.
+static ARTIST_GEN: AtomicU64 = AtomicU64::new(0);
+/// The last published artist document, kept so a partial update can be merged
+/// into it and the WHOLE document re-published (the bridge carries exactly one
+/// JSON property, phase-23 pattern).
+static ARTIST_DOC: Mutex