merge: integrate the QoL batch (label column, unavailable-release page, Copy submenus, Buy on Qobuz) into pre-release

cc
This commit is contained in:
vicrodh committed 2026-09-13 20:36:45 -06:00
commit ee40a02766
28 files changed
+961 -22

No files matched your search

+2
View File
@@ -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<u64>, composer_id: Option<u64>) -> Track {
Track {
favorited_at: None,
purchasable: None,
id: 0,
title: String::new(),
version: None,
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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 "このリリース"
@@ -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"
@@ -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"
@@ -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 "Этот релиз"
+8
View File
@@ -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<i64>,
/// 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<bool>,
#[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<i64>,
/// 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<bool>,
#[serde(default)]
pub title: String,
#[serde(default)]
@@ -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,
+6
View File
@@ -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",
+11 -1
View File
@@ -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 {
+64 -5
View File
@@ -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
+43 -2
View File
@@ -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") {
@@ -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)
+36 -2
View File
@@ -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 {
+91 -5
View File
@@ -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
+31 -3
View File
@@ -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) }
}
}
+1 -1
View File
@@ -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
@@ -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
+24
View File
@@ -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());
}
+91 -1
View File
@@ -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<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<AlbumViewData, String> {
// 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<AlbumCardData> = 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());
+10 -1
View File
@@ -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<i64>,
#[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<AppRuntime<LoggingAdapter>>,
tracks: &[Track],
) -> HashSet<String> {
@@ -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(),
+27 -1
View File
@@ -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<Option<(String, String, String)>> = 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
+17
View File
@@ -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
+63
View File
@@ -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::<u64>() 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();
}
}
});
}
+40
View File
@@ -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<Album> {
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