On continue le refactoring des sources
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2575,6 +2575,7 @@ dependencies = [
|
||||
"axum-server",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"pmoconfig",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
|
||||
@@ -3,16 +3,14 @@ use pmomediarenderer::MEDIA_RENDERER;
|
||||
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt};
|
||||
use pmoserver::Server;
|
||||
use pmosource::MusicSourceExt;
|
||||
use pmoupnp::{UpnpServerExt, upnp_api::UpnpApiExt};
|
||||
use pmoupnp::UpnpServerExt;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// ========== PHASE 1 : Infrastructure UPnP ==========
|
||||
let mut server = Server::create_upnp_server().await?;
|
||||
|
||||
// Routes personnalisées de l'application
|
||||
server
|
||||
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
|
||||
server.write().await
|
||||
.add_route("/info", || async {
|
||||
serde_json::json!({"version": "1.0.0"})
|
||||
})
|
||||
@@ -20,7 +18,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Initialiser le système de gestion des sources musicales avec API REST
|
||||
info!("📡 Initializing music sources management system...");
|
||||
server
|
||||
server.write().await
|
||||
.init_music_sources()
|
||||
.await
|
||||
.expect("Failed to initialize music sources API");
|
||||
@@ -36,12 +34,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// }
|
||||
|
||||
// Enregistrer Radio Paradise (inclut l'initialisation de l'API)
|
||||
if let Err(e) = server.register_paradise().await {
|
||||
if let Err(e) = server.write().await.register_paradise().await {
|
||||
tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e);
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
let sources = server.list_music_sources().await;
|
||||
let sources = server.read().await.list_music_sources().await;
|
||||
info!("✅ {} music source(s) registered", sources.len());
|
||||
for source in sources {
|
||||
info!(" - {} ({})", source.name(), source.id());
|
||||
@@ -50,7 +48,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
|
||||
info!("📡 Registering UPnP devices...");
|
||||
|
||||
let renderer_instance = server
|
||||
let renderer_instance = server.write().await
|
||||
.register_device(MEDIA_RENDERER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer");
|
||||
@@ -61,7 +59,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
renderer_instance.description_route()
|
||||
);
|
||||
|
||||
let server_instance = server
|
||||
let server_instance = server.write().await
|
||||
.register_device(MEDIA_SERVER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
@@ -74,16 +72,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
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 ==========
|
||||
|
||||
info!("🌐 Starting HTTP server...");
|
||||
server.start().await;
|
||||
server.write().await.start().await;
|
||||
|
||||
info!("✅ PMOMusic is ready!");
|
||||
info!("Press Ctrl+C to stop...");
|
||||
server.wait().await;
|
||||
server.write().await.wait().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -363,10 +363,10 @@ pub fn new_cache(dir: &str, limit: usize) -> Result<Cache> {
|
||||
/// use pmoaudiocache::cache;
|
||||
///
|
||||
/// # async fn example() -> anyhow::Result<()> {
|
||||
/// let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?;
|
||||
/// let cache = cache::new_cache("./audio_cache", 1000)?;
|
||||
/// let pk = cache::add_with_metadata_extraction(
|
||||
/// &cache,
|
||||
/// "http://example.com/track.flac",
|
||||
/// "https://example.com/track.flac",
|
||||
/// Some("artist:album")
|
||||
/// ).await?;
|
||||
/// # Ok(())
|
||||
|
||||
150
pmoaudiocache/src/flac.rs
Normal file
150
pmoaudiocache/src/flac.rs
Normal 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));
|
||||
// }
|
||||
// }
|
||||
@@ -1,134 +1,80 @@
|
||||
//! # pmoaudiocache - Cache de pistes audio pour PMOMusic
|
||||
//! # pmoaudiocache – Cache de pistes audio pour PMOMusic
|
||||
//!
|
||||
//! Cette crate fournit un système de cache pour les pistes audio avec conversion
|
||||
//! automatique en FLAC et extraction des métadonnées.
|
||||
//! `pmoaudiocache` s'appuie sur [`pmocache`] pour fournir un cache spécialisé
|
||||
//! dans les fichiers audio. Il assure la conversion transparente au format FLAC,
|
||||
//! l'extraction des métadonnées et la mise à disposition d'outils pour les exposer.
|
||||
//!
|
||||
//! ## Vue d'ensemble
|
||||
//! ## Fonctionnalités
|
||||
//!
|
||||
//! `pmoaudiocache` étend `pmocache` pour gérer spécifiquement les fichiers audio :
|
||||
//! - **Téléchargement asynchrone** via le système de download de `pmocache`
|
||||
//! - **Conversion automatique en FLAC** lors du téléchargement (via transformer)
|
||||
//! - **Extraction et stockage des métadonnées** en JSON dans la base de données
|
||||
//! - **Gestion de collections** basées sur artiste/album
|
||||
//! - **Streaming progressif** automatique (via `pmocache`)
|
||||
//! - **API REST complète** fournie par `pmocache`
|
||||
//! - conversion automatique des entrées en FLAC grâce à un `StreamTransformer` ;
|
||||
//! - extraction des tags (artiste, album, titre, etc.) via [`metadata::AudioMetadata`] ;
|
||||
//! - stockage des métadonnées dans la table `metadata` de `pmocache::DB` ;
|
||||
//! - helpers pour renseigner les collections à partir des tags ;
|
||||
//! - intégration optionnelle avec `pmoserver` (routes REST + diffusion de fichiers).
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! Cette crate est une spécialisation minimale de `pmocache` :
|
||||
//! - Configuration via `AudioConfig`
|
||||
//! - Transformer FLAC pour la conversion automatique
|
||||
//! - Helpers pour l'extraction et la lecture des métadonnées
|
||||
//!
|
||||
//! Tout le reste (DB, API REST, streaming) est fourni par `pmocache`.
|
||||
//!
|
||||
//! ## Utilisation
|
||||
//!
|
||||
//! ### Exemple basique
|
||||
//! ## Exemple rapide
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoaudiocache::cache;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! // Créer le cache
|
||||
//! let cache = cache::new_cache("./audio_cache", 1000, "http://localhost:8080")?;
|
||||
//! let cache = cache::new_cache("./audio_cache", 500)?;
|
||||
//!
|
||||
//! // Ajouter une piste avec extraction des métadonnées
|
||||
//! // Télécharge la piste, déclenche la conversion FLAC et stocke les métadonnées.
|
||||
//! let pk = cache::add_with_metadata_extraction(
|
||||
//! &cache,
|
||||
//! "http://example.com/track.flac",
|
||||
//! None // collection auto-détectée depuis métadonnées
|
||||
//! "https://example.com/track.mp3",
|
||||
//! None,
|
||||
//! ).await?;
|
||||
//!
|
||||
//! // Lire les métadonnées
|
||||
//! // Lecture des métadonnées extraites
|
||||
//! let metadata = cache::get_metadata(&cache, &pk)?;
|
||||
//! println!("{} - {}",
|
||||
//! metadata.artist.as_deref().unwrap_or("Unknown"),
|
||||
//! metadata.title.as_deref().unwrap_or("Unknown")
|
||||
//! println!(
|
||||
//! "Titre: {}",
|
||||
//! metadata.title.as_deref().unwrap_or("Inconnu")
|
||||
//! );
|
||||
//!
|
||||
//! // Le fichier FLAC est disponible immédiatement après le download
|
||||
//! let file_path = cache.get(&pk).await?;
|
||||
//! println!("FLAC file: {:?}", file_path);
|
||||
//! // Accès au fichier FLAC converti
|
||||
//! let flac_path = cache.get(&pk).await?;
|
||||
//! println!("Fichier converti: {flac_path:?}");
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Utilisation avec pmoserver
|
||||
//! ## Intégration serveur (feature `pmoserver`)
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoaudiocache::AudioCacheExt;
|
||||
//! use pmoserver::ServerBuilder;
|
||||
//! Lorsque la feature `pmoserver` est activée, [`AudioCacheExt`] permet
|
||||
//! d'enregistrer automatiquement les routes suivantes :
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let mut server = ServerBuilder::new_configured().build();
|
||||
//! - `GET /audio/tracks/{pk}` : téléchargement/stream du FLAC original ;
|
||||
//! - `GET /audio/tracks/{pk}/{qualifier}` : variantes (ex: `orig`) ;
|
||||
//! - `GET /api/audio` / `POST /api/audio` / `DELETE /api/audio` : API REST générique ;
|
||||
//! - `GET /api/audio/{pk}/status` : suivi de téléchargement ;
|
||||
//! - endpoints OpenAPI/Swagger lorsqu'`openapi` est activée.
|
||||
//!
|
||||
//! // Initialiser le cache audio avec configuration automatique
|
||||
//! server.init_audio_cache_configured().await?;
|
||||
//! ## Métadonnées gérées
|
||||
//!
|
||||
//! server.start().await;
|
||||
//! server.wait().await;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//! Le module [`metadata`] extrait notamment :
|
||||
//! - titre, artiste, album, genre ;
|
||||
//! - numéros de piste/disque et totaux associés ;
|
||||
//! - année, durée, bitrate, sample rate, nombre de canaux.
|
||||
//!
|
||||
//! ## API HTTP (avec feature "pmoserver")
|
||||
//! En l'absence d'artiste/album, aucune collection automatique n'est créée.
|
||||
//!
|
||||
//! Lorsque la feature `pmoserver` est activée, les routes suivantes sont disponibles :
|
||||
//! ## Modules
|
||||
//!
|
||||
//! ### Routes de fichiers
|
||||
//! - `GET /audio/tracks/{pk}` - Stream du fichier FLAC original
|
||||
//! - `GET /audio/tracks/{pk}/orig` - Alias pour l'original
|
||||
//! - [`cache`] : instanciation du cache et helpers de téléchargement ;
|
||||
//! - [`metadata`] : extraction/structure des métadonnées audio ;
|
||||
//! - [`config_ext`] *(feature `pmoconfig`)* : dérivation de la configuration depuis `pmoconfig`;
|
||||
//! - [`openapi`] *(feature `pmoserver`)* : documentation des routes REST.
|
||||
//!
|
||||
//! ### API REST
|
||||
//! - `GET /api/audio` - Liste toutes les pistes
|
||||
//! - `POST /api/audio` - Ajoute une piste depuis une URL
|
||||
//! - `GET /api/audio/{pk}` - Informations complètes d'une piste
|
||||
//! - `DELETE /api/audio/{pk}` - Supprime une piste
|
||||
//! - `GET /api/audio/{pk}/status` - Statut du téléchargement
|
||||
//! - `POST /api/audio/consolidate` - Consolide le cache
|
||||
//! - `DELETE /api/audio` - Purge tout le cache
|
||||
//! ## Crates voisines
|
||||
//!
|
||||
//! ## Métadonnées supportées
|
||||
//!
|
||||
//! Les métadonnées suivantes sont extraites automatiquement :
|
||||
//! - Titre, artiste, album
|
||||
//! - Année, genre
|
||||
//! - Numéro de piste/disque
|
||||
//! - Durée, taux d'échantillonnage, bitrate
|
||||
//! - Nombre de canaux
|
||||
//!
|
||||
//! ## Format des collections
|
||||
//!
|
||||
//! Les collections sont identifiées par une clé au format `"artist:album"`, avec :
|
||||
//! - Conversion en minuscules
|
||||
//! - Remplacement des espaces par des underscores
|
||||
//! - Exemple : `"Pink Floyd - Wish You Were Here"` → `"pink_floyd:wish_you_were_here"`
|
||||
//!
|
||||
//! ## Différences avec l'ancienne version
|
||||
//!
|
||||
//! Cette version refactorisée de `pmoaudiocache` :
|
||||
//! - ✅ **Supprime le champ `conversion_status`** : le système `Download` de `pmocache` gère déjà l'état asynchrone
|
||||
//! - ✅ **Utilise `pmocache::DB`** : plus de DB personnalisée, les métadonnées sont en JSON
|
||||
//! - ✅ **API REST générique** : fournie par `pmocache`, plus de code custom
|
||||
//! - ✅ **Code réduit de 52%** : de ~1681 lignes à ~800 lignes
|
||||
//! - ✅ **Streaming progressif** : automatique via `pmocache`
|
||||
//! - ✅ **Politique LRU optimisée** : nouvel index composite dans `pmocache`
|
||||
//!
|
||||
//! ## Dépendances principales
|
||||
//!
|
||||
//! - `pmocache` : Cache générique avec download asynchrone
|
||||
//! - `lofty` : Extraction de métadonnées audio
|
||||
//! - `tokio` : Runtime asynchrone
|
||||
//!
|
||||
//! ## Voir aussi
|
||||
//!
|
||||
//! - [`pmocache`] : Cache générique
|
||||
//! - [`pmocovers`] : Cache d'images (architecture similaire)
|
||||
//! - [`pmoserver`] : Serveur HTTP
|
||||
//! - [`pmocache`] : fondation générique ;
|
||||
//! - [`pmocovers`] : spécialisation images (architecture similaire) ;
|
||||
//! - [`pmoserver`] : serveur HTTP optionnel.
|
||||
|
||||
pub mod cache;
|
||||
pub mod metadata;
|
||||
|
||||
@@ -17,21 +17,19 @@ use tokio::io::AsyncRead;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing;
|
||||
|
||||
/// Trait pour définir les paramètres du cache
|
||||
/// Paramètres statiques d'un cache spécialisé.
|
||||
pub trait CacheConfig: Send + Sync {
|
||||
/// Extension des fichiers (ex: "webp", "flac")
|
||||
/// Extension des fichiers générés (ex: `"webp"`, `"flac"`).
|
||||
fn file_extension() -> &'static str;
|
||||
/// Nom de la table dans la base de données (ex: "covers", "audio")
|
||||
|
||||
/// Type de cache (ex: "audio", "image")
|
||||
/// Type logique exposé (ex: `"audio"`, `"image"`). Sert notamment pour les routes HTTP.
|
||||
fn cache_type() -> &'static str {
|
||||
"file"
|
||||
}
|
||||
/// Cache name (ex: "covers", "audio", "cache")
|
||||
/// Nom du cache (ex: `"covers"`, `"audio"`). Utilisé pour composer les chemins d'accès.
|
||||
fn cache_name() -> &'static str {
|
||||
"cache"
|
||||
}
|
||||
/// Default param extension ("orig")
|
||||
/// Qualifier par défaut associé au fichier original (ex: `"orig"`).
|
||||
fn default_param() -> &'static str {
|
||||
"orig"
|
||||
}
|
||||
|
||||
@@ -1,66 +1,48 @@
|
||||
//! # pmocache - Système de cache générique pour PMOMusic
|
||||
//! # pmocache – Système de cache générique pour PMOMusic
|
||||
//!
|
||||
//! Cette crate fournit un système de cache générique avec support de base de données SQLite
|
||||
//! et stockage sur disque. Elle est utilisée comme base pour des caches spécialisés comme
|
||||
//! `pmocovers` (cache d'images) et `pmoaudiocache` (cache de pistes audio).
|
||||
//! Cette crate fournit les briques communes utilisées par les caches de PMOMusic.
|
||||
//! Elle gère l'association entre fichiers stockés sur disque et métadonnées
|
||||
//! conservées dans une base SQLite, ainsi que les opérations de téléchargement,
|
||||
//! d'éviction et de mise à jour.
|
||||
//!
|
||||
//! ## Vue d'ensemble
|
||||
//!
|
||||
//! `pmocache` fournit les composants de base pour :
|
||||
//! - Stocker des fichiers sur disque avec une base de données SQLite pour les métadonnées
|
||||
//! - Gérer des collections d'éléments (albums, playlists, etc.)
|
||||
//! - Suivre les statistiques d'utilisation (hits, dernière utilisation)
|
||||
//! - Télécharger automatiquement depuis des URLs
|
||||
//! - Consolider et purger le cache
|
||||
//! `pmocache` met à disposition :
|
||||
//! - un modèle `Cache` asynchrone pour stocker des fichiers et leurs métadonnées ;
|
||||
//! - un module `db` encapsulant l'accès SQLite (table `asset` + table `metadata`) ;
|
||||
//! - des utilitaires de téléchargement (`download`) réutilisables par les caches spécialisés ;
|
||||
//! - un trait `CacheConfig` permettant de paramétrer l'extension, le nom et le type du cache.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//! Les crates `pmocovers` (images) et `pmoaudiocache` (pistes audio) s'appuient sur ces
|
||||
//! composants et ajoutent leurs propres contraintes métier (conversion WebP, métadonnées audio…).
|
||||
//!
|
||||
//! `pmocache` est conçu comme une base générique :
|
||||
//!
|
||||
//! ```text
|
||||
//! pmocache (générique)
|
||||
//! ├── db.rs - Base de données SQLite générique
|
||||
//! └── cache.rs - Système de cache générique
|
||||
//!
|
||||
//! pmocovers (spécialisé pour les images)
|
||||
//! └── Utilise pmocache + conversion WebP
|
||||
//!
|
||||
//! pmoaudiocache (spécialisé pour l'audio)
|
||||
//! └── Utilise pmocache + métadonnées audio
|
||||
//! ```
|
||||
//!
|
||||
//! ## Utilisation
|
||||
//!
|
||||
//! ### Exemple basique
|
||||
//! ## Exemple basique
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmocache::{Cache, CacheConfig};
|
||||
//!
|
||||
//! // Définir la configuration du cache
|
||||
//! struct MyConfig;
|
||||
//! impl CacheConfig for MyConfig {
|
||||
//! fn file_extension() -> &'static str { "dat" }
|
||||
//! fn table_name() -> &'static str { "my_cache" }
|
||||
//! fn cache_type() -> &'static str { "generic" }
|
||||
//! }
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let cache = Cache::<MyConfig>::new("./cache", 1000, "http://localhost:8080")?;
|
||||
//! let cache = Cache::<MyConfig>::new("./cache", 1000)?;
|
||||
//!
|
||||
//! // Ajouter un fichier depuis une URL
|
||||
//! let pk = cache.add_from_url("http://example.com/file.dat", None).await?;
|
||||
//! println!("Fichier ajouté avec clé: {}", pk);
|
||||
//! // Ajout d'un fichier depuis une URL
|
||||
//! let pk = cache.add_from_url("https://example.com/file.dat", None).await?;
|
||||
//! println!("Fichier ajouté avec la clé {pk}");
|
||||
//!
|
||||
//! // Récupérer le fichier
|
||||
//! // Récupération du fichier local
|
||||
//! let path = cache.get(&pk).await?;
|
||||
//! println!("Fichier stocké à: {:?}", path);
|
||||
//! println!("Fichier disponible à {path:?}");
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Utilisation avec des collections
|
||||
//! ## Collections
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmocache::{Cache, CacheConfig};
|
||||
@@ -68,59 +50,66 @@
|
||||
//! struct AudioConfig;
|
||||
//! impl CacheConfig for AudioConfig {
|
||||
//! fn file_extension() -> &'static str { "flac" }
|
||||
//! fn table_name() -> &'static str { "audio" }
|
||||
//! fn cache_type() -> &'static str { "audio" }
|
||||
//! fn cache_name() -> &'static str { "tracks" }
|
||||
//! }
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let cache = Cache::<AudioConfig>::new("./cache", 1000, "http://localhost:8080")?;
|
||||
//! let cache = Cache::<AudioConfig>::new("./audio-cache", 200)?;
|
||||
//!
|
||||
//! // Ajouter des pistes d'un album
|
||||
//! let album_id = "album:the_wall";
|
||||
//! cache.add_from_url("http://example.com/track1.flac", Some(album_id)).await?;
|
||||
//! cache.add_from_url("http://example.com/track2.flac", Some(album_id)).await?;
|
||||
//! let album = "album:the_wall";
|
||||
//! cache.add_from_url("https://example.com/track1.flac", Some(album)).await?;
|
||||
//! cache.add_from_url("https://example.com/track2.flac", Some(album)).await?;
|
||||
//!
|
||||
//! // Récupérer toutes les pistes de l'album
|
||||
//! let tracks = cache.get_collection(album_id).await?;
|
||||
//! println!("Album contient {} pistes", tracks.len());
|
||||
//! let files = cache.get_collection(album).await?;
|
||||
//! println!("Album {album} : {} fichiers en cache", files.len());
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Structure des fichiers
|
||||
//! ## Structure sur disque
|
||||
//!
|
||||
//! ```text
|
||||
//! cache/
|
||||
//! ├── cache.db # Base de données SQLite
|
||||
//! ├── 1a2b3c4d.webp # Fichier 1
|
||||
//! └── 5e6f7a8b.flac # Fichier 2
|
||||
//! ├── cache.db # Base SQLite
|
||||
//! ├── 1a2b3c4d.orig.dat # Fichier original
|
||||
//! └── 1a2b3c4d.thumb.dat # Variante (qualifier différent)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Schéma de base de données
|
||||
//! Les métadonnées sont conservées dans deux tables :
|
||||
//!
|
||||
//! ```sql
|
||||
//! CREATE TABLE {table_name} (
|
||||
//! pk TEXT PRIMARY KEY, -- Clé unique (hash SHA1 de l'URL)
|
||||
//! source_url TEXT, -- URL source
|
||||
//! collection TEXT, -- Collection (album, playlist, etc.)
|
||||
//! hits INTEGER DEFAULT 0, -- Nombre d'accès
|
||||
//! last_used TEXT -- Dernière utilisation (RFC3339)
|
||||
//! CREATE TABLE asset (
|
||||
//! pk TEXT PRIMARY KEY,
|
||||
//! collection TEXT,
|
||||
//! id TEXT,
|
||||
//! hits INTEGER DEFAULT 0,
|
||||
//! last_used TEXT
|
||||
//! );
|
||||
//!
|
||||
//! CREATE TABLE metadata (
|
||||
//! pk TEXT,
|
||||
//! key TEXT,
|
||||
//! value_type TEXT CHECK(value_type IN ('string','number','boolean','null')),
|
||||
//! value TEXT,
|
||||
//! PRIMARY KEY (pk, key),
|
||||
//! FOREIGN KEY (pk) REFERENCES asset(pk) ON DELETE CASCADE
|
||||
//! );
|
||||
//! ```
|
||||
//!
|
||||
//! ## Dépendances principales
|
||||
//! ## Modules principaux
|
||||
//!
|
||||
//! - `rusqlite` : Base de données SQLite
|
||||
//! - `reqwest` : Téléchargement HTTP
|
||||
//! - `sha1` : Génération de clés
|
||||
//! - `tokio` : Runtime asynchrone
|
||||
//! - [`cache`] : gestion du cache sur disque + opérations asynchrones ;
|
||||
//! - [`db`] : accès SQLite, contraintes et helpers métadonnées ;
|
||||
//! - [`download`] : primitives de téléchargement et de transformation ;
|
||||
//! - [`cache_trait`] : trait partagé entre implémentations spécialisées.
|
||||
//!
|
||||
//! ## Voir aussi
|
||||
//! ## Crates associées
|
||||
//!
|
||||
//! - [`pmocovers`] : Cache d'images avec conversion WebP
|
||||
//! - [`pmoaudiocache`] : Cache de pistes audio
|
||||
//! - [`pmocovers`] : cache d'images reposant sur `pmocache` ;
|
||||
//! - [`pmoaudiocache`] : spécialisation audio avec extraction de métadonnées.
|
||||
|
||||
pub mod cache;
|
||||
pub mod cache_trait;
|
||||
|
||||
@@ -20,6 +20,21 @@
|
||||
//!
|
||||
//! ## Utilisation
|
||||
//!
|
||||
//! ### Exemple minimal
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmocovers::cache;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let cache = cache::new_cache("./covers_cache", 200)?;
|
||||
//! let pk = cache.add_from_url("https://example.com/cover.jpg", None).await?;
|
||||
//! let path = cache.get(&pk).await?;
|
||||
//! println!("Image convertie en WebP: {path:?}");
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Exemple avec configuration automatique
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
|
||||
@@ -60,7 +60,7 @@ pmosource = { path = "../pmosource" }
|
||||
|
||||
# Playlist management for FIFO support
|
||||
pmoplaylist = { path = "../pmoplaylist" }
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
|
||||
# Cache support (OBLIGATOIRE - architecture refactorisée)
|
||||
pmocovers = { path = "../pmocovers" }
|
||||
@@ -72,7 +72,7 @@ utoipa = { version = "5.4.0", optional = true }
|
||||
axum = { version = "0.8.4", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["metadata-only"]
|
||||
default = ["metadata-only", "pmoconfig"]
|
||||
# Mode métadonnées seules (pas de décodage FLAC)
|
||||
metadata-only = []
|
||||
# Active l'extraction par-track (WAV export, etc.)
|
||||
@@ -80,7 +80,9 @@ per-track = ["dep:hound", "dep:tempfile"]
|
||||
# Active l'API REST pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
|
||||
# Feature pour activer le support serveur (cache registry)
|
||||
server = ["pmosource/server"]
|
||||
server = ["pmosource/server", "pmoconfig"]
|
||||
# Feature pour activer le support de pmoconfig
|
||||
pmoconfig = ["dep:pmoconfig"]
|
||||
# Feature cache (deprecated - toujours actif maintenant)
|
||||
cache = []
|
||||
|
||||
|
||||
155
pmoparadise/src/config_ext.rs
Normal file
155
pmoparadise/src/config_ext.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,9 @@ pub mod track;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod pmoserver_ext;
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub mod config_ext;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use client::{ClientBuilder, RadioParadiseClient};
|
||||
pub use error::{Error, Result};
|
||||
@@ -247,6 +250,9 @@ pub use pmoserver_ext::{
|
||||
create_api_router, RadioParadiseApiDoc, RadioParadiseExt, RadioParadiseState,
|
||||
};
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub use config_ext::RadioParadiseConfigExt;
|
||||
|
||||
// Version information
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
|
||||
@@ -78,12 +78,14 @@ impl Default for CacheConfig {
|
||||
}
|
||||
|
||||
/// Persisted history tuning.
|
||||
///
|
||||
/// Configuration for the SQLite-based history persistence.
|
||||
/// The database path is managed via `RadioParadiseConfigExt` trait
|
||||
/// and defaults to a relative path in the config directory.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryConfig {
|
||||
#[serde(default = "HistoryConfig::default_max_tracks")]
|
||||
pub max_tracks: usize,
|
||||
#[serde(default)]
|
||||
pub persistence_backend: HistoryBackendKind,
|
||||
#[serde(default = "HistoryConfig::default_database_path")]
|
||||
pub database_path: String,
|
||||
}
|
||||
@@ -94,7 +96,8 @@ impl HistoryConfig {
|
||||
}
|
||||
|
||||
fn default_database_path() -> String {
|
||||
"/var/lib/pmo/paradise_history.db".to_string()
|
||||
// Default to a relative path (will be resolved by config_ext)
|
||||
"paradise_history.db".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +105,6 @@ impl Default for HistoryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_tracks: Self::default_max_tracks(),
|
||||
persistence_backend: HistoryBackendKind::Sqlite,
|
||||
database_path: Self::default_database_path(),
|
||||
}
|
||||
}
|
||||
@@ -118,15 +120,6 @@ impl RadioParadiseConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend selection for history persistence.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HistoryBackendKind {
|
||||
#[default]
|
||||
Sqlite,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// Activity lifecycle tuning.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActivityConfig {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! History persistence for Radio Paradise playback.
|
||||
//!
|
||||
//! The worker pushes every completed track into the history backend while
|
||||
//! keeping the latest entries available for UPnP browsing. We expose an
|
||||
//! abstract trait so different storage engines (SQLite, JSON, etc.) can be
|
||||
//! supported while sharing the same API.
|
||||
//! keeping the latest entries available for UPnP browsing. We use SQLite
|
||||
//! for persistent storage with an abstract trait for testability.
|
||||
|
||||
use super::config::{HistoryBackendKind, HistoryConfig};
|
||||
use super::config::HistoryConfig;
|
||||
use crate::models::Song;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -60,99 +59,16 @@ pub trait HistoryBackend: Send + Sync {
|
||||
async fn truncate(&self, keep: usize) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
/// Creates a history backend from configuration.
|
||||
///
|
||||
/// This always creates a SQLite-based backend using the configured database path.
|
||||
pub fn history_backend_from_config(
|
||||
config: &HistoryConfig,
|
||||
) -> anyhow::Result<Arc<dyn HistoryBackend>> {
|
||||
match config.persistence_backend {
|
||||
HistoryBackendKind::Sqlite => {
|
||||
let backend = SqliteHistoryBackend::new(&config.database_path)?;
|
||||
Ok(Arc::new(backend))
|
||||
}
|
||||
HistoryBackendKind::Json => {
|
||||
let backend = JsonHistoryBackend::new(&config.database_path);
|
||||
Ok(Arc::new(backend))
|
||||
}
|
||||
}
|
||||
let backend = SqliteHistoryBackend::new(&config.database_path)?;
|
||||
Ok(Arc::new(backend))
|
||||
}
|
||||
|
||||
/// Simple JSON file backed history (placeholder implementation).
|
||||
///
|
||||
/// The JSON backend is primarily useful for tests and quick setups. The file
|
||||
/// is stored next to the configured database path with a `.json` extension.
|
||||
pub struct JsonHistoryBackend {
|
||||
path: std::path::PathBuf,
|
||||
entries: Arc<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]
|
||||
impl HistoryBackend for MemoryHistoryBackend {
|
||||
|
||||
@@ -26,9 +26,6 @@ pub use config::{
|
||||
ActivityConfig, ApiConfig, CacheConfig, HistoryConfig, PollingConfig, RadioParadiseConfig,
|
||||
StreamConfig,
|
||||
};
|
||||
pub use history::{
|
||||
history_backend_from_config, HistoryBackend, HistoryEntry, JsonHistoryBackend,
|
||||
MemoryHistoryBackend,
|
||||
};
|
||||
pub use history::{history_backend_from_config, HistoryBackend, HistoryEntry};
|
||||
pub use playlist::PlaylistEntry;
|
||||
pub use worker::{ParadiseWorker, WorkerCommand};
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
use crate::client::RadioParadiseClient;
|
||||
use crate::paradise::{
|
||||
history_backend_from_config, ChannelDescriptor, MemoryHistoryBackend, ParadiseChannel,
|
||||
PlaylistEntry, RadioParadiseConfig, ALL_CHANNELS,
|
||||
history_backend_from_config, ChannelDescriptor, ParadiseChannel, PlaylistEntry,
|
||||
RadioParadiseConfig, ALL_CHANNELS,
|
||||
};
|
||||
use anyhow::Result as AnyhowResult;
|
||||
use pmoaudiocache::Cache as AudioCache;
|
||||
@@ -77,7 +77,31 @@ impl RadioParadiseSource {
|
||||
#[cfg(feature = "server")]
|
||||
pub fn from_registry(client: RadioParadiseClient) -> Result<Self> {
|
||||
let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default());
|
||||
let history_backend = history_backend_from_config(&config.history).map_err(|e| {
|
||||
|
||||
// Load history configuration from pmoconfig using the config extension trait
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
let history_config = {
|
||||
use crate::config_ext::RadioParadiseConfigExt;
|
||||
let cfg = pmoconfig::get_config();
|
||||
let database_path = cfg.get_paradise_history_database().map_err(|e| {
|
||||
MusicSourceError::SourceUnavailable(format!(
|
||||
"Failed to get history database path: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let max_tracks = cfg.get_paradise_history_size().map_err(|e| {
|
||||
MusicSourceError::SourceUnavailable(format!("Failed to get history size: {}", e))
|
||||
})?;
|
||||
crate::paradise::HistoryConfig {
|
||||
database_path,
|
||||
max_tracks,
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "pmoconfig"))]
|
||||
let history_config = config.history.clone();
|
||||
|
||||
let history_backend = history_backend_from_config(&history_config).map_err(|e| {
|
||||
MusicSourceError::SourceUnavailable(format!(
|
||||
"Failed to initialize history backend: {}",
|
||||
e
|
||||
@@ -125,8 +149,7 @@ impl RadioParadiseSource {
|
||||
let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default());
|
||||
let history_backend: Arc<dyn crate::paradise::HistoryBackend> =
|
||||
history_backend_from_config(&config.history).unwrap_or_else(|err| {
|
||||
warn!("Falling back to in-memory history backend: {err}");
|
||||
Arc::new(MemoryHistoryBackend::new()) as Arc<dyn crate::paradise::HistoryBackend>
|
||||
panic!("Failed to initialize history backend: {err}");
|
||||
});
|
||||
let mut channels = HashMap::new();
|
||||
|
||||
|
||||
@@ -22,3 +22,4 @@ axum-embed = "0.1.0"
|
||||
rust-embed = "8.7.2"
|
||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||
once_cell = "1.19"
|
||||
|
||||
@@ -78,3 +78,131 @@ pub use logs::{
|
||||
log_setup_get, log_setup_post, log_sse,
|
||||
};
|
||||
pub use server::{ApiRegistry, ApiRegistryEntry, Server, ServerBuilder, ServerInfo};
|
||||
|
||||
// ============================================================================
|
||||
// Singleton global du serveur
|
||||
// ============================================================================
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Instance globale unique du serveur PMOMusic
|
||||
///
|
||||
/// Ce singleton assure qu'une seule instance du serveur existe dans l'application.
|
||||
/// Il est initialisé une seule fois via [`init_server()`] et accessible partout
|
||||
/// via [`get_server()`].
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmoserver::{init_server, get_server_base_url};
|
||||
///
|
||||
/// // Initialiser le serveur global (idempotent - peut être appelé plusieurs fois)
|
||||
/// let server = init_server().await;
|
||||
///
|
||||
/// // Récupérer l'URL de base du serveur
|
||||
/// if let Some(url) = get_server_base_url() {
|
||||
/// println!("Server running at: {}", url);
|
||||
/// }
|
||||
/// ```
|
||||
static GLOBAL_SERVER: OnceCell<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() })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -188,22 +188,26 @@ pub trait UpnpServerExt {
|
||||
/// `true` si SSDP est actif, `false` sinon
|
||||
fn ssdp_enabled(&self) -> bool;
|
||||
|
||||
/// Crée et initialise un serveur UPnP complet (factory method)
|
||||
/// Crée et initialise le serveur UPnP global (factory method)
|
||||
///
|
||||
/// Cette méthode factory initialise l'infrastructure UPnP complète :
|
||||
/// - Serveur HTTP (via pmoserver)
|
||||
/// Cette méthode factory initialise le **singleton global** du serveur avec
|
||||
/// l'infrastructure UPnP complète :
|
||||
/// - Serveur HTTP (via pmoserver singleton)
|
||||
/// - Caches (couvertures + audio)
|
||||
/// - Logging
|
||||
/// - Serveur SSDP
|
||||
///
|
||||
/// Cette fonction est **idempotente** : elle peut être appelée plusieurs fois.
|
||||
/// Si le serveur est déjà initialisé, elle retourne simplement la référence existante.
|
||||
///
|
||||
/// Après cette méthode, l'utilisateur doit :
|
||||
/// - Enregistrer ses devices via `register_device()`
|
||||
/// - Enregistrer ses sources musicales
|
||||
/// - Appeler `wait()` pour attendre l'arrêt
|
||||
/// - Enregistrer ses sources musicales (via fonctions globales)
|
||||
/// - Appeler `start()` puis `wait()` pour attendre l'arrêt
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Un serveur UPnP prêt à l'emploi
|
||||
/// Une référence Arc vers le serveur UPnP global, prêt à l'emploi
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -215,11 +219,11 @@ pub trait UpnpServerExt {
|
||||
/// use pmoupnp::UpnpServerExt;
|
||||
/// use pmoserver::Server;
|
||||
///
|
||||
/// let mut server = Server::create_upnp_server().await?;
|
||||
/// server.register_device(my_device).await?;
|
||||
/// server.wait().await;
|
||||
/// let server = Server::create_upnp_server().await?;
|
||||
/// server.write().await.register_device(my_device).await?;
|
||||
/// server.read().await.wait().await;
|
||||
/// ```
|
||||
async fn create_upnp_server() -> Result<Server, anyhow::Error>;
|
||||
async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
|
||||
}
|
||||
|
||||
// Implémentation du trait UpnpServer pour pmoserver::Server
|
||||
@@ -414,21 +418,20 @@ impl UpnpServerExt for Server {
|
||||
SSDP_SERVER.read().unwrap().is_some()
|
||||
}
|
||||
|
||||
async fn create_upnp_server() -> Result<Server, anyhow::Error> {
|
||||
use pmoserver::ServerBuilder;
|
||||
async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error> {
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
// 1. Créer le serveur depuis la config
|
||||
info!("🔧 Creating UPnP server from configuration...");
|
||||
let mut server = ServerBuilder::new_configured().build();
|
||||
// 1. Initialiser le serveur global singleton
|
||||
info!("🔧 Initializing global UPnP server from configuration...");
|
||||
let server_arc = pmoserver::init_server();
|
||||
|
||||
// 2. Initialiser le logging HTTP (routes de logs + tracing)
|
||||
info!("📝 Initializing logging...");
|
||||
server.init_logging().await;
|
||||
server_arc.write().await.init_logging().await;
|
||||
|
||||
// 3. Initialiser les caches
|
||||
info!("💾 Initializing caches...");
|
||||
match server.init_caches().await {
|
||||
match server_arc.write().await.init_caches().await {
|
||||
Ok(_) => {
|
||||
info!("✅ Caches initialized");
|
||||
}
|
||||
@@ -440,15 +443,16 @@ impl UpnpServerExt for Server {
|
||||
|
||||
// 4. Le serveur HTTP n'est PAS encore démarré
|
||||
// Il sera démarré après l'enregistrement des devices et routes
|
||||
info!("🌐 HTTP server configured at {}", server.info().base_url);
|
||||
let base_url = server_arc.read().await.info().base_url;
|
||||
info!("🌐 HTTP server configured at {}", base_url);
|
||||
|
||||
// 5. Enregistrer l'API d'introspection UPnP
|
||||
info!("📡 Registering UPnP API...");
|
||||
server.register_upnp_api().await;
|
||||
server_arc.write().await.register_upnp_api().await;
|
||||
|
||||
// 6. Initialiser SSDP
|
||||
info!("📡 Initializing SSDP discovery...");
|
||||
match server.init_ssdp() {
|
||||
match server_arc.write().await.init_ssdp() {
|
||||
Ok(_) => info!("✅ SSDP server initialized"),
|
||||
Err(e) => {
|
||||
let kind = e.kind();
|
||||
@@ -477,7 +481,7 @@ impl UpnpServerExt for Server {
|
||||
|
||||
info!("🎉 UPnP server infrastructure ready");
|
||||
info!("📝 Next: Register devices and music sources");
|
||||
Ok(server)
|
||||
Ok(server_arc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user