From 0264e0c2e120060e7c7beeab4dca5a5b5c1288b7 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Wed, 17 Dec 2025 15:15:10 +0100 Subject: [PATCH] =?UTF-8?q?Corrections=20mineurs=20des=20syst=C3=A8mes=20d?= =?UTF-8?q?e=20caches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + pmoaudiocache/src/cache.rs | 13 +--- pmoaudiocache/src/lib.rs | 8 ++- pmoaudiocache/src/openapi.rs | 3 + pmocache/src/api.rs | 14 ++-- pmocache/src/cache.rs | 56 +++++++++++++++- pmocache/src/config_ext.rs | 4 +- pmocache/src/pmoserver_ext.rs | 6 +- pmocovers/Cargo.toml | 1 + pmocovers/src/api.rs | 91 +++++++++++++++++++++++++ pmocovers/src/cache.rs | 122 +++++++++++++++++++++++++++++++--- pmocovers/src/lib.rs | 89 +++++++++++++++++++++++-- pmocovers/src/openapi.rs | 4 ++ 13 files changed, 371 insertions(+), 41 deletions(-) create mode 100644 pmocovers/src/api.rs diff --git a/Cargo.lock b/Cargo.lock index 7eb06e85..6d69571e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3371,6 +3371,7 @@ dependencies = [ "pmoserver", "reqwest", "serde", + "serde_json", "tempfile", "tokio", "tracing", diff --git a/pmoaudiocache/src/cache.rs b/pmoaudiocache/src/cache.rs index f984b3c9..61e18941 100644 --- a/pmoaudiocache/src/cache.rs +++ b/pmoaudiocache/src/cache.rs @@ -111,18 +111,7 @@ async fn persist_transform_streaminfo(cache: Arc, pk: &str, tmeta: &Trans /// ``` pub async fn new_cache_with_consolidation(dir: &str, limit: usize) -> Result> { let cache = Arc::new(new_cache(dir, limit)?); - - // Lancer la consolidation en arrière-plan pour nettoyer les fichiers incomplets - let cache_clone = cache.clone(); - tokio::spawn(async move { - if let Err(e) = cache_clone.consolidate().await { - tracing::warn!("Failed to consolidate cache on startup: {}", e); - } else { - tracing::info!("Cache consolidated successfully on startup"); - } - }); - - Ok(cache) + Ok(pmocache::Cache::with_consolidation(cache).await) } /// Ajoute un fichier audio local. Les FLAC sont référencés sans copie, les autres formats diff --git a/pmoaudiocache/src/lib.rs b/pmoaudiocache/src/lib.rs index dc9bd2df..90f45318 100755 --- a/pmoaudiocache/src/lib.rs +++ b/pmoaudiocache/src/lib.rs @@ -83,9 +83,15 @@ pub mod cache; pub mod metadata; pub mod metadata_ext; -pub mod streaming; pub mod track_metadata; +/// Module public pour la création de transformers FLAC streaming +/// +/// Ce module expose les fonctionnalités de conversion FLAC progressive +/// pour permettre aux utilisateurs de créer des transformers custom ou +/// de réutiliser les implémentations par défaut. +pub mod streaming; + #[cfg(feature = "pmoserver")] pub mod api; diff --git a/pmoaudiocache/src/openapi.rs b/pmoaudiocache/src/openapi.rs index aa138f27..ed8689e7 100644 --- a/pmoaudiocache/src/openapi.rs +++ b/pmoaudiocache/src/openapi.rs @@ -7,6 +7,9 @@ use utoipa::OpenApi; /// L'API réutilise les handlers génériques de pmocache. #[derive(OpenApi)] #[openapi( + paths( + crate::api::get_cover_url, + ), components( schemas( pmocache::CacheEntry, diff --git a/pmocache/src/api.rs b/pmocache/src/api.rs index 8d10e90f..bda2dd3c 100644 --- a/pmocache/src/api.rs +++ b/pmocache/src/api.rs @@ -115,7 +115,7 @@ pub struct ErrorResponse { /// Liste tous les items en cache avec leurs statistiques /// /// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant. -pub async fn list_items(State(cache): State>>) -> impl IntoResponse { +pub async fn list_items(State(cache): State>>) -> impl IntoResponse { match cache.db.get_all(true) { Ok(entries) => (StatusCode::OK, Json(entries)).into_response(), Err(e) => ( @@ -132,7 +132,7 @@ pub async fn list_items(State(cache): State>>) -> i /// Récupère les informations d'un item spécifique /// /// Retourne les métadonnées d'un item identifié par sa clé (pk). -pub async fn get_item_info( +pub async fn get_item_info( State(cache): State>>, Path(pk): Path, ) -> impl IntoResponse { @@ -153,7 +153,7 @@ pub async fn get_item_info( /// /// Retourne le statut actuel du téléchargement (progression, tailles, erreurs). /// Si le téléchargement est terminé, retourne les informations du fichier. -pub async fn get_download_status( +pub async fn get_download_status( State(cache): State>>, Path(pk): Path, ) -> impl IntoResponse { @@ -265,7 +265,7 @@ enum AddSource<'a> { /// /// Télécharge l'item depuis l'URL fournie et l'ajoute au cache. /// Si l'item existe déjà, il est mis à jour. -pub async fn add_item( +pub async fn add_item( State(cache): State>>, Json(req): Json, ) -> impl IntoResponse { @@ -337,7 +337,7 @@ pub async fn add_item( /// Supprime un item du cache /// /// Supprime l'item et toutes ses variantes du disque et de la base de données. -pub async fn delete_item( +pub async fn delete_item( State(cache): State>>, Path(pk): Path, ) -> impl IntoResponse { @@ -389,7 +389,7 @@ pub async fn delete_item( /// Purge complètement le cache /// /// Supprime tous les items et vide la base de données. Opération irréversible. -pub async fn purge_cache(State(cache): State>>) -> impl IntoResponse { +pub async fn purge_cache(State(cache): State>>) -> impl IntoResponse { match cache.purge().await { Ok(_) => ( StatusCode::OK, @@ -413,7 +413,7 @@ pub async fn purge_cache(State(cache): State>>) -> /// /// Re-télécharge les items manquants et supprime les fichiers orphelins. /// Utile pour réparer un cache corrompu. -pub async fn consolidate_cache( +pub async fn consolidate_cache( State(cache): State>>, ) -> impl IntoResponse { match cache.consolidate().await { diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index ac800c4b..bc55ffbf 100755 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -152,7 +152,7 @@ pub struct Cache { _phantom: std::marker::PhantomData, } -impl Cache { +impl Cache { /// Retourne le chemin du fichier marker de complétion fn get_completion_marker_path(&self, pk: &str) -> PathBuf { self.get_file_path(pk) @@ -424,6 +424,58 @@ impl Cache { }) } + /// Lance une consolidation en arrière-plan pour un cache existant + /// + /// Cette fonction utilitaire lance une tâche asynchrone qui consolide le cache + /// (supprime les fichiers incomplets sans marker de complétion) et retourne + /// immédiatement le cache fourni en paramètre. + /// + /// Idéal pour les crates spécialisées qui veulent offrir une fonction + /// `new_cache_with_consolidation` sans dupliquer la logique de lancement. + /// + /// # Arguments + /// + /// * `cache` - Instance du cache à consolider + /// + /// # Returns + /// + /// Le même `Arc>` fourni en paramètre + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmocache::{Cache, CacheConfig}; + /// use std::sync::Arc; + /// + /// struct MyConfig; + /// impl CacheConfig for MyConfig { + /// fn file_extension() -> &'static str { "dat" } + /// } + /// + /// async fn create_cache_with_cleanup() -> anyhow::Result>> { + /// let cache = Arc::new(Cache::new("./cache", 1000)?); + /// Ok(Cache::with_consolidation(cache).await) + /// } + /// ``` + pub async fn with_consolidation(cache: Arc>) -> Arc> { + let cache_clone = cache.clone(); + tokio::spawn(async move { + if let Err(e) = cache_clone.consolidate().await { + tracing::warn!( + "Failed to consolidate {} cache on startup: {}", + C::cache_name(), + e + ); + } else { + tracing::info!( + "{} cache consolidated successfully on startup", + C::cache_name() + ); + } + }); + cache + } + /// Configure la taille minimale de prébuffering /// /// # Arguments @@ -1559,7 +1611,7 @@ fn link_file(source: &Path, destination: &Path) -> std::io::Result<()> { } /// Implémentation du trait FileCache pour Cache -impl FileCache for Cache { +impl FileCache for Cache { fn get_cache_dir(&self) -> &Path { self.cache_dir() } diff --git a/pmocache/src/config_ext.rs b/pmocache/src/config_ext.rs index 6bb8c337..12faa5d6 100644 --- a/pmocache/src/config_ext.rs +++ b/pmocache/src/config_ext.rs @@ -91,7 +91,7 @@ pub trait CacheConfigExt { /// let config = get_config(); /// let cache = config.create_cache::("audio_cache", "cache_audio", 500)?; /// ``` - fn create_cache( + fn create_cache( &self, cache_type: &str, default_dir: &str, @@ -121,7 +121,7 @@ impl CacheConfigExt for Config { self.set_value(&["host", cache_type, "size"], Value::Number(n)) } - fn create_cache( + fn create_cache( &self, cache_type: &str, default_dir: &str, diff --git a/pmocache/src/pmoserver_ext.rs b/pmocache/src/pmoserver_ext.rs index 5a81f589..959da334 100644 --- a/pmocache/src/pmoserver_ext.rs +++ b/pmocache/src/pmoserver_ext.rs @@ -111,7 +111,7 @@ async fn get_file_with_param( } #[cfg(feature = "pmoserver")] -async fn serve_finalized_pk( +async fn serve_finalized_pk( cache: &Arc>, pk: &str, param: &str, @@ -143,7 +143,7 @@ async fn serve_finalized_pk( /// 5. Broadcast l'event pour PK switching /// 6. Sert directement le fichier téléchargé #[cfg(feature = "pmoserver")] -async fn serve_lazy_audio_file( +async fn serve_lazy_audio_file( cache: &Arc>, lazy_pk: &str, param: &str, @@ -187,7 +187,7 @@ async fn serve_lazy_audio_file( /// 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( +async fn serve_file_with_streaming( cache: &Arc>, pk: &str, param: &str, diff --git a/pmocovers/Cargo.toml b/pmocovers/Cargo.toml index f852291e..804c3a31 100644 --- a/pmocovers/Cargo.toml +++ b/pmocovers/Cargo.toml @@ -17,6 +17,7 @@ reqwest = { version = "0.12", features = ["blocking"] } # Utilitaires anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" # Async tokio = { version = "1.0", features = ["full"] } diff --git a/pmocovers/src/api.rs b/pmocovers/src/api.rs new file mode 100644 index 00000000..7e227876 --- /dev/null +++ b/pmocovers/src/api.rs @@ -0,0 +1,91 @@ +//! API REST handlers spécifiques au cache de couvertures + +use crate::cache; +use crate::Cache; +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + Json, +}; +use pmocache::api::{AddItemRequest, AddItemResponse, ErrorResponse}; +use std::sync::Arc; + +#[derive(Clone, Copy)] +enum AddSource<'a> { + Url(&'a str), + Local(&'a str), +} + +/// Handler spécialisé pour l'ajout d'images dans le cache de couvertures. +/// +/// Supporte l'ajout depuis une URL (avec conversion WebP) ou depuis un fichier local +/// (avec conversion ou passthrough selon le format). +pub async fn add_cover_item( + State(cache): State>, + Json(req): Json, +) -> impl IntoResponse { + let mode = match (req.url.as_deref(), req.path.as_deref()) { + (Some(url), None) if !url.is_empty() => AddSource::Url(url), + (None, Some(path)) if !path.is_empty() => AddSource::Local(path), + (Some(_), Some(_)) => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "INVALID_REQUEST".to_string(), + message: "Provide either 'url' or 'path', not both".to_string(), + }), + ) + .into_response() + } + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "INVALID_REQUEST".to_string(), + message: "Either 'url' or 'path' must be provided".to_string(), + }), + ) + .into_response() + } + }; + + let collection = req.collection.as_deref(); + let add_result = match mode { + AddSource::Url(url) => cache.add_from_url(url, collection).await, + AddSource::Local(path) => cache::add_local_file(&cache, path, collection).await, + }; + + match add_result { + Ok(pk) => { + let origin = + cache + .db + .get_origin_url(&pk) + .ok() + .flatten() + .unwrap_or_else(|| match mode { + AddSource::Url(url) => url.to_string(), + AddSource::Local(path) => format!("file://{}", path), + }); + + ( + StatusCode::CREATED, + Json(AddItemResponse { + pk, + url: origin, + 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(), + } +} diff --git a/pmocovers/src/cache.rs b/pmocovers/src/cache.rs index c6f2521b..7192b421 100644 --- a/pmocovers/src/cache.rs +++ b/pmocovers/src/cache.rs @@ -91,13 +91,117 @@ pub fn new_cache(dir: &str, limit: usize) -> Result { /// des requêtes. pub async fn new_cache_with_consolidation(dir: &str, limit: usize) -> Result> { let cache = Arc::new(new_cache(dir, limit)?); - let cache_clone = cache.clone(); - tokio::spawn(async move { - if let Err(e) = cache_clone.consolidate().await { - tracing::warn!("Failed to consolidate cover cache on startup: {}", e); - } else { - tracing::info!("Cover cache consolidated successfully on startup"); - } - }); - Ok(cache) + Ok(pmocache::Cache::with_consolidation(cache).await) +} + +/// Détecte si un buffer contient un fichier WebP +/// +/// Le format WebP commence par "RIFF" (4 octets), suivi de la taille (4 octets), +/// puis "WEBP" (4 octets). +fn is_webp_header(buf: &[u8]) -> bool { + buf.len() >= 12 && &buf[0..4] == b"RIFF" && &buf[8..12] == b"WEBP" +} + +/// Ajoute un fichier image local au cache +/// +/// Les images WebP sont référencées sans copie (symlink/hardlink), les autres formats +/// sont convertis en WebP via le pipeline classique. +/// +/// # Arguments +/// +/// * `cache` - Instance du cache de couvertures +/// * `path` - Chemin vers le fichier image local +/// * `collection` - Collection optionnelle (ex: "album:xyz") +/// +/// # Returns +/// +/// Clé primaire (pk) de l'image ajoutée au cache +/// +/// # Exemples +/// +/// ```rust,no_run +/// use pmocovers::cache; +/// +/// # async fn example() -> anyhow::Result<()> { +/// let cache = cache::new_cache("./covers", 1000)?; +/// let pk = cache::add_local_file(&cache, "/path/to/cover.webp", None).await?; +/// println!("Image ajoutée avec pk: {}", pk); +/// # Ok(()) +/// # } +/// ``` +pub async fn add_local_file(cache: &Cache, path: &str, collection: Option<&str>) -> Result { + use pmocache::download::read_exact_or_eof; + use pmocache::pk_from_content_header; + use serde_json::json; + use tokio::io::AsyncSeekExt; + + let canonical_path = std::fs::canonicalize(path)?; + let file_url = format!("file://{}", canonical_path.display()); + let length = tokio::fs::metadata(&canonical_path) + .await + .ok() + .map(|m| m.len()); + let mut reader = tokio::fs::File::open(&canonical_path).await?; + + let header = read_exact_or_eof(&mut reader, 1024) + .await + .map_err(|e| anyhow::anyhow!("Failed to read header bytes: {}", e))?; + + let pk_bytes = if header.len() >= 1024 { + &header[512..] + } else { + &header[..] + }; + let pk = pk_from_content_header(pk_bytes); + + // Si déjà en cache, incrémenter hit et retourner + if cache.db.get(&pk, false).is_ok() { + cache.db.update_hit(&pk)?; + return Ok(pk); + } + + // Si téléchargement en cours, attendre et retourner + if let Some(download) = cache.get_download(&pk).await { + if download.finished().await { + cache.db.update_hit(&pk)?; + } + return Ok(pk); + } + + let is_webp = is_webp_header(&header); + if !is_webp { + // Format non-WebP : passer par le pipeline de conversion + reader + .rewind() + .await + .map_err(|e| anyhow::anyhow!("Failed to rewind local file: {}", e))?; + + return cache + .add_from_reader_with_pk(Some(&file_url), reader, length, collection, Some(pk)) + .await; + } + + // Format WebP : créer un lien sans copie (passthrough) + let mut metadata = vec![ + ("local_passthrough".to_string(), json!(true)), + ( + "local_source_path".to_string(), + json!(canonical_path.to_string_lossy().to_string()), + ), + ]; + if let Some(len) = length { + metadata.push(("source_size".to_string(), json!(len))); + } + + cache + .register_local_file_reference( + &pk, + &canonical_path, + collection, + Some(&file_url), + Some(&metadata), + ) + .await?; + + Ok(pk) } diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs index b090caca..7b8a8867 100644 --- a/pmocovers/src/lib.rs +++ b/pmocovers/src/lib.rs @@ -54,13 +54,16 @@ pub mod cache; pub mod webp; +#[cfg(feature = "pmoserver")] +pub mod api; + #[cfg(feature = "pmoserver")] pub mod openapi; #[cfg(feature = "pmoconfig")] pub mod config_ext; -pub use cache::{new_cache, new_cache_with_consolidation, Cache, CoversConfig}; +pub use cache::{add_local_file, new_cache, new_cache_with_consolidation, Cache, CoversConfig}; #[cfg(feature = "pmoserver")] pub use openapi::ApiDoc; @@ -172,7 +175,34 @@ fn create_variant_generator() -> pmocache::pmoserver_ext::ParamGenerator>, axum::extract::Path(pk): axum::extract::Path, @@ -180,7 +210,36 @@ async fn serve_cover_jpeg( serve_jpeg_internal(cache, pk, None).await } +/// Sert une image de couverture redimensionnée au format JPEG (transcodage depuis WebP) +/// +/// Cette route transcode à la volée l'image WebP stockée en cache vers le format JPEG, +/// en la redimensionnant à la taille demandée (format carré). +/// +/// # Arguments +/// +/// * `pk` - Clé primaire de l'image +/// * `size` - Taille souhaitée en pixels (ex: 256 pour 256x256) +/// +/// # Responses +/// +/// * `200 OK` - Image JPEG redimensionnée et transcodée +/// * `404 NOT_FOUND` - Image non trouvée +/// * `500 INTERNAL_SERVER_ERROR` - Erreur de transcodage ou redimensionnement #[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/covers/jpeg/{pk}/{size}", + tag = "covers", + params( + ("pk" = String, Path, description = "Clé primaire de l'image"), + ("size" = String, Path, description = "Taille en pixels (ex: 256, 512)") + ), + responses( + (status = 200, description = "Image JPEG redimensionnée", content_type = "image/jpeg"), + (status = 404, description = "Image non trouvée"), + (status = 500, description = "Erreur de transcodage"), + ) +)] async fn serve_cover_jpeg_with_size( axum::extract::State(cache): axum::extract::State>, axum::extract::Path((pk, size)): axum::extract::Path<(String, String)>, @@ -276,7 +335,7 @@ impl CoverCacheExt for pmoserver::Server { cache_dir: &str, limit: usize, ) -> anyhow::Result> { - use pmocache::pmoserver_ext::{create_api_router, create_file_router_with_generator}; + use pmocache::pmoserver_ext::create_file_router_with_generator; let cache = Arc::new(cache::new_cache(cache_dir, limit)?); @@ -302,9 +361,29 @@ impl CoverCacheExt for pmoserver::Server { let combined_router = file_router.merge(jpeg_router); self.add_router("/", combined_router).await; - // API REST générique (pmocache) - // Routes: GET/POST/DELETE /api/covers, etc. - let api_router = create_api_router(cache.clone()); + // API REST (handlers génériques + POST spécialisé covers) + let api_router = axum::Router::new() + .route( + "/", + axum::routing::get(pmocache::api::list_items::) + .post(crate::api::add_cover_item) + .delete(pmocache::api::purge_cache::), + ) + .route( + "/{pk}", + axum::routing::get(pmocache::api::get_item_info::) + .delete(pmocache::api::delete_item::), + ) + .route( + "/{pk}/status", + axum::routing::get(pmocache::api::get_download_status::), + ) + .route( + "/consolidate", + axum::routing::post(pmocache::api::consolidate_cache::), + ) + .with_state(cache.clone()); + let openapi = crate::ApiDoc::openapi(); self.add_openapi(api_router, openapi, "covers").await; diff --git a/pmocovers/src/openapi.rs b/pmocovers/src/openapi.rs index e4f1b675..0cb93d7e 100644 --- a/pmocovers/src/openapi.rs +++ b/pmocovers/src/openapi.rs @@ -10,6 +10,10 @@ use utoipa::OpenApi; /// L'API réutilise les handlers génériques de pmocache. #[derive(OpenApi)] #[openapi( + paths( + crate::serve_cover_jpeg, + crate::serve_cover_jpeg_with_size, + ), components( schemas( pmocache::CacheEntry,