Première tentative d'une crate pmoqobuz

This commit is contained in:
2025-10-11 15:59:43 +02:00
parent fb94886c53
commit 11187db71b
20 changed files with 3821 additions and 6 deletions

274
Cargo.lock generated
View File

@@ -104,6 +104,16 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "assert_type_match"
version = "0.1.1"
@@ -115,6 +125,17 @@ dependencies = [
"syn",
]
[[package]]
name = "async-lock"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -523,6 +544,53 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -563,6 +631,15 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -692,6 +769,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c272297e804878a2a4b707cfcfc6d2328b5bb936944613b4fdf2b9269afdfd"
[[package]]
name = "document-features"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d"
dependencies = [
"litrs",
]
[[package]]
name = "downcast-rs"
version = "2.0.2"
@@ -760,6 +846,27 @@ dependencies = [
"windows-sys 0.61.1",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "exr"
version = "1.73.0"
@@ -1607,6 +1714,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
name = "litrs"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed"
[[package]]
name = "lock_api"
version = "0.4.13"
@@ -1706,6 +1819,51 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "mockito"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7760e0e418d9b7e5777c0374009ca4c93861b9066f18cb334a20ce50ab63aa48"
dependencies = [
"assert-json-diff",
"bytes",
"colored",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"log",
"rand 0.9.2",
"regex",
"serde_json",
"serde_urlencoded",
"similar",
"tokio",
]
[[package]]
name = "moka"
version = "0.12.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077"
dependencies = [
"async-lock",
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"event-listener",
"futures-util",
"parking_lot",
"portable-atomic",
"rustc_version",
"smallvec",
"tagptr",
"uuid",
]
[[package]]
name = "moxcms"
version = "0.7.6"
@@ -1897,6 +2055,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.4"
@@ -2028,6 +2192,32 @@ dependencies = [
"utoipa-swagger-ui",
]
[[package]]
name = "pmoqobuz"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"hex",
"mockito",
"moka",
"pmoconfig",
"pmocovers",
"pmodidl",
"pmoserver",
"reqwest",
"serde",
"serde_json",
"sha1",
"thiserror 1.0.69",
"tokio",
"tokio-test",
"tracing",
"tracing-subscriber",
"utoipa",
]
[[package]]
name = "pmoserver"
version = "0.1.0"
@@ -2166,6 +2356,22 @@ dependencies = [
"syn",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "pxfm"
version = "0.1.24"
@@ -2232,8 +2438,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
]
[[package]]
@@ -2243,7 +2459,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
]
[[package]]
@@ -2255,6 +2481,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "rav1e"
version = "0.7.1"
@@ -2281,8 +2516,8 @@ dependencies = [
"once_cell",
"paste",
"profiling",
"rand",
"rand_chacha",
"rand 0.8.5",
"rand_chacha 0.3.1",
"simd_helpers",
"system-deps",
"thiserror 1.0.69",
@@ -2382,6 +2617,8 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-channel",
"futures-core",
@@ -2490,6 +2727,15 @@ version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.2"
@@ -2595,6 +2841,12 @@ dependencies = [
"libc",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -2744,6 +2996,12 @@ dependencies = [
"quote",
]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "slab"
version = "0.4.11"
@@ -2861,6 +3119,12 @@ dependencies = [
"version-compare",
]
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "target-lexicon"
version = "0.12.16"

View File

@@ -1,3 +1,3 @@
[workspace]
resolver = "3"
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers", "pmoaudio"]
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers", "pmoaudio", "pmoqobuz"]

View File

@@ -319,6 +319,39 @@ impl Config {
_ => Ok(2000),
}
}
/// Récupère le nom d'utilisateur Qobuz depuis la configuration
pub fn get_qobuz_username(&self) -> Result<String> {
match self.get_value(&["accounts", "qobuz", "username"])? {
Value::String(s) => Ok(s),
_ => Err(anyhow!("Qobuz username not configured")),
}
}
/// Définit le nom d'utilisateur Qobuz dans la configuration
pub fn set_qobuz_username(&self, username: &str) -> Result<()> {
self.set_value(&["accounts", "qobuz", "username"], Value::String(username.to_string()))
}
/// Récupère le mot de passe Qobuz depuis la configuration
pub fn get_qobuz_password(&self) -> Result<String> {
match self.get_value(&["accounts", "qobuz", "password"])? {
Value::String(s) => Ok(s),
_ => Err(anyhow!("Qobuz password not configured")),
}
}
/// Définit le mot de passe Qobuz dans la configuration
pub fn set_qobuz_password(&self, password: &str) -> Result<()> {
self.set_value(&["accounts", "qobuz", "password"], Value::String(password.to_string()))
}
/// Récupère les credentials Qobuz (username + password) depuis la configuration
pub fn get_qobuz_credentials(&self) -> Result<(String, String)> {
let username = self.get_qobuz_username()?;
let password = self.get_qobuz_password()?;
Ok((username, password))
}
}
/// Retourne l'instance globale

62
pmoqobuz/Cargo.toml Normal file
View File

@@ -0,0 +1,62 @@
[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
pmocovers = { path = "../pmocovers", optional = true }
# 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 }
[features]
default = []
# Feature pour activer les extensions pmoserver
pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"]
# Feature pour activer le cache d'images via pmocovers
covers = ["dep:pmocovers"]
[dev-dependencies]
# Tests
tokio-test = "0.4"
mockito = "1.0"
# Pour les exemples
tracing-subscriber = "0.3"

204
pmoqobuz/README.md Normal file
View File

@@ -0,0 +1,204 @@
# 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`)
- 🔄 **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;
```
## Exemples
Exécutez l'exemple :
```bash
cargo run --example basic_usage
```
## 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
```
## 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
## 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/)

View File

@@ -0,0 +1,118 @@
//! 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(())
}

View File

@@ -0,0 +1,100 @@
//! 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);
}

136
pmoqobuz/src/api/auth.rs Normal file
View File

@@ -0,0 +1,136 @@
//! Module d'authentification pour l'API Qobuz
use super::QobuzApi;
use crate::error::{QobuzError, Result};
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
/// Réponse de l'endpoint /user/login
#[derive(Debug, Deserialize)]
struct LoginResponse {
user: UserInfo,
user_auth_token: String,
}
/// Informations utilisateur retournées par l'API
#[derive(Debug, Deserialize)]
struct UserInfo {
id: u64,
#[serde(default)]
email: Option<String>,
#[serde(default)]
firstname: Option<String>,
#[serde(default)]
lastname: Option<String>,
credential: CredentialInfo,
}
/// Informations sur les credentials de l'utilisateur
#[derive(Debug, Deserialize)]
struct CredentialInfo {
#[serde(default)]
parameters: Option<CredentialParameters>,
}
/// Paramètres du niveau d'abonnement
#[derive(Debug, Deserialize)]
struct CredentialParameters {
#[serde(default)]
short_label: Option<String>,
}
/// Informations d'authentification
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthInfo {
/// Token d'authentification
pub token: String,
/// ID utilisateur
pub user_id: String,
/// Label de l'abonnement (ex: "Studio", "Hi-Fi", etc.)
pub subscription_label: Option<String>,
}
impl QobuzApi {
/// Authentifie l'utilisateur avec username et password
///
/// # Arguments
///
/// * `username` - Email ou nom d'utilisateur Qobuz
/// * `password` - Mot de passe
///
/// # Returns
///
/// Retourne les informations d'authentification si le login est réussi
///
/// # Errors
///
/// * `QobuzError::Unauthorized` - Credentials invalides
/// * `QobuzError::SubscriptionRequired` - Compte gratuit (non éligible)
pub async fn login(&mut self, username: &str, password: &str) -> Result<AuthInfo> {
info!("Attempting to login to Qobuz as {}", username);
let params = [
("username", username),
("password", password),
];
let response: LoginResponse = self.post("/user/login", &params).await?;
// Vérifier que l'utilisateur a un abonnement valide
if response.user.credential.parameters.is_none() {
return Err(QobuzError::SubscriptionRequired(
"Free accounts are not eligible for streaming".to_string(),
));
}
let user_id = response.user.id.to_string();
let subscription_label = response
.user
.credential
.parameters
.and_then(|p| p.short_label);
debug!(
"Login successful - User ID: {}, Subscription: {:?}",
user_id, subscription_label
);
// Stocker les informations d'authentification
self.set_auth_token(response.user_auth_token.clone(), user_id.clone());
Ok(AuthInfo {
token: response.user_auth_token,
user_id,
subscription_label,
})
}
/// Vérifie si le client est authentifié
pub fn is_authenticated(&self) -> bool {
self.user_auth_token.is_some() && self.user_id.is_some()
}
/// Déconnecte l'utilisateur
pub fn logout(&mut self) {
debug!("Logging out");
self.user_auth_token = None;
self.user_id = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_authenticated() {
let mut api = QobuzApi::new("test_app_id").unwrap();
assert!(!api.is_authenticated());
api.set_auth_token("token".to_string(), "user123".to_string());
assert!(api.is_authenticated());
api.logout();
assert!(!api.is_authenticated());
}
}

459
pmoqobuz/src/api/catalog.rs Normal file
View File

@@ -0,0 +1,459 @@
//! Module d'accès au catalogue Qobuz (albums, tracks, artistes, playlists)
use super::QobuzApi;
use crate::error::Result;
use crate::models::*;
use serde::Deserialize;
use tracing::debug;
/// Réponse paginée de l'API
#[derive(Debug, Deserialize)]
struct PaginatedResponse<T> {
items: Vec<T>,
#[serde(default)]
total: Option<u32>,
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
offset: Option<u32>,
}
/// Réponse de l'endpoint /album/get
#[derive(Debug, Deserialize)]
pub(crate) struct AlbumResponse {
id: String,
title: String,
artist: ArtistResponse,
#[serde(default)]
tracks_count: Option<u32>,
#[serde(default)]
duration: Option<u32>,
#[serde(default)]
release_date_original: Option<String>,
#[serde(default)]
image: Option<ImageResponse>,
#[serde(default = "default_streamable")]
streamable: bool,
#[serde(default)]
description: Option<String>,
#[serde(default)]
maximum_sampling_rate: Option<f64>,
#[serde(default)]
maximum_bit_depth: Option<u32>,
#[serde(default)]
genre: Option<GenreResponse>,
#[serde(default)]
label: Option<LabelResponse>,
#[serde(default)]
tracks: Option<PaginatedResponse<TrackResponse>>,
}
/// Réponse de l'endpoint /track/get
#[derive(Debug, Deserialize)]
pub(crate) struct TrackResponse {
id: String,
title: String,
#[serde(default)]
performer: Option<ArtistResponse>,
#[serde(default)]
artist: Option<ArtistResponse>,
#[serde(default)]
album: Option<AlbumResponse>,
duration: u32,
track_number: u32,
media_number: u32,
#[serde(default = "default_streamable")]
streamable: bool,
}
/// Réponse artiste
#[derive(Debug, Deserialize)]
pub(crate) struct ArtistResponse {
id: u64,
name: String,
#[serde(default)]
image: Option<ImageResponse>,
#[serde(default)]
albums: Option<PaginatedResponse<AlbumResponse>>,
}
/// Réponse image
#[derive(Debug, Deserialize)]
struct ImageResponse {
#[serde(default)]
large: Option<String>,
}
/// Réponse genre
#[derive(Debug, Deserialize)]
struct GenreResponse {
#[serde(default)]
id: Option<u32>,
name: String,
}
/// Réponse label
#[derive(Debug, Deserialize)]
struct LabelResponse {
name: String,
}
/// Réponse playlist
#[derive(Debug, Deserialize)]
pub(crate) struct PlaylistResponse {
id: u64,
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
tracks_count: Option<u32>,
#[serde(default)]
duration: Option<u32>,
#[serde(default)]
images300: Option<Vec<String>>,
#[serde(default)]
is_public: bool,
#[serde(default)]
owner: Option<OwnerResponse>,
#[serde(default)]
tracks: Option<PaginatedResponse<TrackResponse>>,
}
/// Réponse propriétaire
#[derive(Debug, Deserialize)]
struct OwnerResponse {
id: u64,
name: String,
}
/// Réponse genres list
#[derive(Debug, Deserialize)]
struct GenresResponse {
genres: PaginatedResponse<GenreResponse>,
}
/// Réponse albums featured
#[derive(Debug, Deserialize)]
struct FeaturedAlbumsResponse {
albums: PaginatedResponse<AlbumResponse>,
}
/// Réponse playlists featured
#[derive(Debug, Deserialize)]
struct FeaturedPlaylistsResponse {
playlists: PaginatedResponse<PlaylistResponse>,
}
/// Réponse search
#[derive(Debug, Deserialize)]
struct SearchResponse {
#[serde(default)]
albums: Option<PaginatedResponse<AlbumResponse>>,
#[serde(default)]
artists: Option<PaginatedResponse<ArtistResponse>>,
#[serde(default)]
tracks: Option<PaginatedResponse<TrackResponse>>,
#[serde(default)]
playlists: Option<PaginatedResponse<PlaylistResponse>>,
}
/// Réponse track file URL
#[derive(Debug, Deserialize)]
struct FileUrlResponse {
url: String,
mime_type: String,
sampling_rate: u32,
bit_depth: u32,
format_id: u8,
}
fn default_streamable() -> bool {
true
}
impl QobuzApi {
/// Récupère les détails d'un album
pub async fn get_album(&self, album_id: &str) -> Result<Album> {
debug!("Fetching album {}", album_id);
let params = [("album_id", album_id)];
let response: AlbumResponse = self.get("/album/get", &params).await?;
Ok(Self::parse_album(response))
}
/// Récupère les tracks d'un album
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<Track>> {
debug!("Fetching tracks for album {}", album_id);
let params = [("album_id", album_id)];
let mut response: AlbumResponse = self.get("/album/get", &params).await?;
if let Some(tracks) = response.tracks.take() {
let album = Self::parse_album(response);
Ok(tracks
.items
.into_iter()
.map(|t| Self::parse_track(t, Some(album.clone())))
.collect())
} else {
Ok(Vec::new())
}
}
/// Récupère les détails d'une track
pub async fn get_track(&self, track_id: &str) -> Result<Track> {
debug!("Fetching track {}", track_id);
let params = [("track_id", track_id)];
let response: TrackResponse = self.get("/track/get", &params).await?;
Ok(Self::parse_track(response, None))
}
/// Récupère l'URL de streaming d'une track
pub async fn get_file_url(&self, track_id: &str) -> Result<StreamInfo> {
debug!("Fetching file URL for track {}", track_id);
let format_id = self.format_id.id().to_string();
let params = [
("track_id", track_id),
("format_id", &format_id),
("intent", "stream"),
];
let response: FileUrlResponse = self.get("/track/getFileUrl", &params).await?;
Ok(StreamInfo {
url: response.url,
mime_type: response.mime_type,
sampling_rate: response.sampling_rate,
bit_depth: response.bit_depth,
format_id: response.format_id,
expires_at: chrono::Utc::now() + chrono::Duration::minutes(5),
})
}
/// Récupère les albums d'un artiste
pub async fn get_artist_albums(&self, artist_id: &str) -> Result<Vec<Album>> {
debug!("Fetching albums for artist {}", artist_id);
let params = [("artist_id", artist_id), ("extra", "albums")];
let response: ArtistResponse = self.get("/artist/get", &params).await?;
if let Some(albums) = response.albums {
Ok(albums
.items
.into_iter()
.map(Self::parse_album)
.filter(|a| a.streamable)
.collect())
} else {
Ok(Vec::new())
}
}
/// Récupère les artistes similaires
pub async fn get_similar_artists(&self, artist_id: &str) -> Result<Vec<Artist>> {
debug!("Fetching similar artists for {}", artist_id);
let params = [("artist_id", artist_id)];
#[derive(Debug, Deserialize)]
struct SimilarArtistsResponse {
artists: PaginatedResponse<ArtistResponse>,
}
let response: SimilarArtistsResponse =
self.get("/artist/getSimilarArtists", &params).await?;
Ok(response.artists.items.into_iter().map(Self::parse_artist).collect())
}
/// Récupère les détails d'une playlist
pub async fn get_playlist(&self, playlist_id: &str) -> Result<Playlist> {
debug!("Fetching playlist {}", playlist_id);
let params = [("playlist_id", playlist_id)];
let response: PlaylistResponse = self.get("/playlist/get", &params).await?;
Ok(Self::parse_playlist(response))
}
/// Récupère les tracks d'une playlist
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
debug!("Fetching tracks for playlist {}", playlist_id);
let params = [("playlist_id", playlist_id), ("extra", "tracks")];
let response: PlaylistResponse = self.get("/playlist/get", &params).await?;
if let Some(tracks) = response.tracks {
Ok(tracks.items.into_iter().map(|t| Self::parse_track(t, None)).collect())
} else {
Ok(Vec::new())
}
}
/// Récupère la liste des genres
pub async fn get_genres(&self) -> Result<Vec<Genre>> {
debug!("Fetching genres");
let response: GenresResponse = self.get("/genre/list", &[]).await?;
Ok(response.genres.items.into_iter().map(Self::parse_genre).collect())
}
/// Récupère les albums featured (nouveautés, éditeur, etc.)
pub async fn get_featured_albums(
&self,
genre_id: Option<&str>,
type_: &str,
) -> Result<Vec<Album>> {
debug!("Fetching featured albums (type: {})", type_);
let mut params = vec![("type", type_), ("limit", "100")];
if let Some(gid) = genre_id {
params.push(("genre_ids", gid));
}
let response: FeaturedAlbumsResponse = self.get("/album/getFeatured", &params).await?;
Ok(response
.albums
.items
.into_iter()
.map(Self::parse_album)
.filter(|a| a.streamable)
.collect())
}
/// Récupère les playlists featured
pub async fn get_featured_playlists(
&self,
genre_id: Option<&str>,
tags: Option<&str>,
) -> Result<Vec<Playlist>> {
debug!("Fetching featured playlists");
let mut params = vec![("type", "editor-picks"), ("limit", "100")];
if let Some(gid) = genre_id {
params.push(("genre_ids", gid));
}
if let Some(t) = tags {
params.push(("tags", t));
}
let response: FeaturedPlaylistsResponse =
self.get("/playlist/getFeatured", &params).await?;
Ok(response.playlists.items.into_iter().map(Self::parse_playlist).collect())
}
/// Recherche dans le catalogue
pub async fn search(&self, query: &str, type_: Option<&str>) -> Result<SearchResult> {
debug!("Searching for '{}' (type: {:?})", query, type_);
let mut params = vec![("query", query), ("limit", "200")];
if let Some(t) = type_ {
params.push(("type", t));
}
let response: SearchResponse = self.get("/catalog/search", &params).await?;
Ok(SearchResult {
albums: response
.albums
.map(|a| {
a.items
.into_iter()
.map(Self::parse_album)
.filter(|album| album.streamable)
.collect()
})
.unwrap_or_default(),
artists: response
.artists
.map(|a| a.items.into_iter().map(Self::parse_artist).collect())
.unwrap_or_default(),
tracks: response
.tracks
.map(|t| {
t.items
.into_iter()
.map(|track| Self::parse_track(track, None))
.filter(|track| track.streamable)
.collect()
})
.unwrap_or_default(),
playlists: response
.playlists
.map(|p| p.items.into_iter().map(Self::parse_playlist).collect())
.unwrap_or_default(),
})
}
// Fonctions de parsing publiques (utilisées aussi par le module user)
pub(crate) fn parse_album(response: AlbumResponse) -> Album {
Album {
id: response.id,
title: response.title,
artist: Self::parse_artist(response.artist),
tracks_count: response.tracks_count,
duration: response.duration,
release_date: response.release_date_original,
image: response.image.and_then(|i| i.large),
image_cached: None,
streamable: response.streamable,
description: response.description,
maximum_sampling_rate: response.maximum_sampling_rate,
maximum_bit_depth: response.maximum_bit_depth,
genres: response
.genre
.map(|g| vec![g.name])
.unwrap_or_default(),
label: response.label.map(|l| l.name),
}
}
pub(crate) fn parse_track(response: TrackResponse, album: Option<Album>) -> Track {
let performer = response
.performer
.or(response.artist)
.map(Self::parse_artist);
let album = album.or_else(|| response.album.map(Self::parse_album));
Track {
id: response.id,
title: response.title,
performer,
album,
duration: response.duration,
track_number: response.track_number,
media_number: response.media_number,
streamable: response.streamable,
mime_type: None,
sample_rate: None,
bit_depth: None,
channels: None,
}
}
pub(crate) fn parse_artist(response: ArtistResponse) -> Artist {
Artist {
id: response.id.to_string(),
name: response.name,
image: response.image.and_then(|i| i.large),
image_cached: None,
}
}
pub(crate) fn parse_playlist(response: PlaylistResponse) -> Playlist {
Playlist {
id: response.id.to_string(),
name: response.name,
description: response.description,
tracks_count: response.tracks_count,
duration: response.duration,
image: response.images300.and_then(|imgs| imgs.first().cloned()),
image_cached: None,
is_public: response.is_public,
owner: response.owner.map(|o| PlaylistOwner {
id: o.id,
name: o.name,
}),
}
}
pub(crate) fn parse_genre(response: GenreResponse) -> Genre {
Genre {
id: response.id,
name: response.name,
children: Vec::new(),
}
}
}

201
pmoqobuz/src/api/mod.rs Normal file
View File

@@ -0,0 +1,201 @@
//! 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);
}
}

124
pmoqobuz/src/api/user.rs Normal file
View File

@@ -0,0 +1,124 @@
//! Module d'accès aux données utilisateur (favoris)
use super::catalog::{AlbumResponse, ArtistResponse, PlaylistResponse, TrackResponse};
use super::QobuzApi;
use crate::error::{QobuzError, Result};
use crate::models::*;
use serde::Deserialize;
use tracing::debug;
/// Réponse paginée
#[derive(Debug, Deserialize)]
struct PaginatedResponse<T> {
items: Vec<T>,
}
/// Réponse de l'endpoint /favorite/getUserFavorites
#[derive(Debug, Deserialize)]
struct FavoritesResponse {
#[serde(default)]
albums: Option<PaginatedResponse<AlbumResponse>>,
#[serde(default)]
artists: Option<PaginatedResponse<ArtistResponse>>,
#[serde(default)]
tracks: Option<PaginatedResponse<TrackResponse>>,
}
/// Réponse de l'endpoint /playlist/getUserPlaylists
#[derive(Debug, Deserialize)]
struct UserPlaylistsResponse {
playlists: PaginatedResponse<PlaylistResponse>,
}
impl QobuzApi {
/// Vérifie que l'utilisateur est authentifié
fn ensure_authenticated(&self) -> Result<&str> {
self.user_id
.as_deref()
.ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))
}
/// Récupère les albums favoris de l'utilisateur
pub async fn get_favorite_albums(&self) -> Result<Vec<Album>> {
let user_id = self.ensure_authenticated()?;
debug!("Fetching favorite albums for user {}", user_id);
let params = [("user_id", user_id), ("type", "albums"), ("limit", "1000")];
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", &params).await?;
if let Some(albums) = response.albums {
Ok(albums
.items
.into_iter()
.map(QobuzApi::parse_album)
.filter(|a| a.streamable)
.collect())
} else {
Ok(Vec::new())
}
}
/// Récupère les artistes favoris de l'utilisateur
pub async fn get_favorite_artists(&self) -> Result<Vec<Artist>> {
let user_id = self.ensure_authenticated()?;
debug!("Fetching favorite artists for user {}", user_id);
let params = [
("user_id", user_id),
("type", "artists"),
("limit", "1000"),
];
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", &params).await?;
if let Some(artists) = response.artists {
Ok(artists
.items
.into_iter()
.map(QobuzApi::parse_artist)
.collect())
} else {
Ok(Vec::new())
}
}
/// Récupère les tracks favorites de l'utilisateur
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
let user_id = self.ensure_authenticated()?;
debug!("Fetching favorite tracks for user {}", user_id);
let params = [("user_id", user_id), ("type", "tracks"), ("limit", "1000")];
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", &params).await?;
if let Some(tracks) = response.tracks {
Ok(tracks
.items
.into_iter()
.map(|t| QobuzApi::parse_track(t, None))
.filter(|t| t.streamable)
.collect())
} else {
Ok(Vec::new())
}
}
/// Récupère les playlists de l'utilisateur
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
let user_id = self.ensure_authenticated()?;
debug!("Fetching playlists for user {}", user_id);
let params = [("user_id", user_id), ("limit", "1000")];
let response: UserPlaylistsResponse =
self.get("/playlist/getUserPlaylists", &params).await?;
Ok(response
.playlists
.items
.into_iter()
.map(QobuzApi::parse_playlist)
.collect())
}
}

361
pmoqobuz/src/api_rest.rs Normal file
View File

@@ -0,0 +1,361 @@
//! Endpoints API REST pour Qobuz
//!
//! Ce module définit les handlers HTTP pour accéder aux fonctionnalités Qobuz.
#[cfg(feature = "pmoserver")]
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json, Router,
};
#[cfg(feature = "pmoserver")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use crate::{client::QobuzClient, error::QobuzError, models::*};
/// État partagé de l'application
#[cfg(feature = "pmoserver")]
#[derive(Clone)]
pub struct QobuzState {
pub client: Arc<QobuzClient>,
#[cfg(feature = "covers")]
pub cover_cache: Option<Arc<pmocovers::Cache>>,
}
/// Paramètres de recherche
#[cfg(feature = "pmoserver")]
#[derive(Debug, Deserialize)]
pub struct SearchParams {
/// Requête de recherche
pub q: String,
/// Type de recherche (albums, artists, tracks, playlists)
#[serde(rename = "type")]
pub search_type: Option<String>,
}
/// Paramètres pour featured albums
#[cfg(feature = "pmoserver")]
#[derive(Debug, Deserialize)]
pub struct FeaturedAlbumsParams {
/// ID du genre (optionnel)
pub genre_id: Option<String>,
/// Type (new-releases, ideal-discography, etc.)
#[serde(rename = "type", default = "default_featured_type")]
pub type_: String,
}
#[cfg(feature = "pmoserver")]
fn default_featured_type() -> String {
"new-releases".to_string()
}
/// Paramètres pour featured playlists
#[cfg(feature = "pmoserver")]
#[derive(Debug, Deserialize)]
pub struct FeaturedPlaylistsParams {
/// ID du genre (optionnel)
pub genre_id: Option<String>,
/// Tags (optionnel)
pub tags: Option<String>,
}
/// Crée le router Axum avec tous les endpoints Qobuz
#[cfg(feature = "pmoserver")]
pub fn create_router(state: QobuzState) -> Router {
Router::new()
// Albums
.route("/albums/:id", axum::routing::get(get_album))
.route("/albums/:id/tracks", axum::routing::get(get_album_tracks))
// Tracks
.route("/tracks/:id", axum::routing::get(get_track))
.route("/tracks/:id/stream", axum::routing::get(get_stream_url))
// Artists
.route("/artists/:id/albums", axum::routing::get(get_artist_albums))
.route(
"/artists/:id/similar",
axum::routing::get(get_similar_artists),
)
// Playlists
.route("/playlists/:id", axum::routing::get(get_playlist))
.route(
"/playlists/:id/tracks",
axum::routing::get(get_playlist_tracks),
)
// Recherche
.route("/search", axum::routing::get(search))
// Favoris
.route("/favorites/albums", axum::routing::get(get_favorite_albums))
.route(
"/favorites/artists",
axum::routing::get(get_favorite_artists),
)
.route("/favorites/tracks", axum::routing::get(get_favorite_tracks))
.route(
"/favorites/playlists",
axum::routing::get(get_user_playlists),
)
// Catalogue
.route("/genres", axum::routing::get(get_genres))
.route("/featured/albums", axum::routing::get(get_featured_albums))
.route(
"/featured/playlists",
axum::routing::get(get_featured_playlists),
)
// Cache
.route("/cache/stats", axum::routing::get(get_cache_stats))
.with_state(state)
}
// ============ Handlers ============
#[cfg(feature = "pmoserver")]
async fn get_album(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Album>, AppError> {
let mut album = state.client.get_album(&id).await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
album = cache_album_image(album, cover_cache).await;
}
Ok(Json(album))
}
#[cfg(feature = "pmoserver")]
async fn get_album_tracks(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Vec<Track>>, AppError> {
let tracks = state.client.get_album_tracks(&id).await?;
Ok(Json(tracks))
}
#[cfg(feature = "pmoserver")]
async fn get_track(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Track>, AppError> {
let track = state.client.get_track(&id).await?;
Ok(Json(track))
}
#[cfg(feature = "pmoserver")]
async fn get_stream_url(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let url = state.client.get_stream_url(&id).await?;
Ok(Json(serde_json::json!({ "url": url })))
}
#[cfg(feature = "pmoserver")]
async fn get_artist_albums(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Vec<Album>>, AppError> {
let mut albums = state.client.get_artist_albums(&id).await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
albums = cache_albums_images(albums, cover_cache).await;
}
Ok(Json(albums))
}
#[cfg(feature = "pmoserver")]
async fn get_similar_artists(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Vec<Artist>>, AppError> {
let artists = state.client.get_similar_artists(&id).await?;
Ok(Json(artists))
}
#[cfg(feature = "pmoserver")]
async fn get_playlist(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Playlist>, AppError> {
let playlist = state.client.get_playlist(&id).await?;
Ok(Json(playlist))
}
#[cfg(feature = "pmoserver")]
async fn get_playlist_tracks(
State(state): State<QobuzState>,
Path(id): Path<String>,
) -> Result<Json<Vec<Track>>, AppError> {
let tracks = state.client.get_playlist_tracks(&id).await?;
Ok(Json(tracks))
}
#[cfg(feature = "pmoserver")]
async fn search(
State(state): State<QobuzState>,
Query(params): Query<SearchParams>,
) -> Result<Json<SearchResult>, AppError> {
let mut result = state
.client
.search(&params.q, params.search_type.as_deref())
.await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
result.albums = cache_albums_images(result.albums, cover_cache).await;
}
Ok(Json(result))
}
#[cfg(feature = "pmoserver")]
async fn get_favorite_albums(
State(state): State<QobuzState>,
) -> Result<Json<Vec<Album>>, AppError> {
let mut albums = state.client.get_favorite_albums().await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
albums = cache_albums_images(albums, cover_cache).await;
}
Ok(Json(albums))
}
#[cfg(feature = "pmoserver")]
async fn get_favorite_artists(
State(state): State<QobuzState>,
) -> Result<Json<Vec<Artist>>, AppError> {
let artists = state.client.get_favorite_artists().await?;
Ok(Json(artists))
}
#[cfg(feature = "pmoserver")]
async fn get_favorite_tracks(
State(state): State<QobuzState>,
) -> Result<Json<Vec<Track>>, AppError> {
let tracks = state.client.get_favorite_tracks().await?;
Ok(Json(tracks))
}
#[cfg(feature = "pmoserver")]
async fn get_user_playlists(
State(state): State<QobuzState>,
) -> Result<Json<Vec<Playlist>>, AppError> {
let playlists = state.client.get_user_playlists().await?;
Ok(Json(playlists))
}
#[cfg(feature = "pmoserver")]
async fn get_genres(State(state): State<QobuzState>) -> Result<Json<Vec<Genre>>, AppError> {
let genres = state.client.get_genres().await?;
Ok(Json(genres))
}
#[cfg(feature = "pmoserver")]
async fn get_featured_albums(
State(state): State<QobuzState>,
Query(params): Query<FeaturedAlbumsParams>,
) -> Result<Json<Vec<Album>>, AppError> {
let mut albums = state
.client
.get_featured_albums(params.genre_id.as_deref(), &params.type_)
.await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
albums = cache_albums_images(albums, cover_cache).await;
}
Ok(Json(albums))
}
#[cfg(feature = "pmoserver")]
async fn get_featured_playlists(
State(state): State<QobuzState>,
Query(params): Query<FeaturedPlaylistsParams>,
) -> Result<Json<Vec<Playlist>>, AppError> {
let playlists = state
.client
.get_featured_playlists(params.genre_id.as_deref(), params.tags.as_deref())
.await?;
Ok(Json(playlists))
}
#[cfg(feature = "pmoserver")]
async fn get_cache_stats(
State(state): State<QobuzState>,
) -> Result<Json<crate::cache::CacheStats>, AppError> {
let stats = state.client.cache().stats().await;
Ok(Json(stats))
}
// ============ Helpers pour le cache d'images ============
#[cfg(all(feature = "pmoserver", feature = "covers"))]
async fn cache_album_image(mut album: Album, cover_cache: &Arc<pmocovers::Cache>) -> Album {
if let Some(ref image_url) = album.image {
match cover_cache.ensure_from_url(image_url).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())
}
}

286
pmoqobuz/src/cache.rs Normal file
View File

@@ -0,0 +1,286 @@
//! 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);
}
}

345
pmoqobuz/src/client.rs Normal file
View File

@@ -0,0 +1,345 @@
//! 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 = "950611386";
/// 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
}
}
#[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);
}
}

249
pmoqobuz/src/didl.rs Normal file
View File

@@ -0,0 +1,249 @@
//! 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()),
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()),
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");
}
}

83
pmoqobuz/src/error.rs Normal file
View File

@@ -0,0 +1,83 @@
//! 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)
}
}

174
pmoqobuz/src/lib.rs Normal file
View File

@@ -0,0 +1,174 @@
//! # 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
//!
//! Les images d'albums sont automatiquement cachées via `pmocovers` (feature `covers`) :
//!
//! ```rust,ignore
//! let album = client.get_album("12345").await?;
//! // L'image est automatiquement ajoutée au cache pmocovers
//! let cover_url = album.cover_url_cached; // URL vers le cache local
//! ```
//!
//! ## 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
//! - [`pmoconfig`] : Configuration
//! - [`pmoserver`] : Serveur HTTP
pub mod api;
pub mod cache;
pub mod client;
pub mod didl;
pub mod error;
pub mod models;
// 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};
/// 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;

298
pmoqobuz/src/models.rs Normal file
View File

@@ -0,0 +1,298 @@
//! 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
}
}

View File

@@ -0,0 +1,184 @@
//! 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

View File

@@ -0,0 +1,134 @@
//! 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
}
}