From ff2d6b23bdc8ed869302760e8080555ffee09758 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 7 Oct 2025 08:33:06 +0200 Subject: [PATCH] Ajoute pmocovers --- .DS_Store | Bin 8196 -> 10244 bytes .gitignore | 3 +- Cargo.lock | 899 +++++++++++++++++- Cargo.toml | 2 +- PMOMusic/Cargo.toml | 1 + PMOMusic/src/main.rs | 14 + pmoapp/webapp/src/App.vue | 3 +- .../src/components/CoverCacheManager.vue | 518 ++++++++++ pmoapp/webapp/src/router/index.ts | 2 + pmoapp/webapp/src/services/coverCache.ts | 120 +++ pmoconfig/src/lib.rs | 26 +- pmoconfig/src/pmomusic.yaml | 2 +- pmocovers/Cargo.toml | 39 + pmocovers/src/api.rs | 311 ++++++ pmocovers/src/cache.rs | 145 +++ pmocovers/src/db.rs | 118 +++ pmocovers/src/lib.rs | 277 ++++++ pmocovers/src/openapi.rs | 70 ++ pmocovers/src/pmoserver_impl.rs | 166 ++++ pmocovers/src/webp.rs | 61 ++ pmoserver/src/server.rs | 121 ++- 21 files changed, 2844 insertions(+), 54 deletions(-) create mode 100644 pmoapp/webapp/src/components/CoverCacheManager.vue create mode 100644 pmoapp/webapp/src/services/coverCache.ts create mode 100644 pmocovers/Cargo.toml create mode 100644 pmocovers/src/api.rs create mode 100644 pmocovers/src/cache.rs create mode 100644 pmocovers/src/db.rs create mode 100644 pmocovers/src/lib.rs create mode 100644 pmocovers/src/openapi.rs create mode 100644 pmocovers/src/pmoserver_impl.rs create mode 100644 pmocovers/src/webp.rs diff --git a/.DS_Store b/.DS_Store index 1f4aa30355072a8c3a778ef5a052a4de54b88f51..fb6c2e8dccd347683ecbe6d4668e312775538183 100644 GIT binary patch delta 269 zcmZp1XbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~50$f&q6U^hRb;$$8H2UZSt4sMRg zfdXNS9Fs2y`fvU&u!wQE>h>?+Pa-@j%=9xk}88("/app").await; diff --git a/pmoapp/webapp/src/App.vue b/pmoapp/webapp/src/App.vue index f8a79926..db750d1a 100644 --- a/pmoapp/webapp/src/App.vue +++ b/pmoapp/webapp/src/App.vue @@ -2,7 +2,8 @@
diff --git a/pmoapp/webapp/src/components/CoverCacheManager.vue b/pmoapp/webapp/src/components/CoverCacheManager.vue new file mode 100644 index 00000000..5f43a341 --- /dev/null +++ b/pmoapp/webapp/src/components/CoverCacheManager.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/pmoapp/webapp/src/router/index.ts b/pmoapp/webapp/src/router/index.ts index 8cb9c97e..7aaaebc6 100644 --- a/pmoapp/webapp/src/router/index.ts +++ b/pmoapp/webapp/src/router/index.ts @@ -1,10 +1,12 @@ import { createRouter, createWebHistory } from "vue-router"; import HelloWorld from "../components/HelloWorld.vue"; import LogView from "../components/LogView.vue"; +import CoverCacheManager from "../components/CoverCacheManager.vue"; const routes = [ { path: "/", name: "home", component: HelloWorld }, { path: "/logs", name: "logs", component: LogView }, + { path: "/covers-cache", name: "covers-cache", component: CoverCacheManager }, ]; const router = createRouter({ diff --git a/pmoapp/webapp/src/services/coverCache.ts b/pmoapp/webapp/src/services/coverCache.ts new file mode 100644 index 00000000..96c36d0d --- /dev/null +++ b/pmoapp/webapp/src/services/coverCache.ts @@ -0,0 +1,120 @@ +/** + * Service API pour interagir avec le cache d'images de couvertures + */ + +export interface CacheEntry { + pk: string; + source_url: string; + hits: number; + last_used: string | null; +} + +export interface AddImageRequest { + url: string; +} + +export interface AddImageResponse { + pk: string; + url: string; + message: string; +} + +export interface ApiError { + error: string; + message: string; +} + +/** + * Liste toutes les images en cache + */ +export async function listImages(): Promise { + const response = await fetch("/api/covers/images"); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch images"); + } + return response.json(); +} + +/** + * RĂ©cupĂšre les informations d'une image spĂ©cifique + */ +export async function getImageInfo(pk: string): Promise { + const response = await fetch(`/api/covers/images/${pk}`); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to fetch image info"); + } + return response.json(); +} + +/** + * Ajoute une nouvelle image au cache depuis une URL + */ +export async function addImage(url: string): Promise { + const response = await fetch("/api/covers/images", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ url }), + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to add image"); + } + return response.json(); +} + +/** + * Supprime une image du cache + */ +export async function deleteImage(pk: string): Promise { + const response = await fetch(`/api/covers/images/${pk}`, { + method: "DELETE", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to delete image"); + } +} + +/** + * Purge complĂštement le cache + */ +export async function purgeCache(): Promise { + const response = await fetch("/api/covers/images", { + method: "DELETE", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to purge cache"); + } +} + +/** + * Consolide le cache (re-tĂ©lĂ©charge les images manquantes) + */ +export async function consolidateCache(): Promise { + const response = await fetch("/api/covers/images/consolidate", { + method: "POST", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to consolidate cache"); + } +} + +/** + * GĂ©nĂšre l'URL pour afficher une image + */ +export function getImageUrl(pk: string, size?: number): string { + if (size) { + return `/covers/images/${pk}/${size}`; + } + return `/covers/images/${pk}`; +} diff --git a/pmoconfig/src/lib.rs b/pmoconfig/src/lib.rs index 580ed071..1633c7d8 100644 --- a/pmoconfig/src/lib.rs +++ b/pmoconfig/src/lib.rs @@ -40,10 +40,13 @@ impl Clone for Config { } impl Config { + pub fn load_config(filename: &str) -> Result { let mut path = filename.to_string(); let mut data: Option> = None; + let mut default_value: Value = serde_yaml::from_str(DEFAULT_CONFIG)?; + // Essayer de charger depuis diffĂ©rents emplacements if !filename.is_empty() { info!(config_file=%path, "Trying to load config"); @@ -97,8 +100,11 @@ impl Config { DEFAULT_CONFIG.as_bytes().to_vec() }; - let mut config_value: Value = serde_yaml::from_slice(&yaml_data)?; - config_value = Self::lower_keys_value(config_value); + + let external_value: Value = serde_yaml::from_slice(&yaml_data)?; + merge_yaml(&mut default_value, &external_value); + let mut config_value = Self::lower_keys_value(default_value); + Self::apply_env_overrides(&mut config_value); if path.is_empty() || !Self::is_writable(&path) { @@ -175,8 +181,10 @@ impl Config { fn get_value_internal(data: &Value, path: &[&str]) -> Result { let mut current = data; for (i, key) in path.iter().enumerate() { + if let Value::Mapping(map) = current { let key = key.to_lowercase(); + if let Some(next) = map.get(&Value::String(key)) { current = next; } else { @@ -317,3 +325,17 @@ impl Config { pub fn get_config() -> Arc { CONFIG.clone() } + +fn merge_yaml(default: &mut Value, external: &Value) { + match (default, external) { + (Value::Mapping(dmap), Value::Mapping(emap)) => { + for (k, v) in emap { + match dmap.get_mut(k) { + Some(dv) => merge_yaml(dv, v), + None => { dmap.insert(k.clone(), v.clone()); } + } + } + } + (d, e) => *d = e.clone(), // pour les scalaires ou sĂ©quences, on remplace + } +} diff --git a/pmoconfig/src/pmomusic.yaml b/pmoconfig/src/pmomusic.yaml index c2acfad7..047af744 100644 --- a/pmoconfig/src/pmomusic.yaml +++ b/pmoconfig/src/pmomusic.yaml @@ -1,6 +1,6 @@ host: http_port: "8080" - cover_cache: + cover_cache: directory: "./.pmomusic_covers" size: 2000 devices: diff --git a/pmocovers/Cargo.toml b/pmocovers/Cargo.toml new file mode 100644 index 00000000..feeca835 --- /dev/null +++ b/pmocovers/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "pmocovers" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Gestion d'images +image = "0.25" +webp = "0.3" + +# Base de donnĂ©es +rusqlite = { version = "0.32", features = ["bundled"] } + +# HTTP client +reqwest = { version = "0.12", features = ["blocking"] } + +# Cryptographie +sha1 = "0.10" +hex = "0.4" + +# Utilitaires +anyhow = "1.0" +chrono = "0.4" +serde = { version = "1.0", features = ["derive"] } + +# Async +tokio = { version = "1.0", features = ["full"] } + +# Serveur HTTP (optionnel pour l'extension) +pmoserver = { path = "../pmoserver", optional = true } +pmoconfig = { path = "../pmoconfig", optional = true } +axum = { version = "0.8", optional = true } +utoipa = { version = "5.3", features = ["axum_extras"], optional = true } + +tracing = "0.1.41" + +[features] +default = ["pmoserver"] +pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa"] diff --git a/pmocovers/src/api.rs b/pmocovers/src/api.rs new file mode 100644 index 00000000..84a31330 --- /dev/null +++ b/pmocovers/src/api.rs @@ -0,0 +1,311 @@ +//! API REST pour la gestion du cache de couvertures +//! +//! Ce module expose une API REST documentĂ©e avec OpenAPI/Swagger pour : +//! - Lister les images en cache +//! - Ajouter des images depuis une URL +//! - Supprimer des images +//! - Consulter les statistiques + +use crate::{Cache, CacheEntry}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use utoipa::ToSchema; + +/// RequĂȘte pour ajouter une image au cache +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AddImageRequest { + /// URL de l'image source + #[schema(example = "https://example.com/cover.jpg")] + pub url: String, +} + +/// RĂ©ponse aprĂšs ajout d'une image +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AddImageResponse { + /// ClĂ© primaire (pk) de l'image ajoutĂ©e + #[schema(example = "1a2b3c4d5e6f7a8b")] + pub pk: String, + /// URL source de l'image + #[schema(example = "https://example.com/cover.jpg")] + pub url: String, + /// Message de succĂšs + #[schema(example = "Image added successfully")] + pub message: String, +} + +/// RĂ©ponse de suppression d'une image +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct DeleteImageResponse { + /// Message de succĂšs + #[schema(example = "Image deleted successfully")] + pub message: String, +} + +/// RĂ©ponse d'erreur gĂ©nĂ©rique +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ErrorResponse { + /// Code d'erreur + #[schema(example = "NOT_FOUND")] + pub error: String, + /// Message descriptif + #[schema(example = "Image not found in cache")] + pub message: String, +} + +/// Liste toutes les images en cache avec leurs statistiques +/// +/// Retourne la liste complĂšte des entrĂ©es du cache triĂ©es par nombre d'accĂšs dĂ©croissant. +#[utoipa::path( + get, + path = "/api/covers", + responses( + (status = 200, description = "Liste des images en cache", body = Vec), + (status = 500, description = "Erreur serveur", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn list_images(State(cache): State>) -> impl IntoResponse { + match cache.db.get_all() { + Ok(entries) => (StatusCode::OK, Json(entries)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "DATABASE_ERROR".to_string(), + message: format!("Cannot retrieve cache entries: {}", e), + }), + ) + .into_response(), + } +} + +/// RĂ©cupĂšre les informations d'une image spĂ©cifique +/// +/// Retourne les mĂ©tadonnĂ©es d'une image identifiĂ©e par sa clĂ© (pk). +#[utoipa::path( + get, + path = "/api/covers/{pk}", + params( + ("pk" = String, Path, description = "ClĂ© primaire de l'image", example = "1a2b3c4d5e6f7a8b") + ), + responses( + (status = 200, description = "Informations de l'image", body = CacheEntry), + (status = 404, description = "Image non trouvĂ©e", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn get_image_info( + State(cache): State>, + Path(pk): Path, +) -> impl IntoResponse { + match cache.db.get(&pk) { + Ok(entry) => (StatusCode::OK, Json(entry)).into_response(), + Err(_) => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "NOT_FOUND".to_string(), + message: format!("Image with pk '{}' not found in cache", pk), + }), + ) + .into_response(), + } +} + +/// Ajoute une image au cache depuis une URL +/// +/// TĂ©lĂ©charge l'image depuis l'URL fournie, la convertit en WebP et l'ajoute au cache. +/// Si l'image existe dĂ©jĂ , elle est mise Ă  jour. +#[utoipa::path( + post, + path = "/api/covers", + request_body = AddImageRequest, + responses( + (status = 201, description = "Image ajoutĂ©e avec succĂšs", body = AddImageResponse), + (status = 400, description = "RequĂȘte invalide", body = ErrorResponse), + (status = 500, description = "Erreur lors du tĂ©lĂ©chargement ou de la conversion", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn add_image( + State(cache): State>, + Json(req): Json, +) -> impl IntoResponse { + if req.url.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "INVALID_REQUEST".to_string(), + message: "URL cannot be empty".to_string(), + }), + ) + .into_response(); + } + + match cache.add_from_url(&req.url).await { + Ok(pk) => ( + StatusCode::CREATED, + Json(AddImageResponse { + pk, + url: req.url, + message: "Image added successfully".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "PROCESSING_ERROR".to_string(), + message: format!("Cannot add image: {}", e), + }), + ) + .into_response(), + } +} + +/// Supprime une image du cache +/// +/// Supprime l'image et toutes ses variantes du disque et de la base de donnĂ©es. +#[utoipa::path( + delete, + path = "/api/covers/{pk}", + params( + ("pk" = String, Path, description = "ClĂ© primaire de l'image Ă  supprimer", example = "1a2b3c4d5e6f7a8b") + ), + responses( + (status = 200, description = "Image supprimĂ©e avec succĂšs", body = DeleteImageResponse), + (status = 404, description = "Image non trouvĂ©e", body = ErrorResponse), + (status = 500, description = "Erreur lors de la suppression", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn delete_image( + State(cache): State>, + Path(pk): Path, +) -> impl IntoResponse { + // VĂ©rifier que l'image existe + if cache.db.get(&pk).is_err() { + return ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "NOT_FOUND".to_string(), + message: format!("Image with pk '{}' not found in cache", pk), + }), + ) + .into_response(); + } + + // Supprimer les fichiers (original + variantes) + let orig_path = cache.dir.join(format!("{}.orig.webp", pk)); + if orig_path.exists() { + if let Err(e) = tokio::fs::remove_file(&orig_path).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "FILE_DELETE_ERROR".to_string(), + message: format!("Cannot delete original file: {}", e), + }), + ) + .into_response(); + } + } + + // Supprimer toutes les variantes (*.{pk}.*.webp) + if let Ok(mut entries) = tokio::fs::read_dir(&cache.dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if let Some(filename) = entry.file_name().to_str() { + if filename.starts_with(&pk) && filename.ends_with(".webp") && filename != format!("{}.orig.webp", pk) { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } + } + + // Supprimer de la base de donnĂ©es + match cache.db.delete(&pk) { + Ok(_) => ( + StatusCode::OK, + Json(DeleteImageResponse { + message: format!("Image '{}' deleted successfully", pk), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "DATABASE_ERROR".to_string(), + message: format!("Cannot delete from database: {}", e), + }), + ) + .into_response(), + } +} + +/// Purge complĂštement le cache +/// +/// Supprime toutes les images et vide la base de donnĂ©es. OpĂ©ration irrĂ©versible. +#[utoipa::path( + delete, + path = "/api/covers", + responses( + (status = 200, description = "Cache purgĂ© avec succĂšs", body = DeleteImageResponse), + (status = 500, description = "Erreur lors de la purge", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn purge_cache(State(cache): State>) -> impl IntoResponse { + match cache.purge().await { + Ok(_) => ( + StatusCode::OK, + Json(DeleteImageResponse { + message: "Cache purged successfully".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "PURGE_ERROR".to_string(), + message: format!("Cannot purge cache: {}", e), + }), + ) + .into_response(), + } +} + +/// Consolide le cache +/// +/// Re-tĂ©lĂ©charge les images manquantes et supprime les fichiers orphelins. +/// Utile pour rĂ©parer un cache corrompu. +#[utoipa::path( + post, + path = "/api/covers/consolidate", + responses( + (status = 200, description = "Cache consolidĂ© avec succĂšs", body = DeleteImageResponse), + (status = 500, description = "Erreur lors de la consolidation", body = ErrorResponse) + ), + tag = "covers" +)] +pub async fn consolidate_cache(State(cache): State>) -> impl IntoResponse { + match cache.consolidate().await { + Ok(_) => ( + StatusCode::OK, + Json(DeleteImageResponse { + message: "Cache consolidated successfully".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "CONSOLIDATE_ERROR".to_string(), + message: format!("Cannot consolidate cache: {}", e), + }), + ) + .into_response(), + } +} diff --git a/pmocovers/src/cache.rs b/pmocovers/src/cache.rs new file mode 100644 index 00000000..424ce87b --- /dev/null +++ b/pmocovers/src/cache.rs @@ -0,0 +1,145 @@ +use std::path::PathBuf; +use std::sync::Arc; +use anyhow::{anyhow, Result}; +use sha1::{Sha1, Digest}; +use tokio::sync::Mutex; +use crate::db::DB; +use crate::webp; + +#[derive(Debug)] +pub struct Cache { + pub(crate) dir: PathBuf, + pub(crate) limit: usize, + pub db: DB, + mu: Arc>, +} + +impl Cache { + pub fn new(dir: &str, limit: usize) -> Result { + std::fs::create_dir_all(dir)?; + let db = DB::init(&PathBuf::from(dir).join("cache.db"))?; + + Ok(Self { + dir: PathBuf::from(dir), + limit, + db, + mu: Arc::new(Mutex::new(())), + }) + } + + pub async fn add_from_url(&self, url: &str) -> Result { + let response = reqwest::get(url).await?; + if !response.status().is_success() { + return Err(anyhow!("Bad status: {}", response.status())); + } + + let data = response.bytes().await?; + self.add(url, &data).await + } + + pub async fn ensure_from_url(&self, url: &str) -> Result { + let pk = pk_from_url(url); + + if self.db.get(&pk).is_ok() { + let orig_path = self.dir.join(format!("{}.orig.webp", pk)); + if orig_path.exists() { + return Ok(pk); + } + } + + self.add_from_url(url).await + } + + pub async fn add(&self, url: &str, data: &[u8]) -> Result { + let pk = pk_from_url(url); + let orig_path = self.dir.join(format!("{}.orig.webp", pk)); + + let _lock = self.mu.lock().await; + + if !orig_path.exists() { + let img = image::load_from_memory(data)?; + let webp_data = webp::encode_webp(&img)?; + tokio::fs::write(&orig_path, webp_data).await?; + } + + self.db.add(&pk, url)?; + Ok(pk) + } + + pub async fn get(&self, pk: &str) -> Result { + let _lock = self.mu.lock().await; + + self.db.get(pk)?; + self.db.update_hit(pk)?; + + let orig_path = self.dir.join(format!("{}.orig.webp", pk)); + if orig_path.exists() { + Ok(orig_path) + } else { + Err(anyhow!("File not found")) + } + } + + pub async fn purge(&self) -> Result<()> { + let _lock = self.mu.lock().await; + + let mut entries = tokio::fs::read_dir(&self.dir).await?; + while let Some(entry) = entries.next_entry().await? { + if entry.path().is_file() { + tokio::fs::remove_file(entry.path()).await?; + } + } + + self.db.purge().map_err(|e| anyhow!("Database error: {}", e)) + } + + pub async fn consolidate(&self) -> Result<()> { + let _lock = self.mu.lock().await; + + let entries = self.db.get_all()?; + + for entry in entries { + let orig_path = self.dir.join(format!("{}.orig.webp", entry.pk)); + if !orig_path.exists() { + match reqwest::get(&entry.source_url).await { + Ok(response) if response.status().is_success() => { + let data = response.bytes().await?; + self.add(&entry.source_url, &data).await?; + } + _ => { + self.db.delete(&entry.pk)?; + } + } + } + } + + let mut dir_entries = tokio::fs::read_dir(&self.dir).await?; + while let Some(entry) = dir_entries.next_entry().await? { + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name.ends_with(".orig.webp") { + let pk = file_name.trim_end_matches(".orig.webp"); + if self.db.get(pk).is_err() { + tokio::fs::remove_file(path).await?; + } + } + } + } + } + + Ok(()) + } + + pub fn cache_dir(&self) -> String { + self.dir.to_string_lossy().to_string() + } + +} + +fn pk_from_url(url: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(url.as_bytes()); + let result = hasher.finalize(); + hex::encode(&result[..8]) +} \ No newline at end of file diff --git a/pmocovers/src/db.rs b/pmocovers/src/db.rs new file mode 100644 index 00000000..c7de1120 --- /dev/null +++ b/pmocovers/src/db.rs @@ -0,0 +1,118 @@ +use rusqlite::{Connection, params}; +use serde::Serialize; +use chrono::Utc; +use std::path::Path; +use std::sync::Mutex; + +#[cfg(feature = "pmoserver")] +use utoipa::ToSchema; + +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "pmoserver", derive(ToSchema))] +pub struct CacheEntry { + /// ClĂ© primaire unique de l'image (hash SHA1 de l'URL) + #[cfg_attr(feature = "pmoserver", schema(example = "1a2b3c4d5e6f7a8b"))] + pub pk: String, + /// URL source de l'image + #[cfg_attr(feature = "pmoserver", schema(example = "https://example.com/cover.jpg"))] + pub source_url: String, + /// Nombre d'accĂšs Ă  l'image + #[cfg_attr(feature = "pmoserver", schema(example = 42))] + pub hits: i32, + /// Date/heure du dernier accĂšs (RFC3339) + #[cfg_attr(feature = "pmoserver", schema(example = "2025-01-15T10:30:00Z"))] + pub last_used: Option, +} + +#[derive(Debug)] +pub struct DB { + conn: Mutex, +} + +impl DB { + pub fn init(path: &Path) -> Result { + let conn = Connection::open(path)?; + conn.execute( + "CREATE TABLE IF NOT EXISTS covers ( + pk TEXT PRIMARY KEY, + source_url TEXT, + hits INTEGER DEFAULT 0, + last_used TEXT + )", + [], + )?; + + Ok(Self { conn: Mutex::new(conn) }) + } + + pub fn add(&self, pk: &str, url: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO covers (pk, source_url, hits, last_used) + VALUES (?1, ?2, 0, ?3) + ON CONFLICT(pk) DO UPDATE SET + source_url = excluded.source_url, + last_used = excluded.last_used", + params![pk, url, Utc::now().to_rfc3339()], + )?; + + Ok(()) + } + + pub fn get(&self, pk: &str) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT pk, source_url, hits, last_used FROM covers WHERE pk = ?1", + [pk], + |row| { + Ok(CacheEntry { + pk: row.get(0)?, + source_url: row.get(1)?, + hits: row.get(2)?, + last_used: row.get(3)?, + }) + }, + ) + } + + pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE covers SET hits = hits + 1, last_used = ?1 WHERE pk = ?2", + params![Utc::now().to_rfc3339(), pk], + )?; + + Ok(()) + } + + pub fn purge(&self) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM covers", [])?; + Ok(()) + } + + pub fn get_all(&self) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT pk, source_url, hits, last_used FROM covers ORDER BY hits DESC", + )?; + + let entries = stmt.query_map([], |row| { + Ok(CacheEntry { + pk: row.get(0)?, + source_url: row.get(1)?, + hits: row.get(2)?, + last_used: row.get(3)?, + }) + })? + .collect::>>()?; + + Ok(entries) + } + + pub fn delete(&self, pk: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM covers WHERE pk = ?1", [pk])?; + Ok(()) + } +} diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs new file mode 100644 index 00000000..ba64c6a5 --- /dev/null +++ b/pmocovers/src/lib.rs @@ -0,0 +1,277 @@ +//! # pmocovers - Service de cache d'images de couvertures pour PMOMusic +//! +//! Cette crate fournit un systĂšme de cache d'images optimisĂ© pour les couvertures d'albums, +//! avec conversion automatique en WebP et gĂ©nĂ©ration de variantes de tailles. +//! +//! ## Vue d'ensemble +//! +//! `pmocovers` gĂšre le tĂ©lĂ©chargement, la conversion, le stockage et la distribution +//! d'images de couvertures d'albums, avec : +//! - Conversion automatique en WebP pour rĂ©duire la taille +//! - GĂ©nĂ©ration de variantes de tailles Ă  la demande +//! - Cache persistant avec base de donnĂ©es SQLite +//! - API HTTP pour rĂ©cupĂ©rer les images +//! +//! ## FonctionnalitĂ©s +//! +//! ### 📩 Gestion du cache +//! - TĂ©lĂ©chargement automatique depuis des URLs +//! - Conversion des images en WebP (format optimisĂ©) +//! - Stockage persistant sur disque +//! - Base de donnĂ©es SQLite pour le tracking +//! +//! ### 🎹 GĂ©nĂ©ration de variantes +//! - Redimensionnement automatique Ă  la demande +//! - CrĂ©ation d'images carrĂ©es avec centrage +//! - Cache des variantes gĂ©nĂ©rĂ©es +//! - Support de multiples tailles +//! +//! ### 📊 Statistiques d'utilisation +//! - Comptage des accĂšs (hits) +//! - Suivi de la derniĂšre utilisation +//! - API de statistiques complĂštes +//! +//! ## Architecture +//! +//! `pmocovers` suit le pattern d'extension des autres crates PMO : +//! +//! - `pmoserver` dĂ©finit un serveur HTTP gĂ©nĂ©rique +//! - `pmocovers` Ă©tend ce serveur avec des mĂ©thodes de cache via un trait +//! - Le serveur n'a pas besoin de connaĂźtre `pmocovers` +//! +//! ## Structure des fichiers +//! +//! ```text +//! pmocovers/ +//! ├── Cargo.toml +//! ├── src/ +//! │ ├── lib.rs # Module principal (ce fichier) +//! │ ├── cache.rs # Gestion du cache +//! │ ├── db.rs # Base de donnĂ©es SQLite +//! │ ├── webp.rs # Conversion et redimensionnement WebP +//! │ └── pmoserver_impl.rs # Extension de pmoserver::Server +//! └── cache/ # RĂ©pertoire de cache (gĂ©nĂ©rĂ©) +//! ├── cache.db # Base SQLite +//! ├── *.orig.webp # Images originales +//! └── *.{size}.webp # Variantes de tailles +//! ``` +//! +//! ## Utilisation +//! +//! ### Exemple basique avec configuration automatique +//! +//! ```rust,no_run +//! use pmocovers::CoverCacheExt; +//! use pmoserver::ServerBuilder; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new_configured().build(); +//! +//! // Utilise automatiquement la config (pmoconfig) +//! server.init_cover_cache_configured().await?; +//! +//! server.start().await; +//! server.wait().await; +//! Ok(()) +//! } +//! ``` +//! +//! ### Exemple avec paramĂštres personnalisĂ©s +//! +//! ```rust,no_run +//! use pmocovers::CoverCacheExt; +//! use pmoserver::ServerBuilder; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build(); +//! +//! // ParamĂštres personnalisĂ©s +//! server.init_cover_cache("./cache", 1000).await?; +//! +//! server.start().await; +//! server.wait().await; +//! Ok(()) +//! } +//! ``` +//! +//! ### Utilisation du cache directement +//! +//! ```rust,no_run +//! use pmocovers::Cache; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let cache = Cache::new("./cache", 1000)?; +//! +//! // Ajouter une image depuis une URL +//! let pk = cache.add_from_url("http://example.com/cover.jpg").await?; +//! println!("Image ajoutĂ©e avec clĂ©: {}", pk); +//! +//! // RĂ©cupĂ©rer l'image originale +//! let path = cache.get(&pk).await?; +//! println!("Image stockĂ©e Ă : {:?}", path); +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## API HTTP +//! +//! Une fois enregistrĂ© sur un serveur via `CoverCacheExt`, les endpoints suivants sont disponibles : +//! +//! ### GET /covers/images/{pk} +//! RĂ©cupĂšre l'image originale en WebP +//! +//! ### GET /covers/images/{pk}/{size} +//! RĂ©cupĂšre une variante de taille spĂ©cifique (ex: `/covers/images/abc123/256`) +//! +//! ### GET /covers/stats +//! RĂ©cupĂšre les statistiques du cache (JSON) +//! +//! ## Format des clĂ©s (pk) +//! +//! Les images sont identifiĂ©es par une clĂ© (pk) dĂ©rivĂ©e de l'URL source : +//! - Hash SHA1 de l'URL +//! - EncodĂ© en hexadĂ©cimal (8 premiers octets) +//! - Exemple: `"1a2b3c4d5e6f7a8b"` +//! +//! ## Stockage +//! +//! Les fichiers sont organisĂ©s comme suit : +//! +//! ```text +//! cache/ +//! ├── cache.db # Base SQLite +//! ├── 1a2b3c4d.orig.webp # Image originale +//! ├── 1a2b3c4d.256.webp # Variante 256x256 +//! └── 1a2b3c4d.512.webp # Variante 512x512 +//! ``` +//! +//! ## OpĂ©rations de maintenance +//! +//! ### Purge du cache +//! +//! ```rust,no_run +//! # use pmocovers::Cache; +//! # async fn example(cache: &Cache) -> anyhow::Result<()> { +//! // Supprimer tous les fichiers et entrĂ©es DB +//! cache.purge().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Consolidation du cache +//! +//! ```rust,no_run +//! # use pmocovers::Cache; +//! # async fn example(cache: &Cache) -> anyhow::Result<()> { +//! // Re-tĂ©lĂ©charger les images manquantes et supprimer les orphelins +//! cache.consolidate().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## DĂ©pendances principales +//! +//! - `image` : Chargement et manipulation d'images +//! - `webp` : Encodage WebP +//! - `rusqlite` : Base de donnĂ©es SQLite +//! - `reqwest` : TĂ©lĂ©chargement HTTP +//! - `sha1` : GĂ©nĂ©ration de clĂ©s +//! +//! ## Voir aussi +//! +//! - [`pmoserver`] : Serveur HTTP Axum +//! - [`pmoapp`] : Application web frontend +//! - [`pmoupnp`] : BibliothĂšque UPnP MediaRenderer + +pub mod cache; +pub mod db; +pub mod webp; + +#[cfg(feature = "pmoserver")] +pub mod api; + +#[cfg(feature = "pmoserver")] +pub mod openapi; + +pub use cache::Cache; +pub use db::{CacheEntry, DB}; + +#[cfg(feature = "pmoserver")] +pub use openapi::ApiDoc; + +use anyhow::Result; +use std::sync::Arc; + +/// Trait pour Ă©tendre un serveur HTTP avec des fonctionnalitĂ©s de cache d'images. +/// +/// Ce trait permet Ă  `pmocovers` d'ajouter des mĂ©thodes d'extension sur des types +/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dĂ©pendent de `pmocovers`. +/// +/// # Architecture +/// +/// Similaire au pattern utilisĂ© par `pmoapp` pour `WebAppExt`, ce trait permet +/// une extension propre et dĂ©couplĂ©e : +/// +/// - `pmoserver` dĂ©finit un serveur HTTP gĂ©nĂ©rique +/// - `pmocovers` Ă©tend ce serveur avec des mĂ©thodes de cache via ce trait +/// - Le serveur n'a pas besoin de connaĂźtre `pmocovers` +pub trait CoverCacheExt { + /// Initialise le cache d'images et enregistre les routes HTTP. + /// + /// # Arguments + /// + /// * `cache_dir` - RĂ©pertoire de stockage du cache + /// * `limit` - Limite de taille du cache (en nombre d'images) + /// + /// # Returns + /// + /// * `Arc` - Instance partagĂ©e du cache + /// + /// # Routes enregistrĂ©es + /// + /// - `GET /covers/images/{pk}` - Image originale + /// - `GET /covers/images/{pk}/{size}` - Variante de taille + /// - `GET /covers/stats` - Statistiques + /// - `GET /api/covers` - Liste des images (API REST) + /// - `POST /api/covers` - Ajouter une image (API REST) + /// - `DELETE /api/covers/{pk}` - Supprimer une image (API REST) + /// - `GET /swagger-ui` - Documentation interactive + async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> Result>; + + /// Initialise le cache d'images avec la configuration par dĂ©faut. + /// + /// Utilise automatiquement les paramĂštres de `pmoconfig::Config` : + /// - `host.cover_cache.directory` pour le rĂ©pertoire + /// - `host.cover_cache.size` pour la limite de taille + /// + /// # Returns + /// + /// * `Arc` - Instance partagĂ©e du cache + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmocovers::CoverCacheExt; + /// use pmoserver::ServerBuilder; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let mut server = ServerBuilder::new_configured().build(); + /// + /// // Utilise automatiquement la config + /// server.init_cover_cache_configured().await?; + /// + /// server.start().await; + /// Ok(()) + /// } + /// ``` + async fn init_cover_cache_configured(&mut self) -> Result>; +} + +// ImplĂ©mentation du trait pour pmoserver::Server (feature-gated) +#[cfg(feature = "pmoserver")] +mod pmoserver_impl; diff --git a/pmocovers/src/openapi.rs b/pmocovers/src/openapi.rs new file mode 100644 index 00000000..a86cfd0b --- /dev/null +++ b/pmocovers/src/openapi.rs @@ -0,0 +1,70 @@ +//! Documentation OpenAPI pour l'API REST du cache de couvertures + +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::api::list_images, + crate::api::get_image_info, + crate::api::add_image, + crate::api::delete_image, + crate::api::purge_cache, + crate::api::consolidate_cache, + ), + components( + schemas( + crate::db::CacheEntry, + crate::api::AddImageRequest, + crate::api::AddImageResponse, + crate::api::DeleteImageResponse, + crate::api::ErrorResponse, + ) + ), + tags( + (name = "covers", description = "Gestion du cache d'images de couvertures") + ), + info( + title = "PMOCovers API", + version = "0.1.0", + description = r#" +# API de gestion du cache d'images de couvertures + +Cette API permet de gĂ©rer un cache d'images optimisĂ© pour les couvertures d'albums. + +## FonctionnalitĂ©s + +- **Ajout d'images** : TĂ©lĂ©chargement depuis une URL avec conversion automatique en WebP +- **Consultation** : Liste des images avec statistiques d'utilisation +- **Suppression** : Suppression individuelle ou purge complĂšte +- **Maintenance** : Consolidation du cache pour rĂ©parer les incohĂ©rences + +## Format des images + +Les images sont stockĂ©es au format WebP avec : +- Une version originale (`{pk}.orig.webp`) +- Des variantes de tailles gĂ©nĂ©rĂ©es Ă  la demande (`{pk}.{size}.webp`) + +## ClĂ©s (pk) + +Chaque image est identifiĂ©e par une clĂ© (pk) unique : +- Hash SHA1 des 8 premiers octets de l'URL source +- Encodage hexadĂ©cimal +- Exemple : `1a2b3c4d5e6f7a8b` + +## Statistiques + +Le systĂšme suit automatiquement : +- Le nombre d'accĂšs (hits) +- La date du dernier accĂšs +- L'URL source originale + "#, + contact( + name = "PMOMusic", + ), + license( + name = "MIT", + ), + ) +)] +pub struct ApiDoc; diff --git a/pmocovers/src/pmoserver_impl.rs b/pmocovers/src/pmoserver_impl.rs new file mode 100644 index 00000000..18743e5b --- /dev/null +++ b/pmocovers/src/pmoserver_impl.rs @@ -0,0 +1,166 @@ +//! ImplĂ©mentation du trait CoverCacheExt pour le serveur pmoserver +//! +//! Ce module enrichit `pmoserver::Server` avec les fonctionnalitĂ©s de cache d'images en +//! implĂ©mentant le trait [`CoverCacheExt`](crate::CoverCacheExt). Cette implĂ©mentation +//! permet d'initialiser facilement le cache et d'enregistrer les routes HTTP. +//! +//! ## Architecture +//! +//! `pmocovers` Ă©tend `pmoserver::Server` sans que `pmoserver` connaisse `pmocovers`. +//! C'est le pattern d'extension : `pmocovers` ajoute des fonctionnalitĂ©s Ă  un type +//! externe via un trait, similaire au pattern utilisĂ© par `pmoapp` pour `WebAppExt`. +//! +//! ## Exemple d'utilisation +//! +//! ```rust,no_run +//! use pmocovers::CoverCacheExt; +//! use pmoserver::ServerBuilder; +//! +//! # async fn example() -> anyhow::Result<()> { +//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build(); +//! +//! // Le trait CoverCacheExt est automatiquement disponible +//! let cache = server.init_cover_cache("./cache", 1000).await?; +//! +//! server.start().await; +//! # Ok(()) +//! # } +//! ``` + +use crate::{api, Cache, CoverCacheExt}; +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use pmoserver::Server; +use tracing::{debug, info}; +use std::sync::Arc; +use utoipa::OpenApi; + + + +/// Handler pour GET /covers/images/{pk} +async fn get_cover_image( + State(cache): State>, + req: Request, +) -> Response { + // Extraire pk du path + let path = req.uri().path(); + let parts: Vec<&str> = path.split('/').collect(); + + if parts.len() < 4 { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + let pk = parts[3]; + + match cache.get(pk).await { + Ok(file_path) => { + match tokio::fs::read(&file_path).await { + Ok(data) => ( + StatusCode::OK, + [("content-type", "image/webp")], + data, + ) + .into_response(), + Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), + } + } + Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(), + } +} + +/// Handler pour GET /covers/images/{pk}/{size} +async fn get_cover_variant( + State(cache): State>, + req: Request, +) -> Response { + // Extraire pk et size du path + let path = req.uri().path(); + let parts: Vec<&str> = path.split('/').collect(); + + if parts.len() < 5 { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + let pk = parts[3]; + let size = match parts[4].parse::() { + Ok(s) => s, + Err(_) => return (StatusCode::BAD_REQUEST, "Invalid size").into_response(), + }; + + match crate::webp::generate_variant(&cache, pk, size).await { + Ok(data) => ( + StatusCode::OK, + [("content-type", "image/webp")], + data, + ) + .into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot generate variant").into_response(), + } +} + +/// Handler pour GET /covers/stats +async fn get_cover_stats(State(cache): State>) -> Response { + match cache.db.get_all() { + Ok(entries) => Json(entries).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot retrieve stats").into_response(), + } +} + +impl CoverCacheExt for Server { + async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result> { + let cache = Arc::new(Cache::new(cache_dir, limit)?); + + // Enregistrer les routes HTTP classiques + self.add_handler_with_state("/covers/images", get_cover_image, cache.clone()).await; + self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await; + + // Router API RESTful + // Router API RESTful montĂ© sur /api/covers + let api_router = Router::new() + // Liste et ajout + .route( + "/images/", + get(api::list_images) // GET /api/covers + .post(api::add_image) // POST /api/covers + .delete(api::purge_cache), // DELETE /api/covers + ) + // Ressource unique + .route( + "/images/{pk}", + get(api::get_image_info) // GET /api/covers/{pk} + .delete(api::delete_image), // DELETE /api/covers/{pk} + ) + // Action spĂ©cifique + .route( + "/images/consolidate", + post(api::consolidate_cache), // POST /api/covers/consolidate + ) + .with_state(cache.clone()); + + // Documentation OpenAPI via Utoipa + let openapi = crate::ApiDoc::openapi(); + + // Enregistrer l'API avec Swagger UI + // /api/covers/images... et /swagger-ui/covers + self.add_openapi(api_router, openapi, "covers").await; + + Ok(cache) + } + + async fn init_cover_cache_configured(&mut self) -> anyhow::Result> { + let config = pmoconfig::get_config(); + + let cache_dir = config.get_cover_cache_dir()?; + let limit = config.get_cover_cache_size()?; + + info!("cache directory {}, size {}",cache_dir,limit); + + self.init_cover_cache(&cache_dir, limit).await + } +} diff --git a/pmocovers/src/webp.rs b/pmocovers/src/webp.rs new file mode 100644 index 00000000..42f1d7c3 --- /dev/null +++ b/pmocovers/src/webp.rs @@ -0,0 +1,61 @@ +use anyhow::Result; +use image::{DynamicImage, imageops::FilterType}; +use webp::{Encoder, WebPMemory}; + +pub fn encode_webp(img: &DynamicImage) -> Result> { + let rgb_img = img.to_rgba8(); + let encoder = Encoder::from_rgba(&rgb_img, rgb_img.width(), rgb_img.height()); + let webp_data: WebPMemory = encoder.encode(85.0); + Ok(webp_data.to_vec()) +} + +pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage { + let (width, height) = (img.width(), img.height()); + + // Calculer le ratio de mise Ă  l'Ă©chelle + let scale = if width > height { + size as f32 / width as f32 + } else { + size as f32 / height as f32 + }; + + let new_width = (width as f32 * scale) as u32; + let new_height = (height as f32 * scale) as u32; + + // Redimensionner l'image + let resized = img.resize(new_width, new_height, FilterType::Lanczos3); + + // CrĂ©er une image carrĂ©e avec fond transparent + let mut square = DynamicImage::new_rgba8(size, size); + + // Calculer la position pour centrer l'image redimensionnĂ©e + let x = (size - new_width) / 2; + let y = (size - new_height) / 2; + + // Copier l'image redimensionnĂ©e au centre du carrĂ© + image::imageops::overlay(&mut square, &resized, x.into(), y.into()); + + square +} + +pub async fn generate_variant(cache: &super::cache::Cache, pk: &str, size: usize) -> Result> { + let variant_path = cache.dir.join(format!("{}.{}.webp", pk, size)); + + if variant_path.exists() { + return Ok(tokio::fs::read(variant_path).await?); + } + + let orig_path = cache.dir.join(format!("{}.orig.webp", pk)); + + // Charger l'image de maniĂšre synchrone (image::open n'est pas async) + let img = tokio::task::spawn_blocking(move || { + image::open(orig_path) + }) + .await??; + + let square = ensure_square(&img, size as u32); + let webp_data = encode_webp(&square)?; + + tokio::fs::write(&variant_path, &webp_data).await?; + Ok(webp_data) +} diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index 703cfa17..d22de2a0 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -13,6 +13,7 @@ //! - 📚 **Documentation API** : OpenAPI/Swagger automatique avec `add_openapi()` //! - ⚡ **Gestion gracieuse** : ArrĂȘt propre sur Ctrl+C +use crate::logs::{LogState, LoggingOptions, init_logging, log_dump, log_sse}; use axum::handler::Handler; use axum::response::Redirect; use axum::routing::get; @@ -25,7 +26,6 @@ use std::{net::SocketAddr, sync::Arc}; use tokio::{signal, sync::RwLock, task::JoinHandle}; use tracing::info; use utoipa_swagger_ui::SwaggerUi; -use crate::logs::{LogState, LoggingOptions, init_logging, log_sse, log_dump}; /// Info serveur sĂ©rialisable #[derive(Clone, Serialize, utoipa::ToSchema)] @@ -149,7 +149,7 @@ impl Server { /// ```ignore /// use pmoupnp::server::Server; /// use rust_embed::RustEmbed; - /// + /// /// #[derive(RustEmbed, Clone)] /// #[folder = "static/"] /// struct Assets; @@ -166,9 +166,9 @@ impl Server { E: RustEmbed + Clone + Send + Sync + 'static, { let serve = ServeEmbed::::new(); - + let mut r = self.router.write().await; - + if path == "/" { *r = std::mem::take(&mut *r).fallback_service(serve); } else { @@ -226,9 +226,9 @@ impl Server { axum_embed::FallbackBehavior::Ok, Some("index.html".to_string()), ); - + let mut r = self.router.write().await; - + if path == "/" { *r = std::mem::take(&mut *r).fallback_service(serve); } else { @@ -290,18 +290,18 @@ impl Server { /// use axum::extract::State; /// use axum::response::sse::{Event, Sse, KeepAlive}; /// use tokio::sync::broadcast; - /// + /// /// #[derive(Clone)] - /// struct LogState { - /// tx: broadcast::Sender + /// struct LogState { + /// tx: broadcast::Sender /// } - /// - /// impl LogState { - /// fn subscribe(&self) -> broadcast::Receiver { - /// self.tx.subscribe() - /// } + /// + /// impl LogState { + /// fn subscribe(&self) -> broadcast::Receiver { + /// self.tx.subscribe() + /// } /// } - /// + /// /// async fn log_sse(State(state): State) -> Sse>> { /// let mut rx = state.subscribe(); /// let stream = async_stream::stream! { @@ -321,9 +321,7 @@ impl Server { T: 'static, S: Clone + Send + Sync + 'static, { - let route = Router::new() - .route("/", get(handler)) - .with_state(state); + let route = Router::new().route("/", get(handler)).with_state(state); let mut r = self.router.write().await; *r = std::mem::take(&mut *r).nest(path, route); @@ -391,14 +389,16 @@ impl Server { } } - /// Ajoute une API documentĂ©e avec OpenAPI + /// Ajoute une API documentĂ©e avec OpenAPI et Swagger UI /// - /// Monte un routeur d'API sous `/api` et active Swagger UI sur `/swagger-ui` + /// Cette mĂ©thode fusionne le `api_router` fourni avec le router principal du serveur. + /// Chaque appel peut ajouter une nouvelle API distincte, avec sa propre documentation Swagger. /// /// # Arguments /// /// * `api_router` - Router Axum contenant les routes API - /// * `openapi` - SpĂ©cification OpenAPI gĂ©nĂ©rĂ©e par utoipa + /// * `openapi` - SpĂ©cification OpenAPI gĂ©nĂ©rĂ©e par `utoipa` + /// * `name` - Nom unique pour cette API, utilisĂ© pour diffĂ©rencier le chemin Swagger UI et le JSON OpenAPI /// /// # Exemple /// @@ -418,7 +418,7 @@ impl Server { /// paths(get_users), /// components(schemas(User)) /// )] - /// struct ApiDoc; + /// struct ApiDoc1; /// /// #[utoipa::path( /// get, @@ -429,24 +429,64 @@ impl Server { /// Json(vec![]) /// } /// - /// let api_router = Router::new() - /// .route("/users", get(get_users)); + /// #[derive(utoipa::OpenApi)] + /// #[openapi( + /// paths(get_products), + /// components(schemas(Product)) + /// )] + /// struct ApiDoc2; /// - /// server.add_openapi(api_router, ApiDoc::openapi()).await; + /// #[utoipa::path( + /// get, + /// path = "/products", + /// responses((status = 200, description = "List products")) + /// )] + /// async fn get_products() -> Json> { + /// Json(vec![]) + /// } + /// + /// let api_router1 = Router::new().route("/users", get(get_users)); + /// let api_router2 = Router::new().route("/products", get(get_products)); + /// + /// // Ajouter les deux API au serveur, chacune avec son nom unique + /// server.add_openapi(api_router1, ApiDoc1::openapi(), "api1").await; + /// server.add_openapi(api_router2, ApiDoc2::openapi(), "api2").await; /// ``` - pub async fn add_openapi(&mut self, api_router: Router, openapi: utoipa::openapi::OpenApi) { - // Stocker le routeur API + /// + /// RĂ©sultat : + /// + /// - `/users` et `/products` sont accessibles via Axum. + /// - `/swagger-ui/api1` et `/swagger-ui/api2` affichent la documentation Swagger correspondante. + /// - `/api-docs/api1.json` et `/api-docs/api2.json` fournissent les spĂ©cifications OpenAPI respectives. + + pub async fn add_openapi( + &mut self, + api_router: Router, + openapi: utoipa::openapi::OpenApi, + name: &str, // nom unique pour diffĂ©rencier Swagger et OpenAPI + ) { + use axum::routing::get; + use utoipa_swagger_ui::SwaggerUi; + + // Stocker le router API dans self.api_router si tu veux y accĂ©der plus tard let mut api_r = self.api_router.write().await; - *api_r = Some(api_router); + *api_r = Some(api_router.clone()); - // Ajouter Swagger UI - let swagger = SwaggerUi::new("/swagger-ui") - .url("/api-docs/openapi.json", openapi); + // GĂ©nĂ©rer des chemins uniques pour Swagger UI et OpenAPI JSON + let swagger_path = format!("/swagger-ui/{}", name); + let swagger_path_static: &'static str = Box::leak(swagger_path.into_boxed_str()); + let openapi_json_path = format!("/api-docs/{}.json", name); + let openapi_json_path_static: &'static str = Box::leak(openapi_json_path.into_boxed_str()); + + let swagger = SwaggerUi::new(swagger_path_static).url(openapi_json_path_static, openapi); + + // Fusionner avec le router principal let mut r = self.router.write().await; - *r = std::mem::take(&mut *r).merge(swagger); + let mut combined = std::mem::take(&mut *r); + combined = combined.merge(api_router).merge(swagger); + *r = combined; } - /// DĂ©marre le serveur HTTP /// /// Lance le serveur sur le port configurĂ© et met en place la gestion @@ -465,7 +505,10 @@ impl Server { /// ``` pub async fn start(&mut self) { let addr = SocketAddr::from(([0, 0, 0, 0], self.http_port)); - info!("Server {} running at [http://{}:{}](http://{}:{})", self.name, self.base_url, self.http_port, self.base_url, self.http_port); + info!( + "Server {} running at [http://{}:{}](http://{}:{})", + self.name, self.base_url, self.http_port, self.base_url, self.http_port + ); // Merger le routeur API si prĂ©sent let api_router = self.api_router.read().await; @@ -545,8 +588,10 @@ impl Server { let log_state = init_logging(options); // Enregistrer automatiquement les routes de logging - self.add_handler_with_state("/log-sse", log_sse, log_state.clone()).await; - self.add_handler_with_state("/log-dump", log_dump, log_state.clone()).await; + self.add_handler_with_state("/log-sse", log_sse, log_state.clone()) + .await; + self.add_handler_with_state("/log-dump", log_dump, log_state.clone()) + .await; self.log_state = Some(log_state); } @@ -580,7 +625,7 @@ impl ServerBuilder { Self { name: "PMO-Music-Server".to_string(), base_url: config.get_base_url(), - http_port: config.get_http_port() + http_port: config.get_http_port(), } } @@ -598,4 +643,4 @@ impl ServerBuilder { pub fn build(self) -> Server { Server::new(self.name, self.base_url, self.http_port) } -} \ No newline at end of file +}