4890 lines
150 KiB
Plaintext
4890 lines
150 KiB
Plaintext
|
|
=============== 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(¶ms.q, params.search_type.as_deref())
|
|||
|
|
.await?;
|
|||
|
|
|
|||
|
|
#[cfg(feature = "covers")]
|
|||
|
|
if let Some(ref cover_cache) = state.cover_cache {
|
|||
|
|
result.albums = cache_albums_images(result.albums, cover_cache).await;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Ok(Json(result))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(feature = "pmoserver")]
|
|||
|
|
async fn get_favorite_albums(
|
|||
|
|
State(state): State<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(), ¶ms.type_)
|
|||
|
|
.await?;
|
|||
|
|
|
|||
|
|
#[cfg(feature = "covers")]
|
|||
|
|
if let Some(ref cover_cache) = state.cover_cache {
|
|||
|
|
albums = cache_albums_images(albums, cover_cache).await;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Ok(Json(albums))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(feature = "pmoserver")]
|
|||
|
|
async fn get_featured_playlists(
|
|||
|
|
State(state): State<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", ¶ms).await?;
|
|||
|
|
|
|||
|
|
// Vérifier que l'utilisateur a un abonnement valide
|
|||
|
|
if response.user.credential.parameters.is_none() {
|
|||
|
|
return Err(QobuzError::SubscriptionRequired(
|
|||
|
|
"Free accounts are not eligible for streaming".to_string(),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let user_id = response.user.id.to_string();
|
|||
|
|
let subscription_label = response
|
|||
|
|
.user
|
|||
|
|
.credential
|
|||
|
|
.parameters
|
|||
|
|
.and_then(|p| p.short_label);
|
|||
|
|
|
|||
|
|
debug!(
|
|||
|
|
"Login successful - User ID: {}, Subscription: {:?}",
|
|||
|
|
user_id, subscription_label
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Stocker les informations d'authentification
|
|||
|
|
self.set_auth_token(response.user_auth_token.clone(), user_id.clone());
|
|||
|
|
|
|||
|
|
Ok(AuthInfo {
|
|||
|
|
token: response.user_auth_token,
|
|||
|
|
user_id,
|
|||
|
|
subscription_label,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Vérifie si le client est authentifié
|
|||
|
|
pub fn is_authenticated(&self) -> bool {
|
|||
|
|
self.user_auth_token.is_some() && self.user_id.is_some()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Déconnecte l'utilisateur
|
|||
|
|
pub fn logout(&mut self) {
|
|||
|
|
debug!("Logging out");
|
|||
|
|
self.user_auth_token = None;
|
|||
|
|
self.user_id = None;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn test_is_authenticated() {
|
|||
|
|
let mut api = QobuzApi::new("test_app_id").unwrap();
|
|||
|
|
assert!(!api.is_authenticated());
|
|||
|
|
|
|||
|
|
api.set_auth_token("token".to_string(), "user123".to_string());
|
|||
|
|
assert!(api.is_authenticated());
|
|||
|
|
|
|||
|
|
api.logout();
|
|||
|
|
assert!(!api.is_authenticated());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
========= End of pmoqobuz/src/api/auth.rs ===========
|
|||
|
|
|
|||
|
|
=============== pmoqobuz/src/api/catalog.rs ============
|
|||
|
|
//! Module d'accès au catalogue Qobuz (albums, tracks, artistes, playlists)
|
|||
|
|
|
|||
|
|
use super::QobuzApi;
|
|||
|
|
use crate::error::Result;
|
|||
|
|
use crate::models::*;
|
|||
|
|
use serde::Deserialize;
|
|||
|
|
use tracing::debug;
|
|||
|
|
|
|||
|
|
/// Réponse paginée de l'API
|
|||
|
|
#[derive(Debug, Deserialize)]
|
|||
|
|
struct PaginatedResponse<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", ¶ms).await?;
|
|||
|
|
Ok(Self::parse_album(response))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les tracks d'un album
|
|||
|
|
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<Track>> {
|
|||
|
|
debug!("Fetching tracks for album {}", album_id);
|
|||
|
|
let params = [("album_id", album_id)];
|
|||
|
|
let mut response: AlbumResponse = self.get("/album/get", ¶ms).await?;
|
|||
|
|
|
|||
|
|
if let Some(tracks) = response.tracks.take() {
|
|||
|
|
let album = Self::parse_album(response);
|
|||
|
|
Ok(tracks
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(|t| Self::parse_track(t, Some(album.clone())))
|
|||
|
|
.collect())
|
|||
|
|
} else {
|
|||
|
|
Ok(Vec::new())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les détails d'une track
|
|||
|
|
pub async fn get_track(&self, track_id: &str) -> Result<Track> {
|
|||
|
|
debug!("Fetching track {}", track_id);
|
|||
|
|
let params = [("track_id", track_id)];
|
|||
|
|
let response: TrackResponse = self.get("/track/get", ¶ms).await?;
|
|||
|
|
Ok(Self::parse_track(response, None))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère l'URL de streaming d'une track
|
|||
|
|
pub async fn get_file_url(&self, track_id: &str) -> Result<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", ¶ms).await?;
|
|||
|
|
|
|||
|
|
Ok(StreamInfo {
|
|||
|
|
url: response.url,
|
|||
|
|
mime_type: response.mime_type,
|
|||
|
|
sampling_rate: response.sampling_rate,
|
|||
|
|
bit_depth: response.bit_depth,
|
|||
|
|
format_id: response.format_id,
|
|||
|
|
expires_at: chrono::Utc::now() + chrono::Duration::minutes(5),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les albums d'un artiste
|
|||
|
|
pub async fn get_artist_albums(&self, artist_id: &str) -> Result<Vec<Album>> {
|
|||
|
|
debug!("Fetching albums for artist {}", artist_id);
|
|||
|
|
let params = [("artist_id", artist_id), ("extra", "albums")];
|
|||
|
|
let response: ArtistResponse = self.get("/artist/get", ¶ms).await?;
|
|||
|
|
|
|||
|
|
if let Some(albums) = response.albums {
|
|||
|
|
Ok(albums
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(Self::parse_album)
|
|||
|
|
.filter(|a| a.streamable)
|
|||
|
|
.collect())
|
|||
|
|
} else {
|
|||
|
|
Ok(Vec::new())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les artistes similaires
|
|||
|
|
pub async fn get_similar_artists(&self, artist_id: &str) -> Result<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", ¶ms).await?;
|
|||
|
|
Ok(response
|
|||
|
|
.artists
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(Self::parse_artist)
|
|||
|
|
.collect())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les détails d'une playlist
|
|||
|
|
pub async fn get_playlist(&self, playlist_id: &str) -> Result<Playlist> {
|
|||
|
|
debug!("Fetching playlist {}", playlist_id);
|
|||
|
|
let params = [("playlist_id", playlist_id)];
|
|||
|
|
let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?;
|
|||
|
|
Ok(Self::parse_playlist(response))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les tracks d'une playlist
|
|||
|
|
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
|||
|
|
debug!("Fetching tracks for playlist {}", playlist_id);
|
|||
|
|
let params = [("playlist_id", playlist_id), ("extra", "tracks")];
|
|||
|
|
let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?;
|
|||
|
|
|
|||
|
|
if let Some(tracks) = response.tracks {
|
|||
|
|
Ok(tracks
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(|t| Self::parse_track(t, None))
|
|||
|
|
.collect())
|
|||
|
|
} else {
|
|||
|
|
Ok(Vec::new())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère la liste des genres
|
|||
|
|
pub async fn get_genres(&self) -> Result<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", ¶ms).await?;
|
|||
|
|
Ok(response
|
|||
|
|
.albums
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(Self::parse_album)
|
|||
|
|
.filter(|a| a.streamable)
|
|||
|
|
.collect())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les playlists featured
|
|||
|
|
pub async fn get_featured_playlists(
|
|||
|
|
&self,
|
|||
|
|
genre_id: Option<&str>,
|
|||
|
|
tags: Option<&str>,
|
|||
|
|
) -> Result<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", ¶ms).await?;
|
|||
|
|
Ok(response
|
|||
|
|
.playlists
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(Self::parse_playlist)
|
|||
|
|
.collect())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Recherche dans le catalogue
|
|||
|
|
pub async fn search(&self, query: &str, type_: Option<&str>) -> Result<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", ¶ms).await?;
|
|||
|
|
|
|||
|
|
Ok(SearchResult {
|
|||
|
|
albums: response
|
|||
|
|
.albums
|
|||
|
|
.map(|a| {
|
|||
|
|
a.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(Self::parse_album)
|
|||
|
|
.filter(|album| album.streamable)
|
|||
|
|
.collect()
|
|||
|
|
})
|
|||
|
|
.unwrap_or_default(),
|
|||
|
|
artists: response
|
|||
|
|
.artists
|
|||
|
|
.map(|a| a.items.into_iter().map(Self::parse_artist).collect())
|
|||
|
|
.unwrap_or_default(),
|
|||
|
|
tracks: response
|
|||
|
|
.tracks
|
|||
|
|
.map(|t| {
|
|||
|
|
t.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(|track| Self::parse_track(track, None))
|
|||
|
|
.filter(|track| track.streamable)
|
|||
|
|
.collect()
|
|||
|
|
})
|
|||
|
|
.unwrap_or_default(),
|
|||
|
|
playlists: response
|
|||
|
|
.playlists
|
|||
|
|
.map(|p| p.items.into_iter().map(Self::parse_playlist).collect())
|
|||
|
|
.unwrap_or_default(),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fonctions de parsing publiques (utilisées aussi par le module user)
|
|||
|
|
|
|||
|
|
pub(crate) fn parse_album(response: AlbumResponse) -> Album {
|
|||
|
|
Album {
|
|||
|
|
id: response.id,
|
|||
|
|
title: response.title,
|
|||
|
|
artist: Self::parse_artist(response.artist),
|
|||
|
|
tracks_count: response.tracks_count,
|
|||
|
|
duration: response.duration,
|
|||
|
|
release_date: response.release_date_original,
|
|||
|
|
image: response.image.and_then(|i| i.large),
|
|||
|
|
image_cached: None,
|
|||
|
|
streamable: response.streamable,
|
|||
|
|
description: response.description,
|
|||
|
|
maximum_sampling_rate: response.maximum_sampling_rate,
|
|||
|
|
maximum_bit_depth: response.maximum_bit_depth,
|
|||
|
|
genres: response.genre.map(|g| vec![g.name]).unwrap_or_default(),
|
|||
|
|
label: response.label.map(|l| l.name),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub(crate) fn parse_track(response: TrackResponse, album: Option<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", ¶ms).await?;
|
|||
|
|
|
|||
|
|
if let Some(albums) = response.albums {
|
|||
|
|
Ok(albums
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(QobuzApi::parse_album)
|
|||
|
|
.filter(|a| a.streamable)
|
|||
|
|
.collect())
|
|||
|
|
} else {
|
|||
|
|
Ok(Vec::new())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les artistes favoris de l'utilisateur
|
|||
|
|
pub async fn get_favorite_artists(&self) -> Result<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", ¶ms).await?;
|
|||
|
|
|
|||
|
|
if let Some(artists) = response.artists {
|
|||
|
|
Ok(artists
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(QobuzApi::parse_artist)
|
|||
|
|
.collect())
|
|||
|
|
} else {
|
|||
|
|
Ok(Vec::new())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les tracks favorites de l'utilisateur
|
|||
|
|
pub async fn get_favorite_tracks(&self) -> Result<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", ¶ms).await?;
|
|||
|
|
|
|||
|
|
if let Some(tracks) = response.tracks {
|
|||
|
|
Ok(tracks
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(|t| QobuzApi::parse_track(t, None))
|
|||
|
|
.filter(|t| t.streamable)
|
|||
|
|
.collect())
|
|||
|
|
} else {
|
|||
|
|
Ok(Vec::new())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Récupère les playlists de l'utilisateur
|
|||
|
|
pub async fn get_user_playlists(&self) -> Result<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", ¶ms).await?;
|
|||
|
|
|
|||
|
|
Ok(response
|
|||
|
|
.playlists
|
|||
|
|
.items
|
|||
|
|
.into_iter()
|
|||
|
|
.map(QobuzApi::parse_playlist)
|
|||
|
|
.collect())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Ajoute un album aux favoris
|
|||
|
|
pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> {
|
|||
|
|
let user_id = self.ensure_authenticated()?;
|
|||
|
|
debug!(
|
|||
|
|
"Adding album {} to favorites for user {}",
|
|||
|
|
album_id, user_id
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let params = [("album_id", album_id), ("user_id", user_id)];
|
|||
|
|
|
|||
|
|
self.get::<serde_json::Value>("/favorite/create", ¶ms)
|
|||
|
|
.await?;
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Supprime un album des favoris
|
|||
|
|
pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> {
|
|||
|
|
let user_id = self.ensure_authenticated()?;
|
|||
|
|
debug!(
|
|||
|
|
"Removing album {} from favorites for user {}",
|
|||
|
|
album_id, user_id
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let params = [("album_ids", album_id), ("user_id", user_id)];
|
|||
|
|
|
|||
|
|
self.get::<serde_json::Value>("/favorite/delete", ¶ms)
|
|||
|
|
.await?;
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Ajoute un track aux favoris
|
|||
|
|
pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> {
|
|||
|
|
let user_id = self.ensure_authenticated()?;
|
|||
|
|
debug!(
|
|||
|
|
"Adding track {} to favorites for user {}",
|
|||
|
|
track_id, user_id
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let params = [("track_id", track_id), ("user_id", user_id)];
|
|||
|
|
|
|||
|
|
self.get::<serde_json::Value>("/favorite/create", ¶ms)
|
|||
|
|
.await?;
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Supprime un track des favoris
|
|||
|
|
pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> {
|
|||
|
|
let user_id = self.ensure_authenticated()?;
|
|||
|
|
debug!(
|
|||
|
|
"Removing track {} from favorites for user {}",
|
|||
|
|
track_id, user_id
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let params = [("track_ids", track_id), ("user_id", user_id)];
|
|||
|
|
|
|||
|
|
self.get::<serde_json::Value>("/favorite/delete", ¶ms)
|
|||
|
|
.await?;
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Ajoute un track à une playlist
|
|||
|
|
pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> {
|
|||
|
|
let user_id = self.ensure_authenticated()?;
|
|||
|
|
debug!(
|
|||
|
|
"Adding track {} to playlist {} for user {}",
|
|||
|
|
track_id, playlist_id, user_id
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let params = [("playlist_id", playlist_id), ("track_ids", track_id)];
|
|||
|
|
|
|||
|
|
self.get::<serde_json::Value>("/playlist/addTracks", ¶ms)
|
|||
|
|
.await?;
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
========= End of pmoqobuz/src/api/user.rs ===========
|
|||
|
|
|
|||
|
|
=============== pmoqobuz/src/api/mod.rs ============
|
|||
|
|
//! Couche d'accès à l'API REST Qobuz
|
|||
|
|
//!
|
|||
|
|
//! Ce module fournit une interface bas-niveau pour communiquer avec l'API Qobuz.
|
|||
|
|
|
|||
|
|
pub mod auth;
|
|||
|
|
pub mod catalog;
|
|||
|
|
pub mod user;
|
|||
|
|
|
|||
|
|
use crate::error::{QobuzError, Result};
|
|||
|
|
use crate::models::AudioFormat;
|
|||
|
|
use reqwest::{Client, Response};
|
|||
|
|
use serde::de::DeserializeOwned;
|
|||
|
|
use serde_json::Value;
|
|||
|
|
use std::time::Duration;
|
|||
|
|
use tracing::{debug, warn};
|
|||
|
|
|
|||
|
|
/// URL de base de l'API Qobuz
|
|||
|
|
const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
|
|||
|
|
|
|||
|
|
/// Client API bas-niveau pour communiquer avec Qobuz
|
|||
|
|
pub struct QobuzApi {
|
|||
|
|
/// Client HTTP
|
|||
|
|
client: Client,
|
|||
|
|
/// App ID pour l'authentification
|
|||
|
|
app_id: String,
|
|||
|
|
/// Token d'authentification utilisateur
|
|||
|
|
user_auth_token: Option<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 ===========
|
|||
|
|
|