refactoring des caches

This commit is contained in:
2025-10-25 17:14:24 +02:00
parent ffaa56fac9
commit 37c3d9daf6
21 changed files with 964 additions and 245 deletions

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ xxx
**/.pmomusic.yml
**/.pmomusic_covers/**
**/.pmomusic_audio/**
/.pmomusic
.DS_Store
/target/
/.pmomusic_covers

3
Cargo.lock generated
View File

@@ -2382,10 +2382,13 @@ dependencies = [
"chrono",
"futures-util",
"hex",
"pmoconfig",
"reqwest",
"rusqlite",
"serde",
"serde_yaml",
"sha1",
"sha2",
"tokio",
"tokio-util",
"tracing",

View File

@@ -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){

View File

@@ -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<void> {
}
}
/**
* Récupère le statut du téléchargement d'une image
*/
export async function getDownloadStatus(pk: string): Promise<DownloadStatus> {
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<void> {
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
*/

View File

@@ -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"]

View File

@@ -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<String>;
/// 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<usize>;
/// 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<Arc<crate::Cache>>;
}
impl AudioCacheConfigExt for Config {
fn get_audiocache_dir(&self) -> Result<String> {
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<usize> {
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<Arc<crate::Cache>> {
let dir = self.get_audiocache_dir()?;
let size = self.get_audiocache_size()?;
Ok(Arc::new(crate::cache::new_cache(&dir, size)?))
}
}

View File

@@ -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<Arc<Cache>> {
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
}
}

View File

@@ -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<Self> {
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

View File

@@ -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"]

View File

@@ -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<C: CacheConfig> Cache<C> {
/// 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<C: CacheConfig> Cache<C> {
///
/// # 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<String> {
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<C: CacheConfig> Cache<C> {
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<C: CacheConfig> Cache<C> {
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<C: CacheConfig> Cache<C> {
/// 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<R>(
&self,
source_uri: &str,
reader: R,
mut reader: R,
length: Option<u64>,
collection: Option<&str>,
) -> Result<String>
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<C: CacheConfig> Cache<C> {
/// 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<C: CacheConfig> Cache<C> {
///
/// # 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::<MyConfig>::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<String> {
let canonical_path = std::fs::canonicalize(path)?;
let file_url = format!("file://{}", canonical_path.display());
@@ -264,31 +353,12 @@ impl<C: CacheConfig> Cache<C> {
.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<String> {
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<C: CacheConfig> FileCache<C> for Cache<C> {
self.add_from_file(path, collection).await
}
async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
self.ensure_from_url(url, collection).await
}
async fn get(&self, pk: &str) -> Result<PathBuf> {
self.get(pk).await
}

View File

@@ -116,16 +116,6 @@ pub trait FileCache<C: CacheConfig>: Send + Sync {
/// La clé primaire (pk) du fichier dans le cache
async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
/// 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<String>;
/// Récupère le chemin d'un fichier dans le cache
///
/// # Arguments
@@ -147,9 +137,48 @@ pub trait FileCache<C: CacheConfig>: 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());

225
pmocache/src/config_ext.rs Normal file
View File

@@ -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<String>;
/// 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<usize>;
/// 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::<AudioConfig>("audio_cache", "cache_audio", 500)?;
/// ```
fn create_cache<C: crate::CacheConfig>(
&self,
cache_type: &str,
default_dir: &str,
default_size: usize,
) -> Result<Arc<crate::Cache<C>>>;
}
impl CacheConfigExt for Config {
fn get_cache_dir(&self, cache_type: &str, default: &str) -> Result<String> {
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<usize> {
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<C: crate::CacheConfig>(
&self,
cache_type: &str,
default_dir: &str,
default_size: usize,
) -> Result<Arc<crate::Cache<C>>> {
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::<C>::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<String>;
/// 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<usize>;
/// 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<std::sync::Arc<$cache_type_struct>>;
}
impl $trait_name for pmoconfig::Config {
fn get_cache_dir_ext(&self) -> anyhow::Result<String> {
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<usize> {
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<std::sync::Arc<$cache_type_struct>> {
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)?))
}
}
};
}

View File

@@ -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<u8>` 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<Vec<u8>, 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<u8>` 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<R>(reader: &mut R, max_bytes: usize) -> Result<Vec<u8>, 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)
}

View File

@@ -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;

View File

@@ -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<String> {
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<String> {
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<usize> {
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<String> {
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<usize> {
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<String> {
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<usize> {
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

View File

@@ -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"]

106
pmocovers/src/config_ext.rs Normal file
View File

@@ -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<String>;
/// 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<usize>;
/// 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<Arc<crate::Cache>>;
}
impl CoverCacheConfigExt for Config {
fn get_covers_dir(&self) -> Result<String> {
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<usize> {
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<Arc<crate::Cache>> {
let dir = self.get_covers_dir()?;
let size = self.get_covers_size()?;
Ok(Arc::new(crate::cache::new_cache(&dir, size)?))
}
}

View File

@@ -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<Arc<Cache>> {
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
}
}

View File

@@ -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<Vec<u8>> {
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<Vec<u8>> {
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,

View File

@@ -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<pmocovers::Cache>) -> 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));
}

View File

@@ -367,19 +367,22 @@ impl UpnpServerExt for Server {
}
async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), 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?;