=============== pmoqobuz/Cargo.toml ============ [package] name = "pmoqobuz" version = "0.1.0" edition = "2021" [dependencies] # 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" # Gestion des erreurs anyhow = "1.0" thiserror = "1.0" # Hashing pour les clés de cache sha1 = "0.10" hex = "0.4" # 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" } [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" # Pour les exemples tracing-subscriber = "0.3" # Specify that the with_cache example requires the cache feature [[example]] name = "with_cache" required-features = ["cache"] ========= End of pmoqobuz/Cargo.toml =========== =============== pmoqobuz/README.md ============ # pmoqobuz - Client Qobuz pour PMOMusic Client Rust pour l'API Qobuz avec cache en mémoire, inspiré de l'implémentation Python d'upmpdcli. ## Fonctionnalités - ✅ **Authentification** : Login avec username/password depuis la configuration - ✅ **Catalogue** : Accès complet au catalogue Qobuz (albums, tracks, artistes, playlists) - ✅ **Recherche** : Recherche dans le catalogue avec filtres - ✅ **Favoris** : Accès aux albums, artistes, tracks et playlists favoris - ✅ **Cache en mémoire** : Minimisation des requêtes API avec TTL configurable - ✅ **Export DIDL** : Conversion automatique en format DIDL-Lite (UPnP/DLNA) - ✅ **Integration pmocovers** : Cache automatique des images (feature `covers`) - ✅ **Integration pmoaudiocache** : Cache audio haute résolution avec métadonnées (feature `cache`) - ✅ **API HTTP** : Endpoints REST via pmoserver (feature `pmoserver`) ## Installation Ajoutez la dépendance dans votre `Cargo.toml` : ```toml [dependencies] pmoqobuz = { path = "../pmoqobuz" } ``` ## Configuration Les credentials Qobuz doivent être configurés dans `.pmomusic.yml` : ```yaml accounts: qobuz: username: "votre@email.com" password: "votre_mot_de_passe" ``` ## Utilisation ### Exemple basique ```rust use pmoqobuz::QobuzClient; #[tokio::main] async fn main() -> anyhow::Result<()> { // Connexion depuis la configuration 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(()) } ``` ### Export DIDL ```rust use pmoqobuz::{QobuzClient, ToDIDL}; #[tokio::main] async fn main() -> anyhow::Result<()> { let client = QobuzClient::from_config().await?; let album = client.get_album("album_id").await?; let didl_container = album.to_didl_container("parent_id")?; let tracks = client.get_album_tracks(&album.id).await?; for track in tracks { let didl_item = track.to_didl_item(&didl_container.id)?; println!("{}", didl_item.title); } Ok(()) } ``` ### Favoris ```rust use pmoqobuz::QobuzClient; #[tokio::main] async fn main() -> anyhow::Result<()> { let client = QobuzClient::from_config().await?; // Albums favoris let albums = client.get_favorite_albums().await?; println!("{} albums favoris", albums.len()); // Artistes favoris let artists = client.get_favorite_artists().await?; // Tracks favorites let tracks = client.get_favorite_tracks().await?; // Playlists de l'utilisateur let playlists = client.get_user_playlists().await?; Ok(()) } ``` ## Formats audio Qobuz propose plusieurs formats : | Format | Description | Format ID | |--------|-------------|-----------| | `Mp3_320` | MP3 320 kbps | 5 | | `Flac_Lossless` | FLAC 16 bit / 44.1 kHz | 6 (défaut) | | `Flac_HiRes_96` | FLAC 24 bit / jusqu'à 96 kHz | 7 | | `Flac_HiRes_192` | FLAC 24 bit / jusqu'à 192 kHz | 27 | ```rust use pmoqobuz::{QobuzClient, AudioFormat}; let mut client = QobuzClient::from_config().await?; client.set_format(AudioFormat::Flac_HiRes_96); ``` ## Cache Le cache en mémoire utilise `moka` avec TTL : - **Albums** : 1 heure - **Tracks** : 1 heure - **Artistes** : 1 heure - **Playlists** : 30 minutes - **Recherches** : 15 minutes - **URLs de streaming** : 5 minutes ```rust // Statistiques du cache let stats = client.cache().stats().await; println!("Albums: {}", stats.albums_count); println!("Total: {}", stats.total_count()); // Vider le cache client.cache().clear_all().await; ``` ## Cache avancé (feature `cache`) La feature `cache` active le support complet de pmocovers et pmoaudiocache pour télécharger et cacher localement les images et l'audio haute résolution : ```rust use pmoqobuz::{QobuzSource, QobuzClient}; use pmocovers::Cache as CoverCache; use pmoaudiocache::AudioCache; use std::sync::Arc; #[tokio::main] async fn main() -> anyhow::Result<()> { // Initialize caches let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); // Create source with caching let client = QobuzClient::from_config().await?; let source = QobuzSource::new_with_cache( client, "http://localhost:8080", Some(cover_cache), Some(audio_cache), ); // Add tracks with automatic caching let tracks = source.client().get_favorite_tracks().await?; for track in tracks.iter().take(5) { let track_id = source.add_track(track).await?; // Audio and cover are now cached locally let uri = source.resolve_uri(&track_id).await?; println!("Cached: {}", uri); } Ok(()) } ``` **Métadonnées enrichies préservées** : - Titre, artiste, album - Numéro de piste et de disque - Année de sortie - Genre(s) et label - Qualité audio (sample rate, bit depth, channels) - Durée ## Exemples Exécutez les exemples : ```bash # Exemple basique cargo run --example basic_usage # Exemple avec cache (nécessite la feature cache) cargo run --example with_cache --features cache ``` ## Architecture ``` pmoqobuz/ ├── src/ │ ├── lib.rs # Module principal │ ├── client.rs # Client haut-niveau │ ├── models.rs # Structures de données │ ├── api/ │ │ ├── mod.rs # API client bas-niveau │ │ ├── auth.rs # Authentification │ │ ├── catalog.rs # Accès catalogue │ │ └── user.rs # API utilisateur │ ├── cache.rs # Cache en mémoire │ ├── didl.rs # Export DIDL-Lite │ └── error.rs # Gestion des erreurs └── examples/ └── basic_usage.rs # Exemple d'utilisation ``` ## Tests ```bash cargo test -p pmoqobuz ``` ## Documentation Générez la documentation : ```bash cargo doc -p pmoqobuz --open ``` ## Features - `covers` : Active pmocovers pour le cache d'images - `cache` : Active pmocovers + pmoaudiocache pour le cache complet (images + audio) - `pmoserver` : Active les endpoints REST via pmoserver ## Dépendances principales - `reqwest` : Client HTTP - `tokio` : Runtime asynchrone - `serde` / `serde_json` : Sérialisation JSON - `moka` : Cache en mémoire avec TTL - `pmodidl` : Export DIDL-Lite - `pmoconfig` : Configuration - `pmocovers` : Cache d'images (optionnel) - `pmoaudiocache` : Cache audio (optionnel) ## Licence Ce code fait partie du projet PMOMusic. ## Références - [API Qobuz Documentation](https://github.com/Qobuz/api-documentation) - [upmpdcli Qobuz Plugin](https://www.lesbonscomptes.com/upmpdcli/) ========= End of pmoqobuz/README.md =========== =============== 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/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/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/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; use crate::cache::QobuzCache; use crate::error::{QobuzError, Result}; use crate::models::*; use pmoconfig::Config; use std::sync::Arc; use tracing::{debug, info}; /// App ID Qobuz par défaut (peut être overridé) const DEFAULT_APP_ID: &str = "1401488693436528"; /// 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 } /// Crée un client depuis un objet Config spécifique pub async fn from_config_obj(config: &Config) -> Result { let (username, password) = config.get_qobuz_credentials()?; Self::new(&username, &password).await } /// 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_default_app_id() { assert!(!DEFAULT_APP_ID.is_empty()); } #[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 #[error("Configuration error: {0}")] Config(#[from] anyhow::Error), /// 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 pub fn is_auth_error(&self) -> bool { matches!(self, QobuzError::Unauthorized(_)) } /// 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 didl; 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 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/models.rs ============ //! Structures de données pour représenter les objets Qobuz use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; /// Représente un artiste Qobuz #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Artist { /// Identifiant unique de l'artiste 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 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 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 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) } /// 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/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/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 { id: u64, #[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.to_string(); 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/catalog.rs ============ //! Module d'accès au catalogue Qobuz (albums, tracks, artistes, playlists) use super::QobuzApi; use crate::error::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 { 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 { 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 { id: u64, 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 { id: u64, 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 { id: u64, 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 pub async fn get_file_url(&self, track_id: &str) -> Result { debug!("Fetching file URL for track {}", track_id); let format_id = self.format_id.id().to_string(); let params = [ ("track_id", track_id), ("format_id", &format_id), ("intent", "stream"), ]; 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.to_string(), 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.to_string(), 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, 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(()) } } ========= 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 user; use crate::error::{QobuzError, Result}; use crate::models::AudioFormat; use reqwest::{Client, Response}; use serde::de::DeserializeOwned; use serde_json::Value; use std::time::Duration; use tracing::{debug, warn}; /// URL de base de l'API Qobuz const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2"; /// Client API bas-niveau pour communiquer avec Qobuz pub struct QobuzApi { /// Client HTTP client: Client, /// App ID pour l'authentification app_id: String, /// Token d'authentification utilisateur user_auth_token: Option, /// ID utilisateur user_id: Option, /// Format audio par défaut format_id: AudioFormat, } 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(), user_auth_token: None, user_id: None, format_id: AudioFormat::default(), }) } /// 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() } /// 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); 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 ===========