feat(qbz-qt): fill the Album and Artist pages from the integrations

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
This commit is contained in:
vicrodh committed 2026-07-28 16:23:36 -06:00
1 parent e0fcf7a089
commit 49ab5f5b9d
7 files changed
+1333 -79

No files matched your search

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<svg fill="#d9dde2" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><path d="M1.742 11.982c0-5.668 4.61-10.278 10.276-10.278 1.824 0 3.537.48 5.025 1.317l.814-1.488A11.914 11.914 0 0 0 12.19.003h-.195C5.41.013.072 5.31 0 11.885V12a11.983 11.983 0 0 0 3.775 8.72l1.185-1.28a10.249 10.249 0 0 1-3.218-7.459zm18.62-8.577-1.154 1.24a10.25 10.25 0 0 1 3.088 7.337c0 5.666-4.61 10.276-10.276 10.276-1.783 0-3.46-.456-4.922-1.258l-.854 1.522A11.946 11.946 0 0 0 12 23.998c6.626 0 12.001-5.373 12.001-12a11.977 11.977 0 0 0-3.638-8.593zM1.963 11.982a10.03 10.03 0 0 0 3.146 7.295l1.18-1.276a8.295 8.295 0 0 1-2.586-6.019c0-4.586 3.73-8.315 8.315-8.315 1.483 0 2.875.391 4.082 1.075l.835-1.526a9.973 9.973 0 0 0-4.917-1.289C6.475 1.925 1.963 6.437 1.963 11.982zm18.37 0c0 4.586-3.73 8.315-8.315 8.315a8.273 8.273 0 0 1-3.962-1.005l-.852 1.516a10.006 10.006 0 0 0 4.814 1.229c5.543 0 10.055-4.512 10.055-10.055 0-2.808-1.157-5.347-3.017-7.173l-1.183 1.274a8.282 8.282 0 0 1 2.46 5.899zm-1.948 0a6.37 6.37 0 0 1-6.365 6.364 6.329 6.329 0 0 1-3.006-.756l-.848 1.507a8.039 8.039 0 0 0 3.854.977c4.464 0 8.095-3.63 8.095-8.094a8.07 8.07 0 0 0-2.39-5.738l-1.179 1.267a6.356 6.356 0 0 1 1.839 4.473zm-14.459 0c0 2.301.967 4.382 2.515 5.858l1.173-1.27a6.344 6.344 0 0 1-1.96-4.588 6.37 6.37 0 0 1 6.364-6.364 6.32 6.32 0 0 1 3.144.835l.83-1.517a8.055 8.055 0 0 0-3.974-1.048c-4.461 0-8.092 3.63-8.092 8.094zm12.53 0a4.438 4.438 0 0 1-4.438 4.437 4.42 4.42 0 0 1-2.061-.509l-.835 1.488a6.114 6.114 0 0 0 2.896.727 6.149 6.149 0 0 0 6.143-6.143 6.123 6.123 0 0 0-1.768-4.308l-1.162 1.25a4.43 4.43 0 0 1 1.224 3.058zm-10.581 0a6.12 6.12 0 0 0 1.888 4.425l1.157-1.25.014.014a4.419 4.419 0 0 1-1.355-3.187 4.436 4.436 0 0 1 4.437-4.437 4.4 4.4 0 0 1 2.217.598l.82-1.498a6.097 6.097 0 0 0-3.037-.806c-3.384-.005-6.141 2.753-6.141 6.141zm6.68 0a.538.538 0 0 1-1.074 0 .537.537 0 1 1 1.075 0zm-3.94 0a3.4 3.4 0 1 1 6.801 0 3.4 3.4 0 0 1-6.8 0zm.149 0a3.256 3.256 0 0 0 3.252 3.252 3.255 3.255 0 0 0 3.254-3.252 3.253 3.253 0 1 0-6.506 0z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg height="800px" width="800px" version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112.195 112.195" xml:space="preserve">
<g>
<path style="fill:#D51007;" d="M112.195,56.097c0,30.983-25.114,56.099-56.097,56.099C25.115,112.195,0,87.08,0,56.097C0,25.117,25.115,0,56.099,0C87.081,0,112.195,25.117,112.195,56.097z"/>
<path style="fill:#FFFFFF;" d="M80.487,52.141c-6.203-1.849-8.412-2.874-8.412-4.922c0-3.416,4.937-4.702,5.497-4.79c3.139-0.465,7.072,1.173,8.34,4.99l8.44-2.553c-2.65-8.385-10.487-12.099-18.099-10.975c-7.788,1.146-12.782,6.786-12.782,13.327c0,8.8,8.244,11.332,14.43,13.181c6.509,1.944,8.401,2.637,8.401,5.023c0,2.016-1.196,3.895-4.374,4.735c-6.32,1.664-14.586-0.672-16.887-4.523c-1.351-2.261-2.733-5.203-4.001-8.53c-3.766-9.866-8.926-23.371-23.764-23.371c-7.731,0-21.851,3.743-21.851,24.868c0,8.744,7.481,19.613,21.578,19.613c11.829,0,13.888-4.32,14.358-5.085l-3.861-7.555c-0.098,0.138-3.692,4.936-10.497,4.936c-11.107,0-12.735-11.776-12.735-11.909c0-10.967,4.687-16.238,13.008-16.238c8.094,0,11.382,7.04,15.48,17.759c1.397,3.683,2.922,6.966,4.65,9.865c3.657,6.116,11.55,8.091,19.473,8.091c2.418,0,5.2,0.137,7.351-0.432c7.093-1.878,10.905-6.178,10.905-12.224C95.137,56.218,86.852,54.044,80.487,52.141z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>MusicBrainz icon</title><defs><linearGradient id="mbHalf" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#ba478f"/><stop offset="0.5" stop-color="#ba478f"/><stop offset="0.5" stop-color="#eb743b"/><stop offset="1" stop-color="#eb743b"/></linearGradient></defs><path fill="url(#mbHalf)" d="M11.582 0L1.418 5.832v12.336L11.582 24V10.01L7.1 12.668v3.664c.01.111.01.225 0 .336-.103.435-.54.804-1 1.111-.802.537-1.752.509-2.166-.111-.413-.62-.141-1.631.666-2.168.384-.28.863-.399 1.334-.332V6.619c0-.154.134-.252.226-.308L11.582 3zm.836 0v6.162c.574.03 1.14.16 1.668.387a2.225 2.225 0 0 0 1.656-.717 1.02 1.02 0 1 1 1.832-.803l.004.006a1.022 1.022 0 0 1-1.295 1.197c-.34.403-.792.698-1.297.85.34.263.641.576.891.928a1.04 1.04 0 0 1 .777.125c.768.486.568 1.657-.318 1.857-.886.2-1.574-.77-1.09-1.539.02-.03.042-.06.065-.09a3.598 3.598 0 0 0-1.436-1.166 4.142 4.142 0 0 0-1.457-.369v4.01c.855.06 1.256.493 1.555.834.227.256.356.39.578.402.323.018.568.008.806 0a5.44 5.44 0 0 1 .895.022c.94-.017 1.272-.226 1.605-.446a2.533 2.533 0 0 1 1.131-.463 1.027 1.027 0 0 1 .12-.263 1.04 1.04 0 0 1 .105-.137c.023-.025.047-.044.07-.066a4.775 4.775 0 0 1 0-2.405l-.012-.01a1.02 1.02 0 1 1 .692.272h-.057a4.288 4.288 0 0 0 0 1.877h.063a1.02 1.02 0 1 1-.545 1.883l-.047-.033a1 1 0 0 1-.352-.442 1.885 1.885 0 0 0-.814.354 3.03 3.03 0 0 1-.703.365c.757.555 1.772 1.6 2.199 2.299a1.03 1.03 0 0 1 .256-.033 1.02 1.02 0 1 1-.545 1.88l-.047-.03a1.017 1.017 0 0 1-.27-1.376.72.72 0 0 1 .051-.072c-.445-.775-2.026-2.28-2.46-2.387a4.037 4.037 0 0 0-1.31-.117c-.24.008-.513.018-.866 0-.515-.027-.783-.333-1.043-.629-.26-.296-.51-.56-1.055-.611V18.5a1.877 1.877 0 0 0 .426-.135.333.333 0 0 1 .058-.027c.56-.267 1.421-.91 2.096-2.447a1.02 1.02 0 0 1-.27-1.344 1.02 1.02 0 1 1 .915 1.54 6.273 6.273 0 0 1-1.432 2.136 1.785 1.785 0 0 1 .691.306.667.667 0 0 0 .37.168 3.31 3.31 0 0 0 .888-.222 1.02 1.02 0 0 1 1.787-.79v-.005a1.02 1.02 0 0 1-.773 1.683 1.022 1.022 0 0 1-.719-.287 3.935 3.935 0 0 1-1.168.287h-.05a1.313 1.313 0 0 1-.71-.275c-.262-.177-.51-.345-1.402-.12a2.098 2.098 0 0 1-.707.2V24l10.164-5.832V5.832zm4.154 4.904a.352.352 0 0 0-.197.639l.018.01c.163.1.378.053.484-.108v-.002a.352.352 0 0 0-.303-.539zm-4.99 1.928L7.082 9.5v2l4.5-2.668zm8.385.38a.352.352 0 0 0-.295.165v.002a.35.35 0 0 0 .096.473l.013.01a.357.357 0 0 0 .487-.108.352.352 0 0 0-.301-.541zM16.09 8.647a.352.352 0 0 0-.277.163.355.355 0 0 0 .296.54c.482 0 .463-.73-.02-.703zm3.877 2.477a.352.352 0 0 0-.295.164.35.35 0 0 0 .094.475l.015.01a.357.357 0 0 0 .485-.11.352.352 0 0 0-.3-.539zm-4.375 3.594a.352.352 0 0 0-.291.172.35.35 0 0 0-.04.265.352.352 0 1 0 .33-.437zm4.375.789a.352.352 0 0 0-.295.164v.002a.352.352 0 0 0 .094.473l.015.01a.357.357 0 0 0 .485-.108.352.352 0 0 0-.3-.54zm-2.803 2.488v.002a.347.347 0 0 0-.223.084.352.352 0 0 0 .23.62.347.347 0 0 0 .23-.085.348.348 0 0 0 .12-.24.353.353 0 0 0-.35-.38.347.347 0 0 0-.007 0Z"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+85 -6
View File
@@ -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 || ""
}
}
}
}
}
+482 -54
View File
@@ -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)
}
}
+58 -5
View File
@@ -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<AppRuntime<LoggingAdapter>>, 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()
+697 -14
View File
@@ -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<MbRelationshipJson>,
pub groups: Vec<MbRelationshipJson>,
pub collaborators: Vec<MbRelationshipJson>,
#[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<MbDiscoveryJson>,
}
#[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<ArtistSimilar>,
pub playlists: Vec<ArtistPlaylist>,
/// Magazine tab. Empty + `storiesLoading` false = "No stories".
pub stories: Vec<StoryRow>,
#[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<AlbumCardData> = 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<String> = 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<AppRuntime<LoggingAdapter>>,
artist_id: &str,
) -> Result<String, String> {
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::<serde_json::Value>(&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<String> =
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<Option<(u64, serde_json::Value)>> = 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<AppRuntime<LoggingAdapter>>,
generation: u64,
artist_id: String,
artist_name: String,
similar_names: Vec<String>,
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 (&amp; …),
// 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<AppRuntime<LoggingAdapter>>,
artist_id: &str,
) -> Vec<StoryRow> {
let Ok(id) = artist_id.parse::<u64>() 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<AppRuntime<LoggingAdapter>>,
artist_name: &str,
) -> Result<Option<MbMetadata>, 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<AppRuntime<LoggingAdapter>>,
mbid: &str,
) -> Result<MbRelationshipsJson, String> {
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<qbz_integrations::musicbrainz::RelatedArtist>,
default_role: &str,
) -> Vec<MbRelationshipJson> {
struct Pending {
name: String,
roles: Vec<String>,
begin: Option<String>,
end: Option<String>,
}
let mut by_mbid: HashMap<String, Pending> = HashMap::new();
let mut order: Vec<String> = 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<AppRuntime<LoggingAdapter>>,
seed_mbid: &str,
seed_name: &str,
similar_names: Vec<String>,
) -> Result<(String, Vec<MbDiscoveryJson>), String> {
let dismissed = |_tag: &str| HashSet::<String>::new();
let known = || (HashSet::<u64>::new(), HashSet::<String>::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))
}