From 082914cf8c353d82158a4629a17acdb24ba6361b Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 17 Oct 2025 14:36:39 +0200 Subject: [PATCH] Refactoring manuel --- .DS_Store | Bin 8196 -> 14340 bytes Cargo.lock | 44 +++-- pmoaudiocache/Cargo.toml | 2 +- pmoaudiocache/src/lib.rs | 28 +--- pmoaudiocache/src/pmoserver_ext.rs | 26 +++ pmoaudiocache/src/pmoserver_impl.rs | 3 +- pmocache/Cargo.toml | 7 +- pmocache/src/cache.rs | 247 +++++++++++++++------------- pmocache/src/cache_trait.rs | 126 ++++++++++++++ pmocache/src/lib.rs | 30 +++- pmocache/src/pmoserver_ext.rs | 142 ++++++++++++++++ pmocovers/Cargo.toml | 2 +- pmocovers/src/api.rs | 9 +- pmocovers/src/cache.rs | 227 ++++++++++++++----------- pmocovers/src/lib.rs | 9 +- pmocovers/src/pmoserver_impl.rs | 87 ++++------ pmocovers/src/webp.rs | 5 +- pmoqobuz/src/source.rs | 4 +- 18 files changed, 664 insertions(+), 334 deletions(-) create mode 100644 pmoaudiocache/src/pmoserver_ext.rs create mode 100644 pmocache/src/cache_trait.rs create mode 100644 pmocache/src/pmoserver_ext.rs diff --git a/.DS_Store b/.DS_Store index 9f89623e41bedb9b0bd3e77b90fbd62b415738f1..30336c358f8ee8761b298d127a06c5e3d8b79870 100644 GIT binary patch literal 14340 zcmeHNU2Ggz6+UM+v1iA2ItfXWH6-5J^hd#V{F^vQOV>YX>(rKmoz#w##_aA)ox!s+ zo1ILzn0mX~UI53nO(Q z;aMlLshg^_O4V_D1NFuR1x^1>vRSM3o3Cs%?% zf6>T@qmJP~=0QAl0sSe|PQ|zcGGVR;NL+%hd6eZCtw1GAtFF@q z1{0+h`0x`UJzL`RTbWmC6f=d*t3J~f=9v0Xj6NOARD>pu#4EPKa_z#pggKUhafQ67 zHVo|}+HAs6Sn&*5Z z>%Z2ST|3^_)0sWiKX~n0THV)iAWlQbF9UBLS5IOhJ#+Q zCvE04VM|EUG*(WaDr}(}d2DZ#dq$X}Mc@d@YmmJvbr5`;?Ag-lo(Nf81L*ztjTv7F zx$0rsouGAbZ`42_8m3esgV-U<(4i>SIl5+2inl{jY@ol@dqn@h(EnnO*q~Qzhe+W~ zh&zf73;PM92{%fv1c3yB1c3yB1c3yBTZ=#grq}U#<%fVJeCo(gW_b?aB21QHQRoBa z^{Jl0 z6ZemePk!cR%dXq>-Fq}`9#NrFwH~v~%DENGs9rvw_ll-tSkB_?vgKI?+dS*!OXhB# zNT+Y!zW+c=rZw}4dRLr9)jQO=TEV(fv#O8FIS#dDw2KA=oOBH_i=n@yre#k@TPAZs z^o&+a8=Y}(xOJe4UyjPtnn+~MUlBbuwj_S&6fQKZl9hJ9J=K!TZrMY7=9?P z|4voaj@)72>?F47y?$jq7f0F|YNr9Z7ZK@sL=P+UEPaKZr|-~<^a}lqeo3#<>+}Y_ zNq?lj&|CC(`X~L1-ecl4@8LGSgWI`-J9&tY^9dg1hxjx<%oBW$=lLQ(%9nVNOYCx) zukcm=Jb!_o@A6ChGT-1=_~-m<{tf?@U*k9VE&dz7&F}C(_+2HX zG%Ib&9ZI{>p?p&5Qu>sBUw);2T5w@qANin+q1cWm1*am)mCZb<^??Vq@muOiZ#4V9 z^Rd1A4t3nq)pzt{Zr#RwqpaIu*t(@jM;t+#v|Rs~Eeey$w0>MWxRsZkgD;q?sxWB1 z(WlO8?J9gh-|1KH)b3W{1$u*DJ>1%vlz+M%1C52Qdq3z6h<0$&&>-x1}Q>>$jt`GHUq z$y-D` - Instance partagée du cache - async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result>; +pub use pmoserver_ext::AudioCacheExt; + - /// Initialise le cache audio avec la configuration par défaut. - /// - /// Utilise automatiquement les paramètres de `pmoconfig::Config`. - async fn init_audio_cache_configured(&mut self) -> anyhow::Result>; -} // Implémentation du trait pour pmoserver::Server (feature-gated) #[cfg(feature = "pmoserver")] diff --git a/pmoaudiocache/src/pmoserver_ext.rs b/pmoaudiocache/src/pmoserver_ext.rs new file mode 100644 index 00000000..d5bf97f5 --- /dev/null +++ b/pmoaudiocache/src/pmoserver_ext.rs @@ -0,0 +1,26 @@ +#[cfg(feature = "pmoserver")] +use crate::AudioCache; + +/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache audio. +/// +/// Ce trait permet à `pmoaudiocache` d'ajouter des méthodes d'extension sur des types +/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmoaudiocache`. +#[cfg(feature = "pmoserver")] +pub trait AudioCacheExt { + /// Initialise le cache audio et enregistre les routes HTTP. + /// + /// # Arguments + /// + /// * `cache_dir` - Répertoire de stockage du cache + /// * `limit` - Limite de taille du cache (en nombre de pistes) + /// + /// # Returns + /// + /// * `Arc` - Instance partagée du cache + async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result>; + + /// Initialise le cache audio avec la configuration par défaut. + /// + /// Utilise automatiquement les paramètres de `pmoconfig::Config`. + async fn init_audio_cache_configured(&mut self) -> anyhow::Result>; +} diff --git a/pmoaudiocache/src/pmoserver_impl.rs b/pmoaudiocache/src/pmoserver_impl.rs index 119b831a..b0396ea7 100644 --- a/pmoaudiocache/src/pmoserver_impl.rs +++ b/pmoaudiocache/src/pmoserver_impl.rs @@ -1,6 +1,6 @@ //! Implémentation du trait AudioCacheExt pour le serveur pmoserver - use crate::{api, AudioCache, AudioCacheExt}; + use axum::{ body::Body, extract::State, @@ -142,6 +142,7 @@ async fn list_collections(State(cache): State>) -> Response { } } +#[cfg(feature = "pmoserver")] impl AudioCacheExt for Server { async fn init_audio_cache( &mut self, diff --git a/pmocache/Cargo.toml b/pmocache/Cargo.toml index 1ad73b0a..e5fada70 100644 --- a/pmocache/Cargo.toml +++ b/pmocache/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] # Base de données -rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite = { version = "0.37.0", features = ["bundled"] } # HTTP client reqwest = { version = "0.12", features = ["blocking"] } @@ -25,6 +25,11 @@ tokio = { version = "1.0", features = ["full"] } # Feature pour OpenAPI utoipa = { version = "5.3", optional = true } +# Feature pour pmoserver (extension HTTP) +axum = { version = "0.8", optional = true } +tracing = { version = "0.1", optional = true } + [features] default = [] openapi = ["dep:utoipa"] +pmoserver = ["dep:axum", "dep:tracing"] diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index 2d03d97e..4f8bf234 100644 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -3,35 +3,32 @@ //! Ce module fournit une interface générique pour gérer un cache de fichiers //! avec métadonnées dans une base de données SQLite. -use std::path::PathBuf; -use std::sync::Arc; -use anyhow::{anyhow, Result}; -use sha1::{Sha1, Digest}; -use tokio::sync::Mutex; +use crate::cache_trait::FileCache; use crate::db::DB; +use anyhow::{anyhow, Result}; +use sha1::{Digest, Sha1}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; -/// Configuration du cache -#[derive(Debug, Clone)] -pub struct CacheConfig { - /// Répertoire de stockage - pub dir: PathBuf, - /// Limite de taille du cache (nombre d'éléments) - pub limit: usize, - /// Nom de la table dans la base de données - pub table_name: String, - /// Extension des fichiers dans le cache - pub file_extension: String, -} - -impl CacheConfig { - /// Crée une nouvelle configuration de cache - pub fn new(dir: &str, limit: usize, table_name: &str, file_extension: &str) -> Self { - Self { - dir: PathBuf::from(dir), - limit, - table_name: table_name.to_string(), - file_extension: file_extension.to_string(), - } +/// Trait pour définir les paramètres du cache +pub trait CacheConfig: Send + Sync { + /// Extension des fichiers (ex: "webp", "flac") + fn file_extension() -> &'static str; + /// Nom de la table dans la base de données (ex: "covers", "audio") + fn table_name() -> &'static str { + "cached_items" + } + /// Type de cache (ex: "audio", "image") + fn cache_type() -> &'static str { + "file" + } + /// Cache name (ex: "covers", "audio", "cache") + fn cache_name() -> &'static str { + "cache" + } + /// Default param extension ("orig") + fn default_param() -> &'static str { + "orig" } } @@ -40,42 +37,45 @@ impl CacheConfig { /// Gère le téléchargement, le stockage et la récupération de fichiers /// avec une base de données SQLite pour les métadonnées. /// +/// # Paramètres de type +/// +/// * `C` - Configuration du cache (implémente `CacheConfig`) +/// /// Note : Ce type est conçu pour être utilisé derrière un `Arc`. -/// Les méthodes prennent `&self` et utilisent des `Arc` et `Mutex` internes -/// pour la synchronisation. +/// La synchronisation est gérée par le Mutex interne de la base de données SQLite. #[derive(Debug)] -pub struct Cache { - pub(crate) config: CacheConfig, +pub struct Cache { + /// Répertoire de stockage + dir: PathBuf, + /// Limite de taille du cache (nombre d'éléments) + limit: usize, + /// URL de base pour la génération d'URLs + base_url: String, + /// Base de données SQLite pub db: Arc, - mu: Arc>, + /// Phantom data pour le type de configuration + _phantom: std::marker::PhantomData, } -impl Cache { - /// Crée un nouveau cache avec la configuration spécifiée +impl Cache { + /// Crée un nouveau cache /// /// # Arguments /// - /// * `config` - Configuration du cache - /// - /// # Exemple - /// - /// ```rust,no_run - /// use pmocache::cache::{Cache, CacheConfig}; - /// - /// let config = CacheConfig::new("./cache", 1000, "my_cache", "webp"); - /// let cache = Cache::new(config).unwrap(); - /// ``` - pub fn new(config: CacheConfig) -> Result { - std::fs::create_dir_all(&config.dir)?; - let db = DB::init( - &config.dir.join("cache.db"), - &config.table_name - )?; + /// * `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 + pub fn new(dir: &str, limit: usize, base_url: &str) -> Result { + let directory = PathBuf::from(dir); + std::fs::create_dir_all(&directory)?; + let db = DB::init(&directory.join("cache.db"), C::table_name())?; Ok(Self { - config, + dir: directory, + limit, + base_url: base_url.to_string(), db: Arc::new(db), - mu: Arc::new(Mutex::new(())), + _phantom: std::marker::PhantomData, }) } @@ -120,30 +120,6 @@ impl Cache { self.add_from_url(url, collection).await } - /// Ajoute des données au cache - /// - /// Cette méthode doit être surchargée par les implémentations spécifiques - /// pour gérer la conversion et le stockage des données. - /// - /// # Arguments - /// - /// * `url` - URL source du fichier - /// * `data` - Données brutes à stocker - /// * `collection` - Collection optionnelle à laquelle appartient le fichier - pub async fn add(&self, url: &str, data: &[u8], collection: Option<&str>) -> Result { - let pk = pk_from_url(url); - let file_path = self.file_path(&pk); - - let _lock = self.mu.lock().await; - - if !file_path.exists() { - // Par défaut, on stocke les données telles quelles - tokio::fs::write(&file_path, data).await?; - } - - self.db.add(&pk, url, collection)?; - Ok(pk) - } /// Récupère le chemin d'un fichier dans le cache /// @@ -151,8 +127,6 @@ impl Cache { /// /// * `pk` - Clé primaire du fichier pub async fn get(&self, pk: &str) -> Result { - let _lock = self.mu.lock().await; - self.db.get(pk)?; self.db.update_hit(pk)?; @@ -170,8 +144,6 @@ impl Cache { /// /// * `collection` - Identifiant de la collection pub async fn get_collection(&self, collection: &str) -> Result> { - let _lock = self.mu.lock().await; - let entries = self.db.get_by_collection(collection)?; let mut paths = Vec::new(); @@ -187,25 +159,22 @@ impl Cache { /// Supprime tous les fichiers et entrées du cache pub async fn purge(&self) -> Result<()> { - let _lock = self.mu.lock().await; - - let mut entries = tokio::fs::read_dir(&self.config.dir).await?; + let mut entries = tokio::fs::read_dir(&self.dir).await?; while let Some(entry) = entries.next_entry().await? { - if entry.path().is_file() && entry.path() != self.config.dir.join("cache.db") { + if entry.path().is_file() && entry.path() != self.dir.join("cache.db") { tokio::fs::remove_file(entry.path()).await?; } } - self.db.purge().map_err(|e| anyhow!("Database error: {}", e)) + self.db + .purge() + .map_err(|e| anyhow!("Database error: {}", e)) } /// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants pub async fn consolidate(&self) -> Result<()> { // Récupérer la liste des entrées à traiter - let entries = { - let _lock = self.mu.lock().await; - self.db.get_all()? - }; + let entries = self.db.get_all()?; // Supprimer les entrées sans fichiers correspondants for entry in entries { @@ -214,10 +183,10 @@ impl Cache { 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.add(&entry.source_url, &data, entry.collection.as_deref()) + .await?; } _ => { - let _lock = self.mu.lock().await; self.db.delete(&entry.pk)?; } } @@ -225,15 +194,17 @@ impl Cache { } // Supprimer les fichiers sans entrées DB correspondantes - let _lock = self.mu.lock().await; - let mut dir_entries = tokio::fs::read_dir(&self.config.dir).await?; + 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() && path != self.config.dir.join("cache.db") { + if path.is_file() && path != self.dir.join("cache.db") { if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { - let pk = file_name.trim_end_matches(&format!(".{}", self.config.file_extension)); - if self.db.get(pk).is_err() { - tokio::fs::remove_file(path).await?; + // Format attendu: {pk}.{qualifier}.{EXT} + // On extrait le pk (première partie avant le premier point) + if let Some(pk) = file_name.split('.').next() { + if self.db.get(pk).is_err() { + tokio::fs::remove_file(path).await?; + } } } } @@ -243,22 +214,78 @@ impl Cache { } /// Retourne le répertoire du cache - pub fn cache_dir(&self) -> String { - self.config.dir.to_string_lossy().to_string() + pub fn cache_dir(&self) -> &Path { + &self.dir } - /// Construit le chemin complet d'un fichier dans le cache - fn file_path(&self, pk: &str) -> PathBuf { - self.config.dir.join(format!("{}.{}", pk, self.config.file_extension)) + /// Retourne l'URL de base + pub fn get_base_url(&self) -> &str { + &self.base_url + } + + /// 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()) } } -/// Génère une clé primaire à partir d'une URL -/// -/// Utilise SHA1 pour hasher l'URL et retourne les 8 premiers octets en hexadécimal. -pub 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]) + +/// Implémentation du trait FileCache pour Cache +impl FileCache for Cache { + fn cache_type(&self) -> &str { + C::cache_type() + } + + fn validate_data(&self, data: &[u8]) -> Result> { + // Le cache générique accepte toutes les données + Ok(data.to_vec()) + } + + async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result { + self.add_from_url(url, collection).await + } + + async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result { + self.ensure_from_url(url, collection).await + } + + async fn add(&self, url: &str, data: &[u8], collection: Option<&str>) -> Result { + // Valider les données avant de les ajouter + let validated_data = self.validate_data(data)?; + + let pk = pk_from_url(url); + let file_path = self.file_path(&pk); + + if !file_path.exists() { + tokio::fs::write(&file_path, &validated_data).await?; + } + + self.db.add(&pk, url, collection)?; + Ok(pk) + } + + async fn get(&self, pk: &str) -> Result { + self.get(pk).await + } + + async fn get_collection(&self, collection: &str) -> Result> { + self.get_collection(collection).await + } + + async fn purge(&self) -> Result<()> { + self.purge().await + } + + async fn consolidate(&self) -> Result<()> { + self.consolidate().await + } + + fn get_cache_dir(&self) -> String { + self.cache_dir() + } + + fn get_base_url(&self) -> &str { + self.get_base_url() + } } diff --git a/pmocache/src/cache_trait.rs b/pmocache/src/cache_trait.rs new file mode 100644 index 00000000..9e86a99b --- /dev/null +++ b/pmocache/src/cache_trait.rs @@ -0,0 +1,126 @@ +use anyhow::Result; +use sha1::{Digest, Sha1}; +use std::{path::{Path, PathBuf}, sync::Arc}; + +use crate::{CacheConfig, DB}; + +/// Trait générique pour les caches de fichiers +/// +/// Définit l'interface commune pour tous les types de caches (images, audio, etc.) +pub trait FileCache: Send + Sync { + + fn get_cache_dir(&self) -> &Path; + fn get_database(&self) -> Arc; + fn get_base_url(&self) -> &str; + /// Valide les données avant de les stocker dans le cache + /// + /// Cette méthode peut être surchargée pour vérifier le type MIME, + /// le magic number, ou effectuer des conversions (ex: WebP, FLAC) + /// + /// # Arguments + /// + /// * `data` - Données brutes à valider + /// + /// # Returns + /// + /// Les données validées/converties ou une erreur + fn validate_data(&self, data: &[u8]) -> Result> { + // Par défaut, on accepte les données telles quelles + Ok(data.to_vec()) + } + + /// Retourne le type de cache + fn cache_type(&self) -> &'static str { + C::cache_type() + } + + /// Retourne le type de cache + fn cache_name(&self) -> &'static str { + C::cache_name() + } + + /// Retourne le type de cache + fn default_param(&self) -> &'static str { + C::default_param() + } + + /// Retourne l'extension des fichiers + fn file_extension(&self) -> &'static str { + C::file_extension() + } + + /// Retourne le nom de la table + fn table_name(&self) -> &'static str { + C::table_name() + } + + + + /// Construit le chemin complet d'un fichier dans le cache + /// + /// Format: `{pk}.{qualificatif}.{extension}` + /// Pour le fichier original: `{pk}.orig.{extension}` + fn file_path(&self, pk: &str) -> PathBuf { + self.file_path_with_qualifier(pk, self.default_param()) + } + + /// Construit le chemin d'un fichier avec un qualificatif + /// + /// Format: `{pk}.{qualificatif}.{extension}` + fn file_path_with_qualifier(&self, pk: &str, qualifier: &str) -> PathBuf { + self.get_cache_dir().join(format!("{}.{}.{}", pk, qualifier, C::file_extension())) + } + + /// Télécharge un fichier depuis une URL et l'ajoute au cache + /// + /// # Arguments + /// + /// * `url` - URL du fichier à télécharger + /// * `collection` - Collection optionnelle à laquelle appartient le fichier + /// + /// # Returns + /// + /// La clé primaire (pk) du fichier dans le cache + async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result; + + /// S'assure qu'un fichier est présent dans le cache + /// + /// Si le fichier existe déjà, retourne sa clé. Sinon, le télécharge. + /// + /// # Arguments + /// + /// * `url` - URL du fichier + /// * `collection` - Collection optionnelle à laquelle appartient le fichier + async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result; + + /// Récupère le chemin d'un fichier dans le cache + /// + /// # Arguments + /// + /// * `pk` - Clé primaire du fichier + async fn get(&self, pk: &str) -> Result; + + /// Récupère tous les fichiers d'une collection + /// + /// # Arguments + /// + /// * `collection` - Identifiant de la collection + async fn get_collection(&self, collection: &str) -> Result>; + + /// Supprime tous les fichiers et entrées du cache + async fn purge(&self) -> Result<()>; + + /// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants + async fn consolidate(&self) -> Result<()>; + +} + +/// Génère une clé primaire à partir d'une URL +/// +/// Utilise SHA1 pour hasher l'URL et retourne les 8 premiers octets en hexadécimal. +pub 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]) +} diff --git a/pmocache/src/lib.rs b/pmocache/src/lib.rs index a638ba44..534faa62 100644 --- a/pmocache/src/lib.rs +++ b/pmocache/src/lib.rs @@ -34,12 +34,19 @@ //! ### Exemple basique //! //! ```rust,no_run -//! use pmocache::cache::{Cache, CacheConfig}; +//! use pmocache::{Cache, CacheConfig}; +//! +//! // Définir la configuration du cache +//! struct MyConfig; +//! impl CacheConfig for MyConfig { +//! fn file_extension() -> &'static str { "dat" } +//! fn table_name() -> &'static str { "my_cache" } +//! fn cache_type() -> &'static str { "generic" } +//! } //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { -//! let config = CacheConfig::new("./cache", 1000, "my_cache", "dat"); -//! let cache = Cache::new(config)?; +//! let cache = Cache::::new("./cache", 1000, "http://localhost:8080")?; //! //! // Ajouter un fichier depuis une URL //! let pk = cache.add_from_url("http://example.com/file.dat", None).await?; @@ -56,12 +63,18 @@ //! ### Utilisation avec des collections //! //! ```rust,no_run -//! use pmocache::cache::{Cache, CacheConfig}; +//! use pmocache::{Cache, CacheConfig}; +//! +//! struct AudioConfig; +//! impl CacheConfig for AudioConfig { +//! fn file_extension() -> &'static str { "flac" } +//! fn table_name() -> &'static str { "audio" } +//! fn cache_type() -> &'static str { "audio" } +//! } //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { -//! let config = CacheConfig::new("./cache", 1000, "audio", "flac"); -//! let cache = Cache::new(config)?; +//! let cache = Cache::::new("./cache", 1000, "http://localhost:8080")?; //! //! // Ajouter des pistes d'un album //! let album_id = "album:the_wall"; @@ -111,6 +124,11 @@ pub mod db; pub mod cache; +pub mod cache_trait; + +#[cfg(feature = "pmoserver")] +pub mod pmoserver_ext; pub use db::{DB, CacheEntry}; pub use cache::{Cache, CacheConfig, pk_from_url}; +pub use cache_trait::FileCache; diff --git a/pmocache/src/pmoserver_ext.rs b/pmocache/src/pmoserver_ext.rs new file mode 100644 index 00000000..db262557 --- /dev/null +++ b/pmocache/src/pmoserver_ext.rs @@ -0,0 +1,142 @@ +//! Extension pmoserver pour servir les fichiers du cache via HTTP +//! +//! Ce module fournit des handlers génériques pour servir les fichiers +//! d'un cache via des routes HTTP structurées. +//! +//! ## Routes générées +//! +//! Format: `/{name}/{type}/{pk}[/{param}]` +//! +//! Exemples: +//! - `/covers/images/abc123` - Image avec param par défaut (orig) +//! - `/covers/images/abc123/thumb` - Image avec param spécifique +//! - `/audio/tracks/def456/stream` - Piste audio +//! +//! ## Utilisation +//! +//! ```rust,no_run +//! use pmocache::pmoserver_ext; +//! use axum::Router; +//! +//! # async fn example(cache: std::sync::Arc>) { +//! // Créer un router pour servir les fichiers +//! let router = pmoserver_ext::create_file_router( +//! cache.clone(), +//! "image/webp" // Content-Type +//! ); +//! +//! // Le router peut être monté sur n'importe quel chemin +//! // Exemple: /covers/images -> GET /covers/images/{pk} +//! // -> GET /covers/images/{pk}/{param} +//! # } +//! ``` + +#[cfg(feature = "pmoserver")] +use crate::{Cache, CacheConfig}; +#[cfg(feature = "pmoserver")] +use axum::{ + body::Body, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +#[cfg(feature = "pmoserver")] +use std::sync::Arc; +#[cfg(feature = "pmoserver")] +use tracing::warn; + +/// Handler générique pour GET /{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)>, + Path(pk): Path, +) -> Response { + match cache.get(&pk).await { + Ok(file_path) => match tokio::fs::read(&file_path).await { + Ok(data) => ( + StatusCode::OK, + [("content-type", content_type)], + data, + ) + .into_response(), + Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), + }, + Err(e) => { + warn!("Error getting file {}: {}", pk, e); + (StatusCode::NOT_FOUND, "Item not found").into_response() + } + } +} + +/// Handler générique pour GET /{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)>, + Path((pk, param)): Path<(String, String)>, +) -> Response { + let file_path = cache.file_path_with_qualifier(&pk, ¶m); + + if !file_path.exists() { + warn!("File not found: {:?}", file_path); + return (StatusCode::NOT_FOUND, "File not found").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); + } + + match tokio::fs::read(&file_path).await { + Ok(data) => ( + StatusCode::OK, + [("content-type", content_type)], + data, + ) + .into_response(), + Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), + } +} + +/// Crée un router pour servir les fichiers d'un cache +/// +/// # Arguments +/// +/// * `cache` - Instance du cache +/// * `content_type` - Type MIME des fichiers (ex: "image/webp", "audio/flac") +/// +/// # Routes créées +/// +/// - `GET /{pk}` - Fichier avec param par défaut +/// - `GET /{pk}/{param}` - Fichier avec param spécifique +/// +/// # Exemple +/// +/// ```rust,no_run +/// use pmocache::pmoserver_ext; +/// use axum::Router; +/// use pmoserver::Server; +/// +/// # async fn example(server: &mut Server, cache: std::sync::Arc>) { +/// let router = pmoserver_ext::create_file_router( +/// cache.clone(), +/// "image/webp" +/// ); +/// +/// // Monter le router sur /covers/images +/// server.add_router("/covers/images", router).await; +/// # } +/// ``` +#[cfg(feature = "pmoserver")] +pub fn create_file_router( + cache: Arc>, + content_type: &'static str, +) -> Router { + Router::new() + .route("/:pk", get(get_file::)) + .route("/:pk/:param", get(get_file_with_param::)) + .with_state((cache, content_type)) +} diff --git a/pmocovers/Cargo.toml b/pmocovers/Cargo.toml index 6179feee..d40422b3 100644 --- a/pmocovers/Cargo.toml +++ b/pmocovers/Cargo.toml @@ -31,4 +31,4 @@ tracing = "0.1.41" [features] default = ["pmoserver"] -pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/openapi"] +pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/openapi", "pmocache/pmoserver"] diff --git a/pmocovers/src/api.rs b/pmocovers/src/api.rs index 84a31330..4b7365b6 100644 --- a/pmocovers/src/api.rs +++ b/pmocovers/src/api.rs @@ -6,7 +6,7 @@ //! - Supprimer des images //! - Consulter les statistiques -use crate::{Cache, CacheEntry}; +use crate::{Cache, CacheEntry, ImageCacheExt}; use axum::{ extract::{Path, State}, http::StatusCode, @@ -146,7 +146,7 @@ pub async fn add_image( .into_response(); } - match cache.add_from_url(&req.url).await { + match cache.add_image_from_url(&req.url).await { Ok(pk) => ( StatusCode::CREATED, Json(AddImageResponse { @@ -200,7 +200,8 @@ pub async fn delete_image( } // Supprimer les fichiers (original + variantes) - let orig_path = cache.dir.join(format!("{}.orig.webp", pk)); + 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 ( @@ -215,7 +216,7 @@ pub async fn delete_image( } // Supprimer toutes les variantes (*.{pk}.*.webp) - if let Ok(mut entries) = tokio::fs::read_dir(&cache.dir).await { + 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) { diff --git a/pmocovers/src/cache.rs b/pmocovers/src/cache.rs index 020a367e..3143b2b5 100644 --- a/pmocovers/src/cache.rs +++ b/pmocovers/src/cache.rs @@ -3,132 +3,163 @@ //! 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. -use std::path::PathBuf; -use std::sync::Arc; -use anyhow::{anyhow, Result}; -use pmocache::{Cache as GenericCache, CacheConfig}; +use anyhow::Result; +use pmocache::{CacheConfig, FileCache}; use crate::webp; -use crate::db::DB; +use std::path::PathBuf; +use std::ops::Deref; + +/// Configuration pour le cache de couvertures +pub struct CoversConfig; + +impl CacheConfig for CoversConfig { + fn file_extension() -> &'static str { + "webp" + } + + fn table_name() -> &'static str { + "covers" + } + + fn cache_type() -> &'static str { + "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 /// -/// Gère le téléchargement, la conversion en WebP, le stockage et la génération -/// de variantes de tailles pour les images de couvertures. +/// Format des fichiers : `{pk}.{qualificatif}.webp` +/// Exemple : `a1b2c3d4.orig.webp`, `a1b2c3d4.thumb.webp` +/// +/// 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 { - cache: GenericCache, - pub(crate) dir: PathBuf, - pub(crate) limit: usize, - pub db: Arc, -} +pub struct Cache(pmocache::Cache); impl Cache { /// Crée un nouveau cache d'images - /// - /// # Arguments - /// - /// * `dir` - Répertoire de stockage du cache - /// * `limit` - Limite de taille du cache (nombre d'images) - /// - /// # Exemple - /// - /// ```rust,no_run - /// use pmocovers::Cache; - /// - /// let cache = Cache::new("./cache", 1000).unwrap(); - /// ``` - pub fn new(dir: &str, limit: usize) -> Result { - let config = CacheConfig::new(dir, limit, "covers", "orig.webp"); - let cache = GenericCache::new(config)?; + pub fn new(dir: &str, limit: usize, base_url: &str) -> Result { + Ok(Self(pmocache::Cache::new(dir, limit, base_url)?)) + } +} - Ok(Self { - dir: PathBuf::from(dir), - limit, - db: Arc::clone(&cache.db), - cache, - }) +/// 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() } - /// Télécharge une image depuis une URL et l'ajoute au cache - /// - /// # Arguments - /// - /// * `url` - URL de l'image à télécharger - /// - /// # Returns - /// - /// La clé primaire (pk) de l'image dans le cache - pub async fn add_from_url(&self, url: &str) -> Result { + 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!("Bad status: {}", response.status())); + return Err(anyhow::anyhow!("Bad status: {}", response.status())); } let data = response.bytes().await?; - self.add(url, &data).await + self.add(url, &data, collection).await } - /// S'assure qu'une image est présente dans le cache - /// - /// Si l'image existe déjà, retourne sa clé. Sinon, la télécharge. - /// - /// # Arguments - /// - /// * `url` - URL de l'image - pub async fn ensure_from_url(&self, url: &str) -> Result { - self.cache.ensure_from_url(url, None).await - } - - /// Ajoute une image au cache avec conversion en WebP - /// - /// # Arguments - /// - /// * `url` - URL source de l'image - /// * `data` - Données brutes de l'image - pub async fn add(&self, url: &str, data: &[u8]) -> Result { + async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result { let pk = pmocache::pk_from_url(url); - let orig_path = self.dir.join(format!("{}.orig.webp", pk)); - // Vérifier si le fichier existe déjà - if !orig_path.exists() { - // Convertir l'image en WebP - let img = image::load_from_memory(data)?; - let webp_data = webp::encode_webp(&img)?; - tokio::fs::write(&orig_path, webp_data).await?; + if self.db.get(&pk).is_ok() { + let file_path = self.file_path(&pk); + if file_path.exists() { + return Ok(pk); + } } - // Ajouter à la DB (sans collection pour les covers) - self.db.add(&pk, url, None)?; + 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) } - /// Récupère le chemin d'une image dans le cache - /// - /// # Arguments - /// - /// * `pk` - Clé primaire de l'image - pub async fn get(&self, pk: &str) -> Result { - self.cache.get(pk).await + 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")) + } } - /// Supprime tous les fichiers et entrées du cache - pub async fn purge(&self) -> Result<()> { - self.cache.purge().await + 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) } - /// Consolide le cache en supprimant les orphelins et en re-téléchargeant les images manquantes - pub async fn consolidate(&self) -> Result<()> { + 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 orig_path = self.dir.join(format!("{}.orig.webp", entry.pk)); - if !orig_path.exists() { + 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).await?; + self.add(&entry.source_url, &data, entry.collection.as_deref()) + .await?; } _ => { self.db.delete(&entry.pk)?; @@ -138,13 +169,14 @@ impl Cache { } // Supprimer les fichiers sans entrées DB correspondantes - let mut dir_entries = tokio::fs::read_dir(&self.dir).await?; + 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 != self.dir.join("cache.db") { + 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()) { - if file_name.ends_with(".orig.webp") { - let pk = file_name.trim_end_matches(".orig.webp"); + // 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?; } @@ -156,8 +188,11 @@ impl Cache { Ok(()) } - /// Retourne le répertoire du cache - pub fn cache_dir(&self) -> String { - self.dir.to_string_lossy().to_string() + fn get_cache_dir(&self) -> String { + self.get_cache_dir() + } + + fn get_base_url(&self) -> &str { + self.get_base_url() } } diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs index ba64c6a5..ba30b82c 100644 --- a/pmocovers/src/lib.rs +++ b/pmocovers/src/lib.rs @@ -100,13 +100,14 @@ //! //! ```rust,no_run //! use pmocovers::Cache; +//! use pmocache::FileCache; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { -//! let cache = Cache::new("./cache", 1000)?; +//! let cache = Cache::new("./cache", 1000, "http://localhost:8080")?; //! -//! // Ajouter une image depuis une URL -//! let pk = cache.add_from_url("http://example.com/cover.jpg").await?; +//! // 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 @@ -197,7 +198,7 @@ pub mod api; #[cfg(feature = "pmoserver")] pub mod openapi; -pub use cache::Cache; +pub use cache::{Cache, CoversConfig}; pub use db::{CacheEntry, DB}; #[cfg(feature = "pmoserver")] diff --git a/pmocovers/src/pmoserver_impl.rs b/pmocovers/src/pmoserver_impl.rs index bd4a5af5..b562ecc1 100644 --- a/pmocovers/src/pmoserver_impl.rs +++ b/pmocovers/src/pmoserver_impl.rs @@ -29,80 +29,39 @@ use crate::{api, Cache, CoverCacheExt}; use axum::{ - body::Body, - extract::State, - http::{Request, StatusCode}, + extract::{Path, State}, + http::StatusCode, response::{IntoResponse, Response}, - routing::{delete, get, post}, + routing::{get, post}, Json, Router, }; use pmoserver::Server; -use tracing::{debug, info, warn}; +use tracing::{info, warn}; 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(); - - warn!("{:?}",parts); - - if parts.len() != 2 { - return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); - } - - let pk = parts[1]; - - match cache.get(pk).await { - Ok(file_path) => { - match tokio::fs::read(&file_path).await { - Ok(data) => ( - StatusCode::OK, - [("content-type", "image/webp")], - data, - ) - .into_response(), - Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(), - } - } - Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(), - } -} - /// Handler pour GET /covers/images/{pk}/{size} +/// Génère une variante d'image à la demande async fn get_cover_variant( State(cache): State>, - req: Request, + Path((pk, size)): Path<(String, String)>, ) -> Response { - // Extraire pk et size du path - let path = req.uri().path(); - let parts: Vec<&str> = path.split('/').collect(); - - if parts.len() != 3 { - return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); - } - - let pk = parts[1]; - let size = match parts[2].parse::() { + 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 { + 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(), + Err(e) => { + warn!("Cannot generate variant for {}: {}", pk, e); + (StatusCode::INTERNAL_SERVER_ERROR, "Cannot generate variant").into_response() + } } } @@ -116,18 +75,28 @@ async fn get_cover_stats(State(cache): State>) -> 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)?); + // 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)?); - // Enregistrer les routes HTTP classiques pour servir les images - let image_router = Router::new() - .route("/{pk}", get(get_cover_image)) + // 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; - self.add_router("/covers/images", image_router).await; + // Route pour les stats self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await; - // Router API RESTful // Router API RESTful qui sera nesté sous /api/covers par add_openapi let api_router = Router::new() // Liste et ajout diff --git a/pmocovers/src/webp.rs b/pmocovers/src/webp.rs index 42f1d7c3..0f3fa443 100644 --- a/pmocovers/src/webp.rs +++ b/pmocovers/src/webp.rs @@ -39,13 +39,14 @@ pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage { } pub async fn generate_variant(cache: &super::cache::Cache, pk: &str, size: usize) -> Result> { - let variant_path = cache.dir.join(format!("{}.{}.webp", pk, size)); + // Utiliser file_path_with_qualifier pour obtenir le chemin + let variant_path = cache.file_path_with_qualifier(pk, &size.to_string()); if variant_path.exists() { return Ok(tokio::fs::read(variant_path).await?); } - let orig_path = cache.dir.join(format!("{}.orig.webp", pk)); + let orig_path = cache.file_path_with_qualifier(pk, "orig"); // Charger l'image de manière synchrone (image::open n'est pas async) let img = tokio::task::spawn_blocking(move || { diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index 414de71d..e48afc05 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -14,7 +14,7 @@ use std::time::SystemTime; use tokio::sync::RwLock; #[cfg(feature = "cache")] -use pmocovers::Cache as CoverCache; +use pmocovers::{Cache as CoverCache, ImageCacheExt}; #[cfg(feature = "cache")] use pmoaudiocache::{AudioCache, AudioMetadata}; @@ -226,7 +226,7 @@ impl QobuzSource { let cached_cover_pk = if let Some(ref cover_cache) = self.inner.cover_cache { if let Some(ref album) = track.album { if let Some(ref image_url) = album.image { - match cover_cache.add_from_url(image_url).await { + match cover_cache.add_image_from_url(image_url).await { Ok(pk) => { tracing::info!("Successfully cached cover for track {}: {}", track_id, pk); Some(pk)