On continue le refactoring des sources

This commit is contained in:
2025-10-26 13:44:41 +01:00
parent de40b5b90c
commit e5fbf372c4
18 changed files with 647 additions and 325 deletions

1
Cargo.lock generated
View File

@@ -2575,6 +2575,7 @@ dependencies = [
"axum-server",
"futures",
"futures-util",
"once_cell",
"pmoconfig",
"rust-embed",
"serde",

View File

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

View File

@@ -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
View 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));
// }
// }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = []

View 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);
}
}

View File

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

View File

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

View File

@@ -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))
}
}
}
/// 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 {

View File

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

View File

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

View File

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

View File

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

View File

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