Refactor search handling across the frontend and backend to use a unified reactive state and dynamic container ID generation. The frontend now leverages a centralized `useMediaServers` composable for search queries and results, while the backend computes container IDs dynamically from source entries instead of using hardcoded values. Bumps version to 0.3.52.
2603 lines
98 KiB
Rust
2603 lines
98 KiB
Rust
//! Music source implementation for Qobuz
|
|
//!
|
|
//! This module implements the [`pmosource::MusicSource`] trait for Qobuz,
|
|
//! providing a complete music catalog browsing and searching experience.
|
|
|
|
use crate::client::QobuzClient;
|
|
use crate::didl::{format_duration, ToDIDL};
|
|
use crate::lazy_provider::QobuzLazyProvider;
|
|
use crate::models::Track;
|
|
use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
|
|
use pmocovers::Cache as CoverCache;
|
|
use pmodidl::{Container, Item};
|
|
use pmosource::SourceCacheManager;
|
|
use pmosource::{async_trait, BrowseResult, MediaSearchType, MusicSource, MusicSourceError, Result, SearchQuery, SearchScope};
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
|
|
/// TTL pour les playlists d'albums (7 jours)
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Default image for Qobuz (300x300 WebP, embedded in binary)
|
|
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
|
|
|
/// Qobuz music source with full MusicSource trait implementation
|
|
///
|
|
/// 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
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use std::sync::Arc;
|
|
/// use pmoaudiocache::cache as audio_cache;
|
|
/// use pmocovers::cache as cover_cache;
|
|
/// use pmoqobuz::{QobuzSource, QobuzClient};
|
|
/// use pmosource::MusicSource;
|
|
///
|
|
/// #[tokio::main]
|
|
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
/// let client = QobuzClient::from_config().await?;
|
|
/// 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)?);
|
|
/// let source = QobuzSource::new(client, cover_cache, audio_cache, "http://localhost:8080");
|
|
///
|
|
/// 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(())
|
|
/// }
|
|
/// ```
|
|
#[derive(Clone)]
|
|
pub struct QobuzSource {
|
|
inner: Arc<QobuzSourceInner>,
|
|
}
|
|
|
|
struct QobuzSourceInner {
|
|
/// Qobuz API client
|
|
client: Arc<QobuzClient>,
|
|
|
|
/// Cache manager (centralisé)
|
|
cache_manager: SourceCacheManager,
|
|
|
|
/// Base URL for streaming server (e.g., "http://192.168.0.138:8080")
|
|
base_url: String,
|
|
|
|
/// Nombre de workers concurrents pour register_tracks_lazy (configurable)
|
|
register_concurrency: usize,
|
|
|
|
/// Update tracking
|
|
update_counter: tokio::sync::RwLock<u32>,
|
|
last_change: tokio::sync::RwLock<SystemTime>,
|
|
}
|
|
|
|
impl std::fmt::Debug for QobuzSource {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("QobuzSource").finish()
|
|
}
|
|
}
|
|
|
|
impl QobuzSource {
|
|
/// 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
|
|
/// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080")
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the caches are not initialized in the registry
|
|
#[cfg(feature = "server")]
|
|
pub fn from_registry(client: QobuzClient, base_url: impl Into<String>) -> Result<Self> {
|
|
use crate::config_ext::QobuzConfigExt;
|
|
let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?;
|
|
let client = Arc::new(client);
|
|
cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone())));
|
|
let register_concurrency = pmoconfig::get_config().get_qobuz_register_concurrency();
|
|
|
|
Ok(Self {
|
|
inner: Arc::new(QobuzSourceInner {
|
|
client,
|
|
cache_manager,
|
|
base_url: base_url.into(),
|
|
register_concurrency,
|
|
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)
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `client` - Authenticated Qobuz API client
|
|
/// * `cover_cache` - Cover image cache (required)
|
|
/// * `audio_cache` - Audio cache (required)
|
|
/// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080")
|
|
pub fn new(
|
|
client: QobuzClient,
|
|
cover_cache: Arc<CoverCache>,
|
|
audio_cache: Arc<AudioCache>,
|
|
base_url: impl Into<String>,
|
|
) -> Self {
|
|
let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache);
|
|
let client = Arc::new(client);
|
|
cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone())));
|
|
|
|
Self {
|
|
inner: Arc::new(QobuzSourceInner {
|
|
client,
|
|
cache_manager,
|
|
base_url: base_url.into(),
|
|
register_concurrency: 4,
|
|
update_counter: tokio::sync::RwLock::new(0),
|
|
last_change: tokio::sync::RwLock::new(SystemTime::now()),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Get the Qobuz client
|
|
pub fn client(&self) -> &QobuzClient {
|
|
&self.inner.client
|
|
}
|
|
|
|
/// Add a track from Qobuz with caching
|
|
///
|
|
/// This method downloads and caches both cover art and audio data.
|
|
pub async fn add_track(&self, track: &Track) -> Result<String> {
|
|
let track_id = format!("qobuz://track/{}", track.id);
|
|
|
|
// Get streaming URL
|
|
let stream_url = self
|
|
.inner
|
|
.client
|
|
.get_stream_url(&track.id)
|
|
.await
|
|
.map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?;
|
|
|
|
// 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()
|
|
} 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,
|
|
};
|
|
|
|
// 3. Cache audio via manager
|
|
let cached_audio_pk = self
|
|
.inner
|
|
.cache_manager
|
|
.cache_audio(&stream_url, Some(metadata))
|
|
.await
|
|
.ok();
|
|
|
|
if let (Some(audio_pk), Some(cover_pk)) = (&cached_audio_pk, &cached_cover_pk) {
|
|
let _ =
|
|
self.inner
|
|
.cache_manager
|
|
.set_audio_metadata(audio_pk, "cover_pk", json!(cover_pk));
|
|
}
|
|
|
|
// 4. Store metadata
|
|
self.inner
|
|
.cache_manager
|
|
.update_metadata(
|
|
track_id.clone(),
|
|
pmosource::TrackMetadata {
|
|
original_uri: stream_url,
|
|
cached_audio_pk,
|
|
cached_cover_pk,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
Ok(track_id)
|
|
}
|
|
|
|
/// 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
|
|
///
|
|
/// `(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)> {
|
|
let track_id = format!("qobuz://track/{}", track.id);
|
|
|
|
let lazy_pk = format!("QOBUZ:{}", track.id);
|
|
|
|
// Get streaming URL (for metadata fallback)
|
|
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,
|
|
};
|
|
|
|
// 3. Cache audio LAZILY avec un provider
|
|
let cached_audio_pk = self
|
|
.inner
|
|
.cache_manager
|
|
.cache_audio_lazy_with_provider(
|
|
&lazy_pk,
|
|
Some(metadata.clone()),
|
|
cached_cover_pk.clone(),
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
MusicSourceError::CacheError(format!(
|
|
"Failed to register lazy track {}: {}",
|
|
track.title, e
|
|
))
|
|
})?;
|
|
|
|
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),
|
|
);
|
|
}
|
|
|
|
// Stocker le track_id Qobuz pour reconstruction DIDL ultérieure
|
|
let _ = self.inner.cache_manager.set_audio_metadata(
|
|
&cached_audio_pk,
|
|
"qobuz_track_id",
|
|
json!(track.id),
|
|
);
|
|
|
|
// 4. Store metadata
|
|
self.inner
|
|
.cache_manager
|
|
.update_metadata(
|
|
track_id.clone(),
|
|
pmosource::TrackMetadata {
|
|
original_uri: stream_url,
|
|
cached_audio_pk: Some(cached_audio_pk.clone()),
|
|
cached_cover_pk,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
Ok((track_id, cached_audio_pk))
|
|
}
|
|
|
|
/// 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
|
|
pub async fn add_album_to_playlist(&self, playlist_id: &str, album_id: &str) -> Result<usize> {
|
|
use tracing::{debug, info, warn};
|
|
|
|
// 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 {
|
|
Ok((_track_id, lazy_pk)) => {
|
|
debug!(
|
|
"Track {}/{}: {} (lazy pk {})",
|
|
i + 1,
|
|
tracks.len(),
|
|
track.title,
|
|
&lazy_pk
|
|
);
|
|
lazy_pks.push(lazy_pk);
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
|
|
// Continue with other tracks
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Batch insert into playlist (single DB transaction)
|
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
|
let writer = playlist_manager
|
|
.get_persistent_write_handle(playlist_id.to_string())
|
|
.await
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
writer
|
|
.set_role(pmoplaylist::PlaylistRole::Album)
|
|
.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!(
|
|
"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 {
|
|
Ok((_track_id, lazy_pk)) => {
|
|
debug!(
|
|
"Track {}/{}: {} (lazy pk {})",
|
|
i + 1,
|
|
tracks.len(),
|
|
track.title,
|
|
&lazy_pk
|
|
);
|
|
lazy_pks.push(lazy_pk);
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
|
|
// Continue with other tracks
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Batch insert into playlist (single DB transaction)
|
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
|
let writer = playlist_manager
|
|
.get_persistent_write_handle(playlist_id.to_string())
|
|
.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())
|
|
}
|
|
|
|
/// Vérifie si une playlist d'album existe et est valide (non expirée ET non vide)
|
|
/// 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> {
|
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
|
if !playlist_manager.exists(playlist_id).await {
|
|
return None;
|
|
}
|
|
playlist_manager
|
|
.get_read_handle(playlist_id)
|
|
.await
|
|
.ok()?
|
|
.source_version()
|
|
.await
|
|
}
|
|
|
|
/// 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(
|
|
&self,
|
|
items: Vec<Item>,
|
|
parent_id: &str,
|
|
) -> Result<Vec<Item>> {
|
|
use tracing::warn;
|
|
|
|
let mut adapted = Vec::with_capacity(items.len());
|
|
|
|
for mut item in items {
|
|
let cache_pk = item
|
|
.resources
|
|
.first()
|
|
.and_then(|r| r.url.strip_prefix("/audio/flac/").map(|s| s.to_string()));
|
|
|
|
if let Some(pk) = cache_pk {
|
|
if let Ok(Some(track_id_value)) = self
|
|
.inner
|
|
.cache_manager
|
|
.get_audio_metadata(&pk, "qobuz_track_id")
|
|
{
|
|
if let Some(track_id) = track_id_value.as_str() {
|
|
item.id = format!("qobuz:track:{}", track_id);
|
|
} else {
|
|
warn!("qobuz_track_id not a string for {}", pk);
|
|
}
|
|
} else {
|
|
warn!("No qobuz_track_id metadata for {}", pk);
|
|
}
|
|
|
|
if let Some(resource) = item.resources.first_mut() {
|
|
if resource.url.starts_with('/') {
|
|
resource.url = format!("{}{}", self.inner.base_url, resource.url);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(art) = item.album_art.as_mut() {
|
|
if art.starts_with('/') {
|
|
*art = format!("{}{}", self.inner.base_url, art);
|
|
}
|
|
}
|
|
|
|
item.parent_id = parent_id.to_string();
|
|
adapted.push(item);
|
|
}
|
|
|
|
Ok(adapted)
|
|
}
|
|
|
|
/// 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.
|
|
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();
|
|
let parent_id = format!("qobuz:album:{}", album_id);
|
|
|
|
let cached_version = self.cached_source_version(&playlist_id).await;
|
|
|
|
// 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()))?;
|
|
|
|
let album_version = Some(format!(
|
|
"{}_{}",
|
|
album.released_at.unwrap_or(0),
|
|
album.tracks_count.unwrap_or(0)
|
|
));
|
|
|
|
if cached_version.is_some() && cached_version == album_version {
|
|
debug!("Album playlist {} cache valid (version {:?})", playlist_id, album_version);
|
|
let reader = playlist_manager
|
|
.get_read_handle(&playlist_id)
|
|
.await
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
let items = reader
|
|
.to_items(limit)
|
|
.await
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
return self.adapt_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?;
|
|
|
|
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()))?;
|
|
}
|
|
|
|
// IMPORTANT: Libérer le write lock avant d'appeler add_album_to_playlist
|
|
drop(writer);
|
|
|
|
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, warn};
|
|
|
|
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
|
|
.inner
|
|
.client
|
|
.get_playlist(qobuz_playlist_id)
|
|
.await
|
|
.map_err(|e: crate::error::QobuzError| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
let qobuz_version = qobuz_meta.updated_at.map(|t: i64| t.to_string());
|
|
|
|
// 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)
|
|
.await
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
let (items, total) = reader
|
|
.to_items_paged(0, usize::MAX)
|
|
.await
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
// Si des PKs ne sont pas enregistrés dans le cache audio (e.g. playlist créée
|
|
// avec l'ancien code), on force un refresh pour les ré-enregistrer proprement.
|
|
if items.len() == total {
|
|
return self.adapt_items_to_qobuz(items, &parent_id).await;
|
|
}
|
|
info!(
|
|
"Qobuz playlist {} has {}/{} valid items, forcing refresh to repair missing entries",
|
|
pmo_playlist_id, items.len(), total
|
|
);
|
|
}
|
|
|
|
info!("Qobuz playlist {} creating/refreshing (version {:?})", pmo_playlist_id, qobuz_version);
|
|
|
|
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?;
|
|
|
|
writer
|
|
.set_title(qobuz_meta.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(qobuz_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()))?;
|
|
}
|
|
|
|
let lazy_pks = self.register_tracks_lazy(&tracks).await;
|
|
let all_registered = lazy_pks.len() == tracks.len();
|
|
if !all_registered {
|
|
warn!(
|
|
"Qobuz playlist {}: only {}/{} tracks registered, will not mark cache as complete",
|
|
qobuz_playlist_id, lazy_pks.len(), tracks.len()
|
|
);
|
|
// Invalider la source_version pour forcer un refresh au prochain accès
|
|
writer
|
|
.set_source_version(None)
|
|
.await
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
}
|
|
writer
|
|
.push_lazy_batch(lazy_pks)
|
|
.await
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
drop(writer);
|
|
|
|
let reader = playlist_manager
|
|
.get_read_handle(&pmo_playlist_id)
|
|
.await
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
let (items, _total) = reader
|
|
.to_items_paged(0, usize::MAX)
|
|
.await
|
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
|
|
|
self.adapt_items_to_qobuz(items, &parent_id).await
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
/// 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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
}
|
|
}
|
|
|
|
/// 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| {
|
|
let source = self.clone();
|
|
async move {
|
|
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 {
|
|
if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
|
|
item.set_image_cached(url);
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}).collect();
|
|
tokio::task::JoinSet::from_iter(futs)
|
|
.join_all()
|
|
.await
|
|
}
|
|
|
|
/// 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> {
|
|
// Configurable via accounts.qobuz.register_concurrency (défaut 4).
|
|
// SQLite sérialise les écritures — au-delà de ~4 workers on accumule
|
|
// des threads en attente du mutex DB sans gain de débit.
|
|
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(self.inner.register_concurrency));
|
|
|
|
// On attache l'index original à chaque future pour pouvoir retrier dans l'ordre
|
|
// d'origine après complétion parallèle (JoinSet retourne dans l'ordre de fin).
|
|
let futs: Vec<_> = tracks.iter().enumerate().map(|(idx, track)| {
|
|
let source = self.clone();
|
|
let sem = sem.clone();
|
|
let track_id = track.id.clone();
|
|
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
|
|
};
|
|
|
|
// 2. Register lazy entry + set cover_pk + seed metadata.
|
|
// Si le performer est absent (réponse API incomplète), on passe None pour
|
|
// que le provider appelle get_track et récupère les métadonnées complètes.
|
|
if metadata.artist.is_none() {
|
|
tracing::warn!(
|
|
"register_tracks_lazy: no performer for track {} (id={}), will call provider",
|
|
track_title, track_id
|
|
);
|
|
}
|
|
if cover_pk.is_none() && cover_image_url.is_some() {
|
|
tracing::warn!(
|
|
"register_tracks_lazy: cover download failed for track {} (id={}), will call provider for cover",
|
|
track_title, track_id
|
|
);
|
|
}
|
|
let meta_hint = if metadata.artist.is_some() { Some(metadata) } else { None };
|
|
match source.inner.cache_manager
|
|
.cache_audio_lazy_with_provider(&lazy_pk, meta_hint, cover_pk)
|
|
.await
|
|
{
|
|
Ok(pk) => {
|
|
let _ = source.inner.cache_manager.set_audio_metadata(
|
|
&pk, "qobuz_track_id", json!(track_id),
|
|
);
|
|
Some((idx, pk))
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to register lazy track {}: {}", track_title, e);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}).collect();
|
|
|
|
// Retrier par index original pour conserver l'ordre de la playlist Qobuz
|
|
let mut results: Vec<(usize, String)> = tokio::task::JoinSet::from_iter(futs)
|
|
.join_all()
|
|
.await
|
|
.into_iter()
|
|
.flatten()
|
|
.collect();
|
|
results.sort_unstable_by_key(|(i, _)| *i);
|
|
results.into_iter().map(|(_, pk)| pk).collect()
|
|
}
|
|
|
|
/// Cache les covers d'une liste d'artistes en parallèle.
|
|
async fn cache_artist_covers(&self, artists: Vec<crate::models::Artist>) -> Vec<crate::models::Artist> {
|
|
let futs: Vec<_> = artists.into_iter().map(|mut artist| {
|
|
let source = self.clone();
|
|
async move {
|
|
if let Some(ref image_url) = artist.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) {
|
|
artist.image_cached = Some(url);
|
|
}
|
|
}
|
|
}
|
|
artist
|
|
}
|
|
}).collect();
|
|
tokio::task::JoinSet::from_iter(futs)
|
|
.join_all()
|
|
.await
|
|
}
|
|
|
|
/// 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()))?;
|
|
|
|
let albums = self.cache_album_covers(albums).await;
|
|
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()))?;
|
|
|
|
let tracks = self.cache_covers(tracks).await;
|
|
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()))?;
|
|
|
|
let artists = self.cache_covers(artists).await;
|
|
let containers: Vec<Container> = artists
|
|
.into_iter()
|
|
.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![],
|
|
})
|
|
.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()))?;
|
|
|
|
let playlists = self.cache_playlist_covers(playlists).await;
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
.inner
|
|
.client
|
|
.get_featured_albums(genre_id, filter)
|
|
.await
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
let albums = self.cache_album_covers(albums).await;
|
|
let containers: Vec<Container> = albums
|
|
.into_iter()
|
|
.filter_map(|album| album.to_didl_container(parent_id).ok())
|
|
.collect();
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
}
|
|
|
|
/// 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
|
|
.inner
|
|
.client
|
|
.get_featured_playlists(genre_id, None)
|
|
.await
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
let playlists = self.cache_playlist_covers(playlists).await;
|
|
let containers: Vec<Container> = playlists
|
|
.into_iter()
|
|
.filter_map(|playlist| playlist.to_didl_container(parent_id).ok())
|
|
.collect();
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
}
|
|
|
|
async fn browse_discover_playlists(&self) -> Result<BrowseResult> {
|
|
self.browse_featured_playlists(None, "qobuz:discover:playlists").await
|
|
}
|
|
|
|
async fn browse_discover_albums_ideal(&self) -> Result<BrowseResult> {
|
|
self.browse_featured_albums(None, "ideal-discography", "qobuz:discover:albums:ideal").await
|
|
}
|
|
|
|
async fn browse_discover_albums_qobuzissime(&self) -> Result<BrowseResult> {
|
|
self.browse_featured_albums(None, "qobuzissims", "qobuz:discover:albums:qobuzissime").await
|
|
}
|
|
|
|
async fn browse_discover_albums_new(&self) -> Result<BrowseResult> {
|
|
self.browse_featured_albums(None, "new-releases", "qobuz:discover:albums:new").await
|
|
}
|
|
|
|
/// 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()))?;
|
|
|
|
let artists = self.cache_covers(artists).await;
|
|
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(),
|
|
artist: Some(artist.name.clone()),
|
|
album_art: artist.image_cached.clone(),
|
|
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()))?;
|
|
|
|
let playlists = self.cache_covers(playlists).await;
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
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(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
}
|
|
}
|
|
|
|
async fn browse_genre_new_releases(&self, genre_id: &str) -> Result<BrowseResult> {
|
|
self.browse_featured_albums(Some(genre_id), "new-releases", &format!("qobuz:genre:{}:new-releases", genre_id)).await
|
|
}
|
|
|
|
async fn browse_genre_ideal_discography(&self, genre_id: &str) -> Result<BrowseResult> {
|
|
self.browse_featured_albums(Some(genre_id), "ideal-discography", &format!("qobuz:genre:{}:ideal", genre_id)).await
|
|
}
|
|
|
|
async fn browse_genre_qobuzissime(&self, genre_id: &str) -> Result<BrowseResult> {
|
|
self.browse_featured_albums(Some(genre_id), "qobuzissims", &format!("qobuz:genre:{}:qobuzissime", genre_id)).await
|
|
}
|
|
|
|
async fn browse_genre_editor_picks(&self, genre_id: &str) -> Result<BrowseResult> {
|
|
self.browse_featured_albums(Some(genre_id), "editor-picks", &format!("qobuz:genre:{}:editor-picks", genre_id)).await
|
|
}
|
|
|
|
async fn browse_genre_press_awards(&self, genre_id: &str) -> Result<BrowseResult> {
|
|
self.browse_featured_albums(Some(genre_id), "press-awards", &format!("qobuz:genre:{}:press-awards", genre_id)).await
|
|
}
|
|
|
|
async fn browse_genre_playlists(&self, genre_id: &str) -> Result<BrowseResult> {
|
|
self.browse_featured_playlists(Some(genre_id), &format!("qobuz:genre:{}:playlists", genre_id)).await
|
|
}
|
|
|
|
/// Parse object_id to determine what to browse
|
|
///
|
|
/// Object IDs follow these patterns:
|
|
/// - "qobuz" or "0" → Root container
|
|
/// - "qobuz:discover" → Discover Catalog
|
|
/// - "qobuz:genres" → Discover Genres
|
|
/// - "qobuz:favorites" → My Music
|
|
/// - "qobuz:album:{id}" → Tracks in album
|
|
/// - "qobuz:playlist:{id}" → Tracks in playlist
|
|
/// - etc.
|
|
fn parse_object_id(&self, object_id: &str) -> ObjectIdType {
|
|
tracing::debug!(object_id, "parse_object_id");
|
|
if object_id == "qobuz" || object_id == "0" {
|
|
return ObjectIdType::Root;
|
|
}
|
|
|
|
// Virtual search result containers: qobuz:search:{scope}:{type}:{query}
|
|
// Use splitn(5) so the query (last segment) can contain ':' without ambiguity.
|
|
let search_parts: Vec<&str> = object_id.splitn(5, ':').collect();
|
|
tracing::debug!(len = search_parts.len(), parts = ?search_parts, "parse_object_id splitn");
|
|
if search_parts.len() == 5 && search_parts[0] == "qobuz" && search_parts[1] == "search" {
|
|
let scope = match search_parts[2] {
|
|
"favorites" => SearchScope::UserLibrary,
|
|
_ => SearchScope::Catalog,
|
|
};
|
|
let media_type = match search_parts[3] {
|
|
"albums" => MediaSearchType::Albums,
|
|
"tracks" => MediaSearchType::Tracks,
|
|
"artists" => MediaSearchType::Artists,
|
|
"playlists" => MediaSearchType::Playlists,
|
|
_ => MediaSearchType::All,
|
|
};
|
|
tracing::debug!(scope = ?scope, media_type = ?media_type, query = search_parts[4], "parse_object_id → SearchResult");
|
|
return ObjectIdType::SearchResult(scope, media_type, search_parts[4].to_string());
|
|
}
|
|
|
|
let parts: Vec<&str> = object_id.split(':').collect();
|
|
match parts.as_slice() {
|
|
// Discover Catalog
|
|
["qobuz", "discover"] => ObjectIdType::DiscoverCatalog,
|
|
["qobuz", "discover", "playlists"] => ObjectIdType::DiscoverPlaylists,
|
|
["qobuz", "discover", "albums", "ideal"] => ObjectIdType::DiscoverAlbumsIdeal,
|
|
["qobuz", "discover", "albums", "qobuzissime"] => {
|
|
ObjectIdType::DiscoverAlbumsQobuzissime
|
|
}
|
|
["qobuz", "discover", "albums", "new"] => ObjectIdType::DiscoverAlbumsNew,
|
|
["qobuz", "discover", "artists"] => ObjectIdType::DiscoverArtists,
|
|
["qobuz", "discover", "playlists", tag] => {
|
|
ObjectIdType::DiscoverPlaylistsByTag(tag.to_string())
|
|
}
|
|
|
|
// Discover Genres
|
|
["qobuz", "genres"] => ObjectIdType::DiscoverGenres,
|
|
["qobuz", "genre", id] => ObjectIdType::GenreRoot(id.to_string()),
|
|
["qobuz", "genre", id, "new-releases"] => {
|
|
ObjectIdType::GenreNewReleases(id.to_string())
|
|
}
|
|
["qobuz", "genre", id, "ideal"] => ObjectIdType::GenreIdealDiscography(id.to_string()),
|
|
["qobuz", "genre", id, "qobuzissime"] => ObjectIdType::GenreQobuzissime(id.to_string()),
|
|
["qobuz", "genre", id, "editor-picks"] => {
|
|
ObjectIdType::GenreEditorPicks(id.to_string())
|
|
}
|
|
["qobuz", "genre", id, "press-awards"] => {
|
|
ObjectIdType::GenrePressAwards(id.to_string())
|
|
}
|
|
["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)
|
|
["qobuz", "album", id] => ObjectIdType::Album(id.to_string()),
|
|
["qobuz", "playlist", id] => ObjectIdType::Playlist(id.to_string()),
|
|
["qobuz", "artist", id] => ObjectIdType::Artist(id.to_string()),
|
|
["qobuz", "track", id] => ObjectIdType::Track(id.to_string()),
|
|
|
|
_ => ObjectIdType::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ObjectIdType {
|
|
Root,
|
|
|
|
// Discover Catalog
|
|
DiscoverCatalog,
|
|
DiscoverPlaylists,
|
|
DiscoverAlbumsIdeal,
|
|
DiscoverAlbumsQobuzissime,
|
|
DiscoverAlbumsNew,
|
|
DiscoverArtists,
|
|
DiscoverPlaylistsByTag(String), // tag
|
|
|
|
// Discover Genres
|
|
DiscoverGenres,
|
|
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
|
|
|
|
// Favourites
|
|
Favourites,
|
|
FavouriteAlbums,
|
|
FavouriteTracks,
|
|
FavouriteArtists,
|
|
FavouritePlaylists,
|
|
|
|
// Items (existant)
|
|
Album(String),
|
|
Playlist(String),
|
|
Artist(String),
|
|
Track(String),
|
|
|
|
// Containers virtuels de résultats de recherche
|
|
// (scope, media_type, query)
|
|
SearchResult(SearchScope, MediaSearchType, String),
|
|
|
|
Unknown,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MusicSource for QobuzSource {
|
|
fn name(&self) -> &str {
|
|
"Qobuz"
|
|
}
|
|
|
|
fn id(&self) -> &str {
|
|
"qobuz"
|
|
}
|
|
|
|
fn default_image(&self) -> &[u8] {
|
|
DEFAULT_IMAGE
|
|
}
|
|
|
|
async fn root_container(&self) -> Result<Container> {
|
|
// Create the root container with 3 main branches
|
|
Ok(Container {
|
|
id: "qobuz".to_string(),
|
|
parent_id: "0".to_string(),
|
|
restricted: Some("1".to_string()),
|
|
child_count: Some("3".to_string()),
|
|
searchable: Some("1".to_string()),
|
|
title: "Qobuz".to_string(),
|
|
class: "object.container".to_string(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![
|
|
self.build_discover_catalog_container(),
|
|
self.build_discover_genres_container(),
|
|
self.build_favourites_container(),
|
|
],
|
|
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))
|
|
}
|
|
|
|
// Discover Catalog
|
|
ObjectIdType::DiscoverCatalog => self.browse_discover_catalog().await,
|
|
ObjectIdType::DiscoverPlaylists => self.browse_discover_playlists().await,
|
|
ObjectIdType::DiscoverAlbumsIdeal => self.browse_discover_albums_ideal().await,
|
|
ObjectIdType::DiscoverAlbumsQobuzissime => {
|
|
self.browse_discover_albums_qobuzissime().await
|
|
}
|
|
ObjectIdType::DiscoverAlbumsNew => self.browse_discover_albums_new().await,
|
|
ObjectIdType::DiscoverArtists => self.browse_discover_artists().await,
|
|
ObjectIdType::DiscoverPlaylistsByTag(tag) => {
|
|
self.browse_discover_playlists_tag(&tag).await
|
|
}
|
|
|
|
// 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,
|
|
ObjectIdType::GenreIdealDiscography(id) => {
|
|
self.browse_genre_ideal_discography(&id).await
|
|
}
|
|
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)
|
|
ObjectIdType::Album(album_id) => {
|
|
let items = self
|
|
.get_or_create_album_playlist_items(&album_id, usize::MAX)
|
|
.await?;
|
|
|
|
Ok(BrowseResult::Items(items))
|
|
}
|
|
|
|
ObjectIdType::Playlist(playlist_id) => {
|
|
let items = self
|
|
.get_or_create_qobuz_playlist_items(&playlist_id)
|
|
.await?;
|
|
|
|
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()))?;
|
|
|
|
let albums = self.cache_covers(albums).await;
|
|
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))
|
|
}
|
|
|
|
ObjectIdType::SearchResult(scope, media_type, query) => {
|
|
tracing::debug!(scope = ?scope, media_type = ?media_type, query, "browse → search");
|
|
let is_all_search = media_type == MediaSearchType::All;
|
|
let sq = SearchQuery {
|
|
text: query,
|
|
media_type,
|
|
scope,
|
|
limit: 200,
|
|
offset: 0,
|
|
};
|
|
if is_all_search {
|
|
self.search_grouped(&sq).await
|
|
} else {
|
|
self.execute_search(&sq).await
|
|
}
|
|
}
|
|
|
|
ObjectIdType::Track(_) => {
|
|
// Track object_ids ne sont pas browsables, retourner une erreur
|
|
Err(MusicSourceError::NotSupported(
|
|
"Tracks are not browsable containers".to_string(),
|
|
))
|
|
}
|
|
|
|
ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
|
}
|
|
}
|
|
|
|
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
|
// Try cache manager first
|
|
if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await {
|
|
return Ok(uri);
|
|
}
|
|
|
|
// If not cached, extract track ID and get streaming URL from Qobuz
|
|
let track_id = object_id
|
|
.strip_prefix("qobuz://track/")
|
|
.unwrap_or(object_id);
|
|
|
|
self.inner
|
|
.client
|
|
.get_stream_url(track_id)
|
|
.await
|
|
.map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))
|
|
}
|
|
|
|
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 {
|
|
if let Some(ref image) = album.image {
|
|
if let Ok(pk) = self.inner.cache_manager.cache_cover(image).await {
|
|
self.inner.cache_manager.cover_url(&pk, None).ok()
|
|
} 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(),
|
|
})
|
|
}
|
|
|
|
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()))?;
|
|
|
|
let all_tracks = self.cache_covers(all_tracks).await;
|
|
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: &SearchQuery) -> Result<BrowseResult> {
|
|
use tracing::debug;
|
|
debug!(text = %query.text, scope = ?query.scope, media_type = ?query.media_type, "Qobuz search");
|
|
|
|
if query.media_type == MediaSearchType::All {
|
|
self.search_grouped(query).await
|
|
} else {
|
|
self.execute_search(query).await
|
|
}
|
|
}
|
|
|
|
// ============= 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: true,
|
|
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> {
|
|
self.inner.cache_manager.get_cache_status(object_id).await
|
|
}
|
|
|
|
async fn cache_item(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
|
|
// Extract track ID
|
|
let track_id = object_id
|
|
.strip_prefix("qobuz://track/")
|
|
.unwrap_or(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()))?;
|
|
|
|
// 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
|
|
}
|
|
|
|
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))
|
|
}
|
|
_ => Err(MusicSourceError::NotSupported(
|
|
"Favorites only supported for albums and tracks".to_string(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
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()))?;
|
|
|
|
let playlists = self.cache_covers(playlists).await;
|
|
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) => {
|
|
let all_items = self
|
|
.get_or_create_album_playlist_items(&album_id, usize::MAX)
|
|
.await?;
|
|
|
|
let items: Vec<Item> = all_items.into_iter().skip(offset).take(limit).collect();
|
|
|
|
Ok(BrowseResult::Items(items))
|
|
}
|
|
ObjectIdType::FavouriteAlbums => {
|
|
let albums = self
|
|
.inner
|
|
.client
|
|
.get_favorite_albums()
|
|
.await
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
|
|
let albums = self.cache_covers(albums).await;
|
|
let containers: Vec<Container> = albums
|
|
.into_iter()
|
|
.skip(offset)
|
|
.take(limit)
|
|
.filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok())
|
|
.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());
|
|
}
|
|
|
|
// Get cache statistics from manager
|
|
let cache_stats = self.inner.cache_manager.statistics().await;
|
|
stats.cached_items = Some(cache_stats.cached_tracks);
|
|
|
|
Ok(stats)
|
|
}
|
|
}
|
|
|
|
// Search helpers — inherent methods, called from both `search()` and `browse()`.
|
|
impl QobuzSource {
|
|
/// Recherche groupée : retourne des containers virtuels navigables (un par type).
|
|
pub(crate) async fn search_grouped(&self, query: &SearchQuery) -> Result<BrowseResult> {
|
|
use tracing::debug;
|
|
let scope_str = match query.scope {
|
|
SearchScope::Catalog => "catalog",
|
|
SearchScope::UserLibrary => "favorites",
|
|
};
|
|
|
|
let counts = if query.scope == SearchScope::UserLibrary {
|
|
self.search_favorites_counts(&query.text).await
|
|
} else {
|
|
self.search_catalog_counts(&query.text).await
|
|
};
|
|
|
|
let (n_albums, n_artists, n_tracks, n_playlists) = counts;
|
|
let parent_id = format!("qobuz:search:{}:all:{}", scope_str, query.text);
|
|
|
|
let mk_container = |type_str: &str, title: &str, count: usize| Container {
|
|
id: format!("qobuz:search:{}:{}:{}", scope_str, type_str, query.text),
|
|
parent_id: parent_id.clone(),
|
|
restricted: Some("1".to_string()),
|
|
child_count: Some(count.to_string()),
|
|
searchable: None,
|
|
title: format!("{} ({}{})", title, count, if count >= 1000 { "+" } else { "" }),
|
|
class: "object.container".to_string(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
};
|
|
|
|
let mut containers = Vec::new();
|
|
if n_albums > 0 { containers.push(mk_container("albums", "Albums", n_albums)); }
|
|
if n_artists > 0 { containers.push(mk_container("artists", "Artistes", n_artists)); }
|
|
if n_tracks > 0 { containers.push(mk_container("tracks", "Titres", n_tracks)); }
|
|
if n_playlists > 0 { containers.push(mk_container("playlists", "Playlists", n_playlists)); }
|
|
|
|
debug!(containers = containers.len(), "Search grouped result");
|
|
Ok(BrowseResult::Containers(containers))
|
|
}
|
|
|
|
async fn search_catalog_counts(&self, text: &str) -> (usize, usize, usize, usize) {
|
|
match self.inner.client.search_totals(text).await {
|
|
Ok((albums, artists, tracks, playlists)) => {
|
|
tracing::debug!(albums, artists, tracks, playlists, "search_catalog_counts totals");
|
|
(albums as usize, artists as usize, tracks as usize, playlists as usize)
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "search_catalog_counts failed");
|
|
(0, 0, 0, 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn search_favorites_counts(&self, text: &str) -> (usize, usize, usize, usize) {
|
|
let q = text.to_lowercase();
|
|
let albums = self.inner.client.get_favorite_albums().await.unwrap_or_default()
|
|
.into_iter().filter(|a| a.title.to_lowercase().contains(&q) || a.artist.name.to_lowercase().contains(&q)).count();
|
|
let tracks = self.inner.client.get_favorite_tracks().await.unwrap_or_default()
|
|
.into_iter().filter(|t| t.title.to_lowercase().contains(&q) || t.performer.as_ref().map(|p| p.name.to_lowercase().contains(&q)).unwrap_or(false)).count();
|
|
let artists = self.inner.client.get_favorite_artists().await.unwrap_or_default()
|
|
.into_iter().filter(|a| a.name.to_lowercase().contains(&q)).count();
|
|
let playlists = self.inner.client.get_user_playlists().await.unwrap_or_default()
|
|
.into_iter().filter(|p| p.name.to_lowercase().contains(&q)).count();
|
|
(albums, artists, tracks, playlists)
|
|
}
|
|
|
|
/// Exécute une recherche typée (non-All) et retourne les items/containers directement.
|
|
pub(crate) async fn execute_search(&self, query: &SearchQuery) -> Result<BrowseResult> {
|
|
use tracing::debug;
|
|
debug!(text = %query.text, scope = ?query.scope, media_type = ?query.media_type, "execute_search entry");
|
|
let text = &query.text;
|
|
let scope_str = match query.scope {
|
|
SearchScope::Catalog => "catalog",
|
|
SearchScope::UserLibrary => "favorites",
|
|
};
|
|
let type_str = match query.media_type {
|
|
MediaSearchType::Albums => "albums",
|
|
MediaSearchType::Artists => "artists",
|
|
MediaSearchType::Tracks => "tracks",
|
|
MediaSearchType::Playlists => "playlists",
|
|
MediaSearchType::All => "all",
|
|
};
|
|
let parent_id = format!("qobuz:search:{}:{}:{}", scope_str, type_str, query.text);
|
|
|
|
match (&query.scope, &query.media_type) {
|
|
(SearchScope::UserLibrary, MediaSearchType::Albums) => {
|
|
let q = text.to_lowercase();
|
|
let albums = self.inner.client.get_favorite_albums().await
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
|
|
.into_iter().filter(|a| a.title.to_lowercase().contains(&q) || a.artist.name.to_lowercase().contains(&q))
|
|
.collect::<Vec<_>>();
|
|
let albums = self.cache_album_covers(albums).await;
|
|
Ok(BrowseResult::Containers(albums.into_iter().filter_map(|a| a.to_didl_container(&parent_id).ok()).collect()))
|
|
}
|
|
(SearchScope::UserLibrary, MediaSearchType::Tracks) => {
|
|
let q = text.to_lowercase();
|
|
let tracks = self.inner.client.get_favorite_tracks().await
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
|
|
.into_iter().filter(|t| t.title.to_lowercase().contains(&q) || t.performer.as_ref().map(|p| p.name.to_lowercase().contains(&q)).unwrap_or(false))
|
|
.collect::<Vec<_>>();
|
|
let tracks = self.cache_track_covers(tracks).await;
|
|
Ok(BrowseResult::Items(tracks.into_iter().filter_map(|t| t.to_didl_item(&parent_id).ok()).collect()))
|
|
}
|
|
(SearchScope::UserLibrary, MediaSearchType::Artists) => {
|
|
let q = text.to_lowercase();
|
|
let artists = self.inner.client.get_favorite_artists().await
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
|
|
.into_iter().filter(|a| a.name.to_lowercase().contains(&q))
|
|
.collect::<Vec<_>>();
|
|
let artists = self.cache_artist_covers(artists).await;
|
|
let containers = artists.into_iter().map(|a| Container {
|
|
id: format!("qobuz:artist:{}", a.id),
|
|
parent_id: parent_id.clone(),
|
|
restricted: Some("1".to_string()),
|
|
child_count: None, searchable: Some("1".to_string()),
|
|
title: a.name.clone(), class: "object.container.person.musicArtist".to_string(),
|
|
artist: Some(a.name.clone()), album_art: a.image_cached,
|
|
containers: vec![], items: vec![],
|
|
}).collect();
|
|
Ok(BrowseResult::Containers(containers))
|
|
}
|
|
|
|
(SearchScope::UserLibrary, MediaSearchType::Playlists) => {
|
|
let q = text.to_lowercase();
|
|
let playlists = self.inner.client.get_user_playlists().await
|
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
|
|
.into_iter().filter(|p| p.name.to_lowercase().contains(&q))
|
|
.collect::<Vec<_>>();
|
|
let playlists = self.cache_playlist_covers(playlists).await;
|
|
Ok(BrowseResult::Containers(playlists.into_iter().filter_map(|p| p.to_didl_container(&parent_id).ok()).collect()))
|
|
}
|
|
(SearchScope::Catalog, MediaSearchType::Albums) => {
|
|
let result = self.inner.client.search(text, Some("albums"))
|
|
.await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
let albums = self.cache_album_covers(result.albums).await;
|
|
let containers: Vec<Container> = albums.into_iter()
|
|
.filter_map(|a| a.to_didl_container(&parent_id).ok()).collect();
|
|
debug!(count = containers.len(), "search albums result");
|
|
Ok(BrowseResult::Containers(containers))
|
|
}
|
|
(SearchScope::Catalog, MediaSearchType::Tracks) => {
|
|
let result = self.inner.client.search(text, Some("tracks"))
|
|
.await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
let tracks = self.cache_track_covers(result.tracks).await;
|
|
let items: Vec<Item> = tracks.into_iter()
|
|
.filter_map(|t| t.to_didl_item(&parent_id).ok()).collect();
|
|
debug!(count = items.len(), "search tracks result");
|
|
Ok(BrowseResult::Items(items))
|
|
}
|
|
(SearchScope::Catalog, MediaSearchType::Artists) => {
|
|
let result = self.inner.client.search(text, Some("artists"))
|
|
.await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
let artists = self.cache_artist_covers(result.artists).await;
|
|
let containers: Vec<Container> = artists.into_iter().map(|a| Container {
|
|
id: format!("qobuz:artist:{}", a.id),
|
|
parent_id: parent_id.clone(),
|
|
restricted: Some("1".to_string()),
|
|
child_count: None, searchable: None,
|
|
title: a.name.clone(), class: "object.container.person.musicArtist".to_string(),
|
|
artist: Some(a.name.clone()), album_art: a.image_cached,
|
|
containers: vec![], items: vec![],
|
|
}).collect();
|
|
let first_titles: Vec<&str> = containers.iter().take(4).map(|c| c.title.as_str()).collect();
|
|
debug!(count = containers.len(), first4 = ?first_titles, "search artists result");
|
|
Ok(BrowseResult::Containers(containers))
|
|
}
|
|
(SearchScope::Catalog, MediaSearchType::Playlists) => {
|
|
let result = self.inner.client.search(text, Some("playlists"))
|
|
.await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
let playlists = self.cache_playlist_covers(result.playlists).await;
|
|
let containers: Vec<Container> = playlists.into_iter()
|
|
.filter_map(|p| p.to_didl_container(&parent_id).ok()).collect();
|
|
debug!(count = containers.len(), "search playlists result");
|
|
Ok(BrowseResult::Containers(containers))
|
|
}
|
|
_ => Ok(BrowseResult::Items(vec![])),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_image_present() {
|
|
assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty");
|
|
|
|
// Check WebP magic bytes (RIFF...WEBP)
|
|
assert!(
|
|
DEFAULT_IMAGE.len() >= 12,
|
|
"Image too small to be valid WebP"
|
|
);
|
|
assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header");
|
|
assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature");
|
|
}
|
|
|
|
// 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.
|
|
}
|