debugage lazy cache

This commit is contained in:
2025-12-15 15:05:35 +01:00
parent d2d8111668
commit 68e6f528e5
18 changed files with 641 additions and 194 deletions

2
Cargo.lock generated
View File

@@ -3281,6 +3281,7 @@ name = "pmocache"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"axum 0.8.7", "axum 0.8.7",
"bytes", "bytes",
"chrono", "chrono",
@@ -3603,6 +3604,7 @@ dependencies = [
"futures", "futures",
"lazy_static", "lazy_static",
"pmoaudiocache", "pmoaudiocache",
"pmocache",
"pmoconfig", "pmoconfig",
"pmocovers", "pmocovers",
"pmodidl", "pmodidl",

View File

@@ -38,10 +38,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Enregistrer les sources musicales // Enregistrer les sources musicales
info!("🎵 Registering music sources..."); info!("🎵 Registering music sources...");
// // Enregistrer Qobuz // Enregistrer Qobuz pour activer les lazy providers (QOBUZ:PK)
// if let Err(e) = server.write().await.register_qobuz().await { if let Err(e) = server.write().await.register_qobuz().await {
// tracing::warn!("⚠️ Failed to register Qobuz: {}", e); tracing::warn!("⚠️ Failed to register Qobuz source: {}", e);
// } }
// Initialiser les canaux de streaming Radio Paradise (pipelines + routes HTTP) // Initialiser les canaux de streaming Radio Paradise (pipelines + routes HTTP)
info!("📻 Initializing Radio Paradise streaming channels..."); info!("📻 Initializing Radio Paradise streaming channels...");

View File

@@ -87,7 +87,13 @@
<div v-else class="music-icon">🎵</div> <div v-else class="music-icon">🎵</div>
<div class="track-overlay"> <div class="track-overlay">
<span class="hits" v-if="!isLazyTrack(track)">{{ track.hits }} plays</span> <span class="hits" v-if="!isLazyTrack(track)">{{ track.hits }} plays</span>
<span class="hits lazy" v-else>Lazy</span> <span
v-else
class="hits lazy"
:class="lazyProviderClass(track)"
>
{{ lazyBadgeLabel(track) }}
</span>
</div> </div>
</div> </div>
<div class="track-info"> <div class="track-info">
@@ -102,7 +108,16 @@
</div> </div>
<div class="pk"> <div class="pk">
{{ track.pk }} {{ track.pk }}
<span v-if="isLazyTrack(track)" class="lazy-tag">lazy</span> <span
v-if="isLazyTrack(track)"
class="lazy-tag"
:class="lazyProviderClass(track)"
>
<span class="lazy-label">Lazy</span>
<span class="lazy-provider-name" v-if="lazyProviderName(track)">
{{ lazyProviderName(track) }}
</span>
</span>
</div> </div>
<div class="meta"> <div class="meta">
<span v-if="durationMs(track) !== undefined"> <span v-if="durationMs(track) !== undefined">
@@ -125,7 +140,9 @@
Last used: {{ formatDate(track.last_used) }} Last used: {{ formatDate(track.last_used) }}
</div> </div>
<div class="lazy-warning" v-if="isLazyTrack(track)"> <div class="lazy-warning" v-if="isLazyTrack(track)">
Audio not downloaded yet. First playback (or forcing download) will fetch it automatically. Audio not downloaded yet
<span v-if="lazyProviderName(track)">({{ lazyProviderName(track) }} provider)</span>.
First playback (or forcing download) will fetch it automatically.
</div> </div>
</div> </div>
<div class="track-actions"> <div class="track-actions">
@@ -279,13 +296,30 @@ import {
getCoverUrl, getCoverUrl,
} from "../services/audioCache"; } from "../services/audioCache";
interface LazyDisplayInfo {
prefix: string;
display: string;
className: string;
isLegacy: boolean;
}
const LAZY_PROVIDER_LABELS: Record<string, string> = {
QOBUZ: "Qobuz",
};
const LEGACY_LAZY_INFO: LazyDisplayInfo = {
prefix: "legacy",
display: "Legacy",
className: "lazy-provider-legacy",
isLegacy: true,
};
// --- États --- // --- États ---
const tracks = ref<AudioCacheEntry[]>([]); const tracks = ref<AudioCacheEntry[]>([]);
const selectedTrack = ref<AudioCacheEntry | null>(null); const selectedTrack = ref<AudioCacheEntry | null>(null);
const isLoading = ref(false); const isLoading = ref(false);
const sortBy = ref<"hits" | "last_used" | "recent">("hits"); const sortBy = ref<"hits" | "last_used" | "recent">("hits");
const audioPlayer = ref<HTMLAudioElement | null>(null); const audioPlayer = ref<HTMLAudioElement | null>(null);
const LAZY_PREFIX = "L:"; const LEGACY_LAZY_PREFIX = "L:";
// Formulaire d'ajout // Formulaire d'ajout
const newTrackUrl = ref(""); const newTrackUrl = ref("");
@@ -531,13 +565,73 @@ function handleCoverError(pk: string) {
failedCovers.value.add(pk); failedCovers.value.add(pk);
} }
function prettifyLazyProvider(prefix: string): string {
if (LAZY_PROVIDER_LABELS[prefix]) {
return LAZY_PROVIDER_LABELS[prefix];
}
const normalized = prefix.replace(/[^A-Za-z0-9]+/g, " ").trim();
if (!normalized) {
return prefix.trim().toUpperCase() || "Lazy";
}
return normalized
.split(/\s+/)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
.join(" ");
}
function buildLazyClass(prefix: string): string {
return `lazy-provider-${prefix.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
}
function extractLazyInfoFromPk(pk: string | undefined | null): LazyDisplayInfo | undefined {
if (!pk) return undefined;
if (pk.startsWith(LEGACY_LAZY_PREFIX)) {
return LEGACY_LAZY_INFO;
}
const separatorIndex = pk.indexOf(":");
if (separatorIndex <= 0) {
return undefined;
}
const prefix = pk.slice(0, separatorIndex);
if (!prefix) {
return undefined;
}
return {
prefix,
display: prettifyLazyProvider(prefix),
className: buildLazyClass(prefix),
isLegacy: false,
};
}
function getLazyInfo(track: AudioCacheEntry | null | undefined): LazyDisplayInfo | undefined {
if (!track?.pk) return undefined;
return extractLazyInfoFromPk(track.pk);
}
function isLazyTrack(track: AudioCacheEntry | null | undefined): boolean { function isLazyTrack(track: AudioCacheEntry | null | undefined): boolean {
return !!track?.pk && track.pk.startsWith(LAZY_PREFIX); return !!getLazyInfo(track);
}
function lazyProviderName(track: AudioCacheEntry | null | undefined): string | undefined {
return getLazyInfo(track)?.display;
}
function lazyProviderClass(track: AudioCacheEntry | null | undefined): string {
return getLazyInfo(track)?.className ?? "lazy-provider-generic";
}
function lazyBadgeLabel(track: AudioCacheEntry | null | undefined): string {
const info = getLazyInfo(track);
if (!info) return "";
return info.isLegacy ? "Lazy" : `Lazy - ${info.display}`;
} }
function trackStatusLabel(track: AudioCacheEntry | null): string { function trackStatusLabel(track: AudioCacheEntry | null): string {
if (isLazyTrack(track)) { const info = getLazyInfo(track);
return "Lazy (audio pending download)"; if (info) {
const provider = info.isLegacy ? "" : ` - ${info.display}`;
return `Lazy${provider} (audio pending download)`;
} }
return "Cached"; return "Cached";
} }
@@ -822,11 +916,14 @@ button:disabled {
} }
.track-overlay .hits.lazy { .track-overlay .hits.lazy {
background: rgba(255, 152, 0, 0.85); display: inline-flex;
color: #000; align-items: center;
background: rgba(156, 39, 176, 0.85);
color: #fff;
font-weight: bold; font-weight: bold;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.6rem;
border-radius: 999px; border-radius: 999px;
font-size: 0.8rem;
} }
.track-info { .track-info {
@@ -869,15 +966,30 @@ button:disabled {
} }
.lazy-tag { .lazy-tag {
display: inline-block; display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: 0.5rem; margin-left: 0.5rem;
padding: 0.1rem 0.4rem; padding: 0.15rem 0.6rem;
border-radius: 999px; border-radius: 999px;
background: #ff9800; background: rgba(156, 39, 176, 0.25);
color: #000; color: #f5f5f5;
font-size: 0.65rem; font-size: 0.7rem;
text-transform: uppercase; text-transform: uppercase;
font-weight: bold; font-weight: 600;
border: 1px solid rgba(156, 39, 176, 0.4);
}
.lazy-tag .lazy-label {
font-weight: 700;
letter-spacing: 0.5px;
}
.lazy-tag .lazy-provider-name {
font-size: 0.6rem;
text-transform: none;
letter-spacing: 0.4px;
opacity: 0.9;
} }
.meta { .meta {
@@ -911,6 +1023,27 @@ button:disabled {
border-radius: 6px; border-radius: 6px;
} }
.track-overlay .hits.lazy.lazy-provider-legacy,
.lazy-tag.lazy-provider-legacy {
background: rgba(255, 152, 0, 0.85);
color: #000;
border-color: rgba(255, 193, 7, 0.8);
}
.track-overlay .hits.lazy.lazy-provider-qobuz,
.lazy-tag.lazy-provider-qobuz {
background: rgba(76, 175, 80, 0.9);
color: #fff;
border-color: rgba(165, 214, 167, 0.9);
}
.track-overlay .hits.lazy.lazy-provider-generic,
.lazy-tag.lazy-provider-generic {
background: rgba(103, 58, 183, 0.85);
color: #fff;
border-color: rgba(179, 157, 219, 0.8);
}
.track-actions { .track-actions {
padding: 0 1rem 1rem; padding: 0 1rem 1rem;
display: flex; display: flex;

View File

@@ -8,7 +8,6 @@ use crate::metadata_ext::AudioTrackMetadataExt;
use anyhow::Result; use anyhow::Result;
use pmocache::download::TransformMetadata; use pmocache::download::TransformMetadata;
use pmocache::CacheConfig; use pmocache::CacheConfig;
use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
/// Configuration pour le cache audio /// Configuration pour le cache audio

View File

@@ -18,6 +18,7 @@ hex = "0.4"
# Utilitaires # Utilitaires
anyhow = "1.0" anyhow = "1.0"
async-trait = "0.1"
chrono = "0.4" chrono = "0.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"

View File

@@ -8,17 +8,23 @@ use crate::db::DB;
use crate::download::{ use crate::download::{
download_with_transformer, ingest_with_transformer, Download, StreamTransformer, download_with_transformer, ingest_with_transformer, Download, StreamTransformer,
}; };
use crate::lazy::{lazy_prefix_from_pk, LazyEntryRemoteData, LazyProvider};
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use serde_json::{Number, Value}; use serde_json::{Number, Value};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::{Arc, RwLock as StdRwLock};
use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::sync::{broadcast, RwLock}; use tokio::sync::{broadcast, RwLock};
use tracing; use tracing;
enum FinalizeMode<'a> {
InsertNew,
ConvertLazy { lazy_pk: &'a str },
}
// ============================================================================ // ============================================================================
// LAZY PK SUPPORT // LAZY PK SUPPORT
// ============================================================================ // ============================================================================
@@ -41,7 +47,11 @@ pub fn generate_lazy_pk(url: &str) -> String {
/// Vérifie si un PK est en mode lazy /// Vérifie si un PK est en mode lazy
pub fn is_lazy_pk(pk: &str) -> bool { pub fn is_lazy_pk(pk: &str) -> bool {
pk.starts_with(LAZY_PK_PREFIX) if pk.starts_with(LAZY_PK_PREFIX) {
return true;
}
lazy_prefix_from_pk(pk).is_some()
} }
/// Events émis par le cache pour notifier les changements d'état /// Events émis par le cache pour notifier les changements d'état
@@ -136,6 +146,8 @@ pub struct Cache<C: CacheConfig> {
min_prebuffer_size: u64, min_prebuffer_size: u64,
/// LAZY PK SUPPORT: Channel pour broadcaster les events (lazy downloads, etc.) /// LAZY PK SUPPORT: Channel pour broadcaster les events (lazy downloads, etc.)
served_tx: Option<broadcast::Sender<CacheEvent>>, served_tx: Option<broadcast::Sender<CacheEvent>>,
/// Providers responsables de préfixes lazy spécifiques
lazy_providers: StdRwLock<HashMap<String, Arc<dyn LazyProvider>>>,
/// Phantom data pour le type de configuration /// Phantom data pour le type de configuration
_phantom: std::marker::PhantomData<C>, _phantom: std::marker::PhantomData<C>,
} }
@@ -242,6 +254,7 @@ impl<C: CacheConfig> Cache<C> {
download: Arc<Download>, download: Arc<Download>,
collection: Option<&str>, collection: Option<&str>,
origin_url: Option<&str>, origin_url: Option<&str>,
mode: FinalizeMode<'_>,
) -> Result<String> { ) -> Result<String> {
// Attendre le prébuffering (pour le cache progressif) // Attendre le prébuffering (pour le cache progressif)
if self.min_prebuffer_size > 0 { if self.min_prebuffer_size > 0 {
@@ -256,10 +269,17 @@ impl<C: CacheConfig> Cache<C> {
); );
} }
// Ajouter à la DB une fois le prébuffer terminé // Ajouter ou commuter la DB selon le mode
self.db.add(pk, None, collection)?; match mode {
if let Some(url) = origin_url { FinalizeMode::InsertNew => {
self.db.set_origin_url(pk, url)?; self.db.add(pk, None, collection)?;
if let Some(url) = origin_url {
self.db.set_origin_url(pk, url)?;
}
}
FinalizeMode::ConvertLazy { lazy_pk } => {
self.db.update_lazy_to_downloaded(lazy_pk, pk)?;
}
} }
// Sauvegarder les métadonnées techniques du transformer // Sauvegarder les métadonnées techniques du transformer
@@ -400,6 +420,7 @@ impl<C: CacheConfig> Cache<C> {
transformer_factory, transformer_factory,
min_prebuffer_size: DEFAULT_PREBUFFER_SIZE, min_prebuffer_size: DEFAULT_PREBUFFER_SIZE,
served_tx: Some(served_tx), served_tx: Some(served_tx),
lazy_providers: StdRwLock::new(HashMap::new()),
_phantom: std::marker::PhantomData, _phantom: std::marker::PhantomData,
}) })
} }
@@ -432,6 +453,34 @@ impl<C: CacheConfig> Cache<C> {
self.min_prebuffer_size self.min_prebuffer_size
} }
/// Enregistre un provider responsable d'un préfixe de lazy PK.
pub fn register_lazy_provider(&self, provider: Arc<dyn LazyProvider>) {
let prefix = provider.lazy_prefix().to_string();
let mut guard = self
.lazy_providers
.write()
.expect("lazy provider registry poisoned");
guard.insert(prefix, provider);
}
/// Désenregistre un provider à partir de son préfixe.
pub fn unregister_lazy_provider(&self, prefix: &str) {
let mut guard = self
.lazy_providers
.write()
.expect("lazy provider registry poisoned");
guard.remove(prefix);
}
fn provider_for_lazy_pk(&self, lazy_pk: &str) -> Option<Arc<dyn LazyProvider>> {
let prefix = lazy_prefix_from_pk(lazy_pk)?;
let guard = self
.lazy_providers
.read()
.expect("lazy provider registry poisoned");
guard.get(prefix).cloned()
}
/// S'abonne aux diffusions HTTP pour un `pk` donné. /// S'abonne aux diffusions HTTP pour un `pk` donné.
/// ///
/// La callback est appelée à chaque fois qu'un élément est servi avec succès via les routes /// La callback est appelée à chaque fois qu'un élément est servi avec succès via les routes
@@ -611,10 +660,63 @@ 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)) self.finalize_download(&pk, download, collection, Some(url), FinalizeMode::InsertNew)
.await .await
} }
/// Télécharge un fichier lazy et commute l'entrée existante
pub async fn download_lazy_from_url(
&self,
lazy_pk: &str,
url: &str,
collection: Option<&str>,
) -> Result<String> {
// Si déjà converti, retourner directement
if let Ok(Some(real_pk)) = self.db.get_pk_by_lazy_pk(lazy_pk) {
return Ok(real_pk);
}
// Si l'URL pointe déjà vers un fichier complet, commuter sans re-télécharger
if let Ok(Some(existing_pk)) = self.db.get_pk_by_origin_url(url) {
if existing_pk != lazy_pk && self.check_cached_and_complete(&existing_pk).await? {
self.db.update_lazy_to_downloaded(lazy_pk, &existing_pk)?;
return Ok(existing_pk);
}
}
// 1. Télécharger les 2048 premiers octets pour calculer le pk
let header = crate::download::peek_header(url, 2048)
.await
.map_err(|e| anyhow!("Failed to peek header: {}", e))?;
let pk = crate::cache_trait::pk_from_content_header(&header);
// 2. Si déjà en cache (complet), commuter directement
if self.check_cached_and_complete(&pk).await? {
self.db.update_lazy_to_downloaded(lazy_pk, &pk)?;
return Ok(pk);
}
// 3. Lancer le téléchargement complet
tracing::debug!("Starting lazy download for pk {} (lazy {})", pk, lazy_pk);
let file_path = self.get_file_path(&pk);
let transformer = self.transformer_factory.as_ref().map(|f| f());
let download = download_with_transformer(&file_path, url, transformer);
{
let mut downloads = self.downloads.write().await;
downloads.insert(pk.clone(), download.clone());
}
self.finalize_download(
&pk,
download,
collection,
Some(url),
FinalizeMode::ConvertLazy { lazy_pk },
)
.await
}
/// Ajoute un fichier à partir d'un flux asynchrone. /// Ajoute un fichier à partir d'un flux asynchrone.
/// ///
/// Cette méthode utilise le même système d'identifiants basé sur le contenu que `add_from_url`. /// Cette méthode utilise le même système d'identifiants basé sur le contenu que `add_from_url`.
@@ -725,7 +827,7 @@ 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) self.finalize_download(&pk, download, collection, source_uri, FinalizeMode::InsertNew)
.await .await
} }
@@ -1297,71 +1399,95 @@ impl<C: CacheConfig> Cache<C> {
} }
} }
/// Ajoute une URL en mode deferred (pas de download immédiat) /// Garantit l'existence d'une entrée lazy spécifique.
/// pub async fn ensure_lazy_entry(
/// Vérifie d'abord si l'URL existe déjà en DB : &self,
/// - Si eager (déjà téléchargé) : retourne le lazy_pk si existe, sinon le real pk lazy_pk: &str,
/// - Si lazy (pas encore téléchargé) : retourne le lazy pk existant collection: Option<&str>,
/// - Sinon : crée nouvelle entry lazy origin_url: Option<&str>,
/// ) -> Result<()> {
/// # Arguments if let Ok(true) = self.db.has_lazy_entry(lazy_pk) {
/// self.db.update_hit_by_lazy_pk(lazy_pk)?;
/// * `url` - URL à cacher } else {
/// * `collection` - Collection optionnelle self.db.add_lazy(lazy_pk, None, collection)?;
/// }
/// # Returns
/// if let Some(url) = origin_url {
/// PK (lazy ou real selon l'état) self.db.set_origin_url_for_lazy(lazy_pk, url)?;
/// }
/// # Example
/// Ok(())
/// ```rust,no_run }
/// let cache = Cache::<AudioConfig>::new("./cache", 1000)?;
/// let lazy_pk = cache.add_from_url_deferred("https://example.com/track.mp3", Some("qobuz")).await?; /// Récupère auprès du provider les métadonnées/couvertures associées.
/// // → Returns "L:abc123..." (lazy PK) pub async fn fetch_lazy_provider_data(&self, lazy_pk: &str) -> Result<LazyEntryRemoteData> {
/// // Fichier pas encore téléchargé, juste métadonnées en DB 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 })
} else {
Ok(LazyEntryRemoteData::default())
}
}
/// Résout l'URL d'origine pour un lazy PK, via la DB ou un provider.
pub async fn resolve_lazy_url(&self, lazy_pk: &str) -> Result<String> {
if let Ok(Some(url)) = self.db.get_origin_url(lazy_pk) {
return Ok(url);
}
if let Some(provider) = self.provider_for_lazy_pk(lazy_pk) {
return provider.get_url(lazy_pk).await;
}
bail!("No origin URL or lazy provider registered for {}", lazy_pk);
}
/// Télécharge un lazy PK en résolvant automatiquement son URL.
pub async fn download_lazy(&self, lazy_pk: &str, collection: Option<&str>) -> Result<String> {
let (existing_pk, _lazy_sec, existing_collection) = self
.db
.get_entry_by_pk_or_lazy_pk(lazy_pk)?
.ok_or_else(|| anyhow!("Lazy pk {} not found in DB", lazy_pk))?;
let url = self.resolve_lazy_url(lazy_pk).await?;
let collection = collection.or(existing_collection.as_deref());
if let Some(real_pk) = existing_pk {
if real_pk != lazy_pk && self.check_cached_and_complete(&real_pk).await? {
return Ok(real_pk);
}
}
self.download_lazy_from_url(lazy_pk, &url, collection).await
}
/// Ajoute une URL sans lancer immédiatement le téléchargement.
pub async fn add_from_url_deferred( pub async fn add_from_url_deferred(
&self, &self,
url: &str, url: &str,
collection: Option<&str>, collection: Option<&str>,
) -> Result<String> { ) -> Result<String> {
// 1. Vérifier si URL déjà en cache
if let Ok(Some((pk_opt, lazy_pk_opt))) = self.db.get_entry_by_url(url) { if let Ok(Some((pk_opt, lazy_pk_opt))) = self.db.get_entry_by_url(url) {
// URL existe déjà
if let Some(pk) = pk_opt { if let Some(pk) = pk_opt {
// Fichier déjà téléchargé (eager ou lazy→eager)
tracing::debug!("URL {} already downloaded with pk {}", url, pk);
self.db.update_hit(&pk)?; self.db.update_hit(&pk)?;
// Retourner lazy_pk si existe (pour compatibilité Control Point)
// sinon retourner pk
if let Some(lpk) = lazy_pk_opt { if let Some(lpk) = lazy_pk_opt {
return Ok(lpk); return Ok(lpk);
} }
return Ok(pk); return Ok(pk);
} else if let Some(lpk) = lazy_pk_opt { } else if let Some(lpk) = lazy_pk_opt {
// Entry lazy existante (pas encore téléchargé)
tracing::debug!("URL {} already in lazy mode with pk {}", url, lpk);
self.db.update_hit_by_lazy_pk(&lpk)?; self.db.update_hit_by_lazy_pk(&lpk)?;
return Ok(lpk); return Ok(lpk);
} }
} }
// 2. URL inconnue → créer nouvelle entry lazy let lazy_pk = format!("L:{}", generate_lazy_pk(url));
let lazy_pk = generate_lazy_pk(url);
// Vérifier si ce lazy_pk existe déjà (collision improbable mais...)
if let Ok(true) = self.db.has_lazy_entry(&lazy_pk) { if let Ok(true) = self.db.has_lazy_entry(&lazy_pk) {
bail!("Lazy PK collision for URL: {}", url); bail!("Lazy PK collision for URL: {}", url);
} }
// 3. Ajouter en DB self.ensure_lazy_entry(&lazy_pk, collection, Some(url)).await?;
self.db.add_lazy(&lazy_pk, None, collection)?;
self.db.set_origin_url_for_lazy(&lazy_pk, url)?;
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)
} }
} }

View File

@@ -111,6 +111,7 @@ impl DB {
/// ``` /// ```
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)?;
conn.execute("PRAGMA foreign_keys = ON", [])?;
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS asset ( "CREATE TABLE IF NOT EXISTS asset (
@@ -130,9 +131,10 @@ impl DB {
value_type TEXT NOT NULL CHECK (value_type IN ('string','number','boolean','null')), value_type TEXT NOT NULL CHECK (value_type IN ('string','number','boolean','null')),
value TEXT, value TEXT,
PRIMARY KEY (pk, key), PRIMARY KEY (pk, key),
FOREIGN KEY (pk) REFERENCES asset (pk) ON DELETE CASCADE FOREIGN KEY (pk) REFERENCES asset (pk) ON DELETE CASCADE ON UPDATE CASCADE
)" )",
, [])?; [],
)?;
// Créer un index sur la collection pour les requêtes rapides // Créer un index sur la collection pour les requêtes rapides
conn.execute( conn.execute(
@@ -856,11 +858,9 @@ impl DB {
/// * `real_pk` - Le real PK calculé après téléchargement /// * `real_pk` - Le real PK calculé après téléchargement
pub fn update_lazy_to_downloaded(&self, lazy_pk: &str, real_pk: &str) -> rusqlite::Result<()> { pub fn update_lazy_to_downloaded(&self, lazy_pk: &str, real_pk: &str) -> rusqlite::Result<()> {
let mut conn = self.lock_conn("update_lazy_to_downloaded"); let mut conn = self.lock_conn("update_lazy_to_downloaded");
let tx = conn.transaction()?; let tx = conn.transaction()?;
// 1. Récupérer l'entry lazy (pk = lazy_pk tant que pas téléchargé) let (current_pk, collection, id, hits): (String, Option<String>, Option<String>, i32) = tx
let (old_pk, collection, id, hits): (String, Option<String>, Option<String>, i32) = tx
.query_row( .query_row(
"SELECT pk, collection, id, hits FROM asset WHERE lazy_pk = ?1", "SELECT pk, collection, id, hits FROM asset WHERE lazy_pk = ?1",
[lazy_pk], [lazy_pk],
@@ -869,35 +869,40 @@ impl DB {
.optional()? .optional()?
.ok_or_else(|| Error::QueryReturnedNoRows)?; .ok_or_else(|| Error::QueryReturnedNoRows)?;
if old_pk == real_pk { if current_pk == real_pk {
// Rien à faire si déjà commuté
return Ok(()); return Ok(());
} }
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
let hits_to_add = if hits > 0 { hits } else { 1 }; let hits_to_add = if hits > 0 { hits } else { 1 };
// 2. Créer/mettre à jour l'entry avec le real pk // Supprimer d'éventuelles métadonnées résiduelles associées au futur pk réel
tx.execute( // (peut arriver si un ancien téléchargement a laissé des traces sans asset correspondant).
"INSERT INTO asset (pk, lazy_pk, collection, id, hits, last_used) tx.execute("DELETE FROM metadata WHERE pk = ?1", [real_pk])?;
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(pk) DO UPDATE SET let updated = tx.execute(
lazy_pk = excluded.lazy_pk, "UPDATE asset
collection = COALESCE(excluded.collection, collection), SET pk = ?1,
id = COALESCE(excluded.id, id), lazy_pk = ?2,
hits = hits + excluded.hits, collection = COALESCE(?3, collection),
last_used = excluded.last_used", id = COALESCE(?4, id),
params![real_pk, lazy_pk, collection, id, hits_to_add, now], hits = hits + ?5,
last_used = ?6
WHERE lazy_pk = ?7",
params![
real_pk,
lazy_pk,
collection,
id,
hits_to_add,
now,
lazy_pk
],
)?; )?;
// 3. Re-pointer les métadonnées vers le real pk if updated == 0 {
tx.execute( return Err(Error::QueryReturnedNoRows);
"UPDATE metadata SET pk = ?1 WHERE pk = ?2", }
params![real_pk, old_pk],
)?;
// 4. Supprimer l'ancienne entry lazy
tx.execute("DELETE FROM asset WHERE pk = ?1", [old_pk])?;
tx.commit() tx.commit()
} }
@@ -952,6 +957,20 @@ impl DB {
Ok(result) Ok(result)
} }
/// Retourne une entry à partir d'un pk ou lazy_pk.
pub fn get_entry_by_pk_or_lazy_pk(
&self,
value: &str,
) -> rusqlite::Result<Option<(Option<String>, Option<String>, Option<String>)>> {
let conn = self.lock_conn("get_entry_by_pk_or_lazy_pk");
conn.query_row(
"SELECT pk, lazy_pk, collection FROM asset WHERE pk = ?1 OR lazy_pk = ?1 LIMIT 1",
[value],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()
}
/// Met à jour le compteur d'accès pour une entry lazy (pk = NULL) /// Met à jour le compteur d'accès pour une entry lazy (pk = NULL)
/// ///
/// # Arguments /// # Arguments

42
pmocache/src/lazy.rs Normal file
View File

@@ -0,0 +1,42 @@
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
/// Retourne le préfixe d'un lazy PK (`PREFIX:VALUE`)
pub fn lazy_prefix_from_pk(lazy_pk: &str) -> Option<&str> {
lazy_pk.split_once(':').map(|(prefix, _)| prefix)
}
/// Données optionnelles pouvant être fournies par un [`LazyProvider`]
#[derive(Debug, Clone, Default)]
pub struct LazyEntryRemoteData {
pub metadata: Option<Value>,
pub cover_url: Option<String>,
}
/// Trait générique décrivant un fournisseur de lazy PK.
///
/// Chaque implémentation est responsable d'un préfixe particulier (ex: `QOBUZ`).
/// Lorsque le cache rencontre un lazy PK dont le préfixe correspond,
/// il délègue au provider pour résoudre l'URL et récupérer les informations
/// nécessaires (métadonnées, couverture, etc.).
#[async_trait]
pub trait LazyProvider: Send + Sync {
/// Préfixe associé (sans le `:` final).
fn lazy_prefix(&self) -> &'static str;
/// Retourne l'URL de téléchargement actuelle pour ce lazy PK.
async fn get_url(&self, lazy_pk: &str) -> Result<String>;
/// Métadonnées optionnelles à associer immédiatement à l'entrée lazy.
async fn metadata(&self, lazy_pk: &str) -> Result<Option<Value>> {
let _ = lazy_pk;
Ok(None)
}
/// URL de couverture éventuelle pour permettre un cache eager des jaquettes.
async fn cover_url(&self, lazy_pk: &str) -> Result<Option<String>> {
let _ = lazy_pk;
Ok(None)
}
}

View File

@@ -115,6 +115,7 @@ pub mod cache;
pub mod cache_trait; pub mod cache_trait;
pub mod db; pub mod db;
pub mod download; pub mod download;
pub mod lazy;
pub mod metadata_macros; pub mod metadata_macros;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
@@ -134,6 +135,7 @@ 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,

View File

@@ -110,15 +110,38 @@ async fn get_file_with_param<C: CacheConfig + 'static>(
serve_file_with_streaming(&cache, &pk, &param, content_type, param_generator).await serve_file_with_streaming(&cache, &pk, &param, content_type, param_generator).await
} }
#[cfg(feature = "pmoserver")]
async fn serve_finalized_pk<C: CacheConfig>(
cache: &Arc<Cache<C>>,
pk: &str,
param: &str,
content_type: &'static str,
) -> Response {
let qualifier = param.to_string();
let file_path = cache.get_file_path_with_qualifier(pk, param);
if let Err(e) = cache.db.update_hit(pk) {
warn!("Error updating hit count for {}: {}", pk, e);
}
let response = serve_complete_file(file_path, content_type).await;
if response.status().is_success() {
cache.notify_broadcast(pk, &qualifier).await;
}
response
}
/// Handler spécifique pour les lazy PK /// Handler spécifique pour les lazy PK
/// ///
/// Gère le téléchargement on-demand des fichiers lazy : /// Gère le téléchargement on-demand des fichiers lazy :
/// 1. Fast path : vérifie si déjà téléchargé /// 1. Fast path : vérifie si déjà téléchargé
/// 2. Récupère l'origin_url depuis la DB /// 2. Résout l'URL via la DB ou un provider
/// 3. Lance le téléchargement et calcule le real pk /// 3. Lance le téléchargement et calcule le real pk
/// 4. Met à jour la DB (lazy → downloaded) /// 4. Met à jour la DB (lazy → downloaded)
/// 5. Broadcast l'event pour PK switching /// 5. Broadcast l'event pour PK switching
/// 6. Redirige vers le real pk /// 6. Sert directement le fichier téléchargé
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
async fn serve_lazy_audio_file<C: CacheConfig>( async fn serve_lazy_audio_file<C: CacheConfig>(
cache: &Arc<Cache<C>>, cache: &Arc<Cache<C>>,
@@ -126,54 +149,20 @@ async fn serve_lazy_audio_file<C: CacheConfig>(
param: &str, param: &str,
content_type: &'static str, content_type: &'static str,
) -> Response { ) -> Response {
use axum::response::Redirect;
tracing::info!("Lazy download triggered for pk: {}", lazy_pk); tracing::info!("Lazy download triggered for pk: {}", lazy_pk);
// 1. Vérifier si déjà téléchargé (fast path) // 1. Vérifier si déjà téléchargé (fast path)
if let Ok(Some(real_pk)) = cache.db.get_pk_by_lazy_pk(lazy_pk) { if let Ok(Some(real_pk)) = cache.db.get_pk_by_lazy_pk(lazy_pk) {
tracing::debug!( tracing::debug!(
"Lazy PK {} already downloaded as {}, redirecting", "Lazy PK {} already downloaded as {}, serving immediately",
lazy_pk, lazy_pk,
real_pk real_pk
); );
return serve_finalized_pk(cache, &real_pk, param, content_type).await;
// Construire l'URL de redirection
let redirect_url = if param == C::default_param() {
format!(
"/{}/{}/{}",
C::cache_name(),
C::cache_type(),
real_pk
)
} else {
format!(
"/{}/{}/{}/{}",
C::cache_name(),
C::cache_type(),
real_pk,
param
)
};
return Redirect::temporary(&redirect_url).into_response();
} }
// 2. Récupérer origin_url // 2. Télécharger en résolvant l'URL via la DB ou un provider
let origin_url = match cache.db.get_origin_url(lazy_pk) { let real_pk = match cache.download_lazy(lazy_pk, None).await {
Ok(Some(url)) => url,
Ok(None) => {
tracing::error!("Lazy PK {} has no origin_url", lazy_pk);
return (StatusCode::NOT_FOUND, "Origin URL not found").into_response();
}
Err(e) => {
tracing::error!("Error getting origin_url for {}: {}", lazy_pk, e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
};
// 3. Lancer download complet (cela calcule le VRAI pk basé sur content[512:2048])
let real_pk = match cache.add_from_url(&origin_url, None).await {
Ok(pk) => pk, Ok(pk) => pk,
Err(e) => { Err(e) => {
tracing::error!("Failed to download lazy file: {}", e); tracing::error!("Failed to download lazy file: {}", e);
@@ -185,46 +174,11 @@ async fn serve_lazy_audio_file<C: CacheConfig>(
} }
}; };
// 4. Mettre à jour DB : lazy_pk → real_pk mapping // 4. Broadcast event pour prefetch ET commutation PK
if let Err(e) = cache.db.update_lazy_to_downloaded(lazy_pk, &real_pk) {
tracing::error!(
"Failed to update DB for lazy transition {} → {}: {}",
lazy_pk,
real_pk,
e
);
// Continuer quand même, le fichier est téléchargé
}
// 5. Broadcast event pour prefetch ET commutation PK
cache.broadcast_lazy_downloaded(lazy_pk, &real_pk).await; cache.broadcast_lazy_downloaded(lazy_pk, &real_pk).await;
// 6. Rediriger vers la vraie URL // 5. Servir directement le fichier téléchargé
let redirect_url = if param == C::default_param() { serve_finalized_pk(cache, &real_pk, param, content_type).await
format!(
"/{}/{}/{}",
C::cache_name(),
C::cache_type(),
real_pk
)
} else {
format!(
"/{}/{}/{}/{}",
C::cache_name(),
C::cache_type(),
real_pk,
param
)
};
tracing::debug!(
"Lazy PK {} downloaded as {}, redirecting to {}",
lazy_pk,
real_pk,
redirect_url
);
Redirect::temporary(&redirect_url).into_response()
} }
/// Fonction utilitaire pour servir un fichier avec streaming progressif /// Fonction utilitaire pour servir un fichier avec streaming progressif

View File

@@ -63,15 +63,17 @@ pmosource = { path = "../pmosource" }
# Playlist management # Playlist management
pmoplaylist = { path = "../pmoplaylist" } pmoplaylist = { path = "../pmoplaylist" }
pmocache = { path = "../pmocache" }
[features] [features]
default = [] default = ["async-trait-support"]
# Feature pour activer les extensions pmoserver # Feature pour activer les extensions pmoserver
pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"] pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"]
# Feature pour activer le support serveur (cache registry) # Feature pour activer le support serveur (cache registry)
server = ["pmosource/server"] server = ["pmosource/server"]
# Feature cache (deprecated - toujours actif maintenant) # Feature cache (deprecated - toujours actif maintenant)
cache = [] cache = []
async-trait-support = ["dep:async-trait"]
disk-cache = ["dep:rusqlite", "dep:async-trait"] disk-cache = ["dep:rusqlite", "dep:async-trait"]
[dev-dependencies] [dev-dependencies]
@@ -81,7 +83,6 @@ mockito = "1.0"
tempfile = "3.0" tempfile = "3.0"
# Pour les exemples # Pour les exemples
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
pmocache = { path = "../pmocache" }
# Pour l'exemple spoofer # Pour l'exemple spoofer
# Specify that the with_cache example requires the cache feature # Specify that the with_cache example requires the cache feature

View File

@@ -0,0 +1,91 @@
use crate::client::QobuzClient;
use crate::models::Track;
use anyhow::{anyhow, Result};
use pmoaudiocache::AudioMetadata;
use pmocache::lazy::LazyProvider;
use serde_json::Value;
use std::sync::Arc;
pub struct QobuzLazyProvider {
client: Arc<QobuzClient>,
}
impl QobuzLazyProvider {
pub fn new(client: Arc<QobuzClient>) -> Self {
Self { client }
}
fn track_id_from_lazy<'a>(&self, lazy_pk: &'a str) -> Result<&'a str> {
match lazy_pk.split_once(':') {
Some((prefix, value)) if prefix.eq_ignore_ascii_case("QOBUZ") => Ok(value),
_ => Err(anyhow!("Invalid Qobuz lazy pk {}", lazy_pk)),
}
}
async fn fetch_track(&self, lazy_pk: &str) -> Result<Track> {
let track_id = self.track_id_from_lazy(lazy_pk)?;
self.client
.get_track(track_id)
.await
.map_err(|e| anyhow!("Failed to fetch track {}: {}", track_id, e))
}
fn build_metadata(track: &Track) -> AudioMetadata {
AudioMetadata {
title: Some(track.title.clone()),
artist: track.performer.as_ref().map(|p| p.name.clone()),
album: track.album.as_ref().map(|a| a.title.clone()),
duration_secs: Some(track.duration as u64),
year: track.album.as_ref().and_then(|a| {
a.release_date
.as_ref()
.and_then(|d| d.split('-').next()?.parse().ok())
}),
track_number: Some(track.track_number),
track_total: track.album.as_ref().and_then(|a| a.tracks_count),
disc_number: Some(track.media_number),
disc_total: None,
genre: track.album.as_ref().and_then(|a| {
if !a.genres.is_empty() {
Some(a.genres.join(", "))
} else {
None
}
}),
sample_rate: track.sample_rate,
channels: track.channels,
bitrate: None,
conversion: None,
}
}
}
#[async_trait::async_trait]
impl LazyProvider for QobuzLazyProvider {
fn lazy_prefix(&self) -> &'static str {
"QOBUZ"
}
async fn get_url(&self, lazy_pk: &str) -> Result<String> {
let track_id = self.track_id_from_lazy(lazy_pk)?;
self.client
.get_stream_url(track_id)
.await
.map_err(|e| anyhow!("Failed to resolve stream URL for {}: {}", track_id, e))
}
async fn metadata(&self, lazy_pk: &str) -> Result<Option<Value>> {
let track = self.fetch_track(lazy_pk).await?;
let metadata = Self::build_metadata(&track);
let value = serde_json::to_value(metadata)?;
Ok(Some(value))
}
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) }))
}
}

View File

@@ -214,6 +214,7 @@ pub mod didl;
#[cfg(feature = "disk-cache")] #[cfg(feature = "disk-cache")]
pub mod disk_cache; pub mod disk_cache;
pub mod error; pub mod error;
mod lazy_provider;
pub mod models; pub mod models;
pub mod source; pub mod source;

View File

@@ -5,6 +5,7 @@
use crate::client::QobuzClient; use crate::client::QobuzClient;
use crate::didl::ToDIDL; use crate::didl::ToDIDL;
use crate::lazy_provider::QobuzLazyProvider;
use crate::models::Track; use crate::models::Track;
use pmoaudiocache::{AudioMetadata, Cache as AudioCache}; use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
use pmocovers::Cache as CoverCache; use pmocovers::Cache as CoverCache;
@@ -71,7 +72,7 @@ pub struct QobuzSource {
struct QobuzSourceInner { struct QobuzSourceInner {
/// Qobuz API client /// Qobuz API client
client: QobuzClient, client: Arc<QobuzClient>,
/// Cache manager (centralisé) /// Cache manager (centralisé)
cache_manager: SourceCacheManager, cache_manager: SourceCacheManager,
@@ -103,6 +104,8 @@ impl QobuzSource {
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub fn from_registry(client: QobuzClient) -> Result<Self> { pub fn from_registry(client: QobuzClient) -> Result<Self> {
let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?; let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?;
let client = Arc::new(client);
cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone())));
Ok(Self { Ok(Self {
inner: Arc::new(QobuzSourceInner { inner: Arc::new(QobuzSourceInner {
@@ -127,6 +130,8 @@ impl QobuzSource {
audio_cache: Arc<AudioCache>, audio_cache: Arc<AudioCache>,
) -> Self { ) -> Self {
let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache); let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache);
let client = Arc::new(client);
cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone())));
Self { Self {
inner: Arc::new(QobuzSourceInner { inner: Arc::new(QobuzSourceInner {
@@ -243,7 +248,9 @@ impl QobuzSource {
pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> { pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> {
let track_id = format!("qobuz://track/{}", track.id); let track_id = format!("qobuz://track/{}", track.id);
// Get streaming URL let lazy_pk = format!("QOBUZ:{}", track.id);
// Get streaming URL (for metadata fallback)
let stream_url = self let stream_url = self
.inner .inner
.client .client
@@ -290,15 +297,19 @@ impl QobuzSource {
conversion: None, conversion: None,
}; };
// 3. Cache audio LAZILY (KEY CHANGE: use cache_audio_lazy) // 3. Cache audio LAZILY avec un provider
let cached_audio_pk = self let cached_audio_pk = self
.inner .inner
.cache_manager .cache_manager
.cache_audio_lazy(&stream_url, Some(metadata)) .cache_audio_lazy_with_provider(
&lazy_pk,
Some(metadata.clone()),
cached_cover_pk.clone(),
)
.await .await
.map_err(|e| { .map_err(|e| {
MusicSourceError::CacheError(format!( MusicSourceError::CacheError(format!(
"Failed to cache lazy track {}: {}", "Failed to register lazy track {}: {}",
track.title, e track.title, e
)) ))
})?; })?;

View File

@@ -52,7 +52,7 @@ def main():
mime_type = file_url_data.get('mime_type', '') mime_type = file_url_data.get('mime_type', '')
print(f" ✓ Success!") print(f" ✓ Success!")
print(f" URL: {url[:80]}...") print(f" URL: {url}...")
print(f" MIME type: {mime_type}") print(f" MIME type: {mime_type}")
print("\n=== Test completed successfully! ===") print("\n=== Test completed successfully! ===")

View File

@@ -29,6 +29,7 @@ pmoplaylist = { path = "../pmoplaylist" }
# Optional cache integrations # Optional cache integrations
pmoaudiocache = { path = "../pmoaudiocache", optional = true } pmoaudiocache = { path = "../pmoaudiocache", optional = true }
pmocovers = { path = "../pmocovers", optional = true } pmocovers = { path = "../pmocovers", optional = true }
pmocache = { path = "../pmocache", optional = true }
# Serialization (required for cache metadata) # Serialization (required for cache metadata)
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@@ -49,5 +50,5 @@ futures = { version = "0.3", optional = true }
[features] [features]
default = ["cache"] default = ["cache"]
cache = ["pmoaudiocache", "pmocovers"] cache = ["pmoaudiocache", "pmocovers", "pmocache"]
server = ["pmoserver", "pmoconfig", "pmoupnp", "axum", "utoipa", "tracing", "lazy_static", "tokio-stream", "futures"] server = ["pmoserver", "pmoconfig", "pmoupnp", "axum", "utoipa", "tracing", "lazy_static", "tokio-stream", "futures"]

View File

@@ -20,6 +20,7 @@
use crate::{CacheStatus, MusicSourceError, Result}; use crate::{CacheStatus, MusicSourceError, Result};
use pmoaudiocache::{AudioMetadata, Cache as AudioCache}; use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
use pmocache::lazy::LazyProvider;
use pmocovers::Cache as CoverCache; use pmocovers::Cache as CoverCache;
use serde_json::{json, Value as JsonValue}; use serde_json::{json, Value as JsonValue};
use std::collections::HashMap; use std::collections::HashMap;
@@ -130,6 +131,11 @@ impl SourceCacheManager {
} }
} }
/// Enregistre un provider lazy supplémentaire auprès du cache audio.
pub fn register_lazy_provider(&self, provider: Arc<dyn LazyProvider>) {
self.audio_cache.register_lazy_provider(provider);
}
/// Résoudre l'URI d'une piste (priorité au cache) /// Résoudre l'URI d'une piste (priorité au cache)
/// ///
/// Retourne l'URI du fichier audio en cache si disponible, /// Retourne l'URI du fichier audio en cache si disponible,
@@ -259,21 +265,79 @@ impl SourceCacheManager {
.await .await
.map_err(|e| MusicSourceError::CacheError(e.to_string()))?; .map_err(|e| MusicSourceError::CacheError(e.to_string()))?;
// Store metadata in lazy_pk metadata table if provided // Seed individual metadata entries directly (no redundant JSON blob)
if let Some(meta) = metadata { if let Some(meta) = metadata {
// Serialize metadata to JSON and store
if let Ok(json) = serde_json::to_value(&meta) {
let _ = self
.audio_cache
.db
.set_a_metadata_by_key(&lazy_pk, "audio_metadata", json);
}
self.seed_audio_metadata(&lazy_pk, &meta); self.seed_audio_metadata(&lazy_pk, &meta);
} }
Ok(lazy_pk) Ok(lazy_pk)
} }
/// Crée une entrée lazy basée sur un provider enregistré.
///
/// # Arguments
///
/// * `lazy_pk` - Identifiant logique (préfixe provider + valeur)
/// * `metadata` - Métadonnées optionnelles connues à l'avance
/// * `cover_pk_hint` - PK éventuel d'une cover déjà cachée
pub async fn cache_audio_lazy_with_provider(
&self,
lazy_pk: &str,
metadata: Option<AudioMetadata>,
cover_pk_hint: Option<String>,
) -> Result<String> {
self.audio_cache
.ensure_lazy_entry(lazy_pk, Some(&self.collection_id), None)
.await
.map_err(|e| MusicSourceError::CacheError(e.to_string()))?;
let needs_provider_metadata = metadata.is_none();
let needs_provider_cover = cover_pk_hint.is_none();
let provider_data = if needs_provider_metadata || needs_provider_cover {
Some(
self.audio_cache
.fetch_lazy_provider_data(lazy_pk)
.await
.map_err(|e| MusicSourceError::CacheError(e.to_string()))?,
)
} else {
None
};
let mut final_metadata = metadata;
if final_metadata.is_none() {
if let Some(data) = provider_data.as_ref() {
if let Some(value) = data.metadata.as_ref() {
if let Ok(meta) = serde_json::from_value::<AudioMetadata>(value.clone()) {
final_metadata = Some(meta);
}
}
}
}
let mut final_cover_pk = cover_pk_hint;
if final_cover_pk.is_none() {
if let Some(data) = provider_data.as_ref() {
if let Some(url) = data.cover_url.as_ref() {
if let Ok(pk) = self.cache_cover(url).await {
final_cover_pk = Some(pk);
}
}
}
}
if let Some(meta) = final_metadata.as_ref() {
self.seed_audio_metadata(lazy_pk, meta);
}
if let Some(cover_pk) = final_cover_pk {
let _ = self.set_audio_metadata(lazy_pk, "cover_pk", json!(cover_pk));
}
Ok(lazy_pk.to_string())
}
/// Cache un flux audio via un reader asynchrone /// Cache un flux audio via un reader asynchrone
pub async fn cache_audio_from_reader<R>( pub async fn cache_audio_from_reader<R>(
&self, &self,
@@ -323,7 +387,7 @@ impl SourceCacheManager {
/// Pré-remplit les métadonnées audio pour une entrée lazy /// Pré-remplit les métadonnées audio pour une entrée lazy
fn seed_audio_metadata(&self, audio_pk: &str, metadata: &AudioMetadata) { fn seed_audio_metadata(&self, audio_pk: &str, metadata: &AudioMetadata) {
let audio_pk = audio_pk.to_string(); let audio_pk = audio_pk.to_string();
let mut store = |key: &str, value: JsonValue| { let store = |key: &str, value: JsonValue| {
if let Err(e) = self.audio_cache.db.set_a_metadata(&audio_pk, key, value) { if let Err(e) = self.audio_cache.db.set_a_metadata(&audio_pk, key, value) {
log_metadata_warning(key, &audio_pk, &e.to_string()); log_metadata_warning(key, &audio_pk, &e.to_string());
} }