From 49ab5f5b9da11f8ea6246addba5ccf4eab90566e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:23:36 -0600 Subject: [PATCH] feat(qbz-qt): fill the Album and Artist pages from the integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two pages were missing most of what Slint shows. With MusicBrainz, Last.fm and ListenBrainz wired this round, the sections that depend on them can exist: the artist biography and network, relationship rows, listener figures and the credit blocks the Slint pages carry. Nothing is faked. A section whose data an integration cannot supply is absent rather than an empty frame, and a section fed by an integration the user has not connected degrades to absent without a network call — integrations stay strictly opt-in. The relationship rows are non-navigable for now because resolving a musician name to a catalog artist needs a bridge invokable that is not there yet; an informational row is better than a dead click. cc --- .../qbz-qt/qml/assets/brand/brand-discogs.svg | 2 + .../qbz-qt/qml/assets/brand/brand-lastfm.svg | 7 + .../qml/assets/brand/brand-musicbrainz.svg | 2 + crates/qbz-qt/qml/views/AlbumView.qml | 91 ++- crates/qbz-qt/qml/views/ArtistView.qml | 536 +++++++++++-- crates/qbz-qt/src/album_qt.rs | 63 +- crates/qbz-qt/src/artist_qt.rs | 711 +++++++++++++++++- 7 files changed, 1333 insertions(+), 79 deletions(-) create mode 100644 crates/qbz-qt/qml/assets/brand/brand-discogs.svg create mode 100644 crates/qbz-qt/qml/assets/brand/brand-lastfm.svg create mode 100644 crates/qbz-qt/qml/assets/brand/brand-musicbrainz.svg 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 @@ + +MusicBrainz icon 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> = Mutex::new(None); + +/// Merge `f`'s edits into the stashed document and re-publish it, but only +/// while `generation` is still the page on screen. +fn publish_patch(generation: u64, f: impl FnOnce(&mut serde_json::Value)) { + let json = { + let Ok(mut guard) = ARTIST_DOC.lock() else { + return; + }; + let Some((current, doc)) = guard.as_mut() else { + return; + }; + if *current != generation { + return; + } + f(doc); + match serde_json::to_string(&*doc) { + Ok(s) => s, + Err(e) => { + log::warn!("[qbz-qt] artist doc republish failed: {e}"); + return; + } + } + }; + crate::artist_bridge::ui(move |mut b| { + b.as_mut().set_artist_json(QString::from(json.as_str())); + }); +} + +/// Patch fields inside the `network` object and re-publish. +fn publish_network(generation: u64, fields: Vec<(&'static str, serde_json::Value)>) { + publish_patch(generation, move |doc| { + if let Some(net) = doc.get_mut("network").and_then(|v| v.as_object_mut()) { + for (key, value) in fields { + net.insert(key.to_string(), value); + } + } + }); +} + +/// Every MB section off at once — MB disabled, no confident match, or the +/// resolve failed. The sidebar renders NOTHING for them (opt-in rule). +fn publish_mb_unavailable(generation: u64) { + publish_network( + generation, + vec![ + ("mbAvailable", json!(false)), + ("originLoading", json!(false)), + ("relationshipsLoading", json!(false)), + ("discoveryLoading", json!(false)), + ], + ); +} + +fn spawn_enrichment( + runtime: Arc>, + generation: u64, + artist_id: String, + artist_name: String, + similar_names: Vec, + mb_on: bool, +) { + // --- Magazine stories (Qobuz editorial, MB-independent) --------------- + { + let runtime = runtime.clone(); + crate::spawn(async move { + let stories = load_stories(&runtime, &artist_id).await; + publish_patch(generation, move |doc| { + doc["stories"] = + serde_json::to_value(&stories).unwrap_or_else(|_| json!([])); + doc["storiesLoading"] = json!(false); + }); + }); + } + + // --- MusicBrainz network sidebar -------------------------------------- + // Hard gate: with MB off nothing below runs, so nothing leaves the process. + if !mb_on { + return; + } + crate::spawn(async move { + let meta = match load_mb_metadata(&runtime, &artist_name).await { + Ok(Some(meta)) => meta, + Ok(None) => { + publish_mb_unavailable(generation); + return; + } + Err(e) => { + log::warn!("[qbz-qt] MB metadata load failed: {e}"); + publish_mb_unavailable(generation); + return; + } + }; + let mbid = meta.mbid.clone(); + publish_network( + generation, + vec![ + ("mbid", json!(mbid.clone())), + ( + "origin", + serde_json::to_value(&meta.origin).unwrap_or_else(|_| json!({})), + ), + ("originLoading", json!(false)), + ], + ); + + match load_mb_relationships(&runtime, &mbid).await { + Ok(rel) => publish_network( + generation, + vec![ + ( + "relationships", + serde_json::to_value(&rel).unwrap_or_else(|_| json!({})), + ), + ("relationshipsLoading", json!(false)), + ], + ), + Err(e) => { + log::warn!("[qbz-qt] MB relationships failed: {e}"); + publish_network(generation, vec![("relationshipsLoading", json!(false))]); + } + } + + match load_mb_discovery(&runtime, &mbid, &artist_name, similar_names).await { + Ok((tag, rows)) => publish_network( + generation, + vec![ + ("discoveryTag", json!(tag)), + ( + "discovery", + serde_json::to_value(&rows).unwrap_or_else(|_| json!([])), + ), + ("discoveryLoading", json!(false)), + ], + ), + Err(e) => { + log::warn!("[qbz-qt] MB discovery failed: {e}"); + publish_network(generation, vec![("discoveryLoading", json!(false))]); + } + } + }); +} + +// ----- Magazine stories --------------------------------------------------- + +fn map_story(item: ArtistStoryItem) -> StoryRow { + let author = item + .authors + .and_then(|list| list.into_iter().next()) + .map(|a| a.name) + .unwrap_or_default(); + // `image` is a ready-to-use arc-cdn URL; fall back to the first `images[]`. + let art_url = item + .image + .or_else(|| { + item.images + .and_then(|list| list.into_iter().next()) + .map(|img| img.url) + }) + .unwrap_or_default(); + StoryRow { + url: format!("https://play.qobuz.com/magazine/story/{}", item.id), + // Magazine content comes from a CMS: titles carry entities (& …), + // excerpts may additionally carry markup. + title: qbz_text_utils::strip_html::decode_html_entities(&item.title), + author, + excerpt: item + .description_short + .as_deref() + .map(qbz_text_utils::strip_html::strip_html) + .unwrap_or_default(), + art_url, + } +} + +/// The artist's Magazine stories (limit 2, like the official client). Any +/// failure yields an empty list and the tab shows "No stories". +async fn load_stories( + runtime: &Arc>, + artist_id: &str, +) -> Vec { + let Ok(id) = artist_id.parse::() else { + return Vec::new(); + }; + match runtime.core().get_artist_story(id, 0, 2).await { + Ok(resp) => resp.items.into_iter().map(map_story).collect(), + Err(e) => { + log::warn!("[qbz-qt] artist story load failed: {e}"); + Vec::new() + } + } +} + +// ----- MusicBrainz: Origin ------------------------------------------------ + +struct MbMetadata { + mbid: String, + origin: MbOriginJson, +} + +/// Resolve the artist name to an MBID, then fetch its metadata. `Ok(None)` = +/// MB disabled or no confident match — the caller hides every MB section. +async fn load_mb_metadata( + runtime: &Arc>, + artist_name: &str, +) -> Result, String> { + if !runtime.core().musicbrainz_is_enabled().await { + return Ok(None); + } + let resolved = runtime + .core() + .musicbrainz_resolve_artist(artist_name) + .await + .map_err(|e| e.to_string())?; + let Some(resolved) = resolved else { + return Ok(None); + }; + let mbid = resolved.mbid; + if mbid.is_empty() { + return Ok(None); + } + let meta = runtime + .core() + .musicbrainz_get_artist_metadata(&mbid) + .await + .map_err(|e| e.to_string())?; + Ok(Some(MbMetadata { + origin: map_origin(&meta), + mbid, + })) +} + +fn map_origin(meta: &qbz_integrations::musicbrainz::ArtistMetadata) -> MbOriginJson { + use qbz_integrations::musicbrainz::ArtistType; + + let is_person = matches!(meta.artist_type, ArtistType::Person); + let begin_date = meta + .life_span + .as_ref() + .and_then(|ls| ls.begin.as_deref().map(format_mb_date_short)) + .unwrap_or_default(); + let end_date = meta + .life_span + .as_ref() + .and_then(|ls| ls.end.as_deref().map(format_mb_date_short)) + .unwrap_or_default(); + let location_display = meta + .location + .as_ref() + .map(|loc| loc.display_name.clone()) + .unwrap_or_default(); + let has_data = + !begin_date.is_empty() || !end_date.is_empty() || !location_display.is_empty(); + MbOriginJson { + is_person, + begin_date, + end_date, + location_display, + has_data, + } +} + +/// A MusicBrainz partial date as "1990" / "May 1990" / "May 14, 1990" +/// (artist.rs `format_mb_date_short`; month names go through the catalog). +fn format_mb_date_short(date: &str) -> String { + let parts: Vec<&str> = date.split('-').collect(); + let month = |m: &str| -> Option<&'static str> { + Some(match m { + "01" => qbz_i18n::mark("January"), + "02" => qbz_i18n::mark("February"), + "03" => qbz_i18n::mark("March"), + "04" => qbz_i18n::mark("April"), + "05" => qbz_i18n::mark("May"), + "06" => qbz_i18n::mark("June"), + "07" => qbz_i18n::mark("July"), + "08" => qbz_i18n::mark("August"), + "09" => qbz_i18n::mark("September"), + "10" => qbz_i18n::mark("October"), + "11" => qbz_i18n::mark("November"), + "12" => qbz_i18n::mark("December"), + _ => return None, + }) + }; + match parts.as_slice() { + [y] => (*y).to_string(), + [y, m] => match month(m) { + Some(name) => { + let name_tr = qbz_i18n::t(name); + qbz_i18n::t_args("{} {}", &[name_tr.as_str(), *y]) + } + None => date.to_string(), + }, + [y, m, d] => match month(m) { + Some(name) => { + let day = d.trim_start_matches('0'); + let name_tr = qbz_i18n::t(name); + qbz_i18n::t_args("{} {}, {}", &[name_tr.as_str(), day, *y]) + } + None => date.to_string(), + }, + _ => date.to_string(), + } +} + +// ----- MusicBrainz: Relationships ----------------------------------------- + +async fn load_mb_relationships( + runtime: &Arc>, + mbid: &str, +) -> Result { + let relations = runtime + .core() + .musicbrainz_get_artist_relationships(mbid) + .await + .map_err(|e| e.to_string())?; + Ok(map_relationships(relations)) +} + +fn map_relationships( + rels: qbz_integrations::musicbrainz::ArtistRelationships, +) -> MbRelationshipsJson { + let members = group_relations(rels.members, "Band Member"); + let groups = group_relations(rels.groups, "Band"); + let collaborators = group_relations(rels.collaborators, "Collaborator"); + let has_data = !members.is_empty() || !groups.is_empty() || !collaborators.is_empty(); + MbRelationshipsJson { + members, + groups, + collaborators, + has_data, + } +} + +/// Group repeated relations by mbid, combining their roles (artist.rs +/// `group_relations` / Tauri's `groupMembersByMbid`). +fn group_relations( + rels: Vec, + default_role: &str, +) -> Vec { + struct Pending { + name: String, + roles: Vec, + begin: Option, + end: Option, + } + let mut by_mbid: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + for r in rels { + let begin = r.period.as_ref().and_then(|p| p.begin.clone()); + let end = r.period.as_ref().and_then(|p| p.end.clone()); + match by_mbid.get_mut(&r.mbid) { + Some(existing) => { + if let Some(role) = r.role.clone() { + if !existing.roles.iter().any(|rr| rr == &role) { + existing.roles.push(role); + } + } + } + None => { + order.push(r.mbid.clone()); + let mut roles = Vec::new(); + if let Some(role) = r.role.clone() { + roles.push(role); + } + by_mbid.insert( + r.mbid.clone(), + Pending { + name: r.name, + roles, + begin, + end, + }, + ); + } + } + } + order + .into_iter() + .filter_map(|mbid| by_mbid.remove(&mbid).map(|p| (mbid, p))) + .map(|(mbid, p)| { + let period = format_period(p.begin.as_deref(), p.end.as_deref()); + let tooltip = if !p.roles.is_empty() { + let roles_joined = p.roles.join(", "); + if period.is_empty() { + roles_joined + } else { + format!("{roles_joined} ({period})") + } + } else if !period.is_empty() { + period.clone() + } else { + p.name.clone() + }; + let role = p + .roles + .first() + .cloned() + .unwrap_or_else(|| default_role.to_string()); + MbRelationshipJson { + mbid, + name: p.name, + role, + tooltip, + } + }) + .collect() +} + +fn format_period(begin: Option<&str>, end: Option<&str>) -> String { + if begin.is_some() || end.is_some() { + format!("{} - {}", begin.unwrap_or("?"), end.unwrap_or("present")) + } else { + String::new() + } +} + +// ----- MusicBrainz: Discovery ("You may also like") ----------------------- + +/// Tag-seeded discovery candidates, validated against Qobuz by the core. +/// Returns `(primary_tag, rows)`. +/// +/// The `dismissed_per_tag` / `known_artists` callbacks are EMPTY here: the +/// Slint app feeds them from its `discovery_dismiss`, `play_history` and +/// `reco` stores, none of which this POC opens. Empty = no exclusion, which +/// is exactly what a first-run Slint profile does. +async fn load_mb_discovery( + runtime: &Arc>, + seed_mbid: &str, + seed_name: &str, + similar_names: Vec, +) -> Result<(String, Vec), String> { + let dismissed = |_tag: &str| HashSet::::new(); + let known = || (HashSet::::new(), HashSet::::new()); + let response = runtime + .core() + .musicbrainz_discover_artists(seed_mbid, seed_name, &similar_names, &dismissed, &known) + .await + .map_err(|e| e.to_string())?; + let rows = response + .artists + .into_iter() + .map(|a| MbDiscoveryJson { + mbid: a.mbid, + name: a.name, + qobuz_id: a.qobuz_id.map(|id| id.to_string()).unwrap_or_default(), + }) + .collect(); + Ok((response.primary_tag, rows)) +}