update de qobuz pour utiliser les sources lazy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user