2025-10-16 22:00:35 +02:00
|
|
|
//! Music source implementation for Qobuz
|
|
|
|
|
//!
|
|
|
|
|
//! This module implements the [`pmosource::MusicSource`] trait for Qobuz,
|
2025-10-16 22:13:00 +02:00
|
|
|
//! providing a complete music catalog browsing and searching experience.
|
2025-10-16 22:00:35 +02:00
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
use crate::client::QobuzClient;
|
2026-01-16 23:37:39 +01:00
|
|
|
use crate::didl::{format_duration, ToDIDL};
|
2025-12-15 15:05:35 +01:00
|
|
|
use crate::lazy_provider::QobuzLazyProvider;
|
2025-10-17 08:19:10 +02:00
|
|
|
use crate::models::Track;
|
2025-10-17 23:45:01 +02:00
|
|
|
use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
|
|
|
|
|
use pmocovers::Cache as CoverCache;
|
2025-10-16 22:13:00 +02:00
|
|
|
use pmodidl::{Container, Item};
|
2025-10-19 13:42:29 +02:00
|
|
|
use pmosource::SourceCacheManager;
|
|
|
|
|
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
2025-12-15 12:18:01 +01:00
|
|
|
use serde_json::json;
|
2025-10-16 22:13:00 +02:00
|
|
|
use std::sync::Arc;
|
2026-03-24 17:10:38 +01:00
|
|
|
use std::time::SystemTime;
|
2025-12-28 09:56:05 +01:00
|
|
|
|
|
|
|
|
/// TTL pour les playlists d'albums (7 jours)
|
2025-10-17 07:56:21 +02:00
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
/// Trait pour les types dont on peut cacher la cover image.
|
|
|
|
|
trait CoverCacheable {
|
|
|
|
|
fn image_url(&self) -> Option<&str>;
|
|
|
|
|
fn set_image_cached(&mut self, url: String);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CoverCacheable for crate::models::Album {
|
|
|
|
|
fn image_url(&self) -> Option<&str> { self.image.as_deref() }
|
|
|
|
|
fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CoverCacheable for crate::models::Playlist {
|
|
|
|
|
fn image_url(&self) -> Option<&str> { self.image.as_deref() }
|
|
|
|
|
fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CoverCacheable for crate::models::Artist {
|
|
|
|
|
fn image_url(&self) -> Option<&str> { self.image.as_deref() }
|
|
|
|
|
fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pour Track, la cover est celle de l'album.
|
|
|
|
|
impl CoverCacheable for crate::models::Track {
|
|
|
|
|
fn image_url(&self) -> Option<&str> {
|
|
|
|
|
self.album.as_ref()?.image.as_deref()
|
|
|
|
|
}
|
|
|
|
|
fn set_image_cached(&mut self, url: String) {
|
|
|
|
|
if let Some(ref mut album) = self.album {
|
|
|
|
|
album.image_cached = Some(url);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 22:00:35 +02:00
|
|
|
/// Default image for Qobuz (300x300 WebP, embedded in binary)
|
|
|
|
|
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
/// Qobuz music source with full MusicSource trait implementation
|
2025-10-16 22:00:35 +02:00
|
|
|
///
|
2025-10-16 22:13:00 +02:00
|
|
|
/// This struct combines a [`QobuzClient`] for API access with browsing and
|
|
|
|
|
/// navigation capabilities, implementing the complete [`MusicSource`] trait.
|
|
|
|
|
///
|
|
|
|
|
/// # Features
|
|
|
|
|
///
|
|
|
|
|
/// - **Catalog Navigation**: Browse albums, artists, playlists, favorites
|
|
|
|
|
/// - **Search**: Full-text search across the Qobuz catalog
|
|
|
|
|
/// - **URI Resolution**: Resolves track streaming URIs with authentication
|
|
|
|
|
/// - **DIDL-Lite Export**: Converts albums, tracks, and playlists to UPnP formats
|
|
|
|
|
/// - **Caching**: Integrated with QobuzClient's cache for performance
|
|
|
|
|
///
|
|
|
|
|
/// # Architecture
|
|
|
|
|
///
|
|
|
|
|
/// Unlike streaming sources like Radio Paradise, Qobuz is a catalog-based source:
|
|
|
|
|
/// - Root container has multiple sub-containers (Albums, Artists, Favorites, etc.)
|
|
|
|
|
/// - No FIFO support (it's a static catalog, not a dynamic stream)
|
|
|
|
|
/// - Hierarchical browsing: Root → Category → Albums → Tracks
|
2025-10-16 22:00:35 +02:00
|
|
|
///
|
|
|
|
|
/// # Examples
|
|
|
|
|
///
|
2025-10-16 22:13:00 +02:00
|
|
|
/// ```no_run
|
2025-12-12 21:42:04 +00:00
|
|
|
/// use std::sync::Arc;
|
|
|
|
|
/// use pmoaudiocache::cache as audio_cache;
|
|
|
|
|
/// use pmocovers::cache as cover_cache;
|
2025-10-16 22:13:00 +02:00
|
|
|
/// use pmoqobuz::{QobuzSource, QobuzClient};
|
2025-10-16 22:00:35 +02:00
|
|
|
/// use pmosource::MusicSource;
|
|
|
|
|
///
|
2025-10-16 22:13:00 +02:00
|
|
|
/// #[tokio::main]
|
|
|
|
|
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
|
|
/// let client = QobuzClient::from_config().await?;
|
2025-12-12 21:42:04 +00:00
|
|
|
/// let cover_cache = Arc::new(cover_cache::new_cache("/tmp/qobuz_covers", 256)?);
|
|
|
|
|
/// let audio_cache = Arc::new(audio_cache::new_cache("/tmp/qobuz_audio", 64)?);
|
2025-12-28 12:53:40 +01:00
|
|
|
/// let source = QobuzSource::new(client, cover_cache, audio_cache, "http://localhost:8080");
|
2025-10-16 22:00:35 +02:00
|
|
|
///
|
2025-10-16 22:13:00 +02:00
|
|
|
/// println!("Source: {}", source.name());
|
|
|
|
|
/// println!("Supports FIFO: {}", source.supports_fifo());
|
|
|
|
|
///
|
|
|
|
|
/// // Browse root container
|
|
|
|
|
/// let root = source.root_container().await?;
|
|
|
|
|
/// println!("Root: {} with {} children", root.title, root.child_count.unwrap_or_default());
|
|
|
|
|
///
|
|
|
|
|
/// Ok(())
|
|
|
|
|
/// }
|
2025-10-16 22:00:35 +02:00
|
|
|
/// ```
|
2025-10-16 22:13:00 +02:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct QobuzSource {
|
|
|
|
|
inner: Arc<QobuzSourceInner>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct QobuzSourceInner {
|
|
|
|
|
/// Qobuz API client
|
2025-12-15 15:05:35 +01:00
|
|
|
client: Arc<QobuzClient>,
|
2025-10-16 22:13:00 +02:00
|
|
|
|
2025-10-17 23:45:01 +02:00
|
|
|
/// Cache manager (centralisé)
|
|
|
|
|
cache_manager: SourceCacheManager,
|
2025-10-17 07:56:21 +02:00
|
|
|
|
2025-12-28 12:53:40 +01:00
|
|
|
/// Base URL for streaming server (e.g., "http://192.168.0.138:8080")
|
|
|
|
|
base_url: String,
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
/// Update tracking
|
2025-10-17 23:45:01 +02:00
|
|
|
update_counter: tokio::sync::RwLock<u32>,
|
|
|
|
|
last_change: tokio::sync::RwLock<SystemTime>,
|
2025-10-17 07:56:21 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
impl std::fmt::Debug for QobuzSource {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
f.debug_struct("QobuzSource").finish()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl QobuzSource {
|
2025-10-18 09:58:39 +02:00
|
|
|
/// Create a new Qobuz source from the cache registry
|
|
|
|
|
///
|
|
|
|
|
/// This is the recommended way to create a source when using the UPnP server.
|
|
|
|
|
/// The caches are automatically retrieved from the global registry.
|
|
|
|
|
///
|
|
|
|
|
/// # Arguments
|
|
|
|
|
///
|
|
|
|
|
/// * `client` - Authenticated Qobuz API client
|
2025-12-28 12:53:40 +01:00
|
|
|
/// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080")
|
2025-10-18 09:58:39 +02:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns an error if the caches are not initialized in the registry
|
|
|
|
|
#[cfg(feature = "server")]
|
2025-12-28 12:53:40 +01:00
|
|
|
pub fn from_registry(client: QobuzClient, base_url: impl Into<String>) -> Result<Self> {
|
2025-10-18 09:58:39 +02:00
|
|
|
let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?;
|
2025-12-15 15:05:35 +01:00
|
|
|
let client = Arc::new(client);
|
|
|
|
|
cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone())));
|
2025-10-18 09:58:39 +02:00
|
|
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
inner: Arc::new(QobuzSourceInner {
|
|
|
|
|
client,
|
|
|
|
|
cache_manager,
|
2025-12-28 12:53:40 +01:00
|
|
|
base_url: base_url.into(),
|
2025-10-18 09:58:39 +02:00
|
|
|
update_counter: tokio::sync::RwLock::new(0),
|
|
|
|
|
last_change: tokio::sync::RwLock::new(SystemTime::now()),
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create a new Qobuz source with explicit caches (for tests)
|
2025-10-17 07:56:21 +02:00
|
|
|
///
|
|
|
|
|
/// # Arguments
|
|
|
|
|
///
|
|
|
|
|
/// * `client` - Authenticated Qobuz API client
|
2025-10-17 23:45:01 +02:00
|
|
|
/// * `cover_cache` - Cover image cache (required)
|
|
|
|
|
/// * `audio_cache` - Audio cache (required)
|
2025-12-28 12:53:40 +01:00
|
|
|
/// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080")
|
2025-10-17 23:45:01 +02:00
|
|
|
pub fn new(
|
2025-10-17 07:56:21 +02:00
|
|
|
client: QobuzClient,
|
2025-10-17 23:45:01 +02:00
|
|
|
cover_cache: Arc<CoverCache>,
|
|
|
|
|
audio_cache: Arc<AudioCache>,
|
2025-12-28 12:53:40 +01:00
|
|
|
base_url: impl Into<String>,
|
2025-10-17 07:56:21 +02:00
|
|
|
) -> Self {
|
2025-10-19 13:42:29 +02:00
|
|
|
let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache);
|
2025-12-15 15:05:35 +01:00
|
|
|
let client = Arc::new(client);
|
|
|
|
|
cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone())));
|
2025-10-17 23:45:01 +02:00
|
|
|
|
2025-10-17 07:56:21 +02:00
|
|
|
Self {
|
|
|
|
|
inner: Arc::new(QobuzSourceInner {
|
|
|
|
|
client,
|
2025-10-17 23:45:01 +02:00
|
|
|
cache_manager,
|
2025-12-28 12:53:40 +01:00
|
|
|
base_url: base_url.into(),
|
2025-10-17 23:45:01 +02:00
|
|
|
update_counter: tokio::sync::RwLock::new(0),
|
|
|
|
|
last_change: tokio::sync::RwLock::new(SystemTime::now()),
|
2025-10-16 22:13:00 +02:00
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get the Qobuz client
|
|
|
|
|
pub fn client(&self) -> &QobuzClient {
|
|
|
|
|
&self.inner.client
|
|
|
|
|
}
|
2025-10-16 22:00:35 +02:00
|
|
|
|
2025-10-17 23:45:01 +02:00
|
|
|
/// Add a track from Qobuz with caching
|
2025-10-17 07:56:21 +02:00
|
|
|
///
|
2025-10-17 23:45:01 +02:00
|
|
|
/// This method downloads and caches both cover art and audio data.
|
2025-10-17 07:56:21 +02:00
|
|
|
pub async fn add_track(&self, track: &Track) -> Result<String> {
|
|
|
|
|
let track_id = format!("qobuz://track/{}", track.id);
|
|
|
|
|
|
|
|
|
|
// Get streaming URL
|
2025-10-19 13:42:29 +02:00
|
|
|
let stream_url = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_stream_url(&track.id)
|
|
|
|
|
.await
|
2025-10-17 07:56:21 +02:00
|
|
|
.map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?;
|
|
|
|
|
|
2025-10-17 23:45:01 +02:00
|
|
|
// 1. Cache cover via manager
|
|
|
|
|
let cached_cover_pk = if let Some(ref album) = track.album {
|
|
|
|
|
if let Some(ref image_url) = album.image {
|
|
|
|
|
self.inner.cache_manager.cache_cover(image_url).await.ok()
|
2025-10-19 13:42:29 +02:00
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
2025-10-17 23:45:01 +02:00
|
|
|
|
|
|
|
|
// 2. Prepare rich metadata from Qobuz track
|
|
|
|
|
let metadata = 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| {
|
2025-10-19 13:42:29 +02:00
|
|
|
a.release_date
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|d| d.split('-').next()?.parse().ok())
|
2025-10-17 23:45:01 +02:00
|
|
|
}),
|
|
|
|
|
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| {
|
2025-10-19 13:42:29 +02:00
|
|
|
if !a.genres.is_empty() {
|
|
|
|
|
Some(a.genres.join(", "))
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
2025-10-17 23:45:01 +02:00
|
|
|
}),
|
|
|
|
|
sample_rate: track.sample_rate,
|
|
|
|
|
channels: track.channels,
|
|
|
|
|
bitrate: None,
|
2025-10-28 00:10:51 +01:00
|
|
|
conversion: None,
|
2025-10-17 07:56:21 +02:00
|
|
|
};
|
|
|
|
|
|
2025-10-17 23:45:01 +02:00
|
|
|
// 3. Cache audio via manager
|
2025-10-19 13:42:29 +02:00
|
|
|
let cached_audio_pk = self
|
|
|
|
|
.inner
|
|
|
|
|
.cache_manager
|
|
|
|
|
.cache_audio(&stream_url, Some(metadata))
|
|
|
|
|
.await
|
|
|
|
|
.ok();
|
2025-10-17 07:56:21 +02:00
|
|
|
|
2025-12-15 12:18:01 +01:00
|
|
|
if let (Some(ref audio_pk), Some(ref cover_pk)) = (&cached_audio_pk, &cached_cover_pk) {
|
|
|
|
|
let _ =
|
|
|
|
|
self.inner
|
|
|
|
|
.cache_manager
|
|
|
|
|
.set_audio_metadata(audio_pk, "cover_pk", json!(cover_pk));
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 23:45:01 +02:00
|
|
|
// 4. Store metadata
|
2025-10-19 13:42:29 +02:00
|
|
|
self.inner
|
|
|
|
|
.cache_manager
|
|
|
|
|
.update_metadata(
|
|
|
|
|
track_id.clone(),
|
|
|
|
|
pmosource::TrackMetadata {
|
|
|
|
|
original_uri: stream_url,
|
|
|
|
|
cached_audio_pk,
|
|
|
|
|
cached_cover_pk,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await;
|
2025-10-17 07:56:21 +02:00
|
|
|
|
|
|
|
|
Ok(track_id)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-12 21:42:04 +00:00
|
|
|
/// Add track with lazy audio caching (cover eager, audio lazy)
|
|
|
|
|
///
|
|
|
|
|
/// This method caches cover art immediately (small, needed for UI) but
|
|
|
|
|
/// defers audio download until the track is actually played.
|
|
|
|
|
///
|
|
|
|
|
/// # Arguments
|
|
|
|
|
///
|
|
|
|
|
/// * `track` - The Qobuz track to add
|
|
|
|
|
///
|
|
|
|
|
/// # Returns
|
|
|
|
|
///
|
2025-12-15 11:18:58 +01:00
|
|
|
/// `(track_id, lazy_pk)` where `track_id` is the logical Qobuz URI and
|
|
|
|
|
/// `lazy_pk` the cache identifier stored in pmocache.
|
|
|
|
|
pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> {
|
2025-12-12 21:42:04 +00:00
|
|
|
let track_id = format!("qobuz://track/{}", track.id);
|
|
|
|
|
|
2025-12-15 15:05:35 +01:00
|
|
|
let lazy_pk = format!("QOBUZ:{}", track.id);
|
|
|
|
|
|
|
|
|
|
// Get streaming URL (for metadata fallback)
|
2025-12-12 21:42:04 +00:00
|
|
|
let stream_url = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_stream_url(&track.id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// 1. Cache cover EAGERLY (small, UI needs it)
|
|
|
|
|
let cached_cover_pk = if let Some(ref album) = track.album {
|
|
|
|
|
if let Some(ref image_url) = album.image {
|
|
|
|
|
self.inner.cache_manager.cache_cover(image_url).await.ok()
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 2. Prepare rich metadata from Qobuz track
|
|
|
|
|
let metadata = 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,
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-15 15:05:35 +01:00
|
|
|
// 3. Cache audio LAZILY avec un provider
|
2025-12-12 21:42:04 +00:00
|
|
|
let cached_audio_pk = self
|
|
|
|
|
.inner
|
|
|
|
|
.cache_manager
|
2025-12-15 15:05:35 +01:00
|
|
|
.cache_audio_lazy_with_provider(
|
|
|
|
|
&lazy_pk,
|
|
|
|
|
Some(metadata.clone()),
|
|
|
|
|
cached_cover_pk.clone(),
|
|
|
|
|
)
|
2025-12-12 21:42:04 +00:00
|
|
|
.await
|
2025-12-15 11:18:58 +01:00
|
|
|
.map_err(|e| {
|
|
|
|
|
MusicSourceError::CacheError(format!(
|
2025-12-15 15:05:35 +01:00
|
|
|
"Failed to register lazy track {}: {}",
|
2025-12-15 11:18:58 +01:00
|
|
|
track.title, e
|
|
|
|
|
))
|
|
|
|
|
})?;
|
2025-12-12 21:42:04 +00:00
|
|
|
|
2025-12-15 12:18:01 +01:00
|
|
|
if let Some(ref cover_pk) = cached_cover_pk {
|
|
|
|
|
let _ = self.inner.cache_manager.set_audio_metadata(
|
|
|
|
|
&cached_audio_pk,
|
|
|
|
|
"cover_pk",
|
|
|
|
|
json!(cover_pk),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 09:56:05 +01:00
|
|
|
// 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),
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-12 21:42:04 +00:00
|
|
|
// 4. Store metadata
|
|
|
|
|
self.inner
|
|
|
|
|
.cache_manager
|
|
|
|
|
.update_metadata(
|
|
|
|
|
track_id.clone(),
|
|
|
|
|
pmosource::TrackMetadata {
|
|
|
|
|
original_uri: stream_url,
|
2025-12-15 11:18:58 +01:00
|
|
|
cached_audio_pk: Some(cached_audio_pk.clone()),
|
2025-12-12 21:42:04 +00:00
|
|
|
cached_cover_pk,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.await;
|
|
|
|
|
|
2025-12-15 11:18:58 +01:00
|
|
|
Ok((track_id, cached_audio_pk))
|
2025-12-12 21:42:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load full album into pmoplaylist with lazy audio
|
|
|
|
|
///
|
|
|
|
|
/// This method fetches all tracks from a Qobuz album and adds them to a playlist
|
|
|
|
|
/// with lazy audio loading. Covers are downloaded eagerly, audio lazily.
|
|
|
|
|
///
|
|
|
|
|
/// # Arguments
|
|
|
|
|
///
|
|
|
|
|
/// * `playlist_id` - ID of the target playlist
|
|
|
|
|
/// * `album_id` - Qobuz album ID
|
|
|
|
|
///
|
|
|
|
|
/// # Returns
|
|
|
|
|
///
|
|
|
|
|
/// Number of tracks successfully added
|
2025-12-13 13:36:37 +01:00
|
|
|
pub async fn add_album_to_playlist(&self, playlist_id: &str, album_id: &str) -> Result<usize> {
|
|
|
|
|
use tracing::{debug, info, warn};
|
2025-12-12 21:42:04 +00:00
|
|
|
|
|
|
|
|
// 1. Get tracks from Qobuz (goes through rate limiter)
|
|
|
|
|
let tracks = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_album_tracks(album_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
if tracks.is_empty() {
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
info!(
|
|
|
|
|
"Adding album {} ({} tracks) to playlist {} with lazy audio",
|
|
|
|
|
album_id,
|
|
|
|
|
tracks.len(),
|
|
|
|
|
playlist_id
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 2. Add each track lazily + collect lazy PKs
|
|
|
|
|
let mut lazy_pks = Vec::with_capacity(tracks.len());
|
|
|
|
|
|
|
|
|
|
for (i, track) in tracks.iter().enumerate() {
|
|
|
|
|
match self.add_track_lazy(track).await {
|
2025-12-15 11:18:58 +01:00
|
|
|
Ok((_track_id, lazy_pk)) => {
|
|
|
|
|
debug!(
|
|
|
|
|
"Track {}/{}: {} (lazy pk {})",
|
|
|
|
|
i + 1,
|
|
|
|
|
tracks.len(),
|
|
|
|
|
track.title,
|
|
|
|
|
&lazy_pk
|
|
|
|
|
);
|
|
|
|
|
lazy_pks.push(lazy_pk);
|
2025-12-12 21:42:04 +00:00
|
|
|
}
|
|
|
|
|
Err(e) => {
|
2025-12-13 13:36:37 +01:00
|
|
|
warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
|
2025-12-12 21:42:04 +00:00
|
|
|
// Continue with other tracks
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Batch insert into playlist (single DB transaction)
|
|
|
|
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
|
|
|
|
let writer = playlist_manager
|
2025-12-28 09:56:05 +01:00
|
|
|
.get_persistent_write_handle(playlist_id.to_string())
|
2025-12-12 21:42:04 +00:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
|
2025-12-17 08:31:47 +01:00
|
|
|
writer
|
|
|
|
|
.set_role(pmoplaylist::PlaylistRole::Album)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
|
2025-12-12 21:42:04 +00:00
|
|
|
writer
|
|
|
|
|
.push_lazy_batch(lazy_pks.clone())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// 4. Enable lazy mode with lookahead of 2 tracks
|
|
|
|
|
playlist_manager.enable_lazy_mode(playlist_id, 2);
|
|
|
|
|
|
|
|
|
|
info!(
|
|
|
|
|
"Album {} added: {}/{} tracks",
|
|
|
|
|
album_id,
|
|
|
|
|
lazy_pks.len(),
|
|
|
|
|
tracks.len()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(lazy_pks.len())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load Qobuz playlist into pmoplaylist with lazy audio
|
|
|
|
|
///
|
|
|
|
|
/// This method fetches all tracks from a Qobuz playlist and adds them to a pmoplaylist
|
|
|
|
|
/// with lazy audio loading. Covers are downloaded eagerly, audio lazily.
|
|
|
|
|
///
|
|
|
|
|
/// # Arguments
|
|
|
|
|
///
|
|
|
|
|
/// * `playlist_id` - ID of the target pmoplaylist
|
|
|
|
|
/// * `qobuz_playlist_id` - Qobuz playlist ID
|
|
|
|
|
///
|
|
|
|
|
/// # Returns
|
|
|
|
|
///
|
|
|
|
|
/// Number of tracks successfully added
|
|
|
|
|
pub async fn add_qobuz_playlist_to_playlist(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
qobuz_playlist_id: &str,
|
|
|
|
|
) -> Result<usize> {
|
|
|
|
|
use tracing::{debug, info, warn};
|
|
|
|
|
|
|
|
|
|
// 1. Get tracks from Qobuz playlist (goes through rate limiter)
|
|
|
|
|
let tracks = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_playlist_tracks(qobuz_playlist_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
if tracks.is_empty() {
|
|
|
|
|
return Ok(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
info!(
|
|
|
|
|
"Adding Qobuz playlist {} ({} tracks) to pmoplaylist {} with lazy audio",
|
|
|
|
|
qobuz_playlist_id,
|
|
|
|
|
tracks.len(),
|
|
|
|
|
playlist_id
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 2. Add each track lazily + collect lazy PKs
|
|
|
|
|
let mut lazy_pks = Vec::with_capacity(tracks.len());
|
|
|
|
|
|
|
|
|
|
for (i, track) in tracks.iter().enumerate() {
|
|
|
|
|
match self.add_track_lazy(track).await {
|
2025-12-15 11:18:58 +01:00
|
|
|
Ok((_track_id, lazy_pk)) => {
|
|
|
|
|
debug!(
|
|
|
|
|
"Track {}/{}: {} (lazy pk {})",
|
|
|
|
|
i + 1,
|
|
|
|
|
tracks.len(),
|
|
|
|
|
track.title,
|
|
|
|
|
&lazy_pk
|
|
|
|
|
);
|
|
|
|
|
lazy_pks.push(lazy_pk);
|
2025-12-12 21:42:04 +00:00
|
|
|
}
|
|
|
|
|
Err(e) => {
|
2025-12-13 13:36:37 +01:00
|
|
|
warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
|
2025-12-12 21:42:04 +00:00
|
|
|
// Continue with other tracks
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Batch insert into playlist (single DB transaction)
|
|
|
|
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
|
|
|
|
let writer = playlist_manager
|
2025-12-28 09:56:05 +01:00
|
|
|
.get_persistent_write_handle(playlist_id.to_string())
|
2025-12-12 21:42:04 +00:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
writer
|
|
|
|
|
.push_lazy_batch(lazy_pks.clone())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// 4. Enable lazy mode with lookahead of 2 tracks
|
|
|
|
|
playlist_manager.enable_lazy_mode(playlist_id, 2);
|
|
|
|
|
|
|
|
|
|
info!(
|
|
|
|
|
"Qobuz playlist {} added: {}/{} tracks",
|
|
|
|
|
qobuz_playlist_id,
|
|
|
|
|
lazy_pks.len(),
|
|
|
|
|
tracks.len()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(lazy_pks.len())
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 09:56:05 +01:00
|
|
|
/// Vérifie si une playlist d'album existe et est valide (non expirée ET non vide)
|
2026-03-24 17:10:38 +01:00
|
|
|
/// Retourne la source_version d'une pmoplaylist si elle existe en mémoire ou en DB.
|
|
|
|
|
async fn cached_source_version(&self, playlist_id: &str) -> Option<String> {
|
2025-12-28 09:56:05 +01:00
|
|
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
|
|
|
|
if !playlist_manager.exists(playlist_id).await {
|
2026-03-24 17:10:38 +01:00
|
|
|
return None;
|
2025-12-28 09:56:05 +01:00
|
|
|
}
|
2026-03-24 17:10:38 +01:00
|
|
|
playlist_manager
|
|
|
|
|
.get_read_handle(playlist_id)
|
|
|
|
|
.await
|
|
|
|
|
.ok()?
|
|
|
|
|
.source_version()
|
|
|
|
|
.await
|
2025-12-28 09:56:05 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
/// Adapte des Items DIDL issus d'une pmoplaylist pour le schéma UPnP Qobuz.
|
|
|
|
|
///
|
|
|
|
|
/// - Résout l'ID de la track (`qobuz:track:<id>`) depuis les métadonnées cache
|
|
|
|
|
/// - Rend absolues les URLs relatives (resource + cover)
|
|
|
|
|
/// - Affecte le `parent_id` fourni
|
|
|
|
|
async fn adapt_items_to_qobuz(
|
2025-12-28 09:56:05 +01:00
|
|
|
&self,
|
|
|
|
|
items: Vec<Item>,
|
2026-03-24 17:10:38 +01:00
|
|
|
parent_id: &str,
|
2025-12-28 09:56:05 +01:00
|
|
|
) -> Result<Vec<Item>> {
|
|
|
|
|
use tracing::warn;
|
|
|
|
|
|
|
|
|
|
let mut adapted = Vec::with_capacity(items.len());
|
|
|
|
|
|
|
|
|
|
for mut item in items {
|
2026-03-24 17:10:38 +01:00
|
|
|
let cache_pk = item
|
|
|
|
|
.resources
|
|
|
|
|
.first()
|
|
|
|
|
.and_then(|r| r.url.strip_prefix("/audio/flac/").map(|s| s.to_string()));
|
2025-12-28 09:56:05 +01:00
|
|
|
|
|
|
|
|
if let Some(pk) = cache_pk {
|
2026-01-04 18:54:31 +01:00
|
|
|
if let Ok(Some(track_id_value)) = self
|
|
|
|
|
.inner
|
|
|
|
|
.cache_manager
|
|
|
|
|
.get_audio_metadata(&pk, "qobuz_track_id")
|
|
|
|
|
{
|
2025-12-28 09:56:05 +01:00
|
|
|
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);
|
|
|
|
|
}
|
2025-12-28 12:53:40 +01:00
|
|
|
|
|
|
|
|
if let Some(resource) = item.resources.first_mut() {
|
|
|
|
|
if resource.url.starts_with('/') {
|
|
|
|
|
resource.url = format!("{}{}", self.inner.base_url, resource.url);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-28 09:56:05 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-16 22:58:17 +01:00
|
|
|
if let Some(art) = item.album_art.as_mut() {
|
|
|
|
|
if art.starts_with('/') {
|
|
|
|
|
*art = format!("{}{}", self.inner.base_url, art);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
item.parent_id = parent_id.to_string();
|
2025-12-28 09:56:05 +01:00
|
|
|
adapted.push(item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(adapted)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
/// Ouvre un WriteHandle sur une pmoplaylist persistante, en la créant si absente
|
|
|
|
|
/// ou en la vidant (flush) si elle existe déjà.
|
|
|
|
|
async fn get_or_flush_playlist_writer(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
role: pmoplaylist::PlaylistRole,
|
|
|
|
|
) -> Result<pmoplaylist::WriteHandle> {
|
|
|
|
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
|
|
|
|
|
|
|
|
|
if playlist_manager.exists(playlist_id).await {
|
|
|
|
|
let writer = playlist_manager
|
|
|
|
|
.get_persistent_write_handle(playlist_id.to_string())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
writer
|
|
|
|
|
.flush()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
Ok(writer)
|
|
|
|
|
} else {
|
|
|
|
|
playlist_manager
|
|
|
|
|
.create_persistent_playlist_with_role(playlist_id.to_string(), role)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Met en cache la cover si l'URL est présente.
|
|
|
|
|
async fn cache_cover_opt(&self, image_url: Option<&str>) -> Option<String> {
|
|
|
|
|
self.inner
|
|
|
|
|
.cache_manager
|
|
|
|
|
.cache_cover(image_url?)
|
|
|
|
|
.await
|
|
|
|
|
.ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Récupère ou crée une playlist lazy pour un album, avec invalidation par version.
|
|
|
|
|
///
|
|
|
|
|
/// `source_version` = `"{released_at}_{tracks_count}"` — change uniquement en cas de
|
|
|
|
|
/// réédition avec pistes supplémentaires. Cache valide à vie si la version correspond.
|
2025-12-28 09:56:05 +01:00
|
|
|
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();
|
2026-03-24 17:10:38 +01:00
|
|
|
let parent_id = format!("qobuz:album:{}", album_id);
|
|
|
|
|
|
|
|
|
|
let cached_version = self.cached_source_version(&playlist_id).await;
|
2025-12-28 09:56:05 +01:00
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
// Charger les métadonnées album (contient déjà les tracks)
|
|
|
|
|
let album = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_album(album_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
2025-12-28 09:56:05 +01:00
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
let album_version = Some(format!(
|
|
|
|
|
"{}_{}",
|
|
|
|
|
album.released_at.unwrap_or(0),
|
|
|
|
|
album.tracks_count.unwrap_or(0)
|
|
|
|
|
));
|
2025-12-28 09:56:05 +01:00
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
if cached_version.is_some() && cached_version == album_version {
|
|
|
|
|
debug!("Album playlist {} cache valid (version {:?})", playlist_id, album_version);
|
2025-12-28 09:56:05 +01:00
|
|
|
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()))?;
|
2026-03-24 17:10:38 +01:00
|
|
|
return self.adapt_items_to_qobuz(items, &parent_id).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
info!("Album playlist {} creating/refreshing (version {:?})", playlist_id, album_version);
|
|
|
|
|
|
|
|
|
|
let cover_pk = self.cache_cover_opt(album.image.as_deref()).await;
|
|
|
|
|
|
|
|
|
|
let writer = self
|
|
|
|
|
.get_or_flush_playlist_writer(&playlist_id, pmoplaylist::PlaylistRole::Album)
|
|
|
|
|
.await?;
|
2025-12-28 09:56:05 +01:00
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
writer
|
|
|
|
|
.set_title(album.title.clone())
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
writer
|
|
|
|
|
.set_artist(Some(album.artist.name.clone()))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
writer
|
|
|
|
|
.set_source(Some("qobuz".to_string()))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
writer
|
|
|
|
|
.set_source_version(album_version)
|
|
|
|
|
.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()))?;
|
2025-12-28 09:56:05 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
// IMPORTANT: Libérer le write lock avant d'appeler add_album_to_playlist
|
|
|
|
|
drop(writer);
|
2025-12-28 09:56:05 +01:00
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
self.add_album_to_playlist(&playlist_id, album_id).await?;
|
|
|
|
|
|
|
|
|
|
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()))?;
|
|
|
|
|
|
|
|
|
|
self.adapt_items_to_qobuz(items, &parent_id).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Récupère ou crée une pmoplaylist pour une playlist Qobuz, avec invalidation par updated_at.
|
|
|
|
|
///
|
|
|
|
|
/// - Si la playlist est en cache et que `updated_at` n'a pas changé → retourne le cache.
|
|
|
|
|
/// - Sinon → recharge toutes les tracks depuis Qobuz et reconstruit la pmoplaylist.
|
|
|
|
|
async fn get_or_create_qobuz_playlist_items(
|
|
|
|
|
&self,
|
|
|
|
|
qobuz_playlist_id: &str,
|
|
|
|
|
) -> Result<Vec<Item>> {
|
|
|
|
|
use tracing::{debug, info};
|
|
|
|
|
|
|
|
|
|
let pmo_playlist_id = format!("qobuz-playlist-{}", qobuz_playlist_id);
|
|
|
|
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
|
|
|
|
|
|
|
|
|
let cached_version = self.cached_source_version(&pmo_playlist_id).await;
|
|
|
|
|
|
|
|
|
|
// Obtenir les métadonnées depuis Qobuz pour comparer updated_at
|
|
|
|
|
let qobuz_meta: crate::models::Playlist = self
|
2025-12-28 09:56:05 +01:00
|
|
|
.inner
|
|
|
|
|
.client
|
2026-03-24 17:10:38 +01:00
|
|
|
.get_playlist(qobuz_playlist_id)
|
2025-12-28 09:56:05 +01:00
|
|
|
.await
|
2026-03-24 17:10:38 +01:00
|
|
|
.map_err(|e: crate::error::QobuzError| MusicSourceError::BrowseError(e.to_string()))?;
|
2025-12-28 09:56:05 +01:00
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
let qobuz_version = qobuz_meta.updated_at.map(|t: i64| t.to_string());
|
2025-12-28 09:56:05 +01:00
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
// Cache valide si version correspond
|
|
|
|
|
let cache_valid = cached_version.is_some() && cached_version == qobuz_version;
|
|
|
|
|
|
|
|
|
|
let parent_id = format!("qobuz:playlist:{}", qobuz_playlist_id);
|
|
|
|
|
|
|
|
|
|
if cache_valid {
|
|
|
|
|
debug!("Qobuz playlist {} cache valid (version {:?})", pmo_playlist_id, qobuz_version);
|
|
|
|
|
let reader = playlist_manager
|
|
|
|
|
.get_read_handle(&pmo_playlist_id)
|
2025-12-28 09:56:05 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
2026-03-25 12:25:34 +01:00
|
|
|
let (items, total) = reader
|
2026-03-24 17:10:38 +01:00
|
|
|
.to_items_paged(0, usize::MAX)
|
2025-12-28 09:56:05 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
2026-03-25 12:25:34 +01:00
|
|
|
// 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
|
|
|
|
|
);
|
2026-03-24 17:10:38 +01:00
|
|
|
}
|
2025-12-28 09:56:05 +01:00
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
info!("Qobuz playlist {} creating/refreshing (version {:?})", pmo_playlist_id, qobuz_version);
|
|
|
|
|
|
|
|
|
|
let tracks = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_playlist_tracks(qobuz_playlist_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let cover_pk = self.cache_cover_opt(qobuz_meta.image.as_deref()).await;
|
|
|
|
|
|
|
|
|
|
let writer = self
|
|
|
|
|
.get_or_flush_playlist_writer(&pmo_playlist_id, pmoplaylist::PlaylistRole::Source)
|
|
|
|
|
.await?;
|
2025-12-28 09:56:05 +01:00
|
|
|
|
|
|
|
|
writer
|
2026-03-24 17:10:38 +01:00
|
|
|
.set_title(qobuz_meta.name.clone())
|
2025-12-28 09:56:05 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
2026-01-04 18:54:31 +01:00
|
|
|
writer
|
2026-03-24 17:10:38 +01:00
|
|
|
.set_source(Some("qobuz".to_string()))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
writer
|
|
|
|
|
.set_source_version(qobuz_version)
|
2026-01-04 18:54:31 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
2025-12-28 09:56:05 +01:00
|
|
|
if let Some(pk) = cover_pk {
|
|
|
|
|
writer
|
|
|
|
|
.set_cover_pk(Some(pk))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 11:29:31 +01:00
|
|
|
let lazy_pks = self.register_tracks_lazy(&tracks).await;
|
2026-03-24 17:10:38 +01:00
|
|
|
writer
|
|
|
|
|
.push_lazy_batch(lazy_pks)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
2025-12-28 09:56:05 +01:00
|
|
|
drop(writer);
|
|
|
|
|
|
|
|
|
|
let reader = playlist_manager
|
2026-03-24 17:10:38 +01:00
|
|
|
.get_read_handle(&pmo_playlist_id)
|
2025-12-28 09:56:05 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
2026-03-24 17:10:38 +01:00
|
|
|
let (items, _total) = reader
|
|
|
|
|
.to_items_paged(0, usize::MAX)
|
2025-12-28 09:56:05 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 17:10:38 +01:00
|
|
|
self.adapt_items_to_qobuz(items, &parent_id).await
|
2025-12-28 09:56:05 +01:00
|
|
|
}
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
/// Increment update counter (called on catalog changes)
|
|
|
|
|
async fn increment_update_id(&self) {
|
|
|
|
|
let mut counter = self.inner.update_counter.write().await;
|
|
|
|
|
*counter = counter.wrapping_add(1);
|
|
|
|
|
let mut last = self.inner.last_change.write().await;
|
|
|
|
|
*last = SystemTime::now();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 14:54:27 +01:00
|
|
|
/// Construit le container Discover Catalog
|
|
|
|
|
fn build_discover_catalog_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:discover".to_string(),
|
|
|
|
|
parent_id: "qobuz".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Discover Catalog".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Construit le container Discover Genres
|
|
|
|
|
fn build_discover_genres_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:genres".to_string(),
|
|
|
|
|
parent_id: "qobuz".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Discover Genres".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Construit le container Favourites (My Music)
|
|
|
|
|
fn build_favourites_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:favorites".to_string(),
|
|
|
|
|
parent_id: "qobuz".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "My Music".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Construit le container Albums favoris
|
|
|
|
|
fn build_favourite_albums_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:favorites:albums".to_string(),
|
|
|
|
|
parent_id: "qobuz:favorites".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Albums".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Construit le container Tracks favoris
|
|
|
|
|
fn build_favourite_tracks_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:favorites:tracks".to_string(),
|
|
|
|
|
parent_id: "qobuz:favorites".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Tracks".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Construit le container Artists favoris
|
|
|
|
|
fn build_favourite_artists_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:favorites:artists".to_string(),
|
|
|
|
|
parent_id: "qobuz:favorites".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Artists".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Construit le container Playlists favoris
|
|
|
|
|
fn build_favourite_playlists_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:favorites:playlists".to_string(),
|
|
|
|
|
parent_id: "qobuz:favorites".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Playlists".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
/// Cache les covers d'une liste d'items en parallèle (générique via `CoverCacheable`).
|
|
|
|
|
async fn cache_covers<T>(&self, items: Vec<T>) -> Vec<T>
|
|
|
|
|
where
|
|
|
|
|
T: CoverCacheable + Send + 'static,
|
|
|
|
|
{
|
|
|
|
|
let futs: Vec<_> = items.into_iter().map(|mut item| {
|
2026-03-24 11:23:56 +01:00
|
|
|
let source = self.clone();
|
|
|
|
|
async move {
|
2026-03-24 15:02:14 +01:00
|
|
|
if let Some(image_url) = item.image_url().map(str::to_string) {
|
|
|
|
|
if let Ok(pk) = source.inner.cache_manager.cache_cover(&image_url).await {
|
2026-03-24 11:23:56 +01:00
|
|
|
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
|
2026-03-24 15:02:14 +01:00
|
|
|
item.set_image_cached(url);
|
2026-03-24 11:23:56 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-24 15:02:14 +01:00
|
|
|
item
|
|
|
|
|
}
|
|
|
|
|
}).collect();
|
|
|
|
|
tokio::task::JoinSet::from_iter(futs).join_all().await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn cache_album_covers(&self, albums: Vec<crate::models::Album>) -> Vec<crate::models::Album> {
|
|
|
|
|
self.cache_covers(albums).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn cache_playlist_covers(&self, playlists: Vec<crate::models::Playlist>) -> Vec<crate::models::Playlist> {
|
|
|
|
|
self.cache_covers(playlists).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Cache les covers d'une liste de tracks en parallèle (via l'image de l'album).
|
|
|
|
|
async fn cache_track_covers(&self, tracks: Vec<crate::models::Track>) -> Vec<crate::models::Track> {
|
|
|
|
|
let futs: Vec<_> = tracks.into_iter().map(|mut track| {
|
|
|
|
|
let source = self.clone();
|
|
|
|
|
async move {
|
|
|
|
|
if let Some(ref mut album) = track.album {
|
|
|
|
|
if let Some(ref image_url) = album.image.clone() {
|
|
|
|
|
if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await {
|
|
|
|
|
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
|
|
|
|
|
album.image_cached = Some(url);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
track
|
2026-03-24 11:23:56 +01:00
|
|
|
}
|
|
|
|
|
}).collect();
|
|
|
|
|
tokio::task::JoinSet::from_iter(futs)
|
|
|
|
|
.join_all()
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 11:29:31 +01:00
|
|
|
/// Enregistre une liste de tracks comme lazy entries dans l'audio cache (en parallèle).
|
|
|
|
|
///
|
|
|
|
|
/// Contrairement à `add_track_lazy`, cette méthode ne fait PAS d'appel à `get_stream_url`
|
|
|
|
|
/// (coûteux pour de grandes playlists). L'URL audio est résolue à la demande via
|
|
|
|
|
/// `QobuzLazyProvider` lors de la première lecture.
|
|
|
|
|
///
|
|
|
|
|
/// Pour chaque track : cache la cover, enregistre la lazy entry, stocke les métadonnées.
|
|
|
|
|
/// Retourne la liste des lazy PKs enregistrés avec succès.
|
|
|
|
|
async fn register_tracks_lazy(&self, tracks: &[crate::models::Track]) -> Vec<String> {
|
|
|
|
|
// Limite la concurrence pour ne pas saturer l'API Qobuz ni la connexion réseau.
|
|
|
|
|
// Les covers déjà cachées sont retournées immédiatement (pas d'HTTP), donc même
|
|
|
|
|
// 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));
|
|
|
|
|
|
2026-03-25 12:25:34 +01:00
|
|
|
// 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)| {
|
2026-03-25 11:29:31 +01:00
|
|
|
let source = self.clone();
|
|
|
|
|
let sem = sem.clone();
|
|
|
|
|
let track_id = track.id.clone();
|
|
|
|
|
let track_title = track.title.clone();
|
|
|
|
|
let cover_image_url = track.album.as_ref().and_then(|a| a.image.clone());
|
|
|
|
|
let metadata = pmoaudiocache::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 move {
|
|
|
|
|
let _permit = sem.acquire().await.ok()?;
|
|
|
|
|
let lazy_pk = format!("QOBUZ:{}", track_id);
|
|
|
|
|
|
|
|
|
|
// 1. Cache cover eagerly
|
|
|
|
|
let cover_pk = if let Some(ref url) = cover_image_url {
|
|
|
|
|
source.inner.cache_manager.cache_cover(url).await.ok()
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-25 12:25:34 +01:00
|
|
|
// 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 };
|
2026-03-25 11:29:31 +01:00
|
|
|
match source.inner.cache_manager
|
2026-03-25 12:25:34 +01:00
|
|
|
.cache_audio_lazy_with_provider(&lazy_pk, meta_hint, cover_pk)
|
2026-03-25 11:29:31 +01:00
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(pk) => {
|
|
|
|
|
let _ = source.inner.cache_manager.set_audio_metadata(
|
|
|
|
|
&pk, "qobuz_track_id", json!(track_id),
|
|
|
|
|
);
|
2026-03-25 12:25:34 +01:00
|
|
|
Some((idx, pk))
|
2026-03-25 11:29:31 +01:00
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("Failed to register lazy track {}: {}", track_title, e);
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}).collect();
|
|
|
|
|
|
2026-03-25 12:25:34 +01:00
|
|
|
// Retrier par index original pour conserver l'ordre de la playlist Qobuz
|
|
|
|
|
let mut results: Vec<(usize, String)> = tokio::task::JoinSet::from_iter(futs)
|
2026-03-25 11:29:31 +01:00
|
|
|
.join_all()
|
|
|
|
|
.await
|
|
|
|
|
.into_iter()
|
|
|
|
|
.flatten()
|
2026-03-25 12:25:34 +01:00
|
|
|
.collect();
|
|
|
|
|
results.sort_unstable_by_key(|(i, _)| *i);
|
|
|
|
|
results.into_iter().map(|(_, pk)| pk).collect()
|
2026-03-25 11:29:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
/// Cache les covers d'une liste d'artistes en parallèle.
|
|
|
|
|
async fn cache_artist_covers(&self, artists: Vec<crate::models::Artist>) -> Vec<crate::models::Artist> {
|
|
|
|
|
let futs: Vec<_> = artists.into_iter().map(|mut artist| {
|
2026-03-24 11:23:56 +01:00
|
|
|
let source = self.clone();
|
|
|
|
|
async move {
|
2026-03-24 15:02:14 +01:00
|
|
|
if let Some(ref image_url) = artist.image.clone() {
|
2026-03-24 11:23:56 +01:00
|
|
|
if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await {
|
|
|
|
|
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
|
2026-03-24 15:02:14 +01:00
|
|
|
artist.image_cached = Some(url);
|
2026-03-24 11:23:56 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-24 15:02:14 +01:00
|
|
|
artist
|
2026-03-24 11:23:56 +01:00
|
|
|
}
|
|
|
|
|
}).collect();
|
|
|
|
|
tokio::task::JoinSet::from_iter(futs)
|
|
|
|
|
.join_all()
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 14:54:27 +01:00
|
|
|
/// Browse Favourites - retourne 4 sous-containers
|
|
|
|
|
async fn browse_favourites(&self) -> Result<BrowseResult> {
|
|
|
|
|
let containers = vec![
|
|
|
|
|
self.build_favourite_albums_container(),
|
|
|
|
|
self.build_favourite_tracks_container(),
|
|
|
|
|
self.build_favourite_artists_container(),
|
|
|
|
|
self.build_favourite_playlists_container(),
|
|
|
|
|
];
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Browse Favourite Albums
|
|
|
|
|
async fn browse_favourite_albums(&self) -> Result<BrowseResult> {
|
|
|
|
|
let albums = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_favorite_albums()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 11:23:56 +01:00
|
|
|
let albums = self.cache_album_covers(albums).await;
|
2025-12-28 14:54:27 +01:00
|
|
|
let containers: Vec<Container> = albums
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Browse Favourite Tracks
|
|
|
|
|
async fn browse_favourite_tracks(&self) -> Result<BrowseResult> {
|
|
|
|
|
let tracks = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_favorite_tracks()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let tracks = self.cache_covers(tracks).await;
|
2025-12-28 14:54:27 +01:00
|
|
|
let items: Vec<Item> = tracks
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|track| track.to_didl_item("qobuz:favorites:tracks").ok())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Items(items))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Browse Favourite Artists
|
|
|
|
|
async fn browse_favourite_artists(&self) -> Result<BrowseResult> {
|
|
|
|
|
let artists = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_favorite_artists()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let artists = self.cache_covers(artists).await;
|
2025-12-28 14:54:27 +01:00
|
|
|
let containers: Vec<Container> = artists
|
|
|
|
|
.into_iter()
|
2026-03-24 15:02:14 +01:00
|
|
|
.map(|artist| Container {
|
|
|
|
|
id: format!("qobuz:artist:{}", artist.id),
|
|
|
|
|
parent_id: "qobuz:favorites:artists".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: artist.name.clone(),
|
|
|
|
|
class: "object.container".to_string(),
|
|
|
|
|
artist: Some(artist.name.clone()),
|
|
|
|
|
album_art: artist.image_cached.clone(),
|
|
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
2025-12-28 14:54:27 +01:00
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Browse Favourite Playlists
|
|
|
|
|
async fn browse_favourite_playlists(&self) -> Result<BrowseResult> {
|
|
|
|
|
let playlists = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_user_playlists()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 11:23:56 +01:00
|
|
|
let playlists = self.cache_playlist_covers(playlists).await;
|
2025-12-28 14:54:27 +01:00
|
|
|
let containers: Vec<Container> = playlists
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|playlist| playlist.to_didl_container("qobuz:favorites:playlists").ok())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== DISCOVER CATALOG =====
|
|
|
|
|
|
|
|
|
|
/// Browse Discover Catalog - retourne 5 containers principaux + 10 playlists par tag
|
|
|
|
|
async fn browse_discover_catalog(&self) -> Result<BrowseResult> {
|
|
|
|
|
use crate::models::PlaylistTag;
|
|
|
|
|
|
|
|
|
|
let mut containers = vec![
|
|
|
|
|
self.build_discover_playlists_container(),
|
|
|
|
|
self.build_discover_albums_ideal_container(),
|
|
|
|
|
self.build_discover_albums_qobuzissime_container(),
|
|
|
|
|
self.build_discover_albums_new_container(),
|
|
|
|
|
self.build_discover_artists_container(),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Ajouter les 10 tags de playlists
|
|
|
|
|
for tag in PlaylistTag::all() {
|
|
|
|
|
containers.push(Container {
|
|
|
|
|
id: format!("qobuz:discover:playlists:{}", tag.api_id()),
|
|
|
|
|
parent_id: "qobuz:discover".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: tag.display_name().to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_discover_playlists_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:discover:playlists".to_string(),
|
|
|
|
|
parent_id: "qobuz:discover".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Playlists".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_discover_albums_ideal_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:discover:albums:ideal".to_string(),
|
|
|
|
|
parent_id: "qobuz:discover".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Albums (Ideal Discography)".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_discover_albums_qobuzissime_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:discover:albums:qobuzissime".to_string(),
|
|
|
|
|
parent_id: "qobuz:discover".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Albums (Qobuzissime)".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_discover_albums_new_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:discover:albums:new".to_string(),
|
|
|
|
|
parent_id: "qobuz:discover".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Albums (New Releases)".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_discover_artists_container(&self) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: "qobuz:discover:artists".to_string(),
|
|
|
|
|
parent_id: "qobuz:discover".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Artists".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 11:23:56 +01:00
|
|
|
/// Helper : récupère des albums featured, cache les covers, retourne des containers DIDL
|
|
|
|
|
async fn browse_featured_albums(&self, genre_id: Option<&str>, filter: &str, parent_id: &str) -> Result<BrowseResult> {
|
|
|
|
|
let albums = self
|
2025-12-28 14:54:27 +01:00
|
|
|
.inner
|
|
|
|
|
.client
|
2026-03-24 11:23:56 +01:00
|
|
|
.get_featured_albums(genre_id, filter)
|
2025-12-28 14:54:27 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 11:23:56 +01:00
|
|
|
let albums = self.cache_album_covers(albums).await;
|
|
|
|
|
let containers: Vec<Container> = albums
|
2025-12-28 14:54:27 +01:00
|
|
|
.into_iter()
|
2026-03-24 11:23:56 +01:00
|
|
|
.filter_map(|album| album.to_didl_container(parent_id).ok())
|
2025-12-28 14:54:27 +01:00
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 11:23:56 +01:00
|
|
|
/// Helper : récupère des playlists featured, cache les covers, retourne des containers DIDL
|
|
|
|
|
async fn browse_featured_playlists(&self, genre_id: Option<&str>, parent_id: &str) -> Result<BrowseResult> {
|
|
|
|
|
let playlists = self
|
2025-12-28 14:54:27 +01:00
|
|
|
.inner
|
|
|
|
|
.client
|
2026-03-24 11:23:56 +01:00
|
|
|
.get_featured_playlists(genre_id, None)
|
2025-12-28 14:54:27 +01:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 11:23:56 +01:00
|
|
|
let playlists = self.cache_playlist_covers(playlists).await;
|
|
|
|
|
let containers: Vec<Container> = playlists
|
2025-12-28 14:54:27 +01:00
|
|
|
.into_iter()
|
2026-03-24 11:23:56 +01:00
|
|
|
.filter_map(|playlist| playlist.to_didl_container(parent_id).ok())
|
2025-12-28 14:54:27 +01:00
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 11:23:56 +01:00
|
|
|
async fn browse_discover_playlists(&self) -> Result<BrowseResult> {
|
|
|
|
|
self.browse_featured_playlists(None, "qobuz:discover:playlists").await
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
|
2026-03-24 11:23:56 +01:00
|
|
|
async fn browse_discover_albums_ideal(&self) -> Result<BrowseResult> {
|
|
|
|
|
self.browse_featured_albums(None, "ideal-discography", "qobuz:discover:albums:ideal").await
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
|
2026-03-24 11:23:56 +01:00
|
|
|
async fn browse_discover_albums_qobuzissime(&self) -> Result<BrowseResult> {
|
|
|
|
|
self.browse_featured_albums(None, "qobuzissims", "qobuz:discover:albums:qobuzissime").await
|
2025-12-28 14:54:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn browse_discover_albums_new(&self) -> Result<BrowseResult> {
|
2026-03-24 11:23:56 +01:00
|
|
|
self.browse_featured_albums(None, "new-releases", "qobuz:discover:albums:new").await
|
2025-12-28 14:54:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Browse Discover Artists (Featured Artists)
|
|
|
|
|
async fn browse_discover_artists(&self) -> Result<BrowseResult> {
|
|
|
|
|
let artists = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_featured_artists(None, None, None)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let artists = self.cache_covers(artists).await;
|
2025-12-28 14:54:27 +01:00
|
|
|
let containers: Vec<Container> = artists
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|artist| Container {
|
|
|
|
|
id: format!("qobuz:artist:{}", artist.id),
|
|
|
|
|
parent_id: "qobuz:discover:artists".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: artist.name.clone(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: Some(artist.name.clone()),
|
2026-03-24 15:02:14 +01:00
|
|
|
album_art: artist.image_cached.clone(),
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Browse Discover Playlists by tag
|
|
|
|
|
async fn browse_discover_playlists_tag(&self, tag: &str) -> Result<BrowseResult> {
|
|
|
|
|
let playlists = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_featured_playlists(None, Some(tag))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let playlists = self.cache_covers(playlists).await;
|
2025-12-28 14:54:27 +01:00
|
|
|
let parent_id = format!("qobuz:discover:playlists:{}", tag);
|
|
|
|
|
let containers: Vec<Container> = playlists
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|playlist| playlist.to_didl_container(&parent_id).ok())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== DISCOVER GENRES =====
|
|
|
|
|
|
|
|
|
|
/// Browse Discover Genres - liste des genres
|
|
|
|
|
async fn browse_discover_genres(&self) -> Result<BrowseResult> {
|
|
|
|
|
let genres = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_genres()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let containers: Vec<Container> = genres
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|genre| {
|
|
|
|
|
// Filtrer les genres sans ID
|
|
|
|
|
genre.id.map(|id| Container {
|
|
|
|
|
id: format!("qobuz:genre:{}", id),
|
|
|
|
|
parent_id: "qobuz:genres".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: genre.name.clone(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Browse un genre spécifique - retourne 6 sous-containers
|
|
|
|
|
async fn browse_genre(&self, genre_id: &str) -> Result<BrowseResult> {
|
|
|
|
|
let containers = vec![
|
|
|
|
|
self.build_genre_new_releases_container(genre_id),
|
|
|
|
|
self.build_genre_ideal_discography_container(genre_id),
|
|
|
|
|
self.build_genre_qobuzissime_container(genre_id),
|
|
|
|
|
self.build_genre_editor_picks_container(genre_id),
|
|
|
|
|
self.build_genre_press_awards_container(genre_id),
|
|
|
|
|
self.build_genre_playlists_container(genre_id),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_genre_new_releases_container(&self, genre_id: &str) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: format!("qobuz:genre:{}:new-releases", genre_id),
|
|
|
|
|
parent_id: format!("qobuz:genre:{}", genre_id),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "New Releases".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_genre_ideal_discography_container(&self, genre_id: &str) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: format!("qobuz:genre:{}:ideal", genre_id),
|
|
|
|
|
parent_id: format!("qobuz:genre:{}", genre_id),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Ideal Discography".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_genre_qobuzissime_container(&self, genre_id: &str) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: format!("qobuz:genre:{}:qobuzissime", genre_id),
|
|
|
|
|
parent_id: format!("qobuz:genre:{}", genre_id),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Qobuzissime".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_genre_editor_picks_container(&self, genre_id: &str) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: format!("qobuz:genre:{}:editor-picks", genre_id),
|
|
|
|
|
parent_id: format!("qobuz:genre:{}", genre_id),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Editor Picks".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_genre_press_awards_container(&self, genre_id: &str) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: format!("qobuz:genre:{}:press-awards", genre_id),
|
|
|
|
|
parent_id: format!("qobuz:genre:{}", genre_id),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Press Awards".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_genre_playlists_container(&self, genre_id: &str) -> Container {
|
|
|
|
|
Container {
|
|
|
|
|
id: format!("qobuz:genre:{}:playlists", genre_id),
|
|
|
|
|
parent_id: format!("qobuz:genre:{}", genre_id),
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
child_count: None,
|
|
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: "Qobuz Playlists".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-12-28 14:54:27 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn browse_genre_new_releases(&self, genre_id: &str) -> Result<BrowseResult> {
|
2026-03-24 11:23:56 +01:00
|
|
|
self.browse_featured_albums(Some(genre_id), "new-releases", &format!("qobuz:genre:{}:new-releases", genre_id)).await
|
2025-12-28 14:54:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn browse_genre_ideal_discography(&self, genre_id: &str) -> Result<BrowseResult> {
|
2026-03-24 11:23:56 +01:00
|
|
|
self.browse_featured_albums(Some(genre_id), "ideal-discography", &format!("qobuz:genre:{}:ideal", genre_id)).await
|
2025-12-28 14:54:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn browse_genre_qobuzissime(&self, genre_id: &str) -> Result<BrowseResult> {
|
2026-03-24 11:23:56 +01:00
|
|
|
self.browse_featured_albums(Some(genre_id), "qobuzissims", &format!("qobuz:genre:{}:qobuzissime", genre_id)).await
|
2025-12-28 14:54:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn browse_genre_editor_picks(&self, genre_id: &str) -> Result<BrowseResult> {
|
2026-03-24 11:23:56 +01:00
|
|
|
self.browse_featured_albums(Some(genre_id), "editor-picks", &format!("qobuz:genre:{}:editor-picks", genre_id)).await
|
2025-12-28 14:54:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn browse_genre_press_awards(&self, genre_id: &str) -> Result<BrowseResult> {
|
2026-03-24 11:23:56 +01:00
|
|
|
self.browse_featured_albums(Some(genre_id), "press-awards", &format!("qobuz:genre:{}:press-awards", genre_id)).await
|
2025-12-28 14:54:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn browse_genre_playlists(&self, genre_id: &str) -> Result<BrowseResult> {
|
2026-03-24 11:23:56 +01:00
|
|
|
self.browse_featured_playlists(Some(genre_id), &format!("qobuz:genre:{}:playlists", genre_id)).await
|
2025-12-28 14:54:27 +01:00
|
|
|
}
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
/// Parse object_id to determine what to browse
|
|
|
|
|
///
|
|
|
|
|
/// Object IDs follow these patterns:
|
|
|
|
|
/// - "qobuz" or "0" → Root container
|
2025-12-28 14:54:27 +01:00
|
|
|
/// - "qobuz:discover" → Discover Catalog
|
|
|
|
|
/// - "qobuz:genres" → Discover Genres
|
|
|
|
|
/// - "qobuz:favorites" → My Music
|
2025-10-16 22:13:00 +02:00
|
|
|
/// - "qobuz:album:{id}" → Tracks in album
|
|
|
|
|
/// - "qobuz:playlist:{id}" → Tracks in playlist
|
2025-12-28 14:54:27 +01:00
|
|
|
/// - etc.
|
2025-10-16 22:13:00 +02:00
|
|
|
fn parse_object_id(&self, object_id: &str) -> ObjectIdType {
|
|
|
|
|
if object_id == "qobuz" || object_id == "0" {
|
|
|
|
|
return ObjectIdType::Root;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let parts: Vec<&str> = object_id.split(':').collect();
|
|
|
|
|
match parts.as_slice() {
|
2025-12-28 14:54:27 +01:00
|
|
|
// Discover Catalog
|
|
|
|
|
["qobuz", "discover"] => ObjectIdType::DiscoverCatalog,
|
|
|
|
|
["qobuz", "discover", "playlists"] => ObjectIdType::DiscoverPlaylists,
|
|
|
|
|
["qobuz", "discover", "albums", "ideal"] => ObjectIdType::DiscoverAlbumsIdeal,
|
2026-01-04 18:54:31 +01:00
|
|
|
["qobuz", "discover", "albums", "qobuzissime"] => {
|
|
|
|
|
ObjectIdType::DiscoverAlbumsQobuzissime
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
["qobuz", "discover", "albums", "new"] => ObjectIdType::DiscoverAlbumsNew,
|
|
|
|
|
["qobuz", "discover", "artists"] => ObjectIdType::DiscoverArtists,
|
2026-01-04 18:54:31 +01:00
|
|
|
["qobuz", "discover", "playlists", tag] => {
|
|
|
|
|
ObjectIdType::DiscoverPlaylistsByTag(tag.to_string())
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
|
|
|
|
|
// Discover Genres
|
|
|
|
|
["qobuz", "genres"] => ObjectIdType::DiscoverGenres,
|
|
|
|
|
["qobuz", "genre", id] => ObjectIdType::GenreRoot(id.to_string()),
|
2026-01-04 18:54:31 +01:00
|
|
|
["qobuz", "genre", id, "new-releases"] => {
|
|
|
|
|
ObjectIdType::GenreNewReleases(id.to_string())
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
["qobuz", "genre", id, "ideal"] => ObjectIdType::GenreIdealDiscography(id.to_string()),
|
|
|
|
|
["qobuz", "genre", id, "qobuzissime"] => ObjectIdType::GenreQobuzissime(id.to_string()),
|
2026-01-04 18:54:31 +01:00
|
|
|
["qobuz", "genre", id, "editor-picks"] => {
|
|
|
|
|
ObjectIdType::GenreEditorPicks(id.to_string())
|
|
|
|
|
}
|
|
|
|
|
["qobuz", "genre", id, "press-awards"] => {
|
|
|
|
|
ObjectIdType::GenrePressAwards(id.to_string())
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
["qobuz", "genre", id, "playlists"] => ObjectIdType::GenrePlaylists(id.to_string()),
|
|
|
|
|
|
|
|
|
|
// Favourites
|
|
|
|
|
["qobuz", "favorites"] => ObjectIdType::Favourites,
|
|
|
|
|
["qobuz", "favorites", "albums"] => ObjectIdType::FavouriteAlbums,
|
|
|
|
|
["qobuz", "favorites", "tracks"] => ObjectIdType::FavouriteTracks,
|
|
|
|
|
["qobuz", "favorites", "artists"] => ObjectIdType::FavouriteArtists,
|
|
|
|
|
["qobuz", "favorites", "playlists"] => ObjectIdType::FavouritePlaylists,
|
|
|
|
|
|
|
|
|
|
// Items (existant)
|
2025-10-16 22:13:00 +02:00
|
|
|
["qobuz", "album", id] => ObjectIdType::Album(id.to_string()),
|
|
|
|
|
["qobuz", "playlist", id] => ObjectIdType::Playlist(id.to_string()),
|
|
|
|
|
["qobuz", "artist", id] => ObjectIdType::Artist(id.to_string()),
|
2025-12-28 14:54:27 +01:00
|
|
|
["qobuz", "track", id] => ObjectIdType::Track(id.to_string()),
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
_ => ObjectIdType::Unknown,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
enum ObjectIdType {
|
|
|
|
|
Root,
|
2025-12-28 14:54:27 +01:00
|
|
|
|
|
|
|
|
// Discover Catalog
|
|
|
|
|
DiscoverCatalog,
|
|
|
|
|
DiscoverPlaylists,
|
|
|
|
|
DiscoverAlbumsIdeal,
|
|
|
|
|
DiscoverAlbumsQobuzissime,
|
|
|
|
|
DiscoverAlbumsNew,
|
|
|
|
|
DiscoverArtists,
|
|
|
|
|
DiscoverPlaylistsByTag(String), // tag
|
|
|
|
|
|
|
|
|
|
// Discover Genres
|
|
|
|
|
DiscoverGenres,
|
2026-01-04 18:54:31 +01:00
|
|
|
GenreRoot(String), // genre_id
|
|
|
|
|
GenreNewReleases(String), // genre_id
|
|
|
|
|
GenreIdealDiscography(String), // genre_id
|
|
|
|
|
GenreQobuzissime(String), // genre_id
|
|
|
|
|
GenreEditorPicks(String), // genre_id
|
|
|
|
|
GenrePressAwards(String), // genre_id
|
|
|
|
|
GenrePlaylists(String), // genre_id
|
2025-12-28 14:54:27 +01:00
|
|
|
|
|
|
|
|
// Favourites
|
|
|
|
|
Favourites,
|
|
|
|
|
FavouriteAlbums,
|
|
|
|
|
FavouriteTracks,
|
|
|
|
|
FavouriteArtists,
|
|
|
|
|
FavouritePlaylists,
|
|
|
|
|
|
|
|
|
|
// Items (existant)
|
2025-10-16 22:13:00 +02:00
|
|
|
Album(String),
|
|
|
|
|
Playlist(String),
|
|
|
|
|
Artist(String),
|
2025-12-28 14:54:27 +01:00
|
|
|
Track(String),
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
Unknown,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait]
|
2025-10-16 22:00:35 +02:00
|
|
|
impl MusicSource for QobuzSource {
|
|
|
|
|
fn name(&self) -> &str {
|
|
|
|
|
"Qobuz"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn id(&self) -> &str {
|
|
|
|
|
"qobuz"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_image(&self) -> &[u8] {
|
|
|
|
|
DEFAULT_IMAGE
|
|
|
|
|
}
|
2025-10-16 22:13:00 +02:00
|
|
|
|
|
|
|
|
async fn root_container(&self) -> Result<Container> {
|
2025-12-28 14:54:27 +01:00
|
|
|
// Create the root container with 3 main branches
|
2025-10-16 22:13:00 +02:00
|
|
|
Ok(Container {
|
|
|
|
|
id: "qobuz".to_string(),
|
|
|
|
|
parent_id: "0".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
2025-12-28 14:54:27 +01:00
|
|
|
child_count: Some("3".to_string()),
|
2025-10-26 23:49:20 +01:00
|
|
|
searchable: Some("1".to_string()),
|
2025-10-16 22:13:00 +02:00
|
|
|
title: "Qobuz".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-10-16 22:13:00 +02:00
|
|
|
containers: vec![
|
2025-12-28 14:54:27 +01:00
|
|
|
self.build_discover_catalog_container(),
|
|
|
|
|
self.build_discover_genres_container(),
|
|
|
|
|
self.build_favourites_container(),
|
2025-10-16 22:13:00 +02:00
|
|
|
],
|
|
|
|
|
items: vec![],
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
|
|
|
|
|
match self.parse_object_id(object_id) {
|
|
|
|
|
ObjectIdType::Root => {
|
|
|
|
|
// Return the root container's children
|
|
|
|
|
let root = self.root_container().await?;
|
|
|
|
|
Ok(BrowseResult::Containers(root.containers))
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 14:54:27 +01:00
|
|
|
// Discover Catalog
|
|
|
|
|
ObjectIdType::DiscoverCatalog => self.browse_discover_catalog().await,
|
|
|
|
|
ObjectIdType::DiscoverPlaylists => self.browse_discover_playlists().await,
|
|
|
|
|
ObjectIdType::DiscoverAlbumsIdeal => self.browse_discover_albums_ideal().await,
|
2026-01-04 18:54:31 +01:00
|
|
|
ObjectIdType::DiscoverAlbumsQobuzissime => {
|
|
|
|
|
self.browse_discover_albums_qobuzissime().await
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
ObjectIdType::DiscoverAlbumsNew => self.browse_discover_albums_new().await,
|
|
|
|
|
ObjectIdType::DiscoverArtists => self.browse_discover_artists().await,
|
2026-01-04 18:54:31 +01:00
|
|
|
ObjectIdType::DiscoverPlaylistsByTag(tag) => {
|
|
|
|
|
self.browse_discover_playlists_tag(&tag).await
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
|
|
|
|
|
// Discover Genres
|
|
|
|
|
ObjectIdType::DiscoverGenres => self.browse_discover_genres().await,
|
|
|
|
|
ObjectIdType::GenreRoot(id) => self.browse_genre(&id).await,
|
|
|
|
|
ObjectIdType::GenreNewReleases(id) => self.browse_genre_new_releases(&id).await,
|
2026-01-04 18:54:31 +01:00
|
|
|
ObjectIdType::GenreIdealDiscography(id) => {
|
|
|
|
|
self.browse_genre_ideal_discography(&id).await
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
ObjectIdType::GenreQobuzissime(id) => self.browse_genre_qobuzissime(&id).await,
|
|
|
|
|
ObjectIdType::GenreEditorPicks(id) => self.browse_genre_editor_picks(&id).await,
|
|
|
|
|
ObjectIdType::GenrePressAwards(id) => self.browse_genre_press_awards(&id).await,
|
|
|
|
|
ObjectIdType::GenrePlaylists(id) => self.browse_genre_playlists(&id).await,
|
|
|
|
|
|
|
|
|
|
// Favourites
|
|
|
|
|
ObjectIdType::Favourites => self.browse_favourites().await,
|
|
|
|
|
ObjectIdType::FavouriteAlbums => self.browse_favourite_albums().await,
|
|
|
|
|
ObjectIdType::FavouriteTracks => self.browse_favourite_tracks().await,
|
|
|
|
|
ObjectIdType::FavouriteArtists => self.browse_favourite_artists().await,
|
|
|
|
|
ObjectIdType::FavouritePlaylists => self.browse_favourite_playlists().await,
|
|
|
|
|
|
|
|
|
|
// Items (existant)
|
2025-10-16 22:13:00 +02:00
|
|
|
ObjectIdType::Album(album_id) => {
|
2025-12-28 09:56:05 +01:00
|
|
|
let items = self
|
|
|
|
|
.get_or_create_album_playlist_items(&album_id, usize::MAX)
|
|
|
|
|
.await?;
|
2025-10-16 22:13:00 +02:00
|
|
|
|
|
|
|
|
Ok(BrowseResult::Items(items))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ObjectIdType::Playlist(playlist_id) => {
|
2026-03-24 17:10:38 +01:00
|
|
|
let items = self
|
|
|
|
|
.get_or_create_qobuz_playlist_items(&playlist_id)
|
|
|
|
|
.await?;
|
2025-10-16 22:13:00 +02:00
|
|
|
|
|
|
|
|
Ok(BrowseResult::Items(items))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ObjectIdType::Artist(artist_id) => {
|
|
|
|
|
let albums = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_artist_albums(&artist_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let albums = self.cache_covers(albums).await;
|
2025-10-16 22:13:00 +02:00
|
|
|
let containers: Vec<Container> = albums
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|album| {
|
|
|
|
|
album
|
|
|
|
|
.to_didl_container(&format!("qobuz:artist:{}", artist_id))
|
|
|
|
|
.ok()
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 14:54:27 +01:00
|
|
|
ObjectIdType::Track(_) => {
|
|
|
|
|
// Track object_ids ne sont pas browsables, retourner une erreur
|
|
|
|
|
Err(MusicSourceError::NotSupported(
|
|
|
|
|
"Tracks are not browsable containers".to_string(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
2025-10-17 23:45:01 +02:00
|
|
|
// Try cache manager first
|
|
|
|
|
if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await {
|
|
|
|
|
return Ok(uri);
|
2025-10-17 07:56:21 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-17 23:45:01 +02:00
|
|
|
// If not cached, extract track ID and get streaming URL from Qobuz
|
2025-10-19 13:42:29 +02:00
|
|
|
let track_id = object_id
|
|
|
|
|
.strip_prefix("qobuz://track/")
|
|
|
|
|
.unwrap_or(object_id);
|
2025-10-16 22:13:00 +02:00
|
|
|
|
2025-10-19 13:42:29 +02:00
|
|
|
self.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_stream_url(track_id)
|
|
|
|
|
.await
|
2025-10-16 22:13:00 +02:00
|
|
|
.map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 23:37:39 +01:00
|
|
|
async fn get_item(&self, object_id: &str) -> Result<Item> {
|
|
|
|
|
// Parse object_id to extract track ID
|
|
|
|
|
let track_id = match self.parse_object_id(object_id) {
|
|
|
|
|
ObjectIdType::Track(id) => id,
|
|
|
|
|
_ => {
|
|
|
|
|
return Err(MusicSourceError::ObjectNotFound(format!(
|
|
|
|
|
"Not a track: {}",
|
|
|
|
|
object_id
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Get track from Qobuz API
|
|
|
|
|
let track = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_track(&track_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// Register track in cache with lazy loading (same as albums)
|
|
|
|
|
let (_track_uri, cache_pk) = self.add_track_lazy(&track).await?;
|
|
|
|
|
|
|
|
|
|
// Build Item with HTTP URL pointing to cache
|
|
|
|
|
let parent_id = track
|
|
|
|
|
.album
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|a| format!("qobuz:album:{}", a.id))
|
|
|
|
|
.unwrap_or_else(|| "qobuz".to_string());
|
|
|
|
|
|
|
|
|
|
// Get cover URL from cache if available
|
|
|
|
|
let cover_url = if let Some(ref album) = track.album {
|
2026-03-24 11:23:56 +01:00
|
|
|
if let Some(ref image) = album.image {
|
2026-01-16 23:37:39 +01:00
|
|
|
if let Ok(pk) = self.inner.cache_manager.cache_cover(image).await {
|
2026-03-24 11:23:56 +01:00
|
|
|
self.inner.cache_manager.cover_url(&pk, None).ok()
|
2026-01-16 23:37:39 +01:00
|
|
|
} else {
|
|
|
|
|
Some(image.clone())
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Build the resource with absolute HTTP URL
|
|
|
|
|
let audio_url = format!("{}/audio/flac/{}", self.inner.base_url, cache_pk);
|
|
|
|
|
|
|
|
|
|
let resource = pmodidl::Resource {
|
|
|
|
|
protocol_info: format!(
|
|
|
|
|
"http-get:*:{}:*",
|
|
|
|
|
track.mime_type.as_deref().unwrap_or("audio/flac")
|
|
|
|
|
),
|
|
|
|
|
bits_per_sample: track.bit_depth.map(|b| b.to_string()),
|
|
|
|
|
sample_frequency: track.sample_rate.map(|r| r.to_string()),
|
|
|
|
|
nr_audio_channels: track.channels.map(|c| c.to_string()),
|
|
|
|
|
duration: Some(format_duration(track.duration)),
|
|
|
|
|
url: audio_url,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Item {
|
|
|
|
|
id: format!("qobuz:track:{}", track.id),
|
|
|
|
|
parent_id,
|
|
|
|
|
restricted: Some("1".to_string()),
|
|
|
|
|
title: track.title.clone(),
|
|
|
|
|
creator: track.display_artist().map(|a| a.name.clone()),
|
|
|
|
|
class: "object.item.audioItem.musicTrack".to_string(),
|
|
|
|
|
artist: track.display_artist().map(|a| a.name.clone()),
|
|
|
|
|
album: track.album_name().map(|s| s.to_string()),
|
|
|
|
|
genre: None,
|
|
|
|
|
album_art: cover_url,
|
|
|
|
|
album_art_pk: None,
|
|
|
|
|
date: track.album.as_ref().and_then(|a| a.release_date.clone()),
|
|
|
|
|
original_track_number: Some(track.track_number.to_string()),
|
|
|
|
|
resources: vec![resource],
|
|
|
|
|
descriptions: Vec::new(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
fn supports_fifo(&self) -> bool {
|
|
|
|
|
// Qobuz is a catalog, not a dynamic stream
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn append_track(&self, _track: Item) -> Result<()> {
|
|
|
|
|
Err(MusicSourceError::FifoNotSupported)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
|
|
|
|
Err(MusicSourceError::FifoNotSupported)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn update_id(&self) -> u32 {
|
|
|
|
|
*self.inner.update_counter.read().await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn last_change(&self) -> Option<SystemTime> {
|
|
|
|
|
Some(*self.inner.last_change.read().await)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
|
|
|
|
|
// For Qobuz, "get_items" returns favorite tracks with pagination
|
|
|
|
|
let all_tracks = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_favorite_tracks()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let all_tracks = self.cache_covers(all_tracks).await;
|
2025-10-16 22:13:00 +02:00
|
|
|
let items: Vec<Item> = all_tracks
|
|
|
|
|
.into_iter()
|
|
|
|
|
.skip(offset)
|
|
|
|
|
.take(count)
|
|
|
|
|
.filter_map(|track| track.to_didl_item("qobuz:favorites").ok())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(items)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn search(&self, query: &str) -> Result<BrowseResult> {
|
|
|
|
|
// Search across Qobuz catalog
|
|
|
|
|
let results = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.search(query, None)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let (albums, tracks) = tokio::join!(
|
|
|
|
|
self.cache_covers(results.albums),
|
|
|
|
|
self.cache_covers(results.tracks),
|
|
|
|
|
);
|
|
|
|
|
let containers: Vec<Container> = albums
|
2025-10-16 22:13:00 +02:00
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|album| album.to_didl_container("qobuz").ok())
|
|
|
|
|
.collect();
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let items: Vec<Item> = tracks
|
2025-10-16 22:13:00 +02:00
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|track| track.to_didl_item("qobuz").ok())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
if !containers.is_empty() || !items.is_empty() {
|
|
|
|
|
Ok(BrowseResult::Mixed { containers, items })
|
|
|
|
|
} else {
|
|
|
|
|
Ok(BrowseResult::Items(vec![]))
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-17 08:19:10 +02:00
|
|
|
|
|
|
|
|
// ============= Extended Features Implementation =============
|
|
|
|
|
|
|
|
|
|
fn capabilities(&self) -> pmosource::SourceCapabilities {
|
|
|
|
|
pmosource::SourceCapabilities {
|
|
|
|
|
supports_fifo: false,
|
|
|
|
|
supports_search: true,
|
|
|
|
|
supports_favorites: true,
|
|
|
|
|
supports_playlists: true,
|
|
|
|
|
supports_user_content: false,
|
|
|
|
|
supports_high_res_audio: true,
|
|
|
|
|
max_sample_rate: Some(192_000), // Qobuz supports up to 192kHz
|
|
|
|
|
supports_multiple_formats: true,
|
|
|
|
|
supports_advanced_search: false, // TODO: Qobuz API supports it, not yet implemented
|
|
|
|
|
supports_pagination: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn get_available_formats(&self, object_id: &str) -> Result<Vec<pmosource::AudioFormat>> {
|
|
|
|
|
use pmosource::AudioFormat;
|
|
|
|
|
|
|
|
|
|
// Extract track ID from object_id
|
|
|
|
|
let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") {
|
|
|
|
|
id
|
|
|
|
|
} else {
|
|
|
|
|
object_id
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Get track details from Qobuz
|
|
|
|
|
let track = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_track(track_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// Qobuz provides multiple formats based on subscription
|
|
|
|
|
let mut formats = vec![];
|
|
|
|
|
|
|
|
|
|
// MP3 320 (format_id 5) - available to all
|
|
|
|
|
formats.push(AudioFormat {
|
|
|
|
|
format_id: "mp3-320".to_string(),
|
|
|
|
|
mime_type: "audio/mpeg".to_string(),
|
|
|
|
|
sample_rate: Some(44100),
|
|
|
|
|
bit_depth: None,
|
|
|
|
|
bitrate: Some(320),
|
|
|
|
|
channels: Some(2),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// FLAC 16/44.1 (format_id 6) - CD quality
|
|
|
|
|
formats.push(AudioFormat {
|
|
|
|
|
format_id: "flac-16-44".to_string(),
|
|
|
|
|
mime_type: "audio/flac".to_string(),
|
|
|
|
|
sample_rate: Some(44100),
|
|
|
|
|
bit_depth: Some(16),
|
|
|
|
|
bitrate: None,
|
|
|
|
|
channels: Some(2),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Hi-Res formats (if available for this track)
|
|
|
|
|
if let Some(sample_rate) = track.sample_rate {
|
|
|
|
|
if sample_rate > 44100 {
|
|
|
|
|
// FLAC 24-bit Hi-Res
|
|
|
|
|
let bit_depth = track.bit_depth.map(|d| d as u8).or(Some(24));
|
|
|
|
|
|
|
|
|
|
formats.push(AudioFormat {
|
|
|
|
|
format_id: format!("flac-{}-{}", bit_depth.unwrap_or(24), sample_rate / 1000),
|
|
|
|
|
mime_type: "audio/flac".to_string(),
|
|
|
|
|
sample_rate: Some(sample_rate),
|
|
|
|
|
bit_depth,
|
|
|
|
|
bitrate: None,
|
|
|
|
|
channels: track.channels,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(formats)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn get_cache_status(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
|
2025-10-17 23:45:01 +02:00
|
|
|
self.inner.cache_manager.get_cache_status(object_id).await
|
2025-10-17 08:19:10 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn cache_item(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
|
2025-10-17 23:45:01 +02:00
|
|
|
// Extract track ID
|
2025-10-19 13:42:29 +02:00
|
|
|
let track_id = object_id
|
|
|
|
|
.strip_prefix("qobuz://track/")
|
|
|
|
|
.unwrap_or(object_id);
|
2025-10-17 08:19:10 +02:00
|
|
|
|
2025-10-17 23:45:01 +02:00
|
|
|
// Get track details from Qobuz
|
2025-10-19 13:42:29 +02:00
|
|
|
let track = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_track(track_id)
|
|
|
|
|
.await
|
2025-10-17 23:45:01 +02:00
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// Add track to cache (via manager)
|
|
|
|
|
let cached_id = self.add_track(&track).await?;
|
|
|
|
|
|
|
|
|
|
// Return the cache status
|
|
|
|
|
self.get_cache_status(&cached_id).await
|
2025-10-17 08:19:10 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn add_favorite(&self, object_id: &str) -> Result<()> {
|
|
|
|
|
// Parse object_id to determine type
|
|
|
|
|
let parts: Vec<&str> = object_id.split(':').collect();
|
|
|
|
|
|
|
|
|
|
match parts.as_slice() {
|
|
|
|
|
["qobuz", "album", id] | ["qobuz://album", id] => {
|
|
|
|
|
self.inner
|
|
|
|
|
.client
|
|
|
|
|
.add_favorite_album(id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
|
|
|
|
}
|
|
|
|
|
["qobuz", "track", id] | ["qobuz://track", id] => {
|
|
|
|
|
self.inner
|
|
|
|
|
.client
|
|
|
|
|
.add_favorite_track(id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
return Err(MusicSourceError::NotSupported(
|
|
|
|
|
"Favorites only supported for albums and tracks".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.increment_update_id().await;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn remove_favorite(&self, object_id: &str) -> Result<()> {
|
|
|
|
|
// Parse object_id to determine type
|
|
|
|
|
let parts: Vec<&str> = object_id.split(':').collect();
|
|
|
|
|
|
|
|
|
|
match parts.as_slice() {
|
|
|
|
|
["qobuz", "album", id] | ["qobuz://album", id] => {
|
|
|
|
|
self.inner
|
|
|
|
|
.client
|
|
|
|
|
.remove_favorite_album(id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
|
|
|
|
}
|
|
|
|
|
["qobuz", "track", id] | ["qobuz://track", id] => {
|
|
|
|
|
self.inner
|
|
|
|
|
.client
|
|
|
|
|
.remove_favorite_track(id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
return Err(MusicSourceError::NotSupported(
|
|
|
|
|
"Favorites only supported for albums and tracks".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.increment_update_id().await;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn is_favorite(&self, object_id: &str) -> Result<bool> {
|
|
|
|
|
// Parse object_id to determine type
|
|
|
|
|
let parts: Vec<&str> = object_id.split(':').collect();
|
|
|
|
|
|
|
|
|
|
match parts.as_slice() {
|
|
|
|
|
["qobuz", "album", id] | ["qobuz://album", id] => {
|
|
|
|
|
let favorites = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_favorite_albums()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Ok(favorites.iter().any(|album| album.id == *id))
|
|
|
|
|
}
|
|
|
|
|
["qobuz", "track", id] | ["qobuz://track", id] => {
|
|
|
|
|
let favorites = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_favorite_tracks()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Ok(favorites.iter().any(|track| track.id == *id))
|
|
|
|
|
}
|
2025-10-19 13:42:29 +02:00
|
|
|
_ => Err(MusicSourceError::NotSupported(
|
|
|
|
|
"Favorites only supported for albums and tracks".to_string(),
|
|
|
|
|
)),
|
2025-10-17 08:19:10 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn get_user_playlists(&self) -> Result<Vec<Container>> {
|
|
|
|
|
let playlists = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_user_playlists()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let playlists = self.cache_covers(playlists).await;
|
2025-10-17 08:19:10 +02:00
|
|
|
let containers: Vec<Container> = playlists
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|playlist| playlist.to_didl_container("qobuz").ok())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(containers)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn add_to_playlist(&self, playlist_id: &str, item_id: &str) -> Result<()> {
|
|
|
|
|
// Extract track ID from item_id
|
|
|
|
|
let track_id = if let Some(id) = item_id.strip_prefix("qobuz://track/") {
|
|
|
|
|
id
|
|
|
|
|
} else if let Some(id) = item_id.strip_prefix("qobuz:track:") {
|
|
|
|
|
id
|
|
|
|
|
} else {
|
|
|
|
|
item_id
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
self.inner
|
|
|
|
|
.client
|
|
|
|
|
.add_to_playlist(playlist_id, track_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
self.increment_update_id().await;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn get_item_count(&self, object_id: &str) -> Result<usize> {
|
|
|
|
|
match self.parse_object_id(object_id) {
|
|
|
|
|
ObjectIdType::Album(album_id) => {
|
|
|
|
|
let album = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_album(&album_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Ok(album.tracks_count.unwrap_or(0) as usize)
|
|
|
|
|
}
|
|
|
|
|
ObjectIdType::Playlist(playlist_id) => {
|
|
|
|
|
let playlist = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_playlist(&playlist_id)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Ok(playlist.tracks_count.unwrap_or(0) as usize)
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
// Fall back to default implementation
|
|
|
|
|
let result = self.browse(object_id).await?;
|
|
|
|
|
Ok(result.count())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn browse_paginated(
|
|
|
|
|
&self,
|
|
|
|
|
object_id: &str,
|
|
|
|
|
offset: usize,
|
|
|
|
|
limit: usize,
|
|
|
|
|
) -> Result<BrowseResult> {
|
|
|
|
|
match self.parse_object_id(object_id) {
|
|
|
|
|
ObjectIdType::Album(album_id) => {
|
2025-12-28 09:56:05 +01:00
|
|
|
let all_items = self
|
|
|
|
|
.get_or_create_album_playlist_items(&album_id, usize::MAX)
|
|
|
|
|
.await?;
|
2025-10-17 08:19:10 +02:00
|
|
|
|
2026-01-04 18:54:31 +01:00
|
|
|
let items: Vec<Item> = all_items.into_iter().skip(offset).take(limit).collect();
|
2025-10-17 08:19:10 +02:00
|
|
|
|
|
|
|
|
Ok(BrowseResult::Items(items))
|
|
|
|
|
}
|
2025-12-28 14:54:27 +01:00
|
|
|
ObjectIdType::FavouriteAlbums => {
|
2025-10-17 08:19:10 +02:00
|
|
|
let albums = self
|
|
|
|
|
.inner
|
|
|
|
|
.client
|
|
|
|
|
.get_favorite_albums()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
|
|
2026-03-24 15:02:14 +01:00
|
|
|
let albums = self.cache_covers(albums).await;
|
2025-10-17 08:19:10 +02:00
|
|
|
let containers: Vec<Container> = albums
|
|
|
|
|
.into_iter()
|
|
|
|
|
.skip(offset)
|
|
|
|
|
.take(limit)
|
2025-12-28 14:54:27 +01:00
|
|
|
.filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok())
|
2025-10-17 08:19:10 +02:00
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
|
|
|
|
// Fall back to default implementation
|
|
|
|
|
self.browse(object_id).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn statistics(&self) -> Result<pmosource::SourceStatistics> {
|
|
|
|
|
let mut stats = pmosource::SourceStatistics::default();
|
|
|
|
|
|
|
|
|
|
// Try to get favorite counts
|
|
|
|
|
if let Ok(albums) = self.inner.client.get_favorite_albums().await {
|
|
|
|
|
stats.total_containers = Some(albums.len());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Ok(tracks) = self.inner.client.get_favorite_tracks().await {
|
|
|
|
|
stats.total_items = Some(tracks.len());
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 23:45:01 +02:00
|
|
|
// Get cache statistics from manager
|
|
|
|
|
let cache_stats = self.inner.cache_manager.statistics().await;
|
|
|
|
|
stats.cached_items = Some(cache_stats.cached_tracks);
|
2025-10-17 08:19:10 +02:00
|
|
|
|
|
|
|
|
Ok(stats)
|
|
|
|
|
}
|
2025-10-16 22:00:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_default_image_present() {
|
2025-10-16 22:13:00 +02:00
|
|
|
assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty");
|
2025-10-16 22:00:35 +02:00
|
|
|
|
|
|
|
|
// Check WebP magic bytes (RIFF...WEBP)
|
2025-10-19 13:42:29 +02:00
|
|
|
assert!(
|
|
|
|
|
DEFAULT_IMAGE.len() >= 12,
|
|
|
|
|
"Image too small to be valid WebP"
|
|
|
|
|
);
|
2025-10-16 22:13:00 +02:00
|
|
|
assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header");
|
|
|
|
|
assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature");
|
2025-10-16 22:00:35 +02:00
|
|
|
}
|
2025-10-16 22:13:00 +02:00
|
|
|
|
|
|
|
|
// Note: We can't easily test parse_object_id without creating a real client
|
|
|
|
|
// which requires authentication. The parsing logic is simple enough that
|
|
|
|
|
// it's covered by integration tests.
|
2025-10-16 22:00:35 +02:00
|
|
|
}
|