diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 186cdf3d9..420382c24 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 0a870d399..99eefc6e1 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 7c2dfb91a..b32153092 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 9556d611f..8efb78b63 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 064adf208..7936ab88f 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -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 "購入済み{}曲" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 2f7f07ac5..1537a8bac 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index dc8b71e8d..e00bf3b69 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -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" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index bd6cddc49..de5ff5efb 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -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 "Куплено треков: {}" diff --git a/crates/qbz-offline-cache/src/purchases_service.rs b/crates/qbz-offline-cache/src/purchases_service.rs index 922f35cb9..6393223e2 100644 --- a/crates/qbz-offline-cache/src/purchases_service.rs +++ b/crates/qbz-offline-cache/src/purchases_service.rs @@ -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 QobuzResult { + 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, + track_ids_by_album: HashMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PurchaseEntitlement { + NotOwned, + FullAlbum { + downloadable: bool, + downloadable_format_ids: Vec, + }, + Tracks { + track_ids: HashSet, + }, +} + +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> = 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, tracks: Vec) -> 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); + } + } +} diff --git a/crates/qbz-qobuz/src/purchases.rs b/crates/qbz-qobuz/src/purchases.rs index b604abe02..163ba1574 100644 --- a/crates/qbz-qobuz/src/purchases.rs +++ b/crates/qbz-qobuz/src/purchases.rs @@ -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 { + 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. /// diff --git a/crates/qbz-qt/qml/controls/QbzSelect.qml b/crates/qbz-qt/qml/controls/QbzSelect.qml index 829407690..cbf9140bb 100644 --- a/crates/qbz-qt/qml/controls/QbzSelect.qml +++ b/crates/qbz-qt/qml/controls/QbzSelect.qml @@ -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) diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index e4d77c800..a28afc4d5 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -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) } } } diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs index 33f37a8bf..4ab76759b 100644 --- a/crates/qbz-qt/src/album_qt.rs +++ b/crates/qbz-qt/src/album_qt.rs @@ -239,6 +239,10 @@ pub struct AlbumHeader { pub struct AlbumViewData { pub header: AlbumHeader, pub tracks: Vec, + /// 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 = data + .tracks + .iter() + .filter_map(|track| track.id.parse::().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) { + 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, diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs index c597dc4d5..182a8c825 100644 --- a/crates/qbz-qt/src/auth_qt.rs +++ b/crates/qbz-qt/src/auth_qt.rs @@ -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. diff --git a/crates/qbz-qt/src/purchases_bridge.rs b/crates/qbz-qt/src/purchases_bridge.rs index e6f9069b7..79639482b 100644 --- a/crates/qbz-qt/src/purchases_bridge.rs +++ b/crates/qbz-qt/src/purchases_bridge.rs @@ -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); } diff --git a/crates/qbz-qt/src/purchases_qt.rs b/crates/qbz-qt/src/purchases_qt.rs index 8b645c93e..9121291ec 100644 --- a/crates/qbz-qt/src/purchases_qt.rs +++ b/crates/qbz-qt/src/purchases_qt.rs @@ -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, } static LIST: Mutex> = Mutex::new(None); static DETAIL: Mutex> = Mutex::new(None); static REGISTRY: Mutex> = Mutex::new(None); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EntitlementFreshness { + Unknown, + Fresh, + Stale, +} + +#[derive(Clone)] +struct EntitlementCache { + freshness: EntitlementFreshness, + index: Option, +} + +impl Default for EntitlementCache { + fn default() -> Self { + Self { + freshness: EntitlementFreshness::Unknown, + index: None, + } + } +} + +static ENTITLEMENTS: Mutex = 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 { 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 { + 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, + #[serde(rename = "purchasedTrackCount")] + purchased_track_count: u32, + copies: Vec, + #[serde(rename = "localVariants")] + local_variants: Vec, + #[serde(rename = "playbackMode")] + playback_mode: String, + #[serde(rename = "selectedFormatId")] + selected_format_id: i64, +} + +struct LocalAlbumPurchaseState { + copies: Vec, + preference: qbz_library::PurchaseAlbumPlaybackPreference, + folders: Vec, +} + +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, + 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 = 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 = 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) { 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 = serde_json::from_str::>(&track_ids_json) + .unwrap_or_default() + .into_iter() + .filter_map(|id| id.parse::().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;