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