Files
qbz/src-tauri/src/api/client.rs
T
vicrodh 585053bd42 feat(label): redesign label detail view with Android-style sections
Replace the flat album grid LabelView with a rich detail page featuring:
- Header with label image, name, description, and play button
- Popular Tracks section (5/10/all expand, same UX as ArtistDetailView)
- Releases horizontal scroll row with "See all" → LabelReleasesView
- Critics' Picks section from second releases container
- Playlists, Artists, and More Labels sections

Backend: add /label/page and /label/explore API endpoints with V2
commands (v2_get_label_page, v2_get_label_explore). Models use
serde_json::Value for flexible section parsing.

Old LabelView renamed to LabelReleasesView for the "See all" flow.
i18n keys added to all 4 locales (en, es, de, fr).
2026-02-20 02:51:47 -06:00

2003 lines
65 KiB
Rust

//! Qobuz API client implementation
use reqwest::{Client, StatusCode};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::auth::{get_timestamp, parse_login_response, sign_get_favorites, sign_get_file_url};
use super::bundle::{extract_bundle_tokens, BundleTokens};
use super::endpoints::{self, paths};
use super::error::{ApiError, Result};
use super::models::*;
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
/// Qobuz API client
pub struct QobuzClient {
http: Client,
tokens: Arc<RwLock<Option<BundleTokens>>>,
session: Arc<RwLock<Option<UserSession>>>,
validated_secret: Arc<RwLock<Option<String>>>,
locale: Arc<RwLock<String>>,
}
impl Clone for QobuzClient {
fn clone(&self) -> Self {
Self {
http: self.http.clone(),
tokens: Arc::clone(&self.tokens),
session: Arc::clone(&self.session),
validated_secret: Arc::clone(&self.validated_secret),
locale: Arc::clone(&self.locale),
}
}
}
impl QobuzClient {
/// Create a new client
pub fn new() -> Result<Self> {
let http = Client::builder()
.user_agent(USER_AGENT)
.cookie_store(true)
.build()?;
Ok(Self {
http,
tokens: Arc::new(RwLock::new(None)),
session: Arc::new(RwLock::new(None)),
validated_secret: Arc::new(RwLock::new(None)),
locale: Arc::new(RwLock::new("en".to_string())),
})
}
/// Initialize client by extracting bundle tokens
pub async fn init(&self) -> Result<()> {
let tokens = extract_bundle_tokens(&self.http).await?;
*self.tokens.write().await = Some(tokens);
Ok(())
}
/// Set the locale for API requests
pub async fn set_locale(&self, locale: String) {
*self.locale.write().await = locale;
}
/// Get the current locale (public for cache key generation)
pub async fn get_locale(&self) -> String {
self.locale.read().await.clone()
}
/// Get the current locale (internal use)
async fn locale(&self) -> String {
self.locale.read().await.clone()
}
/// Get app ID (public for catalog search)
pub async fn app_id(&self) -> Result<String> {
self.tokens
.read()
.await
.as_ref()
.map(|t| t.app_id.clone())
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))
}
/// Get HTTP client reference (public for catalog search)
pub fn get_http(&self) -> &Client {
&self.http
}
/// Get validated secret (validates on first use)
async fn secret(&self) -> Result<String> {
// Check if we already have a validated secret
if let Some(secret) = self.validated_secret.read().await.clone() {
return Ok(secret);
}
// Need to validate secrets
let tokens = self.tokens.read().await;
let tokens = tokens
.as_ref()
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?;
for secret in &tokens.secrets {
if self.test_secret(secret).await? {
*self.validated_secret.write().await = Some(secret.clone());
return Ok(secret.clone());
}
}
Err(ApiError::InvalidAppSecret)
}
/// Test if a secret is valid using a known track
async fn test_secret(&self, secret: &str) -> Result<bool> {
let test_track_id = 5966783u64; // Known test track
let timestamp = get_timestamp();
let signature = sign_get_file_url(test_track_id, 5, timestamp, secret);
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
let response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[
("track_id", test_track_id.to_string()),
("format_id", "5".to_string()),
("intent", "stream".to_string()),
("request_ts", timestamp.to_string()),
("request_sig", signature),
])
.send()
.await?;
Ok(response.status() != StatusCode::BAD_REQUEST)
}
/// Login with email and password
pub async fn login(&self, email: &str, password: &str) -> Result<UserSession> {
let url = endpoints::build_url(paths::USER_LOGIN);
let response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[("email", email), ("password", password)])
.send()
.await?;
match response.status() {
StatusCode::OK => {
let json: Value = response.json().await?;
let session = parse_login_response(&json)?;
*self.session.write().await = Some(session.clone());
Ok(session)
}
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
"Invalid credentials".to_string(),
)),
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppId),
status => Err(ApiError::ApiResponse(format!(
"Unexpected status: {}",
status
))),
}
}
/// Check if logged in
pub async fn is_logged_in(&self) -> bool {
self.session.read().await.is_some()
}
/// Logout - clear the session
pub async fn logout(&self) {
*self.session.write().await = None;
}
/// Get current user info (display name, subscription, and expiry if available)
pub async fn get_user_info(&self) -> Option<(String, String, Option<String>)> {
self.session.read().await.as_ref().map(|s| {
(
s.display_name.clone(),
s.subscription_label.clone(),
s.subscription_valid_until.clone(),
)
})
}
/// Get current user ID
pub async fn get_user_id(&self) -> Option<u64> {
self.session.read().await.as_ref().map(|s| s.user_id)
}
/// Get user auth token header value (public for catalog search)
pub async fn auth_token(&self) -> Result<String> {
self.session
.read()
.await
.as_ref()
.map(|s| s.user_auth_token.clone())
.ok_or_else(|| ApiError::AuthenticationError("Not logged in".to_string()))
}
// === Header helpers ===
/// Build standard API headers.
/// Always includes X-App-Id. Includes X-User-Auth-Token when logged in.
async fn api_headers(&self) -> Result<reqwest::header::HeaderMap> {
use reqwest::header::{HeaderMap, HeaderValue};
let mut headers = HeaderMap::new();
let app_id = self.app_id().await?;
headers.insert(
"X-App-Id",
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
);
if let Ok(token) = self.auth_token().await {
if let Ok(val) = HeaderValue::from_str(&token) {
headers.insert("X-User-Auth-Token", val);
}
}
Ok(headers)
}
/// Build headers that REQUIRE authentication. Fails if not logged in.
async fn authenticated_headers(&self) -> Result<reqwest::header::HeaderMap> {
use reqwest::header::{HeaderMap, HeaderValue};
let mut headers = HeaderMap::new();
let app_id = self.app_id().await?;
headers.insert(
"X-App-Id",
HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?,
);
let token = self.auth_token().await?;
headers.insert(
"X-User-Auth-Token",
HeaderValue::from_str(&token)
.map_err(|_| ApiError::AuthenticationError("Invalid auth token format".into()))?,
);
Ok(headers)
}
// === Search endpoints ===
/// Search for albums
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
pub async fn search_albums(
&self,
query: &str,
limit: u32,
offset: u32,
search_type: Option<&str>,
) -> Result<SearchResultsPage<Album>> {
let url = endpoints::build_url(paths::ALBUM_SEARCH);
let limit_str = limit.to_string();
let offset_str = offset.to_string();
let mut params: Vec<(&str, &str)> = vec![
("query", query),
("limit", &limit_str),
("offset", &offset_str),
];
if let Some(st) = search_type {
params.push(("type", st));
}
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&params)
.send()
.await?;
log::debug!("[API] search_albums status={}", http_response.status());
let response: Value = http_response.json().await?;
let albums = response
.get("albums")
.ok_or_else(|| ApiError::ApiResponse("No albums in response".to_string()))?;
Ok(serde_json::from_value(albums.clone())?)
}
/// Search for tracks
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
pub async fn search_tracks(
&self,
query: &str,
limit: u32,
offset: u32,
search_type: Option<&str>,
) -> Result<SearchResultsPage<Track>> {
let url = endpoints::build_url(paths::TRACK_SEARCH);
let limit_str = limit.to_string();
let offset_str = offset.to_string();
let mut params: Vec<(&str, &str)> = vec![
("query", query),
("limit", &limit_str),
("offset", &offset_str),
];
if let Some(st) = search_type {
params.push(("type", st));
}
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&params)
.send()
.await?;
log::debug!("[API] search_tracks status={}", http_response.status());
let response: Value = http_response.json().await?;
let tracks = response
.get("tracks")
.ok_or_else(|| ApiError::ApiResponse("No tracks in response".to_string()))?;
Ok(serde_json::from_value(tracks.clone())?)
}
/// Search for artists
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
pub async fn search_artists(
&self,
query: &str,
limit: u32,
offset: u32,
search_type: Option<&str>,
) -> Result<SearchResultsPage<Artist>> {
let url = endpoints::build_url(paths::ARTIST_SEARCH);
let limit_str = limit.to_string();
let offset_str = offset.to_string();
let mut params: Vec<(&str, &str)> = vec![
("query", query),
("limit", &limit_str),
("offset", &offset_str),
];
if let Some(st) = search_type {
params.push(("type", st));
}
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&params)
.send()
.await?;
log::debug!("[API] search_artists status={}", http_response.status());
let response: Value = http_response.json().await?;
let artists = response
.get("artists")
.ok_or_else(|| ApiError::ApiResponse("No artists in response".to_string()))?;
Ok(serde_json::from_value(artists.clone())?)
}
/// Get similar artists for an artist ID
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
.get(&url)
.headers(self.api_headers().await?)
.query(&[
("artist_id", artist_id.to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.send()
.await?;
log::debug!(
"[API] get_similar_artists({}) status={}",
artist_id,
http_response.status()
);
let response: Value = http_response.json().await?;
let artists = response
.get("artists")
.ok_or_else(|| ApiError::ApiResponse("No artists in response".to_string()))?;
Ok(serde_json::from_value(artists.clone())?)
}
/// 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> {
let url = endpoints::build_url(paths::ARTIST_GET);
let locale = self.locale().await;
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[
("artist_id", artist_id.to_string()),
("extra", "tracks".to_string()),
("lang", locale),
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.send()
.await?;
log::debug!(
"[API] get_artist_tracks({}) status={}",
artist_id,
http_response.status()
);
let response: Value = http_response.json().await?;
let tracks = response
.get("tracks")
.ok_or_else(|| ApiError::ApiResponse("No tracks in artist response".to_string()))?;
Ok(serde_json::from_value(tracks.clone())?)
}
// === Get endpoints ===
/// Get album by ID
pub async fn get_album(&self, album_id: &str) -> Result<Album> {
let url = endpoints::build_url(paths::ALBUM_GET);
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[("album_id", album_id)])
.send()
.await?;
let status = http_response.status();
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
)));
}
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
)));
}
let response: Value = http_response.json().await?;
Ok(serde_json::from_value(response)?)
}
/// Get featured albums by type (new-releases, press-awards, most-streamed)
pub async fn get_featured_albums(
&self,
featured_type: &str,
limit: u32,
offset: u32,
genre_id: Option<u64>,
) -> Result<SearchResultsPage<Album>> {
let url = endpoints::build_url(paths::ALBUM_GET_FEATURED);
let mut query = vec![
("type".to_string(), featured_type.to_string()),
("limit".to_string(), limit.to_string()),
("offset".to_string(), offset.to_string()),
];
if let Some(gid) = genre_id {
query.push(("genre_id".to_string(), gid.to_string()));
}
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&query)
.send()
.await?;
log::debug!(
"[API] get_featured_albums({}) status={}",
featured_type,
http_response.status()
);
let response: Value = http_response.json().await?;
let albums = response
.get("albums")
.ok_or_else(|| ApiError::ApiResponse("No albums in response".to_string()))?;
Ok(serde_json::from_value(albums.clone())?)
}
/// Get list of genres
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())];
if let Some(pid) = parent_id {
query.push(("parent_id", pid.to_string()));
}
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&query)
.send()
.await?;
log::debug!(
"[API] get_genres(parent={:?}) status={}",
parent_id,
http_response.status()
);
let response: Value = http_response.json().await?;
let genres = response
.get("genres")
.and_then(|g| g.get("items"))
.ok_or_else(|| ApiError::ApiResponse("No genres in response".to_string()))?;
Ok(serde_json::from_value(genres.clone())?)
}
/// Get discover index (home page content: playlists, ideal discography, etc.)
pub async fn get_discover_index(
&self,
genre_ids: Option<Vec<u64>>,
) -> Result<DiscoverResponse> {
let url = endpoints::build_url(paths::DISCOVER_INDEX);
let mut query: Vec<(&str, String)> = vec![];
// 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(",");
query.push(("genre_ids", ids_str));
}
}
let http_response = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&query)
.send()
.await?;
log::info!("[API] get_discover_index status={}", http_response.status());
let response: Value = http_response.json().await?;
// Debug: log the response structure
if let Some(obj) = response.as_object() {
log::info!(
"Discover API response keys: {:?}",
obj.keys().collect::<Vec<_>>()
);
if let Some(err) = obj.get("message") {
log::error!("Discover API error: {:?}", err);
}
if let Some(code) = obj.get("code") {
log::error!("Discover API error code: {:?}", code);
}
}
Ok(serde_json::from_value(response)?)
}
/// Get discover albums from a specific browse endpoint (newReleases, idealDiscography, mostStreamed)
pub async fn get_discover_albums(
&self,
endpoint: &str,
genre_ids: Option<Vec<u64>>,
offset: u32,
limit: u32,
) -> Result<DiscoverData<DiscoverAlbum>> {
let url = endpoints::build_url(endpoint);
let mut query: Vec<(&str, String)> = vec![];
if let Some(gids) = genre_ids {
if !gids.is_empty() {
let ids_str = gids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
query.push(("genre_ids", ids_str));
}
}
query.push(("offset", offset.to_string()));
query.push(("limit", limit.to_string()));
let http_response = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&query)
.send()
.await?;
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)?)
}
/// Get discover playlists with optional tag and genre filters
/// Example: tags=label, genre_ids=112,119
pub async fn get_discover_playlists(
&self,
tag: Option<String>,
genre_ids: Option<Vec<u64>>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<DiscoverPlaylistsResponse> {
let url = endpoints::build_url(paths::DISCOVER_PLAYLISTS);
let mut query: Vec<(&str, String)> = vec![];
// Add tag filter if provided (e.g., "label", "partner")
if let Some(ref t) = tag {
query.push(("tags", t.clone()));
}
// 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(",");
query.push(("genre_ids", ids_str));
}
}
// Add limit (default 20)
let lim = limit.unwrap_or(20);
query.push(("limit", lim.to_string()));
// Add offset (default 0)
let off = offset.unwrap_or(0);
query.push(("offset", off.to_string()));
log::debug!(
"[API] get_discover_playlists URL: {} query: {:?}",
url,
query
);
// First get raw JSON to debug structure
let raw_response: serde_json::Value = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&query)
.send()
.await?
.json()
.await?;
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()
);
e
})?;
log::debug!(
"[API] get_discover_playlists response: {} playlists",
response.items.len()
);
Ok(response)
}
/// Get playlist tags with localized names
pub async fn get_playlist_tags(&self) -> Result<Vec<PlaylistTag>> {
let url = endpoints::build_url(paths::PLAYLIST_GET_TAGS);
let http_response = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.send()
.await?;
log::info!("[API] get_playlist_tags status={}", http_response.status());
let raw: PlaylistTagsResponse = http_response.json().await?;
// Get current locale (e.g., "en", "es", "fr", "de")
let locale = self.locale().await;
let lang = locale.split('-').next().unwrap_or("en");
// Convert raw tags to PlaylistTag with localized name
let tags: Vec<PlaylistTag> = raw
.tags
.into_iter()
.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 = 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
.as_ref()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
Some(PlaylistTag {
id,
slug: tag.slug,
name,
})
})
.collect();
log::debug!("[API] get_playlist_tags: {} tags", tags.len());
Ok(tags)
}
/// Get track by ID
pub async fn get_track(&self, track_id: u64) -> Result<Track> {
let url = endpoints::build_url(paths::TRACK_GET);
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[("track_id", track_id.to_string())])
.send()
.await?;
let status = http_response.status();
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
);
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
)));
}
let response: Value = http_response.json().await?;
Ok(serde_json::from_value(response)?)
}
/// Get artist by ID (basic info only - no albums, faster response)
pub async fn get_artist_basic(&self, artist_id: u64) -> Result<Artist> {
let url = endpoints::build_url(paths::ARTIST_GET);
let locale = self.locale().await;
let query = vec![
("artist_id", artist_id.to_string()),
("lang", locale),
// No "extra" parameter = only basic info (id, name, image)
];
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&query)
.send()
.await?;
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
}
/// Get artist detail by ID with albums, playlists, and appears-on tracks
pub async fn get_artist_detail(
&self,
artist_id: u64,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Artist> {
let url = endpoints::build_url(paths::ARTIST_GET);
let locale = self.locale().await;
let mut query = vec![
("artist_id", artist_id.to_string()),
("extra", "albums,tracks_appears_on,playlists".to_string()),
("lang", locale),
];
if let Some(l) = limit {
query.push(("limit", l.to_string()));
}
if let Some(o) = offset {
query.push(("offset", o.to_string()));
}
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&query)
.send()
.await?;
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)?)
}
/// Get artist by ID with album pagination
pub async fn get_artist_with_pagination(
&self,
artist_id: u64,
with_albums: bool,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Artist> {
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
/// Use locale_override to force a specific language (e.g., "en" for genre checking)
pub async fn get_artist_with_pagination_and_locale(
&self,
artist_id: u64,
with_albums: bool,
limit: Option<u32>,
offset: Option<u32>,
locale_override: Option<&str>,
) -> Result<Artist> {
let url = endpoints::build_url(paths::ARTIST_GET);
let locale = match locale_override {
Some(l) => l.to_string(),
None => self.locale().await,
};
let mut query = vec![("artist_id", artist_id.to_string()), ("lang", locale)];
if with_albums {
query.push(("extra", "albums".to_string()));
}
if let Some(l) = limit {
query.push(("limit", l.to_string()));
}
if let Some(o) = offset {
query.push(("offset", o.to_string()));
}
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&query)
.send()
.await?;
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)?)
}
/// Get playlist metadata + ordered track IDs (lightweight, no full Track objects).
/// Uses `playlist/get?extra=track_ids` which returns the playlist with a flat
/// array of track IDs instead of nested Track objects.
pub async fn get_playlist_track_ids(&self, playlist_id: u64) -> Result<PlaylistWithTrackIds> {
let url = endpoints::build_url(paths::PLAYLIST_GET);
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[
("playlist_id", playlist_id.to_string()),
("extra", "track_ids".to_string()),
])
.send()
.await?;
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!(
"[API] get_playlist_track_ids({}) — {} track IDs",
playlist_id,
result.track_ids.len()
);
Ok(result)
}
/// Fetch full Track objects for a batch of track IDs (max 50 per call).
/// Uses the `track/getList` endpoint.
///
/// Tries multiple API call strategies:
/// POST to track/getList with JSON body {"tracks_id": [...]}
/// Returns full Track objects for the given IDs (max 50 per call).
pub async fn get_tracks_batch(&self, track_ids: &[u64]) -> Result<Vec<Track>> {
let url = endpoints::build_url(paths::TRACK_GET_LIST);
let headers = self.api_headers().await?;
let body = serde_json::json!({ "tracks_id": track_ids });
log::debug!("[API] get_tracks_batch POST ({} IDs)", track_ids.len());
let http_response = self
.http
.post(&url)
.headers(headers)
.json(&body)
.send()
.await?;
let status = http_response.status();
log::debug!("[API] get_tracks_batch POST status={}", status);
let value: Value = http_response.json().await?;
// Response: { "tracks": { "total": N, "items": [...] } }
let items = value
.get("tracks")
.and_then(|t| t.get("items"))
.ok_or_else(|| {
let preview = serde_json::to_string(&value)
.unwrap_or_default()
.chars()
.take(500)
.collect::<String>();
ApiError::ApiResponse(format!(
"Missing tracks.items in getList response: {}",
preview
))
})?;
let tracks: Vec<Track> = serde_json::from_value(items.clone())?;
log::debug!("[API] get_tracks_batch returned {} tracks", tracks.len());
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
/// since we know the total track count from the first response.
pub async fn get_playlist(&self, playlist_id: u64) -> Result<Playlist> {
let url = endpoints::build_url(paths::PLAYLIST_GET);
const PAGE_SIZE: u32 = 500;
let start = std::time::Instant::now();
// First page — gives us metadata + total track count
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[
("playlist_id", playlist_id.to_string()),
("limit", PAGE_SIZE.to_string()),
("offset", "0".to_string()),
("extra", "tracks".to_string()),
])
.send()
.await?;
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)?;
// Fetch remaining pages concurrently
if let Some(ref mut container) = playlist.tracks {
let total = container.total;
let fetched = container.items.len() as u32;
if fetched < total {
// Build all remaining page offsets
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
);
// 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 http = &self.http;
let url = &url;
let headers = headers.clone();
let pid = playlist_id.to_string();
let limit = PAGE_SIZE.to_string();
let offset_str = offset.to_string();
async move {
let resp = http
.get(url)
.headers(headers)
.query(&[
("playlist_id", pid.as_str()),
("limit", limit.as_str()),
("offset", offset_str.as_str()),
("extra", "tracks"),
])
.send()
.await?;
let value: Value = resp.json().await?;
let page: Playlist = serde_json::from_value(value)?;
Ok::<_, anyhow::Error>((offset, page))
}
})
.collect();
let results = futures_util::future::join_all(futures).await;
// Collect results sorted by offset to maintain track order
let mut pages: Vec<(u32, Playlist)> = Vec::new();
for result in results {
match result {
Ok(page) => pages.push(page),
Err(e) => {
log::warn!(
"[API] get_playlist({}) page fetch failed: {}",
playlist_id,
e
);
// Continue with what we have
}
}
}
pages.sort_by_key(|(offset, _)| *offset);
// Append tracks in order
for (_, page_playlist) in pages {
if let Some(page_tracks) = page_playlist.tracks {
if !page_tracks.items.is_empty() {
container.items.extend(page_tracks.items);
}
}
}
}
}
let elapsed = start.elapsed();
log::info!(
"[API] get_playlist({}) complete: {} tracks in {:.2}s",
playlist_id,
playlist.tracks.as_ref().map(|t| t.items.len()).unwrap_or(0),
elapsed.as_secs_f64()
);
Ok(playlist)
}
/// Get label page (aggregated: top tracks, releases, playlists, artists)
pub async fn get_label_page(&self, label_id: u64) -> Result<LabelPageData> {
let url = endpoints::build_url(paths::LABEL_PAGE);
log::debug!("[API] get_label_page({})", label_id);
let response: serde_json::Value = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[("label_id", label_id.to_string())])
.send()
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
/// Get label explore (discover more labels)
pub async fn get_label_explore(
&self,
limit: u32,
offset: u32,
) -> Result<LabelExploreResponse> {
let url = endpoints::build_url(paths::LABEL_EXPLORE);
log::debug!("[API] get_label_explore(limit={}, offset={})", limit, offset);
let response: serde_json::Value = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.send()
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
/// Get label by ID with albums
pub async fn get_label(&self, label_id: u64, limit: u32, offset: u32) -> Result<LabelDetail> {
let url = endpoints::build_url(paths::LABEL_GET);
let locale = self.locale().await;
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[
("label_id", label_id.to_string()),
("extra", "albums".to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
("lang", locale),
])
.send()
.await?;
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> {
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(&query)
.send()
.await?;
log::debug!(
"[API] get_user_purchases_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, 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 all_albums: Vec<PurchaseAlbum> = Vec::new();
let mut all_tracks: Vec<PurchaseTrack> = Vec::new();
let mut albums_total = 0u32;
let mut tracks_total = 0u32;
let mut albums_offset = 0u32;
loop {
let page = self
.get_user_purchases_page_typed(Some("albums"), page_limit, albums_offset)
.await?;
if albums_offset == 0 {
albums_total = page.albums.total;
}
let got = page.albums.items.len() as u32;
all_albums.extend(page.albums.items);
if got == 0 || albums_offset + got >= albums_total {
break;
}
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: final_albums_total,
offset: 0,
limit: page_limit,
},
tracks: SearchResultsPage {
items: all_tracks,
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,
},
})
}
// === Authenticated endpoints ===
/// Get a signed file URL for a specific format_id.
pub async fn get_track_file_url_by_format(
&self,
track_id: u64,
format_id: u32,
) -> Result<StreamUrl> {
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
let timestamp = get_timestamp();
let secret = self.secret().await?;
let signature = sign_get_file_url(track_id, format_id, timestamp, &secret);
let response = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[
("track_id", track_id.to_string()),
("format_id", format_id.to_string()),
("intent", "stream".to_string()),
("request_ts", timestamp.to_string()),
("request_sig", signature),
])
.send()
.await?;
match response.status() {
StatusCode::OK => {
let json: Value = response.json().await?;
let restrictions: Vec<StreamRestriction> = json
.get("restrictions")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let stream_url = json
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if stream_url.is_empty() {
return Err(ApiError::TrackUnavailable(track_id));
}
Ok(StreamUrl {
url: stream_url,
format_id: json.get("format_id").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
mime_type: json
.get("mime_type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
sampling_rate: json
.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),
track_id,
restrictions,
})
}
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret),
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
);
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
let timestamp = get_timestamp();
log::debug!("Getting secret for signing...");
let secret = self.secret().await?;
log::debug!("Secret obtained, signing request...");
let signature = sign_get_file_url(track_id, quality.id(), timestamp, &secret);
log::debug!("Sending stream URL request...");
let response = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[
("track_id", track_id.to_string()),
("format_id", quality.id().to_string()),
("intent", "stream".to_string()),
("request_ts", timestamp.to_string()),
("request_sig", signature),
])
.send()
.await?;
log::info!("Stream URL response status: {}", response.status());
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<_>>())
);
// Check for restrictions
let restrictions: Vec<StreamRestriction> = json
.get("restrictions")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
// Validate that we got an actual URL (track may be unavailable)
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
);
return Err(ApiError::TrackUnavailable(track_id));
}
Ok(StreamUrl {
url,
format_id: json["format_id"].as_u64().unwrap_or(0) as u32,
mime_type: json["mime_type"].as_str().unwrap_or("").to_string(),
sampling_rate: json["sampling_rate"].as_f64().unwrap_or(0.0),
bit_depth: json["bit_depth"].as_u64().map(|v| v as u32),
track_id,
restrictions,
})
}
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret),
status => Err(ApiError::ApiResponse(format!(
"Unexpected status: {}",
status
))),
}
}
/// Get stream URL with quality fallback
pub async fn get_stream_url_with_fallback(
&self,
track_id: u64,
preferred: Quality,
) -> Result<StreamUrl> {
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);
let mut track_unavailable = false;
for quality in &qualities[start_idx..] {
log::info!("Trying quality: {:?}", quality);
match self.get_stream_url(track_id, *quality).await {
Ok(url) if !url.has_restrictions() => {
log::info!(
"Got stream URL - requested format_id={}, got format_id={}, sample_rate={}Hz, bit_depth={:?}",
quality.id(),
url.format_id,
url.sampling_rate,
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;
}
}
}
// If all quality levels reported track unavailable, return that specific error
if track_unavailable {
log::error!("Track {} is no longer available on Qobuz", track_id);
return Err(ApiError::TrackUnavailable(track_id));
}
log::error!("No quality available for track {}", track_id);
Err(ApiError::NoQualityAvailable)
}
/// Get user favorites (requires auth + signature)
pub async fn get_favorites(&self, fav_type: &str, limit: u32, offset: u32) -> Result<Value> {
let url = endpoints::build_url(paths::FAVORITE_GET_USER_FAVORITES);
let timestamp = get_timestamp();
let secret = self.secret().await?;
let signature = sign_get_favorites(timestamp, &secret);
let http_response = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[
("type", fav_type),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
("request_ts", &timestamp.to_string()),
("request_sig", &signature),
])
.send()
.await?;
log::debug!(
"[API] get_favorites({}) status={}",
fav_type,
http_response.status()
);
let response: Value = http_response.json().await?;
Ok(response)
}
/// Get user's playlists
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
let url = endpoints::build_url(paths::PLAYLIST_GET_USER_PLAYLISTS);
let http_response = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.send()
.await?;
log::debug!("[API] get_user_playlists status={}", http_response.status());
let response: Value = http_response.json().await?;
let playlists = response
.get("playlists")
.and_then(|p| p.get("items"))
.ok_or_else(|| ApiError::ApiResponse("No playlists in response".to_string()))?;
Ok(serde_json::from_value(playlists.clone())?)
}
/// Search playlists
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
.get(&url)
.headers(self.api_headers().await?)
.query(&[
("query", query),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
])
.send()
.await?;
log::debug!("[API] search_playlists status={}", http_response.status());
let response: Value = http_response.json().await?;
let playlists = response
.get("playlists")
.ok_or_else(|| ApiError::ApiResponse("No playlists in response".to_string()))?;
Ok(serde_json::from_value(playlists.clone())?)
}
/// Create a new 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![
("name", name.to_string()),
("is_public", is_public.to_string()),
];
if let Some(desc) = description {
params.push(("description", desc.to_string()));
}
let response: Playlist = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&params)
.send()
.await?
.json()
.await?;
Ok(response)
}
/// Delete a playlist
pub async fn delete_playlist(&self, playlist_id: u64) -> Result<()> {
let url = endpoints::build_url(paths::PLAYLIST_DELETE);
self.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[("playlist_id", playlist_id.to_string())])
.send()
.await?;
Ok(())
}
/// 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(",");
self.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[
("playlist_id", playlist_id.to_string()),
("track_ids", track_ids_str),
])
.send()
.await?;
Ok(())
}
/// Remove tracks from a playlist
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(",");
self.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[
("playlist_id", playlist_id.to_string()),
("playlist_track_ids", track_ids_str),
])
.send()
.await?;
Ok(())
}
/// Update playlist metadata
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())];
if let Some(n) = name {
params.push(("name", n.to_string()));
}
if let Some(d) = description {
params.push(("description", d.to_string()));
}
if let Some(p) = is_public {
params.push(("is_public", p.to_string()));
}
let response: Playlist = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&params)
.send()
.await?
.json()
.await?;
Ok(response)
}
/// Add item to favorites
pub async fn add_favorite(&self, fav_type: &str, item_id: &str) -> Result<()> {
let url = endpoints::build_url(paths::FAVORITE_CREATE);
let type_key = format!("{}_ids", fav_type); // album_ids, track_ids, artist_ids
let response = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[(&type_key, item_id)])
.send()
.await?;
if response.status().is_success() {
Ok(())
} else {
Err(ApiError::ApiResponse(format!(
"Failed to add favorite: {}",
response.status()
)))
}
}
/// Remove item from favorites
pub async fn remove_favorite(&self, fav_type: &str, item_id: &str) -> Result<()> {
let url = endpoints::build_url(paths::FAVORITE_DELETE);
let type_key = format!("{}_ids", fav_type);
let response = self
.http
.get(&url)
.headers(self.authenticated_headers().await?)
.query(&[(&type_key, item_id)])
.send()
.await?;
if response.status().is_success() {
Ok(())
} else {
Err(ApiError::ApiResponse(format!(
"Failed to remove favorite: {}",
response.status()
)))
}
}
// ============ Artist Page Endpoints ============
/// Get artist page (aggregated: bio, top tracks, releases, similar, playlists)
pub async fn get_artist_page(
&self,
artist_id: u64,
sort: Option<&str>,
) -> Result<PageArtistResponse> {
let url = endpoints::build_url(paths::ARTIST_PAGE);
let mut query = vec![("artist_id", artist_id.to_string())];
if let Some(s) = sort {
query.push(("sort", s.to_string()));
}
log::debug!("[API] get_artist_page({}) sort={:?}", artist_id, sort);
let response: serde_json::Value = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&query)
.send()
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
/// Get artist releases grid (paginated by release_type)
pub async fn get_releases_grid(
&self,
artist_id: u64,
release_type: &str,
limit: u32,
offset: u32,
sort: Option<&str>,
) -> Result<ReleasesGridResponse> {
let url = endpoints::build_url(paths::ARTIST_RELEASES_GRID);
let mut query = vec![
("artist_id", artist_id.to_string()),
("release_type", release_type.to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
];
if let Some(s) = sort {
query.push(("sort", s.to_string()));
}
log::debug!(
"[API] get_releases_grid({}) type={} limit={} offset={}",
artist_id,
release_type,
limit,
offset
);
let response: serde_json::Value = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&query)
.send()
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
}
impl Default for QobuzClient {
fn default() -> Self {
Self::new().expect("Failed to create client")
}
}