feat(cache): fix lazy PK metadata resolution and playlist track ordering

- In `track_metadata.rs`, implement fallback logic for lazy PKs: first try real_pk, then fallback to lazy_pk for metadata keys seeded before download.
- In `db.rs`, add debug logging after migration to track remaining metadata under lazy_pk.
- In `source.rs` (Qobuz):
  - Detect and repair playlists with missing cache entries by forcing refresh when `items.len() < total`.
  - Preserve original track order after parallel processing by attaching and sorting on original index.
  - Handle missing performer/cover gracefully with warnings and provider fallback.
- In `metadata_cache.rs` (Radio France), simplify logging and improve test coverage for expiration logic.
- In `cache.rs`, add debug/warning logs when metadata or cover seeding fails during lazy caching.
This commit is contained in:
2026-03-25 12:25:34 +01:00
parent 09cc2dcf94
commit 5095dd6964
5 changed files with 92 additions and 25 deletions

View File

@@ -35,6 +35,18 @@ impl AudioCacheTrackMetadata {
}
fn read_raw(&self, key: &str) -> Result<Option<Value>, MetadataError> {
// Si le pk est un lazy pk déjà téléchargé (pk != lazy_pk dans l'asset),
// lire d'abord sous le real_pk (métadonnées écrites par FlacCacheSink),
// puis fallback sous le lazy_pk (cover_pk, qobuz_track_id semés à l'enregistrement).
if pmocache::is_lazy_pk(&self.pk) {
if let Ok(Some(real_pk)) = self.cache.db.get_pk_by_lazy_pk(&self.pk) {
if let Ok(Some(v)) = self.cache.db.get_a_metadata(&real_pk, key) {
return Ok(Some(v));
}
// Fallback vers lazy_pk pour les clés semées avant téléchargement (cover_pk, etc.)
return self.cache.db.get_a_metadata(&self.pk, key).map_err(map_db_err);
}
}
self.cache
.db
.get_a_metadata(&self.pk, key)

View File

@@ -1137,7 +1137,23 @@ impl DB {
return Err(Error::QueryReturnedNoRows);
}
tx.commit()
tx.commit()?;
// Compter les métadonnées encore sous l'ancien lazy_pk (après migration de l'asset)
let meta_under_lazy: i64 = {
let conn = self.lock_conn("update_lazy_to_downloaded_meta_check");
conn.query_row(
"SELECT COUNT(*) FROM metadata WHERE pk = ?1",
[lazy_pk],
|r| r.get(0),
).unwrap_or(0)
};
tracing::debug!(
"update_lazy_to_downloaded: {} → {} ({} metadata rows still under lazy_pk)",
lazy_pk, real_pk, meta_under_lazy
);
Ok(())
}
/// Recherche une entry par son origin_url

View File

@@ -795,11 +795,19 @@ impl QobuzSource {
.get_read_handle(&pmo_playlist_id)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
let (items, _total) = reader
let (items, total) = reader
.to_items_paged(0, usize::MAX)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
return self.adapt_items_to_qobuz(items, &parent_id).await;
// Si des PKs ne sont pas enregistrés dans le cache audio (e.g. playlist créée
// avec l'ancien code), on force un refresh pour les ré-enregistrer proprement.
if items.len() == total {
return self.adapt_items_to_qobuz(items, &parent_id).await;
}
info!(
"Qobuz playlist {} has {}/{} valid items, forcing refresh to repair missing entries",
pmo_playlist_id, items.len(), total
);
}
info!("Qobuz playlist {} creating/refreshing (version {:?})", pmo_playlist_id, qobuz_version);
@@ -1047,7 +1055,9 @@ impl QobuzSource {
// 600 tracks ne génèrent que ~N_albums_uniques téléchargements réels.
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(16));
let futs: Vec<_> = tracks.iter().map(|track| {
// On attache l'index original à chaque future pour pouvoir retrier dans l'ordre
// d'origine après complétion parallèle (JoinSet retourne dans l'ordre de fin).
let futs: Vec<_> = tracks.iter().enumerate().map(|(idx, track)| {
let source = self.clone();
let sem = sem.clone();
let track_id = track.id.clone();
@@ -1084,16 +1094,31 @@ impl QobuzSource {
None
};
// 2. Register lazy entry + set cover_pk + seed metadata
// 2. Register lazy entry + set cover_pk + seed metadata.
// Si le performer est absent (réponse API incomplète), on passe None pour
// que le provider appelle get_track et récupère les métadonnées complètes.
if metadata.artist.is_none() {
tracing::warn!(
"register_tracks_lazy: no performer for track {} (id={}), will call provider",
track_title, track_id
);
}
if cover_pk.is_none() && cover_image_url.is_some() {
tracing::warn!(
"register_tracks_lazy: cover download failed for track {} (id={}), will call provider for cover",
track_title, track_id
);
}
let meta_hint = if metadata.artist.is_some() { Some(metadata) } else { None };
match source.inner.cache_manager
.cache_audio_lazy_with_provider(&lazy_pk, Some(metadata), cover_pk)
.cache_audio_lazy_with_provider(&lazy_pk, meta_hint, cover_pk)
.await
{
Ok(pk) => {
let _ = source.inner.cache_manager.set_audio_metadata(
&pk, "qobuz_track_id", json!(track_id),
);
Some(pk)
Some((idx, pk))
}
Err(e) => {
tracing::warn!("Failed to register lazy track {}: {}", track_title, e);
@@ -1103,12 +1128,15 @@ impl QobuzSource {
}
}).collect();
tokio::task::JoinSet::from_iter(futs)
// Retrier par index original pour conserver l'ordre de la playlist Qobuz
let mut results: Vec<(usize, String)> = tokio::task::JoinSet::from_iter(futs)
.join_all()
.await
.into_iter()
.flatten()
.collect()
.collect();
results.sort_unstable_by_key(|(i, _)| *i);
results.into_iter().map(|(_, pk)| pk).collect()
}
/// Cache les covers d'une liste d'artistes en parallèle.

View File

@@ -26,9 +26,6 @@ use tokio::sync::RwLock;
#[cfg(feature = "cache")]
use pmocovers::Cache as CoverCache;
#[cfg(feature = "cache")]
use pmocache::cache_trait::FileCache;
// ============================================================================
// CachedMetadata
// ============================================================================
@@ -583,11 +580,7 @@ impl MetadataCache {
}
Err(discover_err) => {
#[cfg(feature = "logging")]
tracing::error!(
"Rediscovery failed for '{}': {}",
slug,
discover_err
);
tracing::error!("Rediscovery failed for '{}': {}", slug, discover_err);
return Err(e);
}
}
@@ -697,12 +690,7 @@ impl MetadataCache {
match self.client.rediscover_station(slug).await {
Ok((id, url)) => {
#[cfg(feature = "logging")]
tracing::info!(
"Re-discovered station '{}': id={}, url={}",
slug,
id,
url
);
tracing::info!("Re-discovered station '{}': id={}, url={}", slug, id, url);
self.persist_station_mapping();
// Invalider le cache mémoire pour forcer un refresh des métadonnées
let mut cache = self.cache.write().await;
@@ -801,11 +789,18 @@ mod tests {
assert!(base.is_expired());
// end_time dans le futur → non expiré
let not_expired = CachedMetadata { end_time: Some(now + 3600), ..base.clone() };
let not_expired = CachedMetadata {
end_time: Some(now + 3600),
..base.clone()
};
assert!(!not_expired.is_expired());
// Pas de end_time, fetched_at récent → non expiré (fallback TTL)
let no_end_fresh = CachedMetadata { end_time: None, fetched_at: now, ..base.clone() };
let no_end_fresh = CachedMetadata {
end_time: None,
fetched_at: now,
..base.clone()
};
assert!(!no_end_fresh.is_expired());
// Pas de end_time, fetched_at ancien → expiré

View File

@@ -328,6 +328,7 @@ impl SourceCacheManager {
None
};
let metadata_is_some = metadata.is_some();
let mut final_metadata = metadata;
if final_metadata.is_none() {
if let Some(data) = provider_data.as_ref() {
@@ -352,10 +353,25 @@ impl SourceCacheManager {
if let Some(meta) = final_metadata.as_ref() {
self.seed_audio_metadata(lazy_pk, meta);
} else {
#[cfg(feature = "server")]
tracing::warn!(
"cache_audio_lazy_with_provider: no metadata to seed for {} \
(meta_hint={}, provider_data={})",
lazy_pk,
metadata_is_some,
provider_data.as_ref().map(|d| d.metadata.is_some()).unwrap_or(false)
);
}
if let Some(cover_pk) = final_cover_pk {
let _ = self.set_audio_metadata(lazy_pk, "cover_pk", json!(cover_pk));
} else {
#[cfg(feature = "server")]
tracing::debug!(
"cache_audio_lazy_with_provider: no cover_pk seeded for {}",
lazy_pk
);
}
Ok(lazy_pk.to_string())