update de qobuz pour utiliser les sources lazy

This commit is contained in:
2025-12-28 09:56:05 +01:00
parent b915481595
commit 876b1e0147
10 changed files with 467 additions and 40 deletions

View File

@@ -14,6 +14,8 @@ pub struct QobuzCache {
albums: Arc<MokaCache<String, Album>>,
/// Cache des tracks (TTL: 1 heure)
tracks: Arc<MokaCache<String, Track>>,
/// Cache des tracks d'un album complet (TTL: 1 heure)
album_tracks: Arc<MokaCache<String, Vec<Track>>>,
/// Cache des artistes (TTL: 1 heure)
artists: Arc<MokaCache<String, Artist>>,
/// Cache des playlists (TTL: 30 minutes)
@@ -45,6 +47,12 @@ impl QobuzCache {
.time_to_live(Duration::from_secs(3600)) // 1 heure
.build(),
),
album_tracks: Arc::new(
MokaCache::builder()
.max_capacity(max_capacity)
.time_to_live(Duration::from_secs(3600)) // 1 heure
.build(),
),
artists: Arc::new(
MokaCache::builder()
.max_capacity(max_capacity / 2)
@@ -106,6 +114,23 @@ impl QobuzCache {
self.tracks.invalidate(id).await;
}
// ============ Album Tracks (liste complète des tracks d'un album) ============
/// Récupère la liste complète des tracks d'un album depuis le cache
pub async fn get_album_tracks(&self, album_id: &str) -> Option<Vec<Track>> {
self.album_tracks.get(album_id).await
}
/// Ajoute la liste complète des tracks d'un album au cache
pub async fn put_album_tracks(&self, album_id: String, tracks: Vec<Track>) {
self.album_tracks.insert(album_id, tracks).await;
}
/// Invalide la liste des tracks d'un album du cache
pub async fn invalidate_album_tracks(&self, album_id: &str) {
self.album_tracks.invalidate(album_id).await;
}
// ============ Artists ============
/// Récupère un artiste depuis le cache
@@ -180,6 +205,7 @@ impl QobuzCache {
pub async fn clear_all(&self) {
self.albums.invalidate_all();
self.tracks.invalidate_all();
self.album_tracks.invalidate_all();
self.artists.invalidate_all();
self.playlists.invalidate_all();
self.searches.invalidate_all();
@@ -190,6 +216,7 @@ impl QobuzCache {
pub async fn stats(&self) -> CacheStats {
self.albums.run_pending_tasks().await;
self.tracks.run_pending_tasks().await;
self.album_tracks.run_pending_tasks().await;
self.artists.run_pending_tasks().await;
self.playlists.run_pending_tasks().await;
self.searches.run_pending_tasks().await;
@@ -198,6 +225,7 @@ impl QobuzCache {
CacheStats {
albums_count: self.albums.entry_count(),
tracks_count: self.tracks.entry_count(),
album_tracks_count: self.album_tracks.entry_count(),
artists_count: self.artists.entry_count(),
playlists_count: self.playlists.entry_count(),
searches_count: self.searches.entry_count(),
@@ -219,6 +247,8 @@ pub struct CacheStats {
pub albums_count: u64,
/// Nombre de tracks en cache
pub tracks_count: u64,
/// Nombre de listes complètes de tracks d'albums en cache
pub album_tracks_count: u64,
/// Nombre d'artistes en cache
pub artists_count: u64,
/// Nombre de playlists en cache
@@ -234,6 +264,7 @@ impl CacheStats {
pub fn total_count(&self) -> u64 {
self.albums_count
+ self.tracks_count
+ self.album_tracks_count
+ self.artists_count
+ self.playlists_count
+ self.searches_count

View File

@@ -584,14 +584,24 @@ impl QobuzClient {
/// Récupère les tracks d'un album
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<Track>> {
// Vérifier le cache d'abord
if let Some(tracks) = self.cache.get_album_tracks(album_id).await {
debug!("Album tracks for {} found in cache", album_id);
return Ok(tracks);
}
// Sinon, récupérer depuis l'API
let tracks = self
.call_with_auth_repair("get_album_tracks", || self.api.get_album_tracks(album_id))
.await?;
// Mettre les tracks en cache
// Mettre les tracks en cache (individuellement ET la liste complète)
for track in &tracks {
self.cache.put_track(track.id.clone(), track.clone()).await;
}
self.cache
.put_album_tracks(album_id.to_string(), tracks.clone())
.await;
Ok(tracks)
}

View File

@@ -27,10 +27,10 @@ impl ToDIDL for Album {
///
/// ```rust,ignore
/// let album = client.get_album("12345").await?;
/// let container = album.to_didl_container("0$qobuz$albums")?;
/// let container = album.to_didl_container("qobuz:favorites")?;
/// ```
fn to_didl_container(&self, parent_id: &str) -> Result<Container> {
let id = format!("0$qobuz$album${}", self.id);
let id = format!("qobuz:album:{}", self.id);
Ok(Container {
id,
@@ -71,10 +71,10 @@ impl ToDIDL for Track {
///
/// ```rust,ignore
/// let track = client.get_track("98765").await?;
/// let item = track.to_didl_item("0$qobuz$album$12345")?;
/// let item = track.to_didl_item("qobuz:album:12345")?;
/// ```
fn to_didl_item(&self, parent_id: &str) -> Result<Item> {
let id = format!("0$qobuz$track${}", self.id);
let id = format!("qobuz:track:{}", self.id);
// Déterminer l'artiste à afficher
let artist_name = self
@@ -128,7 +128,7 @@ impl ToDIDL for Track {
impl ToDIDL for Playlist {
/// Convertit une playlist en Container DIDL
fn to_didl_container(&self, parent_id: &str) -> Result<Container> {
let id = format!("0$qobuz$playlist${}", self.id);
let id = format!("qobuz:playlist:{}", self.id);
Ok(Container {
id,
@@ -211,7 +211,7 @@ mod tests {
};
let container = album.to_didl_container("parent").unwrap();
assert_eq!(container.id, "0$qobuz$album$123");
assert_eq!(container.id, "qobuz:album:123");
assert_eq!(container.parent_id, "parent");
assert!(container.title.contains("Test Album"));
}
@@ -234,7 +234,7 @@ mod tests {
};
let item = track.to_didl_item("parent").unwrap();
assert_eq!(item.id, "0$qobuz$track$789");
assert_eq!(item.id, "qobuz:track:789");
assert_eq!(item.parent_id, "parent");
assert_eq!(item.title, "Test Track");
}

View File

@@ -14,7 +14,10 @@ use pmosource::SourceCacheManager;
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use serde_json::json;
use std::sync::Arc;
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
/// TTL pour les playlists d'albums (7 jours)
const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600);
/// Default image for Qobuz (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
@@ -322,6 +325,13 @@ impl QobuzSource {
);
}
// Stocker le track_id Qobuz pour reconstruction DIDL ultérieure
let _ = self.inner.cache_manager.set_audio_metadata(
&cached_audio_pk,
"qobuz_track_id",
json!(track.id),
);
// 4. Store metadata
self.inner
.cache_manager
@@ -398,7 +408,7 @@ impl QobuzSource {
// 3. Batch insert into playlist (single DB transaction)
let playlist_manager = pmoplaylist::PlaylistManager();
let writer = playlist_manager
.get_write_handle(playlist_id.to_string())
.get_persistent_write_handle(playlist_id.to_string())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
@@ -489,7 +499,7 @@ impl QobuzSource {
// 3. Batch insert into playlist (single DB transaction)
let playlist_manager = pmoplaylist::PlaylistManager();
let writer = playlist_manager
.get_write_handle(playlist_id.to_string())
.get_persistent_write_handle(playlist_id.to_string())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
@@ -511,6 +521,181 @@ impl QobuzSource {
Ok(lazy_pks.len())
}
/// Vérifie si une playlist d'album existe et est valide (non expirée ET non vide)
async fn is_album_playlist_valid(&self, playlist_id: &str) -> Result<bool> {
let playlist_manager = pmoplaylist::PlaylistManager();
if !playlist_manager.exists(playlist_id).await {
return Ok(false);
}
// Vérifier l'âge
match playlist_manager.get_playlist_age(playlist_id).await {
Ok(Some(age)) if age < ALBUM_PLAYLIST_TTL => {
// Playlist non expirée, vérifier qu'elle contient des tracks
match playlist_manager.get_read_handle(playlist_id).await {
Ok(reader) => {
let count = reader.remaining().await.unwrap_or(0);
Ok(count > 0) // Valide seulement si non vide
}
Err(_) => Ok(false),
}
}
_ => Ok(false),
}
}
/// Adapte les Items d'une playlist pour correspondre au schéma UPnP Qobuz
async fn adapt_playlist_items_to_qobuz(
&self,
items: Vec<Item>,
album_id: &str,
) -> Result<Vec<Item>> {
use tracing::warn;
let parent_id = format!("qobuz:album:{}", album_id);
let mut adapted = Vec::with_capacity(items.len());
for mut item in items {
// Extraire cache_pk depuis l'URL du resource
let cache_pk = if let Some(resource) = item.resources.first() {
resource.url
.strip_prefix("/audio/flac/")
.map(|s| s.to_string())
} else {
None
};
if let Some(pk) = cache_pk {
// Récupérer track_id depuis metadata
if let Ok(Some(track_id_value)) = self.inner.cache_manager.get_audio_metadata(&pk, "qobuz_track_id") {
if let Some(track_id) = track_id_value.as_str() {
item.id = format!("qobuz:track:{}", track_id);
} else {
warn!("qobuz_track_id not a string for {}", pk);
}
} else {
warn!("No qobuz_track_id metadata for {}", pk);
}
}
item.parent_id = parent_id.clone();
adapted.push(item);
}
Ok(adapted)
}
/// Récupère ou crée une playlist lazy pour un album
async fn get_or_create_album_playlist_items(
&self,
album_id: &str,
limit: usize,
) -> Result<Vec<Item>> {
use tracing::{debug, info};
let playlist_id = format!("qobuz-album-{}", album_id);
let playlist_manager = pmoplaylist::PlaylistManager();
// Vérifier validité (existe ET non expirée)
let is_valid = self.is_album_playlist_valid(&playlist_id).await?;
if is_valid {
debug!("Album playlist {} found and valid", playlist_id);
let reader = playlist_manager
.get_read_handle(&playlist_id)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
let items = reader
.to_items(limit)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
return self.adapt_playlist_items_to_qobuz(items, album_id).await;
}
// Playlist invalide/inexistante : (re)créer
info!("Album playlist {} creating/refreshing", playlist_id);
// 1. Métadonnées album
let album = self
.inner
.client
.get_album(album_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// 2. Cache cover
let cover_pk = if let Some(ref image_url) = album.image {
self.inner
.cache_manager
.cache_cover(image_url)
.await
.ok()
} else {
None
};
// 3. Créer ou récupérer playlist
let writer = if playlist_manager.exists(&playlist_id).await {
let writer = playlist_manager
.get_persistent_write_handle(playlist_id.clone())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
writer
.flush()
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
writer
} else {
playlist_manager
.create_persistent_playlist_with_role(
playlist_id.clone(),
pmoplaylist::PlaylistRole::Album,
)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?
};
// 4. Métadonnées playlist
writer
.set_title(album.title.clone())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
if let Some(pk) = cover_pk {
writer
.set_cover_pk(Some(pk))
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
}
// IMPORTANT: Libérer le write lock avant d'appeler add_album_to_playlist
drop(writer);
// 5. Ajouter tracks (réutilise add_album_to_playlist existant)
self.add_album_to_playlist(&playlist_id, album_id).await?;
// 6. Récupérer items
let reader = playlist_manager
.get_read_handle(&playlist_id)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
let items = reader
.to_items(limit)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
// 7. Adapter IDs
self.adapt_playlist_items_to_qobuz(items, album_id).await
}
/// Increment update counter (called on catalog changes)
async fn increment_update_id(&self) {
let mut counter = self.inner.update_counter.write().await;
@@ -620,22 +805,9 @@ impl MusicSource for QobuzSource {
}
ObjectIdType::Album(album_id) => {
// Get tracks in album
let tracks = self
.inner
.client
.get_album_tracks(&album_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let items: Vec<Item> = tracks
.into_iter()
.filter_map(|track| {
track
.to_didl_item(&format!("qobuz:album:{}", album_id))
.ok()
})
.collect();
let items = self
.get_or_create_album_playlist_items(&album_id, usize::MAX)
.await?;
Ok(BrowseResult::Items(items))
}
@@ -1041,23 +1213,14 @@ impl MusicSource for QobuzSource {
) -> Result<BrowseResult> {
match self.parse_object_id(object_id) {
ObjectIdType::Album(album_id) => {
// Qobuz returns all tracks, so we slice them
let tracks = self
.inner
.client
.get_album_tracks(&album_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let all_items = self
.get_or_create_album_playlist_items(&album_id, usize::MAX)
.await?;
let items: Vec<Item> = tracks
let items: Vec<Item> = all_items
.into_iter()
.skip(offset)
.take(limit)
.filter_map(|track| {
track
.to_didl_item(&format!("qobuz:album:{}", album_id))
.ok()
})
.collect();
Ok(BrowseResult::Items(items))