Refactoring manuel

This commit is contained in:
2025-10-17 14:36:39 +02:00
parent 2eec78ab4f
commit 082914cf8c
18 changed files with 664 additions and 334 deletions

View File

@@ -31,4 +31,4 @@ tracing = "0.1.41"
[features]
default = ["pmoserver"]
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/openapi"]
pmoserver = ["dep:pmoserver", "dep:pmoconfig", "dep:axum", "dep:utoipa", "pmocache/openapi", "pmocache/pmoserver"]

View File

@@ -6,7 +6,7 @@
//! - Supprimer des images
//! - Consulter les statistiques
use crate::{Cache, CacheEntry};
use crate::{Cache, CacheEntry, ImageCacheExt};
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -146,7 +146,7 @@ pub async fn add_image(
.into_response();
}
match cache.add_from_url(&req.url).await {
match cache.add_image_from_url(&req.url).await {
Ok(pk) => (
StatusCode::CREATED,
Json(AddImageResponse {
@@ -200,7 +200,8 @@ pub async fn delete_image(
}
// Supprimer les fichiers (original + variantes)
let orig_path = cache.dir.join(format!("{}.orig.webp", pk));
let cache_dir = std::path::PathBuf::from(cache.cache_dir());
let orig_path = cache_dir.join(format!("{}.orig.webp", pk));
if orig_path.exists() {
if let Err(e) = tokio::fs::remove_file(&orig_path).await {
return (
@@ -215,7 +216,7 @@ pub async fn delete_image(
}
// Supprimer toutes les variantes (*.{pk}.*.webp)
if let Ok(mut entries) = tokio::fs::read_dir(&cache.dir).await {
if let Ok(mut entries) = tokio::fs::read_dir(&cache_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if let Some(filename) = entry.file_name().to_str() {
if filename.starts_with(&pk) && filename.ends_with(".webp") && filename != format!("{}.orig.webp", pk) {

View File

@@ -3,132 +3,163 @@
//! Ce module étend le cache générique de `pmocache` avec des fonctionnalités
//! spécifiques aux images : conversion WebP et génération de variantes.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use pmocache::{Cache as GenericCache, CacheConfig};
use anyhow::Result;
use pmocache::{CacheConfig, FileCache};
use crate::webp;
use crate::db::DB;
use std::path::PathBuf;
use std::ops::Deref;
/// Configuration pour le cache de couvertures
pub struct CoversConfig;
impl CacheConfig for CoversConfig {
fn file_extension() -> &'static str {
"webp"
}
fn table_name() -> &'static str {
"covers"
}
fn cache_type() -> &'static str {
"image"
}
/// Cache name (ex: "covers", "audio", "cache")
fn cache_name() -> &'static str {
"covers"
}
}
/// Cache d'images avec conversion WebP et génération de variantes
///
/// Gère le téléchargement, la conversion en WebP, le stockage et la génération
/// de variantes de tailles pour les images de couvertures.
/// Format des fichiers : `{pk}.{qualificatif}.webp`
/// Exemple : `a1b2c3d4.orig.webp`, `a1b2c3d4.thumb.webp`
///
/// Ce type est un wrapper autour de `pmocache::Cache<CoversConfig>` qui permet
/// d'implémenter le trait `FileCache` avec conversion WebP automatique.
#[derive(Debug)]
pub struct Cache {
cache: GenericCache,
pub(crate) dir: PathBuf,
pub(crate) limit: usize,
pub db: Arc<DB>,
}
pub struct Cache(pmocache::Cache<CoversConfig>);
impl Cache {
/// Crée un nouveau cache d'images
///
/// # Arguments
///
/// * `dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (nombre d'images)
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocovers::Cache;
///
/// let cache = Cache::new("./cache", 1000).unwrap();
/// ```
pub fn new(dir: &str, limit: usize) -> Result<Self> {
let config = CacheConfig::new(dir, limit, "covers", "orig.webp");
let cache = GenericCache::new(config)?;
pub fn new(dir: &str, limit: usize, base_url: &str) -> Result<Self> {
Ok(Self(pmocache::Cache::new(dir, limit, base_url)?))
}
}
Ok(Self {
dir: PathBuf::from(dir),
limit,
db: Arc::clone(&cache.db),
cache,
})
/// Permet d'accéder aux méthodes publiques de `pmocache::Cache` directement
impl Deref for Cache {
type Target = pmocache::Cache<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()
}
/// Télécharge une image depuis une URL et l'ajoute au cache
///
/// # Arguments
///
/// * `url` - URL de l'image à télécharger
///
/// # Returns
///
/// La clé primaire (pk) de l'image dans le cache
pub async fn add_from_url(&self, url: &str) -> Result<String> {
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!("Bad status: {}", response.status()));
return Err(anyhow::anyhow!("Bad status: {}", response.status()));
}
let data = response.bytes().await?;
self.add(url, &data).await
self.add(url, &data, collection).await
}
/// S'assure qu'une image est présente dans le cache
///
/// Si l'image existe déjà, retourne sa clé. Sinon, la télécharge.
///
/// # Arguments
///
/// * `url` - URL de l'image
pub async fn ensure_from_url(&self, url: &str) -> Result<String> {
self.cache.ensure_from_url(url, None).await
}
/// Ajoute une image au cache avec conversion en WebP
///
/// # Arguments
///
/// * `url` - URL source de l'image
/// * `data` - Données brutes de l'image
pub async fn add(&self, url: &str, data: &[u8]) -> Result<String> {
async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
let pk = pmocache::pk_from_url(url);
let orig_path = self.dir.join(format!("{}.orig.webp", pk));
// Vérifier si le fichier existe déjà
if !orig_path.exists() {
// Convertir l'image en WebP
let img = image::load_from_memory(data)?;
let webp_data = webp::encode_webp(&img)?;
tokio::fs::write(&orig_path, webp_data).await?;
if self.db.get(&pk).is_ok() {
let file_path = self.file_path(&pk);
if file_path.exists() {
return Ok(pk);
}
}
// Ajouter à la DB (sans collection pour les covers)
self.db.add(&pk, url, None)?;
self.add_from_url(url, collection).await
}
async fn add(&self, url: &str, data: &[u8], collection: Option<&str>) -> Result<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)
}
/// Récupère le chemin d'une image dans le cache
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'image
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
self.cache.get(pk).await
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"))
}
}
/// Supprime tous les fichiers et entrées du cache
pub async fn purge(&self) -> Result<()> {
self.cache.purge().await
async fn get_collection(&self, collection: &str) -> Result<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)
}
/// Consolide le cache en supprimant les orphelins et en re-téléchargeant les images manquantes
pub async fn consolidate(&self) -> Result<()> {
async fn purge(&self) -> Result<()> {
let cache_dir = PathBuf::from(self.get_cache_dir());
let mut entries = tokio::fs::read_dir(&cache_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.path().is_file() && entry.path() != cache_dir.join("cache.db") {
tokio::fs::remove_file(entry.path()).await?;
}
}
self.db
.purge()
.map_err(|e| anyhow::anyhow!("Database error: {}", e))
}
async fn consolidate(&self) -> Result<()> {
// Récupérer la liste des entrées à traiter
let entries = self.db.get_all()?;
// Supprimer les entrées sans fichiers correspondants
for entry in entries {
let orig_path = self.dir.join(format!("{}.orig.webp", entry.pk));
if !orig_path.exists() {
let file_path = self.file_path(&entry.pk);
if !file_path.exists() {
match reqwest::get(&entry.source_url).await {
Ok(response) if response.status().is_success() => {
let data = response.bytes().await?;
self.add(&entry.source_url, &data).await?;
self.add(&entry.source_url, &data, entry.collection.as_deref())
.await?;
}
_ => {
self.db.delete(&entry.pk)?;
@@ -138,13 +169,14 @@ impl Cache {
}
// Supprimer les fichiers sans entrées DB correspondantes
let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
let cache_dir_path = PathBuf::from(self.get_cache_dir());
let mut dir_entries = tokio::fs::read_dir(&cache_dir_path).await?;
while let Some(entry) = dir_entries.next_entry().await? {
let path = entry.path();
if path.is_file() && path != self.dir.join("cache.db") {
if path.is_file() && path != cache_dir_path.join("cache.db") {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.ends_with(".orig.webp") {
let pk = file_name.trim_end_matches(".orig.webp");
// Format attendu: {pk}.{qualifier}.{EXT}
if let Some(pk) = file_name.split('.').next() {
if self.db.get(pk).is_err() {
tokio::fs::remove_file(path).await?;
}
@@ -156,8 +188,11 @@ impl Cache {
Ok(())
}
/// Retourne le répertoire du cache
pub fn cache_dir(&self) -> String {
self.dir.to_string_lossy().to_string()
fn get_cache_dir(&self) -> String {
self.get_cache_dir()
}
fn get_base_url(&self) -> &str {
self.get_base_url()
}
}

View File

@@ -100,13 +100,14 @@
//!
//! ```rust,no_run
//! use pmocovers::Cache;
//! use pmocache::FileCache;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let cache = Cache::new("./cache", 1000)?;
//! let cache = Cache::new("./cache", 1000, "http://localhost:8080")?;
//!
//! // Ajouter une image depuis une URL
//! let pk = cache.add_from_url("http://example.com/cover.jpg").await?;
//! // Ajouter une image depuis une URL (avec conversion WebP automatique)
//! let pk = cache.add_from_url("http://example.com/cover.jpg", None).await?;
//! println!("Image ajoutée avec clé: {}", pk);
//!
//! // Récupérer l'image originale
@@ -197,7 +198,7 @@ pub mod api;
#[cfg(feature = "pmoserver")]
pub mod openapi;
pub use cache::Cache;
pub use cache::{Cache, CoversConfig};
pub use db::{CacheEntry, DB};
#[cfg(feature = "pmoserver")]

View File

@@ -29,80 +29,39 @@
use crate::{api, Cache, CoverCacheExt};
use axum::{
body::Body,
extract::State,
http::{Request, StatusCode},
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get, post},
routing::{get, post},
Json, Router,
};
use pmoserver::Server;
use tracing::{debug, info, warn};
use tracing::{info, warn};
use std::sync::Arc;
use utoipa::OpenApi;
/// Handler pour GET /covers/images/{pk}
async fn get_cover_image(
State(cache): State<Arc<Cache>>,
req: Request<Body>,
) -> Response {
// Extraire pk du path
let path = req.uri().path();
let parts: Vec<&str> = path.split('/').collect();
warn!("{:?}",parts);
if parts.len() != 2 {
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
}
let pk = parts[1];
match cache.get(pk).await {
Ok(file_path) => {
match tokio::fs::read(&file_path).await {
Ok(data) => (
StatusCode::OK,
[("content-type", "image/webp")],
data,
)
.into_response(),
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
}
}
Err(_) => (StatusCode::NOT_FOUND, "Image not found").into_response(),
}
}
/// Handler pour GET /covers/images/{pk}/{size}
/// Génère une variante d'image à la demande
async fn get_cover_variant(
State(cache): State<Arc<Cache>>,
req: Request<Body>,
Path((pk, size)): Path<(String, String)>,
) -> Response {
// Extraire pk et size du path
let path = req.uri().path();
let parts: Vec<&str> = path.split('/').collect();
if parts.len() != 3 {
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
}
let pk = parts[1];
let size = match parts[2].parse::<usize>() {
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 {
match crate::webp::generate_variant(&cache, &pk, size).await {
Ok(data) => (
StatusCode::OK,
[("content-type", "image/webp")],
data,
)
.into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Cannot generate variant").into_response(),
Err(e) => {
warn!("Cannot generate variant for {}: {}", pk, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Cannot generate variant").into_response()
}
}
}
@@ -116,18 +75,28 @@ async fn get_cover_stats(State(cache): State<Arc<Cache>>) -> 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)?);
// Utiliser l'URL du serveur comme base_url
let base_url = self.info().base_url;
let cache = Arc::new(Cache::new(cache_dir, limit, &base_url)?);
// Enregistrer les routes HTTP classiques pour servir les images
let image_router = Router::new()
.route("/{pk}", get(get_cover_image))
// Utiliser le router générique de pmocache pour servir les fichiers
// Routes: GET /covers/images/{pk} et GET /covers/images/{pk}/{param}
let file_router = pmocache::pmoserver_ext::create_file_router(
cache.clone(),
"image/webp"
);
self.add_router("/covers/images", file_router).await;
// Route pour générer les variantes à la demande (redimensionnement)
// Note: Cette route est spécifique à pmocovers car elle nécessite generate_variant
let variant_router = Router::new()
.route("/{pk}/{size}", get(get_cover_variant))
.with_state(cache.clone());
self.add_router("/covers/variants", variant_router).await;
self.add_router("/covers/images", image_router).await;
// Route pour les stats
self.add_handler_with_state("/covers/stats", get_cover_stats, cache.clone()).await;
// Router API RESTful
// Router API RESTful qui sera nesté sous /api/covers par add_openapi
let api_router = Router::new()
// Liste et ajout

View File

@@ -39,13 +39,14 @@ pub fn ensure_square(img: &DynamicImage, size: u32) -> DynamicImage {
}
pub async fn generate_variant(cache: &super::cache::Cache, pk: &str, size: usize) -> Result<Vec<u8>> {
let variant_path = cache.dir.join(format!("{}.{}.webp", pk, size));
// Utiliser file_path_with_qualifier pour obtenir le chemin
let variant_path = cache.file_path_with_qualifier(pk, &size.to_string());
if variant_path.exists() {
return Ok(tokio::fs::read(variant_path).await?);
}
let orig_path = cache.dir.join(format!("{}.orig.webp", pk));
let orig_path = cache.file_path_with_qualifier(pk, "orig");
// Charger l'image de manière synchrone (image::open n'est pas async)
let img = tokio::task::spawn_blocking(move || {