Merge pull request 'push-ytvsvurqotzt' (#14) from push-ytvsvurqotzt into main

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2025-10-07 16:02:22 +02:00
49 changed files with 4604 additions and 670 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3
.gitignore vendored
View File

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

View File

@@ -1,9 +1,15 @@
host:
http_port: '8080'
cover_cache:
directory: ./.pmomusic_covers
size: 2000
devices:
mediarenderer:
mpd_renderer: null
fakerenderer:
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
mediarenderer:
udn: f9ef6c21-0ed3-470c-9846-bc1ae85fea62
mediaserver:
qobuz:
udn: 28963b75-4c5f-4da7-b10e-ffafd
host:
http_port: '8080'

981
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
[workspace]
resolver = "3"
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp"]
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocovers"]

View File

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

View File

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

View File

@@ -5,3 +5,11 @@ edition = "2021"
[dependencies]
rust-embed = "8.5.0"
[dependencies.pmoserver]
path = "../pmoserver"
optional = true
[features]
default = []
pmoserver = ["dep:pmoserver"]

View File

@@ -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();
//! #[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;

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

View File

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

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

View File

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

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

View File

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

39
pmocovers/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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;

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

View File

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

View File

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

View File

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

View File

@@ -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);
} else {
let route = Router::new().fallback_service(serve);
*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 une Single Page Application (SPA)
@@ -226,127 +254,12 @@ impl Server {
let mut r = self.router.write().await;
if path == "/" {
*r = std::mem::take(&mut *r).fallback_service(serve);
} 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);
*r = if path == "/" {
std::mem::take(&mut *r).merge(route)
} else {
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(),
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`.
///

View File

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

View File

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

View File

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

View File

@@ -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,10 +395,7 @@ 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,

101
pmoupnp/src/soap/builder.rs Normal file
View 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\""));
}
}

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

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

View File

@@ -5,3 +5,4 @@ edition = "2024"
[dependencies]
get_if_addrs = "0.5.3"
os_info = "3.8"

View File

@@ -18,3 +18,35 @@
mod ip_utils;
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)
}
}