On continue le refactoring des sources

This commit is contained in:
2025-10-26 13:44:41 +01:00
parent 1268c24faf
commit 1e0a0e2acb
18 changed files with 647 additions and 325 deletions

1
Cargo.lock generated
View File

@@ -2575,6 +2575,7 @@ dependencies = [
"axum-server", "axum-server",
"futures", "futures",
"futures-util", "futures-util",
"once_cell",
"pmoconfig", "pmoconfig",
"rust-embed", "rust-embed",
"serde", "serde",

View File

@@ -3,16 +3,14 @@ use pmomediarenderer::MEDIA_RENDERER;
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt}; use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt};
use pmoserver::Server; use pmoserver::Server;
use pmosource::MusicSourceExt; use pmosource::MusicSourceExt;
use pmoupnp::{UpnpServerExt, upnp_api::UpnpApiExt}; use pmoupnp::UpnpServerExt;
use tracing::info; use tracing::info;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ========== PHASE 1 : Infrastructure UPnP ========== // ========== PHASE 1 : Infrastructure UPnP ==========
let mut server = Server::create_upnp_server().await?; let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
server.write().await
// Routes personnalisées de l'application
server
.add_route("/info", || async { .add_route("/info", || async {
serde_json::json!({"version": "1.0.0"}) serde_json::json!({"version": "1.0.0"})
}) })
@@ -20,7 +18,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialiser le système de gestion des sources musicales avec API REST // Initialiser le système de gestion des sources musicales avec API REST
info!("📡 Initializing music sources management system..."); info!("📡 Initializing music sources management system...");
server server.write().await
.init_music_sources() .init_music_sources()
.await .await
.expect("Failed to initialize music sources API"); .expect("Failed to initialize music sources API");
@@ -36,12 +34,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// } // }
// Enregistrer Radio Paradise (inclut l'initialisation de l'API) // 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); tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e);
} }
// Lister toutes les sources enregistrées // 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()); info!("✅ {} music source(s) registered", sources.len());
for source in sources { for source in sources {
info!(" - {} ({})", source.name(), source.id()); info!(" - {} ({})", source.name(), source.id());
@@ -50,7 +48,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Enregistrer les devices UPnP (HTTP + SSDP automatique) // Enregistrer les devices UPnP (HTTP + SSDP automatique)
info!("📡 Registering UPnP devices..."); info!("📡 Registering UPnP devices...");
let renderer_instance = server let renderer_instance = server.write().await
.register_device(MEDIA_RENDERER.clone()) .register_device(MEDIA_RENDERER.clone())
.await .await
.expect("Failed to register MediaRenderer"); .expect("Failed to register MediaRenderer");
@@ -61,7 +59,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
renderer_instance.description_route() renderer_instance.description_route()
); );
let server_instance = server let server_instance = server.write().await
.register_device(MEDIA_SERVER.clone()) .register_device(MEDIA_SERVER.clone())
.await .await
.expect("Failed to register MediaServer"); .expect("Failed to register MediaServer");
@@ -74,16 +72,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Ajouter la webapp via le trait WebAppExt // Ajouter la webapp via le trait WebAppExt
info!("📡 Registering Web application..."); info!("📡 Registering Web application...");
server.add_webapp_with_redirect::<Webapp>("/app").await; server.write().await.add_webapp_with_redirect::<Webapp>("/app").await;
// ========== PHASE 3 : Démarrage du serveur ========== // ========== PHASE 3 : Démarrage du serveur ==========
info!("🌐 Starting HTTP server..."); info!("🌐 Starting HTTP server...");
server.start().await; server.write().await.start().await;
info!("✅ PMOMusic is ready!"); info!("✅ PMOMusic is ready!");
info!("Press Ctrl+C to stop..."); info!("Press Ctrl+C to stop...");
server.wait().await; server.write().await.wait().await;
Ok(()) Ok(())
} }

View File

@@ -363,10 +363,10 @@ pub fn new_cache(dir: &str, limit: usize) -> Result<Cache> {
/// use pmoaudiocache::cache; /// use pmoaudiocache::cache;
/// ///
/// # async fn example() -> anyhow::Result<()> { /// # 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( /// let pk = cache::add_with_metadata_extraction(
/// &cache, /// &cache,
/// "http://example.com/track.flac", /// "https://example.com/track.flac",
/// Some("artist:album") /// Some("artist:album")
/// ).await?; /// ).await?;
/// # Ok(()) /// # Ok(())

150
pmoaudiocache/src/flac.rs Normal file
View File

@@ -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<Vec<u8>> {
// // 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::<i16>::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));
// }
// }

View File

@@ -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 //! `pmoaudiocache` s'appuie sur [`pmocache`] pour fournir un cache spécialisé
//! automatique en FLAC et extraction des métadonnées. //! 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 : //! - conversion automatique des entrées en FLAC grâce à un `StreamTransformer` ;
//! - **Téléchargement asynchrone** via le système de download de `pmocache` //! - extraction des tags (artiste, album, titre, etc.) via [`metadata::AudioMetadata`] ;
//! - **Conversion automatique en FLAC** lors du téléchargement (via transformer) //! - stockage des métadonnées dans la table `metadata` de `pmocache::DB` ;
//! - **Extraction et stockage des métadonnées** en JSON dans la base de données //! - helpers pour renseigner les collections à partir des tags ;
//! - **Gestion de collections** basées sur artiste/album //! - intégration optionnelle avec `pmoserver` (routes REST + diffusion de fichiers).
//! - **Streaming progressif** automatique (via `pmocache`)
//! - **API REST complète** fournie par `pmocache`
//! //!
//! ## Architecture //! ## Exemple rapide
//!
//! 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
//! //!
//! ```rust,no_run //! ```rust,no_run
//! use pmoaudiocache::cache; //! use pmoaudiocache::cache;
//! //!
//! #[tokio::main] //! #[tokio::main]
//! async fn main() -> anyhow::Result<()> { //! async fn main() -> anyhow::Result<()> {
//! // Créer le cache //! let cache = cache::new_cache("./audio_cache", 500)?;
//! let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?;
//! //!
//! // 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( //! let pk = cache::add_with_metadata_extraction(
//! &cache, //! &cache,
//! "http://example.com/track.flac", //! "https://example.com/track.mp3",
//! None // collection auto-détectée depuis métadonnées //! None,
//! ).await?; //! ).await?;
//! //!
//! // Lire les métadonnées //! // Lecture des métadonnées extraites
//! let metadata = cache::get_metadata(&cache, &pk)?; //! let metadata = cache::get_metadata(&cache, &pk)?;
//! println!("{} - {}", //! println!(
//! metadata.artist.as_deref().unwrap_or("Unknown"), //! "Titre: {}",
//! metadata.title.as_deref().unwrap_or("Unknown") //! metadata.title.as_deref().unwrap_or("Inconnu")
//! ); //! );
//! //!
//! // Le fichier FLAC est disponible immédiatement après le download //! // Accès au fichier FLAC converti
//! let file_path = cache.get(&pk).await?; //! let flac_path = cache.get(&pk).await?;
//! println!("FLAC file: {:?}", file_path); //! println!("Fichier converti: {flac_path:?}");
//! //!
//! Ok(()) //! Ok(())
//! } //! }
//! ``` //! ```
//! //!
//! ### Utilisation avec pmoserver //! ## Intégration serveur (feature `pmoserver`)
//! //!
//! ```rust,no_run //! Lorsque la feature `pmoserver` est activée, [`AudioCacheExt`] permet
//! use pmoaudiocache::AudioCacheExt; //! d'enregistrer automatiquement les routes suivantes :
//! use pmoserver::ServerBuilder;
//! //!
//! #[tokio::main] //! - `GET /audio/tracks/{pk}` : téléchargement/stream du FLAC original ;
//! async fn main() -> anyhow::Result<()> { //! - `GET /audio/tracks/{pk}/{qualifier}` : variantes (ex: `orig`) ;
//! let mut server = ServerBuilder::new_configured().build(); //! - `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 //! ## Métadonnées gérées
//! server.init_audio_cache_configured().await?;
//! //!
//! server.start().await; //! Le module [`metadata`] extrait notamment :
//! server.wait().await; //! - titre, artiste, album, genre ;
//! Ok(()) //! - 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 //! - [`cache`] : instanciation du cache et helpers de téléchargement ;
//! - `GET /audio/tracks/{pk}` - Stream du fichier FLAC original //! - [`metadata`] : extraction/structure des métadonnées audio ;
//! - `GET /audio/tracks/{pk}/orig` - Alias pour l'original //! - [`config_ext`] *(feature `pmoconfig`)* : dérivation de la configuration depuis `pmoconfig`;
//! - [`openapi`] *(feature `pmoserver`)* : documentation des routes REST.
//! //!
//! ### API REST //! ## Crates voisines
//! - `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
//! //!
//! ## Métadonnées supportées //! - [`pmocache`] : fondation générique ;
//! //! - [`pmocovers`] : spécialisation images (architecture similaire) ;
//! Les métadonnées suivantes sont extraites automatiquement : //! - [`pmoserver`] : serveur HTTP optionnel.
//! - 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
pub mod cache; pub mod cache;
pub mod metadata; pub mod metadata;

View File

@@ -17,21 +17,19 @@ use tokio::io::AsyncRead;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing; use tracing;
/// Trait pour définir les paramètres du cache /// Paramètres statiques d'un cache spécialisé.
pub trait CacheConfig: Send + Sync { 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; fn file_extension() -> &'static str;
/// Nom de la table dans la base de données (ex: "covers", "audio") /// Type logique exposé (ex: `"audio"`, `"image"`). Sert notamment pour les routes HTTP.
/// Type de cache (ex: "audio", "image")
fn cache_type() -> &'static str { fn cache_type() -> &'static str {
"file" "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 { fn cache_name() -> &'static str {
"cache" "cache"
} }
/// Default param extension ("orig") /// Qualifier par défaut associé au fichier original (ex: `"orig"`).
fn default_param() -> &'static str { fn default_param() -> &'static str {
"orig" "orig"
} }

View File

@@ -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 //! Cette crate fournit les briques communes utilisées par les caches de PMOMusic.
//! et stockage sur disque. Elle est utilisée comme base pour des caches spécialisés comme //! Elle gère l'association entre fichiers stockés sur disque et métadonnées
//! `pmocovers` (cache d'images) et `pmoaudiocache` (cache de pistes audio). //! conservées dans une base SQLite, ainsi que les opérations de téléchargement,
//! d'éviction et de mise à jour.
//! //!
//! ## Vue d'ensemble //! ## Vue d'ensemble
//! //!
//! `pmocache` fournit les composants de base pour : //! `pmocache` met à disposition :
//! - Stocker des fichiers sur disque avec une base de données SQLite pour les métadonnées //! - un modèle `Cache` asynchrone pour stocker des fichiers et leurs métadonnées ;
//! - Gérer des collections d'éléments (albums, playlists, etc.) //! - un module `db` encapsulant l'accès SQLite (table `asset` + table `metadata`) ;
//! - Suivre les statistiques d'utilisation (hits, dernière utilisation) //! - des utilitaires de téléchargement (`download`) réutilisables par les caches spécialisés ;
//! - Télécharger automatiquement depuis des URLs //! - un trait `CacheConfig` permettant de paramétrer l'extension, le nom et le type du cache.
//! - Consolider et purger le 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 : //! ## Exemple basique
//!
//! ```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
//! //!
//! ```rust,no_run //! ```rust,no_run
//! use pmocache::{Cache, CacheConfig}; //! use pmocache::{Cache, CacheConfig};
//! //!
//! // Définir la configuration du cache
//! struct MyConfig; //! struct MyConfig;
//! impl CacheConfig for MyConfig { //! impl CacheConfig for MyConfig {
//! fn file_extension() -> &'static str { "dat" } //! fn file_extension() -> &'static str { "dat" }
//! fn table_name() -> &'static str { "my_cache" }
//! fn cache_type() -> &'static str { "generic" }
//! } //! }
//! //!
//! #[tokio::main] //! #[tokio::main]
//! async fn main() -> anyhow::Result<()> { //! async fn main() -> anyhow::Result<()> {
//! let cache = Cache::<MyConfig>::new("./cache", 1000, "http://localhost:8080")?; //! let cache = Cache::<MyConfig>::new("./cache", 1000)?;
//! //!
//! // Ajouter un fichier depuis une URL //! // Ajout d'un fichier depuis une URL
//! let pk = cache.add_from_url("http://example.com/file.dat", None).await?; //! let pk = cache.add_from_url("https://example.com/file.dat", None).await?;
//! println!("Fichier ajouté avec clé: {}", pk); //! println!("Fichier ajouté avec la clé {pk}");
//! //!
//! // Récupérer le fichier //! // Récupération du fichier local
//! let path = cache.get(&pk).await?; //! let path = cache.get(&pk).await?;
//! println!("Fichier stocké à: {:?}", path); //! println!("Fichier disponible à {path:?}");
//! //!
//! Ok(()) //! Ok(())
//! } //! }
//! ``` //! ```
//! //!
//! ### Utilisation avec des collections //! ## Collections
//! //!
//! ```rust,no_run //! ```rust,no_run
//! use pmocache::{Cache, CacheConfig}; //! use pmocache::{Cache, CacheConfig};
@@ -68,59 +50,66 @@
//! struct AudioConfig; //! struct AudioConfig;
//! impl CacheConfig for AudioConfig { //! impl CacheConfig for AudioConfig {
//! fn file_extension() -> &'static str { "flac" } //! fn file_extension() -> &'static str { "flac" }
//! fn table_name() -> &'static str { "audio" }
//! fn cache_type() -> &'static str { "audio" } //! fn cache_type() -> &'static str { "audio" }
//! fn cache_name() -> &'static str { "tracks" }
//! } //! }
//! //!
//! #[tokio::main] //! #[tokio::main]
//! async fn main() -> anyhow::Result<()> { //! async fn main() -> anyhow::Result<()> {
//! let cache = Cache::<AudioConfig>::new("./cache", 1000, "http://localhost:8080")?; //! let cache = Cache::<AudioConfig>::new("./audio-cache", 200)?;
//! //!
//! // Ajouter des pistes d'un album //! let album = "album:the_wall";
//! let album_id = "album:the_wall"; //! cache.add_from_url("https://example.com/track1.flac", Some(album)).await?;
//! cache.add_from_url("http://example.com/track1.flac", Some(album_id)).await?; //! cache.add_from_url("https://example.com/track2.flac", Some(album)).await?;
//! cache.add_from_url("http://example.com/track2.flac", Some(album_id)).await?;
//! //!
//! // Récupérer toutes les pistes de l'album //! let files = cache.get_collection(album).await?;
//! let tracks = cache.get_collection(album_id).await?; //! println!("Album {album} : {} fichiers en cache", files.len());
//! println!("Album contient {} pistes", tracks.len());
//! //!
//! Ok(()) //! Ok(())
//! } //! }
//! ``` //! ```
//! //!
//! ## Structure des fichiers //! ## Structure sur disque
//! //!
//! ```text //! ```text
//! cache/ //! cache/
//! ├── cache.db # Base de données SQLite //! ├── cache.db # Base SQLite
//! ├── 1a2b3c4d.webp # Fichier 1 //! ├── 1a2b3c4d.orig.dat # Fichier original
//! └── 5e6f7a8b.flac # Fichier 2 //! └── 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 //! ```sql
//! CREATE TABLE {table_name} ( //! CREATE TABLE asset (
//! pk TEXT PRIMARY KEY, -- Clé unique (hash SHA1 de l'URL) //! pk TEXT PRIMARY KEY,
//! source_url TEXT, -- URL source //! collection TEXT,
//! collection TEXT, -- Collection (album, playlist, etc.) //! id TEXT,
//! hits INTEGER DEFAULT 0, -- Nombre d'accès //! hits INTEGER DEFAULT 0,
//! last_used TEXT -- Dernière utilisation (RFC3339) //! 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 //! - [`cache`] : gestion du cache sur disque + opérations asynchrones ;
//! - `reqwest` : Téléchargement HTTP //! - [`db`] : accès SQLite, contraintes et helpers métadonnées ;
//! - `sha1` : Génération de clés //! - [`download`] : primitives de téléchargement et de transformation ;
//! - `tokio` : Runtime asynchrone //! - [`cache_trait`] : trait partagé entre implémentations spécialisées.
//! //!
//! ## Voir aussi //! ## Crates associées
//! //!
//! - [`pmocovers`] : Cache d'images avec conversion WebP //! - [`pmocovers`] : cache d'images reposant sur `pmocache` ;
//! - [`pmoaudiocache`] : Cache de pistes audio //! - [`pmoaudiocache`] : spécialisation audio avec extraction de métadonnées.
pub mod cache; pub mod cache;
pub mod cache_trait; pub mod cache_trait;

View File

@@ -20,6 +20,21 @@
//! //!
//! ## Utilisation //! ## 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 //! ### Exemple avec configuration automatique
//! //!
//! ```rust,no_run //! ```rust,no_run

View File

@@ -60,7 +60,7 @@ pmosource = { path = "../pmosource" }
# Playlist management for FIFO support # Playlist management for FIFO support
pmoplaylist = { path = "../pmoplaylist" } pmoplaylist = { path = "../pmoplaylist" }
pmoconfig = { path = "../pmoconfig" } pmoconfig = { path = "../pmoconfig", optional = true }
# Cache support (OBLIGATOIRE - architecture refactorisée) # Cache support (OBLIGATOIRE - architecture refactorisée)
pmocovers = { path = "../pmocovers" } pmocovers = { path = "../pmocovers" }
@@ -72,7 +72,7 @@ utoipa = { version = "5.4.0", optional = true }
axum = { version = "0.8.4", optional = true } axum = { version = "0.8.4", optional = true }
[features] [features]
default = ["metadata-only"] default = ["metadata-only", "pmoconfig"]
# Mode métadonnées seules (pas de décodage FLAC) # Mode métadonnées seules (pas de décodage FLAC)
metadata-only = [] metadata-only = []
# Active l'extraction par-track (WAV export, etc.) # Active l'extraction par-track (WAV export, etc.)
@@ -80,7 +80,9 @@ per-track = ["dep:hound", "dep:tempfile"]
# Active l'API REST pmoserver # Active l'API REST pmoserver
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"] pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
# Feature pour activer le support serveur (cache registry) # 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) # Feature cache (deprecated - toujours actif maintenant)
cache = [] cache = []

View File

@@ -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: `<config_dir>/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<String>;
/// 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<usize>;
/// 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<String> {
// 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<usize> {
// 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);
}
}

View File

@@ -232,6 +232,9 @@ pub mod track;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
pub mod pmoserver_ext; pub mod pmoserver_ext;
#[cfg(feature = "pmoconfig")]
pub mod config_ext;
// Re-exports for convenience // Re-exports for convenience
pub use client::{ClientBuilder, RadioParadiseClient}; pub use client::{ClientBuilder, RadioParadiseClient};
pub use error::{Error, Result}; pub use error::{Error, Result};
@@ -247,6 +250,9 @@ pub use pmoserver_ext::{
create_api_router, RadioParadiseApiDoc, RadioParadiseExt, RadioParadiseState, create_api_router, RadioParadiseApiDoc, RadioParadiseExt, RadioParadiseState,
}; };
#[cfg(feature = "pmoconfig")]
pub use config_ext::RadioParadiseConfigExt;
// Version information // Version information
pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View File

@@ -78,12 +78,14 @@ impl Default for CacheConfig {
} }
/// Persisted history tuning. /// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryConfig { pub struct HistoryConfig {
#[serde(default = "HistoryConfig::default_max_tracks")] #[serde(default = "HistoryConfig::default_max_tracks")]
pub max_tracks: usize, pub max_tracks: usize,
#[serde(default)]
pub persistence_backend: HistoryBackendKind,
#[serde(default = "HistoryConfig::default_database_path")] #[serde(default = "HistoryConfig::default_database_path")]
pub database_path: String, pub database_path: String,
} }
@@ -94,7 +96,8 @@ impl HistoryConfig {
} }
fn default_database_path() -> String { 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 { fn default() -> Self {
Self { Self {
max_tracks: Self::default_max_tracks(), max_tracks: Self::default_max_tracks(),
persistence_backend: HistoryBackendKind::Sqlite,
database_path: Self::default_database_path(), 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. /// Activity lifecycle tuning.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityConfig { pub struct ActivityConfig {

View File

@@ -1,11 +1,10 @@
//! History persistence for Radio Paradise playback. //! History persistence for Radio Paradise playback.
//! //!
//! The worker pushes every completed track into the history backend while //! The worker pushes every completed track into the history backend while
//! keeping the latest entries available for UPnP browsing. We expose an //! keeping the latest entries available for UPnP browsing. We use SQLite
//! abstract trait so different storage engines (SQLite, JSON, etc.) can be //! for persistent storage with an abstract trait for testability.
//! supported while sharing the same API.
use super::config::{HistoryBackendKind, HistoryConfig}; use super::config::HistoryConfig;
use crate::models::Song; use crate::models::Song;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@@ -60,99 +59,16 @@ pub trait HistoryBackend: Send + Sync {
async fn truncate(&self, keep: usize) -> anyhow::Result<()>; 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( pub fn history_backend_from_config(
config: &HistoryConfig, config: &HistoryConfig,
) -> anyhow::Result<Arc<dyn HistoryBackend>> { ) -> anyhow::Result<Arc<dyn HistoryBackend>> {
match config.persistence_backend { let backend = SqliteHistoryBackend::new(&config.database_path)?;
HistoryBackendKind::Sqlite => { Ok(Arc::new(backend))
let backend = SqliteHistoryBackend::new(&config.database_path)?;
Ok(Arc::new(backend))
}
HistoryBackendKind::Json => {
let backend = JsonHistoryBackend::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<Mutex<Vec<HistoryEntry>>>,
}
impl JsonHistoryBackend {
pub fn new(path: impl AsRef<Path>) -> 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<Vec<HistoryEntry>> {
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<usize> {
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<Mutex<Vec<HistoryEntry>>>,
}
impl MemoryHistoryBackend {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait] #[async_trait]
impl HistoryBackend for MemoryHistoryBackend { impl HistoryBackend for MemoryHistoryBackend {

View File

@@ -26,9 +26,6 @@ pub use config::{
ActivityConfig, ApiConfig, CacheConfig, HistoryConfig, PollingConfig, RadioParadiseConfig, ActivityConfig, ApiConfig, CacheConfig, HistoryConfig, PollingConfig, RadioParadiseConfig,
StreamConfig, StreamConfig,
}; };
pub use history::{ pub use history::{history_backend_from_config, HistoryBackend, HistoryEntry};
history_backend_from_config, HistoryBackend, HistoryEntry, JsonHistoryBackend,
MemoryHistoryBackend,
};
pub use playlist::PlaylistEntry; pub use playlist::PlaylistEntry;
pub use worker::{ParadiseWorker, WorkerCommand}; pub use worker::{ParadiseWorker, WorkerCommand};

View File

@@ -7,8 +7,8 @@
use crate::client::RadioParadiseClient; use crate::client::RadioParadiseClient;
use crate::paradise::{ use crate::paradise::{
history_backend_from_config, ChannelDescriptor, MemoryHistoryBackend, ParadiseChannel, history_backend_from_config, ChannelDescriptor, ParadiseChannel, PlaylistEntry,
PlaylistEntry, RadioParadiseConfig, ALL_CHANNELS, RadioParadiseConfig, ALL_CHANNELS,
}; };
use anyhow::Result as AnyhowResult; use anyhow::Result as AnyhowResult;
use pmoaudiocache::Cache as AudioCache; use pmoaudiocache::Cache as AudioCache;
@@ -77,7 +77,31 @@ impl RadioParadiseSource {
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub fn from_registry(client: RadioParadiseClient) -> Result<Self> { pub fn from_registry(client: RadioParadiseClient) -> Result<Self> {
let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default()); 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!( MusicSourceError::SourceUnavailable(format!(
"Failed to initialize history backend: {}", "Failed to initialize history backend: {}",
e e
@@ -125,8 +149,7 @@ impl RadioParadiseSource {
let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default()); let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default());
let history_backend: Arc<dyn crate::paradise::HistoryBackend> = let history_backend: Arc<dyn crate::paradise::HistoryBackend> =
history_backend_from_config(&config.history).unwrap_or_else(|err| { history_backend_from_config(&config.history).unwrap_or_else(|err| {
warn!("Falling back to in-memory history backend: {err}"); panic!("Failed to initialize history backend: {err}");
Arc::new(MemoryHistoryBackend::new()) as Arc<dyn crate::paradise::HistoryBackend>
}); });
let mut channels = HashMap::new(); let mut channels = HashMap::new();

View File

@@ -22,3 +22,4 @@ axum-embed = "0.1.0"
rust-embed = "8.7.2" rust-embed = "8.7.2"
utoipa = { version = "5.4.0", features = ["axum_extras"] } utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
once_cell = "1.19"

View File

@@ -78,3 +78,131 @@ pub use logs::{
log_setup_get, log_setup_post, log_sse, log_setup_get, log_setup_post, log_sse,
}; };
pub use server::{ApiRegistry, ApiRegistryEntry, Server, ServerBuilder, ServerInfo}; 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<Arc<RwLock<Server>>> = 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<RwLock<Server>> {
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<RwLock<Server>>)` 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<Arc<RwLock<Server>>> {
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<String> {
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() })
}
})
}

View File

@@ -188,22 +188,26 @@ pub trait UpnpServerExt {
/// `true` si SSDP est actif, `false` sinon /// `true` si SSDP est actif, `false` sinon
fn ssdp_enabled(&self) -> bool; 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 : /// Cette méthode factory initialise le **singleton global** du serveur avec
/// - Serveur HTTP (via pmoserver) /// l'infrastructure UPnP complète :
/// - Serveur HTTP (via pmoserver singleton)
/// - Caches (couvertures + audio) /// - Caches (couvertures + audio)
/// - Logging /// - Logging
/// - Serveur SSDP /// - 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 : /// Après cette méthode, l'utilisateur doit :
/// - Enregistrer ses devices via `register_device()` /// - Enregistrer ses devices via `register_device()`
/// - Enregistrer ses sources musicales /// - Enregistrer ses sources musicales (via fonctions globales)
/// - Appeler `wait()` pour attendre l'arrêt /// - Appeler `start()` puis `wait()` pour attendre l'arrêt
/// ///
/// # Returns /// # Returns
/// ///
/// Un serveur UPnP prêt à l'emploi /// Une référence Arc vers le serveur UPnP global, prêt à l'emploi
/// ///
/// # Errors /// # Errors
/// ///
@@ -215,11 +219,11 @@ pub trait UpnpServerExt {
/// use pmoupnp::UpnpServerExt; /// use pmoupnp::UpnpServerExt;
/// use pmoserver::Server; /// use pmoserver::Server;
/// ///
/// let mut server = Server::create_upnp_server().await?; /// let server = Server::create_upnp_server().await?;
/// server.register_device(my_device).await?; /// server.write().await.register_device(my_device).await?;
/// server.wait().await; /// server.read().await.wait().await;
/// ``` /// ```
async fn create_upnp_server() -> Result<Server, anyhow::Error>; async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
} }
// Implémentation du trait UpnpServer pour pmoserver::Server // Implémentation du trait UpnpServer pour pmoserver::Server
@@ -414,21 +418,20 @@ impl UpnpServerExt for Server {
SSDP_SERVER.read().unwrap().is_some() SSDP_SERVER.read().unwrap().is_some()
} }
async fn create_upnp_server() -> Result<Server, anyhow::Error> { async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error> {
use pmoserver::ServerBuilder;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
// 1. Créer le serveur depuis la config // 1. Initialiser le serveur global singleton
info!("🔧 Creating UPnP server from configuration..."); info!("🔧 Initializing global UPnP server from configuration...");
let mut server = ServerBuilder::new_configured().build(); let server_arc = pmoserver::init_server();
// 2. Initialiser le logging HTTP (routes de logs + tracing) // 2. Initialiser le logging HTTP (routes de logs + tracing)
info!("📝 Initializing logging..."); info!("📝 Initializing logging...");
server.init_logging().await; server_arc.write().await.init_logging().await;
// 3. Initialiser les caches // 3. Initialiser les caches
info!("💾 Initializing caches..."); info!("💾 Initializing caches...");
match server.init_caches().await { match server_arc.write().await.init_caches().await {
Ok(_) => { Ok(_) => {
info!("✅ Caches initialized"); info!("✅ Caches initialized");
} }
@@ -440,15 +443,16 @@ impl UpnpServerExt for Server {
// 4. Le serveur HTTP n'est PAS encore démarré // 4. Le serveur HTTP n'est PAS encore démarré
// Il sera démarré après l'enregistrement des devices et routes // 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 // 5. Enregistrer l'API d'introspection UPnP
info!("📡 Registering UPnP API..."); info!("📡 Registering UPnP API...");
server.register_upnp_api().await; server_arc.write().await.register_upnp_api().await;
// 6. Initialiser SSDP // 6. Initialiser SSDP
info!("📡 Initializing SSDP discovery..."); info!("📡 Initializing SSDP discovery...");
match server.init_ssdp() { match server_arc.write().await.init_ssdp() {
Ok(_) => info!("✅ SSDP server initialized"), Ok(_) => info!("✅ SSDP server initialized"),
Err(e) => { Err(e) => {
let kind = e.kind(); let kind = e.kind();
@@ -477,7 +481,7 @@ impl UpnpServerExt for Server {
info!("🎉 UPnP server infrastructure ready"); info!("🎉 UPnP server infrastructure ready");
info!("📝 Next: Register devices and music sources"); info!("📝 Next: Register devices and music sources");
Ok(server) Ok(server_arc)
} }
} }