From 1bf34fe9492890bad824185718580166e5c552b8 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Wed, 17 Dec 2025 10:10:56 +0100 Subject: [PATCH] no local cache --- .DS_Store | Bin 18436 -> 18436 bytes Cargo.lock | 1 + .../src/components/AudioCacheManager.vue | 193 ++++++++++++++++-- pmoapp/webapp/src/services/audioCache.ts | 16 +- pmoaudiocache/src/api.rs | 79 ++++++- pmoaudiocache/src/cache.rs | 79 ++++++- pmoaudiocache/src/lib.rs | 32 ++- pmoaudiocache/tests/test_cache.rs | 44 ++++ pmocache/Cargo.toml | 1 + pmocache/src/api.rs | 97 ++++++--- pmocache/src/cache.rs | 144 +++++++++---- pmocache/src/db.rs | 12 +- pmocache/src/lib.rs | 11 +- pmocache/tests/test_cache.rs | 3 +- pmoflac/src/autodetect.rs | 7 +- pmoflac/src/lib.rs | 4 +- pmoplaylist/src/handle/read.rs | 8 +- pmoplaylist/src/manager.rs | 13 +- pmoplaylist/src/persistence/mod.rs | 6 +- pmoplaylist/src/playlist/record.rs | 7 +- pmoqobuz/src/lazy_provider.rs | 11 +- 21 files changed, 636 insertions(+), 132 deletions(-) diff --git a/.DS_Store b/.DS_Store index 941815d89bb3fe7d326477d0cdbab7be5591e11c..563111b848472da02223eb997491b0132953068d 100644 GIT binary patch delta 734 zcma)4OG^S#7(L@$8>tB`B9$VnFfnN`A2`;gXw|BsT@>LXBeG^P#cbooodm*NRKFtP zuKEcrTLdlIwrv@-sXnIywFlnI<-X4Mo%20v4XM_U8Y|M}_3ER{&$U}I&n|3yT$$o^ zWlaSj(W2q#69bN6CZ^OUS&9qQWv~GkOdP<#5v?>zIOUtkv1wcZ?Csqemgx@|wh=}I z3GPdd_`5;sgmee%UHVNn(e~FST9`vOjK6wRuZb*j%XuS{&KqfxYnDDMwjKy6JUAMZ zrn!rSgfslm+wc5pv@F?W`9{*$gGH-4k8@$h?|egiE245uTO~ukP!?X8xQ{9F2Y%bT AS^xk5 delta 156 zcmZpfz}PZ@ae_Z%-^PGD%#$??_!(Iz2iS;i=4NeT+Wgzdh>@Ftfsp|WIJh?pa)_~P zR^xff%&5PaMXnmlEIR1cUWlldG-yH^*3uu}ogb5;C!5`DS*9Z!D89va4

➕ Add New Track

+
+ + +
+
+ + +
+

+ Local FLAC files are referenced without duplication. Removing the cache entry never deletes + the original file. +

- -

{{ addError }}

@@ -73,7 +98,7 @@
@@ -94,6 +119,7 @@ > {{ lazyBadgeLabel(track) }} + Local file
@@ -133,6 +159,12 @@ {{ conversionLabel(track) }}
+
+ Local + + {{ localSourcePath(track) || "Original file" }} + +
{{ track.collection }}
@@ -225,6 +257,12 @@

Cache Info

PK: {{ selectedTrack.pk }}

Status: {{ trackStatusLabel(selectedTrack) }}

+

+ Local file: + + {{ localSourcePath(selectedTrack) || "Original file retained" }} + +

Source URL: {{ resolveTrackOrigin(selectedTrack) }} @@ -323,7 +361,9 @@ const LEGACY_LAZY_PREFIX = "L:"; // Formulaire d'ajout const newTrackUrl = ref(""); +const newTrackPath = ref(""); const newTrackCollection = ref(""); +const newTrackSourceType = ref<"url" | "path">("url"); const isAdding = ref(false); const addError = ref(""); const addSuccess = ref(""); @@ -364,6 +404,22 @@ const sortedTracks = computed(() => { } }); +const addButtonDisabled = computed(() => { + if (isAdding.value) return true; + const value = + newTrackSourceType.value === "url" + ? newTrackUrl.value?.trim() + : newTrackPath.value?.trim(); + return !value; +}); + +const addButtonLabel = computed(() => { + if (newTrackSourceType.value === "url") { + return isAdding.value ? "Adding..." : "Add Track"; + } + return isAdding.value ? "Linking..." : "Add Local File"; +}); + // --- Fonctions --- async function refreshTracks() { isLoading.value = true; @@ -375,17 +431,27 @@ async function refreshTracks() { } async function handleAddTrack() { - if (!newTrackUrl.value) return; + const useUrl = newTrackSourceType.value === "url"; + const rawValue = useUrl ? newTrackUrl.value.trim() : newTrackPath.value.trim(); + if (!rawValue) { + addError.value = useUrl ? "URL is required" : "Local path is required"; + return; + } isAdding.value = true; addError.value = ""; addSuccess.value = ""; try { - const result = await addTrack( - newTrackUrl.value, - newTrackCollection.value || undefined - ); - addSuccess.value = `Track added! PK: ${result.pk}`; + const result = await addTrack({ + url: useUrl ? rawValue : undefined, + path: useUrl ? undefined : rawValue, + collection: newTrackCollection.value || undefined, + }); + addSuccess.value = + newTrackSourceType.value === "path" + ? `Local file linked! PK: ${result.pk}` + : `Track added! PK: ${result.pk}`; newTrackUrl.value = ""; + newTrackPath.value = ""; newTrackCollection.value = ""; await refreshTracks(); } catch (e: any) { @@ -397,7 +463,16 @@ async function handleAddTrack() { } async function handleDeleteTrack(pk: string) { - if (!confirm(`Delete track ${pk}?`)) return; + const track = getTrackByPk(pk); + let confirmMessage = `Delete track ${pk}?`; + if (track && isLocalFile(track)) { + const path = localSourcePath(track); + confirmMessage = + `Remove cached reference for local file?\nPK: ${pk}` + + (path ? `\nSource: ${path}` : "") + + "\nOriginal file will remain untouched."; + } + if (!confirm(confirmMessage)) return; deletingTracks.value.add(pk); try { await deleteTrack(pk); @@ -507,7 +582,7 @@ function copyTrackUrl(pk: string) { alert("✅ URL copied!"); } -function resolveTrackOrigin(track: AudioCacheEntry | null): string | undefined { +function resolveTrackOrigin(track: AudioCacheEntry | null | undefined): string | undefined { return track ? getOriginUrl(track) : undefined; } @@ -628,6 +703,9 @@ function lazyBadgeLabel(track: AudioCacheEntry | null | undefined): string { } function trackStatusLabel(track: AudioCacheEntry | null): string { + if (isLocalFile(track)) { + return "Local FLAC reference (original preserved)"; + } const info = getLazyInfo(track); if (info) { const provider = info.isLegacy ? "" : ` - ${info.display}`; @@ -640,6 +718,23 @@ function getTrackByPk(pk: string): AudioCacheEntry | undefined { return tracks.value.find((t) => t.pk === pk); } +function isLocalFile(track: AudioCacheEntry | null | undefined): boolean { + return track?.metadata?.local_passthrough === true; +} + +function localSourcePath(track: AudioCacheEntry | null | undefined): string | undefined { + if (!track) return undefined; + const metaValue = track.metadata?.local_source_path; + if (typeof metaValue === "string" && metaValue.trim().length > 0) { + return metaValue; + } + const origin = resolveTrackOrigin(track); + if (origin?.startsWith("file://")) { + return origin.replace("file://", ""); + } + return undefined; +} + onMounted(() => { refreshTracks(); }); @@ -702,6 +797,25 @@ onMounted(() => { color: #61dafb; } +.source-toggle { + display: flex; + gap: 1rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; + color: #ccc; + font-size: 0.95rem; +} + +.source-toggle input { + margin-right: 0.3rem; +} + +.local-tip { + margin: 0.25rem 0 0.75rem; + font-size: 0.85rem; + color: #bbb; +} + .form-group { display: flex; gap: 0.5rem; @@ -870,6 +984,11 @@ button:disabled { flex-direction: column; } +.track-card.local-reference { + border: 1px solid rgba(97, 218, 251, 0.6); + box-shadow: 0 0 0 1px rgba(97, 218, 251, 0.1); +} + .track-card:hover { transform: translateY(-4px); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); @@ -926,6 +1045,17 @@ button:disabled { font-size: 0.8rem; } +.local-pill { + display: inline-block; + margin-left: 0.5rem; + padding: 0.15rem 0.5rem; + border-radius: 999px; + background: rgba(97, 218, 251, 0.9); + color: #0c1924; + font-size: 0.75rem; + font-weight: 600; +} + .track-info { padding: 1rem; flex: 1; @@ -1000,6 +1130,33 @@ button:disabled { flex-wrap: wrap; } +.local-path { + margin: 0.4rem 0; + font-size: 0.8rem; + color: #a0f0ff; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: baseline; +} + +.local-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.8px; + background: rgba(97, 218, 251, 0.2); + border: 1px solid rgba(97, 218, 251, 0.4); + border-radius: 999px; + padding: 0.1rem 0.5rem; + color: #61dafb; +} + +.local-path-text { + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + color: #e3f7ff; + word-break: break-all; +} + .collection { color: #888; font-size: 0.85rem; diff --git a/pmoapp/webapp/src/services/audioCache.ts b/pmoapp/webapp/src/services/audioCache.ts index 997dc3bb..d75b4169 100644 --- a/pmoapp/webapp/src/services/audioCache.ts +++ b/pmoapp/webapp/src/services/audioCache.ts @@ -4,6 +4,9 @@ export interface AudioCacheMetadata { origin_url?: string; + local_passthrough?: boolean; + local_source_path?: string; + source_size?: number; title?: string; artist?: string; album?: string; @@ -40,7 +43,8 @@ export interface ConversionInfo { } export interface AddTrackRequest { - url: string; + url?: string; + path?: string; collection?: string; } @@ -127,10 +131,12 @@ export async function getDownloadStatus(pk: string): Promise { /** * Ajoute une nouvelle piste au cache depuis une URL */ -export async function addTrack(url: string, collection?: string): Promise { - const body: AddTrackRequest = { url }; - if (collection) { - body.collection = collection; +export async function addTrack(body: AddTrackRequest): Promise { + if ((!body.url || body.url.trim().length === 0) && (!body.path || body.path.trim().length === 0)) { + throw new Error("Either a URL or a local path must be provided"); + } + if (body.url && body.path) { + throw new Error("Provide either a URL or a local path, not both"); } const response = await fetch("/api/audio", { diff --git a/pmoaudiocache/src/api.rs b/pmoaudiocache/src/api.rs index 7962b8f2..0bd9d303 100644 --- a/pmoaudiocache/src/api.rs +++ b/pmoaudiocache/src/api.rs @@ -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>, + Json(req): Json, +) -> 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(), + } +} diff --git a/pmoaudiocache/src/cache.rs b/pmoaudiocache/src/cache.rs index 311bfc55..f984b3c9 100644 --- a/pmoaudiocache/src/cache.rs +++ b/pmoaudiocache/src/cache.rs @@ -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) -> Result { + 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 : diff --git a/pmoaudiocache/src/lib.rs b/pmoaudiocache/src/lib.rs index bb813f3b..dc9bd2df 100755 --- a/pmoaudiocache/src/lib.rs +++ b/pmoaudiocache/src/lib.rs @@ -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::) + .post(crate::api::add_audio_item) + .delete(pmocache::api::purge_cache::), + ) + .route( + "/{pk}", + axum::routing::get(pmocache::api::get_item_info::) + .delete(pmocache::api::delete_item::), + ) + .route( + "/{pk}/status", + axum::routing::get(pmocache::api::get_download_status::), + ) + .route( + "/consolidate", + axum::routing::post(pmocache::api::consolidate_cache::), + ) + .with_state(cache.clone()); // Ajouter les endpoints audio spécifiques // Route: GET /api/audio/{pk}/cover-url diff --git a/pmoaudiocache/tests/test_cache.rs b/pmoaudiocache/tests/test_cache.rs index e4494ec8..65f71039 100644 --- a/pmoaudiocache/tests/test_cache.rs +++ b/pmoaudiocache/tests/test_cache.rs @@ -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()) + ); +} diff --git a/pmocache/Cargo.toml b/pmocache/Cargo.toml index b69a4337..61870502 100644 --- a/pmocache/Cargo.toml +++ b/pmocache/Cargo.toml @@ -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"] } diff --git a/pmocache/src/api.rs b/pmocache/src/api.rs index 87c5e953..8d10e90f 100644 --- a/pmocache/src/api.rs +++ b/pmocache/src/api.rs @@ -57,13 +57,20 @@ pub struct ConversionStatus { pub details: Option, } -/// 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, + /// Chemin local (`file://` implicite) à référencer + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(example = "/mnt/music/track.flac"))] + pub path: Option, /// Collection optionnelle #[cfg_attr(feature = "openapi", schema(example = "album:the_wall"))] pub collection: Option, @@ -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 { .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( State(cache): State>>, Json(req): Json, ) -> 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 { diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index 74f869e1..ac800c4b 100755 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -372,7 +372,7 @@ impl Cache { /// /// # Exemple /// - /// ```rust,no_run + /// ```rust,ignore /// use pmocache::{Cache, CacheConfig, StreamTransformer}; /// use std::sync::Arc; /// @@ -382,11 +382,10 @@ impl Cache { /// } /// /// 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 Cache { } // 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 Cache { } // 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::::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 { let canonical_path = std::fs::canonicalize(path)?; let file_url = format!("file://{}", canonical_path.display()); @@ -868,11 +852,60 @@ impl Cache { .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 Cache { /// # 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> { /// let cache = Cache::::new("./cache", 1000)?; /// let mut rx = cache.subscribe_events(); /// @@ -1376,6 +1417,8 @@ impl Cache { /// } /// } /// }); + /// # Ok(()) + /// # } /// ``` pub fn subscribe_events(&self) -> broadcast::Receiver { self.served_tx @@ -1424,7 +1467,10 @@ impl Cache { 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 Cache { 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 FileCache for Cache { fn get_cache_dir(&self) -> &Path { diff --git a/pmocache/src/db.rs b/pmocache/src/db.rs index a1a41757..78156f45 100644 --- a/pmocache/src/db.rs +++ b/pmocache/src/db.rs @@ -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 { 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 { diff --git a/pmocache/src/lib.rs b/pmocache/src/lib.rs index 57fd9c1a..65782df2 100644 --- a/pmocache/src/lib.rs +++ b/pmocache/src/lib.rs @@ -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é d’enregistrer 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}; diff --git a/pmocache/tests/test_cache.rs b/pmocache/tests/test_cache.rs index f8aa2e69..a484a74f 100644 --- a/pmocache/tests/test_cache.rs +++ b/pmocache/tests/test_cache.rs @@ -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"; diff --git a/pmoflac/src/autodetect.rs b/pmoflac/src/autodetect.rs index b7b6dcb0..dd1f551f 100755 --- a/pmoflac/src/autodetect.rs +++ b/pmoflac/src/autodetect.rs @@ -221,8 +221,13 @@ impl AsyncRead for DecodedReader { } } +/// Retourne `true` si les octets fournis contiennent la signature magique FLAC (`fLaC`). +pub fn is_flac_magic_header(bytes: &[u8]) -> bool { + bytes.len() >= 4 && &bytes[..4] == b"fLaC" +} + fn detect_format(bytes: &[u8]) -> Option { - if bytes.len() >= 4 && &bytes[..4] == b"fLaC" { + if is_flac_magic_header(bytes) { return Some(DetectedFormat::Flac); } if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WAVE" { diff --git a/pmoflac/src/lib.rs b/pmoflac/src/lib.rs index b88e9787..aeaf58e3 100755 --- a/pmoflac/src/lib.rs +++ b/pmoflac/src/lib.rs @@ -115,7 +115,9 @@ mod util; pub mod wav; pub use aiff::{decode_aiff_stream, AiffDecodedStream, AiffError}; -pub use autodetect::{decode_audio_stream, DecodeAudioError, DecodedAudioStream, DecodedReader}; +pub use autodetect::{ + decode_audio_stream, is_flac_magic_header, DecodeAudioError, DecodedAudioStream, DecodedReader, +}; pub use decoder::{decode_flac_stream, FlacDecodedStream}; pub use encoder::{encode_flac_stream, EncoderOptions, FlacEncodedStream}; pub use error::FlacError; diff --git a/pmoplaylist/src/handle/read.rs b/pmoplaylist/src/handle/read.rs index 92d3909e..a23ae28d 100644 --- a/pmoplaylist/src/handle/read.rs +++ b/pmoplaylist/src/handle/read.rs @@ -70,7 +70,13 @@ impl ReadHandle { let role = self.playlist.role().await; let core = self.playlist.core.read().await; let _ = persistence - .save_playlist(&self.playlist.id, &title, &role, &core.config, &core.tracks) + .save_playlist( + &self.playlist.id, + &title, + &role, + &core.config, + &core.tracks, + ) .await; } } diff --git a/pmoplaylist/src/manager.rs b/pmoplaylist/src/manager.rs index 35ebc06f..8a91df14 100644 --- a/pmoplaylist/src/manager.rs +++ b/pmoplaylist/src/manager.rs @@ -495,8 +495,13 @@ impl PlaylistManager { // Reconstruire la playlist let mut playlists = self.inner.playlists.write().await; - let playlist = - Arc::new(Playlist::new(id.to_string(), title.clone(), config, true, role)); + let playlist = Arc::new(Playlist::new( + id.to_string(), + title.clone(), + config, + true, + role, + )); // Restaurer les tracks { @@ -718,9 +723,7 @@ impl PlaylistManager { let mut rx = cache.subscribe_events(); while let Ok(event) = rx.recv().await { if let CacheEvent::LazyDownloaded { lazy_pk, real_pk } = event { - manager - .handle_lazy_download_event(&lazy_pk, &real_pk) - .await; + manager.handle_lazy_download_event(&lazy_pk, &real_pk).await; } } inner.lazy_listener_started.store(false, Ordering::SeqCst); diff --git a/pmoplaylist/src/persistence/mod.rs b/pmoplaylist/src/persistence/mod.rs index be272734..f65494a2 100644 --- a/pmoplaylist/src/persistence/mod.rs +++ b/pmoplaylist/src/persistence/mod.rs @@ -7,9 +7,9 @@ use crate::Result; use rusqlite::{params, Connection}; use std::collections::VecDeque; use std::path::Path; +use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use std::str::FromStr; /// Gestionnaire de persistance (une base pour toutes les playlists) pub struct PersistenceManager { @@ -145,9 +145,7 @@ impl PersistenceManager { // Charger les métadonnées let mut stmt = conn - .prepare( - "SELECT title, role, max_size, default_ttl_secs FROM playlists WHERE id = ?1", - ) + .prepare("SELECT title, role, max_size, default_ttl_secs FROM playlists WHERE id = ?1") .map_err(|e| { crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e)) })?; diff --git a/pmoplaylist/src/playlist/record.rs b/pmoplaylist/src/playlist/record.rs index 13626088..311fd1c1 100644 --- a/pmoplaylist/src/playlist/record.rs +++ b/pmoplaylist/src/playlist/record.rs @@ -77,12 +77,7 @@ fn next_timestamp() -> SystemTime { last.saturating_add(1) }; - match LAST_ADDED_AT.compare_exchange( - last, - candidate, - Ordering::SeqCst, - Ordering::SeqCst, - ) { + match LAST_ADDED_AT.compare_exchange(last, candidate, Ordering::SeqCst, Ordering::SeqCst) { Ok(_) => { let nanos = candidate as u64; return UNIX_EPOCH + Duration::from_nanos(nanos); diff --git a/pmoqobuz/src/lazy_provider.rs b/pmoqobuz/src/lazy_provider.rs index c69f490a..cb83a36e 100644 --- a/pmoqobuz/src/lazy_provider.rs +++ b/pmoqobuz/src/lazy_provider.rs @@ -83,9 +83,12 @@ impl LazyProvider for QobuzLazyProvider { async fn cover_url(&self, lazy_pk: &str) -> Result> { let track = self.fetch_track(lazy_pk).await?; - Ok(track - .album - .and_then(|a| a.image) - .and_then(|url| if url.is_empty() { None } else { Some(url) })) + Ok(track.album.and_then(|a| a.image).and_then(|url| { + if url.is_empty() { + None + } else { + Some(url) + } + })) } }