3936 lines
145 KiB
Rust
3936 lines
145 KiB
Rust
//! Purchases — the two screens and the download engine.
|
|
//!
|
|
//! Contract: `qbz-nix-docs/qt-frontend/2026-08-15-purchases-contract/`
|
|
//! `00-CONTRACT.md` (behaviour) and `01-BUILD-PLAN.md` §G (the frozen bridge
|
|
//! seam). The reference is Tauri's `PurchasesView.svelte` (1,945 lines) +
|
|
//! `PurchaseAlbumDetailView.svelte` (1,017 lines) + `purchaseDownloadStore.ts`;
|
|
//! the shipping Slint controller (`crates/qbz/src/purchases.rs`) supplied the
|
|
//! already-verified line-by-line ports of the pure filter/sort/group functions
|
|
//! and the download store's semantics, and NOTHING else — §8 lists four things
|
|
//! it invented and one it silently diverged on, and all five are corrected here.
|
|
//!
|
|
//! # Nobody can smoke-test this
|
|
//!
|
|
//! Qobuz does not sell purchases in the owner's region, so their account returns
|
|
//! an empty list forever. Every populated screen this file feeds will FIRST be
|
|
//! seen by a stranger. That is why the pure halves (format re-scoping, the
|
|
//! filters, the sort, the path helpers) carry unit tests at the bottom rather
|
|
//! than a promise to check them at runtime.
|
|
//!
|
|
//! # The four corrections to the Slint controller, all §8
|
|
//!
|
|
//! 1. **`is_controlling_remote()` / skip-if-remote is an INVENTION.** Zero hits
|
|
//! for `skipIfRemote` in any Tauri purchases file; the Slint build silently
|
|
//! no-ops "Download All" while a QConnect peer is active, with no toast.
|
|
//! Not ported.
|
|
//! 2. **The album loop registers INSIDE the download.** Slint calls
|
|
//! `download_purchase_track_file_only` and then does a best-effort registry
|
|
//! write that swallows failure, leaving files on disk with no registry row
|
|
//! while reporting success (§8-D1). This calls `download_purchase_track`
|
|
//! with `Some(album_id)`, which registers in the same call and propagates —
|
|
//! a registry failure marks the track `failed`, exactly as BOTH Tauri paths
|
|
//! do.
|
|
//! 3. **The album-downloaded rule reads the LOCAL REGISTRY** (`§11-1`, owner
|
|
//! ruling): `apply_download_flags` + `album_downloaded_from_registry`. The
|
|
//! Slint keeps its own copy of the old nested-tracks predicate, which is
|
|
//! unsatisfiable on the Albums tab and pins every card to "not downloaded".
|
|
//! 4. **The `metadataLoaded` guard is Tauri's and is owed** — the registry and
|
|
//! the two per-type totals load ONCE per visit, CONCURRENTLY.
|
|
//!
|
|
//! # The album download is a FRONTEND loop (§4.1)
|
|
//!
|
|
//! `v2_purchases_download_album` exists in the reference, is registered, is
|
|
//! wrapped, and was **never called**. What ran is `purchaseDownloadStore.ts`'s
|
|
//! strictly sequential, concurrency-1 loop keyed by album id, honouring a cancel
|
|
//! flag checked BEFORE each track. Porting the Rust command instead would lose
|
|
//! cancellation and per-track progress. A failed track does NOT abort the album
|
|
//! (§4.2b): it is marked `failed`, the loop continues, and only `allComplete`
|
|
//! stays false.
|
|
//!
|
|
//! # The SEND boundary, and why the loop runs on its own thread
|
|
//!
|
|
//! `download_purchase_track(&client, &db, …)` holds `&LibraryDatabase` across a
|
|
//! multi-minute CDN await. `rusqlite::Connection` is `Send` but not `Sync`, so
|
|
//! that future is `!Send` and cannot be spawned on the multi-thread runtime. The
|
|
//! loop therefore runs on a `spawn_blocking` worker driving a CURRENT-THREAD
|
|
//! tokio runtime, which owns its own `LibraryDatabase`. That also gives the
|
|
//! required strictly-sequential execution for free.
|
|
|
|
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,
|
|
};
|
|
use qbz_offline_cache::purchases_service as svc;
|
|
use qbz_qobuz::QobuzClient;
|
|
use serde::Serialize;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Preference keys — ui_prefs.json, SHARED with the shipping Slint build
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// §10-A ruling: Qt persists per-MACHINE (its own mechanism) rather than Tauri's
|
|
// per-user `getUserItem`, flagged as §11-3 — account B inherits account A's
|
|
// Purchases visibility. These are the exact key names `crates/qbz/src/
|
|
// ui_prefs.rs:455-470` already declares, so a user moving between the two
|
|
// frontends keeps their filters instead of finding them reset.
|
|
|
|
const PREF_HIDE_UNAVAILABLE: &str = "purchases_hide_unavailable";
|
|
const PREF_HIDE_DOWNLOADED: &str = "purchases_hide_downloaded";
|
|
const PREF_QUALITY_FILTER: &str = "purchases_quality_filter";
|
|
const PREF_REGION_NOTICE_SEEN: &str = "purchases_region_notice_seen";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Documents (contract §G.2 / §G.3 — FROZEN; two QML lanes are written against
|
|
// these). Additive fields are marked ADDITIVE and named in the lane report.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Serialize, Default)]
|
|
struct Counts {
|
|
albums: u32,
|
|
tracks: u32,
|
|
}
|
|
|
|
#[derive(Serialize, Default)]
|
|
struct ToolbarDoc {
|
|
group: String,
|
|
sort: String,
|
|
ascending: bool,
|
|
#[serde(rename = "viewMode")]
|
|
view_mode: String,
|
|
#[serde(rename = "hideUnavailable")]
|
|
hide_unavailable: bool,
|
|
#[serde(rename = "hideDownloaded")]
|
|
hide_downloaded: bool,
|
|
#[serde(rename = "qualityFilter")]
|
|
quality_filter: String,
|
|
/// ADDITIVE: the distinct genres of the loaded albums (sorted) and the
|
|
/// one selected (`""` = all). Transient like group/sort — it dies with
|
|
/// the screen (§10-F), it is not one of the four persisted prefs.
|
|
genres: Vec<String>,
|
|
#[serde(rename = "genreFilter")]
|
|
genre_filter: String,
|
|
}
|
|
|
|
/// One Albums-tab row.
|
|
#[derive(Serialize)]
|
|
struct AlbumRow {
|
|
id: String,
|
|
title: String,
|
|
artist: String,
|
|
#[serde(rename = "artistId")]
|
|
artist_id: String,
|
|
/// The REMOTE cover url — the key `QbzShell.sidebarArtworkWindow` replies
|
|
/// on and the value `AlbumCard`'s pin payload persists.
|
|
#[serde(rename = "artworkUrl")]
|
|
artwork_url: String,
|
|
/// ADDITIVE to §G.2. Rust-resolved `file://` path for the same cover, `""`
|
|
/// until it is on disk. `AlbumCollection.qml:259` binds
|
|
/// `modelData.artPath` into the card's `artSource`; a delegate given only
|
|
/// the remote url draws NOTHING (the disk cache is a Rust-side concern —
|
|
/// `artist_releases_qt.rs:37-47` and `musician_qt.rs::AppearanceRow`
|
|
/// document the same trap).
|
|
#[serde(rename = "artPath")]
|
|
art_path: String,
|
|
year: String,
|
|
/// ADDITIVE: the full `release_date_original` (ISO `YYYY-MM-DD`, `""` when
|
|
/// the listing has none), so the list can show a readable release date
|
|
/// next to the purchase date and sort by it.
|
|
#[serde(rename = "releaseDate")]
|
|
release_date: String,
|
|
genre: String,
|
|
#[serde(rename = "qualityTier")]
|
|
quality_tier: String,
|
|
/// ADDITIVE to §G.2: the exact-quality line ("24-bit / 96 kHz") the shared
|
|
/// quality badge renders beside the tier word.
|
|
#[serde(rename = "qualityDetail")]
|
|
quality_detail: String,
|
|
#[serde(rename = "tracksCount")]
|
|
tracks_count: u32,
|
|
/// Drives the click gate AND the unavailable marker (§2.6).
|
|
downloadable: bool,
|
|
/// From the LOCAL REGISTRY (§11-1), not from the response.
|
|
downloaded: bool,
|
|
#[serde(rename = "purchasedAt")]
|
|
purchased_at: i64,
|
|
}
|
|
|
|
/// One Tracks-tab row.
|
|
#[derive(Serialize)]
|
|
struct TrackRow {
|
|
/// A **string**: QML `int` is 32-bit and track ids overflow it (§G.0).
|
|
id: String,
|
|
title: String,
|
|
/// Rendered as `formatTrackTitle(title, version)` — see `AlbumTrackRow`.
|
|
version: String,
|
|
artist: String,
|
|
album: String,
|
|
#[serde(rename = "albumId")]
|
|
album_id: String,
|
|
#[serde(rename = "artworkUrl")]
|
|
artwork_url: String,
|
|
/// ADDITIVE to §G.2 — see `AlbumRow::art_path`. `TrackRow.qml`'s 36 px art
|
|
/// cell reads `item.artPath` (the same patch `label_qt.rs:452-467` makes).
|
|
#[serde(rename = "artPath")]
|
|
art_path: String,
|
|
duration: u32,
|
|
#[serde(rename = "qualityTier")]
|
|
quality_tier: String,
|
|
/// ADDITIVE — see `AlbumRow::quality_detail`.
|
|
#[serde(rename = "qualityDetail")]
|
|
quality_detail: String,
|
|
/// The purchases-list default is TRUE (`serde_true`, §2.6).
|
|
streamable: bool,
|
|
downloaded: bool,
|
|
#[serde(rename = "purchasedAt")]
|
|
purchased_at: i64,
|
|
}
|
|
|
|
/// `list_json`.
|
|
#[derive(Serialize)]
|
|
struct ListDoc {
|
|
loading: bool,
|
|
/// `""` = none; non-empty renders the retry block.
|
|
error: String,
|
|
tab: String,
|
|
/// From TWO `getUserPurchases(type, limit=1)` calls — one per type — never
|
|
/// from `getUserPurchasesIds`: that endpoint's `tracks.total` counts every
|
|
/// purchased track INCLUDING the ones that came inside an album (34 on an
|
|
/// account with three albums and no standalone track, 2026-09-01), while
|
|
/// the Tracks tab lists standalone track purchases only. Never from the
|
|
/// active tab's own page either: a typed page simply omits the sibling
|
|
/// type's key (§2.5b-2), so the other counter needs its own call.
|
|
counts: Counts,
|
|
search: String,
|
|
searching: bool,
|
|
#[serde(rename = "regionNoticeVisible")]
|
|
region_notice_visible: bool,
|
|
/// The Tracks tab shows ONLY group + the two filters (§5).
|
|
toolbar: ToolbarDoc,
|
|
albums: Vec<AlbumRow>,
|
|
tracks: Vec<TrackRow>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct FormatRow {
|
|
id: u32,
|
|
label: String,
|
|
/// Every track of the album is registered as downloaded IN THIS FORMAT
|
|
/// (the registry, not the transient store): the dropdown marks it.
|
|
downloaded: bool,
|
|
}
|
|
|
|
/// One detail-screen track.
|
|
#[derive(Serialize)]
|
|
struct AlbumTrackRow {
|
|
id: String,
|
|
title: String,
|
|
/// `formatTrackTitle(title, version)` appends `" (version)"` when this is
|
|
/// non-empty (§10-C — the fix for the latent #360 regression; the reference
|
|
/// declared the field on its frontend type and never mapped it).
|
|
version: String,
|
|
artist: String,
|
|
#[serde(rename = "trackNumber")]
|
|
track_number: u32,
|
|
duration: u32,
|
|
/// ADDITIVE to §G.3: the catalog track's tier + exact-quality line, so the
|
|
/// detail rows can carry the same quality badge every other track surface
|
|
/// has. `PurchaseAlbumView.qml:803` reads `qualityDetail`.
|
|
#[serde(rename = "qualityTier")]
|
|
quality_tier: String,
|
|
#[serde(rename = "qualityDetail")]
|
|
quality_detail: String,
|
|
/// The CATALOG default is FALSE (`/album/get`'s `Track`, §2.6) — gates
|
|
/// click-to-play. A blanket "streamable defaults true" inverts this screen.
|
|
streamable: bool,
|
|
/// RE-SCOPED by `selectedFormatId` (§6).
|
|
downloaded: bool,
|
|
/// 0 none · 1 queued · 2 downloading · 3 ready · 4 failed (§G.4).
|
|
status: i32,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct DiscRow {
|
|
number: u32,
|
|
tracks: Vec<AlbumTrackRow>,
|
|
}
|
|
|
|
#[derive(Serialize, Default)]
|
|
struct ProgressDoc {
|
|
active: bool,
|
|
/// Counted in TRACKS, never bytes (§4.2). The reference declares
|
|
/// `bytesDownloaded`/`totalBytes` and uses neither.
|
|
completed: u32,
|
|
total: u32,
|
|
#[serde(rename = "allComplete")]
|
|
all_complete: bool,
|
|
/// ADDITIVE to §G.3. The reference's progress section renders when
|
|
/// `downloadingAll || allComplete || wasCancelled`, and the cancelled arm
|
|
/// has its own already-translated msgid ("Download cancelled ({}/{})").
|
|
/// Without this flag that arm is unreachable from QML.
|
|
cancelled: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct GoodieRow {
|
|
id: String,
|
|
name: String,
|
|
}
|
|
|
|
/// ADDITIVE to §G.3. Goodies are counted SEPARATELY from the track progress —
|
|
/// a goodie is not a track (§14.3) — so they cannot ride `progress`.
|
|
#[derive(Serialize, Default)]
|
|
struct GoodiesProgressDoc {
|
|
active: bool,
|
|
completed: u32,
|
|
total: u32,
|
|
}
|
|
|
|
/// `album_json`.
|
|
#[derive(Serialize)]
|
|
struct AlbumDoc {
|
|
loading: bool,
|
|
error: String,
|
|
id: String,
|
|
title: String,
|
|
artist: String,
|
|
#[serde(rename = "artistId")]
|
|
artist_id: String,
|
|
#[serde(rename = "artworkUrl")]
|
|
artwork_url: String,
|
|
/// ADDITIVE — see `AlbumRow::art_path`.
|
|
#[serde(rename = "artPath")]
|
|
art_path: String,
|
|
year: String,
|
|
label: String,
|
|
genre: String,
|
|
/// ADDITIVE: the header quality badge, same vocabulary as the rows.
|
|
/// `PurchaseAlbumView.qml:411` reads `qualityDetail`.
|
|
#[serde(rename = "qualityTier")]
|
|
quality_tier: String,
|
|
#[serde(rename = "qualityDetail")]
|
|
quality_detail: String,
|
|
#[serde(rename = "tracksCount")]
|
|
tracks_count: u32,
|
|
duration: u32,
|
|
downloadable: bool,
|
|
#[serde(rename = "purchasedAt")]
|
|
purchased_at: i64,
|
|
/// Order IS the dropdown order, and it is load-bearing: the highest
|
|
/// available quality is `formats[0]` and therefore the default (§4.3).
|
|
formats: Vec<FormatRow>,
|
|
#[serde(rename = "selectedFormatId")]
|
|
selected_format_id: i32,
|
|
destination: String,
|
|
/// Empty => render NOTHING. No empty state, no placeholder: the owner will
|
|
/// never see this populated either, so a wrong empty state is a permanent
|
|
/// wrong (§14.3).
|
|
goodies: Vec<GoodieRow>,
|
|
discs: Vec<DiscRow>,
|
|
progress: ProgressDoc,
|
|
#[serde(rename = "goodiesProgress")]
|
|
goodies_progress: GoodiesProgressDoc,
|
|
#[serde(rename = "addToLibraryVisible")]
|
|
add_to_library_visible: bool,
|
|
/// The download folder of this album that the Local Library has indexed
|
|
/// (`""` = none yet). It is the id the local album page opens, so the
|
|
/// detail can link straight to the copy on disk.
|
|
#[serde(rename = "localAlbumId")]
|
|
local_album_id: String,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-track download status — the §G.4 vocabulary
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The four states a track can be in inside an album download (the reference's
|
|
/// `TrackDownloadStatus`, `purchaseDownloadStore.ts:1`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DlStatus {
|
|
Downloading,
|
|
Complete,
|
|
Failed,
|
|
Cancelled,
|
|
}
|
|
|
|
impl DlStatus {
|
|
/// Onto the shell's existing four-part vocabulary: 0 none · 1 queued ·
|
|
/// 2 downloading · 3 ready · 4 failed (§G.4), so a purchase download LOOKS
|
|
/// identical to a cache download in `TrackRow`.
|
|
///
|
|
/// `Cancelled` maps to **none**: the reference draws no distinct cancelled
|
|
/// row state (its `.track-row.failed` CSS class has no rule in the only
|
|
/// stylesheet, §5), and the cancellation is reported at the ALBUM level
|
|
/// through `progress.cancelled`. `queued` is deliberately never emitted —
|
|
/// the reference marks one track at a time and leaves the rest blank.
|
|
fn wire(self) -> i32 {
|
|
match self {
|
|
DlStatus::Downloading => 2,
|
|
DlStatus::Complete => 3,
|
|
DlStatus::Failed => 4,
|
|
DlStatus::Cancelled => 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The download store (process-wide; survives navigation, like the reference's
|
|
// module-level writable + abortFlags map)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Per-album download state (`AlbumDownloadState`,
|
|
/// `purchaseDownloadStore.ts:2-8`), keyed by album id.
|
|
#[derive(Debug, Clone, Default)]
|
|
struct AlbumDownload {
|
|
/// `track_id -> status` for tracks touched by THIS album's downloads.
|
|
statuses: HashMap<u64, DlStatus>,
|
|
/// True while the sequential loop is running.
|
|
downloading_all: bool,
|
|
/// True only once EVERY requested track is `Complete`. NOT format-scoped
|
|
/// here — the scoping is applied at READ time, matching the reference's
|
|
/// derived `allComplete = state.allComplete && (formatId === undefined ||
|
|
/// formatId === selectedFormatId)`.
|
|
all_complete: bool,
|
|
/// The album's own folder on disk, resolved from the FIRST successfully
|
|
/// downloaded track's path.
|
|
///
|
|
/// Deliberately NOT called `destination`, and deliberately not the same
|
|
/// value as `DetailState::destination`. That one is the root the user
|
|
/// picked (`/music`); this one is the leaf a download produced
|
|
/// (`/music/Artist/Album [FLAC][24-bit,96kHz]`). It exists so
|
|
/// "Add to Library" adds the ALBUM rather than the whole music root, and so
|
|
/// covers and goodies land beside the tracks.
|
|
///
|
|
/// Feeding it back in as a download destination nests the tree one level
|
|
/// deeper on every subsequent download of the same album, which is what
|
|
/// happened while the two shared a name.
|
|
resolved_album_folder: Option<String>,
|
|
/// The format these downloads used. `None` until a download starts.
|
|
///
|
|
/// `startTrackDownload` OVERWRITES it (§6), which can retroactively
|
|
/// suppress a completed album's `allComplete` — reproduced deliberately.
|
|
format_id: Option<u32>,
|
|
/// Goodies, counted separately from tracks (§14.3).
|
|
goodies_active: bool,
|
|
goodies_done: u32,
|
|
goodies_total: u32,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct DownloadStore {
|
|
albums: HashMap<String, AlbumDownload>,
|
|
/// Album titles for the nav flyout's Downloads section — the store keys
|
|
/// by id and a menu row cannot show an id.
|
|
titles: HashMap<String, String>,
|
|
/// `album_id -> abort?` (the reference's module-level `abortFlags`, NOT
|
|
/// part of the store itself — cooperative cancellation checked BETWEEN
|
|
/// tracks).
|
|
abort: HashMap<String, bool>,
|
|
}
|
|
|
|
impl DownloadStore {
|
|
fn entry(&mut self, album_id: &str) -> &mut AlbumDownload {
|
|
self.albums.entry(album_id.to_string()).or_default()
|
|
}
|
|
|
|
/// `startAlbumDownload` seed (`:140-148`): clear the abort flag, then
|
|
/// REPLACE the album state. Album downloads seed FRESH; only single-track
|
|
/// merges.
|
|
fn seed_album(&mut self, album_id: &str, format_id: u32) {
|
|
self.abort.remove(album_id);
|
|
let goodies = self
|
|
.albums
|
|
.get(album_id)
|
|
.map(|a| (a.goodies_active, a.goodies_done, a.goodies_total))
|
|
.unwrap_or_default();
|
|
self.albums.insert(
|
|
album_id.to_string(),
|
|
AlbumDownload {
|
|
statuses: HashMap::new(),
|
|
downloading_all: true,
|
|
all_complete: false,
|
|
resolved_album_folder: None,
|
|
format_id: Some(format_id),
|
|
// A goodie download in flight is NOT a track download and must
|
|
// survive the seed, or its counter resets under the user.
|
|
goodies_active: goodies.0,
|
|
goodies_done: goodies.1,
|
|
goodies_total: goodies.2,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn mark(&mut self, album_id: &str, track_id: u64, status: DlStatus) {
|
|
self.entry(album_id).statuses.insert(track_id, status);
|
|
}
|
|
|
|
fn remember_title(&mut self, album_id: &str, title: &str) {
|
|
self.titles.insert(album_id.to_string(), title.to_string());
|
|
}
|
|
|
|
/// The albums with anything in flight — a whole-album loop, a single
|
|
/// track, or goodies — as `(id, title, done, total)` for the sidebar's
|
|
/// download rows and the compact progress ring.
|
|
fn active_downloads(&self) -> Vec<(String, String, u32, u32)> {
|
|
let mut out: Vec<(String, String, u32, u32)> = self
|
|
.albums
|
|
.iter()
|
|
.filter(|(_, a)| {
|
|
a.downloading_all
|
|
|| a.goodies_active
|
|
|| a.statuses
|
|
.values()
|
|
.any(|st| matches!(st, DlStatus::Downloading))
|
|
})
|
|
.map(|(id, a)| {
|
|
let done = a
|
|
.statuses
|
|
.values()
|
|
.filter(|st| matches!(st, DlStatus::Complete))
|
|
.count() as u32;
|
|
(
|
|
id.clone(),
|
|
self.titles.get(id).cloned().unwrap_or_default(),
|
|
done,
|
|
a.statuses.len() as u32,
|
|
)
|
|
})
|
|
.collect();
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
/// Resolve the album folder from the FIRST successful track (`:160-165`).
|
|
fn resolve_album_folder(&mut self, album_id: &str, folder: &str) {
|
|
self.entry(album_id).resolved_album_folder = Some(folder.to_string());
|
|
}
|
|
|
|
/// `finalize` (`:167-174`): drop the abort flag; `allComplete = trackIds
|
|
/// .every(id => statuses[id] === 'complete')`; `isDownloadingAll = false`.
|
|
/// `Iterator::all` matches `Array.prototype.every` exactly, including the
|
|
/// empty case (`[].every(...) === true`).
|
|
fn finalize(&mut self, album_id: &str, track_ids: &[u64]) {
|
|
self.abort.remove(album_id);
|
|
let state = self.entry(album_id);
|
|
state.all_complete = track_ids
|
|
.iter()
|
|
.all(|id| matches!(state.statuses.get(id), Some(DlStatus::Complete)));
|
|
state.downloading_all = false;
|
|
}
|
|
|
|
/// The abort branch (`:151-158`): every requested track with NO status
|
|
/// entry becomes `Cancelled` — a track that already has ANY status
|
|
/// (including `Downloading`) is left alone, because the abort check runs
|
|
/// BETWEEN tracks and the in-flight one has already reached a terminal
|
|
/// state.
|
|
fn apply_cancellation(&mut self, album_id: &str, track_ids: &[u64]) {
|
|
{
|
|
let state = self.entry(album_id);
|
|
for id in track_ids {
|
|
state.statuses.entry(*id).or_insert(DlStatus::Cancelled);
|
|
}
|
|
state.downloading_all = false;
|
|
state.all_complete = false;
|
|
}
|
|
self.abort.remove(album_id);
|
|
}
|
|
|
|
/// Single-track MERGE (`:171-184`): overwrite ONLY the target track's
|
|
/// status and set `formatId`. Every prior field survives — so a post-album
|
|
/// single-track redownload keeps `allComplete` and the Add-to-Library
|
|
/// affordance. Does NOT seed fresh, does NOT rewrite the destination, does
|
|
/// NOT set `isDownloadingAll`.
|
|
fn merge_track_start(&mut self, album_id: &str, track_id: u64, format_id: u32) {
|
|
let state = self.entry(album_id);
|
|
state.statuses.insert(track_id, DlStatus::Downloading);
|
|
state.format_id = Some(format_id);
|
|
}
|
|
|
|
fn set_abort(&mut self, album_id: &str) {
|
|
self.abort.insert(album_id.to_string(), true);
|
|
}
|
|
|
|
fn is_aborted(&self, album_id: &str) -> bool {
|
|
self.abort.get(album_id).copied().unwrap_or(false)
|
|
}
|
|
|
|
/// `clearAlbumDownloadState` (`:124-128`): drop the abort flag AND remove
|
|
/// the album entry, so the progress and Add-to-Library blocks vanish.
|
|
fn clear(&mut self, album_id: &str) {
|
|
self.abort.remove(album_id);
|
|
self.albums.remove(album_id);
|
|
}
|
|
|
|
fn get(&self, album_id: &str) -> Option<&AlbumDownload> {
|
|
self.albums.get(album_id)
|
|
}
|
|
}
|
|
|
|
fn store() -> &'static Mutex<DownloadStore> {
|
|
static STORE: OnceLock<Mutex<DownloadStore>> = OnceLock::new();
|
|
STORE.get_or_init(|| Mutex::new(DownloadStore::default()))
|
|
}
|
|
|
|
/// Lock the store, run `f`, return its result. Recovers from a poisoned mutex —
|
|
/// a panic in a mutator can only corrupt one album's transient status map,
|
|
/// which the next download reseeds anyway.
|
|
fn with_store<R>(f: impl FnOnce(&mut DownloadStore) -> R) -> R {
|
|
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
|
|
f(&mut guard)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Format scoping (§6 / §15.5-5) — PURE, and the behaviour most likely to be
|
|
// silently wrong, so it is the one thing with real unit tests below.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `isDownloadedForFormat` (`PurchaseAlbumDetailView.svelte:446`):
|
|
/// `selectedFormatId !== null && (track.downloaded_format_ids ?? [])
|
|
/// .includes(selectedFormatId)`. Keys off the SERVER-derived registry formats
|
|
/// (the REQUESTED format, §B.2), never off the transient store.
|
|
fn downloaded_for_format(downloaded_format_ids: &[u32], selected: Option<u32>) -> bool {
|
|
match selected {
|
|
Some(fmt) => downloaded_format_ids.contains(&fmt),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// `getTrackStatus(trackId)` (`:406-415`), FORMAT-SCOPED: a `Complete` whose
|
|
/// album `formatId` is set and differs from the selected format reports NOTHING
|
|
/// — that is what makes changing the dropdown hide the green checks. Every
|
|
/// other status passes through unscoped.
|
|
fn status_scoped(
|
|
state: Option<&AlbumDownload>,
|
|
track_id: u64,
|
|
selected: Option<u32>,
|
|
) -> Option<DlStatus> {
|
|
let state = state?;
|
|
let status = *state.statuses.get(&track_id)?;
|
|
if status == DlStatus::Complete {
|
|
if let Some(fmt) = state.format_id {
|
|
if Some(fmt) != selected {
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
Some(status)
|
|
}
|
|
|
|
/// Detail-view `allComplete` (`:388`): the stored flag gated by the selected
|
|
/// format. A fully downloaded album for format X reads `false` while format Y
|
|
/// is selected — which is what retroactively hides the Add-to-Library
|
|
/// affordance and the "complete" progress label.
|
|
fn all_complete_for_format(state: Option<&AlbumDownload>, selected: Option<u32>) -> bool {
|
|
let Some(state) = state else { return false };
|
|
if !state.all_complete {
|
|
return false;
|
|
}
|
|
match state.format_id {
|
|
None => true,
|
|
Some(fmt) => Some(fmt) == selected,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure list helpers — ported from `PurchasesView.svelte` §2.1.5
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum QualityFilter {
|
|
All,
|
|
Hires,
|
|
Cd,
|
|
Lossy,
|
|
}
|
|
|
|
impl QualityFilter {
|
|
fn parse(s: &str) -> Self {
|
|
match s {
|
|
"hires" => QualityFilter::Hires,
|
|
"cd" => QualityFilter::Cd,
|
|
"lossy" => QualityFilter::Lossy,
|
|
_ => QualityFilter::All,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `matchesQualityFilter(hires, bitDepth?, samplingRate?)` (`:188-194`).
|
|
fn matches_quality(
|
|
filter: QualityFilter,
|
|
hires: bool,
|
|
bit_depth: Option<u32>,
|
|
sampling_rate: Option<f64>,
|
|
) -> bool {
|
|
match filter {
|
|
QualityFilter::All => true,
|
|
QualityFilter::Hires => hires,
|
|
// `!hires && (bitDepth === 16 || (!bitDepth && !samplingRate))`
|
|
QualityFilter::Cd => {
|
|
!hires && (bit_depth == Some(16) || (bit_depth.is_none() && sampling_rate.is_none()))
|
|
}
|
|
// `!bitDepth || bitDepth < 16`
|
|
QualityFilter::Lossy => bit_depth.is_none_or(|d| d < 16),
|
|
}
|
|
}
|
|
|
|
/// The tier badge word — `"hires"` | `"cd"`.
|
|
///
|
|
/// `home_qt::quality_tier_from_depth` is THE rule everywhere else in this port,
|
|
/// but it answers `""` when the payload carries no bit depth. A purchase row
|
|
/// always knows whether it is hi-res, because `hires` is a non-optional field
|
|
/// on both purchase structs (and on the catalog track), so the depth decides
|
|
/// when it is there and the flag decides when it is not. Same answer on the
|
|
/// list and on the detail for the same album, which is the point.
|
|
fn tier(hires: bool, bit_depth: Option<u32>) -> String {
|
|
let by_depth = crate::home_qt::quality_tier_from_depth(bit_depth);
|
|
if !by_depth.is_empty() {
|
|
by_depth.to_string()
|
|
} else if hires {
|
|
"hires".to_string()
|
|
} else {
|
|
"cd".to_string()
|
|
}
|
|
}
|
|
|
|
/// The exact-quality line under the badge ("24-bit / 96 kHz"), read by
|
|
/// `PurchaseAlbumView.qml` on the header and on every track row.
|
|
///
|
|
/// `quality_detail_from_parts` INVENTS `16-bit / 44.1 kHz` when handed two
|
|
/// `None`s — sensible where the tier already says "cd", but on a purchase row
|
|
/// with no quality data at all that is a number nobody sent us. Knowing
|
|
/// nothing prints nothing; everything else goes through the shared formatter so
|
|
/// the string matches every other surface character for character.
|
|
/// The badge for what the account BOUGHT. A DSD purchase streams as CD
|
|
/// (`hires:false`, 16/44.1 on the catalog — the live DSD64 album), so the
|
|
/// catalog's depth/rate describe the stream, not the purchase; the entitlement
|
|
/// ids are the only honest source. 55/56 wear the `dsd` tier (the DSD mark)
|
|
/// with the multiple in the detail line; anything without a DSD id keeps the
|
|
/// catalog-derived pair unchanged.
|
|
fn purchased_quality(
|
|
hires: bool,
|
|
bit_depth: Option<u32>,
|
|
sampling_rate: Option<f64>,
|
|
entitlement: &[u32],
|
|
) -> (String, String) {
|
|
if entitlement.contains(&56) {
|
|
return ("dsd".to_string(), "DSD128".to_string());
|
|
}
|
|
if entitlement.contains(&55) {
|
|
return ("dsd".to_string(), "DSD64".to_string());
|
|
}
|
|
(
|
|
tier(hires, bit_depth),
|
|
quality_detail(bit_depth, sampling_rate),
|
|
)
|
|
}
|
|
|
|
fn quality_detail(bit_depth: Option<u32>, sampling_rate: Option<f64>) -> String {
|
|
if bit_depth.is_none() && sampling_rate.is_none() {
|
|
return String::new();
|
|
}
|
|
crate::home_qt::quality_detail_from_parts(bit_depth, sampling_rate)
|
|
}
|
|
|
|
/// The `qualityDir` derivation (§4.4): the selected format label with `'/'`
|
|
/// replaced by `'-'`, then TRIMMED. The trim is real and load-bearing — with a
|
|
/// trailing space the album folder is a different directory.
|
|
fn quality_dir(label: &str) -> String {
|
|
label.replace('/', "-").trim().to_string()
|
|
}
|
|
|
|
/// `albumFolderFromFilePath(filePath)` (`purchaseDownloadStore.ts:160-165`):
|
|
/// everything before the last separator, or the whole path when there is none.
|
|
fn album_folder_from_file_path(file_path: &str) -> String {
|
|
if let Some(idx) = file_path.rfind('/') {
|
|
return file_path[..idx].to_string();
|
|
}
|
|
if let Some(idx) = file_path.rfind('\\') {
|
|
return file_path[..idx].to_string();
|
|
}
|
|
file_path.to_string()
|
|
}
|
|
|
|
/// The album folder a download WOULD write into, derived without waiting for a
|
|
/// track to land. `target_path` owns the whole naming rule (sanitisation, the
|
|
/// quality suffix, the single space that is omitted when `quality_dir` is
|
|
/// empty), so asking it for any file inside the album and taking the parent is
|
|
/// the only way to get this without restating that rule.
|
|
fn album_dir_for(destination: &str, artist: &str, album_title: &str, quality: &str) -> PathBuf {
|
|
svc::target_path(destination, artist, album_title, quality, 1, "cover", "jpg")
|
|
.parent()
|
|
.map(Path::to_path_buf)
|
|
.unwrap_or_else(|| PathBuf::from(destination))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The registry snapshot every annotation reads. Refreshed by the guarded
|
|
/// metadata load and again after every download, so a green check survives a
|
|
/// reload rather than only existing in the transient store.
|
|
#[derive(Default, Clone)]
|
|
struct Registry {
|
|
/// Format-AGNOSTIC downloaded track ids. This accessor also PRUNES rows
|
|
/// whose file is gone, which is why it is the one the list reads.
|
|
downloaded_ids: HashSet<i64>,
|
|
format_map: HashMap<i64, Vec<u32>>,
|
|
/// album id -> count of DISTINCT downloaded tracks whose file still exists.
|
|
album_counts: HashMap<String, u32>,
|
|
}
|
|
|
|
/// The LIST screen. Everything here except the four persisted preferences and
|
|
/// the region flag dies on `leave()` (§10-F) — Tauri's `{#if}` chain destroys
|
|
/// the component, and a QML `StackView` that kept it alive would change
|
|
/// behaviour by accident.
|
|
struct ListState {
|
|
/// Bumped by every load. A response that lands for a visit the user has
|
|
/// already left is DROPPED rather than applied.
|
|
generation: u64,
|
|
loading: bool,
|
|
error: String,
|
|
tab: String,
|
|
counts: Counts,
|
|
search: String,
|
|
searching: bool,
|
|
region_notice_visible: bool,
|
|
group: String,
|
|
sort: String,
|
|
ascending: bool,
|
|
view_mode: String,
|
|
hide_unavailable: bool,
|
|
hide_downloaded: bool,
|
|
quality_filter: String,
|
|
genre_filter: String,
|
|
albums_raw: Vec<PurchaseAlbum>,
|
|
tracks_raw: Vec<PurchaseTrack>,
|
|
albums_loaded: bool,
|
|
tracks_loaded: bool,
|
|
/// Tauri's `metadataLoaded` (`PurchasesView.svelte:71`, `:314`, `:326`) —
|
|
/// the registry and the two per-type totals load ONCE, CONCURRENTLY, and
|
|
/// re-entry returns early. It is a COMPONENT-scoped `let` in the reference,
|
|
/// so it is reset by `leave()` here: switching tabs within one visit hits
|
|
/// the guard (its purpose), a fresh visit re-reads the counters (which a
|
|
/// download in between has just invalidated).
|
|
metadata_loaded: bool,
|
|
/// Monotonic search token, so a stale in-flight refetch cannot overwrite a
|
|
/// newer one.
|
|
search_seq: u64,
|
|
}
|
|
|
|
impl Default for ListState {
|
|
fn default() -> Self {
|
|
Self {
|
|
generation: 0,
|
|
loading: false,
|
|
error: String::new(),
|
|
tab: "albums".to_string(),
|
|
counts: Counts::default(),
|
|
search: String::new(),
|
|
searching: false,
|
|
region_notice_visible: true,
|
|
// FALSE until the entry seed reads the pref: the modal must never
|
|
// flash for a user who already dismissed it.
|
|
group: "off".to_string(),
|
|
// §G.2's spelling of the reference's `'date'` sort key.
|
|
sort: "purchased".to_string(),
|
|
ascending: false,
|
|
view_mode: "grid".to_string(),
|
|
hide_unavailable: false,
|
|
hide_downloaded: false,
|
|
quality_filter: "all".to_string(),
|
|
genre_filter: String::new(),
|
|
albums_raw: Vec::new(),
|
|
tracks_raw: Vec::new(),
|
|
albums_loaded: false,
|
|
tracks_loaded: false,
|
|
metadata_loaded: false,
|
|
search_seq: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The DETAIL screen. One album at a time; opening another resets it wholesale.
|
|
#[derive(Default)]
|
|
struct DetailState {
|
|
generation: u64,
|
|
loading: bool,
|
|
error: String,
|
|
album_id: String,
|
|
/// The purchase-annotated album (header + nested tracks).
|
|
album: Option<PurchaseAlbum>,
|
|
/// The catalog album, kept for the tag context, the cover urls and the
|
|
/// goodies list.
|
|
catalog: Option<Album>,
|
|
art_path: String,
|
|
formats: Vec<PurchaseFormatOption>,
|
|
selected_format_id: Option<u32>,
|
|
/// The folder the user picked for THIS album, before any download has
|
|
/// rewritten the store's copy to the album subfolder.
|
|
destination: String,
|
|
/// 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
|
|
/// generation the new visit also uses, pass the guard, and paint the old
|
|
/// account's or the old tab's rows over the new ones. One counter for both
|
|
/// screens is enough; they only ever compare against their own snapshot.
|
|
static GENERATION: AtomicU64 = AtomicU64::new(0);
|
|
|
|
fn next_generation() -> u64 {
|
|
GENERATION.fetch_add(1, Ordering::Relaxed).wrapping_add(1)
|
|
}
|
|
|
|
fn with_list<R>(f: impl FnOnce(&mut ListState) -> R) -> R {
|
|
let mut guard = LIST.lock().unwrap_or_else(|e| e.into_inner());
|
|
f(guard.get_or_insert_with(ListState::default))
|
|
}
|
|
|
|
fn with_detail<R>(f: impl FnOnce(&mut DetailState) -> R) -> R {
|
|
let mut guard = DETAIL.lock().unwrap_or_else(|e| e.into_inner());
|
|
f(guard.get_or_insert_with(DetailState::default))
|
|
}
|
|
|
|
fn set_registry(value: Registry) {
|
|
*REGISTRY.lock().unwrap_or_else(|e| e.into_inner()) = Some(value);
|
|
}
|
|
|
|
/// The stored registry, reading it once if nothing has. Every annotation goes
|
|
/// through here rather than through its own `read_registry()`: the guarded
|
|
/// metadata load is what OWNS the read (that is Tauri's design —
|
|
/// `loadPurchasesMetadata` reads the two accessors and every later enrichment
|
|
/// uses the values it stored), and three consumers each stat-ing every
|
|
/// registered file would be three sweeps of the whole download folder per
|
|
/// visit.
|
|
async fn ensure_registry() -> Registry {
|
|
if let Some(reg) = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).clone() {
|
|
return reg;
|
|
}
|
|
let reg = read_registry().await;
|
|
set_registry(reg.clone());
|
|
reg
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Publish
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// `purchasesRev` is bumped inline in both publishers rather than through a
|
|
// helper: the `Pin<&mut QbzPurchases>` receiver cannot cross a plain fn boundary
|
|
// without naming the bridge's generated type, and two lines twice is cheaper
|
|
// than that import.
|
|
|
|
fn publish_list() {
|
|
let json =
|
|
with_list(|s| serde_json::to_string(&build_list_doc(s)).unwrap_or_else(|_| "{}".into()));
|
|
crate::purchases_bridge::ui(move |mut b| {
|
|
b.as_mut().set_list_json(QString::from(json.as_str()));
|
|
let next = b.as_ref().purchases_rev() + 1;
|
|
b.as_mut().set_purchases_rev(next);
|
|
});
|
|
}
|
|
|
|
fn publish_album() {
|
|
let json =
|
|
with_detail(|s| serde_json::to_string(&build_album_doc(s)).unwrap_or_else(|_| "{}".into()));
|
|
let active = with_store(|st| {
|
|
serde_json::to_string(
|
|
&st.active_downloads()
|
|
.into_iter()
|
|
.map(|(id, title, done, total)| {
|
|
serde_json::json!({
|
|
"id": id,
|
|
"title": title,
|
|
"done": done,
|
|
"total": total,
|
|
"percent": if total > 0 { done * 100 / total } else { 0 }
|
|
})
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.unwrap_or_else(|_| "[]".into())
|
|
});
|
|
crate::purchases_bridge::ui(move |mut b| {
|
|
b.as_mut().set_album_json(QString::from(json.as_str()));
|
|
b.as_mut()
|
|
.set_active_downloads_json(QString::from(active.as_str()));
|
|
let next = b.as_ref().purchases_rev() + 1;
|
|
b.as_mut().set_purchases_rev(next);
|
|
});
|
|
}
|
|
|
|
/// Filter + sort the raw caches and project them into the wire rows.
|
|
///
|
|
/// GROUPING IS NOT DONE HERE. §G.2 publishes flat arrays plus `toolbar.group`,
|
|
/// and `AlbumCollection` renders pre-built sections (`grouped: [{title,
|
|
/// albums}]`), so the QML host owns the bucketing. The reference's rules, for
|
|
/// whoever writes it: album keys are the artist name or `alphaGroupKey(title)`
|
|
/// (first char uppercased; non-`[A-Z]` → `"#"`); track keys are
|
|
/// `alphaGroupKey(title)` / performer name / album title or `"Unknown"`; groups
|
|
/// are ordered by key, ascending.
|
|
fn build_list_doc(s: &ListState) -> ListDoc {
|
|
let filter = QualityFilter::parse(&s.quality_filter);
|
|
|
|
// `applyAlbumFilters` (`:196-202`) — the ORDER matters: hide unavailable,
|
|
// then quality, then hide downloaded.
|
|
let mut albums: Vec<&PurchaseAlbum> = s
|
|
.albums_raw
|
|
.iter()
|
|
.filter(|a| !s.hide_unavailable || a.downloadable)
|
|
.filter(|a| {
|
|
matches_quality(
|
|
filter,
|
|
a.hires,
|
|
a.maximum_bit_depth,
|
|
a.maximum_sampling_rate,
|
|
)
|
|
})
|
|
.filter(|a| !s.hide_downloaded || !a.downloaded)
|
|
.filter(|a| {
|
|
s.genre_filter.is_empty() || a.genre.as_ref().is_some_and(|g| g.name == s.genre_filter)
|
|
})
|
|
.collect();
|
|
sort_albums(&mut albums, &s.sort, s.ascending);
|
|
let mut genres: Vec<String> = s
|
|
.albums_raw
|
|
.iter()
|
|
.filter_map(|a| a.genre.as_ref().map(|g| g.name.clone()))
|
|
.filter(|g| !g.trim().is_empty())
|
|
.collect();
|
|
genres.sort();
|
|
genres.dedup();
|
|
|
|
// `applyTrackFilters` (`:204-209`): hide-downloaded then quality. Tracks
|
|
// have NO hide-unavailable filter (availability is albums-only) and are
|
|
// NEVER sorted (`:142`).
|
|
let tracks: Vec<&PurchaseTrack> = s
|
|
.tracks_raw
|
|
.iter()
|
|
.filter(|t| !s.hide_downloaded || !t.downloaded)
|
|
.filter(|t| {
|
|
matches_quality(
|
|
filter,
|
|
t.hires,
|
|
t.maximum_bit_depth,
|
|
t.maximum_sampling_rate,
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
ListDoc {
|
|
loading: s.loading,
|
|
error: s.error.clone(),
|
|
tab: s.tab.clone(),
|
|
counts: Counts {
|
|
albums: s.counts.albums,
|
|
tracks: s.counts.tracks,
|
|
},
|
|
search: s.search.clone(),
|
|
searching: s.searching,
|
|
region_notice_visible: s.region_notice_visible,
|
|
toolbar: ToolbarDoc {
|
|
group: s.group.clone(),
|
|
sort: s.sort.clone(),
|
|
ascending: s.ascending,
|
|
view_mode: s.view_mode.clone(),
|
|
hide_unavailable: s.hide_unavailable,
|
|
hide_downloaded: s.hide_downloaded,
|
|
quality_filter: s.quality_filter.clone(),
|
|
genres,
|
|
genre_filter: s.genre_filter.clone(),
|
|
},
|
|
albums: albums.into_iter().map(album_row).collect(),
|
|
tracks: tracks.into_iter().map(track_row).collect(),
|
|
}
|
|
}
|
|
|
|
/// `sortAlbums` (`:211-…`): `dir = asc ? 1 : -1`, applied to one of five keys
|
|
/// (`released` is ours — the reference had no release-date sort).
|
|
/// `"purchased"` is §G.2's spelling of the reference's `"date"`; both are
|
|
/// accepted so a QML lane written against either name works.
|
|
fn sort_albums(list: &mut [&PurchaseAlbum], key: &str, ascending: bool) {
|
|
let flip = |ord: std::cmp::Ordering| if ascending { ord } else { ord.reverse() };
|
|
match key {
|
|
"artist" => list.sort_by(|a, b| flip(a.artist.name.cmp(&b.artist.name))),
|
|
"album" | "title" => list.sort_by(|a, b| flip(a.title.cmp(&b.title))),
|
|
// ISO dates compare as strings; an album without one sorts first
|
|
// ascending / last descending, never among the dated ones.
|
|
"released" => list.sort_by(|a, b| {
|
|
flip(
|
|
a.release_date_original
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.cmp(b.release_date_original.as_deref().unwrap_or("")),
|
|
)
|
|
}),
|
|
// `(a.sr||0)-(b.sr||0) || (a.bd||0)-(b.bd||0)`
|
|
"quality" => list.sort_by(|a, b| {
|
|
let asr = a.maximum_sampling_rate.unwrap_or(0.0);
|
|
let bsr = b.maximum_sampling_rate.unwrap_or(0.0);
|
|
let primary = asr.partial_cmp(&bsr).unwrap_or(std::cmp::Ordering::Equal);
|
|
let ord = if primary != std::cmp::Ordering::Equal {
|
|
primary
|
|
} else {
|
|
a.maximum_bit_depth
|
|
.unwrap_or(0)
|
|
.cmp(&b.maximum_bit_depth.unwrap_or(0))
|
|
};
|
|
flip(ord)
|
|
}),
|
|
// "purchased" / "date" — the default.
|
|
_ => list.sort_by(|a, b| {
|
|
flip(
|
|
a.purchased_at
|
|
.unwrap_or(0)
|
|
.cmp(&b.purchased_at.unwrap_or(0)),
|
|
)
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn album_row(a: &PurchaseAlbum) -> AlbumRow {
|
|
let url = a.image.best().cloned().unwrap_or_default();
|
|
let quality = purchased_quality(
|
|
a.hires,
|
|
a.maximum_bit_depth,
|
|
a.maximum_sampling_rate,
|
|
&a.downloadable_format_ids,
|
|
);
|
|
AlbumRow {
|
|
id: a.id.clone(),
|
|
title: a.title.clone(),
|
|
artist: a.artist.name.clone(),
|
|
artist_id: if a.artist.id != 0 {
|
|
a.artist.id.to_string()
|
|
} else {
|
|
String::new()
|
|
},
|
|
art_path: cached_art(&url),
|
|
artwork_url: url,
|
|
year: svc::parse_release_year(a.release_date_original.as_deref())
|
|
.map(|y| y.to_string())
|
|
.unwrap_or_default(),
|
|
release_date: a.release_date_original.clone().unwrap_or_default(),
|
|
genre: a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),
|
|
quality_tier: quality.0,
|
|
quality_detail: quality.1,
|
|
tracks_count: a.tracks_count.unwrap_or(0),
|
|
downloadable: a.downloadable,
|
|
downloaded: a.downloaded,
|
|
purchased_at: a.purchased_at.unwrap_or(0),
|
|
}
|
|
}
|
|
|
|
fn track_row(t: &PurchaseTrack) -> TrackRow {
|
|
let track_quality = purchased_quality(
|
|
t.hires,
|
|
t.maximum_bit_depth,
|
|
t.maximum_sampling_rate,
|
|
&t.downloadable_format_ids,
|
|
);
|
|
let (album_title, album_id, url) = match &t.album {
|
|
Some(al) => (
|
|
al.title.clone(),
|
|
al.id.clone(),
|
|
al.image.best().cloned().unwrap_or_default(),
|
|
),
|
|
None => (String::new(), String::new(), String::new()),
|
|
};
|
|
TrackRow {
|
|
id: t.id.to_string(),
|
|
title: t.title.clone(),
|
|
version: t.version.clone().unwrap_or_default(),
|
|
artist: t.performer.name.clone(),
|
|
album: album_title,
|
|
album_id,
|
|
art_path: cached_art(&url),
|
|
artwork_url: url,
|
|
duration: t.duration,
|
|
quality_tier: track_quality.0,
|
|
quality_detail: track_quality.1,
|
|
streamable: t.streamable,
|
|
downloaded: t.downloaded,
|
|
purchased_at: t.purchased_at.unwrap_or(0),
|
|
}
|
|
}
|
|
|
|
/// `file://…` for a cover already on disk, `""` otherwise. Synchronous and
|
|
/// memoised, so calling it once per row per publish is cheap.
|
|
fn cached_art(url: &str) -> String {
|
|
if url.is_empty() {
|
|
String::new()
|
|
} else {
|
|
crate::artwork_qt::cached_path(url)
|
|
}
|
|
}
|
|
|
|
/// Every cover url on the list that is NOT on disk yet — the second-pass
|
|
/// download set (`musician_qt::attach_art` / `artist_releases_qt::fetch`).
|
|
fn missing_list_art(s: &ListState) -> Vec<String> {
|
|
let mut missing: Vec<String> = Vec::new();
|
|
for url in s
|
|
.albums_raw
|
|
.iter()
|
|
.map(|a| a.image.best().cloned().unwrap_or_default())
|
|
.chain(s.tracks_raw.iter().map(|t| {
|
|
t.album
|
|
.as_ref()
|
|
.and_then(|al| al.image.best().cloned())
|
|
.unwrap_or_default()
|
|
}))
|
|
{
|
|
if !url.is_empty() && crate::artwork_qt::cached_path(&url).is_empty() {
|
|
missing.push(url);
|
|
}
|
|
}
|
|
missing.sort();
|
|
missing.dedup();
|
|
missing
|
|
}
|
|
|
|
fn build_album_doc(s: &DetailState) -> AlbumDoc {
|
|
let selected = s.selected_format_id;
|
|
let dl = with_store(|st| st.get(&s.album_id).cloned());
|
|
let dl_ref = dl.as_ref();
|
|
|
|
let Some(album) = s.album.as_ref() else {
|
|
// Loading or failed: the header fields are empty and the QML host
|
|
// renders its spinner / retry block off `loading` + `error`.
|
|
return AlbumDoc {
|
|
loading: s.loading,
|
|
error: s.error.clone(),
|
|
id: s.album_id.clone(),
|
|
title: String::new(),
|
|
artist: String::new(),
|
|
artist_id: String::new(),
|
|
artwork_url: String::new(),
|
|
art_path: String::new(),
|
|
year: String::new(),
|
|
label: String::new(),
|
|
genre: String::new(),
|
|
quality_tier: String::new(),
|
|
quality_detail: String::new(),
|
|
tracks_count: 0,
|
|
duration: 0,
|
|
downloadable: true,
|
|
purchased_at: 0,
|
|
formats: Vec::new(),
|
|
selected_format_id: 0,
|
|
destination: s.destination.clone(),
|
|
goodies: Vec::new(),
|
|
discs: Vec::new(),
|
|
progress: ProgressDoc::default(),
|
|
goodies_progress: GoodiesProgressDoc::default(),
|
|
add_to_library_visible: false,
|
|
local_album_id: String::new(),
|
|
};
|
|
};
|
|
|
|
let tracks: &[PurchaseTrack] = album
|
|
.tracks
|
|
.as_ref()
|
|
.map(|p| p.items.as_slice())
|
|
.unwrap_or(&[]);
|
|
let header_quality = purchased_quality(
|
|
album.hires,
|
|
album.maximum_bit_depth,
|
|
album.maximum_sampling_rate,
|
|
&album.downloadable_format_ids,
|
|
);
|
|
|
|
// Disc grouping: item order preserved, bucketed by `media_number ?? 1`.
|
|
let mut discs: Vec<DiscRow> = Vec::new();
|
|
for t in tracks {
|
|
let number = t.media_number.unwrap_or(1);
|
|
let row_quality = purchased_quality(
|
|
t.hires,
|
|
t.maximum_bit_depth,
|
|
t.maximum_sampling_rate,
|
|
&album.downloadable_format_ids,
|
|
);
|
|
let row = AlbumTrackRow {
|
|
id: t.id.to_string(),
|
|
title: t.title.clone(),
|
|
version: t.version.clone().unwrap_or_default(),
|
|
artist: t.performer.name.clone(),
|
|
track_number: t.track_number,
|
|
duration: t.duration,
|
|
// The purchase's own quality (DSD128 on a DSD purchase), not the
|
|
// catalog's 24/352.8 that no stream ever delivers.
|
|
quality_tier: row_quality.0,
|
|
quality_detail: row_quality.1,
|
|
streamable: t.streamable,
|
|
// `isDownloadedForFormat || status === 'complete'` (`:446`, and the
|
|
// per-row derive at `:406-415`). BOTH halves are format-scoped,
|
|
// which is what makes the dropdown re-scope the green checks.
|
|
downloaded: downloaded_for_format(&t.downloaded_format_ids, selected)
|
|
|| status_scoped(dl_ref, t.id, selected) == Some(DlStatus::Complete),
|
|
status: status_scoped(dl_ref, t.id, selected)
|
|
.map(DlStatus::wire)
|
|
.unwrap_or(0),
|
|
};
|
|
match discs.iter_mut().find(|d| d.number == number) {
|
|
Some(disc) => disc.tracks.push(row),
|
|
None => discs.push(DiscRow {
|
|
number,
|
|
tracks: vec![row],
|
|
}),
|
|
}
|
|
}
|
|
|
|
// `completedCount` / `wasCancelled` / the bar fill are NOT format-scoped
|
|
// (§A.4) — they count the RAW status map. Only `allComplete` is.
|
|
let completed = dl_ref
|
|
.map(|d| {
|
|
d.statuses
|
|
.values()
|
|
.filter(|st| **st == DlStatus::Complete)
|
|
.count() as u32
|
|
})
|
|
.unwrap_or(0);
|
|
let any_cancelled = dl_ref
|
|
.map(|d| d.statuses.values().any(|st| *st == DlStatus::Cancelled))
|
|
.unwrap_or(false);
|
|
let downloading_all = dl_ref.map(|d| d.downloading_all).unwrap_or(false);
|
|
let all_complete = all_complete_for_format(dl_ref, selected);
|
|
|
|
// The USER-PICKED root, always. The store's `resolved_album_folder` is a
|
|
// leaf produced by a download and must never be shown here or fed back in as
|
|
// a destination — see its declaration.
|
|
let destination = s.destination.clone();
|
|
|
|
AlbumDoc {
|
|
loading: s.loading,
|
|
error: s.error.clone(),
|
|
id: album.id.clone(),
|
|
title: album.title.clone(),
|
|
artist: album.artist.name.clone(),
|
|
artist_id: if album.artist.id != 0 {
|
|
album.artist.id.to_string()
|
|
} else {
|
|
String::new()
|
|
},
|
|
artwork_url: album.image.best().cloned().unwrap_or_default(),
|
|
art_path: s.art_path.clone(),
|
|
year: svc::parse_release_year(album.release_date_original.as_deref())
|
|
.map(|y| y.to_string())
|
|
.unwrap_or_default(),
|
|
label: album
|
|
.label
|
|
.as_ref()
|
|
.map(|l| l.name.clone())
|
|
.unwrap_or_default(),
|
|
genre: album
|
|
.genre
|
|
.as_ref()
|
|
.map(|g| g.name.clone())
|
|
.unwrap_or_default(),
|
|
quality_tier: header_quality.0,
|
|
quality_detail: header_quality.1,
|
|
tracks_count: album.tracks_count.unwrap_or(tracks.len() as u32),
|
|
duration: album
|
|
.duration
|
|
.unwrap_or_else(|| tracks.iter().map(|t| t.duration).sum::<u32>()),
|
|
downloadable: album.downloadable,
|
|
purchased_at: album.purchased_at.unwrap_or(0),
|
|
formats: s
|
|
.formats
|
|
.iter()
|
|
.map(|f| FormatRow {
|
|
id: f.id,
|
|
label: f.label.clone(),
|
|
downloaded: !tracks.is_empty()
|
|
&& tracks
|
|
.iter()
|
|
.all(|t| t.downloaded_format_ids.contains(&f.id)),
|
|
})
|
|
.collect(),
|
|
selected_format_id: selected.unwrap_or(0) as i32,
|
|
destination,
|
|
goodies: s
|
|
.catalog
|
|
.as_ref()
|
|
.and_then(|c| c.goodies.as_ref())
|
|
.map(|list| {
|
|
list.iter()
|
|
// An item with no usable url anywhere is not renderable and
|
|
// not downloadable; showing it would offer an action that
|
|
// silently does nothing.
|
|
.filter(|g| g.best_url().is_some())
|
|
.map(|g| GoodieRow {
|
|
id: g.id.to_string(),
|
|
name: g.display_name(),
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default(),
|
|
discs,
|
|
progress: ProgressDoc {
|
|
active: downloading_all,
|
|
completed,
|
|
total: tracks.len() as u32,
|
|
all_complete,
|
|
// `wasCancelled = !isDownloadingAll && !allComplete && some cancelled`.
|
|
cancelled: !downloading_all && !all_complete && any_cancelled,
|
|
},
|
|
goodies_progress: GoodiesProgressDoc {
|
|
active: dl_ref.map(|d| d.goodies_active).unwrap_or(false),
|
|
completed: dl_ref.map(|d| d.goodies_done).unwrap_or(0),
|
|
total: dl_ref.map(|d| d.goodies_total).unwrap_or(0),
|
|
},
|
|
// The Add-to-Library block: only when `allComplete` (format-scoped) AND
|
|
// a destination exists in the STORE — i.e. a download really happened,
|
|
// not merely that the user picked a folder.
|
|
add_to_library_visible: all_complete
|
|
&& dl_ref
|
|
.and_then(|d| d.resolved_album_folder.as_ref())
|
|
.is_some(),
|
|
local_album_id: s.local_album_id.clone(),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plumbing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Snapshot-clone the live Qobuz client out of its `RwLock` so nothing holds
|
|
/// the lock across a multi-minute CDN fetch. `None` when logged out.
|
|
async fn snapshot_client() -> Option<QobuzClient> {
|
|
let runtime = crate::app();
|
|
let lock = runtime.core().client();
|
|
let guard = lock.read().await;
|
|
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(|| {
|
|
crate::library_db_qt::with_db(false, |db| {
|
|
Ok((
|
|
db.get_downloaded_purchase_track_ids()?,
|
|
db.get_downloaded_purchase_formats()?,
|
|
db.get_downloaded_purchase_album_counts()?,
|
|
))
|
|
})
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
let Some((ids, formats, album_counts)) = rows else {
|
|
return Registry::default();
|
|
};
|
|
let mut format_map: HashMap<i64, Vec<u32>> = HashMap::new();
|
|
for (track_id, format_id) in formats {
|
|
format_map
|
|
.entry(track_id)
|
|
.or_default()
|
|
.push(format_id as u32);
|
|
}
|
|
Registry {
|
|
downloaded_ids: ids.into_iter().collect(),
|
|
format_map,
|
|
album_counts,
|
|
}
|
|
}
|
|
|
|
/// Open a per-user `LibraryDatabase` the download thread can OWN.
|
|
/// `library_db_qt::with_db` scopes its handle to a synchronous closure, and the
|
|
/// download primitive needs `&db` across an await — so this is the one place
|
|
/// that opens its own.
|
|
///
|
|
/// `create: true` in effect: a fresh account has no `library.db` until the first
|
|
/// local write, and the first purchase download must not be the one action that
|
|
/// silently does nothing on a new install.
|
|
fn open_owned_library_db() -> Option<LibraryDatabase> {
|
|
let uid = qbz_app::user_data::UserDataPaths::load_last_user_id().unwrap_or(0);
|
|
let path = dirs::data_dir()?
|
|
.join("qbz")
|
|
.join("users")
|
|
.join(uid.to_string())
|
|
.join("library.db");
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
match LibraryDatabase::open(&path) {
|
|
Ok(db) => Some(db),
|
|
Err(e) => {
|
|
log::error!("[qbz-qt] purchases: open library.db for download failed: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Annotate the caches with the frontend-computed download flags. Runs the
|
|
/// SHARED `apply_download_flags`, which carries the owner's §11-1 ruling (the
|
|
/// album rule reads the local registry) — never a local re-derivation, so the
|
|
/// list and the detail cannot disagree about the same album.
|
|
fn annotate(
|
|
albums: Vec<PurchaseAlbum>,
|
|
tracks: Vec<PurchaseTrack>,
|
|
reg: &Registry,
|
|
) -> (Vec<PurchaseAlbum>, Vec<PurchaseTrack>) {
|
|
let mut response = PurchaseResponse {
|
|
albums: SearchResultsPage {
|
|
total: albums.len() as u32,
|
|
offset: 0,
|
|
limit: 0,
|
|
items: albums,
|
|
},
|
|
tracks: SearchResultsPage {
|
|
total: tracks.len() as u32,
|
|
offset: 0,
|
|
limit: 0,
|
|
items: tracks,
|
|
},
|
|
};
|
|
svc::apply_download_flags(
|
|
&mut response,
|
|
®.downloaded_ids,
|
|
®.format_map,
|
|
®.album_counts,
|
|
);
|
|
(response.albums.items, response.tracks.items)
|
|
}
|
|
|
|
/// Fill any cover that landed on disk since the last publish, then republish.
|
|
/// Two passes, the `artist_releases_qt::fetch` shape: publish what is cached,
|
|
/// download what is not, republish.
|
|
async fn fill_missing_art(generation: u64) {
|
|
let missing = with_list(|s| {
|
|
if s.generation != generation {
|
|
return Vec::new();
|
|
}
|
|
missing_list_art(s)
|
|
});
|
|
if missing.is_empty() {
|
|
return;
|
|
}
|
|
crate::artwork_qt::download_missing(missing).await;
|
|
let still_current = with_list(|s| s.generation == generation);
|
|
if still_current {
|
|
publish_list();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List — the invokables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Entering the route.
|
|
pub fn open_list() {
|
|
let (generation, tab, claim_metadata) = with_list(|s| {
|
|
s.generation = next_generation();
|
|
// The four persisted preferences + the region flag are the ONLY state
|
|
// that survives a visit (§5 / §10-F). Re-seed them on every entry so a
|
|
// toggle made in the shipping Slint build is honoured here too.
|
|
s.hide_unavailable = crate::settings_qt::pref_bool(PREF_HIDE_UNAVAILABLE, false);
|
|
s.hide_downloaded = crate::settings_qt::pref_bool(PREF_HIDE_DOWNLOADED, false);
|
|
s.quality_filter = crate::settings_qt::pref_str(PREF_QUALITY_FILTER, "all");
|
|
s.region_notice_visible = !crate::settings_qt::pref_bool(PREF_REGION_NOTICE_SEEN, false);
|
|
s.error.clear();
|
|
// THE GUARD (§5). `false -> true` is claimed here, under the lock, so
|
|
// two entries in the same frame cannot both fire the metadata load.
|
|
let claim = !s.metadata_loaded;
|
|
s.metadata_loaded = true;
|
|
(s.generation, s.tab.clone(), claim)
|
|
});
|
|
publish_list();
|
|
|
|
crate::spawn(async move {
|
|
// The metadata load runs FIRST and the tab load follows, because the
|
|
// tab's rows are annotated from the registry the metadata load stores.
|
|
// The "concurrently" in §5 is about the registry and the two totals
|
|
// relative to EACH OTHER (the `tokio::join!` below), not about racing
|
|
// the list fetch — the reference awaits `loadPurchasesMetadata()`
|
|
// before it fetches, and reversing that annotates the first paint
|
|
// against an empty registry.
|
|
if claim_metadata {
|
|
load_metadata(generation).await;
|
|
}
|
|
load_tab(generation, tab, false);
|
|
});
|
|
}
|
|
|
|
/// The metadata load: the registry and the two per-type totals, ONCE and
|
|
/// CONCURRENTLY.
|
|
///
|
|
/// The totals need **two** `getUserPurchases(type, limit=1)` calls, one per
|
|
/// type, each falling back to `None` on error — see `svc::get_purchase_total`
|
|
/// for why the ids endpoint is the wrong source (it counts album tracks as
|
|
/// tracks). NEVER one combined call: a typed page omits the sibling type's key
|
|
/// entirely, so the list response can never supply both counters at once.
|
|
async fn load_metadata(generation: u64) {
|
|
let client = snapshot_client().await;
|
|
let (reg, albums_total, tracks_total) = tokio::join!(
|
|
read_registry(),
|
|
async {
|
|
match client.as_ref() {
|
|
Some(c) => svc::get_purchase_total(c, "albums").await,
|
|
None => None,
|
|
}
|
|
},
|
|
async {
|
|
match client.as_ref() {
|
|
Some(c) => svc::get_purchase_total(c, "tracks").await,
|
|
None => None,
|
|
}
|
|
},
|
|
);
|
|
set_registry(reg);
|
|
|
|
let applied = with_list(|s| {
|
|
if s.generation != generation {
|
|
return false;
|
|
}
|
|
// `.catch(() => null)` on each: a failed counter falls back to 0 and the
|
|
// tab still renders, exactly as the reference does.
|
|
s.counts.albums = albums_total.unwrap_or(0);
|
|
s.counts.tracks = tracks_total.unwrap_or(0);
|
|
true
|
|
});
|
|
if applied {
|
|
publish_list();
|
|
}
|
|
}
|
|
|
|
/// Load one tab's complete list. `force` reloads even when the tab is already
|
|
/// populated (the search-cleared path).
|
|
fn load_tab(generation: u64, tab: String, force: bool) {
|
|
let needed = with_list(|s| {
|
|
if s.generation != generation {
|
|
return false;
|
|
}
|
|
let already = if tab == "tracks" {
|
|
s.tracks_loaded
|
|
} else {
|
|
s.albums_loaded
|
|
};
|
|
if already && !force {
|
|
return false;
|
|
}
|
|
s.loading = true;
|
|
s.error.clear();
|
|
true
|
|
});
|
|
if !needed {
|
|
return;
|
|
}
|
|
publish_list();
|
|
|
|
crate::spawn(async move {
|
|
let Some(client) = snapshot_client().await else {
|
|
finish_tab_error(generation);
|
|
return;
|
|
};
|
|
let result = svc::get_user_purchases_by_type(&client, &tab).await;
|
|
let reg = ensure_registry().await;
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
// Captured before `annotate` consumes the items, for the counter
|
|
// backfill below.
|
|
let server_total = if tab == "tracks" {
|
|
response.tracks.total
|
|
} else {
|
|
response.albums.total
|
|
};
|
|
let (albums, tracks) = annotate(response.albums.items, response.tracks.items, ®);
|
|
let applied = with_list(|s| {
|
|
if s.generation != generation {
|
|
return false;
|
|
}
|
|
if tab == "tracks" {
|
|
s.tracks_raw = tracks;
|
|
s.tracks_loaded = true;
|
|
} else {
|
|
s.albums_raw = albums;
|
|
s.albums_loaded = true;
|
|
}
|
|
|
|
// Backfill a counter the ids call could not supply. The
|
|
// reference does this too and it is easy to miss, because the
|
|
// two `getUserPurchasesIds` calls look like the whole story:
|
|
// `if (!totalAlbumPurchases) { totalAlbumPurchases =
|
|
// response.albums.total || enriched.albums.length }`
|
|
// (`PurchasesView.svelte:348-358`). Those ids calls each
|
|
// `.catch(() => null)`, so a single hiccup leaves a tab
|
|
// reading 0 forever while its list is plainly full of items.
|
|
// The TYPED list response does carry the requested type's
|
|
// total, so the number is already in hand.
|
|
let loaded_len = if tab == "tracks" {
|
|
s.tracks_raw.len() as u32
|
|
} else {
|
|
s.albums_raw.len() as u32
|
|
};
|
|
let counter = if tab == "tracks" {
|
|
&mut s.counts.tracks
|
|
} else {
|
|
&mut s.counts.albums
|
|
};
|
|
if *counter == 0 {
|
|
*counter = if server_total > 0 {
|
|
server_total
|
|
} else {
|
|
loaded_len
|
|
};
|
|
}
|
|
|
|
s.loading = false;
|
|
s.error.clear();
|
|
true
|
|
});
|
|
if applied {
|
|
publish_list();
|
|
fill_missing_art(generation).await;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!("[qbz-qt] purchases: load {tab} failed: {e}");
|
|
finish_tab_error(generation);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn finish_tab_error(generation: u64) {
|
|
let applied = with_list(|s| {
|
|
if s.generation != generation {
|
|
return false;
|
|
}
|
|
s.loading = false;
|
|
// Rust-translated and travelling inside a JSON document, so it cannot
|
|
// re-translate through `trRev` — the same limitation every other
|
|
// controller's error line has. The copy must not accuse the network of
|
|
// an EMPTY result (§5.6 / §2.5b): this string is only ever set on a real
|
|
// request failure, never on "you own nothing".
|
|
s.error = qbz_i18n::t("Couldn't load purchases. Check your connection.");
|
|
true
|
|
});
|
|
if applied {
|
|
publish_list();
|
|
}
|
|
}
|
|
|
|
pub fn leave() {
|
|
// Everything transient dies (§10-F): tab, sort, direction, grouping, view
|
|
// mode, the search text and both caches. The persisted prefs are re-read on
|
|
// the next `open_list`, and the download store is process-wide by design so
|
|
// a running album download survives being navigated away from.
|
|
*LIST.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
}
|
|
|
|
pub fn set_tab(tab: String) {
|
|
let tab = if tab == "tracks" { "tracks" } else { "albums" }.to_string();
|
|
let generation = with_list(|s| {
|
|
s.tab = tab.clone();
|
|
s.generation
|
|
});
|
|
publish_list();
|
|
load_tab(generation, tab, false);
|
|
}
|
|
|
|
/// The search FIRE (the 300 ms debounce is QML's one-shot `Timer`, §G.1).
|
|
///
|
|
/// It REFETCHES FROM THE SERVER and re-requests BOTH types, then filters in
|
|
/// Rust and marks both tabs loaded (§5) — it is NOT a local filter over the
|
|
/// active tab. Clearing it refetches the active tab.
|
|
pub fn search(query: String) {
|
|
let query = query.trim().to_string();
|
|
let (generation, seq, tab) = with_list(|s| {
|
|
s.search = query.clone();
|
|
s.search_seq = s.search_seq.wrapping_add(1);
|
|
s.searching = !query.is_empty();
|
|
(s.generation, s.search_seq, s.tab.clone())
|
|
});
|
|
publish_list();
|
|
|
|
if query.is_empty() {
|
|
// Clearing refetches the ACTIVE tab (the search response replaced both
|
|
// caches, so the tab has to come back from the server).
|
|
with_list(|s| {
|
|
s.albums_loaded = false;
|
|
s.tracks_loaded = false;
|
|
});
|
|
load_tab(generation, tab, true);
|
|
return;
|
|
}
|
|
|
|
crate::spawn(async move {
|
|
let Some(client) = snapshot_client().await else {
|
|
with_list(|s| s.searching = false);
|
|
publish_list();
|
|
return;
|
|
};
|
|
let result = svc::get_user_purchases_all(&client).await;
|
|
let reg = ensure_registry().await;
|
|
|
|
match result {
|
|
Ok(response) => {
|
|
let filtered = svc::filter_purchase_response(response, &query);
|
|
let (albums, tracks) = annotate(filtered.albums.items, filtered.tracks.items, ®);
|
|
let applied = with_list(|s| {
|
|
// Two guards: the visit, and the search token — a slow
|
|
// earlier query must not overwrite a newer one's results.
|
|
if s.generation != generation || s.search_seq != seq {
|
|
return false;
|
|
}
|
|
s.albums_raw = albums;
|
|
s.tracks_raw = tracks;
|
|
s.albums_loaded = true;
|
|
s.tracks_loaded = true;
|
|
s.searching = false;
|
|
s.loading = false;
|
|
s.error.clear();
|
|
true
|
|
});
|
|
if applied {
|
|
publish_list();
|
|
fill_missing_art(generation).await;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!("[qbz-qt] purchases: search failed: {e}");
|
|
let applied = with_list(|s| {
|
|
if s.generation != generation || s.search_seq != seq {
|
|
return false;
|
|
}
|
|
s.searching = false;
|
|
s.error = qbz_i18n::t("Couldn't load purchases. Check your connection.");
|
|
true
|
|
});
|
|
if applied {
|
|
publish_list();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn set_sort(key: String, ascending: bool) {
|
|
with_list(|s| {
|
|
s.sort = key;
|
|
s.ascending = ascending;
|
|
});
|
|
publish_list();
|
|
}
|
|
|
|
pub fn set_group(key: String) {
|
|
with_list(|s| s.group = key);
|
|
publish_list();
|
|
}
|
|
|
|
pub fn set_view_mode(mode: String) {
|
|
with_list(|s| {
|
|
s.view_mode = if mode == "list" {
|
|
"list".into()
|
|
} else {
|
|
"grid".into()
|
|
}
|
|
});
|
|
publish_list();
|
|
}
|
|
|
|
pub fn set_hide_unavailable(on: bool) {
|
|
crate::settings_qt::save_pref(PREF_HIDE_UNAVAILABLE, serde_json::json!(on));
|
|
with_list(|s| s.hide_unavailable = on);
|
|
publish_list();
|
|
}
|
|
|
|
pub fn set_hide_downloaded(on: bool) {
|
|
crate::settings_qt::save_pref(PREF_HIDE_DOWNLOADED, serde_json::json!(on));
|
|
with_list(|s| s.hide_downloaded = on);
|
|
publish_list();
|
|
}
|
|
|
|
/// The genre filter — transient, albums only; `""` = all.
|
|
pub fn set_genre_filter(value: String) {
|
|
with_list(|s| s.genre_filter = value);
|
|
publish_list();
|
|
}
|
|
|
|
pub fn set_quality_filter(value: String) {
|
|
let value = match value.as_str() {
|
|
"hires" | "cd" | "lossy" => value,
|
|
_ => "all".to_string(),
|
|
};
|
|
crate::settings_qt::save_pref(PREF_QUALITY_FILTER, serde_json::json!(value));
|
|
with_list(|s| s.quality_filter = value);
|
|
publish_list();
|
|
}
|
|
|
|
pub fn dismiss_region_notice() {
|
|
crate::settings_qt::save_pref(PREF_REGION_NOTICE_SEEN, serde_json::json!(true));
|
|
with_list(|s| s.region_notice_visible = false);
|
|
publish_list();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Detail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Open the purchased-album detail. Records the album and starts the fetch; the
|
|
/// QML caller navigates AFTERWARDS (§C.2, §G.1) — this deliberately does not
|
|
/// 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;
|
|
}
|
|
let generation = with_detail(|s| {
|
|
s.generation = next_generation();
|
|
s.loading = true;
|
|
s.error.clear();
|
|
s.album_id = album_id.clone();
|
|
// Clear the PREVIOUS album in the same publish that raises the loading
|
|
// flag: without it the view renders the last album until the fetch
|
|
// lands, so opening B after A shows A (`crate::open_album`'s rule).
|
|
s.album = None;
|
|
s.catalog = None;
|
|
s.art_path.clear();
|
|
s.formats.clear();
|
|
s.selected_format_id = None;
|
|
// Start with no destination. The reference's picker is component state
|
|
// on the detail screen and dies with it (§10-F), so reopening an album
|
|
// 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();
|
|
|
|
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.
|
|
///
|
|
/// **Divergence, recorded rather than taken for free (§6):** the duplicate
|
|
/// `/album/get` is consolidated into one call. The failure semantics are
|
|
/// preserved: `/album/get` is the one purchase-path call that hard-checks
|
|
/// status and parses the STRICT structs, so a bad payload fails the whole
|
|
/// screen — and it now fails it once instead of twice.
|
|
///
|
|
/// **The format menu is the ENTITLEMENT (2026-09-01 contract §1.4), not a
|
|
/// synthesis from the catalog.** The purchases listing fetched here carries
|
|
/// `downloadable_format_ids` per album — `[55]` for a DSD64 purchase whose
|
|
/// catalog streams at 16/44.1, `[56]` for DSD128, `[7, 6, 5]` for a hi-res
|
|
/// FLAC — and those are the only ids `getFileUrl(intent=download)` will
|
|
/// honour. The old `synth_formats` ladder (27/7/6/5 from `album.hires`) stays
|
|
/// as the fallback for a listing that does not carry the field, so an older
|
|
/// wire still gets a menu instead of nothing.
|
|
async fn load_album(generation: u64, album_id: String) {
|
|
let Some(client) = snapshot_client().await else {
|
|
fail_album(generation);
|
|
return;
|
|
};
|
|
|
|
// UNSIGNED on the purchase path (§11-6, settled by vendor evidence): the
|
|
// Qobuz desktop client sends `/album/get` without a signature and so did the
|
|
// reference. The entitlement proof is the signature on `getFileUrl`, which
|
|
// nothing here touches.
|
|
let catalog = match client.get_album_for_purchase(&album_id).await {
|
|
Ok(album) => album,
|
|
Err(e) => {
|
|
log::warn!("[qbz-qt] purchases: album {album_id} fetch failed: {e}");
|
|
fail_album(generation);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
|
|
let album =
|
|
svc::build_purchase_album(&catalog, &purchases, ®.downloaded_ids, ®.format_map);
|
|
let formats = svc::purchase_formats(
|
|
&catalog,
|
|
&svc::entitlement_format_ids(&purchases, &album_id),
|
|
);
|
|
let cover_url = catalog.image.best().cloned().unwrap_or_default();
|
|
|
|
let applied = with_detail(|s| {
|
|
if s.generation != generation {
|
|
return false;
|
|
}
|
|
// The dropdown defaults to `formats[0]` — the HIGHEST available quality,
|
|
// which is why the synthesis order is load-bearing (§4.3). A format the
|
|
// store already downloaded in wins, so re-entering the detail does not
|
|
// silently re-scope a finished download's green checks.
|
|
s.selected_format_id = with_store(|st| st.get(&s.album_id).and_then(|d| d.format_id))
|
|
.filter(|id| formats.iter().any(|f| f.id == *id))
|
|
.or_else(|| formats.first().map(|f| f.id));
|
|
s.album = Some(album);
|
|
s.catalog = Some(catalog);
|
|
s.formats = formats;
|
|
s.local_album_id = local_album_id;
|
|
s.art_path = cached_art(&cover_url);
|
|
s.loading = false;
|
|
s.error.clear();
|
|
true
|
|
});
|
|
if !applied {
|
|
return;
|
|
}
|
|
publish_album();
|
|
|
|
if !cover_url.is_empty() && crate::artwork_qt::cached_path(&cover_url).is_empty() {
|
|
crate::artwork_qt::download_missing(vec![cover_url.clone()]).await;
|
|
let landed = with_detail(|s| {
|
|
if s.generation != generation {
|
|
return false;
|
|
}
|
|
s.art_path = cached_art(&cover_url);
|
|
!s.art_path.is_empty()
|
|
});
|
|
if landed {
|
|
publish_album();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn fail_album(generation: u64) {
|
|
let applied = with_detail(|s| {
|
|
if s.generation != generation {
|
|
return false;
|
|
}
|
|
s.loading = false;
|
|
s.error = qbz_i18n::t("Couldn't load purchases. Check your connection.");
|
|
true
|
|
});
|
|
if applied {
|
|
publish_album();
|
|
}
|
|
}
|
|
|
|
/// Changing the format RE-SCOPES every downloaded mark, the progress block AND
|
|
/// the Add-to-Library affordance (§6). It is a pure republish: the scoping is
|
|
/// applied at read time in `build_album_doc`, never baked into the state.
|
|
pub fn set_format(format_id: i32) {
|
|
if format_id <= 0 {
|
|
return;
|
|
}
|
|
let changed = with_detail(|s| {
|
|
let id = format_id as u32;
|
|
if !s.formats.iter().any(|f| f.id == id) {
|
|
log::warn!(
|
|
"[qbz-qt] purchases: format {id} is not offered for album {}",
|
|
s.album_id
|
|
);
|
|
return false;
|
|
}
|
|
s.selected_format_id = Some(id);
|
|
true
|
|
});
|
|
if changed {
|
|
publish_album();
|
|
}
|
|
}
|
|
|
|
/// The native folder picker (`rfd::AsyncFileDialog`, §G.0), defaulting to the
|
|
/// OS audio directory. Cancelling is a no-op, not an error.
|
|
pub fn choose_destination() {
|
|
crate::spawn(async move {
|
|
let Some(folder) = pick_folder().await else {
|
|
return;
|
|
};
|
|
with_detail(|s| s.destination = folder);
|
|
publish_album();
|
|
});
|
|
}
|
|
|
|
async fn pick_folder() -> Option<String> {
|
|
let mut dialog = rfd::AsyncFileDialog::new().set_title(&qbz_i18n::t("Choose folder"));
|
|
if let Some(audio_dir) = dirs::audio_dir() {
|
|
dialog = dialog.set_directory(audio_dir);
|
|
}
|
|
let folder = dialog.pick_folder().await?;
|
|
Some(folder.path().to_string_lossy().to_string())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Downloads
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Everything one download needs, snapshotted off the detail state under the
|
|
/// lock so nothing reads it again across an await.
|
|
#[derive(Clone)]
|
|
struct DownloadPlan {
|
|
album_id: String,
|
|
artist: String,
|
|
album_title: String,
|
|
format_id: u32,
|
|
/// The selected option's route (`PurchaseFormatOption::streaming`).
|
|
streaming: bool,
|
|
quality_dir: String,
|
|
destination: String,
|
|
track_ids: Vec<u64>,
|
|
catalog: Album,
|
|
}
|
|
|
|
/// `None` when the screen cannot start a download at all (no album, no format).
|
|
/// The destination is allowed to be empty here — the caller prompts.
|
|
fn build_plan() -> Option<DownloadPlan> {
|
|
with_detail(|s| {
|
|
let album = s.album.as_ref()?;
|
|
let catalog = s.catalog.as_ref()?;
|
|
let format = s
|
|
.selected_format_id
|
|
.and_then(|id| s.formats.iter().find(|f| f.id == id))?;
|
|
Some(DownloadPlan {
|
|
album_id: album.id.clone(),
|
|
artist: album.artist.name.clone(),
|
|
album_title: album.title.clone(),
|
|
format_id: format.id,
|
|
streaming: format.streaming,
|
|
quality_dir: quality_dir(&format.label),
|
|
// The user-picked ROOT. Reading the store's resolved album folder
|
|
// here nested the tree one level deeper on every later download of
|
|
// the same album.
|
|
destination: s.destination.clone(),
|
|
track_ids: album
|
|
.tracks
|
|
.as_ref()
|
|
.map(|p| p.items.iter().map(|t| t.id).collect())
|
|
.unwrap_or_default(),
|
|
catalog: catalog.clone(),
|
|
})
|
|
})
|
|
}
|
|
|
|
/// "Download all" — the frontend loop (§4.1).
|
|
pub fn download_album() {
|
|
let Some(plan) = build_plan() else {
|
|
crate::toast_qt::error(qbz_i18n::t("No downloadable formats available"));
|
|
return;
|
|
};
|
|
if plan.track_ids.is_empty() {
|
|
crate::toast_qt::error(qbz_i18n::t("Failed to start download. Please try again."));
|
|
return;
|
|
}
|
|
crate::spawn(async move {
|
|
let Some(plan) = ensure_destination(plan).await else {
|
|
return;
|
|
};
|
|
run_album_download(plan).await;
|
|
});
|
|
}
|
|
|
|
/// One track — the row affordance and the retry control on a failed row.
|
|
///
|
|
/// This is NOT a small `download_album`: it never sets `destination`,
|
|
/// `isDownloadingAll` or `allComplete`, and it ignores the abort flag — but it
|
|
/// DOES overwrite the album's `formatId`, which can retroactively suppress a
|
|
/// completed album download's `allComplete` (§6). Reproduced deliberately.
|
|
pub fn download_track(track_id: String) {
|
|
let Ok(track_id) = track_id.trim().parse::<u64>() else {
|
|
return;
|
|
};
|
|
let Some(plan) = build_plan() else {
|
|
crate::toast_qt::error(qbz_i18n::t("No downloadable formats available"));
|
|
return;
|
|
};
|
|
if !plan.track_ids.contains(&track_id) {
|
|
crate::toast_qt::error(qbz_i18n::t("This track has no album to download"));
|
|
return;
|
|
}
|
|
crate::spawn(async move {
|
|
let Some(plan) = ensure_destination(plan).await else {
|
|
return;
|
|
};
|
|
with_store(|st| {
|
|
st.remember_title(&plan.album_id, &plan.album_title);
|
|
st.merge_track_start(&plan.album_id, track_id, plan.format_id)
|
|
});
|
|
publish_album();
|
|
|
|
let Some(client) = snapshot_client().await else {
|
|
with_store(|st| st.mark(&plan.album_id, track_id, DlStatus::Failed));
|
|
publish_album();
|
|
return;
|
|
};
|
|
let ctx = build_context(&plan.catalog).await;
|
|
|
|
let one = plan.clone();
|
|
let done = tokio::task::spawn_blocking(move || {
|
|
run_sequential(&one, &[track_id], client, ctx, false)
|
|
})
|
|
.await;
|
|
if let Err(e) = done {
|
|
log::error!("[qbz-qt] purchases: track-download thread join failed: {e}");
|
|
with_store(|st| st.mark(&plan.album_id, track_id, DlStatus::Failed));
|
|
}
|
|
refresh_after_download().await;
|
|
});
|
|
}
|
|
|
|
/// Prompt for a destination when there is none. The reference's
|
|
/// `promptForFolder` runs inside the download action, which is why this is here
|
|
/// and not a precondition the QML host has to enforce.
|
|
async fn ensure_destination(mut plan: DownloadPlan) -> Option<DownloadPlan> {
|
|
if !plan.destination.trim().is_empty() {
|
|
return Some(plan);
|
|
}
|
|
let folder = pick_folder().await?;
|
|
with_detail(|s| s.destination = folder.clone());
|
|
plan.destination = folder;
|
|
publish_album();
|
|
Some(plan)
|
|
}
|
|
|
|
/// The album-level context every track is tagged from (§14): built ONCE before
|
|
/// the loop, with the cover fetched once rather than per track.
|
|
async fn build_context(catalog: &Album) -> svc::PurchaseAlbumContext {
|
|
let cover = match catalog
|
|
.image
|
|
.large
|
|
.as_deref()
|
|
.or(catalog.image.best().map(String::as_str))
|
|
{
|
|
Some(url) if !url.is_empty() => svc::fetch_asset_bytes(url).await,
|
|
_ => None,
|
|
};
|
|
svc::PurchaseAlbumContext::from_album(catalog).with_cover(cover)
|
|
}
|
|
|
|
async fn run_album_download(plan: DownloadPlan) {
|
|
with_store(|st| {
|
|
st.remember_title(&plan.album_id, &plan.album_title);
|
|
st.seed_album(&plan.album_id, plan.format_id)
|
|
});
|
|
publish_album();
|
|
|
|
let Some(client) = snapshot_client().await else {
|
|
// No client: mark every track failed rather than leaving the UI on a
|
|
// seeded "downloading" that never resolves.
|
|
with_store(|st| {
|
|
for id in &plan.track_ids {
|
|
st.mark(&plan.album_id, *id, DlStatus::Failed);
|
|
}
|
|
st.finalize(&plan.album_id, &plan.track_ids);
|
|
});
|
|
publish_album();
|
|
return;
|
|
};
|
|
|
|
// §14: the cover is fetched ONCE, before the loop, and embedded into every
|
|
// track. Fetching it per track would issue one CDN request per file for a
|
|
// value that never changes.
|
|
let ctx = build_context(&plan.catalog).await;
|
|
let back = match plan.catalog.image.back.as_deref() {
|
|
Some(url) if !url.is_empty() => svc::fetch_asset_bytes(url).await,
|
|
_ => None,
|
|
};
|
|
let cover = ctx.cover_jpeg.clone();
|
|
// The master-size cover (`_max`), written as `large_cover.jpg` — the file
|
|
// the Qobuz desktop client leaves beside its `folder.jpg`. Best effort:
|
|
// an album whose cover url has no size suffix simply gets none.
|
|
let large = match plan
|
|
.catalog
|
|
.image
|
|
.large
|
|
.as_deref()
|
|
.or(plan.catalog.image.best().map(String::as_str))
|
|
.and_then(|url| qbz_models::qobuz_cover_at_px(url, 3000))
|
|
{
|
|
Some(url) => svc::fetch_asset_bytes(&url).await,
|
|
None => None,
|
|
};
|
|
|
|
let loop_plan = plan.clone();
|
|
let ids = plan.track_ids.clone();
|
|
let done =
|
|
tokio::task::spawn_blocking(move || run_sequential(&loop_plan, &ids, client, ctx, true))
|
|
.await;
|
|
if let Err(e) = done {
|
|
log::error!("[qbz-qt] purchases: album-download thread join failed: {e}");
|
|
with_store(|st| st.finalize(&plan.album_id, &plan.track_ids));
|
|
}
|
|
|
|
// §14.2, best-effort and after the audio: `cover.jpg` / `back.jpg` beside
|
|
// the tracks. The folder is the one the FIRST successful track produced —
|
|
// the store's rewritten destination — falling back to the deterministic
|
|
// derivation when nothing landed.
|
|
let folder = with_store(|st| {
|
|
st.get(&plan.album_id)
|
|
.and_then(|d| d.resolved_album_folder.clone())
|
|
})
|
|
.filter(|d| d != &plan.destination)
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| {
|
|
album_dir_for(
|
|
&plan.destination,
|
|
&plan.artist,
|
|
&plan.album_title,
|
|
&plan.quality_dir,
|
|
)
|
|
});
|
|
if cover.is_some() || back.is_some() || large.is_some() {
|
|
let write_folder = folder.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
svc::write_album_cover_files(
|
|
&write_folder,
|
|
cover.as_deref(),
|
|
back.as_deref(),
|
|
large.as_deref(),
|
|
);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
// Everything the album ships with goes with it: booklets and any other
|
|
// goodie the catalog lists are fetched into the same folder without a
|
|
// second click. The desktop client does the same (smoke 2026-09-02); the
|
|
// "Download goodies" control stays as the re-download for a folder that
|
|
// lost them. Best effort, counted separately from the tracks (§14.3).
|
|
let goodies = catalog_goodies(&plan.catalog);
|
|
if !goodies.is_empty() {
|
|
run_goodies_download(&plan, &goodies, &folder, false).await;
|
|
}
|
|
|
|
refresh_after_download().await;
|
|
toast_album_outcome(&plan);
|
|
}
|
|
|
|
/// The catalog's goodies that carry a url — the only ones worth a request.
|
|
fn catalog_goodies(catalog: &Album) -> Vec<qbz_models::Goody> {
|
|
catalog
|
|
.goodies
|
|
.clone()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(|g| g.best_url().is_some())
|
|
.collect()
|
|
}
|
|
|
|
/// Fetch `goodies` into `folder`, driving the separate goodies counter (§14.3).
|
|
/// Shared by the album download (unprompted, no toast of its own) and the
|
|
/// "Download goodies" control (`toast` = true).
|
|
async fn run_goodies_download(
|
|
plan: &DownloadPlan,
|
|
goodies: &[qbz_models::Goody],
|
|
folder: &std::path::Path,
|
|
toast: bool,
|
|
) {
|
|
let total = goodies.len() as u32;
|
|
with_store(|st| {
|
|
let state = st.entry(&plan.album_id);
|
|
state.goodies_active = true;
|
|
state.goodies_done = 0;
|
|
state.goodies_total = total;
|
|
});
|
|
publish_album();
|
|
|
|
for goody in goodies {
|
|
// A goodie failure never fails anything: `download_goodie` returns
|
|
// `None` and logs, and the counter advances either way so the block
|
|
// cannot hang on "3 of 5".
|
|
if svc::download_goodie(goody, folder).await.is_none() {
|
|
log::warn!(
|
|
"[qbz-qt] purchases: goodie {:?} could not be downloaded",
|
|
goody.display_name()
|
|
);
|
|
}
|
|
with_store(|st| st.entry(&plan.album_id).goodies_done += 1);
|
|
publish_album();
|
|
}
|
|
|
|
with_store(|st| st.entry(&plan.album_id).goodies_active = false);
|
|
publish_album();
|
|
if toast {
|
|
crate::toast_qt::success(qbz_i18n::t("Download complete"));
|
|
}
|
|
}
|
|
|
|
/// THE LOOP — strictly sequential, CONCURRENCY 1, keyed by album id, honouring
|
|
/// the cancel flag checked BEFORE each track (§4.1).
|
|
///
|
|
/// Runs on a `spawn_blocking` worker driving a current-thread runtime: the
|
|
/// download primitive holds `&LibraryDatabase` across the CDN await, so its
|
|
/// future is `!Send` and cannot live on the multi-thread runtime (see the module
|
|
/// header).
|
|
///
|
|
/// `cancellable` is false for the single-track path, which ignores the abort
|
|
/// flag exactly as the reference does.
|
|
fn run_sequential(
|
|
plan: &DownloadPlan,
|
|
track_ids: &[u64],
|
|
client: QobuzClient,
|
|
ctx: svc::PurchaseAlbumContext,
|
|
cancellable: bool,
|
|
) {
|
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
Ok(rt) => rt,
|
|
Err(e) => {
|
|
log::error!("[qbz-qt] purchases: download runtime build failed: {e}");
|
|
with_store(|st| {
|
|
for id in track_ids {
|
|
st.mark(&plan.album_id, *id, DlStatus::Failed);
|
|
}
|
|
if cancellable {
|
|
st.finalize(&plan.album_id, track_ids);
|
|
}
|
|
});
|
|
publish_album();
|
|
return;
|
|
}
|
|
};
|
|
|
|
rt.block_on(async move {
|
|
let Some(db) = open_owned_library_db() else {
|
|
// No database means no registry row, and an unregistered file is a
|
|
// download that does not survive a reload — so it is a failure, not
|
|
// a success with a caveat.
|
|
with_store(|st| {
|
|
for id in track_ids {
|
|
st.mark(&plan.album_id, *id, DlStatus::Failed);
|
|
}
|
|
if cancellable {
|
|
st.finalize(&plan.album_id, track_ids);
|
|
}
|
|
});
|
|
publish_album();
|
|
return;
|
|
};
|
|
|
|
let mut first_success = false;
|
|
for track_id in track_ids.iter().copied() {
|
|
// (1) Cooperative abort check, BETWEEN tracks only — the in-flight
|
|
// track always finishes.
|
|
if cancellable && with_store(|st| st.is_aborted(&plan.album_id)) {
|
|
with_store(|st| st.apply_cancellation(&plan.album_id, track_ids));
|
|
publish_album();
|
|
return;
|
|
}
|
|
with_store(|st| st.mark(&plan.album_id, track_id, DlStatus::Downloading));
|
|
publish_album();
|
|
|
|
// (2) The bundled primitive: `/track/get` (unsigned) → SIGNED
|
|
// `getFileUrl` → CDN → `.part`→rename → registry write with the
|
|
// REQUESTED format id → tags. `Some(album_id)` is what makes the
|
|
// album's downloaded state answerable at all (§11-1), and the
|
|
// registry error PROPAGATES — a registry failure marks the track
|
|
// `failed`, matching BOTH reference paths (§8-D1).
|
|
match svc::download_purchase_track(
|
|
&client,
|
|
&db,
|
|
track_id,
|
|
Some(plan.album_id.as_str()),
|
|
plan.format_id,
|
|
plan.streaming,
|
|
&plan.destination,
|
|
&plan.quality_dir,
|
|
Some(&ctx),
|
|
)
|
|
.await
|
|
{
|
|
Ok(file_path) => {
|
|
// (3) The destination is rewritten from the FIRST successful
|
|
// file path (§6) — this is what makes Add-to-Library add the
|
|
// album subfolder rather than the download root.
|
|
if cancellable && !first_success {
|
|
first_success = true;
|
|
let folder = album_folder_from_file_path(&file_path);
|
|
with_store(|st| st.resolve_album_folder(&plan.album_id, &folder));
|
|
}
|
|
with_store(|st| st.mark(&plan.album_id, track_id, DlStatus::Complete));
|
|
}
|
|
Err(e) => {
|
|
// (4) A FAILED TRACK DOES NOT ABORT THE ALBUM (§4.2b): mark
|
|
// it failed, keep going. Only `allComplete` stays false.
|
|
log::warn!("[qbz-qt] purchases: track {track_id} download failed: {e}");
|
|
with_store(|st| st.mark(&plan.album_id, track_id, DlStatus::Failed));
|
|
}
|
|
}
|
|
publish_album();
|
|
}
|
|
|
|
if cancellable {
|
|
with_store(|st| st.finalize(&plan.album_id, track_ids));
|
|
}
|
|
publish_album();
|
|
});
|
|
}
|
|
|
|
/// Toast the album outcome. The reference gives no causal message on a format /
|
|
/// entitlement mismatch — §10-B keeps the visible failed state with its retry
|
|
/// control exactly as it is and ADDS a toast naming what happened.
|
|
fn toast_album_outcome(plan: &DownloadPlan) {
|
|
let (complete, failed, cancelled, done) = with_store(|st| {
|
|
let Some(state) = st.get(&plan.album_id) else {
|
|
return (false, 0usize, false, 0usize);
|
|
};
|
|
let complete = state.all_complete;
|
|
let failed = state
|
|
.statuses
|
|
.values()
|
|
.filter(|s| **s == DlStatus::Failed)
|
|
.count();
|
|
let cancelled = state.statuses.values().any(|s| *s == DlStatus::Cancelled);
|
|
let done = state
|
|
.statuses
|
|
.values()
|
|
.filter(|s| **s == DlStatus::Complete)
|
|
.count();
|
|
(complete, failed, cancelled, done)
|
|
});
|
|
let total = plan.track_ids.len();
|
|
if complete {
|
|
crate::toast_qt::success(qbz_i18n::t("Download complete"));
|
|
} else if cancelled {
|
|
crate::toast_qt::info(qbz_i18n::t_args(
|
|
"Download cancelled ({}/{})",
|
|
&[&done.to_string(), &total.to_string()],
|
|
));
|
|
} else if failed > 0 {
|
|
crate::toast_qt::error(qbz_i18n::t("Failed to start download. Please try again."));
|
|
}
|
|
}
|
|
|
|
/// The download folder of `album_id` the Local Library has INDEXED, or `""`.
|
|
/// Walks the registry's folders for the album (newest first) and asks the
|
|
/// library for rows under each; the first folder with rows is the local album
|
|
/// id (`local_bridge::open_album_by_id` opens a plain local album by its
|
|
/// folder path). Off the UI thread; a missing library answers `""`.
|
|
async fn resolve_local_album(album_id: String) -> String {
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::library_db_qt::with_db(false, |db| {
|
|
for folder in db.get_downloaded_purchase_folders(&album_id)? {
|
|
if !db.list_folder_tracks(&folder, false)?.is_empty() {
|
|
return Ok(folder);
|
|
}
|
|
}
|
|
Ok(String::new())
|
|
})
|
|
.unwrap_or_default()
|
|
})
|
|
.await
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Recompute `local_album_id` for the open detail and republish if it moved.
|
|
async fn refresh_local_album_link() {
|
|
let Some(album_id) = with_detail(|s| (!s.album_id.is_empty()).then(|| s.album_id.clone()))
|
|
else {
|
|
return;
|
|
};
|
|
let id = resolve_local_album(album_id.clone()).await;
|
|
let changed = with_detail(|s| {
|
|
if s.album_id != album_id || s.local_album_id == id {
|
|
return false;
|
|
}
|
|
s.local_album_id = id;
|
|
true
|
|
});
|
|
if changed {
|
|
publish_album();
|
|
}
|
|
}
|
|
|
|
/// Re-read the registry and re-annotate everything the download just changed, so
|
|
/// the green checks survive a reload instead of living only in the transient
|
|
/// store.
|
|
async fn refresh_after_download() {
|
|
refresh_local_album_link().await;
|
|
let reg = read_registry().await;
|
|
set_registry(reg.clone());
|
|
|
|
let (albums, tracks) = with_list(|s| {
|
|
(
|
|
std::mem::take(&mut s.albums_raw),
|
|
std::mem::take(&mut s.tracks_raw),
|
|
)
|
|
});
|
|
let (albums, tracks) = annotate(albums, tracks, ®);
|
|
with_list(|s| {
|
|
s.albums_raw = albums;
|
|
s.tracks_raw = tracks;
|
|
});
|
|
publish_list();
|
|
|
|
// The detail's per-track `downloaded_format_ids` come from the same
|
|
// registry, so the open album needs the same treatment.
|
|
let refreshed = with_detail(|s| {
|
|
let Some(album) = s.album.as_mut() else {
|
|
return false;
|
|
};
|
|
if let Some(page) = album.tracks.as_mut() {
|
|
for track in &mut page.items {
|
|
let tid = track.id as i64;
|
|
track.downloaded = reg.downloaded_ids.contains(&tid);
|
|
track.downloaded_format_ids = reg.format_map.get(&tid).cloned().unwrap_or_default();
|
|
}
|
|
}
|
|
album.downloaded =
|
|
svc::album_downloaded_from_registry(&album.id, album.tracks_count, ®.album_counts);
|
|
true
|
|
});
|
|
if refreshed {
|
|
publish_album();
|
|
}
|
|
}
|
|
|
|
/// Cooperative cancel: the in-flight track finishes, everything that has not
|
|
/// started becomes `cancelled` between tracks (§4.1).
|
|
pub fn cancel_album_download() {
|
|
let album_id = with_detail(|s| s.album_id.clone());
|
|
if album_id.is_empty() {
|
|
return;
|
|
}
|
|
with_store(|st| st.set_abort(&album_id));
|
|
publish_album();
|
|
}
|
|
|
|
/// Album-level goodies (§14.3): booklets, videos. They go to the album folder
|
|
/// beside the tracks, through the same cancel-free, best-effort path, and they
|
|
/// are counted SEPARATELY from the track progress — a goodie is not a track, so
|
|
/// it must never move the bar in §6.
|
|
pub fn download_goodies() {
|
|
let Some(plan) = build_plan() else {
|
|
return;
|
|
};
|
|
let goodies = catalog_goodies(&plan.catalog);
|
|
if goodies.is_empty() {
|
|
// Nothing to do and nothing to say: the affordance is hidden when the
|
|
// list is empty, so reaching here at all means the document moved
|
|
// underneath the click.
|
|
return;
|
|
}
|
|
|
|
crate::spawn(async move {
|
|
let Some(plan) = ensure_destination(plan).await else {
|
|
return;
|
|
};
|
|
// The album folder the tracks went into, or the one they WOULD go into
|
|
// — a booklet is worth downloading before any audio is.
|
|
let folder = with_store(|st| {
|
|
st.get(&plan.album_id)
|
|
.and_then(|d| d.resolved_album_folder.clone())
|
|
})
|
|
.filter(|d| d != &plan.destination)
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| {
|
|
album_dir_for(
|
|
&plan.destination,
|
|
&plan.artist,
|
|
&plan.album_title,
|
|
&plan.quality_dir,
|
|
)
|
|
});
|
|
run_goodies_download(&plan, &goodies, &folder, true).await;
|
|
});
|
|
}
|
|
|
|
/// "Add to Library" — a SEQUENCE, not a banner (§6):
|
|
/// 1. add the RESOLVED album subfolder as a library folder (with the same
|
|
/// network detection Settings > Local Library uses);
|
|
/// 2. clear the download state, so the progress and Add-to-Library blocks
|
|
/// vanish;
|
|
/// 3. success/error toast;
|
|
/// 4. start a background scan.
|
|
///
|
|
/// The destination is the folder the FIRST successful file path produced, which
|
|
/// is why step 1 adds the album subfolder rather than the download root — and
|
|
/// why the scanned files pick up the `source = 'qobuz_purchase'` stamp
|
|
/// (`database.rs:1105-1120`) and the gold badge in the Local Library.
|
|
pub fn add_to_library() {
|
|
let (album_id, destination) = with_detail(|s| {
|
|
let dest = with_store(|st| {
|
|
st.get(&s.album_id)
|
|
.and_then(|d| d.resolved_album_folder.clone())
|
|
})
|
|
.unwrap_or_else(|| s.destination.clone());
|
|
(s.album_id.clone(), dest)
|
|
});
|
|
if album_id.is_empty() || destination.trim().is_empty() {
|
|
crate::toast_qt::error(qbz_i18n::t("Couldn't add to library"));
|
|
return;
|
|
}
|
|
|
|
crate::spawn(async move {
|
|
// The folder table + the network probe + the insert all live in the
|
|
// Settings > Local Library controller; reusing it keeps ONE add-folder
|
|
// path rather than a second copy that drifts.
|
|
crate::settings_qt::library::add_folder(destination.clone()).await;
|
|
|
|
// `add_folder` reports its own status line and does not return the id,
|
|
// so success is confirmed by the folder actually being in the table —
|
|
// a silent no-op (unreadable db, path gone) must not clear the download
|
|
// state and tell the user it worked.
|
|
// Carry the folder's ID out, not just a yes/no. The row is right here
|
|
// and throwing it away costs a full-library rescan below.
|
|
let added_id = tokio::task::spawn_blocking(move || {
|
|
// `add_folder` canonicalizes before inserting, so the comparison
|
|
// has to canonicalize the same way or a symlinked download folder
|
|
// never matches and a real success reports as a failure.
|
|
let canonical = std::fs::canonicalize(&destination)
|
|
.map(|c| c.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|_| destination.clone());
|
|
crate::library_db_qt::with_db(false, |db| Ok(db.get_folders_with_metadata()?))
|
|
.and_then(|folders| folders.iter().find(|f| f.path == canonical).map(|f| f.id))
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
let Some(folder_id) = added_id else {
|
|
crate::toast_qt::error(qbz_i18n::t("Couldn't add to library"));
|
|
return;
|
|
};
|
|
|
|
with_store(|st| st.clear(&album_id));
|
|
publish_album();
|
|
crate::toast_qt::success(qbz_i18n::t("Added to library"));
|
|
// Scan ONLY the folder just added. `settings_qt::library::scan` takes
|
|
// `Option<i64>` and documents exactly this — "every enabled folder
|
|
// (`None`) or exactly one (`Some(id)`)". Passing `None` here would
|
|
// re-walk the user's entire library, which on a large one is minutes of
|
|
// disk for a single new album.
|
|
crate::settings_qt::library::scan(Some(folder_id));
|
|
// The scan runs on its own; the link appears once its rows exist.
|
|
// One look after a generous delay is enough for an album-sized
|
|
// folder, and re-entering the page resolves it again anyway.
|
|
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
|
|
refresh_local_album_link().await;
|
|
});
|
|
}
|
|
|
|
/// Live language switch. Both documents carry exactly one Rust-translated
|
|
/// string — the error line — so this is a no-op unless a screen is standing in
|
|
/// its error state. Called unconditionally for the same reason
|
|
/// `musician_qt::republish` is: `LAST_DETAIL` only ever latches "album" or
|
|
/// "artist" and cannot reach these routes.
|
|
///
|
|
/// NOT WIRED by this lane: `crate::apply_language` lives in `main.rs`, which
|
|
/// lane A may only add `mod` lines to. Whoever owns that file next should add
|
|
/// the call.
|
|
pub fn republish() {
|
|
let list_error = with_list(|s| {
|
|
if s.error.is_empty() {
|
|
return false;
|
|
}
|
|
s.error = qbz_i18n::t("Couldn't load purchases. Check your connection.");
|
|
true
|
|
});
|
|
if list_error {
|
|
publish_list();
|
|
}
|
|
let album_error = with_detail(|s| {
|
|
if s.error.is_empty() {
|
|
return false;
|
|
}
|
|
s.error = qbz_i18n::t("Couldn't load purchases. Check your connection.");
|
|
true
|
|
});
|
|
if album_error {
|
|
publish_album();
|
|
}
|
|
}
|
|
|
|
/// Logout / account switch: drop every trace of the previous user's purchases,
|
|
/// 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;
|
|
with_store(|st| *st = DownloadStore::default());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests — the pure logic, and above all the format re-scoping (§15.5-5)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use qbz_models::{Artist, ImageSet};
|
|
|
|
/// NOTE the explicit `downloadable` / `streamable`: `#[derive(Default)]`
|
|
/// ignores the serde defaults, so a bare `PurchaseAlbum::default()` is
|
|
/// `downloadable: false` while the WIRE default is `true` (`serde_true`,
|
|
/// §2.6). Building fixtures off the derive would test a shape the API never
|
|
/// sends — and would make "hide unavailable" look like it hides everything.
|
|
fn album(id: &str, title: &str, artist: &str) -> PurchaseAlbum {
|
|
PurchaseAlbum {
|
|
id: id.to_string(),
|
|
title: title.to_string(),
|
|
artist: Artist {
|
|
name: artist.to_string(),
|
|
..Default::default()
|
|
},
|
|
image: ImageSet::default(),
|
|
downloadable: true,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn track(id: u64, formats: &[u32]) -> PurchaseTrack {
|
|
PurchaseTrack {
|
|
id,
|
|
title: format!("Track {id}"),
|
|
downloaded_format_ids: formats.to_vec(),
|
|
streamable: true,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn state_with(format_id: Option<u32>, statuses: &[(u64, DlStatus)]) -> AlbumDownload {
|
|
AlbumDownload {
|
|
statuses: statuses.iter().copied().collect(),
|
|
downloading_all: false,
|
|
all_complete: statuses.iter().all(|(_, s)| *s == DlStatus::Complete),
|
|
// The album folder a finished download resolved — NOT the root the
|
|
// user picked. See `resolved_album_folder`'s declaration for why the
|
|
// two must never share a name again.
|
|
resolved_album_folder: Some("/music/Artist/Album".to_string()),
|
|
format_id,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
// ---- §15.5-5: the format re-scoping ---------------------------------
|
|
|
|
#[test]
|
|
fn a_track_is_downloaded_only_for_the_format_it_was_downloaded_in() {
|
|
let t = track(1, &[27]);
|
|
assert!(downloaded_for_format(&t.downloaded_format_ids, Some(27)));
|
|
assert!(!downloaded_for_format(&t.downloaded_format_ids, Some(7)));
|
|
assert!(!downloaded_for_format(&t.downloaded_format_ids, Some(5)));
|
|
// No format selected (an album that offered none) is never downloaded.
|
|
assert!(!downloaded_for_format(&t.downloaded_format_ids, None));
|
|
}
|
|
|
|
#[test]
|
|
fn changing_the_format_rescopes_which_tracks_report_downloaded() {
|
|
// The registry says: track 1 in FLAC 24/192 and MP3, track 2 in MP3
|
|
// only, track 3 in nothing.
|
|
let tracks = [track(1, &[27, 5]), track(2, &[5]), track(3, &[])];
|
|
|
|
let for_27: Vec<bool> = tracks
|
|
.iter()
|
|
.map(|t| downloaded_for_format(&t.downloaded_format_ids, Some(27)))
|
|
.collect();
|
|
assert_eq!(for_27, vec![true, false, false]);
|
|
|
|
let for_5: Vec<bool> = tracks
|
|
.iter()
|
|
.map(|t| downloaded_for_format(&t.downloaded_format_ids, Some(5)))
|
|
.collect();
|
|
assert_eq!(for_5, vec![true, true, false]);
|
|
|
|
// A format nobody downloaded in blanks the whole column — this is the
|
|
// behaviour that surprises people and the reason this test exists.
|
|
let for_7: Vec<bool> = tracks
|
|
.iter()
|
|
.map(|t| downloaded_for_format(&t.downloaded_format_ids, Some(7)))
|
|
.collect();
|
|
assert_eq!(for_7, vec![false, false, false]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_completed_status_is_hidden_while_another_format_is_selected() {
|
|
let state = state_with(Some(27), &[(1, DlStatus::Complete)]);
|
|
assert_eq!(
|
|
status_scoped(Some(&state), 1, Some(27)),
|
|
Some(DlStatus::Complete)
|
|
);
|
|
// Same download, different dropdown value: the green check disappears.
|
|
assert_eq!(status_scoped(Some(&state), 1, Some(7)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_and_downloading_are_never_format_scoped() {
|
|
// Only `complete` is scoped (`PurchaseAlbumDetailView.svelte:406-415`);
|
|
// hiding a failure behind a dropdown change would hide the retry
|
|
// control with it.
|
|
let failed = state_with(Some(27), &[(1, DlStatus::Failed)]);
|
|
assert_eq!(
|
|
status_scoped(Some(&failed), 1, Some(7)),
|
|
Some(DlStatus::Failed)
|
|
);
|
|
|
|
let running = state_with(Some(27), &[(1, DlStatus::Downloading)]);
|
|
assert_eq!(
|
|
status_scoped(Some(&running), 1, Some(7)),
|
|
Some(DlStatus::Downloading)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn all_complete_is_format_scoped_and_gates_add_to_library() {
|
|
let state = state_with(
|
|
Some(27),
|
|
&[(1, DlStatus::Complete), (2, DlStatus::Complete)],
|
|
);
|
|
assert!(all_complete_for_format(Some(&state), Some(27)));
|
|
assert!(!all_complete_for_format(Some(&state), Some(7)));
|
|
assert!(!all_complete_for_format(Some(&state), None));
|
|
// An album the store has never seen is not complete.
|
|
assert!(!all_complete_for_format(None, Some(27)));
|
|
}
|
|
|
|
#[test]
|
|
fn an_album_downloaded_before_any_format_was_recorded_stays_complete() {
|
|
// `format_id` is `None` until a download starts; the reference's
|
|
// `formatId === undefined || formatId === selectedFormatId` passes on
|
|
// the undefined arm.
|
|
let state = AlbumDownload {
|
|
all_complete: true,
|
|
format_id: None,
|
|
..Default::default()
|
|
};
|
|
assert!(all_complete_for_format(Some(&state), Some(7)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_single_track_download_can_retroactively_suppress_all_complete() {
|
|
// §6, reproduced deliberately: `startTrackDownload` overwrites the
|
|
// album's formatId even though it sets nothing else.
|
|
let mut store = DownloadStore::default();
|
|
store.seed_album("A", 27);
|
|
store.mark("A", 1, DlStatus::Complete);
|
|
store.finalize("A", &[1]);
|
|
assert!(all_complete_for_format(store.get("A"), Some(27)));
|
|
|
|
store.merge_track_start("A", 1, 5);
|
|
// The album is still flagged complete, but it now reads as complete for
|
|
// format 5 — so with 27 selected the affordance is gone.
|
|
assert!(!all_complete_for_format(store.get("A"), Some(27)));
|
|
assert!(store.get("A").unwrap().all_complete);
|
|
}
|
|
|
|
// ---- The download store's loop semantics ----------------------------
|
|
|
|
#[test]
|
|
fn a_failed_track_does_not_abort_the_album() {
|
|
// §4.2b: only `allComplete` stays false.
|
|
let mut store = DownloadStore::default();
|
|
store.seed_album("A", 7);
|
|
store.mark("A", 1, DlStatus::Complete);
|
|
store.mark("A", 2, DlStatus::Failed);
|
|
store.mark("A", 3, DlStatus::Complete);
|
|
store.finalize("A", &[1, 2, 3]);
|
|
let state = store.get("A").unwrap();
|
|
assert!(!state.all_complete);
|
|
assert_eq!(state.statuses.get(&3), Some(&DlStatus::Complete));
|
|
assert!(!state.downloading_all);
|
|
}
|
|
|
|
#[test]
|
|
fn cancellation_only_touches_tracks_that_never_started() {
|
|
let mut store = DownloadStore::default();
|
|
store.seed_album("A", 7);
|
|
store.mark("A", 1, DlStatus::Complete);
|
|
store.mark("A", 2, DlStatus::Failed);
|
|
store.set_abort("A");
|
|
store.apply_cancellation("A", &[1, 2, 3, 4]);
|
|
let state = store.get("A").unwrap();
|
|
assert_eq!(state.statuses.get(&1), Some(&DlStatus::Complete));
|
|
assert_eq!(state.statuses.get(&2), Some(&DlStatus::Failed));
|
|
assert_eq!(state.statuses.get(&3), Some(&DlStatus::Cancelled));
|
|
assert_eq!(state.statuses.get(&4), Some(&DlStatus::Cancelled));
|
|
assert!(!state.downloading_all);
|
|
assert!(!state.all_complete);
|
|
assert!(!store.is_aborted("A"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_single_track_download_preserves_its_siblings() {
|
|
// The merge path spreads the existing state; only the one track moves.
|
|
let mut store = DownloadStore::default();
|
|
store.seed_album("A", 7);
|
|
store.mark("A", 1, DlStatus::Complete);
|
|
store.mark("A", 2, DlStatus::Complete);
|
|
store.finalize("A", &[1, 2]);
|
|
store.merge_track_start("A", 1, 7);
|
|
let state = store.get("A").unwrap();
|
|
assert_eq!(state.statuses.get(&2), Some(&DlStatus::Complete));
|
|
assert_eq!(state.statuses.get(&1), Some(&DlStatus::Downloading));
|
|
// Not touched by a single-track download.
|
|
assert!(state.all_complete);
|
|
assert!(!state.downloading_all);
|
|
// The resolved album folder is set by a completed download, never by
|
|
// seeding — seeding it from the picked root is what made a later
|
|
// download nest inside its own output.
|
|
assert_eq!(state.resolved_album_folder, None);
|
|
}
|
|
|
|
#[test]
|
|
fn seeding_an_album_download_does_not_reset_goodies_in_flight() {
|
|
let mut store = DownloadStore::default();
|
|
{
|
|
let state = store.entry("A");
|
|
state.goodies_active = true;
|
|
state.goodies_done = 1;
|
|
state.goodies_total = 3;
|
|
}
|
|
store.seed_album("A", 7);
|
|
let state = store.get("A").unwrap();
|
|
assert!(state.goodies_active);
|
|
assert_eq!(state.goodies_done, 1);
|
|
assert_eq!(state.goodies_total, 3);
|
|
assert!(state.statuses.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn clearing_the_state_hides_the_progress_and_add_to_library_blocks() {
|
|
let mut store = DownloadStore::default();
|
|
store.seed_album("A", 7);
|
|
store.mark("A", 1, DlStatus::Complete);
|
|
store.finalize("A", &[1]);
|
|
store.clear("A");
|
|
assert!(store.get("A").is_none());
|
|
assert!(!all_complete_for_format(store.get("A"), Some(7)));
|
|
}
|
|
|
|
// ---- The status wire vocabulary -------------------------------------
|
|
|
|
#[test]
|
|
fn the_status_wire_values_are_the_shared_four_part_vocabulary() {
|
|
assert_eq!(DlStatus::Downloading.wire(), 2);
|
|
assert_eq!(DlStatus::Complete.wire(), 3);
|
|
assert_eq!(DlStatus::Failed.wire(), 4);
|
|
// Cancelled has no slot in the shared vocabulary; the album-level
|
|
// `progress.cancelled` carries it instead.
|
|
assert_eq!(DlStatus::Cancelled.wire(), 0);
|
|
}
|
|
|
|
// ---- Filters and sort ------------------------------------------------
|
|
|
|
#[test]
|
|
fn the_quality_filter_matches_the_reference_predicates() {
|
|
assert!(matches_quality(QualityFilter::All, false, None, None));
|
|
assert!(matches_quality(
|
|
QualityFilter::Hires,
|
|
true,
|
|
Some(24),
|
|
Some(96.0)
|
|
));
|
|
assert!(!matches_quality(
|
|
QualityFilter::Hires,
|
|
false,
|
|
Some(16),
|
|
Some(44.1)
|
|
));
|
|
// CD: not hires AND (16-bit OR nothing known at all).
|
|
assert!(matches_quality(
|
|
QualityFilter::Cd,
|
|
false,
|
|
Some(16),
|
|
Some(44.1)
|
|
));
|
|
assert!(matches_quality(QualityFilter::Cd, false, None, None));
|
|
assert!(!matches_quality(QualityFilter::Cd, false, None, Some(44.1)));
|
|
assert!(!matches_quality(
|
|
QualityFilter::Cd,
|
|
true,
|
|
Some(16),
|
|
Some(44.1)
|
|
));
|
|
// Lossy: no bit depth, or under 16.
|
|
assert!(matches_quality(
|
|
QualityFilter::Lossy,
|
|
false,
|
|
None,
|
|
Some(44.1)
|
|
));
|
|
assert!(matches_quality(QualityFilter::Lossy, false, Some(8), None));
|
|
assert!(!matches_quality(
|
|
QualityFilter::Lossy,
|
|
false,
|
|
Some(16),
|
|
None
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_quality_filter_string_falls_back_to_all() {
|
|
assert_eq!(QualityFilter::parse("nonsense"), QualityFilter::All);
|
|
assert_eq!(QualityFilter::parse("all"), QualityFilter::All);
|
|
assert_eq!(QualityFilter::parse("lossy"), QualityFilter::Lossy);
|
|
}
|
|
|
|
#[test]
|
|
fn purchased_and_date_are_the_same_sort_key() {
|
|
let mut a = album("1", "A", "X");
|
|
a.purchased_at = Some(100);
|
|
let mut b = album("2", "B", "Y");
|
|
b.purchased_at = Some(200);
|
|
|
|
let mut by_purchased: Vec<&PurchaseAlbum> = vec![&a, &b];
|
|
sort_albums(&mut by_purchased, "purchased", false);
|
|
assert_eq!(by_purchased[0].id, "2", "newest first when descending");
|
|
|
|
let mut by_date: Vec<&PurchaseAlbum> = vec![&a, &b];
|
|
sort_albums(&mut by_date, "date", false);
|
|
assert_eq!(by_date[0].id, "2");
|
|
}
|
|
|
|
#[test]
|
|
fn sorting_by_release_date_orders_iso_strings_and_parks_the_undated() {
|
|
let mut a = album("a", "A", "X");
|
|
a.release_date_original = Some("2024-03-01".to_string());
|
|
let mut b = album("b", "B", "X");
|
|
b.release_date_original = Some("1990-09-24".to_string());
|
|
let c = album("c", "C", "X");
|
|
let mut list: Vec<&PurchaseAlbum> = vec![&a, &b, &c];
|
|
sort_albums(&mut list, "released", false);
|
|
let ids: Vec<&str> = list.iter().map(|x| x.id.as_str()).collect();
|
|
assert_eq!(ids, vec!["a", "b", "c"], "newest first, undated last");
|
|
sort_albums(&mut list, "released", true);
|
|
let ids: Vec<&str> = list.iter().map(|x| x.id.as_str()).collect();
|
|
assert_eq!(ids, vec!["c", "b", "a"]);
|
|
}
|
|
|
|
#[test]
|
|
fn sorting_by_quality_compares_rate_then_depth() {
|
|
let mut low = album("1", "Low", "X");
|
|
low.maximum_sampling_rate = Some(44.1);
|
|
low.maximum_bit_depth = Some(16);
|
|
let mut mid = album("2", "Mid", "X");
|
|
mid.maximum_sampling_rate = Some(96.0);
|
|
mid.maximum_bit_depth = Some(16);
|
|
let mut high = album("3", "High", "X");
|
|
high.maximum_sampling_rate = Some(96.0);
|
|
high.maximum_bit_depth = Some(24);
|
|
|
|
let mut list: Vec<&PurchaseAlbum> = vec![&mid, &low, &high];
|
|
sort_albums(&mut list, "quality", true);
|
|
assert_eq!(
|
|
list.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
|
|
vec!["1", "2", "3"]
|
|
);
|
|
}
|
|
|
|
// ---- Path helpers ----------------------------------------------------
|
|
|
|
#[test]
|
|
fn the_quality_dir_replaces_slashes_and_trims() {
|
|
// The `.trim()` is real (§4.4): a trailing space makes `target_path`
|
|
// build a DIFFERENT directory, and every previously downloaded album
|
|
// would then look un-downloaded.
|
|
assert_eq!(quality_dir("[FLAC][24-bit,96kHz]"), "[FLAC][24-bit,96kHz]");
|
|
assert_eq!(quality_dir("MP3/320 "), "MP3-320");
|
|
assert_eq!(quality_dir(" "), "");
|
|
}
|
|
|
|
#[test]
|
|
fn the_album_folder_is_everything_before_the_last_separator() {
|
|
assert_eq!(
|
|
album_folder_from_file_path("/music/Miles Davis/Kind of Blue [FLAC]/01 - So What.flac"),
|
|
"/music/Miles Davis/Kind of Blue [FLAC]"
|
|
);
|
|
assert_eq!(
|
|
album_folder_from_file_path("C:\\music\\Artist\\Album\\01 - t.flac"),
|
|
"C:\\music\\Artist\\Album"
|
|
);
|
|
assert_eq!(album_folder_from_file_path("track.flac"), "track.flac");
|
|
}
|
|
|
|
#[test]
|
|
fn the_derived_album_dir_matches_what_a_track_download_would_produce() {
|
|
// Brackets are ASCII, so `sanitize_filename` KEEPS them — the quality
|
|
// folder really is `Album [FLAC][24-bit,96kHz]` (§A.1-3).
|
|
let track = svc::target_path(
|
|
"/music",
|
|
"Miles Davis",
|
|
"Kind of Blue",
|
|
"[FLAC][24-bit,96kHz]",
|
|
1,
|
|
"So What",
|
|
"flac",
|
|
);
|
|
let derived = album_dir_for(
|
|
"/music",
|
|
"Miles Davis",
|
|
"Kind of Blue",
|
|
"[FLAC][24-bit,96kHz]",
|
|
);
|
|
assert_eq!(track.parent().unwrap(), derived.as_path());
|
|
assert_eq!(
|
|
derived,
|
|
PathBuf::from("/music/Miles Davis/Kind of Blue [FLAC][24-bit,96kHz]")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_quality_dir_drops_the_separating_space() {
|
|
let derived = album_dir_for("/music", "Artist", "Album", "");
|
|
assert_eq!(derived, PathBuf::from("/music/Artist/Album"));
|
|
}
|
|
|
|
// ---- What was bought, not what streams -------------------------------
|
|
|
|
#[test]
|
|
fn a_dsd_purchase_wears_the_dsd_tier_with_the_multiple_in_the_detail() {
|
|
// Rust In Peace on the live account: hires:false, 16/44.1, [55].
|
|
let (tier, detail) = purchased_quality(false, Some(16), Some(44.1), &[55]);
|
|
assert_eq!((tier.as_str(), detail.as_str()), ("dsd", "DSD64"));
|
|
let (tier, detail) = purchased_quality(true, Some(24), Some(352.8), &[56]);
|
|
assert_eq!((tier.as_str(), detail.as_str()), ("dsd", "DSD128"));
|
|
// A FLAC purchase keeps the catalog-derived pair untouched.
|
|
let (tier, detail) = purchased_quality(true, Some(24), Some(96.0), &[7, 6, 5]);
|
|
assert_eq!(tier, "hires");
|
|
assert_eq!(detail, quality_detail(Some(24), Some(96.0)));
|
|
// And so does a listing without the field.
|
|
let (tier, _) = purchased_quality(false, Some(16), Some(44.1), &[]);
|
|
assert_eq!(tier, "cd");
|
|
}
|
|
|
|
// ---- Document shape --------------------------------------------------
|
|
|
|
#[test]
|
|
fn the_list_document_carries_every_frozen_key() {
|
|
// §G.2 is written against by two QML lanes; a rename here breaks them
|
|
// silently, because a missing key in QML is `undefined`, not an error.
|
|
let doc = build_list_doc(&ListState::default());
|
|
let json = serde_json::to_value(&doc).expect("serializes");
|
|
for key in [
|
|
"loading",
|
|
"error",
|
|
"tab",
|
|
"counts",
|
|
"search",
|
|
"searching",
|
|
"regionNoticeVisible",
|
|
"toolbar",
|
|
"albums",
|
|
"tracks",
|
|
] {
|
|
assert!(json.get(key).is_some(), "missing list key {key}");
|
|
}
|
|
for key in [
|
|
"group",
|
|
"sort",
|
|
"ascending",
|
|
"viewMode",
|
|
"hideUnavailable",
|
|
"hideDownloaded",
|
|
"qualityFilter",
|
|
] {
|
|
assert!(
|
|
json["toolbar"].get(key).is_some(),
|
|
"missing toolbar key {key}"
|
|
);
|
|
}
|
|
assert_eq!(json["tab"], "albums");
|
|
assert_eq!(json["toolbar"]["sort"], "purchased");
|
|
assert_eq!(json["toolbar"]["ascending"], false);
|
|
assert_eq!(json["toolbar"]["qualityFilter"], "all");
|
|
}
|
|
|
|
#[test]
|
|
fn the_album_document_carries_every_frozen_key_even_while_loading() {
|
|
// The empty/loading arm is the one a stranger sees first, and it is the
|
|
// arm no smoke test on the owner's account can ever leave.
|
|
let doc = build_album_doc(&DetailState {
|
|
loading: true,
|
|
album_id: "42".to_string(),
|
|
..Default::default()
|
|
});
|
|
let json = serde_json::to_value(&doc).expect("serializes");
|
|
for key in [
|
|
"loading",
|
|
"error",
|
|
"id",
|
|
"title",
|
|
"artist",
|
|
"artistId",
|
|
"artworkUrl",
|
|
"year",
|
|
"label",
|
|
"genre",
|
|
"tracksCount",
|
|
"duration",
|
|
"downloadable",
|
|
"purchasedAt",
|
|
"formats",
|
|
"selectedFormatId",
|
|
"destination",
|
|
"goodies",
|
|
"discs",
|
|
"progress",
|
|
"addToLibraryVisible",
|
|
] {
|
|
assert!(json.get(key).is_some(), "missing album key {key}");
|
|
}
|
|
assert_eq!(json["loading"], true);
|
|
assert_eq!(json["id"], "42");
|
|
assert!(json["goodies"].as_array().unwrap().is_empty());
|
|
assert!(json["discs"].as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_detail_groups_tracks_by_disc_in_arrival_order() {
|
|
let mut a = album("A", "Two Discs", "X");
|
|
let mut t1 = track(1, &[]);
|
|
t1.media_number = Some(1);
|
|
let mut t2 = track(2, &[]);
|
|
t2.media_number = Some(2);
|
|
let mut t3 = track(3, &[]);
|
|
t3.media_number = Some(1);
|
|
a.tracks = Some(SearchResultsPage {
|
|
total: 3,
|
|
offset: 0,
|
|
limit: 3,
|
|
items: vec![t1, t2, t3],
|
|
});
|
|
|
|
let doc = build_album_doc(&DetailState {
|
|
album_id: "A".to_string(),
|
|
album: Some(a),
|
|
selected_format_id: Some(7),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(doc.discs.len(), 2);
|
|
assert_eq!(doc.discs[0].number, 1);
|
|
assert_eq!(doc.discs[0].tracks.len(), 2);
|
|
assert_eq!(doc.discs[1].number, 2);
|
|
assert_eq!(doc.discs[1].tracks.len(), 1);
|
|
assert_eq!(doc.progress.total, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn a_track_with_no_media_number_lands_on_disc_one() {
|
|
let mut a = album("A", "Single Disc", "X");
|
|
a.tracks = Some(SearchResultsPage {
|
|
total: 1,
|
|
offset: 0,
|
|
limit: 1,
|
|
items: vec![track(1, &[])],
|
|
});
|
|
let doc = build_album_doc(&DetailState {
|
|
album_id: "A".to_string(),
|
|
album: Some(a),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(doc.discs.len(), 1);
|
|
assert_eq!(doc.discs[0].number, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn the_version_rides_along_so_format_track_title_can_use_it() {
|
|
// §10-C: dropping this field is what made every purchased track lose
|
|
// its subtitle (issue #360).
|
|
let mut a = album("A", "Remixes", "X");
|
|
let mut t = track(1, &[]);
|
|
t.version = Some("Player's Ball Mix".to_string());
|
|
a.tracks = Some(SearchResultsPage {
|
|
total: 1,
|
|
offset: 0,
|
|
limit: 1,
|
|
items: vec![t],
|
|
});
|
|
let doc = build_album_doc(&DetailState {
|
|
album_id: "A".to_string(),
|
|
album: Some(a),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(doc.discs[0].tracks[0].version, "Player's Ball Mix");
|
|
}
|
|
|
|
#[test]
|
|
fn hide_downloaded_and_hide_unavailable_filter_in_the_reference_order() {
|
|
let mut owned = album("1", "Owned", "X");
|
|
owned.downloaded = true;
|
|
let mut gone = album("2", "Unavailable", "X");
|
|
gone.downloadable = false;
|
|
let keep = album("3", "Keep", "X");
|
|
|
|
let base = ListState {
|
|
albums_raw: vec![owned, gone, keep],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(build_list_doc(&base).albums.len(), 3);
|
|
|
|
let hidden = ListState {
|
|
hide_downloaded: true,
|
|
hide_unavailable: true,
|
|
albums_raw: base.albums_raw.clone(),
|
|
..Default::default()
|
|
};
|
|
let rows = build_list_doc(&hidden).albums;
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].id, "3");
|
|
}
|
|
|
|
#[test]
|
|
fn tracks_have_no_hide_unavailable_filter() {
|
|
// Availability is an ALBUM concept in the reference; a Tracks tab that
|
|
// honoured `hideUnavailable` would invent a filter.
|
|
let state = ListState {
|
|
tab: "tracks".to_string(),
|
|
hide_unavailable: true,
|
|
tracks_raw: vec![track(1, &[]), track(2, &[])],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(build_list_doc(&state).tracks.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_track_id_reaches_qml_as_a_string() {
|
|
// QML `int` is 32-bit; this id does not fit in one (§G.0).
|
|
let big = 5_000_000_000u64;
|
|
let row = track_row(&track(big, &[]));
|
|
assert_eq!(row.id, "5000000000");
|
|
let json = serde_json::to_value(&row).expect("serializes");
|
|
assert!(json["id"].is_string());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod destination_separation_tests {
|
|
use super::*;
|
|
|
|
/// The picked ROOT and the resolved ALBUM FOLDER are two different values,
|
|
/// and only one of them may ever be used as a download destination.
|
|
///
|
|
/// They used to share the name `destination`, and the album loop rewrote the
|
|
/// store's copy to the album subfolder after the first successful track so
|
|
/// that "Add to Library" would add the album rather than the whole music
|
|
/// root. `build_plan` then preferred that rewritten value, so a SECOND
|
|
/// download of the same album — a retry, or the same album in another
|
|
/// format — was handed `/music/Artist/Album [q]` as its root and produced
|
|
/// `/music/Artist/Album [q]/Artist/Album [q]/01 - x.flac`. One level deeper
|
|
/// every time, silently, on a feature nobody here can smoke-test.
|
|
#[test]
|
|
fn seeding_never_pre_fills_the_resolved_album_folder() {
|
|
let mut store = DownloadStore::default();
|
|
store.seed_album("A", 27);
|
|
assert_eq!(
|
|
store.get("A").unwrap().resolved_album_folder,
|
|
None,
|
|
"seeding must not invent an album folder — only a finished download knows it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_resolved_album_folder_comes_only_from_a_finished_track() {
|
|
let mut store = DownloadStore::default();
|
|
store.seed_album("A", 27);
|
|
store.resolve_album_folder("A", "/music/Artist/Album [FLAC][24-bit,96kHz]");
|
|
assert_eq!(
|
|
store.get("A").unwrap().resolved_album_folder.as_deref(),
|
|
Some("/music/Artist/Album [FLAC][24-bit,96kHz]")
|
|
);
|
|
}
|
|
|
|
/// The regression proper: re-seeding after a resolved folder exists must not
|
|
/// let that folder become the next download's root.
|
|
#[test]
|
|
fn a_second_download_does_not_nest_inside_the_first() {
|
|
let mut store = DownloadStore::default();
|
|
store.seed_album("A", 27);
|
|
store.resolve_album_folder("A", "/music/Artist/Album [FLAC][24-bit,192kHz]");
|
|
|
|
// Same album, different format — the case that nested.
|
|
store.seed_album("A", 7);
|
|
let root = "/music";
|
|
let target = album_dir_for(root, "Artist", "Album", "[FLAC][24-bit,96kHz]");
|
|
|
|
assert_eq!(
|
|
target,
|
|
std::path::PathBuf::from("/music/Artist/Album [FLAC][24-bit,96kHz]"),
|
|
"the second download builds from the picked ROOT, not from the first \
|
|
download's album folder"
|
|
);
|
|
}
|
|
}
|