Refactoring manuel

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

View File

@@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
# Base de données
rusqlite = { version = "0.32", features = ["bundled"] }
rusqlite = { version = "0.37.0", features = ["bundled"] }
# HTTP client
reqwest = { version = "0.12", features = ["blocking"] }
@@ -25,6 +25,11 @@ tokio = { version = "1.0", features = ["full"] }
# Feature pour OpenAPI
utoipa = { version = "5.3", optional = true }
# Feature pour pmoserver (extension HTTP)
axum = { version = "0.8", optional = true }
tracing = { version = "0.1", optional = true }
[features]
default = []
openapi = ["dep:utoipa"]
pmoserver = ["dep:axum", "dep:tracing"]

View File

@@ -3,35 +3,32 @@
//! Ce module fournit une interface générique pour gérer un cache de fichiers
//! avec métadonnées dans une base de données SQLite.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use sha1::{Sha1, Digest};
use tokio::sync::Mutex;
use crate::cache_trait::FileCache;
use crate::db::DB;
use anyhow::{anyhow, Result};
use sha1::{Digest, Sha1};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Configuration du cache
#[derive(Debug, Clone)]
pub struct CacheConfig {
/// Répertoire de stockage
pub dir: PathBuf,
/// Limite de taille du cache (nombre d'éléments)
pub limit: usize,
/// Nom de la table dans la base de données
pub table_name: String,
/// Extension des fichiers dans le cache
pub file_extension: String,
}
impl CacheConfig {
/// Crée une nouvelle configuration de cache
pub fn new(dir: &str, limit: usize, table_name: &str, file_extension: &str) -> Self {
Self {
dir: PathBuf::from(dir),
limit,
table_name: table_name.to_string(),
file_extension: file_extension.to_string(),
}
/// Trait pour définir les paramètres du cache
pub trait CacheConfig: Send + Sync {
/// Extension des fichiers (ex: "webp", "flac")
fn file_extension() -> &'static str;
/// Nom de la table dans la base de données (ex: "covers", "audio")
fn table_name() -> &'static str {
"cached_items"
}
/// Type de cache (ex: "audio", "image")
fn cache_type() -> &'static str {
"file"
}
/// Cache name (ex: "covers", "audio", "cache")
fn cache_name() -> &'static str {
"cache"
}
/// Default param extension ("orig")
fn default_param() -> &'static str {
"orig"
}
}
@@ -40,42 +37,45 @@ impl CacheConfig {
/// Gère le téléchargement, le stockage et la récupération de fichiers
/// avec une base de données SQLite pour les métadonnées.
///
/// # Paramètres de type
///
/// * `C` - Configuration du cache (implémente `CacheConfig`)
///
/// Note : Ce type est conçu pour être utilisé derrière un `Arc<Cache>`.
/// Les méthodes prennent `&self` et utilisent des `Arc` et `Mutex` internes
/// pour la synchronisation.
/// La synchronisation est gérée par le Mutex interne de la base de données SQLite.
#[derive(Debug)]
pub struct Cache {
pub(crate) config: CacheConfig,
pub struct Cache<C: CacheConfig> {
/// Répertoire de stockage
dir: PathBuf,
/// Limite de taille du cache (nombre d'éléments)
limit: usize,
/// URL de base pour la génération d'URLs
base_url: String,
/// Base de données SQLite
pub db: Arc<DB>,
mu: Arc<Mutex<()>>,
/// Phantom data pour le type de configuration
_phantom: std::marker::PhantomData<C>,
}
impl Cache {
/// Crée un nouveau cache avec la configuration spécifiée
impl<C: CacheConfig> Cache<C> {
/// Crée un nouveau cache
///
/// # Arguments
///
/// * `config` - Configuration du cache
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocache::cache::{Cache, CacheConfig};
///
/// let config = CacheConfig::new("./cache", 1000, "my_cache", "webp");
/// let cache = Cache::new(config).unwrap();
/// ```
pub fn new(config: CacheConfig) -> Result<Self> {
std::fs::create_dir_all(&config.dir)?;
let db = DB::init(
&config.dir.join("cache.db"),
&config.table_name
)?;
/// * `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
pub fn new(dir: &str, limit: usize, base_url: &str) -> Result<Self> {
let directory = PathBuf::from(dir);
std::fs::create_dir_all(&directory)?;
let db = DB::init(&directory.join("cache.db"), C::table_name())?;
Ok(Self {
config,
dir: directory,
limit,
base_url: base_url.to_string(),
db: Arc::new(db),
mu: Arc::new(Mutex::new(())),
_phantom: std::marker::PhantomData,
})
}
@@ -120,30 +120,6 @@ impl Cache {
self.add_from_url(url, collection).await
}
/// Ajoute des données au cache
///
/// Cette méthode doit être surchargée par les implémentations spécifiques
/// pour gérer la conversion et le stockage des données.
///
/// # Arguments
///
/// * `url` - URL source du fichier
/// * `data` - Données brutes à stocker
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
pub async fn add(&self, url: &str, data: &[u8], collection: Option<&str>) -> Result<String> {
let pk = pk_from_url(url);
let file_path = self.file_path(&pk);
let _lock = self.mu.lock().await;
if !file_path.exists() {
// Par défaut, on stocke les données telles quelles
tokio::fs::write(&file_path, data).await?;
}
self.db.add(&pk, url, collection)?;
Ok(pk)
}
/// Récupère le chemin d'un fichier dans le cache
///
@@ -151,8 +127,6 @@ impl Cache {
///
/// * `pk` - Clé primaire du fichier
pub async fn get(&self, pk: &str) -> Result<PathBuf> {
let _lock = self.mu.lock().await;
self.db.get(pk)?;
self.db.update_hit(pk)?;
@@ -170,8 +144,6 @@ impl Cache {
///
/// * `collection` - Identifiant de la collection
pub async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>> {
let _lock = self.mu.lock().await;
let entries = self.db.get_by_collection(collection)?;
let mut paths = Vec::new();
@@ -187,25 +159,22 @@ impl Cache {
/// Supprime tous les fichiers et entrées du cache
pub async fn purge(&self) -> Result<()> {
let _lock = self.mu.lock().await;
let mut entries = tokio::fs::read_dir(&self.config.dir).await?;
let mut entries = tokio::fs::read_dir(&self.dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.path().is_file() && entry.path() != self.config.dir.join("cache.db") {
if entry.path().is_file() && entry.path() != self.dir.join("cache.db") {
tokio::fs::remove_file(entry.path()).await?;
}
}
self.db.purge().map_err(|e| anyhow!("Database error: {}", e))
self.db
.purge()
.map_err(|e| anyhow!("Database error: {}", e))
}
/// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants
pub async fn consolidate(&self) -> Result<()> {
// Récupérer la liste des entrées à traiter
let entries = {
let _lock = self.mu.lock().await;
self.db.get_all()?
};
let entries = self.db.get_all()?;
// Supprimer les entrées sans fichiers correspondants
for entry in entries {
@@ -214,10 +183,10 @@ impl Cache {
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.add(&entry.source_url, &data, entry.collection.as_deref())
.await?;
}
_ => {
let _lock = self.mu.lock().await;
self.db.delete(&entry.pk)?;
}
}
@@ -225,15 +194,17 @@ impl Cache {
}
// Supprimer les fichiers sans entrées DB correspondantes
let _lock = self.mu.lock().await;
let mut dir_entries = tokio::fs::read_dir(&self.config.dir).await?;
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() && path != self.config.dir.join("cache.db") {
if path.is_file() && path != self.dir.join("cache.db") {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
let pk = file_name.trim_end_matches(&format!(".{}", self.config.file_extension));
if self.db.get(pk).is_err() {
tokio::fs::remove_file(path).await?;
// Format attendu: {pk}.{qualifier}.{EXT}
// On extrait le pk (première partie avant le premier point)
if let Some(pk) = file_name.split('.').next() {
if self.db.get(pk).is_err() {
tokio::fs::remove_file(path).await?;
}
}
}
}
@@ -243,22 +214,78 @@ impl Cache {
}
/// Retourne le répertoire du cache
pub fn cache_dir(&self) -> String {
self.config.dir.to_string_lossy().to_string()
pub fn cache_dir(&self) -> &Path {
&self.dir
}
/// Construit le chemin complet d'un fichier dans le cache
fn file_path(&self, pk: &str) -> PathBuf {
self.config.dir.join(format!("{}.{}", pk, self.config.file_extension))
/// Retourne l'URL de base
pub fn get_base_url(&self) -> &str {
&self.base_url
}
/// 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())
}
}
/// Génère une clé primaire à partir d'une URL
///
/// Utilise SHA1 pour hasher l'URL et retourne les 8 premiers octets en hexadécimal.
pub 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])
/// Implémentation du trait FileCache pour Cache
impl<C: CacheConfig> FileCache for Cache<C> {
fn cache_type(&self) -> &str {
C::cache_type()
}
fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
// Le cache générique accepte toutes les données
Ok(data.to_vec())
}
async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
self.add_from_url(url, collection).await
}
async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
self.ensure_from_url(url, collection).await
}
async fn add(&self, url: &str, data: &[u8], collection: Option<&str>) -> Result<String> {
// Valider les données avant de les ajouter
let validated_data = self.validate_data(data)?;
let pk = pk_from_url(url);
let file_path = self.file_path(&pk);
if !file_path.exists() {
tokio::fs::write(&file_path, &validated_data).await?;
}
self.db.add(&pk, url, collection)?;
Ok(pk)
}
async fn get(&self, pk: &str) -> Result<PathBuf> {
self.get(pk).await
}
async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>> {
self.get_collection(collection).await
}
async fn purge(&self) -> Result<()> {
self.purge().await
}
async fn consolidate(&self) -> Result<()> {
self.consolidate().await
}
fn get_cache_dir(&self) -> String {
self.cache_dir()
}
fn get_base_url(&self) -> &str {
self.get_base_url()
}
}

126
pmocache/src/cache_trait.rs Normal file
View File

@@ -0,0 +1,126 @@
use anyhow::Result;
use sha1::{Digest, Sha1};
use std::{path::{Path, PathBuf}, sync::Arc};
use crate::{CacheConfig, DB};
/// Trait générique pour les caches de fichiers
///
/// Définit l'interface commune pour tous les types de caches (images, audio, etc.)
pub trait FileCache<C: CacheConfig>: Send + Sync {
fn get_cache_dir(&self) -> &Path;
fn get_database(&self) -> Arc<DB>;
fn get_base_url(&self) -> &str;
/// Valide les données avant de les stocker dans le cache
///
/// Cette méthode peut être surchargée pour vérifier le type MIME,
/// le magic number, ou effectuer des conversions (ex: WebP, FLAC)
///
/// # Arguments
///
/// * `data` - Données brutes à valider
///
/// # Returns
///
/// Les données validées/converties ou une erreur
fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
// Par défaut, on accepte les données telles quelles
Ok(data.to_vec())
}
/// Retourne le type de cache
fn cache_type(&self) -> &'static str {
C::cache_type()
}
/// Retourne le type de cache
fn cache_name(&self) -> &'static str {
C::cache_name()
}
/// Retourne le type de cache
fn default_param(&self) -> &'static str {
C::default_param()
}
/// Retourne l'extension des fichiers
fn file_extension(&self) -> &'static str {
C::file_extension()
}
/// Retourne le nom de la table
fn table_name(&self) -> &'static str {
C::table_name()
}
/// Construit le chemin complet d'un fichier dans le cache
///
/// Format: `{pk}.{qualificatif}.{extension}`
/// Pour le fichier original: `{pk}.orig.{extension}`
fn file_path(&self, pk: &str) -> PathBuf {
self.file_path_with_qualifier(pk, self.default_param())
}
/// Construit le chemin d'un fichier avec un qualificatif
///
/// Format: `{pk}.{qualificatif}.{extension}`
fn file_path_with_qualifier(&self, pk: &str, qualifier: &str) -> PathBuf {
self.get_cache_dir().join(format!("{}.{}.{}", pk, qualifier, C::file_extension()))
}
/// Télécharge un fichier depuis une URL et l'ajoute au cache
///
/// # Arguments
///
/// * `url` - URL du fichier à télécharger
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
///
/// # Returns
///
/// La clé primaire (pk) du fichier dans le cache
async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
/// S'assure qu'un fichier est présent dans le cache
///
/// Si le fichier existe déjà, retourne sa clé. Sinon, le télécharge.
///
/// # Arguments
///
/// * `url` - URL du fichier
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
async fn ensure_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
/// Récupère le chemin d'un fichier dans le cache
///
/// # Arguments
///
/// * `pk` - Clé primaire du fichier
async fn get(&self, pk: &str) -> Result<PathBuf>;
/// Récupère tous les fichiers d'une collection
///
/// # Arguments
///
/// * `collection` - Identifiant de la collection
async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
/// Supprime tous les fichiers et entrées du cache
async fn purge(&self) -> Result<()>;
/// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants
async fn consolidate(&self) -> Result<()>;
}
/// Génère une clé primaire à partir d'une URL
///
/// Utilise SHA1 pour hasher l'URL et retourne les 8 premiers octets en hexadécimal.
pub 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])
}

View File

@@ -34,12 +34,19 @@
//! ### Exemple basique
//!
//! ```rust,no_run
//! use pmocache::cache::{Cache, CacheConfig};
//! use pmocache::{Cache, CacheConfig};
//!
//! // Définir la configuration du cache
//! struct MyConfig;
//! impl CacheConfig for MyConfig {
//! fn file_extension() -> &'static str { "dat" }
//! fn table_name() -> &'static str { "my_cache" }
//! fn cache_type() -> &'static str { "generic" }
//! }
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let config = CacheConfig::new("./cache", 1000, "my_cache", "dat");
//! let cache = Cache::new(config)?;
//! let cache = Cache::<MyConfig>::new("./cache", 1000, "http://localhost:8080")?;
//!
//! // Ajouter un fichier depuis une URL
//! let pk = cache.add_from_url("http://example.com/file.dat", None).await?;
@@ -56,12 +63,18 @@
//! ### Utilisation avec des collections
//!
//! ```rust,no_run
//! use pmocache::cache::{Cache, CacheConfig};
//! use pmocache::{Cache, CacheConfig};
//!
//! struct AudioConfig;
//! impl CacheConfig for AudioConfig {
//! fn file_extension() -> &'static str { "flac" }
//! fn table_name() -> &'static str { "audio" }
//! fn cache_type() -> &'static str { "audio" }
//! }
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let config = CacheConfig::new("./cache", 1000, "audio", "flac");
//! let cache = Cache::new(config)?;
//! let cache = Cache::<AudioConfig>::new("./cache", 1000, "http://localhost:8080")?;
//!
//! // Ajouter des pistes d'un album
//! let album_id = "album:the_wall";
@@ -111,6 +124,11 @@
pub mod db;
pub mod cache;
pub mod cache_trait;
#[cfg(feature = "pmoserver")]
pub mod pmoserver_ext;
pub use db::{DB, CacheEntry};
pub use cache::{Cache, CacheConfig, pk_from_url};
pub use cache_trait::FileCache;

View File

@@ -0,0 +1,142 @@
//! Extension pmoserver pour servir les fichiers du cache via HTTP
//!
//! Ce module fournit des handlers génériques pour servir les fichiers
//! d'un cache via des routes HTTP structurées.
//!
//! ## Routes générées
//!
//! Format: `/{name}/{type}/{pk}[/{param}]`
//!
//! Exemples:
//! - `/covers/images/abc123` - Image avec param par défaut (orig)
//! - `/covers/images/abc123/thumb` - Image avec param spécifique
//! - `/audio/tracks/def456/stream` - Piste audio
//!
//! ## Utilisation
//!
//! ```rust,no_run
//! use pmocache::pmoserver_ext;
//! use axum::Router;
//!
//! # async fn example(cache: std::sync::Arc<pmocache::Cache<CoversConfig>>) {
//! // Créer un router pour servir les fichiers
//! let router = pmoserver_ext::create_file_router(
//! cache.clone(),
//! "image/webp" // Content-Type
//! );
//!
//! // Le router peut être monté sur n'importe quel chemin
//! // Exemple: /covers/images -> GET /covers/images/{pk}
//! // -> GET /covers/images/{pk}/{param}
//! # }
//! ```
#[cfg(feature = "pmoserver")]
use crate::{Cache, CacheConfig};
#[cfg(feature = "pmoserver")]
use axum::{
body::Body,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Router,
};
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use tracing::warn;
/// Handler générique pour GET /{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)>,
Path(pk): Path<String>,
) -> Response {
match cache.get(&pk).await {
Ok(file_path) => match tokio::fs::read(&file_path).await {
Ok(data) => (
StatusCode::OK,
[("content-type", content_type)],
data,
)
.into_response(),
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
},
Err(e) => {
warn!("Error getting file {}: {}", pk, e);
(StatusCode::NOT_FOUND, "Item not found").into_response()
}
}
}
/// Handler générique pour GET /{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)>,
Path((pk, param)): Path<(String, String)>,
) -> Response {
let file_path = cache.file_path_with_qualifier(&pk, &param);
if !file_path.exists() {
warn!("File not found: {:?}", file_path);
return (StatusCode::NOT_FOUND, "File not found").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);
}
match tokio::fs::read(&file_path).await {
Ok(data) => (
StatusCode::OK,
[("content-type", content_type)],
data,
)
.into_response(),
Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
}
}
/// Crée un router pour servir les fichiers d'un cache
///
/// # Arguments
///
/// * `cache` - Instance du cache
/// * `content_type` - Type MIME des fichiers (ex: "image/webp", "audio/flac")
///
/// # Routes créées
///
/// - `GET /{pk}` - Fichier avec param par défaut
/// - `GET /{pk}/{param}` - Fichier avec param spécifique
///
/// # Exemple
///
/// ```rust,no_run
/// use pmocache::pmoserver_ext;
/// use axum::Router;
/// use pmoserver::Server;
///
/// # async fn example(server: &mut Server, cache: std::sync::Arc<pmocache::Cache<CoversConfig>>) {
/// let router = pmoserver_ext::create_file_router(
/// cache.clone(),
/// "image/webp"
/// );
///
/// // Monter le router sur /covers/images
/// server.add_router("/covers/images", router).await;
/// # }
/// ```
#[cfg(feature = "pmoserver")]
pub fn create_file_router<C: CacheConfig + 'static>(
cache: Arc<Cache<C>>,
content_type: &'static str,
) -> Router {
Router::new()
.route("/:pk", get(get_file::<C>))
.route("/:pk/:param", get(get_file_with_param::<C>))
.with_state((cache, content_type))
}