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:
vicrodh committed 2026-06-24 04:16:14 -06:00
1 parent 26b6480ff1
commit bc44fe5e0c
4 files changed
+601

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ pub mod metadata;
pub mod migration;
pub mod path_validator;
pub mod playback;
pub mod purchases_service;
pub mod purge;
pub mod secret_vault;
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);
}
}
+1
View File
@@ -92,6 +92,7 @@ mod playlist;
mod playlist_import;
mod playlist_manager;
mod playlist_snapshot;
mod purchases;
mod plex_auth;
mod plex_settings;
mod playlist_picker;
+331
View File
@@ -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"
);
}
}