feat(purchases): gate album actions by entitlement
cx
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user