feat(api): add purchases-by-type, purchase-ids, and dynamic-suggest endpoints
New API client methods: - get_user_purchases_page_typed: fetch purchases filtered by type (album/track) - get_user_purchases_ids_page_typed: fetch purchase IDs by type - get_user_purchases_all_typed: paginated fetch for a single purchase type - get_user_purchases_ids_page: generic purchase IDs page - get_dynamic_suggest / get_dynamic_suggest_raw: Qobuz dynamic suggestions New models: DynamicSuggestRequest/Response/Track, DynamicTrackToAnalyse, PurchaseIdsResponse, lenient serde deserializers for robust API parsing.
This commit is contained in:
1 parent
3a297c0b2b
commit
a7ffe13469
3 files changed
+681
-125
No files matched your search
+507
-124
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,8 @@ pub mod paths {
|
||||
// User
|
||||
pub const USER_LOGIN: &str = "/user/login";
|
||||
pub const PURCHASE_GET_USER_PURCHASES: &str = "/purchase/getUserPurchases";
|
||||
pub const PURCHASE_GET_USER_PURCHASES_IDS: &str = "/purchase/getUserPurchasesIds";
|
||||
pub const DYNAMIC_SUGGEST: &str = "/dynamic/suggest";
|
||||
|
||||
// Track
|
||||
pub const TRACK_GET: &str = "/track/get";
|
||||
|
||||
+172
-1
@@ -17,6 +17,103 @@ where
|
||||
Ok(serde_json::from_value(value).ok())
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
fn lenient_page_flexible<'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,
|
||||
});
|
||||
}
|
||||
|
||||
let items: Vec<T> = value
|
||||
.get("items")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| serde_json::from_value::<T>(item.clone()).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let limit = value
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(items.len() as u32);
|
||||
|
||||
let offset = value
|
||||
.get("offset")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(0);
|
||||
|
||||
let total = value
|
||||
.get("total")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or(items.len() as u32);
|
||||
|
||||
Ok(SearchResultsPage {
|
||||
items,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fn serde_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -396,17 +493,40 @@ pub struct SearchResultsPage<T> {
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl<T> Default for SearchResultsPage<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Purchases API Models ============
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PurchaseResponse {
|
||||
#[serde(default, deserialize_with = "lenient_page")]
|
||||
pub albums: SearchResultsPage<PurchaseAlbum>,
|
||||
#[serde(default, deserialize_with = "lenient_page")]
|
||||
pub tracks: SearchResultsPage<PurchaseTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PurchaseIdsResponse {
|
||||
#[serde(default, deserialize_with = "lenient_page")]
|
||||
pub albums: SearchResultsPage<serde_json::Value>,
|
||||
#[serde(default, deserialize_with = "lenient_page")]
|
||||
pub tracks: SearchResultsPage<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PurchaseAlbum {
|
||||
#[serde(default, deserialize_with = "deserialize_string_id")]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub artist: Artist,
|
||||
@@ -434,13 +554,15 @@ pub struct PurchaseAlbum {
|
||||
pub downloaded: bool,
|
||||
#[serde(default, deserialize_with = "lenient_option")]
|
||||
pub purchased_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "lenient_option")]
|
||||
pub tracks: Option<SearchResultsPage<PurchaseTrack>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PurchaseTrack {
|
||||
#[serde(default, deserialize_with = "deserialize_u64_id")]
|
||||
pub id: u64,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub track_number: u32,
|
||||
@@ -466,6 +588,55 @@ pub struct PurchaseTrack {
|
||||
pub purchased_at: Option<i64>,
|
||||
}
|
||||
|
||||
// ============ Dynamic Suggest API Models ============
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DynamicSuggestRequest {
|
||||
pub limit: u32,
|
||||
#[serde(default)]
|
||||
pub listened_tracks_ids: Vec<u64>,
|
||||
#[serde(default)]
|
||||
pub track_to_analysed: Vec<DynamicTrackToAnalyse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DynamicTrackToAnalyse {
|
||||
pub track_id: u64,
|
||||
pub artist_id: u64,
|
||||
pub genre_id: u64,
|
||||
pub label_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DynamicSuggestResponse {
|
||||
#[serde(default)]
|
||||
pub algorithm: String,
|
||||
#[serde(default, deserialize_with = "lenient_page_flexible")]
|
||||
pub tracks: SearchResultsPage<DynamicSuggestTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DynamicSuggestTrack {
|
||||
#[serde(default, deserialize_with = "deserialize_u64_id")]
|
||||
pub id: u64,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default, deserialize_with = "lenient_option")]
|
||||
pub duration: Option<u32>,
|
||||
#[serde(default, deserialize_with = "lenient_option")]
|
||||
pub performer: Option<Artist>,
|
||||
#[serde(default, deserialize_with = "lenient_option")]
|
||||
pub album: Option<AlbumSummary>,
|
||||
#[serde(default)]
|
||||
pub hires: bool,
|
||||
#[serde(default, deserialize_with = "lenient_option")]
|
||||
pub maximum_sampling_rate: Option<f64>,
|
||||
#[serde(default, deserialize_with = "lenient_option")]
|
||||
pub maximum_bit_depth: Option<u32>,
|
||||
#[serde(default = "serde_true")]
|
||||
pub streamable: bool,
|
||||
}
|
||||
|
||||
/// Favorites container
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Favorites {
|
||||
|
||||
Reference in new issue
Block a user