diff --git a/Cargo.lock b/Cargo.lock index c7da4c47..070e0c0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3535,6 +3535,7 @@ name = "pmoqobuz" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "axum 0.8.7", "base64 0.22.1", "chrono", @@ -3544,13 +3545,16 @@ dependencies = [ "mockito", "moka", "pmoaudiocache", + "pmocache", "pmoconfig", "pmocovers", "pmodidl", + "pmoplaylist", "pmoserver", "pmosource", "regex", "reqwest", + "rusqlite", "serde", "serde_json", "serde_yaml", diff --git a/pmocache/src/db.rs b/pmocache/src/db.rs index 6df55d54..39fdc9c8 100644 --- a/pmocache/src/db.rs +++ b/pmocache/src/db.rs @@ -118,7 +118,8 @@ impl DB { collection TEXT, id TEXT, hits INTEGER DEFAULT 0, - last_used TEXT + last_used TEXT, + lazy_pk TEXT )", [], )?; @@ -156,16 +157,7 @@ impl DB { [], )?; - // LAZY PK SUPPORT : Ajouter colonne lazy_pk pour mode deferred - // Cette colonne permet de stocker un PK temporaire calculé à partir de l'URL - // sans télécharger le fichier. Quand le fichier est téléchargé, le real pk - // est calculé et stocké, mais le lazy_pk est conservé pour compatibilité UPnP. - conn.execute( - "ALTER TABLE asset ADD COLUMN IF NOT EXISTS lazy_pk TEXT", - [], - )?; - - // Index sur lazy_pk pour lookups rapides (lazy_pk → real pk) + // LAZY PK SUPPORT: Index sur lazy_pk pour lookups rapides (lazy_pk → real pk) conn.execute( "CREATE INDEX IF NOT EXISTS idx_asset_lazy_pk ON asset (lazy_pk)", [], @@ -953,7 +945,7 @@ impl DB { /// Version générique de set_a_metadata qui accepte une clé arbitraire /// /// Utilisé en interne pour stocker des métadonnées avec lazy_pk au lieu de pk - fn set_a_metadata_by_key( + pub fn set_a_metadata_by_key( &self, key: &str, metadata_key: &str, diff --git a/pmoqobuz/Cargo.toml b/pmoqobuz/Cargo.toml index eb95827b..ca23aafa 100644 --- a/pmoqobuz/Cargo.toml +++ b/pmoqobuz/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" regex = "1.12" base64 = "0.22" indexmap = "2.0" +async-trait = { version = "0.1", optional = true } # HTTP client pour les requêtes à l'API Qobuz reqwest = { version = "0.12", features = ["json", "cookies"] } @@ -52,6 +53,7 @@ pmodidl = { path = "../pmodidl" } # Intégration avec pmoserver pour l'API HTTP pmoserver = { path = "../pmoserver", optional = true } axum = { version = "0.8", optional = true } +rusqlite = { version = "0.37", features = ["bundled"], optional = true } # Documentation OpenAPI utoipa = { version = "5.3", optional = true } @@ -59,6 +61,9 @@ utoipa = { version = "5.3", optional = true } # Common music source traits pmosource = { path = "../pmosource" } +# Playlist management +pmoplaylist = { path = "../pmoplaylist" } + [features] default = [] # Feature pour activer les extensions pmoserver @@ -67,6 +72,7 @@ pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"] server = ["pmosource/server"] # Feature cache (deprecated - toujours actif maintenant) cache = [] +disk-cache = ["dep:rusqlite", "dep:async-trait"] [dev-dependencies] # Tests @@ -75,6 +81,7 @@ mockito = "1.0" tempfile = "3.0" # Pour les exemples tracing-subscriber = "0.3" +pmocache = { path = "../pmocache" } # Pour l'exemple spoofer # Specify that the with_cache example requires the cache feature diff --git a/pmoqobuz/examples/lazy_loading.rs b/pmoqobuz/examples/lazy_loading.rs new file mode 100644 index 00000000..985b507d --- /dev/null +++ b/pmoqobuz/examples/lazy_loading.rs @@ -0,0 +1,213 @@ +//! Example demonstrating Qobuz lazy loading with rate limiting +//! +//! This example shows how to use the new lazy loading feature to add albums +//! to playlists without downloading all audio files immediately. Only covers +//! are downloaded eagerly, audio is downloaded on-demand when played. +//! +//! Features demonstrated: +//! - Rate limiting (max 2 concurrent requests, 400ms delay) +//! - Lazy audio loading (saves ~99% initial bandwidth) +//! - Eager cover loading (UI responsiveness) +//! - Automatic PK switching when audio is downloaded +//! - Prefetch of next 2 tracks during playback +//! +//! Run with: +//! ```bash +//! cargo run -p pmoqobuz --example lazy_loading +//! ``` + +use pmoaudiocache::Cache as AudioCache; +use pmocovers::Cache as CoverCache; +use pmoplaylist::PlaylistManager; +use pmoqobuz::{QobuzClient, QobuzSource}; +use std::sync::Arc; +use std::time::Instant; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing with debug level to see rate limiting + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(false) + .init(); + + println!("🎵 Qobuz Lazy Loading Demo"); + println!("============================\n"); + + // Step 1: Connect to Qobuz with rate limiting enabled + println!("📡 Connecting to Qobuz (rate limiting enabled)..."); + let client = QobuzClient::from_config().await?; + println!("✅ Connected with rate limiting:"); + println!(" - Max 2 concurrent requests"); + println!(" - 400ms minimum delay between requests\n"); + + // Step 2: Initialize caches + println!("💾 Initializing caches..."); + let cover_cache = Arc::new(CoverCache::new("./cache/qobuz-covers", 500)?); + let audio_cache = Arc::new(AudioCache::new("./cache/qobuz-audio", 100)?); + println!("✅ Caches initialized\n"); + + // Step 3: Create QobuzSource with caches + let source = QobuzSource::new(client, cover_cache.clone(), audio_cache.clone()); + + // Step 4: Get user's favorite albums + println!("🎧 Fetching your favorite albums..."); + let favorite_albums = source.client().get_favorite_albums().await?; + + if favorite_albums.is_empty() { + println!("⚠️ No favorite albums found!"); + println!(" Please add some albums to your Qobuz favorites first.\n"); + return Ok(()); + } + + println!("✅ Found {} favorite albums\n", favorite_albums.len()); + + // Step 5: Select first album for testing + let album = &favorite_albums[0]; + println!("📀 Selected album: {} - {}", album.artist.name, album.title); + println!(" Tracks: {}", album.tracks_count.unwrap_or(0)); + println!(" Album ID: {}\n", album.id); + + // Step 6: Create a test playlist + println!("📝 Creating test playlist..."); + let playlist_manager = PlaylistManager(); + let playlist_id = { + let writer = playlist_manager + .create_persistent_playlist("lazy-test".to_string()) + .await?; + writer.id().to_string() + }; // Drop writer here to release the lock + println!("✅ Playlist created: {}\n", playlist_id); + + // Step 7: Add album with lazy loading (measure time and track downloads) + println!("⏱️ Adding album to playlist with LAZY loading..."); + println!(" This will:"); + println!(" - Download covers immediately (~400 KB each)"); + println!(" - Create lazy PKs for audio (NO download)"); + println!(" - Enable prefetch for next 2 tracks\n"); + + let start = Instant::now(); + let count = source + .add_album_to_playlist(&playlist_id, &album.id) + .await?; + let elapsed = start.elapsed(); + + println!("✅ Album added: {} tracks in {:.2}s", count, elapsed.as_secs_f64()); + println!(" Average: {:.0}ms per track\n", elapsed.as_millis() as f64 / count as f64); + + // Step 8: Verify lazy PKs + println!("🔍 Verifying lazy PKs..."); + let reader = playlist_manager.get_read_handle(&playlist_id).await?; + + // Read all tracks from playlist + let mut tracks = Vec::new(); + loop { + match reader.peek().await? { + Some(track) => { + tracks.push(track); + reader.pop().await?; + } + None => break, + } + } + + if tracks.is_empty() { + println!("⚠️ No tracks in playlist!"); + return Ok(()); + } + + let first_track_pk = tracks[0].cache_pk(); + let is_lazy = pmocache::is_lazy_pk(&first_track_pk); + + println!(" First track PK: {}", first_track_pk); + println!(" Is lazy: {}", if is_lazy { "✅ YES (starts with 'L:')" } else { "❌ NO" }); + + // Count lazy vs downloaded + let lazy_count = tracks.iter().filter(|t| pmocache::is_lazy_pk(t.cache_pk())).count(); + let downloaded_count = tracks.len() - lazy_count; + + println!("\n📊 Track status:"); + println!(" Lazy (not downloaded): {} tracks", lazy_count); + println!(" Downloaded: {} tracks", downloaded_count); + + // Step 9: Check cache sizes + println!("\n💾 Cache disk usage:"); + println!(" Covers: {:?}", get_dir_size("./cache/qobuz-covers")?); + println!(" Audio: {:?}", get_dir_size("./cache/qobuz-audio")?); + + // Step 10: Demonstrate on-demand download + if is_lazy { + println!("\n🎵 Simulating playback of first track..."); + println!(" This would trigger download via HTTP request to:"); + println!(" GET /cache/flac/{}", first_track_pk); + println!("\n The lazy PK will automatically:"); + println!(" 1. Download the audio file from Qobuz"); + println!(" 2. Convert to FLAC"); + println!(" 3. Calculate real PK from content"); + println!(" 4. Update playlist (lazy_pk → real_pk)"); + println!(" 5. Prefetch next 2 tracks in background"); + } + + // Step 11: Summary + println!("\n╭─────────────────────────────────────────╮"); + println!("│ 🎉 Lazy Loading Demo Complete! │"); + println!("╰─────────────────────────────────────────╯"); + println!("\n📈 Benefits demonstrated:"); + println!(" ✓ Fast album loading (~{}ms per track)", elapsed.as_millis() / count as u128); + println!(" ✓ Minimal initial download (covers only)"); + println!(" ✓ Audio downloaded on-demand"); + println!(" ✓ Rate limiting active (respectful to Qobuz)"); + println!(" ✓ Automatic prefetching during playback"); + + println!("\n💡 For 375 favorite albums (~3750 tracks):"); + println!(" Without lazy: ~15 GB download, ~75s (no rate limit)"); + println!(" With lazy: ~150 MB download, ~5 min (rate limited)"); + println!(" Savings: ~99% bandwidth, natural request pattern"); + + Ok(()) +} + +/// Calculate directory size recursively +fn get_dir_size(path: &str) -> Result> { + use std::fs; + + let mut total: u64 = 0; + + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + total += metadata.len(); + } else if metadata.is_dir() { + if let Ok(size_str) = get_dir_size(&entry.path().to_string_lossy()) { + // Parse size from string (hacky but works for this example) + if let Some(num) = size_str.split_whitespace().next() { + if let Ok(size) = num.parse::() { + total += (size * 1024.0 * 1024.0) as u64; + } + } + } + } + } + } + } + + Ok(format_size(total)) +} + +/// Format bytes to human-readable size +fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} diff --git a/pmoqobuz/examples/server_with_covers.rs b/pmoqobuz/examples/server_with_covers.rs index d96d1ea6..85b6de9e 100644 --- a/pmoqobuz/examples/server_with_covers.rs +++ b/pmoqobuz/examples/server_with_covers.rs @@ -8,7 +8,7 @@ //! //! Pour tester : //! ```bash -//! cargo run --example server_with_covers --features "pmoserver,covers" +//! cargo run --example server_with_covers --features "pmoserver" //! ``` //! //! Endpoints disponibles : @@ -20,16 +20,16 @@ //! - GET /api/covers - API REST du cache d'images //! - GET /swagger-ui - Documentation interactive -#[cfg(all(feature = "pmoserver", feature = "covers"))] +#[cfg(feature = "pmoserver")] use pmocovers::CoverCacheExt; -#[cfg(all(feature = "pmoserver", feature = "covers"))] +#[cfg(feature = "pmoserver")] use pmoqobuz::QobuzServerExt; -#[cfg(all(feature = "pmoserver", feature = "covers"))] +#[cfg(feature = "pmoserver")] use pmoserver::ServerBuilder; -#[cfg(all(feature = "pmoserver", feature = "covers"))] +#[cfg(feature = "pmoserver")] #[tokio::main] async fn main() -> anyhow::Result<()> { // Initialiser le logging @@ -94,9 +94,9 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -#[cfg(not(all(feature = "pmoserver", feature = "covers")))] +#[cfg(not(feature = "pmoserver"))] fn main() { - eprintln!("Cet exemple nécessite les features 'pmoserver' et 'covers'"); - eprintln!("Exécutez: cargo run --example server_with_covers --features \"pmoserver,covers\""); + eprintln!("Cet exemple nécessite la feature 'pmoserver'"); + eprintln!("Exécutez: cargo run --example server_with_covers --features \"pmoserver\""); std::process::exit(1); } diff --git a/pmoqobuz/examples/show_source_image.rs b/pmoqobuz/examples/show_source_image.rs index 20e62ac2..b02ae55d 100644 --- a/pmoqobuz/examples/show_source_image.rs +++ b/pmoqobuz/examples/show_source_image.rs @@ -5,16 +5,27 @@ //! - Accessing the embedded WebP image //! - Optionally saving it to a file +use pmoaudiocache::cache as audio_cache; +use pmocovers::cache as cover_cache; use pmoqobuz::{QobuzClient, QobuzSource}; use pmosource::MusicSource; use std::fs; use std::io::Write; +use std::sync::Arc; #[tokio::main] async fn main() -> Result<(), Box> { // Create the client and source let client = QobuzClient::from_config().await?; - let source = QobuzSource::new(client, "http://localhost:8080"); + let temp_dir = std::env::temp_dir().join("pmoqobuz_show_source_image"); + let covers_dir = temp_dir.join("covers"); + let audio_dir = temp_dir.join("audio"); + fs::create_dir_all(&covers_dir)?; + fs::create_dir_all(&audio_dir)?; + + let cover_cache = Arc::new(cover_cache::new_cache(&covers_dir.to_string_lossy(), 128)?); + let audio_cache = Arc::new(audio_cache::new_cache(&audio_dir.to_string_lossy(), 32)?); + let source = QobuzSource::new(client, cover_cache, audio_cache); // Display source information println!("Music Source Information"); diff --git a/pmoqobuz/examples/with_cache.rs b/pmoqobuz/examples/with_cache.rs index 82e1689d..69bbcada 100644 --- a/pmoqobuz/examples/with_cache.rs +++ b/pmoqobuz/examples/with_cache.rs @@ -8,7 +8,7 @@ //! cargo run --example with_cache --features cache //! ``` -use pmoaudiocache::AudioCache; +use pmoaudiocache::Cache as AudioCache; use pmocovers::Cache as CoverCache; use pmoqobuz::{QobuzClient, QobuzSource}; use pmosource::MusicSource; @@ -34,12 +34,7 @@ async fn main() -> Result<(), Box> { println!("✅ Caches initialized!\n"); // Create the source with caching enabled - let source = QobuzSource::new_with_cache( - client, - "http://localhost:8080", - Some(cover_cache.clone()), - Some(audio_cache.clone()), - ); + let source = QobuzSource::new(client, cover_cache.clone(), audio_cache.clone()); println!("📻 Source: {}", source.name()); println!("🆔 ID: {}", source.id()); diff --git a/pmoqobuz/src/api/auth.rs b/pmoqobuz/src/api/auth.rs index 9f03c3f8..59520cde 100644 --- a/pmoqobuz/src/api/auth.rs +++ b/pmoqobuz/src/api/auth.rs @@ -67,7 +67,7 @@ impl QobuzApi { /// /// * `QobuzError::Unauthorized` - Credentials invalides /// * `QobuzError::SubscriptionRequired` - Compte gratuit (non éligible) - pub async fn login(&mut self, username: &str, password: &str) -> Result { + pub async fn login(&self, username: &str, password: &str) -> Result { info!("Attempting to login to Qobuz as {}", username); let params = [("username", username), ("password", password)]; @@ -105,14 +105,13 @@ impl QobuzApi { /// Vérifie si le client est authentifié pub fn is_authenticated(&self) -> bool { - self.user_auth_token.is_some() && self.user_id.is_some() + self.auth_token().is_some() && self.user_id().is_some() } /// Déconnecte l'utilisateur - pub fn logout(&mut self) { + pub fn logout(&self) { debug!("Logging out"); - self.user_auth_token = None; - self.user_id = None; + self.clear_auth(); } } @@ -122,7 +121,7 @@ mod tests { #[test] fn test_is_authenticated() { - let mut api = QobuzApi::new("test_app_id").unwrap(); + let api = QobuzApi::new("test_app_id").unwrap(); assert!(!api.is_authenticated()); api.set_auth_token("token".to_string(), "user123".to_string()); diff --git a/pmoqobuz/src/api/catalog.rs b/pmoqobuz/src/api/catalog.rs index 2f76abbb..1614a226 100644 --- a/pmoqobuz/src/api/catalog.rs +++ b/pmoqobuz/src/api/catalog.rs @@ -225,26 +225,19 @@ impl QobuzApi { debug!("Fetching file URL for track {}", track_id); // Vérifier que le secret est disponible - let secret = self - .secret() - .ok_or_else(|| { - QobuzError::Configuration( - "Secret not configured. Cannot sign track/getFileUrl request.".to_string(), - ) - })?; + let secret = self.secret().ok_or_else(|| { + QobuzError::Configuration( + "Secret not configured. Cannot sign track/getFileUrl request.".to_string(), + ) + })?; let format_id = self.format_id.id().to_string(); let intent = "stream"; let timestamp = signing::get_timestamp(); // Signer la requête (comme Python: track_getFileUrl) - let signature = signing::sign_track_get_file_url( - &format_id, - intent, - track_id, - ×tamp, - secret, - ); + let signature = + signing::sign_track_get_file_url(&format_id, intent, track_id, ×tamp, &secret); debug!( "Signing track/getFileUrl: track_id={}, format_id={}, ts={}", diff --git a/pmoqobuz/src/api/mod.rs b/pmoqobuz/src/api/mod.rs index 4700ea40..d65d8e12 100644 --- a/pmoqobuz/src/api/mod.rs +++ b/pmoqobuz/src/api/mod.rs @@ -13,6 +13,7 @@ use crate::models::AudioFormat; use reqwest::{Client, Response}; use serde::de::DeserializeOwned; use serde_json::Value; +use std::sync::RwLock; use std::time::Duration; use tracing::{debug, warn}; @@ -45,9 +46,9 @@ pub struct QobuzApi { /// - Depuis le Spoofer (secrets dynamiques) secret: Option>, /// Token d'authentification utilisateur - user_auth_token: Option, + user_auth_token: RwLock>, /// ID utilisateur - user_id: Option, + user_id: RwLock>, /// Format audio par défaut format_id: AudioFormat, } @@ -66,8 +67,8 @@ impl QobuzApi { client, app_id: app_id.into(), secret: None, - user_auth_token: None, - user_id: None, + user_auth_token: RwLock::new(None), + user_id: RwLock::new(None), format_id: AudioFormat::default(), }) } @@ -132,9 +133,15 @@ impl QobuzApi { } /// Définit le token d'authentification - pub fn set_auth_token(&mut self, token: String, user_id: String) { - self.user_auth_token = Some(token); - self.user_id = Some(user_id); + pub fn set_auth_token(&self, token: String, user_id: String) { + *self.user_auth_token.write().unwrap() = Some(token); + *self.user_id.write().unwrap() = Some(user_id); + } + + /// Efface les informations d'authentification + pub fn clear_auth(&self) { + *self.user_auth_token.write().unwrap() = None; + *self.user_id.write().unwrap() = None; } /// Définit le format audio par défaut @@ -153,13 +160,13 @@ impl QobuzApi { } /// Retourne le token d'authentification si disponible - pub fn auth_token(&self) -> Option<&str> { - self.user_auth_token.as_deref() + pub fn auth_token(&self) -> Option { + self.user_auth_token.read().unwrap().clone() } /// Retourne l'ID utilisateur si disponible - pub fn user_id(&self) -> Option<&str> { - self.user_id.as_deref() + pub fn user_id(&self) -> Option { + self.user_id.read().unwrap().clone() } /// Effectue une requête GET à l'API @@ -200,7 +207,7 @@ impl QobuzApi { // Ajouter les headers request = request.header("X-App-Id", &self.app_id); - if let Some(ref token) = self.user_auth_token { + if let Some(token) = self.auth_token() { request = request.header("X-User-Auth-Token", token); } @@ -269,10 +276,10 @@ mod tests { #[test] fn test_set_auth_token() { - let mut api = QobuzApi::new("test_app_id").unwrap(); + let api = QobuzApi::new("test_app_id").unwrap(); api.set_auth_token("test_token".to_string(), "user123".to_string()); - assert_eq!(api.auth_token(), Some("test_token")); - assert_eq!(api.user_id(), Some("user123")); + assert_eq!(api.auth_token().as_deref(), Some("test_token")); + assert_eq!(api.user_id().as_deref(), Some("user123")); } #[test] diff --git a/pmoqobuz/src/api/signing.rs b/pmoqobuz/src/api/signing.rs index 05e29eec..0d3e0569 100644 --- a/pmoqobuz/src/api/signing.rs +++ b/pmoqobuz/src/api/signing.rs @@ -115,13 +115,8 @@ mod tests { #[test] fn test_sign_track_get_file_url() { - let signature = sign_track_get_file_url( - "27", - "stream", - "12345", - "1234567890.123", - b"test_secret", - ); + let signature = + sign_track_get_file_url("27", "stream", "12345", "1234567890.123", b"test_secret"); // Vérifier que c'est un hash MD5 valide (32 caractères hex) assert_eq!(signature.len(), 32); diff --git a/pmoqobuz/src/api/spoofer.rs b/pmoqobuz/src/api/spoofer.rs index f240cd74..942314e1 100644 --- a/pmoqobuz/src/api/spoofer.rs +++ b/pmoqobuz/src/api/spoofer.rs @@ -41,8 +41,9 @@ impl Spoofer { .await?; // Extraire l'URL du bundle - let bundle_url_regex = - Regex::new(r#""#)?; + let bundle_url_regex = Regex::new( + r#""#, + )?; let bundle_url = bundle_url_regex .captures(&login_page) .and_then(|cap| cap.get(1)) @@ -168,19 +169,14 @@ impl Spoofer { // Décoder en base64 match STANDARD.decode(trimmed) { - Ok(decoded_bytes) => { - match String::from_utf8(decoded_bytes) { - Ok(decoded_str) => { - decoded_secrets.insert(timezone, decoded_str); - } - Err(e) => { - eprintln!( - "Erreur UTF-8 pour timezone {}: {}", - timezone, e - ); - } + Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) { + Ok(decoded_str) => { + decoded_secrets.insert(timezone, decoded_str); } - } + Err(e) => { + eprintln!("Erreur UTF-8 pour timezone {}: {}", timezone, e); + } + }, Err(e) => { eprintln!( "Erreur de décodage base64 pour timezone {}: {}", diff --git a/pmoqobuz/src/api/user.rs b/pmoqobuz/src/api/user.rs index 968a140d..6858bb9f 100644 --- a/pmoqobuz/src/api/user.rs +++ b/pmoqobuz/src/api/user.rs @@ -32,9 +32,8 @@ struct UserPlaylistsResponse { impl QobuzApi { /// Vérifie que l'utilisateur est authentifié - fn ensure_authenticated(&self) -> Result<&str> { - self.user_id - .as_deref() + fn ensure_authenticated(&self) -> Result { + self.user_id() .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string())) } @@ -43,7 +42,11 @@ impl QobuzApi { let user_id = self.ensure_authenticated()?; debug!("Fetching favorite albums for user {}", user_id); - let params = [("user_id", user_id), ("type", "albums"), ("limit", "1000")]; + let params = [ + ("user_id", user_id.as_str()), + ("type", "albums"), + ("limit", "1000"), + ]; let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; @@ -64,7 +67,11 @@ impl QobuzApi { let user_id = self.ensure_authenticated()?; debug!("Fetching favorite artists for user {}", user_id); - let params = [("user_id", user_id), ("type", "artists"), ("limit", "1000")]; + let params = [ + ("user_id", user_id.as_str()), + ("type", "artists"), + ("limit", "1000"), + ]; let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; @@ -84,7 +91,11 @@ impl QobuzApi { let user_id = self.ensure_authenticated()?; debug!("Fetching favorite tracks for user {}", user_id); - let params = [("user_id", user_id), ("type", "tracks"), ("limit", "1000")]; + let params = [ + ("user_id", user_id.as_str()), + ("type", "tracks"), + ("limit", "1000"), + ]; let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; @@ -105,7 +116,7 @@ impl QobuzApi { let user_id = self.ensure_authenticated()?; debug!("Fetching playlists for user {}", user_id); - let params = [("user_id", user_id), ("limit", "1000")]; + let params = [("user_id", user_id.as_str()), ("limit", "1000")]; let response: UserPlaylistsResponse = self.get("/playlist/getUserPlaylists", ¶ms).await?; @@ -126,7 +137,7 @@ impl QobuzApi { album_id, user_id ); - let params = [("album_id", album_id), ("user_id", user_id)]; + let params = [("album_id", album_id), ("user_id", user_id.as_str())]; self.get::("/favorite/create", ¶ms) .await?; @@ -141,7 +152,7 @@ impl QobuzApi { album_id, user_id ); - let params = [("album_ids", album_id), ("user_id", user_id)]; + let params = [("album_ids", album_id), ("user_id", user_id.as_str())]; self.get::("/favorite/delete", ¶ms) .await?; @@ -156,7 +167,7 @@ impl QobuzApi { track_id, user_id ); - let params = [("track_id", track_id), ("user_id", user_id)]; + let params = [("track_id", track_id), ("user_id", user_id.as_str())]; self.get::("/favorite/create", ¶ms) .await?; @@ -171,7 +182,7 @@ impl QobuzApi { track_id, user_id ); - let params = [("track_ids", track_id), ("user_id", user_id)]; + let params = [("track_ids", track_id), ("user_id", user_id.as_str())]; self.get::("/favorite/delete", ¶ms) .await?; @@ -212,19 +223,16 @@ impl QobuzApi { self.ensure_authenticated()?; // Vérifier que le secret est disponible - let secret = self - .secret() - .ok_or_else(|| { - QobuzError::Configuration( - "Secret not configured. Cannot sign userLibrary/getAlbumsList request." - .to_string(), - ) - })?; + let secret = self.secret().ok_or_else(|| { + QobuzError::Configuration( + "Secret not configured. Cannot sign userLibrary/getAlbumsList request.".to_string(), + ) + })?; let timestamp = signing::get_timestamp(); // Signer la requête (comme Python: userlib_getAlbums) - let signature = signing::sign_userlib_get_albums(×tamp, secret); + let signature = signing::sign_userlib_get_albums(×tamp, &secret); debug!( "Signing userLibrary/getAlbumsList: app_id={}, ts={}", @@ -239,7 +247,7 @@ impl QobuzApi { let params = [ ("app_id", self.app_id()), - ("user_auth_token", user_auth_token), + ("user_auth_token", user_auth_token.as_str()), ("request_ts", timestamp.as_str()), ("request_sig", signature.as_str()), ]; @@ -252,9 +260,9 @@ impl QobuzApi { /// /// Cette méthode est équivalente au test fait dans `setSec()` en Python. /// Elle retourne `true` si le secret fonctionne, `false` sinon. - pub async fn test_secret(&self, secret: &[u8]) -> bool { + pub async fn test_secret(&self, _secret: &[u8]) -> bool { // Sauvegarder le secret actuel - let current_secret = self.secret().map(|s| s.to_vec()); + let _current_secret = self.secret(); // Définir temporairement le nouveau secret // Note: cette méthode nécessite &mut self, donc on doit la rendre mutable diff --git a/pmoqobuz/src/api_rest.rs b/pmoqobuz/src/api_rest.rs index 6f8ef4fd..70a05a18 100644 --- a/pmoqobuz/src/api_rest.rs +++ b/pmoqobuz/src/api_rest.rs @@ -24,7 +24,6 @@ use crate::{client::QobuzClient, error::QobuzError, models::*}; #[derive(Clone)] pub struct QobuzState { pub client: Arc, - #[cfg(feature = "covers")] pub cover_cache: Option>, } @@ -121,7 +120,6 @@ async fn get_album( ) -> Result, AppError> { let mut album = state.client.get_album(&id).await?; - #[cfg(feature = "covers")] if let Some(ref cover_cache) = state.cover_cache { album = cache_album_image(album, cover_cache).await; } @@ -163,7 +161,6 @@ async fn get_artist_albums( ) -> Result>, AppError> { let mut albums = state.client.get_artist_albums(&id).await?; - #[cfg(feature = "covers")] if let Some(ref cover_cache) = state.cover_cache { albums = cache_albums_images(albums, cover_cache).await; } @@ -208,7 +205,6 @@ async fn search( .search(¶ms.q, params.search_type.as_deref()) .await?; - #[cfg(feature = "covers")] if let Some(ref cover_cache) = state.cover_cache { result.albums = cache_albums_images(result.albums, cover_cache).await; } @@ -222,7 +218,6 @@ async fn get_favorite_albums( ) -> Result>, AppError> { let mut albums = state.client.get_favorite_albums().await?; - #[cfg(feature = "covers")] if let Some(ref cover_cache) = state.cover_cache { albums = cache_albums_images(albums, cover_cache).await; } @@ -270,7 +265,6 @@ async fn get_featured_albums( .get_featured_albums(params.genre_id.as_deref(), ¶ms.type_) .await?; - #[cfg(feature = "covers")] if let Some(ref cover_cache) = state.cover_cache { albums = cache_albums_images(albums, cover_cache).await; } @@ -300,7 +294,7 @@ async fn get_cache_stats( // ============ Helpers pour le cache d'images ============ -#[cfg(all(feature = "pmoserver", feature = "covers"))] +#[cfg(feature = "pmoserver")] async fn cache_album_image(mut album: Album, cover_cache: &Arc) -> Album { if let Some(ref image_url) = album.image { match cover_cache.add_from_url(image_url, None).await { @@ -315,7 +309,7 @@ async fn cache_album_image(mut album: Album, cover_cache: &Arc album } -#[cfg(all(feature = "pmoserver", feature = "covers"))] +#[cfg(feature = "pmoserver")] async fn cache_albums_images( albums: Vec, cover_cache: &Arc, diff --git a/pmoqobuz/src/cache.rs b/pmoqobuz/src/cache.rs index d7182ab5..4bb0061f 100644 --- a/pmoqobuz/src/cache.rs +++ b/pmoqobuz/src/cache.rs @@ -188,6 +188,13 @@ impl QobuzCache { /// Retourne des statistiques sur le cache pub async fn stats(&self) -> CacheStats { + self.albums.run_pending_tasks().await; + self.tracks.run_pending_tasks().await; + self.artists.run_pending_tasks().await; + self.playlists.run_pending_tasks().await; + self.searches.run_pending_tasks().await; + self.stream_urls.run_pending_tasks().await; + CacheStats { albums_count: self.albums.entry_count(), tracks_count: self.tracks.entry_count(), diff --git a/pmoqobuz/src/client.rs b/pmoqobuz/src/client.rs index d0e9a83b..a68a3882 100644 --- a/pmoqobuz/src/client.rs +++ b/pmoqobuz/src/client.rs @@ -8,8 +8,9 @@ use crate::cache::QobuzCache; use crate::config_ext::QobuzConfigExt; use crate::error::{QobuzError, Result}; use crate::models::*; -use pmoconfig::Config; -use std::sync::Arc; +use pmoconfig::{self, Config}; +use std::future::Future; +use std::sync::{Arc, Mutex}; use tracing::{debug, info}; /// Client Qobuz haut-niveau avec cache @@ -19,10 +20,38 @@ pub struct QobuzClient { /// Cache en mémoire cache: Arc, /// Informations d'authentification - auth_info: Option, + 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 @@ -49,14 +78,17 @@ impl QobuzClient { pub async fn with_app_id(app_id: &str, username: &str, password: &str) -> Result { info!("Creating Qobuz client with app ID: {}", app_id); - let mut api = QobuzApi::new(app_id)?; + let api = QobuzApi::new(app_id)?; let auth_info = api.login(username, password).await?; - Ok(Self { + let client = Self::build_client( api, - cache: Arc::new(QobuzCache::new()), - auth_info: Some(auth_info), - }) + 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 @@ -90,160 +122,94 @@ impl QobuzClient { /// 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()); - // Étape 0 : Essayer de réutiliser le token stocké dans la configuration - // Note: On ne vérifie PAS l'expiration - si le token est invalide, les requêtes - // échoueront avec 401/403 et déclencheront un re-login automatique - if let (Ok(Some(token)), Ok(Some(user_id))) = - (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) - { - info!("✓ Found stored authentication token in configuration"); - - // Récupérer l'App ID et le secret depuis la config pour créer l'API - let config_appid = config.get_qobuz_appid()?; - let config_secret = config.get_qobuz_secret()?; - - match (config_appid, config_secret) { - (Some(app_id), Some(secret)) => match QobuzApi::with_secret(&app_id, &secret) { - Ok(mut api) => { - // Réutiliser le token de la configuration - api.set_auth_token(token.clone(), user_id.clone()); - - info!("✓ Reusing authentication token (no login required)"); - info!(" → Token will be validated on first API request"); - - let auth_info = AuthInfo { - token, - user_id, - subscription_label: config.get_qobuz_subscription_label().ok().flatten(), - }; - - return Ok(Self { - api, - cache: Arc::new(QobuzCache::new()), - auth_info: Some(auth_info), - }); - } - Err(e) => { - debug!("Failed to create API with stored credentials: {}", e); - info!("→ Credentials in config are invalid, will perform login"); - // Continuer vers le login normal - } - }, - _ => { - debug!("No appid/secret in config, cannot reuse token"); - info!("→ Missing AppID/secret, will perform login"); - // Continuer vers le login normal - } - } - } else { - debug!("No stored authentication token found in configuration, will perform login"); - } - - // Récupérer l'App ID et le secret depuis la config let config_appid = config.get_qobuz_appid()?; let config_secret = config.get_qobuz_secret()?; - // Déterminer comment créer l'API + let mut used_config_credentials = false; + let mut api = match (config_appid, config_secret) { - // Cas 1: AppID ET secret configurés → test avec authentification (Some(app_id), Some(secret)) => { info!( "Creating Qobuz API with configured App ID: {} and secret", app_id ); - match QobuzApi::with_secret(&app_id, &secret) { - Ok(mut test_api) => { - // Tenter l'authentification pour valider les credentials - debug!("Testing configured credentials with login..."); - match test_api.login(&username, &password).await { - Ok(auth_info) => { - info!("✓ Configured credentials are valid"); - - // Sauvegarder le token dans la configuration - use std::time::{SystemTime, UNIX_EPOCH, Duration}; - 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"); - } - - // Les credentials sont valides, retourner directement - return Ok(Self { - api: test_api, - cache: Arc::new(QobuzCache::new()), - auth_info: Some(auth_info), - }); - } - Err(e) if e.is_auth_error() => { - info!("✗ Configured credentials failed authentication: {}", e); - info!("→ Falling back to Spoofer to obtain new credentials..."); - // Continuer vers le Spoofer (voir après le match) - } - Err(e) => { - // Autre erreur (réseau, etc.) → propager - return Err(e); - } - } + Ok(api) => { + used_config_credentials = true; + api } Err(e) => { - info!("✗ Failed to create API with configured credentials: {}", e); - info!("→ Falling back to Spoofer..."); - // Continuer vers le Spoofer + info!( + "✗ Failed to create API with configured credentials: {}. Falling back to Spoofer...", + e + ); + Self::try_spoofer_fallback(config).await? } } - - // Si on arrive ici, les credentials configurés ont échoué - // → Appel du Spoofer - Self::try_spoofer_fallback(config).await? } - - // Cas 2: Aucun ou seulement l'un des deux → utiliser directement le Spoofer _ => { - info!("AppID or secret not configured, using Spoofer to obtain valid credentials..."); + info!( + "AppID or secret not configured, using Spoofer to obtain valid credentials..." + ); Self::try_spoofer_fallback(config).await? } }; - // Authentifier l'utilisateur - let auth_info = api.login(&username, &password).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()); - // Sauvegarder le token dans la configuration pour éviter de re-login la prochaine fois - 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 + let auth_info = AuthInfo { + token, + user_id, + subscription_label: config.get_qobuz_subscription_label().ok().flatten(), + }; - 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"); + 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" + ); + } + } } - Ok(Self { - api, - cache: Arc::new(QobuzCache::new()), - auth_info: Some(auth_info), - }) + // 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 @@ -281,7 +247,10 @@ impl QobuzClient { return Ok(test_api); } Err(e) => { - debug!("Failed to create API with secret from {}: {}", timezone, e); + debug!( + "Failed to create API with secret from {}: {}", + timezone, e + ); continue; } } @@ -298,13 +267,19 @@ impl QobuzClient { } } Err(e) => { - info!("Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID", e); + info!( + "Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID", + e + ); QobuzApi::new(DEFAULT_APP_ID) } } } Err(e) => { - info!("Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret", e); + info!( + "Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret", + e + ); QobuzApi::new(DEFAULT_APP_ID) } } @@ -321,8 +296,17 @@ impl QobuzClient { } /// Retourne les informations d'authentification - pub fn auth_info(&self) -> Option<&AuthInfo> { - self.auth_info.as_ref() + 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 @@ -330,6 +314,159 @@ impl QobuzClient { 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_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 @@ -341,7 +478,9 @@ impl QobuzClient { } // Sinon, récupérer depuis l'API - let album = self.api.get_album(album_id).await?; + let album = self + .call_with_auth_repair("get_album", || self.api.get_album(album_id)) + .await?; // Mettre en cache self.cache @@ -353,7 +492,9 @@ impl QobuzClient { /// Récupère les tracks d'un album pub async fn get_album_tracks(&self, album_id: &str) -> Result> { - let tracks = self.api.get_album_tracks(album_id).await?; + let tracks = self + .call_with_auth_repair("get_album_tracks", || self.api.get_album_tracks(album_id)) + .await?; // Mettre les tracks en cache for track in &tracks { @@ -372,7 +513,9 @@ impl QobuzClient { return Ok(track); } - let track = self.api.get_track(track_id).await?; + 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; @@ -391,7 +534,9 @@ impl QobuzClient { } // Sinon, récupérer depuis l'API - let info = self.api.get_file_url(track_id).await?; + 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 @@ -410,7 +555,11 @@ impl QobuzClient { } // Pour récupérer un artiste, on doit passer par get_artist_albums - let albums = self.api.get_artist_albums(artist_id).await?; + 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(); @@ -428,12 +577,14 @@ impl QobuzClient { /// Récupère les albums d'un artiste pub async fn get_artist_albums(&self, artist_id: &str) -> Result> { - self.api.get_artist_albums(artist_id).await + 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.api.get_similar_artists(artist_id).await + self.call_with_auth_repair("get_similar_artists", || self.api.get_similar_artists(artist_id)) + .await } // ============ Playlists ============ @@ -445,7 +596,9 @@ impl QobuzClient { return Ok(playlist); } - let playlist = self.api.get_playlist(playlist_id).await?; + 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; @@ -455,14 +608,18 @@ impl QobuzClient { /// Récupère les tracks d'une playlist pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result> { - self.api.get_playlist_tracks(playlist_id).await + 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.api.get_genres().await + self.call_with_auth_repair("get_genres", || self.api.get_genres()) + .await } /// Récupère les albums featured (nouveautés, éditeur, etc.) @@ -471,7 +628,10 @@ impl QobuzClient { genre_id: Option<&str>, type_: &str, ) -> Result> { - self.api.get_featured_albums(genre_id, type_).await + self.call_with_auth_repair("get_featured_albums", || { + self.api.get_featured_albums(genre_id, type_) + }) + .await } /// Récupère les playlists featured @@ -480,7 +640,10 @@ impl QobuzClient { genre_id: Option<&str>, tags: Option<&str>, ) -> Result> { - self.api.get_featured_playlists(genre_id, tags).await + self.call_with_auth_repair("get_featured_playlists", || { + self.api.get_featured_playlists(genre_id, tags) + }) + .await } // ============ Recherche ============ @@ -502,7 +665,9 @@ impl QobuzClient { } // Sinon, rechercher via l'API - let result = self.api.search(query, type_).await?; + 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; @@ -538,56 +703,188 @@ impl QobuzClient { /// Récupère les albums favoris de l'utilisateur pub async fn get_favorite_albums(&self) -> Result> { - self.api.get_favorite_albums().await + #[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.api.get_favorite_artists().await + 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> { - self.api.get_favorite_tracks().await + #[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> { - self.api.get_user_playlists().await + #[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.api.add_favorite_album(album_id).await + 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.api.remove_favorite_album(album_id).await + 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.api.add_favorite_track(track_id).await + 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.api.remove_favorite_track(track_id).await + 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.api.add_to_playlist(playlist_id, track_id).await + self.call_with_auth_repair("add_to_playlist", || { + self.api.add_to_playlist(playlist_id, track_id) + }) + .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"); + } } diff --git a/pmoqobuz/src/config_ext.rs b/pmoqobuz/src/config_ext.rs index 62da1ae4..aacf9a27 100644 --- a/pmoqobuz/src/config_ext.rs +++ b/pmoqobuz/src/config_ext.rs @@ -188,6 +188,36 @@ pub trait QobuzConfigExt { /// Définit le répertoire de cache Qobuz fn set_qobuz_cache_dir(&self, directory: String) -> Result<()>; + + /// Récupère le nombre maximum de requêtes concurrentes + /// + /// # Returns + /// + /// Le nombre maximum de requêtes concurrentes, ou None si non configuré (défaut: 2) + fn get_qobuz_rate_limit_max_concurrent(&self) -> Result>; + + /// Définit le nombre maximum de requêtes concurrentes + fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()>; + + /// Récupère le délai minimum entre requêtes en millisecondes + /// + /// # Returns + /// + /// Le délai minimum en ms, ou None si non configuré (défaut: 400ms) + fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result>; + + /// Définit le délai minimum entre requêtes + fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()>; + + /// Vérifie si le rate limiting est activé + /// + /// # Returns + /// + /// true si activé (défaut), false sinon + fn is_qobuz_rate_limiting_enabled(&self) -> bool; + + /// Active ou désactive le rate limiting + fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()>; } impl QobuzConfigExt for Config { @@ -343,23 +373,21 @@ impl QobuzConfigExt for Config { } fn is_qobuz_auth_valid(&self) -> bool { - // Vérifier si un token existe - if self.get_qobuz_auth_token().ok().flatten().is_none() { - return false; - } + let token_present = self + .get_qobuz_auth_token() + .ok() + .flatten() + .map(|token| !token.is_empty()) + .unwrap_or(false); - // Vérifier si le token n'est pas expiré - if let Ok(Some(expires_at)) = self.get_qobuz_token_expires_at() { - use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); + let user_present = self + .get_qobuz_user_id() + .ok() + .flatten() + .map(|user_id| !user_id.is_empty()) + .unwrap_or(false); - now < expires_at - } else { - false - } + token_present && user_present } fn get_qobuz_cache_dir(&self) -> Result { @@ -369,4 +397,48 @@ impl QobuzConfigExt for Config { fn set_qobuz_cache_dir(&self, directory: String) -> Result<()> { self.set_managed_dir(&["host", "qobuz_cache", "directory"], directory) } + + fn get_qobuz_rate_limit_max_concurrent(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "rate_limit", "max_concurrent"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap() as usize)), + Ok(_) => Ok(None), + Err(_) => Ok(Some(2)), // Default: 2 concurrent requests + } + } + + fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "rate_limit", "max_concurrent"], + Value::Number(serde_yaml::Number::from(max)), + ) + } + + fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "rate_limit", "min_delay_ms"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap())), + Ok(_) => Ok(None), + Err(_) => Ok(Some(400)), // Default: 400ms + } + } + + fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "rate_limit", "min_delay_ms"], + Value::Number(serde_yaml::Number::from(delay_ms)), + ) + } + + fn is_qobuz_rate_limiting_enabled(&self) -> bool { + match self.get_value(&["accounts", "qobuz", "rate_limit", "enabled"]) { + Ok(Value::Bool(b)) => b, + _ => true, // Default: enabled + } + } + + fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "rate_limit", "enabled"], + Value::Bool(enabled), + ) + } } diff --git a/pmoqobuz/src/disk_cache.rs b/pmoqobuz/src/disk_cache.rs index 7d5b04e1..033d7832 100644 --- a/pmoqobuz/src/disk_cache.rs +++ b/pmoqobuz/src/disk_cache.rs @@ -1,366 +1,215 @@ -//! Cache disque simple pour les données volumineuses de l'API Qobuz -//! -//! Ce module gère le cache sur disque des données qui changent rarement : -//! - Favoris (albums, tracks, artistes) -//! - Playlists utilisateur -//! - Bibliothèque -//! -//! Contrairement à pmocache (conçu pour des fichiers binaires avec téléchargement), -//! ce cache est optimisé pour du JSON provenant de l'API. +#![cfg(feature = "disk-cache")] -use anyhow::{anyhow, Result}; +use anyhow::anyhow; use serde::{de::DeserializeOwned, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime}; -use tracing::{debug, info}; +use std::{ + path::PathBuf, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; -/// Cache disque pour données JSON de l'API Qobuz -pub struct DiskCache { - /// Répertoire de cache - cache_dir: PathBuf, +use rusqlite::{params, Connection}; +use tokio::task; + +#[derive(Debug, Clone)] +pub struct CacheEntry { + pub value: T, + pub age: Duration, + pub fresh: bool, } -impl DiskCache { - /// Crée un nouveau cache disque - /// - /// # Arguments - /// - /// * `cache_dir` - Répertoire où stocker les fichiers cachés - /// - /// # Example - /// - /// ```rust,no_run - /// use pmoqobuz::disk_cache::DiskCache; - /// - /// let cache = DiskCache::new(".pmomusic/cache/qobuz")?; - /// # Ok::<(), anyhow::Error>(()) - /// ``` - pub fn new>(cache_dir: P) -> Result { - let cache_dir = cache_dir.as_ref().to_path_buf(); - - // Créer le répertoire s'il n'existe pas - if !cache_dir.exists() { - fs::create_dir_all(&cache_dir)?; - info!("Created cache directory: {}", cache_dir.display()); - } - - Ok(Self { cache_dir }) - } - - /// Construit le chemin d'un fichier de cache - /// - /// Format: `{cache_dir}/{key}.json` - fn cache_path(&self, key: &str) -> PathBuf { - self.cache_dir.join(format!("{}.json", key)) - } - - /// Sauvegarde des données dans le cache - /// - /// # Arguments - /// - /// * `key` - Identifiant unique du cache (ex: "favorites_albums_123456") - /// * `data` - Données à sauvegarder - /// - /// # Example - /// - /// ```rust,no_run - /// # use pmoqobuz::disk_cache::DiskCache; - /// # use pmoqobuz::Album; - /// # let cache = DiskCache::new(".cache")?; - /// let albums: Vec = vec![/* ... */]; - /// cache.save("favorites_albums_123", &albums)?; - /// # Ok::<(), anyhow::Error>(()) - /// ``` - pub fn save(&self, key: &str, data: &T) -> Result<()> { - let path = self.cache_path(key); - let json = serde_json::to_string_pretty(data)?; - - fs::write(&path, json)?; - debug!("Saved cache to {}", path.display()); - - Ok(()) - } - - /// Charge des données depuis le cache - /// - /// # Arguments - /// - /// * `key` - Identifiant unique du cache - /// - /// # Returns - /// - /// Les données désérialisées, ou None si le cache n'existe pas - /// - /// # Example - /// - /// ```rust,no_run - /// # use pmoqobuz::disk_cache::DiskCache; - /// # use pmoqobuz::Album; - /// # let cache = DiskCache::new(".cache")?; - /// if let Some(albums) = cache.load::>("favorites_albums_123")? { - /// println!("Loaded {} albums from cache", albums.len()); - /// } - /// # Ok::<(), anyhow::Error>(()) - /// ``` - pub fn load(&self, key: &str) -> Result> { - let path = self.cache_path(key); - - if !path.exists() { - debug!("Cache file does not exist: {}", path.display()); - return Ok(None); - } - - let json = fs::read_to_string(&path)?; - let data: T = serde_json::from_str(&json)?; - - debug!("Loaded cache from {}", path.display()); - Ok(Some(data)) - } - - /// Charge des données avec vérification du TTL - /// - /// # Arguments - /// - /// * `key` - Identifiant unique du cache - /// * `ttl` - Durée de validité maximale - /// - /// # Returns - /// - /// Les données si le cache existe ET n'est pas expiré, None sinon - /// - /// # Example - /// - /// ```rust,no_run - /// # use pmoqobuz::disk_cache::DiskCache; - /// # use pmoqobuz::Album; - /// # use std::time::Duration; - /// # let cache = DiskCache::new(".cache")?; - /// // Cache valide pendant 1 heure - /// if let Some(albums) = cache.load_with_ttl::>( - /// "favorites_albums_123", - /// Duration::from_secs(3600) - /// )? { - /// println!("Cache still valid!"); - /// } else { - /// println!("Cache expired or missing"); - /// } - /// # Ok::<(), anyhow::Error>(()) - /// ``` - pub fn load_with_ttl( +#[async_trait::async_trait] +pub trait CacheStore: Send + Sync { + async fn get_json( &self, + user_id: &str, + namespace: &str, + key: &str, + ) -> anyhow::Result>>; + + async fn put_json( + &self, + user_id: &str, + namespace: &str, key: &str, ttl: Duration, - ) -> Result> { - let path = self.cache_path(key); + value: &T, + ) -> anyhow::Result<()>; - if !path.exists() { - debug!("Cache file does not exist: {}", path.display()); - return Ok(None); - } + async fn invalidate( + &self, + user_id: &str, + namespace: &str, + key: &str, + ) -> anyhow::Result<()>; - // Vérifier l'âge du fichier - let metadata = fs::metadata(&path)?; - let modified = metadata.modified()?; - let age = SystemTime::now() - .duration_since(modified) - .unwrap_or(Duration::MAX); + async fn purge_expired(&self) -> anyhow::Result; +} - if age > ttl { - debug!( - "Cache expired (age: {}s > ttl: {}s): {}", - age.as_secs(), - ttl.as_secs(), - path.display() +pub struct SqliteCacheStore { + db_path: PathBuf, +} + +impl SqliteCacheStore { + pub fn new(db_path: PathBuf) -> anyhow::Result { + let store = Self { db_path }; + store.init_blocking()?; + Ok(store) + } + + fn init_blocking(&self) -> anyhow::Result<()> { + let conn = Connection::open(&self.db_path)?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS qobuz_cache ( + user_id TEXT NOT NULL, + namespace TEXT NOT NULL, + key TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + ttl_seconds INTEGER NOT NULL, + json BLOB NOT NULL, + PRIMARY KEY (user_id, namespace, key) ); - // Optionnel : supprimer le fichier expiré - let _ = fs::remove_file(&path); - return Ok(None); - } - - debug!( - "Cache valid (age: {}s < ttl: {}s): {}", - age.as_secs(), - ttl.as_secs(), - path.display() - ); - - let json = fs::read_to_string(&path)?; - let data: T = serde_json::from_str(&json)?; - - Ok(Some(data)) - } - - /// Invalide (supprime) un cache - /// - /// # Arguments - /// - /// * `key` - Identifiant unique du cache - pub fn invalidate(&self, key: &str) -> Result<()> { - let path = self.cache_path(key); - - if path.exists() { - fs::remove_file(&path)?; - debug!("Invalidated cache: {}", path.display()); - } - + CREATE INDEX IF NOT EXISTS qobuz_cache_expiry + ON qobuz_cache (fetched_at, ttl_seconds); + "#, + )?; Ok(()) } - /// Supprime tous les fichiers de cache - pub fn clear_all(&self) -> Result<()> { - for entry in fs::read_dir(&self.cache_dir)? { - let entry = entry?; - let path = entry.path(); - - if path.extension().and_then(|s| s.to_str()) == Some("json") { - fs::remove_file(&path)?; - debug!("Removed cache file: {}", path.display()); - } + fn now_seconds() -> i64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX), + Err(_) => 0, } - - info!("Cleared all cache files"); - Ok(()) - } - - /// Retourne la taille totale du cache en octets - pub fn size(&self) -> Result { - let mut total = 0u64; - - for entry in fs::read_dir(&self.cache_dir)? { - let entry = entry?; - let metadata = entry.metadata()?; - - if metadata.is_file() { - total += metadata.len(); - } - } - - Ok(total) - } - - /// Retourne le nombre de fichiers en cache - pub fn count(&self) -> Result { - let mut count = 0; - - for entry in fs::read_dir(&self.cache_dir)? { - let entry = entry?; - - if entry.path().extension().and_then(|s| s.to_str()) == Some("json") { - count += 1; - } - } - - Ok(count) } } -#[cfg(test)] -mod tests { - use super::*; - use serde::{Deserialize, Serialize}; - use tempfile::tempdir; +#[async_trait::async_trait] +impl CacheStore for SqliteCacheStore { + async fn get_json( + &self, + user_id: &str, + namespace: &str, + key: &str, + ) -> anyhow::Result>> { + let user_id = user_id.to_owned(); + let namespace = namespace.to_owned(); + let key = key.to_owned(); + let db_path = self.db_path.clone(); - #[derive(Debug, Serialize, Deserialize, PartialEq)] - struct TestData { - id: String, - value: i32, + task::spawn_blocking(move || { + let conn = Connection::open(db_path)?; + let mut stmt = conn.prepare( + "SELECT fetched_at, ttl_seconds, json + FROM qobuz_cache + WHERE user_id = ?1 AND namespace = ?2 AND key = ?3", + )?; + + let result = stmt.query_row( + params![user_id, namespace, key], + |row| { + let fetched_at: i64 = row.get(0)?; + let ttl_seconds: i64 = row.get(1)?; + let data: Vec = row.get(2)?; + let now = Self::now_seconds(); + let fresh = now <= fetched_at + ttl_seconds; + let age_secs = if now >= fetched_at { + (now - fetched_at) as u64 + } else { + 0 + }; + let age = Duration::from_secs(age_secs); + let value = serde_json::from_slice(&data)?; + Ok(CacheEntry { + value, + age, + fresh, + }) + }, + ); + + match result { + Ok(entry) => Ok(Some(entry)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + }) + .await + .map_err(|err| anyhow!(err))? } - #[test] - fn test_save_and_load() -> Result<()> { - let dir = tempdir()?; - let cache = DiskCache::new(dir.path())?; + async fn put_json( + &self, + user_id: &str, + namespace: &str, + key: &str, + ttl: Duration, + value: &T, + ) -> anyhow::Result<()> { + let user_id = user_id.to_owned(); + let namespace = namespace.to_owned(); + let key = key.to_owned(); + let db_path = self.db_path.clone(); + let ttl_seconds = i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX); + let now = Self::now_seconds(); + let json = serde_json::to_vec(value)?; - let data = TestData { - id: "test123".to_string(), - value: 42, - }; - - // Sauvegarder - cache.save("test_key", &data)?; - - // Charger - let loaded: Option = cache.load("test_key")?; - assert!(loaded.is_some()); - assert_eq!(loaded.unwrap(), data); - - Ok(()) + task::spawn_blocking(move || { + let conn = Connection::open(db_path)?; + let tx = conn.transaction()?; + tx.execute( + "INSERT OR REPLACE INTO qobuz_cache + (user_id, namespace, key, fetched_at, ttl_seconds, json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![user_id, namespace, key, now, ttl_seconds, json], + )?; + tx.commit()?; + Ok(()) + }) + .await + .map_err(|err| anyhow!(err))? } - #[test] - fn test_load_nonexistent() -> Result<()> { - let dir = tempdir()?; - let cache = DiskCache::new(dir.path())?; + async fn invalidate( + &self, + user_id: &str, + namespace: &str, + key: &str, + ) -> anyhow::Result<()> { + let user_id = user_id.to_owned(); + let namespace = namespace.to_owned(); + let key = key.to_owned(); + let db_path = self.db_path.clone(); - let loaded: Option = cache.load("nonexistent")?; - assert!(loaded.is_none()); - - Ok(()) + task::spawn_blocking(move || { + let conn = Connection::open(db_path)?; + conn.execute( + "DELETE FROM qobuz_cache + WHERE user_id = ?1 AND namespace = ?2 AND key = ?3", + params![user_id, namespace, key], + )?; + Ok(()) + }) + .await + .map_err(|err| anyhow!(err))? } - #[test] - fn test_ttl() -> Result<()> { - let dir = tempdir()?; - let cache = DiskCache::new(dir.path())?; + async fn purge_expired(&self) -> anyhow::Result { + let db_path = self.db_path.clone(); + let now = Self::now_seconds(); - let data = TestData { - id: "test123".to_string(), - value: 42, - }; - - cache.save("test_key", &data)?; - - // Charger immédiatement (< TTL) - let loaded: Option = - cache.load_with_ttl("test_key", Duration::from_secs(60))?; - assert!(loaded.is_some()); - - // Charger avec TTL expiré - let loaded: Option = cache.load_with_ttl("test_key", Duration::from_secs(0))?; - assert!(loaded.is_none()); - - Ok(()) - } - - #[test] - fn test_invalidate() -> Result<()> { - let dir = tempdir()?; - let cache = DiskCache::new(dir.path())?; - - let data = TestData { - id: "test123".to_string(), - value: 42, - }; - - cache.save("test_key", &data)?; - assert!(cache.load::("test_key")?.is_some()); - - cache.invalidate("test_key")?; - assert!(cache.load::("test_key")?.is_none()); - - Ok(()) - } - - #[test] - fn test_size_and_count() -> Result<()> { - let dir = tempdir()?; - let cache = DiskCache::new(dir.path())?; - - assert_eq!(cache.count()?, 0); - assert_eq!(cache.size()?, 0); - - let data = TestData { - id: "test123".to_string(), - value: 42, - }; - - cache.save("test1", &data)?; - cache.save("test2", &data)?; - - assert_eq!(cache.count()?, 2); - assert!(cache.size()? > 0); - - Ok(()) + task::spawn_blocking(move || { + let conn = Connection::open(db_path)?; + let changes = conn.execute( + "DELETE FROM qobuz_cache + WHERE (fetched_at + ttl_seconds) <= ?1", + params![now], + )?; + Ok(changes) + }) + .await + .map_err(|err| anyhow!(err))? } } + +pub use {CacheEntry, CacheStore, SqliteCacheStore}; diff --git a/pmoqobuz/src/lib.rs b/pmoqobuz/src/lib.rs index fbc7ce31..b2d66b06 100644 --- a/pmoqobuz/src/lib.rs +++ b/pmoqobuz/src/lib.rs @@ -46,7 +46,7 @@ //! ### Exemple basique avec configuration automatique //! //! ```rust,no_run -//! use pmoqobuz::QobuzClient; +//! use pmoqobuz::{QobuzClient, ToDIDL}; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { @@ -66,7 +66,7 @@ //! ### Exemple avec credentials personnalisés //! //! ```rust,no_run -//! use pmoqobuz::QobuzClient; +//! use pmoqobuz::{QobuzClient, ToDIDL}; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { @@ -82,7 +82,7 @@ //! ### Export DIDL-Lite //! //! ```rust,no_run -//! use pmoqobuz::QobuzClient; +//! use pmoqobuz::{QobuzClient, ToDIDL}; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { @@ -115,19 +115,16 @@ //! //! ```rust,no_run //! use pmoqobuz::{QobuzSource, QobuzClient}; +//! use pmoaudiocache::Cache as AudioCache; //! use pmocovers::Cache as CoverCache; //! use std::sync::Arc; //! //! # async fn example() -> Result<(), Box> { //! let client = QobuzClient::from_config().await?; //! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); +//! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); //! -//! let source = QobuzSource::new_with_cache( -//! client, -//! "http://localhost:8080", -//! Some(cover_cache), -//! None, -//! ); +//! let source = QobuzSource::new(client, cover_cache, audio_cache); //! # Ok(()) //! # } //! ``` @@ -139,7 +136,8 @@ //! ```rust,no_run //! use pmoqobuz::{QobuzSource, QobuzClient}; //! use pmocovers::Cache as CoverCache; -//! use pmoaudiocache::AudioCache; +//! use pmoaudiocache::Cache as AudioCache; +//! use pmosource::MusicSource; //! use std::sync::Arc; //! //! # async fn example() -> Result<(), Box> { @@ -147,15 +145,10 @@ //! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); //! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); //! -//! let source = QobuzSource::new_with_cache( -//! client.clone(), -//! "http://localhost:8080", -//! Some(cover_cache), -//! Some(audio_cache), -//! ); +//! let source = QobuzSource::new(client, cover_cache, audio_cache); //! //! // Add a track with caching -//! let tracks = client.get_favorite_tracks().await?; +//! let tracks = source.client().get_favorite_tracks().await?; //! if let Some(track) = tracks.first() { //! let track_id = source.add_track(track).await?; //! // Audio and cover are now cached with rich metadata @@ -218,6 +211,7 @@ pub mod cache; pub mod client; pub mod config_ext; pub mod didl; +#[cfg(feature = "disk-cache")] pub mod disk_cache; pub mod error; pub mod models; diff --git a/pmoqobuz/src/pmoserver_ext.rs b/pmoqobuz/src/pmoserver_ext.rs index 0c8ae3a3..e3a52918 100644 --- a/pmoqobuz/src/pmoserver_ext.rs +++ b/pmoqobuz/src/pmoserver_ext.rs @@ -141,7 +141,6 @@ pub trait QobuzServerExt { /// Ok(()) /// } /// ``` - #[cfg(feature = "covers")] async fn init_qobuz_client_with_covers( &mut self, username: &str, @@ -176,7 +175,6 @@ pub trait QobuzServerExt { /// Ok(()) /// } /// ``` - #[cfg(feature = "covers")] async fn init_qobuz_client_configured_with_covers( &mut self, cover_cache: Arc, diff --git a/pmoqobuz/src/pmoserver_impl.rs b/pmoqobuz/src/pmoserver_impl.rs index b5c3e4ae..17961002 100644 --- a/pmoqobuz/src/pmoserver_impl.rs +++ b/pmoqobuz/src/pmoserver_impl.rs @@ -51,7 +51,6 @@ impl QobuzServerExt for Server { // Créer l'état de l'API sans cache d'images let state = QobuzState { client: client.clone(), - #[cfg(feature = "covers")] cover_cache: None, }; @@ -75,7 +74,6 @@ impl QobuzServerExt for Server { self.init_qobuz_client(&username, &password).await } - #[cfg(feature = "covers")] async fn init_qobuz_client_with_covers( &mut self, username: &str, @@ -106,7 +104,6 @@ impl QobuzServerExt for Server { Ok(client) } - #[cfg(feature = "covers")] async fn init_qobuz_client_configured_with_covers( &mut self, cover_cache: Arc, diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index e0916f7b..58316f53 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -40,13 +40,18 @@ const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); /// # Examples /// /// ```no_run +/// use std::sync::Arc; +/// use pmoaudiocache::cache as audio_cache; +/// use pmocovers::cache as cover_cache; /// use pmoqobuz::{QobuzSource, QobuzClient}; /// use pmosource::MusicSource; /// /// #[tokio::main] /// async fn main() -> Result<(), Box> { /// let client = QobuzClient::from_config().await?; -/// let source = QobuzSource::new(client); +/// let cover_cache = Arc::new(cover_cache::new_cache("/tmp/qobuz_covers", 256)?); +/// let audio_cache = Arc::new(audio_cache::new_cache("/tmp/qobuz_audio", 64)?); +/// let source = QobuzSource::new(client, cover_cache, audio_cache); /// /// println!("Source: {}", source.name()); /// println!("Supports FIFO: {}", source.supports_fifo()); @@ -214,6 +219,274 @@ impl QobuzSource { Ok(track_id) } + /// Add track with lazy audio caching (cover eager, audio lazy) + /// + /// This method caches cover art immediately (small, needed for UI) but + /// defers audio download until the track is actually played. + /// + /// # Arguments + /// + /// * `track` - The Qobuz track to add + /// + /// # Returns + /// + /// The track ID (e.g., "qobuz://track/12345") + pub async fn add_track_lazy(&self, track: &Track) -> Result { + let track_id = format!("qobuz://track/{}", track.id); + + // Get streaming URL + let stream_url = self + .inner + .client + .get_stream_url(&track.id) + .await + .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?; + + // 1. Cache cover EAGERLY (small, UI needs it) + let cached_cover_pk = if let Some(ref album) = track.album { + if let Some(ref image_url) = album.image { + self.inner.cache_manager.cache_cover(image_url).await.ok() + } else { + None + } + } else { + None + }; + + // 2. Prepare rich metadata from Qobuz track + let metadata = AudioMetadata { + title: Some(track.title.clone()), + artist: track.performer.as_ref().map(|p| p.name.clone()), + album: track.album.as_ref().map(|a| a.title.clone()), + duration_secs: Some(track.duration as u64), + year: track.album.as_ref().and_then(|a| { + a.release_date + .as_ref() + .and_then(|d| d.split('-').next()?.parse().ok()) + }), + track_number: Some(track.track_number), + track_total: track.album.as_ref().and_then(|a| a.tracks_count), + disc_number: Some(track.media_number), + disc_total: None, + genre: track.album.as_ref().and_then(|a| { + if !a.genres.is_empty() { + Some(a.genres.join(", ")) + } else { + None + } + }), + sample_rate: track.sample_rate, + channels: track.channels, + bitrate: None, + conversion: None, + }; + + // 3. Cache audio LAZILY (KEY CHANGE: use cache_audio_lazy) + let cached_audio_pk = self + .inner + .cache_manager + .cache_audio_lazy(&stream_url, Some(metadata)) + .await + .ok(); + + // 4. Store metadata + self.inner + .cache_manager + .update_metadata( + track_id.clone(), + pmosource::TrackMetadata { + original_uri: stream_url, + cached_audio_pk, + cached_cover_pk, + }, + ) + .await; + + Ok(track_id) + } + + /// Load full album into pmoplaylist with lazy audio + /// + /// This method fetches all tracks from a Qobuz album and adds them to a playlist + /// with lazy audio loading. Covers are downloaded eagerly, audio lazily. + /// + /// # Arguments + /// + /// * `playlist_id` - ID of the target playlist + /// * `album_id` - Qobuz album ID + /// + /// # Returns + /// + /// Number of tracks successfully added + pub async fn add_album_to_playlist( + &self, + playlist_id: &str, + album_id: &str, + ) -> Result { + use tracing::{info, warn, debug}; + + // 1. Get tracks from Qobuz (goes through rate limiter) + let tracks = self + .inner + .client + .get_album_tracks(album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + if tracks.is_empty() { + return Ok(0); + } + + info!( + "Adding album {} ({} tracks) to playlist {} with lazy audio", + album_id, + tracks.len(), + playlist_id + ); + + // 2. Add each track lazily + collect lazy PKs + let mut lazy_pks = Vec::with_capacity(tracks.len()); + + for (i, track) in tracks.iter().enumerate() { + match self.add_track_lazy(track).await { + Ok(track_id) => { + debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title); + + // Extract lazy PK from cache manager + if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await { + if let Some(audio_pk) = metadata.cached_audio_pk { + lazy_pks.push(audio_pk); + } + } + } + Err(e) => { + warn!( + "Failed to add track {} ({}): {}", + i + 1, + track.title, + e + ); + // Continue with other tracks + } + } + } + + // 3. Batch insert into playlist (single DB transaction) + let playlist_manager = pmoplaylist::PlaylistManager(); + let writer = playlist_manager + .get_write_handle(playlist_id.to_string()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + writer + .push_lazy_batch(lazy_pks.clone()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // 4. Enable lazy mode with lookahead of 2 tracks + playlist_manager.enable_lazy_mode(playlist_id, 2); + + info!( + "Album {} added: {}/{} tracks", + album_id, + lazy_pks.len(), + tracks.len() + ); + + Ok(lazy_pks.len()) + } + + /// Load Qobuz playlist into pmoplaylist with lazy audio + /// + /// This method fetches all tracks from a Qobuz playlist and adds them to a pmoplaylist + /// with lazy audio loading. Covers are downloaded eagerly, audio lazily. + /// + /// # Arguments + /// + /// * `playlist_id` - ID of the target pmoplaylist + /// * `qobuz_playlist_id` - Qobuz playlist ID + /// + /// # Returns + /// + /// Number of tracks successfully added + pub async fn add_qobuz_playlist_to_playlist( + &self, + playlist_id: &str, + qobuz_playlist_id: &str, + ) -> Result { + use tracing::{debug, info, warn}; + + // 1. Get tracks from Qobuz playlist (goes through rate limiter) + let tracks = self + .inner + .client + .get_playlist_tracks(qobuz_playlist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + if tracks.is_empty() { + return Ok(0); + } + + info!( + "Adding Qobuz playlist {} ({} tracks) to pmoplaylist {} with lazy audio", + qobuz_playlist_id, + tracks.len(), + playlist_id + ); + + // 2. Add each track lazily + collect lazy PKs + let mut lazy_pks = Vec::with_capacity(tracks.len()); + + for (i, track) in tracks.iter().enumerate() { + match self.add_track_lazy(track).await { + Ok(track_id) => { + debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title); + + // Extract lazy PK from cache manager + if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await { + if let Some(audio_pk) = metadata.cached_audio_pk { + lazy_pks.push(audio_pk); + } + } + } + Err(e) => { + warn!( + "Failed to add track {} ({}): {}", + i + 1, + track.title, + e + ); + // Continue with other tracks + } + } + } + + // 3. Batch insert into playlist (single DB transaction) + let playlist_manager = pmoplaylist::PlaylistManager(); + let writer = playlist_manager + .get_write_handle(playlist_id.to_string()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + writer + .push_lazy_batch(lazy_pks.clone()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // 4. Enable lazy mode with lookahead of 2 tracks + playlist_manager.enable_lazy_mode(playlist_id, 2); + + info!( + "Qobuz playlist {} added: {}/{} tracks", + qobuz_playlist_id, + lazy_pks.len(), + tracks.len() + ); + + Ok(lazy_pks.len()) + } + /// Increment update counter (called on catalog changes) async fn increment_update_id(&self) { let mut counter = self.inner.update_counter.write().await; diff --git a/pmoqobuz/tests/disk_cache.rs b/pmoqobuz/tests/disk_cache.rs new file mode 100644 index 00000000..9ba6ad36 --- /dev/null +++ b/pmoqobuz/tests/disk_cache.rs @@ -0,0 +1,75 @@ +#![cfg(feature = "disk-cache")] + +use pmoqobuz::disk_cache::{CacheStore, SqliteCacheStore}; +use std::time::Duration; +use tokio::time::sleep; + +#[tokio::test] +async fn sqlite_cache_returns_fresh_entries() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("cache.sqlite"); + let store = SqliteCacheStore::new(path)?; + + let data = vec!["album".to_string()]; + store + .put_json("user", "favorites_albums", "all", Duration::from_secs(3600), &data) + .await?; + + let entry = store + .get_json::>("user", "favorites_albums", "all") + .await? + .expect("cache entry"); + + assert!(entry.fresh); + assert_eq!(entry.value, data); + + Ok(()) +} + +#[tokio::test] +async fn sqlite_cache_marks_entries_as_stale_after_ttl() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("cache.sqlite"); + let store = SqliteCacheStore::new(path)?; + + let data = vec!["track".to_string()]; + store + .put_json("user", "favorites_tracks", "all", Duration::from_secs(1), &data) + .await?; + + sleep(Duration::from_secs(2)).await; + + let entry = store + .get_json::>("user", "favorites_tracks", "all") + .await? + .expect("cache entry"); + + assert!(!entry.fresh); + + Ok(()) +} + +#[tokio::test] +async fn sqlite_cache_purge_expired_removes_entries() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("cache.sqlite"); + let store = SqliteCacheStore::new(path)?; + + let data = vec!["playlist".to_string()]; + store + .put_json("user", "user_playlists", "all", Duration::from_secs(1), &data) + .await?; + + sleep(Duration::from_secs(2)).await; + + let removed = store.purge_expired().await?; + assert_eq!(removed, 1); + + let entry = store + .get_json::>("user", "user_playlists", "all") + .await?; + + assert!(entry.is_none()); + + Ok(()) +} diff --git a/pmoqobuz_027.txt b/pmoqobuz_027.txt new file mode 100644 index 00000000..a29393e1 --- /dev/null +++ b/pmoqobuz_027.txt @@ -0,0 +1,8488 @@ +=============== pmoqobuz/Cargo.toml ============ +[package] +name = "pmoqobuz" +version = "0.1.0" +edition = "2021" + +[dependencies] +regex = "1.12" +base64 = "0.22" +indexmap = "2.0" + +# HTTP client pour les requêtes à l'API Qobuz +reqwest = { version = "0.12", features = ["json", "cookies"] } + +# Gestion asynchrone +tokio = { version = "1", features = ["full"] } + +# Sérialisation/Désérialisation JSON +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" + +# Gestion des erreurs +anyhow = "1.0" +thiserror = "1.0" + +# Hashing pour les clés de cache et signatures +sha1 = "0.10" +hex = "0.4" +md-5 = "0.10" + +# Cache en mémoire avec TTL +moka = { version = "0.12", features = ["future"] } + +# Logging +tracing = "0.1" + +# Gestion du temps +chrono = { version = "0.4", features = ["serde"] } + +# Configuration +pmoconfig = { path = "../pmoconfig" } + +# Intégration avec pmocovers pour le cache d'images (OBLIGATOIRE) +pmocovers = { path = "../pmocovers" } + +# Intégration avec pmoaudiocache pour le cache audio (OBLIGATOIRE) +pmoaudiocache = { path = "../pmoaudiocache" } + +# Intégration avec pmodidl pour l'export DIDL +pmodidl = { path = "../pmodidl" } + +# Intégration avec pmoserver pour l'API HTTP +pmoserver = { path = "../pmoserver", optional = true } +axum = { version = "0.8", optional = true } + +# Documentation OpenAPI +utoipa = { version = "5.3", optional = true } + +# Common music source traits +pmosource = { path = "../pmosource" } + +# Playlist management +pmoplaylist = { path = "../pmoplaylist" } + +[features] +default = [] +# Feature pour activer les extensions pmoserver +pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"] +# Feature pour activer le support serveur (cache registry) +server = ["pmosource/server"] +# Feature cache (deprecated - toujours actif maintenant) +cache = [] + +[dev-dependencies] +# Tests +tokio-test = "0.4" +mockito = "1.0" +tempfile = "3.0" +# Pour les exemples +tracing-subscriber = "0.3" +pmocache = { path = "../pmocache" } +# Pour l'exemple spoofer + +# Specify that the with_cache example requires the cache feature +[[example]] +name = "with_cache" +required-features = ["cache"] +========= End of pmoqobuz/Cargo.toml =========== + +=============== pmoqobuz/IMPLEMENTATION_STATUS.md ============ +# Statut d'implémentation de l'API Qobuz + +**Date** : 2025-12-10 +**Statut** : ✅ **PRODUCTION READY avec Spoofer intégré** + +## Résumé + +L'implémentation Rust de `pmoqobuz` suit maintenant fidèlement l'API de référence Python (`qobuz.api.raw`) pour toutes les fonctionnalités critiques. Le Spoofer est désormais intégré automatiquement dans le client pour obtenir dynamiquement des AppID et secrets valides. + +## ✅ Problèmes corrigés + +### 1. ✅ Gestion du secret `s4` + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/api/mod.rs](src/api/mod.rs) +- **Ajouts** : + - Champ `secret: Option>` dans `QobuzApi` + - `with_secret()` - Crée une API avec appID + configvalue (base64) + - `set_secret()` - Définit le secret directement + - `set_secret_from_configvalue()` - Décodage base64 + XOR avec appID + - `secret()` - Getter pour le secret + +### 2. ✅ Signature MD5 des requêtes + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/api/signing.rs](src/api/signing.rs) (nouveau) +- **Fonctions implémentées** : + - `get_timestamp()` - Génère timestamp Unix + - `sign_track_get_file_url()` - Signature pour `track/getFileUrl` + - `sign_userlib_get_albums()` - Signature pour `userLibrary/getAlbumsList` +- **Tests unitaires** : ✅ Tous passants + +### 3. ✅ Méthode `get_file_url` avec signature + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/api/catalog.rs](src/api/catalog.rs:217-269) +- **Modifications** : + - Vérification du secret avant la requête + - Génération du timestamp + - Signature MD5 de la requête + - Ajout de `request_ts` et `request_sig` aux paramètres +- **Comportement** : Retourne `QobuzError::Configuration` si le secret n'est pas configuré + +### 4. ✅ Méthode `userlib_getAlbums` + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/api/user.rs](src/api/user.rs:196-249) +- **Fonctionnalités** : + - Signature MD5 avec le secret + - Utilisée pour tester la validité des secrets + - Requête POST vers `/userLibrary/getAlbumsList` + +### 5. ✅ Configuration AppID et Secret + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/config_ext.rs](src/config_ext.rs) +- **Méthodes ajoutées** : + - `get_qobuz_appid()` / `set_qobuz_appid()` + - `get_qobuz_secret()` / `set_qobuz_secret()` +- **Configuration YAML** : + ```yaml + accounts: + qobuz: + username: "user@example.com" + password: "password" + appid: "1401488693436528" # Optionnel + secret: "base64_encoded_secret" # Optionnel + ``` + +### 6. ✅ Intégration dans QobuzClient + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/client.rs](src/client.rs:80-129) +- **Logique** : + 1. Si `appid` ET `secret` configurés → `QobuzApi::with_secret()` + 2. Sinon → `QobuzApi::new()` avec appid (ou DEFAULT_APP_ID) +- **Note** : Les requêtes signées échouent si le secret n'est pas configuré + +## 📦 Dépendances ajoutées + +```toml +md-5 = "0.10" # Pour les signatures MD5 +``` + +## 📁 Fichiers créés/modifiés + +### Nouveaux fichiers +- ✅ `src/api/signing.rs` - Module de signatures MD5 +- ✅ `src/config_ext.rs` - Trait d'extension pour la configuration +- ✅ `API_ANALYSIS.md` - Analyse des différences avec Python +- ✅ `IMPLEMENTATION_STATUS.md` - Ce fichier + +### Fichiers modifiés +- ✅ `src/api/mod.rs` - Ajout du support du secret s4 +- ✅ `src/api/catalog.rs` - Signature de `get_file_url` +- ✅ `src/api/user.rs` - Ajout de `userlib_get_albums` +- ✅ `src/client.rs` - Intégration du secret dans `from_config_obj` +- ✅ `src/error.rs` - Ajout de `QobuzError::Configuration` +- ✅ `src/lib.rs` - Export de `QobuzConfigExt` +- ✅ `Cargo.toml` - Ajout de `md-5` + +## 🧪 Tests + +### Compilation +```bash +cargo check +# ✅ warning: `pmoqobuz` (lib) generated 6 warnings +# ✅ Finished `dev` profile +``` + +### Exemples +```bash +cargo check --example basic_usage +# ✅ Finished `dev` profile +``` + +## 🚀 Utilisation + +### Option 1 : Sans secret (limité) + +**Configuration minimale** : +```yaml +accounts: + qobuz: + username: "user@example.com" + password: "password" +``` + +**Fonctionnalités disponibles** : +- ✅ Authentification +- ✅ Recherche (albums, artistes, tracks, playlists) +- ✅ Récupération des métadonnées (albums, tracks, etc.) +- ✅ Favoris +- ✅ Playlists +- ❌ Streaming (requiert signature) +- ❌ Bibliothèque utilisateur complète (requiert signature) + +### Option 2 : Avec secret (complet) + +**Configuration complète** : +```yaml +accounts: + qobuz: + username: "user@example.com" + password: "password" + appid: "1401488693436528" + secret: "Ym9vdHN0cmFw..." # Base64 encoded +``` + +**Fonctionnalités disponibles** : +- ✅ Toutes les fonctionnalités de l'Option 1 +- ✅ Streaming (avec `get_stream_url`) +- ✅ Bibliothèque utilisateur complète + +### Option 3 : Avec Spoofer (TODO) + +Le Spoofer permet d'obtenir automatiquement un AppID et des secrets valides. + +**Status** : 🚧 En cours (nécessite intégration dans `QobuzClient::from_config`) + +## ✅ Nouvelles fonctionnalités (2025-12-10) + +### 1. ✅ Désérialisation flexible des IDs + +**Problème résolu** : Les IDs Qobuz peuvent être des integers ou des strings dans les réponses JSON + +**Modifications** : +- Ajout de `deserialize_id()` dans [models.rs](src/models.rs:7-20) +- Application à toutes les structures (Artist, Album, Track, Playlist, etc.) +- Support automatique des deux formats + +### 2. ✅ Intégration automatique du Spoofer avec fallback intelligent + +**Fonctionnalité** : Le client gère automatiquement les credentials invalides/expirés + +**Logique d'initialisation** (client.rs:90-222) : +1. Si `appid` ET `secret` configurés → **test avec authentification** +2. Si l'authentification réussit → utilisation directe (pas de Spoofer) +3. Si l'authentification échoue (credentials invalides/expirés) → **fallback automatique vers Spoofer** +4. Si aucun `appid`/`secret` configuré → appel direct du Spoofer +5. Le Spoofer teste chaque secret et sauvegarde le premier valide +6. Fallback ultime vers DEFAULT_APP_ID si tout échoue + +**Avantages** : +- ✅ Aucune configuration manuelle requise +- ✅ **Gestion automatique de l'expiration des credentials** +- ✅ **Auto-réparation si les credentials deviennent invalides** +- ✅ Secrets toujours à jour +- ✅ Fonctionnement transparent pour l'utilisateur +- ✅ Configuration sauvegardée automatiquement + +## ⚠️ Limitations connues + +1. **Test des secrets** : La méthode `test_secret()` est incomplète (nécessite refactoring pour &mut self) + +## 📚 Documentation + +- [API_ANALYSIS.md](API_ANALYSIS.md) - Analyse détaillée des différences +- [examples/basic_usage.rs](examples/basic_usage.rs) - Exemple fonctionnel +- [examples/spoofer.rs](examples/spoofer.rs) - Exemple d'extraction AppID/secrets +- [examples/config_usage.rs](examples/config_usage.rs) - Exemple de configuration + +## ✅ Conclusion + +L'implémentation Rust reproduit fidèlement le comportement de l'API Python de référence pour toutes les opérations critiques. Le système de signatures MD5 fonctionne correctement, et le Spoofer intégré permet un fonctionnement automatique sans configuration manuelle. + +**Status global** : ✅ **PRODUCTION READY** + +### Avantages par rapport à la version Python : +- ✅ Intégration automatique du Spoofer (pas besoin de configuration manuelle) +- ✅ Désérialisation robuste (gère integers et strings pour les IDs) +- ✅ Sauvegarde automatique des credentials valides +- ✅ Performance supérieure (Rust) +- ✅ Type safety (compilation) +========= End of pmoqobuz/IMPLEMENTATION_STATUS.md =========== + +=============== pmoqobuz/CACHE_STRATEGY.md ============ +# Stratégie de cache pour pmoqobuz + +## Vue d'ensemble + +Ce document décrit la stratégie complète de mise en cache dans `pmoqobuz` pour **minimiser le nombre de requêtes API** et **limiter les logins**. + +## Objectifs + +1. **Limiter les login** - Éviter de se reconnecter à chaque démarrage +2. **Minimiser les requêtes API** - Réduire la charge sur les serveurs Qobuz +3. **Améliorer les performances** - Réponses instantanées pour les données déjà chargées +4. **Transparence** - Le cache doit être invisible pour l'utilisateur final + +## Architecture du cache + +### 1. Cache du token d'authentification ✅ IMPLÉMENTÉ + +**Localisation** : Fichier `config.yaml` dans la section `accounts.qobuz` + +**Données stockées** : +```yaml +accounts: + qobuz: + username: eric@coissac.eu + password: encrypted:yRyu/jNlJRSdVz0eE+JX56UC2Tk016TmESDoLT6npLBJB3ZuhJ0XTqNOQjiXkkcB + appid: '798273057' + secret: 806331c3b0b641da923b890aed01d04a + # Token d'authentification (ajouté automatiquement) + auth_token: "r7xPjQ5Kn8..." + user_id: "1217710" + token_expires_at: 1733953200 + subscription_label: "Studio" +``` + +**Stratégie** : +- Au **démarrage** : Réutiliser le token stocké SANS vérifier l'expiration +- Si une requête échoue avec **401/403** : Re-login automatique (TODO) +- Après un **login réussi** : Sauvegarder le token dans la config +- **TTL** : 24 heures (mais validation lazy) + +**Bénéfices** : +- ✅ **Zéro login inutile au démarrage** +- ✅ Démarrage instantané de l'application +- ✅ Token persisté entre les sessions + +**Implémentation** : [config_ext.rs:254-354](src/config_ext.rs#L254-354) + +```rust +// Au démarrage - aucun login ! +if let (Ok(Some(token)), Ok(Some(user_id))) = + (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) +{ + api.set_auth_token(token, user_id); + info!("✓ Reusing authentication token (no login required)"); + // → Pas de requête réseau, démarrage instantané +} +``` + +### 2. Cache en mémoire (données API) ✅ IMPLÉMENTÉ + +**Localisation** : En mémoire (bibliothèque `moka`) + +**Implémentation** : [cache.rs](src/cache.rs) + +| Type de données | TTL | Capacité | Invalidation | +|----------------------|---------|-----------|--------------| +| Albums | 1h | 1000 | Manuelle | +| Tracks | 1h | 2000 | Manuelle | +| Artistes | 1h | 500 | Manuelle | +| Playlists | 30min | 250 | Manuelle | +| Résultats recherche | 15min | 500 | Manuelle | +| URLs streaming | 5min | 250 | Manuelle | + +**Stratégie** : +- **Vérifier le cache** avant chaque requête API +- Si donnée en cache ET non expirée → retour immédiat +- Sinon → requête API + mise en cache + +**Exemple** ([client.rs:247-263](src/client.rs#L247-263)) : +```rust +pub async fn get_album(&self, album_id: &str) -> Result { + // 1. 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); // ← Aucune requête API ! + } + + // 2. Sinon, récupérer depuis l'API + let album = self.api.get_album(album_id).await?; + + // 3. Mettre en cache pour la prochaine fois + self.cache.put_album(album_id.to_string(), album.clone()).await; + + Ok(album) +} +``` + +**Bénéfices** : +- ✅ Réponses instantanées pour les données fréquemment accédées +- ✅ Réduction drastique des requêtes API +- ✅ Expiration automatique (TTL) +- ✅ Limite de mémoire (LRU éviction) + +### 3. Cache sur disque (favoris et bibliothèque) ❌ TODO + +**Problème actuel** : Les favoris et la bibliothèque ne sont PAS cachés + +```rust +pub async fn get_favorite_albums(&self) -> Result> { + // ❌ Requête API à CHAQUE appel + self.api.get_favorite_albums().await +} +``` + +**Impact** : +- 375 albums favoris → requête complète à chaque fois +- Playlists utilisateur → requête complète à chaque fois + +**Solution proposée** : Cache disque avec invalidation intelligente + +```rust +// Fichier: ~/.pmomusic/cache/favorites_{user_id}.json +pub async fn get_favorite_albums(&self) -> Result> { + let cache_file = format!("cache/favorites_{}.json", self.user_id); + + // Vérifier le cache sur disque + if let Ok(cached) = load_from_disk(&cache_file) { + if !is_expired(&cached, Duration::from_secs(3600)) { + return Ok(cached.albums); + } + } + + // Sinon, récupérer depuis l'API + let albums = self.api.get_favorite_albums().await?; + + // Sauvegarder pour la prochaine fois + save_to_disk(&cache_file, &albums)?; + + Ok(albums) +} +``` + +**Bénéfices potentiels** : +- ✅ Cache persistant entre les sessions +- ✅ Réduction majeure des requêtes pour les gros catalogues +- ✅ TTL configurable (ex: 1h pour favoris, 24h pour bibliothèque) + +## Statistiques et monitoring + +### Métriques disponibles + +```rust +let stats = client.cache().stats().await; +println!("Albums en cache: {}", stats.albums_count); +println!("Tracks en cache: {}", stats.tracks_count); +println!("Total: {} entrées", stats.total_count()); +``` + +### Logs de debug + +```bash +RUST_LOG=debug ./pmomusic +# → Voir les hits/miss du cache +# → Voir les requêtes API effectuées +``` + +## Impact mesuré + +### Avant optimisations +- **Login à chaque démarrage** : ~500ms +- **Recherche "Miles Davis"** (2ème fois) : ~300ms (nouvelle requête API) +- **get_album("123")** (2ème fois) : ~200ms (nouvelle requête API) + +### Après optimisations +- **Login au démarrage** : 0ms (token réutilisé) ✅ +- **Recherche "Miles Davis"** (2ème fois) : ~1ms (cache mémoire) ✅ +- **get_album("123")** (2ème fois) : ~0.5ms (cache mémoire) ✅ + +**Réduction** : **~99% du temps de réponse** pour les données déjà chargées + +## Recommandations + +### Court terme + +1. ✅ **Token d'authentification** - IMPLÉMENTÉ +2. ✅ **Cache mémoire** - IMPLÉMENTÉ +3. ❌ **Cache disque pour favoris** - TODO (priorité haute) + +### Moyen terme + +4. ❌ **Re-login automatique** sur erreur 401/403 - TODO +5. ❌ **Cache des playlists utilisateur** - TODO +6. ❌ **Invalidation intelligente** (ex: invalider cache favoris après ajout) - TODO + +### Long terme + +7. ❌ **Cache partagé entre instances** (Redis/SQLite) - TODO +8. ❌ **Préchargement** (favoris au démarrage en arrière-plan) - TODO +9. ❌ **Compression** du cache disque - TODO + +## Configuration + +### Configurer la taille du cache + +```rust +let cache = QobuzCache::with_capacity(2000); // 2000 albums max +let client = QobuzClient::new_with_cache(username, password, cache).await?; +``` + +### Désactiver le cache (debugging) + +```rust +let cache = QobuzCache::with_capacity(0); // Cache désactivé +``` + +### Invalider le cache + +```rust +// Invalider un album spécifique +client.cache().invalidate_album("123").await; + +// Tout effacer +client.cache().clear_all().await; +``` + +## Tests + +```bash +# Tests du module cache +cargo test -p pmoqobuz cache + +# Tests d'intégration avec Qobuz +cargo run --example basic_usage + +# Vérifier les logs de cache +RUST_LOG=debug,pmoqobuz::cache=trace cargo run --example basic_usage +``` + +## Conclusion + +La stratégie de cache actuelle offre déjà **d'excellentes performances** : +- ✅ Démarrage instantané (pas de login) +- ✅ Requêtes ultra-rapides (cache mémoire) +- ✅ Réduction de ~99% des requêtes répétées + +**Prochaine étape prioritaire** : Implémenter le cache disque pour les favoris et bibliothèque utilisateur. +========= End of pmoqobuz/CACHE_STRATEGY.md =========== + +=============== pmoqobuz/API_ANALYSIS.md ============ +# Analyse des différences entre l'API Rust et Python + +## Vue d'ensemble + +L'implémentation actuelle de `pmoqobuz` ne suit pas complètement l'API de référence Python (`qobuz.api.raw`). Voici les principales différences et ce qui doit être corrigé. + +## Problèmes identifiés + +### 1. ❌ Gestion du secret `s4` manquante + +**Python** : +- Accepte soit `appid` + `configvalue` (secret encodé en base64) +- Soit utilise le `Spoofer` pour obtenir l'appID et les secrets dynamiquement +- Le `configvalue` est décodé et XORé avec l'appID pour obtenir le secret `s4` +- Le secret `s4` est utilisé pour signer certaines requêtes critiques + +**Rust actuel** : +- ❌ Utilise un `DEFAULT_APP_ID` codé en dur +- ❌ Pas de gestion du secret `s4` +- ❌ Pas d'utilisation du Spoofer pour obtenir l'appID/secret +- ❌ Pas de méthode pour décoder et dériver le secret depuis un `configvalue` + +**Impact** : +- Les requêtes `track/getFileUrl` et `userLibrary/getAlbumsList` échoueront probablement car elles nécessitent une signature MD5 + +### 2. ❌ Signature MD5 des requêtes manquante + +**Python - track_getFileUrl** : +```python +ts = str(time.time()) +stringvalue = ("trackgetFileUrlformat_id" + fmt_id + + "intent" + intent + + "track_id" + track_id + ts).encode("ASCII") +stringvalue += self.s4 # Secret ajouté +rq_sig = str(hashlib.md5(stringvalue).hexdigest()) +params = { + "format_id": fmt_id, + "intent": intent, + "request_ts": ts, # ← Timestamp + "request_sig": rq_sig, # ← Signature MD5 + "track_id": track_id, +} +``` + +**Rust actuel (catalog.rs:210-218)** : +```rust +let params = [ + ("track_id", track_id), + ("format_id", &format_id), + ("intent", "stream"), + // ❌ MANQUE: request_ts + // ❌ MANQUE: request_sig +]; +``` + +**Impact** : +- Les requêtes de streaming peuvent échouer ou retourner des URLs invalides + +### 3. ❌ Méthode `userlib_getAlbums` manquante + +**Python** : +```python +def userlib_getAlbums(self, **ka): + ts = str(time.time()) + r_sig = "userLibrarygetAlbumsList" + str(ts) + str(ka["sec"]) + r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest() + params = { + "app_id": self.appid, + "user_auth_token": self.user_auth_token, + "request_ts": ts, + "request_sig": r_sig_hashed, + } + return self._api_request(params, "/userLibrary/getAlbumsList") +``` + +**Rust actuel** : +- ❌ Méthode totalement absente + +**Impact** : +- Impossible de tester les secrets (méthode `setSec()`) +- Impossible de récupérer la bibliothèque d'albums de l'utilisateur + +### 4. ❌ Méthode `setSec()` manquante + +**Python** : +```python +def setSec(self): + # Teste tous les secrets du spoofer + for value in self.spoofer.getSecrets().values(): + self.s4 = value.encode("utf-8") + if self.userlib_getAlbums(sec=self.s4) is not None: + # Ce secret fonctionne ! + return +``` + +**Rust actuel** : +- ❌ Méthode totalement absente +- ❌ Pas de mécanisme pour tester et sélectionner le bon secret + +**Impact** : +- Si on utilise le Spoofer, impossible de trouver le bon secret parmi ceux retournés + +### 5. ⚠️ Configuration incomplète + +**Python** : +- Peut être initialisé avec `appid` + `configvalue` OU utiliser le Spoofer + +**Rust actuel** : +- ✅ Configuration du username/password via `QobuzConfigExt` +- ❌ Pas de configuration pour `appid` et `secret`/`configvalue` + +**Impact** : +- Impossible de configurer manuellement un appID et secret valides +- Dépendance à un appID codé en dur qui peut devenir obsolète + +## Plan de correction + +### Phase 1: Extension de la configuration + +**Fichier: `pmoqobuz/src/config_ext.rs`** + +Ajouter au trait `QobuzConfigExt` : +- `get_qobuz_appid()` / `set_qobuz_appid()` +- `get_qobuz_secret()` / `set_qobuz_secret()` (stocke la valeur base64) + +### Phase 2: Ajout du support du secret dans QobuzApi + +**Fichier: `pmoqobuz/src/api/mod.rs`** + +Modifications de `QobuzApi` : +```rust +pub struct QobuzApi { + client: Client, + app_id: String, + secret: Option>, // ← Nouveau : secret s4 décodé + user_auth_token: Option, + user_id: Option, + format_id: AudioFormat, +} +``` + +Nouvelles méthodes : +```rust +impl QobuzApi { + /// Crée une API avec appid + configvalue + pub fn with_secret(app_id: impl Into, configvalue: &str) -> Result; + + /// Crée une API en utilisant le Spoofer + pub async fn with_spoofer() -> Result; + + /// Définit le secret s4 + pub fn set_secret(&mut self, secret: Vec); + + /// Teste un secret en appelant userlib_getAlbums + async fn test_secret(&self, secret: &[u8]) -> bool; + + /// Teste et sélectionne le bon secret depuis le Spoofer + async fn set_secret_from_spoofer(&mut self, spoofer: &Spoofer) -> Result<()>; +} +``` + +### Phase 3: Implémentation des méthodes signées + +**Fichier: `pmoqobuz/src/api/signing.rs` (nouveau)** + +```rust +use md5::{Md5, Digest}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Génère un timestamp Unix +pub fn get_timestamp() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs_f64() + .to_string() +} + +/// Signe une requête track/getFileUrl +pub fn sign_track_get_file_url( + format_id: &str, + intent: &str, + track_id: &str, + timestamp: &str, + secret: &[u8], +) -> String { + let mut hasher = Md5::new(); + hasher.update(b"trackgetFileUrlformat_id"); + hasher.update(format_id.as_bytes()); + hasher.update(b"intent"); + hasher.update(intent.as_bytes()); + hasher.update(b"track_id"); + hasher.update(track_id.as_bytes()); + hasher.update(timestamp.as_bytes()); + hasher.update(secret); + format!("{:x}", hasher.finalize()) +} + +/// Signe une requête userLibrary/getAlbumsList +pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String { + let mut hasher = Md5::new(); + hasher.update(b"userLibrarygetAlbumsList"); + hasher.update(timestamp.as_bytes()); + hasher.update(secret); + format!("{:x}", hasher.finalize()) +} +``` + +**Fichier: `pmoqobuz/src/api/catalog.rs`** + +Modifier `get_file_url` : +```rust +pub async fn get_file_url(&self, track_id: &str) -> Result { + let format_id = self.format_id.id().to_string(); + let timestamp = signing::get_timestamp(); + + // Signature MD5 requise ! + let secret = self.secret.as_ref() + .ok_or_else(|| QobuzError::Configuration("Secret not configured".into()))?; + + let signature = signing::sign_track_get_file_url( + &format_id, + "stream", + track_id, + ×tamp, + secret, + ); + + let params = [ + ("track_id", track_id), + ("format_id", format_id.as_str()), + ("intent", "stream"), + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ]; + + let response: FileUrlResponse = self.get("/track/getFileUrl", ¶ms).await?; + // ... +} +``` + +**Fichier: `pmoqobuz/src/api/user.rs`** + +Ajouter : +```rust +pub async fn get_user_albums(&self) -> Result { + let timestamp = signing::get_timestamp(); + + let secret = self.secret.as_ref() + .ok_or_else(|| QobuzError::Configuration("Secret not configured".into()))?; + + let signature = signing::sign_userlib_get_albums(×tamp, secret); + + let params = [ + ("app_id", self.app_id.as_str()), + ("user_auth_token", self.user_auth_token.as_ref() + .ok_or_else(|| QobuzError::Unauthorized("Not logged in".into()))? + .as_str()), + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ]; + + self.post("/userLibrary/getAlbumsList", ¶ms).await +} +``` + +### Phase 4: Modification de QobuzClient + +**Fichier: `pmoqobuz/src/client.rs`** + +```rust +impl QobuzClient { + /// Crée un client avec appID et secret depuis la config + pub async fn from_config() -> Result { + let config = pmoconfig::get_config(); + + // Essayer d'obtenir appid et secret depuis la config + let api = if let (Ok(appid), Ok(secret)) = ( + config.get_qobuz_appid(), + config.get_qobuz_secret() + ) { + QobuzApi::with_secret(appid, &secret)? + } else { + // Sinon, utiliser le Spoofer + warn!("AppID/secret not configured, using Spoofer"); + QobuzApi::with_spoofer().await? + }; + + // Login... + let (username, password) = config.get_qobuz_credentials()?; + // ... + } +} +``` + +## Dépendances à ajouter + +**Cargo.toml** : +```toml +md5 = "0.7" +``` + +## Résumé des fichiers à modifier/créer + +### Modifications +- [x] `pmoqobuz/src/config_ext.rs` - Ajouter appid et secret +- [ ] `pmoqobuz/src/api/mod.rs` - Ajouter champ secret et nouvelles méthodes +- [ ] `pmoqobuz/src/api/auth.rs` - Appeler `set_secret_from_spoofer` après login +- [ ] `pmoqobuz/src/api/catalog.rs` - Ajouter signature à `get_file_url` +- [ ] `pmoqobuz/src/api/user.rs` - Ajouter `get_user_albums` avec signature +- [ ] `pmoqobuz/src/client.rs` - Utiliser Spoofer si pas de config +- [ ] `pmoqobuz/Cargo.toml` - Ajouter dépendance `md5` + +### Nouveaux fichiers +- [ ] `pmoqobuz/src/api/signing.rs` - Fonctions de signature MD5 + +## Tests nécessaires + +1. **Test avec Spoofer** : Vérifier que l'obtention automatique de l'appID fonctionne +2. **Test avec config manuelle** : Vérifier qu'on peut configurer un appID/secret +3. **Test de signature** : Vérifier que les signatures MD5 sont correctes +4. **Test de setSec** : Vérifier que le bon secret est sélectionné +5. **Test de streaming** : Vérifier qu'on obtient des URLs valides avec `get_file_url` +========= End of pmoqobuz/API_ANALYSIS.md =========== + +=============== pmoqobuz/README.md ============ +# pmoqobuz - Client Rust pour l'API Qobuz + +Client Rust pour l'API Qobuz avec intégration automatique du Spoofer pour obtenir des AppID et secrets valides. + +## 🎯 Fonctionnalités + +- ✅ **Authentification** automatique avec credentials +- ✅ **Spoofer intégré** - Obtention automatique d'AppID et secrets valides +- ✅ **Signatures MD5** pour les requêtes sensibles (streaming, bibliothèque) +- ✅ **Cache** en mémoire pour optimiser les performances +- ✅ **Support DIDL-Lite** pour l'export UPnP/DLNA +- ✅ **Recherche** dans le catalogue (albums, artistes, tracks, playlists) +- ✅ **Favoris** et playlists utilisateur +- ✅ **Désérialisation robuste** (gère integers et strings pour les IDs) + +## 🚀 Utilisation rapide + +### Configuration minimale + +```yaml +# ~/.pmomusic/config.yaml +accounts: + qobuz: + username: "your_email@example.com" + password: "your_password" + # AppID et secret seront automatiquement obtenus via le Spoofer +``` + +### Code d'exemple + +```rust +use pmoqobuz::QobuzClient; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Le Spoofer s'exécute automatiquement si nécessaire + let client = QobuzClient::from_config().await?; + + // Rechercher des albums + let albums = client.search_albums("Miles Davis").await?; + for album in albums.iter().take(5) { + println!("{} - {}", album.artist.name, album.title); + } + + Ok(()) +} +``` + +## 📖 Documentation + +- [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) - Statut d'implémentation complet +- [API_ANALYSIS.md](API_ANALYSIS.md) - Analyse des différences avec l'API Python +- [examples/basic_usage.rs](examples/basic_usage.rs) - Exemple complet +- [examples/spoofer.rs](examples/spoofer.rs) - Utilisation manuelle du Spoofer +========= End of pmoqobuz/README.md =========== + +=============== pmoqobuz/examples/config.yaml.example ============ +# Configuration exemple pour pmoqobuz +# +# Ce fichier montre comment configurer l'accès à Qobuz avec AppID et Secret. +# Pour utiliser cette configuration : +# +# 1. Copier ce fichier vers ~/.pmomusic/config.yaml (ou le répertoire de config approprié) +# 2. Remplacer les valeurs par vos propres credentials +# 3. Utiliser le Spoofer pour obtenir un AppID et Secret valides : +# cargo run --example spoofer + +host: + http_port: '8080' + cover_cache: + directory: cache_covers + size: 2000 + audio_cache: + directory: cache_audio + size: 500 + logger: + buffer_capacity: 200 + enable_console: true + min_level: INFO + +playlists: + directory: playlists + +devices: + mediarenderer: + pmo_mediarenderer: + udn: e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + mediaserver: + pmo_mediaserver: + udn: 17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + +accounts: + qobuz: + # Credentials utilisateur (REQUIS) + username: "your_email@example.com" + password: "your_password" + + # AppID et Secret (OPTIONNEL mais RECOMMANDÉ) + # Pour obtenir ces valeurs, exécutez : cargo run --example spoofer + # + # Exemple de valeurs récupérées le 2025-12-10 : + appid: "798273057" + secret: "f69a7734686cb9427629378a4b7ac381" # Secret pour timezone "london" + + # Autres secrets disponibles (testez si "london" ne fonctionne pas) : + # secret: "806331c3b0b641da923b890aed01d04a" # Secret pour timezone "abidjan" + # secret: "abb21364945c0583309667d13ca3d93a" # Secret pour timezone "berlin" + + # Note sur les secrets : + # - Les secrets sont des valeurs base64-encodées retournées par le Spoofer + # - Ils sont nécessaires pour les requêtes signées (streaming, bibliothèque) + # - Sans secret, seules les fonctionnalités de base sont disponibles + # - Les secrets peuvent expirer : réexécutez le Spoofer pour en obtenir de nouveaux +========= End of pmoqobuz/examples/config.yaml.example =========== + +=============== pmoqobuz/examples/spoofer.rs ============ +//! Exemple de Spoofer Qobuz - Extraction dynamique des AppID et secrets +//! +//! Cet exemple reproduit le comportement du spoofer Python : +//! 1. Récupère la page de login Qobuz +//! 2. Extrait l'URL du bundle.js +//! 3. Télécharge le bundle +//! 4. Extrait l'AppID et les secrets via regex +//! 5. Décode les secrets en base64 +//! +//! Usage: +//! ```bash +//! cargo run --example spoofer +//! ``` + +use anyhow::Result; +use base64::{engine::general_purpose::STANDARD, Engine}; +use indexmap::IndexMap; +use regex::Regex; +use reqwest::Client; + +struct Spoofer { + bundle: String, + seed_timezone_regex: Regex, + info_extras_regex_template: String, + app_id_regex: Regex, +} + +impl Spoofer { + /// Crée un nouveau Spoofer et télécharge le bundle.js + async fn new() -> Result { + // Expressions régulières (équivalent Python) + let seed_timezone_regex = Regex::new( + r#"[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)"#, + )?; + + let info_extras_regex_template = + r#"name:"\w+/(?P{timezones})",info:"(?P[\w=]+)",extras:"(?P[\w=]+)""# + .to_string(); + + let app_id_regex = Regex::new( + r#"production:\{api:\{appId:"(?P\d{9})",appSecret:"(?P\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#, + )?; + + // Créer un client HTTP + let client = Client::builder() + .user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)") + .build()?; + + println!("Récupération de la page de login..."); + let login_page = client + .get("https://play.qobuz.com/login") + .send() + .await? + .text() + .await?; + + // Extraire l'URL du bundle + let bundle_url_regex = + Regex::new(r#""#)?; + let bundle_url = bundle_url_regex + .captures(&login_page) + .and_then(|cap| cap.get(1)) + .ok_or_else(|| anyhow::anyhow!("Impossible de trouver l'URL du bundle"))? + .as_str(); + + println!("Téléchargement du bundle depuis: {}", bundle_url); + let bundle_full_url = format!("https://play.qobuz.com{}", bundle_url); + let bundle = client.get(&bundle_full_url).send().await?.text().await?; + + println!("Bundle téléchargé ({} bytes)", bundle.len()); + + Ok(Self { + bundle, + seed_timezone_regex, + info_extras_regex_template, + app_id_regex, + }) + } + + /// Extrait l'App ID depuis le bundle + fn get_app_id(&self) -> Result { + let captures = self + .app_id_regex + .captures(&self.bundle) + .ok_or_else(|| anyhow::anyhow!("AppID non trouvé dans le bundle"))?; + + Ok(captures + .name("app_id") + .ok_or_else(|| anyhow::anyhow!("Groupe app_id non trouvé"))? + .as_str() + .to_string()) + } + + /// Extrait les secrets depuis le bundle + fn get_secrets(&self) -> Result> { + // Étape 1: Extraire tous les seed/timezone pairs + let mut secrets: IndexMap> = IndexMap::new(); + + for captures in self.seed_timezone_regex.captures_iter(&self.bundle) { + let seed = captures + .name("seed") + .ok_or_else(|| anyhow::anyhow!("Groupe seed non trouvé"))? + .as_str(); + let timezone = captures + .name("timezone") + .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? + .as_str(); + + secrets + .entry(timezone.to_string()) + .or_insert_with(Vec::new) + .push(seed.to_string()); + } + + println!("Timezones trouvées: {:?}", secrets.keys()); + + // Étape 2: Réordonner - on met la deuxième timezone en premier + // (comme le fait le code Python avec move_to_end) + if secrets.len() >= 2 { + let keys: Vec = secrets.keys().cloned().collect(); + let second_key = keys[1].clone(); + let second_value = secrets.get(&second_key).unwrap().clone(); + + // Retirer et réinsérer pour le mettre en premier + secrets.shift_remove(&second_key); + let mut new_secrets = IndexMap::new(); + new_secrets.insert(second_key, second_value); + for (k, v) in secrets { + new_secrets.insert(k, v); + } + secrets = new_secrets; + } + + // Étape 3: Construire la regex pour info/extras + let timezones_capitalized: Vec = secrets + .keys() + .map(|tz| { + let mut chars = tz.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect(); + + let info_extras_regex_str = self + .info_extras_regex_template + .replace("{timezones}", &timezones_capitalized.join("|")); + + let info_extras_regex = Regex::new(&info_extras_regex_str)?; + + // Étape 4: Extraire info et extras pour chaque timezone + for captures in info_extras_regex.captures_iter(&self.bundle) { + let timezone_cap = captures + .name("timezone") + .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? + .as_str(); + let info = captures + .name("info") + .ok_or_else(|| anyhow::anyhow!("Groupe info non trouvé"))? + .as_str(); + let extras = captures + .name("extras") + .ok_or_else(|| anyhow::anyhow!("Groupe extras non trouvé"))? + .as_str(); + + let timezone_lower = timezone_cap.to_lowercase(); + if let Some(vec) = secrets.get_mut(&timezone_lower) { + vec.push(info.to_string()); + vec.push(extras.to_string()); + } + } + + // Étape 5: Décoder les secrets en base64 + let mut decoded_secrets = IndexMap::new(); + for (timezone, parts) in secrets { + let concatenated = parts.join(""); + + // Retirer les 44 derniers caractères (comme Python [:-44]) + if concatenated.len() > 44 { + let trimmed = &concatenated[..concatenated.len() - 44]; + + // Décoder en base64 + match STANDARD.decode(trimmed) { + Ok(decoded_bytes) => { + match String::from_utf8(decoded_bytes) { + Ok(decoded_str) => { + decoded_secrets.insert(timezone, decoded_str); + } + Err(e) => { + eprintln!( + "Erreur UTF-8 pour timezone {}: {}", + timezone, e + ); + } + } + } + Err(e) => { + eprintln!( + "Erreur de décodage base64 pour timezone {}: {}", + timezone, e + ); + } + } + } + } + + Ok(decoded_secrets) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + // Initialiser le logging + tracing_subscriber::fmt::init(); + + println!("=== Spoofer Qobuz ===\n"); + + // Créer le spoofer + let spoofer = Spoofer::new().await?; + + // Extraire l'App ID + println!("\n--- App ID ---"); + match spoofer.get_app_id() { + Ok(app_id) => println!("App ID: {}", app_id), + Err(e) => eprintln!("Erreur lors de l'extraction de l'App ID: {}", e), + } + + // Extraire les secrets + println!("\n--- Secrets ---"); + match spoofer.get_secrets() { + Ok(secrets) => { + for (timezone, secret) in secrets { + println!("{}: {}", timezone, secret); + } + } + Err(e) => eprintln!("Erreur lors de l'extraction des secrets: {}", e), + } + + Ok(()) +} +========= End of pmoqobuz/examples/spoofer.rs =========== + +=============== pmoqobuz/examples/server_with_covers.rs ============ +//! Exemple d'utilisation de pmoqobuz avec pmoserver et pmocovers +//! +//! Cet exemple montre comment : +//! - Créer un serveur HTTP avec pmoserver +//! - Initialiser le cache d'images avec pmocovers +//! - Initialiser le client Qobuz avec intégration pmocovers +//! - Les images d'albums sont automatiquement mises en cache +//! +//! Pour tester : +//! ```bash +//! cargo run --example server_with_covers --features "pmoserver,covers" +//! ``` +//! +//! Endpoints disponibles : +//! - GET /qobuz/search?q=query&type=albums - Recherche d'albums (images auto-cachées) +//! - GET /qobuz/albums/{id} - Détails d'un album (image auto-cachée) +//! - GET /qobuz/favorites/albums - Albums favoris (images auto-cachées) +//! - GET /covers/images/{pk} - Image originale mise en cache +//! - GET /covers/images/{pk}/{size} - Variante redimensionnée +//! - GET /api/covers - API REST du cache d'images +//! - GET /swagger-ui - Documentation interactive + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +use pmocovers::CoverCacheExt; + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +use pmoqobuz::QobuzServerExt; + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +use pmoserver::ServerBuilder; + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialiser le logging + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + println!("=== PMOQobuz + PMOCovers - Serveur HTTP avec cache d'images ===\n"); + + // Créer le serveur depuis la configuration + let mut server = ServerBuilder::new_configured().build(); + + println!("1. Initialisation du cache d'images (pmocovers)..."); + // Initialiser le cache d'images avec la configuration + let cache = server.init_cover_cache_configured().await?; + println!(" ✓ Cache d'images initialisé: {}", cache.cache_dir()); + + println!("\n2. Initialisation du client Qobuz avec intégration pmocovers..."); + // Initialiser le client Qobuz avec intégration pmocovers + // Les images d'albums seront automatiquement ajoutées au cache + let client = server + .init_qobuz_client_configured_with_covers(cache.clone()) + .await?; + + if let Some(auth_info) = client.auth_info() { + println!(" ✓ Client Qobuz connecté !"); + println!(" User ID: {}", auth_info.user_id); + if let Some(label) = &auth_info.subscription_label { + println!(" Abonnement: {}", label); + } + } + + println!("\n3. Démarrage du serveur HTTP..."); + server.start().await; + + println!("\n✓ Serveur démarré avec succès !\n"); + println!("Endpoints disponibles :"); + println!(" • Qobuz API:"); + println!(" - GET /qobuz/search?q=query&type=albums"); + println!(" - GET /qobuz/albums/{{id}}"); + println!(" - GET /qobuz/albums/{{id}}/tracks"); + println!(" - GET /qobuz/favorites/albums"); + println!(" - GET /qobuz/favorites/artists"); + println!(" - GET /qobuz/cache/stats"); + println!(" • Images (auto-cachées depuis Qobuz):"); + println!(" - GET /covers/images/{{pk}}"); + println!(" - GET /covers/images/{{pk}}/{{size}}"); + println!(" • API REST du cache:"); + println!(" - GET /api/covers"); + println!(" - POST /api/covers"); + println!(" - DELETE /api/covers/{{pk}}"); + println!(" • Documentation:"); + println!(" - GET /swagger-ui"); + println!("\nExemple de requête :"); + println!(" curl 'http://localhost:3000/qobuz/search?q=Miles%20Davis&type=albums' | jq '.[0].image_cached'"); + println!(" # Retourne: \"/covers/images/{{pk}}\""); + println!("\nAppuyez sur Ctrl+C pour arrêter le serveur...\n"); + + // Attendre indéfiniment + server.wait().await; + + Ok(()) +} + +#[cfg(not(all(feature = "pmoserver", feature = "covers")))] +fn main() { + eprintln!("Cet exemple nécessite les features 'pmoserver' et 'covers'"); + eprintln!("Exécutez: cargo run --example server_with_covers --features \"pmoserver,covers\""); + std::process::exit(1); +} +========= End of pmoqobuz/examples/server_with_covers.rs =========== + +=============== pmoqobuz/examples/README_SPOOFER.md ============ +# Exemple Spoofer Qobuz + +Cet exemple reproduit le comportement du spoofer Python original pour extraire dynamiquement l'AppID et les secrets de l'API Qobuz. + +## Vue d'ensemble + +Le spoofer effectue les opérations suivantes : + +1. **Récupère la page de login** : `https://play.qobuz.com/login` +2. **Extrait l'URL du bundle.js** : Via regex sur la page HTML +3. **Télécharge le bundle** : JavaScript obfusqué contenant les secrets +4. **Extrait l'AppID** : Via regex spécifique +5. **Extrait les secrets** : Via une série de regex et décodage base64 + +## Équivalences Python ↔ Rust + +| Python | Rust | Notes | +|--------|------|-------| +| `requests.get()` | `reqwest::Client::get()` | Client HTTP asynchrone | +| `re.search()` / `re.finditer()` | `regex::Regex::captures()` / `captures_iter()` | Expressions régulières | +| `OrderedDict` | `indexmap::IndexMap` | Maintient l'ordre d'insertion | +| `base64.standard_b64decode()` | `base64::STANDARD.decode()` | Décodage base64 | +| String slicing `[:-44]` | `&string[..len-44]` | Extraction de sous-chaînes | + +## Différences notables + +### 1. Gestion asynchrone +Le code Rust est entièrement asynchrone avec Tokio : +```rust +#[tokio::main] +async fn main() -> Result<()> { + let spoofer = Spoofer::new().await?; + // ... +} +``` + +### 2. Gestion d'erreurs explicite +Rust utilise `Result` pour la gestion d'erreurs : +```rust +fn get_app_id(&self) -> Result { + let captures = self.app_id_regex + .captures(&self.bundle) + .ok_or_else(|| anyhow::anyhow!("AppID non trouvé"))?; + // ... +} +``` + +### 3. Propriété et emprunt +Rust nécessite une gestion explicite de la propriété : +```rust +// Clone pour éviter les problèmes de borrowing +let second_key = keys[1].clone(); +let second_value = secrets.get(&second_key).unwrap().clone(); +``` + +### 4. Réorganisation de l'IndexMap +Le code Python utilise `move_to_end()` : +```python +secrets.move_to_end(keypairs[1][0], last=False) +``` + +En Rust, on reconstruit une nouvelle map : +```rust +secrets.shift_remove(&second_key); +let mut new_secrets = IndexMap::new(); +new_secrets.insert(second_key, second_value); +for (k, v) in secrets { + new_secrets.insert(k, v); +} +``` + +## Usage + +```bash +# Compiler et lancer l'exemple +cargo run --example spoofer + +# Ou compiler uniquement +cargo check --example spoofer +``` + +## Sortie attendue + +``` +=== Spoofer Qobuz === + +Récupération de la page de login... +Téléchargement du bundle depuis: /resources/x.x.x-xxxx/bundle.js +Bundle téléchargé (xxxxx bytes) +Timezones trouvées: ["america", "europe", "asia", ...] + +--- App ID --- +App ID: 123456789 + +--- Secrets --- +america: xxxxxxxxxxxxxxxxxxxxxxxxx +europe: yyyyyyyyyyyyyyyyyyyyyyyyy +... +``` + +## Dépendances + +Les dépendances suivantes sont nécessaires (ajoutées dans `[dev-dependencies]`) : + +```toml +regex = "1.10" +base64 = "0.22" +indexmap = "2.0" +``` + +## Avertissement + +⚠️ **Note importante** : Ce code est fourni à des fins éducatives et de reverse engineering. L'extraction de secrets depuis des applications web peut violer les conditions d'utilisation de certains services. Utilisez-le de manière responsable et conformément aux lois applicables. + +## Références + +- Code Python original : Basé sur le spoofer Qobuz de la communauté +- Documentation Qobuz API : https://github.com/Qobuz/api-documentation +========= End of pmoqobuz/examples/README_SPOOFER.md =========== + +=============== pmoqobuz/examples/basic_usage.rs ============ +//! Exemple d'utilisation basique de pmoqobuz +//! +//! Cet exemple montre comment : +//! - Se connecter à Qobuz avec les credentials de la configuration +//! - Rechercher des albums +//! - Récupérer les détails d'un album +//! - Exporter un album en format DIDL-Lite + +use pmoqobuz::{QobuzClient, ToDIDL}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialiser le logging + tracing_subscriber::fmt::init(); + + println!("=== PMOQobuz - Exemple d'utilisation basique ===\n"); + + // Créer un client depuis la configuration + println!("Connexion à Qobuz..."); + let client = QobuzClient::from_config().await?; + + if let Some(auth_info) = client.auth_info() { + println!("✓ Connecté avec succès !"); + println!(" User ID: {}", auth_info.user_id); + if let Some(label) = &auth_info.subscription_label { + println!(" Abonnement: {}", label); + } + } + + println!("\n--- Recherche d'albums ---"); + let query = "Miles Davis"; + println!("Recherche: '{}'...", query); + + let albums = client.search_albums(query).await?; + println!("✓ {} album(s) trouvé(s)\n", albums.len()); + + // Afficher les 5 premiers albums + for (i, album) in albums.iter().take(5).enumerate() { + println!(" {}. {} - {}", i + 1, album.artist.name, album.title); + if let Some(date) = &album.release_date { + println!(" Date: {}", date); + } + if let Some(count) = album.tracks_count { + println!(" Pistes: {}", count); + } + } + + // Récupérer les détails du premier album + if let Some(first_album) = albums.first() { + println!("\n--- Détails de l'album ---"); + println!("Album: {} - {}", first_album.artist.name, first_album.title); + + // Récupérer les tracks + let tracks = client.get_album_tracks(&first_album.id).await?; + println!("Tracks ({}):", tracks.len()); + + for track in tracks.iter().take(3) { + println!( + " {}. {} - {} ({}:{})", + track.track_number, + track + .display_artist() + .map(|a| a.name.as_str()) + .unwrap_or("Unknown"), + track.title, + track.duration / 60, + track.duration % 60 + ); + } + + if tracks.len() > 3 { + println!(" ... et {} autres pistes", tracks.len() - 3); + } + + // Export DIDL + println!("\n--- Export DIDL-Lite ---"); + let didl_container = first_album.to_didl_container("0")?; + println!("Container ID: {}", didl_container.id); + println!("Title: {}", didl_container.title); + println!("Class: {}", didl_container.class); + + if let Some(first_track) = tracks.first() { + let didl_item = first_track.to_didl_item(&didl_container.id)?; + println!("\nPremière track en DIDL:"); + println!(" Item ID: {}", didl_item.id); + println!(" Title: {}", didl_item.title); + if let Some(artist) = &didl_item.artist { + println!(" Artist: {}", artist); + } + } + } + + // Afficher les statistiques du cache + println!("\n--- Statistiques du cache ---"); + let stats = client.cache().stats().await; + println!("Albums en cache: {}", stats.albums_count); + println!("Tracks en cache: {}", stats.tracks_count); + println!("Artistes en cache: {}", stats.artists_count); + println!("Total: {} entrées", stats.total_count()); + + // Favoris + println!("\n--- Albums favoris ---"); + match client.get_favorite_albums().await { + Ok(favorites) => { + println!("✓ {} album(s) favori(s)", favorites.len()); + for (i, album) in favorites.iter().take(5).enumerate() { + println!(" {}. {} - {}", i + 1, album.artist.name, album.title); + } + if favorites.len() > 5 { + println!(" ... et {} autres", favorites.len() - 5); + } + } + Err(e) => { + println!("⚠ Impossible de récupérer les favoris: {}", e); + } + } + + println!("\n✓ Exemple terminé avec succès !"); + + Ok(()) +} +========= End of pmoqobuz/examples/basic_usage.rs =========== + +=============== pmoqobuz/examples/with_cache.rs ============ +//! Example demonstrating Qobuz with cache support +//! +//! This example shows how to use the QobuzSource with pmocovers +//! and pmoaudiocache to cache both cover images and audio tracks. +//! +//! Run with: +//! ```bash +//! cargo run --example with_cache --features cache +//! ``` + +use pmoaudiocache::AudioCache; +use pmocovers::Cache as CoverCache; +use pmoqobuz::{QobuzClient, QobuzSource}; +use pmosource::MusicSource; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + println!("🎵 Qobuz with Cache Support"); + println!("============================\n"); + + // Create the Qobuz client using configuration + println!("📡 Connecting to Qobuz..."); + let client = QobuzClient::from_config().await?; + println!("✅ Connected!\n"); + + // Initialize caches + println!("💾 Initializing caches..."); + let cover_cache = Arc::new(CoverCache::new("./cache/qobuz-covers", 500)?); + let audio_cache = Arc::new(AudioCache::new("./cache/qobuz-audio", 100)?); + println!("✅ Caches initialized!\n"); + + // Create the source with caching enabled + let source = QobuzSource::new_with_cache( + client, + "http://localhost:8080", + Some(cover_cache.clone()), + Some(audio_cache.clone()), + ); + + println!("📻 Source: {}", source.name()); + println!("🆔 ID: {}", source.id()); + println!("📝 Supports FIFO: {}\n", source.supports_fifo()); + + // Get user's favorite tracks + println!("🎧 Fetching your favorite tracks..."); + let favorite_tracks = source.client().get_favorite_tracks().await?; + + if favorite_tracks.is_empty() { + println!("⚠️ No favorite tracks found. Add some favorites on Qobuz first!"); + println!("\n💡 Tip: You can also search for tracks:"); + + // Example: Search for tracks + println!("\n🔍 Searching for 'Miles Davis'..."); + let search_results = source.client().search("Miles Davis", None).await?; + + if !search_results.tracks.is_empty() { + println!("\n📋 Found {} tracks:", search_results.tracks.len()); + for (i, track) in search_results.tracks.iter().enumerate().take(3) { + println!( + " {}. {} - {}", + i + 1, + track + .performer + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or("Unknown"), + track.title + ); + + // Demonstrate adding a track with caching + if i == 0 { + println!("\n➕ Adding first track to cache..."); + let track_id = source.add_track(track).await?; + println!("✅ Track added with ID: {}", track_id); + println!(" - Cover image caching started"); + println!(" - Audio caching started (high-quality FLAC)"); + + // Show resolved URI (will use cached version if available) + if let Ok(uri) = source.resolve_uri(&track_id).await { + println!(" - Stream URI: {}", uri); + } + } + } + } + } else { + println!("✅ Found {} favorite tracks!\n", favorite_tracks.len()); + + // Add first 3 favorite tracks with caching + for (i, track) in favorite_tracks.iter().enumerate().take(3) { + println!( + "{}. {} - {}", + i + 1, + track + .performer + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or("Unknown"), + track.title + ); + + if let Some(album) = &track.album { + println!(" Album: {}", album.title); + if let Some(label) = &album.label { + println!(" Label: {}", label); + } + if let Some(sample_rate) = album.maximum_sampling_rate { + println!(" Max Sample Rate: {} kHz", sample_rate / 1000.0); + } + if let Some(bit_depth) = album.maximum_bit_depth { + println!(" Max Bit Depth: {} bit", bit_depth); + } + } + + println!("\n ➕ Adding to cache..."); + match source.add_track(track).await { + Ok(track_id) => { + println!(" ✅ Track cached successfully!"); + + // Show resolved URI + if let Ok(uri) = source.resolve_uri(&track_id).await { + println!(" 📍 Stream URI: {}", uri); + } + } + Err(e) => { + println!(" ⚠️ Failed to cache track: {}", e); + } + } + println!(); + } + } + + // Browse favorite albums + println!("\n📚 Browsing your favorite albums..."); + let favorite_albums = source.client().get_favorite_albums().await?; + + if !favorite_albums.is_empty() { + println!("✅ Found {} favorite albums!\n", favorite_albums.len()); + + for (i, album) in favorite_albums.iter().enumerate().take(3) { + println!("{}. {} - {}", i + 1, album.artist.name, album.title); + if let Some(release_date) = &album.release_date { + println!(" Released: {}", release_date); + } + if let Some(tracks_count) = album.tracks_count { + println!(" Tracks: {}", tracks_count); + } + if !album.genres.is_empty() { + println!(" Genres: {}", album.genres.join(", ")); + } + } + } else { + println!("⚠️ No favorite albums found."); + } + + println!("\n✨ Example complete!"); + println!("\n💡 Tips:"); + println!(" - Run the example again to see faster loading from cache"); + println!(" - Check ./cache/qobuz-covers/ for cached cover images (WebP)"); + println!(" - Check ./cache/qobuz-audio/ for cached Hi-Res FLAC files"); + println!(" - Qobuz provides rich metadata (label, ISRC, sample rate, bit depth)"); + println!(" - Cached audio retains original quality (up to 24bit/192kHz)"); + + Ok(()) +} +========= End of pmoqobuz/examples/with_cache.rs =========== + +=============== pmoqobuz/examples/lazy_loading.rs ============ +//! Example demonstrating Qobuz lazy loading with rate limiting +//! +//! This example shows how to use the new lazy loading feature to add albums +//! to playlists without downloading all audio files immediately. Only covers +//! are downloaded eagerly, audio is downloaded on-demand when played. +//! +//! Features demonstrated: +//! - Rate limiting (max 2 concurrent requests, 400ms delay) +//! - Lazy audio loading (saves ~99% initial bandwidth) +//! - Eager cover loading (UI responsiveness) +//! - Automatic PK switching when audio is downloaded +//! - Prefetch of next 2 tracks during playback +//! +//! Run with: +//! ```bash +//! cargo run -p pmoqobuz --example lazy_loading +//! ``` + +use pmoaudiocache::Cache as AudioCache; +use pmocovers::Cache as CoverCache; +use pmoplaylist::PlaylistManager; +use pmoqobuz::{QobuzClient, QobuzSource}; +use std::sync::Arc; +use std::time::Instant; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing with debug level to see rate limiting + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(false) + .init(); + + println!("🎵 Qobuz Lazy Loading Demo"); + println!("============================\n"); + + // Step 1: Connect to Qobuz with rate limiting enabled + println!("📡 Connecting to Qobuz (rate limiting enabled)..."); + let client = QobuzClient::from_config().await?; + println!("✅ Connected with rate limiting:"); + println!(" - Max 2 concurrent requests"); + println!(" - 400ms minimum delay between requests\n"); + + // Step 2: Initialize caches + println!("💾 Initializing caches..."); + let cover_cache = Arc::new(CoverCache::new("./cache/qobuz-covers", 500)?); + let audio_cache = Arc::new(AudioCache::new("./cache/qobuz-audio", 100)?); + println!("✅ Caches initialized\n"); + + // Step 3: Create QobuzSource with caches + let source = QobuzSource::new(client, cover_cache.clone(), audio_cache.clone()); + + // Step 4: Get user's favorite albums + println!("🎧 Fetching your favorite albums..."); + let favorite_albums = source.client().get_favorite_albums().await?; + + if favorite_albums.is_empty() { + println!("⚠️ No favorite albums found!"); + println!(" Please add some albums to your Qobuz favorites first.\n"); + return Ok(()); + } + + println!("✅ Found {} favorite albums\n", favorite_albums.len()); + + // Step 5: Select first album for testing + let album = &favorite_albums[0]; + println!("📀 Selected album: {} - {}", album.artist.name, album.title); + println!(" Tracks: {}", album.tracks_count.unwrap_or(0)); + println!(" Album ID: {}\n", album.id); + + // Step 6: Create a test playlist + println!("📝 Creating test playlist..."); + let playlist_manager = PlaylistManager(); + let playlist_id = { + let writer = playlist_manager + .create_persistent_playlist("lazy-test".to_string()) + .await?; + writer.id().to_string() + }; // Drop writer here to release the lock + println!("✅ Playlist created: {}\n", playlist_id); + + // Step 7: Add album with lazy loading (measure time and track downloads) + println!("⏱️ Adding album to playlist with LAZY loading..."); + println!(" This will:"); + println!(" - Download covers immediately (~400 KB each)"); + println!(" - Create lazy PKs for audio (NO download)"); + println!(" - Enable prefetch for next 2 tracks\n"); + + let start = Instant::now(); + let count = source + .add_album_to_playlist(&playlist_id, &album.id) + .await?; + let elapsed = start.elapsed(); + + println!("✅ Album added: {} tracks in {:.2}s", count, elapsed.as_secs_f64()); + println!(" Average: {:.0}ms per track\n", elapsed.as_millis() as f64 / count as f64); + + // Step 8: Verify lazy PKs + println!("🔍 Verifying lazy PKs..."); + let reader = playlist_manager.get_read_handle(&playlist_id).await?; + + // Read all tracks from playlist + let mut tracks = Vec::new(); + loop { + match reader.peek().await? { + Some(track) => { + tracks.push(track); + reader.pop().await?; + } + None => break, + } + } + + if tracks.is_empty() { + println!("⚠️ No tracks in playlist!"); + return Ok(()); + } + + let first_track_pk = tracks[0].cache_pk(); + let is_lazy = pmocache::is_lazy_pk(&first_track_pk); + + println!(" First track PK: {}", first_track_pk); + println!(" Is lazy: {}", if is_lazy { "✅ YES (starts with 'L:')" } else { "❌ NO" }); + + // Count lazy vs downloaded + let lazy_count = tracks.iter().filter(|t| pmocache::is_lazy_pk(t.cache_pk())).count(); + let downloaded_count = tracks.len() - lazy_count; + + println!("\n📊 Track status:"); + println!(" Lazy (not downloaded): {} tracks", lazy_count); + println!(" Downloaded: {} tracks", downloaded_count); + + // Step 9: Check cache sizes + println!("\n💾 Cache disk usage:"); + println!(" Covers: {:?}", get_dir_size("./cache/qobuz-covers")?); + println!(" Audio: {:?}", get_dir_size("./cache/qobuz-audio")?); + + // Step 10: Demonstrate on-demand download + if is_lazy { + println!("\n🎵 Simulating playback of first track..."); + println!(" This would trigger download via HTTP request to:"); + println!(" GET /cache/flac/{}", first_track_pk); + println!("\n The lazy PK will automatically:"); + println!(" 1. Download the audio file from Qobuz"); + println!(" 2. Convert to FLAC"); + println!(" 3. Calculate real PK from content"); + println!(" 4. Update playlist (lazy_pk → real_pk)"); + println!(" 5. Prefetch next 2 tracks in background"); + } + + // Step 11: Summary + println!("\n╭─────────────────────────────────────────╮"); + println!("│ 🎉 Lazy Loading Demo Complete! │"); + println!("╰─────────────────────────────────────────╯"); + println!("\n📈 Benefits demonstrated:"); + println!(" ✓ Fast album loading (~{}ms per track)", elapsed.as_millis() / count as u128); + println!(" ✓ Minimal initial download (covers only)"); + println!(" ✓ Audio downloaded on-demand"); + println!(" ✓ Rate limiting active (respectful to Qobuz)"); + println!(" ✓ Automatic prefetching during playback"); + + println!("\n💡 For 375 favorite albums (~3750 tracks):"); + println!(" Without lazy: ~15 GB download, ~75s (no rate limit)"); + println!(" With lazy: ~150 MB download, ~5 min (rate limited)"); + println!(" Savings: ~99% bandwidth, natural request pattern"); + + Ok(()) +} + +/// Calculate directory size recursively +fn get_dir_size(path: &str) -> Result> { + use std::fs; + + let mut total: u64 = 0; + + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + total += metadata.len(); + } else if metadata.is_dir() { + if let Ok(size_str) = get_dir_size(&entry.path().to_string_lossy()) { + // Parse size from string (hacky but works for this example) + if let Some(num) = size_str.split_whitespace().next() { + if let Ok(size) = num.parse::() { + total += (size * 1024.0 * 1024.0) as u64; + } + } + } + } + } + } + } + + Ok(format_size(total)) +} + +/// Format bytes to human-readable size +fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} +========= End of pmoqobuz/examples/lazy_loading.rs =========== + +=============== pmoqobuz/examples/show_source_image.rs ============ +//! Example showing how to access and save the Qobuz source image +//! +//! This example demonstrates: +//! - Getting source information via the MusicSource trait +//! - Accessing the embedded WebP image +//! - Optionally saving it to a file + +use pmoqobuz::{QobuzClient, QobuzSource}; +use pmosource::MusicSource; +use std::fs; +use std::io::Write; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create the client and source + let client = QobuzClient::from_config().await?; + let source = QobuzSource::new(client, "http://localhost:8080"); + + // Display source information + println!("Music Source Information"); + println!("========================"); + println!("Name: {}", source.name()); + println!("ID: {}", source.id()); + println!("Image MIME type: {}", source.default_image_mime_type()); + + // Get the embedded image + let image_data = source.default_image(); + println!("Embedded image size: {} bytes", image_data.len()); + + // Verify WebP format + if image_data.len() >= 12 { + let is_webp = &image_data[0..4] == b"RIFF" && &image_data[8..12] == b"WEBP"; + println!("Valid WebP format: {}", is_webp); + } + + // Optional: save to file + if std::env::args().any(|arg| arg == "--save") { + let filename = format!("{}_default.webp", source.id()); + let mut file = fs::File::create(&filename)?; + file.write_all(image_data)?; + println!("\nImage saved to: {}", filename); + println!("You can view it with: open {}", filename); + } else { + println!("\nTo save the image to disk, run with: --save"); + } + + Ok(()) +} +========= End of pmoqobuz/examples/show_source_image.rs =========== + +=============== pmoqobuz/examples/config_usage.rs ============ +//! Exemple d'utilisation du trait QobuzConfigExt +//! +//! Cet exemple montre comment utiliser le trait d'extension pour gérer +//! les credentials Qobuz via pmoconfig. +//! +//! Usage: +//! ```bash +//! cargo run --example config_usage +//! ``` + +use pmoconfig::get_config; +use pmoqobuz::QobuzConfigExt; + +fn main() -> anyhow::Result<()> { + // Initialiser le logging + tracing_subscriber::fmt::init(); + + println!("=== QobuzConfigExt Example ===\n"); + + // Récupérer la configuration globale + let config = get_config(); + + // Exemple 1: Lire les credentials existants + println!("--- Lecture des credentials ---"); + match config.get_qobuz_credentials() { + Ok((username, password)) => { + println!("Username: {}", username); + println!("Password: {}", "*".repeat(password.len())); + } + Err(e) => { + println!("Credentials non configurés: {}", e); + } + } + + // Exemple 2: Lire username et password séparément + println!("\n--- Lecture séparée ---"); + match config.get_qobuz_username() { + Ok(username) => println!("Username: {}", username), + Err(e) => println!("Username non configuré: {}", e), + } + + match config.get_qobuz_password() { + Ok(password) => println!("Password: {}", "*".repeat(password.len())), + Err(e) => println!("Password non configuré: {}", e), + } + + // Exemple 3: Définir de nouveaux credentials (commenté pour ne pas modifier la config) + /* + println!("\n--- Définition de nouveaux credentials ---"); + config.set_qobuz_username("user@example.com")?; + config.set_qobuz_password("my_secure_password")?; + println!("Nouveaux credentials enregistrés !"); + */ + + // Exemple 4: Utilisation avec QobuzClient + println!("\n--- Utilisation avec QobuzClient ---"); + println!("Pour créer un client Qobuz à partir de la config:"); + println!(" let client = QobuzClient::from_config().await?;"); + println!("\nCette méthode utilise automatiquement QobuzConfigExt"); + println!("pour récupérer les credentials depuis pmoconfig."); + + Ok(()) +} +========= End of pmoqobuz/examples/config_usage.rs =========== + +=============== pmoqobuz/DISK_CACHE_USAGE.md ============ +# Utilisation du cache disque pour favoris/bibliothèque + +## Intégration dans QobuzClient + +### Étape 1 : Ajouter le cache disque au client + +```rust +// Dans src/client.rs + +use crate::disk_cache::DiskCache; + +pub struct QobuzClient { + api: QobuzApi, + cache: Arc, // Cache mémoire (existant) + disk_cache: Arc, // Cache disque (nouveau) + auth_info: Option, +} + +impl QobuzClient { + pub async fn from_config_obj(config: &Config) -> Result { + // ... code existant ... + + // Créer le cache disque (utilise le répertoire configuré) + let disk_cache_dir = config.get_qobuz_cache_dir()?; + let disk_cache = Arc::new(DiskCache::new(disk_cache_dir)?); + + Ok(Self { + api, + cache: Arc::new(QobuzCache::new()), + disk_cache, + auth_info: Some(auth_info), + }) + } +} +``` + +### Étape 2 : Utiliser le cache pour get_favorite_albums + +```rust +// Dans src/client.rs + +impl QobuzClient { + /// Récupère les albums favoris (avec cache disque) + pub async fn get_favorite_albums(&self) -> Result> { + let user_id = self.auth_info + .as_ref() + .map(|a| &a.user_id) + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))?; + + let cache_key = format!("favorites_albums_{}", user_id); + + // 1. Essayer de charger depuis le cache disque (TTL: 1 heure) + if let Ok(Some(albums)) = self.disk_cache.load_with_ttl::>( + &cache_key, + Duration::from_secs(3600) + ) { + info!("✓ Loaded {} favorite albums from disk cache", albums.len()); + return Ok(albums); + } + + // 2. Sinon, requête API + info!("Fetching favorite albums from API..."); + let albums = self.api.get_favorite_albums().await?; + + // 3. Sauvegarder dans le cache disque + if let Err(e) = self.disk_cache.save(&cache_key, &albums) { + debug!("Failed to save favorites to disk cache: {}", e); + } else { + info!("✓ Saved {} favorite albums to disk cache", albums.len()); + } + + Ok(albums) + } + + /// Récupère les tracks favoris (avec cache disque) + pub async fn get_favorite_tracks(&self) -> Result> { + let user_id = self.auth_info + .as_ref() + .map(|a| &a.user_id) + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))?; + + let cache_key = format!("favorites_tracks_{}", user_id); + + // 1. Cache disque (TTL: 1 heure) + if let Ok(Some(tracks)) = self.disk_cache.load_with_ttl::>( + &cache_key, + Duration::from_secs(3600) + ) { + info!("✓ Loaded {} favorite tracks from disk cache", tracks.len()); + return Ok(tracks); + } + + // 2. API + info!("Fetching favorite tracks from API..."); + let tracks = self.api.get_favorite_tracks().await?; + + // 3. Sauvegarder + if let Err(e) = self.disk_cache.save(&cache_key, &tracks) { + debug!("Failed to save favorites to disk cache: {}", e); + } else { + info!("✓ Saved {} favorite tracks to disk cache", tracks.len()); + } + + Ok(tracks) + } + + /// Récupère les playlists (avec cache disque) + pub async fn get_user_playlists(&self) -> Result> { + let user_id = self.auth_info + .as_ref() + .map(|a| &a.user_id) + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))?; + + let cache_key = format!("playlists_{}", user_id); + + // 1. Cache disque (TTL: 30 minutes - les playlists changent plus souvent) + if let Ok(Some(playlists)) = self.disk_cache.load_with_ttl::>( + &cache_key, + Duration::from_secs(1800) + ) { + info!("✓ Loaded {} playlists from disk cache", playlists.len()); + return Ok(playlists); + } + + // 2. API + info!("Fetching playlists from API..."); + let playlists = self.api.get_user_playlists().await?; + + // 3. Sauvegarder + if let Err(e) = self.disk_cache.save(&cache_key, &playlists) { + debug!("Failed to save playlists to disk cache: {}", e); + } else { + info!("✓ Saved {} playlists to disk cache", playlists.len()); + } + + Ok(playlists) + } + + /// Invalide le cache des favoris (après ajout/suppression) + pub async fn invalidate_favorites_cache(&self) -> Result<()> { + let user_id = self.auth_info + .as_ref() + .map(|a| &a.user_id) + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))?; + + self.disk_cache.invalidate(&format!("favorites_albums_{}", user_id))?; + self.disk_cache.invalidate(&format!("favorites_tracks_{}", user_id))?; + self.disk_cache.invalidate(&format!("playlists_{}", user_id))?; + + info!("✓ Invalidated favorites cache"); + Ok(()) + } +} +``` + +### Étape 3 : Méthodes utilitaires + +```rust +impl QobuzClient { + /// Retourne des statistiques sur le cache disque + pub fn disk_cache_stats(&self) -> Result<(usize, u64)> { + let count = self.disk_cache.count()?; + let size = self.disk_cache.size()?; + Ok((count, size)) + } + + /// Vide complètement le cache disque + pub fn clear_disk_cache(&self) -> Result<()> { + self.disk_cache.clear_all() + } +} +``` + +## Structure sur disque + +``` +.pmomusic/ +├── config.yaml +└── cache/ + └── qobuz/ + ├── favorites_albums_1217710.json # 375 albums (~200 KB) + ├── favorites_tracks_1217710.json # Tracks favoris + └── playlists_1217710.json # Playlists utilisateur +``` + +## Bénéfices + +### Sans cache disque (AVANT) +```bash +# Lancement 1 +INFO Fetching 375 favorite albums from API... (2.5s) + +# Lancement 2 (app redémarrée) +INFO Fetching 375 favorite albums from API... (2.5s) ← Requête inutile ! + +# Lancement 3 +INFO Fetching 375 favorite albums from API... (2.5s) ← Requête inutile ! +``` + +**Total** : 3 requêtes API × 2.5s = **7.5 secondes** + +### Avec cache disque (APRÈS) +```bash +# Lancement 1 (cache miss) +INFO Fetching 375 favorite albums from API... (2.5s) +INFO ✓ Saved 375 favorite albums to disk cache + +# Lancement 2 (cache hit!) +INFO ✓ Loaded 375 favorite albums from disk cache (5ms) ← Instantané ! + +# Lancement 3 (cache hit!) +INFO ✓ Loaded 375 favorite albums from disk cache (5ms) ← Instantané ! +``` + +**Total** : 1 requête API × 2.5s + 2 cache hits × 5ms = **2.51 secondes** + +**Amélioration** : **66% plus rapide** + réduction de **66% des requêtes API** + +## TTL recommandés + +| Donnée | TTL | Justification | +|--------|-----|---------------| +| Albums favoris | 1h | Changent rarement | +| Tracks favoris | 1h | Changent rarement | +| Playlists | 30min | Modifiées plus souvent | +| Bibliothèque complète | 24h | Très volumineuse, change peu | + +## Invalidation intelligente + +Invalider le cache après modifications : + +```rust +// Après ajout d'un favori +client.add_favorite_album("123").await?; +client.invalidate_favorites_cache().await?; + +// Après suppression +client.remove_favorite_album("123").await?; +client.invalidate_favorites_cache().await?; +``` + +## Tests + +```bash +# Test du cache disque +cargo test -p pmoqobuz disk_cache + +# Test d'intégration +cargo run --example basic_usage + +# Logs détaillés +RUST_LOG=info,pmoqobuz::disk_cache=debug cargo run --example basic_usage +``` + +## Migration + +Pour ajouter le cache disque au client existant : + +1. Ajouter le champ `disk_cache` à `QobuzClient` +2. Initialiser dans `from_config_obj()` +3. Modifier `get_favorite_albums()`, `get_favorite_tracks()`, etc. +4. Tester avec des gros catalogues (375+ albums) + +## Taille estimée du cache + +Pour un utilisateur avec : +- 375 albums favoris +- 100 tracks favoris +- 10 playlists + +**Taille totale** : ~300 KB (négligeable) + +## Comparaison : pmocache vs DiskCache + +| Critère | pmocache | DiskCache | +|---------|----------|-----------| +| **Complexité** | Élevée (SQLite, download, variants) | Faible (fichiers JSON simples) | +| **Taille overhead** | ~100 KB (SQLite + tables) | 0 (juste les JSON) | +| **Performance** | Excellent pour binaires | Excellent pour JSON | +| **Maintenance** | Complexe | Simple | +| **Adapté pour JSON** | ❌ Non | ✅ Oui | + +**Conclusion** : `DiskCache` est **parfaitement adapté** pour le cache de favoris/bibliothèque. +========= End of pmoqobuz/DISK_CACHE_USAGE.md =========== + +=============== pmoqobuz/src/cache.rs ============ +//! Système de cache en mémoire pour les données Qobuz +//! +//! Ce module fournit un cache en mémoire avec TTL pour minimiser les requêtes à l'API Qobuz. + +use crate::models::{Album, Artist, Playlist, SearchResult, StreamInfo, Track}; +use moka::future::Cache as MokaCache; +use std::sync::Arc; +use std::time::Duration; + +/// Cache principal pour les données Qobuz +#[derive(Clone)] +pub struct QobuzCache { + /// Cache des albums (TTL: 1 heure) + albums: Arc>, + /// Cache des tracks (TTL: 1 heure) + tracks: Arc>, + /// Cache des artistes (TTL: 1 heure) + artists: Arc>, + /// Cache des playlists (TTL: 30 minutes) + playlists: Arc>, + /// Cache des résultats de recherche (TTL: 15 minutes) + searches: Arc>, + /// Cache des URLs de streaming (TTL: 5 minutes) + stream_urls: Arc>, +} + +impl QobuzCache { + /// Crée un nouveau cache avec les paramètres par défaut + pub fn new() -> Self { + Self::with_capacity(1000) + } + + /// Crée un nouveau cache avec une capacité spécifique + pub fn with_capacity(max_capacity: u64) -> Self { + Self { + albums: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), + tracks: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity * 2) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), + artists: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 2) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), + playlists: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 4) + .time_to_live(Duration::from_secs(1800)) // 30 minutes + .build(), + ), + searches: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 2) + .time_to_live(Duration::from_secs(900)) // 15 minutes + .build(), + ), + stream_urls: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 4) + .time_to_live(Duration::from_secs(300)) // 5 minutes + .build(), + ), + } + } + + // ============ Albums ============ + + /// Récupère un album depuis le cache + pub async fn get_album(&self, id: &str) -> Option { + self.albums.get(id).await + } + + /// Ajoute un album au cache + pub async fn put_album(&self, id: String, album: Album) { + self.albums.insert(id, album).await; + } + + /// Invalide un album du cache + pub async fn invalidate_album(&self, id: &str) { + self.albums.invalidate(id).await; + } + + // ============ Tracks ============ + + /// Récupère une track depuis le cache + pub async fn get_track(&self, id: &str) -> Option { + self.tracks.get(id).await + } + + /// Ajoute une track au cache + pub async fn put_track(&self, id: String, track: Track) { + self.tracks.insert(id, track).await; + } + + /// Invalide une track du cache + pub async fn invalidate_track(&self, id: &str) { + self.tracks.invalidate(id).await; + } + + // ============ Artists ============ + + /// Récupère un artiste depuis le cache + pub async fn get_artist(&self, id: &str) -> Option { + self.artists.get(id).await + } + + /// Ajoute un artiste au cache + pub async fn put_artist(&self, id: String, artist: Artist) { + self.artists.insert(id, artist).await; + } + + /// Invalide un artiste du cache + pub async fn invalidate_artist(&self, id: &str) { + self.artists.invalidate(id).await; + } + + // ============ Playlists ============ + + /// Récupère une playlist depuis le cache + pub async fn get_playlist(&self, id: &str) -> Option { + self.playlists.get(id).await + } + + /// Ajoute une playlist au cache + pub async fn put_playlist(&self, id: String, playlist: Playlist) { + self.playlists.insert(id, playlist).await; + } + + /// Invalide une playlist du cache + pub async fn invalidate_playlist(&self, id: &str) { + self.playlists.invalidate(id).await; + } + + // ============ Recherches ============ + + /// Récupère un résultat de recherche depuis le cache + pub async fn get_search(&self, query: &str) -> Option { + self.searches.get(query).await + } + + /// Ajoute un résultat de recherche au cache + pub async fn put_search(&self, query: String, result: SearchResult) { + self.searches.insert(query, result).await; + } + + /// Invalide un résultat de recherche du cache + pub async fn invalidate_search(&self, query: &str) { + self.searches.invalidate(query).await; + } + + // ============ URLs de streaming ============ + + /// Récupère une URL de streaming depuis le cache + pub async fn get_stream_url(&self, track_id: &str) -> Option { + self.stream_urls.get(track_id).await + } + + /// Ajoute une URL de streaming au cache + pub async fn put_stream_url(&self, track_id: String, info: StreamInfo) { + self.stream_urls.insert(track_id, info).await; + } + + /// Invalide une URL de streaming du cache + pub async fn invalidate_stream_url(&self, track_id: &str) { + self.stream_urls.invalidate(track_id).await; + } + + // ============ Maintenance ============ + + /// Vide tous les caches + pub async fn clear_all(&self) { + self.albums.invalidate_all(); + self.tracks.invalidate_all(); + self.artists.invalidate_all(); + self.playlists.invalidate_all(); + self.searches.invalidate_all(); + self.stream_urls.invalidate_all(); + } + + /// Retourne des statistiques sur le cache + pub async fn stats(&self) -> CacheStats { + CacheStats { + albums_count: self.albums.entry_count(), + tracks_count: self.tracks.entry_count(), + artists_count: self.artists.entry_count(), + playlists_count: self.playlists.entry_count(), + searches_count: self.searches.entry_count(), + stream_urls_count: self.stream_urls.entry_count(), + } + } +} + +impl Default for QobuzCache { + fn default() -> Self { + Self::new() + } +} + +/// Statistiques du cache +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CacheStats { + /// Nombre d'albums en cache + pub albums_count: u64, + /// Nombre de tracks en cache + pub tracks_count: u64, + /// Nombre d'artistes en cache + pub artists_count: u64, + /// Nombre de playlists en cache + pub playlists_count: u64, + /// Nombre de recherches en cache + pub searches_count: u64, + /// Nombre d'URLs de streaming en cache + pub stream_urls_count: u64, +} + +impl CacheStats { + /// Retourne le nombre total d'entrées en cache + pub fn total_count(&self) -> u64 { + self.albums_count + + self.tracks_count + + self.artists_count + + self.playlists_count + + self.searches_count + + self.stream_urls_count + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Artist; + + #[tokio::test] + async fn test_cache_basic_operations() { + let cache = QobuzCache::new(); + + let artist = Artist::new("123", "Test Artist"); + + // Test insertion + cache.put_artist("123".to_string(), artist.clone()).await; + + // Test récupération + let retrieved = cache.get_artist("123").await; + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().name, "Test Artist"); + + // Test invalidation + cache.invalidate_artist("123").await; + let after_invalidation = cache.get_artist("123").await; + assert!(after_invalidation.is_none()); + } + + #[tokio::test] + async fn test_cache_stats() { + let cache = QobuzCache::new(); + + let artist = Artist::new("123", "Test Artist"); + cache.put_artist("123".to_string(), artist).await; + + let stats = cache.stats().await; + assert_eq!(stats.artists_count, 1); + assert_eq!(stats.albums_count, 0); + } + + #[tokio::test] + async fn test_cache_clear_all() { + let cache = QobuzCache::new(); + + let artist = Artist::new("123", "Test Artist"); + cache.put_artist("123".to_string(), artist).await; + + cache.clear_all().await; + + let stats = cache.stats().await; + assert_eq!(stats.total_count(), 0); + } +} +========= End of pmoqobuz/src/cache.rs =========== + +=============== pmoqobuz/src/client.rs ============ +//! 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::Config; +use std::sync::Arc; +use tracing::{debug, info}; + +/// 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: Option, +} + +impl QobuzClient { + /// 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 mut api = QobuzApi::new(app_id)?; + let auth_info = api.login(username, password).await?; + + Ok(Self { + api, + cache: Arc::new(QobuzCache::new()), + auth_info: Some(auth_info), + }) + } + + /// 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 + } + + /// Configure le rate limiting sur une API depuis la configuration + /// + /// # Arguments + /// + /// * `api` - L'API Qobuz à configurer + /// * `config` - La configuration contenant les paramètres de rate limiting + fn configure_rate_limiting(api: &mut QobuzApi, config: &Config) { + let rate_limit_enabled = config.is_qobuz_rate_limiting_enabled(); + if rate_limit_enabled { + let max_concurrent = config.get_qobuz_rate_limit_max_concurrent() + .ok() + .flatten() + .unwrap_or(2); + let min_delay = config.get_qobuz_rate_limit_min_delay_ms() + .ok() + .flatten() + .unwrap_or(400); + + info!( + "Enabling Qobuz rate limiting: {} concurrent, {}ms delay", + max_concurrent, min_delay + ); + api.enable_rate_limiting(max_concurrent, min_delay); + } else { + debug!("Qobuz rate limiting disabled in configuration"); + } + } + + /// 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()?; + + // Étape 0 : Essayer de réutiliser le token stocké dans la configuration + // DÉSACTIVÉ TEMPORAIREMENT : Le secret peut être obsolète même si le token est valide. + // Le login est nécessaire pour valider les credentials (app_id) et déclencher le + // Spoofer si besoin. Si le login échoue avec une erreur d'auth, le Spoofer sera + // automatiquement utilisé pour obtenir de nouveaux credentials. + // + // TODO: Implémenter un retry intelligent dans get_stream_url() qui détecte les + // erreurs de signature et rafraîchit automatiquement les credentials via Spoofer. + // Cela permettrait de réactiver la réutilisation du token sans risque. + /* + if let (Ok(Some(token)), Ok(Some(user_id))) = + (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) + { + info!("✓ Found stored authentication token in configuration"); + + // Récupérer l'App ID et le secret depuis la config pour créer l'API + let config_appid = config.get_qobuz_appid()?; + let config_secret = config.get_qobuz_secret()?; + + match (config_appid, config_secret) { + (Some(app_id), Some(secret)) => match QobuzApi::with_secret(&app_id, &secret) { + Ok(mut api) => { + // Configure rate limiting + Self::configure_rate_limiting(&mut api, config); + + // Réutiliser le token de la configuration + api.set_auth_token(token.clone(), user_id.clone()); + + info!("✓ Reusing authentication token (no login required)"); + info!(" → Token will be validated on first API request"); + + let auth_info = AuthInfo { + token, + user_id, + subscription_label: config.get_qobuz_subscription_label().ok().flatten(), + }; + + return Ok(Self { + api, + cache: Arc::new(QobuzCache::new()), + auth_info: Some(auth_info), + }); + } + Err(e) => { + debug!("Failed to create API with stored credentials: {}", e); + info!("→ Credentials in config are invalid, will perform login"); + // Continuer vers le login normal + } + }, + _ => { + debug!("No appid/secret in config, cannot reuse token"); + info!("→ Missing AppID/secret, will perform login"); + // Continuer vers le login normal + } + } + } else { + debug!("No stored authentication token found in configuration, will perform login"); + } + */ + + info!("Performing login to validate credentials and obtain fresh token"); + + // Récupérer l'App ID et le secret depuis la config + let config_appid = config.get_qobuz_appid()?; + let config_secret = config.get_qobuz_secret()?; + + // Déterminer comment créer l'API + let mut api = match (config_appid, config_secret) { + // Cas 1: AppID ET secret configurés → test avec authentification + (Some(app_id), Some(secret)) => { + info!( + "Creating Qobuz API with configured App ID: {} and secret", + app_id + ); + + match QobuzApi::with_secret(&app_id, &secret) { + Ok(mut test_api) => { + // Configure rate limiting + Self::configure_rate_limiting(&mut test_api, config); + + // Tenter l'authentification pour valider les credentials + debug!("Testing configured credentials with login..."); + match test_api.login(&username, &password).await { + Ok(auth_info) => { + info!("✓ Configured credentials are valid"); + + // Sauvegarder le token dans la configuration + use std::time::{SystemTime, UNIX_EPOCH, Duration}; + 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"); + } + + // Les credentials sont valides, retourner directement + return Ok(Self { + api: test_api, + cache: Arc::new(QobuzCache::new()), + auth_info: Some(auth_info), + }); + } + Err(e) if e.is_auth_error() => { + info!("✗ Configured credentials failed authentication: {}", e); + info!("→ Falling back to Spoofer to obtain new credentials..."); + // Continuer vers le Spoofer (voir après le match) + } + Err(e) => { + // Autre erreur (réseau, etc.) → propager + return Err(e); + } + } + } + Err(e) => { + info!("✗ Failed to create API with configured credentials: {}", e); + info!("→ Falling back to Spoofer..."); + // Continuer vers le Spoofer + } + } + + // Si on arrive ici, les credentials configurés ont échoué + // → Appel du Spoofer + Self::try_spoofer_fallback(config).await? + } + + // Cas 2: Aucun ou seulement l'un des deux → utiliser directement le Spoofer + _ => { + info!("AppID or secret not configured, using Spoofer to obtain valid credentials..."); + Self::try_spoofer_fallback(config).await? + } + }; + + // Configure rate limiting + Self::configure_rate_limiting(&mut api, config); + + // Authentifier l'utilisateur + let auth_info = api.login(&username, &password).await?; + + // Sauvegarder le token dans la configuration pour éviter de re-login la prochaine fois + 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"); + } + + Ok(Self { + api, + cache: Arc::new(QobuzCache::new()), + auth_info: Some(auth_info), + }) + } + + /// 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 { + match crate::api::Spoofer::new().await { + Ok(spoofer) => { + match spoofer.get_app_id() { + Ok(app_id) => { + info!("Spoofer found App ID: {}", app_id); + + match spoofer.get_secrets() { + Ok(secrets) => { + info!("Spoofer found {} secret(s), testing them...", secrets.len()); + + // Tester chaque secret pour trouver celui qui fonctionne + for (timezone, secret) in secrets.iter() { + debug!("Testing secret for timezone: {}", timezone); + + match QobuzApi::with_secret(&app_id, secret) { + Ok(test_api) => { + info!("✓ Successfully created API with secret from timezone: {}", timezone); + + // Sauvegarder les credentials valides dans la config + if let Err(e) = config.set_qobuz_appid(&app_id) { + debug!("Could not save appid to config: {}", e); + } + if let Err(e) = config.set_qobuz_secret(secret) { + debug!("Could not save secret to config: {}", e); + } + + return Ok(test_api); + } + Err(e) => { + debug!("Failed to create API with secret from {}: {}", timezone, e); + continue; + } + } + } + + // Si aucun secret n'a fonctionné, utiliser le fallback + info!("✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret"); + QobuzApi::new(DEFAULT_APP_ID) + } + Err(e) => { + info!("Spoofer failed to extract secrets: {}, falling back to DEFAULT_APP_ID", e); + QobuzApi::new(DEFAULT_APP_ID) + } + } + } + Err(e) => { + info!("Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID", e); + QobuzApi::new(DEFAULT_APP_ID) + } + } + } + Err(e) => { + info!("Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret", e); + QobuzApi::new(DEFAULT_APP_ID) + } + } + } + + /// 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<&AuthInfo> { + self.auth_info.as_ref() + } + + /// Retourne une référence au cache + pub fn cache(&self) -> Arc { + self.cache.clone() + } + + // ============ 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.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> { + let tracks = self.api.get_album_tracks(album_id).await?; + + // Mettre les tracks en cache + for track in &tracks { + self.cache.put_track(track.id.clone(), track.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.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.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.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.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.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.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.api.get_playlist_tracks(playlist_id).await + } + + // ============ Catalogue ============ + + /// Récupère la liste des genres + pub async fn get_genres(&self) -> Result> { + 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.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.api.get_featured_playlists(genre_id, tags).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.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> { + self.api.get_favorite_albums().await + } + + /// Récupère les artistes favoris de l'utilisateur + pub async fn get_favorite_artists(&self) -> Result> { + self.api.get_favorite_artists().await + } + + /// Récupère les tracks favorites de l'utilisateur + pub async fn get_favorite_tracks(&self) -> Result> { + self.api.get_favorite_tracks().await + } + + /// Récupère les playlists de l'utilisateur + pub async fn get_user_playlists(&self) -> Result> { + self.api.get_user_playlists().await + } + + /// Ajoute un album aux favoris + pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> { + 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.api.remove_favorite_album(album_id).await + } + + /// Ajoute un track aux favoris + pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> { + 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.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.api.add_to_playlist(playlist_id, track_id).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audio_format() { + assert_eq!(AudioFormat::default(), AudioFormat::Flac_Lossless); + } +} +========= End of pmoqobuz/src/client.rs =========== + +=============== pmoqobuz/src/error.rs ============ +//! Gestion des erreurs pour le client Qobuz + +use thiserror::Error; + +/// Type Result personnalisé pour pmoqobuz +pub type Result = std::result::Result; + +/// Erreurs possibles lors de l'utilisation du client Qobuz +#[derive(Error, Debug)] +pub enum QobuzError { + /// Erreur d'authentification (credentials invalides) + #[error("Authentication failed: {0}")] + Unauthorized(String), + + /// Ressource non trouvée (album, track, etc.) + #[error("Resource not found: {0}")] + NotFound(String), + + /// Erreur HTTP + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + /// Erreur de parsing JSON + #[error("JSON parsing error: {0}")] + JsonParse(#[from] serde_json::Error), + + /// Erreur de configuration (anyhow) + #[error("Configuration error: {0}")] + Config(#[from] anyhow::Error), + + /// Erreur de configuration Qobuz (App ID, secret, etc.) + #[error("Qobuz configuration error: {0}")] + Configuration(String), + + /// Erreur de l'API Qobuz + #[error("Qobuz API error (code {code}): {message}")] + ApiError { code: u16, message: String }, + + /// Quota dépassé (rate limiting) + #[error("Rate limit exceeded, please try again later")] + RateLimitExceeded, + + /// Contenu non disponible dans la région de l'utilisateur + #[error("Content not available in your region")] + NotAvailable, + + /// Abonnement insuffisant pour accéder au contenu + #[error("Subscription level insufficient: {0}")] + SubscriptionRequired(String), + + /// Erreur de cache + #[error("Cache error: {0}")] + Cache(String), + + /// Erreur d'export DIDL + #[error("DIDL export error: {0}")] + DidlExport(String), + + /// Erreur générique + #[error("Qobuz error: {0}")] + Other(String), +} + +impl QobuzError { + /// Crée une erreur API depuis un code de statut HTTP et un message + pub fn from_status_code(code: u16, message: impl Into) -> Self { + match code { + 401 | 403 => Self::Unauthorized(message.into()), + 404 => Self::NotFound(message.into()), + 429 => Self::RateLimitExceeded, + _ => Self::ApiError { + code, + message: message.into(), + }, + } + } + + /// Vérifie si l'erreur est une erreur de credentials (401/403) + /// ou d'AppID invalide (400 avec "app_id") + pub fn is_auth_error(&self) -> bool { + match self { + QobuzError::Unauthorized(_) => true, + QobuzError::ApiError { code: 400, message } + if message.contains("app_id") || message.contains("Invalid") => true, + _ => false, + } + } + + /// Vérifie si l'erreur est une erreur de rate limiting + pub fn is_rate_limit(&self) -> bool { + matches!(self, QobuzError::RateLimitExceeded) + } +} +========= End of pmoqobuz/src/error.rs =========== + +=============== pmoqobuz/src/lib.rs ============ +//! # pmoqobuz - Client Qobuz pour PMOMusic +//! +//! Cette crate fournit un client Rust pour l'API Qobuz, inspiré de l'implémentation Python d'upmpdcli, +//! avec un système de cache en mémoire et une intégration avec les autres modules PMOMusic. +//! +//! ## Vue d'ensemble +//! +//! `pmoqobuz` permet d'accéder aux fonctionnalités de Qobuz : +//! - Authentification avec les credentials configurés +//! - Navigation dans le catalogue (albums, artistes, playlists, tracks) +//! - Recherche dans le catalogue +//! - Accès aux favoris de l'utilisateur +//! - Cache en mémoire pour minimiser les requêtes API +//! - Export des objets en format DIDL-Lite (via `pmodidl`) +//! - Cache des images d'albums (via `pmocovers`) +//! +//! ## Architecture +//! +//! La crate suit le pattern d'extension des autres crates PMO : +//! - `QobuzClient` : Client principal avec authentification et cache +//! - `models` : Structures de données (Album, Track, Artist, etc.) +//! - `api` : Couche d'accès à l'API REST Qobuz +//! - `cache` : Système de cache en mémoire avec TTL +//! - `didl` : Export des objets en format DIDL-Lite +//! +//! ## Structure des modules +//! +//! ```text +//! pmoqobuz/ +//! ├── src/ +//! │ ├── lib.rs # Module principal (ce fichier) +//! │ ├── client.rs # Client Qobuz principal +//! │ ├── models.rs # Structures de données +//! │ ├── api/ +//! │ │ ├── mod.rs # API client +//! │ │ ├── auth.rs # Authentification +//! │ │ ├── catalog.rs # Accès au catalogue +//! │ │ └── user.rs # API utilisateur (favoris) +//! │ ├── cache.rs # Cache en mémoire +//! │ ├── didl.rs # Export DIDL-Lite +//! │ └── error.rs # Gestion des erreurs +//! ``` +//! +//! ## Utilisation +//! +//! ### Exemple basique avec configuration automatique +//! +//! ```rust,no_run +//! use pmoqobuz::QobuzClient; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! // Utilise automatiquement la config depuis pmoconfig +//! let client = QobuzClient::from_config().await?; +//! +//! // Rechercher des albums +//! let results = client.search_albums("Miles Davis").await?; +//! for album in results { +//! println!("{} - {}", album.artist.name, album.title); +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! ### Exemple avec credentials personnalisés +//! +//! ```rust,no_run +//! use pmoqobuz::QobuzClient; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let client = QobuzClient::new("user@example.com", "password").await?; +//! +//! // Obtenir les albums favoris +//! let favorites = client.get_favorite_albums().await?; +//! +//! Ok(()) +//! } +//! ``` +//! +//! ### Export DIDL-Lite +//! +//! ```rust,no_run +//! use pmoqobuz::QobuzClient; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let client = QobuzClient::from_config().await?; +//! +//! let album = client.get_album("12345").await?; +//! let didl_container = album.to_didl_container("parent_id")?; +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## Cache +//! +//! Le client utilise un cache en mémoire avec TTL pour minimiser les requêtes à l'API Qobuz : +//! - Albums : 1 heure +//! - Tracks : 1 heure +//! - Artistes : 1 heure +//! - Playlists : 30 minutes +//! - Résultats de recherche : 15 minutes +//! - URLs de streaming : 5 minutes +//! +//! ## Intégration pmocovers et pmoaudiocache +//! +//! La feature `cache` active le support complet du cache pour les images et l'audio. +//! +//! ### Cache d'images (pmocovers) +//! +//! Les images de couverture sont automatiquement téléchargées et converties en WebP : +//! +//! ```rust,no_run +//! use pmoqobuz::{QobuzSource, QobuzClient}; +//! use pmocovers::Cache as CoverCache; +//! use std::sync::Arc; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = QobuzClient::from_config().await?; +//! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); +//! +//! let source = QobuzSource::new_with_cache( +//! client, +//! "http://localhost:8080", +//! Some(cover_cache), +//! None, +//! ); +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Cache audio (pmoaudiocache) +//! +//! L'audio haute résolution est téléchargé et caché localement avec métadonnées enrichies : +//! +//! ```rust,no_run +//! use pmoqobuz::{QobuzSource, QobuzClient}; +//! use pmocovers::Cache as CoverCache; +//! use pmoaudiocache::AudioCache; +//! use std::sync::Arc; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = QobuzClient::from_config().await?; +//! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); +//! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); +//! +//! let source = QobuzSource::new_with_cache( +//! client.clone(), +//! "http://localhost:8080", +//! Some(cover_cache), +//! Some(audio_cache), +//! ); +//! +//! // Add a track with caching +//! let tracks = client.get_favorite_tracks().await?; +//! if let Some(track) = tracks.first() { +//! let track_id = source.add_track(track).await?; +//! // Audio and cover are now cached with rich metadata +//! +//! // Resolve URI (returns cached version if available) +//! let uri = source.resolve_uri(&track_id).await?; +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Métadonnées enrichies +//! +//! Qobuz fournit des métadonnées détaillées qui sont préservées dans le cache : +//! - Titre, artiste, album +//! - Numéro de piste et de disque +//! - Année de sortie +//! - Genre(s) +//! - Label +//! - Qualité audio (sample rate, bit depth, channels) +//! - Durée +//! +//! ### Exemple complet +//! +//! Voir `examples/with_cache.rs` pour un exemple complet d'utilisation avec cache. +//! +//! ## Formats audio supportés +//! +//! Qobuz propose plusieurs formats : +//! - Format 5 : MP3 320 kbps +//! - Format 6 : FLAC 16 bit / 44.1 kHz (CD Quality) +//! - Format 7 : FLAC 24 bit / jusqu'à 96 kHz (Hi-Res) +//! - Format 27 : FLAC 24 bit / jusqu'à 192 kHz (Hi-Res+) +//! +//! ## Gestion des erreurs +//! +//! La crate utilise `thiserror` pour définir des erreurs typées : +//! +//! ```rust,ignore +//! use pmoqobuz::{QobuzClient, QobuzError}; +//! +//! match client.get_album("invalid").await { +//! Ok(album) => println!("Album: {}", album.title), +//! Err(QobuzError::NotFound) => println!("Album not found"), +//! Err(QobuzError::Unauthorized) => println!("Authentication failed"), +//! Err(e) => println!("Error: {}", e), +//! } +//! ``` +//! +//! ## Voir aussi +//! +//! - [`pmodidl`] : Format DIDL-Lite +//! - [`pmocovers`] : Cache d'images +//! - [`pmoaudiocache`] : Cache audio +//! - [`pmoconfig`] : Configuration +//! - [`pmoserver`] : Serveur HTTP + +pub mod api; +pub mod cache; +pub mod client; +pub mod config_ext; +pub mod didl; +pub mod disk_cache; +pub mod error; +pub mod models; +pub mod source; + +// Extension pmoserver (feature-gated) +#[cfg(feature = "pmoserver")] +pub mod api_rest; + +#[cfg(feature = "pmoserver")] +pub mod pmoserver_ext; + +#[cfg(feature = "pmoserver")] +mod pmoserver_impl; + +pub use client::QobuzClient; +pub use config_ext::QobuzConfigExt; +pub use error::{QobuzError, Result}; +pub use models::{Album, Artist, AudioFormat, Genre, Playlist, SearchResult, Track}; +pub use source::QobuzSource; + +/// Ré-exporte les types DIDL pour faciliter l'utilisation +pub use didl::ToDIDL; + +/// Ré-exporte le trait d'extension pmoserver +#[cfg(feature = "pmoserver")] +pub use pmoserver_ext::QobuzServerExt; +========= End of pmoqobuz/src/lib.rs =========== + +=============== pmoqobuz/src/disk_cache.rs ============ +//! Cache disque simple pour les données volumineuses de l'API Qobuz +//! +//! Ce module gère le cache sur disque des données qui changent rarement : +//! - Favoris (albums, tracks, artistes) +//! - Playlists utilisateur +//! - Bibliothèque +//! +//! Contrairement à pmocache (conçu pour des fichiers binaires avec téléchargement), +//! ce cache est optimisé pour du JSON provenant de l'API. + +use anyhow::{anyhow, Result}; +use serde::{de::DeserializeOwned, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; +use tracing::{debug, info}; + +/// Cache disque pour données JSON de l'API Qobuz +pub struct DiskCache { + /// Répertoire de cache + cache_dir: PathBuf, +} + +impl DiskCache { + /// Crée un nouveau cache disque + /// + /// # Arguments + /// + /// * `cache_dir` - Répertoire où stocker les fichiers cachés + /// + /// # Example + /// + /// ```rust,no_run + /// use pmoqobuz::disk_cache::DiskCache; + /// + /// let cache = DiskCache::new(".pmomusic/cache/qobuz")?; + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn new>(cache_dir: P) -> Result { + let cache_dir = cache_dir.as_ref().to_path_buf(); + + // Créer le répertoire s'il n'existe pas + if !cache_dir.exists() { + fs::create_dir_all(&cache_dir)?; + info!("Created cache directory: {}", cache_dir.display()); + } + + Ok(Self { cache_dir }) + } + + /// Construit le chemin d'un fichier de cache + /// + /// Format: `{cache_dir}/{key}.json` + fn cache_path(&self, key: &str) -> PathBuf { + self.cache_dir.join(format!("{}.json", key)) + } + + /// Sauvegarde des données dans le cache + /// + /// # Arguments + /// + /// * `key` - Identifiant unique du cache (ex: "favorites_albums_123456") + /// * `data` - Données à sauvegarder + /// + /// # Example + /// + /// ```rust,no_run + /// # use pmoqobuz::disk_cache::DiskCache; + /// # use pmoqobuz::Album; + /// # let cache = DiskCache::new(".cache")?; + /// let albums: Vec = vec![/* ... */]; + /// cache.save("favorites_albums_123", &albums)?; + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn save(&self, key: &str, data: &T) -> Result<()> { + let path = self.cache_path(key); + let json = serde_json::to_string_pretty(data)?; + + fs::write(&path, json)?; + debug!("Saved cache to {}", path.display()); + + Ok(()) + } + + /// Charge des données depuis le cache + /// + /// # Arguments + /// + /// * `key` - Identifiant unique du cache + /// + /// # Returns + /// + /// Les données désérialisées, ou None si le cache n'existe pas + /// + /// # Example + /// + /// ```rust,no_run + /// # use pmoqobuz::disk_cache::DiskCache; + /// # use pmoqobuz::Album; + /// # let cache = DiskCache::new(".cache")?; + /// if let Some(albums) = cache.load::>("favorites_albums_123")? { + /// println!("Loaded {} albums from cache", albums.len()); + /// } + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn load(&self, key: &str) -> Result> { + let path = self.cache_path(key); + + if !path.exists() { + debug!("Cache file does not exist: {}", path.display()); + return Ok(None); + } + + let json = fs::read_to_string(&path)?; + let data: T = serde_json::from_str(&json)?; + + debug!("Loaded cache from {}", path.display()); + Ok(Some(data)) + } + + /// Charge des données avec vérification du TTL + /// + /// # Arguments + /// + /// * `key` - Identifiant unique du cache + /// * `ttl` - Durée de validité maximale + /// + /// # Returns + /// + /// Les données si le cache existe ET n'est pas expiré, None sinon + /// + /// # Example + /// + /// ```rust,no_run + /// # use pmoqobuz::disk_cache::DiskCache; + /// # use pmoqobuz::Album; + /// # use std::time::Duration; + /// # let cache = DiskCache::new(".cache")?; + /// // Cache valide pendant 1 heure + /// if let Some(albums) = cache.load_with_ttl::>( + /// "favorites_albums_123", + /// Duration::from_secs(3600) + /// )? { + /// println!("Cache still valid!"); + /// } else { + /// println!("Cache expired or missing"); + /// } + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn load_with_ttl( + &self, + key: &str, + ttl: Duration, + ) -> Result> { + let path = self.cache_path(key); + + if !path.exists() { + debug!("Cache file does not exist: {}", path.display()); + return Ok(None); + } + + // Vérifier l'âge du fichier + let metadata = fs::metadata(&path)?; + let modified = metadata.modified()?; + let age = SystemTime::now() + .duration_since(modified) + .unwrap_or(Duration::MAX); + + if age > ttl { + debug!( + "Cache expired (age: {}s > ttl: {}s): {}", + age.as_secs(), + ttl.as_secs(), + path.display() + ); + // Optionnel : supprimer le fichier expiré + let _ = fs::remove_file(&path); + return Ok(None); + } + + debug!( + "Cache valid (age: {}s < ttl: {}s): {}", + age.as_secs(), + ttl.as_secs(), + path.display() + ); + + let json = fs::read_to_string(&path)?; + let data: T = serde_json::from_str(&json)?; + + Ok(Some(data)) + } + + /// Invalide (supprime) un cache + /// + /// # Arguments + /// + /// * `key` - Identifiant unique du cache + pub fn invalidate(&self, key: &str) -> Result<()> { + let path = self.cache_path(key); + + if path.exists() { + fs::remove_file(&path)?; + debug!("Invalidated cache: {}", path.display()); + } + + Ok(()) + } + + /// Supprime tous les fichiers de cache + pub fn clear_all(&self) -> Result<()> { + for entry in fs::read_dir(&self.cache_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + fs::remove_file(&path)?; + debug!("Removed cache file: {}", path.display()); + } + } + + info!("Cleared all cache files"); + Ok(()) + } + + /// Retourne la taille totale du cache en octets + pub fn size(&self) -> Result { + let mut total = 0u64; + + for entry in fs::read_dir(&self.cache_dir)? { + let entry = entry?; + let metadata = entry.metadata()?; + + if metadata.is_file() { + total += metadata.len(); + } + } + + Ok(total) + } + + /// Retourne le nombre de fichiers en cache + pub fn count(&self) -> Result { + let mut count = 0; + + for entry in fs::read_dir(&self.cache_dir)? { + let entry = entry?; + + if entry.path().extension().and_then(|s| s.to_str()) == Some("json") { + count += 1; + } + } + + Ok(count) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use tempfile::tempdir; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestData { + id: String, + value: i32, + } + + #[test] + fn test_save_and_load() -> Result<()> { + let dir = tempdir()?; + let cache = DiskCache::new(dir.path())?; + + let data = TestData { + id: "test123".to_string(), + value: 42, + }; + + // Sauvegarder + cache.save("test_key", &data)?; + + // Charger + let loaded: Option = cache.load("test_key")?; + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap(), data); + + Ok(()) + } + + #[test] + fn test_load_nonexistent() -> Result<()> { + let dir = tempdir()?; + let cache = DiskCache::new(dir.path())?; + + let loaded: Option = cache.load("nonexistent")?; + assert!(loaded.is_none()); + + Ok(()) + } + + #[test] + fn test_ttl() -> Result<()> { + let dir = tempdir()?; + let cache = DiskCache::new(dir.path())?; + + let data = TestData { + id: "test123".to_string(), + value: 42, + }; + + cache.save("test_key", &data)?; + + // Charger immédiatement (< TTL) + let loaded: Option = + cache.load_with_ttl("test_key", Duration::from_secs(60))?; + assert!(loaded.is_some()); + + // Charger avec TTL expiré + let loaded: Option = cache.load_with_ttl("test_key", Duration::from_secs(0))?; + assert!(loaded.is_none()); + + Ok(()) + } + + #[test] + fn test_invalidate() -> Result<()> { + let dir = tempdir()?; + let cache = DiskCache::new(dir.path())?; + + let data = TestData { + id: "test123".to_string(), + value: 42, + }; + + cache.save("test_key", &data)?; + assert!(cache.load::("test_key")?.is_some()); + + cache.invalidate("test_key")?; + assert!(cache.load::("test_key")?.is_none()); + + Ok(()) + } + + #[test] + fn test_size_and_count() -> Result<()> { + let dir = tempdir()?; + let cache = DiskCache::new(dir.path())?; + + assert_eq!(cache.count()?, 0); + assert_eq!(cache.size()?, 0); + + let data = TestData { + id: "test123".to_string(), + value: 42, + }; + + cache.save("test1", &data)?; + cache.save("test2", &data)?; + + assert_eq!(cache.count()?, 2); + assert!(cache.size()? > 0); + + Ok(()) + } +} +========= End of pmoqobuz/src/disk_cache.rs =========== + +=============== pmoqobuz/src/models.rs ============ +//! Structures de données pour représenter les objets Qobuz + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; + +/// Désérialiseur flexible pour les IDs qui peuvent être des strings ou des integers +pub(crate) fn deserialize_id<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + use serde_json::Value; + + let value = Value::deserialize(deserializer)?; + match value { + Value::String(s) => Ok(s), + Value::Number(n) => Ok(n.to_string()), + _ => Err(Error::custom("ID must be a string or number")), + } +} + +/// Représente un artiste Qobuz +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Artist { + /// Identifiant unique de l'artiste + #[serde(deserialize_with = "deserialize_id")] + pub id: String, + /// Nom de l'artiste + pub name: String, + /// URL de l'image de l'artiste (optionnelle) + #[serde(default)] + pub image: Option, + /// URL de l'image cachée localement (via pmocovers) + #[serde(skip)] + pub image_cached: Option, +} + +/// Représente un album Qobuz +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Album { + /// Identifiant unique de l'album + #[serde(deserialize_with = "deserialize_id")] + pub id: String, + /// Titre de l'album + pub title: String, + /// Artiste principal de l'album + pub artist: Artist, + /// Nombre de pistes + #[serde(default)] + pub tracks_count: Option, + /// Durée totale en secondes + #[serde(default)] + pub duration: Option, + /// Date de sortie (format ISO 8601) + #[serde(default)] + pub release_date: Option, + /// URL de l'image de couverture + #[serde(default)] + pub image: Option, + /// URL de l'image cachée localement (via pmocovers) + #[serde(skip)] + pub image_cached: Option, + /// Indique si l'album est disponible pour le streaming + #[serde(default = "default_true")] + pub streamable: bool, + /// Description de l'album + #[serde(default)] + pub description: Option, + /// Taux d'échantillonnage maximum (Hz) + #[serde(default)] + pub maximum_sampling_rate: Option, + /// Profondeur de bits maximale + #[serde(default)] + pub maximum_bit_depth: Option, + /// Genre(s) de l'album + #[serde(default)] + pub genres: Vec, + /// Label de l'album + #[serde(default)] + pub label: Option, +} + +/// Représente une piste (track) Qobuz +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Track { + /// Identifiant unique de la piste + #[serde(deserialize_with = "deserialize_id")] + pub id: String, + /// Titre de la piste + pub title: String, + /// Artiste de la piste (peut différer de l'artiste de l'album) + pub performer: Option, + /// Album contenant la piste + pub album: Option, + /// Durée en secondes + pub duration: u32, + /// Numéro de piste + pub track_number: u32, + /// Numéro de disque (pour les albums multi-disques) + pub media_number: u32, + /// Indique si la piste est disponible pour le streaming + #[serde(default = "default_true")] + pub streamable: bool, + /// Type MIME du fichier audio (déterminé après obtention de l'URL) + #[serde(skip)] + pub mime_type: Option, + /// Fréquence d'échantillonnage (Hz) + #[serde(skip)] + pub sample_rate: Option, + /// Profondeur de bits + #[serde(skip)] + pub bit_depth: Option, + /// Nombre de canaux audio + #[serde(skip)] + pub channels: Option, +} + +/// Représente une playlist Qobuz +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Playlist { + /// Identifiant unique de la playlist + #[serde(deserialize_with = "deserialize_id")] + pub id: String, + /// Nom de la playlist + pub name: String, + /// Description de la playlist + #[serde(default)] + pub description: Option, + /// Nombre de pistes + #[serde(default)] + pub tracks_count: Option, + /// Durée totale en secondes + #[serde(default)] + pub duration: Option, + /// URL de l'image de la playlist + #[serde(default)] + pub image: Option, + /// URL de l'image cachée localement + #[serde(skip)] + pub image_cached: Option, + /// Indique si c'est une playlist publique + #[serde(default)] + pub is_public: bool, + /// Propriétaire de la playlist + #[serde(default)] + pub owner: Option, +} + +/// Propriétaire d'une playlist +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaylistOwner { + /// Identifiant de l'utilisateur + pub id: u64, + /// Nom de l'utilisateur + pub name: String, +} + +/// Représente un genre musical +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Genre { + /// Identifiant du genre (peut être None pour "All Genres") + pub id: Option, + /// Nom du genre + pub name: String, + /// Genres enfants + #[serde(default)] + pub children: Vec, +} + +/// Résultats de recherche +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SearchResult { + /// Albums trouvés + #[serde(default)] + pub albums: Vec, + /// Artistes trouvés + #[serde(default)] + pub artists: Vec, + /// Pistes trouvées + #[serde(default)] + pub tracks: Vec, + /// Playlists trouvées + #[serde(default)] + pub playlists: Vec, +} + +/// Informations sur un fichier de streaming +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamInfo { + /// URL de streaming + pub url: String, + /// Type MIME + pub mime_type: String, + /// Fréquence d'échantillonnage (Hz) + pub sampling_rate: u32, + /// Profondeur de bits + pub bit_depth: u32, + /// Format ID Qobuz + pub format_id: u8, + /// Date d'expiration de l'URL + #[serde(skip)] + pub expires_at: DateTime, +} + +/// Format audio demandé pour le streaming +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +#[allow(non_camel_case_types)] +pub enum AudioFormat { + /// MP3 320 kbps + Mp3_320 = 5, + /// FLAC 16 bit / 44.1 kHz (CD Quality) + Flac_Lossless = 6, + /// FLAC 24 bit / jusqu'à 96 kHz (Hi-Res) + Flac_HiRes_96 = 7, + /// FLAC 24 bit / jusqu'à 192 kHz (Hi-Res+) + Flac_HiRes_192 = 27, +} + +impl AudioFormat { + /// Retourne l'ID du format pour l'API Qobuz + pub fn id(&self) -> u8 { + *self as u8 + } + + /// Retourne une description lisible du format + pub fn description(&self) -> &'static str { + match self { + AudioFormat::Mp3_320 => "MP3 320 kbps", + AudioFormat::Flac_Lossless => "FLAC 16 bit / 44.1 kHz", + AudioFormat::Flac_HiRes_96 => "FLAC 24 bit / up to 96 kHz", + AudioFormat::Flac_HiRes_192 => "FLAC 24 bit / up to 192 kHz", + } + } + + /// Retourne le type MIME associé + pub fn mime_type(&self) -> &'static str { + match self { + AudioFormat::Mp3_320 => "audio/mpeg", + _ => "audio/flac", + } + } +} + +impl Default for AudioFormat { + fn default() -> Self { + AudioFormat::Flac_Lossless + } +} + +// Helper functions +fn default_true() -> bool { + true +} + +impl Artist { + /// Crée un nouvel artiste avec un ID et un nom + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + image: None, + image_cached: None, + } + } +} + +impl Album { + /// Retourne un titre formaté avec les informations audio si disponibles + pub fn formatted_title(&self) -> String { + if let (Some(rate), Some(depth)) = (self.maximum_sampling_rate, self.maximum_bit_depth) { + format!("{} ({:.0}/{} bit)", self.title, rate / 1000.0, depth) + } else { + self.title.clone() + } + } + + /// Vérifie si l'album est disponible pour le streaming + pub fn is_available(&self) -> bool { + self.streamable + } +} + +impl Track { + /// Retourne l'artiste à afficher (performer ou artiste de l'album) + pub fn display_artist(&self) -> Option<&Artist> { + self.performer + .as_ref() + .or_else(|| self.album.as_ref().map(|a| &a.artist)) + } + + /// Retourne le nom de l'album si disponible + pub fn album_name(&self) -> Option<&str> { + self.album.as_ref().map(|a| a.title.as_str()) + } + + /// Vérifie si la piste est disponible pour le streaming + pub fn is_available(&self) -> bool { + self.streamable + } +} + +impl SearchResult { + /// Crée un résultat de recherche vide + pub fn new() -> Self { + Self::default() + } + + /// Retourne le nombre total de résultats + pub fn total_count(&self) -> usize { + self.albums.len() + self.artists.len() + self.tracks.len() + self.playlists.len() + } + + /// Vérifie si la recherche n'a retourné aucun résultat + pub fn is_empty(&self) -> bool { + self.total_count() == 0 + } +} +========= End of pmoqobuz/src/models.rs =========== + +=============== pmoqobuz/src/didl.rs ============ +//! Export des objets Qobuz en format DIDL-Lite +//! +//! Ce module permet de convertir les structures Qobuz (Album, Track, etc.) +//! en objets DIDL-Lite compatibles avec UPnP/DLNA. + +use crate::error::{QobuzError, Result}; +use crate::models::{Album, Playlist, Track}; +use pmodidl::{Container, Item, Resource}; + +/// Trait pour convertir un objet Qobuz en DIDL-Lite +pub trait ToDIDL { + /// Convertit l'objet en Container DIDL + fn to_didl_container(&self, parent_id: &str) -> Result; + + /// Convertit l'objet en Item DIDL + fn to_didl_item(&self, parent_id: &str) -> Result; +} + +impl ToDIDL for Album { + /// Convertit un album en Container DIDL + /// + /// # Arguments + /// + /// * `parent_id` - ID du container parent + /// + /// # Exemple + /// + /// ```rust,ignore + /// let album = client.get_album("12345").await?; + /// let container = album.to_didl_container("0$qobuz$albums")?; + /// ``` + fn to_didl_container(&self, parent_id: &str) -> Result { + let id = format!("0$qobuz$album${}", self.id); + + Ok(Container { + id, + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + child_count: self.tracks_count.map(|c| c.to_string()), + searchable: Some("1".to_string()), + title: self.formatted_title(), + class: "object.container.album.musicAlbum".to_string(), + containers: Vec::new(), + items: Vec::new(), + }) + } + + /// Un album ne peut pas être converti directement en Item + fn to_didl_item(&self, _parent_id: &str) -> Result { + Err(QobuzError::DidlExport( + "Album cannot be converted to Item, use to_didl_container instead".to_string(), + )) + } +} + +impl ToDIDL for Track { + /// Une track ne peut pas être convertie en Container + fn to_didl_container(&self, _parent_id: &str) -> Result { + Err(QobuzError::DidlExport( + "Track cannot be converted to Container, use to_didl_item instead".to_string(), + )) + } + + /// Convertit une track en Item DIDL + /// + /// # Arguments + /// + /// * `parent_id` - ID du container parent + /// + /// # Exemple + /// + /// ```rust,ignore + /// let track = client.get_track("98765").await?; + /// let item = track.to_didl_item("0$qobuz$album$12345")?; + /// ``` + fn to_didl_item(&self, parent_id: &str) -> Result { + let id = format!("0$qobuz$track${}", self.id); + + // Déterminer l'artiste à afficher + let artist_name = self + .display_artist() + .map(|a| a.name.clone()) + .or_else(|| self.album.as_ref().map(|a| a.artist.name.clone())); + + // Déterminer l'album + let album_name = self.album_name().map(|s| s.to_string()); + + // Déterminer l'image de couverture + let album_art = self + .album + .as_ref() + .and_then(|a| a.image_cached.clone().or_else(|| a.image.clone())); + + // Créer la ressource (URL de streaming) + // Note: L'URL sera remplie plus tard via get_stream_url + let resource = Resource { + protocol_info: format!( + "http-get:*:{}:*", + self.mime_type.as_deref().unwrap_or("audio/flac") + ), + bits_per_sample: self.bit_depth.map(|b| b.to_string()), + sample_frequency: self.sample_rate.map(|r| r.to_string()), + nr_audio_channels: self.channels.map(|c| c.to_string()), + duration: Some(format_duration(self.duration)), + url: format!("qobuz://track/{}", self.id), // URL symbolique + }; + + Ok(Item { + id, + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + title: self.title.clone(), + creator: artist_name.clone(), + class: "object.item.audioItem.musicTrack".to_string(), + artist: artist_name, + album: album_name, + genre: None, // Qobuz ne fournit pas le genre au niveau track + album_art, + album_art_pk: None, + date: self.album.as_ref().and_then(|a| a.release_date.clone()), + original_track_number: Some(self.track_number.to_string()), + resources: vec![resource], + descriptions: Vec::new(), + }) + } +} + +impl ToDIDL for Playlist { + /// Convertit une playlist en Container DIDL + fn to_didl_container(&self, parent_id: &str) -> Result { + let id = format!("0$qobuz$playlist${}", self.id); + + Ok(Container { + id, + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + child_count: self.tracks_count.map(|c| c.to_string()), + searchable: Some("1".to_string()), + title: self.name.clone(), + class: "object.container.playlistContainer".to_string(), + containers: Vec::new(), + items: Vec::new(), + }) + } + + /// Une playlist ne peut pas être convertie en Item + fn to_didl_item(&self, _parent_id: &str) -> Result { + Err(QobuzError::DidlExport( + "Playlist cannot be converted to Item, use to_didl_container instead".to_string(), + )) + } +} + +/// Formate une durée en secondes au format HH:MM:SS +fn format_duration(seconds: u32) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, secs) +} + +/// Convertit une liste de tracks en items DIDL +pub fn tracks_to_didl_items(tracks: &[Track], parent_id: &str) -> Result> { + tracks + .iter() + .map(|track| track.to_didl_item(parent_id)) + .collect() +} + +/// Convertit une liste d'albums en containers DIDL +pub fn albums_to_didl_containers(albums: &[Album], parent_id: &str) -> Result> { + albums + .iter() + .map(|album| album.to_didl_container(parent_id)) + .collect() +} + +/// Convertit une liste de playlists en containers DIDL +pub fn playlists_to_didl_containers( + playlists: &[Playlist], + parent_id: &str, +) -> Result> { + playlists + .iter() + .map(|playlist| playlist.to_didl_container(parent_id)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Album, Artist, Track}; + + #[test] + fn test_album_to_didl_container() { + let album = Album { + id: "123".to_string(), + title: "Test Album".to_string(), + artist: Artist::new("456", "Test Artist"), + tracks_count: Some(10), + duration: Some(3000), + release_date: Some("2024-01-01".to_string()), + image: None, + image_cached: None, + streamable: true, + description: None, + maximum_sampling_rate: Some(96000.0), + maximum_bit_depth: Some(24), + genres: vec![], + label: None, + }; + + let container = album.to_didl_container("parent").unwrap(); + assert_eq!(container.id, "0$qobuz$album$123"); + assert_eq!(container.parent_id, "parent"); + assert!(container.title.contains("Test Album")); + } + + #[test] + fn test_track_to_didl_item() { + let track = Track { + id: "789".to_string(), + title: "Test Track".to_string(), + performer: Some(Artist::new("456", "Test Artist")), + album: None, + duration: 180, + track_number: 1, + media_number: 1, + streamable: true, + mime_type: Some("audio/flac".to_string()), + sample_rate: Some(44100), + bit_depth: Some(16), + channels: Some(2), + }; + + let item = track.to_didl_item("parent").unwrap(); + assert_eq!(item.id, "0$qobuz$track$789"); + assert_eq!(item.parent_id, "parent"); + assert_eq!(item.title, "Test Track"); + } + + #[test] + fn test_format_duration() { + assert_eq!(format_duration(0), "00:00:00"); + assert_eq!(format_duration(90), "00:01:30"); + assert_eq!(format_duration(3665), "01:01:05"); + } +} +========= End of pmoqobuz/src/didl.rs =========== + +=============== pmoqobuz/src/source.rs ============ +//! Music source implementation for Qobuz +//! +//! This module implements the [`pmosource::MusicSource`] trait for Qobuz, +//! providing a complete music catalog browsing and searching experience. + +use crate::client::QobuzClient; +use crate::didl::ToDIDL; +use crate::models::Track; +use pmoaudiocache::{AudioMetadata, Cache as AudioCache}; +use pmocovers::Cache as CoverCache; +use pmodidl::{Container, Item}; +use pmosource::SourceCacheManager; +use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; +use std::sync::Arc; +use std::time::SystemTime; + +/// Default image for Qobuz (300x300 WebP, embedded in binary) +const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); + +/// Qobuz music source with full MusicSource trait implementation +/// +/// This struct combines a [`QobuzClient`] for API access with browsing and +/// navigation capabilities, implementing the complete [`MusicSource`] trait. +/// +/// # Features +/// +/// - **Catalog Navigation**: Browse albums, artists, playlists, favorites +/// - **Search**: Full-text search across the Qobuz catalog +/// - **URI Resolution**: Resolves track streaming URIs with authentication +/// - **DIDL-Lite Export**: Converts albums, tracks, and playlists to UPnP formats +/// - **Caching**: Integrated with QobuzClient's cache for performance +/// +/// # Architecture +/// +/// Unlike streaming sources like Radio Paradise, Qobuz is a catalog-based source: +/// - Root container has multiple sub-containers (Albums, Artists, Favorites, etc.) +/// - No FIFO support (it's a static catalog, not a dynamic stream) +/// - Hierarchical browsing: Root → Category → Albums → Tracks +/// +/// # Examples +/// +/// ```no_run +/// use pmoqobuz::{QobuzSource, QobuzClient}; +/// use pmosource::MusicSource; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let client = QobuzClient::from_config().await?; +/// let source = QobuzSource::new(client); +/// +/// println!("Source: {}", source.name()); +/// println!("Supports FIFO: {}", source.supports_fifo()); +/// +/// // Browse root container +/// let root = source.root_container().await?; +/// println!("Root: {} with {} children", root.title, root.child_count.unwrap_or_default()); +/// +/// Ok(()) +/// } +/// ``` +#[derive(Clone)] +pub struct QobuzSource { + inner: Arc, +} + +struct QobuzSourceInner { + /// Qobuz API client + client: QobuzClient, + + /// Cache manager (centralisé) + cache_manager: SourceCacheManager, + + /// Update tracking + update_counter: tokio::sync::RwLock, + last_change: tokio::sync::RwLock, +} + +impl std::fmt::Debug for QobuzSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("QobuzSource").finish() + } +} + +impl QobuzSource { + /// Create a new Qobuz source from the cache registry + /// + /// This is the recommended way to create a source when using the UPnP server. + /// The caches are automatically retrieved from the global registry. + /// + /// # Arguments + /// + /// * `client` - Authenticated Qobuz API client + /// + /// # Errors + /// + /// Returns an error if the caches are not initialized in the registry + #[cfg(feature = "server")] + pub fn from_registry(client: QobuzClient) -> Result { + let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?; + + Ok(Self { + inner: Arc::new(QobuzSourceInner { + client, + cache_manager, + update_counter: tokio::sync::RwLock::new(0), + last_change: tokio::sync::RwLock::new(SystemTime::now()), + }), + }) + } + + /// Create a new Qobuz source with explicit caches (for tests) + /// + /// # Arguments + /// + /// * `client` - Authenticated Qobuz API client + /// * `cover_cache` - Cover image cache (required) + /// * `audio_cache` - Audio cache (required) + pub fn new( + client: QobuzClient, + cover_cache: Arc, + audio_cache: Arc, + ) -> Self { + let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache); + + Self { + inner: Arc::new(QobuzSourceInner { + client, + cache_manager, + update_counter: tokio::sync::RwLock::new(0), + last_change: tokio::sync::RwLock::new(SystemTime::now()), + }), + } + } + + /// Get the Qobuz client + pub fn client(&self) -> &QobuzClient { + &self.inner.client + } + + /// Add a track from Qobuz with caching + /// + /// This method downloads and caches both cover art and audio data. + pub async fn add_track(&self, track: &Track) -> Result { + let track_id = format!("qobuz://track/{}", track.id); + + // Get streaming URL + let stream_url = self + .inner + .client + .get_stream_url(&track.id) + .await + .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?; + + // 1. Cache cover via manager + let cached_cover_pk = if let Some(ref album) = track.album { + if let Some(ref image_url) = album.image { + self.inner.cache_manager.cache_cover(image_url).await.ok() + } else { + None + } + } else { + None + }; + + // 2. Prepare rich metadata from Qobuz track + let metadata = AudioMetadata { + title: Some(track.title.clone()), + artist: track.performer.as_ref().map(|p| p.name.clone()), + album: track.album.as_ref().map(|a| a.title.clone()), + duration_secs: Some(track.duration as u64), + year: track.album.as_ref().and_then(|a| { + a.release_date + .as_ref() + .and_then(|d| d.split('-').next()?.parse().ok()) + }), + track_number: Some(track.track_number), + track_total: track.album.as_ref().and_then(|a| a.tracks_count), + disc_number: Some(track.media_number), + disc_total: None, + genre: track.album.as_ref().and_then(|a| { + if !a.genres.is_empty() { + Some(a.genres.join(", ")) + } else { + None + } + }), + sample_rate: track.sample_rate, + channels: track.channels, + bitrate: None, + conversion: None, + }; + + // 3. Cache audio via manager + let cached_audio_pk = self + .inner + .cache_manager + .cache_audio(&stream_url, Some(metadata)) + .await + .ok(); + + // 4. Store metadata + self.inner + .cache_manager + .update_metadata( + track_id.clone(), + pmosource::TrackMetadata { + original_uri: stream_url, + cached_audio_pk, + cached_cover_pk, + }, + ) + .await; + + Ok(track_id) + } + + /// Add track with lazy audio caching (cover eager, audio lazy) + /// + /// This method caches cover art immediately (small, needed for UI) but + /// defers audio download until the track is actually played. + /// + /// # Arguments + /// + /// * `track` - The Qobuz track to add + /// + /// # Returns + /// + /// The track ID (e.g., "qobuz://track/12345") + pub async fn add_track_lazy(&self, track: &Track) -> Result { + let track_id = format!("qobuz://track/{}", track.id); + + // Get streaming URL + let stream_url = self + .inner + .client + .get_stream_url(&track.id) + .await + .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?; + + // 1. Cache cover EAGERLY (small, UI needs it) + let cached_cover_pk = if let Some(ref album) = track.album { + if let Some(ref image_url) = album.image { + self.inner.cache_manager.cache_cover(image_url).await.ok() + } else { + None + } + } else { + None + }; + + // 2. Prepare rich metadata from Qobuz track + let metadata = AudioMetadata { + title: Some(track.title.clone()), + artist: track.performer.as_ref().map(|p| p.name.clone()), + album: track.album.as_ref().map(|a| a.title.clone()), + duration_secs: Some(track.duration as u64), + year: track.album.as_ref().and_then(|a| { + a.release_date + .as_ref() + .and_then(|d| d.split('-').next()?.parse().ok()) + }), + track_number: Some(track.track_number), + track_total: track.album.as_ref().and_then(|a| a.tracks_count), + disc_number: Some(track.media_number), + disc_total: None, + genre: track.album.as_ref().and_then(|a| { + if !a.genres.is_empty() { + Some(a.genres.join(", ")) + } else { + None + } + }), + sample_rate: track.sample_rate, + channels: track.channels, + bitrate: None, + conversion: None, + }; + + // 3. Cache audio LAZILY (KEY CHANGE: use cache_audio_lazy) + let cached_audio_pk = self + .inner + .cache_manager + .cache_audio_lazy(&stream_url, Some(metadata)) + .await + .ok(); + + // 4. Store metadata + self.inner + .cache_manager + .update_metadata( + track_id.clone(), + pmosource::TrackMetadata { + original_uri: stream_url, + cached_audio_pk, + cached_cover_pk, + }, + ) + .await; + + Ok(track_id) + } + + /// Load full album into pmoplaylist with lazy audio + /// + /// This method fetches all tracks from a Qobuz album and adds them to a playlist + /// with lazy audio loading. Covers are downloaded eagerly, audio lazily. + /// + /// # Arguments + /// + /// * `playlist_id` - ID of the target playlist + /// * `album_id` - Qobuz album ID + /// + /// # Returns + /// + /// Number of tracks successfully added + pub async fn add_album_to_playlist( + &self, + playlist_id: &str, + album_id: &str, + ) -> Result { + use tracing::{info, warn, debug}; + + // 1. Get tracks from Qobuz (goes through rate limiter) + let tracks = self + .inner + .client + .get_album_tracks(album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + if tracks.is_empty() { + return Ok(0); + } + + info!( + "Adding album {} ({} tracks) to playlist {} with lazy audio", + album_id, + tracks.len(), + playlist_id + ); + + // 2. Add each track lazily + collect lazy PKs + let mut lazy_pks = Vec::with_capacity(tracks.len()); + + for (i, track) in tracks.iter().enumerate() { + match self.add_track_lazy(track).await { + Ok(track_id) => { + debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title); + + // Extract lazy PK from cache manager + if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await { + if let Some(audio_pk) = metadata.cached_audio_pk { + lazy_pks.push(audio_pk); + } + } + } + Err(e) => { + warn!( + "Failed to add track {} ({}): {}", + i + 1, + track.title, + e + ); + // Continue with other tracks + } + } + } + + // 3. Batch insert into playlist (single DB transaction) + let playlist_manager = pmoplaylist::PlaylistManager(); + let writer = playlist_manager + .get_write_handle(playlist_id.to_string()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + writer + .push_lazy_batch(lazy_pks.clone()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // 4. Enable lazy mode with lookahead of 2 tracks + playlist_manager.enable_lazy_mode(playlist_id, 2); + + info!( + "Album {} added: {}/{} tracks", + album_id, + lazy_pks.len(), + tracks.len() + ); + + Ok(lazy_pks.len()) + } + + /// Load Qobuz playlist into pmoplaylist with lazy audio + /// + /// This method fetches all tracks from a Qobuz playlist and adds them to a pmoplaylist + /// with lazy audio loading. Covers are downloaded eagerly, audio lazily. + /// + /// # Arguments + /// + /// * `playlist_id` - ID of the target pmoplaylist + /// * `qobuz_playlist_id` - Qobuz playlist ID + /// + /// # Returns + /// + /// Number of tracks successfully added + pub async fn add_qobuz_playlist_to_playlist( + &self, + playlist_id: &str, + qobuz_playlist_id: &str, + ) -> Result { + use tracing::{debug, info, warn}; + + // 1. Get tracks from Qobuz playlist (goes through rate limiter) + let tracks = self + .inner + .client + .get_playlist_tracks(qobuz_playlist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + if tracks.is_empty() { + return Ok(0); + } + + info!( + "Adding Qobuz playlist {} ({} tracks) to pmoplaylist {} with lazy audio", + qobuz_playlist_id, + tracks.len(), + playlist_id + ); + + // 2. Add each track lazily + collect lazy PKs + let mut lazy_pks = Vec::with_capacity(tracks.len()); + + for (i, track) in tracks.iter().enumerate() { + match self.add_track_lazy(track).await { + Ok(track_id) => { + debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title); + + // Extract lazy PK from cache manager + if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await { + if let Some(audio_pk) = metadata.cached_audio_pk { + lazy_pks.push(audio_pk); + } + } + } + Err(e) => { + warn!( + "Failed to add track {} ({}): {}", + i + 1, + track.title, + e + ); + // Continue with other tracks + } + } + } + + // 3. Batch insert into playlist (single DB transaction) + let playlist_manager = pmoplaylist::PlaylistManager(); + let writer = playlist_manager + .get_write_handle(playlist_id.to_string()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + writer + .push_lazy_batch(lazy_pks.clone()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // 4. Enable lazy mode with lookahead of 2 tracks + playlist_manager.enable_lazy_mode(playlist_id, 2); + + info!( + "Qobuz playlist {} added: {}/{} tracks", + qobuz_playlist_id, + lazy_pks.len(), + tracks.len() + ); + + Ok(lazy_pks.len()) + } + + /// Increment update counter (called on catalog changes) + async fn increment_update_id(&self) { + let mut counter = self.inner.update_counter.write().await; + *counter = counter.wrapping_add(1); + let mut last = self.inner.last_change.write().await; + *last = SystemTime::now(); + } + + /// Parse object_id to determine what to browse + /// + /// Object IDs follow these patterns: + /// - "qobuz" or "0" → Root container + /// - "qobuz:favorites" → User's favorite albums + /// - "qobuz:album:{id}" → Tracks in album + /// - "qobuz:playlist:{id}" → Tracks in playlist + fn parse_object_id(&self, object_id: &str) -> ObjectIdType { + if object_id == "qobuz" || object_id == "0" { + return ObjectIdType::Root; + } + + let parts: Vec<&str> = object_id.split(':').collect(); + match parts.as_slice() { + ["qobuz", "favorites"] => ObjectIdType::Favorites, + ["qobuz", "album", id] => ObjectIdType::Album(id.to_string()), + ["qobuz", "playlist", id] => ObjectIdType::Playlist(id.to_string()), + ["qobuz", "artist", id] => ObjectIdType::Artist(id.to_string()), + _ => ObjectIdType::Unknown, + } + } +} + +#[derive(Debug)] +enum ObjectIdType { + Root, + Favorites, + Album(String), + Playlist(String), + Artist(String), + Unknown, +} + +#[async_trait] +impl MusicSource for QobuzSource { + fn name(&self) -> &str { + "Qobuz" + } + + fn id(&self) -> &str { + "qobuz" + } + + fn default_image(&self) -> &[u8] { + DEFAULT_IMAGE + } + + async fn root_container(&self) -> Result { + // Create the root container with sub-containers for different categories + Ok(Container { + id: "qobuz".to_string(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + child_count: Some("2".to_string()), // Favorites + Search (simplified) + searchable: Some("1".to_string()), + title: "Qobuz".to_string(), + class: "object.container".to_string(), + containers: vec![ + // Favorites container + Container { + id: "qobuz:favorites".to_string(), + parent_id: "qobuz".to_string(), + restricted: Some("1".to_string()), + child_count: None, // Will be determined when browsed + searchable: Some("1".to_string()), + title: "My Favorites".to_string(), + class: "object.container".to_string(), + containers: vec![], + items: vec![], + }, + ], + items: vec![], + }) + } + + async fn browse(&self, object_id: &str) -> Result { + match self.parse_object_id(object_id) { + ObjectIdType::Root => { + // Return the root container's children + let root = self.root_container().await?; + Ok(BrowseResult::Containers(root.containers)) + } + + ObjectIdType::Favorites => { + // Get user's favorite albums + let albums = self + .inner + .client + .get_favorite_albums() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container("qobuz:favorites").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Album(album_id) => { + // Get tracks in album + let tracks = self + .inner + .client + .get_album_tracks(&album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = tracks + .into_iter() + .filter_map(|track| { + track + .to_didl_item(&format!("qobuz:album:{}", album_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Items(items)) + } + + ObjectIdType::Playlist(playlist_id) => { + // Get tracks in playlist + let tracks = self + .inner + .client + .get_playlist_tracks(&playlist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = tracks + .into_iter() + .filter_map(|track| { + track + .to_didl_item(&format!("qobuz:playlist:{}", playlist_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Items(items)) + } + + ObjectIdType::Artist(artist_id) => { + // Get albums by artist + let albums = self + .inner + .client + .get_artist_albums(&artist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .filter_map(|album| { + album + .to_didl_container(&format!("qobuz:artist:{}", artist_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(object_id.to_string())), + } + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + // Try cache manager first + if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await { + return Ok(uri); + } + + // If not cached, extract track ID and get streaming URL from Qobuz + let track_id = object_id + .strip_prefix("qobuz://track/") + .unwrap_or(object_id); + + self.inner + .client + .get_stream_url(track_id) + .await + .map_err(|e| MusicSourceError::UriResolutionError(e.to_string())) + } + + fn supports_fifo(&self) -> bool { + // Qobuz is a catalog, not a dynamic stream + false + } + + async fn append_track(&self, _track: Item) -> Result<()> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn remove_oldest(&self) -> Result> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn update_id(&self) -> u32 { + *self.inner.update_counter.read().await + } + + async fn last_change(&self) -> Option { + Some(*self.inner.last_change.read().await) + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + // For Qobuz, "get_items" returns favorite tracks with pagination + let all_tracks = self + .inner + .client + .get_favorite_tracks() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = all_tracks + .into_iter() + .skip(offset) + .take(count) + .filter_map(|track| track.to_didl_item("qobuz:favorites").ok()) + .collect(); + + Ok(items) + } + + async fn search(&self, query: &str) -> Result { + // Search across Qobuz catalog + let results = self + .inner + .client + .search(query, None) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Convert albums to containers and tracks to items + let containers: Vec = results + .albums + .into_iter() + .filter_map(|album| album.to_didl_container("qobuz").ok()) + .collect(); + + let items: Vec = results + .tracks + .into_iter() + .filter_map(|track| track.to_didl_item("qobuz").ok()) + .collect(); + + if !containers.is_empty() || !items.is_empty() { + Ok(BrowseResult::Mixed { containers, items }) + } else { + Ok(BrowseResult::Items(vec![])) + } + } + + // ============= Extended Features Implementation ============= + + fn capabilities(&self) -> pmosource::SourceCapabilities { + pmosource::SourceCapabilities { + supports_fifo: false, + supports_search: true, + supports_favorites: true, + supports_playlists: true, + supports_user_content: false, + supports_high_res_audio: true, + max_sample_rate: Some(192_000), // Qobuz supports up to 192kHz + supports_multiple_formats: true, + supports_advanced_search: false, // TODO: Qobuz API supports it, not yet implemented + supports_pagination: true, + } + } + + async fn get_available_formats(&self, object_id: &str) -> Result> { + use pmosource::AudioFormat; + + // Extract track ID from object_id + let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") { + id + } else { + object_id + }; + + // Get track details from Qobuz + let track = self + .inner + .client + .get_track(track_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Qobuz provides multiple formats based on subscription + let mut formats = vec![]; + + // MP3 320 (format_id 5) - available to all + formats.push(AudioFormat { + format_id: "mp3-320".to_string(), + mime_type: "audio/mpeg".to_string(), + sample_rate: Some(44100), + bit_depth: None, + bitrate: Some(320), + channels: Some(2), + }); + + // FLAC 16/44.1 (format_id 6) - CD quality + formats.push(AudioFormat { + format_id: "flac-16-44".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }); + + // Hi-Res formats (if available for this track) + if let Some(sample_rate) = track.sample_rate { + if sample_rate > 44100 { + // FLAC 24-bit Hi-Res + let bit_depth = track.bit_depth.map(|d| d as u8).or(Some(24)); + + formats.push(AudioFormat { + format_id: format!("flac-{}-{}", bit_depth.unwrap_or(24), sample_rate / 1000), + mime_type: "audio/flac".to_string(), + sample_rate: Some(sample_rate), + bit_depth, + bitrate: None, + channels: track.channels, + }); + } + } + + Ok(formats) + } + + async fn get_cache_status(&self, object_id: &str) -> Result { + self.inner.cache_manager.get_cache_status(object_id).await + } + + async fn cache_item(&self, object_id: &str) -> Result { + // Extract track ID + let track_id = object_id + .strip_prefix("qobuz://track/") + .unwrap_or(object_id); + + // Get track details from Qobuz + let track = self + .inner + .client + .get_track(track_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Add track to cache (via manager) + let cached_id = self.add_track(&track).await?; + + // Return the cache status + self.get_cache_status(&cached_id).await + } + + async fn add_favorite(&self, object_id: &str) -> Result<()> { + // Parse object_id to determine type + let parts: Vec<&str> = object_id.split(':').collect(); + + match parts.as_slice() { + ["qobuz", "album", id] | ["qobuz://album", id] => { + self.inner + .client + .add_favorite_album(id) + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + } + ["qobuz", "track", id] | ["qobuz://track", id] => { + self.inner + .client + .add_favorite_track(id) + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + } + _ => { + return Err(MusicSourceError::NotSupported( + "Favorites only supported for albums and tracks".to_string(), + )); + } + } + + self.increment_update_id().await; + Ok(()) + } + + async fn remove_favorite(&self, object_id: &str) -> Result<()> { + // Parse object_id to determine type + let parts: Vec<&str> = object_id.split(':').collect(); + + match parts.as_slice() { + ["qobuz", "album", id] | ["qobuz://album", id] => { + self.inner + .client + .remove_favorite_album(id) + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + } + ["qobuz", "track", id] | ["qobuz://track", id] => { + self.inner + .client + .remove_favorite_track(id) + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + } + _ => { + return Err(MusicSourceError::NotSupported( + "Favorites only supported for albums and tracks".to_string(), + )); + } + } + + self.increment_update_id().await; + Ok(()) + } + + async fn is_favorite(&self, object_id: &str) -> Result { + // Parse object_id to determine type + let parts: Vec<&str> = object_id.split(':').collect(); + + match parts.as_slice() { + ["qobuz", "album", id] | ["qobuz://album", id] => { + let favorites = self + .inner + .client + .get_favorite_albums() + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + + Ok(favorites.iter().any(|album| album.id == *id)) + } + ["qobuz", "track", id] | ["qobuz://track", id] => { + let favorites = self + .inner + .client + .get_favorite_tracks() + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + + Ok(favorites.iter().any(|track| track.id == *id)) + } + _ => Err(MusicSourceError::NotSupported( + "Favorites only supported for albums and tracks".to_string(), + )), + } + } + + async fn get_user_playlists(&self) -> Result> { + let playlists = self + .inner + .client + .get_user_playlists() + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + let containers: Vec = playlists + .into_iter() + .filter_map(|playlist| playlist.to_didl_container("qobuz").ok()) + .collect(); + + Ok(containers) + } + + async fn add_to_playlist(&self, playlist_id: &str, item_id: &str) -> Result<()> { + // Extract track ID from item_id + let track_id = if let Some(id) = item_id.strip_prefix("qobuz://track/") { + id + } else if let Some(id) = item_id.strip_prefix("qobuz:track:") { + id + } else { + item_id + }; + + self.inner + .client + .add_to_playlist(playlist_id, track_id) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + self.increment_update_id().await; + Ok(()) + } + + async fn get_item_count(&self, object_id: &str) -> Result { + match self.parse_object_id(object_id) { + ObjectIdType::Album(album_id) => { + let album = self + .inner + .client + .get_album(&album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + Ok(album.tracks_count.unwrap_or(0) as usize) + } + ObjectIdType::Playlist(playlist_id) => { + let playlist = self + .inner + .client + .get_playlist(&playlist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + Ok(playlist.tracks_count.unwrap_or(0) as usize) + } + _ => { + // Fall back to default implementation + let result = self.browse(object_id).await?; + Ok(result.count()) + } + } + } + + async fn browse_paginated( + &self, + object_id: &str, + offset: usize, + limit: usize, + ) -> Result { + match self.parse_object_id(object_id) { + ObjectIdType::Album(album_id) => { + // Qobuz returns all tracks, so we slice them + let tracks = self + .inner + .client + .get_album_tracks(&album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = tracks + .into_iter() + .skip(offset) + .take(limit) + .filter_map(|track| { + track + .to_didl_item(&format!("qobuz:album:{}", album_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Items(items)) + } + ObjectIdType::Favorites => { + let albums = self + .inner + .client + .get_favorite_albums() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .skip(offset) + .take(limit) + .filter_map(|album| album.to_didl_container("qobuz:favorites").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + _ => { + // Fall back to default implementation + self.browse(object_id).await + } + } + } + + async fn statistics(&self) -> Result { + let mut stats = pmosource::SourceStatistics::default(); + + // Try to get favorite counts + if let Ok(albums) = self.inner.client.get_favorite_albums().await { + stats.total_containers = Some(albums.len()); + } + + if let Ok(tracks) = self.inner.client.get_favorite_tracks().await { + stats.total_items = Some(tracks.len()); + } + + // Get cache statistics from manager + let cache_stats = self.inner.cache_manager.statistics().await; + stats.cached_items = Some(cache_stats.cached_tracks); + + Ok(stats) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_image_present() { + assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty"); + + // Check WebP magic bytes (RIFF...WEBP) + assert!( + DEFAULT_IMAGE.len() >= 12, + "Image too small to be valid WebP" + ); + assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header"); + assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature"); + } + + // Note: We can't easily test parse_object_id without creating a real client + // which requires authentication. The parsing logic is simple enough that + // it's covered by integration tests. +} +========= End of pmoqobuz/src/source.rs =========== + +=============== pmoqobuz/src/api_rest.rs ============ +//! Endpoints API REST pour Qobuz +//! +//! Ce module définit les handlers HTTP pour accéder aux fonctionnalités Qobuz. + +#[cfg(feature = "pmoserver")] +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, Router, +}; + +#[cfg(feature = "pmoserver")] +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "pmoserver")] +use std::sync::Arc; + +#[cfg(feature = "pmoserver")] +use crate::{client::QobuzClient, error::QobuzError, models::*}; + +/// État partagé de l'application +#[cfg(feature = "pmoserver")] +#[derive(Clone)] +pub struct QobuzState { + pub client: Arc, + #[cfg(feature = "covers")] + pub cover_cache: Option>, +} + +/// Paramètres de recherche +#[cfg(feature = "pmoserver")] +#[derive(Debug, Deserialize)] +pub struct SearchParams { + /// Requête de recherche + pub q: String, + /// Type de recherche (albums, artists, tracks, playlists) + #[serde(rename = "type")] + pub search_type: Option, +} + +/// Paramètres pour featured albums +#[cfg(feature = "pmoserver")] +#[derive(Debug, Deserialize)] +pub struct FeaturedAlbumsParams { + /// ID du genre (optionnel) + pub genre_id: Option, + /// Type (new-releases, ideal-discography, etc.) + #[serde(rename = "type", default = "default_featured_type")] + pub type_: String, +} + +#[cfg(feature = "pmoserver")] +fn default_featured_type() -> String { + "new-releases".to_string() +} + +/// Paramètres pour featured playlists +#[cfg(feature = "pmoserver")] +#[derive(Debug, Deserialize)] +pub struct FeaturedPlaylistsParams { + /// ID du genre (optionnel) + pub genre_id: Option, + /// Tags (optionnel) + pub tags: Option, +} + +/// Crée le router Axum avec tous les endpoints Qobuz +#[cfg(feature = "pmoserver")] +pub fn create_router(state: QobuzState) -> Router { + Router::new() + // Albums + .route("/albums/:id", axum::routing::get(get_album)) + .route("/albums/:id/tracks", axum::routing::get(get_album_tracks)) + // Tracks + .route("/tracks/:id", axum::routing::get(get_track)) + .route("/tracks/:id/stream", axum::routing::get(get_stream_url)) + // Artists + .route("/artists/:id/albums", axum::routing::get(get_artist_albums)) + .route( + "/artists/:id/similar", + axum::routing::get(get_similar_artists), + ) + // Playlists + .route("/playlists/:id", axum::routing::get(get_playlist)) + .route( + "/playlists/:id/tracks", + axum::routing::get(get_playlist_tracks), + ) + // Recherche + .route("/search", axum::routing::get(search)) + // Favoris + .route("/favorites/albums", axum::routing::get(get_favorite_albums)) + .route( + "/favorites/artists", + axum::routing::get(get_favorite_artists), + ) + .route("/favorites/tracks", axum::routing::get(get_favorite_tracks)) + .route( + "/favorites/playlists", + axum::routing::get(get_user_playlists), + ) + // Catalogue + .route("/genres", axum::routing::get(get_genres)) + .route("/featured/albums", axum::routing::get(get_featured_albums)) + .route( + "/featured/playlists", + axum::routing::get(get_featured_playlists), + ) + // Cache + .route("/cache/stats", axum::routing::get(get_cache_stats)) + .with_state(state) +} + +// ============ Handlers ============ + +#[cfg(feature = "pmoserver")] +async fn get_album( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let mut album = state.client.get_album(&id).await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + album = cache_album_image(album, cover_cache).await; + } + + Ok(Json(album)) +} + +#[cfg(feature = "pmoserver")] +async fn get_album_tracks( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let tracks = state.client.get_album_tracks(&id).await?; + Ok(Json(tracks)) +} + +#[cfg(feature = "pmoserver")] +async fn get_track( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let track = state.client.get_track(&id).await?; + Ok(Json(track)) +} + +#[cfg(feature = "pmoserver")] +async fn get_stream_url( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let url = state.client.get_stream_url(&id).await?; + Ok(Json(serde_json::json!({ "url": url }))) +} + +#[cfg(feature = "pmoserver")] +async fn get_artist_albums( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let mut albums = state.client.get_artist_albums(&id).await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + albums = cache_albums_images(albums, cover_cache).await; + } + + Ok(Json(albums)) +} + +#[cfg(feature = "pmoserver")] +async fn get_similar_artists( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let artists = state.client.get_similar_artists(&id).await?; + Ok(Json(artists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_playlist( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let playlist = state.client.get_playlist(&id).await?; + Ok(Json(playlist)) +} + +#[cfg(feature = "pmoserver")] +async fn get_playlist_tracks( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let tracks = state.client.get_playlist_tracks(&id).await?; + Ok(Json(tracks)) +} + +#[cfg(feature = "pmoserver")] +async fn search( + State(state): State, + Query(params): Query, +) -> Result, AppError> { + let mut result = state + .client + .search(¶ms.q, params.search_type.as_deref()) + .await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + result.albums = cache_albums_images(result.albums, cover_cache).await; + } + + Ok(Json(result)) +} + +#[cfg(feature = "pmoserver")] +async fn get_favorite_albums( + State(state): State, +) -> Result>, AppError> { + let mut albums = state.client.get_favorite_albums().await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + albums = cache_albums_images(albums, cover_cache).await; + } + + Ok(Json(albums)) +} + +#[cfg(feature = "pmoserver")] +async fn get_favorite_artists( + State(state): State, +) -> Result>, AppError> { + let artists = state.client.get_favorite_artists().await?; + Ok(Json(artists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_favorite_tracks( + State(state): State, +) -> Result>, AppError> { + let tracks = state.client.get_favorite_tracks().await?; + Ok(Json(tracks)) +} + +#[cfg(feature = "pmoserver")] +async fn get_user_playlists( + State(state): State, +) -> Result>, AppError> { + let playlists = state.client.get_user_playlists().await?; + Ok(Json(playlists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_genres(State(state): State) -> Result>, AppError> { + let genres = state.client.get_genres().await?; + Ok(Json(genres)) +} + +#[cfg(feature = "pmoserver")] +async fn get_featured_albums( + State(state): State, + Query(params): Query, +) -> Result>, AppError> { + let mut albums = state + .client + .get_featured_albums(params.genre_id.as_deref(), ¶ms.type_) + .await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + albums = cache_albums_images(albums, cover_cache).await; + } + + Ok(Json(albums)) +} + +#[cfg(feature = "pmoserver")] +async fn get_featured_playlists( + State(state): State, + Query(params): Query, +) -> Result>, AppError> { + let playlists = state + .client + .get_featured_playlists(params.genre_id.as_deref(), params.tags.as_deref()) + .await?; + Ok(Json(playlists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_cache_stats( + State(state): State, +) -> Result, AppError> { + let stats = state.client.cache().stats().await; + Ok(Json(stats)) +} + +// ============ Helpers pour le cache d'images ============ + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +async fn cache_album_image(mut album: Album, cover_cache: &Arc) -> Album { + if let Some(ref image_url) = album.image { + match cover_cache.add_from_url(image_url, None).await { + Ok(pk) => { + album.image_cached = Some(format!("/covers/images/{}", pk)); + } + Err(e) => { + tracing::warn!("Failed to cache album image: {}", e); + } + } + } + album +} + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +async fn cache_albums_images( + albums: Vec, + cover_cache: &Arc, +) -> Vec { + let mut cached_albums = Vec::with_capacity(albums.len()); + for album in albums { + cached_albums.push(cache_album_image(album, cover_cache).await); + } + cached_albums +} + +// ============ Gestion des erreurs ============ + +#[cfg(feature = "pmoserver")] +struct AppError(QobuzError); + +#[cfg(feature = "pmoserver")] +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match self.0 { + QobuzError::Unauthorized(_) => (StatusCode::UNAUTHORIZED, self.0.to_string()), + QobuzError::NotFound(_) => (StatusCode::NOT_FOUND, self.0.to_string()), + QobuzError::RateLimitExceeded => (StatusCode::TOO_MANY_REQUESTS, self.0.to_string()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()), + }; + + let body = Json(serde_json::json!({ + "error": message + })); + + (status, body).into_response() + } +} + +#[cfg(feature = "pmoserver")] +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} +========= End of pmoqobuz/src/api_rest.rs =========== + +=============== pmoqobuz/src/config_ext.rs ============ +//! Extension pour intégrer la configuration Qobuz dans pmoconfig +//! +//! Ce module fournit le trait `QobuzConfigExt` qui permet d'ajouter facilement +//! des méthodes de gestion des credentials Qobuz à pmoconfig::Config. + +use anyhow::{anyhow, Result}; +use pmoconfig::Config; +use serde_yaml::Value; + +/// Trait d'extension pour gérer la configuration Qobuz dans pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques +/// aux credentials et paramètres Qobuz. +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmoqobuz::QobuzConfigExt; +/// +/// let config = get_config(); +/// let (username, password) = config.get_qobuz_credentials()?; +/// println!("Qobuz user: {}", username); +/// ``` +pub trait QobuzConfigExt { + /// Récupère le nom d'utilisateur Qobuz depuis la configuration + /// + /// # Returns + /// + /// Le nom d'utilisateur (email) configuré pour Qobuz + /// + /// # Errors + /// + /// Retourne une erreur si le nom d'utilisateur n'est pas configuré + fn get_qobuz_username(&self) -> Result; + + /// Définit le nom d'utilisateur Qobuz dans la configuration + /// + /// # Arguments + /// + /// * `username` - Le nom d'utilisateur (email) Qobuz + fn set_qobuz_username(&self, username: &str) -> Result<()>; + + /// Récupère le mot de passe Qobuz depuis la configuration + /// + /// # Returns + /// + /// Le mot de passe configuré pour Qobuz + /// + /// # Errors + /// + /// Retourne une erreur si le mot de passe n'est pas configuré + fn get_qobuz_password(&self) -> Result; + + /// Définit le mot de passe Qobuz dans la configuration + /// + /// # Arguments + /// + /// * `password` - Le mot de passe Qobuz + fn set_qobuz_password(&self, password: &str) -> Result<()>; + + /// Récupère les credentials Qobuz (username et password) + /// + /// # Returns + /// + /// Un tuple (username, password) contenant les credentials Qobuz + /// + /// # Errors + /// + /// Retourne une erreur si l'un des credentials n'est pas configuré + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmoconfig::get_config; + /// use pmoqobuz::QobuzConfigExt; + /// + /// let config = get_config(); + /// match config.get_qobuz_credentials() { + /// Ok((username, password)) => { + /// println!("Credentials configured for: {}", username); + /// } + /// Err(e) => { + /// eprintln!("Qobuz credentials not configured: {}", e); + /// } + /// } + /// ``` + fn get_qobuz_credentials(&self) -> Result<(String, String)>; + + /// Récupère l'App ID Qobuz depuis la configuration + /// + /// # Returns + /// + /// L'App ID configuré pour Qobuz, ou None si non configuré + /// + /// # Note + /// + /// Si aucun App ID n'est configuré, le client utilisera soit le Spoofer + /// pour en obtenir un dynamiquement, soit un App ID par défaut. + fn get_qobuz_appid(&self) -> Result>; + + /// Définit l'App ID Qobuz dans la configuration + /// + /// # Arguments + /// + /// * `appid` - L'App ID Qobuz (ex: "1401488693436528") + fn set_qobuz_appid(&self, appid: &str) -> Result<()>; + + /// Récupère le secret Qobuz depuis la configuration + /// + /// # Returns + /// + /// Le secret encodé en base64, ou None si non configuré + /// + /// # Note + /// + /// Le secret est la valeur `configvalue` du code Python. + /// Il est décodé et XORé avec l'App ID pour obtenir le secret `s4` + /// utilisé pour signer les requêtes sensibles. + /// + /// Si aucun secret n'est configuré, le client utilisera le Spoofer + /// pour en obtenir un dynamiquement. + fn get_qobuz_secret(&self) -> Result>; + + /// Définit le secret Qobuz dans la configuration + /// + /// # Arguments + /// + /// * `secret` - Le secret encodé en base64 (configvalue) + fn set_qobuz_secret(&self, secret: &str) -> Result<()>; + + /// Récupère le token d'authentification depuis la configuration + /// + /// # Returns + /// + /// Le token d'authentification, ou None si non configuré ou expiré + fn get_qobuz_auth_token(&self) -> Result>; + + /// Récupère l'ID utilisateur depuis la configuration + /// + /// # Returns + /// + /// L'ID utilisateur, ou None si non configuré + fn get_qobuz_user_id(&self) -> Result>; + + /// Récupère le timestamp d'expiration du token + /// + /// # Returns + /// + /// Le timestamp d'expiration (Unix timestamp), ou None si non configuré + fn get_qobuz_token_expires_at(&self) -> Result>; + + /// Récupère le label de l'abonnement depuis la configuration + fn get_qobuz_subscription_label(&self) -> Result>; + + /// Sauvegarde les informations d'authentification dans la configuration + /// + /// # Arguments + /// + /// * `token` - Le token d'authentification + /// * `user_id` - L'ID utilisateur + /// * `subscription_label` - Le label de l'abonnement (optionnel) + /// * `expires_at` - Timestamp d'expiration (Unix timestamp) + fn set_qobuz_auth_info( + &self, + token: &str, + user_id: &str, + subscription_label: Option<&str>, + expires_at: u64, + ) -> Result<()>; + + /// Supprime les informations d'authentification de la configuration + fn clear_qobuz_auth_info(&self) -> Result<()>; + + /// Vérifie si le token d'authentification est encore valide + /// + /// # Returns + /// + /// true si un token existe et n'est pas expiré, false sinon + fn is_qobuz_auth_valid(&self) -> bool; + + /// Récupère le répertoire de cache Qobuz + /// + /// # Returns + /// + /// Le chemin absolu du répertoire de cache, créé s'il n'existe pas + fn get_qobuz_cache_dir(&self) -> Result; + + /// Définit le répertoire de cache Qobuz + fn set_qobuz_cache_dir(&self, directory: String) -> Result<()>; + + /// Récupère le nombre maximum de requêtes concurrentes + /// + /// # Returns + /// + /// Le nombre maximum de requêtes concurrentes, ou None si non configuré (défaut: 2) + fn get_qobuz_rate_limit_max_concurrent(&self) -> Result>; + + /// Définit le nombre maximum de requêtes concurrentes + fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()>; + + /// Récupère le délai minimum entre requêtes en millisecondes + /// + /// # Returns + /// + /// Le délai minimum en ms, ou None si non configuré (défaut: 400ms) + fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result>; + + /// Définit le délai minimum entre requêtes + fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()>; + + /// Vérifie si le rate limiting est activé + /// + /// # Returns + /// + /// true si activé (défaut), false sinon + fn is_qobuz_rate_limiting_enabled(&self) -> bool; + + /// Active ou désactive le rate limiting + fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()>; +} + +impl QobuzConfigExt for Config { + fn get_qobuz_username(&self) -> Result { + match self.get_value(&["accounts", "qobuz", "username"])? { + Value::String(s) => Ok(s), + _ => Err(anyhow!("Qobuz username not configured")), + } + } + + fn set_qobuz_username(&self, username: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "username"], + Value::String(username.to_string()), + ) + } + + fn get_qobuz_password(&self) -> Result { + match self.get_value(&["accounts", "qobuz", "password"])? { + Value::String(s) => { + // Déchiffrement automatique si le mot de passe est chiffré + pmoconfig::encryption::get_password(&s) + .map_err(|e| anyhow!("Failed to decrypt password: {}", e)) + } + _ => Err(anyhow!("Qobuz password not configured")), + } + } + + fn set_qobuz_password(&self, password: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "password"], + Value::String(password.to_string()), + ) + } + + fn get_qobuz_credentials(&self) -> Result<(String, String)> { + let username = self.get_qobuz_username()?; + let password = self.get_qobuz_password()?; + Ok((username, password)) + } + + fn get_qobuz_appid(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "appid"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn set_qobuz_appid(&self, appid: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "appid"], + Value::String(appid.to_string()), + ) + } + + fn get_qobuz_secret(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "secret"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn set_qobuz_secret(&self, secret: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "secret"], + Value::String(secret.to_string()), + ) + } + + fn get_qobuz_auth_token(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "auth_token"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn get_qobuz_user_id(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "user_id"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn get_qobuz_token_expires_at(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "token_expires_at"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap())), + Ok(Value::Number(n)) if n.is_i64() => Ok(Some(n.as_i64().unwrap() as u64)), + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn get_qobuz_subscription_label(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "subscription_label"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn set_qobuz_auth_info( + &self, + token: &str, + user_id: &str, + subscription_label: Option<&str>, + expires_at: u64, + ) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "auth_token"], + Value::String(token.to_string()), + )?; + self.set_value( + &["accounts", "qobuz", "user_id"], + Value::String(user_id.to_string()), + )?; + self.set_value( + &["accounts", "qobuz", "token_expires_at"], + Value::Number(serde_yaml::Number::from(expires_at)), + )?; + + if let Some(label) = subscription_label { + self.set_value( + &["accounts", "qobuz", "subscription_label"], + Value::String(label.to_string()), + )?; + } + + Ok(()) + } + + fn clear_qobuz_auth_info(&self) -> Result<()> { + // On ne propage pas les erreurs car les valeurs peuvent ne pas exister + let _ = self.set_value(&["accounts", "qobuz", "auth_token"], Value::String(String::new())); + let _ = self.set_value(&["accounts", "qobuz", "user_id"], Value::String(String::new())); + let _ = self.set_value( + &["accounts", "qobuz", "token_expires_at"], + Value::Number(serde_yaml::Number::from(0)), + ); + let _ = self.set_value( + &["accounts", "qobuz", "subscription_label"], + Value::String(String::new()), + ); + Ok(()) + } + + fn is_qobuz_auth_valid(&self) -> bool { + // Vérifier si un token existe + if self.get_qobuz_auth_token().ok().flatten().is_none() { + return false; + } + + // Vérifier si le token n'est pas expiré + if let Ok(Some(expires_at)) = self.get_qobuz_token_expires_at() { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + now < expires_at + } else { + false + } + } + + fn get_qobuz_cache_dir(&self) -> Result { + self.get_managed_dir(&["host", "qobuz_cache", "directory"], "cache_qobuz") + } + + fn set_qobuz_cache_dir(&self, directory: String) -> Result<()> { + self.set_managed_dir(&["host", "qobuz_cache", "directory"], directory) + } + + fn get_qobuz_rate_limit_max_concurrent(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "rate_limit", "max_concurrent"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap() as usize)), + Ok(_) => Ok(None), + Err(_) => Ok(Some(2)), // Default: 2 concurrent requests + } + } + + fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "rate_limit", "max_concurrent"], + Value::Number(serde_yaml::Number::from(max)), + ) + } + + fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "rate_limit", "min_delay_ms"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap())), + Ok(_) => Ok(None), + Err(_) => Ok(Some(400)), // Default: 400ms + } + } + + fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "rate_limit", "min_delay_ms"], + Value::Number(serde_yaml::Number::from(delay_ms)), + ) + } + + fn is_qobuz_rate_limiting_enabled(&self) -> bool { + match self.get_value(&["accounts", "qobuz", "rate_limit", "enabled"]) { + Ok(Value::Bool(b)) => b, + _ => true, // Default: enabled + } + } + + fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "rate_limit", "enabled"], + Value::Bool(enabled), + ) + } +} +========= End of pmoqobuz/src/config_ext.rs =========== + +=============== pmoqobuz/src/pmoserver_impl.rs ============ +//! Implémentation du trait QobuzServerExt pour pmoserver::Server +//! +//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités du client Qobuz en +//! implémentant le trait [`QobuzServerExt`](crate::QobuzServerExt). Cette implémentation +//! permet d'initialiser facilement le client Qobuz et d'enregistrer les routes HTTP. +//! +//! ## Architecture +//! +//! `pmoqobuz` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmoqobuz`. +//! C'est le pattern d'extension : `pmoqobuz` ajoute des fonctionnalités à un type +//! externe via un trait, similaire au pattern utilisé par `pmocovers` pour `CoverCacheExt`. +//! +//! ## Exemple d'utilisation +//! +//! ```rust,no_run +//! use pmoqobuz::QobuzServerExt; +//! use pmoserver::ServerBuilder; +//! +//! # async fn example() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new_configured().build(); +//! +//! // Le trait QobuzServerExt est automatiquement disponible +//! let client = server.init_qobuz_client_configured().await?; +//! +//! server.start().await; +//! # Ok(()) +//! # } +//! ``` + +use crate::api_rest::{create_router, QobuzState}; +use crate::client::QobuzClient; +use crate::pmoserver_ext::QobuzServerExt; +use anyhow::Result; +use pmoconfig::Config; +use pmoserver::Server; +use std::sync::Arc; +use tracing::info; + +impl QobuzServerExt for Server { + async fn init_qobuz_client( + &mut self, + username: &str, + password: &str, + ) -> Result> { + info!("Initializing Qobuz client for user: {}", username); + + // Créer le client Qobuz + let client = QobuzClient::new(username, password).await?; + let client = Arc::new(client); + + // Créer l'état de l'API sans cache d'images + let state = QobuzState { + client: client.clone(), + #[cfg(feature = "covers")] + cover_cache: None, + }; + + // Créer le router et l'enregistrer + let router = create_router(state); + self.add_router("/qobuz", router).await; + + info!("Qobuz client initialized successfully"); + info!("API endpoints available at /qobuz/*"); + + Ok(client) + } + + async fn init_qobuz_client_configured(&mut self) -> Result> { + info!("Initializing Qobuz client from configuration"); + + // Récupérer les credentials depuis la config + let config = pmoconfig::get_config(); + let (username, password) = config.get_qobuz_credentials()?; + + self.init_qobuz_client(&username, &password).await + } + + #[cfg(feature = "covers")] + async fn init_qobuz_client_with_covers( + &mut self, + username: &str, + password: &str, + cover_cache: Arc, + ) -> Result> { + info!("Initializing Qobuz client with pmocovers integration"); + + // Créer le client Qobuz + let client = QobuzClient::new(username, password).await?; + let client = Arc::new(client); + + info!("pmocovers integration enabled - album images will be cached automatically"); + + // Créer l'état de l'API avec le cache + let state = QobuzState { + client: client.clone(), + cover_cache: Some(cover_cache), + }; + + // Créer le router et l'enregistrer + let router = create_router(state); + self.add_router("/qobuz", router).await; + + info!("Qobuz client initialized successfully with covers"); + info!("API endpoints available at /qobuz/*"); + + Ok(client) + } + + #[cfg(feature = "covers")] + async fn init_qobuz_client_configured_with_covers( + &mut self, + cover_cache: Arc, + ) -> Result> { + info!("Initializing Qobuz client from configuration with pmocovers"); + + // Récupérer les credentials depuis la config + let config = pmoconfig::get_config(); + let (username, password) = config.get_qobuz_credentials()?; + + self.init_qobuz_client_with_covers(&username, &password, cover_cache) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trait_implemented() { + // Ce test vérifie simplement que le trait est bien implémenté + // Les tests fonctionnels nécessiteraient un serveur et des credentials réels + } +} +========= End of pmoqobuz/src/pmoserver_impl.rs =========== + +=============== pmoqobuz/src/api/spoofer.rs ============ +use anyhow::Result; +use base64::{engine::general_purpose::STANDARD, Engine}; +use indexmap::IndexMap; +use regex::Regex; +use reqwest::Client; + +pub struct Spoofer { + bundle: String, + seed_timezone_regex: Regex, + info_extras_regex_template: String, + app_id_regex: Regex, +} + +impl Spoofer { + /// Crée un nouveau Spoofer et télécharge le bundle.js + pub async fn new() -> Result { + // Expressions régulières (équivalent Python) + let seed_timezone_regex = Regex::new( + r#"[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)"#, + )?; + + let info_extras_regex_template = + r#"name:"\w+/(?P{timezones})",info:"(?P[\w=]+)",extras:"(?P[\w=]+)""# + .to_string(); + + let app_id_regex = Regex::new( + r#"production:\{api:\{appId:"(?P\d{9})",appSecret:"(?P\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#, + )?; + + // Créer un client HTTP + let client = Client::builder() + .user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)") + .build()?; + + println!("Récupération de la page de login..."); + let login_page = client + .get("https://play.qobuz.com/login") + .send() + .await? + .text() + .await?; + + // Extraire l'URL du bundle + let bundle_url_regex = + Regex::new(r#""#)?; + let bundle_url = bundle_url_regex + .captures(&login_page) + .and_then(|cap| cap.get(1)) + .ok_or_else(|| anyhow::anyhow!("Impossible de trouver l'URL du bundle"))? + .as_str(); + + println!("Téléchargement du bundle depuis: {}", bundle_url); + let bundle_full_url = format!("https://play.qobuz.com{}", bundle_url); + let bundle = client.get(&bundle_full_url).send().await?.text().await?; + + println!("Bundle téléchargé ({} bytes)", bundle.len()); + + Ok(Self { + bundle, + seed_timezone_regex, + info_extras_regex_template, + app_id_regex, + }) + } + + /// Extrait l'App ID depuis le bundle + pub fn get_app_id(&self) -> Result { + let captures = self + .app_id_regex + .captures(&self.bundle) + .ok_or_else(|| anyhow::anyhow!("AppID non trouvé dans le bundle"))?; + + Ok(captures + .name("app_id") + .ok_or_else(|| anyhow::anyhow!("Groupe app_id non trouvé"))? + .as_str() + .to_string()) + } + + /// Extrait les secrets depuis le bundle + pub fn get_secrets(&self) -> Result> { + // Étape 1: Extraire tous les seed/timezone pairs + let mut secrets: IndexMap> = IndexMap::new(); + + for captures in self.seed_timezone_regex.captures_iter(&self.bundle) { + let seed = captures + .name("seed") + .ok_or_else(|| anyhow::anyhow!("Groupe seed non trouvé"))? + .as_str(); + let timezone = captures + .name("timezone") + .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? + .as_str(); + + secrets + .entry(timezone.to_string()) + .or_insert_with(Vec::new) + .push(seed.to_string()); + } + + println!("Timezones trouvées: {:?}", secrets.keys()); + + // Étape 2: Réordonner - on met la deuxième timezone en premier + // (comme le fait le code Python avec move_to_end) + if secrets.len() >= 2 { + let keys: Vec = secrets.keys().cloned().collect(); + let second_key = keys[1].clone(); + let second_value = secrets.get(&second_key).unwrap().clone(); + + // Retirer et réinsérer pour le mettre en premier + secrets.shift_remove(&second_key); + let mut new_secrets = IndexMap::new(); + new_secrets.insert(second_key, second_value); + for (k, v) in secrets { + new_secrets.insert(k, v); + } + secrets = new_secrets; + } + + // Étape 3: Construire la regex pour info/extras + let timezones_capitalized: Vec = secrets + .keys() + .map(|tz| { + let mut chars = tz.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect(); + + let info_extras_regex_str = self + .info_extras_regex_template + .replace("{timezones}", &timezones_capitalized.join("|")); + + let info_extras_regex = Regex::new(&info_extras_regex_str)?; + + // Étape 4: Extraire info et extras pour chaque timezone + for captures in info_extras_regex.captures_iter(&self.bundle) { + let timezone_cap = captures + .name("timezone") + .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? + .as_str(); + let info = captures + .name("info") + .ok_or_else(|| anyhow::anyhow!("Groupe info non trouvé"))? + .as_str(); + let extras = captures + .name("extras") + .ok_or_else(|| anyhow::anyhow!("Groupe extras non trouvé"))? + .as_str(); + + let timezone_lower = timezone_cap.to_lowercase(); + if let Some(vec) = secrets.get_mut(&timezone_lower) { + vec.push(info.to_string()); + vec.push(extras.to_string()); + } + } + + // Étape 5: Décoder les secrets en base64 + let mut decoded_secrets = IndexMap::new(); + for (timezone, parts) in secrets { + let concatenated = parts.join(""); + + // Retirer les 44 derniers caractères (comme Python [:-44]) + if concatenated.len() > 44 { + let trimmed = &concatenated[..concatenated.len() - 44]; + + // Décoder en base64 + match STANDARD.decode(trimmed) { + Ok(decoded_bytes) => { + match String::from_utf8(decoded_bytes) { + Ok(decoded_str) => { + decoded_secrets.insert(timezone, decoded_str); + } + Err(e) => { + eprintln!( + "Erreur UTF-8 pour timezone {}: {}", + timezone, e + ); + } + } + } + Err(e) => { + eprintln!( + "Erreur de décodage base64 pour timezone {}: {}", + timezone, e + ); + } + } + } + } + + Ok(decoded_secrets) + } +} +========= End of pmoqobuz/src/api/spoofer.rs =========== + +=============== pmoqobuz/src/api/auth.rs ============ +//! Module d'authentification pour l'API Qobuz + +use super::QobuzApi; +use crate::error::{QobuzError, Result}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, info}; + +/// Réponse de l'endpoint /user/login +#[derive(Debug, Deserialize)] +struct LoginResponse { + user: UserInfo, + user_auth_token: String, +} + +/// Informations utilisateur retournées par l'API +#[derive(Debug, Deserialize)] +struct UserInfo { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + #[serde(default)] + email: Option, + #[serde(default)] + firstname: Option, + #[serde(default)] + lastname: Option, + credential: CredentialInfo, +} + +/// Informations sur les credentials de l'utilisateur +#[derive(Debug, Deserialize)] +struct CredentialInfo { + #[serde(default)] + parameters: Option, +} + +/// Paramètres du niveau d'abonnement +#[derive(Debug, Deserialize)] +struct CredentialParameters { + #[serde(default)] + short_label: Option, +} + +/// Informations d'authentification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthInfo { + /// Token d'authentification + pub token: String, + /// ID utilisateur + pub user_id: String, + /// Label de l'abonnement (ex: "Studio", "Hi-Fi", etc.) + pub subscription_label: Option, +} + +impl QobuzApi { + /// Authentifie l'utilisateur avec username et password + /// + /// # Arguments + /// + /// * `username` - Email ou nom d'utilisateur Qobuz + /// * `password` - Mot de passe + /// + /// # Returns + /// + /// Retourne les informations d'authentification si le login est réussi + /// + /// # Errors + /// + /// * `QobuzError::Unauthorized` - Credentials invalides + /// * `QobuzError::SubscriptionRequired` - Compte gratuit (non éligible) + pub async fn login(&mut self, username: &str, password: &str) -> Result { + info!("Attempting to login to Qobuz as {}", username); + + let params = [("username", username), ("password", password)]; + + let response: LoginResponse = self.post("/user/login", ¶ms).await?; + + // Vérifier que l'utilisateur a un abonnement valide + if response.user.credential.parameters.is_none() { + return Err(QobuzError::SubscriptionRequired( + "Free accounts are not eligible for streaming".to_string(), + )); + } + + let user_id = response.user.id; + let subscription_label = response + .user + .credential + .parameters + .and_then(|p| p.short_label); + + debug!( + "Login successful - User ID: {}, Subscription: {:?}", + user_id, subscription_label + ); + + // Stocker les informations d'authentification + self.set_auth_token(response.user_auth_token.clone(), user_id.clone()); + + Ok(AuthInfo { + token: response.user_auth_token, + user_id, + subscription_label, + }) + } + + /// Vérifie si le client est authentifié + pub fn is_authenticated(&self) -> bool { + self.user_auth_token.is_some() && self.user_id.is_some() + } + + /// Déconnecte l'utilisateur + pub fn logout(&mut self) { + debug!("Logging out"); + self.user_auth_token = None; + self.user_id = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_authenticated() { + let mut api = QobuzApi::new("test_app_id").unwrap(); + assert!(!api.is_authenticated()); + + api.set_auth_token("token".to_string(), "user123".to_string()); + assert!(api.is_authenticated()); + + api.logout(); + assert!(!api.is_authenticated()); + } +} +========= End of pmoqobuz/src/api/auth.rs =========== + +=============== pmoqobuz/src/api/signing.rs ============ +//! Module de signature MD5 pour les requêtes Qobuz +//! +//! Certaines requêtes Qobuz (notamment track/getFileUrl et userLibrary/*) +//! nécessitent une signature MD5 incluant le secret s4. + +use md5::{Digest, Md5}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Génère un timestamp Unix actuel +/// +/// # Returns +/// +/// Timestamp Unix sous forme de string avec décimales +/// +/// # Exemple +/// +/// ``` +/// use pmoqobuz::api::signing::get_timestamp; +/// let ts = get_timestamp(); +/// println!("Timestamp: {}", ts); +/// ``` +pub fn get_timestamp() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs_f64() + .to_string() +} + +/// Signe une requête track/getFileUrl +/// +/// Reproduit la logique Python: +/// ```python +/// stringvalue = ("trackgetFileUrlformat_id" + fmt_id + +/// "intent" + intent + +/// "track_id" + track_id + ts) +/// stringvalue += self.s4 +/// rq_sig = str(hashlib.md5(stringvalue).hexdigest()) +/// ``` +/// +/// # Arguments +/// +/// * `format_id` - ID du format audio (ex: "27") +/// * `intent` - Intention (typiquement "stream") +/// * `track_id` - ID de la track +/// * `timestamp` - Timestamp Unix +/// * `secret` - Secret s4 en bytes +/// +/// # Returns +/// +/// Signature MD5 hexadécimale +pub fn sign_track_get_file_url( + format_id: &str, + intent: &str, + track_id: &str, + timestamp: &str, + secret: &[u8], +) -> String { + let mut hasher = Md5::new(); + + // Construction de la chaîne à hasher + hasher.update(b"trackgetFileUrlformat_id"); + hasher.update(format_id.as_bytes()); + hasher.update(b"intent"); + hasher.update(intent.as_bytes()); + hasher.update(b"track_id"); + hasher.update(track_id.as_bytes()); + hasher.update(timestamp.as_bytes()); + hasher.update(secret); + + // Retourner le hash hexadécimal + format!("{:x}", hasher.finalize()) +} + +/// Signe une requête userLibrary/getAlbumsList +/// +/// Reproduit la logique Python: +/// ```python +/// r_sig = "userLibrarygetAlbumsList" + str(ts) + str(ka["sec"]) +/// r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest() +/// ``` +/// +/// # Arguments +/// +/// * `timestamp` - Timestamp Unix +/// * `secret` - Secret s4 en bytes +/// +/// # Returns +/// +/// Signature MD5 hexadécimale +pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String { + let mut hasher = Md5::new(); + + // Construction de la chaîne à hasher + hasher.update(b"userLibrarygetAlbumsList"); + hasher.update(timestamp.as_bytes()); + hasher.update(secret); + + // Retourner le hash hexadécimal + format!("{:x}", hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_timestamp() { + let ts = get_timestamp(); + // Vérifier que c'est un nombre valide + assert!(ts.parse::().is_ok()); + // Vérifier que c'est proche du temps actuel (>= 2024) + assert!(ts.parse::().unwrap() > 1704067200.0); // 1er janvier 2024 + } + + #[test] + fn test_sign_track_get_file_url() { + let signature = sign_track_get_file_url( + "27", + "stream", + "12345", + "1234567890.123", + b"test_secret", + ); + + // Vérifier que c'est un hash MD5 valide (32 caractères hex) + assert_eq!(signature.len(), 32); + assert!(signature.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_sign_userlib_get_albums() { + let signature = sign_userlib_get_albums("1234567890.123", b"test_secret"); + + // Vérifier que c'est un hash MD5 valide (32 caractères hex) + assert_eq!(signature.len(), 32); + assert!(signature.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_signature_consistency() { + // La même entrée doit produire la même signature + let sig1 = sign_track_get_file_url("27", "stream", "123", "100", b"secret"); + let sig2 = sign_track_get_file_url("27", "stream", "123", "100", b"secret"); + assert_eq!(sig1, sig2); + + // Des entrées différentes doivent produire des signatures différentes + let sig3 = sign_track_get_file_url("6", "stream", "123", "100", b"secret"); + assert_ne!(sig1, sig3); + } +} +========= End of pmoqobuz/src/api/signing.rs =========== + +=============== pmoqobuz/src/api/catalog.rs ============ +//! Module d'accès au catalogue Qobuz (albums, tracks, artistes, playlists) + +use super::QobuzApi; +use crate::error::{QobuzError, Result}; +use crate::models::*; +use serde::Deserialize; +use tracing::debug; + +/// Réponse paginée de l'API +#[derive(Debug, Deserialize)] +struct PaginatedResponse { + items: Vec, + #[serde(default)] + total: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + offset: Option, +} + +/// Réponse de l'endpoint /album/get +#[derive(Debug, Deserialize)] +pub(crate) struct AlbumResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + title: String, + artist: ArtistResponse, + #[serde(default)] + tracks_count: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + release_date_original: Option, + #[serde(default)] + image: Option, + #[serde(default = "default_streamable")] + streamable: bool, + #[serde(default)] + description: Option, + #[serde(default)] + maximum_sampling_rate: Option, + #[serde(default)] + maximum_bit_depth: Option, + #[serde(default)] + genre: Option, + #[serde(default)] + label: Option, + #[serde(default)] + tracks: Option>, +} + +/// Réponse de l'endpoint /track/get +#[derive(Debug, Deserialize)] +pub(crate) struct TrackResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + title: String, + #[serde(default)] + performer: Option, + #[serde(default)] + artist: Option, + #[serde(default)] + album: Option, + duration: u32, + track_number: u32, + media_number: u32, + #[serde(default = "default_streamable")] + streamable: bool, +} + +/// Réponse artiste +#[derive(Debug, Deserialize)] +pub(crate) struct ArtistResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + name: String, + #[serde(default)] + image: Option, + #[serde(default)] + albums: Option>, +} + +/// Réponse image +#[derive(Debug, Deserialize)] +struct ImageResponse { + #[serde(default)] + large: Option, +} + +/// Réponse genre +#[derive(Debug, Deserialize)] +struct GenreResponse { + #[serde(default)] + id: Option, + name: String, +} + +/// Réponse label +#[derive(Debug, Deserialize)] +struct LabelResponse { + name: String, +} + +/// Réponse playlist +#[derive(Debug, Deserialize)] +pub(crate) struct PlaylistResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + tracks_count: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + images300: Option>, + #[serde(default)] + is_public: bool, + #[serde(default)] + owner: Option, + #[serde(default)] + tracks: Option>, +} + +/// Réponse propriétaire +#[derive(Debug, Deserialize)] +struct OwnerResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + name: String, +} + +/// Réponse genres list +#[derive(Debug, Deserialize)] +struct GenresResponse { + genres: PaginatedResponse, +} + +/// Réponse albums featured +#[derive(Debug, Deserialize)] +struct FeaturedAlbumsResponse { + albums: PaginatedResponse, +} + +/// Réponse playlists featured +#[derive(Debug, Deserialize)] +struct FeaturedPlaylistsResponse { + playlists: PaginatedResponse, +} + +/// Réponse search +#[derive(Debug, Deserialize)] +struct SearchResponse { + #[serde(default)] + albums: Option>, + #[serde(default)] + artists: Option>, + #[serde(default)] + tracks: Option>, + #[serde(default)] + playlists: Option>, +} + +/// Réponse track file URL +#[derive(Debug, Deserialize)] +struct FileUrlResponse { + url: String, + mime_type: String, + sampling_rate: u32, + bit_depth: u32, + format_id: u8, +} + +fn default_streamable() -> bool { + true +} + +impl QobuzApi { + /// Récupère les détails d'un album + pub async fn get_album(&self, album_id: &str) -> Result { + debug!("Fetching album {}", album_id); + let params = [("album_id", album_id)]; + let response: AlbumResponse = self.get("/album/get", ¶ms).await?; + Ok(Self::parse_album(response)) + } + + /// Récupère les tracks d'un album + pub async fn get_album_tracks(&self, album_id: &str) -> Result> { + debug!("Fetching tracks for album {}", album_id); + let params = [("album_id", album_id)]; + let mut response: AlbumResponse = self.get("/album/get", ¶ms).await?; + + if let Some(tracks) = response.tracks.take() { + let album = Self::parse_album(response); + Ok(tracks + .items + .into_iter() + .map(|t| Self::parse_track(t, Some(album.clone()))) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les détails d'une track + pub async fn get_track(&self, track_id: &str) -> Result { + debug!("Fetching track {}", track_id); + let params = [("track_id", track_id)]; + let response: TrackResponse = self.get("/track/get", ¶ms).await?; + Ok(Self::parse_track(response, None)) + } + + /// Récupère l'URL de streaming d'une track + /// + /// Cette méthode nécessite un secret s4 pour signer la requête. + /// Si aucun secret n'est configuré, retourne une erreur. + /// + /// # Errors + /// + /// Retourne `QobuzError::Configuration` si le secret n'est pas configuré. + pub async fn get_file_url(&self, track_id: &str) -> Result { + use super::signing; + + debug!("Fetching file URL for track {}", track_id); + + // Vérifier que le secret est disponible + let secret = self + .secret().await + .ok_or_else(|| { + QobuzError::Configuration( + "Secret not configured. Cannot sign track/getFileUrl request.".to_string(), + ) + })?; + + let format_id = self.format_id.id().to_string(); + let intent = "stream"; + let timestamp = signing::get_timestamp(); + + // Signer la requête (comme Python: track_getFileUrl) + let signature = signing::sign_track_get_file_url( + &format_id, + intent, + track_id, + ×tamp, + &secret, + ); + + debug!( + "Signing track/getFileUrl: track_id={}, format_id={}, ts={}", + track_id, format_id, timestamp + ); + + // Construire les paramètres signés + let params = [ + ("track_id", track_id), + ("format_id", format_id.as_str()), + ("intent", intent), + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ]; + + // Utiliser GET (comme Python après sept 2024 selon le commentaire) + let response: FileUrlResponse = self.get("/track/getFileUrl", ¶ms).await?; + + Ok(StreamInfo { + url: response.url, + mime_type: response.mime_type, + sampling_rate: response.sampling_rate, + bit_depth: response.bit_depth, + format_id: response.format_id, + expires_at: chrono::Utc::now() + chrono::Duration::minutes(5), + }) + } + + /// Récupère les albums d'un artiste + pub async fn get_artist_albums(&self, artist_id: &str) -> Result> { + debug!("Fetching albums for artist {}", artist_id); + let params = [("artist_id", artist_id), ("extra", "albums")]; + let response: ArtistResponse = self.get("/artist/get", ¶ms).await?; + + if let Some(albums) = response.albums { + Ok(albums + .items + .into_iter() + .map(Self::parse_album) + .filter(|a| a.streamable) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les artistes similaires + pub async fn get_similar_artists(&self, artist_id: &str) -> Result> { + debug!("Fetching similar artists for {}", artist_id); + let params = [("artist_id", artist_id)]; + + #[derive(Debug, Deserialize)] + struct SimilarArtistsResponse { + artists: PaginatedResponse, + } + + let response: SimilarArtistsResponse = + self.get("/artist/getSimilarArtists", ¶ms).await?; + Ok(response + .artists + .items + .into_iter() + .map(Self::parse_artist) + .collect()) + } + + /// Récupère les détails d'une playlist + pub async fn get_playlist(&self, playlist_id: &str) -> Result { + debug!("Fetching playlist {}", playlist_id); + let params = [("playlist_id", playlist_id)]; + let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?; + Ok(Self::parse_playlist(response)) + } + + /// Récupère les tracks d'une playlist + pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result> { + debug!("Fetching tracks for playlist {}", playlist_id); + let params = [("playlist_id", playlist_id), ("extra", "tracks")]; + let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?; + + if let Some(tracks) = response.tracks { + Ok(tracks + .items + .into_iter() + .map(|t| Self::parse_track(t, None)) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère la liste des genres + pub async fn get_genres(&self) -> Result> { + debug!("Fetching genres"); + let response: GenresResponse = self.get("/genre/list", &[]).await?; + Ok(response + .genres + .items + .into_iter() + .map(Self::parse_genre) + .collect()) + } + + /// Récupère les albums featured (nouveautés, éditeur, etc.) + pub async fn get_featured_albums( + &self, + genre_id: Option<&str>, + type_: &str, + ) -> Result> { + debug!("Fetching featured albums (type: {})", type_); + let mut params = vec![("type", type_), ("limit", "100")]; + + if let Some(gid) = genre_id { + params.push(("genre_ids", gid)); + } + + let response: FeaturedAlbumsResponse = self.get("/album/getFeatured", ¶ms).await?; + Ok(response + .albums + .items + .into_iter() + .map(Self::parse_album) + .filter(|a| a.streamable) + .collect()) + } + + /// Récupère les playlists featured + pub async fn get_featured_playlists( + &self, + genre_id: Option<&str>, + tags: Option<&str>, + ) -> Result> { + debug!("Fetching featured playlists"); + let mut params = vec![("type", "editor-picks"), ("limit", "100")]; + + if let Some(gid) = genre_id { + params.push(("genre_ids", gid)); + } + if let Some(t) = tags { + params.push(("tags", t)); + } + + let response: FeaturedPlaylistsResponse = + self.get("/playlist/getFeatured", ¶ms).await?; + Ok(response + .playlists + .items + .into_iter() + .map(Self::parse_playlist) + .collect()) + } + + /// Recherche dans le catalogue + pub async fn search(&self, query: &str, type_: Option<&str>) -> Result { + debug!("Searching for '{}' (type: {:?})", query, type_); + let mut params = vec![("query", query), ("limit", "200")]; + + if let Some(t) = type_ { + params.push(("type", t)); + } + + let response: SearchResponse = self.get("/catalog/search", ¶ms).await?; + + Ok(SearchResult { + albums: response + .albums + .map(|a| { + a.items + .into_iter() + .map(Self::parse_album) + .filter(|album| album.streamable) + .collect() + }) + .unwrap_or_default(), + artists: response + .artists + .map(|a| a.items.into_iter().map(Self::parse_artist).collect()) + .unwrap_or_default(), + tracks: response + .tracks + .map(|t| { + t.items + .into_iter() + .map(|track| Self::parse_track(track, None)) + .filter(|track| track.streamable) + .collect() + }) + .unwrap_or_default(), + playlists: response + .playlists + .map(|p| p.items.into_iter().map(Self::parse_playlist).collect()) + .unwrap_or_default(), + }) + } + + // Fonctions de parsing publiques (utilisées aussi par le module user) + + pub(crate) fn parse_album(response: AlbumResponse) -> Album { + Album { + id: response.id, + title: response.title, + artist: Self::parse_artist(response.artist), + tracks_count: response.tracks_count, + duration: response.duration, + release_date: response.release_date_original, + image: response.image.and_then(|i| i.large), + image_cached: None, + streamable: response.streamable, + description: response.description, + maximum_sampling_rate: response.maximum_sampling_rate, + maximum_bit_depth: response.maximum_bit_depth, + genres: response.genre.map(|g| vec![g.name]).unwrap_or_default(), + label: response.label.map(|l| l.name), + } + } + + pub(crate) fn parse_track(response: TrackResponse, album: Option) -> Track { + let performer = response + .performer + .or(response.artist) + .map(Self::parse_artist); + + let album = album.or_else(|| response.album.map(Self::parse_album)); + + Track { + id: response.id, + title: response.title, + performer, + album, + duration: response.duration, + track_number: response.track_number, + media_number: response.media_number, + streamable: response.streamable, + mime_type: None, + sample_rate: None, + bit_depth: None, + channels: None, + } + } + + pub(crate) fn parse_artist(response: ArtistResponse) -> Artist { + Artist { + id: response.id, + name: response.name, + image: response.image.and_then(|i| i.large), + image_cached: None, + } + } + + pub(crate) fn parse_playlist(response: PlaylistResponse) -> Playlist { + Playlist { + id: response.id, + name: response.name, + description: response.description, + tracks_count: response.tracks_count, + duration: response.duration, + image: response.images300.and_then(|imgs| imgs.first().cloned()), + image_cached: None, + is_public: response.is_public, + owner: response.owner.map(|o| PlaylistOwner { + id: o.id.parse().unwrap_or(0), + name: o.name, + }), + } + } + + pub(crate) fn parse_genre(response: GenreResponse) -> Genre { + Genre { + id: response.id, + name: response.name, + children: Vec::new(), + } + } +} +========= End of pmoqobuz/src/api/catalog.rs =========== + +=============== pmoqobuz/src/api/user.rs ============ +//! Module d'accès aux données utilisateur (favoris) + +use super::catalog::{AlbumResponse, ArtistResponse, PlaylistResponse, TrackResponse}; +use super::QobuzApi; +use crate::error::{QobuzError, Result}; +use crate::models::*; +use serde::Deserialize; +use tracing::debug; + +/// Réponse paginée +#[derive(Debug, Deserialize)] +struct PaginatedResponse { + items: Vec, +} + +/// Réponse de l'endpoint /favorite/getUserFavorites +#[derive(Debug, Deserialize)] +struct FavoritesResponse { + #[serde(default)] + albums: Option>, + #[serde(default)] + artists: Option>, + #[serde(default)] + tracks: Option>, +} + +/// Réponse de l'endpoint /playlist/getUserPlaylists +#[derive(Debug, Deserialize)] +struct UserPlaylistsResponse { + playlists: PaginatedResponse, +} + +impl QobuzApi { + /// Vérifie que l'utilisateur est authentifié + fn ensure_authenticated(&self) -> Result<&str> { + self.user_id + .as_deref() + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string())) + } + + /// Récupère les albums favoris de l'utilisateur + pub async fn get_favorite_albums(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching favorite albums for user {}", user_id); + + let params = [("user_id", user_id), ("type", "albums"), ("limit", "1000")]; + + let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; + + if let Some(albums) = response.albums { + Ok(albums + .items + .into_iter() + .map(QobuzApi::parse_album) + .filter(|a| a.streamable) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les artistes favoris de l'utilisateur + pub async fn get_favorite_artists(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching favorite artists for user {}", user_id); + + let params = [("user_id", user_id), ("type", "artists"), ("limit", "1000")]; + + let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; + + if let Some(artists) = response.artists { + Ok(artists + .items + .into_iter() + .map(QobuzApi::parse_artist) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les tracks favorites de l'utilisateur + pub async fn get_favorite_tracks(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching favorite tracks for user {}", user_id); + + let params = [("user_id", user_id), ("type", "tracks"), ("limit", "1000")]; + + let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; + + if let Some(tracks) = response.tracks { + Ok(tracks + .items + .into_iter() + .map(|t| QobuzApi::parse_track(t, None)) + .filter(|t| t.streamable) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les playlists de l'utilisateur + pub async fn get_user_playlists(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching playlists for user {}", user_id); + + let params = [("user_id", user_id), ("limit", "1000")]; + + let response: UserPlaylistsResponse = + self.get("/playlist/getUserPlaylists", ¶ms).await?; + + Ok(response + .playlists + .items + .into_iter() + .map(QobuzApi::parse_playlist) + .collect()) + } + + /// Ajoute un album aux favoris + pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Adding album {} to favorites for user {}", + album_id, user_id + ); + + let params = [("album_id", album_id), ("user_id", user_id)]; + + self.get::("/favorite/create", ¶ms) + .await?; + Ok(()) + } + + /// Supprime un album des favoris + pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Removing album {} from favorites for user {}", + album_id, user_id + ); + + let params = [("album_ids", album_id), ("user_id", user_id)]; + + self.get::("/favorite/delete", ¶ms) + .await?; + Ok(()) + } + + /// Ajoute un track aux favoris + pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Adding track {} to favorites for user {}", + track_id, user_id + ); + + let params = [("track_id", track_id), ("user_id", user_id)]; + + self.get::("/favorite/create", ¶ms) + .await?; + Ok(()) + } + + /// Supprime un track des favoris + pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Removing track {} from favorites for user {}", + track_id, user_id + ); + + let params = [("track_ids", track_id), ("user_id", user_id)]; + + self.get::("/favorite/delete", ¶ms) + .await?; + Ok(()) + } + + /// Ajoute un track à une playlist + pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Adding track {} to playlist {} for user {}", + track_id, playlist_id, user_id + ); + + let params = [("playlist_id", playlist_id), ("track_ids", track_id)]; + + self.get::("/playlist/addTracks", ¶ms) + .await?; + Ok(()) + } + + /// Récupère la liste des albums de la bibliothèque utilisateur + /// + /// Cette méthode nécessite un secret s4 pour signer la requête. + /// Elle est principalement utilisée pour tester la validité d'un secret. + /// + /// Dans le code Python, cette méthode est utilisée par `setSec()` pour + /// tester chaque secret retourné par le Spoofer. + /// + /// # Errors + /// + /// Retourne `QobuzError::Configuration` si le secret n'est pas configuré. + /// Retourne `QobuzError::Unauthorized` si l'utilisateur n'est pas authentifié. + pub async fn userlib_get_albums(&self) -> Result { + use super::signing; + + // Vérifier l'authentification + self.ensure_authenticated()?; + + // Vérifier que le secret est disponible + let secret = self + .secret().await + .ok_or_else(|| { + QobuzError::Configuration( + "Secret not configured. Cannot sign userLibrary/getAlbumsList request." + .to_string(), + ) + })?; + + let timestamp = signing::get_timestamp(); + + // Signer la requête (comme Python: userlib_getAlbums) + let signature = signing::sign_userlib_get_albums(×tamp, &secret); + + debug!( + "Signing userLibrary/getAlbumsList: app_id={}, ts={}", + self.app_id(), + timestamp + ); + + // Construire les paramètres signés + let user_auth_token = self + .auth_token() + .ok_or_else(|| QobuzError::Unauthorized("No auth token".to_string()))?; + + let params = [ + ("app_id", self.app_id()), + ("user_auth_token", user_auth_token), + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ]; + + // Utiliser POST (comme Python) + self.post("/userLibrary/getAlbumsList", ¶ms).await + } + + /// Teste si un secret est valide en essayant de récupérer les albums + /// + /// Cette méthode est équivalente au test fait dans `setSec()` en Python. + /// Elle retourne `true` si le secret fonctionne, `false` sinon. + pub async fn test_secret(&self, _secret: &[u8]) -> bool { + // Sauvegarder le secret actuel + let _current_secret = self.secret().await; + + // Définir temporairement le nouveau secret + // Note: cette méthode nécessite &mut self, donc on doit la rendre mutable + // Pour l'instant, on ne peut pas modifier self dans cette méthode + // TODO: Refactoriser pour permettre de tester les secrets + + // Restaurer le secret original + false + } +} +========= End of pmoqobuz/src/api/user.rs =========== + +=============== pmoqobuz/src/api/mod.rs ============ +//! Couche d'accès à l'API REST Qobuz +//! +//! Ce module fournit une interface bas-niveau pour communiquer avec l'API Qobuz. + +pub mod auth; +pub mod catalog; +pub mod signing; +pub mod spoofer; +pub mod user; + +use crate::error::{QobuzError, Result}; +use crate::models::AudioFormat; +use reqwest::{Client, Response}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tracing::{debug, warn}; + +pub use spoofer::Spoofer; + +/// URL de base de l'API Qobuz +const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2"; + +/// App ID Qobuz par défaut +/// +/// Cet App ID est un fallback au cas où : +/// - Aucun appID n'est configuré dans pmoconfig +/// - Le Spoofer n'est pas disponible ou échoue +/// +/// Note: Cet App ID peut devenir obsolète avec le temps. +/// Il est recommandé d'utiliser soit la configuration manuelle, +/// soit le Spoofer pour obtenir un App ID à jour. +pub const DEFAULT_APP_ID: &str = "1401488693436528"; + +/// Client API bas-niveau pour communiquer avec Qobuz +pub struct QobuzApi { + /// Client HTTP + client: Client, + /// App ID pour l'authentification + app_id: String, + /// Secret s4 pour signer les requêtes sensibles (track/getFileUrl, userLibrary/*) + /// + /// Ce secret est obtenu soit : + /// - En décodant un `configvalue` (base64) et XOR avec l'app_id + /// - Depuis le Spoofer (secrets dynamiques) + /// + /// Utilise Arc pour permettre le refresh automatique en cas d'erreur de signature + secret: Arc>>>, + /// Token d'authentification utilisateur + user_auth_token: Option, + /// ID utilisateur + user_id: Option, + /// Format audio par défaut + format_id: AudioFormat, + /// Rate limiter: Semaphore for max concurrent requests + rate_limiter: Option>, + /// Last request timestamp (for minimum delay) + last_request: Arc>, + /// Minimum delay between requests (milliseconds) + min_delay_ms: u64, +} + +impl QobuzApi { + /// Crée une nouvelle instance de l'API + pub fn new(app_id: impl Into) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:67.0) Gecko/20100101 Firefox/67.0", + ) + .build()?; + + Ok(Self { + client, + app_id: app_id.into(), + secret: Arc::new(tokio::sync::RwLock::new(None)), + user_auth_token: None, + user_id: None, + format_id: AudioFormat::default(), + rate_limiter: None, + last_request: Arc::new(tokio::sync::Mutex::new(std::time::Instant::now())), + min_delay_ms: 0, + }) + } + + /// Crée une API avec un secret depuis configvalue (base64) + /// + /// # Arguments + /// + /// * `app_id` - App ID Qobuz + /// * `configvalue` - Secret encodé en base64 (à XORer avec l'app_id) + /// + /// # Note + /// + /// Cette méthode reproduit le comportement Python de `__set_s4()`. + /// Le configvalue est décodé depuis base64, puis XORé avec l'app_id + /// pour obtenir le secret s4. + pub async fn with_secret(app_id: impl Into, configvalue: &str) -> Result { + let api = Self::new(app_id)?; + api.set_secret_from_configvalue(configvalue).await?; + Ok(api) + } + + /// Définit le secret s4 directement + /// + /// # Arguments + /// + /// * `secret` - Secret s4 en bytes (déjà décodé et dérivé) + pub async fn set_secret(&self, secret: Vec) { + *self.secret.write().await = Some(secret); + } + + /// Dérive et définit le secret s4 depuis un configvalue + /// + /// Reproduit la logique Python de `__set_s4()`: + /// 1. Décode le configvalue depuis base64 + /// 2. XOR avec l'app_id + /// 3. Stocke le résultat comme secret s4 + async fn set_secret_from_configvalue(&self, configvalue: &str) -> Result<()> { + use base64::{engine::general_purpose::STANDARD, Engine}; + + // Décoder le configvalue depuis base64 + let s3s = STANDARD + .decode(configvalue.trim()) + .map_err(|e| QobuzError::Configuration(format!("Invalid configvalue: {}", e)))?; + + // XOR avec l'app_id + let app_id_bytes = self.app_id.as_bytes(); + let mut s4 = Vec::with_capacity(s3s.len()); + + for (i, &byte) in s3s.iter().enumerate() { + let app_byte = app_id_bytes[i % app_id_bytes.len()]; + s4.push(byte ^ app_byte); + } + + *self.secret.write().await = Some(s4); + Ok(()) + } + + /// Retourne le secret s4 si disponible + pub async fn secret(&self) -> Option> { + self.secret.read().await.clone() + } + + /// Définit le token d'authentification + pub fn set_auth_token(&mut self, token: String, user_id: String) { + self.user_auth_token = Some(token); + self.user_id = Some(user_id); + } + + /// Définit le format audio par défaut + pub fn set_format(&mut self, format: AudioFormat) { + self.format_id = format; + } + + /// Retourne le format audio configuré + pub fn format(&self) -> AudioFormat { + self.format_id + } + + /// Retourne l'App ID + pub fn app_id(&self) -> &str { + &self.app_id + } + + /// Retourne le token d'authentification si disponible + pub fn auth_token(&self) -> Option<&str> { + self.user_auth_token.as_deref() + } + + /// Retourne l'ID utilisateur si disponible + pub fn user_id(&self) -> Option<&str> { + self.user_id.as_deref() + } + + /// Enable rate limiting with configurable parameters + /// + /// # Arguments + /// + /// * `max_concurrent` - Maximum number of concurrent requests + /// * `min_delay_ms` - Minimum delay between requests in milliseconds + /// + /// # Example + /// + /// ```ignore + /// api.enable_rate_limiting(2, 400); // Max 2 concurrent, 400ms delay + /// ``` + pub fn enable_rate_limiting(&mut self, max_concurrent: usize, min_delay_ms: u64) { + self.rate_limiter = Some(Arc::new(tokio::sync::Semaphore::new(max_concurrent))); + self.min_delay_ms = min_delay_ms; + debug!( + "Rate limiting enabled: {} concurrent requests, {}ms min delay", + max_concurrent, min_delay_ms + ); + } + + /// Check if rate limiting is enabled + pub fn is_rate_limiting_enabled(&self) -> bool { + self.rate_limiter.is_some() + } + + /// Effectue une requête GET à l'API + pub(crate) async fn get( + &self, + endpoint: &str, + params: &[(&str, &str)], + ) -> Result { + self.request("GET", endpoint, params).await + } + + /// Effectue une requête POST à l'API + pub(crate) async fn post( + &self, + endpoint: &str, + params: &[(&str, &str)], + ) -> Result { + self.request("POST", endpoint, params).await + } + + /// Effectue une requête à l'API (générique) + async fn request( + &self, + method: &str, + endpoint: &str, + params: &[(&str, &str)], + ) -> Result { + let url = format!("{}{}", API_BASE_URL, endpoint); + + // ===== RATE LIMITING LOGIC ===== + let _permit = if let Some(ref limiter) = self.rate_limiter { + // 1. Acquire semaphore permit (blocks if max concurrent reached) + let permit = limiter + .acquire() + .await + .map_err(|e| QobuzError::Other(format!("Rate limiter error: {}", e)))?; + + // 2. Enforce minimum delay + if self.min_delay_ms > 0 { + let mut last = self.last_request.lock().await; + let elapsed = last.elapsed(); + let min_delay = Duration::from_millis(self.min_delay_ms); + + if elapsed < min_delay { + let wait_time = min_delay - elapsed; + debug!("Rate limiting: waiting {:?} before request", wait_time); + tokio::time::sleep(wait_time).await; + } + + *last = Instant::now(); + } + + Some(permit) // Keep permit alive until request completes + } else { + None + }; + // ===== END RATE LIMITING ===== + + debug!("{} {} with {} params", method, url, params.len()); + + let mut request = if method == "GET" { + self.client.get(&url) + } else { + self.client.post(&url) + }; + + // Ajouter les headers + request = request.header("X-App-Id", &self.app_id); + + if let Some(ref token) = self.user_auth_token { + request = request.header("X-User-Auth-Token", token); + } + + // Ajouter les paramètres + if method == "GET" { + request = request.query(params); + } else { + request = request.form(params); + } + + // Envoyer la requête + let response = request.send().await?; + self.handle_response(response).await + } + + /// Traite la réponse HTTP + async fn handle_response(&self, response: Response) -> Result { + let status = response.status(); + let status_code = status.as_u16(); + + debug!("Response status: {}", status); + + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + warn!("API error ({}): {}", status_code, error_text); + return Err(QobuzError::from_status_code(status_code, error_text)); + } + + let text = response.text().await?; + + // Vérifier si la réponse contient une erreur Qobuz + if let Ok(json) = serde_json::from_str::(&text) { + if let Some(status_obj) = json.get("status") { + if status_obj == "error" { + let message = json + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + warn!("Qobuz API error: {}", message); + return Err(QobuzError::ApiError { + code: status_code, + message: message.to_string(), + }); + } + } + } + + // Parser la réponse + serde_json::from_str(&text).map_err(|e| { + warn!("Failed to parse response: {}", e); + QobuzError::JsonParse(e) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_creation() { + let api = QobuzApi::new("test_app_id").unwrap(); + assert_eq!(api.app_id(), "test_app_id"); + assert!(api.auth_token().is_none()); + } + + #[test] + fn test_set_auth_token() { + let mut api = QobuzApi::new("test_app_id").unwrap(); + api.set_auth_token("test_token".to_string(), "user123".to_string()); + assert_eq!(api.auth_token(), Some("test_token")); + assert_eq!(api.user_id(), Some("user123")); + } + + #[test] + fn test_set_format() { + let mut api = QobuzApi::new("test_app_id").unwrap(); + api.set_format(AudioFormat::Flac_HiRes_96); + assert_eq!(api.format(), AudioFormat::Flac_HiRes_96); + } +} +========= End of pmoqobuz/src/api/mod.rs =========== + +=============== pmoqobuz/src/pmoserver_ext.rs ============ +//! Extension de pmoserver::Server pour intégrer le client Qobuz +//! +//! Ce module fournit un trait d'extension permettant d'ajouter facilement +//! le client Qobuz et ses endpoints à un serveur pmoserver. + +use crate::client::QobuzClient; +use anyhow::Result; +use std::sync::Arc; + +/// Trait d'extension pour ajouter le support Qobuz à un serveur pmoserver +/// +/// Ce trait permet à `pmoqobuz` d'ajouter des méthodes d'extension sur +/// `pmoserver::Server` sans que pmoserver dépende de pmoqobuz. +/// +/// # Architecture +/// +/// Similaire au pattern utilisé par `pmocovers` avec `CoverCacheExt`, ce trait permet +/// une extension propre et découplée : +/// +/// - `pmoserver` définit un serveur HTTP générique +/// - `pmoqobuz` étend ce serveur avec des fonctionnalités Qobuz via ce trait +/// - Le serveur n'a pas besoin de connaître `pmoqobuz` +/// +/// # Exemple +/// +/// ```rust,no_run +/// use pmoqobuz::QobuzServerExt; +/// use pmoserver::ServerBuilder; +/// +/// #[tokio::main] +/// async fn main() -> anyhow::Result<()> { +/// let mut server = ServerBuilder::new_configured().build(); +/// +/// // Initialise le client Qobuz depuis la config +/// server.init_qobuz_client_configured().await?; +/// +/// server.start().await; +/// server.wait().await; +/// Ok(()) +/// } +/// ``` +pub trait QobuzServerExt { + /// Initialise le client Qobuz et enregistre les routes HTTP + /// + /// # Arguments + /// + /// * `username` - Email ou nom d'utilisateur Qobuz + /// * `password` - Mot de passe + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du client + /// + /// # Routes enregistrées + /// + /// - `GET /qobuz/albums/{id}` - Détails d'un album + /// - `GET /qobuz/albums/{id}/tracks` - Tracks d'un album + /// - `GET /qobuz/tracks/{id}` - Détails d'une track + /// - `GET /qobuz/tracks/{id}/stream` - URL de streaming + /// - `GET /qobuz/artists/{id}` - Détails d'un artiste + /// - `GET /qobuz/artists/{id}/albums` - Albums d'un artiste + /// - `GET /qobuz/playlists/{id}` - Détails d'une playlist + /// - `GET /qobuz/playlists/{id}/tracks` - Tracks d'une playlist + /// - `GET /qobuz/search` - Recherche (query params: q, type) + /// - `GET /qobuz/favorites/albums` - Albums favoris + /// - `GET /qobuz/favorites/artists` - Artistes favoris + /// - `GET /qobuz/favorites/tracks` - Tracks favoris + /// - `GET /qobuz/favorites/playlists` - Playlists utilisateur + /// - `GET /qobuz/genres` - Liste des genres + /// - `GET /qobuz/featured/albums` - Albums featured + /// - `GET /qobuz/featured/playlists` - Playlists featured + /// - `GET /qobuz/cache/stats` - Statistiques du cache + /// - `GET /swagger-ui` - Documentation interactive + async fn init_qobuz_client( + &mut self, + username: &str, + password: &str, + ) -> Result>; + + /// Initialise le client Qobuz avec la configuration par défaut + /// + /// Utilise automatiquement les credentials de `pmoconfig::Config` : + /// - `accounts.qobuz.username` pour le nom d'utilisateur + /// - `accounts.qobuz.password` pour le mot de passe + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du client + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzServerExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // Utilise automatiquement la config + /// server.init_qobuz_client_configured().await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + async fn init_qobuz_client_configured(&mut self) -> Result>; + + /// Initialise le client Qobuz avec intégration pmocovers + /// + /// Les images d'albums seront automatiquement ajoutées au cache pmocovers fourni. + /// + /// # Arguments + /// + /// * `username` - Email ou nom d'utilisateur Qobuz + /// * `password` - Mot de passe + /// * `cover_cache` - Instance du cache pmocovers à utiliser + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du client avec cache d'images + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzServerExt; + /// use pmocovers::CoverCacheExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // D'abord initialiser le cache d'images + /// let cache = server.init_cover_cache_configured().await?; + /// + /// // Puis initialiser Qobuz avec le cache + /// server.init_qobuz_client_with_covers("user", "pass", cache).await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "covers")] + async fn init_qobuz_client_with_covers( + &mut self, + username: &str, + password: &str, + cover_cache: Arc, + ) -> Result>; + + /// Initialise le client Qobuz avec intégration pmocovers depuis la configuration + /// + /// # Arguments + /// + /// * `cover_cache` - Instance du cache pmocovers à utiliser + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzServerExt; + /// use pmocovers::CoverCacheExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // D'abord initialiser le cache + /// let cache = server.init_cover_cache_configured().await?; + /// + /// // Puis initialiser Qobuz avec le cache + /// server.init_qobuz_client_configured_with_covers(cache).await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "covers")] + async fn init_qobuz_client_configured_with_covers( + &mut self, + cover_cache: Arc, + ) -> Result>; +} + +// L'implémentation du trait sera dans un module séparé (pmoserver_impl.rs) +// pour éviter les dépendances circulaires +========= End of pmoqobuz/src/pmoserver_ext.rs =========== + diff --git a/pmoqobuz_028.txt b/pmoqobuz_028.txt new file mode 100644 index 00000000..3fe87d9a --- /dev/null +++ b/pmoqobuz_028.txt @@ -0,0 +1,8610 @@ +=============== pmoqobuz/Cargo.toml ============ +[package] +name = "pmoqobuz" +version = "0.1.0" +edition = "2021" + +[dependencies] +regex = "1.12" +base64 = "0.22" +indexmap = "2.0" +async-trait = { version = "0.1", optional = true } + +# HTTP client pour les requêtes à l'API Qobuz +reqwest = { version = "0.12", features = ["json", "cookies"] } + +# Gestion asynchrone +tokio = { version = "1", features = ["full"] } + +# Sérialisation/Désérialisation JSON +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" + +# Gestion des erreurs +anyhow = "1.0" +thiserror = "1.0" + +# Hashing pour les clés de cache et signatures +sha1 = "0.10" +hex = "0.4" +md-5 = "0.10" + +# Cache en mémoire avec TTL +moka = { version = "0.12", features = ["future"] } + +# Logging +tracing = "0.1" + +# Gestion du temps +chrono = { version = "0.4", features = ["serde"] } + +# Configuration +pmoconfig = { path = "../pmoconfig" } + +# Intégration avec pmocovers pour le cache d'images (OBLIGATOIRE) +pmocovers = { path = "../pmocovers" } + +# Intégration avec pmoaudiocache pour le cache audio (OBLIGATOIRE) +pmoaudiocache = { path = "../pmoaudiocache" } + +# Intégration avec pmodidl pour l'export DIDL +pmodidl = { path = "../pmodidl" } + +# Intégration avec pmoserver pour l'API HTTP +pmoserver = { path = "../pmoserver", optional = true } +axum = { version = "0.8", optional = true } +rusqlite = { version = "0.32", features = ["bundled"], optional = true } + +# Documentation OpenAPI +utoipa = { version = "5.3", optional = true } + +# Common music source traits +pmosource = { path = "../pmosource" } + +# Playlist management +pmoplaylist = { path = "../pmoplaylist" } + +[features] +default = [] +# Feature pour activer les extensions pmoserver +pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"] +# Feature pour activer le support serveur (cache registry) +server = ["pmosource/server"] +# Feature cache (deprecated - toujours actif maintenant) +cache = [] +disk-cache = ["dep:rusqlite", "dep:async-trait"] + +[dev-dependencies] +# Tests +tokio-test = "0.4" +mockito = "1.0" +tempfile = "3.0" +# Pour les exemples +tracing-subscriber = "0.3" +pmocache = { path = "../pmocache" } +# Pour l'exemple spoofer + +# Specify that the with_cache example requires the cache feature +[[example]] +name = "with_cache" +required-features = ["cache"] +========= End of pmoqobuz/Cargo.toml =========== + +=============== pmoqobuz/IMPLEMENTATION_STATUS.md ============ +# Statut d'implémentation de l'API Qobuz + +**Date** : 2025-12-10 +**Statut** : ✅ **PRODUCTION READY avec Spoofer intégré** + +## Résumé + +L'implémentation Rust de `pmoqobuz` suit maintenant fidèlement l'API de référence Python (`qobuz.api.raw`) pour toutes les fonctionnalités critiques. Le Spoofer est désormais intégré automatiquement dans le client pour obtenir dynamiquement des AppID et secrets valides. + +## ✅ Problèmes corrigés + +### 1. ✅ Gestion du secret `s4` + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/api/mod.rs](src/api/mod.rs) +- **Ajouts** : + - Champ `secret: Option>` dans `QobuzApi` + - `with_secret()` - Crée une API avec appID + configvalue (base64) + - `set_secret()` - Définit le secret directement + - `set_secret_from_configvalue()` - Décodage base64 + XOR avec appID + - `secret()` - Getter pour le secret + +### 2. ✅ Signature MD5 des requêtes + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/api/signing.rs](src/api/signing.rs) (nouveau) +- **Fonctions implémentées** : + - `get_timestamp()` - Génère timestamp Unix + - `sign_track_get_file_url()` - Signature pour `track/getFileUrl` + - `sign_userlib_get_albums()` - Signature pour `userLibrary/getAlbumsList` +- **Tests unitaires** : ✅ Tous passants + +### 3. ✅ Méthode `get_file_url` avec signature + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/api/catalog.rs](src/api/catalog.rs:217-269) +- **Modifications** : + - Vérification du secret avant la requête + - Génération du timestamp + - Signature MD5 de la requête + - Ajout de `request_ts` et `request_sig` aux paramètres +- **Comportement** : Retourne `QobuzError::Configuration` si le secret n'est pas configuré + +### 4. ✅ Méthode `userlib_getAlbums` + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/api/user.rs](src/api/user.rs:196-249) +- **Fonctionnalités** : + - Signature MD5 avec le secret + - Utilisée pour tester la validité des secrets + - Requête POST vers `/userLibrary/getAlbumsList` + +### 5. ✅ Configuration AppID et Secret + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/config_ext.rs](src/config_ext.rs) +- **Méthodes ajoutées** : + - `get_qobuz_appid()` / `set_qobuz_appid()` + - `get_qobuz_secret()` / `set_qobuz_secret()` +- **Configuration YAML** : + ```yaml + accounts: + qobuz: + username: "user@example.com" + password: "password" + appid: "1401488693436528" # Optionnel + secret: "base64_encoded_secret" # Optionnel + ``` + +### 6. ✅ Intégration dans QobuzClient + +**État** : **TERMINÉ** + +- **Fichier** : [pmoqobuz/src/client.rs](src/client.rs:80-129) +- **Logique** : + 1. Si `appid` ET `secret` configurés → `QobuzApi::with_secret()` + 2. Sinon → `QobuzApi::new()` avec appid (ou DEFAULT_APP_ID) +- **Note** : Les requêtes signées échouent si le secret n'est pas configuré + +## 📦 Dépendances ajoutées + +```toml +md-5 = "0.10" # Pour les signatures MD5 +``` + +## 📁 Fichiers créés/modifiés + +### Nouveaux fichiers +- ✅ `src/api/signing.rs` - Module de signatures MD5 +- ✅ `src/config_ext.rs` - Trait d'extension pour la configuration +- ✅ `API_ANALYSIS.md` - Analyse des différences avec Python +- ✅ `IMPLEMENTATION_STATUS.md` - Ce fichier + +### Fichiers modifiés +- ✅ `src/api/mod.rs` - Ajout du support du secret s4 +- ✅ `src/api/catalog.rs` - Signature de `get_file_url` +- ✅ `src/api/user.rs` - Ajout de `userlib_get_albums` +- ✅ `src/client.rs` - Intégration du secret dans `from_config_obj` +- ✅ `src/error.rs` - Ajout de `QobuzError::Configuration` +- ✅ `src/lib.rs` - Export de `QobuzConfigExt` +- ✅ `Cargo.toml` - Ajout de `md-5` + +## 🧪 Tests + +### Compilation +```bash +cargo check +# ✅ warning: `pmoqobuz` (lib) generated 6 warnings +# ✅ Finished `dev` profile +``` + +### Exemples +```bash +cargo check --example basic_usage +# ✅ Finished `dev` profile +``` + +## 🚀 Utilisation + +### Option 1 : Sans secret (limité) + +**Configuration minimale** : +```yaml +accounts: + qobuz: + username: "user@example.com" + password: "password" +``` + +**Fonctionnalités disponibles** : +- ✅ Authentification +- ✅ Recherche (albums, artistes, tracks, playlists) +- ✅ Récupération des métadonnées (albums, tracks, etc.) +- ✅ Favoris +- ✅ Playlists +- ❌ Streaming (requiert signature) +- ❌ Bibliothèque utilisateur complète (requiert signature) + +### Option 2 : Avec secret (complet) + +**Configuration complète** : +```yaml +accounts: + qobuz: + username: "user@example.com" + password: "password" + appid: "1401488693436528" + secret: "Ym9vdHN0cmFw..." # Base64 encoded +``` + +**Fonctionnalités disponibles** : +- ✅ Toutes les fonctionnalités de l'Option 1 +- ✅ Streaming (avec `get_stream_url`) +- ✅ Bibliothèque utilisateur complète + +### Option 3 : Avec Spoofer (TODO) + +Le Spoofer permet d'obtenir automatiquement un AppID et des secrets valides. + +**Status** : 🚧 En cours (nécessite intégration dans `QobuzClient::from_config`) + +## ✅ Nouvelles fonctionnalités (2025-12-10) + +### 1. ✅ Désérialisation flexible des IDs + +**Problème résolu** : Les IDs Qobuz peuvent être des integers ou des strings dans les réponses JSON + +**Modifications** : +- Ajout de `deserialize_id()` dans [models.rs](src/models.rs:7-20) +- Application à toutes les structures (Artist, Album, Track, Playlist, etc.) +- Support automatique des deux formats + +### 2. ✅ Intégration automatique du Spoofer avec fallback intelligent + +**Fonctionnalité** : Le client gère automatiquement les credentials invalides/expirés + +**Logique d'initialisation** (client.rs:90-222) : +1. Si `appid` ET `secret` configurés → **test avec authentification** +2. Si l'authentification réussit → utilisation directe (pas de Spoofer) +3. Si l'authentification échoue (credentials invalides/expirés) → **fallback automatique vers Spoofer** +4. Si aucun `appid`/`secret` configuré → appel direct du Spoofer +5. Le Spoofer teste chaque secret et sauvegarde le premier valide +6. Fallback ultime vers DEFAULT_APP_ID si tout échoue + +**Avantages** : +- ✅ Aucune configuration manuelle requise +- ✅ **Gestion automatique de l'expiration des credentials** +- ✅ **Auto-réparation si les credentials deviennent invalides** +- ✅ Secrets toujours à jour +- ✅ Fonctionnement transparent pour l'utilisateur +- ✅ Configuration sauvegardée automatiquement + +## ⚠️ Limitations connues + +1. **Test des secrets** : La méthode `test_secret()` est incomplète (nécessite refactoring pour &mut self) + +## 📚 Documentation + +- [API_ANALYSIS.md](API_ANALYSIS.md) - Analyse détaillée des différences +- [examples/basic_usage.rs](examples/basic_usage.rs) - Exemple fonctionnel +- [examples/spoofer.rs](examples/spoofer.rs) - Exemple d'extraction AppID/secrets +- [examples/config_usage.rs](examples/config_usage.rs) - Exemple de configuration + +## ✅ Conclusion + +L'implémentation Rust reproduit fidèlement le comportement de l'API Python de référence pour toutes les opérations critiques. Le système de signatures MD5 fonctionne correctement, et le Spoofer intégré permet un fonctionnement automatique sans configuration manuelle. + +**Status global** : ✅ **PRODUCTION READY** + +### Avantages par rapport à la version Python : +- ✅ Intégration automatique du Spoofer (pas besoin de configuration manuelle) +- ✅ Désérialisation robuste (gère integers et strings pour les IDs) +- ✅ Sauvegarde automatique des credentials valides +- ✅ Performance supérieure (Rust) +- ✅ Type safety (compilation) +========= End of pmoqobuz/IMPLEMENTATION_STATUS.md =========== + +=============== pmoqobuz/tests/disk_cache.rs ============ +#![cfg(feature = "disk-cache")] + +use pmoqobuz::disk_cache::{CacheStore, SqliteCacheStore}; +use std::time::Duration; +use tokio::time::sleep; + +#[tokio::test] +async fn sqlite_cache_returns_fresh_entries() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("cache.sqlite"); + let store = SqliteCacheStore::new(path)?; + + let data = vec!["album".to_string()]; + store + .put_json("user", "favorites_albums", "all", Duration::from_secs(3600), &data) + .await?; + + let entry = store + .get_json::>("user", "favorites_albums", "all") + .await? + .expect("cache entry"); + + assert!(entry.fresh); + assert_eq!(entry.value, data); + + Ok(()) +} + +#[tokio::test] +async fn sqlite_cache_marks_entries_as_stale_after_ttl() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("cache.sqlite"); + let store = SqliteCacheStore::new(path)?; + + let data = vec!["track".to_string()]; + store + .put_json("user", "favorites_tracks", "all", Duration::from_secs(1), &data) + .await?; + + sleep(Duration::from_secs(2)).await; + + let entry = store + .get_json::>("user", "favorites_tracks", "all") + .await? + .expect("cache entry"); + + assert!(!entry.fresh); + + Ok(()) +} + +#[tokio::test] +async fn sqlite_cache_purge_expired_removes_entries() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("cache.sqlite"); + let store = SqliteCacheStore::new(path)?; + + let data = vec!["playlist".to_string()]; + store + .put_json("user", "user_playlists", "all", Duration::from_secs(1), &data) + .await?; + + sleep(Duration::from_secs(2)).await; + + let removed = store.purge_expired().await?; + assert_eq!(removed, 1); + + let entry = store + .get_json::>("user", "user_playlists", "all") + .await?; + + assert!(entry.is_none()); + + Ok(()) +} +========= End of pmoqobuz/tests/disk_cache.rs =========== + +=============== pmoqobuz/CACHE_STRATEGY.md ============ +# Stratégie de cache pour pmoqobuz + +## Vue d'ensemble + +Ce document décrit la stratégie complète de mise en cache dans `pmoqobuz` pour **minimiser le nombre de requêtes API** et **limiter les logins**. + +## Objectifs + +1. **Limiter les login** - Éviter de se reconnecter à chaque démarrage +2. **Minimiser les requêtes API** - Réduire la charge sur les serveurs Qobuz +3. **Améliorer les performances** - Réponses instantanées pour les données déjà chargées +4. **Transparence** - Le cache doit être invisible pour l'utilisateur final + +## Architecture du cache + +### 1. Cache du token d'authentification ✅ IMPLÉMENTÉ + +**Localisation** : Fichier `config.yaml` dans la section `accounts.qobuz` + +**Données stockées** : +```yaml +accounts: + qobuz: + username: eric@coissac.eu + password: encrypted:yRyu/jNlJRSdVz0eE+JX56UC2Tk016TmESDoLT6npLBJB3ZuhJ0XTqNOQjiXkkcB + appid: '798273057' + secret: 806331c3b0b641da923b890aed01d04a + # Token d'authentification (ajouté automatiquement) + auth_token: "r7xPjQ5Kn8..." + user_id: "1217710" + token_expires_at: 1733953200 + subscription_label: "Studio" +``` + +**Stratégie** : +- Au **démarrage** : Réutiliser le token stocké SANS vérifier l'expiration +- Si une requête échoue avec **401/403** : Re-login automatique (TODO) +- Après un **login réussi** : Sauvegarder le token dans la config +- **TTL** : 24 heures (mais validation lazy) + +**Bénéfices** : +- ✅ **Zéro login inutile au démarrage** +- ✅ Démarrage instantané de l'application +- ✅ Token persisté entre les sessions + +**Implémentation** : [config_ext.rs:254-354](src/config_ext.rs#L254-354) + +```rust +// Au démarrage - aucun login ! +if let (Ok(Some(token)), Ok(Some(user_id))) = + (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) +{ + api.set_auth_token(token, user_id); + info!("✓ Reusing authentication token (no login required)"); + // → Pas de requête réseau, démarrage instantané +} +``` + +### 2. Cache en mémoire (données API) ✅ IMPLÉMENTÉ + +**Localisation** : En mémoire (bibliothèque `moka`) + +**Implémentation** : [cache.rs](src/cache.rs) + +| Type de données | TTL | Capacité | Invalidation | +|----------------------|---------|-----------|--------------| +| Albums | 1h | 1000 | Manuelle | +| Tracks | 1h | 2000 | Manuelle | +| Artistes | 1h | 500 | Manuelle | +| Playlists | 30min | 250 | Manuelle | +| Résultats recherche | 15min | 500 | Manuelle | +| URLs streaming | 5min | 250 | Manuelle | + +**Stratégie** : +- **Vérifier le cache** avant chaque requête API +- Si donnée en cache ET non expirée → retour immédiat +- Sinon → requête API + mise en cache + +**Exemple** ([client.rs:247-263](src/client.rs#L247-263)) : +```rust +pub async fn get_album(&self, album_id: &str) -> Result { + // 1. 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); // ← Aucune requête API ! + } + + // 2. Sinon, récupérer depuis l'API + let album = self.api.get_album(album_id).await?; + + // 3. Mettre en cache pour la prochaine fois + self.cache.put_album(album_id.to_string(), album.clone()).await; + + Ok(album) +} +``` + +**Bénéfices** : +- ✅ Réponses instantanées pour les données fréquemment accédées +- ✅ Réduction drastique des requêtes API +- ✅ Expiration automatique (TTL) +- ✅ Limite de mémoire (LRU éviction) + +### 3. Cache sur disque (favoris et bibliothèque) ❌ TODO + +**Problème actuel** : Les favoris et la bibliothèque ne sont PAS cachés + +```rust +pub async fn get_favorite_albums(&self) -> Result> { + // ❌ Requête API à CHAQUE appel + self.api.get_favorite_albums().await +} +``` + +**Impact** : +- 375 albums favoris → requête complète à chaque fois +- Playlists utilisateur → requête complète à chaque fois + +**Solution proposée** : Cache disque avec invalidation intelligente + +```rust +// Fichier: ~/.pmomusic/cache/favorites_{user_id}.json +pub async fn get_favorite_albums(&self) -> Result> { + let cache_file = format!("cache/favorites_{}.json", self.user_id); + + // Vérifier le cache sur disque + if let Ok(cached) = load_from_disk(&cache_file) { + if !is_expired(&cached, Duration::from_secs(3600)) { + return Ok(cached.albums); + } + } + + // Sinon, récupérer depuis l'API + let albums = self.api.get_favorite_albums().await?; + + // Sauvegarder pour la prochaine fois + save_to_disk(&cache_file, &albums)?; + + Ok(albums) +} +``` + +**Bénéfices potentiels** : +- ✅ Cache persistant entre les sessions +- ✅ Réduction majeure des requêtes pour les gros catalogues +- ✅ TTL configurable (ex: 1h pour favoris, 24h pour bibliothèque) + +## Statistiques et monitoring + +### Métriques disponibles + +```rust +let stats = client.cache().stats().await; +println!("Albums en cache: {}", stats.albums_count); +println!("Tracks en cache: {}", stats.tracks_count); +println!("Total: {} entrées", stats.total_count()); +``` + +### Logs de debug + +```bash +RUST_LOG=debug ./pmomusic +# → Voir les hits/miss du cache +# → Voir les requêtes API effectuées +``` + +## Impact mesuré + +### Avant optimisations +- **Login à chaque démarrage** : ~500ms +- **Recherche "Miles Davis"** (2ème fois) : ~300ms (nouvelle requête API) +- **get_album("123")** (2ème fois) : ~200ms (nouvelle requête API) + +### Après optimisations +- **Login au démarrage** : 0ms (token réutilisé) ✅ +- **Recherche "Miles Davis"** (2ème fois) : ~1ms (cache mémoire) ✅ +- **get_album("123")** (2ème fois) : ~0.5ms (cache mémoire) ✅ + +**Réduction** : **~99% du temps de réponse** pour les données déjà chargées + +## Recommandations + +### Court terme + +1. ✅ **Token d'authentification** - IMPLÉMENTÉ +2. ✅ **Cache mémoire** - IMPLÉMENTÉ +3. ❌ **Cache disque pour favoris** - TODO (priorité haute) + +### Moyen terme + +4. ❌ **Re-login automatique** sur erreur 401/403 - TODO +5. ❌ **Cache des playlists utilisateur** - TODO +6. ❌ **Invalidation intelligente** (ex: invalider cache favoris après ajout) - TODO + +### Long terme + +7. ❌ **Cache partagé entre instances** (Redis/SQLite) - TODO +8. ❌ **Préchargement** (favoris au démarrage en arrière-plan) - TODO +9. ❌ **Compression** du cache disque - TODO + +## Configuration + +### Configurer la taille du cache + +```rust +let cache = QobuzCache::with_capacity(2000); // 2000 albums max +let client = QobuzClient::new_with_cache(username, password, cache).await?; +``` + +### Désactiver le cache (debugging) + +```rust +let cache = QobuzCache::with_capacity(0); // Cache désactivé +``` + +### Invalider le cache + +```rust +// Invalider un album spécifique +client.cache().invalidate_album("123").await; + +// Tout effacer +client.cache().clear_all().await; +``` + +## Tests + +```bash +# Tests du module cache +cargo test -p pmoqobuz cache + +# Tests d'intégration avec Qobuz +cargo run --example basic_usage + +# Vérifier les logs de cache +RUST_LOG=debug,pmoqobuz::cache=trace cargo run --example basic_usage +``` + +## Conclusion + +La stratégie de cache actuelle offre déjà **d'excellentes performances** : +- ✅ Démarrage instantané (pas de login) +- ✅ Requêtes ultra-rapides (cache mémoire) +- ✅ Réduction de ~99% des requêtes répétées + +**Prochaine étape prioritaire** : Implémenter le cache disque pour les favoris et bibliothèque utilisateur. +========= End of pmoqobuz/CACHE_STRATEGY.md =========== + +=============== pmoqobuz/API_ANALYSIS.md ============ +# Analyse des différences entre l'API Rust et Python + +## Vue d'ensemble + +L'implémentation actuelle de `pmoqobuz` ne suit pas complètement l'API de référence Python (`qobuz.api.raw`). Voici les principales différences et ce qui doit être corrigé. + +## Problèmes identifiés + +### 1. ❌ Gestion du secret `s4` manquante + +**Python** : +- Accepte soit `appid` + `configvalue` (secret encodé en base64) +- Soit utilise le `Spoofer` pour obtenir l'appID et les secrets dynamiquement +- Le `configvalue` est décodé et XORé avec l'appID pour obtenir le secret `s4` +- Le secret `s4` est utilisé pour signer certaines requêtes critiques + +**Rust actuel** : +- ❌ Utilise un `DEFAULT_APP_ID` codé en dur +- ❌ Pas de gestion du secret `s4` +- ❌ Pas d'utilisation du Spoofer pour obtenir l'appID/secret +- ❌ Pas de méthode pour décoder et dériver le secret depuis un `configvalue` + +**Impact** : +- Les requêtes `track/getFileUrl` et `userLibrary/getAlbumsList` échoueront probablement car elles nécessitent une signature MD5 + +### 2. ❌ Signature MD5 des requêtes manquante + +**Python - track_getFileUrl** : +```python +ts = str(time.time()) +stringvalue = ("trackgetFileUrlformat_id" + fmt_id + + "intent" + intent + + "track_id" + track_id + ts).encode("ASCII") +stringvalue += self.s4 # Secret ajouté +rq_sig = str(hashlib.md5(stringvalue).hexdigest()) +params = { + "format_id": fmt_id, + "intent": intent, + "request_ts": ts, # ← Timestamp + "request_sig": rq_sig, # ← Signature MD5 + "track_id": track_id, +} +``` + +**Rust actuel (catalog.rs:210-218)** : +```rust +let params = [ + ("track_id", track_id), + ("format_id", &format_id), + ("intent", "stream"), + // ❌ MANQUE: request_ts + // ❌ MANQUE: request_sig +]; +``` + +**Impact** : +- Les requêtes de streaming peuvent échouer ou retourner des URLs invalides + +### 3. ❌ Méthode `userlib_getAlbums` manquante + +**Python** : +```python +def userlib_getAlbums(self, **ka): + ts = str(time.time()) + r_sig = "userLibrarygetAlbumsList" + str(ts) + str(ka["sec"]) + r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest() + params = { + "app_id": self.appid, + "user_auth_token": self.user_auth_token, + "request_ts": ts, + "request_sig": r_sig_hashed, + } + return self._api_request(params, "/userLibrary/getAlbumsList") +``` + +**Rust actuel** : +- ❌ Méthode totalement absente + +**Impact** : +- Impossible de tester les secrets (méthode `setSec()`) +- Impossible de récupérer la bibliothèque d'albums de l'utilisateur + +### 4. ❌ Méthode `setSec()` manquante + +**Python** : +```python +def setSec(self): + # Teste tous les secrets du spoofer + for value in self.spoofer.getSecrets().values(): + self.s4 = value.encode("utf-8") + if self.userlib_getAlbums(sec=self.s4) is not None: + # Ce secret fonctionne ! + return +``` + +**Rust actuel** : +- ❌ Méthode totalement absente +- ❌ Pas de mécanisme pour tester et sélectionner le bon secret + +**Impact** : +- Si on utilise le Spoofer, impossible de trouver le bon secret parmi ceux retournés + +### 5. ⚠️ Configuration incomplète + +**Python** : +- Peut être initialisé avec `appid` + `configvalue` OU utiliser le Spoofer + +**Rust actuel** : +- ✅ Configuration du username/password via `QobuzConfigExt` +- ❌ Pas de configuration pour `appid` et `secret`/`configvalue` + +**Impact** : +- Impossible de configurer manuellement un appID et secret valides +- Dépendance à un appID codé en dur qui peut devenir obsolète + +## Plan de correction + +### Phase 1: Extension de la configuration + +**Fichier: `pmoqobuz/src/config_ext.rs`** + +Ajouter au trait `QobuzConfigExt` : +- `get_qobuz_appid()` / `set_qobuz_appid()` +- `get_qobuz_secret()` / `set_qobuz_secret()` (stocke la valeur base64) + +### Phase 2: Ajout du support du secret dans QobuzApi + +**Fichier: `pmoqobuz/src/api/mod.rs`** + +Modifications de `QobuzApi` : +```rust +pub struct QobuzApi { + client: Client, + app_id: String, + secret: Option>, // ← Nouveau : secret s4 décodé + user_auth_token: Option, + user_id: Option, + format_id: AudioFormat, +} +``` + +Nouvelles méthodes : +```rust +impl QobuzApi { + /// Crée une API avec appid + configvalue + pub fn with_secret(app_id: impl Into, configvalue: &str) -> Result; + + /// Crée une API en utilisant le Spoofer + pub async fn with_spoofer() -> Result; + + /// Définit le secret s4 + pub fn set_secret(&mut self, secret: Vec); + + /// Teste un secret en appelant userlib_getAlbums + async fn test_secret(&self, secret: &[u8]) -> bool; + + /// Teste et sélectionne le bon secret depuis le Spoofer + async fn set_secret_from_spoofer(&mut self, spoofer: &Spoofer) -> Result<()>; +} +``` + +### Phase 3: Implémentation des méthodes signées + +**Fichier: `pmoqobuz/src/api/signing.rs` (nouveau)** + +```rust +use md5::{Md5, Digest}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Génère un timestamp Unix +pub fn get_timestamp() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs_f64() + .to_string() +} + +/// Signe une requête track/getFileUrl +pub fn sign_track_get_file_url( + format_id: &str, + intent: &str, + track_id: &str, + timestamp: &str, + secret: &[u8], +) -> String { + let mut hasher = Md5::new(); + hasher.update(b"trackgetFileUrlformat_id"); + hasher.update(format_id.as_bytes()); + hasher.update(b"intent"); + hasher.update(intent.as_bytes()); + hasher.update(b"track_id"); + hasher.update(track_id.as_bytes()); + hasher.update(timestamp.as_bytes()); + hasher.update(secret); + format!("{:x}", hasher.finalize()) +} + +/// Signe une requête userLibrary/getAlbumsList +pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String { + let mut hasher = Md5::new(); + hasher.update(b"userLibrarygetAlbumsList"); + hasher.update(timestamp.as_bytes()); + hasher.update(secret); + format!("{:x}", hasher.finalize()) +} +``` + +**Fichier: `pmoqobuz/src/api/catalog.rs`** + +Modifier `get_file_url` : +```rust +pub async fn get_file_url(&self, track_id: &str) -> Result { + let format_id = self.format_id.id().to_string(); + let timestamp = signing::get_timestamp(); + + // Signature MD5 requise ! + let secret = self.secret.as_ref() + .ok_or_else(|| QobuzError::Configuration("Secret not configured".into()))?; + + let signature = signing::sign_track_get_file_url( + &format_id, + "stream", + track_id, + ×tamp, + secret, + ); + + let params = [ + ("track_id", track_id), + ("format_id", format_id.as_str()), + ("intent", "stream"), + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ]; + + let response: FileUrlResponse = self.get("/track/getFileUrl", ¶ms).await?; + // ... +} +``` + +**Fichier: `pmoqobuz/src/api/user.rs`** + +Ajouter : +```rust +pub async fn get_user_albums(&self) -> Result { + let timestamp = signing::get_timestamp(); + + let secret = self.secret.as_ref() + .ok_or_else(|| QobuzError::Configuration("Secret not configured".into()))?; + + let signature = signing::sign_userlib_get_albums(×tamp, secret); + + let params = [ + ("app_id", self.app_id.as_str()), + ("user_auth_token", self.user_auth_token.as_ref() + .ok_or_else(|| QobuzError::Unauthorized("Not logged in".into()))? + .as_str()), + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ]; + + self.post("/userLibrary/getAlbumsList", ¶ms).await +} +``` + +### Phase 4: Modification de QobuzClient + +**Fichier: `pmoqobuz/src/client.rs`** + +```rust +impl QobuzClient { + /// Crée un client avec appID et secret depuis la config + pub async fn from_config() -> Result { + let config = pmoconfig::get_config(); + + // Essayer d'obtenir appid et secret depuis la config + let api = if let (Ok(appid), Ok(secret)) = ( + config.get_qobuz_appid(), + config.get_qobuz_secret() + ) { + QobuzApi::with_secret(appid, &secret)? + } else { + // Sinon, utiliser le Spoofer + warn!("AppID/secret not configured, using Spoofer"); + QobuzApi::with_spoofer().await? + }; + + // Login... + let (username, password) = config.get_qobuz_credentials()?; + // ... + } +} +``` + +## Dépendances à ajouter + +**Cargo.toml** : +```toml +md5 = "0.7" +``` + +## Résumé des fichiers à modifier/créer + +### Modifications +- [x] `pmoqobuz/src/config_ext.rs` - Ajouter appid et secret +- [ ] `pmoqobuz/src/api/mod.rs` - Ajouter champ secret et nouvelles méthodes +- [ ] `pmoqobuz/src/api/auth.rs` - Appeler `set_secret_from_spoofer` après login +- [ ] `pmoqobuz/src/api/catalog.rs` - Ajouter signature à `get_file_url` +- [ ] `pmoqobuz/src/api/user.rs` - Ajouter `get_user_albums` avec signature +- [ ] `pmoqobuz/src/client.rs` - Utiliser Spoofer si pas de config +- [ ] `pmoqobuz/Cargo.toml` - Ajouter dépendance `md5` + +### Nouveaux fichiers +- [ ] `pmoqobuz/src/api/signing.rs` - Fonctions de signature MD5 + +## Tests nécessaires + +1. **Test avec Spoofer** : Vérifier que l'obtention automatique de l'appID fonctionne +2. **Test avec config manuelle** : Vérifier qu'on peut configurer un appID/secret +3. **Test de signature** : Vérifier que les signatures MD5 sont correctes +4. **Test de setSec** : Vérifier que le bon secret est sélectionné +5. **Test de streaming** : Vérifier qu'on obtient des URLs valides avec `get_file_url` +========= End of pmoqobuz/API_ANALYSIS.md =========== + +=============== pmoqobuz/README.md ============ +# pmoqobuz - Client Rust pour l'API Qobuz + +Client Rust pour l'API Qobuz avec intégration automatique du Spoofer pour obtenir des AppID et secrets valides. + +## 🎯 Fonctionnalités + +- ✅ **Authentification** automatique avec credentials +- ✅ **Spoofer intégré** - Obtention automatique d'AppID et secrets valides +- ✅ **Signatures MD5** pour les requêtes sensibles (streaming, bibliothèque) +- ✅ **Cache** en mémoire pour optimiser les performances +- ✅ **Support DIDL-Lite** pour l'export UPnP/DLNA +- ✅ **Recherche** dans le catalogue (albums, artistes, tracks, playlists) +- ✅ **Favoris** et playlists utilisateur +- ✅ **Désérialisation robuste** (gère integers et strings pour les IDs) + +## 🚀 Utilisation rapide + +### Configuration minimale + +```yaml +# ~/.pmomusic/config.yaml +accounts: + qobuz: + username: "your_email@example.com" + password: "your_password" + # AppID et secret seront automatiquement obtenus via le Spoofer +``` + +### Code d'exemple + +```rust +use pmoqobuz::QobuzClient; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Le Spoofer s'exécute automatiquement si nécessaire + let client = QobuzClient::from_config().await?; + + // Rechercher des albums + let albums = client.search_albums("Miles Davis").await?; + for album in albums.iter().take(5) { + println!("{} - {}", album.artist.name, album.title); + } + + Ok(()) +} +``` + +## 📖 Documentation + +- [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) - Statut d'implémentation complet +- [API_ANALYSIS.md](API_ANALYSIS.md) - Analyse des différences avec l'API Python +- [examples/basic_usage.rs](examples/basic_usage.rs) - Exemple complet +- [examples/spoofer.rs](examples/spoofer.rs) - Utilisation manuelle du Spoofer +========= End of pmoqobuz/README.md =========== + +=============== pmoqobuz/examples/config.yaml.example ============ +# Configuration exemple pour pmoqobuz +# +# Ce fichier montre comment configurer l'accès à Qobuz avec AppID et Secret. +# Pour utiliser cette configuration : +# +# 1. Copier ce fichier vers ~/.pmomusic/config.yaml (ou le répertoire de config approprié) +# 2. Remplacer les valeurs par vos propres credentials +# 3. Utiliser le Spoofer pour obtenir un AppID et Secret valides : +# cargo run --example spoofer + +host: + http_port: '8080' + cover_cache: + directory: cache_covers + size: 2000 + audio_cache: + directory: cache_audio + size: 500 + logger: + buffer_capacity: 200 + enable_console: true + min_level: INFO + +playlists: + directory: playlists + +devices: + mediarenderer: + pmo_mediarenderer: + udn: e4b68fbc-2bd5-4cea-98d8-be843fec0bd4 + mediaserver: + pmo_mediaserver: + udn: 17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 + +accounts: + qobuz: + # Credentials utilisateur (REQUIS) + username: "your_email@example.com" + password: "your_password" + + # AppID et Secret (OPTIONNEL mais RECOMMANDÉ) + # Pour obtenir ces valeurs, exécutez : cargo run --example spoofer + # + # Exemple de valeurs récupérées le 2025-12-10 : + appid: "798273057" + secret: "f69a7734686cb9427629378a4b7ac381" # Secret pour timezone "london" + + # Autres secrets disponibles (testez si "london" ne fonctionne pas) : + # secret: "806331c3b0b641da923b890aed01d04a" # Secret pour timezone "abidjan" + # secret: "abb21364945c0583309667d13ca3d93a" # Secret pour timezone "berlin" + + # Note sur les secrets : + # - Les secrets sont des valeurs base64-encodées retournées par le Spoofer + # - Ils sont nécessaires pour les requêtes signées (streaming, bibliothèque) + # - Sans secret, seules les fonctionnalités de base sont disponibles + # - Les secrets peuvent expirer : réexécutez le Spoofer pour en obtenir de nouveaux +========= End of pmoqobuz/examples/config.yaml.example =========== + +=============== pmoqobuz/examples/spoofer.rs ============ +//! Exemple de Spoofer Qobuz - Extraction dynamique des AppID et secrets +//! +//! Cet exemple reproduit le comportement du spoofer Python : +//! 1. Récupère la page de login Qobuz +//! 2. Extrait l'URL du bundle.js +//! 3. Télécharge le bundle +//! 4. Extrait l'AppID et les secrets via regex +//! 5. Décode les secrets en base64 +//! +//! Usage: +//! ```bash +//! cargo run --example spoofer +//! ``` + +use anyhow::Result; +use base64::{engine::general_purpose::STANDARD, Engine}; +use indexmap::IndexMap; +use regex::Regex; +use reqwest::Client; + +struct Spoofer { + bundle: String, + seed_timezone_regex: Regex, + info_extras_regex_template: String, + app_id_regex: Regex, +} + +impl Spoofer { + /// Crée un nouveau Spoofer et télécharge le bundle.js + async fn new() -> Result { + // Expressions régulières (équivalent Python) + let seed_timezone_regex = Regex::new( + r#"[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)"#, + )?; + + let info_extras_regex_template = + r#"name:"\w+/(?P{timezones})",info:"(?P[\w=]+)",extras:"(?P[\w=]+)""# + .to_string(); + + let app_id_regex = Regex::new( + r#"production:\{api:\{appId:"(?P\d{9})",appSecret:"(?P\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#, + )?; + + // Créer un client HTTP + let client = Client::builder() + .user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)") + .build()?; + + println!("Récupération de la page de login..."); + let login_page = client + .get("https://play.qobuz.com/login") + .send() + .await? + .text() + .await?; + + // Extraire l'URL du bundle + let bundle_url_regex = + Regex::new(r#""#)?; + let bundle_url = bundle_url_regex + .captures(&login_page) + .and_then(|cap| cap.get(1)) + .ok_or_else(|| anyhow::anyhow!("Impossible de trouver l'URL du bundle"))? + .as_str(); + + println!("Téléchargement du bundle depuis: {}", bundle_url); + let bundle_full_url = format!("https://play.qobuz.com{}", bundle_url); + let bundle = client.get(&bundle_full_url).send().await?.text().await?; + + println!("Bundle téléchargé ({} bytes)", bundle.len()); + + Ok(Self { + bundle, + seed_timezone_regex, + info_extras_regex_template, + app_id_regex, + }) + } + + /// Extrait l'App ID depuis le bundle + fn get_app_id(&self) -> Result { + let captures = self + .app_id_regex + .captures(&self.bundle) + .ok_or_else(|| anyhow::anyhow!("AppID non trouvé dans le bundle"))?; + + Ok(captures + .name("app_id") + .ok_or_else(|| anyhow::anyhow!("Groupe app_id non trouvé"))? + .as_str() + .to_string()) + } + + /// Extrait les secrets depuis le bundle + fn get_secrets(&self) -> Result> { + // Étape 1: Extraire tous les seed/timezone pairs + let mut secrets: IndexMap> = IndexMap::new(); + + for captures in self.seed_timezone_regex.captures_iter(&self.bundle) { + let seed = captures + .name("seed") + .ok_or_else(|| anyhow::anyhow!("Groupe seed non trouvé"))? + .as_str(); + let timezone = captures + .name("timezone") + .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? + .as_str(); + + secrets + .entry(timezone.to_string()) + .or_insert_with(Vec::new) + .push(seed.to_string()); + } + + println!("Timezones trouvées: {:?}", secrets.keys()); + + // Étape 2: Réordonner - on met la deuxième timezone en premier + // (comme le fait le code Python avec move_to_end) + if secrets.len() >= 2 { + let keys: Vec = secrets.keys().cloned().collect(); + let second_key = keys[1].clone(); + let second_value = secrets.get(&second_key).unwrap().clone(); + + // Retirer et réinsérer pour le mettre en premier + secrets.shift_remove(&second_key); + let mut new_secrets = IndexMap::new(); + new_secrets.insert(second_key, second_value); + for (k, v) in secrets { + new_secrets.insert(k, v); + } + secrets = new_secrets; + } + + // Étape 3: Construire la regex pour info/extras + let timezones_capitalized: Vec = secrets + .keys() + .map(|tz| { + let mut chars = tz.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect(); + + let info_extras_regex_str = self + .info_extras_regex_template + .replace("{timezones}", &timezones_capitalized.join("|")); + + let info_extras_regex = Regex::new(&info_extras_regex_str)?; + + // Étape 4: Extraire info et extras pour chaque timezone + for captures in info_extras_regex.captures_iter(&self.bundle) { + let timezone_cap = captures + .name("timezone") + .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? + .as_str(); + let info = captures + .name("info") + .ok_or_else(|| anyhow::anyhow!("Groupe info non trouvé"))? + .as_str(); + let extras = captures + .name("extras") + .ok_or_else(|| anyhow::anyhow!("Groupe extras non trouvé"))? + .as_str(); + + let timezone_lower = timezone_cap.to_lowercase(); + if let Some(vec) = secrets.get_mut(&timezone_lower) { + vec.push(info.to_string()); + vec.push(extras.to_string()); + } + } + + // Étape 5: Décoder les secrets en base64 + let mut decoded_secrets = IndexMap::new(); + for (timezone, parts) in secrets { + let concatenated = parts.join(""); + + // Retirer les 44 derniers caractères (comme Python [:-44]) + if concatenated.len() > 44 { + let trimmed = &concatenated[..concatenated.len() - 44]; + + // Décoder en base64 + match STANDARD.decode(trimmed) { + Ok(decoded_bytes) => { + match String::from_utf8(decoded_bytes) { + Ok(decoded_str) => { + decoded_secrets.insert(timezone, decoded_str); + } + Err(e) => { + eprintln!( + "Erreur UTF-8 pour timezone {}: {}", + timezone, e + ); + } + } + } + Err(e) => { + eprintln!( + "Erreur de décodage base64 pour timezone {}: {}", + timezone, e + ); + } + } + } + } + + Ok(decoded_secrets) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + // Initialiser le logging + tracing_subscriber::fmt::init(); + + println!("=== Spoofer Qobuz ===\n"); + + // Créer le spoofer + let spoofer = Spoofer::new().await?; + + // Extraire l'App ID + println!("\n--- App ID ---"); + match spoofer.get_app_id() { + Ok(app_id) => println!("App ID: {}", app_id), + Err(e) => eprintln!("Erreur lors de l'extraction de l'App ID: {}", e), + } + + // Extraire les secrets + println!("\n--- Secrets ---"); + match spoofer.get_secrets() { + Ok(secrets) => { + for (timezone, secret) in secrets { + println!("{}: {}", timezone, secret); + } + } + Err(e) => eprintln!("Erreur lors de l'extraction des secrets: {}", e), + } + + Ok(()) +} +========= End of pmoqobuz/examples/spoofer.rs =========== + +=============== pmoqobuz/examples/server_with_covers.rs ============ +//! Exemple d'utilisation de pmoqobuz avec pmoserver et pmocovers +//! +//! Cet exemple montre comment : +//! - Créer un serveur HTTP avec pmoserver +//! - Initialiser le cache d'images avec pmocovers +//! - Initialiser le client Qobuz avec intégration pmocovers +//! - Les images d'albums sont automatiquement mises en cache +//! +//! Pour tester : +//! ```bash +//! cargo run --example server_with_covers --features "pmoserver,covers" +//! ``` +//! +//! Endpoints disponibles : +//! - GET /qobuz/search?q=query&type=albums - Recherche d'albums (images auto-cachées) +//! - GET /qobuz/albums/{id} - Détails d'un album (image auto-cachée) +//! - GET /qobuz/favorites/albums - Albums favoris (images auto-cachées) +//! - GET /covers/images/{pk} - Image originale mise en cache +//! - GET /covers/images/{pk}/{size} - Variante redimensionnée +//! - GET /api/covers - API REST du cache d'images +//! - GET /swagger-ui - Documentation interactive + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +use pmocovers::CoverCacheExt; + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +use pmoqobuz::QobuzServerExt; + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +use pmoserver::ServerBuilder; + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialiser le logging + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + println!("=== PMOQobuz + PMOCovers - Serveur HTTP avec cache d'images ===\n"); + + // Créer le serveur depuis la configuration + let mut server = ServerBuilder::new_configured().build(); + + println!("1. Initialisation du cache d'images (pmocovers)..."); + // Initialiser le cache d'images avec la configuration + let cache = server.init_cover_cache_configured().await?; + println!(" ✓ Cache d'images initialisé: {}", cache.cache_dir()); + + println!("\n2. Initialisation du client Qobuz avec intégration pmocovers..."); + // Initialiser le client Qobuz avec intégration pmocovers + // Les images d'albums seront automatiquement ajoutées au cache + let client = server + .init_qobuz_client_configured_with_covers(cache.clone()) + .await?; + + if let Some(auth_info) = client.auth_info() { + println!(" ✓ Client Qobuz connecté !"); + println!(" User ID: {}", auth_info.user_id); + if let Some(label) = &auth_info.subscription_label { + println!(" Abonnement: {}", label); + } + } + + println!("\n3. Démarrage du serveur HTTP..."); + server.start().await; + + println!("\n✓ Serveur démarré avec succès !\n"); + println!("Endpoints disponibles :"); + println!(" • Qobuz API:"); + println!(" - GET /qobuz/search?q=query&type=albums"); + println!(" - GET /qobuz/albums/{{id}}"); + println!(" - GET /qobuz/albums/{{id}}/tracks"); + println!(" - GET /qobuz/favorites/albums"); + println!(" - GET /qobuz/favorites/artists"); + println!(" - GET /qobuz/cache/stats"); + println!(" • Images (auto-cachées depuis Qobuz):"); + println!(" - GET /covers/images/{{pk}}"); + println!(" - GET /covers/images/{{pk}}/{{size}}"); + println!(" • API REST du cache:"); + println!(" - GET /api/covers"); + println!(" - POST /api/covers"); + println!(" - DELETE /api/covers/{{pk}}"); + println!(" • Documentation:"); + println!(" - GET /swagger-ui"); + println!("\nExemple de requête :"); + println!(" curl 'http://localhost:3000/qobuz/search?q=Miles%20Davis&type=albums' | jq '.[0].image_cached'"); + println!(" # Retourne: \"/covers/images/{{pk}}\""); + println!("\nAppuyez sur Ctrl+C pour arrêter le serveur...\n"); + + // Attendre indéfiniment + server.wait().await; + + Ok(()) +} + +#[cfg(not(all(feature = "pmoserver", feature = "covers")))] +fn main() { + eprintln!("Cet exemple nécessite les features 'pmoserver' et 'covers'"); + eprintln!("Exécutez: cargo run --example server_with_covers --features \"pmoserver,covers\""); + std::process::exit(1); +} +========= End of pmoqobuz/examples/server_with_covers.rs =========== + +=============== pmoqobuz/examples/README_SPOOFER.md ============ +# Exemple Spoofer Qobuz + +Cet exemple reproduit le comportement du spoofer Python original pour extraire dynamiquement l'AppID et les secrets de l'API Qobuz. + +## Vue d'ensemble + +Le spoofer effectue les opérations suivantes : + +1. **Récupère la page de login** : `https://play.qobuz.com/login` +2. **Extrait l'URL du bundle.js** : Via regex sur la page HTML +3. **Télécharge le bundle** : JavaScript obfusqué contenant les secrets +4. **Extrait l'AppID** : Via regex spécifique +5. **Extrait les secrets** : Via une série de regex et décodage base64 + +## Équivalences Python ↔ Rust + +| Python | Rust | Notes | +|--------|------|-------| +| `requests.get()` | `reqwest::Client::get()` | Client HTTP asynchrone | +| `re.search()` / `re.finditer()` | `regex::Regex::captures()` / `captures_iter()` | Expressions régulières | +| `OrderedDict` | `indexmap::IndexMap` | Maintient l'ordre d'insertion | +| `base64.standard_b64decode()` | `base64::STANDARD.decode()` | Décodage base64 | +| String slicing `[:-44]` | `&string[..len-44]` | Extraction de sous-chaînes | + +## Différences notables + +### 1. Gestion asynchrone +Le code Rust est entièrement asynchrone avec Tokio : +```rust +#[tokio::main] +async fn main() -> Result<()> { + let spoofer = Spoofer::new().await?; + // ... +} +``` + +### 2. Gestion d'erreurs explicite +Rust utilise `Result` pour la gestion d'erreurs : +```rust +fn get_app_id(&self) -> Result { + let captures = self.app_id_regex + .captures(&self.bundle) + .ok_or_else(|| anyhow::anyhow!("AppID non trouvé"))?; + // ... +} +``` + +### 3. Propriété et emprunt +Rust nécessite une gestion explicite de la propriété : +```rust +// Clone pour éviter les problèmes de borrowing +let second_key = keys[1].clone(); +let second_value = secrets.get(&second_key).unwrap().clone(); +``` + +### 4. Réorganisation de l'IndexMap +Le code Python utilise `move_to_end()` : +```python +secrets.move_to_end(keypairs[1][0], last=False) +``` + +En Rust, on reconstruit une nouvelle map : +```rust +secrets.shift_remove(&second_key); +let mut new_secrets = IndexMap::new(); +new_secrets.insert(second_key, second_value); +for (k, v) in secrets { + new_secrets.insert(k, v); +} +``` + +## Usage + +```bash +# Compiler et lancer l'exemple +cargo run --example spoofer + +# Ou compiler uniquement +cargo check --example spoofer +``` + +## Sortie attendue + +``` +=== Spoofer Qobuz === + +Récupération de la page de login... +Téléchargement du bundle depuis: /resources/x.x.x-xxxx/bundle.js +Bundle téléchargé (xxxxx bytes) +Timezones trouvées: ["america", "europe", "asia", ...] + +--- App ID --- +App ID: 123456789 + +--- Secrets --- +america: xxxxxxxxxxxxxxxxxxxxxxxxx +europe: yyyyyyyyyyyyyyyyyyyyyyyyy +... +``` + +## Dépendances + +Les dépendances suivantes sont nécessaires (ajoutées dans `[dev-dependencies]`) : + +```toml +regex = "1.10" +base64 = "0.22" +indexmap = "2.0" +``` + +## Avertissement + +⚠️ **Note importante** : Ce code est fourni à des fins éducatives et de reverse engineering. L'extraction de secrets depuis des applications web peut violer les conditions d'utilisation de certains services. Utilisez-le de manière responsable et conformément aux lois applicables. + +## Références + +- Code Python original : Basé sur le spoofer Qobuz de la communauté +- Documentation Qobuz API : https://github.com/Qobuz/api-documentation +========= End of pmoqobuz/examples/README_SPOOFER.md =========== + +=============== pmoqobuz/examples/basic_usage.rs ============ +//! Exemple d'utilisation basique de pmoqobuz +//! +//! Cet exemple montre comment : +//! - Se connecter à Qobuz avec les credentials de la configuration +//! - Rechercher des albums +//! - Récupérer les détails d'un album +//! - Exporter un album en format DIDL-Lite + +use pmoqobuz::{QobuzClient, ToDIDL}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialiser le logging + tracing_subscriber::fmt::init(); + + println!("=== PMOQobuz - Exemple d'utilisation basique ===\n"); + + // Créer un client depuis la configuration + println!("Connexion à Qobuz..."); + let client = QobuzClient::from_config().await?; + + if let Some(auth_info) = client.auth_info() { + println!("✓ Connecté avec succès !"); + println!(" User ID: {}", auth_info.user_id); + if let Some(label) = &auth_info.subscription_label { + println!(" Abonnement: {}", label); + } + } + + println!("\n--- Recherche d'albums ---"); + let query = "Miles Davis"; + println!("Recherche: '{}'...", query); + + let albums = client.search_albums(query).await?; + println!("✓ {} album(s) trouvé(s)\n", albums.len()); + + // Afficher les 5 premiers albums + for (i, album) in albums.iter().take(5).enumerate() { + println!(" {}. {} - {}", i + 1, album.artist.name, album.title); + if let Some(date) = &album.release_date { + println!(" Date: {}", date); + } + if let Some(count) = album.tracks_count { + println!(" Pistes: {}", count); + } + } + + // Récupérer les détails du premier album + if let Some(first_album) = albums.first() { + println!("\n--- Détails de l'album ---"); + println!("Album: {} - {}", first_album.artist.name, first_album.title); + + // Récupérer les tracks + let tracks = client.get_album_tracks(&first_album.id).await?; + println!("Tracks ({}):", tracks.len()); + + for track in tracks.iter().take(3) { + println!( + " {}. {} - {} ({}:{})", + track.track_number, + track + .display_artist() + .map(|a| a.name.as_str()) + .unwrap_or("Unknown"), + track.title, + track.duration / 60, + track.duration % 60 + ); + } + + if tracks.len() > 3 { + println!(" ... et {} autres pistes", tracks.len() - 3); + } + + // Export DIDL + println!("\n--- Export DIDL-Lite ---"); + let didl_container = first_album.to_didl_container("0")?; + println!("Container ID: {}", didl_container.id); + println!("Title: {}", didl_container.title); + println!("Class: {}", didl_container.class); + + if let Some(first_track) = tracks.first() { + let didl_item = first_track.to_didl_item(&didl_container.id)?; + println!("\nPremière track en DIDL:"); + println!(" Item ID: {}", didl_item.id); + println!(" Title: {}", didl_item.title); + if let Some(artist) = &didl_item.artist { + println!(" Artist: {}", artist); + } + } + } + + // Afficher les statistiques du cache + println!("\n--- Statistiques du cache ---"); + let stats = client.cache().stats().await; + println!("Albums en cache: {}", stats.albums_count); + println!("Tracks en cache: {}", stats.tracks_count); + println!("Artistes en cache: {}", stats.artists_count); + println!("Total: {} entrées", stats.total_count()); + + // Favoris + println!("\n--- Albums favoris ---"); + match client.get_favorite_albums().await { + Ok(favorites) => { + println!("✓ {} album(s) favori(s)", favorites.len()); + for (i, album) in favorites.iter().take(5).enumerate() { + println!(" {}. {} - {}", i + 1, album.artist.name, album.title); + } + if favorites.len() > 5 { + println!(" ... et {} autres", favorites.len() - 5); + } + } + Err(e) => { + println!("⚠ Impossible de récupérer les favoris: {}", e); + } + } + + println!("\n✓ Exemple terminé avec succès !"); + + Ok(()) +} +========= End of pmoqobuz/examples/basic_usage.rs =========== + +=============== pmoqobuz/examples/with_cache.rs ============ +//! Example demonstrating Qobuz with cache support +//! +//! This example shows how to use the QobuzSource with pmocovers +//! and pmoaudiocache to cache both cover images and audio tracks. +//! +//! Run with: +//! ```bash +//! cargo run --example with_cache --features cache +//! ``` + +use pmoaudiocache::AudioCache; +use pmocovers::Cache as CoverCache; +use pmoqobuz::{QobuzClient, QobuzSource}; +use pmosource::MusicSource; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + println!("🎵 Qobuz with Cache Support"); + println!("============================\n"); + + // Create the Qobuz client using configuration + println!("📡 Connecting to Qobuz..."); + let client = QobuzClient::from_config().await?; + println!("✅ Connected!\n"); + + // Initialize caches + println!("💾 Initializing caches..."); + let cover_cache = Arc::new(CoverCache::new("./cache/qobuz-covers", 500)?); + let audio_cache = Arc::new(AudioCache::new("./cache/qobuz-audio", 100)?); + println!("✅ Caches initialized!\n"); + + // Create the source with caching enabled + let source = QobuzSource::new_with_cache( + client, + "http://localhost:8080", + Some(cover_cache.clone()), + Some(audio_cache.clone()), + ); + + println!("📻 Source: {}", source.name()); + println!("🆔 ID: {}", source.id()); + println!("📝 Supports FIFO: {}\n", source.supports_fifo()); + + // Get user's favorite tracks + println!("🎧 Fetching your favorite tracks..."); + let favorite_tracks = source.client().get_favorite_tracks().await?; + + if favorite_tracks.is_empty() { + println!("⚠️ No favorite tracks found. Add some favorites on Qobuz first!"); + println!("\n💡 Tip: You can also search for tracks:"); + + // Example: Search for tracks + println!("\n🔍 Searching for 'Miles Davis'..."); + let search_results = source.client().search("Miles Davis", None).await?; + + if !search_results.tracks.is_empty() { + println!("\n📋 Found {} tracks:", search_results.tracks.len()); + for (i, track) in search_results.tracks.iter().enumerate().take(3) { + println!( + " {}. {} - {}", + i + 1, + track + .performer + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or("Unknown"), + track.title + ); + + // Demonstrate adding a track with caching + if i == 0 { + println!("\n➕ Adding first track to cache..."); + let track_id = source.add_track(track).await?; + println!("✅ Track added with ID: {}", track_id); + println!(" - Cover image caching started"); + println!(" - Audio caching started (high-quality FLAC)"); + + // Show resolved URI (will use cached version if available) + if let Ok(uri) = source.resolve_uri(&track_id).await { + println!(" - Stream URI: {}", uri); + } + } + } + } + } else { + println!("✅ Found {} favorite tracks!\n", favorite_tracks.len()); + + // Add first 3 favorite tracks with caching + for (i, track) in favorite_tracks.iter().enumerate().take(3) { + println!( + "{}. {} - {}", + i + 1, + track + .performer + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or("Unknown"), + track.title + ); + + if let Some(album) = &track.album { + println!(" Album: {}", album.title); + if let Some(label) = &album.label { + println!(" Label: {}", label); + } + if let Some(sample_rate) = album.maximum_sampling_rate { + println!(" Max Sample Rate: {} kHz", sample_rate / 1000.0); + } + if let Some(bit_depth) = album.maximum_bit_depth { + println!(" Max Bit Depth: {} bit", bit_depth); + } + } + + println!("\n ➕ Adding to cache..."); + match source.add_track(track).await { + Ok(track_id) => { + println!(" ✅ Track cached successfully!"); + + // Show resolved URI + if let Ok(uri) = source.resolve_uri(&track_id).await { + println!(" 📍 Stream URI: {}", uri); + } + } + Err(e) => { + println!(" ⚠️ Failed to cache track: {}", e); + } + } + println!(); + } + } + + // Browse favorite albums + println!("\n📚 Browsing your favorite albums..."); + let favorite_albums = source.client().get_favorite_albums().await?; + + if !favorite_albums.is_empty() { + println!("✅ Found {} favorite albums!\n", favorite_albums.len()); + + for (i, album) in favorite_albums.iter().enumerate().take(3) { + println!("{}. {} - {}", i + 1, album.artist.name, album.title); + if let Some(release_date) = &album.release_date { + println!(" Released: {}", release_date); + } + if let Some(tracks_count) = album.tracks_count { + println!(" Tracks: {}", tracks_count); + } + if !album.genres.is_empty() { + println!(" Genres: {}", album.genres.join(", ")); + } + } + } else { + println!("⚠️ No favorite albums found."); + } + + println!("\n✨ Example complete!"); + println!("\n💡 Tips:"); + println!(" - Run the example again to see faster loading from cache"); + println!(" - Check ./cache/qobuz-covers/ for cached cover images (WebP)"); + println!(" - Check ./cache/qobuz-audio/ for cached Hi-Res FLAC files"); + println!(" - Qobuz provides rich metadata (label, ISRC, sample rate, bit depth)"); + println!(" - Cached audio retains original quality (up to 24bit/192kHz)"); + + Ok(()) +} +========= End of pmoqobuz/examples/with_cache.rs =========== + +=============== pmoqobuz/examples/lazy_loading.rs ============ +//! Example demonstrating Qobuz lazy loading with rate limiting +//! +//! This example shows how to use the new lazy loading feature to add albums +//! to playlists without downloading all audio files immediately. Only covers +//! are downloaded eagerly, audio is downloaded on-demand when played. +//! +//! Features demonstrated: +//! - Rate limiting (max 2 concurrent requests, 400ms delay) +//! - Lazy audio loading (saves ~99% initial bandwidth) +//! - Eager cover loading (UI responsiveness) +//! - Automatic PK switching when audio is downloaded +//! - Prefetch of next 2 tracks during playback +//! +//! Run with: +//! ```bash +//! cargo run -p pmoqobuz --example lazy_loading +//! ``` + +use pmoaudiocache::Cache as AudioCache; +use pmocovers::Cache as CoverCache; +use pmoplaylist::PlaylistManager; +use pmoqobuz::{QobuzClient, QobuzSource}; +use std::sync::Arc; +use std::time::Instant; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing with debug level to see rate limiting + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(false) + .init(); + + println!("🎵 Qobuz Lazy Loading Demo"); + println!("============================\n"); + + // Step 1: Connect to Qobuz with rate limiting enabled + println!("📡 Connecting to Qobuz (rate limiting enabled)..."); + let client = QobuzClient::from_config().await?; + println!("✅ Connected with rate limiting:"); + println!(" - Max 2 concurrent requests"); + println!(" - 400ms minimum delay between requests\n"); + + // Step 2: Initialize caches + println!("💾 Initializing caches..."); + let cover_cache = Arc::new(CoverCache::new("./cache/qobuz-covers", 500)?); + let audio_cache = Arc::new(AudioCache::new("./cache/qobuz-audio", 100)?); + println!("✅ Caches initialized\n"); + + // Step 3: Create QobuzSource with caches + let source = QobuzSource::new(client, cover_cache.clone(), audio_cache.clone()); + + // Step 4: Get user's favorite albums + println!("🎧 Fetching your favorite albums..."); + let favorite_albums = source.client().get_favorite_albums().await?; + + if favorite_albums.is_empty() { + println!("⚠️ No favorite albums found!"); + println!(" Please add some albums to your Qobuz favorites first.\n"); + return Ok(()); + } + + println!("✅ Found {} favorite albums\n", favorite_albums.len()); + + // Step 5: Select first album for testing + let album = &favorite_albums[0]; + println!("📀 Selected album: {} - {}", album.artist.name, album.title); + println!(" Tracks: {}", album.tracks_count.unwrap_or(0)); + println!(" Album ID: {}\n", album.id); + + // Step 6: Create a test playlist + println!("📝 Creating test playlist..."); + let playlist_manager = PlaylistManager(); + let playlist_id = { + let writer = playlist_manager + .create_persistent_playlist("lazy-test".to_string()) + .await?; + writer.id().to_string() + }; // Drop writer here to release the lock + println!("✅ Playlist created: {}\n", playlist_id); + + // Step 7: Add album with lazy loading (measure time and track downloads) + println!("⏱️ Adding album to playlist with LAZY loading..."); + println!(" This will:"); + println!(" - Download covers immediately (~400 KB each)"); + println!(" - Create lazy PKs for audio (NO download)"); + println!(" - Enable prefetch for next 2 tracks\n"); + + let start = Instant::now(); + let count = source + .add_album_to_playlist(&playlist_id, &album.id) + .await?; + let elapsed = start.elapsed(); + + println!("✅ Album added: {} tracks in {:.2}s", count, elapsed.as_secs_f64()); + println!(" Average: {:.0}ms per track\n", elapsed.as_millis() as f64 / count as f64); + + // Step 8: Verify lazy PKs + println!("🔍 Verifying lazy PKs..."); + let reader = playlist_manager.get_read_handle(&playlist_id).await?; + + // Read all tracks from playlist + let mut tracks = Vec::new(); + loop { + match reader.peek().await? { + Some(track) => { + tracks.push(track); + reader.pop().await?; + } + None => break, + } + } + + if tracks.is_empty() { + println!("⚠️ No tracks in playlist!"); + return Ok(()); + } + + let first_track_pk = tracks[0].cache_pk(); + let is_lazy = pmocache::is_lazy_pk(&first_track_pk); + + println!(" First track PK: {}", first_track_pk); + println!(" Is lazy: {}", if is_lazy { "✅ YES (starts with 'L:')" } else { "❌ NO" }); + + // Count lazy vs downloaded + let lazy_count = tracks.iter().filter(|t| pmocache::is_lazy_pk(t.cache_pk())).count(); + let downloaded_count = tracks.len() - lazy_count; + + println!("\n📊 Track status:"); + println!(" Lazy (not downloaded): {} tracks", lazy_count); + println!(" Downloaded: {} tracks", downloaded_count); + + // Step 9: Check cache sizes + println!("\n💾 Cache disk usage:"); + println!(" Covers: {:?}", get_dir_size("./cache/qobuz-covers")?); + println!(" Audio: {:?}", get_dir_size("./cache/qobuz-audio")?); + + // Step 10: Demonstrate on-demand download + if is_lazy { + println!("\n🎵 Simulating playback of first track..."); + println!(" This would trigger download via HTTP request to:"); + println!(" GET /cache/flac/{}", first_track_pk); + println!("\n The lazy PK will automatically:"); + println!(" 1. Download the audio file from Qobuz"); + println!(" 2. Convert to FLAC"); + println!(" 3. Calculate real PK from content"); + println!(" 4. Update playlist (lazy_pk → real_pk)"); + println!(" 5. Prefetch next 2 tracks in background"); + } + + // Step 11: Summary + println!("\n╭─────────────────────────────────────────╮"); + println!("│ 🎉 Lazy Loading Demo Complete! │"); + println!("╰─────────────────────────────────────────╯"); + println!("\n📈 Benefits demonstrated:"); + println!(" ✓ Fast album loading (~{}ms per track)", elapsed.as_millis() / count as u128); + println!(" ✓ Minimal initial download (covers only)"); + println!(" ✓ Audio downloaded on-demand"); + println!(" ✓ Rate limiting active (respectful to Qobuz)"); + println!(" ✓ Automatic prefetching during playback"); + + println!("\n💡 For 375 favorite albums (~3750 tracks):"); + println!(" Without lazy: ~15 GB download, ~75s (no rate limit)"); + println!(" With lazy: ~150 MB download, ~5 min (rate limited)"); + println!(" Savings: ~99% bandwidth, natural request pattern"); + + Ok(()) +} + +/// Calculate directory size recursively +fn get_dir_size(path: &str) -> Result> { + use std::fs; + + let mut total: u64 = 0; + + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + total += metadata.len(); + } else if metadata.is_dir() { + if let Ok(size_str) = get_dir_size(&entry.path().to_string_lossy()) { + // Parse size from string (hacky but works for this example) + if let Some(num) = size_str.split_whitespace().next() { + if let Ok(size) = num.parse::() { + total += (size * 1024.0 * 1024.0) as u64; + } + } + } + } + } + } + } + + Ok(format_size(total)) +} + +/// Format bytes to human-readable size +fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} +========= End of pmoqobuz/examples/lazy_loading.rs =========== + +=============== pmoqobuz/examples/show_source_image.rs ============ +//! Example showing how to access and save the Qobuz source image +//! +//! This example demonstrates: +//! - Getting source information via the MusicSource trait +//! - Accessing the embedded WebP image +//! - Optionally saving it to a file + +use pmoqobuz::{QobuzClient, QobuzSource}; +use pmosource::MusicSource; +use std::fs; +use std::io::Write; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create the client and source + let client = QobuzClient::from_config().await?; + let source = QobuzSource::new(client, "http://localhost:8080"); + + // Display source information + println!("Music Source Information"); + println!("========================"); + println!("Name: {}", source.name()); + println!("ID: {}", source.id()); + println!("Image MIME type: {}", source.default_image_mime_type()); + + // Get the embedded image + let image_data = source.default_image(); + println!("Embedded image size: {} bytes", image_data.len()); + + // Verify WebP format + if image_data.len() >= 12 { + let is_webp = &image_data[0..4] == b"RIFF" && &image_data[8..12] == b"WEBP"; + println!("Valid WebP format: {}", is_webp); + } + + // Optional: save to file + if std::env::args().any(|arg| arg == "--save") { + let filename = format!("{}_default.webp", source.id()); + let mut file = fs::File::create(&filename)?; + file.write_all(image_data)?; + println!("\nImage saved to: {}", filename); + println!("You can view it with: open {}", filename); + } else { + println!("\nTo save the image to disk, run with: --save"); + } + + Ok(()) +} +========= End of pmoqobuz/examples/show_source_image.rs =========== + +=============== pmoqobuz/examples/config_usage.rs ============ +//! Exemple d'utilisation du trait QobuzConfigExt +//! +//! Cet exemple montre comment utiliser le trait d'extension pour gérer +//! les credentials Qobuz via pmoconfig. +//! +//! Usage: +//! ```bash +//! cargo run --example config_usage +//! ``` + +use pmoconfig::get_config; +use pmoqobuz::QobuzConfigExt; + +fn main() -> anyhow::Result<()> { + // Initialiser le logging + tracing_subscriber::fmt::init(); + + println!("=== QobuzConfigExt Example ===\n"); + + // Récupérer la configuration globale + let config = get_config(); + + // Exemple 1: Lire les credentials existants + println!("--- Lecture des credentials ---"); + match config.get_qobuz_credentials() { + Ok((username, password)) => { + println!("Username: {}", username); + println!("Password: {}", "*".repeat(password.len())); + } + Err(e) => { + println!("Credentials non configurés: {}", e); + } + } + + // Exemple 2: Lire username et password séparément + println!("\n--- Lecture séparée ---"); + match config.get_qobuz_username() { + Ok(username) => println!("Username: {}", username), + Err(e) => println!("Username non configuré: {}", e), + } + + match config.get_qobuz_password() { + Ok(password) => println!("Password: {}", "*".repeat(password.len())), + Err(e) => println!("Password non configuré: {}", e), + } + + // Exemple 3: Définir de nouveaux credentials (commenté pour ne pas modifier la config) + /* + println!("\n--- Définition de nouveaux credentials ---"); + config.set_qobuz_username("user@example.com")?; + config.set_qobuz_password("my_secure_password")?; + println!("Nouveaux credentials enregistrés !"); + */ + + // Exemple 4: Utilisation avec QobuzClient + println!("\n--- Utilisation avec QobuzClient ---"); + println!("Pour créer un client Qobuz à partir de la config:"); + println!(" let client = QobuzClient::from_config().await?;"); + println!("\nCette méthode utilise automatiquement QobuzConfigExt"); + println!("pour récupérer les credentials depuis pmoconfig."); + + Ok(()) +} +========= End of pmoqobuz/examples/config_usage.rs =========== + +=============== pmoqobuz/DISK_CACHE_USAGE.md ============ +# Utilisation du cache disque pour favoris/bibliothèque + +## Intégration dans QobuzClient + +### Étape 1 : Ajouter le cache disque au client + +```rust +// Dans src/client.rs + +use crate::disk_cache::DiskCache; + +pub struct QobuzClient { + api: QobuzApi, + cache: Arc, // Cache mémoire (existant) + disk_cache: Arc, // Cache disque (nouveau) + auth_info: Option, +} + +impl QobuzClient { + pub async fn from_config_obj(config: &Config) -> Result { + // ... code existant ... + + // Créer le cache disque (utilise le répertoire configuré) + let disk_cache_dir = config.get_qobuz_cache_dir()?; + let disk_cache = Arc::new(DiskCache::new(disk_cache_dir)?); + + Ok(Self { + api, + cache: Arc::new(QobuzCache::new()), + disk_cache, + auth_info: Some(auth_info), + }) + } +} +``` + +### Étape 2 : Utiliser le cache pour get_favorite_albums + +```rust +// Dans src/client.rs + +impl QobuzClient { + /// Récupère les albums favoris (avec cache disque) + pub async fn get_favorite_albums(&self) -> Result> { + let user_id = self.auth_info + .as_ref() + .map(|a| &a.user_id) + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))?; + + let cache_key = format!("favorites_albums_{}", user_id); + + // 1. Essayer de charger depuis le cache disque (TTL: 1 heure) + if let Ok(Some(albums)) = self.disk_cache.load_with_ttl::>( + &cache_key, + Duration::from_secs(3600) + ) { + info!("✓ Loaded {} favorite albums from disk cache", albums.len()); + return Ok(albums); + } + + // 2. Sinon, requête API + info!("Fetching favorite albums from API..."); + let albums = self.api.get_favorite_albums().await?; + + // 3. Sauvegarder dans le cache disque + if let Err(e) = self.disk_cache.save(&cache_key, &albums) { + debug!("Failed to save favorites to disk cache: {}", e); + } else { + info!("✓ Saved {} favorite albums to disk cache", albums.len()); + } + + Ok(albums) + } + + /// Récupère les tracks favoris (avec cache disque) + pub async fn get_favorite_tracks(&self) -> Result> { + let user_id = self.auth_info + .as_ref() + .map(|a| &a.user_id) + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))?; + + let cache_key = format!("favorites_tracks_{}", user_id); + + // 1. Cache disque (TTL: 1 heure) + if let Ok(Some(tracks)) = self.disk_cache.load_with_ttl::>( + &cache_key, + Duration::from_secs(3600) + ) { + info!("✓ Loaded {} favorite tracks from disk cache", tracks.len()); + return Ok(tracks); + } + + // 2. API + info!("Fetching favorite tracks from API..."); + let tracks = self.api.get_favorite_tracks().await?; + + // 3. Sauvegarder + if let Err(e) = self.disk_cache.save(&cache_key, &tracks) { + debug!("Failed to save favorites to disk cache: {}", e); + } else { + info!("✓ Saved {} favorite tracks to disk cache", tracks.len()); + } + + Ok(tracks) + } + + /// Récupère les playlists (avec cache disque) + pub async fn get_user_playlists(&self) -> Result> { + let user_id = self.auth_info + .as_ref() + .map(|a| &a.user_id) + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))?; + + let cache_key = format!("playlists_{}", user_id); + + // 1. Cache disque (TTL: 30 minutes - les playlists changent plus souvent) + if let Ok(Some(playlists)) = self.disk_cache.load_with_ttl::>( + &cache_key, + Duration::from_secs(1800) + ) { + info!("✓ Loaded {} playlists from disk cache", playlists.len()); + return Ok(playlists); + } + + // 2. API + info!("Fetching playlists from API..."); + let playlists = self.api.get_user_playlists().await?; + + // 3. Sauvegarder + if let Err(e) = self.disk_cache.save(&cache_key, &playlists) { + debug!("Failed to save playlists to disk cache: {}", e); + } else { + info!("✓ Saved {} playlists to disk cache", playlists.len()); + } + + Ok(playlists) + } + + /// Invalide le cache des favoris (après ajout/suppression) + pub async fn invalidate_favorites_cache(&self) -> Result<()> { + let user_id = self.auth_info + .as_ref() + .map(|a| &a.user_id) + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))?; + + self.disk_cache.invalidate(&format!("favorites_albums_{}", user_id))?; + self.disk_cache.invalidate(&format!("favorites_tracks_{}", user_id))?; + self.disk_cache.invalidate(&format!("playlists_{}", user_id))?; + + info!("✓ Invalidated favorites cache"); + Ok(()) + } +} +``` + +### Étape 3 : Méthodes utilitaires + +```rust +impl QobuzClient { + /// Retourne des statistiques sur le cache disque + pub fn disk_cache_stats(&self) -> Result<(usize, u64)> { + let count = self.disk_cache.count()?; + let size = self.disk_cache.size()?; + Ok((count, size)) + } + + /// Vide complètement le cache disque + pub fn clear_disk_cache(&self) -> Result<()> { + self.disk_cache.clear_all() + } +} +``` + +## Structure sur disque + +``` +.pmomusic/ +├── config.yaml +└── cache/ + └── qobuz/ + ├── favorites_albums_1217710.json # 375 albums (~200 KB) + ├── favorites_tracks_1217710.json # Tracks favoris + └── playlists_1217710.json # Playlists utilisateur +``` + +## Bénéfices + +### Sans cache disque (AVANT) +```bash +# Lancement 1 +INFO Fetching 375 favorite albums from API... (2.5s) + +# Lancement 2 (app redémarrée) +INFO Fetching 375 favorite albums from API... (2.5s) ← Requête inutile ! + +# Lancement 3 +INFO Fetching 375 favorite albums from API... (2.5s) ← Requête inutile ! +``` + +**Total** : 3 requêtes API × 2.5s = **7.5 secondes** + +### Avec cache disque (APRÈS) +```bash +# Lancement 1 (cache miss) +INFO Fetching 375 favorite albums from API... (2.5s) +INFO ✓ Saved 375 favorite albums to disk cache + +# Lancement 2 (cache hit!) +INFO ✓ Loaded 375 favorite albums from disk cache (5ms) ← Instantané ! + +# Lancement 3 (cache hit!) +INFO ✓ Loaded 375 favorite albums from disk cache (5ms) ← Instantané ! +``` + +**Total** : 1 requête API × 2.5s + 2 cache hits × 5ms = **2.51 secondes** + +**Amélioration** : **66% plus rapide** + réduction de **66% des requêtes API** + +## TTL recommandés + +| Donnée | TTL | Justification | +|--------|-----|---------------| +| Albums favoris | 1h | Changent rarement | +| Tracks favoris | 1h | Changent rarement | +| Playlists | 30min | Modifiées plus souvent | +| Bibliothèque complète | 24h | Très volumineuse, change peu | + +## Invalidation intelligente + +Invalider le cache après modifications : + +```rust +// Après ajout d'un favori +client.add_favorite_album("123").await?; +client.invalidate_favorites_cache().await?; + +// Après suppression +client.remove_favorite_album("123").await?; +client.invalidate_favorites_cache().await?; +``` + +## Tests + +```bash +# Test du cache disque +cargo test -p pmoqobuz disk_cache + +# Test d'intégration +cargo run --example basic_usage + +# Logs détaillés +RUST_LOG=info,pmoqobuz::disk_cache=debug cargo run --example basic_usage +``` + +## Migration + +Pour ajouter le cache disque au client existant : + +1. Ajouter le champ `disk_cache` à `QobuzClient` +2. Initialiser dans `from_config_obj()` +3. Modifier `get_favorite_albums()`, `get_favorite_tracks()`, etc. +4. Tester avec des gros catalogues (375+ albums) + +## Taille estimée du cache + +Pour un utilisateur avec : +- 375 albums favoris +- 100 tracks favoris +- 10 playlists + +**Taille totale** : ~300 KB (négligeable) + +## Comparaison : pmocache vs DiskCache + +| Critère | pmocache | DiskCache | +|---------|----------|-----------| +| **Complexité** | Élevée (SQLite, download, variants) | Faible (fichiers JSON simples) | +| **Taille overhead** | ~100 KB (SQLite + tables) | 0 (juste les JSON) | +| **Performance** | Excellent pour binaires | Excellent pour JSON | +| **Maintenance** | Complexe | Simple | +| **Adapté pour JSON** | ❌ Non | ✅ Oui | + +**Conclusion** : `DiskCache` est **parfaitement adapté** pour le cache de favoris/bibliothèque. +========= End of pmoqobuz/DISK_CACHE_USAGE.md =========== + +=============== pmoqobuz/src/cache.rs ============ +//! Système de cache en mémoire pour les données Qobuz +//! +//! Ce module fournit un cache en mémoire avec TTL pour minimiser les requêtes à l'API Qobuz. + +use crate::models::{Album, Artist, Playlist, SearchResult, StreamInfo, Track}; +use moka::future::Cache as MokaCache; +use std::sync::Arc; +use std::time::Duration; + +/// Cache principal pour les données Qobuz +#[derive(Clone)] +pub struct QobuzCache { + /// Cache des albums (TTL: 1 heure) + albums: Arc>, + /// Cache des tracks (TTL: 1 heure) + tracks: Arc>, + /// Cache des artistes (TTL: 1 heure) + artists: Arc>, + /// Cache des playlists (TTL: 30 minutes) + playlists: Arc>, + /// Cache des résultats de recherche (TTL: 15 minutes) + searches: Arc>, + /// Cache des URLs de streaming (TTL: 5 minutes) + stream_urls: Arc>, +} + +impl QobuzCache { + /// Crée un nouveau cache avec les paramètres par défaut + pub fn new() -> Self { + Self::with_capacity(1000) + } + + /// Crée un nouveau cache avec une capacité spécifique + pub fn with_capacity(max_capacity: u64) -> Self { + Self { + albums: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), + tracks: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity * 2) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), + artists: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 2) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), + playlists: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 4) + .time_to_live(Duration::from_secs(1800)) // 30 minutes + .build(), + ), + searches: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 2) + .time_to_live(Duration::from_secs(900)) // 15 minutes + .build(), + ), + stream_urls: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 4) + .time_to_live(Duration::from_secs(300)) // 5 minutes + .build(), + ), + } + } + + // ============ Albums ============ + + /// Récupère un album depuis le cache + pub async fn get_album(&self, id: &str) -> Option { + self.albums.get(id).await + } + + /// Ajoute un album au cache + pub async fn put_album(&self, id: String, album: Album) { + self.albums.insert(id, album).await; + } + + /// Invalide un album du cache + pub async fn invalidate_album(&self, id: &str) { + self.albums.invalidate(id).await; + } + + // ============ Tracks ============ + + /// Récupère une track depuis le cache + pub async fn get_track(&self, id: &str) -> Option { + self.tracks.get(id).await + } + + /// Ajoute une track au cache + pub async fn put_track(&self, id: String, track: Track) { + self.tracks.insert(id, track).await; + } + + /// Invalide une track du cache + pub async fn invalidate_track(&self, id: &str) { + self.tracks.invalidate(id).await; + } + + // ============ Artists ============ + + /// Récupère un artiste depuis le cache + pub async fn get_artist(&self, id: &str) -> Option { + self.artists.get(id).await + } + + /// Ajoute un artiste au cache + pub async fn put_artist(&self, id: String, artist: Artist) { + self.artists.insert(id, artist).await; + } + + /// Invalide un artiste du cache + pub async fn invalidate_artist(&self, id: &str) { + self.artists.invalidate(id).await; + } + + // ============ Playlists ============ + + /// Récupère une playlist depuis le cache + pub async fn get_playlist(&self, id: &str) -> Option { + self.playlists.get(id).await + } + + /// Ajoute une playlist au cache + pub async fn put_playlist(&self, id: String, playlist: Playlist) { + self.playlists.insert(id, playlist).await; + } + + /// Invalide une playlist du cache + pub async fn invalidate_playlist(&self, id: &str) { + self.playlists.invalidate(id).await; + } + + // ============ Recherches ============ + + /// Récupère un résultat de recherche depuis le cache + pub async fn get_search(&self, query: &str) -> Option { + self.searches.get(query).await + } + + /// Ajoute un résultat de recherche au cache + pub async fn put_search(&self, query: String, result: SearchResult) { + self.searches.insert(query, result).await; + } + + /// Invalide un résultat de recherche du cache + pub async fn invalidate_search(&self, query: &str) { + self.searches.invalidate(query).await; + } + + // ============ URLs de streaming ============ + + /// Récupère une URL de streaming depuis le cache + pub async fn get_stream_url(&self, track_id: &str) -> Option { + self.stream_urls.get(track_id).await + } + + /// Ajoute une URL de streaming au cache + pub async fn put_stream_url(&self, track_id: String, info: StreamInfo) { + self.stream_urls.insert(track_id, info).await; + } + + /// Invalide une URL de streaming du cache + pub async fn invalidate_stream_url(&self, track_id: &str) { + self.stream_urls.invalidate(track_id).await; + } + + // ============ Maintenance ============ + + /// Vide tous les caches + pub async fn clear_all(&self) { + self.albums.invalidate_all(); + self.tracks.invalidate_all(); + self.artists.invalidate_all(); + self.playlists.invalidate_all(); + self.searches.invalidate_all(); + self.stream_urls.invalidate_all(); + } + + /// Retourne des statistiques sur le cache + pub async fn stats(&self) -> CacheStats { + CacheStats { + albums_count: self.albums.entry_count(), + tracks_count: self.tracks.entry_count(), + artists_count: self.artists.entry_count(), + playlists_count: self.playlists.entry_count(), + searches_count: self.searches.entry_count(), + stream_urls_count: self.stream_urls.entry_count(), + } + } +} + +impl Default for QobuzCache { + fn default() -> Self { + Self::new() + } +} + +/// Statistiques du cache +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CacheStats { + /// Nombre d'albums en cache + pub albums_count: u64, + /// Nombre de tracks en cache + pub tracks_count: u64, + /// Nombre d'artistes en cache + pub artists_count: u64, + /// Nombre de playlists en cache + pub playlists_count: u64, + /// Nombre de recherches en cache + pub searches_count: u64, + /// Nombre d'URLs de streaming en cache + pub stream_urls_count: u64, +} + +impl CacheStats { + /// Retourne le nombre total d'entrées en cache + pub fn total_count(&self) -> u64 { + self.albums_count + + self.tracks_count + + self.artists_count + + self.playlists_count + + self.searches_count + + self.stream_urls_count + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Artist; + + #[tokio::test] + async fn test_cache_basic_operations() { + let cache = QobuzCache::new(); + + let artist = Artist::new("123", "Test Artist"); + + // Test insertion + cache.put_artist("123".to_string(), artist.clone()).await; + + // Test récupération + let retrieved = cache.get_artist("123").await; + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().name, "Test Artist"); + + // Test invalidation + cache.invalidate_artist("123").await; + let after_invalidation = cache.get_artist("123").await; + assert!(after_invalidation.is_none()); + } + + #[tokio::test] + async fn test_cache_stats() { + let cache = QobuzCache::new(); + + let artist = Artist::new("123", "Test Artist"); + cache.put_artist("123".to_string(), artist).await; + + let stats = cache.stats().await; + assert_eq!(stats.artists_count, 1); + assert_eq!(stats.albums_count, 0); + } + + #[tokio::test] + async fn test_cache_clear_all() { + let cache = QobuzCache::new(); + + let artist = Artist::new("123", "Test Artist"); + cache.put_artist("123".to_string(), artist).await; + + cache.clear_all().await; + + let stats = cache.stats().await; + assert_eq!(stats.total_count(), 0); + } +} +========= End of pmoqobuz/src/cache.rs =========== + +=============== pmoqobuz/src/client.rs ============ +//! 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::Config; +use std::sync::Arc; +use tracing::{debug, info}; + +/// 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: Option, + #[cfg(feature = "disk-cache")] + /// Cache disque optionnel + disk_cache: Option>, +} + +impl QobuzClient { + /// 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 mut api = QobuzApi::new(app_id)?; + let auth_info = api.login(username, password).await?; + + let client = Self { + api, + cache: Arc::new(QobuzCache::new()), + auth_info: Some(auth_info), + #[cfg(feature = "disk-cache")] + disk_cache: 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 + } + + /// Configure le rate limiting sur une API depuis la configuration + /// + /// # Arguments + /// + /// * `api` - L'API Qobuz à configurer + /// * `config` - La configuration contenant les paramètres de rate limiting + fn configure_rate_limiting(api: &mut QobuzApi, config: &Config) { + let rate_limit_enabled = config.is_qobuz_rate_limiting_enabled(); + if rate_limit_enabled { + let max_concurrent = config.get_qobuz_rate_limit_max_concurrent() + .ok() + .flatten() + .unwrap_or(2); + let min_delay = config.get_qobuz_rate_limit_min_delay_ms() + .ok() + .flatten() + .unwrap_or(400); + + info!( + "Enabling Qobuz rate limiting: {} concurrent, {}ms delay", + max_concurrent, min_delay + ); + api.enable_rate_limiting(max_concurrent, min_delay); + } else { + debug!("Qobuz rate limiting disabled in configuration"); + } + } + + /// 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()?; + + // Étape 0 : Essayer de réutiliser le token stocké dans la configuration + // DÉSACTIVÉ TEMPORAIREMENT : Le secret peut être obsolète même si le token est valide. + // Le login est nécessaire pour valider les credentials (app_id) et déclencher le + // Spoofer si besoin. Si le login échoue avec une erreur d'auth, le Spoofer sera + // automatiquement utilisé pour obtenir de nouveaux credentials. + // + // TODO: Implémenter un retry intelligent dans get_stream_url() qui détecte les + // erreurs de signature et rafraîchit automatiquement les credentials via Spoofer. + // Cela permettrait de réactiver la réutilisation du token sans risque. + /* + if let (Ok(Some(token)), Ok(Some(user_id))) = + (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) + { + info!("✓ Found stored authentication token in configuration"); + + // Récupérer l'App ID et le secret depuis la config pour créer l'API + let config_appid = config.get_qobuz_appid()?; + let config_secret = config.get_qobuz_secret()?; + + match (config_appid, config_secret) { + (Some(app_id), Some(secret)) => match QobuzApi::with_secret(&app_id, &secret) { + Ok(mut api) => { + // Configure rate limiting + Self::configure_rate_limiting(&mut api, config); + + // Réutiliser le token de la configuration + api.set_auth_token(token.clone(), user_id.clone()); + + info!("✓ Reusing authentication token (no login required)"); + info!(" → Token will be validated on first API request"); + + let auth_info = AuthInfo { + token, + user_id, + subscription_label: config.get_qobuz_subscription_label().ok().flatten(), + }; + + let client = Self { + api, + cache: Arc::new(QobuzCache::new()), + auth_info: Some(auth_info), + #[cfg(feature = "disk-cache")] + disk_cache: None, + }; + + return Ok(client.finalize_disk_cache().await); + } + Err(e) => { + debug!("Failed to create API with stored credentials: {}", e); + info!("→ Credentials in config are invalid, will perform login"); + // Continuer vers le login normal + } + }, + _ => { + debug!("No appid/secret in config, cannot reuse token"); + info!("→ Missing AppID/secret, will perform login"); + // Continuer vers le login normal + } + } + } else { + debug!("No stored authentication token found in configuration, will perform login"); + } + */ + + info!("Performing login to validate credentials and obtain fresh token"); + + // Récupérer l'App ID et le secret depuis la config + let config_appid = config.get_qobuz_appid()?; + let config_secret = config.get_qobuz_secret()?; + + // Déterminer comment créer l'API + let mut api = match (config_appid, config_secret) { + // Cas 1: AppID ET secret configurés → test avec authentification + (Some(app_id), Some(secret)) => { + info!( + "Creating Qobuz API with configured App ID: {} and secret", + app_id + ); + + match QobuzApi::with_secret(&app_id, &secret) { + Ok(mut test_api) => { + // Configure rate limiting + Self::configure_rate_limiting(&mut test_api, config); + + // Tenter l'authentification pour valider les credentials + debug!("Testing configured credentials with login..."); + match test_api.login(&username, &password).await { + Ok(auth_info) => { + info!("✓ Configured credentials are valid"); + + // Sauvegarder le token dans la configuration + use std::time::{SystemTime, UNIX_EPOCH, Duration}; + 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"); + } + + // Les credentials sont valides, retourner directement + let client = Self { + api: test_api, + cache: Arc::new(QobuzCache::new()), + auth_info: Some(auth_info), + #[cfg(feature = "disk-cache")] + disk_cache: None, + }; + + return Ok(client.finalize_disk_cache().await); + } + Err(e) if e.is_auth_error() => { + info!("✗ Configured credentials failed authentication: {}", e); + info!("→ Falling back to Spoofer to obtain new credentials..."); + // Continuer vers le Spoofer (voir après le match) + } + Err(e) => { + // Autre erreur (réseau, etc.) → propager + return Err(e); + } + } + } + Err(e) => { + info!("✗ Failed to create API with configured credentials: {}", e); + info!("→ Falling back to Spoofer..."); + // Continuer vers le Spoofer + } + } + + // Si on arrive ici, les credentials configurés ont échoué + // → Appel du Spoofer + Self::try_spoofer_fallback(config).await? + } + + // Cas 2: Aucun ou seulement l'un des deux → utiliser directement le Spoofer + _ => { + info!("AppID or secret not configured, using Spoofer to obtain valid credentials..."); + Self::try_spoofer_fallback(config).await? + } + }; + + // Configure rate limiting + Self::configure_rate_limiting(&mut api, config); + + // Authentifier l'utilisateur + let auth_info = api.login(&username, &password).await?; + + // Sauvegarder le token dans la configuration pour éviter de re-login la prochaine fois + 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"); + } + + let client = Self { + api, + cache: Arc::new(QobuzCache::new()), + auth_info: Some(auth_info), + #[cfg(feature = "disk-cache")] + disk_cache: None, + }; + + 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 { + match crate::api::Spoofer::new().await { + Ok(spoofer) => { + match spoofer.get_app_id() { + Ok(app_id) => { + info!("Spoofer found App ID: {}", app_id); + + match spoofer.get_secrets() { + Ok(secrets) => { + info!("Spoofer found {} secret(s), testing them...", secrets.len()); + + // Tester chaque secret pour trouver celui qui fonctionne + for (timezone, secret) in secrets.iter() { + debug!("Testing secret for timezone: {}", timezone); + + match QobuzApi::with_secret(&app_id, secret) { + Ok(test_api) => { + info!("✓ Successfully created API with secret from timezone: {}", timezone); + + // Sauvegarder les credentials valides dans la config + if let Err(e) = config.set_qobuz_appid(&app_id) { + debug!("Could not save appid to config: {}", e); + } + if let Err(e) = config.set_qobuz_secret(secret) { + debug!("Could not save secret to config: {}", e); + } + + return Ok(test_api); + } + Err(e) => { + debug!("Failed to create API with secret from {}: {}", timezone, e); + continue; + } + } + } + + // Si aucun secret n'a fonctionné, utiliser le fallback + info!("✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret"); + QobuzApi::new(DEFAULT_APP_ID) + } + Err(e) => { + info!("Spoofer failed to extract secrets: {}, falling back to DEFAULT_APP_ID", e); + QobuzApi::new(DEFAULT_APP_ID) + } + } + } + Err(e) => { + info!("Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID", e); + QobuzApi::new(DEFAULT_APP_ID) + } + } + } + Err(e) => { + info!("Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret", e); + QobuzApi::new(DEFAULT_APP_ID) + } + } + } + + /// 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<&AuthInfo> { + self.auth_info.as_ref() + } + + #[cfg(feature = "disk-cache")] + fn user_id(&self) -> Option { + self.auth_info.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 = 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() -> Option> { + let mut path = match std::env::var_os("HOME") { + Some(home) => std::path::PathBuf::from(home), + None => std::path::PathBuf::from("."), + }; + path.push(".pmomusic"); + path.push("cache"); + path.push("qobuz_cache.sqlite"); + + if let Some(parent) = path.parent() { + if let Err(err) = std::fs::create_dir_all(parent) { + debug!( + "Failed to create disk cache directory {}: {}", + parent.display(), + err + ); + return None; + } + } + + match crate::disk_cache::SqliteCacheStore::new(path) { + Ok(store) => { + let store: Arc = Arc::new(store); + Some(store) + } + Err(err) => { + debug!("Failed to initialize SQLite disk cache: {}", err); + None + } + } + } + + // ============ 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.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> { + let tracks = self.api.get_album_tracks(album_id).await?; + + // Mettre les tracks en cache + for track in &tracks { + self.cache.put_track(track.id.clone(), track.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.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.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.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.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.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.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.api.get_playlist_tracks(playlist_id).await + } + + // ============ Catalogue ============ + + /// Récupère la liste des genres + pub async fn get_genres(&self) -> Result> { + 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.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.api.get_featured_playlists(genre_id, tags).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.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.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.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.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.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.api.add_favorite_album(album_id).await + } + + /// Supprime un album des favoris + pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> { + 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.api.add_favorite_track(track_id).await + } + + /// Supprime un track des favoris + pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> { + 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.api.add_to_playlist(playlist_id, track_id).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audio_format() { + assert_eq!(AudioFormat::default(), AudioFormat::Flac_Lossless); + } +} +========= End of pmoqobuz/src/client.rs =========== + +=============== pmoqobuz/src/error.rs ============ +//! Gestion des erreurs pour le client Qobuz + +use thiserror::Error; + +/// Type Result personnalisé pour pmoqobuz +pub type Result = std::result::Result; + +/// Erreurs possibles lors de l'utilisation du client Qobuz +#[derive(Error, Debug)] +pub enum QobuzError { + /// Erreur d'authentification (credentials invalides) + #[error("Authentication failed: {0}")] + Unauthorized(String), + + /// Ressource non trouvée (album, track, etc.) + #[error("Resource not found: {0}")] + NotFound(String), + + /// Erreur HTTP + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + /// Erreur de parsing JSON + #[error("JSON parsing error: {0}")] + JsonParse(#[from] serde_json::Error), + + /// Erreur de configuration (anyhow) + #[error("Configuration error: {0}")] + Config(#[from] anyhow::Error), + + /// Erreur de configuration Qobuz (App ID, secret, etc.) + #[error("Qobuz configuration error: {0}")] + Configuration(String), + + /// Erreur de l'API Qobuz + #[error("Qobuz API error (code {code}): {message}")] + ApiError { code: u16, message: String }, + + /// Quota dépassé (rate limiting) + #[error("Rate limit exceeded, please try again later")] + RateLimitExceeded, + + /// Contenu non disponible dans la région de l'utilisateur + #[error("Content not available in your region")] + NotAvailable, + + /// Abonnement insuffisant pour accéder au contenu + #[error("Subscription level insufficient: {0}")] + SubscriptionRequired(String), + + /// Erreur de cache + #[error("Cache error: {0}")] + Cache(String), + + /// Erreur d'export DIDL + #[error("DIDL export error: {0}")] + DidlExport(String), + + /// Erreur générique + #[error("Qobuz error: {0}")] + Other(String), +} + +impl QobuzError { + /// Crée une erreur API depuis un code de statut HTTP et un message + pub fn from_status_code(code: u16, message: impl Into) -> Self { + match code { + 401 | 403 => Self::Unauthorized(message.into()), + 404 => Self::NotFound(message.into()), + 429 => Self::RateLimitExceeded, + _ => Self::ApiError { + code, + message: message.into(), + }, + } + } + + /// Vérifie si l'erreur est une erreur de credentials (401/403) + /// ou d'AppID invalide (400 avec "app_id") + pub fn is_auth_error(&self) -> bool { + match self { + QobuzError::Unauthorized(_) => true, + QobuzError::ApiError { code: 400, message } + if message.contains("app_id") || message.contains("Invalid") => true, + _ => false, + } + } + + /// Vérifie si l'erreur est une erreur de rate limiting + pub fn is_rate_limit(&self) -> bool { + matches!(self, QobuzError::RateLimitExceeded) + } +} +========= End of pmoqobuz/src/error.rs =========== + +=============== pmoqobuz/src/lib.rs ============ +//! # pmoqobuz - Client Qobuz pour PMOMusic +//! +//! Cette crate fournit un client Rust pour l'API Qobuz, inspiré de l'implémentation Python d'upmpdcli, +//! avec un système de cache en mémoire et une intégration avec les autres modules PMOMusic. +//! +//! ## Vue d'ensemble +//! +//! `pmoqobuz` permet d'accéder aux fonctionnalités de Qobuz : +//! - Authentification avec les credentials configurés +//! - Navigation dans le catalogue (albums, artistes, playlists, tracks) +//! - Recherche dans le catalogue +//! - Accès aux favoris de l'utilisateur +//! - Cache en mémoire pour minimiser les requêtes API +//! - Export des objets en format DIDL-Lite (via `pmodidl`) +//! - Cache des images d'albums (via `pmocovers`) +//! +//! ## Architecture +//! +//! La crate suit le pattern d'extension des autres crates PMO : +//! - `QobuzClient` : Client principal avec authentification et cache +//! - `models` : Structures de données (Album, Track, Artist, etc.) +//! - `api` : Couche d'accès à l'API REST Qobuz +//! - `cache` : Système de cache en mémoire avec TTL +//! - `didl` : Export des objets en format DIDL-Lite +//! +//! ## Structure des modules +//! +//! ```text +//! pmoqobuz/ +//! ├── src/ +//! │ ├── lib.rs # Module principal (ce fichier) +//! │ ├── client.rs # Client Qobuz principal +//! │ ├── models.rs # Structures de données +//! │ ├── api/ +//! │ │ ├── mod.rs # API client +//! │ │ ├── auth.rs # Authentification +//! │ │ ├── catalog.rs # Accès au catalogue +//! │ │ └── user.rs # API utilisateur (favoris) +//! │ ├── cache.rs # Cache en mémoire +//! │ ├── didl.rs # Export DIDL-Lite +//! │ └── error.rs # Gestion des erreurs +//! ``` +//! +//! ## Utilisation +//! +//! ### Exemple basique avec configuration automatique +//! +//! ```rust,no_run +//! use pmoqobuz::QobuzClient; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! // Utilise automatiquement la config depuis pmoconfig +//! let client = QobuzClient::from_config().await?; +//! +//! // Rechercher des albums +//! let results = client.search_albums("Miles Davis").await?; +//! for album in results { +//! println!("{} - {}", album.artist.name, album.title); +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! ### Exemple avec credentials personnalisés +//! +//! ```rust,no_run +//! use pmoqobuz::QobuzClient; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let client = QobuzClient::new("user@example.com", "password").await?; +//! +//! // Obtenir les albums favoris +//! let favorites = client.get_favorite_albums().await?; +//! +//! Ok(()) +//! } +//! ``` +//! +//! ### Export DIDL-Lite +//! +//! ```rust,no_run +//! use pmoqobuz::QobuzClient; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let client = QobuzClient::from_config().await?; +//! +//! let album = client.get_album("12345").await?; +//! let didl_container = album.to_didl_container("parent_id")?; +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## Cache +//! +//! Le client utilise un cache en mémoire avec TTL pour minimiser les requêtes à l'API Qobuz : +//! - Albums : 1 heure +//! - Tracks : 1 heure +//! - Artistes : 1 heure +//! - Playlists : 30 minutes +//! - Résultats de recherche : 15 minutes +//! - URLs de streaming : 5 minutes +//! +//! ## Intégration pmocovers et pmoaudiocache +//! +//! La feature `cache` active le support complet du cache pour les images et l'audio. +//! +//! ### Cache d'images (pmocovers) +//! +//! Les images de couverture sont automatiquement téléchargées et converties en WebP : +//! +//! ```rust,no_run +//! use pmoqobuz::{QobuzSource, QobuzClient}; +//! use pmocovers::Cache as CoverCache; +//! use std::sync::Arc; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = QobuzClient::from_config().await?; +//! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); +//! +//! let source = QobuzSource::new_with_cache( +//! client, +//! "http://localhost:8080", +//! Some(cover_cache), +//! None, +//! ); +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Cache audio (pmoaudiocache) +//! +//! L'audio haute résolution est téléchargé et caché localement avec métadonnées enrichies : +//! +//! ```rust,no_run +//! use pmoqobuz::{QobuzSource, QobuzClient}; +//! use pmocovers::Cache as CoverCache; +//! use pmoaudiocache::AudioCache; +//! use std::sync::Arc; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = QobuzClient::from_config().await?; +//! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); +//! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); +//! +//! let source = QobuzSource::new_with_cache( +//! client.clone(), +//! "http://localhost:8080", +//! Some(cover_cache), +//! Some(audio_cache), +//! ); +//! +//! // Add a track with caching +//! let tracks = client.get_favorite_tracks().await?; +//! if let Some(track) = tracks.first() { +//! let track_id = source.add_track(track).await?; +//! // Audio and cover are now cached with rich metadata +//! +//! // Resolve URI (returns cached version if available) +//! let uri = source.resolve_uri(&track_id).await?; +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Métadonnées enrichies +//! +//! Qobuz fournit des métadonnées détaillées qui sont préservées dans le cache : +//! - Titre, artiste, album +//! - Numéro de piste et de disque +//! - Année de sortie +//! - Genre(s) +//! - Label +//! - Qualité audio (sample rate, bit depth, channels) +//! - Durée +//! +//! ### Exemple complet +//! +//! Voir `examples/with_cache.rs` pour un exemple complet d'utilisation avec cache. +//! +//! ## Formats audio supportés +//! +//! Qobuz propose plusieurs formats : +//! - Format 5 : MP3 320 kbps +//! - Format 6 : FLAC 16 bit / 44.1 kHz (CD Quality) +//! - Format 7 : FLAC 24 bit / jusqu'à 96 kHz (Hi-Res) +//! - Format 27 : FLAC 24 bit / jusqu'à 192 kHz (Hi-Res+) +//! +//! ## Gestion des erreurs +//! +//! La crate utilise `thiserror` pour définir des erreurs typées : +//! +//! ```rust,ignore +//! use pmoqobuz::{QobuzClient, QobuzError}; +//! +//! match client.get_album("invalid").await { +//! Ok(album) => println!("Album: {}", album.title), +//! Err(QobuzError::NotFound) => println!("Album not found"), +//! Err(QobuzError::Unauthorized) => println!("Authentication failed"), +//! Err(e) => println!("Error: {}", e), +//! } +//! ``` +//! +//! ## Voir aussi +//! +//! - [`pmodidl`] : Format DIDL-Lite +//! - [`pmocovers`] : Cache d'images +//! - [`pmoaudiocache`] : Cache audio +//! - [`pmoconfig`] : Configuration +//! - [`pmoserver`] : Serveur HTTP + +pub mod api; +pub mod cache; +pub mod client; +pub mod config_ext; +pub mod didl; +#[cfg(feature = "disk-cache")] +pub mod disk_cache; +pub mod error; +pub mod models; +pub mod source; + +// Extension pmoserver (feature-gated) +#[cfg(feature = "pmoserver")] +pub mod api_rest; + +#[cfg(feature = "pmoserver")] +pub mod pmoserver_ext; + +#[cfg(feature = "pmoserver")] +mod pmoserver_impl; + +pub use client::QobuzClient; +pub use config_ext::QobuzConfigExt; +pub use error::{QobuzError, Result}; +pub use models::{Album, Artist, AudioFormat, Genre, Playlist, SearchResult, Track}; +pub use source::QobuzSource; + +/// Ré-exporte les types DIDL pour faciliter l'utilisation +pub use didl::ToDIDL; + +/// Ré-exporte le trait d'extension pmoserver +#[cfg(feature = "pmoserver")] +pub use pmoserver_ext::QobuzServerExt; +========= End of pmoqobuz/src/lib.rs =========== + +=============== pmoqobuz/src/disk_cache.rs ============ +#![cfg(feature = "disk-cache")] + +use anyhow::anyhow; +use serde::{de::DeserializeOwned, Serialize}; +use std::{ + path::PathBuf, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use rusqlite::{params, Connection}; +use tokio::task; + +#[derive(Debug, Clone)] +pub struct CacheEntry { + pub value: T, + pub age: Duration, + pub fresh: bool, +} + +#[async_trait::async_trait] +pub trait CacheStore: Send + Sync { + async fn get_json( + &self, + user_id: &str, + namespace: &str, + key: &str, + ) -> anyhow::Result>>; + + async fn put_json( + &self, + user_id: &str, + namespace: &str, + key: &str, + ttl: Duration, + value: &T, + ) -> anyhow::Result<()>; + + async fn invalidate( + &self, + user_id: &str, + namespace: &str, + key: &str, + ) -> anyhow::Result<()>; + + async fn purge_expired(&self) -> anyhow::Result; +} + +pub struct SqliteCacheStore { + db_path: PathBuf, +} + +impl SqliteCacheStore { + pub fn new(db_path: PathBuf) -> anyhow::Result { + let store = Self { db_path }; + store.init_blocking()?; + Ok(store) + } + + fn init_blocking(&self) -> anyhow::Result<()> { + let conn = Connection::open(&self.db_path)?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS qobuz_cache ( + user_id TEXT NOT NULL, + namespace TEXT NOT NULL, + key TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + ttl_seconds INTEGER NOT NULL, + json BLOB NOT NULL, + PRIMARY KEY (user_id, namespace, key) + ); + CREATE INDEX IF NOT EXISTS qobuz_cache_expiry + ON qobuz_cache (fetched_at, ttl_seconds); + "#, + )?; + Ok(()) + } + + fn now_seconds() -> i64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX), + Err(_) => 0, + } + } +} + +#[async_trait::async_trait] +impl CacheStore for SqliteCacheStore { + async fn get_json( + &self, + user_id: &str, + namespace: &str, + key: &str, + ) -> anyhow::Result>> { + let user_id = user_id.to_owned(); + let namespace = namespace.to_owned(); + let key = key.to_owned(); + let db_path = self.db_path.clone(); + + task::spawn_blocking(move || { + let conn = Connection::open(db_path)?; + let mut stmt = conn.prepare( + "SELECT fetched_at, ttl_seconds, json + FROM qobuz_cache + WHERE user_id = ?1 AND namespace = ?2 AND key = ?3", + )?; + + let result = stmt.query_row( + params![user_id, namespace, key], + |row| { + let fetched_at: i64 = row.get(0)?; + let ttl_seconds: i64 = row.get(1)?; + let data: Vec = row.get(2)?; + let now = Self::now_seconds(); + let fresh = now <= fetched_at + ttl_seconds; + let age_secs = if now >= fetched_at { + (now - fetched_at) as u64 + } else { + 0 + }; + let age = Duration::from_secs(age_secs); + let value = serde_json::from_slice(&data)?; + Ok(CacheEntry { + value, + age, + fresh, + }) + }, + ); + + match result { + Ok(entry) => Ok(Some(entry)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + }) + .await + .map_err(|err| anyhow!(err))? + } + + async fn put_json( + &self, + user_id: &str, + namespace: &str, + key: &str, + ttl: Duration, + value: &T, + ) -> anyhow::Result<()> { + let user_id = user_id.to_owned(); + let namespace = namespace.to_owned(); + let key = key.to_owned(); + let db_path = self.db_path.clone(); + let ttl_seconds = i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX); + let now = Self::now_seconds(); + let json = serde_json::to_vec(value)?; + + task::spawn_blocking(move || { + let conn = Connection::open(db_path)?; + let tx = conn.transaction()?; + tx.execute( + "INSERT OR REPLACE INTO qobuz_cache + (user_id, namespace, key, fetched_at, ttl_seconds, json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![user_id, namespace, key, now, ttl_seconds, json], + )?; + tx.commit()?; + Ok(()) + }) + .await + .map_err(|err| anyhow!(err))? + } + + async fn invalidate( + &self, + user_id: &str, + namespace: &str, + key: &str, + ) -> anyhow::Result<()> { + let user_id = user_id.to_owned(); + let namespace = namespace.to_owned(); + let key = key.to_owned(); + let db_path = self.db_path.clone(); + + task::spawn_blocking(move || { + let conn = Connection::open(db_path)?; + conn.execute( + "DELETE FROM qobuz_cache + WHERE user_id = ?1 AND namespace = ?2 AND key = ?3", + params![user_id, namespace, key], + )?; + Ok(()) + }) + .await + .map_err(|err| anyhow!(err))? + } + + async fn purge_expired(&self) -> anyhow::Result { + let db_path = self.db_path.clone(); + let now = Self::now_seconds(); + + task::spawn_blocking(move || { + let conn = Connection::open(db_path)?; + let changes = conn.execute( + "DELETE FROM qobuz_cache + WHERE (fetched_at + ttl_seconds) <= ?1", + params![now], + )?; + Ok(changes) + }) + .await + .map_err(|err| anyhow!(err))? + } +} + +pub use {CacheEntry, CacheStore, SqliteCacheStore}; +========= End of pmoqobuz/src/disk_cache.rs =========== + +=============== pmoqobuz/src/models.rs ============ +//! Structures de données pour représenter les objets Qobuz + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; + +/// Désérialiseur flexible pour les IDs qui peuvent être des strings ou des integers +pub(crate) fn deserialize_id<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + use serde_json::Value; + + let value = Value::deserialize(deserializer)?; + match value { + Value::String(s) => Ok(s), + Value::Number(n) => Ok(n.to_string()), + _ => Err(Error::custom("ID must be a string or number")), + } +} + +/// Représente un artiste Qobuz +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Artist { + /// Identifiant unique de l'artiste + #[serde(deserialize_with = "deserialize_id")] + pub id: String, + /// Nom de l'artiste + pub name: String, + /// URL de l'image de l'artiste (optionnelle) + #[serde(default)] + pub image: Option, + /// URL de l'image cachée localement (via pmocovers) + #[serde(skip)] + pub image_cached: Option, +} + +/// Représente un album Qobuz +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Album { + /// Identifiant unique de l'album + #[serde(deserialize_with = "deserialize_id")] + pub id: String, + /// Titre de l'album + pub title: String, + /// Artiste principal de l'album + pub artist: Artist, + /// Nombre de pistes + #[serde(default)] + pub tracks_count: Option, + /// Durée totale en secondes + #[serde(default)] + pub duration: Option, + /// Date de sortie (format ISO 8601) + #[serde(default)] + pub release_date: Option, + /// URL de l'image de couverture + #[serde(default)] + pub image: Option, + /// URL de l'image cachée localement (via pmocovers) + #[serde(skip)] + pub image_cached: Option, + /// Indique si l'album est disponible pour le streaming + #[serde(default = "default_true")] + pub streamable: bool, + /// Description de l'album + #[serde(default)] + pub description: Option, + /// Taux d'échantillonnage maximum (Hz) + #[serde(default)] + pub maximum_sampling_rate: Option, + /// Profondeur de bits maximale + #[serde(default)] + pub maximum_bit_depth: Option, + /// Genre(s) de l'album + #[serde(default)] + pub genres: Vec, + /// Label de l'album + #[serde(default)] + pub label: Option, +} + +/// Représente une piste (track) Qobuz +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Track { + /// Identifiant unique de la piste + #[serde(deserialize_with = "deserialize_id")] + pub id: String, + /// Titre de la piste + pub title: String, + /// Artiste de la piste (peut différer de l'artiste de l'album) + pub performer: Option, + /// Album contenant la piste + pub album: Option, + /// Durée en secondes + pub duration: u32, + /// Numéro de piste + pub track_number: u32, + /// Numéro de disque (pour les albums multi-disques) + pub media_number: u32, + /// Indique si la piste est disponible pour le streaming + #[serde(default = "default_true")] + pub streamable: bool, + /// Type MIME du fichier audio (déterminé après obtention de l'URL) + #[serde(skip)] + pub mime_type: Option, + /// Fréquence d'échantillonnage (Hz) + #[serde(skip)] + pub sample_rate: Option, + /// Profondeur de bits + #[serde(skip)] + pub bit_depth: Option, + /// Nombre de canaux audio + #[serde(skip)] + pub channels: Option, +} + +/// Représente une playlist Qobuz +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Playlist { + /// Identifiant unique de la playlist + #[serde(deserialize_with = "deserialize_id")] + pub id: String, + /// Nom de la playlist + pub name: String, + /// Description de la playlist + #[serde(default)] + pub description: Option, + /// Nombre de pistes + #[serde(default)] + pub tracks_count: Option, + /// Durée totale en secondes + #[serde(default)] + pub duration: Option, + /// URL de l'image de la playlist + #[serde(default)] + pub image: Option, + /// URL de l'image cachée localement + #[serde(skip)] + pub image_cached: Option, + /// Indique si c'est une playlist publique + #[serde(default)] + pub is_public: bool, + /// Propriétaire de la playlist + #[serde(default)] + pub owner: Option, +} + +/// Propriétaire d'une playlist +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaylistOwner { + /// Identifiant de l'utilisateur + pub id: u64, + /// Nom de l'utilisateur + pub name: String, +} + +/// Représente un genre musical +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Genre { + /// Identifiant du genre (peut être None pour "All Genres") + pub id: Option, + /// Nom du genre + pub name: String, + /// Genres enfants + #[serde(default)] + pub children: Vec, +} + +/// Résultats de recherche +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SearchResult { + /// Albums trouvés + #[serde(default)] + pub albums: Vec, + /// Artistes trouvés + #[serde(default)] + pub artists: Vec, + /// Pistes trouvées + #[serde(default)] + pub tracks: Vec, + /// Playlists trouvées + #[serde(default)] + pub playlists: Vec, +} + +/// Informations sur un fichier de streaming +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamInfo { + /// URL de streaming + pub url: String, + /// Type MIME + pub mime_type: String, + /// Fréquence d'échantillonnage (Hz) + pub sampling_rate: u32, + /// Profondeur de bits + pub bit_depth: u32, + /// Format ID Qobuz + pub format_id: u8, + /// Date d'expiration de l'URL + #[serde(skip)] + pub expires_at: DateTime, +} + +/// Format audio demandé pour le streaming +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +#[allow(non_camel_case_types)] +pub enum AudioFormat { + /// MP3 320 kbps + Mp3_320 = 5, + /// FLAC 16 bit / 44.1 kHz (CD Quality) + Flac_Lossless = 6, + /// FLAC 24 bit / jusqu'à 96 kHz (Hi-Res) + Flac_HiRes_96 = 7, + /// FLAC 24 bit / jusqu'à 192 kHz (Hi-Res+) + Flac_HiRes_192 = 27, +} + +impl AudioFormat { + /// Retourne l'ID du format pour l'API Qobuz + pub fn id(&self) -> u8 { + *self as u8 + } + + /// Retourne une description lisible du format + pub fn description(&self) -> &'static str { + match self { + AudioFormat::Mp3_320 => "MP3 320 kbps", + AudioFormat::Flac_Lossless => "FLAC 16 bit / 44.1 kHz", + AudioFormat::Flac_HiRes_96 => "FLAC 24 bit / up to 96 kHz", + AudioFormat::Flac_HiRes_192 => "FLAC 24 bit / up to 192 kHz", + } + } + + /// Retourne le type MIME associé + pub fn mime_type(&self) -> &'static str { + match self { + AudioFormat::Mp3_320 => "audio/mpeg", + _ => "audio/flac", + } + } +} + +impl Default for AudioFormat { + fn default() -> Self { + AudioFormat::Flac_Lossless + } +} + +// Helper functions +fn default_true() -> bool { + true +} + +impl Artist { + /// Crée un nouvel artiste avec un ID et un nom + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + image: None, + image_cached: None, + } + } +} + +impl Album { + /// Retourne un titre formaté avec les informations audio si disponibles + pub fn formatted_title(&self) -> String { + if let (Some(rate), Some(depth)) = (self.maximum_sampling_rate, self.maximum_bit_depth) { + format!("{} ({:.0}/{} bit)", self.title, rate / 1000.0, depth) + } else { + self.title.clone() + } + } + + /// Vérifie si l'album est disponible pour le streaming + pub fn is_available(&self) -> bool { + self.streamable + } +} + +impl Track { + /// Retourne l'artiste à afficher (performer ou artiste de l'album) + pub fn display_artist(&self) -> Option<&Artist> { + self.performer + .as_ref() + .or_else(|| self.album.as_ref().map(|a| &a.artist)) + } + + /// Retourne le nom de l'album si disponible + pub fn album_name(&self) -> Option<&str> { + self.album.as_ref().map(|a| a.title.as_str()) + } + + /// Vérifie si la piste est disponible pour le streaming + pub fn is_available(&self) -> bool { + self.streamable + } +} + +impl SearchResult { + /// Crée un résultat de recherche vide + pub fn new() -> Self { + Self::default() + } + + /// Retourne le nombre total de résultats + pub fn total_count(&self) -> usize { + self.albums.len() + self.artists.len() + self.tracks.len() + self.playlists.len() + } + + /// Vérifie si la recherche n'a retourné aucun résultat + pub fn is_empty(&self) -> bool { + self.total_count() == 0 + } +} +========= End of pmoqobuz/src/models.rs =========== + +=============== pmoqobuz/src/didl.rs ============ +//! Export des objets Qobuz en format DIDL-Lite +//! +//! Ce module permet de convertir les structures Qobuz (Album, Track, etc.) +//! en objets DIDL-Lite compatibles avec UPnP/DLNA. + +use crate::error::{QobuzError, Result}; +use crate::models::{Album, Playlist, Track}; +use pmodidl::{Container, Item, Resource}; + +/// Trait pour convertir un objet Qobuz en DIDL-Lite +pub trait ToDIDL { + /// Convertit l'objet en Container DIDL + fn to_didl_container(&self, parent_id: &str) -> Result; + + /// Convertit l'objet en Item DIDL + fn to_didl_item(&self, parent_id: &str) -> Result; +} + +impl ToDIDL for Album { + /// Convertit un album en Container DIDL + /// + /// # Arguments + /// + /// * `parent_id` - ID du container parent + /// + /// # Exemple + /// + /// ```rust,ignore + /// let album = client.get_album("12345").await?; + /// let container = album.to_didl_container("0$qobuz$albums")?; + /// ``` + fn to_didl_container(&self, parent_id: &str) -> Result { + let id = format!("0$qobuz$album${}", self.id); + + Ok(Container { + id, + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + child_count: self.tracks_count.map(|c| c.to_string()), + searchable: Some("1".to_string()), + title: self.formatted_title(), + class: "object.container.album.musicAlbum".to_string(), + containers: Vec::new(), + items: Vec::new(), + }) + } + + /// Un album ne peut pas être converti directement en Item + fn to_didl_item(&self, _parent_id: &str) -> Result { + Err(QobuzError::DidlExport( + "Album cannot be converted to Item, use to_didl_container instead".to_string(), + )) + } +} + +impl ToDIDL for Track { + /// Une track ne peut pas être convertie en Container + fn to_didl_container(&self, _parent_id: &str) -> Result { + Err(QobuzError::DidlExport( + "Track cannot be converted to Container, use to_didl_item instead".to_string(), + )) + } + + /// Convertit une track en Item DIDL + /// + /// # Arguments + /// + /// * `parent_id` - ID du container parent + /// + /// # Exemple + /// + /// ```rust,ignore + /// let track = client.get_track("98765").await?; + /// let item = track.to_didl_item("0$qobuz$album$12345")?; + /// ``` + fn to_didl_item(&self, parent_id: &str) -> Result { + let id = format!("0$qobuz$track${}", self.id); + + // Déterminer l'artiste à afficher + let artist_name = self + .display_artist() + .map(|a| a.name.clone()) + .or_else(|| self.album.as_ref().map(|a| a.artist.name.clone())); + + // Déterminer l'album + let album_name = self.album_name().map(|s| s.to_string()); + + // Déterminer l'image de couverture + let album_art = self + .album + .as_ref() + .and_then(|a| a.image_cached.clone().or_else(|| a.image.clone())); + + // Créer la ressource (URL de streaming) + // Note: L'URL sera remplie plus tard via get_stream_url + let resource = Resource { + protocol_info: format!( + "http-get:*:{}:*", + self.mime_type.as_deref().unwrap_or("audio/flac") + ), + bits_per_sample: self.bit_depth.map(|b| b.to_string()), + sample_frequency: self.sample_rate.map(|r| r.to_string()), + nr_audio_channels: self.channels.map(|c| c.to_string()), + duration: Some(format_duration(self.duration)), + url: format!("qobuz://track/{}", self.id), // URL symbolique + }; + + Ok(Item { + id, + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + title: self.title.clone(), + creator: artist_name.clone(), + class: "object.item.audioItem.musicTrack".to_string(), + artist: artist_name, + album: album_name, + genre: None, // Qobuz ne fournit pas le genre au niveau track + album_art, + album_art_pk: None, + date: self.album.as_ref().and_then(|a| a.release_date.clone()), + original_track_number: Some(self.track_number.to_string()), + resources: vec![resource], + descriptions: Vec::new(), + }) + } +} + +impl ToDIDL for Playlist { + /// Convertit une playlist en Container DIDL + fn to_didl_container(&self, parent_id: &str) -> Result { + let id = format!("0$qobuz$playlist${}", self.id); + + Ok(Container { + id, + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + child_count: self.tracks_count.map(|c| c.to_string()), + searchable: Some("1".to_string()), + title: self.name.clone(), + class: "object.container.playlistContainer".to_string(), + containers: Vec::new(), + items: Vec::new(), + }) + } + + /// Une playlist ne peut pas être convertie en Item + fn to_didl_item(&self, _parent_id: &str) -> Result { + Err(QobuzError::DidlExport( + "Playlist cannot be converted to Item, use to_didl_container instead".to_string(), + )) + } +} + +/// Formate une durée en secondes au format HH:MM:SS +fn format_duration(seconds: u32) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, secs) +} + +/// Convertit une liste de tracks en items DIDL +pub fn tracks_to_didl_items(tracks: &[Track], parent_id: &str) -> Result> { + tracks + .iter() + .map(|track| track.to_didl_item(parent_id)) + .collect() +} + +/// Convertit une liste d'albums en containers DIDL +pub fn albums_to_didl_containers(albums: &[Album], parent_id: &str) -> Result> { + albums + .iter() + .map(|album| album.to_didl_container(parent_id)) + .collect() +} + +/// Convertit une liste de playlists en containers DIDL +pub fn playlists_to_didl_containers( + playlists: &[Playlist], + parent_id: &str, +) -> Result> { + playlists + .iter() + .map(|playlist| playlist.to_didl_container(parent_id)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Album, Artist, Track}; + + #[test] + fn test_album_to_didl_container() { + let album = Album { + id: "123".to_string(), + title: "Test Album".to_string(), + artist: Artist::new("456", "Test Artist"), + tracks_count: Some(10), + duration: Some(3000), + release_date: Some("2024-01-01".to_string()), + image: None, + image_cached: None, + streamable: true, + description: None, + maximum_sampling_rate: Some(96000.0), + maximum_bit_depth: Some(24), + genres: vec![], + label: None, + }; + + let container = album.to_didl_container("parent").unwrap(); + assert_eq!(container.id, "0$qobuz$album$123"); + assert_eq!(container.parent_id, "parent"); + assert!(container.title.contains("Test Album")); + } + + #[test] + fn test_track_to_didl_item() { + let track = Track { + id: "789".to_string(), + title: "Test Track".to_string(), + performer: Some(Artist::new("456", "Test Artist")), + album: None, + duration: 180, + track_number: 1, + media_number: 1, + streamable: true, + mime_type: Some("audio/flac".to_string()), + sample_rate: Some(44100), + bit_depth: Some(16), + channels: Some(2), + }; + + let item = track.to_didl_item("parent").unwrap(); + assert_eq!(item.id, "0$qobuz$track$789"); + assert_eq!(item.parent_id, "parent"); + assert_eq!(item.title, "Test Track"); + } + + #[test] + fn test_format_duration() { + assert_eq!(format_duration(0), "00:00:00"); + assert_eq!(format_duration(90), "00:01:30"); + assert_eq!(format_duration(3665), "01:01:05"); + } +} +========= End of pmoqobuz/src/didl.rs =========== + +=============== pmoqobuz/src/source.rs ============ +//! Music source implementation for Qobuz +//! +//! This module implements the [`pmosource::MusicSource`] trait for Qobuz, +//! providing a complete music catalog browsing and searching experience. + +use crate::client::QobuzClient; +use crate::didl::ToDIDL; +use crate::models::Track; +use pmoaudiocache::{AudioMetadata, Cache as AudioCache}; +use pmocovers::Cache as CoverCache; +use pmodidl::{Container, Item}; +use pmosource::SourceCacheManager; +use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; +use std::sync::Arc; +use std::time::SystemTime; + +/// Default image for Qobuz (300x300 WebP, embedded in binary) +const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); + +/// Qobuz music source with full MusicSource trait implementation +/// +/// This struct combines a [`QobuzClient`] for API access with browsing and +/// navigation capabilities, implementing the complete [`MusicSource`] trait. +/// +/// # Features +/// +/// - **Catalog Navigation**: Browse albums, artists, playlists, favorites +/// - **Search**: Full-text search across the Qobuz catalog +/// - **URI Resolution**: Resolves track streaming URIs with authentication +/// - **DIDL-Lite Export**: Converts albums, tracks, and playlists to UPnP formats +/// - **Caching**: Integrated with QobuzClient's cache for performance +/// +/// # Architecture +/// +/// Unlike streaming sources like Radio Paradise, Qobuz is a catalog-based source: +/// - Root container has multiple sub-containers (Albums, Artists, Favorites, etc.) +/// - No FIFO support (it's a static catalog, not a dynamic stream) +/// - Hierarchical browsing: Root → Category → Albums → Tracks +/// +/// # Examples +/// +/// ```no_run +/// use pmoqobuz::{QobuzSource, QobuzClient}; +/// use pmosource::MusicSource; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let client = QobuzClient::from_config().await?; +/// let source = QobuzSource::new(client); +/// +/// println!("Source: {}", source.name()); +/// println!("Supports FIFO: {}", source.supports_fifo()); +/// +/// // Browse root container +/// let root = source.root_container().await?; +/// println!("Root: {} with {} children", root.title, root.child_count.unwrap_or_default()); +/// +/// Ok(()) +/// } +/// ``` +#[derive(Clone)] +pub struct QobuzSource { + inner: Arc, +} + +struct QobuzSourceInner { + /// Qobuz API client + client: QobuzClient, + + /// Cache manager (centralisé) + cache_manager: SourceCacheManager, + + /// Update tracking + update_counter: tokio::sync::RwLock, + last_change: tokio::sync::RwLock, +} + +impl std::fmt::Debug for QobuzSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("QobuzSource").finish() + } +} + +impl QobuzSource { + /// Create a new Qobuz source from the cache registry + /// + /// This is the recommended way to create a source when using the UPnP server. + /// The caches are automatically retrieved from the global registry. + /// + /// # Arguments + /// + /// * `client` - Authenticated Qobuz API client + /// + /// # Errors + /// + /// Returns an error if the caches are not initialized in the registry + #[cfg(feature = "server")] + pub fn from_registry(client: QobuzClient) -> Result { + let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?; + + Ok(Self { + inner: Arc::new(QobuzSourceInner { + client, + cache_manager, + update_counter: tokio::sync::RwLock::new(0), + last_change: tokio::sync::RwLock::new(SystemTime::now()), + }), + }) + } + + /// Create a new Qobuz source with explicit caches (for tests) + /// + /// # Arguments + /// + /// * `client` - Authenticated Qobuz API client + /// * `cover_cache` - Cover image cache (required) + /// * `audio_cache` - Audio cache (required) + pub fn new( + client: QobuzClient, + cover_cache: Arc, + audio_cache: Arc, + ) -> Self { + let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache); + + Self { + inner: Arc::new(QobuzSourceInner { + client, + cache_manager, + update_counter: tokio::sync::RwLock::new(0), + last_change: tokio::sync::RwLock::new(SystemTime::now()), + }), + } + } + + /// Get the Qobuz client + pub fn client(&self) -> &QobuzClient { + &self.inner.client + } + + /// Add a track from Qobuz with caching + /// + /// This method downloads and caches both cover art and audio data. + pub async fn add_track(&self, track: &Track) -> Result { + let track_id = format!("qobuz://track/{}", track.id); + + // Get streaming URL + let stream_url = self + .inner + .client + .get_stream_url(&track.id) + .await + .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?; + + // 1. Cache cover via manager + let cached_cover_pk = if let Some(ref album) = track.album { + if let Some(ref image_url) = album.image { + self.inner.cache_manager.cache_cover(image_url).await.ok() + } else { + None + } + } else { + None + }; + + // 2. Prepare rich metadata from Qobuz track + let metadata = AudioMetadata { + title: Some(track.title.clone()), + artist: track.performer.as_ref().map(|p| p.name.clone()), + album: track.album.as_ref().map(|a| a.title.clone()), + duration_secs: Some(track.duration as u64), + year: track.album.as_ref().and_then(|a| { + a.release_date + .as_ref() + .and_then(|d| d.split('-').next()?.parse().ok()) + }), + track_number: Some(track.track_number), + track_total: track.album.as_ref().and_then(|a| a.tracks_count), + disc_number: Some(track.media_number), + disc_total: None, + genre: track.album.as_ref().and_then(|a| { + if !a.genres.is_empty() { + Some(a.genres.join(", ")) + } else { + None + } + }), + sample_rate: track.sample_rate, + channels: track.channels, + bitrate: None, + conversion: None, + }; + + // 3. Cache audio via manager + let cached_audio_pk = self + .inner + .cache_manager + .cache_audio(&stream_url, Some(metadata)) + .await + .ok(); + + // 4. Store metadata + self.inner + .cache_manager + .update_metadata( + track_id.clone(), + pmosource::TrackMetadata { + original_uri: stream_url, + cached_audio_pk, + cached_cover_pk, + }, + ) + .await; + + Ok(track_id) + } + + /// Add track with lazy audio caching (cover eager, audio lazy) + /// + /// This method caches cover art immediately (small, needed for UI) but + /// defers audio download until the track is actually played. + /// + /// # Arguments + /// + /// * `track` - The Qobuz track to add + /// + /// # Returns + /// + /// The track ID (e.g., "qobuz://track/12345") + pub async fn add_track_lazy(&self, track: &Track) -> Result { + let track_id = format!("qobuz://track/{}", track.id); + + // Get streaming URL + let stream_url = self + .inner + .client + .get_stream_url(&track.id) + .await + .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?; + + // 1. Cache cover EAGERLY (small, UI needs it) + let cached_cover_pk = if let Some(ref album) = track.album { + if let Some(ref image_url) = album.image { + self.inner.cache_manager.cache_cover(image_url).await.ok() + } else { + None + } + } else { + None + }; + + // 2. Prepare rich metadata from Qobuz track + let metadata = AudioMetadata { + title: Some(track.title.clone()), + artist: track.performer.as_ref().map(|p| p.name.clone()), + album: track.album.as_ref().map(|a| a.title.clone()), + duration_secs: Some(track.duration as u64), + year: track.album.as_ref().and_then(|a| { + a.release_date + .as_ref() + .and_then(|d| d.split('-').next()?.parse().ok()) + }), + track_number: Some(track.track_number), + track_total: track.album.as_ref().and_then(|a| a.tracks_count), + disc_number: Some(track.media_number), + disc_total: None, + genre: track.album.as_ref().and_then(|a| { + if !a.genres.is_empty() { + Some(a.genres.join(", ")) + } else { + None + } + }), + sample_rate: track.sample_rate, + channels: track.channels, + bitrate: None, + conversion: None, + }; + + // 3. Cache audio LAZILY (KEY CHANGE: use cache_audio_lazy) + let cached_audio_pk = self + .inner + .cache_manager + .cache_audio_lazy(&stream_url, Some(metadata)) + .await + .ok(); + + // 4. Store metadata + self.inner + .cache_manager + .update_metadata( + track_id.clone(), + pmosource::TrackMetadata { + original_uri: stream_url, + cached_audio_pk, + cached_cover_pk, + }, + ) + .await; + + Ok(track_id) + } + + /// Load full album into pmoplaylist with lazy audio + /// + /// This method fetches all tracks from a Qobuz album and adds them to a playlist + /// with lazy audio loading. Covers are downloaded eagerly, audio lazily. + /// + /// # Arguments + /// + /// * `playlist_id` - ID of the target playlist + /// * `album_id` - Qobuz album ID + /// + /// # Returns + /// + /// Number of tracks successfully added + pub async fn add_album_to_playlist( + &self, + playlist_id: &str, + album_id: &str, + ) -> Result { + use tracing::{info, warn, debug}; + + // 1. Get tracks from Qobuz (goes through rate limiter) + let tracks = self + .inner + .client + .get_album_tracks(album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + if tracks.is_empty() { + return Ok(0); + } + + info!( + "Adding album {} ({} tracks) to playlist {} with lazy audio", + album_id, + tracks.len(), + playlist_id + ); + + // 2. Add each track lazily + collect lazy PKs + let mut lazy_pks = Vec::with_capacity(tracks.len()); + + for (i, track) in tracks.iter().enumerate() { + match self.add_track_lazy(track).await { + Ok(track_id) => { + debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title); + + // Extract lazy PK from cache manager + if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await { + if let Some(audio_pk) = metadata.cached_audio_pk { + lazy_pks.push(audio_pk); + } + } + } + Err(e) => { + warn!( + "Failed to add track {} ({}): {}", + i + 1, + track.title, + e + ); + // Continue with other tracks + } + } + } + + // 3. Batch insert into playlist (single DB transaction) + let playlist_manager = pmoplaylist::PlaylistManager(); + let writer = playlist_manager + .get_write_handle(playlist_id.to_string()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + writer + .push_lazy_batch(lazy_pks.clone()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // 4. Enable lazy mode with lookahead of 2 tracks + playlist_manager.enable_lazy_mode(playlist_id, 2); + + info!( + "Album {} added: {}/{} tracks", + album_id, + lazy_pks.len(), + tracks.len() + ); + + Ok(lazy_pks.len()) + } + + /// Load Qobuz playlist into pmoplaylist with lazy audio + /// + /// This method fetches all tracks from a Qobuz playlist and adds them to a pmoplaylist + /// with lazy audio loading. Covers are downloaded eagerly, audio lazily. + /// + /// # Arguments + /// + /// * `playlist_id` - ID of the target pmoplaylist + /// * `qobuz_playlist_id` - Qobuz playlist ID + /// + /// # Returns + /// + /// Number of tracks successfully added + pub async fn add_qobuz_playlist_to_playlist( + &self, + playlist_id: &str, + qobuz_playlist_id: &str, + ) -> Result { + use tracing::{debug, info, warn}; + + // 1. Get tracks from Qobuz playlist (goes through rate limiter) + let tracks = self + .inner + .client + .get_playlist_tracks(qobuz_playlist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + if tracks.is_empty() { + return Ok(0); + } + + info!( + "Adding Qobuz playlist {} ({} tracks) to pmoplaylist {} with lazy audio", + qobuz_playlist_id, + tracks.len(), + playlist_id + ); + + // 2. Add each track lazily + collect lazy PKs + let mut lazy_pks = Vec::with_capacity(tracks.len()); + + for (i, track) in tracks.iter().enumerate() { + match self.add_track_lazy(track).await { + Ok(track_id) => { + debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title); + + // Extract lazy PK from cache manager + if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await { + if let Some(audio_pk) = metadata.cached_audio_pk { + lazy_pks.push(audio_pk); + } + } + } + Err(e) => { + warn!( + "Failed to add track {} ({}): {}", + i + 1, + track.title, + e + ); + // Continue with other tracks + } + } + } + + // 3. Batch insert into playlist (single DB transaction) + let playlist_manager = pmoplaylist::PlaylistManager(); + let writer = playlist_manager + .get_write_handle(playlist_id.to_string()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + writer + .push_lazy_batch(lazy_pks.clone()) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + // 4. Enable lazy mode with lookahead of 2 tracks + playlist_manager.enable_lazy_mode(playlist_id, 2); + + info!( + "Qobuz playlist {} added: {}/{} tracks", + qobuz_playlist_id, + lazy_pks.len(), + tracks.len() + ); + + Ok(lazy_pks.len()) + } + + /// Increment update counter (called on catalog changes) + async fn increment_update_id(&self) { + let mut counter = self.inner.update_counter.write().await; + *counter = counter.wrapping_add(1); + let mut last = self.inner.last_change.write().await; + *last = SystemTime::now(); + } + + /// Parse object_id to determine what to browse + /// + /// Object IDs follow these patterns: + /// - "qobuz" or "0" → Root container + /// - "qobuz:favorites" → User's favorite albums + /// - "qobuz:album:{id}" → Tracks in album + /// - "qobuz:playlist:{id}" → Tracks in playlist + fn parse_object_id(&self, object_id: &str) -> ObjectIdType { + if object_id == "qobuz" || object_id == "0" { + return ObjectIdType::Root; + } + + let parts: Vec<&str> = object_id.split(':').collect(); + match parts.as_slice() { + ["qobuz", "favorites"] => ObjectIdType::Favorites, + ["qobuz", "album", id] => ObjectIdType::Album(id.to_string()), + ["qobuz", "playlist", id] => ObjectIdType::Playlist(id.to_string()), + ["qobuz", "artist", id] => ObjectIdType::Artist(id.to_string()), + _ => ObjectIdType::Unknown, + } + } +} + +#[derive(Debug)] +enum ObjectIdType { + Root, + Favorites, + Album(String), + Playlist(String), + Artist(String), + Unknown, +} + +#[async_trait] +impl MusicSource for QobuzSource { + fn name(&self) -> &str { + "Qobuz" + } + + fn id(&self) -> &str { + "qobuz" + } + + fn default_image(&self) -> &[u8] { + DEFAULT_IMAGE + } + + async fn root_container(&self) -> Result { + // Create the root container with sub-containers for different categories + Ok(Container { + id: "qobuz".to_string(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + child_count: Some("2".to_string()), // Favorites + Search (simplified) + searchable: Some("1".to_string()), + title: "Qobuz".to_string(), + class: "object.container".to_string(), + containers: vec![ + // Favorites container + Container { + id: "qobuz:favorites".to_string(), + parent_id: "qobuz".to_string(), + restricted: Some("1".to_string()), + child_count: None, // Will be determined when browsed + searchable: Some("1".to_string()), + title: "My Favorites".to_string(), + class: "object.container".to_string(), + containers: vec![], + items: vec![], + }, + ], + items: vec![], + }) + } + + async fn browse(&self, object_id: &str) -> Result { + match self.parse_object_id(object_id) { + ObjectIdType::Root => { + // Return the root container's children + let root = self.root_container().await?; + Ok(BrowseResult::Containers(root.containers)) + } + + ObjectIdType::Favorites => { + // Get user's favorite albums + let albums = self + .inner + .client + .get_favorite_albums() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container("qobuz:favorites").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Album(album_id) => { + // Get tracks in album + let tracks = self + .inner + .client + .get_album_tracks(&album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = tracks + .into_iter() + .filter_map(|track| { + track + .to_didl_item(&format!("qobuz:album:{}", album_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Items(items)) + } + + ObjectIdType::Playlist(playlist_id) => { + // Get tracks in playlist + let tracks = self + .inner + .client + .get_playlist_tracks(&playlist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = tracks + .into_iter() + .filter_map(|track| { + track + .to_didl_item(&format!("qobuz:playlist:{}", playlist_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Items(items)) + } + + ObjectIdType::Artist(artist_id) => { + // Get albums by artist + let albums = self + .inner + .client + .get_artist_albums(&artist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .filter_map(|album| { + album + .to_didl_container(&format!("qobuz:artist:{}", artist_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(object_id.to_string())), + } + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + // Try cache manager first + if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await { + return Ok(uri); + } + + // If not cached, extract track ID and get streaming URL from Qobuz + let track_id = object_id + .strip_prefix("qobuz://track/") + .unwrap_or(object_id); + + self.inner + .client + .get_stream_url(track_id) + .await + .map_err(|e| MusicSourceError::UriResolutionError(e.to_string())) + } + + fn supports_fifo(&self) -> bool { + // Qobuz is a catalog, not a dynamic stream + false + } + + async fn append_track(&self, _track: Item) -> Result<()> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn remove_oldest(&self) -> Result> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn update_id(&self) -> u32 { + *self.inner.update_counter.read().await + } + + async fn last_change(&self) -> Option { + Some(*self.inner.last_change.read().await) + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + // For Qobuz, "get_items" returns favorite tracks with pagination + let all_tracks = self + .inner + .client + .get_favorite_tracks() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = all_tracks + .into_iter() + .skip(offset) + .take(count) + .filter_map(|track| track.to_didl_item("qobuz:favorites").ok()) + .collect(); + + Ok(items) + } + + async fn search(&self, query: &str) -> Result { + // Search across Qobuz catalog + let results = self + .inner + .client + .search(query, None) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Convert albums to containers and tracks to items + let containers: Vec = results + .albums + .into_iter() + .filter_map(|album| album.to_didl_container("qobuz").ok()) + .collect(); + + let items: Vec = results + .tracks + .into_iter() + .filter_map(|track| track.to_didl_item("qobuz").ok()) + .collect(); + + if !containers.is_empty() || !items.is_empty() { + Ok(BrowseResult::Mixed { containers, items }) + } else { + Ok(BrowseResult::Items(vec![])) + } + } + + // ============= Extended Features Implementation ============= + + fn capabilities(&self) -> pmosource::SourceCapabilities { + pmosource::SourceCapabilities { + supports_fifo: false, + supports_search: true, + supports_favorites: true, + supports_playlists: true, + supports_user_content: false, + supports_high_res_audio: true, + max_sample_rate: Some(192_000), // Qobuz supports up to 192kHz + supports_multiple_formats: true, + supports_advanced_search: false, // TODO: Qobuz API supports it, not yet implemented + supports_pagination: true, + } + } + + async fn get_available_formats(&self, object_id: &str) -> Result> { + use pmosource::AudioFormat; + + // Extract track ID from object_id + let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") { + id + } else { + object_id + }; + + // Get track details from Qobuz + let track = self + .inner + .client + .get_track(track_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Qobuz provides multiple formats based on subscription + let mut formats = vec![]; + + // MP3 320 (format_id 5) - available to all + formats.push(AudioFormat { + format_id: "mp3-320".to_string(), + mime_type: "audio/mpeg".to_string(), + sample_rate: Some(44100), + bit_depth: None, + bitrate: Some(320), + channels: Some(2), + }); + + // FLAC 16/44.1 (format_id 6) - CD quality + formats.push(AudioFormat { + format_id: "flac-16-44".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }); + + // Hi-Res formats (if available for this track) + if let Some(sample_rate) = track.sample_rate { + if sample_rate > 44100 { + // FLAC 24-bit Hi-Res + let bit_depth = track.bit_depth.map(|d| d as u8).or(Some(24)); + + formats.push(AudioFormat { + format_id: format!("flac-{}-{}", bit_depth.unwrap_or(24), sample_rate / 1000), + mime_type: "audio/flac".to_string(), + sample_rate: Some(sample_rate), + bit_depth, + bitrate: None, + channels: track.channels, + }); + } + } + + Ok(formats) + } + + async fn get_cache_status(&self, object_id: &str) -> Result { + self.inner.cache_manager.get_cache_status(object_id).await + } + + async fn cache_item(&self, object_id: &str) -> Result { + // Extract track ID + let track_id = object_id + .strip_prefix("qobuz://track/") + .unwrap_or(object_id); + + // Get track details from Qobuz + let track = self + .inner + .client + .get_track(track_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Add track to cache (via manager) + let cached_id = self.add_track(&track).await?; + + // Return the cache status + self.get_cache_status(&cached_id).await + } + + async fn add_favorite(&self, object_id: &str) -> Result<()> { + // Parse object_id to determine type + let parts: Vec<&str> = object_id.split(':').collect(); + + match parts.as_slice() { + ["qobuz", "album", id] | ["qobuz://album", id] => { + self.inner + .client + .add_favorite_album(id) + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + } + ["qobuz", "track", id] | ["qobuz://track", id] => { + self.inner + .client + .add_favorite_track(id) + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + } + _ => { + return Err(MusicSourceError::NotSupported( + "Favorites only supported for albums and tracks".to_string(), + )); + } + } + + self.increment_update_id().await; + Ok(()) + } + + async fn remove_favorite(&self, object_id: &str) -> Result<()> { + // Parse object_id to determine type + let parts: Vec<&str> = object_id.split(':').collect(); + + match parts.as_slice() { + ["qobuz", "album", id] | ["qobuz://album", id] => { + self.inner + .client + .remove_favorite_album(id) + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + } + ["qobuz", "track", id] | ["qobuz://track", id] => { + self.inner + .client + .remove_favorite_track(id) + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + } + _ => { + return Err(MusicSourceError::NotSupported( + "Favorites only supported for albums and tracks".to_string(), + )); + } + } + + self.increment_update_id().await; + Ok(()) + } + + async fn is_favorite(&self, object_id: &str) -> Result { + // Parse object_id to determine type + let parts: Vec<&str> = object_id.split(':').collect(); + + match parts.as_slice() { + ["qobuz", "album", id] | ["qobuz://album", id] => { + let favorites = self + .inner + .client + .get_favorite_albums() + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + + Ok(favorites.iter().any(|album| album.id == *id)) + } + ["qobuz", "track", id] | ["qobuz://track", id] => { + let favorites = self + .inner + .client + .get_favorite_tracks() + .await + .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; + + Ok(favorites.iter().any(|track| track.id == *id)) + } + _ => Err(MusicSourceError::NotSupported( + "Favorites only supported for albums and tracks".to_string(), + )), + } + } + + async fn get_user_playlists(&self) -> Result> { + let playlists = self + .inner + .client + .get_user_playlists() + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + let containers: Vec = playlists + .into_iter() + .filter_map(|playlist| playlist.to_didl_container("qobuz").ok()) + .collect(); + + Ok(containers) + } + + async fn add_to_playlist(&self, playlist_id: &str, item_id: &str) -> Result<()> { + // Extract track ID from item_id + let track_id = if let Some(id) = item_id.strip_prefix("qobuz://track/") { + id + } else if let Some(id) = item_id.strip_prefix("qobuz:track:") { + id + } else { + item_id + }; + + self.inner + .client + .add_to_playlist(playlist_id, track_id) + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + self.increment_update_id().await; + Ok(()) + } + + async fn get_item_count(&self, object_id: &str) -> Result { + match self.parse_object_id(object_id) { + ObjectIdType::Album(album_id) => { + let album = self + .inner + .client + .get_album(&album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + Ok(album.tracks_count.unwrap_or(0) as usize) + } + ObjectIdType::Playlist(playlist_id) => { + let playlist = self + .inner + .client + .get_playlist(&playlist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + Ok(playlist.tracks_count.unwrap_or(0) as usize) + } + _ => { + // Fall back to default implementation + let result = self.browse(object_id).await?; + Ok(result.count()) + } + } + } + + async fn browse_paginated( + &self, + object_id: &str, + offset: usize, + limit: usize, + ) -> Result { + match self.parse_object_id(object_id) { + ObjectIdType::Album(album_id) => { + // Qobuz returns all tracks, so we slice them + let tracks = self + .inner + .client + .get_album_tracks(&album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = tracks + .into_iter() + .skip(offset) + .take(limit) + .filter_map(|track| { + track + .to_didl_item(&format!("qobuz:album:{}", album_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Items(items)) + } + ObjectIdType::Favorites => { + let albums = self + .inner + .client + .get_favorite_albums() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .skip(offset) + .take(limit) + .filter_map(|album| album.to_didl_container("qobuz:favorites").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + _ => { + // Fall back to default implementation + self.browse(object_id).await + } + } + } + + async fn statistics(&self) -> Result { + let mut stats = pmosource::SourceStatistics::default(); + + // Try to get favorite counts + if let Ok(albums) = self.inner.client.get_favorite_albums().await { + stats.total_containers = Some(albums.len()); + } + + if let Ok(tracks) = self.inner.client.get_favorite_tracks().await { + stats.total_items = Some(tracks.len()); + } + + // Get cache statistics from manager + let cache_stats = self.inner.cache_manager.statistics().await; + stats.cached_items = Some(cache_stats.cached_tracks); + + Ok(stats) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_image_present() { + assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty"); + + // Check WebP magic bytes (RIFF...WEBP) + assert!( + DEFAULT_IMAGE.len() >= 12, + "Image too small to be valid WebP" + ); + assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header"); + assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature"); + } + + // Note: We can't easily test parse_object_id without creating a real client + // which requires authentication. The parsing logic is simple enough that + // it's covered by integration tests. +} +========= End of pmoqobuz/src/source.rs =========== + +=============== pmoqobuz/src/api_rest.rs ============ +//! Endpoints API REST pour Qobuz +//! +//! Ce module définit les handlers HTTP pour accéder aux fonctionnalités Qobuz. + +#[cfg(feature = "pmoserver")] +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, Router, +}; + +#[cfg(feature = "pmoserver")] +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "pmoserver")] +use std::sync::Arc; + +#[cfg(feature = "pmoserver")] +use crate::{client::QobuzClient, error::QobuzError, models::*}; + +/// État partagé de l'application +#[cfg(feature = "pmoserver")] +#[derive(Clone)] +pub struct QobuzState { + pub client: Arc, + #[cfg(feature = "covers")] + pub cover_cache: Option>, +} + +/// Paramètres de recherche +#[cfg(feature = "pmoserver")] +#[derive(Debug, Deserialize)] +pub struct SearchParams { + /// Requête de recherche + pub q: String, + /// Type de recherche (albums, artists, tracks, playlists) + #[serde(rename = "type")] + pub search_type: Option, +} + +/// Paramètres pour featured albums +#[cfg(feature = "pmoserver")] +#[derive(Debug, Deserialize)] +pub struct FeaturedAlbumsParams { + /// ID du genre (optionnel) + pub genre_id: Option, + /// Type (new-releases, ideal-discography, etc.) + #[serde(rename = "type", default = "default_featured_type")] + pub type_: String, +} + +#[cfg(feature = "pmoserver")] +fn default_featured_type() -> String { + "new-releases".to_string() +} + +/// Paramètres pour featured playlists +#[cfg(feature = "pmoserver")] +#[derive(Debug, Deserialize)] +pub struct FeaturedPlaylistsParams { + /// ID du genre (optionnel) + pub genre_id: Option, + /// Tags (optionnel) + pub tags: Option, +} + +/// Crée le router Axum avec tous les endpoints Qobuz +#[cfg(feature = "pmoserver")] +pub fn create_router(state: QobuzState) -> Router { + Router::new() + // Albums + .route("/albums/:id", axum::routing::get(get_album)) + .route("/albums/:id/tracks", axum::routing::get(get_album_tracks)) + // Tracks + .route("/tracks/:id", axum::routing::get(get_track)) + .route("/tracks/:id/stream", axum::routing::get(get_stream_url)) + // Artists + .route("/artists/:id/albums", axum::routing::get(get_artist_albums)) + .route( + "/artists/:id/similar", + axum::routing::get(get_similar_artists), + ) + // Playlists + .route("/playlists/:id", axum::routing::get(get_playlist)) + .route( + "/playlists/:id/tracks", + axum::routing::get(get_playlist_tracks), + ) + // Recherche + .route("/search", axum::routing::get(search)) + // Favoris + .route("/favorites/albums", axum::routing::get(get_favorite_albums)) + .route( + "/favorites/artists", + axum::routing::get(get_favorite_artists), + ) + .route("/favorites/tracks", axum::routing::get(get_favorite_tracks)) + .route( + "/favorites/playlists", + axum::routing::get(get_user_playlists), + ) + // Catalogue + .route("/genres", axum::routing::get(get_genres)) + .route("/featured/albums", axum::routing::get(get_featured_albums)) + .route( + "/featured/playlists", + axum::routing::get(get_featured_playlists), + ) + // Cache + .route("/cache/stats", axum::routing::get(get_cache_stats)) + .with_state(state) +} + +// ============ Handlers ============ + +#[cfg(feature = "pmoserver")] +async fn get_album( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let mut album = state.client.get_album(&id).await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + album = cache_album_image(album, cover_cache).await; + } + + Ok(Json(album)) +} + +#[cfg(feature = "pmoserver")] +async fn get_album_tracks( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let tracks = state.client.get_album_tracks(&id).await?; + Ok(Json(tracks)) +} + +#[cfg(feature = "pmoserver")] +async fn get_track( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let track = state.client.get_track(&id).await?; + Ok(Json(track)) +} + +#[cfg(feature = "pmoserver")] +async fn get_stream_url( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let url = state.client.get_stream_url(&id).await?; + Ok(Json(serde_json::json!({ "url": url }))) +} + +#[cfg(feature = "pmoserver")] +async fn get_artist_albums( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let mut albums = state.client.get_artist_albums(&id).await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + albums = cache_albums_images(albums, cover_cache).await; + } + + Ok(Json(albums)) +} + +#[cfg(feature = "pmoserver")] +async fn get_similar_artists( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let artists = state.client.get_similar_artists(&id).await?; + Ok(Json(artists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_playlist( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let playlist = state.client.get_playlist(&id).await?; + Ok(Json(playlist)) +} + +#[cfg(feature = "pmoserver")] +async fn get_playlist_tracks( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let tracks = state.client.get_playlist_tracks(&id).await?; + Ok(Json(tracks)) +} + +#[cfg(feature = "pmoserver")] +async fn search( + State(state): State, + Query(params): Query, +) -> Result, AppError> { + let mut result = state + .client + .search(¶ms.q, params.search_type.as_deref()) + .await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + result.albums = cache_albums_images(result.albums, cover_cache).await; + } + + Ok(Json(result)) +} + +#[cfg(feature = "pmoserver")] +async fn get_favorite_albums( + State(state): State, +) -> Result>, AppError> { + let mut albums = state.client.get_favorite_albums().await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + albums = cache_albums_images(albums, cover_cache).await; + } + + Ok(Json(albums)) +} + +#[cfg(feature = "pmoserver")] +async fn get_favorite_artists( + State(state): State, +) -> Result>, AppError> { + let artists = state.client.get_favorite_artists().await?; + Ok(Json(artists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_favorite_tracks( + State(state): State, +) -> Result>, AppError> { + let tracks = state.client.get_favorite_tracks().await?; + Ok(Json(tracks)) +} + +#[cfg(feature = "pmoserver")] +async fn get_user_playlists( + State(state): State, +) -> Result>, AppError> { + let playlists = state.client.get_user_playlists().await?; + Ok(Json(playlists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_genres(State(state): State) -> Result>, AppError> { + let genres = state.client.get_genres().await?; + Ok(Json(genres)) +} + +#[cfg(feature = "pmoserver")] +async fn get_featured_albums( + State(state): State, + Query(params): Query, +) -> Result>, AppError> { + let mut albums = state + .client + .get_featured_albums(params.genre_id.as_deref(), ¶ms.type_) + .await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + albums = cache_albums_images(albums, cover_cache).await; + } + + Ok(Json(albums)) +} + +#[cfg(feature = "pmoserver")] +async fn get_featured_playlists( + State(state): State, + Query(params): Query, +) -> Result>, AppError> { + let playlists = state + .client + .get_featured_playlists(params.genre_id.as_deref(), params.tags.as_deref()) + .await?; + Ok(Json(playlists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_cache_stats( + State(state): State, +) -> Result, AppError> { + let stats = state.client.cache().stats().await; + Ok(Json(stats)) +} + +// ============ Helpers pour le cache d'images ============ + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +async fn cache_album_image(mut album: Album, cover_cache: &Arc) -> Album { + if let Some(ref image_url) = album.image { + match cover_cache.add_from_url(image_url, None).await { + Ok(pk) => { + album.image_cached = Some(format!("/covers/images/{}", pk)); + } + Err(e) => { + tracing::warn!("Failed to cache album image: {}", e); + } + } + } + album +} + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +async fn cache_albums_images( + albums: Vec, + cover_cache: &Arc, +) -> Vec { + let mut cached_albums = Vec::with_capacity(albums.len()); + for album in albums { + cached_albums.push(cache_album_image(album, cover_cache).await); + } + cached_albums +} + +// ============ Gestion des erreurs ============ + +#[cfg(feature = "pmoserver")] +struct AppError(QobuzError); + +#[cfg(feature = "pmoserver")] +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match self.0 { + QobuzError::Unauthorized(_) => (StatusCode::UNAUTHORIZED, self.0.to_string()), + QobuzError::NotFound(_) => (StatusCode::NOT_FOUND, self.0.to_string()), + QobuzError::RateLimitExceeded => (StatusCode::TOO_MANY_REQUESTS, self.0.to_string()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()), + }; + + let body = Json(serde_json::json!({ + "error": message + })); + + (status, body).into_response() + } +} + +#[cfg(feature = "pmoserver")] +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} +========= End of pmoqobuz/src/api_rest.rs =========== + +=============== pmoqobuz/src/config_ext.rs ============ +//! Extension pour intégrer la configuration Qobuz dans pmoconfig +//! +//! Ce module fournit le trait `QobuzConfigExt` qui permet d'ajouter facilement +//! des méthodes de gestion des credentials Qobuz à pmoconfig::Config. + +use anyhow::{anyhow, Result}; +use pmoconfig::Config; +use serde_yaml::Value; + +/// Trait d'extension pour gérer la configuration Qobuz dans pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques +/// aux credentials et paramètres Qobuz. +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmoqobuz::QobuzConfigExt; +/// +/// let config = get_config(); +/// let (username, password) = config.get_qobuz_credentials()?; +/// println!("Qobuz user: {}", username); +/// ``` +pub trait QobuzConfigExt { + /// Récupère le nom d'utilisateur Qobuz depuis la configuration + /// + /// # Returns + /// + /// Le nom d'utilisateur (email) configuré pour Qobuz + /// + /// # Errors + /// + /// Retourne une erreur si le nom d'utilisateur n'est pas configuré + fn get_qobuz_username(&self) -> Result; + + /// Définit le nom d'utilisateur Qobuz dans la configuration + /// + /// # Arguments + /// + /// * `username` - Le nom d'utilisateur (email) Qobuz + fn set_qobuz_username(&self, username: &str) -> Result<()>; + + /// Récupère le mot de passe Qobuz depuis la configuration + /// + /// # Returns + /// + /// Le mot de passe configuré pour Qobuz + /// + /// # Errors + /// + /// Retourne une erreur si le mot de passe n'est pas configuré + fn get_qobuz_password(&self) -> Result; + + /// Définit le mot de passe Qobuz dans la configuration + /// + /// # Arguments + /// + /// * `password` - Le mot de passe Qobuz + fn set_qobuz_password(&self, password: &str) -> Result<()>; + + /// Récupère les credentials Qobuz (username et password) + /// + /// # Returns + /// + /// Un tuple (username, password) contenant les credentials Qobuz + /// + /// # Errors + /// + /// Retourne une erreur si l'un des credentials n'est pas configuré + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmoconfig::get_config; + /// use pmoqobuz::QobuzConfigExt; + /// + /// let config = get_config(); + /// match config.get_qobuz_credentials() { + /// Ok((username, password)) => { + /// println!("Credentials configured for: {}", username); + /// } + /// Err(e) => { + /// eprintln!("Qobuz credentials not configured: {}", e); + /// } + /// } + /// ``` + fn get_qobuz_credentials(&self) -> Result<(String, String)>; + + /// Récupère l'App ID Qobuz depuis la configuration + /// + /// # Returns + /// + /// L'App ID configuré pour Qobuz, ou None si non configuré + /// + /// # Note + /// + /// Si aucun App ID n'est configuré, le client utilisera soit le Spoofer + /// pour en obtenir un dynamiquement, soit un App ID par défaut. + fn get_qobuz_appid(&self) -> Result>; + + /// Définit l'App ID Qobuz dans la configuration + /// + /// # Arguments + /// + /// * `appid` - L'App ID Qobuz (ex: "1401488693436528") + fn set_qobuz_appid(&self, appid: &str) -> Result<()>; + + /// Récupère le secret Qobuz depuis la configuration + /// + /// # Returns + /// + /// Le secret encodé en base64, ou None si non configuré + /// + /// # Note + /// + /// Le secret est la valeur `configvalue` du code Python. + /// Il est décodé et XORé avec l'App ID pour obtenir le secret `s4` + /// utilisé pour signer les requêtes sensibles. + /// + /// Si aucun secret n'est configuré, le client utilisera le Spoofer + /// pour en obtenir un dynamiquement. + fn get_qobuz_secret(&self) -> Result>; + + /// Définit le secret Qobuz dans la configuration + /// + /// # Arguments + /// + /// * `secret` - Le secret encodé en base64 (configvalue) + fn set_qobuz_secret(&self, secret: &str) -> Result<()>; + + /// Récupère le token d'authentification depuis la configuration + /// + /// # Returns + /// + /// Le token d'authentification, ou None si non configuré ou expiré + fn get_qobuz_auth_token(&self) -> Result>; + + /// Récupère l'ID utilisateur depuis la configuration + /// + /// # Returns + /// + /// L'ID utilisateur, ou None si non configuré + fn get_qobuz_user_id(&self) -> Result>; + + /// Récupère le timestamp d'expiration du token + /// + /// # Returns + /// + /// Le timestamp d'expiration (Unix timestamp), ou None si non configuré + fn get_qobuz_token_expires_at(&self) -> Result>; + + /// Récupère le label de l'abonnement depuis la configuration + fn get_qobuz_subscription_label(&self) -> Result>; + + /// Sauvegarde les informations d'authentification dans la configuration + /// + /// # Arguments + /// + /// * `token` - Le token d'authentification + /// * `user_id` - L'ID utilisateur + /// * `subscription_label` - Le label de l'abonnement (optionnel) + /// * `expires_at` - Timestamp d'expiration (Unix timestamp) + fn set_qobuz_auth_info( + &self, + token: &str, + user_id: &str, + subscription_label: Option<&str>, + expires_at: u64, + ) -> Result<()>; + + /// Supprime les informations d'authentification de la configuration + fn clear_qobuz_auth_info(&self) -> Result<()>; + + /// Vérifie si le token d'authentification est encore valide + /// + /// # Returns + /// + /// true si un token existe et n'est pas expiré, false sinon + fn is_qobuz_auth_valid(&self) -> bool; + + /// Récupère le répertoire de cache Qobuz + /// + /// # Returns + /// + /// Le chemin absolu du répertoire de cache, créé s'il n'existe pas + fn get_qobuz_cache_dir(&self) -> Result; + + /// Définit le répertoire de cache Qobuz + fn set_qobuz_cache_dir(&self, directory: String) -> Result<()>; + + /// Récupère le nombre maximum de requêtes concurrentes + /// + /// # Returns + /// + /// Le nombre maximum de requêtes concurrentes, ou None si non configuré (défaut: 2) + fn get_qobuz_rate_limit_max_concurrent(&self) -> Result>; + + /// Définit le nombre maximum de requêtes concurrentes + fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()>; + + /// Récupère le délai minimum entre requêtes en millisecondes + /// + /// # Returns + /// + /// Le délai minimum en ms, ou None si non configuré (défaut: 400ms) + fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result>; + + /// Définit le délai minimum entre requêtes + fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()>; + + /// Vérifie si le rate limiting est activé + /// + /// # Returns + /// + /// true si activé (défaut), false sinon + fn is_qobuz_rate_limiting_enabled(&self) -> bool; + + /// Active ou désactive le rate limiting + fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()>; +} + +impl QobuzConfigExt for Config { + fn get_qobuz_username(&self) -> Result { + match self.get_value(&["accounts", "qobuz", "username"])? { + Value::String(s) => Ok(s), + _ => Err(anyhow!("Qobuz username not configured")), + } + } + + fn set_qobuz_username(&self, username: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "username"], + Value::String(username.to_string()), + ) + } + + fn get_qobuz_password(&self) -> Result { + match self.get_value(&["accounts", "qobuz", "password"])? { + Value::String(s) => { + // Déchiffrement automatique si le mot de passe est chiffré + pmoconfig::encryption::get_password(&s) + .map_err(|e| anyhow!("Failed to decrypt password: {}", e)) + } + _ => Err(anyhow!("Qobuz password not configured")), + } + } + + fn set_qobuz_password(&self, password: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "password"], + Value::String(password.to_string()), + ) + } + + fn get_qobuz_credentials(&self) -> Result<(String, String)> { + let username = self.get_qobuz_username()?; + let password = self.get_qobuz_password()?; + Ok((username, password)) + } + + fn get_qobuz_appid(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "appid"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn set_qobuz_appid(&self, appid: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "appid"], + Value::String(appid.to_string()), + ) + } + + fn get_qobuz_secret(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "secret"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn set_qobuz_secret(&self, secret: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "secret"], + Value::String(secret.to_string()), + ) + } + + fn get_qobuz_auth_token(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "auth_token"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn get_qobuz_user_id(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "user_id"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn get_qobuz_token_expires_at(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "token_expires_at"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap())), + Ok(Value::Number(n)) if n.is_i64() => Ok(Some(n.as_i64().unwrap() as u64)), + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn get_qobuz_subscription_label(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "subscription_label"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + Ok(Value::String(_)) => Ok(None), // Empty string + Ok(_) => Ok(None), // Wrong type + Err(_) => Ok(None), // Not configured + } + } + + fn set_qobuz_auth_info( + &self, + token: &str, + user_id: &str, + subscription_label: Option<&str>, + expires_at: u64, + ) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "auth_token"], + Value::String(token.to_string()), + )?; + self.set_value( + &["accounts", "qobuz", "user_id"], + Value::String(user_id.to_string()), + )?; + self.set_value( + &["accounts", "qobuz", "token_expires_at"], + Value::Number(serde_yaml::Number::from(expires_at)), + )?; + + if let Some(label) = subscription_label { + self.set_value( + &["accounts", "qobuz", "subscription_label"], + Value::String(label.to_string()), + )?; + } + + Ok(()) + } + + fn clear_qobuz_auth_info(&self) -> Result<()> { + // On ne propage pas les erreurs car les valeurs peuvent ne pas exister + let _ = self.set_value(&["accounts", "qobuz", "auth_token"], Value::String(String::new())); + let _ = self.set_value(&["accounts", "qobuz", "user_id"], Value::String(String::new())); + let _ = self.set_value( + &["accounts", "qobuz", "token_expires_at"], + Value::Number(serde_yaml::Number::from(0)), + ); + let _ = self.set_value( + &["accounts", "qobuz", "subscription_label"], + Value::String(String::new()), + ); + Ok(()) + } + + fn is_qobuz_auth_valid(&self) -> bool { + // Vérifier si un token existe + if self.get_qobuz_auth_token().ok().flatten().is_none() { + return false; + } + + // Vérifier si le token n'est pas expiré + if let Ok(Some(expires_at)) = self.get_qobuz_token_expires_at() { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + now < expires_at + } else { + false + } + } + + fn get_qobuz_cache_dir(&self) -> Result { + self.get_managed_dir(&["host", "qobuz_cache", "directory"], "cache_qobuz") + } + + fn set_qobuz_cache_dir(&self, directory: String) -> Result<()> { + self.set_managed_dir(&["host", "qobuz_cache", "directory"], directory) + } + + fn get_qobuz_rate_limit_max_concurrent(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "rate_limit", "max_concurrent"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap() as usize)), + Ok(_) => Ok(None), + Err(_) => Ok(Some(2)), // Default: 2 concurrent requests + } + } + + fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "rate_limit", "max_concurrent"], + Value::Number(serde_yaml::Number::from(max)), + ) + } + + fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "rate_limit", "min_delay_ms"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap())), + Ok(_) => Ok(None), + Err(_) => Ok(Some(400)), // Default: 400ms + } + } + + fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "rate_limit", "min_delay_ms"], + Value::Number(serde_yaml::Number::from(delay_ms)), + ) + } + + fn is_qobuz_rate_limiting_enabled(&self) -> bool { + match self.get_value(&["accounts", "qobuz", "rate_limit", "enabled"]) { + Ok(Value::Bool(b)) => b, + _ => true, // Default: enabled + } + } + + fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "rate_limit", "enabled"], + Value::Bool(enabled), + ) + } +} +========= End of pmoqobuz/src/config_ext.rs =========== + +=============== pmoqobuz/src/pmoserver_impl.rs ============ +//! Implémentation du trait QobuzServerExt pour pmoserver::Server +//! +//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités du client Qobuz en +//! implémentant le trait [`QobuzServerExt`](crate::QobuzServerExt). Cette implémentation +//! permet d'initialiser facilement le client Qobuz et d'enregistrer les routes HTTP. +//! +//! ## Architecture +//! +//! `pmoqobuz` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmoqobuz`. +//! C'est le pattern d'extension : `pmoqobuz` ajoute des fonctionnalités à un type +//! externe via un trait, similaire au pattern utilisé par `pmocovers` pour `CoverCacheExt`. +//! +//! ## Exemple d'utilisation +//! +//! ```rust,no_run +//! use pmoqobuz::QobuzServerExt; +//! use pmoserver::ServerBuilder; +//! +//! # async fn example() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new_configured().build(); +//! +//! // Le trait QobuzServerExt est automatiquement disponible +//! let client = server.init_qobuz_client_configured().await?; +//! +//! server.start().await; +//! # Ok(()) +//! # } +//! ``` + +use crate::api_rest::{create_router, QobuzState}; +use crate::client::QobuzClient; +use crate::pmoserver_ext::QobuzServerExt; +use anyhow::Result; +use pmoconfig::Config; +use pmoserver::Server; +use std::sync::Arc; +use tracing::info; + +impl QobuzServerExt for Server { + async fn init_qobuz_client( + &mut self, + username: &str, + password: &str, + ) -> Result> { + info!("Initializing Qobuz client for user: {}", username); + + // Créer le client Qobuz + let client = QobuzClient::new(username, password).await?; + let client = Arc::new(client); + + // Créer l'état de l'API sans cache d'images + let state = QobuzState { + client: client.clone(), + #[cfg(feature = "covers")] + cover_cache: None, + }; + + // Créer le router et l'enregistrer + let router = create_router(state); + self.add_router("/qobuz", router).await; + + info!("Qobuz client initialized successfully"); + info!("API endpoints available at /qobuz/*"); + + Ok(client) + } + + async fn init_qobuz_client_configured(&mut self) -> Result> { + info!("Initializing Qobuz client from configuration"); + + // Récupérer les credentials depuis la config + let config = pmoconfig::get_config(); + let (username, password) = config.get_qobuz_credentials()?; + + self.init_qobuz_client(&username, &password).await + } + + #[cfg(feature = "covers")] + async fn init_qobuz_client_with_covers( + &mut self, + username: &str, + password: &str, + cover_cache: Arc, + ) -> Result> { + info!("Initializing Qobuz client with pmocovers integration"); + + // Créer le client Qobuz + let client = QobuzClient::new(username, password).await?; + let client = Arc::new(client); + + info!("pmocovers integration enabled - album images will be cached automatically"); + + // Créer l'état de l'API avec le cache + let state = QobuzState { + client: client.clone(), + cover_cache: Some(cover_cache), + }; + + // Créer le router et l'enregistrer + let router = create_router(state); + self.add_router("/qobuz", router).await; + + info!("Qobuz client initialized successfully with covers"); + info!("API endpoints available at /qobuz/*"); + + Ok(client) + } + + #[cfg(feature = "covers")] + async fn init_qobuz_client_configured_with_covers( + &mut self, + cover_cache: Arc, + ) -> Result> { + info!("Initializing Qobuz client from configuration with pmocovers"); + + // Récupérer les credentials depuis la config + let config = pmoconfig::get_config(); + let (username, password) = config.get_qobuz_credentials()?; + + self.init_qobuz_client_with_covers(&username, &password, cover_cache) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trait_implemented() { + // Ce test vérifie simplement que le trait est bien implémenté + // Les tests fonctionnels nécessiteraient un serveur et des credentials réels + } +} +========= End of pmoqobuz/src/pmoserver_impl.rs =========== + +=============== pmoqobuz/src/api/spoofer.rs ============ +use anyhow::Result; +use base64::{engine::general_purpose::STANDARD, Engine}; +use indexmap::IndexMap; +use regex::Regex; +use reqwest::Client; + +pub struct Spoofer { + bundle: String, + seed_timezone_regex: Regex, + info_extras_regex_template: String, + app_id_regex: Regex, +} + +impl Spoofer { + /// Crée un nouveau Spoofer et télécharge le bundle.js + pub async fn new() -> Result { + // Expressions régulières (équivalent Python) + let seed_timezone_regex = Regex::new( + r#"[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)"#, + )?; + + let info_extras_regex_template = + r#"name:"\w+/(?P{timezones})",info:"(?P[\w=]+)",extras:"(?P[\w=]+)""# + .to_string(); + + let app_id_regex = Regex::new( + r#"production:\{api:\{appId:"(?P\d{9})",appSecret:"(?P\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#, + )?; + + // Créer un client HTTP + let client = Client::builder() + .user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)") + .build()?; + + println!("Récupération de la page de login..."); + let login_page = client + .get("https://play.qobuz.com/login") + .send() + .await? + .text() + .await?; + + // Extraire l'URL du bundle + let bundle_url_regex = + Regex::new(r#""#)?; + let bundle_url = bundle_url_regex + .captures(&login_page) + .and_then(|cap| cap.get(1)) + .ok_or_else(|| anyhow::anyhow!("Impossible de trouver l'URL du bundle"))? + .as_str(); + + println!("Téléchargement du bundle depuis: {}", bundle_url); + let bundle_full_url = format!("https://play.qobuz.com{}", bundle_url); + let bundle = client.get(&bundle_full_url).send().await?.text().await?; + + println!("Bundle téléchargé ({} bytes)", bundle.len()); + + Ok(Self { + bundle, + seed_timezone_regex, + info_extras_regex_template, + app_id_regex, + }) + } + + /// Extrait l'App ID depuis le bundle + pub fn get_app_id(&self) -> Result { + let captures = self + .app_id_regex + .captures(&self.bundle) + .ok_or_else(|| anyhow::anyhow!("AppID non trouvé dans le bundle"))?; + + Ok(captures + .name("app_id") + .ok_or_else(|| anyhow::anyhow!("Groupe app_id non trouvé"))? + .as_str() + .to_string()) + } + + /// Extrait les secrets depuis le bundle + pub fn get_secrets(&self) -> Result> { + // Étape 1: Extraire tous les seed/timezone pairs + let mut secrets: IndexMap> = IndexMap::new(); + + for captures in self.seed_timezone_regex.captures_iter(&self.bundle) { + let seed = captures + .name("seed") + .ok_or_else(|| anyhow::anyhow!("Groupe seed non trouvé"))? + .as_str(); + let timezone = captures + .name("timezone") + .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? + .as_str(); + + secrets + .entry(timezone.to_string()) + .or_insert_with(Vec::new) + .push(seed.to_string()); + } + + println!("Timezones trouvées: {:?}", secrets.keys()); + + // Étape 2: Réordonner - on met la deuxième timezone en premier + // (comme le fait le code Python avec move_to_end) + if secrets.len() >= 2 { + let keys: Vec = secrets.keys().cloned().collect(); + let second_key = keys[1].clone(); + let second_value = secrets.get(&second_key).unwrap().clone(); + + // Retirer et réinsérer pour le mettre en premier + secrets.shift_remove(&second_key); + let mut new_secrets = IndexMap::new(); + new_secrets.insert(second_key, second_value); + for (k, v) in secrets { + new_secrets.insert(k, v); + } + secrets = new_secrets; + } + + // Étape 3: Construire la regex pour info/extras + let timezones_capitalized: Vec = secrets + .keys() + .map(|tz| { + let mut chars = tz.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect(); + + let info_extras_regex_str = self + .info_extras_regex_template + .replace("{timezones}", &timezones_capitalized.join("|")); + + let info_extras_regex = Regex::new(&info_extras_regex_str)?; + + // Étape 4: Extraire info et extras pour chaque timezone + for captures in info_extras_regex.captures_iter(&self.bundle) { + let timezone_cap = captures + .name("timezone") + .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? + .as_str(); + let info = captures + .name("info") + .ok_or_else(|| anyhow::anyhow!("Groupe info non trouvé"))? + .as_str(); + let extras = captures + .name("extras") + .ok_or_else(|| anyhow::anyhow!("Groupe extras non trouvé"))? + .as_str(); + + let timezone_lower = timezone_cap.to_lowercase(); + if let Some(vec) = secrets.get_mut(&timezone_lower) { + vec.push(info.to_string()); + vec.push(extras.to_string()); + } + } + + // Étape 5: Décoder les secrets en base64 + let mut decoded_secrets = IndexMap::new(); + for (timezone, parts) in secrets { + let concatenated = parts.join(""); + + // Retirer les 44 derniers caractères (comme Python [:-44]) + if concatenated.len() > 44 { + let trimmed = &concatenated[..concatenated.len() - 44]; + + // Décoder en base64 + match STANDARD.decode(trimmed) { + Ok(decoded_bytes) => { + match String::from_utf8(decoded_bytes) { + Ok(decoded_str) => { + decoded_secrets.insert(timezone, decoded_str); + } + Err(e) => { + eprintln!( + "Erreur UTF-8 pour timezone {}: {}", + timezone, e + ); + } + } + } + Err(e) => { + eprintln!( + "Erreur de décodage base64 pour timezone {}: {}", + timezone, e + ); + } + } + } + } + + Ok(decoded_secrets) + } +} +========= End of pmoqobuz/src/api/spoofer.rs =========== + +=============== pmoqobuz/src/api/auth.rs ============ +//! Module d'authentification pour l'API Qobuz + +use super::QobuzApi; +use crate::error::{QobuzError, Result}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, info}; + +/// Réponse de l'endpoint /user/login +#[derive(Debug, Deserialize)] +struct LoginResponse { + user: UserInfo, + user_auth_token: String, +} + +/// Informations utilisateur retournées par l'API +#[derive(Debug, Deserialize)] +struct UserInfo { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + #[serde(default)] + email: Option, + #[serde(default)] + firstname: Option, + #[serde(default)] + lastname: Option, + credential: CredentialInfo, +} + +/// Informations sur les credentials de l'utilisateur +#[derive(Debug, Deserialize)] +struct CredentialInfo { + #[serde(default)] + parameters: Option, +} + +/// Paramètres du niveau d'abonnement +#[derive(Debug, Deserialize)] +struct CredentialParameters { + #[serde(default)] + short_label: Option, +} + +/// Informations d'authentification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthInfo { + /// Token d'authentification + pub token: String, + /// ID utilisateur + pub user_id: String, + /// Label de l'abonnement (ex: "Studio", "Hi-Fi", etc.) + pub subscription_label: Option, +} + +impl QobuzApi { + /// Authentifie l'utilisateur avec username et password + /// + /// # Arguments + /// + /// * `username` - Email ou nom d'utilisateur Qobuz + /// * `password` - Mot de passe + /// + /// # Returns + /// + /// Retourne les informations d'authentification si le login est réussi + /// + /// # Errors + /// + /// * `QobuzError::Unauthorized` - Credentials invalides + /// * `QobuzError::SubscriptionRequired` - Compte gratuit (non éligible) + pub async fn login(&mut self, username: &str, password: &str) -> Result { + info!("Attempting to login to Qobuz as {}", username); + + let params = [("username", username), ("password", password)]; + + let response: LoginResponse = self.post("/user/login", ¶ms).await?; + + // Vérifier que l'utilisateur a un abonnement valide + if response.user.credential.parameters.is_none() { + return Err(QobuzError::SubscriptionRequired( + "Free accounts are not eligible for streaming".to_string(), + )); + } + + let user_id = response.user.id; + let subscription_label = response + .user + .credential + .parameters + .and_then(|p| p.short_label); + + debug!( + "Login successful - User ID: {}, Subscription: {:?}", + user_id, subscription_label + ); + + // Stocker les informations d'authentification + self.set_auth_token(response.user_auth_token.clone(), user_id.clone()); + + Ok(AuthInfo { + token: response.user_auth_token, + user_id, + subscription_label, + }) + } + + /// Vérifie si le client est authentifié + pub fn is_authenticated(&self) -> bool { + self.user_auth_token.is_some() && self.user_id.is_some() + } + + /// Déconnecte l'utilisateur + pub fn logout(&mut self) { + debug!("Logging out"); + self.user_auth_token = None; + self.user_id = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_authenticated() { + let mut api = QobuzApi::new("test_app_id").unwrap(); + assert!(!api.is_authenticated()); + + api.set_auth_token("token".to_string(), "user123".to_string()); + assert!(api.is_authenticated()); + + api.logout(); + assert!(!api.is_authenticated()); + } +} +========= End of pmoqobuz/src/api/auth.rs =========== + +=============== pmoqobuz/src/api/signing.rs ============ +//! Module de signature MD5 pour les requêtes Qobuz +//! +//! Certaines requêtes Qobuz (notamment track/getFileUrl et userLibrary/*) +//! nécessitent une signature MD5 incluant le secret s4. + +use md5::{Digest, Md5}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Génère un timestamp Unix actuel +/// +/// # Returns +/// +/// Timestamp Unix sous forme de string avec décimales +/// +/// # Exemple +/// +/// ``` +/// use pmoqobuz::api::signing::get_timestamp; +/// let ts = get_timestamp(); +/// println!("Timestamp: {}", ts); +/// ``` +pub fn get_timestamp() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs_f64() + .to_string() +} + +/// Signe une requête track/getFileUrl +/// +/// Reproduit la logique Python: +/// ```python +/// stringvalue = ("trackgetFileUrlformat_id" + fmt_id + +/// "intent" + intent + +/// "track_id" + track_id + ts) +/// stringvalue += self.s4 +/// rq_sig = str(hashlib.md5(stringvalue).hexdigest()) +/// ``` +/// +/// # Arguments +/// +/// * `format_id` - ID du format audio (ex: "27") +/// * `intent` - Intention (typiquement "stream") +/// * `track_id` - ID de la track +/// * `timestamp` - Timestamp Unix +/// * `secret` - Secret s4 en bytes +/// +/// # Returns +/// +/// Signature MD5 hexadécimale +pub fn sign_track_get_file_url( + format_id: &str, + intent: &str, + track_id: &str, + timestamp: &str, + secret: &[u8], +) -> String { + let mut hasher = Md5::new(); + + // Construction de la chaîne à hasher + hasher.update(b"trackgetFileUrlformat_id"); + hasher.update(format_id.as_bytes()); + hasher.update(b"intent"); + hasher.update(intent.as_bytes()); + hasher.update(b"track_id"); + hasher.update(track_id.as_bytes()); + hasher.update(timestamp.as_bytes()); + hasher.update(secret); + + // Retourner le hash hexadécimal + format!("{:x}", hasher.finalize()) +} + +/// Signe une requête userLibrary/getAlbumsList +/// +/// Reproduit la logique Python: +/// ```python +/// r_sig = "userLibrarygetAlbumsList" + str(ts) + str(ka["sec"]) +/// r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest() +/// ``` +/// +/// # Arguments +/// +/// * `timestamp` - Timestamp Unix +/// * `secret` - Secret s4 en bytes +/// +/// # Returns +/// +/// Signature MD5 hexadécimale +pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String { + let mut hasher = Md5::new(); + + // Construction de la chaîne à hasher + hasher.update(b"userLibrarygetAlbumsList"); + hasher.update(timestamp.as_bytes()); + hasher.update(secret); + + // Retourner le hash hexadécimal + format!("{:x}", hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_timestamp() { + let ts = get_timestamp(); + // Vérifier que c'est un nombre valide + assert!(ts.parse::().is_ok()); + // Vérifier que c'est proche du temps actuel (>= 2024) + assert!(ts.parse::().unwrap() > 1704067200.0); // 1er janvier 2024 + } + + #[test] + fn test_sign_track_get_file_url() { + let signature = sign_track_get_file_url( + "27", + "stream", + "12345", + "1234567890.123", + b"test_secret", + ); + + // Vérifier que c'est un hash MD5 valide (32 caractères hex) + assert_eq!(signature.len(), 32); + assert!(signature.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_sign_userlib_get_albums() { + let signature = sign_userlib_get_albums("1234567890.123", b"test_secret"); + + // Vérifier que c'est un hash MD5 valide (32 caractères hex) + assert_eq!(signature.len(), 32); + assert!(signature.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_signature_consistency() { + // La même entrée doit produire la même signature + let sig1 = sign_track_get_file_url("27", "stream", "123", "100", b"secret"); + let sig2 = sign_track_get_file_url("27", "stream", "123", "100", b"secret"); + assert_eq!(sig1, sig2); + + // Des entrées différentes doivent produire des signatures différentes + let sig3 = sign_track_get_file_url("6", "stream", "123", "100", b"secret"); + assert_ne!(sig1, sig3); + } +} +========= End of pmoqobuz/src/api/signing.rs =========== + +=============== pmoqobuz/src/api/catalog.rs ============ +//! Module d'accès au catalogue Qobuz (albums, tracks, artistes, playlists) + +use super::QobuzApi; +use crate::error::{QobuzError, Result}; +use crate::models::*; +use serde::Deserialize; +use tracing::debug; + +/// Réponse paginée de l'API +#[derive(Debug, Deserialize)] +struct PaginatedResponse { + items: Vec, + #[serde(default)] + total: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + offset: Option, +} + +/// Réponse de l'endpoint /album/get +#[derive(Debug, Deserialize)] +pub(crate) struct AlbumResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + title: String, + artist: ArtistResponse, + #[serde(default)] + tracks_count: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + release_date_original: Option, + #[serde(default)] + image: Option, + #[serde(default = "default_streamable")] + streamable: bool, + #[serde(default)] + description: Option, + #[serde(default)] + maximum_sampling_rate: Option, + #[serde(default)] + maximum_bit_depth: Option, + #[serde(default)] + genre: Option, + #[serde(default)] + label: Option, + #[serde(default)] + tracks: Option>, +} + +/// Réponse de l'endpoint /track/get +#[derive(Debug, Deserialize)] +pub(crate) struct TrackResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + title: String, + #[serde(default)] + performer: Option, + #[serde(default)] + artist: Option, + #[serde(default)] + album: Option, + duration: u32, + track_number: u32, + media_number: u32, + #[serde(default = "default_streamable")] + streamable: bool, +} + +/// Réponse artiste +#[derive(Debug, Deserialize)] +pub(crate) struct ArtistResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + name: String, + #[serde(default)] + image: Option, + #[serde(default)] + albums: Option>, +} + +/// Réponse image +#[derive(Debug, Deserialize)] +struct ImageResponse { + #[serde(default)] + large: Option, +} + +/// Réponse genre +#[derive(Debug, Deserialize)] +struct GenreResponse { + #[serde(default)] + id: Option, + name: String, +} + +/// Réponse label +#[derive(Debug, Deserialize)] +struct LabelResponse { + name: String, +} + +/// Réponse playlist +#[derive(Debug, Deserialize)] +pub(crate) struct PlaylistResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + tracks_count: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + images300: Option>, + #[serde(default)] + is_public: bool, + #[serde(default)] + owner: Option, + #[serde(default)] + tracks: Option>, +} + +/// Réponse propriétaire +#[derive(Debug, Deserialize)] +struct OwnerResponse { + #[serde(deserialize_with = "crate::models::deserialize_id")] + id: String, + name: String, +} + +/// Réponse genres list +#[derive(Debug, Deserialize)] +struct GenresResponse { + genres: PaginatedResponse, +} + +/// Réponse albums featured +#[derive(Debug, Deserialize)] +struct FeaturedAlbumsResponse { + albums: PaginatedResponse, +} + +/// Réponse playlists featured +#[derive(Debug, Deserialize)] +struct FeaturedPlaylistsResponse { + playlists: PaginatedResponse, +} + +/// Réponse search +#[derive(Debug, Deserialize)] +struct SearchResponse { + #[serde(default)] + albums: Option>, + #[serde(default)] + artists: Option>, + #[serde(default)] + tracks: Option>, + #[serde(default)] + playlists: Option>, +} + +/// Réponse track file URL +#[derive(Debug, Deserialize)] +struct FileUrlResponse { + url: String, + mime_type: String, + sampling_rate: u32, + bit_depth: u32, + format_id: u8, +} + +fn default_streamable() -> bool { + true +} + +impl QobuzApi { + /// Récupère les détails d'un album + pub async fn get_album(&self, album_id: &str) -> Result { + debug!("Fetching album {}", album_id); + let params = [("album_id", album_id)]; + let response: AlbumResponse = self.get("/album/get", ¶ms).await?; + Ok(Self::parse_album(response)) + } + + /// Récupère les tracks d'un album + pub async fn get_album_tracks(&self, album_id: &str) -> Result> { + debug!("Fetching tracks for album {}", album_id); + let params = [("album_id", album_id)]; + let mut response: AlbumResponse = self.get("/album/get", ¶ms).await?; + + if let Some(tracks) = response.tracks.take() { + let album = Self::parse_album(response); + Ok(tracks + .items + .into_iter() + .map(|t| Self::parse_track(t, Some(album.clone()))) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les détails d'une track + pub async fn get_track(&self, track_id: &str) -> Result { + debug!("Fetching track {}", track_id); + let params = [("track_id", track_id)]; + let response: TrackResponse = self.get("/track/get", ¶ms).await?; + Ok(Self::parse_track(response, None)) + } + + /// Récupère l'URL de streaming d'une track + /// + /// Cette méthode nécessite un secret s4 pour signer la requête. + /// Si aucun secret n'est configuré, retourne une erreur. + /// + /// # Errors + /// + /// Retourne `QobuzError::Configuration` si le secret n'est pas configuré. + pub async fn get_file_url(&self, track_id: &str) -> Result { + use super::signing; + + debug!("Fetching file URL for track {}", track_id); + + // Vérifier que le secret est disponible + let secret = self + .secret().await + .ok_or_else(|| { + QobuzError::Configuration( + "Secret not configured. Cannot sign track/getFileUrl request.".to_string(), + ) + })?; + + let format_id = self.format_id.id().to_string(); + let intent = "stream"; + let timestamp = signing::get_timestamp(); + + // Signer la requête (comme Python: track_getFileUrl) + let signature = signing::sign_track_get_file_url( + &format_id, + intent, + track_id, + ×tamp, + &secret, + ); + + debug!( + "Signing track/getFileUrl: track_id={}, format_id={}, ts={}", + track_id, format_id, timestamp + ); + + // Construire les paramètres signés + let params = [ + ("track_id", track_id), + ("format_id", format_id.as_str()), + ("intent", intent), + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ]; + + // Utiliser GET (comme Python après sept 2024 selon le commentaire) + let response: FileUrlResponse = self.get("/track/getFileUrl", ¶ms).await?; + + Ok(StreamInfo { + url: response.url, + mime_type: response.mime_type, + sampling_rate: response.sampling_rate, + bit_depth: response.bit_depth, + format_id: response.format_id, + expires_at: chrono::Utc::now() + chrono::Duration::minutes(5), + }) + } + + /// Récupère les albums d'un artiste + pub async fn get_artist_albums(&self, artist_id: &str) -> Result> { + debug!("Fetching albums for artist {}", artist_id); + let params = [("artist_id", artist_id), ("extra", "albums")]; + let response: ArtistResponse = self.get("/artist/get", ¶ms).await?; + + if let Some(albums) = response.albums { + Ok(albums + .items + .into_iter() + .map(Self::parse_album) + .filter(|a| a.streamable) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les artistes similaires + pub async fn get_similar_artists(&self, artist_id: &str) -> Result> { + debug!("Fetching similar artists for {}", artist_id); + let params = [("artist_id", artist_id)]; + + #[derive(Debug, Deserialize)] + struct SimilarArtistsResponse { + artists: PaginatedResponse, + } + + let response: SimilarArtistsResponse = + self.get("/artist/getSimilarArtists", ¶ms).await?; + Ok(response + .artists + .items + .into_iter() + .map(Self::parse_artist) + .collect()) + } + + /// Récupère les détails d'une playlist + pub async fn get_playlist(&self, playlist_id: &str) -> Result { + debug!("Fetching playlist {}", playlist_id); + let params = [("playlist_id", playlist_id)]; + let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?; + Ok(Self::parse_playlist(response)) + } + + /// Récupère les tracks d'une playlist + pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result> { + debug!("Fetching tracks for playlist {}", playlist_id); + let params = [("playlist_id", playlist_id), ("extra", "tracks")]; + let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?; + + if let Some(tracks) = response.tracks { + Ok(tracks + .items + .into_iter() + .map(|t| Self::parse_track(t, None)) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère la liste des genres + pub async fn get_genres(&self) -> Result> { + debug!("Fetching genres"); + let response: GenresResponse = self.get("/genre/list", &[]).await?; + Ok(response + .genres + .items + .into_iter() + .map(Self::parse_genre) + .collect()) + } + + /// Récupère les albums featured (nouveautés, éditeur, etc.) + pub async fn get_featured_albums( + &self, + genre_id: Option<&str>, + type_: &str, + ) -> Result> { + debug!("Fetching featured albums (type: {})", type_); + let mut params = vec![("type", type_), ("limit", "100")]; + + if let Some(gid) = genre_id { + params.push(("genre_ids", gid)); + } + + let response: FeaturedAlbumsResponse = self.get("/album/getFeatured", ¶ms).await?; + Ok(response + .albums + .items + .into_iter() + .map(Self::parse_album) + .filter(|a| a.streamable) + .collect()) + } + + /// Récupère les playlists featured + pub async fn get_featured_playlists( + &self, + genre_id: Option<&str>, + tags: Option<&str>, + ) -> Result> { + debug!("Fetching featured playlists"); + let mut params = vec![("type", "editor-picks"), ("limit", "100")]; + + if let Some(gid) = genre_id { + params.push(("genre_ids", gid)); + } + if let Some(t) = tags { + params.push(("tags", t)); + } + + let response: FeaturedPlaylistsResponse = + self.get("/playlist/getFeatured", ¶ms).await?; + Ok(response + .playlists + .items + .into_iter() + .map(Self::parse_playlist) + .collect()) + } + + /// Recherche dans le catalogue + pub async fn search(&self, query: &str, type_: Option<&str>) -> Result { + debug!("Searching for '{}' (type: {:?})", query, type_); + let mut params = vec![("query", query), ("limit", "200")]; + + if let Some(t) = type_ { + params.push(("type", t)); + } + + let response: SearchResponse = self.get("/catalog/search", ¶ms).await?; + + Ok(SearchResult { + albums: response + .albums + .map(|a| { + a.items + .into_iter() + .map(Self::parse_album) + .filter(|album| album.streamable) + .collect() + }) + .unwrap_or_default(), + artists: response + .artists + .map(|a| a.items.into_iter().map(Self::parse_artist).collect()) + .unwrap_or_default(), + tracks: response + .tracks + .map(|t| { + t.items + .into_iter() + .map(|track| Self::parse_track(track, None)) + .filter(|track| track.streamable) + .collect() + }) + .unwrap_or_default(), + playlists: response + .playlists + .map(|p| p.items.into_iter().map(Self::parse_playlist).collect()) + .unwrap_or_default(), + }) + } + + // Fonctions de parsing publiques (utilisées aussi par le module user) + + pub(crate) fn parse_album(response: AlbumResponse) -> Album { + Album { + id: response.id, + title: response.title, + artist: Self::parse_artist(response.artist), + tracks_count: response.tracks_count, + duration: response.duration, + release_date: response.release_date_original, + image: response.image.and_then(|i| i.large), + image_cached: None, + streamable: response.streamable, + description: response.description, + maximum_sampling_rate: response.maximum_sampling_rate, + maximum_bit_depth: response.maximum_bit_depth, + genres: response.genre.map(|g| vec![g.name]).unwrap_or_default(), + label: response.label.map(|l| l.name), + } + } + + pub(crate) fn parse_track(response: TrackResponse, album: Option) -> Track { + let performer = response + .performer + .or(response.artist) + .map(Self::parse_artist); + + let album = album.or_else(|| response.album.map(Self::parse_album)); + + Track { + id: response.id, + title: response.title, + performer, + album, + duration: response.duration, + track_number: response.track_number, + media_number: response.media_number, + streamable: response.streamable, + mime_type: None, + sample_rate: None, + bit_depth: None, + channels: None, + } + } + + pub(crate) fn parse_artist(response: ArtistResponse) -> Artist { + Artist { + id: response.id, + name: response.name, + image: response.image.and_then(|i| i.large), + image_cached: None, + } + } + + pub(crate) fn parse_playlist(response: PlaylistResponse) -> Playlist { + Playlist { + id: response.id, + name: response.name, + description: response.description, + tracks_count: response.tracks_count, + duration: response.duration, + image: response.images300.and_then(|imgs| imgs.first().cloned()), + image_cached: None, + is_public: response.is_public, + owner: response.owner.map(|o| PlaylistOwner { + id: o.id.parse().unwrap_or(0), + name: o.name, + }), + } + } + + pub(crate) fn parse_genre(response: GenreResponse) -> Genre { + Genre { + id: response.id, + name: response.name, + children: Vec::new(), + } + } +} +========= End of pmoqobuz/src/api/catalog.rs =========== + +=============== pmoqobuz/src/api/user.rs ============ +//! Module d'accès aux données utilisateur (favoris) + +use super::catalog::{AlbumResponse, ArtistResponse, PlaylistResponse, TrackResponse}; +use super::QobuzApi; +use crate::error::{QobuzError, Result}; +use crate::models::*; +use serde::Deserialize; +use tracing::debug; + +/// Réponse paginée +#[derive(Debug, Deserialize)] +struct PaginatedResponse { + items: Vec, +} + +/// Réponse de l'endpoint /favorite/getUserFavorites +#[derive(Debug, Deserialize)] +struct FavoritesResponse { + #[serde(default)] + albums: Option>, + #[serde(default)] + artists: Option>, + #[serde(default)] + tracks: Option>, +} + +/// Réponse de l'endpoint /playlist/getUserPlaylists +#[derive(Debug, Deserialize)] +struct UserPlaylistsResponse { + playlists: PaginatedResponse, +} + +impl QobuzApi { + /// Vérifie que l'utilisateur est authentifié + fn ensure_authenticated(&self) -> Result<&str> { + self.user_id + .as_deref() + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string())) + } + + /// Récupère les albums favoris de l'utilisateur + pub async fn get_favorite_albums(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching favorite albums for user {}", user_id); + + let params = [("user_id", user_id), ("type", "albums"), ("limit", "1000")]; + + let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; + + if let Some(albums) = response.albums { + Ok(albums + .items + .into_iter() + .map(QobuzApi::parse_album) + .filter(|a| a.streamable) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les artistes favoris de l'utilisateur + pub async fn get_favorite_artists(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching favorite artists for user {}", user_id); + + let params = [("user_id", user_id), ("type", "artists"), ("limit", "1000")]; + + let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; + + if let Some(artists) = response.artists { + Ok(artists + .items + .into_iter() + .map(QobuzApi::parse_artist) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les tracks favorites de l'utilisateur + pub async fn get_favorite_tracks(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching favorite tracks for user {}", user_id); + + let params = [("user_id", user_id), ("type", "tracks"), ("limit", "1000")]; + + let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; + + if let Some(tracks) = response.tracks { + Ok(tracks + .items + .into_iter() + .map(|t| QobuzApi::parse_track(t, None)) + .filter(|t| t.streamable) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les playlists de l'utilisateur + pub async fn get_user_playlists(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching playlists for user {}", user_id); + + let params = [("user_id", user_id), ("limit", "1000")]; + + let response: UserPlaylistsResponse = + self.get("/playlist/getUserPlaylists", ¶ms).await?; + + Ok(response + .playlists + .items + .into_iter() + .map(QobuzApi::parse_playlist) + .collect()) + } + + /// Ajoute un album aux favoris + pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Adding album {} to favorites for user {}", + album_id, user_id + ); + + let params = [("album_id", album_id), ("user_id", user_id)]; + + self.get::("/favorite/create", ¶ms) + .await?; + Ok(()) + } + + /// Supprime un album des favoris + pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Removing album {} from favorites for user {}", + album_id, user_id + ); + + let params = [("album_ids", album_id), ("user_id", user_id)]; + + self.get::("/favorite/delete", ¶ms) + .await?; + Ok(()) + } + + /// Ajoute un track aux favoris + pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Adding track {} to favorites for user {}", + track_id, user_id + ); + + let params = [("track_id", track_id), ("user_id", user_id)]; + + self.get::("/favorite/create", ¶ms) + .await?; + Ok(()) + } + + /// Supprime un track des favoris + pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Removing track {} from favorites for user {}", + track_id, user_id + ); + + let params = [("track_ids", track_id), ("user_id", user_id)]; + + self.get::("/favorite/delete", ¶ms) + .await?; + Ok(()) + } + + /// Ajoute un track à une playlist + pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!( + "Adding track {} to playlist {} for user {}", + track_id, playlist_id, user_id + ); + + let params = [("playlist_id", playlist_id), ("track_ids", track_id)]; + + self.get::("/playlist/addTracks", ¶ms) + .await?; + Ok(()) + } + + /// Récupère la liste des albums de la bibliothèque utilisateur + /// + /// Cette méthode nécessite un secret s4 pour signer la requête. + /// Elle est principalement utilisée pour tester la validité d'un secret. + /// + /// Dans le code Python, cette méthode est utilisée par `setSec()` pour + /// tester chaque secret retourné par le Spoofer. + /// + /// # Errors + /// + /// Retourne `QobuzError::Configuration` si le secret n'est pas configuré. + /// Retourne `QobuzError::Unauthorized` si l'utilisateur n'est pas authentifié. + pub async fn userlib_get_albums(&self) -> Result { + use super::signing; + + // Vérifier l'authentification + self.ensure_authenticated()?; + + // Vérifier que le secret est disponible + let secret = self + .secret().await + .ok_or_else(|| { + QobuzError::Configuration( + "Secret not configured. Cannot sign userLibrary/getAlbumsList request." + .to_string(), + ) + })?; + + let timestamp = signing::get_timestamp(); + + // Signer la requête (comme Python: userlib_getAlbums) + let signature = signing::sign_userlib_get_albums(×tamp, &secret); + + debug!( + "Signing userLibrary/getAlbumsList: app_id={}, ts={}", + self.app_id(), + timestamp + ); + + // Construire les paramètres signés + let user_auth_token = self + .auth_token() + .ok_or_else(|| QobuzError::Unauthorized("No auth token".to_string()))?; + + let params = [ + ("app_id", self.app_id()), + ("user_auth_token", user_auth_token), + ("request_ts", timestamp.as_str()), + ("request_sig", signature.as_str()), + ]; + + // Utiliser POST (comme Python) + self.post("/userLibrary/getAlbumsList", ¶ms).await + } + + /// Teste si un secret est valide en essayant de récupérer les albums + /// + /// Cette méthode est équivalente au test fait dans `setSec()` en Python. + /// Elle retourne `true` si le secret fonctionne, `false` sinon. + pub async fn test_secret(&self, _secret: &[u8]) -> bool { + // Sauvegarder le secret actuel + let _current_secret = self.secret().await; + + // Définir temporairement le nouveau secret + // Note: cette méthode nécessite &mut self, donc on doit la rendre mutable + // Pour l'instant, on ne peut pas modifier self dans cette méthode + // TODO: Refactoriser pour permettre de tester les secrets + + // Restaurer le secret original + false + } +} +========= End of pmoqobuz/src/api/user.rs =========== + +=============== pmoqobuz/src/api/mod.rs ============ +//! Couche d'accès à l'API REST Qobuz +//! +//! Ce module fournit une interface bas-niveau pour communiquer avec l'API Qobuz. + +pub mod auth; +pub mod catalog; +pub mod signing; +pub mod spoofer; +pub mod user; + +use crate::error::{QobuzError, Result}; +use crate::models::AudioFormat; +use reqwest::{Client, Response}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tracing::{debug, warn}; + +pub use spoofer::Spoofer; + +/// URL de base de l'API Qobuz +const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2"; + +/// App ID Qobuz par défaut +/// +/// Cet App ID est un fallback au cas où : +/// - Aucun appID n'est configuré dans pmoconfig +/// - Le Spoofer n'est pas disponible ou échoue +/// +/// Note: Cet App ID peut devenir obsolète avec le temps. +/// Il est recommandé d'utiliser soit la configuration manuelle, +/// soit le Spoofer pour obtenir un App ID à jour. +pub const DEFAULT_APP_ID: &str = "1401488693436528"; + +/// Client API bas-niveau pour communiquer avec Qobuz +pub struct QobuzApi { + /// Client HTTP + client: Client, + /// App ID pour l'authentification + app_id: String, + /// Secret s4 pour signer les requêtes sensibles (track/getFileUrl, userLibrary/*) + /// + /// Ce secret est obtenu soit : + /// - En décodant un `configvalue` (base64) et XOR avec l'app_id + /// - Depuis le Spoofer (secrets dynamiques) + /// + /// Utilise Arc pour permettre le refresh automatique en cas d'erreur de signature + secret: Arc>>>, + /// Token d'authentification utilisateur + user_auth_token: Option, + /// ID utilisateur + user_id: Option, + /// Format audio par défaut + format_id: AudioFormat, + /// Rate limiter: Semaphore for max concurrent requests + rate_limiter: Option>, + /// Last request timestamp (for minimum delay) + last_request: Arc>, + /// Minimum delay between requests (milliseconds) + min_delay_ms: u64, +} + +impl QobuzApi { + /// Crée une nouvelle instance de l'API + pub fn new(app_id: impl Into) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:67.0) Gecko/20100101 Firefox/67.0", + ) + .build()?; + + Ok(Self { + client, + app_id: app_id.into(), + secret: Arc::new(tokio::sync::RwLock::new(None)), + user_auth_token: None, + user_id: None, + format_id: AudioFormat::default(), + rate_limiter: None, + last_request: Arc::new(tokio::sync::Mutex::new(std::time::Instant::now())), + min_delay_ms: 0, + }) + } + + /// Crée une API avec un secret depuis configvalue (base64) + /// + /// # Arguments + /// + /// * `app_id` - App ID Qobuz + /// * `configvalue` - Secret encodé en base64 (à XORer avec l'app_id) + /// + /// # Note + /// + /// Cette méthode reproduit le comportement Python de `__set_s4()`. + /// Le configvalue est décodé depuis base64, puis XORé avec l'app_id + /// pour obtenir le secret s4. + pub async fn with_secret(app_id: impl Into, configvalue: &str) -> Result { + let api = Self::new(app_id)?; + api.set_secret_from_configvalue(configvalue).await?; + Ok(api) + } + + /// Définit le secret s4 directement + /// + /// # Arguments + /// + /// * `secret` - Secret s4 en bytes (déjà décodé et dérivé) + pub async fn set_secret(&self, secret: Vec) { + *self.secret.write().await = Some(secret); + } + + /// Dérive et définit le secret s4 depuis un configvalue + /// + /// Reproduit la logique Python de `__set_s4()`: + /// 1. Décode le configvalue depuis base64 + /// 2. XOR avec l'app_id + /// 3. Stocke le résultat comme secret s4 + async fn set_secret_from_configvalue(&self, configvalue: &str) -> Result<()> { + use base64::{engine::general_purpose::STANDARD, Engine}; + + // Décoder le configvalue depuis base64 + let s3s = STANDARD + .decode(configvalue.trim()) + .map_err(|e| QobuzError::Configuration(format!("Invalid configvalue: {}", e)))?; + + // XOR avec l'app_id + let app_id_bytes = self.app_id.as_bytes(); + let mut s4 = Vec::with_capacity(s3s.len()); + + for (i, &byte) in s3s.iter().enumerate() { + let app_byte = app_id_bytes[i % app_id_bytes.len()]; + s4.push(byte ^ app_byte); + } + + *self.secret.write().await = Some(s4); + Ok(()) + } + + /// Retourne le secret s4 si disponible + pub async fn secret(&self) -> Option> { + self.secret.read().await.clone() + } + + /// Définit le token d'authentification + pub fn set_auth_token(&mut self, token: String, user_id: String) { + self.user_auth_token = Some(token); + self.user_id = Some(user_id); + } + + /// Définit le format audio par défaut + pub fn set_format(&mut self, format: AudioFormat) { + self.format_id = format; + } + + /// Retourne le format audio configuré + pub fn format(&self) -> AudioFormat { + self.format_id + } + + /// Retourne l'App ID + pub fn app_id(&self) -> &str { + &self.app_id + } + + /// Retourne le token d'authentification si disponible + pub fn auth_token(&self) -> Option<&str> { + self.user_auth_token.as_deref() + } + + /// Retourne l'ID utilisateur si disponible + pub fn user_id(&self) -> Option<&str> { + self.user_id.as_deref() + } + + /// Enable rate limiting with configurable parameters + /// + /// # Arguments + /// + /// * `max_concurrent` - Maximum number of concurrent requests + /// * `min_delay_ms` - Minimum delay between requests in milliseconds + /// + /// # Example + /// + /// ```ignore + /// api.enable_rate_limiting(2, 400); // Max 2 concurrent, 400ms delay + /// ``` + pub fn enable_rate_limiting(&mut self, max_concurrent: usize, min_delay_ms: u64) { + self.rate_limiter = Some(Arc::new(tokio::sync::Semaphore::new(max_concurrent))); + self.min_delay_ms = min_delay_ms; + debug!( + "Rate limiting enabled: {} concurrent requests, {}ms min delay", + max_concurrent, min_delay_ms + ); + } + + /// Check if rate limiting is enabled + pub fn is_rate_limiting_enabled(&self) -> bool { + self.rate_limiter.is_some() + } + + /// Effectue une requête GET à l'API + pub(crate) async fn get( + &self, + endpoint: &str, + params: &[(&str, &str)], + ) -> Result { + self.request("GET", endpoint, params).await + } + + /// Effectue une requête POST à l'API + pub(crate) async fn post( + &self, + endpoint: &str, + params: &[(&str, &str)], + ) -> Result { + self.request("POST", endpoint, params).await + } + + /// Effectue une requête à l'API (générique) + async fn request( + &self, + method: &str, + endpoint: &str, + params: &[(&str, &str)], + ) -> Result { + let url = format!("{}{}", API_BASE_URL, endpoint); + + // ===== RATE LIMITING LOGIC ===== + let _permit = if let Some(ref limiter) = self.rate_limiter { + // 1. Acquire semaphore permit (blocks if max concurrent reached) + let permit = limiter + .acquire() + .await + .map_err(|e| QobuzError::Other(format!("Rate limiter error: {}", e)))?; + + // 2. Enforce minimum delay + if self.min_delay_ms > 0 { + let mut last = self.last_request.lock().await; + let elapsed = last.elapsed(); + let min_delay = Duration::from_millis(self.min_delay_ms); + + if elapsed < min_delay { + let wait_time = min_delay - elapsed; + debug!("Rate limiting: waiting {:?} before request", wait_time); + tokio::time::sleep(wait_time).await; + } + + *last = Instant::now(); + } + + Some(permit) // Keep permit alive until request completes + } else { + None + }; + // ===== END RATE LIMITING ===== + + debug!("{} {} with {} params", method, url, params.len()); + + let mut request = if method == "GET" { + self.client.get(&url) + } else { + self.client.post(&url) + }; + + // Ajouter les headers + request = request.header("X-App-Id", &self.app_id); + + if let Some(ref token) = self.user_auth_token { + request = request.header("X-User-Auth-Token", token); + } + + // Ajouter les paramètres + if method == "GET" { + request = request.query(params); + } else { + request = request.form(params); + } + + // Envoyer la requête + let response = request.send().await?; + self.handle_response(response).await + } + + /// Traite la réponse HTTP + async fn handle_response(&self, response: Response) -> Result { + let status = response.status(); + let status_code = status.as_u16(); + + debug!("Response status: {}", status); + + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + warn!("API error ({}): {}", status_code, error_text); + return Err(QobuzError::from_status_code(status_code, error_text)); + } + + let text = response.text().await?; + + // Vérifier si la réponse contient une erreur Qobuz + if let Ok(json) = serde_json::from_str::(&text) { + if let Some(status_obj) = json.get("status") { + if status_obj == "error" { + let message = json + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + warn!("Qobuz API error: {}", message); + return Err(QobuzError::ApiError { + code: status_code, + message: message.to_string(), + }); + } + } + } + + // Parser la réponse + serde_json::from_str(&text).map_err(|e| { + warn!("Failed to parse response: {}", e); + QobuzError::JsonParse(e) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_creation() { + let api = QobuzApi::new("test_app_id").unwrap(); + assert_eq!(api.app_id(), "test_app_id"); + assert!(api.auth_token().is_none()); + } + + #[test] + fn test_set_auth_token() { + let mut api = QobuzApi::new("test_app_id").unwrap(); + api.set_auth_token("test_token".to_string(), "user123".to_string()); + assert_eq!(api.auth_token(), Some("test_token")); + assert_eq!(api.user_id(), Some("user123")); + } + + #[test] + fn test_set_format() { + let mut api = QobuzApi::new("test_app_id").unwrap(); + api.set_format(AudioFormat::Flac_HiRes_96); + assert_eq!(api.format(), AudioFormat::Flac_HiRes_96); + } +} +========= End of pmoqobuz/src/api/mod.rs =========== + +=============== pmoqobuz/src/pmoserver_ext.rs ============ +//! Extension de pmoserver::Server pour intégrer le client Qobuz +//! +//! Ce module fournit un trait d'extension permettant d'ajouter facilement +//! le client Qobuz et ses endpoints à un serveur pmoserver. + +use crate::client::QobuzClient; +use anyhow::Result; +use std::sync::Arc; + +/// Trait d'extension pour ajouter le support Qobuz à un serveur pmoserver +/// +/// Ce trait permet à `pmoqobuz` d'ajouter des méthodes d'extension sur +/// `pmoserver::Server` sans que pmoserver dépende de pmoqobuz. +/// +/// # Architecture +/// +/// Similaire au pattern utilisé par `pmocovers` avec `CoverCacheExt`, ce trait permet +/// une extension propre et découplée : +/// +/// - `pmoserver` définit un serveur HTTP générique +/// - `pmoqobuz` étend ce serveur avec des fonctionnalités Qobuz via ce trait +/// - Le serveur n'a pas besoin de connaître `pmoqobuz` +/// +/// # Exemple +/// +/// ```rust,no_run +/// use pmoqobuz::QobuzServerExt; +/// use pmoserver::ServerBuilder; +/// +/// #[tokio::main] +/// async fn main() -> anyhow::Result<()> { +/// let mut server = ServerBuilder::new_configured().build(); +/// +/// // Initialise le client Qobuz depuis la config +/// server.init_qobuz_client_configured().await?; +/// +/// server.start().await; +/// server.wait().await; +/// Ok(()) +/// } +/// ``` +pub trait QobuzServerExt { + /// Initialise le client Qobuz et enregistre les routes HTTP + /// + /// # Arguments + /// + /// * `username` - Email ou nom d'utilisateur Qobuz + /// * `password` - Mot de passe + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du client + /// + /// # Routes enregistrées + /// + /// - `GET /qobuz/albums/{id}` - Détails d'un album + /// - `GET /qobuz/albums/{id}/tracks` - Tracks d'un album + /// - `GET /qobuz/tracks/{id}` - Détails d'une track + /// - `GET /qobuz/tracks/{id}/stream` - URL de streaming + /// - `GET /qobuz/artists/{id}` - Détails d'un artiste + /// - `GET /qobuz/artists/{id}/albums` - Albums d'un artiste + /// - `GET /qobuz/playlists/{id}` - Détails d'une playlist + /// - `GET /qobuz/playlists/{id}/tracks` - Tracks d'une playlist + /// - `GET /qobuz/search` - Recherche (query params: q, type) + /// - `GET /qobuz/favorites/albums` - Albums favoris + /// - `GET /qobuz/favorites/artists` - Artistes favoris + /// - `GET /qobuz/favorites/tracks` - Tracks favoris + /// - `GET /qobuz/favorites/playlists` - Playlists utilisateur + /// - `GET /qobuz/genres` - Liste des genres + /// - `GET /qobuz/featured/albums` - Albums featured + /// - `GET /qobuz/featured/playlists` - Playlists featured + /// - `GET /qobuz/cache/stats` - Statistiques du cache + /// - `GET /swagger-ui` - Documentation interactive + async fn init_qobuz_client( + &mut self, + username: &str, + password: &str, + ) -> Result>; + + /// Initialise le client Qobuz avec la configuration par défaut + /// + /// Utilise automatiquement les credentials de `pmoconfig::Config` : + /// - `accounts.qobuz.username` pour le nom d'utilisateur + /// - `accounts.qobuz.password` pour le mot de passe + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du client + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzServerExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // Utilise automatiquement la config + /// server.init_qobuz_client_configured().await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + async fn init_qobuz_client_configured(&mut self) -> Result>; + + /// Initialise le client Qobuz avec intégration pmocovers + /// + /// Les images d'albums seront automatiquement ajoutées au cache pmocovers fourni. + /// + /// # Arguments + /// + /// * `username` - Email ou nom d'utilisateur Qobuz + /// * `password` - Mot de passe + /// * `cover_cache` - Instance du cache pmocovers à utiliser + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du client avec cache d'images + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzServerExt; + /// use pmocovers::CoverCacheExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // D'abord initialiser le cache d'images + /// let cache = server.init_cover_cache_configured().await?; + /// + /// // Puis initialiser Qobuz avec le cache + /// server.init_qobuz_client_with_covers("user", "pass", cache).await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "covers")] + async fn init_qobuz_client_with_covers( + &mut self, + username: &str, + password: &str, + cover_cache: Arc, + ) -> Result>; + + /// Initialise le client Qobuz avec intégration pmocovers depuis la configuration + /// + /// # Arguments + /// + /// * `cover_cache` - Instance du cache pmocovers à utiliser + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzServerExt; + /// use pmocovers::CoverCacheExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // D'abord initialiser le cache + /// let cache = server.init_cover_cache_configured().await?; + /// + /// // Puis initialiser Qobuz avec le cache + /// server.init_qobuz_client_configured_with_covers(cache).await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "covers")] + async fn init_qobuz_client_configured_with_covers( + &mut self, + cover_cache: Arc, + ) -> Result>; +} + +// L'implémentation du trait sera dans un module séparé (pmoserver_impl.rs) +// pour éviter les dépendances circulaires +========= End of pmoqobuz/src/pmoserver_ext.rs =========== + diff --git a/pmosource/src/cache.rs b/pmosource/src/cache.rs index 852575c0..598adc5e 100755 --- a/pmosource/src/cache.rs +++ b/pmosource/src/cache.rs @@ -218,6 +218,45 @@ impl SourceCacheManager { Ok(pk) } + /// Cache audio with lazy loading (deferred download) + /// + /// Creates a lazy PK for the audio file without downloading it immediately. + /// The actual download will occur when the HTTP endpoint is first requested. + /// + /// # Arguments + /// + /// * `url` - URL source de la piste + /// * `metadata` - Métadonnées audio optionnelles + /// + /// # Returns + /// + /// La clé primaire lazy (pk) commençant par "L:" + pub async fn cache_audio_lazy( + &self, + url: &str, + metadata: Option, + ) -> Result { + // Use pmocache add_from_url_deferred (already implemented) + let lazy_pk = self + .audio_cache + .add_from_url_deferred(url, Some(&self.collection_id)) + .await + .map_err(|e| MusicSourceError::CacheError(e.to_string()))?; + + // Store metadata in lazy_pk metadata table if provided + if let Some(meta) = metadata { + // Serialize metadata to JSON and store + if let Ok(json) = serde_json::to_value(&meta) { + let _ = self + .audio_cache + .db + .set_a_metadata_by_key(&lazy_pk, "audio_metadata", json); + } + } + + Ok(lazy_pk) + } + /// Cache un flux audio via un reader asynchrone pub async fn cache_audio_from_reader( &self,