Ajoute pmocovers

This commit is contained in:
2025-10-07 08:33:06 +02:00
parent e260330877
commit 9bf5bc7fe2
22 changed files with 2850 additions and 56 deletions

39
pmocovers/Cargo.toml Normal file
View File

@@ -0,0 +1,39 @@
[package]
name = "pmocovers"
version = "0.1.0"
edition = "2021"
[dependencies]
# Gestion d'images
image = "0.25"
webp = "0.3"
# Base de données
rusqlite = { version = "0.32", features = ["bundled"] }
# HTTP client
reqwest = { version = "0.12", features = ["blocking"] }
# Cryptographie
sha1 = "0.10"
hex = "0.4"
# Utilitaires
anyhow = "1.0"
chrono = "0.4"
serde = { version = "1.0", features = ["derive"] }
# Async
tokio = { version = "1.0", features = ["full"] }
# Serveur HTTP (optionnel pour l'extension)
pmoserver = { path = "../pmoserver", optional = true }
pmoconfig = { path = "../pmoconfig", optional = true }
axum = { version = "0.8", optional = true }
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
tracing = "0.1.41"
[features]
default = ["pmoserver"]
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa"]

311
pmocovers/src/api.rs Normal file
View File

@@ -0,0 +1,311 @@
//! 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};
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_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 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(),
}
}

145
pmocovers/src/cache.rs Normal file
View File

@@ -0,0 +1,145 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use sha1::{Sha1, Digest};
use tokio::sync::Mutex;
use crate::db::DB;
use crate::webp;
#[derive(Debug)]
pub struct Cache {
pub(crate) dir: PathBuf,
pub(crate) limit: usize,
pub db: DB,
mu: Arc<Mutex<()>>,
}
impl Cache {
pub fn new(dir: &str, limit: usize) -> Result<Self> {
std::fs::create_dir_all(dir)?;
let db = DB::init(&PathBuf::from(dir).join("cache.db"))?;
Ok(Self {
dir: PathBuf::from(dir),
limit,
db,
mu: Arc::new(Mutex::new(())),
})
}
pub async fn add_from_url(&self, url: &str) -> Result<String> {
let response = reqwest::get(url).await?;
if !response.status().is_success() {
return Err(anyhow!("Bad status: {}", response.status()));
}
let data = response.bytes().await?;
self.add(url, &data).await
}
pub async fn ensure_from_url(&self, url: &str) -> Result<String> {
let pk = pk_from_url(url);
if self.db.get(&pk).is_ok() {
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
if orig_path.exists() {
return Ok(pk);
}
}
self.add_from_url(url).await
}
pub async fn add(&self, url: &str, data: &[u8]) -> Result<String> {
let pk = pk_from_url(url);
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
let _lock = self.mu.lock().await;
if !orig_path.exists() {
let img = image::load_from_memory(data)?;
let webp_data = webp::encode_webp(&img)?;
tokio::fs::write(&orig_path, webp_data).await?;
}
self.db.add(&pk, url)?;
Ok(pk)
}
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
let _lock = self.mu.lock().await;
self.db.get(pk)?;
self.db.update_hit(pk)?;
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
if orig_path.exists() {
Ok(orig_path)
} else {
Err(anyhow!("File not found"))
}
}
pub async fn purge(&self) -> Result<()> {
let _lock = self.mu.lock().await;
let mut entries = tokio::fs::read_dir(&self.dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.path().is_file() {
tokio::fs::remove_file(entry.path()).await?;
}
}
self.db.purge().map_err(|e| anyhow!("Database error: {}", e))
}
pub async fn consolidate(&self) -> Result<()> {
let _lock = self.mu.lock().await;
let entries = self.db.get_all()?;
for entry in entries {
let orig_path = self.dir.join(format!("{}.orig.webp", entry.pk));
if !orig_path.exists() {
match reqwest::get(&entry.source_url).await {
Ok(response) if response.status().is_success() => {
let data = response.bytes().await?;
self.add(&entry.source_url, &data).await?;
}
_ => {
self.db.delete(&entry.pk)?;
}
}
}
}
let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
while let Some(entry) = dir_entries.next_entry().await? {
let path = entry.path();
if path.is_file() {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".orig.webp") {
let pk = file_name.trim_end_matches(".orig.webp");
if self.db.get(pk).is_err() {
tokio::fs::remove_file(path).await?;
}
}
}
}
}
Ok(())
}
pub fn cache_dir(&self) -> String {
self.dir.to_string_lossy().to_string()
}
}
fn pk_from_url(url: &str) -> String {
let mut hasher = Sha1::new();
hasher.update(url.as_bytes());
let result = hasher.finalize();
hex::encode(&result[..8])
}

118
pmocovers/src/db.rs Normal file
View File

@@ -0,0 +1,118 @@
use rusqlite::{Connection, params};
use serde::Serialize;
use chrono::Utc;
use std::path::Path;
use std::sync::Mutex;
#[cfg(feature = "pmoserver")]
use utoipa::ToSchema;
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "pmoserver", derive(ToSchema))]
pub struct CacheEntry {
/// Clé primaire unique de l'image (hash SHA1 de l'URL)
#[cfg_attr(feature = "pmoserver", schema(example = "1a2b3c4d5e6f7a8b"))]
pub pk: String,
/// URL source de l'image
#[cfg_attr(feature = "pmoserver", schema(example = "https://example.com/cover.jpg"))]
pub source_url: String,
/// Nombre d'accès à l'image
#[cfg_attr(feature = "pmoserver", schema(example = 42))]
pub hits: i32,
/// Date/heure du dernier accès (RFC3339)
#[cfg_attr(feature = "pmoserver", schema(example = "2025-01-15T10:30:00Z"))]
pub last_used: Option<String>,
}
#[derive(Debug)]
pub struct DB {
conn: Mutex<Connection>,
}
impl DB {
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS covers (
pk TEXT PRIMARY KEY,
source_url TEXT,
hits INTEGER DEFAULT 0,
last_used TEXT
)",
[],
)?;
Ok(Self { conn: Mutex::new(conn) })
}
pub fn add(&self, pk: &str, url: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO covers (pk, source_url, hits, last_used)
VALUES (?1, ?2, 0, ?3)
ON CONFLICT(pk) DO UPDATE SET
source_url = excluded.source_url,
last_used = excluded.last_used",
params![pk, url, Utc::now().to_rfc3339()],
)?;
Ok(())
}
pub fn get(&self, pk: &str) -> rusqlite::Result<CacheEntry> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT pk, source_url, hits, last_used FROM covers WHERE pk = ?1",
[pk],
|row| {
Ok(CacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
hits: row.get(2)?,
last_used: row.get(3)?,
})
},
)
}
pub fn update_hit(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE covers SET hits = hits + 1, last_used = ?1 WHERE pk = ?2",
params![Utc::now().to_rfc3339(), pk],
)?;
Ok(())
}
pub fn purge(&self) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM covers", [])?;
Ok(())
}
pub fn get_all(&self) -> rusqlite::Result<Vec<CacheEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT pk, source_url, hits, last_used FROM covers ORDER BY hits DESC",
)?;
let entries = stmt.query_map([], |row| {
Ok(CacheEntry {
pk: row.get(0)?,
source_url: row.get(1)?,
hits: row.get(2)?,
last_used: row.get(3)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
pub fn delete(&self, pk: &str) -> rusqlite::Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM covers WHERE pk = ?1", [pk])?;
Ok(())
}
}

277
pmocovers/src/lib.rs Normal file
View File

@@ -0,0 +1,277 @@
//! # pmocovers - Service de cache d'images de couvertures pour PMOMusic
//!
//! 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
//!
//! `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
//!
//! ## Architecture
//!
//! `pmocovers` suit le pattern d'extension des autres crates PMO :
//!
//! - `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
//! ```
//!
//! ## Utilisation
//!
//! ### Exemple basique avec configuration automatique
//!
//! ```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 (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;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let cache = Cache::new("./cache", 1000)?;
//!
//! // Ajouter une image depuis une URL
//! let pk = cache.add_from_url("http://example.com/cover.jpg").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;
pub use db::{CacheEntry, DB};
#[cfg(feature = "pmoserver")]
pub use openapi::ApiDoc;
use anyhow::Result;
use std::sync::Arc;
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache 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`
pub trait CoverCacheExt {
/// Initialise le cache d'images et enregistre les routes HTTP.
///
/// # Arguments
///
/// * `cache_dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (en nombre d'images)
///
/// # Returns
///
/// * `Arc<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 /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>>;
/// 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>>;
}
// Implémentation du trait pour pmoserver::Server (feature-gated)
#[cfg(feature = "pmoserver")]
mod pmoserver_impl;

70
pmocovers/src/openapi.rs Normal file
View File

@@ -0,0 +1,70 @@
//! Documentation OpenAPI pour l'API REST du cache de couvertures
use utoipa::OpenApi;
#[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,
)
),
tags(
(name = "covers", description = "Gestion du cache d'images de couvertures")
),
info(
title = "PMOCovers API",
version = "0.1.0",
description = r#"
# API de gestion du cache d'images de couvertures
Cette API permet de gérer un cache d'images optimisé pour les couvertures d'albums.
## Fonctionnalités
- **Ajout d'images** : Téléchargement depuis une URL avec conversion automatique en WebP
- **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
## Format des images
Les images sont stockées au format WebP avec :
- Une version originale (`{pk}.orig.webp`)
- Des variantes de tailles générées à la demande (`{pk}.{size}.webp`)
## Clés (pk)
Chaque image est identifiée par une clé (pk) unique :
- Hash SHA1 des 8 premiers octets de l'URL source
- Encodage hexadécimal
- Exemple : `1a2b3c4d5e6f7a8b`
## Statistiques
Le système suit automatiquement :
- Le nombre d'accès (hits)
- La date du dernier accès
- L'URL source originale
"#,
contact(
name = "PMOMusic",
),
license(
name = "MIT",
),
)
)]
pub struct ApiDoc;

View File

@@ -0,0 +1,166 @@
//! 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::{
body::Body,
extract::State,
http::{Request, StatusCode},
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
};
use pmoserver::Server;
use tracing::{debug, info};
use std::sync::Arc;
use utoipa::OpenApi;
/// Handler pour GET /covers/images/{pk}
async fn get_cover_image(
State(cache): State<Arc<Cache>>,
req: Request<Body>,
) -> Response {
// Extraire pk du path
let path = req.uri().path();
let parts: Vec<&str> = path.split('/').collect();
if parts.len() < 4 {
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
}
let pk = parts[3];
match cache.get(pk).await {
Ok(file_path) => {
match tokio::fs::read(&file_path).await {
Ok(data) => (
StatusCode::OK,
[("content-type", "image/webp")],
data,
)
.into_response(),
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
}
}
Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(),
}
}
/// Handler pour GET /covers/images/{pk}/{size}
async fn get_cover_variant(
State(cache): State<Arc<Cache>>,
req: Request<Body>,
) -> Response {
// Extraire pk et size du path
let path = req.uri().path();
let parts: Vec<&str> = path.split('/').collect();
if parts.len() < 5 {
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
}
let pk = parts[3];
let size = match parts[4].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(_) => (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>> {
let cache = Arc::new(Cache::new(cache_dir, limit)?);
// Enregistrer les routes HTTP classiques
self.add_handler_with_state("/covers/images", get_cover_image, cache.clone()).await;
self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await;
// Router API RESTful
// Router API RESTful monté sur /api/covers
let api_router = Router::new()
// Liste et ajout
.route(
"/images/",
get(api::list_images) // GET /api/covers
.post(api::add_image) // POST /api/covers
.delete(api::purge_cache), // DELETE /api/covers
)
// Ressource unique
.route(
"/images/{pk}",
get(api::get_image_info) // GET /api/covers/{pk}
.delete(api::delete_image), // DELETE /api/covers/{pk}
)
// Action spécifique
.route(
"/images/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
// /api/covers/images... et /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
}
}

61
pmocovers/src/webp.rs Normal file
View File

@@ -0,0 +1,61 @@
use anyhow::Result;
use image::{DynamicImage, imageops::FilterType};
use webp::{Encoder, WebPMemory};
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());
let webp_data: WebPMemory = encoder.encode(85.0);
Ok(webp_data.to_vec())
}
pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage {
let (width, height) = (img.width(), img.height());
// Calculer le ratio de mise à l'échelle
let scale = if width > height {
size as f32 / width as f32
} else {
size as f32 / height as f32
};
let new_width = (width as f32 * scale) as u32;
let new_height = (height as f32 * scale) as u32;
// Redimensionner l'image
let resized = img.resize(new_width, new_height, FilterType::Lanczos3);
// Créer une image carrée avec fond transparent
let mut square = DynamicImage::new_rgba8(size, size);
// Calculer la position pour centrer l'image redimensionnée
let x = (size - new_width) / 2;
let y = (size - new_height) / 2;
// Copier l'image redimensionnée au centre du carré
image::imageops::overlay(&mut square, &resized, x.into(), y.into());
square
}
pub async fn generate_variant(cache: &super::cache::Cache, pk: &str, size: usize) -> Result<Vec<u8>> {
let variant_path = cache.dir.join(format!("{}.{}.webp", pk, size));
if variant_path.exists() {
return Ok(tokio::fs::read(variant_path).await?);
}
let orig_path = cache.dir.join(format!("{}.orig.webp", pk));
// Charger l'image de manière synchrone (image::open n'est pas async)
let img = tokio::task::spawn_blocking(move || {
image::open(orig_path)
})
.await??;
let square = ensure_square(&img, size as u32);
let webp_data = encode_webp(&square)?;
tokio::fs::write(&variant_path, &webp_data).await?;
Ok(webp_data)
}