feat(purchases): add purchase wire models + lenient deserializers

Slice 1/13 of the Purchases Tauri->Slint port. Adds PurchaseResponse,
PurchaseIdsResponse, PurchaseAlbum, PurchaseTrack (no version field) and
PurchaseFormatOption to qbz-models, plus the 5 lenient deserializers ported
verbatim from src-tauri api/models.rs. Derives Default on SearchResultsPage
for the empty-page fallback. 4 round-trip tests, cargo test green.

cc
This commit is contained in:
vicrodh committed 2026-06-24 01:48:34 -06:00
1 parent 502b0dae2b
commit aceaf9b039
3 files changed
+370 -1

No files matched your search

+7
View File
@@ -33,6 +33,7 @@ pub mod error;
pub mod events;
pub mod mixtape;
pub mod playback;
pub mod purchase_serde;
pub mod source;
pub mod traits;
pub mod types;
@@ -110,6 +111,12 @@ pub use types::{
PageArtistTrack,
PageArtistTrackAlbum,
Playlist,
// Purchase types
PurchaseAlbum,
PurchaseFormatOption,
PurchaseIdsResponse,
PurchaseResponse,
PurchaseTrack,
PlaylistDuplicateResult,
PlaylistGenre,
PlaylistOwner,
+244
View File
@@ -0,0 +1,244 @@
//! Lenient deserializers for the Purchases wire models.
//!
//! Ported verbatim from `src-tauri/src/api/models.rs:8-119` so the shared,
//! frontend-agnostic models deserialize real Qobuz `/purchase/*` JSON exactly
//! as the Tauri reference path does. The purchase endpoints return loosely
//! typed JSON (numeric-or-string ids, missing booleans, occasionally malformed
//! pages); these helpers coerce rather than fail the whole response.
//!
//! NOTE: `deserialize_string_or_int` in `types.rs` returns `Option<String>` and
//! is NOT a substitute for `deserialize_string_id` (which yields a bare
//! `String`); `PurchaseAlbum.id` must be a non-optional `String`, hence the
//! distinct helper here.
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer};
use crate::types::SearchResultsPage;
/// Lenient deserializer: if the field is present but has a wrong type, return
/// `None` instead of failing the entire response. Use for non-vital optional
/// fields.
pub fn lenient_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: DeserializeOwned,
{
let value = serde_json::Value::deserialize(deserializer)?;
if value.is_null() {
return Ok(None);
}
Ok(serde_json::from_value(value).ok())
}
/// Deserialize a `SearchResultsPage<T>`; on `null` OR any parse failure, return
/// an empty page (`{items:[], total:0, offset:0, limit:0}`). Never throws — a
/// malformed `albums` block yields an empty albums page while `tracks` still
/// parses.
pub fn lenient_page<'de, D, T>(deserializer: D) -> Result<SearchResultsPage<T>, D::Error>
where
D: Deserializer<'de>,
T: DeserializeOwned,
{
let value = serde_json::Value::deserialize(deserializer)?;
if value.is_null() {
return Ok(SearchResultsPage {
items: Vec::new(),
total: 0,
offset: 0,
limit: 0,
});
}
Ok(serde_json::from_value(value).unwrap_or(SearchResultsPage {
items: Vec::new(),
total: 0,
offset: 0,
limit: 0,
}))
}
/// JSON String OR Number → `String`; anything else → `""`. Used for
/// `PurchaseAlbum.id`.
pub fn deserialize_string_id<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
Ok(match value {
serde_json::Value::String(v) => v,
serde_json::Value::Number(v) => v.to_string(),
_ => String::new(),
})
}
/// Number OR numeric String → `u64` (parse-or-0); anything else → 0. Used for
/// `PurchaseTrack.id`.
pub fn deserialize_u64_id<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
Ok(match value {
serde_json::Value::Number(v) => v.as_u64().unwrap_or(0),
serde_json::Value::String(v) => v.parse::<u64>().unwrap_or(0),
_ => 0,
})
}
/// Default `true`. Used for `downloadable` / `streamable`.
pub fn serde_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use crate::types::{PurchaseIdsResponse, PurchaseResponse, PurchaseTrack};
// No captured `/purchase/*` sample exists under `qbz-nix-docs/qobuz-api/`
// (the user cannot exercise a populated-purchase path), so this fixture is
// hand-built to exercise every quirk the source-of-truth §4 calls out:
// - album `id` arriving as a NUMBER (deserialize_string_id → "12345")
// - track `id` arriving as a numeric STRING (deserialize_u64_id → 777)
// - `downloadable` / `streamable` ABSENT → must default TRUE (serde_true)
// - a malformed optional field (`genre` as a string) → dropped, not fatal
// - nested album `tracks` page round-trips
// - `tracks` top-level page MISSING entirely → empty page (lenient_page)
const SAMPLE: &str = r#"{
"albums": {
"limit": 50,
"offset": 0,
"total": 1,
"items": [
{
"id": 12345,
"title": "Test Album",
"artist": { "id": 99, "name": "Test Artist" },
"image": { "large": "https://example/large.jpg" },
"genre": "not-an-object",
"hires": true,
"maximum_sampling_rate": 192.0,
"maximum_bit_depth": 24,
"purchased_at": 1700000000,
"tracks": {
"limit": 2,
"offset": 0,
"total": 2,
"items": [
{
"id": "777",
"title": "Track One",
"track_number": 1,
"media_number": 1,
"duration": 200
},
{
"id": 778,
"title": "Track Two",
"track_number": 2,
"duration": 210,
"streamable": false,
"downloaded": true,
"downloaded_format_ids": [27, 6]
}
]
}
}
]
}
}"#;
#[test]
fn round_trip_real_shaped_sample() {
let resp: PurchaseResponse =
serde_json::from_str(SAMPLE).expect("purchase response must deserialize");
// Missing `tracks` top-level page → empty page, not an error.
assert_eq!(resp.tracks.items.len(), 0);
assert_eq!(resp.tracks.total, 0);
assert_eq!(resp.albums.total, 1);
assert_eq!(resp.albums.items.len(), 1);
let album = &resp.albums.items[0];
// Numeric album id coerced to String.
assert_eq!(album.id, "12345");
assert_eq!(album.title, "Test Album");
assert_eq!(album.artist.id, 99);
assert_eq!(album.artist.name, "Test Artist");
// `downloadable` absent → defaults TRUE; `downloaded` absent → false.
assert!(album.downloadable);
assert!(!album.downloaded);
// Malformed `genre` (string instead of object) → dropped, not fatal.
assert!(album.genre.is_none());
assert!(album.hires);
assert_eq!(album.maximum_sampling_rate, Some(192.0));
assert_eq!(album.maximum_bit_depth, Some(24));
assert_eq!(album.purchased_at, Some(1_700_000_000));
// Nested tracks page round-trips.
let nested = album.tracks.as_ref().expect("nested tracks present");
assert_eq!(nested.total, 2);
assert_eq!(nested.items.len(), 2);
let t1 = &nested.items[0];
// numeric-string id → u64.
assert_eq!(t1.id, 777);
assert_eq!(t1.media_number, Some(1));
// `streamable` absent → defaults TRUE.
assert!(t1.streamable);
assert!(!t1.downloaded);
assert!(t1.downloaded_format_ids.is_empty());
let t2 = &nested.items[1];
assert_eq!(t2.id, 778);
// `streamable` explicitly false is honored.
assert!(!t2.streamable);
assert!(t2.downloaded);
assert_eq!(t2.downloaded_format_ids, vec![27, 6]);
}
#[test]
fn missing_pages_default_empty() {
// Entirely empty object → both pages empty, no error.
let resp: PurchaseResponse =
serde_json::from_str("{}").expect("empty object must deserialize");
assert_eq!(resp.albums.items.len(), 0);
assert_eq!(resp.albums.total, 0);
assert_eq!(resp.tracks.items.len(), 0);
assert_eq!(resp.tracks.total, 0);
}
#[test]
fn ids_response_reads_only_total() {
// PurchaseIdsResponse items are opaque; the UI reads only `.total`.
const IDS: &str = r#"{
"albums": { "limit": 1, "offset": 0, "total": 42, "items": [ {"foo": 1} ] },
"tracks": { "limit": 1, "offset": 0, "total": 7, "items": [ 12345 ] }
}"#;
let resp: PurchaseIdsResponse =
serde_json::from_str(IDS).expect("ids response must deserialize");
assert_eq!(resp.albums.total, 42);
assert_eq!(resp.tracks.total, 7);
}
#[test]
fn purchase_track_has_no_version_field() {
// Compile-time + behavioral guard: an incoming `version` key must be
// silently ignored (PurchaseTrack does not declare it). A faithful port
// never renders a subtitle/version on purchased tracks.
const TRACK: &str = r#"{
"id": 1,
"title": "No Version",
"version": "Remastered 2024"
}"#;
let track: PurchaseTrack =
serde_json::from_str(TRACK).expect("track with stray version must deserialize");
assert_eq!(track.id, 1);
assert_eq!(track.title, "No Version");
// `streamable` defaults TRUE even on the bare track shape.
assert!(track.streamable);
}
}
+119 -1
View File
@@ -786,7 +786,7 @@ pub struct SearchResults {
pub playlists: Option<SearchResultsPage<Playlist>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SearchResultsPage<T> {
pub items: Vec<T>,
pub total: u32,
@@ -794,6 +794,124 @@ pub struct SearchResultsPage<T> {
pub limit: u32,
}
// ============ Purchases API Models ============
//
// Ported field-for-field from `src-tauri/src/api/models.rs:546-628`. These are
// the wire shapes returned by the Qobuz `/purchase/*` endpoints. The lenient
// deserializers live in `crate::purchase_serde` (see that module's docs for the
// per-field coercion rules).
/// Response from `/purchase/getUserPurchases` (commands #1/#3/#4).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurchaseResponse {
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_page")]
pub albums: SearchResultsPage<PurchaseAlbum>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_page")]
pub tracks: SearchResultsPage<PurchaseTrack>,
}
/// Response from `/purchase/getUserPurchasesIds` (command #2). Items are OPAQUE
/// JSON — the UI reads only `.total` from each page.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurchaseIdsResponse {
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_page")]
pub albums: SearchResultsPage<serde_json::Value>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_page")]
pub tracks: SearchResultsPage<serde_json::Value>,
}
/// A purchased album. `downloadable` defaults TRUE; `downloaded` is NOT from
/// Qobuz — it is server-computed from the local registry. `purchased_at` is
/// unix epoch seconds. Nested `tracks` is populated only on the album-detail /
/// by-type-albums paths.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PurchaseAlbum {
#[serde(
default,
deserialize_with = "crate::purchase_serde::deserialize_string_id"
)]
pub id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub artist: Artist,
#[serde(default)]
pub image: ImageSet,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub release_date_original: Option<String>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub label: Option<Label>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub genre: Option<Genre>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub tracks_count: Option<u32>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub duration: Option<u32>,
#[serde(default)]
pub hires: bool,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub maximum_sampling_rate: Option<f64>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub maximum_bit_depth: Option<u32>,
#[serde(default = "crate::purchase_serde::serde_true")]
pub downloadable: bool,
#[serde(default)]
pub downloaded: bool,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub purchased_at: Option<i64>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub tracks: Option<SearchResultsPage<PurchaseTrack>>,
}
/// A purchased track. NOTE: there is intentionally **no `version` field** — the
/// purchases path never carries the track subtitle/edition (see source-of-truth
/// §4.6). `streamable` defaults TRUE; `downloaded`/`downloaded_format_ids` are
/// server-computed from the local registry; `media_number` is the disc number
/// used for disc-grouping. `purchased_at` is unix epoch seconds.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PurchaseTrack {
#[serde(default, deserialize_with = "crate::purchase_serde::deserialize_u64_id")]
pub id: u64,
#[serde(default)]
pub title: String,
#[serde(default)]
pub track_number: u32,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub media_number: Option<u32>,
#[serde(default)]
pub duration: u32,
#[serde(default)]
pub performer: Artist,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub album: Option<AlbumSummary>,
#[serde(default)]
pub hires: bool,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub maximum_sampling_rate: Option<f64>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub maximum_bit_depth: Option<u32>,
#[serde(default = "crate::purchase_serde::serde_true")]
pub streamable: bool,
#[serde(default)]
pub downloaded: bool,
#[serde(default)]
pub downloaded_format_ids: Vec<u32>,
#[serde(default, deserialize_with = "crate::purchase_serde::lenient_option")]
pub purchased_at: Option<i64>,
}
/// A downloadable format option synthesized client-side from an `Album`
/// (command #6). `id` feeds `getFileUrl`'s `format_id`; `label` (with `/`→`-`)
/// becomes the `qualityDir` subfolder. The synthesis table lives in the
/// orchestration service (Slice 4); this struct is just the wire/UI shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurchaseFormatOption {
pub id: u32,
pub label: String,
pub bit_depth: Option<u32>,
pub sampling_rate: Option<f64>,
}
/// Response from `/album/suggest` — albums similar to a seed album.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumSuggestResponse {