amélioration de la webapp

This commit is contained in:
2025-10-19 13:42:29 +02:00
parent bdfba87de4
commit 82fe72d659
179 changed files with 2426 additions and 1685 deletions

View File

@@ -92,9 +92,7 @@ pub struct ErrorResponse {
/// Liste tous les items en cache avec leurs statistiques
///
/// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant.
pub async fn list_items<C: CacheConfig>(
State(cache): State<Arc<Cache<C>>>,
) -> impl IntoResponse {
pub async fn list_items<C: CacheConfig>(State(cache): State<Arc<Cache<C>>>) -> impl IntoResponse {
match cache.db.get_all() {
Ok(entries) => (StatusCode::OK, Json(entries)).into_response(),
Err(e) => (
@@ -192,7 +190,10 @@ pub async fn add_item<C: CacheConfig>(
.into_response();
}
match cache.add_from_url(&req.url, req.collection.as_deref()).await {
match cache
.add_from_url(&req.url, req.collection.as_deref())
.await
{
Ok(pk) => (
StatusCode::CREATED,
Json(AddItemResponse {
@@ -268,9 +269,7 @@ pub async fn delete_item<C: CacheConfig>(
/// Purge complètement le cache
///
/// Supprime tous les items et vide la base de données. Opération irréversible.
pub async fn purge_cache<C: CacheConfig>(
State(cache): State<Arc<Cache<C>>>,
) -> impl IntoResponse {
pub async fn purge_cache<C: CacheConfig>(State(cache): State<Arc<Cache<C>>>) -> impl IntoResponse {
match cache.purge().await {
Ok(_) => (
StatusCode::OK,

View File

@@ -3,9 +3,9 @@
//! Ce module fournit une interface générique pour gérer un cache de fichiers
//! avec métadonnées dans une base de données SQLite.
use crate::cache_trait::{FileCache, pk_from_url};
use crate::cache_trait::{pk_from_url, FileCache};
use crate::db::DB;
use crate::download::{Download, download_with_transformer, StreamTransformer};
use crate::download::{download_with_transformer, Download, StreamTransformer};
use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -26,10 +26,10 @@ pub trait CacheConfig: Send + Sync {
"file"
}
/// Cache name (ex: "covers", "audio", "cache")
fn cache_name() -> &'static str {
fn cache_name() -> &'static str {
"cache"
}
/// Default param extension ("orig")
/// Default param extension ("orig")
fn default_param() -> &'static str {
"orig"
}
@@ -155,11 +155,7 @@ 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,
transformer,
);
let download = download_with_transformer(&file_path, url, transformer);
// Stocker dans la map des downloads en cours
{
@@ -228,7 +224,6 @@ impl<C: CacheConfig> Cache<C> {
self.add_from_url(url, collection).await
}
/// Récupère le chemin d'un fichier dans le cache
///
/// # Arguments
@@ -289,8 +284,11 @@ impl<C: CacheConfig> Cache<C> {
let file_path = self.file_path(&entry.pk);
if !file_path.exists() {
// Re-télécharger le fichier manquant
match self.add_from_url(&entry.source_url, entry.collection.as_deref()).await {
Ok(_) => {},
match self
.add_from_url(&entry.source_url, entry.collection.as_deref())
.await
{
Ok(_) => {}
Err(_) => {
// Si le téléchargement échoue, supprimer l'entrée DB
self.db.delete(&entry.pk)?;
@@ -413,7 +411,9 @@ impl<C: CacheConfig> Cache<C> {
/// * `min_size` - Taille minimale attendue en bytes
pub async fn wait_until_min_size(&self, pk: &str, min_size: u64) -> Result<()> {
if let Some(download) = self.get_download(pk).await {
download.wait_until_min_size(min_size).await
download
.wait_until_min_size(min_size)
.await
.map_err(|e| anyhow!("Download error: {}", e))
} else {
// Déjà terminé ou n'existe pas
@@ -432,7 +432,9 @@ impl<C: CacheConfig> Cache<C> {
/// * `pk` - Clé primaire du fichier
pub async fn wait_until_finished(&self, pk: &str) -> Result<()> {
if let Some(download) = self.get_download(pk).await {
download.wait_until_finished().await
download
.wait_until_finished()
.await
.map_err(|e| anyhow!("Download error: {}", e))
} else {
// Déjà terminé ou n'existe pas
@@ -460,7 +462,8 @@ impl<C: CacheConfig> Cache<C> {
///
/// 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()))
self.dir
.join(format!("{}.{}.{}", pk, qualifier, C::file_extension()))
}
/// Valide les données avant de les stocker
@@ -499,7 +502,9 @@ impl<C: CacheConfig> Cache<C> {
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)) {
if filename.starts_with(&entry.pk)
&& filename.starts_with(&format!("{}.", entry.pk))
{
let _ = tokio::fs::remove_file(dir_entry.path()).await;
}
}
@@ -515,15 +520,18 @@ impl<C: CacheConfig> Cache<C> {
}
if removed > 0 {
tracing::info!("LRU eviction: removed {} old entries (cache size: {} -> {})",
removed, count, count - removed);
tracing::info!(
"LRU eviction: removed {} old entries (cache size: {} -> {})",
removed,
count,
count - removed
);
}
Ok(removed)
}
}
/// Implémentation du trait FileCache pour Cache
impl<C: CacheConfig> FileCache<C> for Cache<C> {
fn get_cache_dir(&self) -> &Path {

View File

@@ -3,9 +3,9 @@
//! Ce module fournit une interface générique pour gérer les métadonnées
//! des éléments en cache, avec tracking des accès et des statistiques.
use rusqlite::{Connection, params};
use serde::Serialize;
use chrono::Utc;
use rusqlite::{params, Connection};
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
@@ -32,7 +32,10 @@ pub struct CacheEntry {
#[cfg_attr(feature = "openapi", schema(example = "2025-01-15T10:30:00Z"))]
pub last_used: Option<String>,
/// Métadonnées JSON optionnelles (ex: métadonnées audio, EXIF images, etc.)
#[cfg_attr(feature = "openapi", schema(example = r#"{"title":"Track","artist":"Artist"}"#))]
#[cfg_attr(
feature = "openapi",
schema(example = r#"{"title":"Track","artist":"Artist"}"#)
)]
pub metadata_json: Option<String>,
}
@@ -161,20 +164,16 @@ impl DB {
self.table_name
);
conn.query_row(
&sql,
[pk],
|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)?,
metadata_json: row.get(5)?,
})
},
)
conn.query_row(&sql, [pk], |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)?,
metadata_json: row.get(5)?,
})
})
}
/// Met à jour le compteur d'accès et la date du dernier accès
@@ -189,10 +188,7 @@ impl DB {
self.table_name
);
conn.execute(
&sql,
params![Utc::now().to_rfc3339(), pk],
)?;
conn.execute(&sql, params![Utc::now().to_rfc3339(), pk])?;
Ok(())
}
@@ -215,17 +211,18 @@ impl DB {
let mut stmt = conn.prepare(&sql)?;
let entries = stmt.query_map([], |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)?,
metadata_json: row.get(5)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let entries = stmt
.query_map([], |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)?,
metadata_json: row.get(5)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
@@ -244,17 +241,18 @@ impl DB {
let mut stmt = conn.prepare(&sql)?;
let entries = stmt.query_map([collection], |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)?,
metadata_json: row.get(5)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let entries = stmt
.query_map([collection], |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)?,
metadata_json: row.get(5)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}
@@ -319,17 +317,18 @@ impl DB {
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)?,
metadata_json: row.get(5)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
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)?,
metadata_json: row.get(5)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(entries)
}

View File

@@ -1,3 +1,4 @@
use futures_util::Future;
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
@@ -5,7 +6,6 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use futures_util::Future;
/// Type pour une fonction de transformation de stream
///
@@ -248,20 +248,16 @@ async fn download_impl(
.map_err(|e| e.to_string())?;
// Lancer la requête
let response = client
.get(&url)
.send()
.await
.map_err(|e| {
let error = format!("Failed to fetch URL: {}", e);
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let mut s = state.write().await;
s.error = Some(error.clone());
});
let response = client.get(&url).send().await.map_err(|e| {
let error = format!("Failed to fetch URL: {}", e);
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let mut s = state.write().await;
s.error = Some(error.clone());
});
error
})?;
});
error
})?;
// Vérifier le statut
if !response.status().is_success() {
@@ -279,31 +275,30 @@ async fn download_impl(
}
// Créer le fichier de destination
let file = tokio::fs::File::create(&filename)
.await
.map_err(|e| {
let error = format!("Failed to create file: {}", e);
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let mut s = state.write().await;
s.error = Some(error.clone());
s.finished = true;
});
let file = tokio::fs::File::create(&filename).await.map_err(|e| {
let error = format!("Failed to create file: {}", e);
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let mut s = state.write().await;
s.error = Some(error.clone());
s.finished = true;
});
error
})?;
});
error
})?;
// Si un transformer est fourni, l'utiliser
if let Some(transformer) = transformer {
// Créer un callback pour mettre à jour la progression
let state_clone = Arc::clone(&state);
let progress_callback: Arc<dyn Fn(u64) + Send + Sync> = Arc::new(move |transformed_bytes| {
let state = Arc::clone(&state_clone);
tokio::spawn(async move {
let mut s = state.write().await;
s.transformed_size = transformed_bytes;
let progress_callback: Arc<dyn Fn(u64) + Send + Sync> =
Arc::new(move |transformed_bytes| {
let state = Arc::clone(&state_clone);
tokio::spawn(async move {
let mut s = state.write().await;
s.transformed_size = transformed_bytes;
});
});
});
// Appeler le transformer
match transformer(response, file, progress_callback).await {
@@ -331,8 +326,8 @@ async fn default_download(
mut file: tokio::fs::File,
state: Arc<RwLock<DownloadState>>,
) -> Result<(), String> {
use tokio::io::AsyncWriteExt;
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
let mut stream = response.bytes_stream();

View File

@@ -122,9 +122,9 @@
//! - [`pmocovers`] : Cache d'images avec conversion WebP
//! - [`pmoaudiocache`] : Cache de pistes audio
pub mod db;
pub mod cache;
pub mod cache_trait;
pub mod db;
pub mod download;
#[cfg(feature = "pmoserver")]
@@ -136,16 +136,13 @@ pub mod api;
#[cfg(feature = "openapi")]
pub mod openapi;
pub use db::{DB, CacheEntry};
pub use cache::{Cache, CacheConfig};
pub use cache_trait::{FileCache, pk_from_url};
pub use download::{Download, download, download_with_transformer, StreamTransformer};
pub use cache_trait::{pk_from_url, FileCache};
pub use db::{CacheEntry, DB};
pub use download::{download, download_with_transformer, Download, StreamTransformer};
#[cfg(feature = "pmoserver")]
pub use pmoserver_ext::{create_file_router, create_api_router, GenericCacheExt};
pub use pmoserver_ext::{create_api_router, create_file_router, GenericCacheExt};
#[cfg(all(feature = "pmoserver", feature = "openapi"))]
pub use api::{
DownloadStatus, AddItemRequest, AddItemResponse,
DeleteItemResponse, ErrorResponse,
};
pub use api::{AddItemRequest, AddItemResponse, DeleteItemResponse, DownloadStatus, ErrorResponse};

View File

@@ -49,15 +49,15 @@ use axum::{
Router,
};
#[cfg(feature = "pmoserver")]
use std::future::Future;
#[cfg(feature = "pmoserver")]
use std::pin::Pin;
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
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
///
@@ -75,16 +75,20 @@ use std::future::Future;
/// 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
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, param_generator)): State<(Arc<Cache<C>>, &'static str, Option<ParamGenerator<C>>)>,
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
@@ -96,7 +100,11 @@ async fn get_file<C: CacheConfig + 'static>(
/// Sert un fichier avec un param spécifique
#[cfg(feature = "pmoserver")]
async fn get_file_with_param<C: CacheConfig + 'static>(
State((cache, content_type, param_generator)): State<(Arc<Cache<C>>, &'static str, Option<ParamGenerator<C>>)>,
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, param_generator).await
@@ -122,11 +130,7 @@ async fn serve_file_with_streaming<C: CacheConfig>(
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();
return (StatusCode::OK, [("content-type", content_type)], data).into_response();
}
}
}
@@ -207,12 +211,7 @@ async fn serve_complete_file(
}
match tokio::fs::read(&file_path).await {
Ok(data) => (
StatusCode::OK,
[("content-type", content_type)],
data,
)
.into_response(),
Ok(data) => (StatusCode::OK, [("content-type", content_type)], data).into_response(),
Err(e) => {
warn!("Error reading file {:?}: {}", file_path, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response()
@@ -332,9 +331,7 @@ pub fn create_file_router_with_generator<C: CacheConfig + 'static>(
/// - `DELETE /{pk}` - Supprimer un item
/// - `POST /consolidate` - Consolider le cache
#[cfg(feature = "pmoserver")]
pub fn create_api_router<C: CacheConfig + 'static>(
cache: Arc<Cache<C>>,
) -> Router {
pub fn create_api_router<C: CacheConfig + 'static>(cache: Arc<Cache<C>>) -> Router {
use crate::api;
Router::new()
@@ -346,8 +343,7 @@ pub fn create_api_router<C: CacheConfig + 'static>(
)
.route(
"/{pk}",
get(api::get_item_info::<C>)
.delete(api::delete_item::<C>),
get(api::get_item_info::<C>).delete(api::delete_item::<C>),
)
.route("/{pk}/status", get(api::get_download_status::<C>))
.route("/consolidate", post(api::consolidate_cache::<C>))