From 4a01c907207ed56a59451e3e0599c4334a06e3c1 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 13 Oct 2025 11:35:07 +0200 Subject: [PATCH] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20doc=20des=20ca?= =?UTF-8?q?ches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pmomusic.yml | 7 +++ PMOMusic/src/main.rs | 2 +- pmoaudiocache/src/lib.rs | 109 +++++++++++++++++++++++++++++------- pmoconfig/src/lib.rs | 106 ++++++++++++++++++++++++++++++++--- pmoconfig/src/pmomusic.yaml | 7 +++ pmoserver/src/logs/mod.rs | 47 ++++++++++++---- pmoserver/src/server.rs | 14 ++--- 7 files changed, 242 insertions(+), 50 deletions(-) diff --git a/.pmomusic.yml b/.pmomusic.yml index 965dbc41..6f707452 100644 --- a/.pmomusic.yml +++ b/.pmomusic.yml @@ -3,6 +3,13 @@ host: cover_cache: directory: ./.pmomusic_covers size: 2000 + audio_cache: + directory: ./.pmomusic_audio + size: 500 + logger: + buffer_capacity: 200 + enable_console: true + min_level: INFO devices: mediarenderer: mpd_renderer: null diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index c99fa2c9..3ca1f989 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -18,7 +18,7 @@ async fn main() { let mut server = ServerBuilder::new_configured().build(); // Initialiser le logging et enregistrer les routes de logs - server.init_logging(LoggingOptions::default()).await; + server.init_logging().await; info!("📡 Registering the cover cache..."); diff --git a/pmoaudiocache/src/lib.rs b/pmoaudiocache/src/lib.rs index 3951101f..07a76d98 100644 --- a/pmoaudiocache/src/lib.rs +++ b/pmoaudiocache/src/lib.rs @@ -6,19 +6,36 @@ //! ## Vue d'ensemble //! //! `pmoaudiocache` étend `pmocache` pour gérer spécifiquement les fichiers audio : +//! - **Cache à deux phases** : métadonnées immédiates + conversion asynchrone //! - Téléchargement et stockage de pistes audio -//! - Extraction automatique des métadonnées (titre, artiste, album, etc.) +//! - Extraction automatique des métadonnées (fichier + services externes) //! - Gestion de collections basées sur artiste/album //! - Cache persistant avec base de données SQLite //! - API HTTP optionnelle pour récupérer les pistes //! -//! ## Fonctionnalités +//! ## Fonctionnalités principales +//! +//! ### ⚡ Cache à deux phases +//! +//! Le système de cache permet de servir les métadonnées **immédiatement** (< 1 seconde) +//! pendant que la conversion FLAC s'effectue en arrière-plan : +//! +//! **Phase 1 (immédiate)** : +//! - Extraction des métadonnées du fichier original +//! - Fusion avec métadonnées externes (Qobuz, Radio Paradise, CD) +//! - Stockage en base de données +//! - Service immédiat du DIDL-Lite pour MediaServer +//! +//! **Phase 2 (asynchrone)** : +//! - Conversion automatique en FLAC en arrière-plan +//! - Suivi du statut de conversion +//! - Nettoyage automatique des fichiers temporaires //! //! ### 📦 Gestion du cache //! - Téléchargement automatique depuis des URLs //! - **Conversion automatique en FLAC** (standardisation du stockage) //! - Stockage persistant sur disque -//! - Base de données SQLite pour le tracking +//! - Base de données SQLite pour le tracking des métadonnées ET du statut //! - Extraction des métadonnées audio (via lofty) //! //! ### 🎵 Gestion des collections @@ -54,19 +71,59 @@ //! ### Exemple basique //! //! ```rust,no_run -//! use pmoaudiocache::AudioCache; +//! use pmoaudiocache::{AudioCache, AudioMetadata}; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { //! let cache = AudioCache::new("./audio_cache", 1000)?; //! -//! // Ajouter une piste depuis une URL -//! let (pk, metadata) = cache.add_from_url("http://example.com/track.flac").await?; -//! println!("Piste ajoutée: {} - {}", metadata.artist.unwrap(), metadata.title.unwrap()); +//! // Ajouter une piste depuis une URL (sans métadonnées externes) +//! let (pk, metadata) = cache.add_from_url("http://example.com/track.flac", None).await?; +//! println!("Piste ajoutée: {} - {}", +//! metadata.artist.as_deref().unwrap_or("Unknown"), +//! metadata.title.as_deref().unwrap_or("Unknown")); //! -//! // Récupérer la piste -//! let (path, metadata) = cache.get(&pk).await?; -//! println!("Piste stockée à: {:?}", path); +//! // Les métadonnées sont disponibles IMMÉDIATEMENT +//! let metadata = cache.get_metadata(&pk).await?; +//! println!("Métadonnées disponibles: {:?}", metadata); +//! +//! // Le fichier FLAC est disponible après conversion +//! let file_path = cache.get_file(&pk).await?; +//! println!("Fichier FLAC stocké à: {:?}", file_path); +//! +//! Ok(()) +//! } +//! ``` +//! +//! ### Exemple avec métadonnées externes (Qobuz, Radio Paradise, etc.) +//! +//! ```rust,no_run +//! use pmoaudiocache::{AudioCache, AudioMetadata}; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let cache = AudioCache::new("./audio_cache", 1000)?; +//! +//! // Métadonnées provenant d'un service externe (Qobuz, etc.) +//! let external_metadata = AudioMetadata { +//! title: Some("Wish You Were Here".to_string()), +//! artist: Some("Pink Floyd".to_string()), +//! album: Some("Wish You Were Here".to_string()), +//! year: Some(1975), +//! track_number: Some(1), +//! ..Default::default() +//! }; +//! +//! // Ajouter la piste avec fusion des métadonnées +//! // (les métadonnées externes ont priorité sur celles du fichier) +//! let (pk, metadata) = cache.add_from_url( +//! "http://example.com/track.flac", +//! Some(external_metadata) +//! ).await?; +//! +//! // Générer immédiatement le DIDL-Lite pour MediaServer +//! let didl = cache.get_didl(&pk, "http://localhost:8080").await?; +//! println!("DIDL-Lite disponible immédiatement:\n{}", didl); //! //! Ok(()) //! } @@ -82,8 +139,8 @@ //! let cache = AudioCache::new("./audio_cache", 1000)?; //! //! // Ajouter des pistes (elles seront automatiquement regroupées par album) -//! cache.add_from_url("http://example.com/track1.flac").await?; -//! cache.add_from_url("http://example.com/track2.flac").await?; +//! cache.add_from_url("http://example.com/track1.flac", None).await?; +//! cache.add_from_url("http://example.com/track2.flac", None).await?; //! //! // Lister les collections disponibles //! let collections = cache.list_collections().await?; @@ -93,11 +150,11 @@ //! //! // Récupérer toutes les pistes d'un album //! let tracks = cache.get_collection("pink_floyd:wish_you_were_here").await?; -//! for (pk, path, metadata) in tracks { +//! for entry in tracks { //! println!("{:02}. {} - {}", -//! metadata.track_number.unwrap_or(0), -//! metadata.title.unwrap_or_default(), -//! path.display() +//! entry.metadata.track_number.unwrap_or(0), +//! entry.metadata.title.as_deref().unwrap_or("Unknown"), +//! entry.pk //! ); //! } //! @@ -129,11 +186,23 @@ //! //! Les endpoints suivants sont disponibles : //! -//! - `GET /audio/tracks/{pk}` - Récupère une piste audio -//! - `GET /audio/tracks/{pk}/metadata` - Récupère les métadonnées d'une piste -//! - `GET /audio/collections` - Liste les collections disponibles -//! - `GET /audio/collections/{collection}` - Récupère toutes les pistes d'une collection +//! ### Routes directes +//! - `GET /audio/tracks/{pk}/stream` - Stream le fichier FLAC (attend la conversion si nécessaire) +//! - `GET /audio/tracks/{pk}/metadata` - Récupère les métadonnées JSON (disponible immédiatement) +//! - `GET /audio/tracks/{pk}/didl` - Récupère le DIDL-Lite XML (disponible immédiatement) +//! - `GET /audio/tracks/{pk}/status` - Récupère le statut de conversion //! - `GET /audio/stats` - Statistiques du cache +//! - `GET /audio/collections` - Liste les collections disponibles +//! +//! ### API REST (sous `/api/audio`) +//! - `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}/metadata` - Métadonnées d'une piste +//! - `GET /api/audio/{pk}/didl` - DIDL-Lite d'une piste +//! - `POST /api/audio/consolidate` - Consolide le cache (nettoie les entrées orphelines) +//! - `DELETE /api/audio` - Purge tout le cache //! //! ## Métadonnées supportées //! diff --git a/pmoconfig/src/lib.rs b/pmoconfig/src/lib.rs index a0e79d77..45a772fb 100644 --- a/pmoconfig/src/lib.rs +++ b/pmoconfig/src/lib.rs @@ -2,7 +2,7 @@ use anyhow::{anyhow, Result}; use dirs::home_dir; use lazy_static::lazy_static; use pmoutils::guess_local_ip; -use serde_yaml::{Mapping, Value}; +use serde_yaml::{Mapping, Number, Value}; use std::{ env, fs, path::{Path, PathBuf}, @@ -40,7 +40,6 @@ impl Clone for Config { } impl Config { - pub fn load_config(filename: &str) -> Result { let mut path = filename.to_string(); let mut data: Option> = None; @@ -100,10 +99,9 @@ impl Config { DEFAULT_CONFIG.as_bytes().to_vec() }; - let external_value: Value = serde_yaml::from_slice(&yaml_data)?; merge_yaml(&mut default_value, &external_value); - let mut config_value = Self::lower_keys_value(default_value); + let mut config_value = Self::lower_keys_value(default_value); Self::apply_env_overrides(&mut config_value); @@ -181,7 +179,6 @@ impl Config { fn get_value_internal(data: &Value, path: &[&str]) -> Result { let mut current = data; for (i, key) in path.iter().enumerate() { - if let Value::Mapping(map) = current { let key = key.to_lowercase(); @@ -293,6 +290,11 @@ impl Config { } } + pub fn set_http_port(&self, port: u16) -> Result<()> { + let n = Number::from(port); + self.set_value(&["host", "http_port"], Value::Number(n)) + } + pub fn get_device_udn(&self, devtype: &str, name: &str) -> Result { let path = &["devices", devtype, name, "udn"]; match self.get_value(path) { @@ -305,6 +307,10 @@ impl Config { } } + pub fn set_device_udn(&self, devtype: &str, name: &str, udn: String) -> Result<()> { + self.set_value(&["devices", devtype, name, "udn"], Value::String(udn)) + } + pub fn get_cover_cache_dir(&self) -> Result { match self.get_value(&["host", "cover_cache", "directory"])? { Value::String(s) => Ok(s), @@ -312,6 +318,13 @@ impl Config { } } + pub fn set_cover_cache_dir(&self, directory: String) -> Result<()> { + self.set_value( + &["host", "cover_cache", "directory"], + Value::String(directory), + ) + } + pub fn get_cover_cache_size(&self) -> Result { match self.get_value(&["host", "cover_cache", "size"])? { Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize), @@ -320,6 +333,38 @@ impl Config { } } + pub fn set_cover_cache_size(&self, size: usize) -> Result<()> { + let n = Number::from(size); + self.set_value(&["host", "cover_cache", "size"], Value::Number(n)) + } + + pub fn get_audio_cache_dir(&self) -> Result { + match self.get_value(&["host", "audio_cache", "directory"])? { + Value::String(s) => Ok(s), + _ => Ok("./.pmomusic_audio".to_string()), + } + } + + pub fn set_audio_cache_dir(&self, directory: String) -> Result<()> { + self.set_value( + &["host", "audio_cache", "directory"], + Value::String(directory), + ) + } + + pub fn get_audio_cache_size(&self) -> Result { + match self.get_value(&["host", "audio_cache", "size"])? { + Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize), + Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize), + _ => Ok(500), + } + } + + pub fn set_audio_cache_size(&self, size: usize) -> Result<()> { + let n = Number::from(size); + self.set_value(&["host", "audio_cache", "size"], Value::Number(n)) + } + /// Récupère le nom d'utilisateur Qobuz depuis la configuration pub fn get_qobuz_username(&self) -> Result { match self.get_value(&["accounts", "qobuz", "username"])? { @@ -330,7 +375,10 @@ impl Config { /// Définit le nom d'utilisateur Qobuz dans la configuration pub fn set_qobuz_username(&self, username: &str) -> Result<()> { - self.set_value(&["accounts", "qobuz", "username"], Value::String(username.to_string())) + self.set_value( + &["accounts", "qobuz", "username"], + Value::String(username.to_string()), + ) } /// Récupère le mot de passe Qobuz depuis la configuration @@ -343,7 +391,10 @@ impl Config { /// Définit le mot de passe Qobuz dans la configuration pub fn set_qobuz_password(&self, password: &str) -> Result<()> { - self.set_value(&["accounts", "qobuz", "password"], Value::String(password.to_string())) + self.set_value( + &["accounts", "qobuz", "password"], + Value::String(password.to_string()), + ) } /// Récupère les credentials Qobuz (username + password) depuis la configuration @@ -352,6 +403,43 @@ impl Config { let password = self.get_qobuz_password()?; Ok((username, password)) } + + pub fn get_log_cache_size(&self) -> Result { + match self.get_value(&["host", "logger", "buffer_capacity"])? { + Value::Number(n) => n + .as_u64() + .map(|v| v as usize) + .ok_or_else(|| anyhow::anyhow!("Number is not an unsigned integer")), + _ => Ok(1000), + } + } + + pub fn set_log_cache_size(&self, size: usize) -> Result<()> { + let n = Number::from(size); + self.set_value(&["host", "logger", "buffer_capacity"], Value::Number(n)) + } + + pub fn get_log_enable_console(&self) -> Result { + match self.get_value(&["host", "logger", "enable_console"])? { + Value::Bool(b) => Ok(b), + _ => Ok(true), + } + } + + pub fn set_log_enable_console(&self, enable: bool) -> Result<()> { + self.set_value(&["host", "logger", "enable_console"], Value::Bool(enable)) + } + + pub fn get_log_min_level(&self) -> Result { + match self.get_value(&["host", "logger", "min_level"])? { + Value::String(s) => Ok(s), + _ => Ok("INFO".to_string()), + } + } + + pub fn set_log_min_level(&self, level: String) -> Result<()> { + self.set_value(&["host", "logger", "min_level"], Value::String(level)) + } } /// Retourne l'instance globale @@ -365,7 +453,9 @@ fn merge_yaml(default: &mut Value, external: &Value) { for (k, v) in emap { match dmap.get_mut(k) { Some(dv) => merge_yaml(dv, v), - None => { dmap.insert(k.clone(), v.clone()); } + None => { + dmap.insert(k.clone(), v.clone()); + } } } } diff --git a/pmoconfig/src/pmomusic.yaml b/pmoconfig/src/pmomusic.yaml index 047af744..d584d632 100644 --- a/pmoconfig/src/pmomusic.yaml +++ b/pmoconfig/src/pmomusic.yaml @@ -3,6 +3,13 @@ host: cover_cache: directory: "./.pmomusic_covers" size: 2000 + audio_cache: + directory: "./.pmomusic_audio" + size: 500 + logger: + buffer_capacity: 200 + enable_console: true + min_level: "INFO" devices: mediarenderer: mpd_renderer: diff --git a/pmoserver/src/logs/mod.rs b/pmoserver/src/logs/mod.rs index c1e66ea6..aa0ed96d 100644 --- a/pmoserver/src/logs/mod.rs +++ b/pmoserver/src/logs/mod.rs @@ -1,6 +1,7 @@ // logs.rs mod sselayer; +use pmoconfig::get_config; pub use sselayer::SseLayer; use std::{ @@ -61,13 +62,7 @@ impl LogState { *self.max_level.write().unwrap() = level; // Convertir Level en LevelFilter - let level_filter = match level { - Level::ERROR => LevelFilter::ERROR, - Level::WARN => LevelFilter::WARN, - Level::INFO => LevelFilter::INFO, - Level::DEBUG => LevelFilter::DEBUG, - Level::TRACE => LevelFilter::TRACE, - }; + let level_filter = level_to_levelfilter(level); // Recharger le filtre dynamiquement if let Err(e) = self.reload_handle.write().unwrap().reload(level_filter) { @@ -228,19 +223,39 @@ impl Default for LoggingOptions { /// enable_console: true, /// }); /// ``` -pub fn init_logging(options: LoggingOptions) -> LogState { +pub fn init_logging() -> LogState { + let config = get_config(); // Créer un filtre rechargeable qui commence à TRACE - let (filter, reload_handle) = reload::Layer::new(LevelFilter::TRACE); + + let log_level = match config.get_log_min_level() { + Ok(l) => match string_to_level(&l) { + Some(lev) => level_to_levelfilter(lev), + None => LevelFilter::INFO, + } + Err(_) => LevelFilter::INFO + }; + + let (filter, reload_handle) = reload::Layer::new(log_level); + + let buffer_capacity = match config.get_log_cache_size() { + Ok(c) => c, + Err(_) => 500 + }; // Créer le LogState avec le handle de rechargement - let log_state = LogState::new(options.buffer_capacity, reload_handle); + let log_state = LogState::new(buffer_capacity, reload_handle); // Construire le subscriber avec le filtre rechargeable let subscriber = Registry::default() .with(filter) .with(SseLayer::new(log_state.clone())); - if options.enable_console { + let enable_console = match config.get_log_enable_console() { + Ok(b) => b, + Err(_) => true, + }; + + if enable_console { subscriber .with( tracing_subscriber::fmt::layer() @@ -342,3 +357,13 @@ fn level_to_string(level: Level) -> String { } .to_string() } + +fn level_to_levelfilter(level: Level) -> LevelFilter { + match level { + Level::ERROR => LevelFilter::ERROR, + Level::WARN => LevelFilter::WARN, + Level::INFO => LevelFilter::INFO, + Level::DEBUG => LevelFilter::DEBUG, + Level::TRACE => LevelFilter::TRACE, + } +} \ No newline at end of file diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index 31449391..f8ee41e6 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -486,20 +486,14 @@ impl Server { /// # async fn main() { /// let mut server = ServerBuilder::new_configured().build(); /// - /// // Initialiser les logs avec console - /// server.init_logging(LoggingOptions::default()).await; - /// - /// // Ou sans console - /// server.init_logging(LoggingOptions { - /// buffer_capacity: 1000, - /// enable_console: false, - /// }).await; + /// // Initialiser les logs + /// server.init_logging().await; /// /// server.start().await; /// # } /// ``` - pub async fn init_logging(&mut self, options: LoggingOptions) { - let log_state = init_logging(options); + pub async fn init_logging(&mut self) { + let log_state = init_logging(); // Enregistrer automatiquement les routes de logging self.add_handler_with_state("/log-sse", log_sse, log_state.clone())