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:
3 files changed
+370
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user