feat(purchases): gate album actions by entitlement

cx
This commit is contained in:
vicrodh committed 2026-09-03 04:32:23 -06:00
1 parent 247c0403b7
commit 561b3c25c7
16 files changed
+1181 -37

No files matched your search

@@ -6609,3 +6609,27 @@ msgstr "Dieser Bibliotheksordner ist deaktiviert."
msgid "That folder overlaps an existing library folder."
msgstr "Dieser Ordner überschneidet sich mit einem vorhandenen Bibliotheksordner."
msgid "This purchase is not available for download."
msgstr "Dieser Kauf steht nicht zum Download zur Verfügung."
msgid "Could not save playback preference."
msgstr "Die Wiedergabeeinstellung konnte nicht gespeichert werden."
msgid "Qobuz · stream/offline copy"
msgstr "Qobuz · Stream/Offline-Kopie"
msgid "Local purchase"
msgstr "Lokaler Kauf"
msgid "Play with"
msgstr "Wiedergeben mit"
msgid "Download purchase"
msgstr "Kauf herunterladen"
msgid "1 track purchased"
msgstr "1 Titel gekauft"
msgid "{} tracks purchased"
msgstr "{} Titel gekauft"
@@ -2193,3 +2193,27 @@ msgstr "That library folder is disabled."
msgid "That folder overlaps an existing library folder."
msgstr "That folder overlaps an existing library folder."
msgid "This purchase is not available for download."
msgstr "This purchase is not available for download."
msgid "Could not save playback preference."
msgstr "Could not save playback preference."
msgid "Qobuz · stream/offline copy"
msgstr "Qobuz · stream/offline copy"
msgid "Local purchase"
msgstr "Local purchase"
msgid "Play with"
msgstr "Play with"
msgid "Download purchase"
msgstr "Download purchase"
msgid "1 track purchased"
msgstr "1 track purchased"
msgid "{} tracks purchased"
msgstr "{} tracks purchased"
@@ -6615,3 +6615,27 @@ msgstr "Esa carpeta de la biblioteca está deshabilitada."
msgid "That folder overlaps an existing library folder."
msgstr "Esa carpeta se superpone con una carpeta existente de la biblioteca."
msgid "This purchase is not available for download."
msgstr "Esta compra no está disponible para descargar."
msgid "Could not save playback preference."
msgstr "No se pudo guardar la preferencia de reproducción."
msgid "Qobuz · stream/offline copy"
msgstr "Qobuz · streaming/copia offline"
msgid "Local purchase"
msgstr "Compra local"
msgid "Play with"
msgstr "Reproducir con"
msgid "Download purchase"
msgstr "Descargar compra"
msgid "1 track purchased"
msgstr "1 pista comprada"
msgid "{} tracks purchased"
msgstr "{} pistas compradas"
@@ -6607,3 +6607,27 @@ msgstr "Ce dossier de bibliothèque est désactivé."
msgid "That folder overlaps an existing library folder."
msgstr "Ce dossier chevauche un dossier de bibliothèque existant."
msgid "This purchase is not available for download."
msgstr "Cet achat n’est pas disponible au téléchargement."
msgid "Could not save playback preference."
msgstr "Impossible d’enregistrer la préférence de lecture."
msgid "Qobuz · stream/offline copy"
msgstr "Qobuz · streaming/copie hors ligne"
msgid "Local purchase"
msgstr "Achat local"
msgid "Play with"
msgstr "Lire avec"
msgid "Download purchase"
msgstr "Télécharger l’achat"
msgid "1 track purchased"
msgstr "1 piste achetée"
msgid "{} tracks purchased"
msgstr "{} pistes achetées"
@@ -6590,3 +6590,27 @@ msgstr "そのライブラリフォルダーは無効です。"
msgid "That folder overlaps an existing library folder."
msgstr "そのフォルダーは既存のライブラリフォルダーと重複しています。"
msgid "This purchase is not available for download."
msgstr "この購入作品はダウンロードできません。"
msgid "Could not save playback preference."
msgstr "再生設定を保存できませんでした。"
msgid "Qobuz · stream/offline copy"
msgstr "Qobuz · ストリーミング/オフラインコピー"
msgid "Local purchase"
msgstr "ローカル購入作品"
msgid "Play with"
msgstr "再生元"
msgid "Download purchase"
msgstr "購入作品をダウンロード"
msgid "1 track purchased"
msgstr "購入済み1曲"
msgid "{} tracks purchased"
msgstr "購入済み{}曲"
@@ -6597,3 +6597,27 @@ msgstr "Deze bibliotheekmap is uitgeschakeld."
msgid "That folder overlaps an existing library folder."
msgstr "Deze map overlapt met een bestaande bibliotheekmap."
msgid "This purchase is not available for download."
msgstr "Deze aankoop is niet beschikbaar om te downloaden."
msgid "Could not save playback preference."
msgstr "De afspeelvoorkeur kon niet worden opgeslagen."
msgid "Qobuz · stream/offline copy"
msgstr "Qobuz · stream/offlinekopie"
msgid "Local purchase"
msgstr "Lokale aankoop"
msgid "Play with"
msgstr "Afspelen met"
msgid "Download purchase"
msgstr "Aankoop downloaden"
msgid "1 track purchased"
msgstr "1 nummer gekocht"
msgid "{} tracks purchased"
msgstr "{} nummers gekocht"
@@ -6607,3 +6607,27 @@ msgstr "Essa pasta da biblioteca está desativada."
msgid "That folder overlaps an existing library folder."
msgstr "Essa pasta se sobrepõe a uma pasta existente da biblioteca."
msgid "This purchase is not available for download."
msgstr "Esta compra não está disponível para download."
msgid "Could not save playback preference."
msgstr "Não foi possível salvar a preferência de reprodução."
msgid "Qobuz · stream/offline copy"
msgstr "Qobuz · streaming/cópia offline"
msgid "Local purchase"
msgstr "Compra local"
msgid "Play with"
msgstr "Reproduzir com"
msgid "Download purchase"
msgstr "Baixar compra"
msgid "1 track purchased"
msgstr "1 faixa comprada"
msgid "{} tracks purchased"
msgstr "{} faixas compradas"
@@ -6628,3 +6628,27 @@ msgstr "Эта папка медиатеки отключена."
msgid "That folder overlaps an existing library folder."
msgstr "Эта папка пересекается с существующей папкой медиатеки."
msgid "This purchase is not available for download."
msgstr "Эта покупка недоступна для скачивания."
msgid "Could not save playback preference."
msgstr "Не удалось сохранить настройку воспроизведения."
msgid "Qobuz · stream/offline copy"
msgstr "Qobuz · поток/офлайн-копия"
msgid "Local purchase"
msgstr "Локальная покупка"
msgid "Play with"
msgstr "Воспроизводить через"
msgid "Download purchase"
msgstr "Скачать покупку"
msgid "1 track purchased"
msgstr "Куплен 1 трек"
msgid "{} tracks purchased"
msgstr "Куплено треков: {}"
@@ -35,6 +35,7 @@ use qbz_models::{
Album, PurchaseAlbum, PurchaseFormatOption, PurchaseResponse, PurchaseTrack, SearchResultsPage,
Track,
};
use qbz_qobuz::purchases::{purchase_page_step, PurchasePageStep};
use qbz_qobuz::QobuzClient;
use qbz_qobuz::Result as QobuzResult;
@@ -64,6 +65,128 @@ pub async fn get_user_purchases_all(client: &QobuzClient) -> QobuzResult<Purchas
client.get_user_purchases_all().await
}
/// Fetch the complete account entitlement with hard HTTP status checks. This
/// is deliberately separate from the historical list loader: AlbumView and
/// every new-download gate must distinguish an error from a fresh empty list.
pub async fn get_entitlement_purchases_all(client: &QobuzClient) -> QobuzResult<PurchaseResponse> {
let page_limit = 500;
let mut albums = Vec::new();
let mut tracks = Vec::new();
for kind in ["albums", "tracks"] {
let mut offset = 0;
let mut total = 0;
loop {
let page = client
.get_user_purchases_page_typed_checked(Some(kind), page_limit, offset)
.await?;
let (got, items_total) = if kind == "albums" {
let got = page.albums.items.len() as u32;
albums.extend(page.albums.items);
(got, page.albums.total)
} else {
let got = page.tracks.items.len() as u32;
tracks.extend(page.tracks.items);
(got, page.tracks.total)
};
if offset == 0 {
total = items_total;
}
match purchase_page_step(offset, got, total) {
PurchasePageStep::Stop => break,
PurchasePageStep::Continue { next_offset } => offset = next_offset,
}
}
}
Ok(PurchaseResponse {
albums: SearchResultsPage {
total: albums.len() as u32,
offset: 0,
limit: page_limit,
items: albums,
},
tracks: SearchResultsPage {
total: tracks.len() as u32,
offset: 0,
limit: page_limit,
items: tracks,
},
})
}
#[derive(Debug, Clone, Default)]
pub struct PurchaseEntitlementIndex {
albums: HashMap<String, PurchaseAlbum>,
track_ids_by_album: HashMap<String, HashSet<u64>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PurchaseEntitlement {
NotOwned,
FullAlbum {
downloadable: bool,
downloadable_format_ids: Vec<u32>,
},
Tracks {
track_ids: HashSet<u64>,
},
}
impl PurchaseEntitlementIndex {
pub fn from_response(response: &PurchaseResponse) -> Self {
let albums = response
.albums
.items
.iter()
.cloned()
.map(|album| (album.id.clone(), album))
.collect();
let mut track_ids_by_album: HashMap<String, HashSet<u64>> = HashMap::new();
for track in &response.tracks.items {
let Some(album_id) = track
.album
.as_ref()
.map(|album| album.id.trim())
.filter(|album_id| !album_id.is_empty())
else {
continue;
};
track_ids_by_album
.entry(album_id.to_string())
.or_default()
.insert(track.id);
}
Self {
albums,
track_ids_by_album,
}
}
pub fn entitlement_for(&self, album_id: &str) -> PurchaseEntitlement {
if let Some(album) = self.albums.get(album_id) {
return PurchaseEntitlement::FullAlbum {
downloadable: album.downloadable,
downloadable_format_ids: album.downloadable_format_ids.clone(),
};
}
match self.track_ids_by_album.get(album_id) {
Some(track_ids) if !track_ids.is_empty() => PurchaseEntitlement::Tracks {
track_ids: track_ids.clone(),
},
_ => PurchaseEntitlement::NotOwned,
}
}
/// Exact album row used by the defensive download gate. The format list
/// must be explicit: catalog synthesis is not entitlement evidence.
pub fn downloadable_album(&self, album_id: &str) -> Option<&PurchaseAlbum> {
self.albums
.get(album_id)
.filter(|album| album.downloadable && !album.downloadable_format_ids.is_empty())
}
}
/// Fetch ALL purchases for a SINGLE type by paginating (`"albums"` /
/// `"tracks"`). Pass-through to `get_user_purchases_all_typed` — the primary
/// per-tab list-load path (command #3). The OTHER type's `total` is forced to 0
@@ -2588,3 +2711,88 @@ mod scope_expansion_tests {
}
}
}
#[cfg(test)]
mod entitlement_index_tests {
use super::*;
use qbz_models::AlbumSummary;
fn response(albums: Vec<PurchaseAlbum>, tracks: Vec<PurchaseTrack>) -> PurchaseResponse {
PurchaseResponse {
albums: SearchResultsPage {
total: albums.len() as u32,
items: albums,
..Default::default()
},
tracks: SearchResultsPage {
total: tracks.len() as u32,
items: tracks,
..Default::default()
},
}
}
#[test]
fn exact_full_album_ownership_never_matches_a_similar_id() {
let index = PurchaseEntitlementIndex::from_response(&response(
vec![PurchaseAlbum {
id: "album-10".into(),
downloadable: true,
downloadable_format_ids: vec![55],
..Default::default()
}],
vec![],
));
assert!(matches!(
index.entitlement_for("album-10"),
PurchaseEntitlement::FullAlbum { .. }
));
assert_eq!(
index.entitlement_for("album-1"),
PurchaseEntitlement::NotOwned
);
}
#[test]
fn track_ownership_is_not_promoted_to_album_ownership() {
let index = PurchaseEntitlementIndex::from_response(&response(
vec![],
vec![PurchaseTrack {
id: 7,
album: Some(AlbumSummary {
id: "album".into(),
title: String::new(),
image: Default::default(),
label: None,
genre: None,
}),
..Default::default()
}],
));
assert!(matches!(
index.entitlement_for("album"),
PurchaseEntitlement::Tracks { track_ids } if track_ids == HashSet::from([7])
));
assert!(index.downloadable_album("album").is_none());
}
#[test]
fn new_download_requires_availability_and_explicit_formats() {
for (downloadable, formats, expected) in [
(true, vec![55], true),
(false, vec![55], false),
(true, vec![], false),
] {
let index = PurchaseEntitlementIndex::from_response(&response(
vec![PurchaseAlbum {
id: "album".into(),
downloadable,
downloadable_format_ids: formats,
..Default::default()
}],
vec![],
));
assert_eq!(index.downloadable_album("album").is_some(), expected);
}
}
}
+40
View File
@@ -205,6 +205,46 @@ impl QobuzClient {
Ok(serde_json::from_value(response)?)
}
/// Fail-closed variant used for entitlement decisions. Unlike the legacy
/// Purchases list loader, an HTTP error is never allowed to deserialize as
/// an empty page and masquerade as a fresh "not owned" answer.
pub async fn get_user_purchases_page_typed_checked(
&self,
purchase_type: Option<&str>,
limit: u32,
offset: u32,
) -> Result<PurchaseResponse> {
let url = endpoints::build_url(paths::PURCHASE_GET_USER_PURCHASES);
let mut query: Vec<(&str, String)> =
vec![("limit", limit.to_string()), ("offset", offset.to_string())];
if let Some(kind) = purchase_type {
query.push(("type", kind.to_string()));
}
let http_response = self
.http()?
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&query)
.send()
.await?;
let status = http_response.status();
log::debug!(
"[Purchases] checked entitlement page(type={:?}, limit={}, offset={}) status={}",
purchase_type,
limit,
offset,
status
);
if !status.is_success() {
return Err(ApiError::ApiResponse(format!(
"checked purchase entitlement request failed with status {status}"
)));
}
let response: Value = http_response.json().await?;
Ok(serde_json::from_value(response)?)
}
/// Get one purchases-ids page from Qobuz, optionally constrained by purchase
/// type. The items are OPAQUE — the UI reads only `.total` per type.
///
+23 -8
View File
@@ -63,11 +63,12 @@ Rectangle {
popup.close()
else
selectRoot.openPopup()
} else if (event.key === Qt.Key_Up && selectRoot.currentIndex > 0) {
selectRoot.selected(selectRoot.currentIndex - 1)
} else if (event.key === Qt.Key_Down
&& selectRoot.currentIndex + 1 < selectRoot.options.length) {
selectRoot.selected(selectRoot.currentIndex + 1)
} else if (event.key === Qt.Key_Up) {
var previous = selectRoot.adjacentEnabled(selectRoot.currentIndex, -1)
if (previous >= 0) selectRoot.selected(previous)
} else if (event.key === Qt.Key_Down) {
var next = selectRoot.adjacentEnabled(selectRoot.currentIndex, 1)
if (next >= 0) selectRoot.selected(next)
} else {
return
}
@@ -111,6 +112,16 @@ Rectangle {
const o = options[i]
return (o && o.detail !== undefined) ? o.detail : ""
}
function optEnabled(i) {
const o = options[i]
return !(o && o.enabled !== undefined) || o.enabled === true
}
function adjacentEnabled(start, delta) {
for (var i = start + delta; i >= 0 && i < options.length; i += delta) {
if (optEnabled(i)) return i
}
return -1
}
function openPopup() {
if (!selectRoot.enabled || selectRoot.options.length === 0)
return
@@ -122,6 +133,7 @@ Rectangle {
}
function optionMatches(i) {
return i >= 0 && i < options.length
&& optEnabled(i)
&& (filter === ""
|| optLabel(i).toLowerCase().indexOf(filter.toLowerCase()) >= 0)
}
@@ -371,8 +383,10 @@ Rectangle {
width: parent.width
height: selectRoot.optDetail(optRow.index) !== "" ? 26 : parent.height
text: optRow.label
color: optRow.index === selectRoot.currentIndex
? theme.accent : theme.textSecondary
color: !selectRoot.optEnabled(optRow.index)
? theme.textMuted
: (optRow.index === selectRoot.currentIndex
? theme.accent : theme.textSecondary)
font.pixelSize: selectRoot.sm ? 12 : theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
@@ -416,7 +430,8 @@ Rectangle {
id: optArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: selectRoot.optEnabled(optRow.index)
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
popup.close()
selectRoot.selected(optRow.index)
+137 -15
View File
@@ -107,6 +107,56 @@ Rectangle {
readonly property var albumHeader: album.header || ({})
readonly property var tracks: album.tracks || []
readonly property var purchase: album.purchase || ({})
readonly property var localPurchaseVariants: purchase.localVariants || []
readonly property bool showPlaybackSource: {
for (var i = 0; i < localPurchaseVariants.length; i++) {
if (localPurchaseVariants[i].selectable === true)
return true
}
return false
}
function albumTrackIdsJson() {
var ids = []
for (var i = 0; i < root.tracks.length; i++) {
var id = String(root.tracks[i].id || "")
if (/^\d+$/.test(id)) ids.push(id)
}
return JSON.stringify(ids)
}
function playbackSourceOptions() {
var out = [{
"label": QbzSession.tr("Qobuz · stream/offline copy", QbzSession.trRev),
"mode": "qobuz", "formatId": 0, "enabled": true
}]
var selected = Number(root.purchase.selectedFormatId || 0)
for (var i = 0; i < root.localPurchaseVariants.length; i++) {
var variant = root.localPurchaseVariants[i]
if (variant.selectable !== true && Number(variant.formatId) !== selected)
continue
var label = QbzSession.tr("Local purchase", QbzSession.trRev)
+ " · " + (variant.label || String(variant.formatId))
if (variant.selectable !== true)
label += " · " + QbzSession.tr("Unavailable", QbzSession.trRev)
out.push({ "label": label, "mode": "purchase",
"formatId": Number(variant.formatId),
"enabled": variant.selectable === true })
}
return out
}
function playbackSourceIndex(options) {
if ((root.purchase.playbackMode || "qobuz") !== "purchase")
return 0
var selected = Number(root.purchase.selectedFormatId || 0)
for (var i = 1; i < options.length; i++) {
if (Number(options[i].formatId) === selected)
return i
}
return 0
}
// One global opt-in for every album page. `settingsJson` is the live
// channel (including clicks made in another open AlbumView); the album
// document carries the persisted cold-start fallback until that snapshot
@@ -1010,6 +1060,40 @@ Rectangle {
elide: Text.ElideRight
}
Row {
visible: root.purchase.entitlementState === "owned-album"
|| root.purchase.entitlementState === "owned-tracks"
|| (root.purchase.entitlementState === "stale"
&& (root.purchase.lastKnownEntitlementState === "owned-album"
|| root.purchase.lastKnownEntitlementState === "owned-tracks"))
spacing: 6
QbzIcon {
name: "circle-check-big"
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: "amber"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: {
var known = root.purchase.entitlementState === "stale"
? root.purchase.lastKnownEntitlementState
: root.purchase.entitlementState
if (known === "owned-tracks") {
var n = Number(root.purchase.purchasedTrackCount || 0)
return n === 1
? QbzSession.tr("1 track purchased", QbzSession.trRev)
: QbzSession.tr("{} tracks purchased", QbzSession.trRev).replace("{}", String(n))
}
return QbzSession.tr("Purchased", QbzSession.trRev)
}
color: "#e0b341"
font.pixelSize: theme.fontLegal
font.weight: Font.DemiBold
}
}
// Editorial description + Read more.
Item {
visible: !root.compactHeaderPref
@@ -1142,6 +1226,34 @@ Rectangle {
onClicked: function (mouse) { albumMenu.openAtCursor(albumMenuBtn, mouse.x, mouse.y) }
}
}
Column {
visible: root.showPlaybackSource
spacing: 5
width: 300
property var sourceOptions: root.playbackSourceOptions()
Text {
text: QbzSession.tr("Play with", QbzSession.trRev)
color: root.hdrBody
font.pixelSize: theme.fontLegal
}
QbzSelect {
width: parent.width
menuWidth: parent.width
options: parent.sourceOptions
currentIndex: root.playbackSourceIndex(parent.sourceOptions)
onSelected: function (i) {
if (i < 0 || i >= parent.sourceOptions.length)
return
var option = parent.sourceOptions[i]
if (option.enabled !== true)
return
QbzPurchases.setAlbumPlaybackMode(
albumHeader.id, option.mode,
Number(option.formatId), root.albumTrackIdsJson())
}
}
}
}
}
@@ -1816,6 +1928,28 @@ Rectangle {
return rows
}
function buildAlbumMenuModel() {
var rows = [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": root.toggleState("album", albumHeader.isFavorite) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.toggleState("album", albumHeader.isFavorite) ? "heart-filled" : "heart", "action": "favorite" },
{ "label": root.toggleState("pin", albumHeader.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("pin", albumHeader.isPinned) ? "pin-filled" : "pin", "action": "pin" },
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" },
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
{ "label": QbzSession.tr("Share Qobuz link", QbzSession.trRev), "icon": "link", "action": "share-qobuz" },
{ "label": QbzSession.tr("Share Album.link", QbzSession.trRev), "icon": "link", "action": "share-albumlink" },
{ "label": root.fullyCachedLive ? QbzSession.tr("Refresh offline copy", QbzSession.trRev) : QbzSession.tr("Make available offline", QbzSession.trRev), "icon": root.fullyCachedLive ? "refresh-cw" : "cloud-download", "action": root.fullyCachedLive ? "recache-album" : "cache-album" }
]
// Separate DRM-free purchase action. Unknown/stale/error/track-only
// ownership never reaches this branch; Rust computes the gate.
if (root.purchase.canDownloadPurchase === true)
rows.push({ "label": QbzSession.tr("Download purchase", QbzSession.trRev),
"icon": "download", "action": "download-purchase" })
return rows
}
// Cover right-click menu (AlbumPageView.slint:300-370) + "View cover",
// the lightbox entry that is NEW in this port.
QbzContextMenu {
@@ -1881,21 +2015,7 @@ Rectangle {
id: albumMenu
menuWidth: 196
Repeater {
model: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": root.toggleState("album", albumHeader.isFavorite) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.toggleState("album", albumHeader.isFavorite) ? "heart-filled" : "heart", "action": "favorite" },
{ "label": root.toggleState("pin", albumHeader.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("pin", albumHeader.isPinned) ? "pin-filled" : "pin", "action": "pin" },
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" },
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
{ "label": QbzSession.tr("Share Qobuz link", QbzSession.trRev), "icon": "link", "action": "share-qobuz" },
{ "label": QbzSession.tr("Share Album.link", QbzSession.trRev), "icon": "link", "action": "share-albumlink" },
// AlbumContextMenu.slint:137-152 — the offline row swaps
// on the all-cached aggregate (live from row flips).
{ "label": root.fullyCachedLive ? QbzSession.tr("Refresh offline copy", QbzSession.trRev) : QbzSession.tr("Make available offline", QbzSession.trRev), "icon": root.fullyCachedLive ? "refresh-cw" : "cloud-download", "action": root.fullyCachedLive ? "recache-album" : "cache-album" },
]
model: root.buildAlbumMenuModel()
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
@@ -1954,6 +2074,8 @@ Rectangle {
QbzAlbum.albumCacheOffline(albumHeader.id)
} else if (a === "recache-album") {
QbzAlbum.albumRefreshOffline(albumHeader.id)
} else if (a === "download-purchase") {
QbzPurchases.openEntitledAlbum(albumHeader.id)
}
}
}
+65
View File
@@ -239,6 +239,10 @@ pub struct AlbumHeader {
pub struct AlbumViewData {
pub header: AlbumHeader,
pub tracks: Vec<TrackRow>,
/// Purchases is deliberately progressive. The primary album response
/// starts at `unknown`; the shared per-profile entitlement index and local
/// copy inventory patch it without delaying cover, metadata, or tracklist.
pub purchase: serde_json::Value,
/// Cold-start fallback for the live `settingsJson` preference consumed by
/// AlbumView.qml. The view switches to the settings document as soon as it
/// is published, so toggling one open album updates every other instance.
@@ -1253,7 +1257,13 @@ pub async fn load_album_view(
// the band simply does not paint — no colour pop.
data.header_gradient = crate::settings_qt::pref_bool("album_header_gradient", true);
data.compact_header = crate::settings_qt::pref_bool("compact_album_header", false);
data.purchase = empty_purchase_doc();
let header_art = data.header.artwork_url.clone();
let purchase_track_ids: Vec<i64> = data
.tracks
.iter()
.filter_map(|track| track.id.parse::<i64>().ok())
.collect();
let json = serde_json::to_string(&data).map_err(|e| e.to_string())?;
log::info!(
@@ -1274,6 +1284,7 @@ pub async fn load_album_view(
}
spawn_header_color(generation, header_art);
spawn_purchase_state(generation, album_id.to_string(), purchase_track_ids);
spawn_deferred_rows(
runtime.clone(),
generation,
@@ -1286,6 +1297,21 @@ pub async fn load_album_view(
Ok(json)
}
fn empty_purchase_doc() -> serde_json::Value {
json!({
"entitlementState": "unknown",
"lastKnownEntitlementState": "unknown",
"downloadable": false,
"canDownloadPurchase": false,
"downloadableFormatIds": [],
"purchasedTrackCount": 0,
"copies": [],
"localVariants": [],
"playbackMode": "qobuz",
"selectedFormatId": 0
})
}
// ==================== Deferred carousels (progressive publish) =============
//
// Same machinery as `artist_qt.rs`'s enrichment pass, aimed at the album
@@ -1330,6 +1356,45 @@ fn publish_patch(generation: u64, f: impl FnOnce(&mut serde_json::Value)) {
});
}
/// Reflect a successfully persisted AlbumView playback preference in the
/// currently stashed document. The album-id check plus the page generation
/// prevents a late database write from repainting a different album.
pub fn patch_purchase_playback_preference(album_id: &str, mode: &str, format_id: i64) {
let generation = {
let Ok(guard) = ALBUM_DOC.lock() else {
return;
};
let Some((generation, doc)) = guard.as_ref() else {
return;
};
if doc["header"]["id"].as_str() != Some(album_id) {
return;
}
*generation
};
let mode = mode.to_string();
publish_patch(generation, move |doc| {
doc["purchase"]["playbackMode"] = json!(mode);
doc["purchase"]["selectedFormatId"] = json!(format_id);
});
}
fn spawn_purchase_state(generation: u64, album_id: String, track_ids: Vec<i64>) {
crate::spawn(async move {
// Local inventory + any warm profile snapshot first. This never waits
// for a purchase endpoint and is useful offline.
let cached =
crate::purchases_qt::album_purchase_state(album_id.clone(), track_ids.clone(), false)
.await;
publish_patch(generation, move |doc| doc["purchase"] = cached);
// Then refresh ownership online. Errors republish `stale` and never
// expose the download action; local variants remain available.
let refreshed = crate::purchases_qt::album_purchase_state(album_id, track_ids, true).await;
publish_patch(generation, move |doc| doc["purchase"] = refreshed);
});
}
/// Write one carousel's cards + clear its loading flag, in one republish.
fn publish_row(
generation: u64,
+3
View File
@@ -242,6 +242,9 @@ async fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) {
// Offline DOWNLOAD cache (index.db + library.db + cached-id seed) —
// awaited so the ready-set is seeded before any view can build rows.
crate::offline_qt::activate(user_id).await;
// Purchases ownership is account-scoped. Reset before warming so a late
// response from the previous account cannot grant AlbumView an action.
crate::purchases_qt::activate_profile();
// The catalog sidecar is profile-scoped. Coalescing here covers first
// login and account switches even when the one-shot boot worker began on
// the previous/guest profile.
+35
View File
@@ -144,6 +144,22 @@ pub mod qbz_purchases_bridge {
#[qinvokable]
fn open_album(self: Pin<&mut QbzPurchases>, album_id: QString);
/// AlbumView-only door: revalidates exact full-album ownership,
/// availability and explicit entitled formats before navigating.
#[qinvokable]
fn open_entitled_album(self: Pin<&mut QbzPurchases>, album_id: QString);
/// Persist AlbumView's `qobuz` or exact `purchase(format_id)` source.
/// Track ids remain QStrings inside the JSON array (QML ints are 32-bit).
#[qinvokable]
fn set_album_playback_mode(
self: Pin<&mut QbzPurchases>,
album_id: QString,
mode: QString,
format_id: i32,
track_ids_json: QString,
);
/// Change the selected download format. RE-SCOPES every downloaded
/// mark on the detail screen, the progress block AND the
/// Add-to-Library affordance (§6) — the single most surprising
@@ -294,6 +310,25 @@ impl qbz_purchases_bridge::QbzPurchases {
crate::purchases_qt::open_album(album_id.to_string());
}
pub fn open_entitled_album(self: Pin<&mut Self>, album_id: QString) {
crate::purchases_qt::open_entitled_album(album_id.to_string());
}
pub fn set_album_playback_mode(
self: Pin<&mut Self>,
album_id: QString,
mode: QString,
format_id: i32,
track_ids_json: QString,
) {
crate::purchases_qt::set_album_playback_mode(
album_id.to_string(),
mode.to_string(),
format_id,
track_ids_json.to_string(),
);
}
pub fn set_format(self: Pin<&mut Self>, format_id: i32) {
crate::purchases_qt::set_format(format_id);
}
+478 -14
View File
@@ -56,13 +56,14 @@
//! tokio runtime, which owns its own `LibraryDatabase`. That also gives the
//! required strictly-sequential execution for free.
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use cxx_qt_lib::QString;
use qbz_library::LibraryDatabase;
use qbz_library::{PurchaseCopyHealth, PurchasePlaybackMode};
use qbz_models::{
Album, PurchaseAlbum, PurchaseFormatOption, PurchaseResponse, PurchaseTrack, SearchResultsPage,
};
@@ -876,12 +877,44 @@ struct DetailState {
/// See `AlbumDoc::local_album_id`. Resolved on load and after every
/// download / add-to-library, never guessed.
local_album_id: String,
/// Set only by AlbumView's fail-closed gate. It is a fresh exact purchase
/// row captured immediately before this detail opened.
verified_entitlement: Option<PurchaseAlbum>,
}
static LIST: Mutex<Option<ListState>> = Mutex::new(None);
static DETAIL: Mutex<Option<DetailState>> = Mutex::new(None);
static REGISTRY: Mutex<Option<Registry>> = Mutex::new(None);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EntitlementFreshness {
Unknown,
Fresh,
Stale,
}
#[derive(Clone)]
struct EntitlementCache {
freshness: EntitlementFreshness,
index: Option<svc::PurchaseEntitlementIndex>,
}
impl Default for EntitlementCache {
fn default() -> Self {
Self {
freshness: EntitlementFreshness::Unknown,
index: None,
}
}
}
static ENTITLEMENTS: Mutex<EntitlementCache> = Mutex::new(EntitlementCache {
freshness: EntitlementFreshness::Unknown,
index: None,
});
static ENTITLEMENT_PROFILE_GENERATION: AtomicU64 = AtomicU64::new(0);
static ENTITLEMENT_REQUEST_GENERATION: AtomicU64 = AtomicU64::new(0);
/// PROCESS-monotonic, deliberately not per-state. `leave()` destroys the whole
/// `ListState`, so a counter living inside it restarts at zero on the next
/// visit — and a slow response from the PREVIOUS visit would then carry a
@@ -1415,6 +1448,315 @@ async fn snapshot_client() -> Option<QobuzClient> {
guard.as_ref().cloned()
}
#[derive(Clone, Copy)]
struct EntitlementGate {
profile: u64,
request: u64,
}
fn next_entitlement_gate() -> EntitlementGate {
EntitlementGate {
profile: ENTITLEMENT_PROFILE_GENERATION.load(Ordering::SeqCst),
request: ENTITLEMENT_REQUEST_GENERATION
.fetch_add(1, Ordering::SeqCst)
.wrapping_add(1),
}
}
fn entitlement_gate_is_current(gate: EntitlementGate) -> bool {
ENTITLEMENT_PROFILE_GENERATION.load(Ordering::SeqCst) == gate.profile
&& ENTITLEMENT_REQUEST_GENERATION.load(Ordering::SeqCst) == gate.request
}
async fn refresh_entitlement_index() -> Option<svc::PurchaseEntitlementIndex> {
let gate = next_entitlement_gate();
let response = match snapshot_client().await {
Some(client) => svc::get_entitlement_purchases_all(&client).await,
None => return None,
};
if !entitlement_gate_is_current(gate) {
return None;
}
match response {
Ok(response) => {
let index = svc::PurchaseEntitlementIndex::from_response(&response);
*ENTITLEMENTS.lock().unwrap_or_else(|e| e.into_inner()) = EntitlementCache {
freshness: EntitlementFreshness::Fresh,
index: Some(index.clone()),
};
Some(index)
}
Err(error) => {
log::warn!("[qbz-qt] purchase entitlement refresh failed: {error}");
let mut cache = ENTITLEMENTS.lock().unwrap_or_else(|e| e.into_inner());
cache.freshness = EntitlementFreshness::Stale;
None
}
}
}
fn invalidate_entitlement_index() {
ENTITLEMENT_PROFILE_GENERATION.fetch_add(1, Ordering::SeqCst);
ENTITLEMENT_REQUEST_GENERATION.fetch_add(1, Ordering::SeqCst);
*ENTITLEMENTS.lock().unwrap_or_else(|e| e.into_inner()) = EntitlementCache::default();
}
/// Bind the shared index to the newly active profile and warm it without
/// delaying session entry. The generations reject every late response from a
/// prior account.
pub fn activate_profile() {
invalidate_entitlement_index();
crate::spawn(async {
let _ = refresh_entitlement_index().await;
});
}
#[derive(Serialize)]
struct AlbumPurchaseCopyDoc {
#[serde(rename = "copyId")]
copy_id: String,
#[serde(rename = "formatId")]
format_id: i64,
#[serde(rename = "folderLabel")]
folder_label: String,
state: String,
#[serde(rename = "downloadedTracks")]
downloaded_tracks: u32,
#[serde(rename = "healthyTracks")]
healthy_tracks: u32,
#[serde(rename = "totalTracks")]
total_tracks: u32,
#[serde(rename = "inLocalLibrary")]
in_local_library: bool,
}
#[derive(Serialize)]
struct AlbumLocalVariantDoc {
#[serde(rename = "formatId")]
format_id: i64,
label: String,
#[serde(rename = "healthyCompleteCopies")]
healthy_complete_copies: u32,
#[serde(rename = "healthyTracks")]
healthy_tracks: u32,
#[serde(rename = "totalTracks")]
total_tracks: u32,
selectable: bool,
}
#[derive(Serialize)]
struct AlbumPurchaseDoc {
#[serde(rename = "entitlementState")]
entitlement_state: String,
#[serde(rename = "lastKnownEntitlementState")]
last_known_entitlement_state: String,
downloadable: bool,
#[serde(rename = "canDownloadPurchase")]
can_download_purchase: bool,
#[serde(rename = "downloadableFormatIds")]
downloadable_format_ids: Vec<u32>,
#[serde(rename = "purchasedTrackCount")]
purchased_track_count: u32,
copies: Vec<AlbumPurchaseCopyDoc>,
#[serde(rename = "localVariants")]
local_variants: Vec<AlbumLocalVariantDoc>,
#[serde(rename = "playbackMode")]
playback_mode: String,
#[serde(rename = "selectedFormatId")]
selected_format_id: i64,
}
struct LocalAlbumPurchaseState {
copies: Vec<qbz_library::PurchaseCopySnapshot>,
preference: qbz_library::PurchaseAlbumPlaybackPreference,
folders: Vec<qbz_library::LibraryFolder>,
}
fn local_variant_label(format_id: i64) -> String {
match format_id {
56 => "DSD128 · DSF".to_string(),
55 => "DSD64 · DSF".to_string(),
27 => "24-bit / 192 kHz · FLAC".to_string(),
7 => "24-bit / 96 kHz · FLAC".to_string(),
6 => "16-bit / 44.1 kHz · FLAC".to_string(),
5 => "320 kbps · MP3".to_string(),
other => format!("#{other}"),
}
}
fn copy_health_word(health: PurchaseCopyHealth) -> &'static str {
match health {
PurchaseCopyHealth::CompleteHealthy => "complete",
PurchaseCopyHealth::Partial => "partial",
PurchaseCopyHealth::Missing => "missing",
PurchaseCopyHealth::Changed => "changed",
PurchaseCopyHealth::Unreachable => "unreachable",
PurchaseCopyHealth::Unreadable => "unreadable",
}
}
fn folder_is_in_local_library(folder: &str, roots: &[qbz_library::LibraryFolder]) -> bool {
let candidate = Path::new(folder)
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(folder));
roots
.iter()
.any(|root| root.enabled && candidate.starts_with(Path::new(&root.path)))
}
fn local_album_purchase_state(
album_id: &str,
expected_track_ids: &[i64],
) -> LocalAlbumPurchaseState {
crate::library_db_qt::with_db(false, |db| {
Ok(LocalAlbumPurchaseState {
copies: db.inspect_purchase_copies(album_id, expected_track_ids)?,
preference: db.purchase_playback_preference(album_id)?,
folders: db.get_folders_with_metadata()?,
})
})
.unwrap_or_else(|| LocalAlbumPurchaseState {
copies: Vec::new(),
preference: qbz_library::PurchaseAlbumPlaybackPreference {
album_id: album_id.to_string(),
mode: PurchasePlaybackMode::Qobuz,
format_id: None,
updated_at: 0,
},
folders: Vec::new(),
})
}
/// Build AlbumView's progressive Purchases subdocument. With `refresh`, the
/// remote index is refreshed first; without it, the last profile snapshot is
/// used so local copies can paint immediately.
pub async fn album_purchase_state(
album_id: String,
expected_track_ids: Vec<i64>,
refresh: bool,
) -> serde_json::Value {
if refresh {
let _ = refresh_entitlement_index().await;
}
let local_album_id = album_id.clone();
let local_ids = expected_track_ids.clone();
let local = tokio::task::spawn_blocking(move || {
local_album_purchase_state(&local_album_id, &local_ids)
})
.await
.unwrap_or_else(|_| LocalAlbumPurchaseState {
copies: Vec::new(),
preference: qbz_library::PurchaseAlbumPlaybackPreference {
album_id: album_id.clone(),
mode: PurchasePlaybackMode::Qobuz,
format_id: None,
updated_at: 0,
},
folders: Vec::new(),
});
let entitlement_cache = ENTITLEMENTS
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let entitlement = entitlement_cache
.index
.as_ref()
.map(|index| index.entitlement_for(&album_id));
let (known_state, downloadable, downloadable_format_ids, purchased_track_count) =
match entitlement {
Some(svc::PurchaseEntitlement::FullAlbum {
downloadable,
downloadable_format_ids,
}) => (
"owned-album",
downloadable,
downloadable_format_ids,
expected_track_ids.len() as u32,
),
Some(svc::PurchaseEntitlement::Tracks { track_ids }) => {
let expected: HashSet<u64> = expected_track_ids
.iter()
.filter_map(|id| u64::try_from(*id).ok())
.collect();
let count = track_ids.intersection(&expected).count() as u32;
if count > 0 {
("owned-tracks", false, Vec::new(), count)
} else {
("not-owned", false, Vec::new(), 0)
}
}
Some(svc::PurchaseEntitlement::NotOwned) => ("not-owned", false, Vec::new(), 0),
None => ("unknown", false, Vec::new(), 0),
};
let entitlement_state = match entitlement_cache.freshness {
EntitlementFreshness::Fresh => known_state,
EntitlementFreshness::Stale => "stale",
EntitlementFreshness::Unknown => "unknown",
};
let can_download_purchase = entitlement_cache.freshness == EntitlementFreshness::Fresh
&& known_state == "owned-album"
&& downloadable
&& !downloadable_format_ids.is_empty();
let mut variants: BTreeMap<i64, AlbumLocalVariantDoc> = BTreeMap::new();
let mut copies = Vec::new();
for snapshot in local.copies {
let variant =
variants
.entry(snapshot.copy.format_id)
.or_insert_with(|| AlbumLocalVariantDoc {
format_id: snapshot.copy.format_id,
label: local_variant_label(snapshot.copy.format_id),
healthy_complete_copies: 0,
healthy_tracks: 0,
total_tracks: snapshot.total_tracks,
selectable: false,
});
variant.healthy_tracks = variant.healthy_tracks.max(snapshot.healthy_tracks);
if snapshot.health == PurchaseCopyHealth::CompleteHealthy {
variant.healthy_complete_copies += 1;
variant.selectable = true;
}
let folder_label = Path::new(&snapshot.copy.resolved_album_folder)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_string();
copies.push(AlbumPurchaseCopyDoc {
copy_id: snapshot.copy.copy_id,
format_id: snapshot.copy.format_id,
folder_label,
state: copy_health_word(snapshot.health).to_string(),
downloaded_tracks: snapshot.downloaded_tracks,
healthy_tracks: snapshot.healthy_tracks,
total_tracks: snapshot.total_tracks,
in_local_library: folder_is_in_local_library(
&snapshot.copy.resolved_album_folder,
&local.folders,
),
});
}
serde_json::to_value(AlbumPurchaseDoc {
entitlement_state: entitlement_state.to_string(),
last_known_entitlement_state: known_state.to_string(),
downloadable,
can_download_purchase,
downloadable_format_ids,
purchased_track_count,
copies,
local_variants: variants.into_values().collect(),
playback_mode: match local.preference.mode {
PurchasePlaybackMode::Qobuz => "qobuz",
PurchasePlaybackMode::Purchase => "purchase",
}
.to_string(),
selected_format_id: local.preference.format_id.unwrap_or(0),
})
.unwrap_or_else(|_| serde_json::json!({}))
}
/// Read the three registry accessors in ONE blocking hop.
async fn read_registry() -> Registry {
let rows = tokio::task::spawn_blocking(|| {
@@ -1887,6 +2229,10 @@ pub fn dismiss_region_notice() {
/// call `nav_qt::record` itself, or `QbzShell.navigateTo` would push a second
/// entry for the same route.
pub fn open_album(album_id: String) {
begin_open_album(album_id, None);
}
fn begin_open_album(album_id: String, verified_entitlement: Option<PurchaseAlbum>) {
let album_id = album_id.trim().to_string();
if album_id.is_empty() {
return;
@@ -1909,6 +2255,7 @@ pub fn open_album(album_id: String) {
// asks again — and seeding it from a previous download's RESOLVED ALBUM
// FOLDER would hand the next download a leaf to nest inside.
s.destination = String::new();
s.verified_entitlement = verified_entitlement;
s.generation
});
publish_album();
@@ -1916,6 +2263,102 @@ pub fn open_album(album_id: String) {
crate::spawn(async move { load_album(generation, album_id).await });
}
/// AlbumView's defensive door. A fresh, checked, fully paginated account
/// entitlement must contain this exact downloadable album and explicit format
/// IDs before the purchase detail is opened.
pub fn open_entitled_album(album_id: String) {
let album_id = album_id.trim().to_string();
if album_id.is_empty() {
return;
}
crate::spawn(async move {
let verified = refresh_entitlement_index()
.await
.and_then(|index| index.downloadable_album(&album_id).cloned());
let Some(verified) = verified else {
crate::toast_qt::error(qbz_i18n::t("This purchase is not available for download."));
return;
};
begin_open_album(album_id, Some(verified));
crate::navigate_to("purchase-album");
});
}
/// Persist AlbumView's playback source immediately. A purchase variant is
/// accepted only when one exact-format copy is complete and healthy against
/// the album's exact track IDs.
pub fn set_album_playback_mode(
album_id: String,
mode: String,
format_id: i32,
track_ids_json: String,
) {
let album_id = album_id.trim().to_string();
if album_id.is_empty() {
return;
}
let expected: Vec<i64> = serde_json::from_str::<Vec<String>>(&track_ids_json)
.unwrap_or_default()
.into_iter()
.filter_map(|id| id.parse::<i64>().ok())
.collect();
let requested_purchase = mode == "purchase";
let patch_album_id = album_id.clone();
crate::spawn(async move {
let stored = tokio::task::spawn_blocking(move || {
crate::library_db_qt::with_db(true, |db| {
if requested_purchase {
if format_id <= 0 || expected.is_empty() {
return Ok(false);
}
let valid = db
.inspect_purchase_copies(&album_id, &expected)?
.iter()
.any(|copy| {
copy.copy.format_id == i64::from(format_id)
&& copy.health == PurchaseCopyHealth::CompleteHealthy
});
if !valid {
return Ok(false);
}
db.set_purchase_playback_preference(
&album_id,
PurchasePlaybackMode::Purchase,
Some(i64::from(format_id)),
)?;
} else {
db.set_purchase_playback_preference(
&album_id,
PurchasePlaybackMode::Qobuz,
None,
)?;
}
Ok(true)
})
.unwrap_or(false)
})
.await
.unwrap_or(false);
if !stored {
crate::toast_qt::error(qbz_i18n::t("Could not save playback preference."));
} else {
crate::album_qt::patch_purchase_playback_preference(
&patch_album_id,
if requested_purchase {
"purchase"
} else {
"qobuz"
},
if requested_purchase {
i64::from(format_id)
} else {
0
},
);
}
});
}
/// Opening the detail is a request GRAPH in the reference: `/album/get`, then
/// ALL album and track purchase pages (to recover `downloadable` and
/// `purchased_at`), and CONCURRENTLY a SECOND `/album/get` for the formats.
@@ -1953,19 +2396,39 @@ async fn load_album(generation: u64, album_id: String) {
}
};
// The purchases listing supplies `downloadable` and `purchased_at`, which
// the catalog album does not carry. A failure here is NOT fatal:
// `build_purchase_album` defaults `downloadable` to TRUE when the album is
// absent from the listing, which is the reference's own fallback.
let purchases = svc::get_user_purchases_all(&client)
.await
.unwrap_or_else(|e| {
log::warn!("[qbz-qt] purchases: listing for album {album_id} unavailable: {e}");
PurchaseResponse {
albums: SearchResultsPage::default(),
tracks: SearchResultsPage::default(),
}
});
// AlbumView enters with a freshly revalidated exact row. Keep that proof
// attached to this detail rather than immediately replacing it with the
// historical fail-open list path. Direct entries from Purchases retain the
// existing fallback for compatibility.
let verified = with_detail(|s| {
(s.generation == generation)
.then(|| s.verified_entitlement.clone())
.flatten()
});
let purchases = if let Some(album) = verified {
PurchaseResponse {
albums: SearchResultsPage {
total: 1,
offset: 0,
limit: 1,
items: vec![album],
},
tracks: SearchResultsPage::default(),
}
} else {
// The Purchases list/detail's historical entry remains tolerant: its
// own listing supplied the user's intent, and this fallback predates
// AlbumView. It is never used by `open_entitled_album`.
svc::get_user_purchases_all(&client)
.await
.unwrap_or_else(|e| {
log::warn!("[qbz-qt] purchases: listing for album {album_id} unavailable: {e}");
PurchaseResponse {
albums: SearchResultsPage::default(),
tracks: SearchResultsPage::default(),
}
})
};
let reg = ensure_registry().await;
let local_album_id = resolve_local_album(album_id.clone()).await;
@@ -2763,6 +3226,7 @@ pub fn republish() {
/// including in-flight download statuses. Unwired for the same reason
/// `republish` is.
pub fn reset() {
invalidate_entitlement_index();
*LIST.lock().unwrap_or_else(|e| e.into_inner()) = None;
*DETAIL.lock().unwrap_or_else(|e| e.into_inner()) = None;
*REGISTRY.lock().unwrap_or_else(|e| e.into_inner()) = None;