Mise à jour de la doc des caches
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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...");
|
||||
|
||||
@@ -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
|
||||
//!
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user