From dd5299f59a5b8e1ed2281d6814cf6147e639a77d Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 6 Oct 2025 11:54:32 +0200 Subject: [PATCH 1/7] Documente la crate pmoapp --- pmoapp/src/lib.rs | 236 +++++++++++++++++++++-- pmoupnp/src/devices/device_instance.rs | 5 +- pmoupnp/src/object_trait.rs | 5 +- pmoupnp/src/services/mod.rs | 5 +- pmoupnp/src/services/service_instance.rs | 7 +- 5 files changed, 229 insertions(+), 29 deletions(-) diff --git a/pmoapp/src/lib.rs b/pmoapp/src/lib.rs index f1b8ced1..763181ae 100644 --- a/pmoapp/src/lib.rs +++ b/pmoapp/src/lib.rs @@ -1,30 +1,242 @@ //! # pmoapp - Application web UPnP pour PMOMusic //! -//! Cette crate fournit l'application web frontend pour le contrôle UPnP, -//! intégrée via RustEmbed pour être servie par pmoserver. +//! Cette crate fournit l'application web frontend pour le contrôle et la visualisation +//! des devices UPnP MediaRenderer, intégrée via RustEmbed pour être servie par pmoserver. +//! +//! ## Vue d'ensemble +//! +//! `pmoapp` est une application Vue.js 3 moderne avec TypeScript qui offre une interface +//! utilisateur pour : +//! - Visualiser les logs système en temps réel (Server-Sent Events) +//! - Contrôler les devices UPnP MediaRenderer +//! - Afficher et formater automatiquement le XML dans les logs //! //! ## Fonctionnalités //! -//! - 📦 **Frontend intégré** : Application web compilée et embarquée dans le binaire -//! - 🎨 **Interface de contrôle** : UI pour gérer les devices UPnP MediaRenderer -//! - 🚀 **Zero configuration** : Pas besoin de servir des fichiers statiques séparés +//! ### 📦 Frontend intégré +//! - Application web compilée et embarquée dans le binaire Rust +//! - Aucun fichier statique externe à gérer en production +//! - Intégration via `RustEmbed` pour une distribution simplifiée +//! +//! ### 🎨 Interface utilisateur +//! - **LogView** : Visualisation des logs en temps réel avec filtres par niveau +//! - **Auto-scroll** : Défilement automatique des nouveaux logs (désactivable) +//! - **Formatage XML** : Détection et coloration syntaxique automatique du XML +//! - **Design responsive** : Compatible desktop et mobile +//! - **Thème sombre** : Style inspiré de VS Code pour une meilleure lisibilité +//! +//! ### 🚀 Zero configuration +//! - Pas besoin de serveur web séparé pour les assets +//! - Les fichiers sont servis directement depuis la mémoire du binaire +//! - Configuration automatique du routing Vue Router +//! +//! ## Architecture +//! +//! ### Stack technique +//! +//! - **Frontend** : Vue.js 3 avec Composition API +//! - **Langage** : TypeScript +//! - **Build** : Vite (rapide, moderne, HMR) +//! - **Routing** : Vue Router +//! - **Markdown** : Marked.js pour le rendu +//! - **Sécurité** : DOMPurify pour la sanitization HTML +//! +//! ### Structure des fichiers +//! +//! ```text +//! pmoapp/ +//! ├── Cargo.toml # Dépendances Rust (rust-embed) +//! ├── src/ +//! │ └── lib.rs # Point d'entrée Rust (ce fichier) +//! └── webapp/ +//! ├── src/ +//! │ ├── main.ts # Point d'entrée Vue.js +//! │ ├── App.vue # Composant racine +//! │ ├── router/ # Configuration Vue Router +//! │ └── components/ +//! │ ├── LogView.vue # Visualiseur de logs SSE +//! │ └── ... +//! ├── dist/ # Build output (généré, non versionné) +//! ├── package.json # Dépendances npm +//! └── vite.config.ts # Configuration Vite +//! ``` +//! +//! ## Workflow de build +//! +//! ### 1. Build de la webapp (Vue.js) +//! +//! ```bash +//! # Installation des dépendances +//! cd pmoapp/webapp +//! npm install +//! +//! # Build de production +//! npm run build +//! # Génère : webapp/dist/index.html, assets/*.js, assets/*.css +//! ``` +//! +//! ### 2. Compilation Rust +//! +//! ```bash +//! cargo build +//! # RustEmbed inclut automatiquement les fichiers de webapp/dist/ +//! ``` +//! +//! ### 3. Utilisation avec Makefile +//! +//! ```bash +//! # Build complet (webapp + Rust) +//! make build +//! +//! # Ou juste la webapp +//! make webapp +//! +//! # Clean +//! make clean +//! ``` //! //! ## Utilisation //! +//! ### Exemple basique +//! //! ```rust,no_run //! use pmoapp::Webapp; //! use pmoserver::ServerBuilder; //! -//! # async fn example() { -//! let mut server = ServerBuilder::new("MyApp").build(); -//! server.add_spa::("/app").await; -//! # } +//! #[tokio::main] +//! async fn main() { +//! let mut server = ServerBuilder::new("MyApp") +//! .http_port(8080) +//! .build(); +//! +//! // Ajouter la webapp comme Single Page Application +//! server.add_spa::("/app").await; +//! +//! // Ajouter une redirection de la racine vers /app +//! server.add_redirect("/", "/app").await; +//! +//! server.start().await; +//! server.wait().await; +//! } //! ``` //! -//! ## Structure +//! ### Exemple avec logs SSE //! -//! La webapp est construite avec Vite et Vue.js, et les fichiers statiques -//! sont embarqués dans le binaire au moment de la compilation via `RustEmbed`. +//! ```rust,no_run +//! use pmoapp::Webapp; +//! use pmoserver::{ServerBuilder, logs::{LogState, SseLayer}}; +//! use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +//! +//! #[tokio::main] +//! async fn main() { +//! // Configuration des logs avec SSE +//! let log_state = LogState::new(1000); // Buffer de 1000 logs +//! tracing_subscriber::registry() +//! .with(tracing_subscriber::fmt::layer()) +//! .with(SseLayer::new(log_state.clone())) +//! .init(); +//! +//! let mut server = ServerBuilder::new("MyApp").build(); +//! +//! // Endpoints SSE pour les logs +//! server.add_handler_with_state("/log-sse", pmoserver::logs::log_sse, log_state.clone()).await; +//! server.add_handler_with_state("/log-dump", pmoserver::logs::log_dump, log_state).await; +//! +//! // Webapp (consommera les logs via /log-sse) +//! server.add_spa::("/app").await; +//! server.add_redirect("/", "/app").await; +//! +//! server.start().await; +//! server.wait().await; +//! } +//! ``` +//! +//! ## Développement +//! +//! ### Mode développement Vue.js +//! +//! Pour développer la webapp avec Hot Module Replacement : +//! +//! ```bash +//! cd pmoapp/webapp +//! npm run dev +//! # Serveur de dev sur http://localhost:5173 +//! ``` +//! +//! ### Rebuild après modifications +//! +//! Après avoir modifié le code Vue.js : +//! +//! ```bash +//! # Rebuild webapp + recompile Rust +//! make build +//! +//! # Ou séparément +//! make webapp # Build Vue.js seulement +//! cargo build # Recompile Rust (intègre le nouveau dist/) +//! ``` +//! +//! ## Composants Vue.js +//! +//! ### LogView +//! +//! Composant principal pour la visualisation des logs : +//! +//! - **Connexion SSE** : Stream temps réel via EventSource +//! - **Filtrage** : Par niveau (TRACE, DEBUG, INFO, WARN, ERROR) +//! - **Auto-scroll** : Activable/désactivable +//! - **Formatage** : Markdown + détection XML automatique +//! - **Buffer** : Limite à 1000 logs en mémoire +//! - **Déduplication** : Évite les logs en double +//! +//! ### Formatage XML +//! +//! Le composant LogView détecte automatiquement le XML dans les messages : +//! +//! ``` +//! Input: "INFO: ..." +//! Output: Bloc de code avec coloration syntaxique XML +//! ``` +//! +//! - Détection via regex : ``, ``, etc.) +//! - Conversion en bloc markdown : ` ```xml ... ``` ` +//! - Rendu avec coloration et scrollbar pour le XML long +//! +//! ## Intégration avec pmoupnp +//! +//! La webapp communique avec les devices UPnP via les endpoints HTTP fournis par +//! `pmoserver` et `pmoupnp` : +//! +//! - `/log-sse` : Stream de logs (Server-Sent Events) +//! - `/log-dump` : Historique des logs +//! - `/device/*/description.xml` : Descripteurs UPnP +//! - `/service/*/control` : Endpoints de contrôle SOAP +//! - `/service/*/event` : Souscription aux événements UPnP +//! +//! ## Notes de déploiement +//! +//! ### Taille du binaire +//! +//! La webapp ajoutera ~150KB au binaire (compressé avec gzip par RustEmbed). +//! +//! ### Cache du navigateur +//! +//! Les assets sont servis avec des hashes dans les noms de fichiers +//! (`index-BBZcSinC.js`) pour un cache busting automatique. +//! +//! ### Compatibilité navigateurs +//! +//! - Chrome/Edge : ✅ Moderne +//! - Firefox : ✅ Moderne +//! - Safari : ✅ iOS 13+ +//! - IE11 : ❌ Non supporté (utilise ES modules) +//! +//! ## Voir aussi +//! +//! - [`pmoserver`] : Serveur HTTP Axum pour servir la webapp +//! - [`pmoupnp`] : Bibliothèque UPnP MediaRenderer +//! - [Vue.js Documentation](https://vuejs.org/) +//! - [Vite Documentation](https://vitejs.dev/) use rust_embed::RustEmbed; diff --git a/pmoupnp/src/devices/device_instance.rs b/pmoupnp/src/devices/device_instance.rs index 9c74eb71..26526aa1 100644 --- a/pmoupnp/src/devices/device_instance.rs +++ b/pmoupnp/src/devices/device_instance.rs @@ -316,10 +316,7 @@ impl DeviceInstance { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } - let mut xml = String::from_utf8_lossy(&xml_output).to_string(); - - // Ajouter l'en-tête XML - xml.insert_str(0, "\n"); + let xml = String::from_utf8_lossy(&xml_output).to_string(); ( StatusCode::OK, diff --git a/pmoupnp/src/object_trait.rs b/pmoupnp/src/object_trait.rs index 99c151e9..f7b0f2a8 100644 --- a/pmoupnp/src/object_trait.rs +++ b/pmoupnp/src/object_trait.rs @@ -112,10 +112,7 @@ pub trait UpnpObject: Clone + Debug { elem.write_with_config(&mut buf, config) .expect("Failed to write XML"); - let mut xml_string = "\n".to_string(); - xml_string.push_str(&String::from_utf8(buf).expect("Invalid UTF-8")); - - xml_string + String::from_utf8(buf).expect("Invalid UTF-8") } /// Convertit l'objet en représentation Markdown. diff --git a/pmoupnp/src/services/mod.rs b/pmoupnp/src/services/mod.rs index cd22ad46..15a48c68 100644 --- a/pmoupnp/src/services/mod.rs +++ b/pmoupnp/src/services/mod.rs @@ -447,10 +447,7 @@ impl Service { elem.write_with_config(&mut buf, config) .expect("Failed to write XML"); - let mut xml_string = "\n".to_string(); - xml_string.push_str(&String::from_utf8(buf).expect("Invalid UTF-8")); - - xml_string + String::from_utf8(buf).expect("Invalid UTF-8") } } diff --git a/pmoupnp/src/services/service_instance.rs b/pmoupnp/src/services/service_instance.rs index 7fab502d..de9dbae1 100644 --- a/pmoupnp/src/services/service_instance.rs +++ b/pmoupnp/src/services/service_instance.rs @@ -395,11 +395,8 @@ impl ServiceInstance { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } - let mut xml = String::from_utf8_lossy(&xml_output).to_string(); - - // Ajouter l'en-tête XML - xml.insert_str(0, "\n"); - + let xml = String::from_utf8_lossy(&xml_output).to_string(); + ( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], From c4ace53ea0fc4bce98eb0277102c89ee16ee0790 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 6 Oct 2025 12:05:56 +0200 Subject: [PATCH 2/7] Developement de la couche sspd --- pmoupnp/src/lib.rs | 2 + pmoupnp/src/soap/builder.rs | 101 ++++++++++++ pmoupnp/src/soap/envelope.rs | 45 ++++++ pmoupnp/src/soap/fault.rs | 173 ++++++++++++++++++++ pmoupnp/src/soap/mod.rs | 89 ++++++++++ pmoupnp/src/soap/parser.rs | 149 +++++++++++++++++ pmoupnp/src/ssdp/device.rs | 58 +++++++ pmoupnp/src/ssdp/mod.rs | 38 +++++ pmoupnp/src/ssdp/server.rs | 304 +++++++++++++++++++++++++++++++++++ 9 files changed, 959 insertions(+) create mode 100644 pmoupnp/src/soap/builder.rs create mode 100644 pmoupnp/src/soap/envelope.rs create mode 100644 pmoupnp/src/soap/fault.rs create mode 100644 pmoupnp/src/soap/mod.rs create mode 100644 pmoupnp/src/soap/parser.rs create mode 100644 pmoupnp/src/ssdp/device.rs create mode 100644 pmoupnp/src/ssdp/mod.rs create mode 100644 pmoupnp/src/ssdp/server.rs diff --git a/pmoupnp/src/lib.rs b/pmoupnp/src/lib.rs index 9930f3d0..a23d1cbc 100644 --- a/pmoupnp/src/lib.rs +++ b/pmoupnp/src/lib.rs @@ -6,6 +6,8 @@ pub mod devices; pub mod mediarenderer; pub mod server; pub mod services; +pub mod soap; +pub mod ssdp; pub mod state_variables; pub mod value_ranges; pub mod variable_types; diff --git a/pmoupnp/src/soap/builder.rs b/pmoupnp/src/soap/builder.rs new file mode 100644 index 00000000..7e9d4c7e --- /dev/null +++ b/pmoupnp/src/soap/builder.rs @@ -0,0 +1,101 @@ +//! Construction de réponses SOAP + +use std::collections::HashMap; +use xmltree::{Element, XMLNode}; + +/// Construit une réponse SOAP UPnP +/// +/// # Arguments +/// +/// * `service_urn` - URN du service (ex: "urn:schemas-upnp-org:service:AVTransport:1") +/// * `action` - Nom de l'action (ex: "GetPositionInfo") +/// * `values` - Map des valeurs de retour +/// +/// # Returns +/// +/// XML SOAP formaté en String +pub fn build_soap_response( + service_urn: &str, + action: &str, + values: HashMap, +) -> Result { + // Construire l'élément de réponse + // Format: + let response_name = format!("{}Response", action); + let mut response_elem = Element::new(&response_name); + response_elem.namespace = Some(service_urn.to_string()); + response_elem + .attributes + .insert("xmlns:u".to_string(), service_urn.to_string()); + + // Ajouter les valeurs de retour + for (key, value) in values { + let mut child = Element::new(&key); + child.children.push(XMLNode::Text(value)); + response_elem.children.push(XMLNode::Element(child)); + } + + // Construire le Body + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(response_elem)); + + // Construire l'Envelope + let mut envelope = Element::new("s:Envelope"); + envelope.attributes.insert( + "xmlns:s".to_string(), + "http://schemas.xmlsoap.org/soap/envelope/".to_string(), + ); + envelope.attributes.insert( + "s:encodingStyle".to_string(), + "http://schemas.xmlsoap.org/soap/encoding/".to_string(), + ); + envelope.children.push(XMLNode::Element(body)); + + // Sérialiser en XML + let mut buf = Vec::new(); + let config = xmltree::EmitterConfig::new() + .perform_indent(true) + .indent_string(" "); + envelope.write_with_config(&mut buf, config)?; + + Ok(String::from_utf8(buf).unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_response() { + let mut values = HashMap::new(); + values.insert("Track".to_string(), "5".to_string()); + values.insert("TrackDuration".to_string(), "00:03:45".to_string()); + + let xml = build_soap_response( + "urn:schemas-upnp-org:service:AVTransport:1", + "GetPositionInfo", + values, + ) + .unwrap(); + + assert!(xml.contains("GetPositionInfoResponse")); + assert!(xml.contains("5")); + assert!(xml.contains("00:03:45")); + assert!(xml.contains("xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")); + } + + #[test] + fn test_build_empty_response() { + let values = HashMap::new(); + + let xml = build_soap_response( + "urn:schemas-upnp-org:service:AVTransport:1", + "Stop", + values, + ) + .unwrap(); + + assert!(xml.contains("StopResponse")); + assert!(xml.contains("xmlns:u=\"urn:schemas-upnp-org:service:AVTransport:1\"")); + } +} diff --git a/pmoupnp/src/soap/envelope.rs b/pmoupnp/src/soap/envelope.rs new file mode 100644 index 00000000..3ab19418 --- /dev/null +++ b/pmoupnp/src/soap/envelope.rs @@ -0,0 +1,45 @@ +//! Structures de l'enveloppe SOAP + +use xmltree::Element; + +/// Enveloppe SOAP complète +#[derive(Debug, Clone)] +pub struct SoapEnvelope { + /// En-tête SOAP optionnel + pub header: Option, + + /// Corps SOAP contenant l'action ou la réponse + pub body: SoapBody, +} + +/// En-tête SOAP +#[derive(Debug, Clone)] +pub struct SoapHeader { + /// Contenu XML brut de l'en-tête + pub content: Element, +} + +/// Corps SOAP +#[derive(Debug, Clone)] +pub struct SoapBody { + /// Contenu XML brut du corps + pub content: Element, +} + +impl SoapEnvelope { + /// Crée une nouvelle enveloppe SOAP + pub fn new(body: SoapBody) -> Self { + Self { + header: None, + body, + } + } + + /// Crée une nouvelle enveloppe avec header + pub fn with_header(header: SoapHeader, body: SoapBody) -> Self { + Self { + header: Some(header), + body, + } + } +} diff --git a/pmoupnp/src/soap/fault.rs b/pmoupnp/src/soap/fault.rs new file mode 100644 index 00000000..b79ce855 --- /dev/null +++ b/pmoupnp/src/soap/fault.rs @@ -0,0 +1,173 @@ +//! SOAP Faults pour UPnP + +use xmltree::{Element, XMLNode}; + +/// Erreur SOAP (Fault) +#[derive(Debug, Clone)] +pub struct SoapFault { + /// Code d'erreur (ex: "s:Client", "401") + pub fault_code: String, + + /// Description de l'erreur + pub fault_string: String, + + /// Détails UPnP optionnels + pub upnp_error: Option, +} + +/// Erreur UPnP spécifique +#[derive(Debug, Clone)] +pub struct UpnpError { + /// Code d'erreur UPnP (ex: "401", "501") + pub error_code: String, + + /// Description de l'erreur + pub error_description: String, +} + +impl SoapFault { + /// Crée un fault SOAP simple + pub fn new(fault_code: String, fault_string: String) -> Self { + Self { + fault_code, + fault_string, + upnp_error: None, + } + } + + /// Crée un fault SOAP avec erreur UPnP + pub fn with_upnp_error( + fault_code: String, + fault_string: String, + error_code: String, + error_description: String, + ) -> Self { + Self { + fault_code, + fault_string, + upnp_error: Some(UpnpError { + error_code, + error_description, + }), + } + } +} + +/// Construit un SOAP Fault XML +/// +/// # Arguments +/// +/// * `fault_code` - Code du fault (ex: "s:Client") +/// * `fault_string` - Message d'erreur +/// * `upnp_error_code` - Code d'erreur UPnP optionnel (ex: "401") +/// * `upnp_error_desc` - Description d'erreur UPnP optionnelle +/// +/// # Returns +/// +/// XML SOAP Fault formaté +pub fn build_soap_fault( + fault_code: &str, + fault_string: &str, + upnp_error_code: Option<&str>, + upnp_error_desc: Option<&str>, +) -> Result { + // Construire l'élément Fault + let mut fault = Element::new("s:Fault"); + + // faultcode + let mut faultcode_elem = Element::new("faultcode"); + faultcode_elem + .children + .push(XMLNode::Text(fault_code.to_string())); + fault.children.push(XMLNode::Element(faultcode_elem)); + + // faultstring + let mut faultstring_elem = Element::new("faultstring"); + faultstring_elem + .children + .push(XMLNode::Text(fault_string.to_string())); + fault.children.push(XMLNode::Element(faultstring_elem)); + + // detail (si erreur UPnP) + if let (Some(code), Some(desc)) = (upnp_error_code, upnp_error_desc) { + let mut detail = Element::new("detail"); + + let mut upnp_error = Element::new("UPnPError"); + upnp_error.attributes.insert( + "xmlns".to_string(), + "urn:schemas-upnp-org:control-1-0".to_string(), + ); + + let mut error_code_elem = Element::new("errorCode"); + error_code_elem + .children + .push(XMLNode::Text(code.to_string())); + upnp_error + .children + .push(XMLNode::Element(error_code_elem)); + + let mut error_desc_elem = Element::new("errorDescription"); + error_desc_elem + .children + .push(XMLNode::Text(desc.to_string())); + upnp_error + .children + .push(XMLNode::Element(error_desc_elem)); + + detail.children.push(XMLNode::Element(upnp_error)); + fault.children.push(XMLNode::Element(detail)); + } + + // Construire le Body + let mut body = Element::new("s:Body"); + body.children.push(XMLNode::Element(fault)); + + // Construire l'Envelope + let mut envelope = Element::new("s:Envelope"); + envelope.attributes.insert( + "xmlns:s".to_string(), + "http://schemas.xmlsoap.org/soap/envelope/".to_string(), + ); + envelope.children.push(XMLNode::Element(body)); + + // Sérialiser + let mut buf = Vec::new(); + let config = xmltree::EmitterConfig::new() + .perform_indent(true) + .indent_string(" "); + envelope.write_with_config(&mut buf, config)?; + + Ok(String::from_utf8(buf).unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_simple_fault() { + let xml = build_soap_fault("s:Client", "Invalid Action", None, None).unwrap(); + + assert!(xml.contains("")); + assert!(xml.contains("s:Client")); + assert!(xml.contains("Invalid Action")); + assert!(!xml.contains("UPnPError")); + } + + #[test] + fn test_build_upnp_fault() { + let xml = build_soap_fault( + "s:Client", + "UPnP Error", + Some("401"), + Some("Invalid Action"), + ) + .unwrap(); + + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("401")); + assert!(xml.contains("Invalid Action")); + } +} diff --git a/pmoupnp/src/soap/mod.rs b/pmoupnp/src/soap/mod.rs new file mode 100644 index 00000000..06dd7868 --- /dev/null +++ b/pmoupnp/src/soap/mod.rs @@ -0,0 +1,89 @@ +//! # Module SOAP - Simple Object Access Protocol +//! +//! Ce module implémente le support SOAP pour UPnP, permettant l'invocation d'actions +//! et la gestion des réponses/erreurs. +//! +//! ## Fonctionnalités +//! +//! - ✅ Parsing d'enveloppes SOAP +//! - ✅ Extraction d'actions UPnP avec arguments +//! - ✅ Construction de réponses SOAP +//! - ✅ Gestion des SOAP Faults +//! - ✅ Support des namespaces UPnP +//! +//! ## Architecture +//! +//! - [`SoapEnvelope`] : Enveloppe SOAP complète +//! - [`SoapAction`] : Action UPnP extraite +//! - [`SoapResponse`] : Réponse UPnP +//! - [`SoapFault`] : Erreur SOAP +//! +//! ## Example +//! +//! ```ignore +//! use pmoupnp::soap::{parse_soap_action, build_soap_response}; +//! +//! // Parser une action SOAP +//! let body = r#" +//! +//! +//! +//! 0 +//! 1 +//! +//! +//! "#; +//! +//! let action = parse_soap_action(body.as_bytes()).unwrap(); +//! assert_eq!(action.name, "Play"); +//! assert_eq!(action.args.get("InstanceID"), Some(&"0".to_string())); +//! +//! // Construire une réponse +//! let mut values = std::collections::HashMap::new(); +//! values.insert("CurrentTrack".to_string(), "5".to_string()); +//! let response = build_soap_response( +//! "urn:schemas-upnp-org:service:AVTransport:1", +//! "GetPositionInfo", +//! values +//! ).unwrap(); +//! ``` + +mod envelope; +mod parser; +mod builder; +mod fault; + +pub use envelope::{SoapEnvelope, SoapHeader, SoapBody}; +pub use parser::{parse_soap_action, SoapAction}; +pub use builder::build_soap_response; +pub use fault::{SoapFault, build_soap_fault}; + +/// Codes d'erreur SOAP UPnP standards +pub mod error_codes { + /// Action invalide + pub const INVALID_ACTION: &str = "401"; + + /// Arguments invalides + pub const INVALID_ARGS: &str = "402"; + + /// Action échouée + pub const ACTION_FAILED: &str = "501"; + + /// Argument manquant + pub const ARGUMENT_VALUE_INVALID: &str = "600"; + + /// Argument hors limites + pub const ARGUMENT_VALUE_OUT_OF_RANGE: &str = "601"; + + /// Action optionnelle non implémentée + pub const OPTIONAL_ACTION_NOT_IMPLEMENTED: &str = "602"; + + /// Mémoire insuffisante + pub const OUT_OF_MEMORY: &str = "603"; + + /// Erreur humaine lisible + pub const HUMAN_INTERVENTION_REQUIRED: &str = "604"; + + /// Argument sous forme de chaîne trop long + pub const STRING_ARGUMENT_TOO_LONG: &str = "605"; +} diff --git a/pmoupnp/src/soap/parser.rs b/pmoupnp/src/soap/parser.rs new file mode 100644 index 00000000..d5d243f9 --- /dev/null +++ b/pmoupnp/src/soap/parser.rs @@ -0,0 +1,149 @@ +//! Parser SOAP pour actions UPnP + +use super::{SoapBody, SoapEnvelope, SoapHeader}; +use std::collections::HashMap; +use std::io::BufReader; +use xmltree::Element; + +/// Action UPnP extraite d'une enveloppe SOAP +#[derive(Debug, Clone)] +pub struct SoapAction { + /// Nom de l'action (ex: "Play", "SetAVTransportURI") + pub name: String, + + /// Namespace de l'action (ex: "urn:schemas-upnp-org:service:AVTransport:1") + pub namespace: Option, + + /// Arguments de l'action + pub args: HashMap, +} + +/// Erreur de parsing SOAP +#[derive(Debug, thiserror::Error)] +pub enum SoapParseError { + #[error("XML parse error: {0}")] + XmlError(#[from] xmltree::ParseError), + + #[error("Missing SOAP Envelope")] + MissingEnvelope, + + #[error("Missing SOAP Body")] + MissingBody, + + #[error("No action found in SOAP Body")] + NoAction, +} + +/// Parse une action SOAP à partir de bytes XML +pub fn parse_soap_action(xml: &[u8]) -> Result { + let envelope = parse_soap_envelope(xml)?; + extract_action_from_body(&envelope.body) +} + +/// Parse une enveloppe SOAP complète +pub fn parse_soap_envelope(xml: &[u8]) -> Result { + let reader = BufReader::new(xml); + let root = Element::parse(reader)?; + + // Vérifier que c'est bien une Envelope + if !root.name.ends_with("Envelope") { + return Err(SoapParseError::MissingEnvelope); + } + + // Extraire Header (optionnel) + let header = root + .get_child("Header") + .or_else(|| root.children.iter().find_map(|n| n.as_element())) + .filter(|e| e.name.ends_with("Header")) + .map(|e| SoapHeader { + content: e.clone(), + }); + + // Extraire Body (obligatoire) + let body_elem = root + .get_child("Body") + .or_else(|| root.children.iter().find_map(|n| { + n.as_element() + .filter(|e| e.name.ends_with("Body")) + })) + .ok_or(SoapParseError::MissingBody)?; + + let body = SoapBody { + content: body_elem.clone(), + }; + + Ok(SoapEnvelope { header, body }) +} + +/// Extrait l'action UPnP du corps SOAP +fn extract_action_from_body(body: &SoapBody) -> Result { + // Le Body contient un élément enfant qui est l'action + // Format: ... + + let action_elem = body + .content + .children + .iter() + .find_map(|n| n.as_element()) + .ok_or(SoapParseError::NoAction)?; + + let name = action_elem.name.clone(); + let namespace = action_elem.namespace.clone(); + + // Extraire les arguments (enfants directs de l'action) + let mut args = HashMap::new(); + for child in &action_elem.children { + if let Some(elem) = child.as_element() { + let arg_name = elem.name.clone(); + let arg_value = elem.get_text().unwrap_or_default().to_string(); + args.insert(arg_name, arg_value); + } + } + + Ok(SoapAction { + name, + namespace, + args, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_action() { + let xml = r#" + + + + 0 + 1 + + +"#; + + let action = parse_soap_action(xml.as_bytes()).unwrap(); + assert_eq!(action.name, "Play"); + assert_eq!( + action.namespace, + Some("urn:schemas-upnp-org:service:AVTransport:1".to_string()) + ); + assert_eq!(action.args.get("InstanceID"), Some(&"0".to_string())); + assert_eq!(action.args.get("Speed"), Some(&"1".to_string())); + } + + #[test] + fn test_parse_action_no_args() { + let xml = r#" + + + + +"#; + + let action = parse_soap_action(xml.as_bytes()).unwrap(); + assert_eq!(action.name, "Stop"); + assert!(action.args.is_empty()); + } +} diff --git a/pmoupnp/src/ssdp/device.rs b/pmoupnp/src/ssdp/device.rs new file mode 100644 index 00000000..2b1af1ec --- /dev/null +++ b/pmoupnp/src/ssdp/device.rs @@ -0,0 +1,58 @@ +//! Représentation d'un device SSDP + +/// Device SSDP avec ses métadonnées pour les annonces +#[derive(Debug, Clone)] +pub struct SsdpDevice { + /// UUID du device (sans le préfixe "uuid:") + pub uuid: String, + + /// Type du device (ex: "urn:schemas-upnp-org:device:MediaRenderer:1") + pub device_type: String, + + /// URL de la description du device + pub location: String, + + /// Identifiant du serveur (ex: "Linux/5.0 UPnP/1.1 PMOMusic/1.0") + pub server: String, + + /// Liste des types de notification (NT) à annoncer + /// Typiquement: [uuid:xxx, device_type, services...] + pub notification_types: Vec, +} + +impl SsdpDevice { + /// Crée un nouveau device SSDP + pub fn new( + uuid: String, + device_type: String, + location: String, + server: String, + ) -> Self { + // Construction automatique des NTs standards + let notification_types = vec![ + format!("uuid:{}", uuid), + "upnp:rootdevice".to_string(), + device_type.clone(), + ]; + + Self { + uuid, + device_type, + location, + server, + notification_types, + } + } + + /// Ajoute un type de notification (ex: pour un service) + pub fn add_notification_type(&mut self, nt: String) { + if !self.notification_types.contains(&nt) { + self.notification_types.push(nt); + } + } + + /// Retourne la liste des types de notification + pub fn get_notification_types(&self) -> &[String] { + &self.notification_types + } +} diff --git a/pmoupnp/src/ssdp/mod.rs b/pmoupnp/src/ssdp/mod.rs new file mode 100644 index 00000000..17e5cb97 --- /dev/null +++ b/pmoupnp/src/ssdp/mod.rs @@ -0,0 +1,38 @@ +//! # Module SSDP - Simple Service Discovery Protocol +//! +//! Ce module implémente le protocole SSDP (Simple Service Discovery Protocol) pour UPnP, +//! permettant la découverte automatique des devices sur le réseau. +//! +//! ## Fonctionnalités +//! +//! - ✅ Envoi de NOTIFY alive/byebye en multicast +//! - ✅ Réponse aux M-SEARCH en unicast +//! - ✅ Gestion multi-devices avec types de notification +//! - ✅ Annonces périodiques automatiques +//! - ✅ Arrêt propre avec byebye +//! +//! ## Architecture +//! +//! - [`SsdpServer`] : Serveur SSDP principal gérant les devices +//! - [`SsdpDevice`] : Représentation d'un device pour SSDP +//! +//! ## Constants SSDP +//! +//! - **Multicast Address**: 239.255.255.250:1900 +//! - **Max-Age**: 1800 secondes (30 minutes) +//! - **Announcement Period**: 900 secondes (15 minutes, Max-Age/2) + +mod device; +mod server; + +pub use device::SsdpDevice; +pub use server::SsdpServer; + +/// Adresse multicast SSDP +pub const SSDP_MULTICAST_ADDR: &str = "239.255.255.250"; + +/// Port SSDP +pub const SSDP_PORT: u16 = 1900; + +/// Durée de validité des annonces (en secondes) +pub const MAX_AGE: u32 = 1800; diff --git a/pmoupnp/src/ssdp/server.rs b/pmoupnp/src/ssdp/server.rs new file mode 100644 index 00000000..fc344487 --- /dev/null +++ b/pmoupnp/src/ssdp/server.rs @@ -0,0 +1,304 @@ +//! Serveur SSDP + +use super::{SsdpDevice, SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE}; +use std::collections::HashMap; +use std::net::{SocketAddr, UdpSocket}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use tracing::{error, info, warn}; + +/// Serveur SSDP gérant les annonces et découvertes +pub struct SsdpServer { + /// Devices enregistrés (UUID -> Device) + devices: Arc>>, + + /// Socket UDP pour SSDP + socket: Option>, +} + +impl SsdpServer { + /// Crée un nouveau serveur SSDP + pub fn new() -> Self { + Self { + devices: Arc::new(RwLock::new(HashMap::new())), + socket: None, + } + } + + /// Démarre le serveur SSDP + /// + /// # Returns + /// + /// `Ok(())` si le démarrage a réussi, `Err` sinon + pub fn start(&mut self) -> std::io::Result<()> { + let addr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT); + let socket = UdpSocket::bind(("0.0.0.0", SSDP_PORT))?; + + // Rejoindre le groupe multicast + socket.join_multicast_v4( + &SSDP_MULTICAST_ADDR.parse().unwrap(), + &"0.0.0.0".parse().unwrap(), + )?; + + socket.set_read_timeout(Some(Duration::from_secs(1)))?; + socket.set_multicast_loop_v4(false)?; + + let socket = Arc::new(socket); + self.socket = Some(socket.clone()); + + info!("✅ SSDP server started on {}", addr); + + // Lancer les goroutines d'annonces périodiques et d'écoute M-SEARCH + self.start_periodic_announcements(socket.clone()); + self.start_msearch_listener(socket.clone()); + + Ok(()) + } + + /// Ajoute un device et envoie un alive initial + pub fn add_device(&self, device: SsdpDevice) { + let uuid = device.uuid.clone(); + let mut devices = self.devices.write().unwrap(); + devices.insert(uuid.clone(), device.clone()); + drop(devices); + + // Envoyer alive pour tous les NTs + if let Some(ref socket) = self.socket { + for nt in device.get_notification_types() { + self.send_alive(socket, &device, nt); + } + } + } + + /// Supprime un device et envoie un byebye + pub fn remove_device(&self, uuid: &str) { + let mut devices = self.devices.write().unwrap(); + if let Some(device) = devices.remove(uuid) { + drop(devices); + + // Envoyer byebye pour tous les NTs + if let Some(ref socket) = self.socket { + for nt in device.get_notification_types() { + self.send_byebye(socket, &device, nt); + } + } + } + } + + /// Envoie un NOTIFY alive + fn send_alive(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let msg = format!( + "NOTIFY * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + CACHE-CONTROL: max-age={}\r\n\ + LOCATION: {}\r\n\ + NT: {}\r\n\ + NTS: ssdp:alive\r\n\ + SERVER: {}\r\n\ + USN: {}\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match socket.send_to(msg.as_bytes(), addr) { + Ok(_) => info!("✅ NOTIFY alive: {} (NT={})", usn, nt), + Err(e) => warn!("❌ Failed to send NOTIFY alive for {}: {}", usn, e), + } + } + + /// Envoie un NOTIFY byebye + fn send_byebye(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let msg = format!( + "NOTIFY * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + NT: {}\r\n\ + NTS: ssdp:byebye\r\n\ + USN: {}\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, nt, usn + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match socket.send_to(msg.as_bytes(), addr) { + Ok(_) => info!("👋 NOTIFY byebye: {} (NT={})", usn, nt), + Err(e) => warn!("❌ Failed to send NOTIFY byebye for {}: {}", usn, e), + } + } + + /// Démarre les annonces périodiques (toutes les MAX_AGE/2 secondes) + fn start_periodic_announcements(&self, socket: Arc) { + let devices = Arc::clone(&self.devices); + let period = Duration::from_secs((MAX_AGE / 2) as u64); + + std::thread::spawn(move || { + loop { + std::thread::sleep(period); + + let devices = devices.read().unwrap(); + for device in devices.values() { + for nt in device.get_notification_types() { + Self::send_alive_static(&socket, device, nt); + } + } + } + }); + } + + /// Version statique de send_alive pour les threads + fn send_alive_static(socket: &UdpSocket, device: &SsdpDevice, nt: &str) { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let msg = format!( + "NOTIFY * HTTP/1.1\r\n\ + HOST: {}:{}\r\n\ + CACHE-CONTROL: max-age={}\r\n\ + LOCATION: {}\r\n\ + NT: {}\r\n\ + NTS: ssdp:alive\r\n\ + SERVER: {}\r\n\ + USN: {}\r\n\ + \r\n", + SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn + ); + + let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT) + .parse() + .unwrap(); + + match socket.send_to(msg.as_bytes(), addr) { + Ok(_) => info!("✅ NOTIFY alive (periodic): {} (NT={})", usn, nt), + Err(e) => warn!("❌ Failed to send periodic NOTIFY alive for {}: {}", usn, e), + } + } + + /// Démarre l'écoute des M-SEARCH + fn start_msearch_listener(&self, socket: Arc) { + let devices = Arc::clone(&self.devices); + + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match socket.recv_from(&mut buf) { + Ok((n, src)) => { + let data = String::from_utf8_lossy(&buf[..n]); + if data.starts_with("M-SEARCH") { + if let Some(st) = Self::parse_st(&data) { + let devices = devices.read().unwrap(); + for device in devices.values() { + Self::handle_msearch(&socket, &src, &st, device); + } + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // Timeout, continuer + continue; + } + Err(e) => { + warn!("❌ SSDP read error: {}", e); + } + } + } + }); + } + + /// Parse le champ ST d'un M-SEARCH + fn parse_st(data: &str) -> Option { + for line in data.lines() { + if line.to_uppercase().starts_with("ST:") { + let st = line[3..].trim().to_string(); + info!("✅ M-SEARCH received with ST={}", st); + return Some(st); + } + } + None + } + + /// Répond à un M-SEARCH + fn handle_msearch(socket: &UdpSocket, src: &SocketAddr, st: &str, device: &SsdpDevice) { + let mut nts = Vec::new(); + + if st == "ssdp:all" { + nts.extend(device.get_notification_types().iter().cloned()); + } else if device.get_notification_types().contains(&st.to_string()) { + nts.push(st.to_string()); + } else { + return; // Pas de match + } + + for nt in nts { + let usn = if nt.starts_with("uuid:") { + format!("{}", nt) + } else { + format!("uuid:{}::{}", device.uuid, nt) + }; + + let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT"); + + let resp = format!( + "HTTP/1.1 200 OK\r\n\ + CACHE-CONTROL: max-age={}\r\n\ + DATE: {}\r\n\ + EXT:\r\n\ + LOCATION: {}\r\n\ + SERVER: {}\r\n\ + ST: {}\r\n\ + USN: {}\r\n\ + \r\n", + MAX_AGE, date, device.location, device.server, nt, usn + ); + + match socket.send_to(resp.as_bytes(), src) { + Ok(_) => info!( + "📡 M-SEARCH response sent to {} with ST={}\n
\n\n```\n{}\n```\n
\n", + src, nt, resp + ), + Err(e) => warn!("❌ Failed to send M-SEARCH response to {}: {}", src, e), + } + } + } +} + +impl Default for SsdpServer { + fn default() -> Self { + Self::new() + } +} + +impl Drop for SsdpServer { + fn drop(&mut self) { + // Envoyer byebye pour tous les devices + if let Some(ref socket) = self.socket { + info!("✅ Shutting down SSDP server, sending byebye for all devices"); + let devices = self.devices.read().unwrap(); + for device in devices.values() { + for nt in device.get_notification_types() { + self.send_byebye(socket, device, nt); + } + } + } + } +} From c57ef5df30b390bb244185e0fe7a56177abb018f Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 6 Oct 2025 12:15:33 +0200 Subject: [PATCH 3/7] Connection de la couche sspd aux instances --- .pmomusic.yml | 2 + Cargo.lock | 1 + PMOMusic/src/main.rs | 91 +++++++++++++++++++------- pmoupnp/Cargo.toml | 1 + pmoupnp/src/devices/device_instance.rs | 10 ++- 5 files changed, 80 insertions(+), 25 deletions(-) diff --git a/.pmomusic.yml b/.pmomusic.yml index c91972b5..e07192c9 100644 --- a/.pmomusic.yml +++ b/.pmomusic.yml @@ -2,6 +2,8 @@ devices: mediarenderer: fakerenderer: udn: d7eaad15-7d21-4411-926a-bc1eea0713db + mediarenderer: + udn: f9ef6c21-0ed3-470c-9846-bc1ae85fea62 mediaserver: qobuz: udn: 28963b75-4c5f-4da7-b10e-ffafd diff --git a/Cargo.lock b/Cargo.lock index 5d372720..fc276813 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1515,6 +1515,7 @@ dependencies = [ "parking_lot", "pmoconfig", "pmodidl", + "pmoutils", "reqwest", "serde", "serde_json", diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index 4e75369f..905558ac 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -1,4 +1,8 @@ -use pmoupnp::{mediarenderer::avtransport::AVTTRANSPORT, UpnpObject}; +use pmoupnp::{ + mediarenderer::MEDIA_RENDERER, + ssdp::{SsdpDevice, SsdpServer}, + UpnpModel, +}; use pmoserver::{ logs::{log_dump, log_sse, LogState, SseLayer}, ServerBuilder @@ -10,17 +14,22 @@ use tracing::info; #[tokio::main] async fn main() { - // Charger la config + // Initialiser le logging d'abord + let log_state = LogState::new(1000); + let subscriber = Registry::default() + .with( + tracing_subscriber::fmt::layer() + .with_target(true) + .with_level(true) + .with_ansi(true), + ) + .with(SseLayer::new(log_state.clone())); + tracing::subscriber::set_global_default(subscriber).unwrap(); + // Créer le serveur let mut server = ServerBuilder::new_configured().build(); - // Ajouter des routes - server - .add_route("/hello", || async { - serde_json::json!({"message": "Hello World"}) - }) - .await; - + // Routes de base server .add_route("/info", || async { serde_json::json!({"version": "1.0.0"}) @@ -29,18 +38,7 @@ async fn main() { server.add_spa::("/app").await; - // Gère la sortie des logs et sur le serveur SSE pour l'interface web et sur la console - let log_state = LogState::new(1000); - let subscriber = Registry::default() - .with( - tracing_subscriber::fmt::layer() - .with_target(true) - .with_level(true) - .with_ansi(true), // Couleurs dans le terminal - ) - .with(SseLayer::new(log_state.clone())); - tracing::subscriber::set_global_default(subscriber).unwrap(); - + // Routes de logging server .add_handler_with_state("/log-sse", log_sse, log_state.clone()) .await; @@ -50,8 +48,55 @@ async fn main() { server.add_redirect("/", "/app").await; - info!("{}",AVTTRANSPORT.to_markdown()); - info!("{}",AVTTRANSPORT.scpd_xml()); + // Créer et enregistrer le MediaRenderer + info!("🎵 Creating MediaRenderer instance..."); + let renderer_instance = MEDIA_RENDERER.create_instance(); + + // Créer et ajouter les instances de services + for service in MEDIA_RENDERER.services().iter() { + let service_instance = service.create_instance(); + renderer_instance + .add_service(service_instance) + .expect("Failed to add service to renderer"); + } + + info!("📡 Registering MediaRenderer routes..."); + renderer_instance + .register_urls(&mut server) + .await + .expect("Failed to register MediaRenderer routes"); + + info!("✅ MediaRenderer ready at {}{}", + renderer_instance.base_url(), + renderer_instance.description_route() + ); + + // Créer et démarrer le serveur SSDP + info!("📡 Starting SSDP discovery..."); + let mut ssdp_server = SsdpServer::new(); + ssdp_server.start().expect("Failed to start SSDP server"); + + // Créer le device SSDP pour le MediaRenderer + let location = format!("{}{}", + renderer_instance.base_url(), + renderer_instance.description_route() + ); + + let mut ssdp_device = SsdpDevice::new( + renderer_instance.udn().to_string(), + MEDIA_RENDERER.device_type(), + location, + format!("Linux/5.0 UPnP/1.1 PMOMusic/1.0"), + ); + + // Ajouter les types de notification pour chaque service + for service in renderer_instance.services() { + ssdp_device.add_notification_type(service.service_type()); + } + + // Enregistrer le device et envoyer les annonces SSDP + ssdp_server.add_device(ssdp_device); + info!("✅ SSDP announcements sent for MediaRenderer"); server.start().await; server.wait().await; diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index d6bf20b4..3d550251 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] pmoconfig = { path = "../pmoconfig" } pmodidl = { path = "../pmodidl"} +pmoutils = { path = "../pmoutils" } url = "2.5.7" uuid = "1.18.1" diff --git a/pmoupnp/src/devices/device_instance.rs b/pmoupnp/src/devices/device_instance.rs index 26526aa1..1aa2f6c4 100644 --- a/pmoupnp/src/devices/device_instance.rs +++ b/pmoupnp/src/devices/device_instance.rs @@ -87,6 +87,11 @@ impl UpnpInstance for DeviceInstance { format!("uuid:{}_{}", model.udn_prefix(), uuid::Uuid::new_v4()) }; + // Obtenir l'IP locale et le port depuis la configuration + let local_ip = pmoutils::guess_local_ip(); + let port = pmoconfig::get_config().get_http_port(); + let server_base_url = format!("http://{}:{}", local_ip, port); + Self { object: UpnpObjectType { name: model.get_name().to_string(), @@ -94,7 +99,7 @@ impl UpnpInstance for DeviceInstance { }, model: Arc::new(model.clone()), udn, - server_base_url: "http://localhost:8080".to_string(), + server_base_url, services: RwLock::new(HashMap::new()), devices: RwLock::new(HashMap::new()), } @@ -184,8 +189,9 @@ impl DeviceInstance { } /// Retourne la route du device (chemin relatif). + /// Utilise l'UDN pour garantir l'unicité si plusieurs devices du même type existent. pub fn route(&self) -> String { - format!("/device/{}", self.get_name()) + format!("/device/{}", self.udn()) } /// Retourne la route de description du device. From e260330877b77745267e0e43cad62a4e0ca2e2bf Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 6 Oct 2025 20:24:04 +0200 Subject: [PATCH 4/7] Revue de code et refactoring --- .DS_Store | Bin 0 -> 8196 bytes Cargo.lock | 81 ++++++- PMOMusic/Cargo.toml | 2 +- PMOMusic/src/main.rs | 77 ++----- pmoapp/Cargo.toml | 8 + pmoapp/src/lib.rs | 64 +++++- pmoapp/src/pmoserver_impl.rs | 57 +++++ pmoserver/Cargo.toml | 4 - pmoserver/src/lib.rs | 17 +- pmoserver/src/logs/mod.rs | 58 +++++ pmoserver/src/server.rs | 42 ++++ pmoserver/src/upnp_impl.rs | 94 -------- pmoupnp/Cargo.toml | 1 + pmoupnp/src/devices/device_instance.rs | 43 +++- pmoupnp/src/devices/device_methods.rs | 18 +- pmoupnp/src/lib.rs | 6 +- pmoupnp/src/server.rs | 274 ++--------------------- pmoupnp/src/services/service_instance.rs | 2 +- pmoutils/Cargo.toml | 3 +- pmoutils/src/lib.rs | 34 ++- 20 files changed, 445 insertions(+), 440 deletions(-) create mode 100644 .DS_Store create mode 100644 pmoapp/src/pmoserver_impl.rs delete mode 100644 pmoserver/src/upnp_impl.rs diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1f4aa30355072a8c3a778ef5a052a4de54b88f51 GIT binary patch literal 8196 zcmeHM&2G~`5FRIKeh5W50O|!{iEA2K`hyUckhVfdL`4mP1E7$^rbKn@D0Z4kQL7yJ zc?VvB8{$cL7f$fau2a^V1|cC;73^Bx+1THVcfXlgcU&T}YC~t0Xo-mOI9Zm8=+0<- zpU;_=im%*+w4hJaqalUVr4BV`(B^_+z%XDKFbo(53$QjyvhY#r!C2>_YFWzjH39Uyeky5C_rmP;USk;P43`?rxzRhsR zYAh#GN_DcNI$80Pt++z5#M7Z?3piO-DNSz}FbvExz_ojcD%3~q9wh4b4t^h!O9Kkq zqp;uhf;O;U+kLMaMk_1dtW-L)kj>=^xnk~B<83f%M6GBT)>?xX{Of5Dc zN6pgJt-z03UEduDg|6cQ^747tcY;wZ82U~qbQ|(Up^z^$OH1SN>e_1AUfb9_F5BaK zcUH>w`sVuaaUp;0*6sUy%>%FJ2Os3ptay8%MQy1c|74wqje+k4{ywm5Cr2&va8xLw zBZ?k*K__N`H<<@GD?dcTh&8tU5uUITjfqRN1uh1(kC>={kpN$K4m3VKYkVYiA`x9) zQpdWX>11K|H0m_$cTjwy@SP%u+8TzvtokGmjEmR~G3yY~yszP5Gl5S7kF_OHDUJu# z>SfqG0v|nC_AqY3b{{i$l6oRSJ}EQKQFp@i8nBYhbQrZP!(UKU0B_wWqOFwGTgX}Uptx>)23==cIHZLHlV)8>koJV?n00;nX)!q;0z(P>bUvY=S!`fQ4d!T$Mg+&W z>{um{znuGqcYQowUn|=icYm|py@MCf=5BZIaq|}wr|Z(vdg_TGJmSxI8c~tpDIfc7 zh!E!33pHLjK~>KcXiu=|AW>W-U{6mFj*Nsx28k3RCi-e(EaDO`V2BKh(6fuq%w`cf zCrapKftS0MJF|Op*r?bAU;`Yp%i?fRk!j>U4sG#FmWmSYx!_$|&Tpv*xikO!{{>%# znVw<5Fz`1FSefm{b`2J$e?05PxYo9DzQ)OoaT6(p3OZ4aLyB@7^7J2u7~6odPBoSj aDRBku+&=`E_rFr$$g("/app").await; + // Ajouter la webapp via le trait WebAppExt + info!("📡 Registering Web application..."); + server.add_webapp_with_redirect::("/app").await; - // Routes de logging - server - .add_handler_with_state("/log-sse", log_sse, log_state.clone()) - .await; - server - .add_handler_with_state("/log-dump", log_dump, log_state.clone()) - .await; - - server.add_redirect("/", "/app").await; - - // Créer et enregistrer le MediaRenderer - info!("🎵 Creating MediaRenderer instance..."); - let renderer_instance = MEDIA_RENDERER.create_instance(); - - // Créer et ajouter les instances de services - for service in MEDIA_RENDERER.services().iter() { - let service_instance = service.create_instance(); - renderer_instance - .add_service(service_instance) - .expect("Failed to add service to renderer"); - } - - info!("📡 Registering MediaRenderer routes..."); - renderer_instance - .register_urls(&mut server) + info!("📡 Registering MediaRenderer..."); + let renderer_instance = server.register_device(MEDIA_RENDERER.clone()) .await .expect("Failed to register MediaRenderer routes"); @@ -76,25 +45,9 @@ async fn main() { let mut ssdp_server = SsdpServer::new(); ssdp_server.start().expect("Failed to start SSDP server"); - // Créer le device SSDP pour le MediaRenderer - let location = format!("{}{}", - renderer_instance.base_url(), - renderer_instance.description_route() - ); - - let mut ssdp_device = SsdpDevice::new( - renderer_instance.udn().to_string(), - MEDIA_RENDERER.device_type(), - location, - format!("Linux/5.0 UPnP/1.1 PMOMusic/1.0"), - ); - - // Ajouter les types de notification pour chaque service - for service in renderer_instance.services() { - ssdp_device.add_notification_type(service.service_type()); - } - - // Enregistrer le device et envoyer les annonces SSDP + // Créer et enregistrer le device SSDP pour le MediaRenderer + let ssdp_device = renderer_instance + .to_ssdp_device("PMOMusic", "1.0"); ssdp_server.add_device(ssdp_device); info!("✅ SSDP announcements sent for MediaRenderer"); diff --git a/pmoapp/Cargo.toml b/pmoapp/Cargo.toml index 894dea15..8b18b4e2 100644 --- a/pmoapp/Cargo.toml +++ b/pmoapp/Cargo.toml @@ -5,3 +5,11 @@ edition = "2021" [dependencies] rust-embed = "8.5.0" + +[dependencies.pmoserver] +path = "../pmoserver" +optional = true + +[features] +default = [] +pmoserver = ["dep:pmoserver"] diff --git a/pmoapp/src/lib.rs b/pmoapp/src/lib.rs index 763181ae..d7e1a37f 100644 --- a/pmoapp/src/lib.rs +++ b/pmoapp/src/lib.rs @@ -239,6 +239,8 @@ //! - [Vite Documentation](https://vitejs.dev/) use rust_embed::RustEmbed; +use std::future::Future; +use std::pin::Pin; /// Structure représentant l'application web embarquée. /// @@ -248,16 +250,72 @@ use rust_embed::RustEmbed; /// ## Exemple /// /// ```rust,no_run -/// use pmoapp::Webapp; +/// use pmoapp::{Webapp, WebAppExt}; /// use pmoserver::ServerBuilder; /// /// # async fn example() { /// let mut server = ServerBuilder::new("MyApp").build(); /// -/// // Ajouter la webapp comme SPA sur le chemin /app -/// server.add_spa::("/app").await; +/// // Ajouter la webapp via le trait WebAppExt +/// server.add_webapp::("/app").await; /// # } /// ``` #[derive(RustEmbed, Clone)] #[folder = "webapp/dist"] pub struct Webapp; + +/// Trait pour étendre un serveur HTTP avec des fonctionnalités webapp. +/// +/// Ce trait permet à `pmoapp` d'ajouter des méthodes d'extension sur des types +/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmoapp`. +/// +/// # Architecture +/// +/// Similaire au pattern utilisé par `pmoupnp` pour `UpnpServer`, ce trait permet +/// une extension propre et découplée : +/// +/// - `pmoserver` définit un serveur HTTP générique +/// - `pmoapp` étend ce serveur avec des méthodes webapp via ce trait +/// - Le serveur n'a pas besoin de connaître `pmoapp` +/// +/// # Exemple d'implémentation +/// +/// ```ignore +/// impl WebAppExt for pmoserver::Server { +/// fn add_webapp(&mut self, path: &str) -> ... { +/// // Délègue à la méthode interne add_spa +/// self.add_spa::(path) +/// } +/// } +/// ``` +pub trait WebAppExt { + /// Ajoute une Single Page Application au serveur. + /// + /// # Arguments + /// + /// * `path` - Le chemin où monter la webapp (ex: "/app") + /// + /// # Type Parameter + /// + /// * `W` - Type RustEmbed contenant les fichiers de la webapp + fn add_webapp(&mut self, path: &str) -> Pin + Send + '_>> + where + W: RustEmbed + Clone + Send + Sync + 'static; + + /// Ajoute une webapp avec une redirection automatique depuis la racine. + /// + /// # Arguments + /// + /// * `path` - Le chemin où monter la webapp (ex: "/app") + /// + /// # Type Parameter + /// + /// * `W` - Type RustEmbed contenant les fichiers de la webapp + fn add_webapp_with_redirect(&mut self, path: &str) -> Pin + Send + '_>> + where + W: RustEmbed + Clone + Send + Sync + 'static; +} + +// Implémentation du trait pour pmoserver::Server (feature-gated) +#[cfg(feature = "pmoserver")] +mod pmoserver_impl; diff --git a/pmoapp/src/pmoserver_impl.rs b/pmoapp/src/pmoserver_impl.rs new file mode 100644 index 00000000..d5e7db7f --- /dev/null +++ b/pmoapp/src/pmoserver_impl.rs @@ -0,0 +1,57 @@ +//! Implémentation du trait WebAppExt pour le serveur pmoserver +//! +//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités webapp en +//! implémentant le trait [`WebAppExt`](crate::WebAppExt). Cette implémentation +//! permet d'enregistrer facilement des webapps embarquées sur le serveur. +//! +//! ## Architecture +//! +//! `pmoapp` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmoapp`. +//! C'est le pattern d'extension : `pmoapp` ajoute des fonctionnalités à un type +//! externe via un trait, similaire au pattern utilisé par `pmoupnp` pour `UpnpServer`. +//! +//! ## Exemple d'utilisation +//! +//! ```rust,no_run +//! use pmoapp::{Webapp, WebAppExt}; +//! use pmoserver::ServerBuilder; +//! +//! # async fn example() { +//! let mut server = ServerBuilder::new("MyApp").build(); +//! +//! // Le trait WebAppExt est automatiquement disponible +//! server.add_webapp::("/app").await; +//! +//! // Ou avec redirection +//! server.add_webapp_with_redirect::("/app").await; +//! # } +//! ``` + +use crate::WebAppExt; +use pmoserver::Server; +use rust_embed::RustEmbed; +use std::future::Future; +use std::pin::Pin; + +impl WebAppExt for Server { + fn add_webapp(&mut self, path: &str) -> Pin + Send + '_>> + where + W: RustEmbed + Clone + Send + Sync + 'static, + { + let path = path.to_string(); + Box::pin(async move { + self.add_spa::(&path).await; + }) + } + + fn add_webapp_with_redirect(&mut self, path: &str) -> Pin + Send + '_>> + where + W: RustEmbed + Clone + Send + Sync + 'static, + { + let path = path.to_string(); + Box::pin(async move { + self.add_spa::(&path).await; + self.add_redirect("/", &path).await; + }) + } +} diff --git a/pmoserver/Cargo.toml b/pmoserver/Cargo.toml index d5d74ddd..c2388bb8 100644 --- a/pmoserver/Cargo.toml +++ b/pmoserver/Cargo.toml @@ -21,7 +21,3 @@ 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"] } - -[dependencies.pmoupnp] -path = "../pmoupnp" -default-features = false diff --git a/pmoserver/src/lib.rs b/pmoserver/src/lib.rs index 8125fa06..9cea71a4 100644 --- a/pmoserver/src/lib.rs +++ b/pmoserver/src/lib.rs @@ -20,7 +20,6 @@ //! //! - [`server`] : Implémentation du serveur principal et du builder //! - [`logs`] : Système de logs SSE pour monitoring en temps réel -//! - `upnp_impl` : Implémentation du trait `pmoupnp::UpnpServer` (privé) //! //! ## Exemple d'utilisation //! @@ -53,27 +52,25 @@ //! //! ## Intégration UPnP //! -//! Le serveur implémente automatiquement le trait `pmoupnp::UpnpServer`, permettant -//! de connecter des devices UPnP : +//! Le serveur peut être étendu avec UPnP via le trait `pmoupnp::UpnpServer`. +//! L'implémentation est fournie par `pmoupnp` (feature `pmoserver`), permettant +//! de connecter des devices UPnP sans que `pmoserver` dépende de `pmoupnp` : //! //! ```rust,no_run -//! use pmoupnp::{UpnpServer, mediarenderer::device::MEDIA_RENDERER}; -//! use pmoupnp::devices::DeviceInstance; +//! use pmoupnp::{UpnpServer, mediarenderer::MEDIA_RENDERER}; //! use pmoserver::ServerBuilder; -//! use std::sync::Arc; //! //! # async fn example() { //! let mut server = ServerBuilder::new("MediaRenderer").build(); -//! let device = Arc::new(DeviceInstance::new(&MEDIA_RENDERER)); +//! let device = MEDIA_RENDERER.create_instance(); //! -//! // Le device enregistre automatiquement ses routes +//! // Le trait UpnpServer est automatiquement disponible (implémenté dans pmoupnp) //! device.register_urls(&mut server).await; //! # } //! ``` pub mod server; pub mod logs; -mod upnp_impl; pub use server::{Server, ServerBuilder, ServerInfo}; -pub use logs::{LogState, SseLayer, log_sse, log_dump}; +pub use logs::{LogState, SseLayer, log_sse, log_dump, init_logging, LoggingOptions}; diff --git a/pmoserver/src/logs/mod.rs b/pmoserver/src/logs/mod.rs index 6ff83ece..1d05e274 100644 --- a/pmoserver/src/logs/mod.rs +++ b/pmoserver/src/logs/mod.rs @@ -19,6 +19,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; +use tracing_subscriber::{Registry, layer::SubscriberExt}; /// Représente une entrée de log #[derive(Debug, Clone, Serialize)] @@ -157,3 +158,60 @@ fn filter_entry(entry: &LogEntry, q: &LogQuery) -> bool { allowed } + +/// Options d'initialisation du système de logging +#[derive(Debug, Clone)] +pub struct LoggingOptions { + /// Capacité du buffer circulaire (nombre d'entrées conservées) + pub buffer_capacity: usize, + /// Activer la sortie vers stderr/stdout + pub enable_console: bool, +} + +impl Default for LoggingOptions { + fn default() -> Self { + Self { + buffer_capacity: 1000, + enable_console: true, + } + } +} + +/// Initialise le système de logging avec SSE et optionnellement la console +/// +/// # Arguments +/// * `options` - Options de configuration du logging +/// +/// # Retourne +/// Le `LogState` qui peut être utilisé pour ajouter les routes de logging au serveur +/// +/// # Exemple +/// ```rust,no_run +/// use pmoserver::logs::{init_logging, LoggingOptions}; +/// +/// let log_state = init_logging(LoggingOptions { +/// buffer_capacity: 1000, +/// enable_console: true, +/// }); +/// ``` +pub fn init_logging(options: LoggingOptions) -> LogState { + let log_state = LogState::new(options.buffer_capacity); + + let subscriber = Registry::default().with(SseLayer::new(log_state.clone())); + + if options.enable_console { + let subscriber = subscriber.with( + tracing_subscriber::fmt::layer() + .with_target(true) + .with_level(true) + .with_ansi(true), + ); + tracing::subscriber::set_global_default(subscriber) + .expect("Failed to set global default subscriber"); + } else { + tracing::subscriber::set_global_default(subscriber) + .expect("Failed to set global default subscriber"); + } + + log_state +} diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index 0f621ce9..703cfa17 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -25,6 +25,7 @@ use std::{net::SocketAddr, sync::Arc}; use tokio::{signal, sync::RwLock, task::JoinHandle}; use tracing::info; use utoipa_swagger_ui::SwaggerUi; +use crate::logs::{LogState, LoggingOptions, init_logging, log_sse, log_dump}; /// Info serveur sérialisable #[derive(Clone, Serialize, utoipa::ToSchema)] @@ -45,6 +46,7 @@ pub struct Server { router: Arc>, api_router: Arc>>, join_handle: Option>, + log_state: Option, } impl Server { @@ -70,6 +72,7 @@ impl Server { router: Arc::new(RwLock::new(Router::new())), api_router: Arc::new(RwLock::new(None)), join_handle: None, + log_state: None, } } @@ -508,6 +511,45 @@ impl Server { http_port: self.http_port, } } + + /// Initialise le système de logging et enregistre les routes de logs + /// + /// Cette méthode configure le système de tracing avec SSE et optionnellement la console, + /// puis enregistre automatiquement les routes `/log-sse` et `/log-dump`. + /// + /// # Arguments + /// + /// * `options` - Options de configuration du logging + /// + /// # Exemple + /// + /// ```rust,no_run + /// # use pmoserver::{ServerBuilder, logs::LoggingOptions}; + /// # #[tokio::main] + /// # async fn main() { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // Initialiser les logs avec console + /// server.init_logging(LoggingOptions::default()).await; + /// + /// // Ou sans console + /// server.init_logging(LoggingOptions { + /// buffer_capacity: 1000, + /// enable_console: false, + /// }).await; + /// + /// server.start().await; + /// # } + /// ``` + pub async fn init_logging(&mut self, options: LoggingOptions) { + let log_state = init_logging(options); + + // Enregistrer automatiquement les routes de logging + self.add_handler_with_state("/log-sse", log_sse, log_state.clone()).await; + self.add_handler_with_state("/log-dump", log_dump, log_state.clone()).await; + + self.log_state = Some(log_state); + } } /// Builder pattern diff --git a/pmoserver/src/upnp_impl.rs b/pmoserver/src/upnp_impl.rs deleted file mode 100644 index d877c108..00000000 --- a/pmoserver/src/upnp_impl.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Implémentation du trait UpnpServer pour le serveur pmoserver -//! -//! Ce module fournit l'implémentation du trait [`pmoupnp::UpnpServer`] pour -//! le [`Server`](crate::server::Server) de pmoserver, permettant aux devices -//! et services UPnP d'enregistrer automatiquement leurs endpoints HTTP. -//! -//! ## Architecture -//! -//! L'implémentation fait le pont entre : -//! - Les pointeurs de fonction du trait `UpnpServer` (agnostiques du framework web) -//! - Les handlers Axum (spécifiques à l'implémentation `pmoserver`) -//! -//! Chaque méthode du trait crée un wrapper qui : -//! 1. Convertit les pointeurs de fonction en closures compatibles Axum -//! 2. Délègue l'enregistrement aux méthodes internes du `Server` -//! 3. Retourne une future qui se résout une fois le handler enregistré -//! -//! ## Exemple d'utilisation -//! -//! ```rust,no_run -//! use pmoupnp::{UpnpServer, mediarenderer::device::MEDIA_RENDERER}; -//! use pmoupnp::devices::DeviceInstance; -//! use pmoserver::ServerBuilder; -//! use std::sync::Arc; -//! -//! # async fn example() { -//! let mut server = ServerBuilder::new("MyRenderer").build(); -//! let device = Arc::new(DeviceInstance::new(&MEDIA_RENDERER)); -//! -//! // Le trait UpnpServer est automatiquement disponible -//! device.register_urls(&mut server).await; -//! # } -//! ``` - -use crate::server::Server; -use pmoupnp::{UpnpServer, server::{Response, HeaderMap, Request}}; -use std::future::Future; -use std::pin::Pin; -use axum::extract::State; - -impl UpnpServer for Server { - fn add_handler(&mut self, path: &str, handler: F) -> Pin + Send + '_>> - where - F: Fn() -> Fut + Send + Sync + 'static + Clone, - Fut: Future + Send + 'static, - { - let path = path.to_string(); - Box::pin(async move { - Self::add_handler(self, &path, handler).await; - }) - } - - fn add_post_handler_with_state( - &mut self, - path: &str, - handler: fn(State, String) -> Pin + Send>>, - state: S, - ) -> Pin + Send + '_>> - where - S: Clone + Send + Sync + 'static, - { - let path = path.to_string(); - - // Créer un wrapper qui convertit le fn pointer en handler Axum - let wrapper = move |State(s): State, body: String| -> Pin + Send>> { - handler(State(s), body) - }; - - Box::pin(async move { - Self::add_post_handler_with_state(self, &path, wrapper, state).await; - }) - } - - fn add_handler_with_state( - &mut self, - path: &str, - handler: fn(State, HeaderMap, Request) -> Pin + Send>>, - state: S, - ) -> Pin + Send + '_>> - where - S: Clone + Send + Sync + 'static, - { - let path = path.to_string(); - - // Créer un wrapper qui convertit le fn pointer en handler Axum - let wrapper = move |State(s): State, headers: HeaderMap, req: Request| -> Pin + Send>> { - handler(State(s), headers, req) - }; - - Box::pin(async move { - Self::add_handler_with_state(self, &path, wrapper, state).await; - }) - } -} diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index 3d550251..b6b761d3 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" pmoconfig = { path = "../pmoconfig" } pmodidl = { path = "../pmodidl"} pmoutils = { path = "../pmoutils" } +pmoserver = { path = "../pmoserver" } url = "2.5.7" uuid = "1.18.1" diff --git a/pmoupnp/src/devices/device_instance.rs b/pmoupnp/src/devices/device_instance.rs index 1aa2f6c4..8a9695ad 100644 --- a/pmoupnp/src/devices/device_instance.rs +++ b/pmoupnp/src/devices/device_instance.rs @@ -251,7 +251,7 @@ impl DeviceInstance { } /// Enregistre toutes les URLs du device et de ses services dans le serveur. - pub fn register_urls<'a, S: crate::UpnpServer + ?Sized>(&'a self, server: &'a mut S) -> std::pin::Pin> + 'a>> { + pub fn register_urls<'a>(&'a self, server: &'a mut pmoserver::Server) -> std::pin::Pin> + 'a>> { Box::pin(async move { info!( "✅ Device description for {} available at: {}{}", @@ -330,4 +330,45 @@ impl DeviceInstance { xml, ).into_response() } + + /// Crée un SsdpDevice configuré pour ce device UPnP. + /// + /// Cette méthode simplifie la création d'un device SSDP en configurant automatiquement : + /// - L'UDN du device + /// - Le type de device + /// - La location (URL de description) + /// - Le serveur (User-Agent avec OS/version détecté automatiquement) + /// - Les types de notification pour tous les services + /// + /// # Arguments + /// + /// * `app_name` - Nom de l'application (ex: "PMOMusic") + /// * `app_version` - Version de l'application (ex: "1.0") + /// + /// # Exemple + /// + /// ```ignore + /// let renderer_instance = MEDIA_RENDERER.create_instance(); + /// let ssdp_device = renderer_instance.to_ssdp_device("PMOMusic", "1.0"); + /// ssdp_server.add_device(ssdp_device); + /// ``` + pub fn to_ssdp_device(&self, app_name: &str, app_version: &str) -> crate::ssdp::SsdpDevice { + let location = format!("{}{}", self.base_url(), self.description_route()); + let os_string = pmoutils::get_os_string(); + let server_string = format!("{} UPnP/1.1 {}/{}", os_string, app_name, app_version); + + let mut ssdp_device = crate::ssdp::SsdpDevice::new( + self.udn().to_string(), + self.model.device_type(), + location, + server_string, + ); + + // Ajouter les types de notification pour chaque service + for service in self.services() { + ssdp_device.add_notification_type(service.service_type()); + } + + ssdp_device + } } diff --git a/pmoupnp/src/devices/device_methods.rs b/pmoupnp/src/devices/device_methods.rs index 96fd9b3b..cf4b76e8 100644 --- a/pmoupnp/src/devices/device_methods.rs +++ b/pmoupnp/src/devices/device_methods.rs @@ -1,10 +1,11 @@ //! Implémentation des traits UPnP pour Device. +use std::sync::Arc; use xmltree::{Element, XMLNode}; use crate::{ devices::{Device, DeviceInstance}, - UpnpObject, UpnpModel, + UpnpObject, UpnpModel, UpnpInstance, }; impl UpnpObject for Device { @@ -115,4 +116,19 @@ impl UpnpObject for Device { impl UpnpModel for Device { type Instance = DeviceInstance; + + /// Crée une instance du device avec ses services déjà instanciés. + /// + /// Les services sont créés dans DeviceInstance::new(), cette méthode + /// établit uniquement les liens bidirectionnels parent-enfant. + fn create_instance(&self) -> Arc { + let instance = Arc::new(DeviceInstance::new(self)); + + // Établir le lien parent pour chaque service + for service in instance.services() { + service.set_device(Arc::clone(&instance)); + } + + instance + } } diff --git a/pmoupnp/src/lib.rs b/pmoupnp/src/lib.rs index a23d1cbc..a5cd62d9 100644 --- a/pmoupnp/src/lib.rs +++ b/pmoupnp/src/lib.rs @@ -1,10 +1,10 @@ mod object_trait; mod object_set; +mod server; pub mod actions; pub mod devices; pub mod mediarenderer; -pub mod server; pub mod services; pub mod soap; pub mod ssdp; @@ -12,8 +12,7 @@ pub mod state_variables; pub mod value_ranges; pub mod variable_types; -// Re-exports -pub use server::UpnpServer; + use std::{collections::HashMap, sync::Arc}; @@ -21,6 +20,7 @@ use std::{collections::HashMap, sync::Arc}; use std::sync::RwLock; pub use crate::object_trait::*; +pub use crate::server::UpnpServer; #[derive(Debug, Clone)] pub struct UpnpObjectType { diff --git a/pmoupnp/src/server.rs b/pmoupnp/src/server.rs index 007c8342..6b5a288e 100644 --- a/pmoupnp/src/server.rs +++ b/pmoupnp/src/server.rs @@ -1,262 +1,22 @@ -//! Trait pour les serveurs UPnP -//! -//! Ce module définit le trait [`UpnpServer`] qui permet de connecter -//! des devices UPnP à n'importe quelle implémentation de serveur web. -//! -//! ## Architecture -//! -//! Le trait `UpnpServer` définit une interface minimale permettant aux devices -//! et services UPnP d'enregistrer leurs endpoints HTTP sans dépendre d'une -//! implémentation de serveur spécifique. -//! -//! ## Séparation des responsabilités -//! -//! - **pmoupnp** : Définit le trait `UpnpServer` et l'utilise via des contraintes génériques -//! - **pmoserver** : Fournit une implémentation concrète basée sur Axum -//! - **Autres crates** : Peuvent fournir leurs propres implémentations (actix-web, warp, etc.) -//! -//! ## Exemple d'utilisation -//! -//! ```rust,no_run -//! use pmoupnp::{UpnpServer, devices::{Device, DeviceInstance}}; -//! use std::sync::Arc; -//! -//! # async fn example(mut server: S) { -//! // Créer un device -//! let device = Device::new( -//! "MyDevice".to_string(), -//! "MyDeviceType".to_string(), -//! "Friendly Name".to_string(), -//! ); -//! let device_instance = Arc::new(DeviceInstance::new(&device)); -//! -//! // Le device enregistre automatiquement ses routes UPnP -//! device_instance.register_urls(&mut server).await; -//! # } -//! ``` -//! -//! ## Implémentation -//! -//! Pour implémenter ce trait, votre serveur doit fournir trois méthodes -//! pour enregistrer des handlers HTTP asynchrones : -//! -//! ```rust,no_run -//! use pmoupnp::UpnpServer; -//! use std::future::Future; -//! use std::pin::Pin; -//! -//! struct MyServer { -//! // votre implémentation -//! } -//! -//! impl UpnpServer for MyServer { -//! fn add_handler(&mut self, path: &str, handler: F) -//! -> Pin + Send + '_>> -//! where -//! F: Fn() -> Fut + Send + Sync + 'static + Clone, -//! Fut: Future + Send + 'static, -//! { -//! // Enregistrer le handler pour GET requests -//! # todo!() -//! } -//! -//! fn add_post_handler_with_state( -//! &mut self, -//! path: &str, -//! handler: fn(axum::extract::State, String) -//! -> Pin + Send>>, -//! state: S, -//! ) -> Pin + Send + '_>> -//! where -//! S: Clone + Send + Sync + 'static, -//! { -//! // Enregistrer le handler pour POST avec body -//! # todo!() -//! } -//! -//! fn add_handler_with_state( -//! &mut self, -//! path: &str, -//! handler: fn(axum::extract::State, -//! pmoupnp::server::HeaderMap, -//! pmoupnp::server::Request) -//! -> Pin + Send>>, -//! state: S, -//! ) -> Pin + Send + '_>> -//! where -//! S: Clone + Send + Sync + 'static, -//! { -//! // Enregistrer le handler avec accès complet à la requête -//! # todo!() -//! } -//! } -//! ``` +use std::sync::Arc; -use std::future::Future; -use std::pin::Pin; +use pmoserver::Server; -/// Type alias pour la réponse HTTP (basé sur Axum). -/// -/// Utilisé pour éviter une dépendance directe sur axum dans les signatures de trait, -/// tout en restant compatible avec les types Axum. -pub type Response = axum::response::Response; +use crate::devices::errors::DeviceError; +use crate::devices::{Device, DeviceInstance}; +use crate::UpnpModel; -/// Type alias pour les en-têtes HTTP (basé sur Axum). -pub type HeaderMap = axum::http::HeaderMap; +pub trait UpnpServer { + async fn register_device(&mut self, device: Arc) -> Result,DeviceError> ; -/// Type alias pour la requête HTTP (basé sur Axum). -pub type Request = axum::extract::Request; - -/// Trait pour les serveurs compatibles UPnP. -/// -/// Ce trait définit l'interface minimale qu'un serveur web doit implémenter -/// pour supporter l'enregistrement automatique des endpoints UPnP par les -/// [`DeviceInstance`](crate::devices::DeviceInstance) et -/// [`ServiceInstance`](crate::services::ServiceInstance). -/// -/// ## Contraintes -/// -/// - `Send + Sync` : Le serveur doit être partageable entre threads -/// -/// ## Méthodes -/// -/// Les trois méthodes permettent d'enregistrer différents types de handlers : -/// -/// 1. **`add_handler`** : Handler GET simple sans état -/// 2. **`add_post_handler_with_state`** : Handler POST avec état et body texte (pour SOAP) -/// 3. **`add_handler_with_state`** : Handler générique avec accès complet (pour SUBSCRIBE/UNSUBSCRIBE) -/// -/// ## Implémentations -/// -/// - **pmoserver::Server** : Implémentation basée sur Axum (fournie par la crate `pmoserver`) -pub trait UpnpServer: Send + Sync { - /// Ajoute un handler GET pour un chemin donné. - /// - /// Utilisé principalement pour servir les descripteurs XML des devices et services. - /// - /// # Arguments - /// - /// * `path` - Le chemin HTTP (ex: `/device/MediaRenderer/description.xml`) - /// * `handler` - Une closure asynchrone qui génère la réponse - /// - /// # Retour - /// - /// Une future qui se résout quand le handler est enregistré. - /// - /// # Exemple - /// - /// ```rust,no_run - /// use pmoupnp::UpnpServer; - /// use axum::response::IntoResponse; - /// - /// # async fn example(mut server: S) { - /// server.add_handler("/description.xml", || async { - /// "".into_response() - /// }).await; - /// # } - /// ``` - fn add_handler(&mut self, path: &str, handler: F) -> Pin + Send + '_>> - where - F: Fn() -> Fut + Send + Sync + 'static + Clone, - Fut: Future + Send + 'static; - - /// Ajoute un handler POST avec état pour un chemin donné. - /// - /// Utilisé pour les endpoints de contrôle SOAP des services UPnP. - /// - /// # Arguments - /// - /// * `path` - Le chemin HTTP (ex: `/service/AVTransport/control`) - /// * `handler` - Un pointeur de fonction qui traite la requête SOAP - /// * `state` - L'état partagé (typiquement une `ServiceInstance`) - /// - /// # Retour - /// - /// Une future qui se résout quand le handler est enregistré. - /// - /// # Exemple - /// - /// ```rust,no_run - /// use pmoupnp::{UpnpServer, server::Response}; - /// use axum::extract::State; - /// use std::pin::Pin; - /// use std::future::Future; - /// - /// fn soap_handler( - /// State(service): State, - /// body: String, - /// ) -> Pin + Send>> { - /// Box::pin(async move { - /// // Traiter la requête SOAP - /// axum::response::Response::default() - /// }) - /// } - /// - /// # async fn example(mut server: S) { - /// server.add_post_handler_with_state( - /// "/control", - /// soap_handler, - /// "ServiceName".to_string(), - /// ).await; - /// # } - /// ``` - fn add_post_handler_with_state( - &mut self, - path: &str, - handler: fn(axum::extract::State, String) -> Pin + Send>>, - state: S, - ) -> Pin + Send + '_>> - where - S: Clone + Send + Sync + 'static; - - /// Ajoute un handler avec état et accès complet à la requête. - /// - /// Utilisé pour les endpoints d'événements (SUBSCRIBE/UNSUBSCRIBE) qui nécessitent - /// un accès aux en-têtes HTTP et à la méthode HTTP. - /// - /// # Arguments - /// - /// * `path` - Le chemin HTTP (ex: `/service/AVTransport/event`) - /// * `handler` - Un pointeur de fonction avec accès complet à la requête - /// * `state` - L'état partagé (typiquement une `ServiceInstance`) - /// - /// # Retour - /// - /// Une future qui se résout quand le handler est enregistré. - /// - /// # Exemple - /// - /// ```rust,no_run - /// use pmoupnp::{UpnpServer, server::{Response, HeaderMap, Request}}; - /// use axum::extract::State; - /// use std::pin::Pin; - /// use std::future::Future; - /// - /// fn event_handler( - /// State(service): State, - /// headers: HeaderMap, - /// req: Request, - /// ) -> Pin + Send>> { - /// Box::pin(async move { - /// // Traiter SUBSCRIBE/UNSUBSCRIBE - /// axum::response::Response::default() - /// }) - /// } - /// - /// # async fn example(mut server: S) { - /// server.add_handler_with_state( - /// "/event", - /// event_handler, - /// "ServiceName".to_string(), - /// ).await; - /// # } - /// ``` - fn add_handler_with_state( - &mut self, - path: &str, - handler: fn(axum::extract::State, HeaderMap, Request) -> Pin + Send>>, - state: S, - ) -> Pin + Send + '_>> - where - S: Clone + Send + Sync + 'static; } + +impl UpnpServer for Server { + async fn register_device(&mut self, device: Arc) -> Result,DeviceError> { + let di = device.create_instance(); + + di.register_urls(self).await?; + + Ok(di) + } +} \ No newline at end of file diff --git a/pmoupnp/src/services/service_instance.rs b/pmoupnp/src/services/service_instance.rs index de9dbae1..76796619 100644 --- a/pmoupnp/src/services/service_instance.rs +++ b/pmoupnp/src/services/service_instance.rs @@ -304,7 +304,7 @@ impl ServiceInstance { /// # Errors /// /// Retourne une erreur si l'enregistrement des routes échoue. - pub async fn register_urls(&self, server: &mut S) -> Result<(), ServiceError> { + pub async fn register_urls(&self, server: &mut pmoserver::Server) -> Result<(), ServiceError> { let device = self.device.read().unwrap(); let device_name = device.as_ref().map(|d| d.get_name().clone()).unwrap_or_else(|| "unknown".to_string()); let server_url = device.as_ref().map(|d| d.base_url().to_string()).unwrap_or_default(); diff --git a/pmoutils/Cargo.toml b/pmoutils/Cargo.toml index 439ebc85..e971e145 100644 --- a/pmoutils/Cargo.toml +++ b/pmoutils/Cargo.toml @@ -4,4 +4,5 @@ version = "0.1.0" edition = "2024" [dependencies] -get_if_addrs = "0.5.3" \ No newline at end of file +get_if_addrs = "0.5.3" +os_info = "3.8" \ No newline at end of file diff --git a/pmoutils/src/lib.rs b/pmoutils/src/lib.rs index e578b603..68b03512 100644 --- a/pmoutils/src/lib.rs +++ b/pmoutils/src/lib.rs @@ -17,4 +17,36 @@ /// ``` mod ip_utils; -pub use ip_utils::guess_local_ip; \ No newline at end of file +pub use ip_utils::guess_local_ip; + +/// Retourne une chaîne décrivant le système d'exploitation et sa version. +/// +/// Utilise la crate `os_info` pour obtenir de manière portable et fiable +/// les informations sur le système d'exploitation courant. +/// +/// # Format +/// - macOS: "macOS/15.1" ou "Mac OS/10.15.7" +/// - Linux: "Linux/6.5.0" ou "Ubuntu/22.04" +/// - Windows: "Windows/10.0.19045" +/// - Autre: "{OS}/Unknown" +/// +/// # Exemples +/// +/// ``` +/// use pmoutils::get_os_string; +/// +/// let os = get_os_string(); +/// println!("OS: {}", os); // Ex: "Linux/6.5.0" +/// ``` +pub fn get_os_string() -> String { + let info = os_info::get(); + let os_type = format!("{:?}", info.os_type()); + + // Obtenir la version si disponible + let version = info.version(); + if version != &os_info::Version::Unknown { + format!("{}/{}", os_type, version) + } else { + format!("{}/Unknown", os_type) + } +} \ No newline at end of file From 9bf5bc7fe2695e89ff7801c45380e2d6e108b3b5 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 7 Oct 2025 08:33:06 +0200 Subject: [PATCH 5/7] Ajoute pmocovers --- .DS_Store | Bin 8196 -> 10244 bytes .gitignore | 3 +- .pmomusic.yml | 8 +- Cargo.lock | 899 +++++++++++++++++- Cargo.toml | 2 +- PMOMusic/Cargo.toml | 1 + PMOMusic/src/main.rs | 14 + pmoapp/webapp/src/App.vue | 3 +- .../src/components/CoverCacheManager.vue | 518 ++++++++++ pmoapp/webapp/src/router/index.ts | 2 + pmoapp/webapp/src/services/coverCache.ts | 120 +++ pmoconfig/src/lib.rs | 26 +- pmoconfig/src/pmomusic.yaml | 2 +- pmocovers/Cargo.toml | 39 + pmocovers/src/api.rs | 311 ++++++ pmocovers/src/cache.rs | 145 +++ pmocovers/src/db.rs | 118 +++ pmocovers/src/lib.rs | 277 ++++++ pmocovers/src/openapi.rs | 70 ++ pmocovers/src/pmoserver_impl.rs | 166 ++++ pmocovers/src/webp.rs | 61 ++ pmoserver/src/server.rs | 121 ++- 22 files changed, 2850 insertions(+), 56 deletions(-) create mode 100644 pmoapp/webapp/src/components/CoverCacheManager.vue create mode 100644 pmoapp/webapp/src/services/coverCache.ts create mode 100644 pmocovers/Cargo.toml create mode 100644 pmocovers/src/api.rs create mode 100644 pmocovers/src/cache.rs create mode 100644 pmocovers/src/db.rs create mode 100644 pmocovers/src/lib.rs create mode 100644 pmocovers/src/openapi.rs create mode 100644 pmocovers/src/pmoserver_impl.rs create mode 100644 pmocovers/src/webp.rs diff --git a/.DS_Store b/.DS_Store index 1f4aa30355072a8c3a778ef5a052a4de54b88f51..fb6c2e8dccd347683ecbe6d4668e312775538183 100644 GIT binary patch delta 269 zcmZp1XbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~50$f&q6U^hRb;$$8H2UZSt4sMRg zfdXNS9Fs2y`fvU&u!wQE>h>?+Pa-@j%=9xk}88("/app").await; diff --git a/pmoapp/webapp/src/App.vue b/pmoapp/webapp/src/App.vue index f8a79926..db750d1a 100644 --- a/pmoapp/webapp/src/App.vue +++ b/pmoapp/webapp/src/App.vue @@ -2,7 +2,8 @@
diff --git a/pmoapp/webapp/src/components/CoverCacheManager.vue b/pmoapp/webapp/src/components/CoverCacheManager.vue new file mode 100644 index 00000000..5f43a341 --- /dev/null +++ b/pmoapp/webapp/src/components/CoverCacheManager.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/pmoapp/webapp/src/router/index.ts b/pmoapp/webapp/src/router/index.ts index 8cb9c97e..7aaaebc6 100644 --- a/pmoapp/webapp/src/router/index.ts +++ b/pmoapp/webapp/src/router/index.ts @@ -1,10 +1,12 @@ import { createRouter, createWebHistory } from "vue-router"; import HelloWorld from "../components/HelloWorld.vue"; import LogView from "../components/LogView.vue"; +import CoverCacheManager from "../components/CoverCacheManager.vue"; const routes = [ { path: "/", name: "home", component: HelloWorld }, { path: "/logs", name: "logs", component: LogView }, + { path: "/covers-cache", name: "covers-cache", component: CoverCacheManager }, ]; const router = createRouter({ diff --git a/pmoapp/webapp/src/services/coverCache.ts b/pmoapp/webapp/src/services/coverCache.ts new file mode 100644 index 00000000..96c36d0d --- /dev/null +++ b/pmoapp/webapp/src/services/coverCache.ts @@ -0,0 +1,120 @@ +/** + * Service API pour interagir avec le cache d'images de couvertures + */ + +export interface CacheEntry { + pk: string; + source_url: string; + hits: number; + last_used: string | null; +} + +export interface AddImageRequest { + url: string; +} + +export interface AddImageResponse { + pk: string; + url: string; + message: string; +} + +export interface ApiError { + error: string; + message: string; +} + +/** + * Liste toutes les images en cache + */ +export async function listImages(): Promise { + const response = await fetch("/api/covers/images"); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch images"); + } + return response.json(); +} + +/** + * Récupère les informations d'une image spécifique + */ +export async function getImageInfo(pk: string): Promise { + const response = await fetch(`/api/covers/images/${pk}`); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch image info"); + } + return response.json(); +} + +/** + * Ajoute une nouvelle image au cache depuis une URL + */ +export async function addImage(url: string): Promise { + const response = await fetch("/api/covers/images", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ url }), + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to add image"); + } + return response.json(); +} + +/** + * Supprime une image du cache + */ +export async function deleteImage(pk: string): Promise { + const response = await fetch(`/api/covers/images/${pk}`, { + method: "DELETE", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to delete image"); + } +} + +/** + * Purge complètement le cache + */ +export async function purgeCache(): Promise { + const response = await fetch("/api/covers/images", { + method: "DELETE", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to purge cache"); + } +} + +/** + * Consolide le cache (re-télécharge les images manquantes) + */ +export async function consolidateCache(): Promise { + const response = await fetch("/api/covers/images/consolidate", { + method: "POST", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to consolidate cache"); + } +} + +/** + * Génère l'URL pour afficher une image + */ +export function getImageUrl(pk: string, size?: number): string { + if (size) { + return `/covers/images/${pk}/${size}`; + } + return `/covers/images/${pk}`; +} diff --git a/pmoconfig/src/lib.rs b/pmoconfig/src/lib.rs index 580ed071..1633c7d8 100644 --- a/pmoconfig/src/lib.rs +++ b/pmoconfig/src/lib.rs @@ -40,10 +40,13 @@ impl Clone for Config { } impl Config { + pub fn load_config(filename: &str) -> Result { let mut path = filename.to_string(); let mut data: Option> = None; + let mut default_value: Value = serde_yaml::from_str(DEFAULT_CONFIG)?; + // Essayer de charger depuis différents emplacements if !filename.is_empty() { info!(config_file=%path, "Trying to load config"); @@ -97,8 +100,11 @@ impl Config { DEFAULT_CONFIG.as_bytes().to_vec() }; - let mut config_value: Value = serde_yaml::from_slice(&yaml_data)?; - config_value = Self::lower_keys_value(config_value); + + let external_value: Value = serde_yaml::from_slice(&yaml_data)?; + merge_yaml(&mut default_value, &external_value); + let mut config_value = Self::lower_keys_value(default_value); + Self::apply_env_overrides(&mut config_value); if path.is_empty() || !Self::is_writable(&path) { @@ -175,8 +181,10 @@ impl Config { fn get_value_internal(data: &Value, path: &[&str]) -> Result { let mut current = data; for (i, key) in path.iter().enumerate() { + if let Value::Mapping(map) = current { let key = key.to_lowercase(); + if let Some(next) = map.get(&Value::String(key)) { current = next; } else { @@ -317,3 +325,17 @@ impl Config { pub fn get_config() -> Arc { CONFIG.clone() } + +fn merge_yaml(default: &mut Value, external: &Value) { + match (default, external) { + (Value::Mapping(dmap), Value::Mapping(emap)) => { + for (k, v) in emap { + match dmap.get_mut(k) { + Some(dv) => merge_yaml(dv, v), + None => { dmap.insert(k.clone(), v.clone()); } + } + } + } + (d, e) => *d = e.clone(), // pour les scalaires ou séquences, on remplace + } +} diff --git a/pmoconfig/src/pmomusic.yaml b/pmoconfig/src/pmomusic.yaml index c2acfad7..047af744 100644 --- a/pmoconfig/src/pmomusic.yaml +++ b/pmoconfig/src/pmomusic.yaml @@ -1,6 +1,6 @@ host: http_port: "8080" - cover_cache: + cover_cache: directory: "./.pmomusic_covers" size: 2000 devices: diff --git a/pmocovers/Cargo.toml b/pmocovers/Cargo.toml new file mode 100644 index 00000000..feeca835 --- /dev/null +++ b/pmocovers/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "pmocovers" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Gestion d'images +image = "0.25" +webp = "0.3" + +# Base de données +rusqlite = { version = "0.32", features = ["bundled"] } + +# HTTP client +reqwest = { version = "0.12", features = ["blocking"] } + +# Cryptographie +sha1 = "0.10" +hex = "0.4" + +# Utilitaires +anyhow = "1.0" +chrono = "0.4" +serde = { version = "1.0", features = ["derive"] } + +# Async +tokio = { version = "1.0", features = ["full"] } + +# Serveur HTTP (optionnel pour l'extension) +pmoserver = { path = "../pmoserver", optional = true } +pmoconfig = { path = "../pmoconfig", optional = true } +axum = { version = "0.8", optional = true } +utoipa = { version = "5.3", features = ["axum_extras"], optional = true } + +tracing = "0.1.41" + +[features] +default = ["pmoserver"] +pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa"] diff --git a/pmocovers/src/api.rs b/pmocovers/src/api.rs new file mode 100644 index 00000000..84a31330 --- /dev/null +++ b/pmocovers/src/api.rs @@ -0,0 +1,311 @@ +//! API REST pour la gestion du cache de couvertures +//! +//! Ce module expose une API REST documentée avec OpenAPI/Swagger pour : +//! - Lister les images en cache +//! - Ajouter des images depuis une URL +//! - Supprimer des images +//! - Consulter les statistiques + +use crate::{Cache, CacheEntry}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use utoipa::ToSchema; + +/// Requête pour ajouter une image au cache +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AddImageRequest { + /// URL de l'image source + #[schema(example = "https://example.com/cover.jpg")] + pub url: String, +} + +/// Réponse après ajout d'une image +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AddImageResponse { + /// Clé primaire (pk) de l'image ajoutée + #[schema(example = "1a2b3c4d5e6f7a8b")] + pub pk: String, + /// URL source de l'image + #[schema(example = "https://example.com/cover.jpg")] + pub url: String, + /// Message de succès + #[schema(example = "Image added successfully")] + pub message: String, +} + +/// Réponse de suppression d'une image +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct DeleteImageResponse { + /// Message de succès + #[schema(example = "Image deleted successfully")] + pub message: String, +} + +/// Réponse d'erreur générique +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ErrorResponse { + /// Code d'erreur + #[schema(example = "NOT_FOUND")] + pub error: String, + /// Message descriptif + #[schema(example = "Image not found in cache")] + pub message: String, +} + +/// Liste toutes les images en cache avec leurs statistiques +/// +/// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant. +#[utoipa::path( + get, + path = "/api/covers", + responses( + (status = 200, description = "Liste des images en cache", body = Vec), + (status = 500, description = "Erreur serveur", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn list_images(State(cache): State>) -> impl IntoResponse { + match cache.db.get_all() { + Ok(entries) => (StatusCode::OK, Json(entries)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "DATABASE_ERROR".to_string(), + message: format!("Cannot retrieve cache entries: {}", e), + }), + ) + .into_response(), + } +} + +/// Récupère les informations d'une image spécifique +/// +/// Retourne les métadonnées d'une image identifiée par sa clé (pk). +#[utoipa::path( + get, + path = "/api/covers/{pk}", + params( + ("pk" = String, Path, description = "Clé primaire de l'image", example = "1a2b3c4d5e6f7a8b") + ), + responses( + (status = 200, description = "Informations de l'image", body = CacheEntry), + (status = 404, description = "Image non trouvée", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn get_image_info( + State(cache): State>, + Path(pk): Path, +) -> impl IntoResponse { + match cache.db.get(&pk) { + Ok(entry) => (StatusCode::OK, Json(entry)).into_response(), + Err(_) => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "NOT_FOUND".to_string(), + message: format!("Image with pk '{}' not found in cache", pk), + }), + ) + .into_response(), + } +} + +/// Ajoute une image au cache depuis une URL +/// +/// Télécharge l'image depuis l'URL fournie, la convertit en WebP et l'ajoute au cache. +/// Si l'image existe déjà, elle est mise à jour. +#[utoipa::path( + post, + path = "/api/covers", + request_body = AddImageRequest, + responses( + (status = 201, description = "Image ajoutée avec succès", body = AddImageResponse), + (status = 400, description = "Requête invalide", body = ErrorResponse), + (status = 500, description = "Erreur lors du téléchargement ou de la conversion", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn add_image( + State(cache): State>, + Json(req): Json, +) -> impl IntoResponse { + if req.url.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "INVALID_REQUEST".to_string(), + message: "URL cannot be empty".to_string(), + }), + ) + .into_response(); + } + + match cache.add_from_url(&req.url).await { + Ok(pk) => ( + StatusCode::CREATED, + Json(AddImageResponse { + pk, + url: req.url, + message: "Image added successfully".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "PROCESSING_ERROR".to_string(), + message: format!("Cannot add image: {}", e), + }), + ) + .into_response(), + } +} + +/// Supprime une image du cache +/// +/// Supprime l'image et toutes ses variantes du disque et de la base de données. +#[utoipa::path( + delete, + path = "/api/covers/{pk}", + params( + ("pk" = String, Path, description = "Clé primaire de l'image à supprimer", example = "1a2b3c4d5e6f7a8b") + ), + responses( + (status = 200, description = "Image supprimée avec succès", body = DeleteImageResponse), + (status = 404, description = "Image non trouvée", body = ErrorResponse), + (status = 500, description = "Erreur lors de la suppression", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn delete_image( + State(cache): State>, + Path(pk): Path, +) -> impl IntoResponse { + // Vérifier que l'image existe + if cache.db.get(&pk).is_err() { + return ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "NOT_FOUND".to_string(), + message: format!("Image with pk '{}' not found in cache", pk), + }), + ) + .into_response(); + } + + // Supprimer les fichiers (original + variantes) + let orig_path = cache.dir.join(format!("{}.orig.webp", pk)); + if orig_path.exists() { + if let Err(e) = tokio::fs::remove_file(&orig_path).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "FILE_DELETE_ERROR".to_string(), + message: format!("Cannot delete original file: {}", e), + }), + ) + .into_response(); + } + } + + // Supprimer toutes les variantes (*.{pk}.*.webp) + if let Ok(mut entries) = tokio::fs::read_dir(&cache.dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if let Some(filename) = entry.file_name().to_str() { + if filename.starts_with(&pk) && filename.ends_with(".webp") && filename != format!("{}.orig.webp", pk) { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } + } + + // Supprimer de la base de données + match cache.db.delete(&pk) { + Ok(_) => ( + StatusCode::OK, + Json(DeleteImageResponse { + message: format!("Image '{}' deleted successfully", pk), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "DATABASE_ERROR".to_string(), + message: format!("Cannot delete from database: {}", e), + }), + ) + .into_response(), + } +} + +/// Purge complètement le cache +/// +/// Supprime toutes les images et vide la base de données. Opération irréversible. +#[utoipa::path( + delete, + path = "/api/covers", + responses( + (status = 200, description = "Cache purgé avec succès", body = DeleteImageResponse), + (status = 500, description = "Erreur lors de la purge", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn purge_cache(State(cache): State>) -> impl IntoResponse { + match cache.purge().await { + Ok(_) => ( + StatusCode::OK, + Json(DeleteImageResponse { + message: "Cache purged successfully".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "PURGE_ERROR".to_string(), + message: format!("Cannot purge cache: {}", e), + }), + ) + .into_response(), + } +} + +/// Consolide le cache +/// +/// Re-télécharge les images manquantes et supprime les fichiers orphelins. +/// Utile pour réparer un cache corrompu. +#[utoipa::path( + post, + path = "/api/covers/consolidate", + responses( + (status = 200, description = "Cache consolidé avec succès", body = DeleteImageResponse), + (status = 500, description = "Erreur lors de la consolidation", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn consolidate_cache(State(cache): State>) -> impl IntoResponse { + match cache.consolidate().await { + Ok(_) => ( + StatusCode::OK, + Json(DeleteImageResponse { + message: "Cache consolidated successfully".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "CONSOLIDATE_ERROR".to_string(), + message: format!("Cannot consolidate cache: {}", e), + }), + ) + .into_response(), + } +} diff --git a/pmocovers/src/cache.rs b/pmocovers/src/cache.rs new file mode 100644 index 00000000..424ce87b --- /dev/null +++ b/pmocovers/src/cache.rs @@ -0,0 +1,145 @@ +use std::path::PathBuf; +use std::sync::Arc; +use anyhow::{anyhow, Result}; +use sha1::{Sha1, Digest}; +use tokio::sync::Mutex; +use crate::db::DB; +use crate::webp; + +#[derive(Debug)] +pub struct Cache { + pub(crate) dir: PathBuf, + pub(crate) limit: usize, + pub db: DB, + mu: Arc>, +} + +impl Cache { + pub fn new(dir: &str, limit: usize) -> Result { + std::fs::create_dir_all(dir)?; + let db = DB::init(&PathBuf::from(dir).join("cache.db"))?; + + Ok(Self { + dir: PathBuf::from(dir), + limit, + db, + mu: Arc::new(Mutex::new(())), + }) + } + + pub async fn add_from_url(&self, url: &str) -> Result { + let response = reqwest::get(url).await?; + if !response.status().is_success() { + return Err(anyhow!("Bad status: {}", response.status())); + } + + let data = response.bytes().await?; + self.add(url, &data).await + } + + pub async fn ensure_from_url(&self, url: &str) -> Result { + let pk = pk_from_url(url); + + if self.db.get(&pk).is_ok() { + let orig_path = self.dir.join(format!("{}.orig.webp", pk)); + if orig_path.exists() { + return Ok(pk); + } + } + + self.add_from_url(url).await + } + + pub async fn add(&self, url: &str, data: &[u8]) -> Result { + let pk = pk_from_url(url); + let orig_path = self.dir.join(format!("{}.orig.webp", pk)); + + let _lock = self.mu.lock().await; + + if !orig_path.exists() { + let img = image::load_from_memory(data)?; + let webp_data = webp::encode_webp(&img)?; + tokio::fs::write(&orig_path, webp_data).await?; + } + + self.db.add(&pk, url)?; + Ok(pk) + } + + pub async fn get(&self, pk: &str) -> Result { + let _lock = self.mu.lock().await; + + self.db.get(pk)?; + self.db.update_hit(pk)?; + + let orig_path = self.dir.join(format!("{}.orig.webp", pk)); + if orig_path.exists() { + Ok(orig_path) + } else { + Err(anyhow!("File not found")) + } + } + + pub async fn purge(&self) -> Result<()> { + let _lock = self.mu.lock().await; + + let mut entries = tokio::fs::read_dir(&self.dir).await?; + while let Some(entry) = entries.next_entry().await? { + if entry.path().is_file() { + tokio::fs::remove_file(entry.path()).await?; + } + } + + self.db.purge().map_err(|e| anyhow!("Database error: {}", e)) + } + + pub async fn consolidate(&self) -> Result<()> { + let _lock = self.mu.lock().await; + + let entries = self.db.get_all()?; + + for entry in entries { + let orig_path = self.dir.join(format!("{}.orig.webp", entry.pk)); + if !orig_path.exists() { + match reqwest::get(&entry.source_url).await { + Ok(response) if response.status().is_success() => { + let data = response.bytes().await?; + self.add(&entry.source_url, &data).await?; + } + _ => { + self.db.delete(&entry.pk)?; + } + } + } + } + + let mut dir_entries = tokio::fs::read_dir(&self.dir).await?; + while let Some(entry) = dir_entries.next_entry().await? { + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name.ends_with(".orig.webp") { + let pk = file_name.trim_end_matches(".orig.webp"); + if self.db.get(pk).is_err() { + tokio::fs::remove_file(path).await?; + } + } + } + } + } + + Ok(()) + } + + pub fn cache_dir(&self) -> String { + self.dir.to_string_lossy().to_string() + } + +} + +fn pk_from_url(url: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(url.as_bytes()); + let result = hasher.finalize(); + hex::encode(&result[..8]) +} \ No newline at end of file diff --git a/pmocovers/src/db.rs b/pmocovers/src/db.rs new file mode 100644 index 00000000..c7de1120 --- /dev/null +++ b/pmocovers/src/db.rs @@ -0,0 +1,118 @@ +use rusqlite::{Connection, params}; +use serde::Serialize; +use chrono::Utc; +use std::path::Path; +use std::sync::Mutex; + +#[cfg(feature = "pmoserver")] +use utoipa::ToSchema; + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "pmoserver", derive(ToSchema))] +pub struct CacheEntry { + /// Clé primaire unique de l'image (hash SHA1 de l'URL) + #[cfg_attr(feature = "pmoserver", schema(example = "1a2b3c4d5e6f7a8b"))] + pub pk: String, + /// URL source de l'image + #[cfg_attr(feature = "pmoserver", schema(example = "https://example.com/cover.jpg"))] + pub source_url: String, + /// Nombre d'accès à l'image + #[cfg_attr(feature = "pmoserver", schema(example = 42))] + pub hits: i32, + /// Date/heure du dernier accès (RFC3339) + #[cfg_attr(feature = "pmoserver", schema(example = "2025-01-15T10:30:00Z"))] + pub last_used: Option, +} + +#[derive(Debug)] +pub struct DB { + conn: Mutex, +} + +impl DB { + pub fn init(path: &Path) -> Result { + let conn = Connection::open(path)?; + conn.execute( + "CREATE TABLE IF NOT EXISTS covers ( + pk TEXT PRIMARY KEY, + source_url TEXT, + hits INTEGER DEFAULT 0, + last_used TEXT + )", + [], + )?; + + Ok(Self { conn: Mutex::new(conn) }) + } + + pub fn add(&self, pk: &str, url: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO covers (pk, source_url, hits, last_used) + VALUES (?1, ?2, 0, ?3) + ON CONFLICT(pk) DO UPDATE SET + source_url = excluded.source_url, + last_used = excluded.last_used", + params![pk, url, Utc::now().to_rfc3339()], + )?; + + Ok(()) + } + + pub fn get(&self, pk: &str) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT pk, source_url, hits, last_used FROM covers WHERE pk = ?1", + [pk], + |row| { + Ok(CacheEntry { + pk: row.get(0)?, + source_url: row.get(1)?, + hits: row.get(2)?, + last_used: row.get(3)?, + }) + }, + ) + } + + pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE covers SET hits = hits + 1, last_used = ?1 WHERE pk = ?2", + params![Utc::now().to_rfc3339(), pk], + )?; + + Ok(()) + } + + pub fn purge(&self) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM covers", [])?; + Ok(()) + } + + pub fn get_all(&self) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT pk, source_url, hits, last_used FROM covers ORDER BY hits DESC", + )?; + + let entries = stmt.query_map([], |row| { + Ok(CacheEntry { + pk: row.get(0)?, + source_url: row.get(1)?, + hits: row.get(2)?, + last_used: row.get(3)?, + }) + })? + .collect::>>()?; + + Ok(entries) + } + + pub fn delete(&self, pk: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM covers WHERE pk = ?1", [pk])?; + Ok(()) + } +} diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs new file mode 100644 index 00000000..ba64c6a5 --- /dev/null +++ b/pmocovers/src/lib.rs @@ -0,0 +1,277 @@ +//! # pmocovers - Service de cache d'images de couvertures pour PMOMusic +//! +//! Cette crate fournit un système de cache d'images optimisé pour les couvertures d'albums, +//! avec conversion automatique en WebP et génération de variantes de tailles. +//! +//! ## Vue d'ensemble +//! +//! `pmocovers` gère le téléchargement, la conversion, le stockage et la distribution +//! d'images de couvertures d'albums, avec : +//! - Conversion automatique en WebP pour réduire la taille +//! - Génération de variantes de tailles à la demande +//! - Cache persistant avec base de données SQLite +//! - API HTTP pour récupérer les images +//! +//! ## Fonctionnalités +//! +//! ### 📦 Gestion du cache +//! - Téléchargement automatique depuis des URLs +//! - Conversion des images en WebP (format optimisé) +//! - Stockage persistant sur disque +//! - Base de données SQLite pour le tracking +//! +//! ### 🎨 Génération de variantes +//! - Redimensionnement automatique à la demande +//! - Création d'images carrées avec centrage +//! - Cache des variantes générées +//! - Support de multiples tailles +//! +//! ### 📊 Statistiques d'utilisation +//! - Comptage des accès (hits) +//! - Suivi de la dernière utilisation +//! - API de statistiques complètes +//! +//! ## Architecture +//! +//! `pmocovers` suit le pattern d'extension des autres crates PMO : +//! +//! - `pmoserver` définit un serveur HTTP générique +//! - `pmocovers` étend ce serveur avec des méthodes de cache via un trait +//! - Le serveur n'a pas besoin de connaître `pmocovers` +//! +//! ## Structure des fichiers +//! +//! ```text +//! pmocovers/ +//! ├── Cargo.toml +//! ├── src/ +//! │ ├── lib.rs # Module principal (ce fichier) +//! │ ├── cache.rs # Gestion du cache +//! │ ├── db.rs # Base de données SQLite +//! │ ├── webp.rs # Conversion et redimensionnement WebP +//! │ └── pmoserver_impl.rs # Extension de pmoserver::Server +//! └── cache/ # Répertoire de cache (généré) +//! ├── cache.db # Base SQLite +//! ├── *.orig.webp # Images originales +//! └── *.{size}.webp # Variantes de tailles +//! ``` +//! +//! ## Utilisation +//! +//! ### Exemple basique avec configuration automatique +//! +//! ```rust,no_run +//! use pmocovers::CoverCacheExt; +//! use pmoserver::ServerBuilder; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new_configured().build(); +//! +//! // Utilise automatiquement la config (pmoconfig) +//! server.init_cover_cache_configured().await?; +//! +//! server.start().await; +//! server.wait().await; +//! Ok(()) +//! } +//! ``` +//! +//! ### Exemple avec paramètres personnalisés +//! +//! ```rust,no_run +//! use pmocovers::CoverCacheExt; +//! use pmoserver::ServerBuilder; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build(); +//! +//! // Paramètres personnalisés +//! server.init_cover_cache("./cache", 1000).await?; +//! +//! server.start().await; +//! server.wait().await; +//! Ok(()) +//! } +//! ``` +//! +//! ### Utilisation du cache directement +//! +//! ```rust,no_run +//! use pmocovers::Cache; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let cache = Cache::new("./cache", 1000)?; +//! +//! // Ajouter une image depuis une URL +//! let pk = cache.add_from_url("http://example.com/cover.jpg").await?; +//! println!("Image ajoutée avec clé: {}", pk); +//! +//! // Récupérer l'image originale +//! let path = cache.get(&pk).await?; +//! println!("Image stockée à: {:?}", path); +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## API HTTP +//! +//! Une fois enregistré sur un serveur via `CoverCacheExt`, les endpoints suivants sont disponibles : +//! +//! ### GET /covers/images/{pk} +//! Récupère l'image originale en WebP +//! +//! ### GET /covers/images/{pk}/{size} +//! Récupère une variante de taille spécifique (ex: `/covers/images/abc123/256`) +//! +//! ### GET /covers/stats +//! Récupère les statistiques du cache (JSON) +//! +//! ## Format des clés (pk) +//! +//! Les images sont identifiées par une clé (pk) dérivée de l'URL source : +//! - Hash SHA1 de l'URL +//! - Encodé en hexadécimal (8 premiers octets) +//! - Exemple: `"1a2b3c4d5e6f7a8b"` +//! +//! ## Stockage +//! +//! Les fichiers sont organisés comme suit : +//! +//! ```text +//! cache/ +//! ├── cache.db # Base SQLite +//! ├── 1a2b3c4d.orig.webp # Image originale +//! ├── 1a2b3c4d.256.webp # Variante 256x256 +//! └── 1a2b3c4d.512.webp # Variante 512x512 +//! ``` +//! +//! ## Opérations de maintenance +//! +//! ### Purge du cache +//! +//! ```rust,no_run +//! # use pmocovers::Cache; +//! # async fn example(cache: &Cache) -> anyhow::Result<()> { +//! // Supprimer tous les fichiers et entrées DB +//! cache.purge().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Consolidation du cache +//! +//! ```rust,no_run +//! # use pmocovers::Cache; +//! # async fn example(cache: &Cache) -> anyhow::Result<()> { +//! // Re-télécharger les images manquantes et supprimer les orphelins +//! cache.consolidate().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Dépendances principales +//! +//! - `image` : Chargement et manipulation d'images +//! - `webp` : Encodage WebP +//! - `rusqlite` : Base de données SQLite +//! - `reqwest` : Téléchargement HTTP +//! - `sha1` : Génération de clés +//! +//! ## Voir aussi +//! +//! - [`pmoserver`] : Serveur HTTP Axum +//! - [`pmoapp`] : Application web frontend +//! - [`pmoupnp`] : Bibliothèque UPnP MediaRenderer + +pub mod cache; +pub mod db; +pub mod webp; + +#[cfg(feature = "pmoserver")] +pub mod api; + +#[cfg(feature = "pmoserver")] +pub mod openapi; + +pub use cache::Cache; +pub use db::{CacheEntry, DB}; + +#[cfg(feature = "pmoserver")] +pub use openapi::ApiDoc; + +use anyhow::Result; +use std::sync::Arc; + +/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache d'images. +/// +/// Ce trait permet à `pmocovers` d'ajouter des méthodes d'extension sur des types +/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmocovers`. +/// +/// # Architecture +/// +/// Similaire au pattern utilisé par `pmoapp` pour `WebAppExt`, ce trait permet +/// une extension propre et découplée : +/// +/// - `pmoserver` définit un serveur HTTP générique +/// - `pmocovers` étend ce serveur avec des méthodes de cache via ce trait +/// - Le serveur n'a pas besoin de connaître `pmocovers` +pub trait CoverCacheExt { + /// Initialise le cache d'images et enregistre les routes HTTP. + /// + /// # Arguments + /// + /// * `cache_dir` - Répertoire de stockage du cache + /// * `limit` - Limite de taille du cache (en nombre d'images) + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du cache + /// + /// # Routes enregistrées + /// + /// - `GET /covers/images/{pk}` - Image originale + /// - `GET /covers/images/{pk}/{size}` - Variante de taille + /// - `GET /covers/stats` - Statistiques + /// - `GET /api/covers` - Liste des images (API REST) + /// - `POST /api/covers` - Ajouter une image (API REST) + /// - `DELETE /api/covers/{pk}` - Supprimer une image (API REST) + /// - `GET /swagger-ui` - Documentation interactive + async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> Result>; + + /// Initialise le cache d'images avec la configuration par défaut. + /// + /// Utilise automatiquement les paramètres de `pmoconfig::Config` : + /// - `host.cover_cache.directory` pour le répertoire + /// - `host.cover_cache.size` pour la limite de taille + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du cache + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmocovers::CoverCacheExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // Utilise automatiquement la config + /// server.init_cover_cache_configured().await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + async fn init_cover_cache_configured(&mut self) -> Result>; +} + +// Implémentation du trait pour pmoserver::Server (feature-gated) +#[cfg(feature = "pmoserver")] +mod pmoserver_impl; diff --git a/pmocovers/src/openapi.rs b/pmocovers/src/openapi.rs new file mode 100644 index 00000000..a86cfd0b --- /dev/null +++ b/pmocovers/src/openapi.rs @@ -0,0 +1,70 @@ +//! Documentation OpenAPI pour l'API REST du cache de couvertures + +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::api::list_images, + crate::api::get_image_info, + crate::api::add_image, + crate::api::delete_image, + crate::api::purge_cache, + crate::api::consolidate_cache, + ), + components( + schemas( + crate::db::CacheEntry, + crate::api::AddImageRequest, + crate::api::AddImageResponse, + crate::api::DeleteImageResponse, + crate::api::ErrorResponse, + ) + ), + tags( + (name = "covers", description = "Gestion du cache d'images de couvertures") + ), + info( + title = "PMOCovers API", + version = "0.1.0", + description = r#" +# API de gestion du cache d'images de couvertures + +Cette API permet de gérer un cache d'images optimisé pour les couvertures d'albums. + +## Fonctionnalités + +- **Ajout d'images** : Téléchargement depuis une URL avec conversion automatique en WebP +- **Consultation** : Liste des images avec statistiques d'utilisation +- **Suppression** : Suppression individuelle ou purge complète +- **Maintenance** : Consolidation du cache pour réparer les incohérences + +## Format des images + +Les images sont stockées au format WebP avec : +- Une version originale (`{pk}.orig.webp`) +- Des variantes de tailles générées à la demande (`{pk}.{size}.webp`) + +## Clés (pk) + +Chaque image est identifiée par une clé (pk) unique : +- Hash SHA1 des 8 premiers octets de l'URL source +- Encodage hexadécimal +- Exemple : `1a2b3c4d5e6f7a8b` + +## Statistiques + +Le système suit automatiquement : +- Le nombre d'accès (hits) +- La date du dernier accès +- L'URL source originale + "#, + contact( + name = "PMOMusic", + ), + license( + name = "MIT", + ), + ) +)] +pub struct ApiDoc; diff --git a/pmocovers/src/pmoserver_impl.rs b/pmocovers/src/pmoserver_impl.rs new file mode 100644 index 00000000..18743e5b --- /dev/null +++ b/pmocovers/src/pmoserver_impl.rs @@ -0,0 +1,166 @@ +//! Implémentation du trait CoverCacheExt pour le serveur pmoserver +//! +//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités de cache d'images en +//! implémentant le trait [`CoverCacheExt`](crate::CoverCacheExt). Cette implémentation +//! permet d'initialiser facilement le cache et d'enregistrer les routes HTTP. +//! +//! ## Architecture +//! +//! `pmocovers` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmocovers`. +//! C'est le pattern d'extension : `pmocovers` ajoute des fonctionnalités à un type +//! externe via un trait, similaire au pattern utilisé par `pmoapp` pour `WebAppExt`. +//! +//! ## Exemple d'utilisation +//! +//! ```rust,no_run +//! use pmocovers::CoverCacheExt; +//! use pmoserver::ServerBuilder; +//! +//! # async fn example() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build(); +//! +//! // Le trait CoverCacheExt est automatiquement disponible +//! let cache = server.init_cover_cache("./cache", 1000).await?; +//! +//! server.start().await; +//! # Ok(()) +//! # } +//! ``` + +use crate::{api, Cache, CoverCacheExt}; +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use pmoserver::Server; +use tracing::{debug, info}; +use std::sync::Arc; +use utoipa::OpenApi; + + + +/// Handler pour GET /covers/images/{pk} +async fn get_cover_image( + State(cache): State>, + req: Request, +) -> Response { + // Extraire pk du path + let path = req.uri().path(); + let parts: Vec<&str> = path.split('/').collect(); + + if parts.len() < 4 { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + let pk = parts[3]; + + match cache.get(pk).await { + Ok(file_path) => { + match tokio::fs::read(&file_path).await { + Ok(data) => ( + StatusCode::OK, + [("content-type", "image/webp")], + data, + ) + .into_response(), + Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), + } + } + Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(), + } +} + +/// Handler pour GET /covers/images/{pk}/{size} +async fn get_cover_variant( + State(cache): State>, + req: Request, +) -> Response { + // Extraire pk et size du path + let path = req.uri().path(); + let parts: Vec<&str> = path.split('/').collect(); + + if parts.len() < 5 { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + let pk = parts[3]; + let size = match parts[4].parse::() { + Ok(s) => s, + Err(_) => return (StatusCode::BAD_REQUEST, "Invalid size").into_response(), + }; + + match crate::webp::generate_variant(&cache, pk, size).await { + Ok(data) => ( + StatusCode::OK, + [("content-type", "image/webp")], + data, + ) + .into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot generate variant").into_response(), + } +} + +/// Handler pour GET /covers/stats +async fn get_cover_stats(State(cache): State>) -> Response { + match cache.db.get_all() { + Ok(entries) => Json(entries).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot retrieve stats").into_response(), + } +} + +impl CoverCacheExt for Server { + async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result> { + let cache = Arc::new(Cache::new(cache_dir, limit)?); + + // Enregistrer les routes HTTP classiques + self.add_handler_with_state("/covers/images", get_cover_image, cache.clone()).await; + self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await; + + // Router API RESTful + // Router API RESTful monté sur /api/covers + let api_router = Router::new() + // Liste et ajout + .route( + "/images/", + get(api::list_images) // GET /api/covers + .post(api::add_image) // POST /api/covers + .delete(api::purge_cache), // DELETE /api/covers + ) + // Ressource unique + .route( + "/images/{pk}", + get(api::get_image_info) // GET /api/covers/{pk} + .delete(api::delete_image), // DELETE /api/covers/{pk} + ) + // Action spécifique + .route( + "/images/consolidate", + post(api::consolidate_cache), // POST /api/covers/consolidate + ) + .with_state(cache.clone()); + + // Documentation OpenAPI via Utoipa + let openapi = crate::ApiDoc::openapi(); + + // Enregistrer l'API avec Swagger UI + // /api/covers/images... et /swagger-ui/covers + self.add_openapi(api_router, openapi, "covers").await; + + Ok(cache) + } + + async fn init_cover_cache_configured(&mut self) -> anyhow::Result> { + let config = pmoconfig::get_config(); + + let cache_dir = config.get_cover_cache_dir()?; + let limit = config.get_cover_cache_size()?; + + info!("cache directory {}, size {}",cache_dir,limit); + + self.init_cover_cache(&cache_dir, limit).await + } +} diff --git a/pmocovers/src/webp.rs b/pmocovers/src/webp.rs new file mode 100644 index 00000000..42f1d7c3 --- /dev/null +++ b/pmocovers/src/webp.rs @@ -0,0 +1,61 @@ +use anyhow::Result; +use image::{DynamicImage, imageops::FilterType}; +use webp::{Encoder, WebPMemory}; + +pub fn encode_webp(img: &DynamicImage) -> Result> { + let rgb_img = img.to_rgba8(); + let encoder = Encoder::from_rgba(&rgb_img, rgb_img.width(), rgb_img.height()); + let webp_data: WebPMemory = encoder.encode(85.0); + Ok(webp_data.to_vec()) +} + +pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage { + let (width, height) = (img.width(), img.height()); + + // Calculer le ratio de mise à l'échelle + let scale = if width > height { + size as f32 / width as f32 + } else { + size as f32 / height as f32 + }; + + let new_width = (width as f32 * scale) as u32; + let new_height = (height as f32 * scale) as u32; + + // Redimensionner l'image + let resized = img.resize(new_width, new_height, FilterType::Lanczos3); + + // Créer une image carrée avec fond transparent + let mut square = DynamicImage::new_rgba8(size, size); + + // Calculer la position pour centrer l'image redimensionnée + let x = (size - new_width) / 2; + let y = (size - new_height) / 2; + + // Copier l'image redimensionnée au centre du carré + image::imageops::overlay(&mut square, &resized, x.into(), y.into()); + + square +} + +pub async fn generate_variant(cache: &super::cache::Cache, pk: &str, size: usize) -> Result> { + let variant_path = cache.dir.join(format!("{}.{}.webp", pk, size)); + + if variant_path.exists() { + return Ok(tokio::fs::read(variant_path).await?); + } + + let orig_path = cache.dir.join(format!("{}.orig.webp", pk)); + + // Charger l'image de manière synchrone (image::open n'est pas async) + let img = tokio::task::spawn_blocking(move || { + image::open(orig_path) + }) + .await??; + + let square = ensure_square(&img, size as u32); + let webp_data = encode_webp(&square)?; + + tokio::fs::write(&variant_path, &webp_data).await?; + Ok(webp_data) +} diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index 703cfa17..d22de2a0 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -13,6 +13,7 @@ //! - 📚 **Documentation API** : OpenAPI/Swagger automatique avec `add_openapi()` //! - ⚡ **Gestion gracieuse** : Arrêt propre sur Ctrl+C +use crate::logs::{LogState, LoggingOptions, init_logging, log_dump, log_sse}; use axum::handler::Handler; use axum::response::Redirect; use axum::routing::get; @@ -25,7 +26,6 @@ use std::{net::SocketAddr, sync::Arc}; use tokio::{signal, sync::RwLock, task::JoinHandle}; use tracing::info; use utoipa_swagger_ui::SwaggerUi; -use crate::logs::{LogState, LoggingOptions, init_logging, log_sse, log_dump}; /// Info serveur sérialisable #[derive(Clone, Serialize, utoipa::ToSchema)] @@ -149,7 +149,7 @@ impl Server { /// ```ignore /// use pmoupnp::server::Server; /// use rust_embed::RustEmbed; - /// + /// /// #[derive(RustEmbed, Clone)] /// #[folder = "static/"] /// struct Assets; @@ -166,9 +166,9 @@ impl Server { E: RustEmbed + Clone + Send + Sync + 'static, { let serve = ServeEmbed::::new(); - + let mut r = self.router.write().await; - + if path == "/" { *r = std::mem::take(&mut *r).fallback_service(serve); } else { @@ -226,9 +226,9 @@ impl Server { axum_embed::FallbackBehavior::Ok, Some("index.html".to_string()), ); - + let mut r = self.router.write().await; - + if path == "/" { *r = std::mem::take(&mut *r).fallback_service(serve); } else { @@ -290,18 +290,18 @@ impl Server { /// use axum::extract::State; /// use axum::response::sse::{Event, Sse, KeepAlive}; /// use tokio::sync::broadcast; - /// + /// /// #[derive(Clone)] - /// struct LogState { - /// tx: broadcast::Sender + /// struct LogState { + /// tx: broadcast::Sender /// } - /// - /// impl LogState { - /// fn subscribe(&self) -> broadcast::Receiver { - /// self.tx.subscribe() - /// } + /// + /// impl LogState { + /// fn subscribe(&self) -> broadcast::Receiver { + /// self.tx.subscribe() + /// } /// } - /// + /// /// async fn log_sse(State(state): State) -> Sse>> { /// let mut rx = state.subscribe(); /// let stream = async_stream::stream! { @@ -321,9 +321,7 @@ impl Server { T: 'static, S: Clone + Send + Sync + 'static, { - let route = Router::new() - .route("/", get(handler)) - .with_state(state); + let route = Router::new().route("/", get(handler)).with_state(state); let mut r = self.router.write().await; *r = std::mem::take(&mut *r).nest(path, route); @@ -391,14 +389,16 @@ impl Server { } } - /// Ajoute une API documentée avec OpenAPI + /// Ajoute une API documentée avec OpenAPI et Swagger UI /// - /// Monte un routeur d'API sous `/api` et active Swagger UI sur `/swagger-ui` + /// Cette méthode fusionne le `api_router` fourni avec le router principal du serveur. + /// Chaque appel peut ajouter une nouvelle API distincte, avec sa propre documentation Swagger. /// /// # Arguments /// /// * `api_router` - Router Axum contenant les routes API - /// * `openapi` - Spécification OpenAPI générée par utoipa + /// * `openapi` - Spécification OpenAPI générée par `utoipa` + /// * `name` - Nom unique pour cette API, utilisé pour différencier le chemin Swagger UI et le JSON OpenAPI /// /// # Exemple /// @@ -418,7 +418,7 @@ impl Server { /// paths(get_users), /// components(schemas(User)) /// )] - /// struct ApiDoc; + /// struct ApiDoc1; /// /// #[utoipa::path( /// get, @@ -429,24 +429,64 @@ impl Server { /// Json(vec![]) /// } /// - /// let api_router = Router::new() - /// .route("/users", get(get_users)); + /// #[derive(utoipa::OpenApi)] + /// #[openapi( + /// paths(get_products), + /// components(schemas(Product)) + /// )] + /// struct ApiDoc2; /// - /// server.add_openapi(api_router, ApiDoc::openapi()).await; + /// #[utoipa::path( + /// get, + /// path = "/products", + /// responses((status = 200, description = "List products")) + /// )] + /// async fn get_products() -> Json> { + /// Json(vec![]) + /// } + /// + /// let api_router1 = Router::new().route("/users", get(get_users)); + /// let api_router2 = Router::new().route("/products", get(get_products)); + /// + /// // Ajouter les deux API au serveur, chacune avec son nom unique + /// server.add_openapi(api_router1, ApiDoc1::openapi(), "api1").await; + /// server.add_openapi(api_router2, ApiDoc2::openapi(), "api2").await; /// ``` - pub async fn add_openapi(&mut self, api_router: Router, openapi: utoipa::openapi::OpenApi) { - // Stocker le routeur API + /// + /// Résultat : + /// + /// - `/users` et `/products` sont accessibles via Axum. + /// - `/swagger-ui/api1` et `/swagger-ui/api2` affichent la documentation Swagger correspondante. + /// - `/api-docs/api1.json` et `/api-docs/api2.json` fournissent les spécifications OpenAPI respectives. + + pub async fn add_openapi( + &mut self, + api_router: Router, + openapi: utoipa::openapi::OpenApi, + name: &str, // nom unique pour différencier Swagger et OpenAPI + ) { + use axum::routing::get; + use utoipa_swagger_ui::SwaggerUi; + + // Stocker le router API dans self.api_router si tu veux y accéder plus tard let mut api_r = self.api_router.write().await; - *api_r = Some(api_router); + *api_r = Some(api_router.clone()); - // Ajouter Swagger UI - let swagger = SwaggerUi::new("/swagger-ui") - .url("/api-docs/openapi.json", openapi); + // Générer des chemins uniques pour Swagger UI et OpenAPI JSON + let swagger_path = format!("/swagger-ui/{}", name); + let swagger_path_static: &'static str = Box::leak(swagger_path.into_boxed_str()); + let openapi_json_path = format!("/api-docs/{}.json", name); + let openapi_json_path_static: &'static str = Box::leak(openapi_json_path.into_boxed_str()); + + let swagger = SwaggerUi::new(swagger_path_static).url(openapi_json_path_static, openapi); + + // Fusionner avec le router principal let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).merge(swagger); + let mut combined = std::mem::take(&mut *r); + combined = combined.merge(api_router).merge(swagger); + *r = combined; } - /// Démarre le serveur HTTP /// /// Lance le serveur sur le port configuré et met en place la gestion @@ -465,7 +505,10 @@ impl Server { /// ``` pub async fn start(&mut self) { let addr = SocketAddr::from(([0, 0, 0, 0], self.http_port)); - info!("Server {} running at [http://{}:{}](http://{}:{})", self.name, self.base_url, self.http_port, self.base_url, self.http_port); + info!( + "Server {} running at [http://{}:{}](http://{}:{})", + self.name, self.base_url, self.http_port, self.base_url, self.http_port + ); // Merger le routeur API si présent let api_router = self.api_router.read().await; @@ -545,8 +588,10 @@ impl Server { let log_state = init_logging(options); // Enregistrer automatiquement les routes de logging - self.add_handler_with_state("/log-sse", log_sse, log_state.clone()).await; - self.add_handler_with_state("/log-dump", log_dump, log_state.clone()).await; + self.add_handler_with_state("/log-sse", log_sse, log_state.clone()) + .await; + self.add_handler_with_state("/log-dump", log_dump, log_state.clone()) + .await; self.log_state = Some(log_state); } @@ -580,7 +625,7 @@ impl ServerBuilder { Self { name: "PMO-Music-Server".to_string(), base_url: config.get_base_url(), - http_port: config.get_http_port() + http_port: config.get_http_port(), } } @@ -598,4 +643,4 @@ impl ServerBuilder { pub fn build(self) -> Server { Server::new(self.name, self.base_url, self.http_port) } -} \ No newline at end of file +} From 24be870e4a0c0bb96c6baeded9c7336da815ac96 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 7 Oct 2025 13:12:44 +0200 Subject: [PATCH 6/7] Debuggage --- pmoapp/webapp/src/services/coverCache.ts | 12 ++++----- pmocovers/src/pmoserver_impl.rs | 21 ++++++++++----- pmoserver/src/server.rs | 33 +++++++++++++++++++++--- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/pmoapp/webapp/src/services/coverCache.ts b/pmoapp/webapp/src/services/coverCache.ts index 96c36d0d..a277c097 100644 --- a/pmoapp/webapp/src/services/coverCache.ts +++ b/pmoapp/webapp/src/services/coverCache.ts @@ -28,7 +28,7 @@ export interface ApiError { * Liste toutes les images en cache */ export async function listImages(): Promise { - const response = await fetch("/api/covers/images"); + const response = await fetch("/api/covers"); if (!response.ok) { const error: ApiError = await response.json(); throw new Error(error.message || "Failed to fetch images"); @@ -40,7 +40,7 @@ export async function listImages(): Promise { * Récupère les informations d'une image spécifique */ export async function getImageInfo(pk: string): Promise { - const response = await fetch(`/api/covers/images/${pk}`); + const response = await fetch(`/api/covers/${pk}`); if (!response.ok) { const error: ApiError = await response.json(); throw new Error(error.message || "Failed to fetch image info"); @@ -52,7 +52,7 @@ export async function getImageInfo(pk: string): Promise { * Ajoute une nouvelle image au cache depuis une URL */ export async function addImage(url: string): Promise { - const response = await fetch("/api/covers/images", { + const response = await fetch("/api/covers", { method: "POST", headers: { "Content-Type": "application/json", @@ -71,7 +71,7 @@ export async function addImage(url: string): Promise { * Supprime une image du cache */ export async function deleteImage(pk: string): Promise { - const response = await fetch(`/api/covers/images/${pk}`, { + const response = await fetch(`/api/covers/${pk}`, { method: "DELETE", }); @@ -85,7 +85,7 @@ export async function deleteImage(pk: string): Promise { * Purge complètement le cache */ export async function purgeCache(): Promise { - const response = await fetch("/api/covers/images", { + const response = await fetch("/api/covers", { method: "DELETE", }); @@ -99,7 +99,7 @@ export async function purgeCache(): Promise { * Consolide le cache (re-télécharge les images manquantes) */ export async function consolidateCache(): Promise { - const response = await fetch("/api/covers/images/consolidate", { + const response = await fetch("/api/covers/consolidate", { method: "POST", }); diff --git a/pmocovers/src/pmoserver_impl.rs b/pmocovers/src/pmoserver_impl.rs index 18743e5b..c36029ab 100644 --- a/pmocovers/src/pmoserver_impl.rs +++ b/pmocovers/src/pmoserver_impl.rs @@ -116,29 +116,34 @@ impl CoverCacheExt for Server { async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result> { let cache = Arc::new(Cache::new(cache_dir, limit)?); - // Enregistrer les routes HTTP classiques - self.add_handler_with_state("/covers/images", get_cover_image, cache.clone()).await; + // Enregistrer les routes HTTP classiques pour servir les images + let image_router = Router::new() + .route("/{pk}", get(get_cover_image)) + .route("/{pk}/{size}", get(get_cover_variant)) + .with_state(cache.clone()); + + self.add_router("/covers/images", image_router).await; self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await; // Router API RESTful - // Router API RESTful monté sur /api/covers + // Router API RESTful qui sera nesté sous /api/covers par add_openapi let api_router = Router::new() // Liste et ajout .route( - "/images/", + "/", get(api::list_images) // GET /api/covers .post(api::add_image) // POST /api/covers .delete(api::purge_cache), // DELETE /api/covers ) // Ressource unique .route( - "/images/{pk}", + "/{pk}", get(api::get_image_info) // GET /api/covers/{pk} .delete(api::delete_image), // DELETE /api/covers/{pk} ) // Action spécifique .route( - "/images/consolidate", + "/consolidate", post(api::consolidate_cache), // POST /api/covers/consolidate ) .with_state(cache.clone()); @@ -147,7 +152,9 @@ impl CoverCacheExt for Server { let openapi = crate::ApiDoc::openapi(); // Enregistrer l'API avec Swagger UI - // /api/covers/images... et /swagger-ui/covers + // Le router sera nesté automatiquement sous /api/covers par add_openapi + // Routes finales: /api/covers, /api/covers/{pk}, /api/covers/consolidate + // Swagger UI sera disponible à /swagger-ui/covers self.add_openapi(api_router, openapi, "covers").await; Ok(cache) diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index d22de2a0..665ada30 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -131,6 +131,29 @@ impl Server { *r = std::mem::take(&mut *r).nest(path, route); } + /// Add a new router safely: + /// - If `path` starts with '/', it is merged at root level. + /// - Otherwise, it is nested under the given subpath. + pub async fn add_router(&mut self, path: &str, route: Router) { + let mut r = self.router.write().await; + + // Take current router without losing content + let current = std::mem::take(&mut *r); + + let combined = if path.starts_with('/') { + // Absolute path => merge directly at root + tracing::debug!("Merging router at root path: {}", path); + current.merge(route) + } else { + // Relative path => nest under the given path + let normalized = format!("/{}", path.trim_start_matches('/')); + tracing::debug!("Nesting router under: {}", normalized); + current.nest(&normalized, route) + }; + + *r = combined; + } + /// Ajoute un répertoire de fichiers statiques /// /// Sert des fichiers embarqués via `RustEmbed`. Les fichiers sont compilés @@ -455,7 +478,7 @@ impl Server { /// /// Résultat : /// - /// - `/users` et `/products` sont accessibles via Axum. + /// - `/api/api1/users` et `/api/api2/products` sont accessibles via Axum. /// - `/swagger-ui/api1` et `/swagger-ui/api2` affichent la documentation Swagger correspondante. /// - `/api-docs/api1.json` et `/api-docs/api2.json` fournissent les spécifications OpenAPI respectives. @@ -480,11 +503,15 @@ impl Server { let openapi_json_path_static: &'static str = Box::leak(openapi_json_path.into_boxed_str()); let swagger = SwaggerUi::new(swagger_path_static).url(openapi_json_path_static, openapi); - + + // Nester le router API sous /api/{name} + let base_path = format!("/api/{}", name); + let nested_router = Router::new().nest(&base_path, api_router); + // Fusionner avec le router principal let mut r = self.router.write().await; let mut combined = std::mem::take(&mut *r); - combined = combined.merge(api_router).merge(swagger); + combined = combined.merge(nested_router).merge(swagger); *r = combined; } /// Démarre le serveur HTTP From 3154c11845eda57fc14c1756194009f59b615165 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 7 Oct 2025 15:03:52 +0200 Subject: [PATCH 7/7] Correction de ChatGPT --- pmocovers/src/pmoserver_impl.rs | 14 +- pmoserver/src/server.rs | 321 ++++++++----------------- pmoupnp/src/actions/action_instance.rs | 2 - pmoupnp/src/object_set.rs | 2 +- pmoupnp/src/ssdp/server.rs | 2 +- 5 files changed, 114 insertions(+), 227 deletions(-) diff --git a/pmocovers/src/pmoserver_impl.rs b/pmocovers/src/pmoserver_impl.rs index c36029ab..bd4a5af5 100644 --- a/pmocovers/src/pmoserver_impl.rs +++ b/pmocovers/src/pmoserver_impl.rs @@ -37,7 +37,7 @@ use axum::{ Json, Router, }; use pmoserver::Server; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use std::sync::Arc; use utoipa::OpenApi; @@ -52,11 +52,13 @@ async fn get_cover_image( let path = req.uri().path(); let parts: Vec<&str> = path.split('/').collect(); - if parts.len() < 4 { + warn!("{:?}",parts); + + if parts.len() != 2 { return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); } - let pk = parts[3]; + let pk = parts[1]; match cache.get(pk).await { Ok(file_path) => { @@ -83,12 +85,12 @@ async fn get_cover_variant( let path = req.uri().path(); let parts: Vec<&str> = path.split('/').collect(); - if parts.len() < 5 { + if parts.len() != 3 { return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); } - let pk = parts[3]; - let size = match parts[4].parse::() { + let pk = parts[1]; + let size = match parts[2].parse::() { Ok(s) => s, Err(_) => return (StatusCode::BAD_REQUEST, "Invalid size").into_response(), }; diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index 665ada30..3f8f559a 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -16,13 +16,15 @@ use crate::logs::{LogState, LoggingOptions, init_logging, log_dump, log_sse}; use axum::handler::Handler; use axum::response::Redirect; -use axum::routing::get; +use axum::routing::{get, post}; use axum::{Json, Router}; use axum_embed::ServeEmbed; use pmoconfig::get_config; use rust_embed::RustEmbed; use serde::Serialize; -use std::{net::SocketAddr, sync::Arc}; +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; use tokio::{signal, sync::RwLock, task::JoinHandle}; use tracing::info; use utoipa_swagger_ui::SwaggerUi; @@ -30,11 +32,8 @@ use utoipa_swagger_ui::SwaggerUi; /// Info serveur sérialisable #[derive(Clone, Serialize, utoipa::ToSchema)] pub struct ServerInfo { - /// Nom du serveur pub name: String, - /// URL de base pub base_url: String, - /// Port HTTP pub http_port: u16, } @@ -80,8 +79,7 @@ impl Server { let config = get_config(); let url = config.get_base_url(); let port = config.get_http_port(); - - return Self::new("PMO-Music-Server", url, port); + Self::new("PMO-Music-Server", url, port) } /// Ajoute une route JSON dynamique @@ -112,11 +110,10 @@ impl Server { pub async fn add_route(&mut self, path: &str, f: F) where F: Fn() -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, + Fut: Future + Send + 'static, T: Serialize + Send + 'static, { let f = Arc::new(f); - let handler = { let f = f.clone(); move || { @@ -128,76 +125,81 @@ impl Server { let route = Router::new().route("/", get(handler)); let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).nest(path, route); - } - - /// Add a new router safely: - /// - If `path` starts with '/', it is merged at root level. - /// - Otherwise, it is nested under the given subpath. - pub async fn add_router(&mut self, path: &str, route: Router) { - let mut r = self.router.write().await; - - // Take current router without losing content - let current = std::mem::take(&mut *r); - - let combined = if path.starts_with('/') { - // Absolute path => merge directly at root - tracing::debug!("Merging router at root path: {}", path); - current.merge(route) + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) } else { - // Relative path => nest under the given path - let normalized = format!("/{}", path.trim_start_matches('/')); - tracing::debug!("Nesting router under: {}", normalized); - current.nest(&normalized, route) + std::mem::take(&mut *r).nest(path, route) }; - - *r = combined; } - - /// Ajoute un répertoire de fichiers statiques - /// - /// Sert des fichiers embarqués via `RustEmbed`. Les fichiers sont compilés - /// dans le binaire à la compilation. - /// - /// # Arguments - /// - /// * `path` - Chemin où monter les fichiers statiques - /// - /// # Type Parameter - /// - /// * `E` - Type RustEmbed définissant le répertoire à servir - /// - /// # Exemple - /// - /// ```ignore - /// use pmoupnp::server::Server; - /// use rust_embed::RustEmbed; - /// - /// #[derive(RustEmbed, Clone)] - /// #[folder = "static/"] - /// struct Assets; - /// - /// # #[tokio::main] - /// # async fn main() { - /// let mut server = Server::new("Test", "http://localhost:3000", 3000); - /// server.add_dir::("/assets").await; - /// // Les fichiers de static/ sont accessibles via /assets/* - /// # } - /// ``` + + /// Ajoute un handler Axum standard + pub async fn add_handler(&mut self, path: &str, handler: H) + where + H: Handler + Clone + 'static, + T: 'static, + { + let route = Router::new().route("/", get(handler.clone())); + + let mut r = self.router.write().await; + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) + } else { + std::mem::take(&mut *r).nest(path, route) + }; + } + + /// Ajoute un handler POST avec état + pub async fn add_post_handler_with_state(&mut self, path: &str, handler: H, state: S) + where + H: Handler + Clone + 'static, + T: 'static, + S: Clone + Send + Sync + 'static, + { + let route = Router::new() + .route("/", post(handler.clone())) + .with_state(state.clone()); + + let mut r = self.router.write().await; + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) + } else { + std::mem::take(&mut *r).nest(path, route) + }; + } + + /// Ajoute un handler avec état + pub async fn add_handler_with_state(&mut self, path: &str, handler: H, state: S) + where + H: Handler + Clone + 'static, + T: 'static, + S: Clone + Send + Sync + 'static, + { + let route = Router::new() + .route("/", get(handler.clone())) + .with_state(state.clone()); + + let mut r = self.router.write().await; + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) + } else { + std::mem::take(&mut *r).nest(path, route) + }; + } + + /// Ajoute un répertoire statique pub async fn add_dir(&mut self, path: &str) where E: RustEmbed + Clone + Send + Sync + 'static, { let serve = ServeEmbed::::new(); - let mut r = self.router.write().await; - if path == "/" { - *r = std::mem::take(&mut *r).fallback_service(serve); + let route = Router::new().fallback_service(serve); + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) } else { - let route = Router::new().fallback_service(serve); - *r = std::mem::take(&mut *r).nest(path, route); - } + std::mem::take(&mut *r).nest(path, route) + }; } /// Ajoute une Single Page Application (SPA) @@ -252,125 +254,12 @@ impl Server { let mut r = self.router.write().await; - if path == "/" { - *r = std::mem::take(&mut *r).fallback_service(serve); + let route = Router::new().fallback_service(serve); + *r = if path == "/" { + std::mem::take(&mut *r).merge(route) } else { - let route = Router::new().fallback_service(serve); - *r = std::mem::take(&mut *r).nest(path, route); - } - } - - /// Ajoute un handler Axum personnalisé - /// - /// Pour des cas d'usage avancés nécessitant un contrôle complet sur le handler. - /// - /// # Arguments - /// - /// * `path` - Chemin de la route - /// * `handler` - Handler Axum - /// - /// # Exemple - /// - /// ```rust,no_run - /// # use pmoupnp::server::Server; - /// # use axum::response::Html; - /// # #[tokio::main] - /// # async fn main() { - /// # let mut server = Server::new("Test", "http://localhost:3000", 3000); - /// async fn custom_handler() -> Html<&'static str> { - /// Html("

Custom Response

") - /// } - /// - /// server.add_handler("/custom", custom_handler).await; - /// # } - /// ``` - pub async fn add_handler(&mut self, path: &str, handler: H) - where - H: Handler, - T: 'static, - { - let route = Router::new().route("/", get(handler)); - - let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).nest(path, route); - } - - /// Ajoute un handler avec state (pour SSE, extracteurs, etc.) - /// - /// Permet d'utiliser des extracteurs Axum comme `State`, `Query`, etc. - /// Idéal pour Server-Sent Events (SSE), WebSockets ou tout handler nécessitant un état partagé. - /// - /// # Arguments - /// - /// * `path` - Chemin de la route - /// * `handler` - Handler Axum avec extracteurs - /// * `state` - État partagé (doit être Clone + Send + Sync) - /// - /// # Exemple avec SSE - /// - /// ```ignore - /// use pmoupnp::server::Server; - /// use axum::extract::State; - /// use axum::response::sse::{Event, Sse, KeepAlive}; - /// use tokio::sync::broadcast; - /// - /// #[derive(Clone)] - /// struct LogState { - /// tx: broadcast::Sender - /// } - /// - /// impl LogState { - /// fn subscribe(&self) -> broadcast::Receiver { - /// self.tx.subscribe() - /// } - /// } - /// - /// async fn log_sse(State(state): State) -> Sse>> { - /// let mut rx = state.subscribe(); - /// let stream = async_stream::stream! { - /// while let Ok(msg) = rx.recv().await { - /// yield Ok(Event::default().data(msg)); - /// } - /// }; - /// Sse::new(stream).keep_alive(KeepAlive::default()) - /// } - /// - /// let log_state = LogState { tx: broadcast::channel(100).0 }; - /// server.add_handler_with_state("/logs", log_sse, log_state).await; - /// ``` - pub async fn add_handler_with_state(&mut self, path: &str, handler: H, state: S) - where - H: Handler, - T: 'static, - S: Clone + Send + Sync + 'static, - { - let route = Router::new().route("/", get(handler)).with_state(state); - - let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).nest(path, route); - } - - /// Ajoute un handler POST avec state - /// - /// Similaire à `add_handler_with_state` mais pour les requêtes POST. - /// - /// # Arguments - /// - /// * `path` - Chemin de la route - /// * `handler` - Handler Axum pour POST - /// * `state` - État partagé - pub async fn add_post_handler_with_state(&mut self, path: &str, handler: H, state: S) - where - H: Handler, - T: 'static, - S: Clone + Send + Sync + 'static, - { - let route = Router::new() - .route("/", axum::routing::post(handler)) - .with_state(state); - - let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).nest(path, route); + std::mem::take(&mut *r).nest(path, route) + }; } /// Ajoute une redirection HTTP @@ -393,23 +282,20 @@ impl Server { /// server.add_redirect("/", "/app").await; /// # } /// ``` + pub async fn add_redirect(&mut self, from: &str, to: &str) { let to = to.to_string(); - let handler = move || { - let to = to.clone(); - async move { Redirect::permanent(&to) } + let make_handler = || { + let target = to.clone(); + get(move || async move { Redirect::permanent(&target) }) }; let mut r = self.router.write().await; - - if from == "/" { - // Pour la racine, utiliser merge au lieu de nest - let route = Router::new().route("/", get(handler)); - *r = std::mem::take(&mut *r).merge(route); + *r = if from == "/" { + std::mem::take(&mut *r).merge(Router::new().route("/", make_handler())) } else { - let route = Router::new().route("/", get(handler)); - *r = std::mem::take(&mut *r).nest(from, route); - } + std::mem::take(&mut *r).nest(from, Router::new().route("/", make_handler())) + }; } /// Ajoute une API documentée avec OpenAPI et Swagger UI @@ -481,21 +367,16 @@ impl Server { /// - `/api/api1/users` et `/api/api2/products` sont accessibles via Axum. /// - `/swagger-ui/api1` et `/swagger-ui/api2` affichent la documentation Swagger correspondante. /// - `/api-docs/api1.json` et `/api-docs/api2.json` fournissent les spécifications OpenAPI respectives. - pub async fn add_openapi( &mut self, api_router: Router, openapi: utoipa::openapi::OpenApi, - name: &str, // nom unique pour différencier Swagger et OpenAPI + name: &str, ) { - use axum::routing::get; - use utoipa_swagger_ui::SwaggerUi; - - // Stocker le router API dans self.api_router si tu veux y accéder plus tard let mut api_r = self.api_router.write().await; *api_r = Some(api_router.clone()); + drop(api_r); - // Générer des chemins uniques pour Swagger UI et OpenAPI JSON let swagger_path = format!("/swagger-ui/{}", name); let swagger_path_static: &'static str = Box::leak(swagger_path.into_boxed_str()); @@ -504,16 +385,31 @@ impl Server { let swagger = SwaggerUi::new(swagger_path_static).url(openapi_json_path_static, openapi); - // Nester le router API sous /api/{name} let base_path = format!("/api/{}", name); let nested_router = Router::new().nest(&base_path, api_router); - // Fusionner avec le router principal let mut r = self.router.write().await; - let mut combined = std::mem::take(&mut *r); - combined = combined.merge(nested_router).merge(swagger); + *r = std::mem::take(&mut *r).merge(nested_router).merge(swagger); + } + /// Ajoute un sous-router au serveur + /// + /// - Si `path` est "/", merge directement au router principal + /// - Sinon, nest le router sous le chemin donné + pub async fn add_router(&mut self, path: &str, sub_router: Router) { + let mut r = self.router.write().await; + + let combined = if path == "/" { + // Merge directement à la racine + r.clone().merge(sub_router) + } else { + // Sous-chemin => nest + let normalized = format!("/{}", path.trim_start_matches('/')); + r.clone().nest(&normalized, sub_router) + }; + *r = combined; } + /// Démarre le serveur HTTP /// /// Lance le serveur sur le port configuré et met en place la gestion @@ -537,16 +433,7 @@ impl Server { self.name, self.base_url, self.http_port, self.base_url, self.http_port ); - // Merger le routeur API si présent - let api_router = self.api_router.read().await; - if let Some(api_r) = api_router.as_ref() { - let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).nest("/api", api_r.clone()); - } - drop(api_router); - let router = self.router.clone(); - let server_task = tokio::spawn(async move { let r = router.read().await.clone(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); diff --git a/pmoupnp/src/actions/action_instance.rs b/pmoupnp/src/actions/action_instance.rs index 49ca2938..02e95fd7 100644 --- a/pmoupnp/src/actions/action_instance.rs +++ b/pmoupnp/src/actions/action_instance.rs @@ -3,8 +3,6 @@ use std::sync::Arc; use xmltree::{Element, XMLNode}; use crate::actions::Action; -use crate::actions::Argument; -use crate::actions::ArgumentSet; use crate::actions::ArgInstanceSet; use crate::actions::ActionInstance; use crate::UpnpInstance; diff --git a/pmoupnp/src/object_set.rs b/pmoupnp/src/object_set.rs index 224ce185..2d99ebec 100644 --- a/pmoupnp/src/object_set.rs +++ b/pmoupnp/src/object_set.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc}; use std::sync::RwLock; -use crate::{UpnpDeepClone, UpnpObjectSet, UpnpObjectSetError, UpnpSet, UpnpTypedObject}; +use crate::{UpnpDeepClone, UpnpObjectSet, UpnpObjectSetError, UpnpTypedObject}; /// Implémentation du clonage profond pour `UpnpObjectSet`. /// diff --git a/pmoupnp/src/ssdp/server.rs b/pmoupnp/src/ssdp/server.rs index fc344487..4f729242 100644 --- a/pmoupnp/src/ssdp/server.rs +++ b/pmoupnp/src/ssdp/server.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::net::{SocketAddr, UdpSocket}; use std::sync::{Arc, RwLock}; use std::time::Duration; -use tracing::{error, info, warn}; +use tracing::{info, warn}; /// Serveur SSDP gérant les annonces et découvertes pub struct SsdpServer {