From 37c3d9daf60525457365cf39b18826fb5c2fcd01 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 25 Oct 2025 17:14:24 +0200 Subject: [PATCH] refactoring des caches --- .gitignore | 1 + Cargo.lock | 3 + .../src/components/CoverCacheManager.vue | 8 +- pmoapp/webapp/src/services/coverCache.ts | 56 +++++ pmoaudiocache/Cargo.toml | 3 +- pmoaudiocache/src/config_ext.rs | 106 +++++++++ pmoaudiocache/src/lib.rs | 11 +- pmoaudiocache/src/metadata.rs | 112 ++++----- pmocache/Cargo.toml | 6 + pmocache/src/cache.rs | 172 ++++++++----- pmocache/src/cache_trait.rs | 51 +++- pmocache/src/config_ext.rs | 225 ++++++++++++++++++ pmocache/src/download.rs | 94 ++++++++ pmocache/src/lib.rs | 11 +- pmoconfig/src/lib.rs | 143 ++++------- pmocovers/Cargo.toml | 3 +- pmocovers/src/config_ext.rs | 106 +++++++++ pmocovers/src/lib.rs | 11 +- pmocovers/src/webp.rs | 74 ++++++ pmoqobuz/src/api_rest.rs | 2 +- pmoupnp/src/upnp_server.rs | 11 +- 21 files changed, 964 insertions(+), 245 deletions(-) create mode 100644 pmoaudiocache/src/config_ext.rs create mode 100644 pmocache/src/config_ext.rs create mode 100644 pmocovers/src/config_ext.rs diff --git a/.gitignore b/.gitignore index 4745374f..a0b3cab9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ xxx **/.pmomusic.yml **/.pmomusic_covers/** **/.pmomusic_audio/** +/.pmomusic .DS_Store /target/ /.pmomusic_covers diff --git a/Cargo.lock b/Cargo.lock index bee5aee5..ca4ab018 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2382,10 +2382,13 @@ dependencies = [ "chrono", "futures-util", "hex", + "pmoconfig", "reqwest", "rusqlite", "serde", + "serde_yaml", "sha1", + "sha2", "tokio", "tokio-util", "tracing", diff --git a/pmoapp/webapp/src/components/CoverCacheManager.vue b/pmoapp/webapp/src/components/CoverCacheManager.vue index 075bb4e0..026444b6 100644 --- a/pmoapp/webapp/src/components/CoverCacheManager.vue +++ b/pmoapp/webapp/src/components/CoverCacheManager.vue @@ -141,6 +141,7 @@ import { purgeCache, consolidateCache, getImageUrl, + waitForDownload, } from "../services/coverCache"; // --- États --- @@ -190,11 +191,16 @@ async function handleAddImage() { isAdding.value = true; addError.value=""; addSuccess.value=""; try { const result = await addImage(newImageUrl.value); + addSuccess.value = `Image downloading... PK: ${result.pk}`; + + // Attendre que le téléchargement et la transformation soient terminés + await waitForDownload(result.pk); + addSuccess.value = `Image added! PK: ${result.pk}`; newImageUrl.value = ""; await refreshImages(); } catch(e:any) { addError.value = e.message ?? "Failed to add image"; } - finally { isAdding.value=false; setTimeout(()=>addSuccess.value="",1500); } + finally { isAdding.value=false; setTimeout(()=>addSuccess.value="",3000); } } async function handleDeleteImage(pk:string){ diff --git a/pmoapp/webapp/src/services/coverCache.ts b/pmoapp/webapp/src/services/coverCache.ts index 427d63f6..d4d231fe 100644 --- a/pmoapp/webapp/src/services/coverCache.ts +++ b/pmoapp/webapp/src/services/coverCache.ts @@ -24,6 +24,14 @@ export interface ApiError { message: string; } +export interface DownloadStatus { + pk: string; + finished: boolean; + current_size?: number; + expected_size?: number; + transformed_size?: number; +} + /** * Liste toutes les images en cache */ @@ -109,6 +117,54 @@ export async function consolidateCache(): Promise { } } +/** + * Récupère le statut du téléchargement d'une image + */ +export async function getDownloadStatus(pk: string): Promise { + const response = await fetch(`/api/covers/${pk}/status`); + if (!response.ok) { + const error: ApiError = await response.json(); + throw new Error(error.message || "Failed to get download status"); + } + return response.json(); +} + +/** + * Attend que le téléchargement d'une image soit terminé + * + * @param pk - Clé primaire de l'image + * @param maxWaitMs - Temps maximum d'attente en millisecondes (défaut: 30000) + * @param pollIntervalMs - Intervalle entre les vérifications en millisecondes (défaut: 500) + */ +export async function waitForDownload( + pk: string, + maxWaitMs: number = 30000, + pollIntervalMs: number = 500 +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + try { + const status = await getDownloadStatus(pk); + if (status.finished) { + return; // Téléchargement terminé + } + } catch (error) { + // Si l'API retourne une erreur, on continue d'attendre + console.warn(`Error checking download status for ${pk}:`, error); + } + + // Attendre avant la prochaine vérification + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + + // Timeout atteint, on lance une dernière vérification + const finalStatus = await getDownloadStatus(pk); + if (!finalStatus.finished) { + console.warn(`Download timeout for ${pk}, but continuing anyway`); + } +} + /** * Génère l'URL pour afficher une image */ diff --git a/pmoaudiocache/Cargo.toml b/pmoaudiocache/Cargo.toml index bb44c721..246e2ebf 100644 --- a/pmoaudiocache/Cargo.toml +++ b/pmoaudiocache/Cargo.toml @@ -48,4 +48,5 @@ tracing-subscriber = "0.3" [features] default = ["pmoserver"] -pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"] +pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"] +pmoserver = ["pmoconfig", "dep:pmoserver", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"] diff --git a/pmoaudiocache/src/config_ext.rs b/pmoaudiocache/src/config_ext.rs new file mode 100644 index 00000000..b4268b9e --- /dev/null +++ b/pmoaudiocache/src/config_ext.rs @@ -0,0 +1,106 @@ +//! Extension pour intégrer le cache audio dans pmoconfig +//! +//! Ce module fournit le trait `AudioCacheConfigExt` qui permet d'ajouter facilement +//! des méthodes de gestion du cache audio à pmoconfig::Config. + +use anyhow::Result; +use pmoconfig::Config; +use pmocache::CacheConfigExt; +use std::sync::Arc; + +const DEFAULT_AUDIO_CACHE_DIR: &str = "cache_audio"; +const DEFAULT_AUDIO_CACHE_SIZE: usize = 500; + +/// Trait d'extension pour gérer le cache audio dans pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques +/// au cache audio avec conversion FLAC. +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmoaudiocache::AudioCacheConfigExt; +/// +/// let config = get_config(); +/// let cache = config.create_audio_cache()?; +/// +/// // Utiliser le cache +/// let pk = cache.add_from_url("http://example.com/track.mp3", Some("album:123")).await?; +/// ``` +pub trait AudioCacheConfigExt { + /// Récupère le répertoire du cache audio + /// + /// # Returns + /// + /// Le chemin absolu du répertoire du cache audio (default: "cache_audio") + fn get_audiocache_dir(&self) -> Result; + + /// Définit le répertoire du cache audio + /// + /// # Arguments + /// + /// * `directory` - Chemin du répertoire (absolu ou relatif au config_dir) + fn set_audiocache_dir(&self, directory: String) -> Result<()>; + + /// Récupère la taille maximale du cache audio + /// + /// # Returns + /// + /// Le nombre maximal de pistes audio dans le cache (default: 500) + fn get_audiocache_size(&self) -> Result; + + /// Définit la taille maximale du cache audio + /// + /// # Arguments + /// + /// * `size` - Nombre maximal de pistes audio + fn set_audiocache_size(&self, size: usize) -> Result<()>; + + /// Crée une instance du cache audio configurée avec conversion FLAC + /// + /// Cette méthode factory crée un cache audio en utilisant les paramètres + /// de configuration (répertoire et taille) et active la conversion FLAC + /// automatique pour tous les fichiers audio téléchargés. + /// + /// # Returns + /// + /// Une instance Arc du cache audio configuré + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmoconfig::get_config; + /// use pmoaudiocache::AudioCacheConfigExt; + /// + /// let config = get_config(); + /// let cache = config.create_audio_cache()?; + /// + /// // Le cache est prêt à être utilisé avec conversion FLAC automatique + /// ``` + fn create_audio_cache(&self) -> Result>; +} + +impl AudioCacheConfigExt for Config { + fn get_audiocache_dir(&self) -> Result { + self.get_cache_dir("audio_cache", DEFAULT_AUDIO_CACHE_DIR) + } + + fn set_audiocache_dir(&self, directory: String) -> Result<()> { + self.set_cache_dir("audio_cache", directory) + } + + fn get_audiocache_size(&self) -> Result { + self.get_cache_size("audio_cache", DEFAULT_AUDIO_CACHE_SIZE) + } + + fn set_audiocache_size(&self, size: usize) -> Result<()> { + self.set_cache_size("audio_cache", size) + } + + fn create_audio_cache(&self) -> Result> { + let dir = self.get_audiocache_dir()?; + let size = self.get_audiocache_size()?; + Ok(Arc::new(crate::cache::new_cache(&dir, size)?)) + } +} diff --git a/pmoaudiocache/src/lib.rs b/pmoaudiocache/src/lib.rs index 090060dc..66311976 100644 --- a/pmoaudiocache/src/lib.rs +++ b/pmoaudiocache/src/lib.rs @@ -137,10 +137,16 @@ pub mod metadata; #[cfg(feature = "pmoserver")] pub mod openapi; +#[cfg(feature = "pmoconfig")] +pub mod config_ext; + // Re-exports principaux pub use cache::{add_with_metadata_extraction, get_metadata, new_cache, AudioConfig, Cache}; pub use metadata::AudioMetadata; +#[cfg(feature = "pmoconfig")] +pub use config_ext::AudioCacheConfigExt; + #[cfg(feature = "pmoserver")] pub use openapi::ApiDoc; @@ -207,9 +213,10 @@ impl AudioCacheExt for pmoserver::Server { } async fn init_audio_cache_configured(&mut self) -> anyhow::Result> { + use crate::AudioCacheConfigExt; let config = pmoconfig::get_config(); - let cache_dir = config.get_audio_cache_dir()?; - let limit = config.get_audio_cache_size()?; + let cache_dir = config.get_audiocache_dir()?; + let limit = config.get_audiocache_size()?; self.init_audio_cache(&cache_dir, limit).await } } diff --git a/pmoaudiocache/src/metadata.rs b/pmoaudiocache/src/metadata.rs index 964ed896..7d46a50c 100644 --- a/pmoaudiocache/src/metadata.rs +++ b/pmoaudiocache/src/metadata.rs @@ -71,6 +71,46 @@ pub struct AudioMetadata { } impl AudioMetadata { + /// Extrait les métadonnées depuis un fichier audio taggé + /// + /// Fonction interne commune pour extraire les métadonnées depuis un TaggedFile + fn from_tagged_file(tagged_file: lofty::file::TaggedFile) -> Self { + let properties = tagged_file.properties(); + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); + + let mut metadata = Self { + title: None, + artist: None, + album: None, + year: None, + track_number: None, + track_total: None, + disc_number: None, + disc_total: None, + genre: None, + duration_secs: Some(properties.duration().as_secs()), + sample_rate: properties.sample_rate(), + channels: properties.channels(), + bitrate: properties.audio_bitrate(), + }; + + if let Some(tag) = tag { + metadata.title = tag.title().map(|s| s.to_string()); + metadata.artist = tag.artist().map(|s| s.to_string()); + metadata.album = tag.album().map(|s| s.to_string()); + metadata.year = tag.year(); + metadata.track_number = tag.track(); + metadata.track_total = tag.track_total(); + metadata.disc_number = tag.disk(); + metadata.disc_total = tag.disk_total(); + metadata.genre = tag.genre().map(|s| s.to_string()); + } + + metadata + } + /// Extrait les métadonnées d'un fichier audio /// /// # Arguments @@ -88,41 +128,7 @@ impl AudioMetadata { /// ``` pub fn from_file(path: &Path) -> Result { let tagged_file = Probe::open(path)?.options(ParseOptions::new()).read()?; - - let properties = tagged_file.properties(); - let tag = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()); - - let mut metadata = Self { - title: None, - artist: None, - album: None, - year: None, - track_number: None, - track_total: None, - disc_number: None, - disc_total: None, - genre: None, - duration_secs: Some(properties.duration().as_secs()), - sample_rate: properties.sample_rate(), - channels: properties.channels(), - bitrate: properties.audio_bitrate(), - }; - - if let Some(tag) = tag { - metadata.title = tag.title().map(|s| s.to_string()); - metadata.artist = tag.artist().map(|s| s.to_string()); - metadata.album = tag.album().map(|s| s.to_string()); - metadata.year = tag.year(); - metadata.track_number = tag.track(); - metadata.track_total = tag.track_total(); - metadata.disc_number = tag.disk(); - metadata.disc_total = tag.disk_total(); - metadata.genre = tag.genre().map(|s| s.to_string()); - } - - Ok(metadata) + Ok(Self::from_tagged_file(tagged_file)) } /// Crée des métadonnées depuis des données brutes audio @@ -136,41 +142,7 @@ impl AudioMetadata { .guess_file_type()? .options(ParseOptions::new()) .read()?; - - let properties = tagged_file.properties(); - let tag = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()); - - let mut metadata = Self { - title: None, - artist: None, - album: None, - year: None, - track_number: None, - track_total: None, - disc_number: None, - disc_total: None, - genre: None, - duration_secs: Some(properties.duration().as_secs()), - sample_rate: properties.sample_rate(), - channels: properties.channels(), - bitrate: properties.audio_bitrate(), - }; - - if let Some(tag) = tag { - metadata.title = tag.title().map(|s| s.to_string()); - metadata.artist = tag.artist().map(|s| s.to_string()); - metadata.album = tag.album().map(|s| s.to_string()); - metadata.year = tag.year(); - metadata.track_number = tag.track(); - metadata.track_total = tag.track_total(); - metadata.disc_number = tag.disk(); - metadata.disc_total = tag.disk_total(); - metadata.genre = tag.genre().map(|s| s.to_string()); - } - - Ok(metadata) + Ok(Self::from_tagged_file(tagged_file)) } /// Génère une clé de collection basée sur l'artiste et l'album diff --git a/pmocache/Cargo.toml b/pmocache/Cargo.toml index a9789621..65cbcdb6 100644 --- a/pmocache/Cargo.toml +++ b/pmocache/Cargo.toml @@ -13,6 +13,7 @@ futures-util = "0.3" # Cryptographie sha1 = "0.10" +sha2 = "0.10" hex = "0.4" # Utilitaires @@ -34,7 +35,12 @@ utoipa = { version = "5.3", optional = true } # Feature pour pmoserver (extension HTTP) axum = { version = "0.8", optional = true } +# Feature pour pmoconfig (extension de configuration) +pmoconfig = { path = "../pmoconfig", optional = true } +serde_yaml = { version = "0.9", optional = true } + [features] default = [] openapi = ["dep:utoipa"] pmoserver = ["dep:axum"] +pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"] diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index 2b7b40dd..43b9295c 100644 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -3,7 +3,7 @@ //! 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 crate::cache_trait::{pk_from_url, FileCache}; +use crate::cache_trait::FileCache; use crate::db::DB; use crate::download::{ download_with_transformer, ingest_with_transformer, Download, StreamTransformer, @@ -132,8 +132,18 @@ impl Cache { /// 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. - /// Le download est tracké dans la map jusqu'à sa fin. + /// Cette méthode utilise un système d'identifiants basé sur le contenu plutôt que sur l'URL. + /// Elle télécharge les 512 premiers octets du fichier pour calculer un identifiant unique (pk), + /// puis vérifie si le fichier est déjà en cache. Si c'est le cas, elle met à jour le timestamp + /// et retourne rapidement. Sinon, elle lance le téléchargement complet en arrière-plan. + /// + /// # Workflow + /// + /// 1. Télécharge les 512 premiers octets via une requête HTTP partielle + /// 2. Calcule le pk en hashant (SHA256) ces premiers octets + /// 3. Vérifie si le fichier existe déjà dans le cache + /// 4. Si oui : update timestamp et retour rapide + /// 5. Si non : lance le téléchargement complet en background /// /// # Arguments /// @@ -142,21 +152,46 @@ impl Cache { /// /// # Returns /// - /// La clé primaire (pk) du fichier dans le cache + /// La clé primaire (pk) du fichier dans le cache, calculée à partir du contenu + /// + /// # Note + /// + /// Deux URLs différentes pointant vers le même contenu auront le même pk, + /// permettant une déduplication automatique. pub async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result { - let pk = pk_from_url(url); - let file_path = self.file_path(&pk); + // 1. Télécharger les 512 premiers octets pour calculer le pk + let header = crate::download::peek_header(url, 512) + .await + .map_err(|e| anyhow!("Failed to peek header: {}", e))?; - // Vérifier si déjà en cours de téléchargement - { - let downloads = self.downloads.read().await; - if downloads.contains_key(&pk) { - // Download déjà en cours, retourner la clé + // 2. Calculer le pk basé sur le contenu + let pk = crate::cache_trait::pk_from_content_header(&header); + tracing::debug!("Computed pk {} for URL {}", pk, url); + + // 3. Vérifier si le fichier est déjà en cache + if self.db.get(&pk).is_ok() { + let file_path = self.file_path(&pk); + if file_path.exists() { + // Déjà en cache, update timestamp et retour rapide + tracing::debug!("File with pk {} already in cache, updating timestamp", pk); + self.db.update_hit(&pk)?; return Ok(pk); } } - // Lancer le téléchargement avec transformer + // 4. Vérifier si un download est déjà en cours pour ce pk + { + let downloads = self.downloads.read().await; + if downloads.contains_key(&pk) { + // Download déjà en cours pour ce contenu, retourner la clé + tracing::debug!("Download already in progress for pk {}", pk); + return Ok(pk); + } + } + + // 5. Lancer le téléchargement complet avec transformer + tracing::debug!("Starting full download for pk {} from URL {}", pk, url); + let file_path = self.file_path(&pk); let transformer = self.transformer_factory.as_ref().map(|f| f()); let download = download_with_transformer(&file_path, url, transformer); @@ -170,7 +205,6 @@ impl Cache { 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); } @@ -179,9 +213,7 @@ impl Cache { let downloads_clone = self.downloads.clone(); let pk_clone = pk.clone(); tokio::spawn(async move { - // Attendre la fin du téléchargement let _ = download.wait_until_finished().await; - // Retirer de la map downloads_clone.write().await.remove(&pk_clone); }); @@ -190,38 +222,79 @@ impl Cache { /// Ajoute un fichier à partir d'un flux asynchrone. /// - /// Le flux peut provenir de n'importe quelle source (stream HTTP custom, décodeur, - /// extraction en mémoire, etc.). Les mêmes transformers que `add_from_url` sont - /// appliqués. + /// Cette méthode utilise le même système d'identifiants basé sur le contenu que `add_from_url`. + /// Elle lit les 512 premiers octets du flux pour calculer l'identifiant, puis reconstitue + /// le flux complet pour l'ingestion. + /// + /// # Workflow + /// + /// 1. Lit les 512 premiers octets du reader + /// 2. Calcule le pk en hashant (SHA256) ces premiers octets + /// 3. Vérifie si le fichier existe déjà dans le cache + /// 4. Si oui : update timestamp et retour rapide + /// 5. Si non : reconstitue le reader (header + reste) et lance l'ingestion /// /// # Arguments /// - /// * `source_uri` - Identifiant logique du flux (utilisé pour générer le pk) + /// * `source_uri` - Identifiant logique du flux (pour traçabilité dans la DB) /// * `reader` - Flux asynchrone fournissant les données /// * `length` - Taille attendue (si connue) /// * `collection` - Collection optionnelle à laquelle appartient l'élément + /// + /// # Returns + /// + /// La clé primaire (pk) du fichier dans le cache, calculée à partir du contenu pub async fn add_from_reader( &self, source_uri: &str, - reader: R, + mut reader: R, length: Option, collection: Option<&str>, ) -> Result where R: AsyncRead + Send + Unpin + 'static, { - let pk = pk_from_url(source_uri); - let file_path = self.file_path(&pk); + // 1. Lire les 512 premiers octets pour calculer le pk + let header = crate::download::peek_reader_header(&mut reader, 512) + .await + .map_err(|e| anyhow!("Failed to peek reader header: {}", e))?; - { - let downloads = self.downloads.read().await; - if downloads.contains_key(&pk) { + // 2. Calculer le pk basé sur le contenu + let pk = crate::cache_trait::pk_from_content_header(&header); + tracing::debug!("Computed pk {} for source_uri {}", pk, source_uri); + + // 3. Vérifier si le fichier est déjà en cache + if self.db.get(&pk).is_ok() { + let file_path = self.file_path(&pk); + if file_path.exists() { + // Déjà en cache, update timestamp et retour rapide + tracing::debug!("File with pk {} already in cache, updating timestamp", pk); + self.db.update_hit(&pk)?; return Ok(pk); } } + // 4. Vérifier si un download est déjà en cours pour ce pk + { + let downloads = self.downloads.read().await; + if downloads.contains_key(&pk) { + tracing::debug!("Download already in progress for pk {}", pk); + return Ok(pk); + } + } + + // 5. Reconstituer le reader complet (header + reste) + // Utiliser tokio::io::chain pour créer un reader composé + use std::io::Cursor; + use tokio::io::AsyncReadExt; + let header_reader = Cursor::new(header); + let full_reader = header_reader.chain(reader); + + // 6. Lancer l'ingestion avec transformer + tracing::debug!("Starting ingestion for pk {} from reader", pk); + let file_path = self.file_path(&pk); let transformer = self.transformer_factory.as_ref().map(|factory| factory()); - let download = ingest_with_transformer(&file_path, reader, length, transformer); + let download = ingest_with_transformer(&file_path, full_reader, length, transformer); { let mut downloads = self.downloads.write().await; @@ -246,7 +319,9 @@ impl Cache { /// Ajoute un fichier local au cache /// - /// Le fichier est copié dans le cache via une URL file:// + /// Cette méthode lit les 512 premiers octets du fichier local pour calculer + /// l'identifiant basé sur le contenu, puis utilise `add_from_reader()` pour + /// l'ingestion complète. /// /// # Arguments /// @@ -255,7 +330,21 @@ impl Cache { /// /// # Returns /// - /// La clé primaire (pk) du fichier dans le cache + /// La clé primaire (pk) du fichier dans le cache, calculée à partir du contenu + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmocache::{Cache, CacheConfig}; + /// + /// struct MyConfig; + /// impl CacheConfig for MyConfig { + /// fn file_extension() -> &'static str { "dat" } + /// } + /// + /// let cache = Cache::::new("./cache", 1000)?; + /// let pk = cache.add_from_file("/path/to/file.dat", None).await?; + /// ``` pub async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result { let canonical_path = std::fs::canonicalize(path)?; let file_url = format!("file://{}", canonical_path.display()); @@ -264,31 +353,12 @@ impl Cache { .ok() .map(|m| m.len()); let reader = tokio::fs::File::open(&canonical_path).await?; + + // add_from_reader() s'occupe de lire les 512 premiers octets et de calculer le pk self.add_from_reader(&file_url, reader, length, collection) .await } - /// 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 - pub async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result { - let pk = 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 - } - /// Récupère le chemin d'un fichier dans le cache /// /// # Arguments @@ -620,10 +690,6 @@ impl FileCache for Cache { self.add_from_file(path, collection).await } - async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result { - self.ensure_from_url(url, collection).await - } - async fn get(&self, pk: &str) -> Result { self.get(pk).await } diff --git a/pmocache/src/cache_trait.rs b/pmocache/src/cache_trait.rs index f6453121..c80225c9 100644 --- a/pmocache/src/cache_trait.rs +++ b/pmocache/src/cache_trait.rs @@ -116,16 +116,6 @@ pub trait FileCache: Send + Sync { /// La clé primaire (pk) du fichier dans le cache async fn add_from_file(&self, path: &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 @@ -147,9 +137,48 @@ pub trait FileCache: Send + Sync { async fn consolidate(&self) -> Result<()>; } -/// Génère une clé primaire à partir d'une URL +/// Génère une clé primaire à partir des premiers octets d'un document +/// +/// Utilise SHA256 pour hasher les premiers octets du contenu et retourne les 16 premiers octets +/// en hexadécimal (32 caractères). L'utilisation de 16 octets au lieu de 8 réduit considérablement +/// les risques de collision. +/// +/// # Arguments +/// +/// * `header` - Les premiers octets du document (typiquement 512 octets) +/// +/// # Returns +/// +/// Une chaîne hexadécimale de 32 caractères servant de clé primaire unique +/// +/// # Exemple +/// +/// ``` +/// use pmocache::pk_from_content_header; +/// +/// let data = b"Some file content..."; +/// let pk = pk_from_content_header(data); +/// assert_eq!(pk.len(), 32); // 16 bytes = 32 hex chars +/// ``` +pub fn pk_from_content_header(header: &[u8]) -> String { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(header); + let result = hasher.finalize(); + hex::encode(&result[..16]) // 16 octets = 32 caractères hex +} + +/// Génère une clé primaire à partir d'une URL (legacy) +/// +/// **DEPRECATED**: Cette fonction est obsolète et ne devrait plus être utilisée. +/// Utilisez `pk_from_content_header()` à la place pour générer des identifiants +/// basés sur le contenu plutôt que sur l'URL. /// /// Utilise SHA1 pour hasher l'URL et retourne les 8 premiers octets en hexadécimal. +#[deprecated( + since = "0.2.0", + note = "Utilisez pk_from_content_header() pour des identifiants basés sur le contenu" +)] pub fn pk_from_url(url: &str) -> String { let mut hasher = Sha1::new(); hasher.update(url.as_bytes()); diff --git a/pmocache/src/config_ext.rs b/pmocache/src/config_ext.rs new file mode 100644 index 00000000..6bb8c337 --- /dev/null +++ b/pmocache/src/config_ext.rs @@ -0,0 +1,225 @@ +//! Extension pour intégrer la gestion des caches dans pmoconfig +//! +//! Ce module fournit le trait `CacheConfigExt` qui permet d'ajouter facilement +//! des méthodes de gestion de cache générique à pmoconfig::Config. +//! +//! Il propose également un macro `impl_cache_config_ext!` pour simplifier +//! l'implémentation de traits d'extension spécialisés (audio, covers, etc.). + +use anyhow::Result; +use pmoconfig::Config; +use serde_yaml::{Number, Value}; +use std::sync::Arc; + +/// Trait d'extension pour ajouter la gestion des caches à pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes génériques pour gérer +/// n'importe quel type de cache (audio, images, etc.). +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmocache::{CacheConfigExt, AudioConfig}; +/// +/// let config = get_config(); +/// let cache_dir = config.get_cache_dir("audio_cache", "cache_audio")?; +/// let cache_size = config.get_cache_size("audio_cache", 500)?; +/// ``` +pub trait CacheConfigExt { + /// Récupère le répertoire d'un cache + /// + /// # Arguments + /// + /// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache") + /// * `default` - Nom de répertoire par défaut si non configuré + /// + /// # Returns + /// + /// Le chemin absolu du répertoire du cache + fn get_cache_dir(&self, cache_type: &str, default: &str) -> Result; + + /// Définit le répertoire d'un cache + /// + /// # Arguments + /// + /// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache") + /// * `directory` - Chemin du répertoire (absolu ou relatif au config_dir) + fn set_cache_dir(&self, cache_type: &str, directory: String) -> Result<()>; + + /// Récupère la taille maximale d'un cache + /// + /// # Arguments + /// + /// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache") + /// * `default` - Taille par défaut si non configurée + /// + /// # Returns + /// + /// Le nombre maximal d'éléments dans le cache + fn get_cache_size(&self, cache_type: &str, default: usize) -> Result; + + /// Définit la taille maximale d'un cache + /// + /// # Arguments + /// + /// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache") + /// * `size` - Nombre maximal d'éléments + fn set_cache_size(&self, cache_type: &str, size: usize) -> Result<()>; + + /// Crée une instance de cache générique configurée + /// + /// Cette méthode factory crée un cache en utilisant les paramètres + /// de configuration (répertoire et taille). + /// + /// # Arguments + /// + /// * `cache_type` - Type de cache (ex: "audio_cache", "cover_cache") + /// * `default_dir` - Répertoire par défaut + /// * `default_size` - Taille par défaut + /// + /// # Returns + /// + /// Une instance Arc du cache configuré + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmoconfig::get_config; + /// use pmocache::{CacheConfigExt, AudioConfig}; + /// + /// let config = get_config(); + /// let cache = config.create_cache::("audio_cache", "cache_audio", 500)?; + /// ``` + fn create_cache( + &self, + cache_type: &str, + default_dir: &str, + default_size: usize, + ) -> Result>>; +} + +impl CacheConfigExt for Config { + fn get_cache_dir(&self, cache_type: &str, default: &str) -> Result { + self.get_managed_dir(&["host", cache_type, "directory"], default) + } + + fn set_cache_dir(&self, cache_type: &str, directory: String) -> Result<()> { + self.set_managed_dir(&["host", cache_type, "directory"], directory) + } + + fn get_cache_size(&self, cache_type: &str, default: usize) -> Result { + match self.get_value(&["host", cache_type, "size"])? { + Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize), + Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize), + _ => Ok(default), + } + } + + fn set_cache_size(&self, cache_type: &str, size: usize) -> Result<()> { + let n = Number::from(size); + self.set_value(&["host", cache_type, "size"], Value::Number(n)) + } + + fn create_cache( + &self, + cache_type: &str, + default_dir: &str, + default_size: usize, + ) -> Result>> { + let dir = self.get_cache_dir(cache_type, default_dir)?; + let size = self.get_cache_size(cache_type, default_size)?; + Ok(Arc::new(crate::Cache::::new(&dir, size)?)) + } +} + +/// Macro pour simplifier l'implémentation de traits d'extension de cache spécialisés +/// +/// Ce macro génère automatiquement un trait d'extension pour `pmoconfig::Config` +/// avec des méthodes spécifiques à un type de cache (audio, covers, etc.). +/// +/// # Arguments +/// +/// * `trait_name` - Nom du trait à générer (ex: `AudioCacheConfigExt`) +/// * `cache_type` - Type de cache dans la config (ex: `"audio_cache"`) +/// * `default_dir` - Répertoire par défaut (ex: `"cache_audio"`) +/// * `default_size` - Taille par défaut (ex: `500`) +/// * `cache_struct` - Type du cache (ex: `crate::Cache`) +/// * `constructor` - Expression pour construire le cache (ex: `crate::cache::new_cache(&dir, size)`) +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmocache::impl_cache_config_ext; +/// +/// impl_cache_config_ext! { +/// AudioCacheConfigExt, +/// "audio_cache", +/// "cache_audio", +/// 500, +/// crate::Cache, +/// |dir, size| crate::cache::new_cache(dir, size) +/// } +/// ``` +/// +/// Cela génère un trait avec les méthodes : +/// - `get_audiocache_dir()` / `set_audiocache_dir()` +/// - `get_audiocache_size()` / `set_audiocache_size()` +/// - `create_audio_cache()` +#[macro_export] +macro_rules! impl_cache_config_ext { + ( + $trait_name:ident, + $cache_type:expr, + $default_dir:expr, + $default_size:expr, + $cache_type_struct:ty, + $constructor:expr + ) => { + pub trait $trait_name { + /// Récupère le répertoire du cache + fn get_cache_dir_ext(&self) -> anyhow::Result; + + /// Définit le répertoire du cache + fn set_cache_dir_ext(&self, directory: String) -> anyhow::Result<()>; + + /// Récupère la taille maximale du cache + fn get_cache_size_ext(&self) -> anyhow::Result; + + /// Définit la taille maximale du cache + fn set_cache_size_ext(&self, size: usize) -> anyhow::Result<()>; + + /// Crée une instance du cache configurée + fn create_cache_ext(&self) -> anyhow::Result>; + } + + impl $trait_name for pmoconfig::Config { + fn get_cache_dir_ext(&self) -> anyhow::Result { + use $crate::CacheConfigExt; + self.get_cache_dir($cache_type, $default_dir) + } + + fn set_cache_dir_ext(&self, directory: String) -> anyhow::Result<()> { + use $crate::CacheConfigExt; + self.set_cache_dir($cache_type, directory) + } + + fn get_cache_size_ext(&self) -> anyhow::Result { + use $crate::CacheConfigExt; + self.get_cache_size($cache_type, $default_size) + } + + fn set_cache_size_ext(&self, size: usize) -> anyhow::Result<()> { + use $crate::CacheConfigExt; + self.set_cache_size($cache_type, size) + } + + fn create_cache_ext(&self) -> anyhow::Result> { + let dir = self.get_cache_dir_ext()?; + let size = self.get_cache_size_ext()?; + let constructor = $constructor; + Ok(std::sync::Arc::new(constructor(&dir, size)?)) + } + } + }; +} diff --git a/pmocache/src/download.rs b/pmocache/src/download.rs index ab161a9c..992ca34d 100644 --- a/pmocache/src/download.rs +++ b/pmocache/src/download.rs @@ -460,3 +460,97 @@ async fn default_copy( s.finished = true; Ok(()) } + +/// Lit les premiers octets d'une URL sans télécharger le fichier complet +/// +/// Cette fonction effectue une requête HTTP partielle (Range header) pour télécharger +/// uniquement les premiers octets d'un fichier. C'est utilisé pour calculer l'identifiant +/// basé sur le contenu sans avoir à télécharger tout le fichier. +/// +/// # Arguments +/// +/// * `url` - URL du fichier à télécharger +/// * `max_bytes` - Nombre maximum d'octets à lire (par défaut 512) +/// +/// # Returns +/// +/// Un `Vec` contenant les premiers octets du fichier +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmocache::download::peek_header; +/// +/// let header = peek_header("http://example.com/file.dat", 512).await?; +/// let pk = pk_from_content_header(&header); +/// ``` +pub async fn peek_header(url: &str, max_bytes: usize) -> Result, String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + + // Essayer d'abord avec une requête Range + let range_header = format!("bytes=0-{}", max_bytes - 1); + let mut response = client + .get(url) + .header("Range", range_header) + .send() + .await + .map_err(|e| format!("Failed to fetch URL '{}': {}", url, e))?; + + // Si le serveur ne supporte pas Range (status 200 au lieu de 206), + // on lit quand même mais on limite la lecture + if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT { + return Err(format!("HTTP error: {}", response.status())); + } + + let mut buffer = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { + buffer.extend_from_slice(&chunk); + if buffer.len() >= max_bytes { + buffer.truncate(max_bytes); + break; + } + } + + Ok(buffer) +} + +/// Lit les premiers octets d'un reader asynchrone +/// +/// Cette fonction lit jusqu'à `max_bytes` octets depuis un reader asynchrone. +/// C'est utilisé pour calculer l'identifiant basé sur le contenu des fichiers locaux +/// ou des streams. +/// +/// # Arguments +/// +/// * `reader` - Le reader asynchrone à lire +/// * `max_bytes` - Nombre maximum d'octets à lire (par défaut 512) +/// +/// # Returns +/// +/// Un `Vec` contenant les premiers octets lus +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmocache::download::peek_reader_header; +/// use tokio::fs::File; +/// +/// let mut file = File::open("file.dat").await?; +/// let header = peek_reader_header(&mut file, 512).await?; +/// let pk = pk_from_content_header(&header); +/// ``` +pub async fn peek_reader_header(reader: &mut R, max_bytes: usize) -> Result, String> +where + R: AsyncRead + Unpin, +{ + let mut buffer = vec![0u8; max_bytes]; + let n = reader + .read(&mut buffer) + .await + .map_err(|e| format!("Failed to read from stream: {}", e))?; + buffer.truncate(n); + Ok(buffer) +} diff --git a/pmocache/src/lib.rs b/pmocache/src/lib.rs index aaccc91d..36d1b1f9 100644 --- a/pmocache/src/lib.rs +++ b/pmocache/src/lib.rs @@ -136,11 +136,15 @@ pub mod api; #[cfg(feature = "openapi")] pub mod openapi; +#[cfg(feature = "pmoconfig")] +pub mod config_ext; + pub use cache::{Cache, CacheConfig}; -pub use cache_trait::{pk_from_url, FileCache}; +pub use cache_trait::{pk_from_content_header, pk_from_url, FileCache}; pub use db::{CacheEntry, DB}; pub use download::{ - download, download_with_transformer, ingest_with_transformer, Download, StreamTransformer, + download, download_with_transformer, ingest_with_transformer, peek_header, + peek_reader_header, Download, StreamTransformer, }; #[cfg(feature = "pmoserver")] @@ -148,3 +152,6 @@ pub use pmoserver_ext::{create_api_router, create_file_router, GenericCacheExt}; #[cfg(all(feature = "pmoserver", feature = "openapi"))] pub use api::{AddItemRequest, AddItemResponse, DeleteItemResponse, DownloadStatus, ErrorResponse}; + +#[cfg(feature = "pmoconfig")] +pub use config_ext::CacheConfigExt; diff --git a/pmoconfig/src/lib.rs b/pmoconfig/src/lib.rs index 62a9b02d..6117a3bd 100644 --- a/pmoconfig/src/lib.rs +++ b/pmoconfig/src/lib.rs @@ -59,10 +59,6 @@ const ENV_PREFIX: &str = "PMOMUSIC_CONFIG__"; // Default values for configuration const DEFAULT_HTTP_PORT: u16 = 8080; -const DEFAULT_COVER_CACHE_DIR: &str = "cache_covers"; -const DEFAULT_COVER_CACHE_SIZE: usize = 2000; -const DEFAULT_AUDIO_CACHE_DIR: &str = "cache_audio"; -const DEFAULT_AUDIO_CACHE_SIZE: usize = 500; const DEFAULT_LOG_BUFFER_CAPACITY: usize = 1000; const DEFAULT_LOG_MIN_LEVEL: &str = "TRACE"; const DEFAULT_LOG_ENABLE_CONSOLE: bool = true; @@ -437,33 +433,57 @@ impl Config { Ok(absolute_path.to_string_lossy().to_string()) } - /// Generic function to get cache directory - fn get_cache_dir(&self, cache_type: &str, default_dir: &str) -> Result { - let dir_path = match self.get_value(&["host", cache_type, "directory"]) { + /// Récupère un répertoire géré par la configuration + /// + /// Cette méthode générique permet de récupérer n'importe quel répertoire + /// configuré dans le YAML. Le répertoire peut être absolu ou relatif au + /// répertoire de configuration. Il sera créé s'il n'existe pas. + /// + /// # Arguments + /// + /// * `path` - Chemin dans l'arbre de configuration (ex: `&["host", "cache", "directory"]`) + /// * `default` - Nom de répertoire par défaut si non configuré + /// + /// # Returns + /// + /// Le chemin absolu du répertoire, créé s'il n'existait pas + /// + /// # Exemple + /// + /// ```no_run + /// use pmoconfig::get_config; + /// + /// let config = get_config(); + /// let cache_dir = config.get_managed_dir(&["host", "audio_cache", "directory"], "cache_audio")?; + /// println!("Audio cache directory: {}", cache_dir); + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn get_managed_dir(&self, path: &[&str], default: &str) -> Result { + let dir_path = match self.get_value(path) { Ok(Value::String(s)) => s, - _ => default_dir.to_string(), + _ => default.to_string(), }; self.resolve_and_create_dir(&dir_path) } - /// Generic function to set cache directory - fn set_cache_dir(&self, cache_type: &str, directory: String) -> Result<()> { - self.set_value(&["host", cache_type, "directory"], Value::String(directory)) - } - - /// Generic function to get cache size - fn get_cache_size(&self, cache_type: &str, default_size: usize) -> Result { - match self.get_value(&["host", cache_type, "size"])? { - Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize), - Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize), - _ => Ok(default_size), - } - } - - /// Generic function to set cache size - fn set_cache_size(&self, cache_type: &str, size: usize) -> Result<()> { - let n = Number::from(size); - self.set_value(&["host", cache_type, "size"], Value::Number(n)) + /// Définit un répertoire géré par la configuration + /// + /// # Arguments + /// + /// * `path` - Chemin dans l'arbre de configuration (ex: `&["host", "cache", "directory"]`) + /// * `directory` - Chemin du répertoire (absolu ou relatif au config_dir) + /// + /// # Exemple + /// + /// ```no_run + /// use pmoconfig::get_config; + /// + /// let config = get_config(); + /// config.set_managed_dir(&["host", "audio_cache", "directory"], "/var/cache/audio".to_string())?; + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn set_managed_dir(&self, path: &[&str], directory: String) -> Result<()> { + self.set_value(path, Value::String(directory)) } /// Gets the base URL for the HTTP server @@ -566,77 +586,6 @@ impl Config { self.set_value(&["devices", devtype, name, "udn"], Value::String(udn)) } - /// Gets the cover cache directory - /// - /// # Returns - /// - /// Returns a `Result` containing the absolute path to the cover cache directory (default: "cache_covers") - pub fn get_cover_cache_dir(&self) -> Result { - self.get_cache_dir("cover_cache", DEFAULT_COVER_CACHE_DIR) - } - - /// Sets the cover cache directory - /// - /// # Arguments - /// - /// * `directory` - The directory path (absolute or relative to config dir) - pub fn set_cover_cache_dir(&self, directory: String) -> Result<()> { - self.set_cache_dir("cover_cache", directory) - } - - /// Gets the maximum number of items in the cover cache - /// - /// # Returns - /// - /// Returns a `Result` containing the cache size (default: 2000) - pub fn get_cover_cache_size(&self) -> Result { - self.get_cache_size("cover_cache", DEFAULT_COVER_CACHE_SIZE) - } - - /// Sets the maximum number of items in the cover cache - /// - /// # Arguments - /// - /// * `size` - The maximum cache size - pub fn set_cover_cache_size(&self, size: usize) -> Result<()> { - self.set_cache_size("cover_cache", size) - } - - /// Gets the audio cache directory - /// - /// # Returns - /// - /// Returns a `Result` containing the absolute path to the audio cache directory (default: "cache_audio") - pub fn get_audio_cache_dir(&self) -> Result { - self.get_cache_dir("audio_cache", DEFAULT_AUDIO_CACHE_DIR) - } - - /// Sets the audio cache directory - /// - /// # Arguments - /// - /// * `directory` - The directory path (absolute or relative to config dir) - pub fn set_audio_cache_dir(&self, directory: String) -> Result<()> { - self.set_cache_dir("audio_cache", directory) - } - - /// Gets the maximum number of items in the audio cache - /// - /// # Returns - /// - /// Returns a `Result` containing the cache size (default: 500) - pub fn get_audio_cache_size(&self) -> Result { - self.get_cache_size("audio_cache", DEFAULT_AUDIO_CACHE_SIZE) - } - - /// Sets the maximum number of items in the audio cache - /// - /// # Arguments - /// - /// * `size` - The maximum cache size - pub fn set_audio_cache_size(&self, size: usize) -> Result<()> { - self.set_cache_size("audio_cache", size) - } impl_string_config!( /// Gets the Qobuz username from configuration diff --git a/pmocovers/Cargo.toml b/pmocovers/Cargo.toml index d40422b3..a9a237bc 100644 --- a/pmocovers/Cargo.toml +++ b/pmocovers/Cargo.toml @@ -31,4 +31,5 @@ tracing = "0.1.41" [features] default = ["pmoserver"] -pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/openapi", "pmocache/pmoserver"] +pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"] +pmoserver = ["pmoconfig", "dep:pmoserver", "dep:axum", "dep:utoipa", "pmocache/openapi", "pmocache/pmoserver"] diff --git a/pmocovers/src/config_ext.rs b/pmocovers/src/config_ext.rs new file mode 100644 index 00000000..43776672 --- /dev/null +++ b/pmocovers/src/config_ext.rs @@ -0,0 +1,106 @@ +//! Extension pour intégrer le cache de couvertures dans pmoconfig +//! +//! Ce module fournit le trait `CoverCacheConfigExt` qui permet d'ajouter facilement +//! des méthodes de gestion du cache de couvertures à pmoconfig::Config. + +use anyhow::Result; +use pmoconfig::Config; +use pmocache::CacheConfigExt; +use std::sync::Arc; + +const DEFAULT_COVER_CACHE_DIR: &str = "cache_covers"; +const DEFAULT_COVER_CACHE_SIZE: usize = 2000; + +/// Trait d'extension pour gérer le cache de couvertures dans pmoconfig +/// +/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques +/// au cache de couvertures avec conversion WebP. +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmoconfig::get_config; +/// use pmocovers::CoverCacheConfigExt; +/// +/// let config = get_config(); +/// let cache = config.create_cover_cache()?; +/// +/// // Utiliser le cache +/// let pk = cache.add_from_url("http://example.com/cover.jpg", Some("album:123")).await?; +/// ``` +pub trait CoverCacheConfigExt { + /// Récupère le répertoire du cache de couvertures + /// + /// # Returns + /// + /// Le chemin absolu du répertoire du cache de couvertures (default: "cache_covers") + fn get_covers_dir(&self) -> Result; + + /// Définit le répertoire du cache de couvertures + /// + /// # Arguments + /// + /// * `directory` - Chemin du répertoire (absolu ou relatif au config_dir) + fn set_covers_dir(&self, directory: String) -> Result<()>; + + /// Récupère la taille maximale du cache de couvertures + /// + /// # Returns + /// + /// Le nombre maximal d'images dans le cache (default: 2000) + fn get_covers_size(&self) -> Result; + + /// Définit la taille maximale du cache de couvertures + /// + /// # Arguments + /// + /// * `size` - Nombre maximal d'images + fn set_covers_size(&self, size: usize) -> Result<()>; + + /// Crée une instance du cache de couvertures configurée avec conversion WebP + /// + /// Cette méthode factory crée un cache de couvertures en utilisant les paramètres + /// de configuration (répertoire et taille) et active la conversion WebP + /// automatique pour toutes les images téléchargées. + /// + /// # Returns + /// + /// Une instance Arc du cache de couvertures configuré + /// + /// # Exemple + /// + /// ```rust,ignore + /// use pmoconfig::get_config; + /// use pmocovers::CoverCacheConfigExt; + /// + /// let config = get_config(); + /// let cache = config.create_cover_cache()?; + /// + /// // Le cache est prêt à être utilisé avec conversion WebP automatique + /// ``` + fn create_cover_cache(&self) -> Result>; +} + +impl CoverCacheConfigExt for Config { + fn get_covers_dir(&self) -> Result { + self.get_cache_dir("cover_cache", DEFAULT_COVER_CACHE_DIR) + } + + fn set_covers_dir(&self, directory: String) -> Result<()> { + self.set_cache_dir("cover_cache", directory) + } + + fn get_covers_size(&self) -> Result { + self.get_cache_size("cover_cache", DEFAULT_COVER_CACHE_SIZE) + } + + fn set_covers_size(&self, size: usize) -> Result<()> { + self.set_cache_size("cover_cache", size) + } + + fn create_cover_cache(&self) -> Result> { + let dir = self.get_covers_dir()?; + let size = self.get_covers_size()?; + Ok(Arc::new(crate::cache::new_cache(&dir, size)?)) + } +} diff --git a/pmocovers/src/lib.rs b/pmocovers/src/lib.rs index 1d73204a..f800a133 100644 --- a/pmocovers/src/lib.rs +++ b/pmocovers/src/lib.rs @@ -42,11 +42,17 @@ pub mod webp; #[cfg(feature = "pmoserver")] pub mod openapi; +#[cfg(feature = "pmoconfig")] +pub mod config_ext; + pub use cache::{new_cache, Cache, CoversConfig}; #[cfg(feature = "pmoserver")] pub use openapi::ApiDoc; +#[cfg(feature = "pmoconfig")] +pub use config_ext::CoverCacheConfigExt; + #[cfg(feature = "pmoserver")] use std::sync::Arc; #[cfg(feature = "pmoserver")] @@ -146,9 +152,10 @@ impl CoverCacheExt for pmoserver::Server { } async fn init_cover_cache_configured(&mut self) -> anyhow::Result> { + use crate::CoverCacheConfigExt; let config = pmoconfig::get_config(); - let cache_dir = config.get_cover_cache_dir()?; - let limit = config.get_cover_cache_size()?; + let cache_dir = config.get_covers_dir()?; + let limit = config.get_covers_size()?; self.init_cover_cache(&cache_dir, limit).await } } diff --git a/pmocovers/src/webp.rs b/pmocovers/src/webp.rs index c1a11464..05988347 100644 --- a/pmocovers/src/webp.rs +++ b/pmocovers/src/webp.rs @@ -2,6 +2,25 @@ use anyhow::Result; use image::{imageops::FilterType, DynamicImage}; use webp::{Encoder, WebPMemory}; +/// Encode une image en format WebP avec un niveau de qualité de 85% +/// +/// # Arguments +/// +/// * `img` - Image à encoder +/// +/// # Returns +/// +/// Les données WebP encodées sous forme de vecteur d'octets +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmocovers::webp::encode_webp; +/// use image::DynamicImage; +/// +/// let img = DynamicImage::new_rgba8(100, 100); +/// let webp_data = encode_webp(&img)?; +/// ``` pub fn encode_webp(img: &DynamicImage) -> Result> { let rgb_img = img.to_rgba8(); let encoder = Encoder::from_rgba(&rgb_img, rgb_img.width(), rgb_img.height()); @@ -9,6 +28,31 @@ pub fn encode_webp(img: &DynamicImage) -> Result> { Ok(webp_data.to_vec()) } +/// Redimensionne une image pour l'inscrire dans un carré de taille donnée +/// +/// Cette fonction préserve le ratio d'aspect de l'image originale en la redimensionnant +/// pour qu'elle tienne dans un carré, puis la centre sur un fond transparent. +/// +/// # Arguments +/// +/// * `img` - Image à redimensionner +/// * `size` - Taille du carré de sortie (en pixels) +/// +/// # Returns +/// +/// Une nouvelle image carrée de taille `size × size` avec l'image originale centrée +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmocovers::webp::ensure_square; +/// use image::DynamicImage; +/// +/// let img = DynamicImage::new_rgba8(800, 600); +/// let square = ensure_square(&img, 256); +/// assert_eq!(square.width(), 256); +/// assert_eq!(square.height(), 256); +/// ``` pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage { let (width, height) = (img.width(), img.height()); @@ -38,6 +82,36 @@ pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage { square } +/// Génère une variante redimensionnée d'une image en cache +/// +/// Cette fonction crée (ou récupère si déjà existante) une variante redimensionnée +/// d'une image. La variante est mise en cache sur disque pour éviter les +/// recalculs futurs. +/// +/// # Arguments +/// +/// * `cache` - Instance du cache de couvertures +/// * `pk` - Clé primaire de l'image +/// * `size` - Taille de la variante (carré de `size × size`) +/// +/// # Returns +/// +/// Les données WebP de la variante redimensionnée +/// +/// # Comportement +/// +/// 1. Si la variante existe déjà sur disque, elle est retournée directement +/// 2. Sinon, l'image originale est chargée, redimensionnée et encodée en WebP +/// 3. La variante est sauvegardée sur disque pour utilisation future +/// +/// # Exemple +/// +/// ```rust,ignore +/// use pmocovers::webp::generate_variant; +/// +/// let cache = pmocovers::cache::new_cache("./cache", 1000)?; +/// let variant_256 = generate_variant(&cache, "abc123", 256).await?; +/// ``` pub async fn generate_variant( cache: &super::cache::Cache, pk: &str, diff --git a/pmoqobuz/src/api_rest.rs b/pmoqobuz/src/api_rest.rs index 1d4abab8..6f8ef4fd 100644 --- a/pmoqobuz/src/api_rest.rs +++ b/pmoqobuz/src/api_rest.rs @@ -303,7 +303,7 @@ async fn get_cache_stats( #[cfg(all(feature = "pmoserver", feature = "covers"))] async fn cache_album_image(mut album: Album, cover_cache: &Arc) -> Album { if let Some(ref image_url) = album.image { - match cover_cache.ensure_from_url(image_url).await { + match cover_cache.add_from_url(image_url, None).await { Ok(pk) => { album.image_cached = Some(format!("/covers/images/{}", pk)); } diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index fc4abc21..9e0a21d4 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -367,19 +367,22 @@ impl UpnpServerExt for Server { } async fn init_caches(&mut self) -> Result<(Arc, Arc), anyhow::Error> { + use pmocovers::CoverCacheConfigExt; + use pmoaudiocache::AudioCacheConfigExt; + let config = pmoconfig::get_config(); let cover_cache = self .init_cover_cache( - &config.get_cover_cache_dir()?, - config.get_cover_cache_size()?, + &config.get_covers_dir()?, + config.get_covers_size()?, ) .await?; let audio_cache = self .init_audio_cache( - &config.get_audio_cache_dir()?, - config.get_audio_cache_size()?, + &config.get_audiocache_dir()?, + config.get_audiocache_size()?, ) .await?;