From 22e064f340757bd1848ff3a044b7dacc7671003c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:26:11 -0600 Subject: [PATCH 1/4] feat(models): albums and tracks carry the catalog's purchasable flag Qobuz answers `purchasable` on every album and track object (captured in the API samples); the models never read it. `Some(false)` is the catalog saying a release is not sold, absence stays unknown. The exhaustive test fixtures in qbz-core and qbz-playlist-import spell the field out. cc --- crates/qbz-core/src/core.rs | 2 ++ crates/qbz-models/src/types.rs | 8 ++++++++ crates/qbz-playlist-import/src/match_qobuz.rs | 1 + 3 files changed, 11 insertions(+) diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 12bee64d8..6bfcb382c 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -3863,6 +3863,7 @@ mod tests { .collect(); Album { favorited_at: None, + purchasable: None, id: String::new(), title: String::new(), artist: Artist { @@ -3900,6 +3901,7 @@ mod tests { fn track_with(performer_id: Option, composer_id: Option) -> Track { Track { favorited_at: None, + purchasable: None, id: 0, title: String::new(), version: None, diff --git a/crates/qbz-models/src/types.rs b/crates/qbz-models/src/types.rs index 281302d90..092e68639 100644 --- a/crates/qbz-models/src/types.rs +++ b/crates/qbz-models/src/types.rs @@ -518,6 +518,10 @@ pub struct Track { /// Favourites-list rows only (unix seconds); see `Album::favorited_at`. #[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")] pub favorited_at: Option, + /// Store flag from the catalog (`purchasable`): `Some(false)` is the API + /// saying this release is not sold on Qobuz; absence is unknown, not no. + #[serde(default)] + pub purchasable: Option, #[serde(default)] pub title: String, /// Subtitle/edition info from Qobuz (e.g. "Player's Ball Mix", @@ -730,6 +734,10 @@ pub struct Album { /// it (unix seconds); every other album shape leaves it `None`. #[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")] pub favorited_at: Option, + /// Store flag from the catalog (`purchasable`): `Some(false)` is the API + /// saying this release is not sold on Qobuz; absence is unknown, not no. + #[serde(default)] + pub purchasable: Option, #[serde(default)] pub title: String, #[serde(default)] diff --git a/crates/qbz-playlist-import/src/match_qobuz.rs b/crates/qbz-playlist-import/src/match_qobuz.rs index 1130b441b..065bfe6b6 100644 --- a/crates/qbz-playlist-import/src/match_qobuz.rs +++ b/crates/qbz-playlist-import/src/match_qobuz.rs @@ -393,6 +393,7 @@ mod tests { fn qobuz_track(id: u64, title: &str, artist: &str) -> Track { Track { favorited_at: None, + purchasable: None, id, title: title.to_string(), version: None, From 81c8d3908e06c2b221bb7f3720e5946ec0a3d887 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:26:11 -0600 Subject: [PATCH 2/4] feat(qt): the Releases list shows each release's label, and album menus open it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library feed album items carry the catalog label id next to its name. Library > Releases (list) gains a LABEL column between the item and its quality — a link to the label page when the id is known, muted text otherwise — hidden on the label page itself and on narrow kiosk rows. AlbumListRow and AlbumCard menus gain "View label" (the card only where its host passes the id: the library grid does). The library's release-wide availability helper is shared with the playlist rows for the next change. cc --- crates/qbz-qt/qml/cards/AlbumCard.qml | 6 +++ crates/qbz-qt/qml/views/AlbumListHeader.qml | 10 +++++ crates/qbz-qt/qml/views/AlbumListRow.qml | 38 ++++++++++++++++++- crates/qbz-qt/qml/views/LabelView.qml | 2 +- .../qbz-qt/qml/views/library/FeedGridCell.qml | 1 + crates/qbz-qt/src/library_qt.rs | 11 +++++- 6 files changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index 65480fc4a..4fa255551 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -40,6 +40,9 @@ Rectangle { property string title: "" property string artist: "" property string artistId: "" + /// The release's label id when the host knows it (library feed rows do): + /// the "View label" menu entry (2026-09-13). "" = no entry. + property string labelId: "" // Hosts with only a display snapshot (Home's persisted Pinned row) can // still make the artist line a link and resolve the destination lazily. // Normal catalog cards keep the direct artistId path and pay no lookup. @@ -391,6 +394,8 @@ Rectangle { } // Host tail (see `extraMenuEntries`). `concat` so the host's array is // never mutated — it is usually a binding's return value. + if (root.labelId !== "") + m.push({ "label": t("View label", r), "icon": "tags", "action": "view-label" }) var extra = root.extraMenuEntries || [] return extra.length > 0 ? m.concat(extra) : m } @@ -415,6 +420,7 @@ Rectangle { // denormalized snapshot and a file:// cache path is dead on any other // machine, the same reason the pin payload uses it. if (a === "favorite") { root.toggleFavorite(); return } + if (a === "view-label") { QbzHome.openLabel(root.labelId); return } if (a === "find-release") { QbzTrackReplace.openRelease(JSON.stringify({ "targetKind": "album", diff --git a/crates/qbz-qt/qml/views/AlbumListHeader.qml b/crates/qbz-qt/qml/views/AlbumListHeader.qml index 243aaf49d..4c3ae53f4 100644 --- a/crates/qbz-qt/qml/views/AlbumListHeader.qml +++ b/crates/qbz-qt/qml/views/AlbumListHeader.qml @@ -27,6 +27,10 @@ Rectangle { // AlbumListCols: art 52, quality 150, year 64, overflow 36, gap 12. readonly property int colArt: 52 + /// The release's label, between the item and its quality (2026-09-13); + /// off on the label page itself, where every row would repeat the same. + property bool showLabel: true + readonly property int colLabel: showLabel ? 150 : 0 readonly property int colQuality: 150 readonly property int colYear: 64 readonly property int colOverflow: 36 @@ -56,10 +60,16 @@ Rectangle { ColLabel { width: parent.width - root.colArt - root.colQuality - root.colYear - root.colOverflow - 4 * root.colGap + - (root.showLabel ? root.colLabel + root.colGap : 0) - (root.selectMode ? 18 + root.colGap : 0) text: QbzSession.tr("ITEM", QbzSession.trRev) elide: Text.ElideRight } + ColLabel { + visible: root.showLabel + width: root.colLabel + text: QbzSession.tr("LABEL", QbzSession.trRev) + } ColLabel { width: root.colQuality text: QbzSession.tr("QUALITY", QbzSession.trRev) diff --git a/crates/qbz-qt/qml/views/AlbumListRow.qml b/crates/qbz-qt/qml/views/AlbumListRow.qml index 08990d53c..a86e72a58 100644 --- a/crates/qbz-qt/qml/views/AlbumListRow.qml +++ b/crates/qbz-qt/qml/views/AlbumListRow.qml @@ -76,6 +76,9 @@ Rectangle { // Same columns (and widths) as AlbumListHeader.qml. readonly property int colArt: 52 + /// See AlbumListHeader.showLabel. + property bool showLabel: true + readonly property int colLabel: (!root.showLabel || (root.kioskHost && width < 500)) ? 0 : 150 readonly property int colQuality: root.kioskHost && width < 500 ? 0 : 150 readonly property int colYear: root.kioskHost && width < 500 ? 0 : 64 readonly property int colOverflow: root.kioskHost ? 44 : 36 @@ -107,7 +110,7 @@ Rectangle { return } if (root.selectMode) root.toggleSelect(mouse.modifiers) - else if (!root.pulledDead) QbzAlbum.openAlbum(root.item.id || "") + else if (!root.pulledDead) QbzAlbum.openAlbumFrom(root.item.id || "", root.item.title || "", root.item.artist || "") } // Touch has no right button. Desktop-inert: `pressAndHold` never // fires for a mouse press that is released normally, and the guard @@ -160,13 +163,17 @@ Rectangle { } if (!root.pulledDead && root.catalogRow) m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" }) + // The label page outlives a pulled release, so this one stays. + if ((root.item.labelId || "") !== "") + m.push({ "label": t("View label", r), "icon": "tags", "action": "view-label" }) return m } function menuAction(a) { var id = root.item.id || "" if (id === "") return + if (a === "view-label") { QbzHome.openLabel(root.item.labelId || ""); return } if (root.pulledDead) return - if (a === "open") QbzAlbum.openAlbum(id) + if (a === "open") QbzAlbum.openAlbumFrom(id, root.item.title || "", root.item.artist || "") else if (a === "play") QbzPlayer.playAlbum(id) // `artUrl`, never `artPath`: the store keeps a denormalized cover url // and a file:// cache path is dead on any other machine. @@ -274,6 +281,7 @@ Rectangle { // multi-select is switched on. width: parent.width - root.colArt - root.colQuality - root.colYear - root.colOverflow - 4 * root.colGap + - (root.colLabel > 0 ? root.colLabel + root.colGap : 0) - (root.selectMode ? 18 + root.colGap : 0) anchors.verticalCenter: parent.verticalCenter spacing: 2 @@ -305,6 +313,32 @@ Rectangle { } } + // LABEL — the release's label, a link to its page when the feed + // carries the id (2026-09-13). Zero-width (and skipped by the Row) + // where the column is off. + Item { + visible: root.colLabel > 0 + width: root.colLabel + height: parent.height + Text { + id: labelText + width: parent.width + anchors.verticalCenter: parent.verticalCenter + text: root.item.label || "" + color: labelArea.containsMouse && labelArea.enabled ? theme.accent : theme.textMuted + font.pixelSize: 12 + elide: Text.ElideRight + MouseArea { + id: labelArea + anchors.fill: parent + enabled: (root.item.labelId || "") !== "" + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzHome.openLabel(root.item.labelId) + } + } + } + // QUALITY — the SAME badge the album page shows (tier label over the // exact bit-depth / sample-rate line). Item { diff --git a/crates/qbz-qt/qml/views/LabelView.qml b/crates/qbz-qt/qml/views/LabelView.qml index 30bcb3c4a..c4b812833 100644 --- a/crates/qbz-qt/qml/views/LabelView.qml +++ b/crates/qbz-qt/qml/views/LabelView.qml @@ -213,7 +213,7 @@ Rectangle { height: root.kioskHost && (cr.kind === "album" || cr.kind === "playlist") ? 64 : 246 KioskCoverSource { id: cellArt; remote: root.kioskHost ? (crCell.modelData.artUrl || "") : ""; local: crCell.modelData.artPath || ""; edge: 44 } - Component { id: crKioskAlbum; AlbumListRow { kioskHost: true; item: crCell.modelData; artSource: root.kioskHost ? cellArt.source : (crCell.modelData.artPath || "") } } + Component { id: crKioskAlbum; AlbumListRow { kioskHost: true; showLabel: false; item: crCell.modelData; artSource: root.kioskHost ? cellArt.source : (crCell.modelData.artPath || "") } } Component { id: crKioskPlaylist; PlaylistListRow { kioskHost: true; item: crCell.modelData; artSource: root.kioskHost ? cellArt.source : (crCell.modelData.artPath || "") } } Component { id: crAlbum diff --git a/crates/qbz-qt/qml/views/library/FeedGridCell.qml b/crates/qbz-qt/qml/views/library/FeedGridCell.qml index 855e4d36a..c43933838 100644 --- a/crates/qbz-qt/qml/views/library/FeedGridCell.qml +++ b/crates/qbz-qt/qml/views/library/FeedGridCell.qml @@ -88,6 +88,7 @@ Item { title: cell.item.title artist: cell.item.artist artistId: cell.item.artistId + labelId: cell.item.labelId || "" genre: cell.item.genre year: cell.item.year qualityTier: cell.item.qualityTier diff --git a/crates/qbz-qt/src/library_qt.rs b/crates/qbz-qt/src/library_qt.rs index df987b9ca..22ee44f12 100644 --- a/crates/qbz-qt/src/library_qt.rs +++ b/crates/qbz-qt/src/library_qt.rs @@ -151,6 +151,9 @@ pub struct FeedItem { pub genre: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub label: String, + /// Catalog label id behind `label` ("" when the release carries none) — + /// the Releases list column and the "View label" menu entry open it. + pub label_id: String, #[serde(default, rename = "updatedAt", skip_serializing_if = "Option::is_none")] pub updated_at: Option, #[serde(default, rename = "trackCount", skip_serializing_if = "Option::is_none")] @@ -722,6 +725,11 @@ fn map_album(album: Album, ready_offline_tracks: usize) -> FeedItem { subtitle: artist.clone(), artist, artist_id: album.artist.id.to_string(), + label_id: album + .label + .as_ref() + .map(|label| label.id.to_string()) + .unwrap_or_default(), label: album.label.map(|label| label.name).unwrap_or_default(), duration_secs: album.duration.unwrap_or(0), track_count: Some(track_count as u32), @@ -1009,7 +1017,7 @@ async fn probe_release_availability( /// Resolve release-wide availability only for favourite tracks already known /// to be unavailable. Explicit embedded answers cost no request; unknown album /// ids are de-duplicated and probed with a bounded fan-out. -async fn unavailable_release_ids( +pub(crate) async fn unavailable_release_ids( runtime: &Arc>, tracks: &[Track], ) -> HashSet { @@ -1662,6 +1670,7 @@ fn map_purchased_album(a: qbz_models::PurchaseAlbum) -> FeedItem { ), added_at: a.purchased_at.unwrap_or(0), label: a.label.as_ref().map(|label| label.name.clone()).unwrap_or_default(), + label_id: a.label.as_ref().map(|label| label.id.to_string()).unwrap_or_default(), duration_secs: a.duration.unwrap_or(0), track_count: a.tracks_count, genre: a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(), From 1817de0743a1f136576174d734211f9d075d5464 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:26:11 -0600 Subject: [PATCH 3/4] feat(qt): release-aware album links, an unavailable-release page, Copy submenus and Buy on Qobuz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unavailable tracks (8): playlist rows now say when the WHOLE release is gone (`releaseUnavailable`, one probe per distinct album of the pulled rows through the library's helper); TrackRow's album link and "Go to album" go dark on it instead of opening an empty page. Every row that opens an album passes the names it knows (`openAlbumFrom`), so when the catalog answers 404 the page names the release: AlbumView renders an "unavailable" arm — heading, "This release is no longer available on Qobuz." — with the best-ranked alternatives Qobuz still sells (the replacement flow's ranking, exposed as `ranked_alternatives`) in a rail; a network failure gets the same page with "Couldn't load this release." and no rail. Copy (9): the player bar's "+" and the track row menus (shared TrackRow and the artist's Popular Tracks rows) gain a Copy entry with a submenu — "Track name" or "Track - Album - Artist" — through the shared clipboard carrier; CardMenu marks `submenu: true` entries with a chevron and the host reopens the second menu on the same anchor. Row menus keep both lazy. Buy on Qobuz (10): the album header shows a store action when the catalog says the release is purchasable and it is not already owned; the track menus and the player bar carry the same entry for catalog rows and resolve it at click time — the store page opens in the browser, or a toast says the release is not sold. The link is `https://www.qobuz.com/album/-/{id}`, which the store answers with a 301 to the localised page (verified). cc --- crates/qbz-qt/qml/controls/CardMenu.qml | 12 +++- crates/qbz-qt/qml/rows/TrackRow.qml | 69 ++++++++++++++++-- crates/qbz-qt/qml/shell/PlayerBar.qml | 45 +++++++++++- crates/qbz-qt/qml/views/AlbumView.qml | 96 +++++++++++++++++++++++-- crates/qbz-qt/qml/views/ArtistView.qml | 34 ++++++++- crates/qbz-qt/src/album_bridge.rs | 24 +++++++ crates/qbz-qt/src/album_qt.rs | 92 +++++++++++++++++++++++- crates/qbz-qt/src/main.rs | 28 +++++++- crates/qbz-qt/src/playlist_qt.rs | 17 +++++ crates/qbz-qt/src/store_qt.rs | 63 ++++++++++++++++ crates/qbz-qt/src/track_replace_qt.rs | 40 +++++++++++ 11 files changed, 502 insertions(+), 18 deletions(-) create mode 100644 crates/qbz-qt/src/store_qt.rs diff --git a/crates/qbz-qt/qml/controls/CardMenu.qml b/crates/qbz-qt/qml/controls/CardMenu.qml index e30021635..fb78981f1 100644 --- a/crates/qbz-qt/qml/controls/CardMenu.qml +++ b/crates/qbz-qt/qml/controls/CardMenu.qml @@ -80,7 +80,7 @@ QbzContextMenu { } Text { height: parent.height - width: parent.width - 23 + width: parent.width - 23 - (row.modelData.submenu === true ? 22 : 0) text: row.modelData.label || "" color: row.isDanger ? theme.danger : (row.hot ? theme.textPrimary : theme.textSecondary) @@ -88,6 +88,16 @@ QbzContextMenu { verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } + // Submenu marker (`submenu: true`): the host answers the pick + // by opening a second menu on the same anchor. + QbzIcon { + visible: row.modelData.submenu === true + name: "chevron-right" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: row.hot ? "textPrimary" : "muted" + } } MouseArea { diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index 48fc9357a..36d82da3f 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -258,6 +258,10 @@ Rectangle { /// owns — a worse bug than the one this treatment fixes, and it would bite /// hardest in offline mode. It gets `pulledCached` below instead. readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3 + /// The whole release is gone from the catalog (`releaseUnavailable`, + /// decided by the row's producer): the album link and "Go to album" go + /// dark instead of opening an empty page (2026-09-13). + readonly property bool releaseGone: root.item.releaseUnavailable === true /// Pulled from the catalogue, but playable from the downloaded copy. Keeps /// its play cell, its heart and its click; gains only an honest badge, @@ -1058,15 +1062,18 @@ Rectangle { width: cols.colAlbum anchors.verticalCenter: parent.verticalCenter text: root.item.album || "" - color: albumArea.containsMouse ? theme.accent : theme.textMuted + color: (albumArea.containsMouse && albumArea.enabled) ? theme.accent : theme.textMuted font.pixelSize: 12 elide: Text.ElideRight MouseArea { id: albumArea anchors.fill: parent + enabled: !!root.item.albumId && !root.releaseGone hoverEnabled: true - cursorShape: root.item.albumId ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (root.item.albumId) QbzAlbum.openAlbum(root.item.albumId) + cursorShape: Qt.PointingHandCursor + // The names ride along so a release the catalog lost can + // still be named on its page (album_qt::publish_unavailable). + onClicked: QbzAlbum.openAlbumFrom(root.item.albumId, root.item.album || "", root.item.artist || "") } } // Duration. @@ -1268,10 +1275,53 @@ Rectangle { /// Build the popup on first use, then open it. Called by `openRowMenu` /// below, which owns the empty-menu guard. function openRowMenuLazy(anchor, x, y) { + root._menuAnchor = anchor + root._menuX = x + root._menuY = y rowMenuLoader.active = true rowMenuLoader.item.openAtCursor(anchor, x, y) } + // --- Copy submenu + clipboard (2026-09-13) -------------------------------- + // Both lazy, like the row menu: a list row must not carry a Popup tree + // or a TextEdit per delegate. Reopened where the row menu was. + property Item _menuAnchor: null + property real _menuX: 0 + property real _menuY: 0 + Loader { + id: copyMenuLoader + active: false + sourceComponent: CardMenu { + kioskHost: root.kioskHost + menuWidth: 224 + entries: [ + { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, + { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } + ] + onPicked: function (a) { root.copyRow(a) } + } + } + Loader { + id: clipboardLoader + active: false + sourceComponent: QbzClipboard { } + } + function openCopyMenu() { + if (!root._menuAnchor) + return + copyMenuLoader.active = true + copyMenuLoader.item.openAtCursor(root._menuAnchor, root._menuX, root._menuY) + } + function copyRow(which) { + var parts = [root.item.title || ""] + if (which === "copy-full") { + if ((root.item.album || "") !== "") parts.push(root.item.album) + if ((root.item.artist || "") !== "") parts.push(root.item.artist) + } + clipboardLoader.active = true + clipboardLoader.item.copy(parts.join(" - ")) + } + // TrackContextMenu.slint, in its order. Every row here reaches a live // seam; the seamless ones are gated off above. /// Open the row menu, unless it would come up EMPTY. @@ -1362,7 +1412,7 @@ Rectangle { m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" }) } } - if (root.menuShowGoTo && root.item.albumId) + if (root.menuShowGoTo && root.item.albumId && !root.releaseGone) m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" }) if (root.menuShowGoTo && root.item.artistId) m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" }) @@ -1370,6 +1420,13 @@ Rectangle { // opens on nothing. if (root.menuShowTrackInfo && !root.pulled) m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" }) + // "Buy on Qobuz" (2026-09-13): the store page; store_qt answers with a + // toast when the catalog says the release is not sold. + if (root.catalogRow && !root.localSourceRow && !root.pulledDead) + m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) + // Copy (2026-09-13): a submenu — the track name, or "Track - Album - Artist". + if ((root.item.title || "") !== "") + m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true }) return m } @@ -1393,8 +1450,10 @@ Rectangle { } else if (a === "go-album") { if (root.routeGoToExternally) root.goToRequested("album") - else QbzAlbum.openAlbum(root.item.albumId) + else QbzAlbum.openAlbumFrom(root.item.albumId, root.item.album || "", root.item.artist || "") } + else if (a === "buy") QbzAlbum.buyTrack(root.item.id || "") + else if (a === "copy") root.openCopyMenu() else if (a === "favorite") root.toggleFavorite() else if (a === "mixtape") root.mixtapeRequested() // Add to playlist. The internal arm is the CATALOG one and nothing diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 105bd5afa..01fc58619 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -657,7 +657,7 @@ Rectangle { favorite: root.npFavorite ephemeral: root.npEphemeral tooltip: root.tooltip - onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -680,7 +680,7 @@ Rectangle { playCircle: true favorite: root.npFavorite tooltip: root.tooltip - onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -908,6 +908,26 @@ Rectangle { // "Add to…" flyout behind the transport "+" (TransportControls.slint's // add-menu), on the shared CardMenu surface. Same seven entries, same // order, same icons. + // The "+" button that opened the add-menu: the Copy submenu reopens on it. + property Item addAnchor: null + QbzClipboard { id: npClipboard } + CardMenu { + id: npCopyMenu + menuWidth: 232 + entries: [ + { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, + { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } + ] + onPicked: function (a) { + var parts = [QbzPlayer.npTitle || ""] + if (a === "copy-full") { + if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum) + if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist) + } + npClipboard.copy(parts.join(" - ")) + } + } + CardMenu { id: addMenu menuWidth: 232 @@ -963,6 +983,23 @@ Rectangle { "action": "album-favorite" }) } + // "Buy on Qobuz" (2026-09-13): catalog tracks only; store_qt + // resolves whether the release is sold and toasts when not. + if (root.npSource === "qobuz") { + m.push({ + "label": QbzSession.tr("Buy on Qobuz", QbzSession.trRev), + "icon": "shopping-bag", + "action": "buy" + }) + } + // Copy (2026-09-13): a submenu — the track name, or + // "Track - Album - Artist". + m.push({ + "label": QbzSession.tr("Copy", QbzSession.trRev), + "icon": "copy", + "action": "copy", + "submenu": true + }) return m.concat(root.abEntries()) } onPicked: function (a) { @@ -978,6 +1015,10 @@ Rectangle { } else if (a === "album-favorite") { if (QbzPlayer.npAlbumId !== "") QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) + } else if (a === "buy") { + if (id !== "") QbzAlbum.buyTrack(id) + } else if (a === "copy") { + if (root.addAnchor) npCopyMenu.openBelowRight(root.addAnchor) } else if (a === "ab-mark") { QbzPlayer.abMark() } else if (a === "ab-clear") { diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index face85dee..df98929ec 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -106,6 +106,8 @@ Rectangle { } readonly property var albumHeader: album.header || ({}) + /// The "cannot show this release" arm of the document (album_qt::publish_unavailable). + readonly property var unavailable: album.unavailable || null readonly property var tracks: album.tracks || [] readonly property var purchase: album.purchase || ({}) readonly property var localPurchaseVariants: purchase.localVariants || [] @@ -889,12 +891,79 @@ Rectangle { // NavButtons is a 0px placeholder in the Slint source. Item { width: 1; height: 22 } + // The release cannot be shown (album_qt::publish_unavailable, + // 2026-09-13): gone from the catalog, or a load failure. The + // heading comes from the row that opened it, then the reason, and + // for a gone release the best-ranked alternatives Qobuz still + // sells — the same ranking the playlist replacement flow uses. + Column { + visible: root.unavailable !== null + width: parent.width - 64 + spacing: 12 + Item { width: 1; height: 8 } + Text { + width: parent.width + text: root.unavailable && (root.unavailable.title || "") !== "" + ? root.unavailable.title + : QbzSession.tr("This release", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontTitle + font.weight: theme.weightBold + wrapMode: Text.WordWrap + } + Text { + visible: root.unavailable && (root.unavailable.artist || "") !== "" + width: parent.width + text: root.unavailable ? (root.unavailable.artist || "") : "" + color: theme.textSecondary + font.pixelSize: theme.fontSection + elide: Text.ElideRight + } + Row { + width: parent.width + spacing: 8 + QbzIcon { + name: "circle-alert" + width: 18 + height: 18 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + Text { + width: parent.width - 26 + anchors.verticalCenter: parent.verticalCenter + text: root.unavailable && root.unavailable.gone === true + ? QbzSession.tr("This release is no longer available on Qobuz.", QbzSession.trRev) + : QbzSession.tr("Couldn't load this release.", QbzSession.trRev) + + ((root.unavailable && (root.unavailable.error || "") !== "") + ? " " + root.unavailable.error : "") + color: theme.textMuted + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + } + Item { width: 1; height: 8 } + Text { + visible: root.unavailable && root.unavailable.loading === true + text: QbzSession.tr("Looking for other versions…", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + SectionRail { + visible: root.unavailable && (root.unavailable.alternatives || []).length > 0 + width: parent.width + title: QbzSession.tr("You might be interested in these instead", QbzSession.trRev) + items: root.unavailable ? (root.unavailable.alternatives || []) : [] + coverMap: root.coverMap + } + } + // --- Album header skeleton ---------------------------------- // Mounted on the primary flag, and the real header is hidden by // the same flag: opening album B never renders a half-empty // header frame while B's document is in flight. Row { - visible: root.primaryLoading + visible: root.primaryLoading && root.unavailable === null width: parent.width - 64 spacing: root.headerGapPx @@ -932,7 +1001,7 @@ Rectangle { // --- Album header ------------------------------------------- Row { - visible: !root.primaryLoading + visible: !root.primaryLoading && root.unavailable === null width: parent.width - 64 spacing: root.headerGapPx @@ -1322,6 +1391,23 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter onClicked: albumInfo.openFor(albumHeader.id) } + // "Buy on Qobuz" (2026-09-13): only when the catalog + // says the release is sold and it is not already + // owned; a link to the store page, not a purchase here. + QbzCircleAction { + id: buyButton + visible: albumHeader.purchasable === true + && (root.purchase.entitlementState || "") !== "purchased" + name: "shopping-bag" + diameterOverride: root.compactHeaderPref ? 28 : 0 + overlay: root.hdrOverlay + anchors.verticalCenter: parent.verticalCenter + onClicked: QbzAlbum.buyAlbum(albumHeader.id) + HoverHandler { id: buyHover } + ToolTip.visible: buyHover.hovered + ToolTip.text: QbzSession.tr("Buy on Qobuz", QbzSession.trRev) + ToolTip.delay: 350 + } QbzCircleAction { id: albumMenuBtn name: "ellipsis" @@ -1429,7 +1515,7 @@ Rectangle { // instead of a floating overlay. QbzMultiSelectBar { id: bulkBar - visible: root.multiSelect && !root.primaryLoading + visible: root.multiSelect && !root.primaryLoading && root.unavailable === null width: parent.width selectedCount: root.selectedCount // The reference's full AlbumView inventory @@ -1449,7 +1535,7 @@ Rectangle { // Toolbar — quality badge + track search (+ inert select). Row { - visible: !QbzAlbum.albumLoading + visible: !QbzAlbum.albumLoading && root.unavailable === null width: parent.width height: 52 spacing: 16 @@ -1560,7 +1646,7 @@ Rectangle { // the band so `centerIn` centres them, which is the fix // this block used to document at length. TrackListHeader { - visible: !QbzAlbum.albumLoading + visible: !QbzAlbum.albumLoading && root.unavailable === null width: parent.width bandHeight: 40 labelSpacing: 0.5 diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index fe0f90cb3..c9ef633f0 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -871,6 +871,9 @@ Rectangle { // row). Activated on demand, torn down on close (Qt.callLater so the // Popup is not destroyed mid-signal) — the same shape rows/TrackRow.qml // carries per row. + // One clipboard carrier for every Popular Tracks row's Copy submenu. + QbzClipboard { id: artistClipboard } + Loader { id: artistTrackInfo active: false @@ -1692,7 +1695,7 @@ Rectangle { enabled: !popRow.selectMode hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: function (mouse) { popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popRow._menuAnchor = moreArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } } } } @@ -1774,6 +1777,9 @@ Rectangle { m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" }) if (!popRow.pulled) m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" }) + if (!popRow.pulledDead) + m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) + m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true }) return m } onPicked: function (a) { @@ -1804,9 +1810,31 @@ Rectangle { else if (a === "cache") QbzPlayer.cacheTrack(id) else if (a === "uncache") QbzPlayer.uncacheTrack(id) else if (a === "recache") QbzPlayer.recacheTrack(id) - else if (a === "go-album") QbzAlbum.openAlbum(popRow.row.albumId) + else if (a === "go-album") QbzAlbum.openAlbumFrom(popRow.row.albumId, popRow.row.album || "", popRow.row.artist || "") else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId) else if (a === "track-info") root.openTrackInfo(id) + else if (a === "buy") QbzAlbum.buyTrack(id) + else if (a === "copy") popCopyMenu.openAtCursor(popRow._menuAnchor, popRow._menuX, popRow._menuY) + } + } + // Copy submenu (2026-09-13), reopened where the row menu was. + property Item _menuAnchor: null + property real _menuX: 0 + property real _menuY: 0 + CardMenu { + id: popCopyMenu + menuWidth: 224 + entries: [ + { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, + { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } + ] + onPicked: function (a) { + var parts = [popRow.row.title || ""] + if (a === "copy-full") { + if ((popRow.row.album || "") !== "") parts.push(popRow.row.album) + if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist) + } + artistClipboard.copy(parts.join(" - ")) } } @@ -1832,7 +1860,7 @@ Rectangle { id: popRcArea anchors.fill: parent acceptedButtons: Qt.RightButton - onClicked: function (mouse) { popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popRow._menuAnchor = popRcArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } } } diff --git a/crates/qbz-qt/src/album_bridge.rs b/crates/qbz-qt/src/album_bridge.rs index 2e3be06ce..aa9d9e641 100644 --- a/crates/qbz-qt/src/album_bridge.rs +++ b/crates/qbz-qt/src/album_bridge.rs @@ -62,6 +62,16 @@ pub mod qbz_album_bridge { /// Open the album detail view (pushes "album" on the nav stack). #[qinvokable] fn open_album(self: Pin<&mut QbzAlbum>, album_id: QString); + /// `open_album` with the title/artist the caller knows, so a release + /// the catalog no longer has can still be named on its page. + #[qinvokable] + fn open_album_from(self: Pin<&mut QbzAlbum>, album_id: QString, title: QString, artist: QString); + /// "Buy on Qobuz": the store page in the browser when the release is + /// sold, a toast otherwise (store_qt). + #[qinvokable] + fn buy_album(self: Pin<&mut QbzAlbum>, album_id: QString); + #[qinvokable] + fn buy_track(self: Pin<&mut QbzAlbum>, track_id: QString); /// AlbumCard's picture-in-picture action. Fetches the compact album /// document without navigating or touching `album_json`. #[qinvokable] @@ -189,6 +199,20 @@ impl qbz_album_bridge::QbzAlbum { pub fn open_album(self: Pin<&mut Self>, album_id: QString) { crate::open_album(album_id.to_string()); } + pub fn open_album_from( + self: Pin<&mut Self>, + album_id: QString, + title: QString, + artist: QString, + ) { + crate::open_album_from(album_id.to_string(), title.to_string(), artist.to_string()); + } + pub fn buy_album(self: Pin<&mut Self>, album_id: QString) { + crate::store_qt::buy_album(album_id.to_string()); + } + pub fn buy_track(self: Pin<&mut Self>, track_id: QString) { + crate::store_qt::buy_track(track_id.to_string()); + } pub fn open_quick_view(self: Pin<&mut Self>, album_id: QString) { crate::album_quick_view_qt::open(album_id.to_string()); } diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs index 6f022e4d5..3190eaebb 100644 --- a/crates/qbz-qt/src/album_qt.rs +++ b/crates/qbz-qt/src/album_qt.rs @@ -197,6 +197,9 @@ pub struct AlbumHeader { pub label: String, #[serde(rename = "labelId")] pub label_id: String, + /// The catalog's `purchasable` for the release: the header's "Buy on + /// Qobuz" action shows only on `true` (2026-09-13). + pub purchasable: bool, /// (id, name) award pairs for the sidebar. pub awards: Vec<(String, String)>, #[serde(rename = "isFavorite")] @@ -619,11 +622,19 @@ pub async fn load_album( runtime: &Arc>, album_id: &str, ) -> Result { + // A release the catalog no longer has is the one failure the page can + // explain (and offer alternatives for): it travels as `ALBUM_GONE` so + // `open_album` can tell it from a network error. let album = runtime .core() .get_album(album_id) .await - .map_err(|e| e.to_string())?; + .map_err(|e| match e { + qbz_core::CoreError::Api(ref error) if error.is_album_unavailable(album_id) => { + ALBUM_GONE.to_string() + } + other => other.to_string(), + })?; let artist = album.artist.name.clone(); let artist_id = album.artist.id.to_string(); @@ -770,6 +781,7 @@ pub async fn load_album( .as_ref() .map(|l| l.id.to_string()) .unwrap_or_default(), + purchasable: album.purchasable == Some(true), awards, has_booklet: !booklet_url.is_empty(), // album.rs:677-679 — the whole album is cached when every track is. @@ -1077,6 +1089,84 @@ pub(crate) fn format_album_title(title: &str, version: Option<&str>) -> String { /// `main-artist` role, else the first entry). `/album/suggest` returns items /// whose flat `artist` is empty but whose `artists[]` is populated — without /// the fallback the "Listening suggestions" cards render no artist line. +/// `load_album`'s error for a release the catalog no longer has (see there). +pub(crate) const ALBUM_GONE: &str = "album-gone"; + +/// The album page when the release cannot be shown (2026-09-13). `gone` is +/// the catalog saying the release no longer exists — a pulled album that +/// still answers keeps its normal page, rows marked — and anything else is a +/// load failure. Published as the `unavailable` arm of the album document, +/// which AlbumView renders instead of a header; for a gone release whose +/// title is known (the row that opened it passes one, a 404 carries none), +/// the best-ranked alternatives Qobuz still sells follow, through the same +/// ranking the playlist replacement flow uses. +pub(crate) fn publish_unavailable(album_id: String, gone: bool, error: String) { + let (title, artist) = crate::album_hint(&album_id).unwrap_or_default(); + let generation = ALBUM_GEN.fetch_add(1, Ordering::SeqCst) + 1; + let searching = gone && !title.trim().is_empty(); + let doc = json!({ + "unavailable": { + "id": album_id, + "title": title, + "artist": artist, + "gone": gone, + "error": if gone { String::new() } else { error }, + "loading": searching, + "alternatives": [], + } + }); + if let Ok(mut guard) = ALBUM_DOC.lock() { + *guard = Some((generation, doc.clone())); + } + let json = doc.to_string(); + crate::album_bridge::ui(move |mut b| { + b.as_mut().set_album_json(QString::from(json.as_str())); + b.as_mut().set_album_loading(false); + }); + if !searching { + return; + } + crate::spawn(async move { + let found = + crate::track_replace_qt::ranked_alternatives(&album_id, &title, &artist).await; + let cards: Vec = found.iter().take(12).map(album_to_card).collect(); + let value = serde_json::to_value(&cards).unwrap_or_else(|_| json!([])); + publish_patch(generation, move |doc| { + if let Some(arm) = doc.get_mut("unavailable") { + arm["alternatives"] = value; + arm["loading"] = json!(false); + } + }); + }); +} + +/// A catalog album as one card of a rail (the alternatives rail above). +fn album_to_card(album: &Album) -> AlbumCardData { + let (artist, artist_id) = card_artist(album); + let date = album + .release_date_original + .as_deref() + .or_else(|| album.dates.as_ref().and_then(|d| d.original.as_deref())); + AlbumCardData { + id: album.id.clone(), + title: format_album_title(&album.title, album.version.as_deref()), + artist, + artist_id, + genre: album.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(), + year: qbz_text_utils::dates::release_label(date), + release_sort_key: qbz_text_utils::dates::release_sort_key(date), + quality_tier: home_qt::quality_tier_from_depth(album.maximum_bit_depth).to_string(), + quality_detail: home_qt::quality_detail_from_parts( + album.maximum_bit_depth, + album.maximum_sampling_rate, + ), + art_url: album.image.best().cloned().unwrap_or_default(), + is_pinned: crate::sidebar_qt::is_pinned("album", &album.id), + is_favorite: crate::library_qt::is_favorite("album", &album.id), + default_order: 0, + } +} + fn card_artist(album: &Album) -> (String, String) { if !album.artist.name.is_empty() { return (album.artist.name.clone(), album.artist.id.to_string()); diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index ebbc99e9c..67219117d 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -299,6 +299,7 @@ mod rip_qt; mod rip_wizard_qt; mod sacd_qt; mod share_qt; +mod store_qt; mod tag_editor_bridge; mod tag_editor_qt; // Offline cache (downloads tier): state activation on login + the action @@ -1448,7 +1449,8 @@ pub(crate) fn open_album(album_id: String) { }), Err(e) => { log::warn!("[qbz-qt] album view load failed: {e}"); - album_bridge::ui(move |mut b| b.as_mut().set_album_loading(false)); + let gone = e == album_qt::ALBUM_GONE; + album_qt::publish_unavailable(album_id, gone, e); } } }); @@ -2754,6 +2756,30 @@ pub(crate) fn navigate_to_tab(view: &str, tab: &str) { /// language switch so its Rust-built section headers re-translate. static LAST_DETAIL: Mutex<(String, String)> = Mutex::new((String::new(), String::new())); +/// The names the row that opened an album knew (id, title, artist): a 404 +/// carries none, and the "no longer available" page needs them for its +/// heading and its alternatives search (album_qt::publish_unavailable). +static ALBUM_HINT: Mutex> = Mutex::new(None); + +pub(crate) fn album_hint(album_id: &str) -> Option<(String, String)> { + ALBUM_HINT + .lock() + .ok() + .and_then(|guard| guard.clone()) + .filter(|(id, _, _)| id == album_id) + .map(|(_, title, artist)| (title, artist)) +} + +/// `open_album` with the title/artist the caller knows — every row that +/// opens an album passes them, so the page can name a release the catalog +/// no longer has. +pub(crate) fn open_album_from(album_id: String, title: String, artist: String) { + if let Ok(mut guard) = ALBUM_HINT.lock() { + *guard = Some((album_id.clone(), title, artist)); + } + open_album(album_id); +} + /// Settings > Appearance > Language: the pref is already persisted by the /// settings arm; this applies it LIVE — "auto" resolves POSIX env — then: /// 1. bumps trRev, so EVERY `QbzBridge.tr(msgid, QbzBridge.trRev)` binding diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index acca6fa6d..e28286a9c 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -159,6 +159,14 @@ pub struct PlaylistTrackRow { skip_serializing_if = "std::ops::Not::not" )] pub upcoming: bool, + /// The whole RELEASE this row belongs to is gone from the catalog: every + /// track withdrawn, or `/album/get` answering unavailable — decided by + /// `library_qt::unavailable_release_ids` once the rows are built, one + /// probe per distinct album of the pulled rows. A pulled track on a live + /// album keeps `false`: its album page still opens. The row's album link + /// and "Go to album" go dark on `true` (2026-09-13). + #[serde(rename = "releaseUnavailable")] + pub release_unavailable: bool, /// The recording identifier, carried so the replacement search's ISRC /// short-circuit can fire (`qbz-playlist-import/src/match_qobuz.rs:156-159` /// scores an ISRC hit 1.0). That is the owner's "a veces cambia el ID del @@ -1199,6 +1207,8 @@ pub async fn load( .await .unwrap_or_default(); let mixed = !sidecar.is_empty(); + // Release-wide withdrawals among the pulled rows (see the field). + let gone_releases = crate::library_qt::unavailable_release_ids(runtime, &tracks).await; let merged = interleave_rows(tracks, sidecar); // Display rows + the playable snapshot + the row positions in ONE pass, so @@ -1217,6 +1227,13 @@ pub async fn load( } rows.push(item); } + if !gone_releases.is_empty() { + for item in rows.iter_mut() { + if item.not_streamable && gone_releases.contains(&item.album_id) { + item.release_unavailable = true; + } + } + } // Seam B: a mixed detail plays through local_playlist's merged queue // snapshot (source-aware QueueTracks — a local file must never be queued as diff --git a/crates/qbz-qt/src/store_qt.rs b/crates/qbz-qt/src/store_qt.rs new file mode 100644 index 000000000..99a48cb36 --- /dev/null +++ b/crates/qbz-qt/src/store_qt.rs @@ -0,0 +1,63 @@ +//! "Buy on Qobuz" (2026-09-13): a link to the store page in the browser, +//! never a purchase inside QBZ. The album page shows the action only when the +//! catalog says the release is `purchasable`; the track menus and the player +//! bar resolve that at click time — not every row document carries the flag — +//! and say so in a toast when a release is not sold. The URL keeps the store +//! locale to Qobuz: `/album/-/{id}` answers 301 to the localised page +//! (verified 2026-09-13). Tracks are sold on their album's page. + +pub(crate) fn album_store_url(album_id: &str) -> String { + format!("https://www.qobuz.com/album/-/{album_id}") +} + +fn open_store(url: String) { + if let Err(error) = open::that(&url) { + log::warn!("[qbz-qt] store link {url} failed to open: {error}"); + crate::toast_qt::error(qbz_i18n::t("Could not open the browser")); + } +} + +fn not_sold() { + crate::toast_qt::info(qbz_i18n::t("This release isn't sold on Qobuz.")); +} + +fn unknown() { + crate::toast_qt::info(qbz_i18n::t("Couldn't check the store for this release.")); +} + +pub(crate) fn buy_album(album_id: String) { + if album_id.is_empty() { + return; + } + let runtime = crate::app(); + crate::spawn(async move { + match runtime.core().get_album(&album_id).await { + Ok(album) if album.purchasable == Some(false) => not_sold(), + Ok(_) => open_store(album_store_url(&album_id)), + Err(error) => { + log::warn!("[qbz-qt] store: album {album_id} lookup failed: {error}"); + unknown(); + } + } + }); +} + +pub(crate) fn buy_track(track_id: String) { + let Ok(id) = track_id.parse::() else { + return; + }; + let runtime = crate::app(); + crate::spawn(async move { + match runtime.core().get_track(id).await { + Ok(track) if track.purchasable == Some(false) => not_sold(), + Ok(track) => match track.album.as_ref().map(|a| a.id.clone()) { + Some(album_id) if !album_id.is_empty() => open_store(album_store_url(&album_id)), + _ => unknown(), + }, + Err(error) => { + log::warn!("[qbz-qt] store: track {id} lookup failed: {error}"); + unknown(); + } + } + }); +} diff --git a/crates/qbz-qt/src/track_replace_qt.rs b/crates/qbz-qt/src/track_replace_qt.rs index 59bcf6f3a..635f68452 100644 --- a/crates/qbz-qt/src/track_replace_qt.rs +++ b/crates/qbz-qt/src/track_replace_qt.rs @@ -656,6 +656,46 @@ async fn run_release_search(release: ReleaseSeed, query: String, generation: u64 } } +/// The ranked release candidates for a title (and artist) the catalog no +/// longer has — the album page's "you might be interested in these" rail +/// (2026-09-13). Same query normalisation, search and ranking as the +/// replacement flow; no modal state is touched. Empty when nothing to ask. +pub(crate) async fn ranked_alternatives(album_id: &str, title: &str, artist: &str) -> Vec { + let seed: ReleaseSeed = match serde_json::from_value(serde_json::json!({ + "targetKind": "album", + "albumId": album_id, + "albumTitle": title, + "artist": artist, + "albumArtist": artist, + })) { + Ok(seed) => seed, + Err(_) => return Vec::new(), + }; + let normalized = qbz_external_reco::normalize_catalog_name(title); + let query = if normalized.is_empty() { + title.trim().to_string() + } else { + normalized + }; + if query.is_empty() { + return Vec::new(); + } + let runtime = crate::app(); + let found = runtime + .core() + .search_albums(&query, SEARCH_LIMIT, 0, None) + .await + .map(|page| page.items) + .unwrap_or_else(|error| { + log::warn!("[qbz-qt] album alternatives: search '{query}' failed: {error}"); + Vec::new() + }); + rank_release_candidates(&seed, &found) + .into_iter() + .map(|(album, _)| album) + .collect() +} + fn release_quality(album: &Album) -> (u32, f64) { let bit_depth = album .audio_info From 119fab64ee40c17d2c5fd5ab0c08e452a9431a14 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:26:11 -0600 Subject: [PATCH 4/4] i18n: strings for View label, Copy submenu, Buy on Qobuz and the unavailable-release page Twelve new msgids in all eight catalogs. cc --- .../translations/de/LC_MESSAGES/qbz-ui.po | 48 +++++++++++++++++++ .../translations/en/LC_MESSAGES/qbz-ui.po | 48 +++++++++++++++++++ .../translations/es/LC_MESSAGES/qbz-ui.po | 48 +++++++++++++++++++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 48 +++++++++++++++++++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 48 +++++++++++++++++++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 48 +++++++++++++++++++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 48 +++++++++++++++++++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 48 +++++++++++++++++++ 8 files changed, 384 insertions(+) diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index cf041026a..32f2efadf 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -7647,3 +7647,51 @@ msgstr "Basisdaten kopieren" #: crates/qbz-qt/qml/shell/TrackInfoBody.qml msgid "Copy full" msgstr "Alles kopieren" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "View label" +msgstr "Label anzeigen" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Buy on Qobuz" +msgstr "Bei Qobuz kaufen" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release isn't sold on Qobuz." +msgstr "Diese Veröffentlichung wird bei Qobuz nicht verkauft." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't check the store for this release." +msgstr "Der Store konnte für diese Veröffentlichung nicht abgefragt werden." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Could not open the browser" +msgstr "Der Browser konnte nicht geöffnet werden" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track name" +msgstr "Titelname" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track - Album - Artist" +msgstr "Titel - Album - Künstler" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release is no longer available on Qobuz." +msgstr "Diese Veröffentlichung ist bei Qobuz nicht mehr verfügbar." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "You might be interested in these instead" +msgstr "Stattdessen könnten dich diese interessieren" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't load this release." +msgstr "Diese Veröffentlichung konnte nicht geladen werden." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Looking for other versions…" +msgstr "Suche nach anderen Versionen…" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release" +msgstr "Diese Veröffentlichung" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 072413aa4..3d21c3910 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -3302,3 +3302,51 @@ msgstr "Copy basic data" #: crates/qbz-qt/qml/shell/TrackInfoBody.qml msgid "Copy full" msgstr "Copy full" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "View label" +msgstr "View label" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Buy on Qobuz" +msgstr "Buy on Qobuz" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release isn't sold on Qobuz." +msgstr "This release isn't sold on Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't check the store for this release." +msgstr "Couldn't check the store for this release." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Could not open the browser" +msgstr "Could not open the browser" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track name" +msgstr "Track name" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track - Album - Artist" +msgstr "Track - Album - Artist" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release is no longer available on Qobuz." +msgstr "This release is no longer available on Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "You might be interested in these instead" +msgstr "You might be interested in these instead" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't load this release." +msgstr "Couldn't load this release." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Looking for other versions…" +msgstr "Looking for other versions…" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release" +msgstr "This release" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index cfad174dc..5179bf451 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -7653,3 +7653,51 @@ msgstr "Copiar datos básicos" #: crates/qbz-qt/qml/shell/TrackInfoBody.qml msgid "Copy full" msgstr "Copiar todo" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "View label" +msgstr "Ver sello" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Buy on Qobuz" +msgstr "Comprar en Qobuz" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release isn't sold on Qobuz." +msgstr "Este lanzamiento no se vende en Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't check the store for this release." +msgstr "No se pudo consultar la tienda para este lanzamiento." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Could not open the browser" +msgstr "No se pudo abrir el navegador" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track name" +msgstr "Nombre de la pista" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track - Album - Artist" +msgstr "Pista - Álbum - Artista" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release is no longer available on Qobuz." +msgstr "Este lanzamiento ya no está disponible en Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "You might be interested in these instead" +msgstr "Quizá te interesen estas alternativas" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't load this release." +msgstr "No se pudo cargar este lanzamiento." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Looking for other versions…" +msgstr "Buscando otras versiones…" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release" +msgstr "Este lanzamiento" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 4088a0c07..beafc9386 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -7645,3 +7645,51 @@ msgstr "Copier les données de base" #: crates/qbz-qt/qml/shell/TrackInfoBody.qml msgid "Copy full" msgstr "Tout copier" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "View label" +msgstr "Voir le label" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Buy on Qobuz" +msgstr "Acheter sur Qobuz" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release isn't sold on Qobuz." +msgstr "Cette sortie n'est pas vendue sur Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't check the store for this release." +msgstr "Impossible de vérifier la boutique pour cette sortie." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Could not open the browser" +msgstr "Impossible d'ouvrir le navigateur" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track name" +msgstr "Nom du titre" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track - Album - Artist" +msgstr "Titre - Album - Artiste" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release is no longer available on Qobuz." +msgstr "Cette sortie n'est plus disponible sur Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "You might be interested in these instead" +msgstr "Ces alternatives pourraient vous intéresser" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't load this release." +msgstr "Impossible de charger cette sortie." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Looking for other versions…" +msgstr "Recherche d'autres versions…" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release" +msgstr "Cette sortie" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 9088277b5..01603bf64 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -7628,3 +7628,51 @@ msgstr "基本情報をコピー" #: crates/qbz-qt/qml/shell/TrackInfoBody.qml msgid "Copy full" msgstr "すべてコピー" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "View label" +msgstr "レーベルを見る" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Buy on Qobuz" +msgstr "Qobuzで購入" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release isn't sold on Qobuz." +msgstr "このリリースはQobuzでは販売されていません。" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't check the store for this release." +msgstr "このリリースのストア情報を確認できませんでした。" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Could not open the browser" +msgstr "ブラウザーを開けませんでした" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track name" +msgstr "トラック名" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track - Album - Artist" +msgstr "トラック - アルバム - アーティスト" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release is no longer available on Qobuz." +msgstr "このリリースはQobuzで利用できなくなりました。" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "You might be interested in these instead" +msgstr "代わりにこちらはいかがでしょうか" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't load this release." +msgstr "このリリースを読み込めませんでした。" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Looking for other versions…" +msgstr "他のバージョンを探しています…" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release" +msgstr "このリリース" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index f1dee7b1e..9a53d4814 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -7635,3 +7635,51 @@ msgstr "Basisgegevens kopiëren" #: crates/qbz-qt/qml/shell/TrackInfoBody.qml msgid "Copy full" msgstr "Alles kopiëren" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "View label" +msgstr "Label bekijken" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Buy on Qobuz" +msgstr "Kopen bij Qobuz" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release isn't sold on Qobuz." +msgstr "Deze release wordt niet verkocht bij Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't check the store for this release." +msgstr "Kon de winkel niet controleren voor deze release." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Could not open the browser" +msgstr "Kon de browser niet openen" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track name" +msgstr "Tracknaam" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track - Album - Artist" +msgstr "Track - Album - Artiest" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release is no longer available on Qobuz." +msgstr "Deze release is niet meer beschikbaar op Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "You might be interested in these instead" +msgstr "Misschien zijn deze alternatieven interessant" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't load this release." +msgstr "Kon deze release niet laden." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Looking for other versions…" +msgstr "Andere versies zoeken…" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release" +msgstr "Deze release" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 5d9d6a1a1..e64cdb11f 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -7645,3 +7645,51 @@ msgstr "Copiar dados básicos" #: crates/qbz-qt/qml/shell/TrackInfoBody.qml msgid "Copy full" msgstr "Copiar tudo" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "View label" +msgstr "Ver editora" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Buy on Qobuz" +msgstr "Comprar na Qobuz" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release isn't sold on Qobuz." +msgstr "Este lançamento não está à venda na Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't check the store for this release." +msgstr "Não foi possível consultar a loja para este lançamento." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Could not open the browser" +msgstr "Não foi possível abrir o navegador" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track name" +msgstr "Nome da faixa" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track - Album - Artist" +msgstr "Faixa - Álbum - Artista" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release is no longer available on Qobuz." +msgstr "Este lançamento já não está disponível na Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "You might be interested in these instead" +msgstr "Talvez estas alternativas te interessem" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't load this release." +msgstr "Não foi possível carregar este lançamento." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Looking for other versions…" +msgstr "A procurar outras versões…" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release" +msgstr "Este lançamento" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index bc54bdd8b..aea8cc68e 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -7666,3 +7666,51 @@ msgstr "Копировать основные данные" #: crates/qbz-qt/qml/shell/TrackInfoBody.qml msgid "Copy full" msgstr "Копировать всё" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "View label" +msgstr "Открыть лейбл" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Buy on Qobuz" +msgstr "Купить на Qobuz" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release isn't sold on Qobuz." +msgstr "Этот релиз не продаётся на Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't check the store for this release." +msgstr "Не удалось проверить магазин для этого релиза." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Could not open the browser" +msgstr "Не удалось открыть браузер" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track name" +msgstr "Название трека" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Track - Album - Artist" +msgstr "Трек - Альбом - Исполнитель" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release is no longer available on Qobuz." +msgstr "Этот релиз больше недоступен на Qobuz." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "You might be interested in these instead" +msgstr "Возможно, вас заинтересуют эти альтернативы" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Couldn't load this release." +msgstr "Не удалось загрузить этот релиз." + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "Looking for other versions…" +msgstr "Ищем другие версии…" + +#: crates/qbz-qt/qml/views/AlbumView.qml +msgid "This release" +msgstr "Этот релиз"