From 2e4b69e758907441517bb30d1beb82ea4e9d7a95 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 17 Oct 2025 22:15:00 +0200 Subject: [PATCH] adaptation de la crate pmocovers --- pmocache/src/api.rs | 2 +- pmocache/src/cache.rs | 137 ++++++++++++-- pmocache/src/db.rs | 50 +++++ pmocache/src/pmoserver_ext.rs | 108 +++++++++-- pmocovers/src/api.rs | 312 -------------------------------- pmocovers/src/cache.rs | 220 ++++++---------------- pmocovers/src/lib.rs | 295 +++++++++--------------------- pmocovers/src/openapi.rs | 57 ++++-- pmocovers/src/pmoserver_impl.rs | 144 --------------- 9 files changed, 450 insertions(+), 875 deletions(-) delete mode 100644 pmocovers/src/api.rs delete mode 100644 pmocovers/src/pmoserver_impl.rs diff --git a/pmocache/src/api.rs b/pmocache/src/api.rs index b18f4651..5a043182 100644 --- a/pmocache/src/api.rs +++ b/pmocache/src/api.rs @@ -7,7 +7,7 @@ //! - Supprimer des items //! - Purger et consolider le cache -use crate::{Cache, CacheConfig, CacheEntry}; +use crate::{Cache, CacheConfig}; use axum::{ extract::{Path, State}, http::StatusCode, diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index 056a4453..2f5582fc 100644 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -11,6 +11,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::RwLock; +use tracing; /// Trait pour définir les paramètres du cache pub trait CacheConfig: Send + Sync { @@ -46,7 +47,6 @@ pub trait CacheConfig: Send + Sync { /// Note : Ce type est conçu pour être utilisé derrière un `Arc`. /// La synchronisation est gérée par le Mutex interne de la base de données SQLite /// et par le RwLock pour la map des downloads. -#[derive(Debug)] pub struct Cache { /// Répertoire de stockage dir: PathBuf, @@ -58,12 +58,14 @@ pub struct Cache { pub db: Arc, /// Map des downloads en cours (pk -> Download) downloads: Arc>>>, + /// Factory pour créer des transformers (optionnel) + transformer_factory: Option StreamTransformer + Send + Sync>>, /// Phantom data pour le type de configuration _phantom: std::marker::PhantomData, } impl Cache { - /// Crée un nouveau cache + /// Crée un nouveau cache sans transformer /// /// # Arguments /// @@ -71,6 +73,52 @@ impl Cache { /// * `limit` - Limite de taille du cache (nombre d'éléments) /// * `base_url` - URL de base pour la génération d'URLs pub fn new(dir: &str, limit: usize, base_url: &str) -> Result { + Self::with_transformer(dir, limit, base_url, None) + } + + /// Crée un nouveau cache avec un transformer optionnel + /// + /// # Arguments + /// + /// * `dir` - Répertoire de stockage du cache + /// * `limit` - Limite de taille du cache (nombre d'éléments) + /// * `base_url` - URL de base pour la génération d'URLs + /// * `transformer_factory` - Factory pour créer des transformers à chaque téléchargement + /// + /// # Exemple + /// + /// ```rust,no_run + /// use pmocache::{Cache, CacheConfig, StreamTransformer}; + /// use std::sync::Arc; + /// + /// struct MyConfig; + /// impl CacheConfig for MyConfig { + /// fn file_extension() -> &'static str { "dat" } + /// } + /// + /// let transformer_factory = Arc::new(|| { + /// // Créer un transformer qui convertit les données + /// Box::new(|response, file, progress| { + /// Box::pin(async move { + /// // Transformation personnalisée + /// Ok(()) + /// }) + /// }) as StreamTransformer + /// }); + /// + /// let cache = Cache::::with_transformer( + /// "./cache", + /// 1000, + /// "http://localhost:8080", + /// Some(transformer_factory) + /// ).unwrap(); + /// ``` + pub fn with_transformer( + dir: &str, + limit: usize, + base_url: &str, + transformer_factory: Option StreamTransformer + Send + Sync>>, + ) -> Result { let directory = PathBuf::from(dir); std::fs::create_dir_all(&directory)?; let db = DB::init(&directory.join("cache.db"), C::table_name())?; @@ -81,18 +129,11 @@ impl Cache { base_url: base_url.to_string(), db: Arc::new(db), downloads: Arc::new(RwLock::new(HashMap::new())), + transformer_factory, _phantom: std::marker::PhantomData, }) } - /// Retourne le transformer pour ce cache - /// - /// Par défaut retourne None (pas de transformation). - /// Les caches spécialisés peuvent surcharger cette méthode. - fn get_transformer(&self) -> Option { - None - } - /// Télécharge un fichier depuis une URL et l'ajoute au cache /// /// Utilise le module download pour gérer le téléchargement asynchrone. @@ -120,10 +161,11 @@ impl Cache { } // Lancer le téléchargement avec transformer + let transformer = self.transformer_factory.as_ref().map(|f| f()); let download = download_with_transformer( &file_path, url, - self.get_transformer(), + transformer, ); // Stocker dans la map des downloads en cours @@ -135,6 +177,12 @@ impl Cache { // Ajouter immédiatement à la DB self.db.add(&pk, url, collection)?; + // Appliquer la politique d'éviction LRU si nécessaire + // Cela garantit que le cache respecte toujours la limite configurée + if let Err(e) = self.enforce_limit().await { + tracing::warn!("Error enforcing cache limit: {}", e); + } + // Lancer une tâche de nettoyage en background let downloads_clone = self.downloads.clone(); let pk_clone = pk.clone(); @@ -413,11 +461,78 @@ impl Cache { &self.base_url } + /// Construit le chemin complet d'un fichier dans le cache avec le param par défaut + /// + /// Format: `{pk}.{default_param}.{extension}` + pub fn file_path(&self, pk: &str) -> PathBuf { + self.file_path_with_qualifier(pk, C::default_param()) + } + + /// Construit le chemin d'un fichier dans le cache avec un qualificatif + /// + /// Format: `{pk}.{qualifier}.{extension}` + pub fn file_path_with_qualifier(&self, pk: &str, qualifier: &str) -> PathBuf { + self.dir.join(format!("{}.{}.{}", pk, qualifier, C::file_extension())) + } + /// Valide les données avant de les stocker /// Par défaut, accepte toutes les données pub fn validate_data(&self, data: &[u8]) -> Result> { Ok(data.to_vec()) } + + /// Applique la politique d'éviction LRU (Least Recently Used) + /// + /// Si le nombre d'entrées dépasse la limite configurée, supprime + /// les entrées les plus anciennes (moins récemment utilisées). + /// + /// Cette méthode : + /// 1. Compte le nombre total d'entrées + /// 2. Si > limit, récupère les N entrées les plus anciennes + /// 3. Supprime ces entrées de la DB et leurs fichiers du disque + /// + /// # Returns + /// + /// Le nombre d'entrées supprimées + pub async fn enforce_limit(&self) -> Result { + let count = self.db.count()?; + + if count <= self.limit { + return Ok(0); + } + + let to_remove = count - self.limit; + let old_entries = self.db.get_oldest(to_remove)?; + + let mut removed = 0; + for entry in old_entries { + // Supprimer tous les fichiers avec ce pk (toutes variantes) + if let Ok(mut dir_entries) = tokio::fs::read_dir(&self.dir).await { + while let Ok(Some(dir_entry)) = dir_entries.next_entry().await { + if let Some(filename) = dir_entry.file_name().to_str() { + // Format: {pk}.{param}.{ext} + if filename.starts_with(&entry.pk) && filename.starts_with(&format!("{}.", entry.pk)) { + let _ = tokio::fs::remove_file(dir_entry.path()).await; + } + } + } + } + + // Supprimer de la base de données + if let Err(e) = self.db.delete(&entry.pk) { + tracing::warn!("Error deleting entry {} from DB: {}", entry.pk, e); + } else { + removed += 1; + } + } + + if removed > 0 { + tracing::info!("LRU eviction: removed {} old entries (cache size: {} -> {})", + removed, count, count - removed); + } + + Ok(removed) + } } diff --git a/pmocache/src/db.rs b/pmocache/src/db.rs index 56fff1ac..b2d7f358 100644 --- a/pmocache/src/db.rs +++ b/pmocache/src/db.rs @@ -248,4 +248,54 @@ impl DB { conn.execute(&sql, [pk])?; Ok(()) } + + /// Compte le nombre total d'entrées dans le cache + /// + /// # Returns + /// + /// Le nombre total d'entrées + pub fn count(&self) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + let sql = format!("SELECT COUNT(*) FROM {}", self.table_name); + let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?; + Ok(count as usize) + } + + /// Récupère les N entrées les plus anciennes (LRU - Least Recently Used) + /// + /// Trie par last_used (les plus anciens en premier), puis par hits (les moins utilisés). + /// Utile pour implémenter une politique d'éviction LRU. + /// + /// # Arguments + /// + /// * `limit` - Nombre maximum d'entrées à récupérer + /// + /// # Returns + /// + /// Liste des entrées les plus anciennes, triées par last_used ASC + pub fn get_oldest(&self, limit: usize) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let sql = format!( + "SELECT pk, source_url, collection, hits, last_used + FROM {} + ORDER BY last_used ASC, hits ASC + LIMIT ?1", + self.table_name + ); + + let mut stmt = conn.prepare(&sql)?; + + let entries = stmt.query_map([limit], |row| { + Ok(CacheEntry { + pk: row.get(0)?, + source_url: row.get(1)?, + collection: row.get(2)?, + hits: row.get(3)?, + last_used: row.get(4)?, + }) + })? + .collect::>>()?; + + Ok(entries) + } } diff --git a/pmocache/src/pmoserver_ext.rs b/pmocache/src/pmoserver_ext.rs index 3840adfe..6e5f81bc 100644 --- a/pmocache/src/pmoserver_ext.rs +++ b/pmocache/src/pmoserver_ext.rs @@ -40,8 +40,6 @@ #[cfg(feature = "pmoserver")] use crate::{Cache, CacheConfig}; #[cfg(feature = "pmoserver")] -use crate::cache_trait::FileCache; -#[cfg(feature = "pmoserver")] use axum::{ body::Body, extract::{Path, State}, @@ -56,42 +54,83 @@ use std::sync::Arc; use tokio_util::io::ReaderStream; #[cfg(feature = "pmoserver")] use tracing::warn; +#[cfg(feature = "pmoserver")] +use std::pin::Pin; +#[cfg(feature = "pmoserver")] +use std::future::Future; + +/// Type pour le callback de génération de param +/// +/// Appelé quand un fichier avec param n'existe pas. +/// Permet de générer à la volée (ex: redimensionnement d'images). +/// +/// # Arguments +/// +/// - `cache`: le cache +/// - `pk`: clé primaire +/// - `param`: paramètre demandé (ex: "256" pour une taille) +/// +/// # Retourne +/// +/// Les données générées ou None si le param n'est pas supporté +#[cfg(feature = "pmoserver")] +pub type ParamGenerator = Arc< + dyn Fn(Arc>, String, String) + -> Pin>> + Send>> + + Send + Sync +>; /// Handler générique pour GET /{cache_name}/{cache_type}/{pk} /// Sert un fichier avec le param par défaut #[cfg(feature = "pmoserver")] async fn get_file( - State((cache, content_type)): State<(Arc>, &'static str)>, + State((cache, content_type, param_generator)): State<(Arc>, &'static str, Option>)>, Path(pk): Path, ) -> Response { // Utiliser le param par défaut let param = C::default_param(); - serve_file_with_streaming(&cache, &pk, param, content_type).await + serve_file_with_streaming(&cache, &pk, param, content_type, param_generator).await } /// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param} /// Sert un fichier avec un param spécifique #[cfg(feature = "pmoserver")] async fn get_file_with_param( - State((cache, content_type)): State<(Arc>, &'static str)>, + State((cache, content_type, param_generator)): State<(Arc>, &'static str, Option>)>, Path((pk, param)): Path<(String, String)>, ) -> Response { - serve_file_with_streaming(&cache, &pk, ¶m, content_type).await + serve_file_with_streaming(&cache, &pk, ¶m, content_type, param_generator).await } /// Fonction utilitaire pour servir un fichier avec streaming progressif /// /// Si le fichier est en cours de téléchargement, il est streamé au fur et à mesure. /// Sinon, le fichier complet est servi normalement. +/// Si le fichier n'existe pas et qu'un param_generator est fourni, tente de générer le param. #[cfg(feature = "pmoserver")] async fn serve_file_with_streaming( cache: &Arc>, pk: &str, param: &str, content_type: &'static str, + param_generator: Option>, ) -> Response { let file_path = cache.file_path_with_qualifier(pk, param); + // Si le fichier n'existe pas et qu'on a un générateur, l'utiliser + if !file_path.exists() { + if let Some(generator) = param_generator { + if let Some(data) = generator(cache.clone(), pk.to_string(), param.to_string()).await { + // Le générateur a créé les données, les servir directement + return ( + StatusCode::OK, + [("content-type", content_type)], + data, + ).into_response(); + } + } + } + // Mettre à jour les stats d'utilisation if let Err(e) = cache.db.update_hit(pk) { warn!("Error updating hit count for {}: {}", pk, e); @@ -218,18 +257,63 @@ async fn serve_complete_file( pub fn create_file_router( cache: Arc>, content_type: &'static str, +) -> Router { + create_file_router_with_generator(cache, content_type, None) +} + +/// Crée un router pour servir les fichiers d'un cache avec générateur de param +/// +/// Similaire à `create_file_router` mais permet de fournir un générateur +/// pour créer des variantes à la volée (ex: redimensionnement d'images). +/// +/// # Arguments +/// +/// * `cache` - Instance du cache +/// * `content_type` - Type MIME des fichiers (ex: "image/webp", "audio/flac") +/// * `param_generator` - Générateur optionnel pour créer des params à la volée +/// +/// # Exemple +/// +/// ```rust,no_run +/// use pmocache::pmoserver_ext::{create_file_router_with_generator, ParamGenerator}; +/// use std::sync::Arc; +/// +/// # async fn example(cache: std::sync::Arc>) { +/// let generator: ParamGenerator = Arc::new(|cache, pk, param| { +/// Box::pin(async move { +/// // Générer une variante si param est numérique +/// if let Ok(size) = param.parse::() { +/// // Générer et retourner les données +/// Some(vec![]) +/// } else { +/// None +/// } +/// }) +/// }); +/// +/// let router = create_file_router_with_generator( +/// cache.clone(), +/// "image/webp", +/// Some(generator) +/// ); +/// # } +/// ``` +#[cfg(feature = "pmoserver")] +pub fn create_file_router_with_generator( + cache: Arc>, + content_type: &'static str, + param_generator: Option>, ) -> Router { let cache_name = C::cache_name(); let cache_type = C::cache_type(); - let path_base = format!("/{}/{}", cache_name, cache_type); - let path_with_param = format!("/{}/{}/:pk/:param", cache_name, cache_type); - let path_without_param = format!("/{}/{}/:pk", cache_name, cache_type); + let path_with_param = format!("/{}/{}/{{pk}}/{{param}}", cache_name, cache_type); + let path_without_param = format!("/{}/{}/{{pk}}", cache_name, cache_type); Router::new() .route(&path_without_param, get(get_file::)) .route(&path_with_param, get(get_file_with_param::)) - .with_state((cache, content_type)) + .with_state((cache, content_type, param_generator)) } /// Crée un router pour l'API REST du cache @@ -261,11 +345,11 @@ pub fn create_api_router( .delete(api::purge_cache::), ) .route( - "/:pk", + "/{pk}", get(api::get_item_info::) .delete(api::delete_item::), ) - .route("/:pk/status", get(api::get_download_status::)) + .route("/{pk}/status", get(api::get_download_status::)) .route("/consolidate", post(api::consolidate_cache::)) .with_state(cache) } diff --git a/pmocovers/src/api.rs b/pmocovers/src/api.rs deleted file mode 100644 index 4b7365b6..00000000 --- a/pmocovers/src/api.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! 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, ImageCacheExt}; -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_image_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 cache_dir = std::path::PathBuf::from(cache.cache_dir()); - 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 index 3143b2b5..44a7f112 100644 --- a/pmocovers/src/cache.rs +++ b/pmocovers/src/cache.rs @@ -1,13 +1,11 @@ //! Module de gestion du cache d'images avec conversion WebP //! //! Ce module étend le cache générique de `pmocache` avec des fonctionnalités -//! spécifiques aux images : conversion WebP et génération de variantes. +//! spécifiques aux images : conversion WebP automatique lors du téléchargement. use anyhow::Result; -use pmocache::{CacheConfig, FileCache}; -use crate::webp; -use std::path::PathBuf; -use std::ops::Deref; +use pmocache::{CacheConfig, StreamTransformer}; +use std::sync::Arc; /// Configuration pour le cache de couvertures pub struct CoversConfig; @@ -25,174 +23,60 @@ impl CacheConfig for CoversConfig { "image" } - /// Cache name (ex: "covers", "audio", "cache") fn cache_name() -> &'static str { "covers" } } -/// Cache d'images avec conversion WebP et génération de variantes +/// Type alias pour le cache de couvertures avec conversion WebP +pub type Cache = pmocache::Cache; + +/// Créateur de transformer WebP /// -/// Format des fichiers : `{pk}.{qualificatif}.webp` -/// Exemple : `a1b2c3d4.orig.webp`, `a1b2c3d4.thumb.webp` +/// Convertit automatiquement toute image téléchargée en format WebP +fn create_webp_transformer() -> StreamTransformer { + Box::new(|response, mut file, progress| { + Box::pin(async move { + // Télécharger tout en mémoire + let bytes = response.bytes().await.map_err(|e| e.to_string())?; + + // Convertir en WebP + let img = image::load_from_memory(&bytes) + .map_err(|e| format!("Image decode error: {}", e))?; + let webp_data = crate::webp::encode_webp(&img) + .map_err(|e| format!("WebP encode error: {}", e))?; + + // Écrire et mettre à jour la progression + use tokio::io::AsyncWriteExt; + file.write_all(&webp_data).await.map_err(|e| e.to_string())?; + file.flush().await.map_err(|e| e.to_string())?; + progress(webp_data.len() as u64); + + Ok(()) + }) + }) +} + +/// Crée un cache de couvertures avec conversion WebP automatique /// -/// Ce type est un wrapper autour de `pmocache::Cache` qui permet -/// d'implémenter le trait `FileCache` avec conversion WebP automatique. -#[derive(Debug)] -pub struct Cache(pmocache::Cache); - -impl Cache { - /// Crée un nouveau cache d'images - pub fn new(dir: &str, limit: usize, base_url: &str) -> Result { - Ok(Self(pmocache::Cache::new(dir, limit, base_url)?)) - } -} - -/// Permet d'accéder aux méthodes publiques de `pmocache::Cache` directement -impl Deref for Cache { - type Target = pmocache::Cache; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -/// Implémentation de FileCache pour Cache avec conversion WebP automatique -impl FileCache for Cache { - fn cache_type(&self) -> &str { - CoversConfig::cache_type() - } - - fn validate_data(&self, data: &[u8]) -> Result> { - // Convertir l'image en WebP - let img = image::load_from_memory(data)?; - webp::encode_webp(&img) - } - - async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result { - let response = reqwest::get(url).await?; - if !response.status().is_success() { - return Err(anyhow::anyhow!("Bad status: {}", response.status())); - } - - let data = response.bytes().await?; - self.add(url, &data, collection).await - } - - async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result { - let pk = pmocache::pk_from_url(url); - - if self.db.get(&pk).is_ok() { - let file_path = self.file_path(&pk); - if file_path.exists() { - return Ok(pk); - } - } - - self.add_from_url(url, collection).await - } - - async fn add(&self, url: &str, data: &[u8], collection: Option<&str>) -> Result { - // Valider et convertir les données en WebP - let webp_data = self.validate_data(data)?; - - let pk = pmocache::pk_from_url(url); - let file_path = self.file_path(&pk); - - if !file_path.exists() { - tokio::fs::write(&file_path, &webp_data).await?; - } - - self.db.add(&pk, url, collection)?; - Ok(pk) - } - - async fn get(&self, pk: &str) -> Result { - self.db.get(pk)?; - self.db.update_hit(pk)?; - - let file_path = self.file_path(pk); - if file_path.exists() { - Ok(file_path) - } else { - Err(anyhow::anyhow!("File not found")) - } - } - - async fn get_collection(&self, collection: &str) -> Result> { - let entries = self.db.get_by_collection(collection)?; - let mut paths = Vec::new(); - - for entry in entries { - let path = self.file_path(&entry.pk); - if path.exists() { - paths.push(path); - } - } - - Ok(paths) - } - - async fn purge(&self) -> Result<()> { - let cache_dir = PathBuf::from(self.get_cache_dir()); - let mut entries = tokio::fs::read_dir(&cache_dir).await?; - while let Some(entry) = entries.next_entry().await? { - if entry.path().is_file() && entry.path() != cache_dir.join("cache.db") { - tokio::fs::remove_file(entry.path()).await?; - } - } - - self.db - .purge() - .map_err(|e| anyhow::anyhow!("Database error: {}", e)) - } - - async fn consolidate(&self) -> Result<()> { - // Récupérer la liste des entrées à traiter - let entries = self.db.get_all()?; - - // Supprimer les entrées sans fichiers correspondants - for entry in entries { - let file_path = self.file_path(&entry.pk); - if !file_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, entry.collection.as_deref()) - .await?; - } - _ => { - self.db.delete(&entry.pk)?; - } - } - } - } - - // Supprimer les fichiers sans entrées DB correspondantes - let cache_dir_path = PathBuf::from(self.get_cache_dir()); - let mut dir_entries = tokio::fs::read_dir(&cache_dir_path).await?; - while let Some(entry) = dir_entries.next_entry().await? { - let path = entry.path(); - if path.is_file() && path != cache_dir_path.join("cache.db") { - if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { - // Format attendu: {pk}.{qualifier}.{EXT} - if let Some(pk) = file_name.split('.').next() { - if self.db.get(pk).is_err() { - tokio::fs::remove_file(path).await?; - } - } - } - } - } - - Ok(()) - } - - fn get_cache_dir(&self) -> String { - self.get_cache_dir() - } - - fn get_base_url(&self) -> &str { - self.get_base_url() - } +/// # Arguments +/// +/// * `dir` - Répertoire de stockage du cache +/// * `limit` - Limite de taille du cache (nombre d'images) +/// * `base_url` - URL de base pour la génération d'URLs +/// +/// # Returns +/// +/// Instance du cache configurée pour la conversion WebP automatique +/// +/// # Exemple +/// +/// ```rust,no_run +/// use pmocovers::cache; +/// +/// let cache = cache::new_cache("./cache", 1000, "http://localhost:8080").unwrap(); +/// ``` +pub fn new_cache(dir: &str, limit: usize, base_url: &str) -> Result { + let transformer_factory = Arc::new(|| create_webp_transformer()); + Cache::with_transformer(dir, limit, base_url, Some(transformer_factory)) } diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs index ba30b82c..91331667 100644 --- a/pmocovers/src/lib.rs +++ b/pmocovers/src/lib.rs @@ -3,62 +3,24 @@ //! 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 +//! ## Fonctionnalités //! -//! `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 +//! - API HTTP complète (fournie par `pmocache`) //! //! ## Architecture //! -//! `pmocovers` suit le pattern d'extension des autres crates PMO : +//! `pmocovers` est une spécialisation minimale de `pmocache` qui ajoute : +//! 1. La conversion WebP automatique lors du téléchargement (via transformer) +//! 2. La génération de variantes redimensionnées à la demande (via param generator) //! -//! - `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 -//! ``` +//! Tout le reste (API REST, serveur de fichiers, DB) est fourni par `pmocache`. //! //! ## Utilisation //! -//! ### Exemple basique avec configuration automatique +//! ### Exemple avec configuration automatique //! //! ```rust,no_run //! use pmocovers::CoverCacheExt; @@ -67,161 +29,56 @@ //! #[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; -//! use pmocache::FileCache; -//! -//! #[tokio::main] -//! async fn main() -> anyhow::Result<()> { -//! let cache = Cache::new("./cache", 1000, "http://localhost:8080")?; -//! -//! // Ajouter une image depuis une URL (avec conversion WebP automatique) -//! let pk = cache.add_from_url("http://example.com/cover.jpg", None).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, CoversConfig}; -pub use db::{CacheEntry, DB}; +pub use cache::{Cache, CoversConfig, new_cache}; #[cfg(feature = "pmoserver")] pub use openapi::ApiDoc; -use anyhow::Result; +#[cfg(feature = "pmoserver")] +use utoipa::OpenApi; +#[cfg(feature = "pmoserver")] use std::sync::Arc; -/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache d'images. +/// Générateur de variantes 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` +/// Si param est numérique, génère une variante redimensionnée +#[cfg(feature = "pmoserver")] +fn create_variant_generator() -> pmocache::pmoserver_ext::ParamGenerator { + Arc::new(|cache, pk, param| { + Box::pin(async move { + // Si le param est numérique, c'est une taille de variante + if let Ok(size) = param.parse::() { + match webp::generate_variant(&cache, &pk, size).await { + Ok(data) => return Some(data), + Err(e) => { + tracing::warn!("Cannot generate variant {}x{} for {}: {}", size, size, pk, e); + return None; + } + } + } + // Param non reconnu + None + }) + }) +} + +/// Trait d'extension pour ajouter le cache de couvertures à pmoserver +#[cfg(feature = "pmoserver")] pub trait CoverCacheExt { - /// Initialise le cache d'images et enregistre les routes HTTP. + /// Initialise le cache d'images et enregistre les routes HTTP /// /// # Arguments /// @@ -230,49 +87,59 @@ pub trait CoverCacheExt { /// /// # Returns /// - /// * `Arc` - Instance partagée du 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 /covers/image/{pk}` - Image originale + /// - `GET /covers/image/{pk}/{size}` - Variante de taille (ex: 256, 512) /// - `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>; + /// - `GET /api/covers/{pk}/status` - Statut du téléchargement + /// - `GET /swagger-ui/covers` - Documentation interactive + async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) + -> anyhow::Result>; - /// Initialise le cache d'images avec la configuration par défaut. + /// 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>; + /// Utilise automatiquement les paramètres de `pmoconfig::Config` + async fn init_cover_cache_configured(&mut self) + -> anyhow::Result>; } -// Implémentation du trait pour pmoserver::Server (feature-gated) #[cfg(feature = "pmoserver")] -mod pmoserver_impl; +impl CoverCacheExt for pmoserver::Server { + async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) + -> anyhow::Result> { + use pmocache::pmoserver_ext::{create_file_router_with_generator, create_api_router}; + + let base_url = self.info().base_url; + let cache = Arc::new(cache::new_cache(cache_dir, limit, &base_url)?); + + // Router de fichiers avec génération de variantes + // Routes: GET /covers/image/{pk} et GET /covers/image/{pk}/{size} + let file_router = create_file_router_with_generator( + cache.clone(), + "image/webp", + Some(create_variant_generator()) + ); + self.add_router("/", file_router).await; + + // API REST générique (pmocache) + // Routes: GET/POST/DELETE /api/covers, etc. + let api_router = create_api_router(cache.clone()); + let openapi = crate::ApiDoc::openapi(); + 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()?; + self.init_cover_cache(&cache_dir, limit).await + } +} diff --git a/pmocovers/src/openapi.rs b/pmocovers/src/openapi.rs index a86cfd0b..85e1b9e8 100644 --- a/pmocovers/src/openapi.rs +++ b/pmocovers/src/openapi.rs @@ -1,24 +1,23 @@ //! Documentation OpenAPI pour l'API REST du cache de couvertures +//! +//! Ce module fournit une documentation OpenAPI simple pour l'API REST +//! fournie par pmocache, spécialisée pour les images de couvertures. use utoipa::OpenApi; +/// Documentation OpenAPI pour l'API PMOCovers +/// +/// L'API réutilise les handlers génériques de pmocache. #[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, + pmocache::CacheEntry, + pmocache::api::AddItemRequest, + pmocache::api::AddItemResponse, + pmocache::api::DeleteItemResponse, + pmocache::api::ErrorResponse, + pmocache::api::DownloadStatus, ) ), tags( @@ -38,6 +37,38 @@ Cette API permet de gérer un cache d'images optimisé pour les couvertures d'al - **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 +- **Statut** : Suivi des téléchargements en cours + +## Endpoints principaux + +### GET /api/covers +Liste toutes les images en cache avec leurs statistiques + +### POST /api/covers +Ajoute une image depuis une URL (conversion WebP automatique) + +### GET /api/covers/{pk} +Récupère les informations d'une image + +### DELETE /api/covers/{pk} +Supprime une image et ses variantes + +### GET /api/covers/{pk}/status +Récupère le statut du téléchargement + +### DELETE /api/covers +Purge complètement le cache + +### POST /api/covers/consolidate +Consolide le cache (répare les incohérences) + +## Servir les fichiers + +### GET /covers/image/{pk} +Récupère l'image originale en WebP + +### GET /covers/image/{pk}/{size} +Récupère une variante redimensionnée (ex: /covers/image/abc123/256) ## Format des images diff --git a/pmocovers/src/pmoserver_impl.rs b/pmocovers/src/pmoserver_impl.rs deleted file mode 100644 index b562ecc1..00000000 --- a/pmocovers/src/pmoserver_impl.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! 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::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{get, post}, - Json, Router, -}; -use pmoserver::Server; -use tracing::{info, warn}; -use std::sync::Arc; -use utoipa::OpenApi; - -/// Handler pour GET /covers/images/{pk}/{size} -/// Génère une variante d'image à la demande -async fn get_cover_variant( - State(cache): State>, - Path((pk, size)): Path<(String, String)>, -) -> Response { - let size = match size.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(e) => { - warn!("Cannot generate variant for {}: {}", pk, e); - (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> { - // Utiliser l'URL du serveur comme base_url - let base_url = self.info().base_url; - let cache = Arc::new(Cache::new(cache_dir, limit, &base_url)?); - - // Utiliser le router générique de pmocache pour servir les fichiers - // Routes: GET /covers/images/{pk} et GET /covers/images/{pk}/{param} - let file_router = pmocache::pmoserver_ext::create_file_router( - cache.clone(), - "image/webp" - ); - self.add_router("/covers/images", file_router).await; - - // Route pour générer les variantes à la demande (redimensionnement) - // Note: Cette route est spécifique à pmocovers car elle nécessite generate_variant - let variant_router = Router::new() - .route("/{pk}/{size}", get(get_cover_variant)) - .with_state(cache.clone()); - self.add_router("/covers/variants", variant_router).await; - - // Route pour les stats - self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await; - - // 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> { - 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 - } -}