From f9b1265dbc7e03b7b917ca32e828872221a32504 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 26 Oct 2025 13:44:41 +0100 Subject: [PATCH] On continue le refactoring des sources --- Cargo.lock | 1 + PMOMusic/src/main.rs | 24 ++--- pmoaudiocache/src/cache.rs | 4 +- pmoaudiocache/src/flac.rs | 150 +++++++++++++++++++++++++++ pmoaudiocache/src/lib.rs | 144 ++++++++------------------ pmocache/src/cache.rs | 12 +-- pmocache/src/lib.rs | 121 ++++++++++------------ pmocovers/src/lib.rs | 15 +++ pmoparadise/Cargo.toml | 8 +- pmoparadise/src/config_ext.rs | 155 ++++++++++++++++++++++++++++ pmoparadise/src/lib.rs | 6 ++ pmoparadise/src/paradise/config.rs | 19 ++-- pmoparadise/src/paradise/history.rs | 100 ++---------------- pmoparadise/src/paradise/mod.rs | 5 +- pmoparadise/src/source.rs | 33 +++++- pmoserver/Cargo.toml | 1 + pmoserver/src/lib.rs | 128 +++++++++++++++++++++++ pmoupnp/src/upnp_server.rs | 46 +++++---- 18 files changed, 647 insertions(+), 325 deletions(-) create mode 100644 pmoaudiocache/src/flac.rs create mode 100644 pmoparadise/src/config_ext.rs diff --git a/Cargo.lock b/Cargo.lock index 748c01ad..4b3796a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2575,6 +2575,7 @@ dependencies = [ "axum-server", "futures", "futures-util", + "once_cell", "pmoconfig", "rust-embed", "serde", diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index 0ab5a884..f2c4afdd 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -3,16 +3,14 @@ use pmomediarenderer::MEDIA_RENDERER; use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt}; use pmoserver::Server; use pmosource::MusicSourceExt; -use pmoupnp::{UpnpServerExt, upnp_api::UpnpApiExt}; +use pmoupnp::UpnpServerExt; use tracing::info; #[tokio::main] async fn main() -> Result<(), Box> { // ========== PHASE 1 : Infrastructure UPnP ========== - let mut server = Server::create_upnp_server().await?; - - // Routes personnalisées de l'application - server +let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application + server.write().await .add_route("/info", || async { serde_json::json!({"version": "1.0.0"}) }) @@ -20,7 +18,7 @@ async fn main() -> Result<(), Box> { // Initialiser le système de gestion des sources musicales avec API REST info!("📡 Initializing music sources management system..."); - server + server.write().await .init_music_sources() .await .expect("Failed to initialize music sources API"); @@ -36,12 +34,12 @@ async fn main() -> Result<(), Box> { // } // Enregistrer Radio Paradise (inclut l'initialisation de l'API) - if let Err(e) = server.register_paradise().await { + if let Err(e) = server.write().await.register_paradise().await { tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e); } // Lister toutes les sources enregistrées - let sources = server.list_music_sources().await; + let sources = server.read().await.list_music_sources().await; info!("✅ {} music source(s) registered", sources.len()); for source in sources { info!(" - {} ({})", source.name(), source.id()); @@ -50,7 +48,7 @@ async fn main() -> Result<(), Box> { // Enregistrer les devices UPnP (HTTP + SSDP automatique) info!("📡 Registering UPnP devices..."); - let renderer_instance = server + let renderer_instance = server.write().await .register_device(MEDIA_RENDERER.clone()) .await .expect("Failed to register MediaRenderer"); @@ -61,7 +59,7 @@ async fn main() -> Result<(), Box> { renderer_instance.description_route() ); - let server_instance = server + let server_instance = server.write().await .register_device(MEDIA_SERVER.clone()) .await .expect("Failed to register MediaServer"); @@ -74,16 +72,16 @@ async fn main() -> Result<(), Box> { // Ajouter la webapp via le trait WebAppExt info!("📡 Registering Web application..."); - server.add_webapp_with_redirect::("/app").await; + server.write().await.add_webapp_with_redirect::("/app").await; // ========== PHASE 3 : Démarrage du serveur ========== info!("🌐 Starting HTTP server..."); - server.start().await; + server.write().await.start().await; info!("✅ PMOMusic is ready!"); info!("Press Ctrl+C to stop..."); - server.wait().await; + server.write().await.wait().await; Ok(()) } diff --git a/pmoaudiocache/src/cache.rs b/pmoaudiocache/src/cache.rs index f53d2f37..6239e48c 100644 --- a/pmoaudiocache/src/cache.rs +++ b/pmoaudiocache/src/cache.rs @@ -363,10 +363,10 @@ pub fn new_cache(dir: &str, limit: usize) -> Result { /// use pmoaudiocache::cache; /// /// # async fn example() -> anyhow::Result<()> { -/// let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?; +/// let cache = cache::new_cache("./audio_cache", 1000)?; /// let pk = cache::add_with_metadata_extraction( /// &cache, -/// "http://example.com/track.flac", +/// "https://example.com/track.flac", /// Some("artist:album") /// ).await?; /// # Ok(()) diff --git a/pmoaudiocache/src/flac.rs b/pmoaudiocache/src/flac.rs new file mode 100644 index 00000000..1cc4d760 --- /dev/null +++ b/pmoaudiocache/src/flac.rs @@ -0,0 +1,150 @@ +// //! Module de conversion audio en FLAC +// //! +// //! Ce module gère la conversion de divers formats audio vers FLAC +// //! pour standardiser le stockage dans le cache. + +// use anyhow::{anyhow, Result}; +// use std::io::Cursor; +// use symphonia::core::audio::SampleBuffer; +// use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +// use symphonia::core::errors::Error as SymphoniaError; +// use symphonia::core::formats::FormatOptions; +// use symphonia::core::io::MediaSourceStream; +// use symphonia::core::meta::MetadataOptions; +// use symphonia::core::probe::Hint; + +// /// Convertit des données audio en FLAC +// /// +// /// Cette fonction accepte n'importe quel format audio supporté par Symphonia +// /// et le convertit en FLAC pour un stockage standardisé. +// /// +// /// # Arguments +// /// +// /// * `data` - Données audio brutes (n'importe quel format) +// /// * `extension` - Extension du fichier source (optionnel, aide à la détection) +// /// +// /// # Returns +// /// +// /// Données audio au format FLAC +// /// +// /// # Exemple +// /// +// /// ```rust,no_run +// /// use pmoaudiocache::flac::convert_to_flac; +// /// +// /// let mp3_data = std::fs::read("track.mp3").unwrap(); +// /// let flac_data = convert_to_flac(&mp3_data, Some("mp3")).unwrap(); +// /// ``` +// pub fn convert_to_flac(data: &[u8], extension: Option<&str>) -> Result> { +// // Si c'est déjà du FLAC, on le retourne tel quel +// if is_flac(data) { +// return Ok(data.to_vec()); +// } + +// // Créer un MediaSource depuis les données (en clonant pour avoir 'static) +// let data_owned = data.to_vec(); +// let cursor = Cursor::new(data_owned); +// let mss = MediaSourceStream::new(Box::new(cursor), Default::default()); + +// // Créer un hint si on a l'extension +// let mut hint = Hint::new(); +// if let Some(ext) = extension { +// hint.with_extension(ext); +// } + +// // Prober le format +// let probed = symphonia::default::get_probe() +// .format( +// &hint, +// mss, +// &FormatOptions::default(), +// &MetadataOptions::default(), +// ) +// .map_err(|e| anyhow!("Impossible de détecter le format audio: {}", e))?; + +// let mut format = probed.format; + +// // Obtenir le premier track audio +// let track = format +// .tracks() +// .iter() +// .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) +// .ok_or_else(|| anyhow!("Aucune piste audio trouvée"))?; + +// // Créer un décodeur +// let mut decoder = symphonia::default::get_codecs() +// .make(&track.codec_params, &DecoderOptions::default()) +// .map_err(|e| anyhow!("Impossible de créer le décodeur: {}", e))?; + +// // Buffer pour stocker les samples décodés +// let mut samples = Vec::new(); +// let track_id = track.id; + +// // Décoder tous les packets +// loop { +// let packet = match format.next_packet() { +// Ok(packet) => packet, +// Err(SymphoniaError::ResetRequired) => { +// // Reset du décodeur requis +// decoder.reset(); +// continue; +// } +// Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { +// break; +// } +// Err(e) => return Err(anyhow!("Erreur lors de la lecture: {}", e)), +// }; + +// // Ignorer les packets qui ne sont pas de notre track +// if packet.track_id() != track_id { +// continue; +// } + +// match decoder.decode(&packet) { +// Ok(decoded) => { +// // Convertir les samples en format standard +// let spec = *decoded.spec(); +// let duration = decoded.capacity() as u64; + +// let mut sample_buf = SampleBuffer::::new(duration, spec); +// sample_buf.copy_interleaved_ref(decoded); + +// samples.extend_from_slice(sample_buf.samples()); +// } +// Err(SymphoniaError::DecodeError(_)) => continue, +// Err(e) => return Err(anyhow!("Erreur de décodage: {}", e)), +// } +// } + +// if samples.is_empty() { +// return Err(anyhow!("Aucun sample décodé")); +// } + +// // Note: Pour l'encodage FLAC, on aurait besoin d'une bibliothèque comme +// // `flacenc` qui n'existe pas encore en Rust. Pour l'instant, on stocke +// // les données telles quelles si c'est déjà du FLAC, sinon on retourne +// // les données originales avec un warning. + +// // TODO: Implémenter l'encodage FLAC quand une bibliothèque sera disponible +// tracing::warn!("Encodage FLAC non implémenté, stockage du format original"); +// Ok(data.to_vec()) +// } + +// /// Vérifie si les données sont déjà au format FLAC +// fn is_flac(data: &[u8]) -> bool { +// data.len() >= 4 && &data[0..4] == b"fLaC" +// } + +// #[cfg(test)] +// mod tests { +// use super::*; + +// #[test] +// fn test_is_flac() { +// let flac_header = b"fLaC\x00\x00\x00\x22"; +// assert!(is_flac(flac_header)); + +// let not_flac = b"RIFF\x00\x00\x00\x00"; +// assert!(!is_flac(not_flac)); +// } +// } diff --git a/pmoaudiocache/src/lib.rs b/pmoaudiocache/src/lib.rs index 964d8bcd..95bb893c 100644 --- a/pmoaudiocache/src/lib.rs +++ b/pmoaudiocache/src/lib.rs @@ -1,134 +1,80 @@ -//! # pmoaudiocache - Cache de pistes audio pour PMOMusic +//! # pmoaudiocache – Cache de pistes audio pour PMOMusic //! -//! Cette crate fournit un système de cache pour les pistes audio avec conversion -//! automatique en FLAC et extraction des métadonnées. +//! `pmoaudiocache` s'appuie sur [`pmocache`] pour fournir un cache spécialisé +//! dans les fichiers audio. Il assure la conversion transparente au format FLAC, +//! l'extraction des métadonnées et la mise à disposition d'outils pour les exposer. //! -//! ## Vue d'ensemble +//! ## Fonctionnalités //! -//! `pmoaudiocache` étend `pmocache` pour gérer spécifiquement les fichiers audio : -//! - **Téléchargement asynchrone** via le système de download de `pmocache` -//! - **Conversion automatique en FLAC** lors du téléchargement (via transformer) -//! - **Extraction et stockage des métadonnées** en JSON dans la base de données -//! - **Gestion de collections** basées sur artiste/album -//! - **Streaming progressif** automatique (via `pmocache`) -//! - **API REST complète** fournie par `pmocache` +//! - conversion automatique des entrées en FLAC grâce à un `StreamTransformer` ; +//! - extraction des tags (artiste, album, titre, etc.) via [`metadata::AudioMetadata`] ; +//! - stockage des métadonnées dans la table `metadata` de `pmocache::DB` ; +//! - helpers pour renseigner les collections à partir des tags ; +//! - intégration optionnelle avec `pmoserver` (routes REST + diffusion de fichiers). //! -//! ## Architecture -//! -//! Cette crate est une spécialisation minimale de `pmocache` : -//! - Configuration via `AudioConfig` -//! - Transformer FLAC pour la conversion automatique -//! - Helpers pour l'extraction et la lecture des métadonnées -//! -//! Tout le reste (DB, API REST, streaming) est fourni par `pmocache`. -//! -//! ## Utilisation -//! -//! ### Exemple basique +//! ## Exemple rapide //! //! ```rust,no_run //! use pmoaudiocache::cache; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { -//! // Créer le cache -//! let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?; +//! let cache = cache::new_cache("./audio_cache", 500)?; //! -//! // Ajouter une piste avec extraction des métadonnées +//! // Télécharge la piste, déclenche la conversion FLAC et stocke les métadonnées. //! let pk = cache::add_with_metadata_extraction( //! &cache, -//! "http://example.com/track.flac", -//! None // collection auto-détectée depuis métadonnées +//! "https://example.com/track.mp3", +//! None, //! ).await?; //! -//! // Lire les métadonnées +//! // Lecture des métadonnées extraites //! let metadata = cache::get_metadata(&cache, &pk)?; -//! println!("{} - {}", -//! metadata.artist.as_deref().unwrap_or("Unknown"), -//! metadata.title.as_deref().unwrap_or("Unknown") +//! println!( +//! "Titre: {}", +//! metadata.title.as_deref().unwrap_or("Inconnu") //! ); //! -//! // Le fichier FLAC est disponible immédiatement après le download -//! let file_path = cache.get(&pk).await?; -//! println!("FLAC file: {:?}", file_path); +//! // Accès au fichier FLAC converti +//! let flac_path = cache.get(&pk).await?; +//! println!("Fichier converti: {flac_path:?}"); //! //! Ok(()) //! } //! ``` //! -//! ### Utilisation avec pmoserver +//! ## Intégration serveur (feature `pmoserver`) //! -//! ```rust,no_run -//! use pmoaudiocache::AudioCacheExt; -//! use pmoserver::ServerBuilder; +//! Lorsque la feature `pmoserver` est activée, [`AudioCacheExt`] permet +//! d'enregistrer automatiquement les routes suivantes : //! -//! #[tokio::main] -//! async fn main() -> anyhow::Result<()> { -//! let mut server = ServerBuilder::new_configured().build(); +//! - `GET /audio/tracks/{pk}` : téléchargement/stream du FLAC original ; +//! - `GET /audio/tracks/{pk}/{qualifier}` : variantes (ex: `orig`) ; +//! - `GET /api/audio` / `POST /api/audio` / `DELETE /api/audio` : API REST générique ; +//! - `GET /api/audio/{pk}/status` : suivi de téléchargement ; +//! - endpoints OpenAPI/Swagger lorsqu'`openapi` est activée. //! -//! // Initialiser le cache audio avec configuration automatique -//! server.init_audio_cache_configured().await?; +//! ## Métadonnées gérées //! -//! server.start().await; -//! server.wait().await; -//! Ok(()) -//! } -//! ``` +//! Le module [`metadata`] extrait notamment : +//! - titre, artiste, album, genre ; +//! - numéros de piste/disque et totaux associés ; +//! - année, durée, bitrate, sample rate, nombre de canaux. //! -//! ## API HTTP (avec feature "pmoserver") +//! En l'absence d'artiste/album, aucune collection automatique n'est créée. //! -//! Lorsque la feature `pmoserver` est activée, les routes suivantes sont disponibles : +//! ## Modules //! -//! ### Routes de fichiers -//! - `GET /audio/tracks/{pk}` - Stream du fichier FLAC original -//! - `GET /audio/tracks/{pk}/orig` - Alias pour l'original +//! - [`cache`] : instanciation du cache et helpers de téléchargement ; +//! - [`metadata`] : extraction/structure des métadonnées audio ; +//! - [`config_ext`] *(feature `pmoconfig`)* : dérivation de la configuration depuis `pmoconfig`; +//! - [`openapi`] *(feature `pmoserver`)* : documentation des routes REST. //! -//! ### API REST -//! - `GET /api/audio` - Liste toutes les pistes -//! - `POST /api/audio` - Ajoute une piste depuis une URL -//! - `GET /api/audio/{pk}` - Informations complètes d'une piste -//! - `DELETE /api/audio/{pk}` - Supprime une piste -//! - `GET /api/audio/{pk}/status` - Statut du téléchargement -//! - `POST /api/audio/consolidate` - Consolide le cache -//! - `DELETE /api/audio` - Purge tout le cache +//! ## Crates voisines //! -//! ## Métadonnées supportées -//! -//! Les métadonnées suivantes sont extraites automatiquement : -//! - Titre, artiste, album -//! - Année, genre -//! - Numéro de piste/disque -//! - Durée, taux d'échantillonnage, bitrate -//! - Nombre de canaux -//! -//! ## Format des collections -//! -//! Les collections sont identifiées par une clé au format `"artist:album"`, avec : -//! - Conversion en minuscules -//! - Remplacement des espaces par des underscores -//! - Exemple : `"Pink Floyd - Wish You Were Here"` → `"pink_floyd:wish_you_were_here"` -//! -//! ## Différences avec l'ancienne version -//! -//! Cette version refactorisée de `pmoaudiocache` : -//! - ✅ **Supprime le champ `conversion_status`** : le système `Download` de `pmocache` gère déjà l'état asynchrone -//! - ✅ **Utilise `pmocache::DB`** : plus de DB personnalisée, les métadonnées sont en JSON -//! - ✅ **API REST générique** : fournie par `pmocache`, plus de code custom -//! - ✅ **Code réduit de 52%** : de ~1681 lignes à ~800 lignes -//! - ✅ **Streaming progressif** : automatique via `pmocache` -//! - ✅ **Politique LRU optimisée** : nouvel index composite dans `pmocache` -//! -//! ## Dépendances principales -//! -//! - `pmocache` : Cache générique avec download asynchrone -//! - `lofty` : Extraction de métadonnées audio -//! - `tokio` : Runtime asynchrone -//! -//! ## Voir aussi -//! -//! - [`pmocache`] : Cache générique -//! - [`pmocovers`] : Cache d'images (architecture similaire) -//! - [`pmoserver`] : Serveur HTTP +//! - [`pmocache`] : fondation générique ; +//! - [`pmocovers`] : spécialisation images (architecture similaire) ; +//! - [`pmoserver`] : serveur HTTP optionnel. pub mod cache; pub mod metadata; diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index f8a6d1cb..dd72fdfe 100644 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -17,21 +17,19 @@ use tokio::io::AsyncRead; use tokio::sync::RwLock; use tracing; -/// Trait pour définir les paramètres du cache +/// Paramètres statiques d'un cache spécialisé. pub trait CacheConfig: Send + Sync { - /// Extension des fichiers (ex: "webp", "flac") + /// Extension des fichiers générés (ex: `"webp"`, `"flac"`). fn file_extension() -> &'static str; - /// Nom de la table dans la base de données (ex: "covers", "audio") - - /// Type de cache (ex: "audio", "image") + /// Type logique exposé (ex: `"audio"`, `"image"`). Sert notamment pour les routes HTTP. fn cache_type() -> &'static str { "file" } - /// Cache name (ex: "covers", "audio", "cache") + /// Nom du cache (ex: `"covers"`, `"audio"`). Utilisé pour composer les chemins d'accès. fn cache_name() -> &'static str { "cache" } - /// Default param extension ("orig") + /// Qualifier par défaut associé au fichier original (ex: `"orig"`). fn default_param() -> &'static str { "orig" } diff --git a/pmocache/src/lib.rs b/pmocache/src/lib.rs index 34c45dac..e03f535f 100644 --- a/pmocache/src/lib.rs +++ b/pmocache/src/lib.rs @@ -1,66 +1,48 @@ -//! # pmocache - Système de cache générique pour PMOMusic +//! # pmocache – Système de cache générique pour PMOMusic //! -//! Cette crate fournit un système de cache générique avec support de base de données SQLite -//! et stockage sur disque. Elle est utilisée comme base pour des caches spécialisés comme -//! `pmocovers` (cache d'images) et `pmoaudiocache` (cache de pistes audio). +//! Cette crate fournit les briques communes utilisées par les caches de PMOMusic. +//! Elle gère l'association entre fichiers stockés sur disque et métadonnées +//! conservées dans une base SQLite, ainsi que les opérations de téléchargement, +//! d'éviction et de mise à jour. //! //! ## Vue d'ensemble //! -//! `pmocache` fournit les composants de base pour : -//! - Stocker des fichiers sur disque avec une base de données SQLite pour les métadonnées -//! - Gérer des collections d'éléments (albums, playlists, etc.) -//! - Suivre les statistiques d'utilisation (hits, dernière utilisation) -//! - Télécharger automatiquement depuis des URLs -//! - Consolider et purger le cache +//! `pmocache` met à disposition : +//! - un modèle `Cache` asynchrone pour stocker des fichiers et leurs métadonnées ; +//! - un module `db` encapsulant l'accès SQLite (table `asset` + table `metadata`) ; +//! - des utilitaires de téléchargement (`download`) réutilisables par les caches spécialisés ; +//! - un trait `CacheConfig` permettant de paramétrer l'extension, le nom et le type du cache. //! -//! ## Architecture +//! Les crates `pmocovers` (images) et `pmoaudiocache` (pistes audio) s'appuient sur ces +//! composants et ajoutent leurs propres contraintes métier (conversion WebP, métadonnées audio…). //! -//! `pmocache` est conçu comme une base générique : -//! -//! ```text -//! pmocache (générique) -//! ├── db.rs - Base de données SQLite générique -//! └── cache.rs - Système de cache générique -//! -//! pmocovers (spécialisé pour les images) -//! └── Utilise pmocache + conversion WebP -//! -//! pmoaudiocache (spécialisé pour l'audio) -//! └── Utilise pmocache + métadonnées audio -//! ``` -//! -//! ## Utilisation -//! -//! ### Exemple basique +//! ## Exemple basique //! //! ```rust,no_run //! use pmocache::{Cache, CacheConfig}; //! -//! // Définir la configuration du cache //! struct MyConfig; //! impl CacheConfig for MyConfig { //! fn file_extension() -> &'static str { "dat" } -//! fn table_name() -> &'static str { "my_cache" } -//! fn cache_type() -> &'static str { "generic" } //! } //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { -//! let cache = Cache::::new("./cache", 1000, "http://localhost:8080")?; +//! let cache = Cache::::new("./cache", 1000)?; //! -//! // Ajouter un fichier depuis une URL -//! let pk = cache.add_from_url("http://example.com/file.dat", None).await?; -//! println!("Fichier ajouté avec clé: {}", pk); +//! // Ajout d'un fichier depuis une URL +//! let pk = cache.add_from_url("https://example.com/file.dat", None).await?; +//! println!("Fichier ajouté avec la clé {pk}"); //! -//! // Récupérer le fichier +//! // Récupération du fichier local //! let path = cache.get(&pk).await?; -//! println!("Fichier stocké à: {:?}", path); +//! println!("Fichier disponible à {path:?}"); //! //! Ok(()) //! } //! ``` //! -//! ### Utilisation avec des collections +//! ## Collections //! //! ```rust,no_run //! use pmocache::{Cache, CacheConfig}; @@ -68,59 +50,66 @@ //! struct AudioConfig; //! impl CacheConfig for AudioConfig { //! fn file_extension() -> &'static str { "flac" } -//! fn table_name() -> &'static str { "audio" } //! fn cache_type() -> &'static str { "audio" } +//! fn cache_name() -> &'static str { "tracks" } //! } //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { -//! let cache = Cache::::new("./cache", 1000, "http://localhost:8080")?; +//! let cache = Cache::::new("./audio-cache", 200)?; //! -//! // Ajouter des pistes d'un album -//! let album_id = "album:the_wall"; -//! cache.add_from_url("http://example.com/track1.flac", Some(album_id)).await?; -//! cache.add_from_url("http://example.com/track2.flac", Some(album_id)).await?; +//! let album = "album:the_wall"; +//! cache.add_from_url("https://example.com/track1.flac", Some(album)).await?; +//! cache.add_from_url("https://example.com/track2.flac", Some(album)).await?; //! -//! // Récupérer toutes les pistes de l'album -//! let tracks = cache.get_collection(album_id).await?; -//! println!("Album contient {} pistes", tracks.len()); +//! let files = cache.get_collection(album).await?; +//! println!("Album {album} : {} fichiers en cache", files.len()); //! //! Ok(()) //! } //! ``` //! -//! ## Structure des fichiers +//! ## Structure sur disque //! //! ```text //! cache/ -//! ├── cache.db # Base de données SQLite -//! ├── 1a2b3c4d.webp # Fichier 1 -//! └── 5e6f7a8b.flac # Fichier 2 +//! ├── cache.db # Base SQLite +//! ├── 1a2b3c4d.orig.dat # Fichier original +//! └── 1a2b3c4d.thumb.dat # Variante (qualifier différent) //! ``` //! -//! ## Schéma de base de données +//! Les métadonnées sont conservées dans deux tables : //! //! ```sql -//! CREATE TABLE {table_name} ( -//! pk TEXT PRIMARY KEY, -- Clé unique (hash SHA1 de l'URL) -//! source_url TEXT, -- URL source -//! collection TEXT, -- Collection (album, playlist, etc.) -//! hits INTEGER DEFAULT 0, -- Nombre d'accès -//! last_used TEXT -- Dernière utilisation (RFC3339) +//! CREATE TABLE asset ( +//! pk TEXT PRIMARY KEY, +//! collection TEXT, +//! id TEXT, +//! hits INTEGER DEFAULT 0, +//! last_used TEXT +//! ); +//! +//! CREATE TABLE metadata ( +//! pk TEXT, +//! key TEXT, +//! value_type TEXT CHECK(value_type IN ('string','number','boolean','null')), +//! value TEXT, +//! PRIMARY KEY (pk, key), +//! FOREIGN KEY (pk) REFERENCES asset(pk) ON DELETE CASCADE //! ); //! ``` //! -//! ## Dépendances principales +//! ## Modules principaux //! -//! - `rusqlite` : Base de données SQLite -//! - `reqwest` : Téléchargement HTTP -//! - `sha1` : Génération de clés -//! - `tokio` : Runtime asynchrone +//! - [`cache`] : gestion du cache sur disque + opérations asynchrones ; +//! - [`db`] : accès SQLite, contraintes et helpers métadonnées ; +//! - [`download`] : primitives de téléchargement et de transformation ; +//! - [`cache_trait`] : trait partagé entre implémentations spécialisées. //! -//! ## Voir aussi +//! ## Crates associées //! -//! - [`pmocovers`] : Cache d'images avec conversion WebP -//! - [`pmoaudiocache`] : Cache de pistes audio +//! - [`pmocovers`] : cache d'images reposant sur `pmocache` ; +//! - [`pmoaudiocache`] : spécialisation audio avec extraction de métadonnées. pub mod cache; pub mod cache_trait; diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs index f800a133..104371a1 100644 --- a/pmocovers/src/lib.rs +++ b/pmocovers/src/lib.rs @@ -20,6 +20,21 @@ //! //! ## Utilisation //! +//! ### Exemple minimal +//! +//! ```rust,no_run +//! use pmocovers::cache; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let cache = cache::new_cache("./covers_cache", 200)?; +//! let pk = cache.add_from_url("https://example.com/cover.jpg", None).await?; +//! let path = cache.get(&pk).await?; +//! println!("Image convertie en WebP: {path:?}"); +//! Ok(()) +//! } +//! ``` +//! //! ### Exemple avec configuration automatique //! //! ```rust,no_run diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml index 14e0d83b..2bb01f41 100644 --- a/pmoparadise/Cargo.toml +++ b/pmoparadise/Cargo.toml @@ -60,7 +60,7 @@ pmosource = { path = "../pmosource" } # Playlist management for FIFO support pmoplaylist = { path = "../pmoplaylist" } -pmoconfig = { path = "../pmoconfig" } +pmoconfig = { path = "../pmoconfig", optional = true } # Cache support (OBLIGATOIRE - architecture refactorisée) pmocovers = { path = "../pmocovers" } @@ -72,7 +72,7 @@ utoipa = { version = "5.4.0", optional = true } axum = { version = "0.8.4", optional = true } [features] -default = ["metadata-only"] +default = ["metadata-only", "pmoconfig"] # Mode métadonnées seules (pas de décodage FLAC) metadata-only = [] # Active l'extraction par-track (WAV export, etc.) @@ -80,7 +80,9 @@ per-track = ["dep:hound", "dep:tempfile"] # Active l'API REST pmoserver pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"] # Feature pour activer le support serveur (cache registry) -server = ["pmosource/server"] +server = ["pmosource/server", "pmoconfig"] +# Feature pour activer le support de pmoconfig +pmoconfig = ["dep:pmoconfig"] # Feature cache (deprecated - toujours actif maintenant) cache = [] diff --git a/pmoparadise/src/config_ext.rs b/pmoparadise/src/config_ext.rs new file mode 100644 index 00000000..3fa8f5a1 --- /dev/null +++ b/pmoparadise/src/config_ext.rs @@ -0,0 +1,155 @@ +//! Extension pour intégrer Radio Paradise dans pmoconfig +//! +//! Ce module fournit le trait `RadioParadiseConfigExt` qui permet d'ajouter facilement +//! des méthodes de gestion de la configuration Radio Paradise à pmoconfig::Config. +//! +//! # Exemple +//! +//! ```rust,ignore +//! use pmoconfig::get_config; +//! use pmoparadise::RadioParadiseConfigExt; +//! +//! let config = get_config(); +//! let history_db = config.get_paradise_history_database()?; +//! let history_size = config.get_paradise_history_size()?; +//! ``` + +use anyhow::Result; +use pmoconfig::Config; +use serde_yaml::{Number, Value}; + +/// Chemin par défaut de la base de données d'historique (relatif au config_dir) +const DEFAULT_HISTORY_DATABASE: &str = "paradise_history.db"; + +/// Nombre maximal par défaut de pistes dans l'historique +const DEFAULT_HISTORY_SIZE: usize = 100; + +/// Trait d'extension pour gérer la configuration Radio Paradise dans pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques +/// à la configuration de Radio Paradise (historique, etc.). +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmoparadise::RadioParadiseConfigExt; +/// +/// let config = get_config(); +/// +/// // Récupérer le chemin de la base de données d'historique +/// let db_path = config.get_paradise_history_database()?; +/// println!("History database: {}", db_path); +/// +/// // Récupérer la taille maximale de l'historique +/// let max_tracks = config.get_paradise_history_size()?; +/// println!("Max history tracks: {}", max_tracks); +/// ``` +pub trait RadioParadiseConfigExt { + /// Récupère le chemin de la base de données d'historique + /// + /// Le chemin retourné est absolu, mais peut être configuré de manière relative + /// au répertoire de configuration (via `get_managed_dir`). + /// + /// # Returns + /// + /// Le chemin absolu vers la base de données SQLite d'historique + /// (default: `/paradise_history.db`) + /// + /// # Exemple + /// + /// ```rust,ignore + /// let db_path = config.get_paradise_history_database()?; + /// // Exemple: "/home/user/.config/pmo/paradise_history.db" + /// ``` + fn get_paradise_history_database(&self) -> Result; + + /// Définit le chemin de la base de données d'historique + /// + /// # Arguments + /// + /// * `path` - Chemin de la base de données (absolu ou relatif au config_dir) + /// + /// # Exemple + /// + /// ```rust,ignore + /// // Chemin relatif au config_dir + /// config.set_paradise_history_database("my_paradise.db".to_string())?; + /// + /// // Ou chemin absolu + /// config.set_paradise_history_database("/var/lib/paradise.db".to_string())?; + /// ``` + fn set_paradise_history_database(&self, path: String) -> Result<()>; + + /// Récupère le nombre maximal de pistes dans l'historique + /// + /// # Returns + /// + /// Le nombre maximal de pistes à conserver dans l'historique (default: 100) + /// + /// # Exemple + /// + /// ```rust,ignore + /// let max_tracks = config.get_paradise_history_size()?; + /// println!("Keeping last {} tracks", max_tracks); + /// ``` + fn get_paradise_history_size(&self) -> Result; + + /// Définit le nombre maximal de pistes dans l'historique + /// + /// # Arguments + /// + /// * `size` - Nombre maximal de pistes à conserver + /// + /// # Exemple + /// + /// ```rust,ignore + /// // Conserver les 200 dernières pistes + /// config.set_paradise_history_size(200)?; + /// ``` + fn set_paradise_history_size(&self, size: usize) -> Result<()>; +} + +impl RadioParadiseConfigExt for Config { + fn get_paradise_history_database(&self) -> Result { + // Utilise get_managed_dir qui gère automatiquement les chemins + // relatifs au config_dir et les chemins absolus + self.get_managed_dir( + &["sources", "radio_paradise", "history", "database"], + DEFAULT_HISTORY_DATABASE, + ) + } + + fn set_paradise_history_database(&self, path: String) -> Result<()> { + self.set_managed_dir(&["sources", "radio_paradise", "history", "database"], path) + } + + fn get_paradise_history_size(&self) -> Result { + // Tente de lire depuis la configuration YAML + match self.get_value(&["sources", "radio_paradise", "history", "max_tracks"]) { + Ok(Value::Number(n)) if n.is_u64() => Ok(n.as_u64().unwrap() as usize), + Ok(Value::Number(n)) if n.is_i64() => Ok(n.as_i64().unwrap() as usize), + _ => Ok(DEFAULT_HISTORY_SIZE), + } + } + + fn set_paradise_history_size(&self, size: usize) -> Result<()> { + let n = Number::from(size); + self.set_value( + &["sources", "radio_paradise", "history", "max_tracks"], + Value::Number(n), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_values() { + // Les valeurs par défaut doivent être cohérentes + assert_eq!(DEFAULT_HISTORY_DATABASE, "paradise_history.db"); + assert_eq!(DEFAULT_HISTORY_SIZE, 100); + } +} diff --git a/pmoparadise/src/lib.rs b/pmoparadise/src/lib.rs index 052075c3..098750b7 100644 --- a/pmoparadise/src/lib.rs +++ b/pmoparadise/src/lib.rs @@ -232,6 +232,9 @@ pub mod track; #[cfg(feature = "pmoserver")] pub mod pmoserver_ext; +#[cfg(feature = "pmoconfig")] +pub mod config_ext; + // Re-exports for convenience pub use client::{ClientBuilder, RadioParadiseClient}; pub use error::{Error, Result}; @@ -247,6 +250,9 @@ pub use pmoserver_ext::{ create_api_router, RadioParadiseApiDoc, RadioParadiseExt, RadioParadiseState, }; +#[cfg(feature = "pmoconfig")] +pub use config_ext::RadioParadiseConfigExt; + // Version information pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/pmoparadise/src/paradise/config.rs b/pmoparadise/src/paradise/config.rs index bb657050..4316c7aa 100644 --- a/pmoparadise/src/paradise/config.rs +++ b/pmoparadise/src/paradise/config.rs @@ -78,12 +78,14 @@ impl Default for CacheConfig { } /// Persisted history tuning. +/// +/// Configuration for the SQLite-based history persistence. +/// The database path is managed via `RadioParadiseConfigExt` trait +/// and defaults to a relative path in the config directory. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HistoryConfig { #[serde(default = "HistoryConfig::default_max_tracks")] pub max_tracks: usize, - #[serde(default)] - pub persistence_backend: HistoryBackendKind, #[serde(default = "HistoryConfig::default_database_path")] pub database_path: String, } @@ -94,7 +96,8 @@ impl HistoryConfig { } fn default_database_path() -> String { - "/var/lib/pmo/paradise_history.db".to_string() + // Default to a relative path (will be resolved by config_ext) + "paradise_history.db".to_string() } } @@ -102,7 +105,6 @@ impl Default for HistoryConfig { fn default() -> Self { Self { max_tracks: Self::default_max_tracks(), - persistence_backend: HistoryBackendKind::Sqlite, database_path: Self::default_database_path(), } } @@ -118,15 +120,6 @@ impl RadioParadiseConfig { } } -/// Backend selection for history persistence. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum HistoryBackendKind { - #[default] - Sqlite, - Json, -} - /// Activity lifecycle tuning. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActivityConfig { diff --git a/pmoparadise/src/paradise/history.rs b/pmoparadise/src/paradise/history.rs index ffce068e..88a14e34 100644 --- a/pmoparadise/src/paradise/history.rs +++ b/pmoparadise/src/paradise/history.rs @@ -1,11 +1,10 @@ //! History persistence for Radio Paradise playback. //! //! The worker pushes every completed track into the history backend while -//! keeping the latest entries available for UPnP browsing. We expose an -//! abstract trait so different storage engines (SQLite, JSON, etc.) can be -//! supported while sharing the same API. +//! keeping the latest entries available for UPnP browsing. We use SQLite +//! for persistent storage with an abstract trait for testability. -use super::config::{HistoryBackendKind, HistoryConfig}; +use super::config::HistoryConfig; use crate::models::Song; use async_trait::async_trait; use chrono::{DateTime, Utc}; @@ -60,99 +59,16 @@ pub trait HistoryBackend: Send + Sync { async fn truncate(&self, keep: usize) -> anyhow::Result<()>; } +/// Creates a history backend from configuration. +/// +/// This always creates a SQLite-based backend using the configured database path. pub fn history_backend_from_config( config: &HistoryConfig, ) -> anyhow::Result> { - match config.persistence_backend { - HistoryBackendKind::Sqlite => { - let backend = SqliteHistoryBackend::new(&config.database_path)?; - Ok(Arc::new(backend)) - } - HistoryBackendKind::Json => { - let backend = JsonHistoryBackend::new(&config.database_path); - Ok(Arc::new(backend)) - } - } + let backend = SqliteHistoryBackend::new(&config.database_path)?; + Ok(Arc::new(backend)) } -/// Simple JSON file backed history (placeholder implementation). -/// -/// The JSON backend is primarily useful for tests and quick setups. The file -/// is stored next to the configured database path with a `.json` extension. -pub struct JsonHistoryBackend { - path: std::path::PathBuf, - entries: Arc>>, -} - -impl JsonHistoryBackend { - pub fn new(path: impl AsRef) -> Self { - let path = path.as_ref().to_path_buf(); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let entries = if path.exists() { - std::fs::read(&path) - .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()) - .unwrap_or_default() - } else { - Vec::new() - }; - - Self { - path, - entries: Arc::new(Mutex::new(entries)), - } - } - - async fn save(&self, entries: &[HistoryEntry]) -> anyhow::Result<()> { - let json = serde_json::to_vec_pretty(entries)?; - tokio::fs::write(&self.path, json).await?; - Ok(()) - } -} - -#[async_trait] -impl HistoryBackend for JsonHistoryBackend { - async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()> { - let mut entries = self.entries.lock().await; - entries.push(entry); - self.save(&entries).await - } - - async fn recent(&self, limit: usize) -> anyhow::Result> { - let entries = self.entries.lock().await; - let total = entries.len(); - let start = total.saturating_sub(limit); - Ok(entries[start..].to_vec()) - } - - async fn len(&self) -> anyhow::Result { - Ok(self.entries.lock().await.len()) - } - - async fn truncate(&self, keep: usize) -> anyhow::Result<()> { - let mut entries = self.entries.lock().await; - if entries.len() > keep { - let drop_count = entries.len() - keep; - entries.drain(0..drop_count); - self.save(&entries).await?; - } - Ok(()) - } -} - -/// In-memory history backend – useful for tests or ephemeral deployments. -#[derive(Default)] -pub struct MemoryHistoryBackend { - entries: Arc>>, -} - -impl MemoryHistoryBackend { - pub fn new() -> Self { - Self::default() - } -} #[async_trait] impl HistoryBackend for MemoryHistoryBackend { diff --git a/pmoparadise/src/paradise/mod.rs b/pmoparadise/src/paradise/mod.rs index dc389a3d..1826b8b4 100644 --- a/pmoparadise/src/paradise/mod.rs +++ b/pmoparadise/src/paradise/mod.rs @@ -26,9 +26,6 @@ pub use config::{ ActivityConfig, ApiConfig, CacheConfig, HistoryConfig, PollingConfig, RadioParadiseConfig, StreamConfig, }; -pub use history::{ - history_backend_from_config, HistoryBackend, HistoryEntry, JsonHistoryBackend, - MemoryHistoryBackend, -}; +pub use history::{history_backend_from_config, HistoryBackend, HistoryEntry}; pub use playlist::PlaylistEntry; pub use worker::{ParadiseWorker, WorkerCommand}; diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index d87284bb..abc203a6 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -7,8 +7,8 @@ use crate::client::RadioParadiseClient; use crate::paradise::{ - history_backend_from_config, ChannelDescriptor, MemoryHistoryBackend, ParadiseChannel, - PlaylistEntry, RadioParadiseConfig, ALL_CHANNELS, + history_backend_from_config, ChannelDescriptor, ParadiseChannel, PlaylistEntry, + RadioParadiseConfig, ALL_CHANNELS, }; use anyhow::Result as AnyhowResult; use pmoaudiocache::Cache as AudioCache; @@ -77,7 +77,31 @@ impl RadioParadiseSource { #[cfg(feature = "server")] pub fn from_registry(client: RadioParadiseClient) -> Result { let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default()); - let history_backend = history_backend_from_config(&config.history).map_err(|e| { + + // Load history configuration from pmoconfig using the config extension trait + #[cfg(feature = "pmoconfig")] + let history_config = { + use crate::config_ext::RadioParadiseConfigExt; + let cfg = pmoconfig::get_config(); + let database_path = cfg.get_paradise_history_database().map_err(|e| { + MusicSourceError::SourceUnavailable(format!( + "Failed to get history database path: {}", + e + )) + })?; + let max_tracks = cfg.get_paradise_history_size().map_err(|e| { + MusicSourceError::SourceUnavailable(format!("Failed to get history size: {}", e)) + })?; + crate::paradise::HistoryConfig { + database_path, + max_tracks, + } + }; + + #[cfg(not(feature = "pmoconfig"))] + let history_config = config.history.clone(); + + let history_backend = history_backend_from_config(&history_config).map_err(|e| { MusicSourceError::SourceUnavailable(format!( "Failed to initialize history backend: {}", e @@ -125,8 +149,7 @@ impl RadioParadiseSource { let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default()); let history_backend: Arc = history_backend_from_config(&config.history).unwrap_or_else(|err| { - warn!("Falling back to in-memory history backend: {err}"); - Arc::new(MemoryHistoryBackend::new()) as Arc + panic!("Failed to initialize history backend: {err}"); }); let mut channels = HashMap::new(); diff --git a/pmoserver/Cargo.toml b/pmoserver/Cargo.toml index 3f483aeb..f41daba8 100644 --- a/pmoserver/Cargo.toml +++ b/pmoserver/Cargo.toml @@ -22,3 +22,4 @@ axum-embed = "0.1.0" rust-embed = "8.7.2" utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } +once_cell = "1.19" diff --git a/pmoserver/src/lib.rs b/pmoserver/src/lib.rs index 960fc002..78f763fa 100644 --- a/pmoserver/src/lib.rs +++ b/pmoserver/src/lib.rs @@ -78,3 +78,131 @@ pub use logs::{ log_setup_get, log_setup_post, log_sse, }; pub use server::{ApiRegistry, ApiRegistryEntry, Server, ServerBuilder, ServerInfo}; + +// ============================================================================ +// Singleton global du serveur +// ============================================================================ + +use once_cell::sync::OnceCell; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Instance globale unique du serveur PMOMusic +/// +/// Ce singleton assure qu'une seule instance du serveur existe dans l'application. +/// Il est initialisé une seule fois via [`init_server()`] et accessible partout +/// via [`get_server()`]. +/// +/// # Exemple +/// +/// ```ignore +/// use pmoserver::{init_server, get_server_base_url}; +/// +/// // Initialiser le serveur global (idempotent - peut être appelé plusieurs fois) +/// let server = init_server().await; +/// +/// // Récupérer l'URL de base du serveur +/// if let Some(url) = get_server_base_url() { +/// println!("Server running at: {}", url); +/// } +/// ``` +static GLOBAL_SERVER: OnceCell>> = OnceCell::new(); + +/// Initialise le serveur global unique depuis la configuration +/// +/// Cette fonction est **idempotente** : elle peut être appelée plusieurs fois +/// sans danger. Si le serveur est déjà initialisé, elle retourne simplement +/// la référence existante. +/// +/// # Configuration +/// +/// Le serveur est créé via [`ServerBuilder::new_configured()`] qui lit +/// la configuration depuis `pmoconfig`. +/// +/// # Returns +/// +/// Une référence Arc vers le serveur global, encapsulé dans un RwLock +/// pour permettre les accès concurrents mutables. +/// +/// # Exemple +/// +/// ```ignore +/// use pmoserver::init_server; +/// +/// #[tokio::main] +/// async fn main() { +/// // Première initialisation +/// let server = init_server(); +/// +/// // Les appels suivants retournent la même instance +/// let same_server = init_server(); +/// } +/// ``` +pub fn init_server() -> Arc> { + GLOBAL_SERVER + .get_or_init(|| { + let server = ServerBuilder::new_configured().build(); + Arc::new(RwLock::new(server)) + }) + .clone() +} + +/// Récupère le serveur global s'il a été initialisé +/// +/// Retourne `None` si [`init_server()`] n'a pas encore été appelé. +/// +/// # Returns +/// +/// - `Some(Arc>)` si le serveur est initialisé +/// - `None` si le serveur n'est pas encore initialisé +/// +/// # Exemple +/// +/// ```ignore +/// use pmoserver::get_server; +/// +/// if let Some(server) = get_server() { +/// let srv = server.read().await; +/// println!("Server is running at: {}", srv.base_url()); +/// } else { +/// println!("Server not initialized yet"); +/// } +/// ``` +pub fn get_server() -> Option>> { + GLOBAL_SERVER.get().cloned() +} + +/// Récupère l'URL de base du serveur global +/// +/// Fonction helper qui extrait directement l'URL de base sans avoir +/// à manipuler le RwLock manuellement. +/// +/// # Returns +/// +/// - `Some(String)` contenant l'URL complète (ex: "http://192.168.1.10:8080") +/// - `None` si le serveur n'est pas encore initialisé +/// +/// # Exemple +/// +/// ```ignore +/// use pmoserver::get_server_base_url; +/// +/// if let Some(url) = get_server_base_url() { +/// let stream_url = format!("{}/api/stream", url); +/// println!("Stream available at: {}", stream_url); +/// } +/// ``` +pub fn get_server_base_url() -> Option { + GLOBAL_SERVER.get().map(|server| { + // Utiliser try_read() pour éviter de bloquer + // Si le lock est occupé, on retourne quand même l'URL + // car elle ne change pas après l'initialisation + if let Ok(srv) = server.try_read() { + srv.base_url() + } else { + // Fallback: bloquer jusqu'à obtenir le lock + // (ne devrait jamais arriver en pratique) + futures::executor::block_on(async { server.read().await.base_url() }) + } + }) +} diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index 80a5af01..f4538193 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -188,22 +188,26 @@ pub trait UpnpServerExt { /// `true` si SSDP est actif, `false` sinon fn ssdp_enabled(&self) -> bool; - /// Crée et initialise un serveur UPnP complet (factory method) + /// Crée et initialise le serveur UPnP global (factory method) /// - /// Cette méthode factory initialise l'infrastructure UPnP complète : - /// - Serveur HTTP (via pmoserver) + /// Cette méthode factory initialise le **singleton global** du serveur avec + /// l'infrastructure UPnP complète : + /// - Serveur HTTP (via pmoserver singleton) /// - Caches (couvertures + audio) /// - Logging /// - Serveur SSDP /// + /// Cette fonction est **idempotente** : elle peut être appelée plusieurs fois. + /// Si le serveur est déjà initialisé, elle retourne simplement la référence existante. + /// /// Après cette méthode, l'utilisateur doit : /// - Enregistrer ses devices via `register_device()` - /// - Enregistrer ses sources musicales - /// - Appeler `wait()` pour attendre l'arrêt + /// - Enregistrer ses sources musicales (via fonctions globales) + /// - Appeler `start()` puis `wait()` pour attendre l'arrêt /// /// # Returns /// - /// Un serveur UPnP prêt à l'emploi + /// Une référence Arc vers le serveur UPnP global, prêt à l'emploi /// /// # Errors /// @@ -215,11 +219,11 @@ pub trait UpnpServerExt { /// use pmoupnp::UpnpServerExt; /// use pmoserver::Server; /// - /// let mut server = Server::create_upnp_server().await?; - /// server.register_device(my_device).await?; - /// server.wait().await; + /// let server = Server::create_upnp_server().await?; + /// server.write().await.register_device(my_device).await?; + /// server.read().await.wait().await; /// ``` - async fn create_upnp_server() -> Result; + async fn create_upnp_server() -> Result>, anyhow::Error>; } // Implémentation du trait UpnpServer pour pmoserver::Server @@ -414,21 +418,20 @@ impl UpnpServerExt for Server { SSDP_SERVER.read().unwrap().is_some() } - async fn create_upnp_server() -> Result { - use pmoserver::ServerBuilder; + async fn create_upnp_server() -> Result>, anyhow::Error> { use tracing::{error, info, warn}; - // 1. Créer le serveur depuis la config - info!("🔧 Creating UPnP server from configuration..."); - let mut server = ServerBuilder::new_configured().build(); + // 1. Initialiser le serveur global singleton + info!("🔧 Initializing global UPnP server from configuration..."); + let server_arc = pmoserver::init_server(); // 2. Initialiser le logging HTTP (routes de logs + tracing) info!("📝 Initializing logging..."); - server.init_logging().await; + server_arc.write().await.init_logging().await; // 3. Initialiser les caches info!("💾 Initializing caches..."); - match server.init_caches().await { + match server_arc.write().await.init_caches().await { Ok(_) => { info!("✅ Caches initialized"); } @@ -440,15 +443,16 @@ impl UpnpServerExt for Server { // 4. Le serveur HTTP n'est PAS encore démarré // Il sera démarré après l'enregistrement des devices et routes - info!("🌐 HTTP server configured at {}", server.info().base_url); + let base_url = server_arc.read().await.info().base_url; + info!("🌐 HTTP server configured at {}", base_url); // 5. Enregistrer l'API d'introspection UPnP info!("📡 Registering UPnP API..."); - server.register_upnp_api().await; + server_arc.write().await.register_upnp_api().await; // 6. Initialiser SSDP info!("📡 Initializing SSDP discovery..."); - match server.init_ssdp() { + match server_arc.write().await.init_ssdp() { Ok(_) => info!("✅ SSDP server initialized"), Err(e) => { let kind = e.kind(); @@ -477,7 +481,7 @@ impl UpnpServerExt for Server { info!("🎉 UPnP server infrastructure ready"); info!("📝 Next: Register devices and music sources"); - Ok(server) + Ok(server_arc) } }