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

BIN
.DS_Store vendored

Binary file not shown.

1
Cargo.lock generated
View File

@@ -3289,6 +3289,7 @@ dependencies = [
"hex",
"paste",
"pmoconfig",
"pmoflac",
"reqwest",
"rusqlite",
"serde",

View File

@@ -13,14 +13,39 @@
<div class="add-form">
<h3> Add New Track</h3>
<form @submit.prevent="handleAddTrack">
<div class="source-toggle">
<label>
<input type="radio" value="url" v-model="newTrackSourceType" /> Remote URL
</label>
<label>
<input type="radio" value="path" v-model="newTrackSourceType" /> Local FLAC reference
</label>
</div>
<div class="form-group">
<template v-if="newTrackSourceType === 'url'">
<input
v-model="newTrackUrl"
type="url"
placeholder="https://example.com/track.flac"
:required="newTrackSourceType === 'url'"
:disabled="isAdding"
/>
</template>
<template v-else>
<input
v-model="newTrackPath"
type="text"
placeholder="/mnt/music/MyTrack.flac"
:required="newTrackSourceType === 'path'"
:disabled="isAdding"
/>
</template>
</div>
<p class="local-tip" v-if="newTrackSourceType === 'path'">
Local FLAC files are referenced without duplication. Removing the cache entry never deletes
the original file.
</p>
<div class="form-group">
<input
v-model="newTrackUrl"
type="url"
placeholder="https://example.com/track.flac"
required
:disabled="isAdding"
/>
<input
v-model="newTrackCollection"
type="text"
@@ -28,8 +53,8 @@
:disabled="isAdding"
class="collection-input"
/>
<button type="submit" :disabled="isAdding || !newTrackUrl">
{{ isAdding ? "Adding..." : "Add Track" }}
<button type="submit" :disabled="addButtonDisabled">
{{ addButtonLabel }}
</button>
</div>
<p v-if="addError" class="error">{{ addError }}</p>
@@ -73,7 +98,7 @@
<div
v-for="track in sortedTracks"
:key="track.pk"
class="track-card"
:class="['track-card', { 'local-reference': isLocalFile(track) }]"
@click="selectedTrack = track"
>
<div class="track-icon">
@@ -94,6 +119,7 @@
>
{{ lazyBadgeLabel(track) }}
</span>
<span v-if="isLocalFile(track)" class="local-pill">Local file</span>
</div>
</div>
<div class="track-info">
@@ -133,6 +159,12 @@
{{ conversionLabel(track) }}
</span>
</div>
<div class="local-path" v-if="isLocalFile(track)">
<span class="local-badge">Local</span>
<span class="local-path-text">
{{ localSourcePath(track) || "Original file" }}
</span>
</div>
<div class="collection" v-if="track.collection">
{{ track.collection }}
</div>
@@ -225,6 +257,12 @@
<h4>Cache Info</h4>
<p><strong>PK:</strong> {{ selectedTrack.pk }}</p>
<p><strong>Status:</strong> {{ trackStatusLabel(selectedTrack) }}</p>
<p v-if="isLocalFile(selectedTrack)">
<strong>Local file:</strong>
<span class="local-path-text">
{{ localSourcePath(selectedTrack) || "Original file retained" }}
</span>
</p>
<p v-if="resolveTrackOrigin(selectedTrack)">
<strong>Source URL:</strong>
<a :href="resolveTrackOrigin(selectedTrack)" target="_blank">{{ resolveTrackOrigin(selectedTrack) }}</a>
@@ -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;

View File

@@ -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<DownloadStatus> {
/**
* Ajoute une nouvelle piste au cache depuis une URL
*/
export async function addTrack(url: string, collection?: string): Promise<AddTrackResponse> {
const body: AddTrackRequest = { url };
if (collection) {
body.collection = collection;
export async function addTrack(body: AddTrackRequest): Promise<AddTrackResponse> {
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", {

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())
);
}

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";

View File

@@ -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<DetectedFormat> {
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" {

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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))
})?;

View File

@@ -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);

View File

@@ -83,9 +83,12 @@ impl LazyProvider for QobuzLazyProvider {
async fn cover_url(&self, lazy_pk: &str) -> Result<Option<String>> {
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)
}
}))
}
}