Mise à jour de la doc des caches

This commit is contained in:
2025-10-13 11:35:07 +02:00
parent 1c83416be4
commit e7e6727123
7 changed files with 242 additions and 50 deletions

View File

@@ -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

View File

@@ -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...");

View File

@@ -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
//!

View File

@@ -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<Self> {
let mut path = filename.to_string();
let mut data: Option<Vec<u8>> = 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<Value> {
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<String> {
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<String> {
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<usize> {
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<String> {
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<usize> {
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<String> {
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<usize> {
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<bool> {
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<String> {
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());
}
}
}
}

View File

@@ -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:

View File

@@ -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,
}
}

View File

@@ -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())