//! # PMOSource //! //! Common traits and types for PMOMusic sources. //! //! This crate provides the foundational abstractions for different music sources //! in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, etc. //! //! ## Features //! //! - **FIFO Support**: Dynamic audio sources using `pmoplaylist` for streaming. //! - **Container/Item Navigation**: Browse and search using DIDL-Lite format (`pmodidl`). //! - **Cache Integration**: Automatic URI resolution with `pmoaudiocache` and `pmocovers`. //! - **Change Tracking**: `update_id` and `last_change` for UPnP notifications. //! - **Send + Sync**: Ready for async servers. //! - **Server Extension**: Optional `pmoserver` integration with REST API (feature `server`). //! //! ## Usage //! //! ### Basic Usage (implementing a source) //! //! See the [examples/radio_paradise.rs](../examples/radio_paradise.rs) for a complete implementation. //! //! ### Server Integration (feature `server`) //! //! ```rust,ignore //! use pmosource::MusicSourceExt; //! use pmoserver::ServerBuilder; //! //! let mut server = ServerBuilder::new_configured().build(); //! //! // Initialiser le système de sources //! server.init_music_sources().await?; //! //! // Enregistrer des sources //! server.register_music_source(Arc::new(my_source)).await; //! ``` pub mod cache; use pmodidl::{Container, Item}; use std::fmt::Debug; use std::time::SystemTime; /// Standard size for default images (300x300 pixels) pub const DEFAULT_IMAGE_SIZE: u32 = 300; /// Error types for music source operations #[derive(Debug, thiserror::Error)] pub enum MusicSourceError { #[error("Failed to load default image: {0}")] ImageLoadError(String), #[error("Invalid image format: {0}")] InvalidImageFormat(String), #[error("Source not available: {0}")] SourceUnavailable(String), #[error("Object not found: {0}")] ObjectNotFound(String), #[error("Browse error: {0}")] BrowseError(String), #[error("Search not supported")] SearchNotSupported, #[error("FIFO not supported")] FifoNotSupported, #[error("Cache error: {0}")] CacheError(String), #[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 { /// List of sub-containers only Containers(Vec), /// List of items only Items(Vec), /// Mixed: both containers and items Mixed { containers: Vec, items: Vec, }, } impl BrowseResult { /// Returns the total count of objects (containers + items) pub fn count(&self) -> usize { match self { BrowseResult::Containers(c) => c.len(), BrowseResult::Items(i) => i.len(), BrowseResult::Mixed { containers, items } => containers.len() + items.len(), } } /// Returns all containers pub fn containers(&self) -> &[Container] { match self { BrowseResult::Containers(c) => c, BrowseResult::Items(_) => &[], BrowseResult::Mixed { containers, .. } => containers, } } /// Returns all items pub fn items(&self) -> &[Item] { match self { BrowseResult::Containers(_) => &[], BrowseResult::Items(i) => i, BrowseResult::Mixed { items, .. } => items, } } } /// Main trait for music sources /// /// This trait defines the common interface that all music sources must implement. /// It provides methods for: /// - Getting the source name and identification /// - Retrieving default images/logos /// - Browsing containers and items (ContentDirectory) /// - Resolving audio URIs (using caches when available) /// - Managing FIFO playlists for dynamic sources /// - Tracking changes via `update_id` and `last_change` /// /// # Thread Safety /// /// All implementations must be `Send + Sync` for use in async servers. /// /// # Examples /// /// ```rust,no_run /// use pmosource::{MusicSource, BrowseResult, Result}; /// use pmodidl::{Container, Item}; /// use std::time::SystemTime; /// /// #[derive(Debug)] /// struct RadioParadise { /// // implementation details /// } /// /// #[async_trait::async_trait] /// impl MusicSource for RadioParadise { /// fn name(&self) -> &str { /// "Radio Paradise" /// } /// /// fn id(&self) -> &str { /// "radio-paradise" /// } /// /// fn default_image(&self) -> &[u8] { /// // WebP image bytes /// &[] /// } /// /// async fn root_container(&self) -> Result { /// Ok(Container { /// id: "0".to_string(), /// parent_id: "-1".to_string(), /// restricted: Some("1".to_string()), /// child_count: Some("0".to_string()), /// title: "Radio Paradise".to_string(), /// class: "object.container".to_string(), /// containers: vec![], /// items: vec![], /// }) /// } /// /// async fn browse(&self, object_id: &str) -> Result { /// // Browse implementation /// Ok(BrowseResult::Items(vec![])) /// } /// /// async fn resolve_uri(&self, object_id: &str) -> Result { /// // Return cached URI or original URI /// Ok("http://example.com/track.mp3".to_string()) /// } /// /// fn supports_fifo(&self) -> bool { /// true /// } /// /// async fn append_track(&self, track: Item) -> Result<()> { /// // Add track to FIFO /// Ok(()) /// } /// /// async fn remove_oldest(&self) -> Result> { /// // Remove oldest track /// Ok(None) /// } /// /// async fn update_id(&self) -> u32 { /// 0 /// } /// /// async fn last_change(&self) -> Option { /// None /// } /// /// async fn get_items(&self, offset: usize, count: usize) -> Result> { /// Ok(vec![]) /// } /// /// async fn search(&self, query: &str) -> Result { /// Err(pmosource::MusicSourceError::SearchNotSupported) /// } /// } /// ``` #[async_trait::async_trait] pub trait MusicSource: Debug + Send + Sync { // ============= Basic Information ============= /// Returns the human-readable name of the music source /// /// # Examples /// /// ```ignore /// assert_eq!(source.name(), "Radio Paradise"); /// ``` fn name(&self) -> &str; /// Returns a unique identifier for the music source /// /// This is typically a lowercase, hyphenated version of the name /// suitable for use in URLs, file names, etc. /// /// # Examples /// /// ```ignore /// assert_eq!(source.id(), "radio-paradise"); /// ``` fn id(&self) -> &str; /// Returns the default image/logo for this source as WebP bytes /// /// The image should be square (300x300 pixels) and in WebP format. /// This is embedded in the binary for offline availability. /// /// # Returns /// /// A byte slice containing the WebP-encoded image data /// /// # Examples /// /// ```ignore /// let image_data = source.default_image(); /// assert!(image_data.len() > 0); /// ``` fn default_image(&self) -> &[u8]; /// Returns the MIME type of the default image /// /// By default, this returns "image/webp" since all default images /// should be in WebP format. fn default_image_mime_type(&self) -> &str { "image/webp" } // ============= ContentDirectory Navigation ============= /// Returns the root container for this source /// /// This container is exposed at the top level of the ContentDirectory. /// Its `id` should be unique across all sources, typically the source id. /// /// # Returns /// /// A `Container` representing the root of this source's hierarchy. /// /// # Examples /// /// ```ignore /// let root = source.root_container().await?; /// assert_eq!(root.id, "radio-paradise"); /// assert_eq!(root.title, "Radio Paradise"); /// ``` async fn root_container(&self) -> Result; /// Browse a container or item by its object_id /// /// Returns the children of the specified container, or an error if the /// object doesn't exist or isn't browsable. /// /// # Arguments /// /// * `object_id` - The ID of the container to browse /// /// # Returns /// /// A `BrowseResult` containing sub-containers and/or items. /// /// # Examples /// /// ```ignore /// let result = source.browse("radio-paradise").await?; /// for item in result.items() { /// println!("Track: {}", item.title); /// } /// ``` async fn browse(&self, object_id: &str) -> Result; /// Resolve the actual URI for a track /// /// This method should return the URI that can be used to stream/download /// the audio. If the track is cached (via `pmoaudiocache`), return the /// cached URI. Otherwise, return the original URI. /// /// # Arguments /// /// * `object_id` - The ID of the track to resolve /// /// # Returns /// /// The HTTP URI to access the audio file. /// /// # Examples /// /// ```ignore /// let uri = source.resolve_uri("track-123").await?; /// // Returns something like: "http://localhost:8080/cache/audio/abc123" /// // or the original URL if not cached /// ``` async fn resolve_uri(&self, object_id: &str) -> Result; // ============= FIFO Support ============= /// Indicates whether this source supports FIFO operations /// /// Dynamic sources (like radios) typically return `true`, while /// static sources (like albums) return `false`. /// /// # Returns /// /// `true` if the source supports FIFO operations, `false` otherwise. fn supports_fifo(&self) -> bool; /// Append a track to the FIFO /// /// This method is only applicable for sources that support FIFO. /// It adds the track to the end of the queue, potentially removing /// the oldest track if capacity is reached. /// /// Updates `update_id` and `last_change`. /// /// # Arguments /// /// * `track` - The `Item` to add to the FIFO /// /// # Errors /// /// Returns `MusicSourceError::FifoNotSupported` if the source doesn't /// support FIFO operations. /// /// # Examples /// /// ```ignore /// let track = Item { /// id: "track-1".to_string(), /// title: "Song Title".to_string(), /// // ... other fields /// }; /// source.append_track(track).await?; /// ``` async fn append_track(&self, track: Item) -> Result<()>; /// Remove the oldest track from the FIFO /// /// This method is only applicable for sources that support FIFO. /// Updates `update_id` and `last_change` if a track is removed. /// /// # Returns /// /// The removed track, or `None` if the FIFO is empty. /// /// # Errors /// /// Returns `MusicSourceError::FifoNotSupported` if the source doesn't /// support FIFO operations. async fn remove_oldest(&self) -> Result>; // ============= Change Tracking ============= /// Returns the current update_id /// /// This counter is incremented each time the source's content changes /// (track added, removed, metadata updated, etc.). It's used by UPnP /// Control Points to detect changes and refresh their view. /// /// # Returns /// /// The current update_id value. Wraps around on overflow. async fn update_id(&self) -> u32; /// Returns the timestamp of the last change /// /// This is used to notify MediaRenderers and Control Points about /// content updates. /// /// # Returns /// /// The `SystemTime` of the last modification, or `None` if never modified. async fn last_change(&self) -> Option; // ============= Pagination & Search ============= /// Get a paginated list of items /// /// This is useful for browsing large collections without loading /// everything into memory. /// /// # Arguments /// /// * `offset` - Starting index (0-based) /// * `count` - Maximum number of items to return /// /// # Returns /// /// A vector of `Item` objects, potentially empty if offset is out of range. /// /// # Examples /// /// ```ignore /// // Get items 10-19 /// let items = source.get_items(10, 10).await?; /// ``` async fn get_items(&self, offset: usize, count: usize) -> Result>; /// Search for tracks matching a query /// /// This is an optional feature. Sources that don't support search /// should return `MusicSourceError::SearchNotSupported`. /// /// # Arguments /// /// * `query` - Search query string /// /// # Returns /// /// A `BrowseResult` containing matching items/containers. /// /// # Errors /// /// Returns `MusicSourceError::SearchNotSupported` if not implemented. /// /// # Examples /// /// ```ignore /// let results = source.search("Pink Floyd").await?; /// for item in results.items() { /// println!("Found: {}", item.title); /// } /// ``` async fn search(&self, query: &str) -> Result { 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 pub use async_trait::async_trait; pub use pmodidl; pub use pmoplaylist; // Re-export cache types pub use cache::{CacheStatistics, SourceCacheManager, TrackMetadata}; // Server extension modules (feature-gated) #[cfg(feature = "server")] pub mod pmoserver_ext; #[cfg(feature = "server")] pub mod api; #[cfg(feature = "server")] mod pmoserver_impl; // Re-export server extension trait #[cfg(feature = "server")] pub use pmoserver_ext::MusicSourceExt; #[cfg(test)] mod tests { use super::*; #[derive(Debug)] struct TestSource; #[async_trait] impl MusicSource for TestSource { fn name(&self) -> &str { "Test Source" } fn id(&self) -> &str { "test-source" } fn default_image(&self) -> &[u8] { &[] } async fn root_container(&self) -> Result { Ok(Container { id: "test-source".to_string(), parent_id: "0".to_string(), restricted: Some("1".to_string()), child_count: Some("0".to_string()), title: "Test Source".to_string(), class: "object.container".to_string(), containers: vec![], items: vec![], }) } async fn browse(&self, _object_id: &str) -> Result { Ok(BrowseResult::Items(vec![])) } async fn resolve_uri(&self, object_id: &str) -> Result { Ok(format!("http://example.com/{}", object_id)) } fn supports_fifo(&self) -> bool { false } async fn append_track(&self, _track: Item) -> Result<()> { Err(MusicSourceError::FifoNotSupported) } async fn remove_oldest(&self) -> Result> { Err(MusicSourceError::FifoNotSupported) } async fn update_id(&self) -> u32 { 0 } async fn last_change(&self) -> Option { None } async fn get_items(&self, _offset: usize, _count: usize) -> Result> { Ok(vec![]) } } #[tokio::test] async fn test_music_source_trait() { let source = TestSource; assert_eq!(source.name(), "Test Source"); assert_eq!(source.id(), "test-source"); assert_eq!(source.default_image_mime_type(), "image/webp"); assert!(!source.supports_fifo()); } #[tokio::test] async fn test_root_container() { let source = TestSource; let root = source.root_container().await.unwrap(); assert_eq!(root.id, "test-source"); assert_eq!(root.title, "Test Source"); } #[tokio::test] async fn test_browse_result() { let items = vec![]; let result = BrowseResult::Items(items); assert_eq!(result.count(), 0); assert_eq!(result.items().len(), 0); assert_eq!(result.containers().len(), 0); } #[tokio::test] async fn test_search_not_supported() { let source = TestSource; let result = source.search("test").await; assert!(matches!(result, Err(MusicSourceError::SearchNotSupported))); } #[tokio::test] async fn test_fifo_not_supported() { let source = TestSource; let item = Item { id: "test-1".to_string(), parent_id: "0".to_string(), restricted: Some("1".to_string()), title: "Test".to_string(), creator: None, class: "object.item.audioItem.musicTrack".to_string(), artist: None, album: None, genre: None, album_art: None, album_art_pk: None, date: None, original_track_number: None, resources: vec![], descriptions: vec![], }; let result = source.append_track(item).await; assert!(matches!(result, Err(MusicSourceError::FifoNotSupported))); let result = source.remove_oldest().await; assert!(matches!(result, Err(MusicSourceError::FifoNotSupported))); } }