adaptation de la crate pmocovers

This commit is contained in:
2025-10-17 22:15:00 +02:00
parent 9bd0cd173b
commit 993ef18ac6
9 changed files with 450 additions and 875 deletions

View File

@@ -7,7 +7,7 @@
//! - Supprimer des items
//! - Purger et consolider le cache
use crate::{Cache, CacheConfig, CacheEntry};
use crate::{Cache, CacheConfig};
use axum::{
extract::{Path, State},
http::StatusCode,

View File

@@ -11,6 +11,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing;
/// Trait pour définir les paramètres du cache
pub trait CacheConfig: Send + Sync {
@@ -46,7 +47,6 @@ pub trait CacheConfig: Send + Sync {
/// Note : Ce type est conçu pour être utilisé derrière un `Arc<Cache>`.
/// La synchronisation est gérée par le Mutex interne de la base de données SQLite
/// et par le RwLock pour la map des downloads.
#[derive(Debug)]
pub struct Cache<C: CacheConfig> {
/// Répertoire de stockage
dir: PathBuf,
@@ -58,12 +58,14 @@ pub struct Cache<C: CacheConfig> {
pub db: Arc<DB>,
/// Map des downloads en cours (pk -> Download)
downloads: Arc<RwLock<HashMap<String, Arc<Download>>>>,
/// Factory pour créer des transformers (optionnel)
transformer_factory: Option<Arc<dyn Fn() -> StreamTransformer + Send + Sync>>,
/// Phantom data pour le type de configuration
_phantom: std::marker::PhantomData<C>,
}
impl<C: CacheConfig> Cache<C> {
/// Crée un nouveau cache
/// Crée un nouveau cache sans transformer
///
/// # Arguments
///
@@ -71,6 +73,52 @@ impl<C: CacheConfig> Cache<C> {
/// * `limit` - Limite de taille du cache (nombre d'éléments)
/// * `base_url` - URL de base pour la génération d'URLs
pub fn new(dir: &str, limit: usize, base_url: &str) -> Result<Self> {
Self::with_transformer(dir, limit, base_url, None)
}
/// Crée un nouveau cache avec un transformer optionnel
///
/// # Arguments
///
/// * `dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre d'éléments)
/// * `base_url` - URL de base pour la génération d'URLs
/// * `transformer_factory` - Factory pour créer des transformers à chaque téléchargement
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocache::{Cache, CacheConfig, StreamTransformer};
/// use std::sync::Arc;
///
/// struct MyConfig;
/// impl CacheConfig for MyConfig {
/// fn file_extension() -> &'static str { "dat" }
/// }
///
/// let transformer_factory = Arc::new(|| {
/// // Créer un transformer qui convertit les données
/// Box::new(|response, file, progress| {
/// Box::pin(async move {
/// // Transformation personnalisée
/// Ok(())
/// })
/// }) as StreamTransformer
/// });
///
/// let cache = Cache::<MyConfig>::with_transformer(
/// "./cache",
/// 1000,
/// "http://localhost:8080",
/// Some(transformer_factory)
/// ).unwrap();
/// ```
pub fn with_transformer(
dir: &str,
limit: usize,
base_url: &str,
transformer_factory: Option<Arc<dyn Fn() -> StreamTransformer + Send + Sync>>,
) -> Result<Self> {
let directory = PathBuf::from(dir);
std::fs::create_dir_all(&directory)?;
let db = DB::init(&directory.join("cache.db"), C::table_name())?;
@@ -81,18 +129,11 @@ impl<C: CacheConfig> Cache<C> {
base_url: base_url.to_string(),
db: Arc::new(db),
downloads: Arc::new(RwLock::new(HashMap::new())),
transformer_factory,
_phantom: std::marker::PhantomData,
})
}
/// Retourne le transformer pour ce cache
///
/// Par défaut retourne None (pas de transformation).
/// Les caches spécialisés peuvent surcharger cette méthode.
fn get_transformer(&self) -> Option<StreamTransformer> {
None
}
/// Télécharge un fichier depuis une URL et l'ajoute au cache
///
/// Utilise le module download pour gérer le téléchargement asynchrone.
@@ -120,10 +161,11 @@ impl<C: CacheConfig> Cache<C> {
}
// Lancer le téléchargement avec transformer
let transformer = self.transformer_factory.as_ref().map(|f| f());
let download = download_with_transformer(
&file_path,
url,
self.get_transformer(),
transformer,
);
// Stocker dans la map des downloads en cours
@@ -135,6 +177,12 @@ impl<C: CacheConfig> Cache<C> {
// Ajouter immédiatement à la DB
self.db.add(&pk, url, collection)?;
// Appliquer la politique d'éviction LRU si nécessaire
// Cela garantit que le cache respecte toujours la limite configurée
if let Err(e) = self.enforce_limit().await {
tracing::warn!("Error enforcing cache limit: {}", e);
}
// Lancer une tâche de nettoyage en background
let downloads_clone = self.downloads.clone();
let pk_clone = pk.clone();
@@ -413,11 +461,78 @@ impl<C: CacheConfig> Cache<C> {
&self.base_url
}
/// Construit le chemin complet d'un fichier dans le cache avec le param par défaut
///
/// Format: `{pk}.{default_param}.{extension}`
pub fn file_path(&self, pk: &str) -> PathBuf {
self.file_path_with_qualifier(pk, C::default_param())
}
/// Construit le chemin d'un fichier dans le cache avec un qualificatif
///
/// Format: `{pk}.{qualifier}.{extension}`
pub fn file_path_with_qualifier(&self, pk: &str, qualifier: &str) -> PathBuf {
self.dir.join(format!("{}.{}.{}", pk, qualifier, C::file_extension()))
}
/// Valide les données avant de les stocker
/// Par défaut, accepte toutes les données
pub fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
Ok(data.to_vec())
}
/// Applique la politique d'éviction LRU (Least Recently Used)
///
/// Si le nombre d'entrées dépasse la limite configurée, supprime
/// les entrées les plus anciennes (moins récemment utilisées).
///
/// Cette méthode :
/// 1. Compte le nombre total d'entrées
/// 2. Si > limit, récupère les N entrées les plus anciennes
/// 3. Supprime ces entrées de la DB et leurs fichiers du disque
///
/// # Returns
///
/// Le nombre d'entrées supprimées
pub async fn enforce_limit(&self) -> Result<usize> {
let count = self.db.count()?;
if count <= self.limit {
return Ok(0);
}
let to_remove = count - self.limit;
let old_entries = self.db.get_oldest(to_remove)?;
let mut removed = 0;
for entry in old_entries {
// Supprimer tous les fichiers avec ce pk (toutes variantes)
if let Ok(mut dir_entries) = tokio::fs::read_dir(&self.dir).await {
while let Ok(Some(dir_entry)) = dir_entries.next_entry().await {
if let Some(filename) = dir_entry.file_name().to_str() {
// Format: {pk}.{param}.{ext}
if filename.starts_with(&entry.pk) && filename.starts_with(&format!("{}.", entry.pk)) {
let _ = tokio::fs::remove_file(dir_entry.path()).await;
}
}
}
}
// Supprimer de la base de données
if let Err(e) = self.db.delete(&entry.pk) {
tracing::warn!("Error deleting entry {} from DB: {}", entry.pk, e);
} else {
removed += 1;
}
}
if removed > 0 {
tracing::info!("LRU eviction: removed {} old entries (cache size: {} -> {})",
removed, count, count - removed);
}
Ok(removed)
}
}

View File

@@ -248,4 +248,54 @@ impl DB {
conn.execute(&sql, [pk])?;
Ok(())
}
/// Compte le nombre total d'entrées dans le cache
///
/// # Returns
///
/// Le nombre total d'entrées
pub fn count(&self) -> rusqlite::Result<usize> {
let conn = self.conn.lock().unwrap();
let sql = format!("SELECT COUNT(*) FROM {}", self.table_name);
let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
Ok(count as usize)
}
/// Récupère les N entrées les plus anciennes (LRU - Least Recently Used)
///
/// Trie par last_used (les plus anciens en premier), puis par hits (les moins utilisés).
/// Utile pour implémenter une politique d'éviction LRU.
///
/// # Arguments
///
/// * `limit` - Nombre maximum d'entrées à récupérer
///
/// # Returns
///
/// Liste des entrées les plus anciennes, triées par last_used ASC
pub fn get_oldest(&self, limit: usize) -> rusqlite::Result<Vec<CacheEntry>> {
let conn = self.conn.lock().unwrap();
let sql = format!(
"SELECT pk, source_url, collection, hits, last_used
FROM {}
ORDER BY last_used ASC, hits ASC
LIMIT ?1",
self.table_name
);
let mut stmt = conn.prepare(&sql)?;
let entries = stmt.query_map([limit], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
collection: row.get(2)?,
hits: row.get(3)?,
last_used: row.get(4)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
}

View File

@@ -40,8 +40,6 @@
#[cfg(feature = "pmoserver")]
use crate::{Cache, CacheConfig};
#[cfg(feature = "pmoserver")]
use crate::cache_trait::FileCache;
#[cfg(feature = "pmoserver")]
use axum::{
body::Body,
extract::{Path, State},
@@ -56,42 +54,83 @@ use std::sync::Arc;
use tokio_util::io::ReaderStream;
#[cfg(feature = "pmoserver")]
use tracing::warn;
#[cfg(feature = "pmoserver")]
use std::pin::Pin;
#[cfg(feature = "pmoserver")]
use std::future::Future;
/// Type pour le callback de génération de param
///
/// Appelé quand un fichier avec param n'existe pas.
/// Permet de générer à la volée (ex: redimensionnement d'images).
///
/// # Arguments
///
/// - `cache`: le cache
/// - `pk`: clé primaire
/// - `param`: paramètre demandé (ex: "256" pour une taille)
///
/// # Retourne
///
/// Les données générées ou None si le param n'est pas supporté
#[cfg(feature = "pmoserver")]
pub type ParamGenerator<C> = Arc<
dyn Fn(Arc<Cache<C>>, String, String)
-> Pin<Box<dyn Future<Output = Option<Vec<u8>>> + Send>>
+ Send + Sync
>;
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}
/// Sert un fichier avec le param par défaut
#[cfg(feature = "pmoserver")]
async fn get_file<C: CacheConfig + 'static>(
State((cache, content_type)): State<(Arc<Cache<C>>, &'static str)>,
State((cache, content_type, param_generator)): State<(Arc<Cache<C>>, &'static str, Option<ParamGenerator<C>>)>,
Path(pk): Path<String>,
) -> Response {
// Utiliser le param par défaut
let param = C::default_param();
serve_file_with_streaming(&cache, &pk, param, content_type).await
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator).await
}
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param}
/// Sert un fichier avec un param spécifique
#[cfg(feature = "pmoserver")]
async fn get_file_with_param<C: CacheConfig + 'static>(
State((cache, content_type)): State<(Arc<Cache<C>>, &'static str)>,
State((cache, content_type, param_generator)): State<(Arc<Cache<C>>, &'static str, Option<ParamGenerator<C>>)>,
Path((pk, param)): Path<(String, String)>,
) -> Response {
serve_file_with_streaming(&cache, &pk, &param, content_type).await
serve_file_with_streaming(&cache, &pk, &param, content_type, param_generator).await
}
/// Fonction utilitaire pour servir un fichier avec streaming progressif
///
/// Si le fichier est en cours de téléchargement, il est streamé au fur et à mesure.
/// Sinon, le fichier complet est servi normalement.
/// Si le fichier n'existe pas et qu'un param_generator est fourni, tente de générer le param.
#[cfg(feature = "pmoserver")]
async fn serve_file_with_streaming<C: CacheConfig>(
cache: &Arc<Cache<C>>,
pk: &str,
param: &str,
content_type: &'static str,
param_generator: Option<ParamGenerator<C>>,
) -> Response {
let file_path = cache.file_path_with_qualifier(pk, param);
// Si le fichier n'existe pas et qu'on a un générateur, l'utiliser
if !file_path.exists() {
if let Some(generator) = param_generator {
if let Some(data) = generator(cache.clone(), pk.to_string(), param.to_string()).await {
// Le générateur a créé les données, les servir directement
return (
StatusCode::OK,
[("content-type", content_type)],
data,
).into_response();
}
}
}
// Mettre à jour les stats d'utilisation
if let Err(e) = cache.db.update_hit(pk) {
warn!("Error updating hit count for {}: {}", pk, e);
@@ -218,18 +257,63 @@ async fn serve_complete_file(
pub fn create_file_router<C: CacheConfig + 'static>(
cache: Arc<Cache<C>>,
content_type: &'static str,
) -> Router {
create_file_router_with_generator(cache, content_type, None)
}
/// Crée un router pour servir les fichiers d'un cache avec générateur de param
///
/// Similaire à `create_file_router` mais permet de fournir un générateur
/// pour créer des variantes à la volée (ex: redimensionnement d'images).
///
/// # Arguments
///
/// * `cache` - Instance du cache
/// * `content_type` - Type MIME des fichiers (ex: "image/webp", "audio/flac")
/// * `param_generator` - Générateur optionnel pour créer des params à la volée
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocache::pmoserver_ext::{create_file_router_with_generator, ParamGenerator};
/// use std::sync::Arc;
///
/// # async fn example(cache: std::sync::Arc<pmocache::Cache<CoversConfig>>) {
/// let generator: ParamGenerator<CoversConfig> = Arc::new(|cache, pk, param| {
/// Box::pin(async move {
/// // Générer une variante si param est numérique
/// if let Ok(size) = param.parse::<usize>() {
/// // Générer et retourner les données
/// Some(vec![])
/// } else {
/// None
/// }
/// })
/// });
///
/// let router = create_file_router_with_generator(
/// cache.clone(),
/// "image/webp",
/// Some(generator)
/// );
/// # }
/// ```
#[cfg(feature = "pmoserver")]
pub fn create_file_router_with_generator<C: CacheConfig + 'static>(
cache: Arc<Cache<C>>,
content_type: &'static str,
param_generator: Option<ParamGenerator<C>>,
) -> Router {
let cache_name = C::cache_name();
let cache_type = C::cache_type();
let path_base = format!("/{}/{}", cache_name, cache_type);
let path_with_param = format!("/{}/{}/:pk/:param", cache_name, cache_type);
let path_without_param = format!("/{}/{}/:pk", cache_name, cache_type);
let path_with_param = format!("/{}/{}/{{pk}}/{{param}}", cache_name, cache_type);
let path_without_param = format!("/{}/{}/{{pk}}", cache_name, cache_type);
Router::new()
.route(&path_without_param, get(get_file::<C>))
.route(&path_with_param, get(get_file_with_param::<C>))
.with_state((cache, content_type))
.with_state((cache, content_type, param_generator))
}
/// Crée un router pour l'API REST du cache
@@ -261,11 +345,11 @@ pub fn create_api_router<C: CacheConfig + 'static>(
.delete(api::purge_cache::<C>),
)
.route(
"/:pk",
"/{pk}",
get(api::get_item_info::<C>)
.delete(api::delete_item::<C>),
)
.route("/:pk/status", get(api::get_download_status::<C>))
.route("/{pk}/status", get(api::get_download_status::<C>))
.route("/consolidate", post(api::consolidate_cache::<C>))
.with_state(cache)
}

View File

@@ -1,312 +0,0 @@
//! API REST pour la gestion du cache de couvertures
//!
//! Ce module expose une API REST documentée avec OpenAPI/Swagger pour :
//! - Lister les images en cache
//! - Ajouter des images depuis une URL
//! - Supprimer des images
//! - Consulter les statistiques
use crate::{Cache, CacheEntry, ImageCacheExt};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use utoipa::ToSchema;
/// Requête pour ajouter une image au cache
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AddImageRequest {
/// URL de l'image source
#[schema(example = "https://example.com/cover.jpg")]
pub url: String,
}
/// Réponse après ajout d'une image
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AddImageResponse {
/// Clé primaire (pk) de l'image ajoutée
#[schema(example = "1a2b3c4d5e6f7a8b")]
pub pk: String,
/// URL source de l'image
#[schema(example = "https://example.com/cover.jpg")]
pub url: String,
/// Message de succès
#[schema(example = "Image added successfully")]
pub message: String,
}
/// Réponse de suppression d'une image
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct DeleteImageResponse {
/// Message de succès
#[schema(example = "Image deleted successfully")]
pub message: String,
}
/// Réponse d'erreur générique
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ErrorResponse {
/// Code d'erreur
#[schema(example = "NOT_FOUND")]
pub error: String,
/// Message descriptif
#[schema(example = "Image not found in cache")]
pub message: String,
}
/// Liste toutes les images en cache avec leurs statistiques
///
/// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant.
#[utoipa::path(
get,
path = "/api/covers",
responses(
(status = 200, description = "Liste des images en cache", body = Vec<CacheEntry>),
(status = 500, description = "Erreur serveur", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn list_images(State(cache): State<Arc<Cache>>) -> impl IntoResponse {
match cache.db.get_all() {
Ok(entries) => (StatusCode::OK, Json(entries)).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "DATABASE_ERROR".to_string(),
message: format!("Cannot retrieve cache entries: {}", e),
}),
)
.into_response(),
}
}
/// Récupère les informations d'une image spécifique
///
/// Retourne les métadonnées d'une image identifiée par sa clé (pk).
#[utoipa::path(
get,
path = "/api/covers/{pk}",
params(
("pk" = String, Path, description = "Clé primaire de l'image", example = "1a2b3c4d5e6f7a8b")
),
responses(
(status = 200, description = "Informations de l'image", body = CacheEntry),
(status = 404, description = "Image non trouvée", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn get_image_info(
State(cache): State<Arc<Cache>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
match cache.db.get(&pk) {
Ok(entry) => (StatusCode::OK, Json(entry)).into_response(),
Err(_) => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Image with pk '{}' not found in cache", pk),
}),
)
.into_response(),
}
}
/// Ajoute une image au cache depuis une URL
///
/// Télécharge l'image depuis l'URL fournie, la convertit en WebP et l'ajoute au cache.
/// Si l'image existe déjà, elle est mise à jour.
#[utoipa::path(
post,
path = "/api/covers",
request_body = AddImageRequest,
responses(
(status = 201, description = "Image ajoutée avec succès", body = AddImageResponse),
(status = 400, description = "Requête invalide", body = ErrorResponse),
(status = 500, description = "Erreur lors du téléchargement ou de la conversion", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn add_image(
State(cache): State<Arc<Cache>>,
Json(req): Json<AddImageRequest>,
) -> impl IntoResponse {
if req.url.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "INVALID_REQUEST".to_string(),
message: "URL cannot be empty".to_string(),
}),
)
.into_response();
}
match cache.add_image_from_url(&req.url).await {
Ok(pk) => (
StatusCode::CREATED,
Json(AddImageResponse {
pk,
url: req.url,
message: "Image added successfully".to_string(),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "PROCESSING_ERROR".to_string(),
message: format!("Cannot add image: {}", e),
}),
)
.into_response(),
}
}
/// Supprime une image du cache
///
/// Supprime l'image et toutes ses variantes du disque et de la base de données.
#[utoipa::path(
delete,
path = "/api/covers/{pk}",
params(
("pk" = String, Path, description = "Clé primaire de l'image à supprimer", example = "1a2b3c4d5e6f7a8b")
),
responses(
(status = 200, description = "Image supprimée avec succès", body = DeleteImageResponse),
(status = 404, description = "Image non trouvée", body = ErrorResponse),
(status = 500, description = "Erreur lors de la suppression", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn delete_image(
State(cache): State<Arc<Cache>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
// Vérifier que l'image existe
if cache.db.get(&pk).is_err() {
return (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Image with pk '{}' not found in cache", pk),
}),
)
.into_response();
}
// Supprimer les fichiers (original + variantes)
let cache_dir = std::path::PathBuf::from(cache.cache_dir());
let orig_path = cache_dir.join(format!("{}.orig.webp", pk));
if orig_path.exists() {
if let Err(e) = tokio::fs::remove_file(&orig_path).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "FILE_DELETE_ERROR".to_string(),
message: format!("Cannot delete original file: {}", e),
}),
)
.into_response();
}
}
// Supprimer toutes les variantes (*.{pk}.*.webp)
if let Ok(mut entries) = tokio::fs::read_dir(&cache_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if let Some(filename) = entry.file_name().to_str() {
if filename.starts_with(&pk) && filename.ends_with(".webp") && filename != format!("{}.orig.webp", pk) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
}
// Supprimer de la base de données
match cache.db.delete(&pk) {
Ok(_) => (
StatusCode::OK,
Json(DeleteImageResponse {
message: format!("Image '{}' deleted successfully", pk),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "DATABASE_ERROR".to_string(),
message: format!("Cannot delete from database: {}", e),
}),
)
.into_response(),
}
}
/// Purge complètement le cache
///
/// Supprime toutes les images et vide la base de données. Opération irréversible.
#[utoipa::path(
delete,
path = "/api/covers",
responses(
(status = 200, description = "Cache purgé avec succès", body = DeleteImageResponse),
(status = 500, description = "Erreur lors de la purge", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn purge_cache(State(cache): State<Arc<Cache>>) -> impl IntoResponse {
match cache.purge().await {
Ok(_) => (
StatusCode::OK,
Json(DeleteImageResponse {
message: "Cache purged successfully".to_string(),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "PURGE_ERROR".to_string(),
message: format!("Cannot purge cache: {}", e),
}),
)
.into_response(),
}
}
/// Consolide le cache
///
/// Re-télécharge les images manquantes et supprime les fichiers orphelins.
/// Utile pour réparer un cache corrompu.
#[utoipa::path(
post,
path = "/api/covers/consolidate",
responses(
(status = 200, description = "Cache consolidé avec succès", body = DeleteImageResponse),
(status = 500, description = "Erreur lors de la consolidation", body = ErrorResponse)
),
tag = "covers"
)]
pub async fn consolidate_cache(State(cache): State<Arc<Cache>>) -> impl IntoResponse {
match cache.consolidate().await {
Ok(_) => (
StatusCode::OK,
Json(DeleteImageResponse {
message: "Cache consolidated successfully".to_string(),
}),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "CONSOLIDATE_ERROR".to_string(),
message: format!("Cannot consolidate cache: {}", e),
}),
)
.into_response(),
}
}

View File

@@ -1,13 +1,11 @@
//! Module de gestion du cache d'images avec conversion WebP
//!
//! Ce module étend le cache générique de `pmocache` avec des fonctionnalités
//! spécifiques aux images : conversion WebP et génération de variantes.
//! spécifiques aux images : conversion WebP automatique lors du téléchargement.
use anyhow::Result;
use pmocache::{CacheConfig, FileCache};
use crate::webp;
use std::path::PathBuf;
use std::ops::Deref;
use pmocache::{CacheConfig, StreamTransformer};
use std::sync::Arc;
/// Configuration pour le cache de couvertures
pub struct CoversConfig;
@@ -25,174 +23,60 @@ impl CacheConfig for CoversConfig {
"image"
}
/// Cache name (ex: "covers", "audio", "cache")
fn cache_name() -> &'static str {
"covers"
}
}
/// Cache d'images avec conversion WebP et génération de variantes
/// Type alias pour le cache de couvertures avec conversion WebP
pub type Cache = pmocache::Cache<CoversConfig>;
/// Créateur de transformer WebP
///
/// Format des fichiers : `{pk}.{qualificatif}.webp`
/// Exemple : `a1b2c3d4.orig.webp`, `a1b2c3d4.thumb.webp`
/// Convertit automatiquement toute image téléchargée en format WebP
fn create_webp_transformer() -> StreamTransformer {
Box::new(|response, mut file, progress| {
Box::pin(async move {
// Télécharger tout en mémoire
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
// Convertir en WebP
let img = image::load_from_memory(&bytes)
.map_err(|e| format!("Image decode error: {}", e))?;
let webp_data = crate::webp::encode_webp(&img)
.map_err(|e| format!("WebP encode error: {}", e))?;
// Écrire et mettre à jour la progression
use tokio::io::AsyncWriteExt;
file.write_all(&webp_data).await.map_err(|e| e.to_string())?;
file.flush().await.map_err(|e| e.to_string())?;
progress(webp_data.len() as u64);
Ok(())
})
})
}
/// Crée un cache de couvertures avec conversion WebP automatique
///
/// Ce type est un wrapper autour de `pmocache::Cache<CoversConfig>` qui permet
/// d'implémenter le trait `FileCache` avec conversion WebP automatique.
#[derive(Debug)]
pub struct Cache(pmocache::Cache<CoversConfig>);
impl Cache {
/// Crée un nouveau cache d'images
pub fn new(dir: &str, limit: usize, base_url: &str) -> Result<Self> {
Ok(Self(pmocache::Cache::new(dir, limit, base_url)?))
}
}
/// Permet d'accéder aux méthodes publiques de `pmocache::Cache` directement
impl Deref for Cache {
type Target = pmocache::Cache<CoversConfig>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// Implémentation de FileCache pour Cache avec conversion WebP automatique
impl FileCache for Cache {
fn cache_type(&self) -> &str {
CoversConfig::cache_type()
}
fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
// Convertir l'image en WebP
let img = image::load_from_memory(data)?;
webp::encode_webp(&img)
}
async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
let response = reqwest::get(url).await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Bad status: {}", response.status()));
}
let data = response.bytes().await?;
self.add(url, &data, collection).await
}
async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
let pk = pmocache::pk_from_url(url);
if self.db.get(&pk).is_ok() {
let file_path = self.file_path(&pk);
if file_path.exists() {
return Ok(pk);
}
}
self.add_from_url(url, collection).await
}
async fn add(&self, url: &str, data: &[u8], collection: Option<&str>) -> Result<String> {
// Valider et convertir les données en WebP
let webp_data = self.validate_data(data)?;
let pk = pmocache::pk_from_url(url);
let file_path = self.file_path(&pk);
if !file_path.exists() {
tokio::fs::write(&file_path, &webp_data).await?;
}
self.db.add(&pk, url, collection)?;
Ok(pk)
}
async fn get(&self, pk: &str) -> Result<PathBuf> {
self.db.get(pk)?;
self.db.update_hit(pk)?;
let file_path = self.file_path(pk);
if file_path.exists() {
Ok(file_path)
} else {
Err(anyhow::anyhow!("File not found"))
}
}
async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>> {
let entries = self.db.get_by_collection(collection)?;
let mut paths = Vec::new();
for entry in entries {
let path = self.file_path(&entry.pk);
if path.exists() {
paths.push(path);
}
}
Ok(paths)
}
async fn purge(&self) -> Result<()> {
let cache_dir = PathBuf::from(self.get_cache_dir());
let mut entries = tokio::fs::read_dir(&cache_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.path().is_file() && entry.path() != cache_dir.join("cache.db") {
tokio::fs::remove_file(entry.path()).await?;
}
}
self.db
.purge()
.map_err(|e| anyhow::anyhow!("Database error: {}", e))
}
async fn consolidate(&self) -> Result<()> {
// Récupérer la liste des entrées à traiter
let entries = self.db.get_all()?;
// Supprimer les entrées sans fichiers correspondants
for entry in entries {
let file_path = self.file_path(&entry.pk);
if !file_path.exists() {
match reqwest::get(&entry.source_url).await {
Ok(response) if response.status().is_success() => {
let data = response.bytes().await?;
self.add(&entry.source_url, &data, entry.collection.as_deref())
.await?;
}
_ => {
self.db.delete(&entry.pk)?;
}
}
}
}
// Supprimer les fichiers sans entrées DB correspondantes
let cache_dir_path = PathBuf::from(self.get_cache_dir());
let mut dir_entries = tokio::fs::read_dir(&cache_dir_path).await?;
while let Some(entry) = dir_entries.next_entry().await? {
let path = entry.path();
if path.is_file() && path != cache_dir_path.join("cache.db") {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
// Format attendu: {pk}.{qualifier}.{EXT}
if let Some(pk) = file_name.split('.').next() {
if self.db.get(pk).is_err() {
tokio::fs::remove_file(path).await?;
}
}
}
}
}
Ok(())
}
fn get_cache_dir(&self) -> String {
self.get_cache_dir()
}
fn get_base_url(&self) -> &str {
self.get_base_url()
}
/// # Arguments
///
/// * `dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre d'images)
/// * `base_url` - URL de base pour la génération d'URLs
///
/// # Returns
///
/// Instance du cache configurée pour la conversion WebP automatique
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocovers::cache;
///
/// let cache = cache::new_cache("./cache", 1000, "http://localhost:8080").unwrap();
/// ```
pub fn new_cache(dir: &str, limit: usize, base_url: &str) -> Result<Cache> {
let transformer_factory = Arc::new(|| create_webp_transformer());
Cache::with_transformer(dir, limit, base_url, Some(transformer_factory))
}

View File

@@ -3,62 +3,24 @@
//! Cette crate fournit un système de cache d'images optimisé pour les couvertures d'albums,
//! avec conversion automatique en WebP et génération de variantes de tailles.
//!
//! ## Vue d'ensemble
//! ## Fonctionnalités
//!
//! `pmocovers` gère le téléchargement, la conversion, le stockage et la distribution
//! d'images de couvertures d'albums, avec :
//! - Conversion automatique en WebP pour réduire la taille
//! - Génération de variantes de tailles à la demande
//! - Cache persistant avec base de données SQLite
//! - API HTTP pour récupérer les images
//!
//! ## Fonctionnalités
//!
//! ### 📦 Gestion du cache
//! - Téléchargement automatique depuis des URLs
//! - Conversion des images en WebP (format optimisé)
//! - Stockage persistant sur disque
//! - Base de données SQLite pour le tracking
//!
//! ### 🎨 Génération de variantes
//! - Redimensionnement automatique à la demande
//! - Création d'images carrées avec centrage
//! - Cache des variantes générées
//! - Support de multiples tailles
//!
//! ### 📊 Statistiques d'utilisation
//! - Comptage des accès (hits)
//! - Suivi de la dernière utilisation
//! - API de statistiques complètes
//! - API HTTP complète (fournie par `pmocache`)
//!
//! ## Architecture
//!
//! `pmocovers` suit le pattern d'extension des autres crates PMO :
//! `pmocovers` est une spécialisation minimale de `pmocache` qui ajoute :
//! 1. La conversion WebP automatique lors du téléchargement (via transformer)
//! 2. La génération de variantes redimensionnées à la demande (via param generator)
//!
//! - `pmoserver` définit un serveur HTTP générique
//! - `pmocovers` étend ce serveur avec des méthodes de cache via un trait
//! - Le serveur n'a pas besoin de connaître `pmocovers`
//!
//! ## Structure des fichiers
//!
//! ```text
//! pmocovers/
//! ├── Cargo.toml
//! ├── src/
//! │ ├── lib.rs # Module principal (ce fichier)
//! │ ├── cache.rs # Gestion du cache
//! │ ├── db.rs # Base de données SQLite
//! │ ├── webp.rs # Conversion et redimensionnement WebP
//! │ └── pmoserver_impl.rs # Extension de pmoserver::Server
//! └── cache/ # Répertoire de cache (généré)
//! ├── cache.db # Base SQLite
//! ├── *.orig.webp # Images originales
//! └── *.{size}.webp # Variantes de tailles
//! ```
//! Tout le reste (API REST, serveur de fichiers, DB) est fourni par `pmocache`.
//!
//! ## Utilisation
//!
//! ### Exemple basique avec configuration automatique
//! ### Exemple avec configuration automatique
//!
//! ```rust,no_run
//! use pmocovers::CoverCacheExt;
@@ -67,161 +29,56 @@
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let mut server = ServerBuilder::new_configured().build();
//!
//! // Utilise automatiquement la config (pmoconfig)
//! server.init_cover_cache_configured().await?;
//!
//! server.start().await;
//! server.wait().await;
//! Ok(())
//! }
//! ```
//!
//! ### Exemple avec paramètres personnalisés
//!
//! ```rust,no_run
//! use pmocovers::CoverCacheExt;
//! use pmoserver::ServerBuilder;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build();
//!
//! // Paramètres personnalisés
//! server.init_cover_cache("./cache", 1000).await?;
//!
//! server.start().await;
//! server.wait().await;
//! Ok(())
//! }
//! ```
//!
//! ### Utilisation du cache directement
//!
//! ```rust,no_run
//! use pmocovers::Cache;
//! use pmocache::FileCache;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let cache = Cache::new("./cache", 1000, "http://localhost:8080")?;
//!
//! // Ajouter une image depuis une URL (avec conversion WebP automatique)
//! let pk = cache.add_from_url("http://example.com/cover.jpg", None).await?;
//! println!("Image ajoutée avec clé: {}", pk);
//!
//! // Récupérer l'image originale
//! let path = cache.get(&pk).await?;
//! println!("Image stockée à: {:?}", path);
//!
//! Ok(())
//! }
//! ```
//!
//! ## API HTTP
//!
//! Une fois enregistré sur un serveur via `CoverCacheExt`, les endpoints suivants sont disponibles :
//!
//! ### GET /covers/images/{pk}
//! Récupère l'image originale en WebP
//!
//! ### GET /covers/images/{pk}/{size}
//! Récupère une variante de taille spécifique (ex: `/covers/images/abc123/256`)
//!
//! ### GET /covers/stats
//! Récupère les statistiques du cache (JSON)
//!
//! ## Format des clés (pk)
//!
//! Les images sont identifiées par une clé (pk) dérivée de l'URL source :
//! - Hash SHA1 de l'URL
//! - Encodé en hexadécimal (8 premiers octets)
//! - Exemple: `"1a2b3c4d5e6f7a8b"`
//!
//! ## Stockage
//!
//! Les fichiers sont organisés comme suit :
//!
//! ```text
//! cache/
//! ├── cache.db # Base SQLite
//! ├── 1a2b3c4d.orig.webp # Image originale
//! ├── 1a2b3c4d.256.webp # Variante 256x256
//! └── 1a2b3c4d.512.webp # Variante 512x512
//! ```
//!
//! ## Opérations de maintenance
//!
//! ### Purge du cache
//!
//! ```rust,no_run
//! # use pmocovers::Cache;
//! # async fn example(cache: &Cache) -> anyhow::Result<()> {
//! // Supprimer tous les fichiers et entrées DB
//! cache.purge().await?;
//! # Ok(())
//! # }
//! ```
//!
//! ### Consolidation du cache
//!
//! ```rust,no_run
//! # use pmocovers::Cache;
//! # async fn example(cache: &Cache) -> anyhow::Result<()> {
//! // Re-télécharger les images manquantes et supprimer les orphelins
//! cache.consolidate().await?;
//! # Ok(())
//! # }
//! ```
//!
//! ## Dépendances principales
//!
//! - `image` : Chargement et manipulation d'images
//! - `webp` : Encodage WebP
//! - `rusqlite` : Base de données SQLite
//! - `reqwest` : Téléchargement HTTP
//! - `sha1` : Génération de clés
//!
//! ## Voir aussi
//!
//! - [`pmoserver`] : Serveur HTTP Axum
//! - [`pmoapp`] : Application web frontend
//! - [`pmoupnp`] : Bibliothèque UPnP MediaRenderer
pub mod cache;
pub mod db;
pub mod webp;
#[cfg(feature = "pmoserver")]
pub mod api;
#[cfg(feature = "pmoserver")]
pub mod openapi;
pub use cache::{Cache, CoversConfig};
pub use db::{CacheEntry, DB};
pub use cache::{Cache, CoversConfig, new_cache};
#[cfg(feature = "pmoserver")]
pub use openapi::ApiDoc;
use anyhow::Result;
#[cfg(feature = "pmoserver")]
use utoipa::OpenApi;
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache d'images.
/// Générateur de variantes d'images
///
/// Ce trait permet à `pmocovers` d'ajouter des méthodes d'extension sur des types
/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmocovers`.
///
/// # Architecture
///
/// Similaire au pattern utilisé par `pmoapp` pour `WebAppExt`, ce trait permet
/// une extension propre et découplée :
///
/// - `pmoserver` définit un serveur HTTP générique
/// - `pmocovers` étend ce serveur avec des méthodes de cache via ce trait
/// - Le serveur n'a pas besoin de connaître `pmocovers`
/// Si param est numérique, génère une variante redimensionnée
#[cfg(feature = "pmoserver")]
fn create_variant_generator() -> pmocache::pmoserver_ext::ParamGenerator<CoversConfig> {
Arc::new(|cache, pk, param| {
Box::pin(async move {
// Si le param est numérique, c'est une taille de variante
if let Ok(size) = param.parse::<usize>() {
match webp::generate_variant(&cache, &pk, size).await {
Ok(data) => return Some(data),
Err(e) => {
tracing::warn!("Cannot generate variant {}x{} for {}: {}", size, size, pk, e);
return None;
}
}
}
// Param non reconnu
None
})
})
}
/// Trait d'extension pour ajouter le cache de couvertures à pmoserver
#[cfg(feature = "pmoserver")]
pub trait CoverCacheExt {
/// Initialise le cache d'images et enregistre les routes HTTP.
/// Initialise le cache d'images et enregistre les routes HTTP
///
/// # Arguments
///
@@ -230,49 +87,59 @@ pub trait CoverCacheExt {
///
/// # Returns
///
/// * `Arc<Cache>` - Instance partagée du cache
/// Instance partagée du cache
///
/// # Routes enregistrées
///
/// - `GET /covers/images/{pk}` - Image originale
/// - `GET /covers/images/{pk}/{size}` - Variante de taille
/// - `GET /covers/stats` - Statistiques
/// - `GET /covers/image/{pk}` - Image originale
/// - `GET /covers/image/{pk}/{size}` - Variante de taille (ex: 256, 512)
/// - `GET /api/covers` - Liste des images (API REST)
/// - `POST /api/covers` - Ajouter une image (API REST)
/// - `DELETE /api/covers/{pk}` - Supprimer une image (API REST)
/// - `GET /swagger-ui` - Documentation interactive
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> Result<Arc<Cache>>;
/// - `GET /api/covers/{pk}/status` - Statut du téléchargement
/// - `GET /swagger-ui/covers` - Documentation interactive
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize)
-> anyhow::Result<Arc<Cache>>;
/// Initialise le cache d'images avec la configuration par défaut.
/// Initialise le cache d'images avec la configuration par défaut
///
/// Utilise automatiquement les paramètres de `pmoconfig::Config` :
/// - `host.cover_cache.directory` pour le répertoire
/// - `host.cover_cache.size` pour la limite de taille
///
/// # Returns
///
/// * `Arc<Cache>` - Instance partagée du cache
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocovers::CoverCacheExt;
/// use pmoserver::ServerBuilder;
///
/// #[tokio::main]
/// async fn main() -> anyhow::Result<()> {
/// let mut server = ServerBuilder::new_configured().build();
///
/// // Utilise automatiquement la config
/// server.init_cover_cache_configured().await?;
///
/// server.start().await;
/// Ok(())
/// }
/// ```
async fn init_cover_cache_configured(&mut self) -> Result<Arc<Cache>>;
/// Utilise automatiquement les paramètres de `pmoconfig::Config`
async fn init_cover_cache_configured(&mut self)
-> anyhow::Result<Arc<Cache>>;
}
// Implémentation du trait pour pmoserver::Server (feature-gated)
#[cfg(feature = "pmoserver")]
mod pmoserver_impl;
impl CoverCacheExt for pmoserver::Server {
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize)
-> anyhow::Result<Arc<Cache>> {
use pmocache::pmoserver_ext::{create_file_router_with_generator, create_api_router};
let base_url = self.info().base_url;
let cache = Arc::new(cache::new_cache(cache_dir, limit, &base_url)?);
// Router de fichiers avec génération de variantes
// Routes: GET /covers/image/{pk} et GET /covers/image/{pk}/{size}
let file_router = create_file_router_with_generator(
cache.clone(),
"image/webp",
Some(create_variant_generator())
);
self.add_router("/", file_router).await;
// API REST générique (pmocache)
// Routes: GET/POST/DELETE /api/covers, etc.
let api_router = create_api_router(cache.clone());
let openapi = crate::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "covers").await;
Ok(cache)
}
async fn init_cover_cache_configured(&mut self)
-> anyhow::Result<Arc<Cache>> {
let config = pmoconfig::get_config();
let cache_dir = config.get_cover_cache_dir()?;
let limit = config.get_cover_cache_size()?;
self.init_cover_cache(&cache_dir, limit).await
}
}

View File

@@ -1,24 +1,23 @@
//! Documentation OpenAPI pour l'API REST du cache de couvertures
//!
//! Ce module fournit une documentation OpenAPI simple pour l'API REST
//! fournie par pmocache, spécialisée pour les images de couvertures.
use utoipa::OpenApi;
/// Documentation OpenAPI pour l'API PMOCovers
///
/// L'API réutilise les handlers génériques de pmocache.
#[derive(OpenApi)]
#[openapi(
paths(
crate::api::list_images,
crate::api::get_image_info,
crate::api::add_image,
crate::api::delete_image,
crate::api::purge_cache,
crate::api::consolidate_cache,
),
components(
schemas(
crate::db::CacheEntry,
crate::api::AddImageRequest,
crate::api::AddImageResponse,
crate::api::DeleteImageResponse,
crate::api::ErrorResponse,
pmocache::CacheEntry,
pmocache::api::AddItemRequest,
pmocache::api::AddItemResponse,
pmocache::api::DeleteItemResponse,
pmocache::api::ErrorResponse,
pmocache::api::DownloadStatus,
)
),
tags(
@@ -38,6 +37,38 @@ Cette API permet de gérer un cache d'images optimisé pour les couvertures d'al
- **Consultation** : Liste des images avec statistiques d'utilisation
- **Suppression** : Suppression individuelle ou purge complète
- **Maintenance** : Consolidation du cache pour réparer les incohérences
- **Statut** : Suivi des téléchargements en cours
## Endpoints principaux
### GET /api/covers
Liste toutes les images en cache avec leurs statistiques
### POST /api/covers
Ajoute une image depuis une URL (conversion WebP automatique)
### GET /api/covers/{pk}
Récupère les informations d'une image
### DELETE /api/covers/{pk}
Supprime une image et ses variantes
### GET /api/covers/{pk}/status
Récupère le statut du téléchargement
### DELETE /api/covers
Purge complètement le cache
### POST /api/covers/consolidate
Consolide le cache (répare les incohérences)
## Servir les fichiers
### GET /covers/image/{pk}
Récupère l'image originale en WebP
### GET /covers/image/{pk}/{size}
Récupère une variante redimensionnée (ex: /covers/image/abc123/256)
## Format des images

View File

@@ -1,144 +0,0 @@
//! Implémentation du trait CoverCacheExt pour le serveur pmoserver
//!
//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités de cache d'images en
//! implémentant le trait [`CoverCacheExt`](crate::CoverCacheExt). Cette implémentation
//! permet d'initialiser facilement le cache et d'enregistrer les routes HTTP.
//!
//! ## Architecture
//!
//! `pmocovers` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmocovers`.
//! C'est le pattern d'extension : `pmocovers` ajoute des fonctionnalités à un type
//! externe via un trait, similaire au pattern utilisé par `pmoapp` pour `WebAppExt`.
//!
//! ## Exemple d'utilisation
//!
//! ```rust,no_run
//! use pmocovers::CoverCacheExt;
//! use pmoserver::ServerBuilder;
//!
//! # async fn example() -> anyhow::Result<()> {
//! let mut server = ServerBuilder::new("MyApp", "http://localhost:3000", 3000).build();
//!
//! // Le trait CoverCacheExt est automatiquement disponible
//! let cache = server.init_cover_cache("./cache", 1000).await?;
//!
//! server.start().await;
//! # Ok(())
//! # }
//! ```
use crate::{api, Cache, CoverCacheExt};
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use pmoserver::Server;
use tracing::{info, warn};
use std::sync::Arc;
use utoipa::OpenApi;
/// Handler pour GET /covers/images/{pk}/{size}
/// Génère une variante d'image à la demande
async fn get_cover_variant(
State(cache): State<Arc<Cache>>,
Path((pk, size)): Path<(String, String)>,
) -> Response {
let size = match size.parse::<usize>() {
Ok(s) => s,
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid size").into_response(),
};
match crate::webp::generate_variant(&cache, &pk, size).await {
Ok(data) => (
StatusCode::OK,
[("content-type", "image/webp")],
data,
)
.into_response(),
Err(e) => {
warn!("Cannot generate variant for {}: {}", pk, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Cannot generate variant").into_response()
}
}
}
/// Handler pour GET /covers/stats
async fn get_cover_stats(State(cache): State<Arc<Cache>>) -> Response {
match cache.db.get_all() {
Ok(entries) => Json(entries).into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot retrieve stats").into_response(),
}
}
impl CoverCacheExt for Server {
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result<Arc<Cache>> {
// Utiliser l'URL du serveur comme base_url
let base_url = self.info().base_url;
let cache = Arc::new(Cache::new(cache_dir, limit, &base_url)?);
// Utiliser le router générique de pmocache pour servir les fichiers
// Routes: GET /covers/images/{pk} et GET /covers/images/{pk}/{param}
let file_router = pmocache::pmoserver_ext::create_file_router(
cache.clone(),
"image/webp"
);
self.add_router("/covers/images", file_router).await;
// Route pour générer les variantes à la demande (redimensionnement)
// Note: Cette route est spécifique à pmocovers car elle nécessite generate_variant
let variant_router = Router::new()
.route("/{pk}/{size}", get(get_cover_variant))
.with_state(cache.clone());
self.add_router("/covers/variants", variant_router).await;
// Route pour les stats
self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await;
// Router API RESTful qui sera nesté sous /api/covers par add_openapi
let api_router = Router::new()
// Liste et ajout
.route(
"/",
get(api::list_images) // GET /api/covers
.post(api::add_image) // POST /api/covers
.delete(api::purge_cache), // DELETE /api/covers
)
// Ressource unique
.route(
"/{pk}",
get(api::get_image_info) // GET /api/covers/{pk}
.delete(api::delete_image), // DELETE /api/covers/{pk}
)
// Action spécifique
.route(
"/consolidate",
post(api::consolidate_cache), // POST /api/covers/consolidate
)
.with_state(cache.clone());
// Documentation OpenAPI via Utoipa
let openapi = crate::ApiDoc::openapi();
// Enregistrer l'API avec Swagger UI
// Le router sera nesté automatiquement sous /api/covers par add_openapi
// Routes finales: /api/covers, /api/covers/{pk}, /api/covers/consolidate
// Swagger UI sera disponible à /swagger-ui/covers
self.add_openapi(api_router, openapi, "covers").await;
Ok(cache)
}
async fn init_cover_cache_configured(&mut self) -> anyhow::Result<Arc<Cache>> {
let config = pmoconfig::get_config();
let cache_dir = config.get_cover_cache_dir()?;
let limit = config.get_cover_cache_size()?;
info!("cache directory {}, size {}",cache_dir,limit);
self.init_cover_cache(&cache_dir, limit).await
}
}