//! Client principal pour interagir avec l'API Qobuz //! //! Ce module fournit un client haut-niveau avec authentification et cache intégré. use crate::api::auth::AuthInfo; use crate::api::{QobuzApi, DEFAULT_APP_ID}; use crate::cache::QobuzCache; use crate::config_ext::QobuzConfigExt; use crate::error::{QobuzError, Result}; use crate::models::*; use pmoconfig::{self, Config}; use std::future::Future; use std::sync::{Arc, Mutex}; use tracing::{debug, info, warn}; /// Client Qobuz haut-niveau avec cache pub struct QobuzClient { /// API bas-niveau api: QobuzApi, /// Cache en mémoire cache: Arc, /// Informations d'authentification auth_info: Mutex>, /// Identifiants utilisateur pour relogin automatique credentials: Option<(String, String)>, /// Configuration partagée (pour persister les tokens) config: Option>, #[cfg(feature = "disk-cache")] /// Cache disque optionnel disk_cache: Option>, } impl QobuzClient { fn build_client( api: QobuzApi, auth_info: Option, credentials: Option<(String, String)>, config: Option>, ) -> Self { if let Some(info) = &auth_info { api.set_auth_token(info.token.clone(), info.user_id.clone()); } Self { api, cache: Arc::new(QobuzCache::new()), auth_info: Mutex::new(auth_info), credentials, config, #[cfg(feature = "disk-cache")] disk_cache: None, } } /// Crée un nouveau client et authentifie avec les credentials fournis /// /// # Arguments /// /// * `username` - Email ou nom d'utilisateur Qobuz /// * `password` - Mot de passe /// /// # Exemple /// /// ```rust,no_run /// use pmoqobuz::QobuzClient; /// /// #[tokio::main] /// async fn main() -> anyhow::Result<()> { /// let client = QobuzClient::new("user@example.com", "password").await?; /// Ok(()) /// } /// ``` pub async fn new(username: &str, password: &str) -> Result { Self::with_app_id(DEFAULT_APP_ID, username, password).await } /// Crée un nouveau client avec un App ID personnalisé pub async fn with_app_id(app_id: &str, username: &str, password: &str) -> Result { info!("Creating Qobuz client with app ID: {}", app_id); let api = QobuzApi::new(app_id)?; let auth_info = api.login(username, password).await?; let client = Self::build_client( api, Some(auth_info), Some((username.to_string(), password.to_string())), None, ); Ok(client.finalize_disk_cache().await) } /// Crée un client en utilisant la configuration de pmoconfig /// /// # Exemple /// /// ```rust,no_run /// use pmoqobuz::QobuzClient; /// /// #[tokio::main] /// async fn main() -> anyhow::Result<()> { /// let client = QobuzClient::from_config().await?; /// Ok(()) /// } /// ``` pub async fn from_config() -> Result { let config = pmoconfig::get_config(); Self::from_config_obj(config.as_ref()).await } /// Crée un client depuis un objet Config spécifique /// /// Cette méthode récupère les credentials, l'App ID et optionnellement /// le secret depuis la configuration. /// /// Ordre de priorité pour l'initialisation : /// 0. **Vérifier le cache du token d'authentification** (évite un login si token valide) /// 1. Si `appid` ET `secret` configurés → teste d'abord avec ces credentials /// 2. Si échec d'authentification → utilise le Spoofer pour obtenir de nouveaux credentials /// 3. Si aucun `appid`/`secret` configuré → utilise directement le Spoofer /// 4. Fallback ultime → utilise DEFAULT_APP_ID sans secret (requêtes signées échoueront) pub async fn from_config_obj(config: &Config) -> Result { let (username, password) = config.get_qobuz_credentials()?; let credentials = (username.clone(), password.clone()); let config_arc = Arc::new(config.clone()); let config_appid = config.get_qobuz_appid()?; let config_secret = config.get_qobuz_secret()?; let config_spoofer_secret = config.get_qobuz_spoofer_secret()?; let mut used_config_credentials = false; let mut api = match (config_appid.clone(), config_spoofer_secret, config_secret) { // Priority 1: Try memorized Spoofer secret (raw, no XOR) (Some(app_id), Some(spoofer_secret), _) => { info!("Trying memorized Spoofer secret with App ID: {}", app_id); match QobuzApi::with_raw_secret(&app_id, &spoofer_secret) { Ok(api) => { used_config_credentials = true; api } Err(e) => { info!( "✗ Memorized Spoofer secret failed: {}. Re-fetching from Spoofer...", e ); Self::try_spoofer_fallback(config).await? } } } // Priority 2: Try XOR secret (legacy configvalue) (Some(app_id), None, Some(secret)) => { info!( "Creating Qobuz API with configured App ID: {} and XOR secret", app_id ); match QobuzApi::with_secret(&app_id, &secret) { Ok(api) => { used_config_credentials = true; api } Err(e) => { info!( "✗ Failed to create API with configured credentials: {}. Falling back to Spoofer...", e ); Self::try_spoofer_fallback(config).await? } } } // Priority 3: Fallback to Spoofer _ => { info!("AppID or secret not configured, using Spoofer..."); Self::try_spoofer_fallback(config).await? } }; if config.is_qobuz_auth_valid() { match (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) { (Ok(Some(token)), Ok(Some(user_id))) if !token.is_empty() && !user_id.is_empty() => { info!("✓ Reusing authentication token (optimistic, no login)"); api.set_auth_token(token.clone(), user_id.clone()); let auth_info = AuthInfo { token, user_id, subscription_label: config.get_qobuz_subscription_label().ok().flatten(), }; let client = Self::build_client( api, Some(auth_info), Some(credentials.clone()), Some(config_arc.clone()), ); return Ok(client.finalize_disk_cache().await); } _ => { debug!( "Auth marked valid in config but token/user_id missing or invalid, performing login" ); } } } // Authentifier l'utilisateur let mut auth_result = api.login(&username, &password).await; if used_config_credentials { if let Err(err) = &auth_result { if err.is_auth_error() { info!("✗ Configured credentials failed authentication: {}", err); info!("→ Falling back to Spoofer to obtain new credentials..."); api = Self::try_spoofer_fallback(config).await?; auth_result = api.login(&username, &password).await; } } } let auth_info = auth_result?; Self::persist_auth_info(config, &auth_info); let client = Self::build_client(api, Some(auth_info), Some(credentials), Some(config_arc)); Ok(client.finalize_disk_cache().await) } /// Tente d'utiliser le Spoofer pour obtenir des credentials valides /// /// Cette méthode est appelée soit : /// - Quand aucun appid/secret n'est configuré /// - Quand les credentials configurés sont invalides/expirés async fn try_spoofer_fallback(config: &Config) -> Result { // Vérification bon marché : si la version du bundle n'a pas changé, // re-télécharger les 7 MB ne donnera pas de meilleurs secrets. // On court-circuite l'extraction et on passe directement au DEFAULT_APP_ID. if let Ok(Some(cached_version)) = config.get_qobuz_bundle_version() { match crate::api::Spoofer::fetch_current_bundle_version().await { Ok(current) if current == cached_version => { info!( "[Spoofer] Bundle inchangé ({}) — secret invalide pour une autre raison, skip extraction", current ); return QobuzApi::new(DEFAULT_APP_ID); } Ok(new_version) => { info!("[Spoofer] Bundle rotaté : {} → {}, re-extraction", cached_version, new_version); } Err(e) => { debug!("[Spoofer] Impossible de vérifier la version du bundle : {}", e); } } } if let Some((app_id, secret)) = Self::fetch_spoofer_credentials(config).await? { return QobuzApi::with_raw_secret(app_id, &secret); } info!("✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret"); QobuzApi::new(DEFAULT_APP_ID) } async fn fetch_spoofer_credentials(config: &Config) -> Result> { match crate::api::Spoofer::new().await { Ok(spoofer) => match spoofer.get_app_id() { Ok(app_id) => { // Use timezone secrets (like Python) // Note: App Secret from bundle doesn't work for signed requests match spoofer.get_secrets() { Ok(secrets) => { info!("Testing {} timezone secret(s)...", secrets.len()); let (username, password) = config.get_qobuz_credentials()?; // Optimization: Login once with first secret to get auth token // Then test all secrets using the same token if let Some((_first_timezone, first_secret)) = secrets.first() { if let Ok(temp_api) = QobuzApi::with_raw_secret(&app_id, first_secret) { if let Ok(_auth_info) = temp_api.login(&username, &password).await { // Now test each secret with the authenticated token for (timezone, secret) in secrets.iter() { debug!("Testing timezone secret: {}", timezone); if let Ok(test_api) = QobuzApi::with_raw_secret(&app_id, secret) { // Set the auth token from our initial login test_api.set_auth_token( temp_api.auth_token().unwrap(), temp_api.user_id().unwrap(), ); // Test the secret using track/getFileUrl (like qobuz-player-client) // Use the same hardcoded track_id (64868955) as qobuz-player-client if test_api.get_file_url("64868955").await.is_ok() { info!( "✓ Secret from timezone '{}' works!", timezone ); // Save appid, secret, and bundle version if let Err(e) = config.set_qobuz_appid(&app_id) { debug!("Could not save appid: {}", e); } if let Err(e) = config.set_qobuz_spoofer_secret(secret) { debug!("Could not save spoofer secret: {}", e); } if let Err(e) = config.set_qobuz_bundle_version(spoofer.bundle_version()) { debug!("Could not save bundle version: {}", e); } return Ok(Some(( app_id.clone(), secret.clone(), ))); } else { debug!("✗ Secret from timezone '{}' failed track/getFileUrl test", timezone); } } } } } } info!("✗ No valid secret found"); Ok(None) } Err(e) => { info!("Failed to extract timezone secrets: {}", e); Ok(None) } } } Err(e) => { info!( "Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID", e ); Ok(None) } }, Err(e) => { info!( "Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret", e ); Ok(None) } } } async fn refresh_credentials_via_spoofer(&self) -> Result<()> { let config_arc = match &self.config { Some(cfg) => cfg.clone(), None => pmoconfig::get_config(), }; match Self::fetch_spoofer_credentials(config_arc.as_ref()).await? { Some((app_id, secret)) => { // Use raw secret (no XOR) from Spoofer self.api.update_credentials_raw(app_id, &secret); info!("✓ Updated Qobuz API credentials using Spoofer"); Ok(()) } None => Err(QobuzError::Configuration( "Unable to refresh Qobuz credentials via Spoofer".to_string(), )), } } /// Définit le format audio par défaut pub fn set_format(&mut self, format: AudioFormat) { self.api.set_format(format); } /// Retourne le format audio configuré pub fn format(&self) -> AudioFormat { self.api.format() } /// Retourne les informations d'authentification pub fn auth_info(&self) -> Option { self.auth_info.lock().unwrap().clone() } #[cfg(feature = "disk-cache")] fn user_id(&self) -> Option { self.auth_info .lock() .unwrap() .as_ref() .map(|info| info.user_id.clone()) } /// Retourne une référence au cache pub fn cache(&self) -> Arc { self.cache.clone() } #[cfg(feature = "disk-cache")] pub async fn purge_disk_cache(&self) -> Result { if let Some(disk) = &self.disk_cache { disk.purge_expired() .await .map_err(|err| QobuzError::Cache(err.to_string())) } else { Ok(0) } } #[cfg(feature = "disk-cache")] pub fn with_disk_cache(mut self, store: Arc) -> Self { self.disk_cache = Some(store); self } #[cfg(feature = "disk-cache")] fn attach_default_disk_cache(mut self) -> Self { if let Some(store) = Self::default_disk_cache_store(self.config.as_deref()) { self = self.with_disk_cache(store); } self } #[cfg(not(feature = "disk-cache"))] fn attach_default_disk_cache(self) -> Self { self } #[cfg(feature = "disk-cache")] async fn finalize_disk_cache(self) -> Self { let client = self.attach_default_disk_cache(); if let Err(err) = client.purge_disk_cache().await { debug!("Failed to purge disk cache on startup: {}", err); } client } #[cfg(not(feature = "disk-cache"))] async fn finalize_disk_cache(self) -> Self { self.attach_default_disk_cache() } #[cfg(feature = "disk-cache")] fn default_disk_cache_store( config: Option<&Config>, ) -> Option> { let cache_dir = match config { Some(cfg) => match cfg.get_qobuz_cache_dir() { Ok(dir) => dir, Err(err) => { debug!("Failed to read qobuz cache dir from config: {}", err); return None; } }, None => { let global = pmoconfig::get_config(); match global.get_qobuz_cache_dir() { Ok(dir) => dir, Err(err) => { debug!("Failed to read qobuz cache dir from global config: {}", err); return None; } } } }; let dir_path = std::path::PathBuf::from(&cache_dir); if let Err(err) = std::fs::create_dir_all(&dir_path) { debug!( "Failed to create disk cache directory {}: {}", dir_path.display(), err ); return None; } let db_path = dir_path.join("qobuz_cache.sqlite"); match crate::disk_cache::SqliteCacheStore::new(db_path) { Ok(store) => { let store: Arc = Arc::new(store); Some(store) } Err(err) => { debug!("Failed to initialize SQLite disk cache: {}", err); None } } } async fn call_with_auth_repair(&self, operation: &str, mut op: F) -> Result where F: FnMut() -> Fut, Fut: Future> + Send, { match op().await { Ok(result) => Ok(result), Err(err) if err.is_signature_error() => { warn!( "Request signature error during {}. Qobuz app secret may be outdated.", operation ); if let Err(refresh_err) = self.refresh_credentials_via_spoofer().await { warn!( "Failed to refresh Qobuz credentials automatically: {}", refresh_err ); Err(err) } else { info!( "Successfully refreshed Qobuz credentials. Retrying {}...", operation ); op().await } } Err(err) if err.is_auth_error() => { info!( "Authentication error during {}. Attempting automatic repair...", operation ); self.repair_auth().await?; op().await } Err(err) => Err(err), } } async fn repair_auth(&self) -> Result<()> { let (username, password) = self.credentials.as_ref().cloned().ok_or_else(|| { QobuzError::Unauthorized( "Cannot repair authentication without stored credentials".to_string(), ) })?; let auth_info = self.api.login(&username, &password).await?; if let Some(config) = &self.config { Self::persist_auth_info(config.as_ref(), &auth_info); } let mut guard = self.auth_info.lock().unwrap(); *guard = Some(auth_info); Ok(()) } fn persist_auth_info(config: &Config, auth_info: &AuthInfo) { use std::time::{Duration, SystemTime, UNIX_EPOCH}; let expires_at = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() + Duration::from_secs(24 * 3600).as_secs(); // 24h if let Err(e) = config.set_qobuz_auth_info( &auth_info.token, &auth_info.user_id, auth_info.subscription_label.as_deref(), expires_at, ) { debug!("Failed to save authentication to config: {}", e); } else { info!("✓ Saved authentication token to configuration"); } } // ============ Albums ============ /// Récupère un album par son ID pub async fn get_album(&self, album_id: &str) -> Result { // Vérifier le cache d'abord if let Some(album) = self.cache.get_album(album_id).await { debug!("Album {} found in cache", album_id); return Ok(album); } // Sinon, récupérer depuis l'API let album = self .call_with_auth_repair("get_album", || self.api.get_album(album_id)) .await?; // Mettre en cache self.cache .put_album(album_id.to_string(), album.clone()) .await; Ok(album) } /// Récupère les tracks d'un album pub async fn get_album_tracks(&self, album_id: &str) -> Result> { // Vérifier le cache d'abord if let Some(tracks) = self.cache.get_album_tracks(album_id).await { debug!("Album tracks for {} found in cache", album_id); return Ok(tracks); } // Sinon, récupérer depuis l'API let tracks = self .call_with_auth_repair("get_album_tracks", || self.api.get_album_tracks(album_id)) .await?; // Mettre les tracks en cache (individuellement ET la liste complète) for track in &tracks { self.cache.put_track(track.id.clone(), track.clone()).await; } self.cache .put_album_tracks(album_id.to_string(), tracks.clone()) .await; Ok(tracks) } // ============ Tracks ============ /// Récupère une track par son ID pub async fn get_track(&self, track_id: &str) -> Result { if let Some(track) = self.cache.get_track(track_id).await { debug!("Track {} found in cache", track_id); return Ok(track); } let track = self .call_with_auth_repair("get_track", || self.api.get_track(track_id)) .await?; self.cache .put_track(track_id.to_string(), track.clone()) .await; Ok(track) } /// Récupère l'URL de streaming d'une track pub async fn get_stream_url(&self, track_id: &str) -> Result { // Vérifier le cache d'abord if let Some(info) = self.cache.get_stream_url(track_id).await { if info.expires_at > chrono::Utc::now() { debug!("Stream URL for track {} found in cache", track_id); return Ok(info.url); } } // Sinon, récupérer depuis l'API let info = self .call_with_auth_repair("get_file_url", || self.api.get_file_url(track_id)) .await?; let url = info.url.clone(); // Mettre en cache self.cache.put_stream_url(track_id.to_string(), info).await; Ok(url) } // ============ Artists ============ /// Récupère un artiste par son ID pub async fn get_artist(&self, artist_id: &str) -> Result { if let Some(artist) = self.cache.get_artist(artist_id).await { debug!("Artist {} found in cache", artist_id); return Ok(artist); } // Pour récupérer un artiste, on doit passer par get_artist_albums let albums = self .call_with_auth_repair("get_artist_albums_for_artist", || { self.api.get_artist_albums(artist_id) }) .await?; if let Some(first_album) = albums.first() { let artist = first_album.artist.clone(); self.cache .put_artist(artist_id.to_string(), artist.clone()) .await; Ok(artist) } else { Err(QobuzError::NotFound(format!( "Artist {} not found", artist_id ))) } } /// Récupère les albums d'un artiste pub async fn get_artist_albums(&self, artist_id: &str) -> Result> { self.call_with_auth_repair("get_artist_albums", || { self.api.get_artist_albums(artist_id) }) .await } /// Récupère les artistes similaires pub async fn get_similar_artists(&self, artist_id: &str) -> Result> { self.call_with_auth_repair("get_similar_artists", || { self.api.get_similar_artists(artist_id) }) .await } // ============ Playlists ============ /// Récupère une playlist par son ID pub async fn get_playlist(&self, playlist_id: &str) -> Result { if let Some(playlist) = self.cache.get_playlist(playlist_id).await { debug!("Playlist {} found in cache", playlist_id); return Ok(playlist); } let playlist = self .call_with_auth_repair("get_playlist", || self.api.get_playlist(playlist_id)) .await?; self.cache .put_playlist(playlist_id.to_string(), playlist.clone()) .await; Ok(playlist) } /// Récupère les tracks d'une playlist pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result> { self.call_with_auth_repair("get_playlist_tracks", || { self.api.get_playlist_tracks(playlist_id) }) .await } // ============ Catalogue ============ /// Récupère la liste des genres pub async fn get_genres(&self) -> Result> { self.call_with_auth_repair("get_genres", || self.api.get_genres()) .await } /// Récupère les albums featured (nouveautés, éditeur, etc.) pub async fn get_featured_albums( &self, genre_id: Option<&str>, type_: &str, ) -> Result> { self.call_with_auth_repair("get_featured_albums", || { self.api.get_featured_albums(genre_id, type_) }) .await } /// Récupère les playlists featured pub async fn get_featured_playlists( &self, genre_id: Option<&str>, tags: Option<&str>, ) -> Result> { self.call_with_auth_repair("get_featured_playlists", || { self.api.get_featured_playlists(genre_id, tags) }) .await } /// Récupère les artistes featured pub async fn get_featured_artists( &self, genre_id: Option<&str>, limit: Option, offset: Option, ) -> Result> { self.call_with_auth_repair("get_featured_artists", || { self.api.get_featured_artists(genre_id, limit, offset) }) .await } // ============ Recherche ============ /// Recherche dans le catalogue Qobuz /// /// # Arguments /// /// * `query` - Termes de recherche /// * `type_` - Type de recherche : None (tous), Some("albums"), Some("artists"), Some("tracks"), Some("playlists") pub async fn search(&self, query: &str, type_: Option<&str>) -> Result { // Créer une clé de cache let cache_key = format!("{}:{}", query, type_.unwrap_or("all")); // Vérifier le cache if let Some(result) = self.cache.get_search(&cache_key).await { debug!("Search results for '{}' found in cache", query); return Ok(result); } // Sinon, rechercher via l'API let result = self .call_with_auth_repair("search", || self.api.search(query, type_)) .await?; // Mettre en cache self.cache.put_search(cache_key, result.clone()).await; Ok(result) } /// Recherche des albums pub async fn search_albums(&self, query: &str) -> Result> { let result = self.search(query, Some("albums")).await?; Ok(result.albums) } /// Recherche des artistes pub async fn search_artists(&self, query: &str) -> Result> { let result = self.search(query, Some("artists")).await?; Ok(result.artists) } /// Recherche des tracks pub async fn search_tracks(&self, query: &str) -> Result> { let result = self.search(query, Some("tracks")).await?; Ok(result.tracks) } /// Recherche des playlists pub async fn search_playlists(&self, query: &str) -> Result> { let result = self.search(query, Some("playlists")).await?; Ok(result.playlists) } // ============ Favoris ============ /// Récupère les albums favoris de l'utilisateur pub async fn get_favorite_albums(&self) -> Result> { #[cfg(feature = "disk-cache")] let user_id = self .user_id() .ok_or_else(|| QobuzError::Unauthorized("Missing authenticated user ID".into()))?; #[cfg(feature = "disk-cache")] let ttl = std::time::Duration::from_secs(6 * 3600); #[cfg(feature = "disk-cache")] if let Some(disk) = &self.disk_cache { if let Some(entry) = disk .get_json::>(&user_id, "favorites_albums", "all") .await? { if entry.fresh { return Ok(entry.value); } } } let albums = self .call_with_auth_repair("get_favorite_albums", || self.api.get_favorite_albums()) .await?; #[cfg(feature = "disk-cache")] if let Some(disk) = &self.disk_cache { let _ = disk .put_json(&user_id, "favorites_albums", "all", ttl, &albums) .await; } Ok(albums) } /// Récupère les artistes favoris de l'utilisateur pub async fn get_favorite_artists(&self) -> Result> { self.call_with_auth_repair("get_favorite_artists", || self.api.get_favorite_artists()) .await } /// Récupère les tracks favorites de l'utilisateur pub async fn get_favorite_tracks(&self) -> Result> { #[cfg(feature = "disk-cache")] let user_id = self .user_id() .ok_or_else(|| QobuzError::Unauthorized("Missing authenticated user ID".into()))?; #[cfg(feature = "disk-cache")] let ttl = std::time::Duration::from_secs(6 * 3600); #[cfg(feature = "disk-cache")] if let Some(disk) = &self.disk_cache { if let Some(entry) = disk .get_json::>(&user_id, "favorites_tracks", "all") .await? { if entry.fresh { return Ok(entry.value); } } } let tracks = self .call_with_auth_repair("get_favorite_tracks", || self.api.get_favorite_tracks()) .await?; #[cfg(feature = "disk-cache")] if let Some(disk) = &self.disk_cache { let _ = disk .put_json(&user_id, "favorites_tracks", "all", ttl, &tracks) .await; } Ok(tracks) } /// Récupère les playlists de l'utilisateur pub async fn get_user_playlists(&self) -> Result> { #[cfg(feature = "disk-cache")] let user_id = self .user_id() .ok_or_else(|| QobuzError::Unauthorized("Missing authenticated user ID".into()))?; #[cfg(feature = "disk-cache")] let ttl = std::time::Duration::from_secs(6 * 3600); #[cfg(feature = "disk-cache")] if let Some(disk) = &self.disk_cache { if let Some(entry) = disk .get_json::>(&user_id, "user_playlists", "all") .await? { if entry.fresh { return Ok(entry.value); } } } let playlists = self .call_with_auth_repair("get_user_playlists", || self.api.get_user_playlists()) .await?; #[cfg(feature = "disk-cache")] if let Some(disk) = &self.disk_cache { let _ = disk .put_json(&user_id, "user_playlists", "all", ttl, &playlists) .await; } Ok(playlists) } /// Ajoute un album aux favoris pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> { self.call_with_auth_repair("add_favorite_album", || { self.api.add_favorite_album(album_id) }) .await } /// Supprime un album des favoris pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> { self.call_with_auth_repair("remove_favorite_album", || { self.api.remove_favorite_album(album_id) }) .await } /// Ajoute un track aux favoris pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> { self.call_with_auth_repair("add_favorite_track", || { self.api.add_favorite_track(track_id) }) .await } /// Supprime un track des favoris pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> { self.call_with_auth_repair("remove_favorite_track", || { self.api.remove_favorite_track(track_id) }) .await } /// Ajoute un track à une playlist pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> { self.call_with_auth_repair("add_to_playlist", || { self.api.add_to_playlist(playlist_id, track_id) }) .await } // ============ CMAF (streaming moderne) ============ /// Prépare le streaming CMAF pour un track : dérive les clés et fetche /// le segment d'initialisation. Retourne une `CmafStreamInfo` prête à l'emploi. /// /// Pour télécharger la totalité du FLAC déchiffré d'un coup, utilisez /// plutôt `download_cmaf_full`. Pour streamer segment par segment, utilisez /// les données de `CmafStreamInfo` avec `cmaf::fetch_all_segments`. /// /// # Erreurs /// /// * `QobuzError::NotFound` — track non disponible sur Qobuz /// * `QobuzError::Unauthorized` — session expirée (réparée automatiquement) /// * `QobuzError::Other` — erreur CMAF (clé invalide, segment init corrompu, etc.) pub async fn get_cmaf_stream_info( &self, track_id: &str, ) -> Result { let format_id = self.api.format().id() as u32; let file_url = self .call_with_auth_repair("get_cmaf_file_url", || { self.api.get_cmaf_file_url(track_id, format_id) }) .await?; let bit_depth = file_url.resolved_bit_depth(); let url_template = file_url .url_template .ok_or_else(|| QobuzError::Other("CMAF file/url: url_template absent".into()))?; let key_str = file_url .key .ok_or_else(|| QobuzError::Other("CMAF file/url: key absent".into()))?; let (_session_id, infos) = self .call_with_auth_repair("ensure_cmaf_session", || { self.api.ensure_cmaf_session() }) .await?; let setup = crate::cmaf::setup_streaming( url_template, &key_str, &infos, file_url.n_segments, file_url.format_id.unwrap_or(format_id), file_url.sampling_rate, bit_depth, ) .await?; Ok(crate::models::CmafStreamInfo { url_template: setup.url_template, n_segments: setup.n_segments, content_key: setup.content_key, flac_header: setup.flac_header, segment_table: setup.segment_table, format_id: setup.format_id, sampling_rate: setup.sampling_rate, bit_depth: setup.bit_depth, }) } /// Ouvre un flux FLAC déchiffré en mode progressif pour un track CMAF. /// /// Retourne un `(AsyncRead, taille_estimée)`. Le flux commence à produire /// du FLAC dès que le premier segment est déchiffré — le cache peut commencer /// à servir avant la fin du téléchargement (progressive caching préservé). /// /// C'est la méthode à utiliser pour alimenter `pmocache::add_from_reader`. pub async fn open_cmaf_stream( &self, track_id: &str, ) -> Result<(impl tokio::io::AsyncRead + Send + Unpin + 'static, u64)> { let format_id = self.api.format().id() as u32; let file_url = self .call_with_auth_repair("get_cmaf_file_url", || { self.api.get_cmaf_file_url(track_id, format_id) }) .await?; let bit_depth = file_url.resolved_bit_depth(); let url_template = file_url .url_template .ok_or_else(|| QobuzError::Other("CMAF: url_template absent".into()))?; let key_str = file_url .key .ok_or_else(|| QobuzError::Other("CMAF: key absent".into()))?; let (_session_id, infos) = self .call_with_auth_repair("ensure_cmaf_session", || self.api.ensure_cmaf_session()) .await?; crate::cmaf::open_flac_stream( url_template, key_str, infos, file_url.n_segments, file_url.format_id.unwrap_or(format_id), file_url.sampling_rate, bit_depth, ) .await } /// Télécharge un track complet via CMAF et retourne les bytes FLAC déchiffrés. /// /// Bloquant jusqu'à la fin du téléchargement. Pour les gros fichiers Hi-Res, /// préférer `open_cmaf_stream` qui préserve le progressive caching. pub async fn download_cmaf_full(&self, track_id: &str) -> Result> { let format_id = self.api.format().id() as u32; let file_url = self .call_with_auth_repair("get_cmaf_file_url", || { self.api.get_cmaf_file_url(track_id, format_id) }) .await?; let bit_depth = file_url.resolved_bit_depth(); let url_template = file_url .url_template .ok_or_else(|| QobuzError::Other("CMAF file/url: url_template absent".into()))?; let key_str = file_url .key .ok_or_else(|| QobuzError::Other("CMAF file/url: key absent".into()))?; let (_session_id, infos) = self .call_with_auth_repair("ensure_cmaf_session", || { self.api.ensure_cmaf_session() }) .await?; crate::cmaf::download_full( url_template, &key_str, &infos, file_url.n_segments, file_url.format_id.unwrap_or(format_id), file_url.sampling_rate, bit_depth, None, ) .await } } #[cfg(test)] mod tests { use super::*; use crate::config_ext::QobuzConfigExt; #[test] fn test_audio_format() { assert_eq!(AudioFormat::default(), AudioFormat::Flac_Lossless); } #[tokio::test] async fn from_config_reuses_token_without_login() { let temp_dir = tempfile::tempdir().unwrap(); let config_path = temp_dir.path().to_string_lossy().to_string(); let config = pmoconfig::Config::load_config(&config_path).unwrap(); config.set_qobuz_username("user@example.com").unwrap(); config.set_qobuz_password("password").unwrap(); config.set_qobuz_appid("1401488693436528").unwrap(); // base64 for "secret" config.set_qobuz_secret("c2VjcmV0").unwrap(); config .set_qobuz_auth_info("token123", "user123", Some("Hi-Fi"), 1_700_000_000) .unwrap(); let client = QobuzClient::from_config_obj(&config).await.unwrap(); let auth_info = client.auth_info().expect("auth info"); assert_eq!(auth_info.token, "token123"); assert_eq!(auth_info.user_id, "user123"); } }