Merge pull request 'push-ytvsvurqotzt' (#14) from push-ytvsvurqotzt into main
Reviewed-on: #14
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,8 +10,7 @@ xxx
|
||||
/dcai/
|
||||
**/.pmomusic.yml
|
||||
**/.pmomusic_covers/**
|
||||
**/.DS_Strore/**
|
||||
**/.DS_Strore
|
||||
.DS_Store
|
||||
/target/
|
||||
.pmomusic_covers
|
||||
C/src/soxr-0.1.3/Release/tests
|
||||
|
||||
981
Cargo.lock
generated
981
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp"]
|
||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers"]
|
||||
|
||||
@@ -7,7 +7,8 @@ edition = "2024"
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
pmoapp = { path = "../pmoapp" }
|
||||
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||
|
||||
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||
|
||||
@@ -1,57 +1,69 @@
|
||||
use pmoupnp::{mediarenderer::avtransport::AVTTRANSPORT, UpnpObject};
|
||||
use pmoupnp::{
|
||||
mediarenderer::MEDIA_RENDERER,
|
||||
ssdp::SsdpServer,
|
||||
UpnpServer,
|
||||
UpnpModel,
|
||||
};
|
||||
use pmoserver::{
|
||||
logs::{log_dump, log_sse, LogState, SseLayer},
|
||||
logs::LoggingOptions,
|
||||
ServerBuilder
|
||||
};
|
||||
use pmoapp::Webapp;
|
||||
use tracing_subscriber::Registry;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use pmoapp::{Webapp, WebAppExt};
|
||||
use pmocovers::CoverCacheExt;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Charger la config
|
||||
|
||||
// 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;
|
||||
// Initialiser le logging et enregistrer les routes de logs
|
||||
server.init_logging(LoggingOptions::default()).await;
|
||||
|
||||
|
||||
info!("📡 Registering the cover cache...");
|
||||
let cache = server.init_cover_cache_configured()
|
||||
.await
|
||||
.expect("Cannot initialise the image cache");
|
||||
|
||||
info!("✅ Cover cache ready at {}",
|
||||
cache.cache_dir(),
|
||||
);
|
||||
|
||||
|
||||
|
||||
// Routes de base
|
||||
server
|
||||
.add_route("/info", || async {
|
||||
serde_json::json!({"version": "1.0.0"})
|
||||
})
|
||||
.await;
|
||||
|
||||
server.add_spa::<Webapp>("/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();
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server.add_webapp_with_redirect::<Webapp>("/app").await;
|
||||
|
||||
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;
|
||||
info!("📡 Registering MediaRenderer...");
|
||||
let renderer_instance = server.register_device(MEDIA_RENDERER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer routes");
|
||||
|
||||
server.add_redirect("/", "/app").await;
|
||||
info!("✅ MediaRenderer ready at {}{}",
|
||||
renderer_instance.base_url(),
|
||||
renderer_instance.description_route()
|
||||
);
|
||||
|
||||
info!("{}",AVTTRANSPORT.to_markdown());
|
||||
info!("{}",AVTTRANSPORT.scpd_xml());
|
||||
// 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 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");
|
||||
|
||||
server.start().await;
|
||||
server.wait().await;
|
||||
|
||||
@@ -5,3 +5,11 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rust-embed = "8.5.0"
|
||||
|
||||
[dependencies.pmoserver]
|
||||
path = "../pmoserver"
|
||||
optional = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
pmoserver = ["dep:pmoserver"]
|
||||
|
||||
@@ -1,32 +1,246 @@
|
||||
//! # 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::<Webapp>("/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::<Webapp>("/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::<Webapp>("/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: <?xml version=\"1.0\"?><scpd>...</scpd>"
|
||||
//! Output: Bloc de code avec coloration syntaxique XML
|
||||
//! ```
|
||||
//!
|
||||
//! - Détection via regex : `<?xml` ou balises courantes (`<scpd>`, `<service>`, 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;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Structure représentant l'application web embarquée.
|
||||
///
|
||||
@@ -36,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::<Webapp>("/app").await;
|
||||
/// // Ajouter la webapp via le trait WebAppExt
|
||||
/// server.add_webapp::<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<W: RustEmbed>(&mut self, path: &str) -> ... {
|
||||
/// // Délègue à la méthode interne add_spa
|
||||
/// self.add_spa::<W>(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<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + 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<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static;
|
||||
}
|
||||
|
||||
// Implémentation du trait pour pmoserver::Server (feature-gated)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
mod pmoserver_impl;
|
||||
|
||||
57
pmoapp/src/pmoserver_impl.rs
Normal file
57
pmoapp/src/pmoserver_impl.rs
Normal file
@@ -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::<Webapp>("/app").await;
|
||||
//!
|
||||
//! // Ou avec redirection
|
||||
//! server.add_webapp_with_redirect::<Webapp>("/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<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.to_string();
|
||||
Box::pin(async move {
|
||||
self.add_spa::<W>(&path).await;
|
||||
})
|
||||
}
|
||||
|
||||
fn add_webapp_with_redirect<W>(&mut self, path: &str) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.to_string();
|
||||
Box::pin(async move {
|
||||
self.add_spa::<W>(&path).await;
|
||||
self.add_redirect("/", &path).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
<div>
|
||||
<nav>
|
||||
<router-link to="/">Accueil</router-link> |
|
||||
<router-link to="/logs">Logs</router-link>
|
||||
<router-link to="/logs">Logs</router-link> |
|
||||
<router-link to="/covers-cache">Cover Cache</router-link>
|
||||
</nav>
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
518
pmoapp/webapp/src/components/CoverCacheManager.vue
Normal file
518
pmoapp/webapp/src/components/CoverCacheManager.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="cover-cache-manager">
|
||||
<div class="header">
|
||||
<h2>🖼️ Cover Cache Manager</h2>
|
||||
<div class="stats">
|
||||
<span>{{ images.length }} images</span>
|
||||
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div class="add-form">
|
||||
<h3>➕ Add New Cover</h3>
|
||||
<form @submit.prevent="handleAddImage">
|
||||
<div class="form-group">
|
||||
<input
|
||||
v-model="newImageUrl"
|
||||
type="url"
|
||||
placeholder="https://example.com/cover.jpg"
|
||||
required
|
||||
:disabled="isAdding"
|
||||
/>
|
||||
<button type="submit" :disabled="isAdding || !newImageUrl">
|
||||
{{ isAdding ? "Adding..." : "Add Image" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="addError" class="error">❌ {{ addError }}</p>
|
||||
<p v-if="addSuccess" class="success">✅ {{ addSuccess }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Contrôles -->
|
||||
<div class="controls">
|
||||
<div class="sort-controls">
|
||||
<label>Sort by:</label>
|
||||
<select v-model="sortBy">
|
||||
<option value="hits">Most Used</option>
|
||||
<option value="last_used">Recently Used</option>
|
||||
<option value="recent">Recently Added</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="refreshImages" :disabled="isLoading">
|
||||
🔄 {{ isLoading ? "Loading..." : "Refresh" }}
|
||||
</button>
|
||||
<button @click="handleConsolidate" :disabled="isConsolidating" class="btn-secondary">
|
||||
🔧 {{ isConsolidating ? "Consolidating..." : "Consolidate" }}
|
||||
</button>
|
||||
<button @click="handlePurge" class="btn-danger" :disabled="isPurging">
|
||||
🗑️ {{ isPurging ? "Purging..." : "Purge All" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Galerie d'images -->
|
||||
<div v-if="isLoading && images.length === 0" class="loading-state">
|
||||
⏳ Loading images...
|
||||
</div>
|
||||
|
||||
<div v-else-if="images.length === 0" class="empty-state">
|
||||
📭 No images in cache. Add one using the form above!
|
||||
</div>
|
||||
|
||||
<div v-else class="image-grid">
|
||||
<div
|
||||
v-for="image in sortedImages"
|
||||
:key="image.pk"
|
||||
class="image-card"
|
||||
@click="selectedImage = image"
|
||||
>
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
:src="getImageUrl(image.pk, 256)"
|
||||
:alt="image.source_url"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="image-overlay">
|
||||
<span class="hits">👁️ {{ image.hits }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-info">
|
||||
<div class="pk">{{ image.pk }}</div>
|
||||
<div class="url" :title="image.source_url">
|
||||
{{ truncateUrl(image.source_url) }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span v-if="image.last_used" class="last-used">
|
||||
🕐 {{ formatDate(image.last_used) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-actions">
|
||||
<button
|
||||
@click.stop="handleDeleteImage(image.pk)"
|
||||
class="btn-delete"
|
||||
:disabled="deletingImages.has(image.pk)"
|
||||
>
|
||||
{{ deletingImages.has(image.pk) ? "..." : "🗑️" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de détails -->
|
||||
<div v-if="selectedImage" class="modal" @click="selectedImage = null">
|
||||
<div class="modal-content" @click.stop>
|
||||
<button class="modal-close" @click="selectedImage = null">✕</button>
|
||||
<img
|
||||
:src="getImageUrl(selectedImage.pk)"
|
||||
:alt="selectedImage.source_url"
|
||||
class="modal-image"
|
||||
/>
|
||||
<div class="modal-info">
|
||||
<h3>Image Details</h3>
|
||||
<p><strong>PK:</strong> {{ selectedImage.pk }}</p>
|
||||
<p><strong>Source URL:</strong> <a :href="selectedImage.source_url" target="_blank">{{ selectedImage.source_url }}</a></p>
|
||||
<p><strong>Hits:</strong> {{ selectedImage.hits }}</p>
|
||||
<p v-if="selectedImage.last_used"><strong>Last Used:</strong> {{ formatDate(selectedImage.last_used) }}</p>
|
||||
<div class="modal-actions">
|
||||
<button @click="copyImageUrl(selectedImage.pk)" class="btn-secondary">
|
||||
📋 Copy URL
|
||||
</button>
|
||||
<button @click="handleDeleteImage(selectedImage.pk); selectedImage = null" class="btn-danger">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import type { CacheEntry } from "../services/coverCache";
|
||||
import {
|
||||
listImages,
|
||||
addImage,
|
||||
deleteImage,
|
||||
purgeCache,
|
||||
consolidateCache,
|
||||
getImageUrl,
|
||||
} from "../services/coverCache";
|
||||
|
||||
// --- États ---
|
||||
const images = ref<CacheEntry[]>([]);
|
||||
const selectedImage = ref<CacheEntry | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
|
||||
|
||||
// Formulaire d'ajout
|
||||
const newImageUrl = ref("");
|
||||
const isAdding = ref(false);
|
||||
const addError = ref("");
|
||||
const addSuccess = ref("");
|
||||
|
||||
// Contrôles
|
||||
const isConsolidating = ref(false);
|
||||
const isPurging = ref(false);
|
||||
const deletingImages = ref(new Set<string>());
|
||||
|
||||
// --- Computed ---
|
||||
const totalHits = computed(() => images.value.reduce((sum, i) => sum + i.hits, 0));
|
||||
|
||||
const sortedImages = computed(() => {
|
||||
const arr = [...images.value];
|
||||
switch (sortBy.value) {
|
||||
case "hits": return arr.sort((a,b)=>b.hits-a.hits);
|
||||
case "last_used":
|
||||
return arr.sort((a,b)=>{
|
||||
if(!a.last_used) return 1;
|
||||
if(!b.last_used) return -1;
|
||||
return new Date(b.last_used).getTime()-new Date(a.last_used).getTime();
|
||||
});
|
||||
case "recent": return arr.reverse();
|
||||
default: return arr;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Fonctions ---
|
||||
async function refreshImages() {
|
||||
isLoading.value = true;
|
||||
try { images.value = await listImages(); }
|
||||
finally { isLoading.value = false; }
|
||||
}
|
||||
|
||||
async function handleAddImage() {
|
||||
if(!newImageUrl.value) return;
|
||||
isAdding.value = true; addError.value=""; addSuccess.value="";
|
||||
try {
|
||||
const result = await addImage(newImageUrl.value);
|
||||
addSuccess.value = `Image added! PK: ${result.pk}`;
|
||||
newImageUrl.value = "";
|
||||
await refreshImages();
|
||||
} catch(e:any) { addError.value = e.message ?? "Failed to add image"; }
|
||||
finally { isAdding.value=false; setTimeout(()=>addSuccess.value="",1500); }
|
||||
}
|
||||
|
||||
async function handleDeleteImage(pk:string){
|
||||
if(!confirm(`Delete image ${pk}?`)) return;
|
||||
deletingImages.value.add(pk);
|
||||
try{ await deleteImage(pk); await refreshImages(); }
|
||||
finally{ deletingImages.value.delete(pk); }
|
||||
}
|
||||
|
||||
async function handlePurge(){
|
||||
if(!confirm("⚠️ Delete ALL images?")) return;
|
||||
isPurging.value = true;
|
||||
try{ await purgeCache(); await refreshImages(); }
|
||||
finally{ isPurging.value=false; }
|
||||
}
|
||||
|
||||
async function handleConsolidate(){
|
||||
if(!confirm("Consolidate cache?")) return;
|
||||
isConsolidating.value=true;
|
||||
try{ await consolidateCache(); await refreshImages(); }
|
||||
finally{ isConsolidating.value=false; }
|
||||
}
|
||||
|
||||
function copyImageUrl(pk:string){
|
||||
navigator.clipboard.writeText(window.location.origin + getImageUrl(pk));
|
||||
alert("✅ URL copied!");
|
||||
}
|
||||
|
||||
function truncateUrl(url:string,maxLength=40){ return url.length<=maxLength?url:url.slice(0,maxLength-3)+"..."; }
|
||||
function formatDate(dateString:string){
|
||||
const d=new Date(dateString), diff=Date.now()-d.getTime(), days=Math.floor(diff/(1000*60*60*24));
|
||||
if(days===0)return"Today"; if(days===1)return"Yesterday"; if(days<7)return`${days} days ago`; return d.toLocaleDateString();
|
||||
}
|
||||
function handleImageError(e:Event){(e.target as HTMLImageElement).src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Crect fill='%23333' width='256' height='256'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-size='20'%3EError%3C/text%3E%3C/svg%3E";}
|
||||
|
||||
onMounted(()=>refreshImages());
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cover-cache-manager {
|
||||
padding: 1rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #444;
|
||||
}
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #61dafb;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #999;
|
||||
} /* Formulaire d'ajout */
|
||||
.add-form {
|
||||
background: #2a2a2a;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.add-form h3 {
|
||||
margin-top: 0;
|
||||
color: #61dafb;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.form-group input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.form-group button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #61dafb;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.form-group button:hover:not(:disabled) {
|
||||
background: #4fa8c5;
|
||||
}
|
||||
.form-group button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.success {
|
||||
color: #51cf66;
|
||||
margin-top: 0.5rem;
|
||||
} /* Contrôles */
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.sort-controls label {
|
||||
color: #999;
|
||||
}
|
||||
.sort-controls select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
button:not(.btn-danger):not(.btn-secondary) {
|
||||
background: #61dafb;
|
||||
color: #000;
|
||||
}
|
||||
button:not(.btn-danger):not(.btn-secondary):hover:not(:disabled) {
|
||||
background: #4fa8c5;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #666;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #ff6b6b;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #ee5a52;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
} /* États */
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #999;
|
||||
font-size: 1.2rem;
|
||||
} /* Grille d'images */
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.image-card {
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.image-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 100%; /* Ratio 1:1 */
|
||||
background: #1a1a1a;
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-wrapper img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.hits {
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.image-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
.pk {
|
||||
font-family: monospace;
|
||||
color: #61dafb;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.url {
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #777;
|
||||
}
|
||||
.image-actions {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
.btn-delete {
|
||||
width: 100%;
|
||||
background: #555;
|
||||
color: #fff;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
background: #ff6b6b;
|
||||
} /* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
}
|
||||
.modal-content {
|
||||
background: #2a2a2a;
|
||||
border-radius: 12px;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
z-index: 1;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.modal-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.modal-info {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.modal-info h3 {
|
||||
margin-top: 0;
|
||||
color: #61dafb;
|
||||
}
|
||||
.modal-info p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.modal-info a {
|
||||
color: #61dafb;
|
||||
text-decoration: none;
|
||||
}
|
||||
.modal-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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({
|
||||
|
||||
120
pmoapp/webapp/src/services/coverCache.ts
Normal file
120
pmoapp/webapp/src/services/coverCache.ts
Normal file
@@ -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<CacheEntry[]> {
|
||||
const response = await fetch("/api/covers");
|
||||
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<CacheEntry> {
|
||||
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");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une nouvelle image au cache depuis une URL
|
||||
*/
|
||||
export async function addImage(url: string): Promise<AddImageResponse> {
|
||||
const response = await fetch("/api/covers", {
|
||||
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<void> {
|
||||
const response = await fetch(`/api/covers/${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<void> {
|
||||
const response = await fetch("/api/covers", {
|
||||
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<void> {
|
||||
const response = await fetch("/api/covers/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}`;
|
||||
}
|
||||
@@ -40,10 +40,13 @@ impl Clone for Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
||||
pub fn load_config(filename: &str) -> Result<Self> {
|
||||
let mut path = filename.to_string();
|
||||
let mut data: Option<Vec<u8>> = None;
|
||||
|
||||
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<Value> {
|
||||
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> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
host:
|
||||
http_port: "8080"
|
||||
cover_cache:
|
||||
cover_cache:
|
||||
directory: "./.pmomusic_covers"
|
||||
size: 2000
|
||||
devices:
|
||||
|
||||
39
pmocovers/Cargo.toml
Normal file
39
pmocovers/Cargo.toml
Normal file
@@ -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"]
|
||||
311
pmocovers/src/api.rs
Normal file
311
pmocovers/src/api.rs
Normal file
@@ -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<CacheEntry>),
|
||||
(status = 500, description = "Erreur serveur", body = ErrorResponse)
|
||||
),
|
||||
tag = "covers"
|
||||
)]
|
||||
pub async fn list_images(State(cache): State<Arc<Cache>>) -> 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<Arc<Cache>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> 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<Arc<Cache>>,
|
||||
Json(req): Json<AddImageRequest>,
|
||||
) -> 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<Arc<Cache>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> 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<Arc<Cache>>) -> 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<Arc<Cache>>) -> 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(),
|
||||
}
|
||||
}
|
||||
145
pmocovers/src/cache.rs
Normal file
145
pmocovers/src/cache.rs
Normal file
@@ -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<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn new(dir: &str, limit: usize) -> Result<Self> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<PathBuf> {
|
||||
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])
|
||||
}
|
||||
118
pmocovers/src/db.rs
Normal file
118
pmocovers/src/db.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DB {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl DB {
|
||||
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||
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<CacheEntry> {
|
||||
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<Vec<CacheEntry>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
277
pmocovers/src/lib.rs
Normal file
277
pmocovers/src/lib.rs
Normal file
@@ -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<Cache>` - 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<Arc<Cache>>;
|
||||
|
||||
/// 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<Cache>` - 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<Arc<Cache>>;
|
||||
}
|
||||
|
||||
// Implémentation du trait pour pmoserver::Server (feature-gated)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
mod pmoserver_impl;
|
||||
70
pmocovers/src/openapi.rs
Normal file
70
pmocovers/src/openapi.rs
Normal file
@@ -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;
|
||||
175
pmocovers/src/pmoserver_impl.rs
Normal file
175
pmocovers/src/pmoserver_impl.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! 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, warn};
|
||||
use std::sync::Arc;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
|
||||
|
||||
/// Handler pour GET /covers/images/{pk}
|
||||
async fn get_cover_image(
|
||||
State(cache): State<Arc<Cache>>,
|
||||
req: Request<Body>,
|
||||
) -> Response {
|
||||
// Extraire pk du path
|
||||
let path = req.uri().path();
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
|
||||
warn!("{:?}",parts);
|
||||
|
||||
if parts.len() != 2 {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||
}
|
||||
|
||||
let pk = parts[1];
|
||||
|
||||
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<Arc<Cache>>,
|
||||
req: Request<Body>,
|
||||
) -> Response {
|
||||
// Extraire pk et size du path
|
||||
let path = req.uri().path();
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
|
||||
if parts.len() != 3 {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
||||
}
|
||||
|
||||
let pk = parts[1];
|
||||
let size = match parts[2].parse::<usize>() {
|
||||
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<Arc<Cache>>) -> 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<Arc<Cache>> {
|
||||
let cache = Arc::new(Cache::new(cache_dir, limit)?);
|
||||
|
||||
// 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 qui sera nesté sous /api/covers par add_openapi
|
||||
let api_router = Router::new()
|
||||
// Liste et ajout
|
||||
.route(
|
||||
"/",
|
||||
get(api::list_images) // GET /api/covers
|
||||
.post(api::add_image) // POST /api/covers
|
||||
.delete(api::purge_cache), // DELETE /api/covers
|
||||
)
|
||||
// Ressource unique
|
||||
.route(
|
||||
"/{pk}",
|
||||
get(api::get_image_info) // GET /api/covers/{pk}
|
||||
.delete(api::delete_image), // DELETE /api/covers/{pk}
|
||||
)
|
||||
// Action spécifique
|
||||
.route(
|
||||
"/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
|
||||
// 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)
|
||||
}
|
||||
|
||||
async fn init_cover_cache_configured(&mut self) -> anyhow::Result<Arc<Cache>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
61
pmocovers/src/webp.rs
Normal file
61
pmocovers/src/webp.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use anyhow::Result;
|
||||
use image::{DynamicImage, imageops::FilterType};
|
||||
use webp::{Encoder, WebPMemory};
|
||||
|
||||
pub fn encode_webp(img: &DynamicImage) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,15 +13,18 @@
|
||||
//! - 📚 **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;
|
||||
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;
|
||||
@@ -29,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,
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct Server {
|
||||
router: Arc<RwLock<Router>>,
|
||||
api_router: Arc<RwLock<Option<Router>>>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
log_state: Option<LogState>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -70,6 +71,7 @@ impl Server {
|
||||
router: Arc::new(RwLock::new(Router::new())),
|
||||
api_router: Arc::new(RwLock::new(None)),
|
||||
join_handle: None,
|
||||
log_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,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
|
||||
@@ -109,11 +110,10 @@ impl Server {
|
||||
pub async fn add_route<F, Fut, T>(&mut self, path: &str, f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: std::future::Future<Output = T> + Send + 'static,
|
||||
Fut: Future<Output = T> + Send + 'static,
|
||||
T: Serialize + Send + 'static,
|
||||
{
|
||||
let f = Arc::new(f);
|
||||
|
||||
let handler = {
|
||||
let f = f.clone();
|
||||
move || {
|
||||
@@ -125,53 +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);
|
||||
*r = if path == "/" {
|
||||
std::mem::take(&mut *r).merge(route)
|
||||
} else {
|
||||
std::mem::take(&mut *r).nest(path, route)
|
||||
};
|
||||
}
|
||||
|
||||
/// 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>("/assets").await;
|
||||
/// // Les fichiers de static/ sont accessibles via /assets/*
|
||||
/// # }
|
||||
/// ```
|
||||
/// Ajoute un handler Axum standard
|
||||
pub async fn add_handler<H, T>(&mut self, path: &str, handler: H)
|
||||
where
|
||||
H: Handler<T, ()> + 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<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||
where
|
||||
H: Handler<T, S> + 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<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||
where
|
||||
H: Handler<T, S> + 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<E>(&mut self, path: &str)
|
||||
where
|
||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let serve = ServeEmbed::<E>::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)
|
||||
@@ -223,130 +251,15 @@ 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);
|
||||
|
||||
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("<h1>Custom Response</h1>")
|
||||
/// }
|
||||
///
|
||||
/// server.add_handler("/custom", custom_handler).await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_handler<H, T>(&mut self, path: &str, handler: H)
|
||||
where
|
||||
H: Handler<T, ()>,
|
||||
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<String>
|
||||
/// }
|
||||
///
|
||||
/// impl LogState {
|
||||
/// fn subscribe(&self) -> broadcast::Receiver<String> {
|
||||
/// self.tx.subscribe()
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// async fn log_sse(State(state): State<LogState>) -> Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
/// 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<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||
where
|
||||
H: Handler<T, S>,
|
||||
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<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||
where
|
||||
H: Handler<T, S>,
|
||||
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
|
||||
@@ -369,33 +282,32 @@ 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
|
||||
/// 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
|
||||
///
|
||||
@@ -415,7 +327,7 @@ impl Server {
|
||||
/// paths(get_users),
|
||||
/// components(schemas(User))
|
||||
/// )]
|
||||
/// struct ApiDoc;
|
||||
/// struct ApiDoc1;
|
||||
///
|
||||
/// #[utoipa::path(
|
||||
/// get,
|
||||
@@ -426,22 +338,76 @@ 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<Vec<Product>> {
|
||||
/// 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 :
|
||||
///
|
||||
/// - `/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,
|
||||
) {
|
||||
let mut api_r = self.api_router.write().await;
|
||||
*api_r = Some(api_router);
|
||||
*api_r = Some(api_router.clone());
|
||||
drop(api_r);
|
||||
|
||||
// Ajouter Swagger UI
|
||||
let swagger = SwaggerUi::new("/swagger-ui")
|
||||
.url("/api-docs/openapi.json", openapi);
|
||||
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);
|
||||
|
||||
let base_path = format!("/api/{}", name);
|
||||
let nested_router = Router::new().nest(&base_path, api_router);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).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
|
||||
@@ -462,18 +428,12 @@ 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);
|
||||
|
||||
// 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);
|
||||
info!(
|
||||
"Server {} running at [http://{}:{}](http://{}:{})",
|
||||
self.name, self.base_url, self.http_port, self.base_url, self.http_port
|
||||
);
|
||||
|
||||
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();
|
||||
@@ -508,6 +468,47 @@ 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
|
||||
@@ -538,7 +539,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,4 +557,4 @@ impl ServerBuilder {
|
||||
pub fn build(self) -> Server {
|
||||
Server::new(self.name, self.base_url, self.http_port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<F, Fut>(&mut self, path: &str, handler: F) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static + Clone,
|
||||
Fut: Future<Output = Response> + Send + 'static,
|
||||
{
|
||||
let path = path.to_string();
|
||||
Box::pin(async move {
|
||||
Self::add_handler(self, &path, handler).await;
|
||||
})
|
||||
}
|
||||
|
||||
fn add_post_handler_with_state<S>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
handler: fn(State<S>, String) -> Pin<Box<dyn Future<Output = Response> + Send>>,
|
||||
state: S,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + 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<S>, body: String| -> Pin<Box<dyn Future<Output = Response> + 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<S>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
handler: fn(State<S>, HeaderMap, Request) -> Pin<Box<dyn Future<Output = Response> + Send>>,
|
||||
state: S,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + 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<S>, headers: HeaderMap, req: Request| -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||
handler(State(s), headers, req)
|
||||
};
|
||||
|
||||
Box::pin(async move {
|
||||
Self::add_handler_with_state(self, &path, wrapper, state).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ edition = "2024"
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmodidl = { path = "../pmodidl"}
|
||||
pmoutils = { path = "../pmoutils" }
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
|
||||
url = "2.5.7"
|
||||
uuid = "1.18.1"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -245,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<Box<dyn std::future::Future<Output = Result<(), DeviceError>> + 'a>> {
|
||||
pub fn register_urls<'a>(&'a self, server: &'a mut pmoserver::Server) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), DeviceError>> + 'a>> {
|
||||
Box::pin(async move {
|
||||
info!(
|
||||
"✅ Device description for {} available at: {}{}",
|
||||
@@ -316,10 +322,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, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
let xml = String::from_utf8_lossy(&xml_output).to_string();
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
@@ -327,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DeviceInstance> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
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;
|
||||
pub mod state_variables;
|
||||
pub mod value_ranges;
|
||||
pub mod variable_types;
|
||||
|
||||
// Re-exports
|
||||
pub use server::UpnpServer;
|
||||
|
||||
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
@@ -19,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 {
|
||||
|
||||
@@ -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`.
|
||||
///
|
||||
|
||||
@@ -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 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\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.
|
||||
|
||||
@@ -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<S: UpnpServer>(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<F, Fut>(&mut self, path: &str, handler: F)
|
||||
//! -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
//! where
|
||||
//! F: Fn() -> Fut + Send + Sync + 'static + Clone,
|
||||
//! Fut: Future<Output = pmoupnp::server::Response> + Send + 'static,
|
||||
//! {
|
||||
//! // Enregistrer le handler pour GET requests
|
||||
//! # todo!()
|
||||
//! }
|
||||
//!
|
||||
//! fn add_post_handler_with_state<S>(
|
||||
//! &mut self,
|
||||
//! path: &str,
|
||||
//! handler: fn(axum::extract::State<S>, String)
|
||||
//! -> Pin<Box<dyn Future<Output = pmoupnp::server::Response> + Send>>,
|
||||
//! state: S,
|
||||
//! ) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
//! where
|
||||
//! S: Clone + Send + Sync + 'static,
|
||||
//! {
|
||||
//! // Enregistrer le handler pour POST avec body
|
||||
//! # todo!()
|
||||
//! }
|
||||
//!
|
||||
//! fn add_handler_with_state<S>(
|
||||
//! &mut self,
|
||||
//! path: &str,
|
||||
//! handler: fn(axum::extract::State<S>,
|
||||
//! pmoupnp::server::HeaderMap,
|
||||
//! pmoupnp::server::Request)
|
||||
//! -> Pin<Box<dyn Future<Output = pmoupnp::server::Response> + Send>>,
|
||||
//! state: S,
|
||||
//! ) -> Pin<Box<dyn Future<Output = ()> + 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<Device>) -> Result<Arc<DeviceInstance>,DeviceError> ;
|
||||
|
||||
/// Type alias pour la requête HTTP (basé sur Axum).
|
||||
pub type Request = axum::extract::Request<axum::body::Body>;
|
||||
|
||||
/// 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<S: UpnpServer>(mut server: S) {
|
||||
/// server.add_handler("/description.xml", || async {
|
||||
/// "<?xml version=\"1.0\"?><root></root>".into_response()
|
||||
/// }).await;
|
||||
/// # }
|
||||
/// ```
|
||||
fn add_handler<F, Fut>(&mut self, path: &str, handler: F) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static + Clone,
|
||||
Fut: Future<Output = Response> + 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<String>,
|
||||
/// body: String,
|
||||
/// ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||
/// Box::pin(async move {
|
||||
/// // Traiter la requête SOAP
|
||||
/// axum::response::Response::default()
|
||||
/// })
|
||||
/// }
|
||||
///
|
||||
/// # async fn example<S: UpnpServer>(mut server: S) {
|
||||
/// server.add_post_handler_with_state(
|
||||
/// "/control",
|
||||
/// soap_handler,
|
||||
/// "ServiceName".to_string(),
|
||||
/// ).await;
|
||||
/// # }
|
||||
/// ```
|
||||
fn add_post_handler_with_state<S>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
handler: fn(axum::extract::State<S>, String) -> Pin<Box<dyn Future<Output = Response> + Send>>,
|
||||
state: S,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + 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<String>,
|
||||
/// headers: HeaderMap,
|
||||
/// req: Request,
|
||||
/// ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||
/// Box::pin(async move {
|
||||
/// // Traiter SUBSCRIBE/UNSUBSCRIBE
|
||||
/// axum::response::Response::default()
|
||||
/// })
|
||||
/// }
|
||||
///
|
||||
/// # async fn example<S: UpnpServer>(mut server: S) {
|
||||
/// server.add_handler_with_state(
|
||||
/// "/event",
|
||||
/// event_handler,
|
||||
/// "ServiceName".to_string(),
|
||||
/// ).await;
|
||||
/// # }
|
||||
/// ```
|
||||
fn add_handler_with_state<S>(
|
||||
&mut self,
|
||||
path: &str,
|
||||
handler: fn(axum::extract::State<S>, HeaderMap, Request) -> Pin<Box<dyn Future<Output = Response> + Send>>,
|
||||
state: S,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static;
|
||||
}
|
||||
|
||||
impl UpnpServer for Server {
|
||||
async fn register_device(&mut self, device: Arc<Device>) -> Result<Arc<DeviceInstance>,DeviceError> {
|
||||
let di = device.create_instance();
|
||||
|
||||
di.register_urls(self).await?;
|
||||
|
||||
Ok(di)
|
||||
}
|
||||
}
|
||||
@@ -447,10 +447,7 @@ impl Service {
|
||||
elem.write_with_config(&mut buf, config)
|
||||
.expect("Failed to write XML");
|
||||
|
||||
let mut xml_string = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\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")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ impl ServiceInstance {
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si l'enregistrement des routes échoue.
|
||||
pub async fn register_urls<S: crate::UpnpServer + ?Sized>(&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();
|
||||
@@ -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, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
|
||||
let xml = String::from_utf8_lossy(&xml_output).to_string();
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")],
|
||||
|
||||
101
pmoupnp/src/soap/builder.rs
Normal file
101
pmoupnp/src/soap/builder.rs
Normal file
@@ -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<String, String>,
|
||||
) -> Result<String, xmltree::Error> {
|
||||
// Construire l'élément de réponse
|
||||
// Format: <u:ActionResponse xmlns:u="service-urn">
|
||||
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("<Track>5</Track>"));
|
||||
assert!(xml.contains("<TrackDuration>00:03:45</TrackDuration>"));
|
||||
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\""));
|
||||
}
|
||||
}
|
||||
45
pmoupnp/src/soap/envelope.rs
Normal file
45
pmoupnp/src/soap/envelope.rs
Normal file
@@ -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<SoapHeader>,
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
173
pmoupnp/src/soap/fault.rs
Normal file
173
pmoupnp/src/soap/fault.rs
Normal file
@@ -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<UpnpError>,
|
||||
}
|
||||
|
||||
/// 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<String, xmltree::Error> {
|
||||
// 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("<s:Fault>"));
|
||||
assert!(xml.contains("<faultcode>s:Client</faultcode>"));
|
||||
assert!(xml.contains("<faultstring>Invalid Action</faultstring>"));
|
||||
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("<s:Fault>"));
|
||||
assert!(xml.contains("<detail>"));
|
||||
assert!(xml.contains("<UPnPError"));
|
||||
assert!(xml.contains("<errorCode>401</errorCode>"));
|
||||
assert!(xml.contains("<errorDescription>Invalid Action</errorDescription>"));
|
||||
}
|
||||
}
|
||||
89
pmoupnp/src/soap/mod.rs
Normal file
89
pmoupnp/src/soap/mod.rs
Normal file
@@ -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#"<?xml version="1.0"?>
|
||||
//! <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
//! <s:Body>
|
||||
//! <u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
//! <InstanceID>0</InstanceID>
|
||||
//! <Speed>1</Speed>
|
||||
//! </u:Play>
|
||||
//! </s:Body>
|
||||
//! </s:Envelope>"#;
|
||||
//!
|
||||
//! 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";
|
||||
}
|
||||
149
pmoupnp/src/soap/parser.rs
Normal file
149
pmoupnp/src/soap/parser.rs
Normal file
@@ -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<String>,
|
||||
|
||||
/// Arguments de l'action
|
||||
pub args: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// 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<SoapAction, SoapParseError> {
|
||||
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<SoapEnvelope, SoapParseError> {
|
||||
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<SoapAction, SoapParseError> {
|
||||
// Le Body contient un élément enfant qui est l'action
|
||||
// Format: <u:ActionName xmlns:u="service-urn">...</u:ActionName>
|
||||
|
||||
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#"<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<InstanceID>0</InstanceID>
|
||||
<Speed>1</Speed>
|
||||
</u:Play>
|
||||
</s:Body>
|
||||
</s:Envelope>"#;
|
||||
|
||||
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#"<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>"#;
|
||||
|
||||
let action = parse_soap_action(xml.as_bytes()).unwrap();
|
||||
assert_eq!(action.name, "Stop");
|
||||
assert!(action.args.is_empty());
|
||||
}
|
||||
}
|
||||
58
pmoupnp/src/ssdp/device.rs
Normal file
58
pmoupnp/src/ssdp/device.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
38
pmoupnp/src/ssdp/mod.rs
Normal file
38
pmoupnp/src/ssdp/mod.rs
Normal file
@@ -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;
|
||||
304
pmoupnp/src/ssdp/server.rs
Normal file
304
pmoupnp/src/ssdp/server.rs
Normal file
@@ -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::{info, warn};
|
||||
|
||||
/// Serveur SSDP gérant les annonces et découvertes
|
||||
pub struct SsdpServer {
|
||||
/// Devices enregistrés (UUID -> Device)
|
||||
devices: Arc<RwLock<HashMap<String, SsdpDevice>>>,
|
||||
|
||||
/// Socket UDP pour SSDP
|
||||
socket: Option<Arc<UdpSocket>>,
|
||||
}
|
||||
|
||||
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<UdpSocket>) {
|
||||
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<UdpSocket>) {
|
||||
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<String> {
|
||||
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<details>\n\n```\n{}\n```\n</details>\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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
get_if_addrs = "0.5.3"
|
||||
get_if_addrs = "0.5.3"
|
||||
os_info = "3.8"
|
||||
@@ -17,4 +17,36 @@
|
||||
/// ```
|
||||
mod ip_utils;
|
||||
|
||||
pub use ip_utils::guess_local_ip;
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user