feat(purchases): add pagination + per-type totals service + controller load shells
Slice 3/13. New qbz-offline-cache/src/purchases_service.rs (pagination glue, get_purchase_total two-call per-type-totals primitive, byte-for-byte filter_purchase_response). New qbz-slint/src/purchases.rs controller load shells (get_downloaded_track_ids -> HashSet, load_purchases_metadata with TWO separate ids-totals calls, load_purchases_by_tab with total backfill + error mapping). Replicates Tauri's 'Failed to fetch ... purchases' wrapper so network errors map to purchases.loadFailed 1:1 (TDD-caught). 10 unit tests green. Lazy/cache/force gating deferred to UI slices 8/9 by design. cc
This commit is contained in:
4 files changed
+601
No files matched your search
@@ -23,6 +23,7 @@ pub mod metadata;
|
|||||||
pub mod migration;
|
pub mod migration;
|
||||||
pub mod path_validator;
|
pub mod path_validator;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
|
pub mod purchases_service;
|
||||||
pub mod purge;
|
pub mod purge;
|
||||||
pub mod secret_vault;
|
pub mod secret_vault;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
//! Purchases orchestration service (Slice 3 of the Purchases port).
|
||||||
|
//!
|
||||||
|
//! Frontend-agnostic glue between the `qbz-qobuz` purchase HTTP methods
|
||||||
|
//! (Slice 2) and the `qbz-library` `downloaded_purchases` registry. This crate
|
||||||
|
//! is the only one that already depends on BOTH `qbz-qobuz` and `qbz-library`,
|
||||||
|
//! so the orchestration lives here (ADR-006); the `qbz-slint` controller calls
|
||||||
|
//! these fns directly, never wrapping a `src-tauri` command.
|
||||||
|
//!
|
||||||
|
//! Slice 3 scope: the pagination-glue helpers around the client's
|
||||||
|
//! `get_user_purchases_*` methods, and the pure `filter_purchase_response`
|
||||||
|
//! search filter (`v2_filter_purchase_response`, ported from
|
||||||
|
//! `src-tauri/src/commands_v2/legacy_compat.rs:2627`). Format synthesis
|
||||||
|
//! (Slice 4), download-flag annotation (Slice 4) and the single-track download
|
||||||
|
//! primitive (Slice 5) are added in later slices.
|
||||||
|
|
||||||
|
use qbz_models::{PurchaseAlbum, PurchaseResponse, PurchaseTrack, SearchResultsPage};
|
||||||
|
use qbz_qobuz::QobuzClient;
|
||||||
|
use qbz_qobuz::Result as QobuzResult;
|
||||||
|
|
||||||
|
/// Fetch ONE purchases page, typed by purchase kind (`"albums"` / `"tracks"`,
|
||||||
|
/// or `None` for both). Thin pass-through to the client's
|
||||||
|
/// `get_user_purchases_page_typed` so the controller has a single service entry
|
||||||
|
/// point and never reaches into `qbz-qobuz` directly. Mirrors command #1's
|
||||||
|
/// single-page branch (both `limit` + `offset` present).
|
||||||
|
pub async fn get_user_purchases_page(
|
||||||
|
client: &QobuzClient,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
kind: Option<&str>,
|
||||||
|
) -> QobuzResult<PurchaseResponse> {
|
||||||
|
client
|
||||||
|
.get_user_purchases_page_typed(kind, limit, offset)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch ALL purchases (both types) by paginating through the Qobuz API.
|
||||||
|
/// Pass-through to the client's `get_user_purchases_all` (command #1's
|
||||||
|
/// paginate-all branch, used by search). The two-call per-type totals quirk is
|
||||||
|
/// preserved inside the client; this glue does not collapse it.
|
||||||
|
pub async fn get_user_purchases_all(client: &QobuzClient) -> QobuzResult<PurchaseResponse> {
|
||||||
|
client.get_user_purchases_all().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch ALL purchases for a SINGLE type by paginating (`"albums"` /
|
||||||
|
/// `"tracks"`). Pass-through to `get_user_purchases_all_typed` — the primary
|
||||||
|
/// per-tab list-load path (command #3). The OTHER type's `total` is forced to 0
|
||||||
|
/// in the returned envelope (the root of the totals gotcha); the controller
|
||||||
|
/// recovers both totals via the two separate `get_ids(1,0,type)` calls in
|
||||||
|
/// `load_purchases_metadata`.
|
||||||
|
pub async fn get_user_purchases_by_type(
|
||||||
|
client: &QobuzClient,
|
||||||
|
purchase_type: &str,
|
||||||
|
) -> QobuzResult<PurchaseResponse> {
|
||||||
|
client.get_user_purchases_all_typed(purchase_type).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the per-type purchase TOTAL via a single `getUserPurchasesIds`
|
||||||
|
/// page (`limit=1, offset=0, type`). The items are opaque; only `.total` for
|
||||||
|
/// the matching type is read. Returns `None` on any error (the controller falls
|
||||||
|
/// back to 0 / the response length — `loadPurchasesMetadata`'s `.catch(()=>null)`).
|
||||||
|
///
|
||||||
|
/// GOTCHA (per-type totals): this MUST be called once per type. A single
|
||||||
|
/// unfiltered `limit=1` ids call carries only the FIRST type's total, so the
|
||||||
|
/// controller fires two of these — `get_purchase_total(client, "albums")` and
|
||||||
|
/// `get_purchase_total(client, "tracks")` — never one combined call.
|
||||||
|
pub async fn get_purchase_total(client: &QobuzClient, purchase_type: &str) -> Option<u32> {
|
||||||
|
match client
|
||||||
|
.get_user_purchases_ids_page_typed(Some(purchase_type), 1, 0)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => match purchase_type {
|
||||||
|
"albums" => Some(resp.albums.total),
|
||||||
|
"tracks" => Some(resp.tracks.total),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[Purchases] get_purchase_total({purchase_type}) failed: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter a `PurchaseResponse` in-memory by a search query. Pure — no I/O.
|
||||||
|
///
|
||||||
|
/// Ported byte-for-byte from `v2_filter_purchase_response`
|
||||||
|
/// (`src-tauri/src/commands_v2/legacy_compat.rs:2627`):
|
||||||
|
/// * the query is lowercased once;
|
||||||
|
/// * an album is RETAINED when its lowercased `title` OR `artist.name`
|
||||||
|
/// contains the query (case-insensitive substring);
|
||||||
|
/// * a track is RETAINED when its lowercased `title` OR `performer.name` OR
|
||||||
|
/// (if present) `album.title` contains the query;
|
||||||
|
/// * each surviving page's `total` is reset to its filtered `items.len()` and
|
||||||
|
/// `offset` is reset to 0 (`limit` is left untouched, matching the source).
|
||||||
|
///
|
||||||
|
/// No fuzzy matching, no ranking. An empty/whitespace query is handled by the
|
||||||
|
/// caller (it skips the filter entirely), so this fn always applies the
|
||||||
|
/// substring test as written.
|
||||||
|
pub fn filter_purchase_response(response: PurchaseResponse, query: &str) -> PurchaseResponse {
|
||||||
|
let needle = query.to_lowercase();
|
||||||
|
|
||||||
|
let albums: Vec<PurchaseAlbum> = response
|
||||||
|
.albums
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|album| {
|
||||||
|
album.title.to_lowercase().contains(&needle)
|
||||||
|
|| album.artist.name.to_lowercase().contains(&needle)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let tracks: Vec<PurchaseTrack> = response
|
||||||
|
.tracks
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|track| {
|
||||||
|
track.title.to_lowercase().contains(&needle)
|
||||||
|
|| track.performer.name.to_lowercase().contains(&needle)
|
||||||
|
|| track
|
||||||
|
.album
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.title.to_lowercase().contains(&needle))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
PurchaseResponse {
|
||||||
|
albums: SearchResultsPage {
|
||||||
|
total: albums.len() as u32,
|
||||||
|
offset: 0,
|
||||||
|
limit: response.albums.limit,
|
||||||
|
items: albums,
|
||||||
|
},
|
||||||
|
tracks: SearchResultsPage {
|
||||||
|
total: tracks.len() as u32,
|
||||||
|
offset: 0,
|
||||||
|
limit: response.tracks.limit,
|
||||||
|
items: tracks,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use qbz_models::{Artist, AlbumSummary, PurchaseAlbum, PurchaseTrack, SearchResultsPage};
|
||||||
|
|
||||||
|
fn album(title: &str, artist: &str) -> PurchaseAlbum {
|
||||||
|
PurchaseAlbum {
|
||||||
|
title: title.to_string(),
|
||||||
|
artist: Artist {
|
||||||
|
name: artist.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(title: &str, performer: &str, album_title: Option<&str>) -> PurchaseTrack {
|
||||||
|
PurchaseTrack {
|
||||||
|
title: title.to_string(),
|
||||||
|
performer: Artist {
|
||||||
|
name: performer.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
album: album_title.map(|t| AlbumSummary {
|
||||||
|
id: String::new(),
|
||||||
|
title: t.to_string(),
|
||||||
|
image: Default::default(),
|
||||||
|
label: None,
|
||||||
|
genre: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(albums: Vec<PurchaseAlbum>, tracks: Vec<PurchaseTrack>) -> PurchaseResponse {
|
||||||
|
PurchaseResponse {
|
||||||
|
albums: SearchResultsPage {
|
||||||
|
total: albums.len() as u32,
|
||||||
|
offset: 7,
|
||||||
|
limit: 500,
|
||||||
|
items: albums,
|
||||||
|
},
|
||||||
|
tracks: SearchResultsPage {
|
||||||
|
total: tracks.len() as u32,
|
||||||
|
offset: 9,
|
||||||
|
limit: 500,
|
||||||
|
items: tracks,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_matches_album_title_case_insensitive() {
|
||||||
|
let resp = response(
|
||||||
|
vec![album("Kind of Blue", "Miles Davis"), album("Thriller", "Michael Jackson")],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let out = filter_purchase_response(resp, "BLUE");
|
||||||
|
assert_eq!(out.albums.items.len(), 1);
|
||||||
|
assert_eq!(out.albums.items[0].title, "Kind of Blue");
|
||||||
|
// total reset to filtered length, offset reset to 0, limit preserved.
|
||||||
|
assert_eq!(out.albums.total, 1);
|
||||||
|
assert_eq!(out.albums.offset, 0);
|
||||||
|
assert_eq!(out.albums.limit, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_matches_album_artist_name() {
|
||||||
|
let resp = response(
|
||||||
|
vec![album("Thriller", "Michael Jackson"), album("Blue", "Davis")],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let out = filter_purchase_response(resp, "jackson");
|
||||||
|
assert_eq!(out.albums.items.len(), 1);
|
||||||
|
assert_eq!(out.albums.items[0].title, "Thriller");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_matches_track_title_performer_and_album() {
|
||||||
|
let resp = response(
|
||||||
|
vec![],
|
||||||
|
vec![
|
||||||
|
track("So What", "Miles Davis", Some("Kind of Blue")),
|
||||||
|
track("Beat It", "Michael Jackson", Some("Thriller")),
|
||||||
|
track("Random", "Nobody", None),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
// performer match
|
||||||
|
let by_performer = filter_purchase_response(resp.clone(), "jackson");
|
||||||
|
assert_eq!(by_performer.tracks.items.len(), 1);
|
||||||
|
assert_eq!(by_performer.tracks.items[0].title, "Beat It");
|
||||||
|
|
||||||
|
// album-title match
|
||||||
|
let by_album = filter_purchase_response(resp.clone(), "kind of blue");
|
||||||
|
assert_eq!(by_album.tracks.items.len(), 1);
|
||||||
|
assert_eq!(by_album.tracks.items[0].title, "So What");
|
||||||
|
|
||||||
|
// title match
|
||||||
|
let by_title = filter_purchase_response(resp, "random");
|
||||||
|
assert_eq!(by_title.tracks.items.len(), 1);
|
||||||
|
assert_eq!(by_title.tracks.items[0].title, "Random");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_track_with_no_album_does_not_panic() {
|
||||||
|
let resp = response(vec![], vec![track("Solo", "Artist", None)]);
|
||||||
|
let out = filter_purchase_response(resp, "solo");
|
||||||
|
assert_eq!(out.tracks.items.len(), 1);
|
||||||
|
// total/offset reset, limit preserved on the tracks page too.
|
||||||
|
assert_eq!(out.tracks.total, 1);
|
||||||
|
assert_eq!(out.tracks.offset, 0);
|
||||||
|
assert_eq!(out.tracks.limit, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_no_match_yields_empty_pages() {
|
||||||
|
let resp = response(
|
||||||
|
vec![album("Thriller", "Michael Jackson")],
|
||||||
|
vec![track("Beat It", "Michael Jackson", Some("Thriller"))],
|
||||||
|
);
|
||||||
|
let out = filter_purchase_response(resp, "zzz-no-match");
|
||||||
|
assert!(out.albums.items.is_empty());
|
||||||
|
assert!(out.tracks.items.is_empty());
|
||||||
|
assert_eq!(out.albums.total, 0);
|
||||||
|
assert_eq!(out.tracks.total, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ mod playlist;
|
|||||||
mod playlist_import;
|
mod playlist_import;
|
||||||
mod playlist_manager;
|
mod playlist_manager;
|
||||||
mod playlist_snapshot;
|
mod playlist_snapshot;
|
||||||
|
mod purchases;
|
||||||
mod plex_auth;
|
mod plex_auth;
|
||||||
mod plex_settings;
|
mod plex_settings;
|
||||||
mod playlist_picker;
|
mod playlist_picker;
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
//! Purchases controller (Slint) — Slice 3: data-loading shells.
|
||||||
|
//!
|
||||||
|
//! Mirrors Tauri's `PurchasesView.svelte` data-loading flow
|
||||||
|
//! (`source-of-truth §2.1.6`), ported to the shared service. No `tauri::State`
|
||||||
|
//! anywhere: every fn is generic over `A: FrontendAdapter` and reaches Qobuz
|
||||||
|
//! through `runtime.core().client()` and the local registry through
|
||||||
|
//! `crate::library_db::with_db`, exactly like `award.rs` / `myqbz_play.rs`.
|
||||||
|
//!
|
||||||
|
//! Slice 3 scope = the load fns only (no `.slint` apply/derive/reset yet —
|
||||||
|
//! those land in Slices 8/9). What is implemented here:
|
||||||
|
//!
|
||||||
|
//! * `get_downloaded_track_ids` → `HashSet<u64>` (the §3.2 `getDownloadedTrackIds`
|
||||||
|
//! wrapper; O(1) `Set.has` lookups);
|
||||||
|
//! * `load_purchases_metadata` — the one-shot metadata fetch (dlIds + TWO
|
||||||
|
//! separate `getUserPurchasesIds(1,0,type)` totals; the two-call per-type
|
||||||
|
//! totals quirk is preserved verbatim);
|
||||||
|
//! * `load_purchases_by_tab(tab, force, &metadata)` — the per-tab list load
|
||||||
|
//! (`get_user_purchases_all_typed`) with §3.6 error mapping and total
|
||||||
|
//! backfill.
|
||||||
|
//!
|
||||||
|
//! Download-flag enrichment (`enrichWithDownloadStatus`) is Slice 4; the
|
||||||
|
//! download state machine + actions are Slice 7; the UI apply is Slices 8/9.
|
||||||
|
//! These load fns therefore return PLAIN, `Send` payload structs (no
|
||||||
|
//! `slint::Image` / `ModelRc`) so they may be built off the event loop and held
|
||||||
|
//! across `.await`.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use qbz_app::shell::AppRuntime;
|
||||||
|
use qbz_models::{PurchaseAlbum, PurchaseTrack};
|
||||||
|
use qbz_offline_cache::purchases_service;
|
||||||
|
|
||||||
|
use crate::adapter::SlintAdapter;
|
||||||
|
|
||||||
|
type Runtime = Arc<AppRuntime<SlintAdapter>>;
|
||||||
|
|
||||||
|
/// Which purchase tab to load. The wire `type` string (`"albums"` / `"tracks"`)
|
||||||
|
/// is derived via [`PurchaseTab::as_str`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PurchaseTab {
|
||||||
|
Albums,
|
||||||
|
Tracks,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PurchaseTab {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PurchaseTab::Albums => "albums",
|
||||||
|
PurchaseTab::Tracks => "tracks",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-shot purchases metadata (mirrors the Svelte `loadPurchasesMetadata`
|
||||||
|
/// result: `downloadedTrackIds` + the two per-type totals). `Send` — no Slint
|
||||||
|
/// types. The caller caches this behind a `metadataLoaded` guard (UI state in
|
||||||
|
/// Slices 8/9); the fetch itself is idempotent.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PurchasesMetadata {
|
||||||
|
/// Track ids with a downloaded purchase on disk (O(1) `contains`).
|
||||||
|
pub downloaded_track_ids: HashSet<u64>,
|
||||||
|
/// Server total of purchased albums (from the dedicated ids call), or 0.
|
||||||
|
pub total_albums: u32,
|
||||||
|
/// Server total of purchased tracks (from the dedicated ids call), or 0.
|
||||||
|
pub total_tracks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single tab's list payload. `albums`/`tracks` carry the RAW wire items for
|
||||||
|
/// the active tab (the inactive tab's vec is empty — the Svelte flow assigns
|
||||||
|
/// only the active tab's array). `total` is the resolved count after backfill.
|
||||||
|
/// `Send` (plain serde structs).
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PurchasesTabPayload {
|
||||||
|
pub tab_albums: Vec<PurchaseAlbum>,
|
||||||
|
pub tab_tracks: Vec<PurchaseTrack>,
|
||||||
|
/// The resolved total for the loaded tab (metadata total, else response
|
||||||
|
/// total, else loaded length — the §2.1.6 backfill chain).
|
||||||
|
pub total: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the downloaded-purchase track ids from the local registry as a
|
||||||
|
/// `HashSet<u64>`. Mirrors the §3.2 `getDownloadedTrackIds()` service wrapper:
|
||||||
|
/// the registry call ALSO prunes stale rows whose file vanished off disk (a
|
||||||
|
/// side effect of `get_downloaded_purchase_track_ids`). On a DB error or no
|
||||||
|
/// active user → empty set (the Svelte `.catch(() => new Set())`).
|
||||||
|
///
|
||||||
|
/// Runs the blocking DB op on a `spawn_blocking` worker so the async caller is
|
||||||
|
/// not stalled (consistent with the rest of the controller's DB access).
|
||||||
|
pub async fn get_downloaded_track_ids() -> HashSet<u64> {
|
||||||
|
tokio::task::spawn_blocking(|| {
|
||||||
|
crate::library_db::with_db(|db| Ok(db.get_downloaded_purchase_track_ids()?))
|
||||||
|
.map(|ids| ids.into_iter().map(|id| id as u64).collect::<HashSet<u64>>())
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one-shot metadata load (Svelte `loadPurchasesMetadata`, §2.1.6):
|
||||||
|
/// concurrently (a) read the downloaded-track ids from the registry and (b)
|
||||||
|
/// fetch the TWO per-type totals via `getUserPurchasesIds(1, 0, type)` — one
|
||||||
|
/// call for `"albums"`, one for `"tracks"`.
|
||||||
|
///
|
||||||
|
/// GOTCHA (per-type totals, §3.6): the two ids calls MUST stay separate. A
|
||||||
|
/// single unfiltered `limit=1` ids call carries only the FIRST type's total, so
|
||||||
|
/// collapsing them would zero the other tab's count. Each call independently
|
||||||
|
/// falls back to 0 on error (`getPurchaseIds(...).catch(() => null)` →
|
||||||
|
/// `... ?? 0`). dlIds independently falls back to an empty set.
|
||||||
|
pub async fn load_purchases_metadata(runtime: &Runtime) -> PurchasesMetadata {
|
||||||
|
// (a) Registry ids — independent of the Qobuz client (works offline).
|
||||||
|
let dl_ids = get_downloaded_track_ids().await;
|
||||||
|
|
||||||
|
// (b) Two per-type totals. Snapshot the client once; if there is no client
|
||||||
|
// (logged-out / pre-login), both totals fall back to 0 — same shape as the
|
||||||
|
// Svelte `.catch(() => null)` path.
|
||||||
|
let client_lock = runtime.core().client();
|
||||||
|
let client = {
|
||||||
|
let guard = client_lock.read().await;
|
||||||
|
guard.as_ref().cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (total_albums, total_tracks) = match client {
|
||||||
|
Some(client) => {
|
||||||
|
// Two SEPARATE ids calls (never one). Order matches the Svelte
|
||||||
|
// `Promise.all([albums, tracks])`; each maps its own type's total.
|
||||||
|
let total_albums = purchases_service::get_purchase_total(&client, "albums")
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
let total_tracks = purchases_service::get_purchase_total(&client, "tracks")
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
(total_albums, total_tracks)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::warn!("[Purchases] load_purchases_metadata: no Qobuz client; totals default to 0");
|
||||||
|
(0, 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
PurchasesMetadata {
|
||||||
|
downloaded_track_ids: dl_ids,
|
||||||
|
total_albums,
|
||||||
|
total_tracks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load ONE tab's purchase list (Svelte `loadPurchasesByTab`, §2.1.6). Fetches
|
||||||
|
/// the full per-type list via `get_user_purchases_all_typed`, then resolves the
|
||||||
|
/// tab total with the backfill chain:
|
||||||
|
///
|
||||||
|
/// metadata total (if non-zero) → response page total → loaded item count.
|
||||||
|
///
|
||||||
|
/// Only the active tab's items are populated (the inactive vec stays empty),
|
||||||
|
/// mirroring the Svelte "assign ONLY the active tab's array". The `force` flag
|
||||||
|
/// and the `albumsLoaded`/`tracksLoaded` cache gating live in the UI layer
|
||||||
|
/// (Slices 8/9) — this fn always performs the fetch when called; the caller
|
||||||
|
/// decides whether to skip it.
|
||||||
|
///
|
||||||
|
/// On error returns `Err(mapped)` where the message is mapped per §3.6:
|
||||||
|
/// network-ish errors → the i18n key `purchases.loadFailed`; anything else →
|
||||||
|
/// the raw error string (surfaced verbatim).
|
||||||
|
pub async fn load_purchases_by_tab(
|
||||||
|
runtime: &Runtime,
|
||||||
|
tab: PurchaseTab,
|
||||||
|
metadata: &PurchasesMetadata,
|
||||||
|
) -> Result<PurchasesTabPayload, String> {
|
||||||
|
let client_lock = runtime.core().client();
|
||||||
|
let client = {
|
||||||
|
let guard = client_lock.read().await;
|
||||||
|
guard.as_ref().cloned()
|
||||||
|
};
|
||||||
|
let client = match client {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
// No session → surface as the generic load-failed key (the Svelte
|
||||||
|
// flow would hit an auth error string, which maps the same way).
|
||||||
|
return Err("purchases.loadFailed".to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match purchases_service::get_user_purchases_by_type(&client, tab.as_str()).await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
// Replicate Tauri command #3's error wrapping
|
||||||
|
// (`legacy_compat.rs:2784`): `"Failed to fetch {type} purchases:
|
||||||
|
// {e}"`. The `"fetch"` token is LOAD-BEARING — it is what makes a
|
||||||
|
// genuine network error map to `purchases.loadFailed` via §3.6
|
||||||
|
// (the raw `ApiError::NetworkError` Display is `"Network error: …"`,
|
||||||
|
// which contains none of the three Svelte tokens). Dropping the
|
||||||
|
// prefix would diverge from Tauri; keep it verbatim.
|
||||||
|
let wrapped = format!("Failed to fetch {} purchases: {}", tab.as_str(), e);
|
||||||
|
return Err(map_load_error(&wrapped));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = match tab {
|
||||||
|
PurchaseTab::Albums => {
|
||||||
|
let response_total = response.albums.total;
|
||||||
|
let items = response.albums.items;
|
||||||
|
let total = resolve_tab_total(metadata.total_albums, response_total, items.len());
|
||||||
|
PurchasesTabPayload {
|
||||||
|
tab_albums: items,
|
||||||
|
tab_tracks: Vec::new(),
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PurchaseTab::Tracks => {
|
||||||
|
let response_total = response.tracks.total;
|
||||||
|
let items = response.tracks.items;
|
||||||
|
let total = resolve_tab_total(metadata.total_tracks, response_total, items.len());
|
||||||
|
PurchasesTabPayload {
|
||||||
|
tab_albums: Vec::new(),
|
||||||
|
tab_tracks: items,
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §2.1.6 total backfill: prefer the metadata (ids-call) total; if that is 0
|
||||||
|
/// fall back to the response page total, then to the loaded item count.
|
||||||
|
fn resolve_tab_total(metadata_total: u32, response_total: u32, loaded_len: usize) -> u32 {
|
||||||
|
if metadata_total != 0 {
|
||||||
|
metadata_total
|
||||||
|
} else if response_total != 0 {
|
||||||
|
response_total
|
||||||
|
} else {
|
||||||
|
loaded_len as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.6 error mapping (Svelte `loadPurchasesByTab` catch): if the error string
|
||||||
|
/// contains `"Load failed"`, `"fetch"`, or `"NetworkError"`, surface the i18n
|
||||||
|
/// key `purchases.loadFailed`; otherwise surface the raw message unchanged.
|
||||||
|
///
|
||||||
|
/// (The list view i18n-maps network errors; the DETAIL view surfaces the RAW
|
||||||
|
/// string — that asymmetry is intentional and handled in Slice 9, not here.)
|
||||||
|
fn map_load_error(message: &str) -> String {
|
||||||
|
if message.contains("Load failed")
|
||||||
|
|| message.contains("fetch")
|
||||||
|
|| message.contains("NetworkError")
|
||||||
|
{
|
||||||
|
"purchases.loadFailed".to_string()
|
||||||
|
} else {
|
||||||
|
message.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_as_str_maps_wire_strings() {
|
||||||
|
assert_eq!(PurchaseTab::Albums.as_str(), "albums");
|
||||||
|
assert_eq!(PurchaseTab::Tracks.as_str(), "tracks");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn total_backfill_prefers_metadata_then_response_then_len() {
|
||||||
|
// metadata wins when non-zero
|
||||||
|
assert_eq!(resolve_tab_total(42, 10, 3), 42);
|
||||||
|
// response total used when metadata is 0
|
||||||
|
assert_eq!(resolve_tab_total(0, 10, 3), 10);
|
||||||
|
// loaded length used when both are 0
|
||||||
|
assert_eq!(resolve_tab_total(0, 0, 3), 3);
|
||||||
|
// all zero → 0
|
||||||
|
assert_eq!(resolve_tab_total(0, 0, 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_mapping_network_to_key() {
|
||||||
|
// Verbatim Svelte tokens (§3.6 `msg.includes(...)`): the three string
|
||||||
|
// checks are ported 1:1 — `"Load failed"`, `"fetch"`, `"NetworkError"`.
|
||||||
|
assert_eq!(map_load_error("Load failed"), "purchases.loadFailed");
|
||||||
|
assert_eq!(
|
||||||
|
map_load_error("Failed to fetch purchases: timeout"),
|
||||||
|
"purchases.loadFailed"
|
||||||
|
);
|
||||||
|
// The literal `"NetworkError"` token (e.g. a JS-shaped error surfaced
|
||||||
|
// through) maps to the key.
|
||||||
|
assert_eq!(
|
||||||
|
map_load_error("NetworkError when attempting to fetch"),
|
||||||
|
"purchases.loadFailed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrapped_network_error_maps_to_key_via_fetch_token() {
|
||||||
|
// `load_purchases_by_tab` wraps fetch errors as Tauri command #3 does:
|
||||||
|
// `"Failed to fetch {type} purchases: {e}"`. That wrapper carries the
|
||||||
|
// `"fetch"` token, so even a raw `ApiError::NetworkError` ("Network
|
||||||
|
// error: …") maps to the key — matching Tauri 1:1.
|
||||||
|
let raw = "Network error: connection refused";
|
||||||
|
let wrapped = format!("Failed to fetch {} purchases: {}", "albums", raw);
|
||||||
|
assert_eq!(map_load_error(&wrapped), "purchases.loadFailed");
|
||||||
|
// An offline-gate error is likewise wrapped → carries `"fetch"` → key.
|
||||||
|
let offline = format!(
|
||||||
|
"Failed to fetch {} purchases: {}",
|
||||||
|
"tracks", "Offline mode is active - Qobuz services are disabled"
|
||||||
|
);
|
||||||
|
assert_eq!(map_load_error(&offline), "purchases.loadFailed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_mapping_passthrough_raw() {
|
||||||
|
assert_eq!(
|
||||||
|
map_load_error("Album 123 not found (404)"),
|
||||||
|
"Album 123 not found (404)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map_load_error("No active session - please log in"),
|
||||||
|
"No active session - please log in"
|
||||||
|
);
|
||||||
|
// FIDELITY NOTE: the Rust `ApiError::NetworkError` Display string is
|
||||||
|
// `"Network error: ..."` (space, lowercase `e`) — it does NOT contain
|
||||||
|
// the verbatim Svelte token `"NetworkError"`, so a genuine Rust network
|
||||||
|
// error falls through to the RAW branch. This is a faithful consequence
|
||||||
|
// of porting the JS substring checks 1:1 (the spec mandates replicating
|
||||||
|
// the string checks, not "map every network error"). Do NOT "fix" the
|
||||||
|
// check to match the Rust Display — that would diverge from Tauri.
|
||||||
|
assert_eq!(
|
||||||
|
map_load_error("Network error: connection refused"),
|
||||||
|
"Network error: connection refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user