no local cache
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3289,6 +3289,7 @@ dependencies = [
|
||||
"hex",
|
||||
"paste",
|
||||
"pmoconfig",
|
||||
"pmoflac",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 :
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
})?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user