From b8154a4837ffda490097623febc9e516faf595dc Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 11 Oct 2025 15:59:43 +0200 Subject: [PATCH] =?UTF-8?q?Premi=C3=A8re=20tentative=20d'une=20crate=20pmo?= =?UTF-8?q?qobuz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 274 +++++++++++++- Cargo.toml | 2 +- pmoconfig/src/lib.rs | 33 ++ pmoqobuz/Cargo.toml | 62 ++++ pmoqobuz/README.md | 204 +++++++++++ pmoqobuz/examples/basic_usage.rs | 118 ++++++ pmoqobuz/examples/server_with_covers.rs | 100 ++++++ pmoqobuz/src/api/auth.rs | 136 +++++++ pmoqobuz/src/api/catalog.rs | 459 ++++++++++++++++++++++++ pmoqobuz/src/api/mod.rs | 201 +++++++++++ pmoqobuz/src/api/user.rs | 124 +++++++ pmoqobuz/src/api_rest.rs | 361 +++++++++++++++++++ pmoqobuz/src/cache.rs | 286 +++++++++++++++ pmoqobuz/src/client.rs | 345 ++++++++++++++++++ pmoqobuz/src/didl.rs | 249 +++++++++++++ pmoqobuz/src/error.rs | 83 +++++ pmoqobuz/src/lib.rs | 174 +++++++++ pmoqobuz/src/models.rs | 298 +++++++++++++++ pmoqobuz/src/pmoserver_ext.rs | 184 ++++++++++ pmoqobuz/src/pmoserver_impl.rs | 134 +++++++ 20 files changed, 3821 insertions(+), 6 deletions(-) create mode 100644 pmoqobuz/Cargo.toml create mode 100644 pmoqobuz/README.md create mode 100644 pmoqobuz/examples/basic_usage.rs create mode 100644 pmoqobuz/examples/server_with_covers.rs create mode 100644 pmoqobuz/src/api/auth.rs create mode 100644 pmoqobuz/src/api/catalog.rs create mode 100644 pmoqobuz/src/api/mod.rs create mode 100644 pmoqobuz/src/api/user.rs create mode 100644 pmoqobuz/src/api_rest.rs create mode 100644 pmoqobuz/src/cache.rs create mode 100644 pmoqobuz/src/client.rs create mode 100644 pmoqobuz/src/didl.rs create mode 100644 pmoqobuz/src/error.rs create mode 100644 pmoqobuz/src/lib.rs create mode 100644 pmoqobuz/src/models.rs create mode 100644 pmoqobuz/src/pmoserver_ext.rs create mode 100644 pmoqobuz/src/pmoserver_impl.rs diff --git a/Cargo.lock b/Cargo.lock index 0844c37f..639b736f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d0b28bff..2212f764 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/pmoconfig/src/lib.rs b/pmoconfig/src/lib.rs index 1633c7d8..a0e79d77 100644 --- a/pmoconfig/src/lib.rs +++ b/pmoconfig/src/lib.rs @@ -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 { + 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 { + 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 diff --git a/pmoqobuz/Cargo.toml b/pmoqobuz/Cargo.toml new file mode 100644 index 00000000..36a1e1ec --- /dev/null +++ b/pmoqobuz/Cargo.toml @@ -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" diff --git a/pmoqobuz/README.md b/pmoqobuz/README.md new file mode 100644 index 00000000..c508bc2c --- /dev/null +++ b/pmoqobuz/README.md @@ -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/) diff --git a/pmoqobuz/examples/basic_usage.rs b/pmoqobuz/examples/basic_usage.rs new file mode 100644 index 00000000..2b628052 --- /dev/null +++ b/pmoqobuz/examples/basic_usage.rs @@ -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(()) +} diff --git a/pmoqobuz/examples/server_with_covers.rs b/pmoqobuz/examples/server_with_covers.rs new file mode 100644 index 00000000..4362e1b5 --- /dev/null +++ b/pmoqobuz/examples/server_with_covers.rs @@ -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); +} diff --git a/pmoqobuz/src/api/auth.rs b/pmoqobuz/src/api/auth.rs new file mode 100644 index 00000000..1d8ddc6a --- /dev/null +++ b/pmoqobuz/src/api/auth.rs @@ -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, + #[serde(default)] + firstname: Option, + #[serde(default)] + lastname: Option, + credential: CredentialInfo, +} + +/// Informations sur les credentials de l'utilisateur +#[derive(Debug, Deserialize)] +struct CredentialInfo { + #[serde(default)] + parameters: Option, +} + +/// Paramètres du niveau d'abonnement +#[derive(Debug, Deserialize)] +struct CredentialParameters { + #[serde(default)] + short_label: Option, +} + +/// Informations d'authentification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthInfo { + /// Token d'authentification + pub token: String, + /// ID utilisateur + pub user_id: String, + /// Label de l'abonnement (ex: "Studio", "Hi-Fi", etc.) + pub subscription_label: Option, +} + +impl QobuzApi { + /// Authentifie l'utilisateur avec username et password + /// + /// # Arguments + /// + /// * `username` - Email ou nom d'utilisateur Qobuz + /// * `password` - Mot de passe + /// + /// # Returns + /// + /// Retourne les informations d'authentification si le login est réussi + /// + /// # Errors + /// + /// * `QobuzError::Unauthorized` - Credentials invalides + /// * `QobuzError::SubscriptionRequired` - Compte gratuit (non éligible) + pub async fn login(&mut self, username: &str, password: &str) -> Result { + info!("Attempting to login to Qobuz as {}", username); + + let params = [ + ("username", username), + ("password", password), + ]; + + let response: LoginResponse = self.post("/user/login", ¶ms).await?; + + // Vérifier que l'utilisateur a un abonnement valide + if response.user.credential.parameters.is_none() { + return Err(QobuzError::SubscriptionRequired( + "Free accounts are not eligible for streaming".to_string(), + )); + } + + let user_id = response.user.id.to_string(); + let subscription_label = response + .user + .credential + .parameters + .and_then(|p| p.short_label); + + debug!( + "Login successful - User ID: {}, Subscription: {:?}", + user_id, subscription_label + ); + + // Stocker les informations d'authentification + self.set_auth_token(response.user_auth_token.clone(), user_id.clone()); + + Ok(AuthInfo { + token: response.user_auth_token, + user_id, + subscription_label, + }) + } + + /// Vérifie si le client est authentifié + pub fn is_authenticated(&self) -> bool { + self.user_auth_token.is_some() && self.user_id.is_some() + } + + /// Déconnecte l'utilisateur + pub fn logout(&mut self) { + debug!("Logging out"); + self.user_auth_token = None; + self.user_id = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_authenticated() { + let mut api = QobuzApi::new("test_app_id").unwrap(); + assert!(!api.is_authenticated()); + + api.set_auth_token("token".to_string(), "user123".to_string()); + assert!(api.is_authenticated()); + + api.logout(); + assert!(!api.is_authenticated()); + } +} diff --git a/pmoqobuz/src/api/catalog.rs b/pmoqobuz/src/api/catalog.rs new file mode 100644 index 00000000..d9cf403e --- /dev/null +++ b/pmoqobuz/src/api/catalog.rs @@ -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 { + items: Vec, + #[serde(default)] + total: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + offset: Option, +} + +/// Réponse de l'endpoint /album/get +#[derive(Debug, Deserialize)] +pub(crate) struct AlbumResponse { + id: String, + title: String, + artist: ArtistResponse, + #[serde(default)] + tracks_count: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + release_date_original: Option, + #[serde(default)] + image: Option, + #[serde(default = "default_streamable")] + streamable: bool, + #[serde(default)] + description: Option, + #[serde(default)] + maximum_sampling_rate: Option, + #[serde(default)] + maximum_bit_depth: Option, + #[serde(default)] + genre: Option, + #[serde(default)] + label: Option, + #[serde(default)] + tracks: Option>, +} + +/// Réponse de l'endpoint /track/get +#[derive(Debug, Deserialize)] +pub(crate) struct TrackResponse { + id: String, + title: String, + #[serde(default)] + performer: Option, + #[serde(default)] + artist: Option, + #[serde(default)] + album: Option, + duration: u32, + track_number: u32, + media_number: u32, + #[serde(default = "default_streamable")] + streamable: bool, +} + +/// Réponse artiste +#[derive(Debug, Deserialize)] +pub(crate) struct ArtistResponse { + id: u64, + name: String, + #[serde(default)] + image: Option, + #[serde(default)] + albums: Option>, +} + +/// Réponse image +#[derive(Debug, Deserialize)] +struct ImageResponse { + #[serde(default)] + large: Option, +} + +/// Réponse genre +#[derive(Debug, Deserialize)] +struct GenreResponse { + #[serde(default)] + id: Option, + name: String, +} + +/// Réponse label +#[derive(Debug, Deserialize)] +struct LabelResponse { + name: String, +} + +/// Réponse playlist +#[derive(Debug, Deserialize)] +pub(crate) struct PlaylistResponse { + id: u64, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + tracks_count: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + images300: Option>, + #[serde(default)] + is_public: bool, + #[serde(default)] + owner: Option, + #[serde(default)] + tracks: Option>, +} + +/// Réponse propriétaire +#[derive(Debug, Deserialize)] +struct OwnerResponse { + id: u64, + name: String, +} + +/// Réponse genres list +#[derive(Debug, Deserialize)] +struct GenresResponse { + genres: PaginatedResponse, +} + +/// Réponse albums featured +#[derive(Debug, Deserialize)] +struct FeaturedAlbumsResponse { + albums: PaginatedResponse, +} + +/// Réponse playlists featured +#[derive(Debug, Deserialize)] +struct FeaturedPlaylistsResponse { + playlists: PaginatedResponse, +} + +/// Réponse search +#[derive(Debug, Deserialize)] +struct SearchResponse { + #[serde(default)] + albums: Option>, + #[serde(default)] + artists: Option>, + #[serde(default)] + tracks: Option>, + #[serde(default)] + playlists: Option>, +} + +/// Réponse track file URL +#[derive(Debug, Deserialize)] +struct FileUrlResponse { + url: String, + mime_type: String, + sampling_rate: u32, + bit_depth: u32, + format_id: u8, +} + +fn default_streamable() -> bool { + true +} + +impl QobuzApi { + /// Récupère les détails d'un album + pub async fn get_album(&self, album_id: &str) -> Result { + debug!("Fetching album {}", album_id); + let params = [("album_id", album_id)]; + let response: AlbumResponse = self.get("/album/get", ¶ms).await?; + Ok(Self::parse_album(response)) + } + + /// Récupère les tracks d'un album + pub async fn get_album_tracks(&self, album_id: &str) -> Result> { + debug!("Fetching tracks for album {}", album_id); + let params = [("album_id", album_id)]; + let mut response: AlbumResponse = self.get("/album/get", ¶ms).await?; + + if let Some(tracks) = response.tracks.take() { + let album = Self::parse_album(response); + Ok(tracks + .items + .into_iter() + .map(|t| Self::parse_track(t, Some(album.clone()))) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les détails d'une track + pub async fn get_track(&self, track_id: &str) -> Result { + debug!("Fetching track {}", track_id); + let params = [("track_id", track_id)]; + let response: TrackResponse = self.get("/track/get", ¶ms).await?; + Ok(Self::parse_track(response, None)) + } + + /// Récupère l'URL de streaming d'une track + pub async fn get_file_url(&self, track_id: &str) -> Result { + debug!("Fetching file URL for track {}", track_id); + let format_id = self.format_id.id().to_string(); + let params = [ + ("track_id", track_id), + ("format_id", &format_id), + ("intent", "stream"), + ]; + let response: FileUrlResponse = self.get("/track/getFileUrl", ¶ms).await?; + + Ok(StreamInfo { + url: response.url, + mime_type: response.mime_type, + sampling_rate: response.sampling_rate, + bit_depth: response.bit_depth, + format_id: response.format_id, + expires_at: chrono::Utc::now() + chrono::Duration::minutes(5), + }) + } + + /// Récupère les albums d'un artiste + pub async fn get_artist_albums(&self, artist_id: &str) -> Result> { + debug!("Fetching albums for artist {}", artist_id); + let params = [("artist_id", artist_id), ("extra", "albums")]; + let response: ArtistResponse = self.get("/artist/get", ¶ms).await?; + + if let Some(albums) = response.albums { + Ok(albums + .items + .into_iter() + .map(Self::parse_album) + .filter(|a| a.streamable) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les artistes similaires + pub async fn get_similar_artists(&self, artist_id: &str) -> Result> { + debug!("Fetching similar artists for {}", artist_id); + let params = [("artist_id", artist_id)]; + + #[derive(Debug, Deserialize)] + struct SimilarArtistsResponse { + artists: PaginatedResponse, + } + + let response: SimilarArtistsResponse = + self.get("/artist/getSimilarArtists", ¶ms).await?; + Ok(response.artists.items.into_iter().map(Self::parse_artist).collect()) + } + + /// Récupère les détails d'une playlist + pub async fn get_playlist(&self, playlist_id: &str) -> Result { + debug!("Fetching playlist {}", playlist_id); + let params = [("playlist_id", playlist_id)]; + let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?; + Ok(Self::parse_playlist(response)) + } + + /// Récupère les tracks d'une playlist + pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result> { + debug!("Fetching tracks for playlist {}", playlist_id); + let params = [("playlist_id", playlist_id), ("extra", "tracks")]; + let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?; + + if let Some(tracks) = response.tracks { + Ok(tracks.items.into_iter().map(|t| Self::parse_track(t, None)).collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère la liste des genres + pub async fn get_genres(&self) -> Result> { + debug!("Fetching genres"); + let response: GenresResponse = self.get("/genre/list", &[]).await?; + Ok(response.genres.items.into_iter().map(Self::parse_genre).collect()) + } + + /// Récupère les albums featured (nouveautés, éditeur, etc.) + pub async fn get_featured_albums( + &self, + genre_id: Option<&str>, + type_: &str, + ) -> Result> { + debug!("Fetching featured albums (type: {})", type_); + let mut params = vec![("type", type_), ("limit", "100")]; + + if let Some(gid) = genre_id { + params.push(("genre_ids", gid)); + } + + let response: FeaturedAlbumsResponse = self.get("/album/getFeatured", ¶ms).await?; + Ok(response + .albums + .items + .into_iter() + .map(Self::parse_album) + .filter(|a| a.streamable) + .collect()) + } + + /// Récupère les playlists featured + pub async fn get_featured_playlists( + &self, + genre_id: Option<&str>, + tags: Option<&str>, + ) -> Result> { + debug!("Fetching featured playlists"); + let mut params = vec![("type", "editor-picks"), ("limit", "100")]; + + if let Some(gid) = genre_id { + params.push(("genre_ids", gid)); + } + if let Some(t) = tags { + params.push(("tags", t)); + } + + let response: FeaturedPlaylistsResponse = + self.get("/playlist/getFeatured", ¶ms).await?; + Ok(response.playlists.items.into_iter().map(Self::parse_playlist).collect()) + } + + /// Recherche dans le catalogue + pub async fn search(&self, query: &str, type_: Option<&str>) -> Result { + debug!("Searching for '{}' (type: {:?})", query, type_); + let mut params = vec![("query", query), ("limit", "200")]; + + if let Some(t) = type_ { + params.push(("type", t)); + } + + let response: SearchResponse = self.get("/catalog/search", ¶ms).await?; + + Ok(SearchResult { + albums: response + .albums + .map(|a| { + a.items + .into_iter() + .map(Self::parse_album) + .filter(|album| album.streamable) + .collect() + }) + .unwrap_or_default(), + artists: response + .artists + .map(|a| a.items.into_iter().map(Self::parse_artist).collect()) + .unwrap_or_default(), + tracks: response + .tracks + .map(|t| { + t.items + .into_iter() + .map(|track| Self::parse_track(track, None)) + .filter(|track| track.streamable) + .collect() + }) + .unwrap_or_default(), + playlists: response + .playlists + .map(|p| p.items.into_iter().map(Self::parse_playlist).collect()) + .unwrap_or_default(), + }) + } + + // Fonctions de parsing publiques (utilisées aussi par le module user) + + pub(crate) fn parse_album(response: AlbumResponse) -> Album { + Album { + id: response.id, + title: response.title, + artist: Self::parse_artist(response.artist), + tracks_count: response.tracks_count, + duration: response.duration, + release_date: response.release_date_original, + image: response.image.and_then(|i| i.large), + image_cached: None, + streamable: response.streamable, + description: response.description, + maximum_sampling_rate: response.maximum_sampling_rate, + maximum_bit_depth: response.maximum_bit_depth, + genres: response + .genre + .map(|g| vec![g.name]) + .unwrap_or_default(), + label: response.label.map(|l| l.name), + } + } + + pub(crate) fn parse_track(response: TrackResponse, album: Option) -> Track { + let performer = response + .performer + .or(response.artist) + .map(Self::parse_artist); + + let album = album.or_else(|| response.album.map(Self::parse_album)); + + Track { + id: response.id, + title: response.title, + performer, + album, + duration: response.duration, + track_number: response.track_number, + media_number: response.media_number, + streamable: response.streamable, + mime_type: None, + sample_rate: None, + bit_depth: None, + channels: None, + } + } + + pub(crate) fn parse_artist(response: ArtistResponse) -> Artist { + Artist { + id: response.id.to_string(), + name: response.name, + image: response.image.and_then(|i| i.large), + image_cached: None, + } + } + + pub(crate) fn parse_playlist(response: PlaylistResponse) -> Playlist { + Playlist { + id: response.id.to_string(), + name: response.name, + description: response.description, + tracks_count: response.tracks_count, + duration: response.duration, + image: response.images300.and_then(|imgs| imgs.first().cloned()), + image_cached: None, + is_public: response.is_public, + owner: response.owner.map(|o| PlaylistOwner { + id: o.id, + name: o.name, + }), + } + } + + pub(crate) fn parse_genre(response: GenreResponse) -> Genre { + Genre { + id: response.id, + name: response.name, + children: Vec::new(), + } + } +} diff --git a/pmoqobuz/src/api/mod.rs b/pmoqobuz/src/api/mod.rs new file mode 100644 index 00000000..43550829 --- /dev/null +++ b/pmoqobuz/src/api/mod.rs @@ -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, + /// ID utilisateur + user_id: Option, + /// Format audio par défaut + format_id: AudioFormat, +} + +impl QobuzApi { + /// Crée une nouvelle instance de l'API + pub fn new(app_id: impl Into) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:67.0) Gecko/20100101 Firefox/67.0") + .build()?; + + Ok(Self { + client, + app_id: app_id.into(), + user_auth_token: None, + user_id: None, + format_id: AudioFormat::default(), + }) + } + + /// Définit le token d'authentification + pub fn set_auth_token(&mut self, token: String, user_id: String) { + self.user_auth_token = Some(token); + self.user_id = Some(user_id); + } + + /// Définit le format audio par défaut + pub fn set_format(&mut self, format: AudioFormat) { + self.format_id = format; + } + + /// Retourne le format audio configuré + pub fn format(&self) -> AudioFormat { + self.format_id + } + + /// Retourne l'App ID + pub fn app_id(&self) -> &str { + &self.app_id + } + + /// Retourne le token d'authentification si disponible + pub fn auth_token(&self) -> Option<&str> { + self.user_auth_token.as_deref() + } + + /// Retourne l'ID utilisateur si disponible + pub fn user_id(&self) -> Option<&str> { + self.user_id.as_deref() + } + + /// Effectue une requête GET à l'API + pub(crate) async fn get( + &self, + endpoint: &str, + params: &[(&str, &str)], + ) -> Result { + self.request("GET", endpoint, params).await + } + + /// Effectue une requête POST à l'API + pub(crate) async fn post( + &self, + endpoint: &str, + params: &[(&str, &str)], + ) -> Result { + self.request("POST", endpoint, params).await + } + + /// Effectue une requête à l'API (générique) + async fn request( + &self, + method: &str, + endpoint: &str, + params: &[(&str, &str)], + ) -> Result { + let url = format!("{}{}", API_BASE_URL, endpoint); + + debug!("{} {} with {} params", method, url, params.len()); + + let mut request = if method == "GET" { + self.client.get(&url) + } else { + self.client.post(&url) + }; + + // Ajouter les headers + request = request.header("X-App-Id", &self.app_id); + + if let Some(ref token) = self.user_auth_token { + request = request.header("X-User-Auth-Token", token); + } + + // Ajouter les paramètres + if method == "GET" { + request = request.query(params); + } else { + request = request.form(params); + } + + // Envoyer la requête + let response = request.send().await?; + self.handle_response(response).await + } + + /// Traite la réponse HTTP + async fn handle_response(&self, response: Response) -> Result { + let status = response.status(); + let status_code = status.as_u16(); + + debug!("Response status: {}", status); + + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + warn!("API error ({}): {}", status_code, error_text); + return Err(QobuzError::from_status_code(status_code, error_text)); + } + + let text = response.text().await?; + + // Vérifier si la réponse contient une erreur Qobuz + if let Ok(json) = serde_json::from_str::(&text) { + if let Some(status_obj) = json.get("status") { + if status_obj == "error" { + let message = json + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + warn!("Qobuz API error: {}", message); + return Err(QobuzError::ApiError { + code: status_code, + message: message.to_string(), + }); + } + } + } + + // Parser la réponse + serde_json::from_str(&text).map_err(|e| { + warn!("Failed to parse response: {}", e); + QobuzError::JsonParse(e) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_creation() { + let api = QobuzApi::new("test_app_id").unwrap(); + assert_eq!(api.app_id(), "test_app_id"); + assert!(api.auth_token().is_none()); + } + + #[test] + fn test_set_auth_token() { + let mut api = QobuzApi::new("test_app_id").unwrap(); + api.set_auth_token("test_token".to_string(), "user123".to_string()); + assert_eq!(api.auth_token(), Some("test_token")); + assert_eq!(api.user_id(), Some("user123")); + } + + #[test] + fn test_set_format() { + let mut api = QobuzApi::new("test_app_id").unwrap(); + api.set_format(AudioFormat::Flac_HiRes_96); + assert_eq!(api.format(), AudioFormat::Flac_HiRes_96); + } +} diff --git a/pmoqobuz/src/api/user.rs b/pmoqobuz/src/api/user.rs new file mode 100644 index 00000000..606aaddc --- /dev/null +++ b/pmoqobuz/src/api/user.rs @@ -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 { + items: Vec, +} + +/// Réponse de l'endpoint /favorite/getUserFavorites +#[derive(Debug, Deserialize)] +struct FavoritesResponse { + #[serde(default)] + albums: Option>, + #[serde(default)] + artists: Option>, + #[serde(default)] + tracks: Option>, +} + +/// Réponse de l'endpoint /playlist/getUserPlaylists +#[derive(Debug, Deserialize)] +struct UserPlaylistsResponse { + playlists: PaginatedResponse, +} + +impl QobuzApi { + /// Vérifie que l'utilisateur est authentifié + fn ensure_authenticated(&self) -> Result<&str> { + self.user_id + .as_deref() + .ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string())) + } + + /// Récupère les albums favoris de l'utilisateur + pub async fn get_favorite_albums(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching favorite albums for user {}", user_id); + + let params = [("user_id", user_id), ("type", "albums"), ("limit", "1000")]; + + let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; + + if let Some(albums) = response.albums { + Ok(albums + .items + .into_iter() + .map(QobuzApi::parse_album) + .filter(|a| a.streamable) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les artistes favoris de l'utilisateur + pub async fn get_favorite_artists(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching favorite artists for user {}", user_id); + + let params = [ + ("user_id", user_id), + ("type", "artists"), + ("limit", "1000"), + ]; + + let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; + + if let Some(artists) = response.artists { + Ok(artists + .items + .into_iter() + .map(QobuzApi::parse_artist) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les tracks favorites de l'utilisateur + pub async fn get_favorite_tracks(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching favorite tracks for user {}", user_id); + + let params = [("user_id", user_id), ("type", "tracks"), ("limit", "1000")]; + + let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?; + + if let Some(tracks) = response.tracks { + Ok(tracks + .items + .into_iter() + .map(|t| QobuzApi::parse_track(t, None)) + .filter(|t| t.streamable) + .collect()) + } else { + Ok(Vec::new()) + } + } + + /// Récupère les playlists de l'utilisateur + pub async fn get_user_playlists(&self) -> Result> { + let user_id = self.ensure_authenticated()?; + debug!("Fetching playlists for user {}", user_id); + + let params = [("user_id", user_id), ("limit", "1000")]; + + let response: UserPlaylistsResponse = + self.get("/playlist/getUserPlaylists", ¶ms).await?; + + Ok(response + .playlists + .items + .into_iter() + .map(QobuzApi::parse_playlist) + .collect()) + } +} diff --git a/pmoqobuz/src/api_rest.rs b/pmoqobuz/src/api_rest.rs new file mode 100644 index 00000000..1d4abab8 --- /dev/null +++ b/pmoqobuz/src/api_rest.rs @@ -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, + #[cfg(feature = "covers")] + pub cover_cache: Option>, +} + +/// Paramètres de recherche +#[cfg(feature = "pmoserver")] +#[derive(Debug, Deserialize)] +pub struct SearchParams { + /// Requête de recherche + pub q: String, + /// Type de recherche (albums, artists, tracks, playlists) + #[serde(rename = "type")] + pub search_type: Option, +} + +/// Paramètres pour featured albums +#[cfg(feature = "pmoserver")] +#[derive(Debug, Deserialize)] +pub struct FeaturedAlbumsParams { + /// ID du genre (optionnel) + pub genre_id: Option, + /// Type (new-releases, ideal-discography, etc.) + #[serde(rename = "type", default = "default_featured_type")] + pub type_: String, +} + +#[cfg(feature = "pmoserver")] +fn default_featured_type() -> String { + "new-releases".to_string() +} + +/// Paramètres pour featured playlists +#[cfg(feature = "pmoserver")] +#[derive(Debug, Deserialize)] +pub struct FeaturedPlaylistsParams { + /// ID du genre (optionnel) + pub genre_id: Option, + /// Tags (optionnel) + pub tags: Option, +} + +/// Crée le router Axum avec tous les endpoints Qobuz +#[cfg(feature = "pmoserver")] +pub fn create_router(state: QobuzState) -> Router { + Router::new() + // Albums + .route("/albums/:id", axum::routing::get(get_album)) + .route("/albums/:id/tracks", axum::routing::get(get_album_tracks)) + // Tracks + .route("/tracks/:id", axum::routing::get(get_track)) + .route("/tracks/:id/stream", axum::routing::get(get_stream_url)) + // Artists + .route("/artists/:id/albums", axum::routing::get(get_artist_albums)) + .route( + "/artists/:id/similar", + axum::routing::get(get_similar_artists), + ) + // Playlists + .route("/playlists/:id", axum::routing::get(get_playlist)) + .route( + "/playlists/:id/tracks", + axum::routing::get(get_playlist_tracks), + ) + // Recherche + .route("/search", axum::routing::get(search)) + // Favoris + .route("/favorites/albums", axum::routing::get(get_favorite_albums)) + .route( + "/favorites/artists", + axum::routing::get(get_favorite_artists), + ) + .route("/favorites/tracks", axum::routing::get(get_favorite_tracks)) + .route( + "/favorites/playlists", + axum::routing::get(get_user_playlists), + ) + // Catalogue + .route("/genres", axum::routing::get(get_genres)) + .route("/featured/albums", axum::routing::get(get_featured_albums)) + .route( + "/featured/playlists", + axum::routing::get(get_featured_playlists), + ) + // Cache + .route("/cache/stats", axum::routing::get(get_cache_stats)) + .with_state(state) +} + +// ============ Handlers ============ + +#[cfg(feature = "pmoserver")] +async fn get_album( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let mut album = state.client.get_album(&id).await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + album = cache_album_image(album, cover_cache).await; + } + + Ok(Json(album)) +} + +#[cfg(feature = "pmoserver")] +async fn get_album_tracks( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let tracks = state.client.get_album_tracks(&id).await?; + Ok(Json(tracks)) +} + +#[cfg(feature = "pmoserver")] +async fn get_track( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let track = state.client.get_track(&id).await?; + Ok(Json(track)) +} + +#[cfg(feature = "pmoserver")] +async fn get_stream_url( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let url = state.client.get_stream_url(&id).await?; + Ok(Json(serde_json::json!({ "url": url }))) +} + +#[cfg(feature = "pmoserver")] +async fn get_artist_albums( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let mut albums = state.client.get_artist_albums(&id).await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + albums = cache_albums_images(albums, cover_cache).await; + } + + Ok(Json(albums)) +} + +#[cfg(feature = "pmoserver")] +async fn get_similar_artists( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let artists = state.client.get_similar_artists(&id).await?; + Ok(Json(artists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_playlist( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let playlist = state.client.get_playlist(&id).await?; + Ok(Json(playlist)) +} + +#[cfg(feature = "pmoserver")] +async fn get_playlist_tracks( + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let tracks = state.client.get_playlist_tracks(&id).await?; + Ok(Json(tracks)) +} + +#[cfg(feature = "pmoserver")] +async fn search( + State(state): State, + Query(params): Query, +) -> Result, AppError> { + let mut result = state + .client + .search(¶ms.q, params.search_type.as_deref()) + .await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + result.albums = cache_albums_images(result.albums, cover_cache).await; + } + + Ok(Json(result)) +} + +#[cfg(feature = "pmoserver")] +async fn get_favorite_albums( + State(state): State, +) -> Result>, AppError> { + let mut albums = state.client.get_favorite_albums().await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + albums = cache_albums_images(albums, cover_cache).await; + } + + Ok(Json(albums)) +} + +#[cfg(feature = "pmoserver")] +async fn get_favorite_artists( + State(state): State, +) -> Result>, AppError> { + let artists = state.client.get_favorite_artists().await?; + Ok(Json(artists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_favorite_tracks( + State(state): State, +) -> Result>, AppError> { + let tracks = state.client.get_favorite_tracks().await?; + Ok(Json(tracks)) +} + +#[cfg(feature = "pmoserver")] +async fn get_user_playlists( + State(state): State, +) -> Result>, AppError> { + let playlists = state.client.get_user_playlists().await?; + Ok(Json(playlists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_genres(State(state): State) -> Result>, AppError> { + let genres = state.client.get_genres().await?; + Ok(Json(genres)) +} + +#[cfg(feature = "pmoserver")] +async fn get_featured_albums( + State(state): State, + Query(params): Query, +) -> Result>, AppError> { + let mut albums = state + .client + .get_featured_albums(params.genre_id.as_deref(), ¶ms.type_) + .await?; + + #[cfg(feature = "covers")] + if let Some(ref cover_cache) = state.cover_cache { + albums = cache_albums_images(albums, cover_cache).await; + } + + Ok(Json(albums)) +} + +#[cfg(feature = "pmoserver")] +async fn get_featured_playlists( + State(state): State, + Query(params): Query, +) -> Result>, AppError> { + let playlists = state + .client + .get_featured_playlists(params.genre_id.as_deref(), params.tags.as_deref()) + .await?; + Ok(Json(playlists)) +} + +#[cfg(feature = "pmoserver")] +async fn get_cache_stats( + State(state): State, +) -> Result, AppError> { + let stats = state.client.cache().stats().await; + Ok(Json(stats)) +} + +// ============ Helpers pour le cache d'images ============ + +#[cfg(all(feature = "pmoserver", feature = "covers"))] +async fn cache_album_image(mut album: Album, cover_cache: &Arc) -> Album { + if let Some(ref image_url) = album.image { + match cover_cache.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, + cover_cache: &Arc, +) -> Vec { + let mut cached_albums = Vec::with_capacity(albums.len()); + for album in albums { + cached_albums.push(cache_album_image(album, cover_cache).await); + } + cached_albums +} + +// ============ Gestion des erreurs ============ + +#[cfg(feature = "pmoserver")] +struct AppError(QobuzError); + +#[cfg(feature = "pmoserver")] +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match self.0 { + QobuzError::Unauthorized(_) => (StatusCode::UNAUTHORIZED, self.0.to_string()), + QobuzError::NotFound(_) => (StatusCode::NOT_FOUND, self.0.to_string()), + QobuzError::RateLimitExceeded => (StatusCode::TOO_MANY_REQUESTS, self.0.to_string()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()), + }; + + let body = Json(serde_json::json!({ + "error": message + })); + + (status, body).into_response() + } +} + +#[cfg(feature = "pmoserver")] +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} diff --git a/pmoqobuz/src/cache.rs b/pmoqobuz/src/cache.rs new file mode 100644 index 00000000..d7182ab5 --- /dev/null +++ b/pmoqobuz/src/cache.rs @@ -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>, + /// Cache des tracks (TTL: 1 heure) + tracks: Arc>, + /// Cache des artistes (TTL: 1 heure) + artists: Arc>, + /// Cache des playlists (TTL: 30 minutes) + playlists: Arc>, + /// Cache des résultats de recherche (TTL: 15 minutes) + searches: Arc>, + /// Cache des URLs de streaming (TTL: 5 minutes) + stream_urls: Arc>, +} + +impl QobuzCache { + /// Crée un nouveau cache avec les paramètres par défaut + pub fn new() -> Self { + Self::with_capacity(1000) + } + + /// Crée un nouveau cache avec une capacité spécifique + pub fn with_capacity(max_capacity: u64) -> Self { + Self { + albums: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), + tracks: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity * 2) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), + artists: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 2) + .time_to_live(Duration::from_secs(3600)) // 1 heure + .build(), + ), + playlists: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 4) + .time_to_live(Duration::from_secs(1800)) // 30 minutes + .build(), + ), + searches: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 2) + .time_to_live(Duration::from_secs(900)) // 15 minutes + .build(), + ), + stream_urls: Arc::new( + MokaCache::builder() + .max_capacity(max_capacity / 4) + .time_to_live(Duration::from_secs(300)) // 5 minutes + .build(), + ), + } + } + + // ============ Albums ============ + + /// Récupère un album depuis le cache + pub async fn get_album(&self, id: &str) -> Option { + self.albums.get(id).await + } + + /// Ajoute un album au cache + pub async fn put_album(&self, id: String, album: Album) { + self.albums.insert(id, album).await; + } + + /// Invalide un album du cache + pub async fn invalidate_album(&self, id: &str) { + self.albums.invalidate(id).await; + } + + // ============ Tracks ============ + + /// Récupère une track depuis le cache + pub async fn get_track(&self, id: &str) -> Option { + self.tracks.get(id).await + } + + /// Ajoute une track au cache + pub async fn put_track(&self, id: String, track: Track) { + self.tracks.insert(id, track).await; + } + + /// Invalide une track du cache + pub async fn invalidate_track(&self, id: &str) { + self.tracks.invalidate(id).await; + } + + // ============ Artists ============ + + /// Récupère un artiste depuis le cache + pub async fn get_artist(&self, id: &str) -> Option { + self.artists.get(id).await + } + + /// Ajoute un artiste au cache + pub async fn put_artist(&self, id: String, artist: Artist) { + self.artists.insert(id, artist).await; + } + + /// Invalide un artiste du cache + pub async fn invalidate_artist(&self, id: &str) { + self.artists.invalidate(id).await; + } + + // ============ Playlists ============ + + /// Récupère une playlist depuis le cache + pub async fn get_playlist(&self, id: &str) -> Option { + self.playlists.get(id).await + } + + /// Ajoute une playlist au cache + pub async fn put_playlist(&self, id: String, playlist: Playlist) { + self.playlists.insert(id, playlist).await; + } + + /// Invalide une playlist du cache + pub async fn invalidate_playlist(&self, id: &str) { + self.playlists.invalidate(id).await; + } + + // ============ Recherches ============ + + /// Récupère un résultat de recherche depuis le cache + pub async fn get_search(&self, query: &str) -> Option { + self.searches.get(query).await + } + + /// Ajoute un résultat de recherche au cache + pub async fn put_search(&self, query: String, result: SearchResult) { + self.searches.insert(query, result).await; + } + + /// Invalide un résultat de recherche du cache + pub async fn invalidate_search(&self, query: &str) { + self.searches.invalidate(query).await; + } + + // ============ URLs de streaming ============ + + /// Récupère une URL de streaming depuis le cache + pub async fn get_stream_url(&self, track_id: &str) -> Option { + self.stream_urls.get(track_id).await + } + + /// Ajoute une URL de streaming au cache + pub async fn put_stream_url(&self, track_id: String, info: StreamInfo) { + self.stream_urls.insert(track_id, info).await; + } + + /// Invalide une URL de streaming du cache + pub async fn invalidate_stream_url(&self, track_id: &str) { + self.stream_urls.invalidate(track_id).await; + } + + // ============ Maintenance ============ + + /// Vide tous les caches + pub async fn clear_all(&self) { + self.albums.invalidate_all(); + self.tracks.invalidate_all(); + self.artists.invalidate_all(); + self.playlists.invalidate_all(); + self.searches.invalidate_all(); + self.stream_urls.invalidate_all(); + } + + /// Retourne des statistiques sur le cache + pub async fn stats(&self) -> CacheStats { + CacheStats { + albums_count: self.albums.entry_count(), + tracks_count: self.tracks.entry_count(), + artists_count: self.artists.entry_count(), + playlists_count: self.playlists.entry_count(), + searches_count: self.searches.entry_count(), + stream_urls_count: self.stream_urls.entry_count(), + } + } +} + +impl Default for QobuzCache { + fn default() -> Self { + Self::new() + } +} + +/// Statistiques du cache +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CacheStats { + /// Nombre d'albums en cache + pub albums_count: u64, + /// Nombre de tracks en cache + pub tracks_count: u64, + /// Nombre d'artistes en cache + pub artists_count: u64, + /// Nombre de playlists en cache + pub playlists_count: u64, + /// Nombre de recherches en cache + pub searches_count: u64, + /// Nombre d'URLs de streaming en cache + pub stream_urls_count: u64, +} + +impl CacheStats { + /// Retourne le nombre total d'entrées en cache + pub fn total_count(&self) -> u64 { + self.albums_count + + self.tracks_count + + self.artists_count + + self.playlists_count + + self.searches_count + + self.stream_urls_count + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Artist; + + #[tokio::test] + async fn test_cache_basic_operations() { + let cache = QobuzCache::new(); + + let artist = Artist::new("123", "Test Artist"); + + // Test insertion + cache.put_artist("123".to_string(), artist.clone()).await; + + // Test récupération + let retrieved = cache.get_artist("123").await; + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().name, "Test Artist"); + + // Test invalidation + cache.invalidate_artist("123").await; + let after_invalidation = cache.get_artist("123").await; + assert!(after_invalidation.is_none()); + } + + #[tokio::test] + async fn test_cache_stats() { + let cache = QobuzCache::new(); + + let artist = Artist::new("123", "Test Artist"); + cache.put_artist("123".to_string(), artist).await; + + let stats = cache.stats().await; + assert_eq!(stats.artists_count, 1); + assert_eq!(stats.albums_count, 0); + } + + #[tokio::test] + async fn test_cache_clear_all() { + let cache = QobuzCache::new(); + + let artist = Artist::new("123", "Test Artist"); + cache.put_artist("123".to_string(), artist).await; + + cache.clear_all().await; + + let stats = cache.stats().await; + assert_eq!(stats.total_count(), 0); + } +} diff --git a/pmoqobuz/src/client.rs b/pmoqobuz/src/client.rs new file mode 100644 index 00000000..208026ee --- /dev/null +++ b/pmoqobuz/src/client.rs @@ -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, + /// Informations d'authentification + auth_info: Option, +} + +impl QobuzClient { + /// Crée un nouveau client et authentifie avec les credentials fournis + /// + /// # Arguments + /// + /// * `username` - Email ou nom d'utilisateur Qobuz + /// * `password` - Mot de passe + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzClient; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let client = QobuzClient::new("user@example.com", "password").await?; + /// Ok(()) + /// } + /// ``` + pub async fn new(username: &str, password: &str) -> Result { + Self::with_app_id(DEFAULT_APP_ID, username, password).await + } + + /// Crée un nouveau client avec un App ID personnalisé + pub async fn with_app_id(app_id: &str, username: &str, password: &str) -> Result { + info!("Creating Qobuz client with app ID: {}", app_id); + + let mut api = QobuzApi::new(app_id)?; + let auth_info = api.login(username, password).await?; + + Ok(Self { + api, + cache: Arc::new(QobuzCache::new()), + auth_info: Some(auth_info), + }) + } + + /// Crée un client en utilisant la configuration de pmoconfig + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzClient; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let client = QobuzClient::from_config().await?; + /// Ok(()) + /// } + /// ``` + pub async fn from_config() -> Result { + let config = pmoconfig::get_config(); + Self::from_config_obj(config.as_ref()).await + } + + /// Crée un client depuis un objet Config spécifique + pub async fn from_config_obj(config: &Config) -> Result { + let (username, password) = config.get_qobuz_credentials()?; + Self::new(&username, &password).await + } + + /// Définit le format audio par défaut + pub fn set_format(&mut self, format: AudioFormat) { + self.api.set_format(format); + } + + /// Retourne le format audio configuré + pub fn format(&self) -> AudioFormat { + self.api.format() + } + + /// Retourne les informations d'authentification + pub fn auth_info(&self) -> Option<&AuthInfo> { + self.auth_info.as_ref() + } + + /// Retourne une référence au cache + pub fn cache(&self) -> Arc { + self.cache.clone() + } + + // ============ Albums ============ + + /// Récupère un album par son ID + pub async fn get_album(&self, album_id: &str) -> Result { + // Vérifier le cache d'abord + if let Some(album) = self.cache.get_album(album_id).await { + debug!("Album {} found in cache", album_id); + return Ok(album); + } + + // Sinon, récupérer depuis l'API + let album = self.api.get_album(album_id).await?; + + // Mettre en cache + self.cache.put_album(album_id.to_string(), album.clone()).await; + + Ok(album) + } + + /// Récupère les tracks d'un album + pub async fn get_album_tracks(&self, album_id: &str) -> Result> { + let tracks = self.api.get_album_tracks(album_id).await?; + + // Mettre les tracks en cache + for track in &tracks { + self.cache + .put_track(track.id.clone(), track.clone()) + .await; + } + + Ok(tracks) + } + + // ============ Tracks ============ + + /// Récupère une track par son ID + pub async fn get_track(&self, track_id: &str) -> Result { + if let Some(track) = self.cache.get_track(track_id).await { + debug!("Track {} found in cache", track_id); + return Ok(track); + } + + let track = self.api.get_track(track_id).await?; + self.cache.put_track(track_id.to_string(), track.clone()).await; + + Ok(track) + } + + /// Récupère l'URL de streaming d'une track + pub async fn get_stream_url(&self, track_id: &str) -> Result { + // Vérifier le cache d'abord + if let Some(info) = self.cache.get_stream_url(track_id).await { + if info.expires_at > chrono::Utc::now() { + debug!("Stream URL for track {} found in cache", track_id); + return Ok(info.url); + } + } + + // Sinon, récupérer depuis l'API + let info = self.api.get_file_url(track_id).await?; + let url = info.url.clone(); + + // Mettre en cache + self.cache + .put_stream_url(track_id.to_string(), info) + .await; + + Ok(url) + } + + // ============ Artists ============ + + /// Récupère un artiste par son ID + pub async fn get_artist(&self, artist_id: &str) -> Result { + if let Some(artist) = self.cache.get_artist(artist_id).await { + debug!("Artist {} found in cache", artist_id); + return Ok(artist); + } + + // Pour récupérer un artiste, on doit passer par get_artist_albums + let albums = self.api.get_artist_albums(artist_id).await?; + + if let Some(first_album) = albums.first() { + let artist = first_album.artist.clone(); + self.cache + .put_artist(artist_id.to_string(), artist.clone()) + .await; + Ok(artist) + } else { + Err(QobuzError::NotFound(format!("Artist {} not found", artist_id))) + } + } + + /// Récupère les albums d'un artiste + pub async fn get_artist_albums(&self, artist_id: &str) -> Result> { + self.api.get_artist_albums(artist_id).await + } + + /// Récupère les artistes similaires + pub async fn get_similar_artists(&self, artist_id: &str) -> Result> { + self.api.get_similar_artists(artist_id).await + } + + // ============ Playlists ============ + + /// Récupère une playlist par son ID + pub async fn get_playlist(&self, playlist_id: &str) -> Result { + if let Some(playlist) = self.cache.get_playlist(playlist_id).await { + debug!("Playlist {} found in cache", playlist_id); + return Ok(playlist); + } + + let playlist = self.api.get_playlist(playlist_id).await?; + self.cache + .put_playlist(playlist_id.to_string(), playlist.clone()) + .await; + + Ok(playlist) + } + + /// Récupère les tracks d'une playlist + pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result> { + self.api.get_playlist_tracks(playlist_id).await + } + + // ============ Catalogue ============ + + /// Récupère la liste des genres + pub async fn get_genres(&self) -> Result> { + self.api.get_genres().await + } + + /// Récupère les albums featured (nouveautés, éditeur, etc.) + pub async fn get_featured_albums( + &self, + genre_id: Option<&str>, + type_: &str, + ) -> Result> { + self.api.get_featured_albums(genre_id, type_).await + } + + /// Récupère les playlists featured + pub async fn get_featured_playlists( + &self, + genre_id: Option<&str>, + tags: Option<&str>, + ) -> Result> { + self.api.get_featured_playlists(genre_id, tags).await + } + + // ============ Recherche ============ + + /// Recherche dans le catalogue Qobuz + /// + /// # Arguments + /// + /// * `query` - Termes de recherche + /// * `type_` - Type de recherche : None (tous), Some("albums"), Some("artists"), Some("tracks"), Some("playlists") + pub async fn search(&self, query: &str, type_: Option<&str>) -> Result { + // Créer une clé de cache + let cache_key = format!("{}:{}", query, type_.unwrap_or("all")); + + // Vérifier le cache + if let Some(result) = self.cache.get_search(&cache_key).await { + debug!("Search results for '{}' found in cache", query); + return Ok(result); + } + + // Sinon, rechercher via l'API + let result = self.api.search(query, type_).await?; + + // Mettre en cache + self.cache.put_search(cache_key, result.clone()).await; + + Ok(result) + } + + /// Recherche des albums + pub async fn search_albums(&self, query: &str) -> Result> { + let result = self.search(query, Some("albums")).await?; + Ok(result.albums) + } + + /// Recherche des artistes + pub async fn search_artists(&self, query: &str) -> Result> { + let result = self.search(query, Some("artists")).await?; + Ok(result.artists) + } + + /// Recherche des tracks + pub async fn search_tracks(&self, query: &str) -> Result> { + let result = self.search(query, Some("tracks")).await?; + Ok(result.tracks) + } + + /// Recherche des playlists + pub async fn search_playlists(&self, query: &str) -> Result> { + let result = self.search(query, Some("playlists")).await?; + Ok(result.playlists) + } + + // ============ Favoris ============ + + /// Récupère les albums favoris de l'utilisateur + pub async fn get_favorite_albums(&self) -> Result> { + self.api.get_favorite_albums().await + } + + /// Récupère les artistes favoris de l'utilisateur + pub async fn get_favorite_artists(&self) -> Result> { + self.api.get_favorite_artists().await + } + + /// Récupère les tracks favorites de l'utilisateur + pub async fn get_favorite_tracks(&self) -> Result> { + self.api.get_favorite_tracks().await + } + + /// Récupère les playlists de l'utilisateur + pub async fn get_user_playlists(&self) -> Result> { + self.api.get_user_playlists().await + } +} + +#[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); + } +} diff --git a/pmoqobuz/src/didl.rs b/pmoqobuz/src/didl.rs new file mode 100644 index 00000000..b7090684 --- /dev/null +++ b/pmoqobuz/src/didl.rs @@ -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; + + /// Convertit l'objet en Item DIDL + fn to_didl_item(&self, parent_id: &str) -> Result; +} + +impl ToDIDL for Album { + /// Convertit un album en Container DIDL + /// + /// # Arguments + /// + /// * `parent_id` - ID du container parent + /// + /// # Exemple + /// + /// ```rust,ignore + /// let album = client.get_album("12345").await?; + /// let container = album.to_didl_container("0$qobuz$albums")?; + /// ``` + fn to_didl_container(&self, parent_id: &str) -> Result { + let id = format!("0$qobuz$album${}", self.id); + + Ok(Container { + id, + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + child_count: self.tracks_count.map(|c| c.to_string()), + title: self.formatted_title(), + class: "object.container.album.musicAlbum".to_string(), + containers: Vec::new(), + items: Vec::new(), + }) + } + + /// Un album ne peut pas être converti directement en Item + fn to_didl_item(&self, _parent_id: &str) -> Result { + Err(QobuzError::DidlExport( + "Album cannot be converted to Item, use to_didl_container instead".to_string(), + )) + } +} + +impl ToDIDL for Track { + /// Une track ne peut pas être convertie en Container + fn to_didl_container(&self, _parent_id: &str) -> Result { + Err(QobuzError::DidlExport( + "Track cannot be converted to Container, use to_didl_item instead".to_string(), + )) + } + + /// Convertit une track en Item DIDL + /// + /// # Arguments + /// + /// * `parent_id` - ID du container parent + /// + /// # Exemple + /// + /// ```rust,ignore + /// let track = client.get_track("98765").await?; + /// let item = track.to_didl_item("0$qobuz$album$12345")?; + /// ``` + fn to_didl_item(&self, parent_id: &str) -> Result { + let id = format!("0$qobuz$track${}", self.id); + + // Déterminer l'artiste à afficher + let artist_name = self + .display_artist() + .map(|a| a.name.clone()) + .or_else(|| self.album.as_ref().map(|a| a.artist.name.clone())); + + // Déterminer l'album + let album_name = self.album_name().map(|s| s.to_string()); + + // Déterminer l'image de couverture + let album_art = self + .album + .as_ref() + .and_then(|a| a.image_cached.clone().or_else(|| a.image.clone())); + + // Créer la ressource (URL de streaming) + // Note: L'URL sera remplie plus tard via get_stream_url + let resource = Resource { + protocol_info: format!( + "http-get:*:{}:*", + self.mime_type.as_deref().unwrap_or("audio/flac") + ), + bits_per_sample: self.bit_depth.map(|b| b.to_string()), + sample_frequency: self.sample_rate.map(|r| r.to_string()), + nr_audio_channels: self.channels.map(|c| c.to_string()), + duration: Some(format_duration(self.duration)), + url: format!("qobuz://track/{}", self.id), // URL symbolique + }; + + Ok(Item { + id, + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + title: self.title.clone(), + creator: artist_name.clone(), + class: "object.item.audioItem.musicTrack".to_string(), + artist: artist_name, + album: album_name, + genre: None, // Qobuz ne fournit pas le genre au niveau track + album_art, + album_art_pk: None, + date: self + .album + .as_ref() + .and_then(|a| a.release_date.clone()), + original_track_number: Some(self.track_number.to_string()), + resources: vec![resource], + descriptions: Vec::new(), + }) + } +} + +impl ToDIDL for Playlist { + /// Convertit une playlist en Container DIDL + fn to_didl_container(&self, parent_id: &str) -> Result { + let id = format!("0$qobuz$playlist${}", self.id); + + Ok(Container { + id, + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + child_count: self.tracks_count.map(|c| c.to_string()), + title: self.name.clone(), + class: "object.container.playlistContainer".to_string(), + containers: Vec::new(), + items: Vec::new(), + }) + } + + /// Une playlist ne peut pas être convertie en Item + fn to_didl_item(&self, _parent_id: &str) -> Result { + Err(QobuzError::DidlExport( + "Playlist cannot be converted to Item, use to_didl_container instead".to_string(), + )) + } +} + +/// Formate une durée en secondes au format HH:MM:SS +fn format_duration(seconds: u32) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + format!("{:02}:{:02}:{:02}", hours, minutes, secs) +} + +/// Convertit une liste de tracks en items DIDL +pub fn tracks_to_didl_items(tracks: &[Track], parent_id: &str) -> Result> { + tracks + .iter() + .map(|track| track.to_didl_item(parent_id)) + .collect() +} + +/// Convertit une liste d'albums en containers DIDL +pub fn albums_to_didl_containers(albums: &[Album], parent_id: &str) -> Result> { + albums + .iter() + .map(|album| album.to_didl_container(parent_id)) + .collect() +} + +/// Convertit une liste de playlists en containers DIDL +pub fn playlists_to_didl_containers( + playlists: &[Playlist], + parent_id: &str, +) -> Result> { + playlists + .iter() + .map(|playlist| playlist.to_didl_container(parent_id)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Album, Artist, Track}; + + #[test] + fn test_album_to_didl_container() { + let album = Album { + id: "123".to_string(), + title: "Test Album".to_string(), + artist: Artist::new("456", "Test Artist"), + tracks_count: Some(10), + duration: Some(3000), + release_date: Some("2024-01-01".to_string()), + image: None, + image_cached: None, + streamable: true, + description: None, + maximum_sampling_rate: Some(96000.0), + maximum_bit_depth: Some(24), + genres: vec![], + label: None, + }; + + let container = album.to_didl_container("parent").unwrap(); + assert_eq!(container.id, "0$qobuz$album$123"); + assert_eq!(container.parent_id, "parent"); + assert!(container.title.contains("Test Album")); + } + + #[test] + fn test_track_to_didl_item() { + let track = Track { + id: "789".to_string(), + title: "Test Track".to_string(), + performer: Some(Artist::new("456", "Test Artist")), + album: None, + duration: 180, + track_number: 1, + media_number: 1, + streamable: true, + mime_type: Some("audio/flac".to_string()), + sample_rate: Some(44100), + bit_depth: Some(16), + channels: Some(2), + }; + + let item = track.to_didl_item("parent").unwrap(); + assert_eq!(item.id, "0$qobuz$track$789"); + assert_eq!(item.parent_id, "parent"); + assert_eq!(item.title, "Test Track"); + } + + #[test] + fn test_format_duration() { + assert_eq!(format_duration(0), "00:00:00"); + assert_eq!(format_duration(90), "00:01:30"); + assert_eq!(format_duration(3665), "01:01:05"); + } +} diff --git a/pmoqobuz/src/error.rs b/pmoqobuz/src/error.rs new file mode 100644 index 00000000..84479778 --- /dev/null +++ b/pmoqobuz/src/error.rs @@ -0,0 +1,83 @@ +//! Gestion des erreurs pour le client Qobuz + +use thiserror::Error; + +/// Type Result personnalisé pour pmoqobuz +pub type Result = std::result::Result; + +/// Erreurs possibles lors de l'utilisation du client Qobuz +#[derive(Error, Debug)] +pub enum QobuzError { + /// Erreur d'authentification (credentials invalides) + #[error("Authentication failed: {0}")] + Unauthorized(String), + + /// Ressource non trouvée (album, track, etc.) + #[error("Resource not found: {0}")] + NotFound(String), + + /// Erreur HTTP + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + /// Erreur de parsing JSON + #[error("JSON parsing error: {0}")] + JsonParse(#[from] serde_json::Error), + + /// Erreur de configuration + #[error("Configuration error: {0}")] + Config(#[from] anyhow::Error), + + /// Erreur de l'API Qobuz + #[error("Qobuz API error (code {code}): {message}")] + ApiError { code: u16, message: String }, + + /// Quota dépassé (rate limiting) + #[error("Rate limit exceeded, please try again later")] + RateLimitExceeded, + + /// Contenu non disponible dans la région de l'utilisateur + #[error("Content not available in your region")] + NotAvailable, + + /// Abonnement insuffisant pour accéder au contenu + #[error("Subscription level insufficient: {0}")] + SubscriptionRequired(String), + + /// Erreur de cache + #[error("Cache error: {0}")] + Cache(String), + + /// Erreur d'export DIDL + #[error("DIDL export error: {0}")] + DidlExport(String), + + /// Erreur générique + #[error("Qobuz error: {0}")] + Other(String), +} + +impl QobuzError { + /// Crée une erreur API depuis un code de statut HTTP et un message + pub fn from_status_code(code: u16, message: impl Into) -> Self { + match code { + 401 | 403 => Self::Unauthorized(message.into()), + 404 => Self::NotFound(message.into()), + 429 => Self::RateLimitExceeded, + _ => Self::ApiError { + code, + message: message.into(), + }, + } + } + + /// Vérifie si l'erreur est une erreur de credentials + pub fn is_auth_error(&self) -> bool { + matches!(self, QobuzError::Unauthorized(_)) + } + + /// Vérifie si l'erreur est une erreur de rate limiting + pub fn is_rate_limit(&self) -> bool { + matches!(self, QobuzError::RateLimitExceeded) + } +} diff --git a/pmoqobuz/src/lib.rs b/pmoqobuz/src/lib.rs new file mode 100644 index 00000000..2eb4649a --- /dev/null +++ b/pmoqobuz/src/lib.rs @@ -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; diff --git a/pmoqobuz/src/models.rs b/pmoqobuz/src/models.rs new file mode 100644 index 00000000..36a90a9d --- /dev/null +++ b/pmoqobuz/src/models.rs @@ -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, + /// URL de l'image cachée localement (via pmocovers) + #[serde(skip)] + pub image_cached: Option, +} + +/// Représente un album Qobuz +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Album { + /// Identifiant unique de l'album + pub id: String, + /// Titre de l'album + pub title: String, + /// Artiste principal de l'album + pub artist: Artist, + /// Nombre de pistes + #[serde(default)] + pub tracks_count: Option, + /// Durée totale en secondes + #[serde(default)] + pub duration: Option, + /// Date de sortie (format ISO 8601) + #[serde(default)] + pub release_date: Option, + /// URL de l'image de couverture + #[serde(default)] + pub image: Option, + /// URL de l'image cachée localement (via pmocovers) + #[serde(skip)] + pub image_cached: Option, + /// Indique si l'album est disponible pour le streaming + #[serde(default = "default_true")] + pub streamable: bool, + /// Description de l'album + #[serde(default)] + pub description: Option, + /// Taux d'échantillonnage maximum (Hz) + #[serde(default)] + pub maximum_sampling_rate: Option, + /// Profondeur de bits maximale + #[serde(default)] + pub maximum_bit_depth: Option, + /// Genre(s) de l'album + #[serde(default)] + pub genres: Vec, + /// Label de l'album + #[serde(default)] + pub label: Option, +} + +/// Représente une piste (track) Qobuz +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Track { + /// Identifiant unique de la piste + pub id: String, + /// Titre de la piste + pub title: String, + /// Artiste de la piste (peut différer de l'artiste de l'album) + pub performer: Option, + /// Album contenant la piste + pub album: Option, + /// Durée en secondes + pub duration: u32, + /// Numéro de piste + pub track_number: u32, + /// Numéro de disque (pour les albums multi-disques) + pub media_number: u32, + /// Indique si la piste est disponible pour le streaming + #[serde(default = "default_true")] + pub streamable: bool, + /// Type MIME du fichier audio (déterminé après obtention de l'URL) + #[serde(skip)] + pub mime_type: Option, + /// Fréquence d'échantillonnage (Hz) + #[serde(skip)] + pub sample_rate: Option, + /// Profondeur de bits + #[serde(skip)] + pub bit_depth: Option, + /// Nombre de canaux audio + #[serde(skip)] + pub channels: Option, +} + +/// Représente une playlist Qobuz +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Playlist { + /// Identifiant unique de la playlist + pub id: String, + /// Nom de la playlist + pub name: String, + /// Description de la playlist + #[serde(default)] + pub description: Option, + /// Nombre de pistes + #[serde(default)] + pub tracks_count: Option, + /// Durée totale en secondes + #[serde(default)] + pub duration: Option, + /// URL de l'image de la playlist + #[serde(default)] + pub image: Option, + /// URL de l'image cachée localement + #[serde(skip)] + pub image_cached: Option, + /// Indique si c'est une playlist publique + #[serde(default)] + pub is_public: bool, + /// Propriétaire de la playlist + #[serde(default)] + pub owner: Option, +} + +/// Propriétaire d'une playlist +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaylistOwner { + /// Identifiant de l'utilisateur + pub id: u64, + /// Nom de l'utilisateur + pub name: String, +} + +/// Représente un genre musical +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Genre { + /// Identifiant du genre (peut être None pour "All Genres") + pub id: Option, + /// Nom du genre + pub name: String, + /// Genres enfants + #[serde(default)] + pub children: Vec, +} + +/// Résultats de recherche +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SearchResult { + /// Albums trouvés + #[serde(default)] + pub albums: Vec, + /// Artistes trouvés + #[serde(default)] + pub artists: Vec, + /// Pistes trouvées + #[serde(default)] + pub tracks: Vec, + /// Playlists trouvées + #[serde(default)] + pub playlists: Vec, +} + +/// Informations sur un fichier de streaming +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamInfo { + /// URL de streaming + pub url: String, + /// Type MIME + pub mime_type: String, + /// Fréquence d'échantillonnage (Hz) + pub sampling_rate: u32, + /// Profondeur de bits + pub bit_depth: u32, + /// Format ID Qobuz + pub format_id: u8, + /// Date d'expiration de l'URL + #[serde(skip)] + pub expires_at: DateTime, +} + +/// Format audio demandé pour le streaming +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +#[allow(non_camel_case_types)] +pub enum AudioFormat { + /// MP3 320 kbps + Mp3_320 = 5, + /// FLAC 16 bit / 44.1 kHz (CD Quality) + Flac_Lossless = 6, + /// FLAC 24 bit / jusqu'à 96 kHz (Hi-Res) + Flac_HiRes_96 = 7, + /// FLAC 24 bit / jusqu'à 192 kHz (Hi-Res+) + Flac_HiRes_192 = 27, +} + +impl AudioFormat { + /// Retourne l'ID du format pour l'API Qobuz + pub fn id(&self) -> u8 { + *self as u8 + } + + /// Retourne une description lisible du format + pub fn description(&self) -> &'static str { + match self { + AudioFormat::Mp3_320 => "MP3 320 kbps", + AudioFormat::Flac_Lossless => "FLAC 16 bit / 44.1 kHz", + AudioFormat::Flac_HiRes_96 => "FLAC 24 bit / up to 96 kHz", + AudioFormat::Flac_HiRes_192 => "FLAC 24 bit / up to 192 kHz", + } + } + + /// Retourne le type MIME associé + pub fn mime_type(&self) -> &'static str { + match self { + AudioFormat::Mp3_320 => "audio/mpeg", + _ => "audio/flac", + } + } +} + +impl Default for AudioFormat { + fn default() -> Self { + AudioFormat::Flac_Lossless + } +} + +// Helper functions +fn default_true() -> bool { + true +} + +impl Artist { + /// Crée un nouvel artiste avec un ID et un nom + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + image: None, + image_cached: None, + } + } +} + +impl Album { + /// Retourne un titre formaté avec les informations audio si disponibles + pub fn formatted_title(&self) -> String { + if let (Some(rate), Some(depth)) = (self.maximum_sampling_rate, self.maximum_bit_depth) { + format!("{} ({:.0}/{} bit)", self.title, rate / 1000.0, depth) + } else { + self.title.clone() + } + } + + /// Vérifie si l'album est disponible pour le streaming + pub fn is_available(&self) -> bool { + self.streamable + } +} + +impl Track { + /// Retourne l'artiste à afficher (performer ou artiste de l'album) + pub fn display_artist(&self) -> Option<&Artist> { + self.performer + .as_ref() + .or_else(|| self.album.as_ref().map(|a| &a.artist)) + } + + /// Retourne le nom de l'album si disponible + pub fn album_name(&self) -> Option<&str> { + self.album.as_ref().map(|a| a.title.as_str()) + } + + /// Vérifie si la piste est disponible pour le streaming + pub fn is_available(&self) -> bool { + self.streamable + } +} + +impl SearchResult { + /// Crée un résultat de recherche vide + pub fn new() -> Self { + Self::default() + } + + /// Retourne le nombre total de résultats + pub fn total_count(&self) -> usize { + self.albums.len() + self.artists.len() + self.tracks.len() + self.playlists.len() + } + + /// Vérifie si la recherche n'a retourné aucun résultat + pub fn is_empty(&self) -> bool { + self.total_count() == 0 + } +} diff --git a/pmoqobuz/src/pmoserver_ext.rs b/pmoqobuz/src/pmoserver_ext.rs new file mode 100644 index 00000000..64d57b7b --- /dev/null +++ b/pmoqobuz/src/pmoserver_ext.rs @@ -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` - Instance partagée du client + /// + /// # Routes enregistrées + /// + /// - `GET /qobuz/albums/{id}` - Détails d'un album + /// - `GET /qobuz/albums/{id}/tracks` - Tracks d'un album + /// - `GET /qobuz/tracks/{id}` - Détails d'une track + /// - `GET /qobuz/tracks/{id}/stream` - URL de streaming + /// - `GET /qobuz/artists/{id}` - Détails d'un artiste + /// - `GET /qobuz/artists/{id}/albums` - Albums d'un artiste + /// - `GET /qobuz/playlists/{id}` - Détails d'une playlist + /// - `GET /qobuz/playlists/{id}/tracks` - Tracks d'une playlist + /// - `GET /qobuz/search` - Recherche (query params: q, type) + /// - `GET /qobuz/favorites/albums` - Albums favoris + /// - `GET /qobuz/favorites/artists` - Artistes favoris + /// - `GET /qobuz/favorites/tracks` - Tracks favoris + /// - `GET /qobuz/favorites/playlists` - Playlists utilisateur + /// - `GET /qobuz/genres` - Liste des genres + /// - `GET /qobuz/featured/albums` - Albums featured + /// - `GET /qobuz/featured/playlists` - Playlists featured + /// - `GET /qobuz/cache/stats` - Statistiques du cache + /// - `GET /swagger-ui` - Documentation interactive + async fn init_qobuz_client(&mut self, username: &str, password: &str) + -> Result>; + + /// Initialise le client Qobuz avec la configuration par défaut + /// + /// Utilise automatiquement les credentials de `pmoconfig::Config` : + /// - `accounts.qobuz.username` pour le nom d'utilisateur + /// - `accounts.qobuz.password` pour le mot de passe + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du client + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzServerExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // Utilise automatiquement la config + /// server.init_qobuz_client_configured().await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + async fn init_qobuz_client_configured(&mut self) -> Result>; + + /// Initialise le client Qobuz avec intégration pmocovers + /// + /// Les images d'albums seront automatiquement ajoutées au cache pmocovers fourni. + /// + /// # Arguments + /// + /// * `username` - Email ou nom d'utilisateur Qobuz + /// * `password` - Mot de passe + /// * `cover_cache` - Instance du cache pmocovers à utiliser + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du client avec cache d'images + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzServerExt; + /// use pmocovers::CoverCacheExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // D'abord initialiser le cache d'images + /// let cache = server.init_cover_cache_configured().await?; + /// + /// // Puis initialiser Qobuz avec le cache + /// server.init_qobuz_client_with_covers("user", "pass", cache).await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "covers")] + async fn init_qobuz_client_with_covers( + &mut self, + username: &str, + password: &str, + cover_cache: Arc, + ) -> Result>; + + /// Initialise le client Qobuz avec intégration pmocovers depuis la configuration + /// + /// # Arguments + /// + /// * `cover_cache` - Instance du cache pmocovers à utiliser + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmoqobuz::QobuzServerExt; + /// use pmocovers::CoverCacheExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // D'abord initialiser le cache + /// let cache = server.init_cover_cache_configured().await?; + /// + /// // Puis initialiser Qobuz avec le cache + /// server.init_qobuz_client_configured_with_covers(cache).await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + #[cfg(feature = "covers")] + async fn init_qobuz_client_configured_with_covers( + &mut self, + cover_cache: Arc, + ) -> Result>; +} + +// L'implémentation du trait sera dans un module séparé (pmoserver_impl.rs) +// pour éviter les dépendances circulaires diff --git a/pmoqobuz/src/pmoserver_impl.rs b/pmoqobuz/src/pmoserver_impl.rs new file mode 100644 index 00000000..b5c3e4ae --- /dev/null +++ b/pmoqobuz/src/pmoserver_impl.rs @@ -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> { + info!("Initializing Qobuz client for user: {}", username); + + // Créer le client Qobuz + let client = QobuzClient::new(username, password).await?; + let client = Arc::new(client); + + // Créer l'état de l'API sans cache d'images + let state = QobuzState { + client: client.clone(), + #[cfg(feature = "covers")] + cover_cache: None, + }; + + // Créer le router et l'enregistrer + let router = create_router(state); + self.add_router("/qobuz", router).await; + + info!("Qobuz client initialized successfully"); + info!("API endpoints available at /qobuz/*"); + + Ok(client) + } + + async fn init_qobuz_client_configured(&mut self) -> Result> { + info!("Initializing Qobuz client from configuration"); + + // Récupérer les credentials depuis la config + let config = pmoconfig::get_config(); + let (username, password) = config.get_qobuz_credentials()?; + + self.init_qobuz_client(&username, &password).await + } + + #[cfg(feature = "covers")] + async fn init_qobuz_client_with_covers( + &mut self, + username: &str, + password: &str, + cover_cache: Arc, + ) -> Result> { + info!("Initializing Qobuz client with pmocovers integration"); + + // Créer le client Qobuz + let client = QobuzClient::new(username, password).await?; + let client = Arc::new(client); + + info!("pmocovers integration enabled - album images will be cached automatically"); + + // Créer l'état de l'API avec le cache + let state = QobuzState { + client: client.clone(), + cover_cache: Some(cover_cache), + }; + + // Créer le router et l'enregistrer + let router = create_router(state); + self.add_router("/qobuz", router).await; + + info!("Qobuz client initialized successfully with covers"); + info!("API endpoints available at /qobuz/*"); + + Ok(client) + } + + #[cfg(feature = "covers")] + async fn init_qobuz_client_configured_with_covers( + &mut self, + cover_cache: Arc, + ) -> Result> { + info!("Initializing Qobuz client from configuration with pmocovers"); + + // Récupérer les credentials depuis la config + let config = pmoconfig::get_config(); + let (username, password) = config.get_qobuz_credentials()?; + + self.init_qobuz_client_with_covers(&username, &password, cover_cache) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trait_implemented() { + // Ce test vérifie simplement que le trait est bien implémenté + // Les tests fonctionnels nécessiteraient un serveur et des credentials réels + } +}