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

@@ -1,5 +1,6 @@
//! API REST handlers spécifiques au cache audio
use crate::cache;
use crate::metadata_ext::AudioTrackMetadataExt;
use crate::Cache;
use axum::{
@@ -8,7 +9,7 @@ use axum::{
response::IntoResponse,
Json,
};
use pmometadata::TrackMetadata;
use pmocache::api::{AddItemRequest, AddItemResponse, ErrorResponse};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -96,3 +97,79 @@ pub async fn get_cover_url(
)
.into_response()
}
#[derive(Clone, Copy)]
enum AddSource<'a> {
Url(&'a str),
Local(&'a str),
}
/// Handler spécialisé pour l'ajout d'éléments dans le cache audio.
pub async fn add_audio_item(
State(cache): State<Arc<Cache>>,
Json(req): Json<AddItemRequest>,
) -> impl IntoResponse {
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()
}
};
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_local_file(&cache, 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 {
error: "PROCESSING_ERROR".to_string(),
message: format!("Cannot add item: {}", e),
}),
)
.into_response(),
}
}

View File

@@ -5,10 +5,13 @@
//! des métadonnées en JSON dans la base de données.
use crate::metadata_ext::AudioTrackMetadataExt;
use anyhow::Result;
use pmocache::download::TransformMetadata;
use pmocache::CacheConfig;
use anyhow::{anyhow, Result};
use pmocache::download::{read_exact_or_eof, TransformMetadata};
use pmocache::{pk_from_content_header, CacheConfig};
use pmoflac::is_flac_magic_header;
use serde_json::json;
use std::sync::Arc;
use tokio::io::AsyncSeekExt;
/// Configuration pour le cache audio
pub struct AudioConfig;
@@ -122,6 +125,76 @@ pub async fn new_cache_with_consolidation(dir: &str, limit: usize) -> Result<Arc
Ok(cache)
}
/// Ajoute un fichier audio local. Les FLAC sont référencés sans copie, les autres formats
/// sont convertis via le pipeline classique.
pub async fn add_local_file(cache: &Cache, path: &str, collection: Option<&str>) -> Result<String> {
let canonical_path = std::fs::canonicalize(path)?;
let file_url = format!("file://{}", canonical_path.display());
let length = tokio::fs::metadata(&canonical_path)
.await
.ok()
.map(|m| m.len());
let mut reader = tokio::fs::File::open(&canonical_path).await?;
let header = read_exact_or_eof(&mut reader, 1024)
.await
.map_err(|e| anyhow!("Failed to read header bytes: {}", e))?;
let pk_bytes = if header.len() >= 1024 {
&header[512..]
} else {
&header[..]
};
let pk = pk_from_content_header(pk_bytes);
if cache.db.get(&pk, false).is_ok() {
cache.db.update_hit(&pk)?;
return Ok(pk);
}
if let Some(download) = cache.get_download(&pk).await {
if download.finished().await {
cache.db.update_hit(&pk)?;
}
return Ok(pk);
}
let is_flac = is_flac_magic_header(&header);
if !is_flac {
reader
.rewind()
.await
.map_err(|e| anyhow!("Failed to rewind local file: {}", e))?;
return cache
.add_from_reader_with_pk(Some(&file_url), reader, length, collection, Some(pk))
.await;
}
let mut metadata = vec![
("local_passthrough".to_string(), json!(true)),
(
"local_source_path".to_string(),
json!(canonical_path.to_string_lossy().to_string()),
),
];
if let Some(len) = length {
metadata.push(("source_size".to_string(), json!(len)));
}
cache
.register_local_file_reference(
&pk,
&canonical_path,
collection,
Some(&file_url),
Some(&metadata),
)
.await?;
Ok(pk)
}
/// Ajoute une piste audio depuis une URL avec extraction et stockage des métadonnées
///
/// Cette fonction étend `add_from_url` du cache en ajoutant :

View File

@@ -11,6 +11,11 @@
//! - stockage des métadonnées dans la table `metadata` de `pmocache::DB` ;
//! - helpers pour renseigner les collections à partir des tags ;
//! - intégration optionnelle avec `pmoserver` (routes REST + diffusion de fichiers).
//! - référence de fichiers FLAC locaux: [`cache::add_local_file`] détecte les fichiers déjà au
//! bon format et enregistre une entrée du cache sans recopier les octets tout en laissant les
//! autres formats passer par la conversion standard.
//! - support complet des lazy PK hérités de [`pmocache`], permettant de publier des playlists
//! avec des entrées différées et de déclencher le téléchargement lors de la première lecture.
//!
//! ## Exemple rapide
//!
@@ -198,7 +203,7 @@ pub trait AudioCacheExt {
}
#[cfg(feature = "pmoserver")]
use pmocache::pmoserver_ext::{create_api_router, create_file_router};
use pmocache::pmoserver_ext::create_file_router;
#[cfg(feature = "pmoserver")]
use utoipa::OpenApi;
@@ -219,9 +224,28 @@ impl AudioCacheExt for pmoserver::Server {
);
self.add_router("/", file_router).await;
// API REST générique (pmocache)
// Routes: GET/POST/DELETE /api/audio, etc.
let mut api_router = create_api_router(cache.clone());
// API REST (handlers génériques + POST spécialisé audio)
let mut api_router = axum::Router::new()
.route(
"/",
axum::routing::get(pmocache::api::list_items::<AudioConfig>)
.post(crate::api::add_audio_item)
.delete(pmocache::api::purge_cache::<AudioConfig>),
)
.route(
"/{pk}",
axum::routing::get(pmocache::api::get_item_info::<AudioConfig>)
.delete(pmocache::api::delete_item::<AudioConfig>),
)
.route(
"/{pk}/status",
axum::routing::get(pmocache::api::get_download_status::<AudioConfig>),
)
.route(
"/consolidate",
axum::routing::post(pmocache::api::consolidate_cache::<AudioConfig>),
)
.with_state(cache.clone());
// Ajouter les endpoints audio spécifiques
// Route: GET /api/audio/{pk}/cover-url

View File

@@ -94,3 +94,47 @@ async fn test_cache_limit() {
let count = cache.db.count().unwrap();
assert_eq!(count, 2);
}
#[cfg(unix)]
#[tokio::test]
async fn test_local_flac_passthrough_symlink() {
let (_temp_dir, cache) = create_test_cache();
let flac_file = tempfile::NamedTempFile::with_suffix(".flac").unwrap();
let mut data = vec![0u8; 2048];
data[..4].copy_from_slice(b"fLaC");
for (idx, byte) in data.iter_mut().enumerate().skip(4) {
*byte = (idx % 251) as u8;
}
std::fs::write(flac_file.path(), &data).unwrap();
let pk = cache::add_local_file(
&cache,
flac_file.path().to_str().unwrap(),
Some("album:test"),
)
.await
.unwrap();
let cached_path = cache.get(&pk).await.unwrap();
let metadata = std::fs::symlink_metadata(&cached_path).unwrap();
assert!(metadata.file_type().is_symlink());
let canonical_source = std::fs::canonicalize(flac_file.path()).unwrap();
let link_target = std::fs::read_link(&cached_path).unwrap();
assert_eq!(link_target, canonical_source);
let stored_metadata = cache.db.get_metadata(&pk).unwrap().unwrap();
assert_eq!(
stored_metadata
.get("local_passthrough")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
stored_metadata
.get("local_source_path")
.and_then(|v| v.as_str()),
Some(canonical_source.to_str().unwrap())
);
}