Files
pmomusic/pmoqobuz_026.txt

4890 lines
150 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
=============== 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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
// 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<MokaCache<String, Album>>,
/// Cache des tracks (TTL: 1 heure)
tracks: Arc<MokaCache<String, Track>>,
/// Cache des artistes (TTL: 1 heure)
artists: Arc<MokaCache<String, Artist>>,
/// Cache des playlists (TTL: 30 minutes)
playlists: Arc<MokaCache<String, Playlist>>,
/// Cache des résultats de recherche (TTL: 15 minutes)
searches: Arc<MokaCache<String, SearchResult>>,
/// Cache des URLs de streaming (TTL: 5 minutes)
stream_urls: Arc<MokaCache<String, StreamInfo>>,
}
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<Album> {
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<Track> {
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<Artist> {
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<Playlist> {
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<SearchResult> {
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<StreamInfo> {
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<QobuzCache>,
/// Informations d'authentification
auth_info: Option<AuthInfo>,
}
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> {
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<Self> {
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<Self> {
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<Self> {
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<QobuzCache> {
self.cache.clone()
}
// ============ Albums ============
/// Récupère un album par son ID
pub async fn get_album(&self, album_id: &str) -> Result<Album> {
// 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<Vec<Track>> {
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<Track> {
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<String> {
// 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<Artist> {
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<Vec<Album>> {
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<Vec<Artist>> {
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<Playlist> {
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<Vec<Track>> {
self.api.get_playlist_tracks(playlist_id).await
}
// ============ Catalogue ============
/// Récupère la liste des genres
pub async fn get_genres(&self) -> Result<Vec<Genre>> {
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<Vec<Album>> {
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<Vec<Playlist>> {
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<SearchResult> {
// 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<Vec<Album>> {
let result = self.search(query, Some("albums")).await?;
Ok(result.albums)
}
/// Recherche des artistes
pub async fn search_artists(&self, query: &str) -> Result<Vec<Artist>> {
let result = self.search(query, Some("artists")).await?;
Ok(result.artists)
}
/// Recherche des tracks
pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>> {
let result = self.search(query, Some("tracks")).await?;
Ok(result.tracks)
}
/// Recherche des playlists
pub async fn search_playlists(&self, query: &str) -> Result<Vec<Playlist>> {
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<Vec<Album>> {
self.api.get_favorite_albums().await
}
/// Récupère les artistes favoris de l'utilisateur
pub async fn get_favorite_artists(&self) -> Result<Vec<Artist>> {
self.api.get_favorite_artists().await
}
/// Récupère les tracks favorites de l'utilisateur
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
self.api.get_favorite_tracks().await
}
/// Récupère les playlists de l'utilisateur
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
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<T> = std::result::Result<T, QobuzError>;
/// 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<String>) -> 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<dyn std::error::Error>> {
//! 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<dyn std::error::Error>> {
//! 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<String>,
/// URL de l'image cachée localement (via pmocovers)
#[serde(skip)]
pub image_cached: Option<String>,
}
/// 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<u32>,
/// Durée totale en secondes
#[serde(default)]
pub duration: Option<u32>,
/// Date de sortie (format ISO 8601)
#[serde(default)]
pub release_date: Option<String>,
/// URL de l'image de couverture
#[serde(default)]
pub image: Option<String>,
/// URL de l'image cachée localement (via pmocovers)
#[serde(skip)]
pub image_cached: Option<String>,
/// 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<String>,
/// Taux d'échantillonnage maximum (Hz)
#[serde(default)]
pub maximum_sampling_rate: Option<f64>,
/// Profondeur de bits maximale
#[serde(default)]
pub maximum_bit_depth: Option<u32>,
/// Genre(s) de l'album
#[serde(default)]
pub genres: Vec<String>,
/// Label de l'album
#[serde(default)]
pub label: Option<String>,
}
/// 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<Artist>,
/// Album contenant la piste
pub album: Option<Album>,
/// 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<String>,
/// Fréquence d'échantillonnage (Hz)
#[serde(skip)]
pub sample_rate: Option<u32>,
/// Profondeur de bits
#[serde(skip)]
pub bit_depth: Option<u32>,
/// Nombre de canaux audio
#[serde(skip)]
pub channels: Option<u8>,
}
/// 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<String>,
/// Nombre de pistes
#[serde(default)]
pub tracks_count: Option<u32>,
/// Durée totale en secondes
#[serde(default)]
pub duration: Option<u32>,
/// URL de l'image de la playlist
#[serde(default)]
pub image: Option<String>,
/// URL de l'image cachée localement
#[serde(skip)]
pub image_cached: Option<String>,
/// Indique si c'est une playlist publique
#[serde(default)]
pub is_public: bool,
/// Propriétaire de la playlist
#[serde(default)]
pub owner: Option<PlaylistOwner>,
}
/// 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<u32>,
/// Nom du genre
pub name: String,
/// Genres enfants
#[serde(default)]
pub children: Vec<Genre>,
}
/// Résultats de recherche
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchResult {
/// Albums trouvés
#[serde(default)]
pub albums: Vec<Album>,
/// Artistes trouvés
#[serde(default)]
pub artists: Vec<Artist>,
/// Pistes trouvées
#[serde(default)]
pub tracks: Vec<Track>,
/// Playlists trouvées
#[serde(default)]
pub playlists: Vec<Playlist>,
}
/// 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<Utc>,
}
/// 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<String>, name: impl Into<String>) -> 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<Container>;
/// Convertit l'objet en Item DIDL
fn to_didl_item(&self, parent_id: &str) -> Result<Item>;
}
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<Container> {
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<Item> {
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<Container> {
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<Item> {
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<Container> {
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<Item> {
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<Vec<Item>> {
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<Vec<Container>> {
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<Vec<Container>> {
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<dyn std::error::Error>> {
/// 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<QobuzSourceInner>,
}
struct QobuzSourceInner {
/// Qobuz API client
client: QobuzClient,
/// Cache manager (centralisé)
cache_manager: SourceCacheManager,
/// Update tracking
update_counter: tokio::sync::RwLock<u32>,
last_change: tokio::sync::RwLock<SystemTime>,
}
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<Self> {
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<CoverCache>,
audio_cache: Arc<AudioCache>,
) -> 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<String> {
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<Container> {
// 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<BrowseResult> {
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<Container> = 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<Item> = 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<Item> = 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<Container> = 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<String> {
// 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<Option<Item>> {
Err(MusicSourceError::FifoNotSupported)
}
async fn update_id(&self) -> u32 {
*self.inner.update_counter.read().await
}
async fn last_change(&self) -> Option<SystemTime> {
Some(*self.inner.last_change.read().await)
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
// 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<Item> = 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<BrowseResult> {
// 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<Container> = results
.albums
.into_iter()
.filter_map(|album| album.to_didl_container("qobuz").ok())
.collect();
let items: Vec<Item> = 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<Vec<pmosource::AudioFormat>> {
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<pmosource::CacheStatus> {
self.inner.cache_manager.get_cache_status(object_id).await
}
async fn cache_item(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
// 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<bool> {
// 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<Vec<Container>> {
let playlists = self
.inner
.client
.get_user_playlists()
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
let containers: Vec<Container> = 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<usize> {
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<BrowseResult> {
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<Item> = 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<Container> = 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<pmosource::SourceStatistics> {
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<QobuzClient>,
#[cfg(feature = "covers")]
pub cover_cache: Option<Arc<pmocovers::Cache>>,
}
/// 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<String>,
}
/// Paramètres pour featured albums
#[cfg(feature = "pmoserver")]
#[derive(Debug, Deserialize)]
pub struct FeaturedAlbumsParams {
/// ID du genre (optionnel)
pub genre_id: Option<String>,
/// 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<String>,
/// Tags (optionnel)
pub tags: Option<String>,
}
/// 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<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Album>, 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<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Vec<Track>>, AppError> {
let tracks = state.client.get_album_tracks(&id).await?;
Ok(Json(tracks))
}
#[cfg(feature = "pmoserver")]
async fn get_track(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Track>, AppError> {
let track = state.client.get_track(&id).await?;
Ok(Json(track))
}
#[cfg(feature = "pmoserver")]
async fn get_stream_url(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, 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<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Vec<Album>>, 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<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Vec<Artist>>, AppError> {
let artists = state.client.get_similar_artists(&id).await?;
Ok(Json(artists))
}
#[cfg(feature = "pmoserver")]
async fn get_playlist(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Playlist>, AppError> {
let playlist = state.client.get_playlist(&id).await?;
Ok(Json(playlist))
}
#[cfg(feature = "pmoserver")]
async fn get_playlist_tracks(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Vec<Track>>, AppError> {
let tracks = state.client.get_playlist_tracks(&id).await?;
Ok(Json(tracks))
}
#[cfg(feature = "pmoserver")]
async fn search(
State(state): State<QobuzState>,
Query(params): Query<SearchParams>,
) -> Result<Json<SearchResult>, AppError> {
let mut result = state
.client
.search(&params.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<QobuzState>,
) -> Result<Json<Vec<Album>>, 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<QobuzState>,
) -> Result<Json<Vec<Artist>>, AppError> {
let artists = state.client.get_favorite_artists().await?;
Ok(Json(artists))
}
#[cfg(feature = "pmoserver")]
async fn get_favorite_tracks(
State(state): State<QobuzState>,
) -> Result<Json<Vec<Track>>, AppError> {
let tracks = state.client.get_favorite_tracks().await?;
Ok(Json(tracks))
}
#[cfg(feature = "pmoserver")]
async fn get_user_playlists(
State(state): State<QobuzState>,
) -> Result<Json<Vec<Playlist>>, AppError> {
let playlists = state.client.get_user_playlists().await?;
Ok(Json(playlists))
}
#[cfg(feature = "pmoserver")]
async fn get_genres(State(state): State<QobuzState>) -> Result<Json<Vec<Genre>>, AppError> {
let genres = state.client.get_genres().await?;
Ok(Json(genres))
}
#[cfg(feature = "pmoserver")]
async fn get_featured_albums(
State(state): State<QobuzState>,
Query(params): Query<FeaturedAlbumsParams>,
) -> Result<Json<Vec<Album>>, AppError> {
let mut albums = state
.client
.get_featured_albums(params.genre_id.as_deref(), &params.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<QobuzState>,
Query(params): Query<FeaturedPlaylistsParams>,
) -> Result<Json<Vec<Playlist>>, 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<QobuzState>,
) -> Result<Json<crate::cache::CacheStats>, 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<pmocovers::Cache>) -> 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<Album>,
cover_cache: &Arc<pmocovers::Cache>,
) -> Vec<Album> {
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<E> From<E> for AppError
where
E: Into<QobuzError>,
{
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<Arc<QobuzClient>> {
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<Arc<QobuzClient>> {
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<pmocovers::Cache>,
) -> Result<Arc<QobuzClient>> {
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<pmocovers::Cache>,
) -> Result<Arc<QobuzClient>> {
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<String>,
#[serde(default)]
firstname: Option<String>,
#[serde(default)]
lastname: Option<String>,
credential: CredentialInfo,
}
/// Informations sur les credentials de l'utilisateur
#[derive(Debug, Deserialize)]
struct CredentialInfo {
#[serde(default)]
parameters: Option<CredentialParameters>,
}
/// Paramètres du niveau d'abonnement
#[derive(Debug, Deserialize)]
struct CredentialParameters {
#[serde(default)]
short_label: Option<String>,
}
/// 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<String>,
}
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<AuthInfo> {
info!("Attempting to login to Qobuz as {}", username);
let params = [("username", username), ("password", password)];
let response: LoginResponse = self.post("/user/login", &params).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<T> {
items: Vec<T>,
#[serde(default)]
total: Option<u32>,
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
offset: Option<u32>,
}
/// 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<u32>,
#[serde(default)]
duration: Option<u32>,
#[serde(default)]
release_date_original: Option<String>,
#[serde(default)]
image: Option<ImageResponse>,
#[serde(default = "default_streamable")]
streamable: bool,
#[serde(default)]
description: Option<String>,
#[serde(default)]
maximum_sampling_rate: Option<f64>,
#[serde(default)]
maximum_bit_depth: Option<u32>,
#[serde(default)]
genre: Option<GenreResponse>,
#[serde(default)]
label: Option<LabelResponse>,
#[serde(default)]
tracks: Option<PaginatedResponse<TrackResponse>>,
}
/// Réponse de l'endpoint /track/get
#[derive(Debug, Deserialize)]
pub(crate) struct TrackResponse {
id: String,
title: String,
#[serde(default)]
performer: Option<ArtistResponse>,
#[serde(default)]
artist: Option<ArtistResponse>,
#[serde(default)]
album: Option<AlbumResponse>,
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<ImageResponse>,
#[serde(default)]
albums: Option<PaginatedResponse<AlbumResponse>>,
}
/// Réponse image
#[derive(Debug, Deserialize)]
struct ImageResponse {
#[serde(default)]
large: Option<String>,
}
/// Réponse genre
#[derive(Debug, Deserialize)]
struct GenreResponse {
#[serde(default)]
id: Option<u32>,
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<String>,
#[serde(default)]
tracks_count: Option<u32>,
#[serde(default)]
duration: Option<u32>,
#[serde(default)]
images300: Option<Vec<String>>,
#[serde(default)]
is_public: bool,
#[serde(default)]
owner: Option<OwnerResponse>,
#[serde(default)]
tracks: Option<PaginatedResponse<TrackResponse>>,
}
/// Réponse propriétaire
#[derive(Debug, Deserialize)]
struct OwnerResponse {
id: u64,
name: String,
}
/// Réponse genres list
#[derive(Debug, Deserialize)]
struct GenresResponse {
genres: PaginatedResponse<GenreResponse>,
}
/// Réponse albums featured
#[derive(Debug, Deserialize)]
struct FeaturedAlbumsResponse {
albums: PaginatedResponse<AlbumResponse>,
}
/// Réponse playlists featured
#[derive(Debug, Deserialize)]
struct FeaturedPlaylistsResponse {
playlists: PaginatedResponse<PlaylistResponse>,
}
/// Réponse search
#[derive(Debug, Deserialize)]
struct SearchResponse {
#[serde(default)]
albums: Option<PaginatedResponse<AlbumResponse>>,
#[serde(default)]
artists: Option<PaginatedResponse<ArtistResponse>>,
#[serde(default)]
tracks: Option<PaginatedResponse<TrackResponse>>,
#[serde(default)]
playlists: Option<PaginatedResponse<PlaylistResponse>>,
}
/// 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<Album> {
debug!("Fetching album {}", album_id);
let params = [("album_id", album_id)];
let response: AlbumResponse = self.get("/album/get", &params).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<Vec<Track>> {
debug!("Fetching tracks for album {}", album_id);
let params = [("album_id", album_id)];
let mut response: AlbumResponse = self.get("/album/get", &params).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<Track> {
debug!("Fetching track {}", track_id);
let params = [("track_id", track_id)];
let response: TrackResponse = self.get("/track/get", &params).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<StreamInfo> {
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", &params).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<Vec<Album>> {
debug!("Fetching albums for artist {}", artist_id);
let params = [("artist_id", artist_id), ("extra", "albums")];
let response: ArtistResponse = self.get("/artist/get", &params).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<Vec<Artist>> {
debug!("Fetching similar artists for {}", artist_id);
let params = [("artist_id", artist_id)];
#[derive(Debug, Deserialize)]
struct SimilarArtistsResponse {
artists: PaginatedResponse<ArtistResponse>,
}
let response: SimilarArtistsResponse =
self.get("/artist/getSimilarArtists", &params).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<Playlist> {
debug!("Fetching playlist {}", playlist_id);
let params = [("playlist_id", playlist_id)];
let response: PlaylistResponse = self.get("/playlist/get", &params).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<Vec<Track>> {
debug!("Fetching tracks for playlist {}", playlist_id);
let params = [("playlist_id", playlist_id), ("extra", "tracks")];
let response: PlaylistResponse = self.get("/playlist/get", &params).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<Vec<Genre>> {
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<Vec<Album>> {
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", &params).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<Vec<Playlist>> {
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", &params).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<SearchResult> {
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", &params).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<Album>) -> 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<T> {
items: Vec<T>,
}
/// Réponse de l'endpoint /favorite/getUserFavorites
#[derive(Debug, Deserialize)]
struct FavoritesResponse {
#[serde(default)]
albums: Option<PaginatedResponse<AlbumResponse>>,
#[serde(default)]
artists: Option<PaginatedResponse<ArtistResponse>>,
#[serde(default)]
tracks: Option<PaginatedResponse<TrackResponse>>,
}
/// Réponse de l'endpoint /playlist/getUserPlaylists
#[derive(Debug, Deserialize)]
struct UserPlaylistsResponse {
playlists: PaginatedResponse<PlaylistResponse>,
}
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<Vec<Album>> {
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", &params).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<Vec<Artist>> {
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", &params).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<Vec<Track>> {
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", &params).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<Vec<Playlist>> {
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", &params).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::<serde_json::Value>("/favorite/create", &params)
.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::<serde_json::Value>("/favorite/delete", &params)
.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::<serde_json::Value>("/favorite/create", &params)
.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::<serde_json::Value>("/favorite/delete", &params)
.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::<serde_json::Value>("/playlist/addTracks", &params)
.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<String>,
/// ID utilisateur
user_id: Option<String>,
/// 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<String>) -> Result<Self> {
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<T: DeserializeOwned>(
&self,
endpoint: &str,
params: &[(&str, &str)],
) -> Result<T> {
self.request("GET", endpoint, params).await
}
/// Effectue une requête POST à l'API
pub(crate) async fn post<T: DeserializeOwned>(
&self,
endpoint: &str,
params: &[(&str, &str)],
) -> Result<T> {
self.request("POST", endpoint, params).await
}
/// Effectue une requête à l'API (générique)
async fn request<T: DeserializeOwned>(
&self,
method: &str,
endpoint: &str,
params: &[(&str, &str)],
) -> Result<T> {
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<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
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::<Value>(&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<QobuzClient>` - 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<Arc<QobuzClient>>;
/// 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<QobuzClient>` - 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<Arc<QobuzClient>>;
/// 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<QobuzClient>` - 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<pmocovers::Cache>,
) -> Result<Arc<QobuzClient>>;
/// 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<pmocovers::Cache>,
) -> Result<Arc<QobuzClient>>;
}
// 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 ===========