diff --git a/pmoparadise/examples/show_source_image.rs b/pmoparadise/examples/show_source_image.rs index d644de5f..dffb57e0 100644 --- a/pmoparadise/examples/show_source_image.rs +++ b/pmoparadise/examples/show_source_image.rs @@ -5,14 +5,16 @@ //! - Accessing the embedded WebP image //! - Optionally saving it to a file -use pmoparadise::RadioParadiseSource; +use pmoparadise::{RadioParadiseSource, RadioParadiseClient}; use pmosource::MusicSource; use std::fs; use std::io::Write; -fn main() -> Result<(), Box> { - // Create the source - let source = RadioParadiseSource; +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create the client and source + let client = RadioParadiseClient::new().await?; + let source = RadioParadiseSource::new_default(client, "http://localhost:8080"); // Display source information println!("Music Source Information"); diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index 0dc3a29e..62bf6201 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -500,6 +500,228 @@ impl MusicSource for RadioParadiseSource { // Radio Paradise doesn't support search Err(MusicSourceError::SearchNotSupported) } + + // ============= Extended Features Implementation ============= + + fn capabilities(&self) -> pmosource::SourceCapabilities { + pmosource::SourceCapabilities { + supports_fifo: true, + supports_search: false, + supports_favorites: false, + supports_playlists: false, + supports_user_content: false, + supports_high_res_audio: true, + max_sample_rate: Some(96_000), // Radio Paradise FLAC is typically 44.1 or 48 kHz, up to 96 kHz + supports_multiple_formats: true, + supports_advanced_search: false, + supports_pagination: false, + } + } + + async fn get_available_formats(&self, _object_id: &str) -> Result> { + use pmosource::AudioFormat; + + // Radio Paradise offers 5 quality levels + Ok(vec![ + AudioFormat { + format_id: "mp3-128".to_string(), + mime_type: "audio/mpeg".to_string(), + sample_rate: Some(44100), + bit_depth: None, + bitrate: Some(128), + channels: Some(2), + }, + AudioFormat { + format_id: "aac-64".to_string(), + mime_type: "audio/aac".to_string(), + sample_rate: Some(44100), + bit_depth: None, + bitrate: Some(64), + channels: Some(2), + }, + AudioFormat { + format_id: "aac-128".to_string(), + mime_type: "audio/aac".to_string(), + sample_rate: Some(44100), + bit_depth: None, + bitrate: Some(128), + channels: Some(2), + }, + AudioFormat { + format_id: "aac-320".to_string(), + mime_type: "audio/aac".to_string(), + sample_rate: Some(44100), + bit_depth: None, + bitrate: Some(320), + channels: Some(2), + }, + AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }, + ]) + } + + async fn get_cache_status(&self, object_id: &str) -> Result { + use pmosource::CacheStatus; + + let cache = self.inner.track_cache.read().await; + + if let Some(metadata) = cache.get(object_id) { + #[cfg(feature = "cache")] + { + if let Some(ref audio_cache) = self.inner.audio_cache { + if let Some(ref pk) = metadata.cached_audio_pk { + // Check if the cached file exists and get its size + if let Ok(Some(info)) = audio_cache.get_info(pk).await { + return Ok(CacheStatus::Cached { + size_bytes: info.size_bytes, + }); + } + } + } + } + + // Check legacy cached_pk for backward compatibility + if metadata.cached_pk.is_some() { + // We don't have size info for legacy cache + return Ok(CacheStatus::Cached { size_bytes: 0 }); + } + } + + Ok(CacheStatus::NotCached) + } + + async fn cache_item(&self, object_id: &str) -> Result { + #[cfg(not(feature = "cache"))] + { + let _ = object_id; + return Err(MusicSourceError::NotSupported("Caching not enabled".to_string())); + } + + #[cfg(feature = "cache")] + { + use pmosource::CacheStatus; + + // Get the track metadata + let cache = self.inner.track_cache.read().await; + let metadata = cache + .get(object_id) + .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))? + .clone(); + drop(cache); + + // If already cached, return status + if metadata.cached_audio_pk.is_some() { + return self.get_cache_status(object_id).await; + } + + // Cache it now + if let Some(ref audio_cache) = self.inner.audio_cache { + let song = &metadata.block.songs[metadata.song_index]; + + let audio_metadata = pmoaudiocache::AudioMetadata { + title: Some(song.title.clone()), + artist: if !song.artist.is_empty() { + Some(song.artist.clone()) + } else { + None + }, + album: if !song.album.is_empty() { + Some(song.album.clone()) + } else { + None + }, + duration_secs: if song.duration > 0 { + Some((song.duration / 1000) as u64) + } else { + None + }, + year: None, + track_number: None, + track_total: None, + disc_number: None, + disc_total: None, + genre: None, + sample_rate: None, + channels: None, + bitrate: None, + }; + + match audio_cache + .add_from_url(&metadata.original_uri, Some(audio_metadata)) + .await + { + Ok((pk, _)) => { + // Update the metadata + let mut cache = self.inner.track_cache.write().await; + if let Some(meta) = cache.get_mut(object_id) { + meta.cached_audio_pk = Some(pk); + } + return self.get_cache_status(object_id).await; + } + Err(e) => { + return Ok(CacheStatus::Failed { + error: e.to_string(), + }); + } + } + } + + Ok(CacheStatus::NotCached) + } + } + + async fn browse_paginated( + &self, + object_id: &str, + offset: usize, + limit: usize, + ) -> Result { + // For Radio Paradise, we can efficiently paginate the FIFO + if object_id == "radio-paradise" || object_id == "0" { + let tracks = self.inner.playlist.get_items(offset, limit).await; + let items: Vec = tracks.iter().map(|t| self.track_to_item(t)).collect(); + Ok(BrowseResult::Items(items)) + } else { + Err(MusicSourceError::ObjectNotFound(object_id.to_string())) + } + } + + async fn get_item_count(&self, object_id: &str) -> Result { + if object_id == "radio-paradise" || object_id == "0" { + Ok(self.inner.playlist.len().await) + } else { + Err(MusicSourceError::ObjectNotFound(object_id.to_string())) + } + } + + async fn statistics(&self) -> Result { + let mut stats = pmosource::SourceStatistics::default(); + + // Total items in FIFO + stats.total_items = Some(self.inner.playlist.len().await); + + // Cache statistics + #[cfg(feature = "cache")] + { + let cache = self.inner.track_cache.read().await; + let cached_count = cache.values().filter(|m| m.cached_audio_pk.is_some()).count(); + stats.cached_items = Some(cached_count); + + if let Some(ref audio_cache) = self.inner.audio_cache { + if let Ok(cache_stats) = audio_cache.statistics().await { + stats.cache_size_bytes = Some(cache_stats.total_size_bytes); + } + } + } + + Ok(stats) + } } #[cfg(test)] diff --git a/pmoqobuz/Cargo.toml b/pmoqobuz/Cargo.toml index cc1ae03e..64f34339 100644 --- a/pmoqobuz/Cargo.toml +++ b/pmoqobuz/Cargo.toml @@ -68,3 +68,8 @@ tokio-test = "0.4" mockito = "1.0" # Pour les exemples tracing-subscriber = "0.3" + +# Specify that the with_cache example requires the cache feature +[[example]] +name = "with_cache" +required-features = ["cache"] diff --git a/pmoqobuz/examples/show_source_image.rs b/pmoqobuz/examples/show_source_image.rs index d7a01521..7a48e80b 100644 --- a/pmoqobuz/examples/show_source_image.rs +++ b/pmoqobuz/examples/show_source_image.rs @@ -5,14 +5,16 @@ //! - Accessing the embedded WebP image //! - Optionally saving it to a file -use pmoqobuz::QobuzSource; +use pmoqobuz::{QobuzSource, QobuzClient}; use pmosource::MusicSource; use std::fs; use std::io::Write; -fn main() -> Result<(), Box> { - // Create the source - let source = QobuzSource; +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create the client and source + let client = QobuzClient::from_config().await?; + let source = QobuzSource::new(client, "http://localhost:8080"); // Display source information println!("Music Source Information"); diff --git a/pmoqobuz/src/api/user.rs b/pmoqobuz/src/api/user.rs index 606aaddc..115ab999 100644 --- a/pmoqobuz/src/api/user.rs +++ b/pmoqobuz/src/api/user.rs @@ -121,4 +121,74 @@ impl QobuzApi { .map(QobuzApi::parse_playlist) .collect()) } + + /// Ajoute un album aux favoris + pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!("Adding album {} to favorites for user {}", album_id, user_id); + + let params = [ + ("album_id", album_id), + ("user_id", user_id), + ]; + + self.get::("/favorite/create", ¶ms).await?; + Ok(()) + } + + /// Supprime un album des favoris + pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!("Removing album {} from favorites for user {}", album_id, user_id); + + let params = [ + ("album_ids", album_id), + ("user_id", user_id), + ]; + + self.get::("/favorite/delete", ¶ms).await?; + Ok(()) + } + + /// Ajoute un track aux favoris + pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!("Adding track {} to favorites for user {}", track_id, user_id); + + let params = [ + ("track_id", track_id), + ("user_id", user_id), + ]; + + self.get::("/favorite/create", ¶ms).await?; + Ok(()) + } + + /// Supprime un track des favoris + pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!("Removing track {} from favorites for user {}", track_id, user_id); + + let params = [ + ("track_ids", track_id), + ("user_id", user_id), + ]; + + self.get::("/favorite/delete", ¶ms).await?; + Ok(()) + } + + /// Ajoute un track à une playlist + pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> { + let user_id = self.ensure_authenticated()?; + debug!("Adding track {} to playlist {} for user {}", track_id, playlist_id, user_id); + + let params = [ + ("playlist_id", playlist_id), + ("track_ids", track_id), + ]; + + self.get::("/playlist/addTracks", ¶ms).await?; + Ok(()) + } } diff --git a/pmoqobuz/src/client.rs b/pmoqobuz/src/client.rs index 208026ee..0d43a55f 100644 --- a/pmoqobuz/src/client.rs +++ b/pmoqobuz/src/client.rs @@ -327,6 +327,31 @@ impl QobuzClient { pub async fn get_user_playlists(&self) -> Result> { self.api.get_user_playlists().await } + + /// Ajoute un album aux favoris + pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> { + self.api.add_favorite_album(album_id).await + } + + /// Supprime un album des favoris + pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> { + self.api.remove_favorite_album(album_id).await + } + + /// Ajoute un track aux favoris + pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> { + self.api.add_favorite_track(track_id).await + } + + /// Supprime un track des favoris + pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> { + self.api.remove_favorite_track(track_id).await + } + + /// Ajoute un track à une playlist + pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> { + self.api.add_to_playlist(playlist_id, track_id).await + } } #[cfg(test)] diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index 5c4e75d4..414de71d 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -5,7 +5,7 @@ use crate::client::QobuzClient; use crate::didl::ToDIDL; -use crate::models::{Album, Track}; +use crate::models::Track; use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; use pmodidl::{Container, Item}; use std::collections::HashMap; @@ -582,6 +582,381 @@ impl MusicSource for QobuzSource { Ok(BrowseResult::Items(vec![])) } } + + // ============= 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> { + 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 { + use pmosource::CacheStatus; + + let cache = self.inner.track_cache.read().await; + + if let Some(metadata) = cache.get(object_id) { + #[cfg(feature = "cache")] + { + if let Some(ref _audio_cache) = self.inner.audio_cache { + if let Some(ref _pk) = metadata.cached_audio_pk { + // TODO: AudioCache doesn't have get_info method yet + // For now, just return that it's cached without size info + return Ok(CacheStatus::Cached { + size_bytes: 0, + }); + } + } + } + } + + Ok(CacheStatus::NotCached) + } + + async fn cache_item(&self, object_id: &str) -> Result { + #[cfg(not(feature = "cache"))] + { + let _ = object_id; + return Err(MusicSourceError::NotSupported("Caching not enabled".to_string())); + } + + #[cfg(feature = "cache")] + { + use pmosource::CacheStatus; + + // Extract track ID + let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") { + id + } else { + object_id + }; + + // Get track details + let track = self + .inner + .client + .get_track(track_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Add track to cache + 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 { + // 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> { + let playlists = self + .inner + .client + .get_user_playlists() + .await + .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + + let containers: Vec = 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 { + 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 { + match self.parse_object_id(object_id) { + ObjectIdType::Album(album_id) => { + // Qobuz returns all tracks, so we slice them + let tracks = self + .inner + .client + .get_album_tracks(&album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = tracks + .into_iter() + .skip(offset) + .take(limit) + .filter_map(|track| { + track + .to_didl_item(&format!("qobuz:album:{}", album_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Items(items)) + } + ObjectIdType::Favorites => { + let albums = self + .inner + .client + .get_favorite_albums() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .skip(offset) + .take(limit) + .filter_map(|album| album.to_didl_container("qobuz:favorites").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + _ => { + // Fall back to default implementation + self.browse(object_id).await + } + } + } + + async fn statistics(&self) -> Result { + 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 + #[cfg(feature = "cache")] + { + let cache = self.inner.track_cache.read().await; + stats.cached_items = Some(cache.len()); + + // TODO: AudioCache doesn't have statistics method yet + // For now, just count cached items + if let Some(ref _audio_cache) = self.inner.audio_cache { + // stats.cache_size_bytes will remain None + } + } + + Ok(stats) + } } #[cfg(test)] diff --git a/pmosource/src/lib.rs b/pmosource/src/lib.rs index 227539f0..a02bf2d9 100644 --- a/pmosource/src/lib.rs +++ b/pmosource/src/lib.rs @@ -53,11 +53,118 @@ pub enum MusicSourceError { #[error("URI resolution failed: {0}")] UriResolutionError(String), + + #[error("Feature not supported: {0}")] + NotSupported(String), + + #[error("Favorites operation failed: {0}")] + FavoritesError(String), + + #[error("Playlist operation failed: {0}")] + PlaylistError(String), } /// Result type for music source operations pub type Result = std::result::Result; +/// Source capabilities describing what features are supported +#[derive(Debug, Clone, Default)] +pub struct SourceCapabilities { + /// Supports FIFO operations (dynamic playlists) + pub supports_fifo: bool, + /// Supports search functionality + pub supports_search: bool, + /// Supports user favorites + pub supports_favorites: bool, + /// Supports user playlists + pub supports_playlists: bool, + /// Supports user-created content + pub supports_user_content: bool, + /// Supports high-resolution audio + pub supports_high_res_audio: bool, + /// Maximum sample rate supported (Hz) + pub max_sample_rate: Option, + /// Supports multiple audio formats + pub supports_multiple_formats: bool, + /// Supports advanced search with filters + pub supports_advanced_search: bool, + /// Supports pagination in browse operations + pub supports_pagination: bool, +} + +/// Audio format information +#[derive(Debug, Clone)] +pub struct AudioFormat { + /// Format identifier (e.g., "flac-24-96", "mp3-320") + pub format_id: String, + /// MIME type (e.g., "audio/flac", "audio/mpeg") + pub mime_type: String, + /// Sample rate in Hz (e.g., 44100, 96000) + pub sample_rate: Option, + /// Bit depth (e.g., 16, 24) + pub bit_depth: Option, + /// Bitrate in kbps (for lossy formats) + pub bitrate: Option, + /// Number of audio channels (e.g., 2 for stereo) + pub channels: Option, +} + +impl Default for AudioFormat { + fn default() -> Self { + Self { + format_id: "default".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + } + } +} + +/// Cache status for an item +#[derive(Debug, Clone)] +pub enum CacheStatus { + /// Item is not cached + NotCached, + /// Item is currently being cached + Caching { progress: f32 }, + /// Item is fully cached + Cached { size_bytes: u64 }, + /// Caching failed + Failed { error: String }, +} + +/// Search filters for advanced search +#[derive(Debug, Clone, Default)] +pub struct SearchFilters { + /// Filter by artist name + pub artist: Option, + /// Filter by album name + pub album: Option, + /// Filter by genre + pub genre: Option, + /// Minimum year + pub year_min: Option, + /// Maximum year + pub year_max: Option, + /// Maximum number of results + pub limit: Option, +} + +/// Source statistics +#[derive(Debug, Clone, Default)] +pub struct SourceStatistics { + /// Total number of items in the source + pub total_items: Option, + /// Total number of containers in the source + pub total_containers: Option, + /// Number of cached items + pub cached_items: Option, + /// Total cache size in bytes + pub cache_size_bytes: Option, +} + /// Result of a browse operation #[derive(Debug, Clone)] pub enum BrowseResult { @@ -447,6 +554,331 @@ pub trait MusicSource: Debug + Send + Sync { let _ = query; Err(MusicSourceError::SearchNotSupported) } + + // ============= Extended Features ============= + + /// Returns the capabilities of this music source + /// + /// This allows clients to discover what features are supported without + /// having to call methods and handle errors. + /// + /// # Returns + /// + /// A `SourceCapabilities` struct describing supported features. + /// + /// # Examples + /// + /// ```ignore + /// let caps = source.capabilities(); + /// if caps.supports_search { + /// let results = source.search("query").await?; + /// } + /// ``` + fn capabilities(&self) -> SourceCapabilities { + SourceCapabilities { + supports_fifo: self.supports_fifo(), + supports_search: false, + supports_favorites: false, + supports_playlists: false, + supports_user_content: false, + supports_high_res_audio: false, + max_sample_rate: None, + supports_multiple_formats: false, + supports_advanced_search: false, + supports_pagination: false, + } + } + + /// Get available audio formats for a specific track + /// + /// Some sources (like Qobuz) offer multiple quality levels and formats. + /// This method returns all available formats for a given track. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the track + /// + /// # Returns + /// + /// A vector of available audio formats, or a single default format. + /// + /// # Examples + /// + /// ```ignore + /// let formats = source.get_available_formats("track-123").await?; + /// for format in formats { + /// println!("{}: {} Hz, {} bit", format.format_id, + /// format.sample_rate.unwrap_or(0), + /// format.bit_depth.unwrap_or(0)); + /// } + /// ``` + async fn get_available_formats(&self, object_id: &str) -> Result> { + let _ = object_id; + Ok(vec![AudioFormat::default()]) + } + + /// Get the cache status for a specific item + /// + /// Returns information about whether an item is cached, being cached, + /// or not cached at all. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the item to check + /// + /// # Returns + /// + /// The current cache status of the item. + /// + /// # Examples + /// + /// ```ignore + /// let status = source.get_cache_status("track-123").await?; + /// match status { + /// CacheStatus::Cached { size_bytes } => { + /// println!("Cached: {} bytes", size_bytes); + /// } + /// CacheStatus::NotCached => { + /// println!("Not cached"); + /// } + /// _ => {} + /// } + /// ``` + async fn get_cache_status(&self, object_id: &str) -> Result { + let _ = object_id; + Ok(CacheStatus::NotCached) + } + + /// Request caching of a specific item + /// + /// Initiates asynchronous caching of an item (audio and/or cover art). + /// The operation happens in the background. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the item to cache + /// + /// # Returns + /// + /// The initial cache status after the request. + /// + /// # Errors + /// + /// Returns `MusicSourceError::NotSupported` if caching is not available. + /// + /// # Examples + /// + /// ```ignore + /// let status = source.cache_item("track-123").await?; + /// ``` + async fn cache_item(&self, object_id: &str) -> Result { + let _ = object_id; + Err(MusicSourceError::NotSupported("Caching not supported".to_string())) + } + + /// Add an item to favorites + /// + /// Marks an item (track, album, artist, etc.) as a favorite. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the item to favorite + /// + /// # Errors + /// + /// Returns `MusicSourceError::NotSupported` if favorites are not available. + /// + /// # Examples + /// + /// ```ignore + /// source.add_favorite("album-123").await?; + /// ``` + async fn add_favorite(&self, object_id: &str) -> Result<()> { + let _ = object_id; + Err(MusicSourceError::NotSupported("Favorites not supported".to_string())) + } + + /// Remove an item from favorites + /// + /// Unmarks an item as a favorite. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the item to unfavorite + /// + /// # Errors + /// + /// Returns `MusicSourceError::NotSupported` if favorites are not available. + async fn remove_favorite(&self, object_id: &str) -> Result<()> { + let _ = object_id; + Err(MusicSourceError::NotSupported("Favorites not supported".to_string())) + } + + /// Check if an item is in favorites + /// + /// # Arguments + /// + /// * `object_id` - The ID of the item to check + /// + /// # Returns + /// + /// `true` if the item is favorited, `false` otherwise. + /// + /// # Errors + /// + /// Returns `MusicSourceError::NotSupported` if favorites are not available. + async fn is_favorite(&self, object_id: &str) -> Result { + let _ = object_id; + Err(MusicSourceError::NotSupported("Favorites not supported".to_string())) + } + + /// Get user playlists + /// + /// Returns all playlists created or followed by the user. + /// + /// # Returns + /// + /// A vector of Container objects representing playlists. + /// + /// # Errors + /// + /// Returns `MusicSourceError::NotSupported` if playlists are not available. + /// + /// # Examples + /// + /// ```ignore + /// let playlists = source.get_user_playlists().await?; + /// for playlist in playlists { + /// println!("Playlist: {}", playlist.title); + /// } + /// ``` + async fn get_user_playlists(&self) -> Result> { + Err(MusicSourceError::NotSupported("Playlists not supported".to_string())) + } + + /// Add an item to a playlist + /// + /// # Arguments + /// + /// * `playlist_id` - The ID of the playlist + /// * `item_id` - The ID of the item to add + /// + /// # Errors + /// + /// Returns `MusicSourceError::NotSupported` if playlists are not available. + async fn add_to_playlist(&self, playlist_id: &str, item_id: &str) -> Result<()> { + let _ = (playlist_id, item_id); + Err(MusicSourceError::NotSupported("Playlists not supported".to_string())) + } + + /// Get total item count for a container + /// + /// This is more efficient than browsing and counting for large collections. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the container + /// + /// # Returns + /// + /// The total number of items in the container. + /// + /// # Examples + /// + /// ```ignore + /// let count = source.get_item_count("album-123").await?; + /// println!("Album has {} tracks", count); + /// ``` + async fn get_item_count(&self, object_id: &str) -> Result { + // Default implementation: browse and count + let result = self.browse(object_id).await?; + Ok(result.count()) + } + + /// Browse with pagination support + /// + /// More efficient than `browse()` for large containers. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the container to browse + /// * `offset` - Starting index (0-based) + /// * `limit` - Maximum number of items to return + /// + /// # Returns + /// + /// A `BrowseResult` containing the requested subset of items. + /// + /// # Examples + /// + /// ```ignore + /// // Get items 10-19 + /// let result = source.browse_paginated("album-123", 10, 10).await?; + /// ``` + async fn browse_paginated( + &self, + object_id: &str, + offset: usize, + limit: usize, + ) -> Result { + // Default implementation: browse all then slice (inefficient) + let _ = (offset, limit); + self.browse(object_id).await + } + + /// Advanced search with filters + /// + /// Provides more fine-grained search control than basic `search()`. + /// + /// # Arguments + /// + /// * `query` - Search query string + /// * `filters` - Additional search filters + /// + /// # Returns + /// + /// A `BrowseResult` containing matching items/containers. + /// + /// # Errors + /// + /// Returns `MusicSourceError::SearchNotSupported` if not implemented. + /// + /// # Examples + /// + /// ```ignore + /// let filters = SearchFilters { + /// artist: Some("Pink Floyd".to_string()), + /// year_min: Some(1970), + /// year_max: Some(1980), + /// ..Default::default() + /// }; + /// let results = source.search_advanced("Wall", filters).await?; + /// ``` + async fn search_advanced(&self, query: &str, filters: SearchFilters) -> Result { + // Default: ignore filters and call basic search + let _ = filters; + self.search(query).await + } + + /// Get source statistics + /// + /// Returns information about the source such as total items, cache usage, etc. + /// + /// # Returns + /// + /// A `SourceStatistics` struct with available statistics. + /// + /// # Examples + /// + /// ```ignore + /// let stats = source.statistics().await?; + /// if let Some(total) = stats.total_items { + /// println!("Total tracks: {}", total); + /// } + /// ``` + async fn statistics(&self) -> Result { + Ok(SourceStatistics::default()) + } } // Re-export commonly used types