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
+658
-102
No files matched your search
+484
-101
@@ -153,11 +153,14 @@ impl QobuzClient {
|
||||
*self.session.write().await = Some(session.clone());
|
||||
Ok(session)
|
||||
}
|
||||
StatusCode::UNAUTHORIZED => {
|
||||
Err(ApiError::AuthenticationError("Invalid credentials".to_string()))
|
||||
}
|
||||
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
|
||||
"Invalid credentials".to_string(),
|
||||
)),
|
||||
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppId),
|
||||
status => Err(ApiError::ApiResponse(format!("Unexpected status: {}", status))),
|
||||
status => Err(ApiError::ApiResponse(format!(
|
||||
"Unexpected status: {}",
|
||||
status
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +367,12 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Get similar artists for an artist ID
|
||||
pub async fn get_similar_artists(&self, artist_id: u64, limit: u32, offset: u32) -> Result<SearchResultsPage<Artist>> {
|
||||
pub async fn get_similar_artists(
|
||||
&self,
|
||||
artist_id: u64,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<SearchResultsPage<Artist>> {
|
||||
let url = endpoints::build_url(paths::ARTIST_GET_SIMILAR);
|
||||
let http_response = self
|
||||
.http
|
||||
@@ -377,7 +385,11 @@ impl QobuzClient {
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_similar_artists({}) status={}", artist_id, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_similar_artists({}) status={}",
|
||||
artist_id,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
|
||||
let artists = response
|
||||
@@ -388,7 +400,12 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Get an artist's tracks (public endpoint via artist/get?extra=tracks)
|
||||
pub async fn get_artist_tracks(&self, artist_id: u64, limit: u32, offset: u32) -> Result<TracksContainer> {
|
||||
pub async fn get_artist_tracks(
|
||||
&self,
|
||||
artist_id: u64,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<TracksContainer> {
|
||||
let url = endpoints::build_url(paths::ARTIST_GET);
|
||||
let locale = self.locale().await;
|
||||
|
||||
@@ -405,7 +422,11 @@ impl QobuzClient {
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_artist_tracks({}) status={}", artist_id, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_artist_tracks({}) status={}",
|
||||
artist_id,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
|
||||
let tracks = response
|
||||
@@ -431,12 +452,21 @@ impl QobuzClient {
|
||||
log::debug!("[API] get_album({}) status={}", album_id, status);
|
||||
|
||||
if status == StatusCode::NOT_FOUND {
|
||||
log::warn!("[API] get_album({}) returned 404 — album not found", album_id);
|
||||
return Err(ApiError::ApiResponse(format!("Album {} not found (404)", album_id)));
|
||||
log::warn!(
|
||||
"[API] get_album({}) returned 404 — album not found",
|
||||
album_id
|
||||
);
|
||||
return Err(ApiError::ApiResponse(format!(
|
||||
"Album {} not found (404)",
|
||||
album_id
|
||||
)));
|
||||
}
|
||||
if !status.is_success() {
|
||||
log::error!("[API] get_album({}) unexpected status={}", album_id, status);
|
||||
return Err(ApiError::ApiResponse(format!("get_album({}) status {}", album_id, status)));
|
||||
return Err(ApiError::ApiResponse(format!(
|
||||
"get_album({}) status {}",
|
||||
album_id, status
|
||||
)));
|
||||
}
|
||||
|
||||
let response: Value = http_response.json().await?;
|
||||
@@ -469,7 +499,11 @@ impl QobuzClient {
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_featured_albums({}) status={}", featured_type, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_featured_albums({}) status={}",
|
||||
featured_type,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
|
||||
let albums = response
|
||||
@@ -483,9 +517,7 @@ impl QobuzClient {
|
||||
pub async fn get_genres(&self, parent_id: Option<u64>) -> Result<Vec<GenreInfo>> {
|
||||
let url = endpoints::build_url(paths::GENRE_LIST);
|
||||
// Force English for consistent genre names across all user regions
|
||||
let mut query: Vec<(&str, String)> = vec![
|
||||
("lang", "en".to_string()),
|
||||
];
|
||||
let mut query: Vec<(&str, String)> = vec![("lang", "en".to_string())];
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
query.push(("parent_id", pid.to_string()));
|
||||
@@ -498,7 +530,11 @@ impl QobuzClient {
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_genres(parent={:?}) status={}", parent_id, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_genres(parent={:?}) status={}",
|
||||
parent_id,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
|
||||
let genres = response
|
||||
@@ -520,7 +556,11 @@ impl QobuzClient {
|
||||
// Add genre_ids as comma-separated list if provided
|
||||
if let Some(gids) = genre_ids {
|
||||
if !gids.is_empty() {
|
||||
let ids_str = gids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let ids_str = gids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
query.push(("genre_ids", ids_str));
|
||||
}
|
||||
}
|
||||
@@ -537,7 +577,10 @@ impl QobuzClient {
|
||||
|
||||
// Debug: log the response structure
|
||||
if let Some(obj) = response.as_object() {
|
||||
log::info!("Discover API response keys: {:?}", obj.keys().collect::<Vec<_>>());
|
||||
log::info!(
|
||||
"Discover API response keys: {:?}",
|
||||
obj.keys().collect::<Vec<_>>()
|
||||
);
|
||||
if let Some(err) = obj.get("message") {
|
||||
log::error!("Discover API error: {:?}", err);
|
||||
}
|
||||
@@ -562,7 +605,11 @@ impl QobuzClient {
|
||||
|
||||
if let Some(gids) = genre_ids {
|
||||
if !gids.is_empty() {
|
||||
let ids_str = gids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let ids_str = gids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
query.push(("genre_ids", ids_str));
|
||||
}
|
||||
}
|
||||
@@ -577,7 +624,11 @@ impl QobuzClient {
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
log::info!("[API] get_discover_albums({}) status={}", endpoint, http_response.status());
|
||||
log::info!(
|
||||
"[API] get_discover_albums({}) status={}",
|
||||
endpoint,
|
||||
http_response.status()
|
||||
);
|
||||
let response: serde_json::Value = http_response.json().await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
@@ -603,7 +654,11 @@ impl QobuzClient {
|
||||
// Add genre_ids as comma-separated list if provided
|
||||
if let Some(gids) = genre_ids {
|
||||
if !gids.is_empty() {
|
||||
let ids_str = gids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let ids_str = gids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
query.push(("genre_ids", ids_str));
|
||||
}
|
||||
}
|
||||
@@ -616,7 +671,11 @@ impl QobuzClient {
|
||||
let off = offset.unwrap_or(0);
|
||||
query.push(("offset", off.to_string()));
|
||||
|
||||
log::debug!("[API] get_discover_playlists URL: {} query: {:?}", url, query);
|
||||
log::debug!(
|
||||
"[API] get_discover_playlists URL: {} query: {:?}",
|
||||
url,
|
||||
query
|
||||
);
|
||||
|
||||
// First get raw JSON to debug structure
|
||||
let raw_response: serde_json::Value = self
|
||||
@@ -629,17 +688,28 @@ impl QobuzClient {
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
log::debug!("[API] get_discover_playlists raw response keys: {:?}", raw_response.as_object().map(|o| o.keys().collect::<Vec<_>>()));
|
||||
log::debug!(
|
||||
"[API] get_discover_playlists raw response keys: {:?}",
|
||||
raw_response
|
||||
.as_object()
|
||||
.map(|o| o.keys().collect::<Vec<_>>())
|
||||
);
|
||||
|
||||
// Try to parse as expected structure
|
||||
let response: DiscoverPlaylistsResponse = serde_json::from_value(raw_response.clone())
|
||||
.map_err(|e| {
|
||||
log::error!("[API] Failed to parse discover playlists response: {}", e);
|
||||
log::error!("[API] Raw response: {}", serde_json::to_string_pretty(&raw_response).unwrap_or_default());
|
||||
log::error!(
|
||||
"[API] Raw response: {}",
|
||||
serde_json::to_string_pretty(&raw_response).unwrap_or_default()
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
log::debug!("[API] get_discover_playlists response: {} playlists", response.items.len());
|
||||
log::debug!(
|
||||
"[API] get_discover_playlists response: {} playlists",
|
||||
response.items.len()
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -669,18 +739,22 @@ impl QobuzClient {
|
||||
.filter(|tag| tag.is_discover.as_deref() == Some("true"))
|
||||
.filter_map(|tag| {
|
||||
// Parse name_json to get localized name
|
||||
let name_map: serde_json::Value =
|
||||
serde_json::from_str(&tag.name_json).ok()?;
|
||||
let name_map: serde_json::Value = serde_json::from_str(&tag.name_json).ok()?;
|
||||
let name = name_map
|
||||
.get(lang)
|
||||
.or_else(|| name_map.get("en"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())?;
|
||||
let id = tag.featured_tag_id
|
||||
let id = tag
|
||||
.featured_tag_id
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
Some(PlaylistTag { id, slug: tag.slug, name })
|
||||
Some(PlaylistTag {
|
||||
id,
|
||||
slug: tag.slug,
|
||||
name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -702,12 +776,18 @@ impl QobuzClient {
|
||||
log::debug!("[API] get_track({}) status={}", track_id, status);
|
||||
|
||||
if status == StatusCode::NOT_FOUND {
|
||||
log::warn!("[API] get_track({}) returned 404 — track no longer available", track_id);
|
||||
log::warn!(
|
||||
"[API] get_track({}) returned 404 — track no longer available",
|
||||
track_id
|
||||
);
|
||||
return Err(ApiError::TrackUnavailable(track_id));
|
||||
}
|
||||
if !status.is_success() {
|
||||
log::error!("[API] get_track({}) unexpected status={}", track_id, status);
|
||||
return Err(ApiError::ApiResponse(format!("get_track({}) status {}", track_id, status)));
|
||||
return Err(ApiError::ApiResponse(format!(
|
||||
"get_track({}) status {}",
|
||||
track_id, status
|
||||
)));
|
||||
}
|
||||
|
||||
let response: Value = http_response.json().await?;
|
||||
@@ -731,19 +811,20 @@ impl QobuzClient {
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_artist_basic({}) status={}", artist_id, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_artist_basic({}) status={}",
|
||||
artist_id,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get artist by ID
|
||||
pub async fn get_artist(
|
||||
&self,
|
||||
artist_id: u64,
|
||||
with_albums: bool,
|
||||
) -> Result<Artist> {
|
||||
self.get_artist_with_pagination(artist_id, with_albums, None, None).await
|
||||
pub async fn get_artist(&self, artist_id: u64, with_albums: bool) -> Result<Artist> {
|
||||
self.get_artist_with_pagination(artist_id, with_albums, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get artist detail by ID with albums, playlists, and appears-on tracks
|
||||
@@ -774,7 +855,11 @@ impl QobuzClient {
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_artist_detail({}) status={}", artist_id, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_artist_detail({}) status={}",
|
||||
artist_id,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
@@ -788,7 +873,8 @@ impl QobuzClient {
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<Artist> {
|
||||
self.get_artist_with_pagination_and_locale(artist_id, with_albums, limit, offset, None).await
|
||||
self.get_artist_with_pagination_and_locale(artist_id, with_albums, limit, offset, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get artist by ID with album pagination and optional locale override
|
||||
@@ -806,10 +892,7 @@ impl QobuzClient {
|
||||
Some(l) => l.to_string(),
|
||||
None => self.locale().await,
|
||||
};
|
||||
let mut query = vec![
|
||||
("artist_id", artist_id.to_string()),
|
||||
("lang", locale),
|
||||
];
|
||||
let mut query = vec![("artist_id", artist_id.to_string()), ("lang", locale)];
|
||||
if with_albums {
|
||||
query.push(("extra", "albums".to_string()));
|
||||
}
|
||||
@@ -827,7 +910,12 @@ impl QobuzClient {
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_artist({}, albums={}) status={}", artist_id, with_albums, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_artist({}, albums={}) status={}",
|
||||
artist_id,
|
||||
with_albums,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
@@ -848,7 +936,11 @@ impl QobuzClient {
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_playlist_track_ids({}) status={}", playlist_id, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_playlist_track_ids({}) status={}",
|
||||
playlist_id,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
let result: PlaylistWithTrackIds = serde_json::from_value(response)?;
|
||||
log::info!(
|
||||
@@ -906,6 +998,56 @@ impl QobuzClient {
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
/// Fetch dynamic suggestions for mixes (`/dynamic/suggest`).
|
||||
pub async fn get_dynamic_suggest(
|
||||
&self,
|
||||
request: &DynamicSuggestRequest,
|
||||
) -> Result<DynamicSuggestResponse> {
|
||||
let url = endpoints::build_url(paths::DYNAMIC_SUGGEST);
|
||||
|
||||
let http_response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.headers(self.authenticated_headers().await?)
|
||||
.json(request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
log::debug!(
|
||||
"[API] get_dynamic_suggest(limit={}, listened={}, analysed={}) status={}",
|
||||
request.limit,
|
||||
request.listened_tracks_ids.len(),
|
||||
request.track_to_analysed.len(),
|
||||
http_response.status()
|
||||
);
|
||||
|
||||
let response: Value = http_response.json().await?;
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Raw dynamic suggestions response for diagnostics.
|
||||
pub async fn get_dynamic_suggest_raw(&self, request: &DynamicSuggestRequest) -> Result<Value> {
|
||||
let url = endpoints::build_url(paths::DYNAMIC_SUGGEST);
|
||||
|
||||
let http_response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.headers(self.authenticated_headers().await?)
|
||||
.json(request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
log::debug!(
|
||||
"[API] get_dynamic_suggest_raw(limit={}, listened={}, analysed={}) status={}",
|
||||
request.limit,
|
||||
request.listened_tracks_ids.len(),
|
||||
request.track_to_analysed.len(),
|
||||
http_response.status()
|
||||
);
|
||||
|
||||
Ok(http_response.json().await?)
|
||||
}
|
||||
|
||||
/// Get playlist by ID (paginates automatically to fetch all tracks)
|
||||
///
|
||||
/// After the first page, remaining pages are fetched concurrently
|
||||
@@ -929,7 +1071,11 @@ impl QobuzClient {
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_playlist({}) status={}", playlist_id, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_playlist({}) status={}",
|
||||
playlist_id,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
let mut playlist: Playlist = serde_json::from_value(response)?;
|
||||
|
||||
@@ -943,14 +1089,19 @@ impl QobuzClient {
|
||||
let offsets: Vec<u32> = (fetched..total).step_by(PAGE_SIZE as usize).collect();
|
||||
log::debug!(
|
||||
"[API] get_playlist({}) fetching {} remaining pages concurrently ({}/{})",
|
||||
playlist_id, offsets.len(), fetched, total
|
||||
playlist_id,
|
||||
offsets.len(),
|
||||
fetched,
|
||||
total
|
||||
);
|
||||
|
||||
// Prepare headers once for all concurrent requests
|
||||
let headers = self.api_headers().await?;
|
||||
|
||||
// Launch all page requests concurrently
|
||||
let futures: Vec<_> = offsets.iter().map(|&offset| {
|
||||
let futures: Vec<_> = offsets
|
||||
.iter()
|
||||
.map(|&offset| {
|
||||
let http = &self.http;
|
||||
let url = &url;
|
||||
let headers = headers.clone();
|
||||
@@ -973,7 +1124,8 @@ impl QobuzClient {
|
||||
let page: Playlist = serde_json::from_value(value)?;
|
||||
Ok::<_, anyhow::Error>((offset, page))
|
||||
}
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = futures_util::future::join_all(futures).await;
|
||||
|
||||
@@ -983,7 +1135,11 @@ impl QobuzClient {
|
||||
match result {
|
||||
Ok(page) => pages.push(page),
|
||||
Err(e) => {
|
||||
log::warn!("[API] get_playlist({}) page fetch failed: {}", playlist_id, e);
|
||||
log::warn!(
|
||||
"[API] get_playlist({}) page fetch failed: {}",
|
||||
playlist_id,
|
||||
e
|
||||
);
|
||||
// Continue with what we have
|
||||
}
|
||||
}
|
||||
@@ -1030,27 +1186,50 @@ impl QobuzClient {
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_label({}) status={}", label_id, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_label({}) status={}",
|
||||
label_id,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get one purchases page from Qobuz.
|
||||
pub async fn get_user_purchases_page(&self, limit: u32, offset: u32) -> Result<PurchaseResponse> {
|
||||
pub async fn get_user_purchases_page(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<PurchaseResponse> {
|
||||
self.get_user_purchases_page_typed(None, limit, offset)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get one purchases page from Qobuz, optionally constrained by purchase type.
|
||||
pub async fn get_user_purchases_page_typed(
|
||||
&self,
|
||||
purchase_type: Option<&str>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<PurchaseResponse> {
|
||||
let url = endpoints::build_url(paths::PURCHASE_GET_USER_PURCHASES);
|
||||
let mut query: Vec<(&str, String)> =
|
||||
vec![("limit", limit.to_string()), ("offset", offset.to_string())];
|
||||
if let Some(kind) = purchase_type {
|
||||
query.push(("type", kind.to_string()));
|
||||
}
|
||||
|
||||
let http_response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.authenticated_headers().await?)
|
||||
.query(&[
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
])
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_user_purchases_page(limit={}, offset={}) status={}",
|
||||
"[API] get_user_purchases_page(type={:?}, limit={}, offset={}) status={}",
|
||||
purchase_type,
|
||||
limit,
|
||||
offset,
|
||||
http_response.status()
|
||||
@@ -1059,48 +1238,185 @@ impl QobuzClient {
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get one purchases-ids page from Qobuz, optionally constrained by purchase type.
|
||||
pub async fn get_user_purchases_ids_page_typed(
|
||||
&self,
|
||||
purchase_type: Option<&str>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<PurchaseIdsResponse> {
|
||||
let url = endpoints::build_url(paths::PURCHASE_GET_USER_PURCHASES_IDS);
|
||||
let mut query: Vec<(&str, String)> =
|
||||
vec![("limit", limit.to_string()), ("offset", offset.to_string())];
|
||||
if let Some(kind) = purchase_type {
|
||||
query.push(("type", kind.to_string()));
|
||||
}
|
||||
|
||||
let http_response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.authenticated_headers().await?)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[API] get_user_purchases_ids_page(type={:?}, limit={}, offset={}) status={}",
|
||||
purchase_type,
|
||||
limit,
|
||||
offset,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get one purchases-ids page from Qobuz.
|
||||
pub async fn get_user_purchases_ids_page(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<PurchaseIdsResponse> {
|
||||
self.get_user_purchases_ids_page_typed(None, limit, offset)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get all purchases by paginating through Qobuz purchases API.
|
||||
pub async fn get_user_purchases_all(&self) -> Result<PurchaseResponse> {
|
||||
let page_limit = 500u32;
|
||||
let mut offset = 0u32;
|
||||
|
||||
let mut all_albums: Vec<PurchaseAlbum> = Vec::new();
|
||||
let mut all_tracks: Vec<PurchaseTrack> = Vec::new();
|
||||
let mut albums_total: u32;
|
||||
let mut tracks_total: u32;
|
||||
let mut totals_initialized = false;
|
||||
let mut albums_total = 0u32;
|
||||
let mut tracks_total = 0u32;
|
||||
|
||||
let mut albums_offset = 0u32;
|
||||
loop {
|
||||
let page = self.get_user_purchases_page(page_limit, offset).await?;
|
||||
if !totals_initialized {
|
||||
let page = self
|
||||
.get_user_purchases_page_typed(Some("albums"), page_limit, albums_offset)
|
||||
.await?;
|
||||
if albums_offset == 0 {
|
||||
albums_total = page.albums.total;
|
||||
tracks_total = page.tracks.total;
|
||||
totals_initialized = true;
|
||||
} else {
|
||||
albums_total = page.albums.total;
|
||||
tracks_total = page.tracks.total;
|
||||
}
|
||||
|
||||
let got = page.albums.items.len() as u32;
|
||||
all_albums.extend(page.albums.items);
|
||||
all_tracks.extend(page.tracks.items);
|
||||
|
||||
if got == 0 || offset + got >= albums_total {
|
||||
if got == 0 || albums_offset + got >= albums_total {
|
||||
break;
|
||||
}
|
||||
offset += got;
|
||||
albums_offset += got;
|
||||
}
|
||||
|
||||
let mut tracks_offset = 0u32;
|
||||
loop {
|
||||
let page = self
|
||||
.get_user_purchases_page_typed(Some("tracks"), page_limit, tracks_offset)
|
||||
.await?;
|
||||
if tracks_offset == 0 {
|
||||
tracks_total = page.tracks.total;
|
||||
}
|
||||
|
||||
let got = page.tracks.items.len() as u32;
|
||||
all_tracks.extend(page.tracks.items);
|
||||
|
||||
if got == 0 || tracks_offset + got >= tracks_total {
|
||||
break;
|
||||
}
|
||||
tracks_offset += got;
|
||||
}
|
||||
|
||||
let final_albums_total = if albums_total == 0 {
|
||||
all_albums.len() as u32
|
||||
} else {
|
||||
albums_total
|
||||
};
|
||||
let final_tracks_total = if tracks_total == 0 {
|
||||
all_tracks.len() as u32
|
||||
} else {
|
||||
tracks_total
|
||||
};
|
||||
|
||||
Ok(PurchaseResponse {
|
||||
albums: SearchResultsPage {
|
||||
items: all_albums,
|
||||
total: if totals_initialized { albums_total } else { 0 },
|
||||
total: final_albums_total,
|
||||
offset: 0,
|
||||
limit: page_limit,
|
||||
},
|
||||
tracks: SearchResultsPage {
|
||||
items: all_tracks,
|
||||
total: if totals_initialized { tracks_total } else { 0 },
|
||||
total: final_tracks_total,
|
||||
offset: 0,
|
||||
limit: page_limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all purchases for a single type by paginating through Qobuz purchases API.
|
||||
pub async fn get_user_purchases_all_typed(&self, purchase_type: &str) -> Result<PurchaseResponse> {
|
||||
let page_limit = 500u32;
|
||||
let mut offset = 0u32;
|
||||
|
||||
let mut all_albums: Vec<PurchaseAlbum> = Vec::new();
|
||||
let mut all_tracks: Vec<PurchaseTrack> = Vec::new();
|
||||
let mut total = 0u32;
|
||||
|
||||
loop {
|
||||
let page = self
|
||||
.get_user_purchases_page_typed(Some(purchase_type), page_limit, offset)
|
||||
.await?;
|
||||
|
||||
match purchase_type {
|
||||
"albums" => {
|
||||
if offset == 0 {
|
||||
total = page.albums.total;
|
||||
}
|
||||
let got = page.albums.items.len() as u32;
|
||||
all_albums.extend(page.albums.items);
|
||||
if got == 0 || offset + got >= total {
|
||||
break;
|
||||
}
|
||||
offset += got;
|
||||
}
|
||||
"tracks" => {
|
||||
if offset == 0 {
|
||||
total = page.tracks.total;
|
||||
}
|
||||
let got = page.tracks.items.len() as u32;
|
||||
all_tracks.extend(page.tracks.items);
|
||||
if got == 0 || offset + got >= total {
|
||||
break;
|
||||
}
|
||||
offset += got;
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiError::ApiResponse(format!(
|
||||
"Unsupported purchase type: {}",
|
||||
purchase_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let final_total = if total == 0 {
|
||||
if purchase_type == "albums" {
|
||||
all_albums.len() as u32
|
||||
} else {
|
||||
all_tracks.len() as u32
|
||||
}
|
||||
} else {
|
||||
total
|
||||
};
|
||||
|
||||
Ok(PurchaseResponse {
|
||||
albums: SearchResultsPage {
|
||||
items: all_albums,
|
||||
total: if purchase_type == "albums" { final_total } else { 0 },
|
||||
offset: 0,
|
||||
limit: page_limit,
|
||||
},
|
||||
tracks: SearchResultsPage {
|
||||
items: all_tracks,
|
||||
total: if purchase_type == "tracks" { final_total } else { 0 },
|
||||
offset: 0,
|
||||
limit: page_limit,
|
||||
},
|
||||
@@ -1165,19 +1481,29 @@ impl QobuzClient {
|
||||
.get("sampling_rate")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0),
|
||||
bit_depth: json.get("bit_depth").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||
bit_depth: json
|
||||
.get("bit_depth")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32),
|
||||
track_id,
|
||||
restrictions,
|
||||
})
|
||||
}
|
||||
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret),
|
||||
status => Err(ApiError::ApiResponse(format!("Unexpected status: {}", status))),
|
||||
status => Err(ApiError::ApiResponse(format!(
|
||||
"Unexpected status: {}",
|
||||
status
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stream URL for a track (requires auth + signature)
|
||||
pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result<StreamUrl> {
|
||||
log::info!("Getting stream URL for track {} with quality {:?}", track_id, quality);
|
||||
log::info!(
|
||||
"Getting stream URL for track {} with quality {:?}",
|
||||
track_id,
|
||||
quality
|
||||
);
|
||||
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
|
||||
let timestamp = get_timestamp();
|
||||
log::debug!("Getting secret for signing...");
|
||||
@@ -1204,7 +1530,10 @@ impl QobuzClient {
|
||||
match response.status() {
|
||||
StatusCode::OK => {
|
||||
let json: Value = response.json().await?;
|
||||
log::debug!("Stream URL response JSON keys: {:?}", json.as_object().map(|o| o.keys().collect::<Vec<_>>()));
|
||||
log::debug!(
|
||||
"Stream URL response JSON keys: {:?}",
|
||||
json.as_object().map(|o| o.keys().collect::<Vec<_>>())
|
||||
);
|
||||
|
||||
// Check for restrictions
|
||||
let restrictions: Vec<StreamRestriction> = json
|
||||
@@ -1216,8 +1545,13 @@ impl QobuzClient {
|
||||
let url = json["url"].as_str().unwrap_or("").to_string();
|
||||
if url.is_empty() {
|
||||
// Log the restriction codes for debugging
|
||||
let restriction_codes: Vec<&str> = restrictions.iter().map(|r| r.code.as_str()).collect();
|
||||
log::warn!("Stream URL missing for track {} - restrictions: {:?}", track_id, restriction_codes);
|
||||
let restriction_codes: Vec<&str> =
|
||||
restrictions.iter().map(|r| r.code.as_str()).collect();
|
||||
log::warn!(
|
||||
"Stream URL missing for track {} - restrictions: {:?}",
|
||||
track_id,
|
||||
restriction_codes
|
||||
);
|
||||
return Err(ApiError::TrackUnavailable(track_id));
|
||||
}
|
||||
|
||||
@@ -1232,7 +1566,10 @@ impl QobuzClient {
|
||||
})
|
||||
}
|
||||
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret),
|
||||
status => Err(ApiError::ApiResponse(format!("Unexpected status: {}", status))),
|
||||
status => Err(ApiError::ApiResponse(format!(
|
||||
"Unexpected status: {}",
|
||||
status
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1242,7 +1579,11 @@ impl QobuzClient {
|
||||
track_id: u64,
|
||||
preferred: Quality,
|
||||
) -> Result<StreamUrl> {
|
||||
log::info!("Getting stream URL with fallback for track {}, preferred quality: {:?}", track_id, preferred);
|
||||
log::info!(
|
||||
"Getting stream URL with fallback for track {}, preferred quality: {:?}",
|
||||
track_id,
|
||||
preferred
|
||||
);
|
||||
let qualities = Quality::fallback_order();
|
||||
let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0);
|
||||
|
||||
@@ -1260,24 +1601,24 @@ impl QobuzClient {
|
||||
url.bit_depth
|
||||
);
|
||||
return Ok(url);
|
||||
},
|
||||
}
|
||||
Ok(_) => {
|
||||
log::info!("Quality {:?} has restrictions, trying next", quality);
|
||||
continue;
|
||||
},
|
||||
}
|
||||
Err(ApiError::InvalidAppSecret) => {
|
||||
log::error!("Invalid app secret");
|
||||
return Err(ApiError::InvalidAppSecret);
|
||||
},
|
||||
}
|
||||
Err(ApiError::TrackUnavailable(_)) => {
|
||||
// Track is completely unavailable on Qobuz
|
||||
track_unavailable = true;
|
||||
continue;
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Quality {:?} failed: {}, trying next", quality, e);
|
||||
continue;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1311,7 +1652,11 @@ impl QobuzClient {
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
log::debug!("[API] get_favorites({}) status={}", fav_type, http_response.status());
|
||||
log::debug!(
|
||||
"[API] get_favorites({}) status={}",
|
||||
fav_type,
|
||||
http_response.status()
|
||||
);
|
||||
let response: Value = http_response.json().await?;
|
||||
|
||||
Ok(response)
|
||||
@@ -1338,7 +1683,12 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Search playlists
|
||||
pub async fn search_playlists(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Playlist>> {
|
||||
pub async fn search_playlists(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<SearchResultsPage<Playlist>> {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_SEARCH);
|
||||
let http_response = self
|
||||
.http
|
||||
@@ -1362,7 +1712,12 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Create a new playlist
|
||||
pub async fn create_playlist(&self, name: &str, description: Option<&str>, is_public: bool) -> Result<Playlist> {
|
||||
pub async fn create_playlist(
|
||||
&self,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
is_public: bool,
|
||||
) -> Result<Playlist> {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_CREATE);
|
||||
|
||||
let mut params = vec![
|
||||
@@ -1403,7 +1758,11 @@ impl QobuzClient {
|
||||
/// Add tracks to a playlist
|
||||
pub async fn add_tracks_to_playlist(&self, playlist_id: u64, track_ids: &[u64]) -> Result<()> {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_ADD_TRACKS);
|
||||
let track_ids_str = track_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let track_ids_str = track_ids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
self.http
|
||||
.get(&url)
|
||||
@@ -1419,9 +1778,17 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Remove tracks from a playlist
|
||||
pub async fn remove_tracks_from_playlist(&self, playlist_id: u64, playlist_track_ids: &[u64]) -> Result<()> {
|
||||
pub async fn remove_tracks_from_playlist(
|
||||
&self,
|
||||
playlist_id: u64,
|
||||
playlist_track_ids: &[u64],
|
||||
) -> Result<()> {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_DELETE_TRACKS);
|
||||
let track_ids_str = playlist_track_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
||||
let track_ids_str = playlist_track_ids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
self.http
|
||||
.get(&url)
|
||||
@@ -1437,7 +1804,13 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Update playlist metadata
|
||||
pub async fn update_playlist(&self, playlist_id: u64, name: Option<&str>, description: Option<&str>, is_public: Option<bool>) -> Result<Playlist> {
|
||||
pub async fn update_playlist(
|
||||
&self,
|
||||
playlist_id: u64,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
is_public: Option<bool>,
|
||||
) -> Result<Playlist> {
|
||||
let url = endpoints::build_url(paths::PLAYLIST_UPDATE);
|
||||
|
||||
let mut params = vec![("playlist_id", playlist_id.to_string())];
|
||||
@@ -1480,7 +1853,10 @@ impl QobuzClient {
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::ApiResponse(format!("Failed to add favorite: {}", response.status())))
|
||||
Err(ApiError::ApiResponse(format!(
|
||||
"Failed to add favorite: {}",
|
||||
response.status()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1500,7 +1876,10 @@ impl QobuzClient {
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::ApiResponse(format!("Failed to remove favorite: {}", response.status())))
|
||||
Err(ApiError::ApiResponse(format!(
|
||||
"Failed to remove favorite: {}",
|
||||
response.status()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1513,9 +1892,7 @@ impl QobuzClient {
|
||||
sort: Option<&str>,
|
||||
) -> Result<PageArtistResponse> {
|
||||
let url = endpoints::build_url(paths::ARTIST_PAGE);
|
||||
let mut query = vec![
|
||||
("artist_id", artist_id.to_string()),
|
||||
];
|
||||
let mut query = vec![("artist_id", artist_id.to_string())];
|
||||
if let Some(s) = sort {
|
||||
query.push(("sort", s.to_string()));
|
||||
}
|
||||
@@ -1554,7 +1931,13 @@ impl QobuzClient {
|
||||
query.push(("sort", s.to_string()));
|
||||
}
|
||||
|
||||
log::debug!("[API] get_releases_grid({}) type={} limit={} offset={}", artist_id, release_type, limit, offset);
|
||||
log::debug!(
|
||||
"[API] get_releases_grid({}) type={} limit={} offset={}",
|
||||
artist_id,
|
||||
release_type,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
let response: serde_json::Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
|
||||
@@ -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