no local cache
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3289,6 +3289,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"paste",
|
"paste",
|
||||||
"pmoconfig",
|
"pmoconfig",
|
||||||
|
"pmoflac",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -13,14 +13,39 @@
|
|||||||
<div class="add-form">
|
<div class="add-form">
|
||||||
<h3>➕ Add New Track</h3>
|
<h3>➕ Add New Track</h3>
|
||||||
<form @submit.prevent="handleAddTrack">
|
<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">
|
<div class="form-group">
|
||||||
|
<template v-if="newTrackSourceType === 'url'">
|
||||||
<input
|
<input
|
||||||
v-model="newTrackUrl"
|
v-model="newTrackUrl"
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://example.com/track.flac"
|
placeholder="https://example.com/track.flac"
|
||||||
required
|
:required="newTrackSourceType === 'url'"
|
||||||
:disabled="isAdding"
|
: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
|
<input
|
||||||
v-model="newTrackCollection"
|
v-model="newTrackCollection"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -28,8 +53,8 @@
|
|||||||
:disabled="isAdding"
|
:disabled="isAdding"
|
||||||
class="collection-input"
|
class="collection-input"
|
||||||
/>
|
/>
|
||||||
<button type="submit" :disabled="isAdding || !newTrackUrl">
|
<button type="submit" :disabled="addButtonDisabled">
|
||||||
{{ isAdding ? "Adding..." : "Add Track" }}
|
{{ addButtonLabel }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="addError" class="error">{{ addError }}</p>
|
<p v-if="addError" class="error">{{ addError }}</p>
|
||||||
@@ -73,7 +98,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="track in sortedTracks"
|
v-for="track in sortedTracks"
|
||||||
:key="track.pk"
|
:key="track.pk"
|
||||||
class="track-card"
|
:class="['track-card', { 'local-reference': isLocalFile(track) }]"
|
||||||
@click="selectedTrack = track"
|
@click="selectedTrack = track"
|
||||||
>
|
>
|
||||||
<div class="track-icon">
|
<div class="track-icon">
|
||||||
@@ -94,6 +119,7 @@
|
|||||||
>
|
>
|
||||||
{{ lazyBadgeLabel(track) }}
|
{{ lazyBadgeLabel(track) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="isLocalFile(track)" class="local-pill">Local file</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
@@ -133,6 +159,12 @@
|
|||||||
{{ conversionLabel(track) }}
|
{{ conversionLabel(track) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div class="collection" v-if="track.collection">
|
||||||
{{ track.collection }}
|
{{ track.collection }}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,6 +257,12 @@
|
|||||||
<h4>Cache Info</h4>
|
<h4>Cache Info</h4>
|
||||||
<p><strong>PK:</strong> {{ selectedTrack.pk }}</p>
|
<p><strong>PK:</strong> {{ selectedTrack.pk }}</p>
|
||||||
<p><strong>Status:</strong> {{ trackStatusLabel(selectedTrack) }}</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)">
|
<p v-if="resolveTrackOrigin(selectedTrack)">
|
||||||
<strong>Source URL:</strong>
|
<strong>Source URL:</strong>
|
||||||
<a :href="resolveTrackOrigin(selectedTrack)" target="_blank">{{ resolveTrackOrigin(selectedTrack) }}</a>
|
<a :href="resolveTrackOrigin(selectedTrack)" target="_blank">{{ resolveTrackOrigin(selectedTrack) }}</a>
|
||||||
@@ -323,7 +361,9 @@ const LEGACY_LAZY_PREFIX = "L:";
|
|||||||
|
|
||||||
// Formulaire d'ajout
|
// Formulaire d'ajout
|
||||||
const newTrackUrl = ref("");
|
const newTrackUrl = ref("");
|
||||||
|
const newTrackPath = ref("");
|
||||||
const newTrackCollection = ref("");
|
const newTrackCollection = ref("");
|
||||||
|
const newTrackSourceType = ref<"url" | "path">("url");
|
||||||
const isAdding = ref(false);
|
const isAdding = ref(false);
|
||||||
const addError = ref("");
|
const addError = ref("");
|
||||||
const addSuccess = 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 ---
|
// --- Fonctions ---
|
||||||
async function refreshTracks() {
|
async function refreshTracks() {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
@@ -375,17 +431,27 @@ async function refreshTracks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddTrack() {
|
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;
|
isAdding.value = true;
|
||||||
addError.value = "";
|
addError.value = "";
|
||||||
addSuccess.value = "";
|
addSuccess.value = "";
|
||||||
try {
|
try {
|
||||||
const result = await addTrack(
|
const result = await addTrack({
|
||||||
newTrackUrl.value,
|
url: useUrl ? rawValue : undefined,
|
||||||
newTrackCollection.value || undefined
|
path: useUrl ? undefined : rawValue,
|
||||||
);
|
collection: newTrackCollection.value || undefined,
|
||||||
addSuccess.value = `Track added! PK: ${result.pk}`;
|
});
|
||||||
|
addSuccess.value =
|
||||||
|
newTrackSourceType.value === "path"
|
||||||
|
? `Local file linked! PK: ${result.pk}`
|
||||||
|
: `Track added! PK: ${result.pk}`;
|
||||||
newTrackUrl.value = "";
|
newTrackUrl.value = "";
|
||||||
|
newTrackPath.value = "";
|
||||||
newTrackCollection.value = "";
|
newTrackCollection.value = "";
|
||||||
await refreshTracks();
|
await refreshTracks();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -397,7 +463,16 @@ async function handleAddTrack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteTrack(pk: string) {
|
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);
|
deletingTracks.value.add(pk);
|
||||||
try {
|
try {
|
||||||
await deleteTrack(pk);
|
await deleteTrack(pk);
|
||||||
@@ -507,7 +582,7 @@ function copyTrackUrl(pk: string) {
|
|||||||
alert("✅ URL copied!");
|
alert("✅ URL copied!");
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTrackOrigin(track: AudioCacheEntry | null): string | undefined {
|
function resolveTrackOrigin(track: AudioCacheEntry | null | undefined): string | undefined {
|
||||||
return track ? getOriginUrl(track) : undefined;
|
return track ? getOriginUrl(track) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +703,9 @@ function lazyBadgeLabel(track: AudioCacheEntry | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function trackStatusLabel(track: AudioCacheEntry | null): string {
|
function trackStatusLabel(track: AudioCacheEntry | null): string {
|
||||||
|
if (isLocalFile(track)) {
|
||||||
|
return "Local FLAC reference (original preserved)";
|
||||||
|
}
|
||||||
const info = getLazyInfo(track);
|
const info = getLazyInfo(track);
|
||||||
if (info) {
|
if (info) {
|
||||||
const provider = info.isLegacy ? "" : ` - ${info.display}`;
|
const provider = info.isLegacy ? "" : ` - ${info.display}`;
|
||||||
@@ -640,6 +718,23 @@ function getTrackByPk(pk: string): AudioCacheEntry | undefined {
|
|||||||
return tracks.value.find((t) => t.pk === pk);
|
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(() => {
|
onMounted(() => {
|
||||||
refreshTracks();
|
refreshTracks();
|
||||||
});
|
});
|
||||||
@@ -702,6 +797,25 @@ onMounted(() => {
|
|||||||
color: #61dafb;
|
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 {
|
.form-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -870,6 +984,11 @@ button:disabled {
|
|||||||
flex-direction: column;
|
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 {
|
.track-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
@@ -926,6 +1045,17 @@ button:disabled {
|
|||||||
font-size: 0.8rem;
|
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 {
|
.track-info {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1000,6 +1130,33 @@ button:disabled {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.collection {
|
||||||
color: #888;
|
color: #888;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
export interface AudioCacheMetadata {
|
export interface AudioCacheMetadata {
|
||||||
origin_url?: string;
|
origin_url?: string;
|
||||||
|
local_passthrough?: boolean;
|
||||||
|
local_source_path?: string;
|
||||||
|
source_size?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
album?: string;
|
album?: string;
|
||||||
@@ -40,7 +43,8 @@ export interface ConversionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AddTrackRequest {
|
export interface AddTrackRequest {
|
||||||
url: string;
|
url?: string;
|
||||||
|
path?: string;
|
||||||
collection?: string;
|
collection?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,10 +131,12 @@ export async function getDownloadStatus(pk: string): Promise<DownloadStatus> {
|
|||||||
/**
|
/**
|
||||||
* Ajoute une nouvelle piste au cache depuis une URL
|
* Ajoute une nouvelle piste au cache depuis une URL
|
||||||
*/
|
*/
|
||||||
export async function addTrack(url: string, collection?: string): Promise<AddTrackResponse> {
|
export async function addTrack(body: AddTrackRequest): Promise<AddTrackResponse> {
|
||||||
const body: AddTrackRequest = { url };
|
if ((!body.url || body.url.trim().length === 0) && (!body.path || body.path.trim().length === 0)) {
|
||||||
if (collection) {
|
throw new Error("Either a URL or a local path must be provided");
|
||||||
body.collection = collection;
|
}
|
||||||
|
if (body.url && body.path) {
|
||||||
|
throw new Error("Provide either a URL or a local path, not both");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/audio", {
|
const response = await fetch("/api/audio", {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! API REST handlers spécifiques au cache audio
|
//! API REST handlers spécifiques au cache audio
|
||||||
|
|
||||||
|
use crate::cache;
|
||||||
use crate::metadata_ext::AudioTrackMetadataExt;
|
use crate::metadata_ext::AudioTrackMetadataExt;
|
||||||
use crate::Cache;
|
use crate::Cache;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -8,7 +9,7 @@ use axum::{
|
|||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use pmometadata::TrackMetadata;
|
use pmocache::api::{AddItemRequest, AddItemResponse, ErrorResponse};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -96,3 +97,79 @@ pub async fn get_cover_url(
|
|||||||
)
|
)
|
||||||
.into_response()
|
.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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@
|
|||||||
//! des métadonnées en JSON dans la base de données.
|
//! des métadonnées en JSON dans la base de données.
|
||||||
|
|
||||||
use crate::metadata_ext::AudioTrackMetadataExt;
|
use crate::metadata_ext::AudioTrackMetadataExt;
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use pmocache::download::TransformMetadata;
|
use pmocache::download::{read_exact_or_eof, TransformMetadata};
|
||||||
use pmocache::CacheConfig;
|
use pmocache::{pk_from_content_header, CacheConfig};
|
||||||
|
use pmoflac::is_flac_magic_header;
|
||||||
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::io::AsyncSeekExt;
|
||||||
|
|
||||||
/// Configuration pour le cache audio
|
/// Configuration pour le cache audio
|
||||||
pub struct AudioConfig;
|
pub struct AudioConfig;
|
||||||
@@ -122,6 +125,76 @@ pub async fn new_cache_with_consolidation(dir: &str, limit: usize) -> Result<Arc
|
|||||||
Ok(cache)
|
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
|
/// 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 :
|
/// Cette fonction étend `add_from_url` du cache en ajoutant :
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
//! - stockage des métadonnées dans la table `metadata` de `pmocache::DB` ;
|
//! - stockage des métadonnées dans la table `metadata` de `pmocache::DB` ;
|
||||||
//! - helpers pour renseigner les collections à partir des tags ;
|
//! - helpers pour renseigner les collections à partir des tags ;
|
||||||
//! - intégration optionnelle avec `pmoserver` (routes REST + diffusion de fichiers).
|
//! - 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
|
//! ## Exemple rapide
|
||||||
//!
|
//!
|
||||||
@@ -198,7 +203,7 @@ pub trait AudioCacheExt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use pmocache::pmoserver_ext::{create_api_router, create_file_router};
|
use pmocache::pmoserver_ext::create_file_router;
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
@@ -219,9 +224,28 @@ impl AudioCacheExt for pmoserver::Server {
|
|||||||
);
|
);
|
||||||
self.add_router("/", file_router).await;
|
self.add_router("/", file_router).await;
|
||||||
|
|
||||||
// API REST générique (pmocache)
|
// API REST (handlers génériques + POST spécialisé audio)
|
||||||
// Routes: GET/POST/DELETE /api/audio, etc.
|
let mut api_router = axum::Router::new()
|
||||||
let mut api_router = create_api_router(cache.clone());
|
.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
|
// Ajouter les endpoints audio spécifiques
|
||||||
// Route: GET /api/audio/{pk}/cover-url
|
// Route: GET /api/audio/{pk}/cover-url
|
||||||
|
|||||||
@@ -94,3 +94,47 @@ async fn test_cache_limit() {
|
|||||||
let count = cache.db.count().unwrap();
|
let count = cache.db.count().unwrap();
|
||||||
assert_eq!(count, 2);
|
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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
bytes = "1.6"
|
bytes = "1.6"
|
||||||
paste = "1.0"
|
paste = "1.0"
|
||||||
|
pmoflac = { path = "../pmoflac" }
|
||||||
|
|
||||||
# Async
|
# Async
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
|||||||
@@ -57,13 +57,20 @@ pub struct ConversionStatus {
|
|||||||
pub details: Option<String>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "openapi", derive(ToSchema))]
|
#[cfg_attr(feature = "openapi", derive(ToSchema))]
|
||||||
pub struct AddItemRequest {
|
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"))]
|
#[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
|
/// Collection optionnelle
|
||||||
#[cfg_attr(feature = "openapi", schema(example = "album:the_wall"))]
|
#[cfg_attr(feature = "openapi", schema(example = "album:the_wall"))]
|
||||||
pub collection: Option<String>,
|
pub collection: Option<String>,
|
||||||
@@ -76,7 +83,7 @@ pub struct AddItemResponse {
|
|||||||
/// Clé primaire (pk) de l'item ajouté
|
/// Clé primaire (pk) de l'item ajouté
|
||||||
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
|
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
|
||||||
pub pk: String,
|
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"))]
|
#[cfg_attr(feature = "openapi", schema(example = "https://example.com/file.dat"))]
|
||||||
pub url: String,
|
pub url: String,
|
||||||
/// Message de succès
|
/// 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())
|
.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
|
/// Ajoute un item au cache depuis une URL
|
||||||
///
|
///
|
||||||
/// Télécharge l'item depuis l'URL fournie et l'ajoute au cache.
|
/// 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>>>,
|
State(cache): State<Arc<Cache<C>>>,
|
||||||
Json(req): Json<AddItemRequest>,
|
Json(req): Json<AddItemRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
if req.url.is_empty() {
|
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 (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: "INVALID_REQUEST".to_string(),
|
error: "INVALID_REQUEST".to_string(),
|
||||||
message: "URL cannot be empty".to_string(),
|
message: "Provide either 'url' or 'path', not both".to_string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response();
|
.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
|
let collection = req.collection.as_deref();
|
||||||
.add_from_url(&req.url, req.collection.as_deref())
|
let add_result = match mode {
|
||||||
.await
|
AddSource::Url(url) => cache.add_from_url(url, collection).await,
|
||||||
{
|
AddSource::Local(path) => cache.add_from_file(path, collection).await,
|
||||||
Ok(pk) => (
|
};
|
||||||
|
|
||||||
|
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,
|
StatusCode::CREATED,
|
||||||
Json(AddItemResponse {
|
Json(AddItemResponse {
|
||||||
pk,
|
pk,
|
||||||
url: req.url,
|
url: origin,
|
||||||
message: "Item added successfully".to_string(),
|
message: "Item added successfully".to_string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response()
|
||||||
|
}
|
||||||
Err(e) => (
|
Err(e) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
///
|
///
|
||||||
/// # Exemple
|
/// # Exemple
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,ignore
|
||||||
/// use pmocache::{Cache, CacheConfig, StreamTransformer};
|
/// use pmocache::{Cache, CacheConfig, StreamTransformer};
|
||||||
/// use std::sync::Arc;
|
/// use std::sync::Arc;
|
||||||
///
|
///
|
||||||
@@ -382,11 +382,10 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// let transformer_factory = Arc::new(|| {
|
/// let transformer_factory = Arc::new(|| {
|
||||||
/// // Créer un transformer qui convertit les données
|
/// // Créer un transformer qui effectue une opération personnalisée
|
||||||
/// Box::new(|input, file, ctx| {
|
/// Box::new(|_input, _file, _ctx| {
|
||||||
/// Box::pin(async move {
|
/// Box::pin(async move {
|
||||||
/// // Transformation personnalisée
|
/// // Transformation personnalisée
|
||||||
/// ctx.report_progress(0);
|
|
||||||
/// Ok(())
|
/// Ok(())
|
||||||
/// })
|
/// })
|
||||||
/// }) as StreamTransformer
|
/// }) as StreamTransformer
|
||||||
@@ -660,7 +659,13 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finaliser avec prébuffering et nettoyage
|
// Finaliser avec prébuffering et nettoyage
|
||||||
self.finalize_download(&pk, download, collection, Some(url), FinalizeMode::InsertNew)
|
self.finalize_download(
|
||||||
|
&pk,
|
||||||
|
download,
|
||||||
|
collection,
|
||||||
|
Some(url),
|
||||||
|
FinalizeMode::InsertNew,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,38 +832,17 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finaliser avec prébuffering et nettoyage
|
// Finaliser avec prébuffering et nettoyage
|
||||||
self.finalize_download(&pk, download, collection, source_uri, FinalizeMode::InsertNew)
|
self.finalize_download(
|
||||||
|
&pk,
|
||||||
|
download,
|
||||||
|
collection,
|
||||||
|
source_uri,
|
||||||
|
FinalizeMode::InsertNew,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ajoute un fichier local au cache
|
/// Ajoute un fichier local au cache en copiant son contenu.
|
||||||
///
|
|
||||||
/// 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?;
|
|
||||||
/// ```
|
|
||||||
pub async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String> {
|
pub async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String> {
|
||||||
let canonical_path = std::fs::canonicalize(path)?;
|
let canonical_path = std::fs::canonicalize(path)?;
|
||||||
let file_url = format!("file://{}", canonical_path.display());
|
let file_url = format!("file://{}", canonical_path.display());
|
||||||
@@ -868,11 +852,60 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
.map(|m| m.len());
|
.map(|m| m.len());
|
||||||
let reader = tokio::fs::File::open(&canonical_path).await?;
|
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)
|
self.add_from_reader(Some(&file_url), reader, length, collection)
|
||||||
.await
|
.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<()> {
|
pub async fn delete_item(&self, pk: &str) -> Result<()> {
|
||||||
// Vérifie l'existence pour signaler une erreur explicite si l'entrée est absente
|
// Vérifie l'existence pour signaler une erreur explicite si l'entrée est absente
|
||||||
self.db.get(pk, false)?;
|
self.db.get(pk, false)?;
|
||||||
@@ -1363,6 +1396,14 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```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 cache = Cache::<AudioConfig>::new("./cache", 1000)?;
|
||||||
/// let mut rx = cache.subscribe_events();
|
/// let mut rx = cache.subscribe_events();
|
||||||
///
|
///
|
||||||
@@ -1376,6 +1417,8 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// });
|
/// });
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn subscribe_events(&self) -> broadcast::Receiver<CacheEvent> {
|
pub fn subscribe_events(&self) -> broadcast::Receiver<CacheEvent> {
|
||||||
self.served_tx
|
self.served_tx
|
||||||
@@ -1424,7 +1467,10 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
if let Some(provider) = self.provider_for_lazy_pk(lazy_pk) {
|
if let Some(provider) = self.provider_for_lazy_pk(lazy_pk) {
|
||||||
let metadata = provider.metadata(lazy_pk).await?;
|
let metadata = provider.metadata(lazy_pk).await?;
|
||||||
let cover_url = provider.cover_url(lazy_pk).await?;
|
let cover_url = provider.cover_url(lazy_pk).await?;
|
||||||
Ok(LazyEntryRemoteData { metadata, cover_url })
|
Ok(LazyEntryRemoteData {
|
||||||
|
metadata,
|
||||||
|
cover_url,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
Ok(LazyEntryRemoteData::default())
|
Ok(LazyEntryRemoteData::default())
|
||||||
}
|
}
|
||||||
@@ -1486,12 +1532,32 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
bail!("Lazy PK collision for URL: {}", url);
|
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);
|
tracing::debug!("Created new lazy pk {} for URL {}", lazy_pk, url);
|
||||||
Ok(lazy_pk)
|
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émentation du trait FileCache pour Cache
|
||||||
impl<C: CacheConfig> FileCache<C> for Cache<C> {
|
impl<C: CacheConfig> FileCache<C> for Cache<C> {
|
||||||
fn get_cache_dir(&self) -> &Path {
|
fn get_cache_dir(&self) -> &Path {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ impl DB {
|
|||||||
/// use pmocache::db::DB;
|
/// use pmocache::db::DB;
|
||||||
/// use std::path::Path;
|
/// 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> {
|
pub fn init(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
@@ -889,15 +889,7 @@ impl DB {
|
|||||||
hits = hits + ?5,
|
hits = hits + ?5,
|
||||||
last_used = ?6
|
last_used = ?6
|
||||||
WHERE lazy_pk = ?7",
|
WHERE lazy_pk = ?7",
|
||||||
params![
|
params![real_pk, lazy_pk, collection, id, hits_to_add, now, lazy_pk],
|
||||||
real_pk,
|
|
||||||
lazy_pk,
|
|
||||||
collection,
|
|
||||||
id,
|
|
||||||
hits_to_add,
|
|
||||||
now,
|
|
||||||
lazy_pk
|
|
||||||
],
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if updated == 0 {
|
if updated == 0 {
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
//! conservées dans une base SQLite, ainsi que les opérations de téléchargement,
|
//! conservées dans une base SQLite, ainsi que les opérations de téléchargement,
|
||||||
//! d'éviction et de mise à jour.
|
//! 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
|
//! ## Vue d'ensemble
|
||||||
//!
|
//!
|
||||||
//! `pmocache` met à disposition :
|
//! `pmocache` met à disposition :
|
||||||
@@ -135,12 +144,12 @@ pub use cache::{
|
|||||||
CacheSubscription,
|
CacheSubscription,
|
||||||
};
|
};
|
||||||
pub use cache_trait::{pk_from_content_header, FileCache};
|
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 db::{CacheEntry, DB};
|
||||||
pub use download::{
|
pub use download::{
|
||||||
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,
|
download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header,
|
||||||
Download, StreamTransformer, TransformContextHandle, TransformMetadata,
|
Download, StreamTransformer, TransformContextHandle, TransformMetadata,
|
||||||
};
|
};
|
||||||
|
pub use lazy::{lazy_prefix_from_pk, LazyEntryRemoteData, LazyProvider};
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub use pmoserver_ext::{create_api_router, create_file_router, GenericCacheExt};
|
pub use pmoserver_ext::{create_api_router, create_file_router, GenericCacheExt};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use pmocache::{Cache, CacheConfig};
|
use pmocache::{Cache, CacheConfig};
|
||||||
use std::io::Write;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
/// Configuration de test simple
|
/// Configuration de test simple
|
||||||
@@ -307,7 +306,7 @@ async fn test_touch() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_consolidate() {
|
async fn test_consolidate() {
|
||||||
let (temp_dir, cache) = create_test_cache(10);
|
let (_temp_dir, cache) = create_test_cache(10);
|
||||||
|
|
||||||
// Ajouter un fichier
|
// Ajouter un fichier
|
||||||
let test_data = b"Test data";
|
let test_data = b"Test data";
|
||||||
|
|||||||
@@ -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> {
|
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);
|
return Some(DetectedFormat::Flac);
|
||||||
}
|
}
|
||||||
if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WAVE" {
|
if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WAVE" {
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ mod util;
|
|||||||
pub mod wav;
|
pub mod wav;
|
||||||
|
|
||||||
pub use aiff::{decode_aiff_stream, AiffDecodedStream, AiffError};
|
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 decoder::{decode_flac_stream, FlacDecodedStream};
|
||||||
pub use encoder::{encode_flac_stream, EncoderOptions, FlacEncodedStream};
|
pub use encoder::{encode_flac_stream, EncoderOptions, FlacEncodedStream};
|
||||||
pub use error::FlacError;
|
pub use error::FlacError;
|
||||||
|
|||||||
@@ -70,7 +70,13 @@ impl ReadHandle {
|
|||||||
let role = self.playlist.role().await;
|
let role = self.playlist.role().await;
|
||||||
let core = self.playlist.core.read().await;
|
let core = self.playlist.core.read().await;
|
||||||
let _ = persistence
|
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;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,8 +495,13 @@ impl PlaylistManager {
|
|||||||
// Reconstruire la playlist
|
// Reconstruire la playlist
|
||||||
let mut playlists = self.inner.playlists.write().await;
|
let mut playlists = self.inner.playlists.write().await;
|
||||||
|
|
||||||
let playlist =
|
let playlist = Arc::new(Playlist::new(
|
||||||
Arc::new(Playlist::new(id.to_string(), title.clone(), config, true, role));
|
id.to_string(),
|
||||||
|
title.clone(),
|
||||||
|
config,
|
||||||
|
true,
|
||||||
|
role,
|
||||||
|
));
|
||||||
|
|
||||||
// Restaurer les tracks
|
// Restaurer les tracks
|
||||||
{
|
{
|
||||||
@@ -718,9 +723,7 @@ impl PlaylistManager {
|
|||||||
let mut rx = cache.subscribe_events();
|
let mut rx = cache.subscribe_events();
|
||||||
while let Ok(event) = rx.recv().await {
|
while let Ok(event) = rx.recv().await {
|
||||||
if let CacheEvent::LazyDownloaded { lazy_pk, real_pk } = event {
|
if let CacheEvent::LazyDownloaded { lazy_pk, real_pk } = event {
|
||||||
manager
|
manager.handle_lazy_download_event(&lazy_pk, &real_pk).await;
|
||||||
.handle_lazy_download_event(&lazy_pk, &real_pk)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inner.lazy_listener_started.store(false, Ordering::SeqCst);
|
inner.lazy_listener_started.store(false, Ordering::SeqCst);
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use crate::Result;
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
/// Gestionnaire de persistance (une base pour toutes les playlists)
|
/// Gestionnaire de persistance (une base pour toutes les playlists)
|
||||||
pub struct PersistenceManager {
|
pub struct PersistenceManager {
|
||||||
@@ -145,9 +145,7 @@ impl PersistenceManager {
|
|||||||
|
|
||||||
// Charger les métadonnées
|
// Charger les métadonnées
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare("SELECT title, role, max_size, default_ttl_secs FROM playlists WHERE id = ?1")
|
||||||
"SELECT title, role, max_size, default_ttl_secs FROM playlists WHERE id = ?1",
|
|
||||||
)
|
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
|
crate::Error::PersistenceError(format!("Failed to prepare statement: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -77,12 +77,7 @@ fn next_timestamp() -> SystemTime {
|
|||||||
last.saturating_add(1)
|
last.saturating_add(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
match LAST_ADDED_AT.compare_exchange(
|
match LAST_ADDED_AT.compare_exchange(last, candidate, Ordering::SeqCst, Ordering::SeqCst) {
|
||||||
last,
|
|
||||||
candidate,
|
|
||||||
Ordering::SeqCst,
|
|
||||||
Ordering::SeqCst,
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let nanos = candidate as u64;
|
let nanos = candidate as u64;
|
||||||
return UNIX_EPOCH + Duration::from_nanos(nanos);
|
return UNIX_EPOCH + Duration::from_nanos(nanos);
|
||||||
|
|||||||
@@ -83,9 +83,12 @@ impl LazyProvider for QobuzLazyProvider {
|
|||||||
|
|
||||||
async fn cover_url(&self, lazy_pk: &str) -> Result<Option<String>> {
|
async fn cover_url(&self, lazy_pk: &str) -> Result<Option<String>> {
|
||||||
let track = self.fetch_track(lazy_pk).await?;
|
let track = self.fetch_track(lazy_pk).await?;
|
||||||
Ok(track
|
Ok(track.album.and_then(|a| a.image).and_then(|url| {
|
||||||
.album
|
if url.is_empty() {
|
||||||
.and_then(|a| a.image)
|
None
|
||||||
.and_then(|url| if url.is_empty() { None } else { Some(url) }))
|
} else {
|
||||||
|
Some(url)
|
||||||
|
}
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user