no local cache

This commit is contained in:
2025-12-17 10:10:56 +01:00
parent ca36a102e5
commit 1bf34fe949
21 changed files with 636 additions and 132 deletions

View File

@@ -24,6 +24,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
bytes = "1.6"
paste = "1.0"
pmoflac = { path = "../pmoflac" }
# Async
tokio = { version = "1.0", features = ["full"] }

View File

@@ -57,13 +57,20 @@ pub struct ConversionStatus {
pub details: Option<String>,
}
/// Requête pour ajouter un item au cache
/// Requête pour ajouter un item au cache.
///
/// Au moins une des deux entrées (`url` ou `path`) doit être fournie.
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct AddItemRequest {
/// URL de la source
/// URL HTTP/HTTPS/UPnP à télécharger
#[serde(default)]
#[cfg_attr(feature = "openapi", schema(example = "https://example.com/file.dat"))]
pub url: String,
pub url: Option<String>,
/// Chemin local (`file://` implicite) à référencer
#[serde(default)]
#[cfg_attr(feature = "openapi", schema(example = "/mnt/music/track.flac"))]
pub path: Option<String>,
/// Collection optionnelle
#[cfg_attr(feature = "openapi", schema(example = "album:the_wall"))]
pub collection: Option<String>,
@@ -76,7 +83,7 @@ pub struct AddItemResponse {
/// Clé primaire (pk) de l'item ajouté
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
pub pk: String,
/// URL source de l'item
/// URL ou chemin source de l'item
#[cfg_attr(feature = "openapi", schema(example = "https://example.com/file.dat"))]
pub url: String,
/// Message de succès
@@ -248,6 +255,12 @@ fn conversion_from_json(value: &Value) -> Option<ConversionStatus> {
.and_then(|conv| serde_json::from_value(conv.clone()).ok())
}
#[derive(Clone, Copy)]
enum AddSource<'a> {
Url(&'a str),
Local(&'a str),
}
/// Ajoute un item au cache depuis une URL
///
/// Télécharge l'item depuis l'URL fournie et l'ajoute au cache.
@@ -256,30 +269,60 @@ pub async fn add_item<C: CacheConfig>(
State(cache): State<Arc<Cache<C>>>,
Json(req): Json<AddItemRequest>,
) -> 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();
}
let mode = match (req.url.as_deref(), req.path.as_deref()) {
(Some(url), None) if !url.is_empty() => AddSource::Url(url),
(None, Some(path)) if !path.is_empty() => AddSource::Local(path),
(Some(_), Some(_)) => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "INVALID_REQUEST".to_string(),
message: "Provide either 'url' or 'path', not both".to_string(),
}),
)
.into_response()
}
_ => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "INVALID_REQUEST".to_string(),
message: "Either 'url' or 'path' must be provided".to_string(),
}),
)
.into_response()
}
};
match cache
.add_from_url(&req.url, req.collection.as_deref())
.await
{
Ok(pk) => (
StatusCode::CREATED,
Json(AddItemResponse {
pk,
url: req.url,
message: "Item added successfully".to_string(),
}),
)
.into_response(),
let collection = req.collection.as_deref();
let add_result = match mode {
AddSource::Url(url) => cache.add_from_url(url, collection).await,
AddSource::Local(path) => cache.add_from_file(path, collection).await,
};
match add_result {
Ok(pk) => {
let origin =
cache
.db
.get_origin_url(&pk)
.ok()
.flatten()
.unwrap_or_else(|| match mode {
AddSource::Url(url) => url.to_string(),
AddSource::Local(path) => format!("file://{}", path),
});
(
StatusCode::CREATED,
Json(AddItemResponse {
pk,
url: origin,
message: "Item added successfully".to_string(),
}),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {

View File

@@ -372,7 +372,7 @@ impl<C: CacheConfig> Cache<C> {
///
/// # Exemple
///
/// ```rust,no_run
/// ```rust,ignore
/// use pmocache::{Cache, CacheConfig, StreamTransformer};
/// use std::sync::Arc;
///
@@ -382,11 +382,10 @@ impl<C: CacheConfig> Cache<C> {
/// }
///
/// let transformer_factory = Arc::new(|| {
/// // Créer un transformer qui convertit les données
/// Box::new(|input, file, ctx| {
/// // Créer un transformer qui effectue une opération personnalisée
/// Box::new(|_input, _file, _ctx| {
/// Box::pin(async move {
/// // Transformation personnalisée
/// ctx.report_progress(0);
/// Ok(())
/// })
/// }) as StreamTransformer
@@ -660,8 +659,14 @@ impl<C: CacheConfig> Cache<C> {
}
// Finaliser avec prébuffering et nettoyage
self.finalize_download(&pk, download, collection, Some(url), FinalizeMode::InsertNew)
.await
self.finalize_download(
&pk,
download,
collection,
Some(url),
FinalizeMode::InsertNew,
)
.await
}
/// Télécharge un fichier lazy et commute l'entrée existante
@@ -827,38 +832,17 @@ impl<C: CacheConfig> Cache<C> {
}
// Finaliser avec prébuffering et nettoyage
self.finalize_download(&pk, download, collection, source_uri, FinalizeMode::InsertNew)
.await
self.finalize_download(
&pk,
download,
collection,
source_uri,
FinalizeMode::InsertNew,
)
.await
}
/// Ajoute un fichier local au cache
///
/// Cette méthode lit les 512 premiers octets du fichier local pour calculer
/// l'identifiant basé sur le contenu, puis utilise `add_from_reader()` pour
/// l'ingestion complète.
///
/// # Arguments
///
/// * `path` - Chemin du fichier local
/// * `collection` - Collection optionnelle à laquelle appartient le fichier
///
/// # Returns
///
/// La clé primaire (pk) du fichier dans le cache, calculée à partir du contenu
///
/// # Exemple
///
/// ```rust,ignore
/// use pmocache::{Cache, CacheConfig};
///
/// struct MyConfig;
/// impl CacheConfig for MyConfig {
/// fn file_extension() -> &'static str { "dat" }
/// }
///
/// let cache = Cache::<MyConfig>::new("./cache", 1000)?;
/// let pk = cache.add_from_file("/path/to/file.dat", None).await?;
/// ```
/// Ajoute un fichier local au cache en copiant son contenu.
pub async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String> {
let canonical_path = std::fs::canonicalize(path)?;
let file_url = format!("file://{}", canonical_path.display());
@@ -868,11 +852,60 @@ impl<C: CacheConfig> Cache<C> {
.map(|m| m.len());
let reader = tokio::fs::File::open(&canonical_path).await?;
// add_from_reader() s'occupe de lire les 512 premiers octets et de calculer le pk
self.add_from_reader(Some(&file_url), reader, length, collection)
.await
}
/// Enregistre une référence vers un fichier déjà présent sur le disque sans duplication.
pub async fn register_local_file_reference(
&self,
pk: &str,
source_path: &Path,
collection: Option<&str>,
origin_url: Option<&str>,
extra_metadata: Option<&[(String, Value)]>,
) -> Result<()> {
let cache_path = self.get_file_path(pk);
if cache_path.exists() {
if let Err(err) = tokio::fs::remove_file(&cache_path).await {
if err.kind() != std::io::ErrorKind::NotFound {
return Err(err.into());
}
}
}
link_file(source_path, &cache_path)
.map_err(|e| anyhow!("Failed to link local file into cache: {}", e))?;
let completion_marker = self.get_completion_marker_path(pk);
if completion_marker.exists() {
let _ = std::fs::remove_file(&completion_marker);
}
std::fs::write(&completion_marker, "")
.map_err(|e| anyhow!("Failed to create completion marker for local file: {}", e))?;
self.db.add(pk, None, collection)?;
if let Some(url) = origin_url {
self.db.set_origin_url(pk, url)?;
}
if let Some(entries) = extra_metadata {
for (key, value) in entries {
self.db.set_a_metadata(pk, key, value.clone())?;
}
}
if let Err(e) = self.enforce_limit().await {
tracing::warn!(
"Error enforcing cache limit after local file registration (pk={}): {}",
pk,
e
);
}
Ok(())
}
pub async fn delete_item(&self, pk: &str) -> Result<()> {
// Vérifie l'existence pour signaler une erreur explicite si l'entrée est absente
self.db.get(pk, false)?;
@@ -1363,6 +1396,14 @@ impl<C: CacheConfig> Cache<C> {
/// # Example
///
/// ```rust,no_run
/// use pmocache::{Cache, CacheConfig, CacheEvent};
///
/// struct AudioConfig;
/// impl CacheConfig for AudioConfig {
/// fn file_extension() -> &'static str { "flac" }
/// }
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let cache = Cache::<AudioConfig>::new("./cache", 1000)?;
/// let mut rx = cache.subscribe_events();
///
@@ -1376,6 +1417,8 @@ impl<C: CacheConfig> Cache<C> {
/// }
/// }
/// });
/// # Ok(())
/// # }
/// ```
pub fn subscribe_events(&self) -> broadcast::Receiver<CacheEvent> {
self.served_tx
@@ -1424,7 +1467,10 @@ impl<C: CacheConfig> Cache<C> {
if let Some(provider) = self.provider_for_lazy_pk(lazy_pk) {
let metadata = provider.metadata(lazy_pk).await?;
let cover_url = provider.cover_url(lazy_pk).await?;
Ok(LazyEntryRemoteData { metadata, cover_url })
Ok(LazyEntryRemoteData {
metadata,
cover_url,
})
} else {
Ok(LazyEntryRemoteData::default())
}
@@ -1486,12 +1532,32 @@ impl<C: CacheConfig> Cache<C> {
bail!("Lazy PK collision for URL: {}", url);
}
self.ensure_lazy_entry(&lazy_pk, collection, Some(url)).await?;
self.ensure_lazy_entry(&lazy_pk, collection, Some(url))
.await?;
tracing::debug!("Created new lazy pk {} for URL {}", lazy_pk, url);
Ok(lazy_pk)
}
}
fn link_file(source: &Path, destination: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(source, destination)
}
#[cfg(windows)]
{
use std::os::windows::fs::symlink_file;
symlink_file(source, destination)
}
#[cfg(not(any(unix, windows)))]
{
std::fs::hard_link(source, destination)
}
}
/// Implémentation du trait FileCache pour Cache
impl<C: CacheConfig> FileCache<C> for Cache<C> {
fn get_cache_dir(&self) -> &Path {

View File

@@ -107,7 +107,7 @@ impl DB {
/// use pmocache::db::DB;
/// use std::path::Path;
///
/// let db = DB::init(Path::new("cache.db"), "my_cache").unwrap();
/// let db = DB::init(Path::new("cache.db")).unwrap();
/// ```
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
@@ -889,15 +889,7 @@ impl DB {
hits = hits + ?5,
last_used = ?6
WHERE lazy_pk = ?7",
params![
real_pk,
lazy_pk,
collection,
id,
hits_to_add,
now,
lazy_pk
],
params![real_pk, lazy_pk, collection, id, hits_to_add, now, lazy_pk],
)?;
if updated == 0 {

View File

@@ -5,6 +5,15 @@
//! conservées dans une base SQLite, ainsi que les opérations de téléchargement,
//! d'éviction et de mise à jour.
//!
//! Les fonctionnalités clés incluent:
//! - **lazy caching** : génération de clés «lazy» (préfixées `L:`) permettant de publier des
//! URLs stables avant le téléchargement. Le cache résout automatiquement un lazy PK lors du
//! premier accès, commute l'entrée vers le PK réel et notifie les abonnés ;
//! - **local caching** : possibilité denregistrer un fichier déjà présent sur le disque via
//! [`Cache::register_local_file_reference`] sans duplication physique. Cette méthode crée un
//! lien (symlink ou hard link suivant la plateforme), marque lélément comme complet et laisse
//! la gestion métier (audio, images…) décider des métadonnées à persister.
//!
//! ## Vue d'ensemble
//!
//! `pmocache` met à disposition :
@@ -135,12 +144,12 @@ pub use cache::{
CacheSubscription,
};
pub use cache_trait::{pk_from_content_header, FileCache};
pub use lazy::{lazy_prefix_from_pk, LazyEntryRemoteData, LazyProvider};
pub use db::{CacheEntry, DB};
pub use download::{
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,
Download, StreamTransformer, TransformContextHandle, TransformMetadata,
};
pub use lazy::{lazy_prefix_from_pk, LazyEntryRemoteData, LazyProvider};
#[cfg(feature = "pmoserver")]
pub use pmoserver_ext::{create_api_router, create_file_router, GenericCacheExt};

View File

@@ -1,5 +1,4 @@
use pmocache::{Cache, CacheConfig};
use std::io::Write;
use tempfile::TempDir;
/// Configuration de test simple
@@ -307,7 +306,7 @@ async fn test_touch() {
#[tokio::test]
async fn test_consolidate() {
let (temp_dir, cache) = create_test_cache(10);
let (_temp_dir, cache) = create_test_cache(10);
// Ajouter un fichier
let test_data = b"Test data";