Replaces raw string queries with structured `SearchQuery` types across the `MusicSource` trait and UPnP handlers. Adds dedicated paginated endpoints, explicit caching, and type-specific filtering for the Qobuz source. Fixes a frontend navigation bug by aligning state management with UPnP browse semantics and properly routing virtual container IDs. Also updates the Makefile for dynamic `RUST_LOG` configuration, standardizes logging with `tracing`, and adds the dependency lockfile.
1080 lines
31 KiB
Rust
1080 lines
31 KiB
Rust
//! # 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<T> = std::result::Result<T, MusicSourceError>;
|
|
|
|
/// 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<u32>,
|
|
/// 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<u32>,
|
|
/// Bit depth (e.g., 16, 24)
|
|
pub bit_depth: Option<u8>,
|
|
/// Bitrate in kbps (for lossy formats)
|
|
pub bitrate: Option<u32>,
|
|
/// Number of audio channels (e.g., 2 for stereo)
|
|
pub channels: Option<u8>,
|
|
}
|
|
|
|
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 },
|
|
}
|
|
|
|
/// Scope of a search operation
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SearchScope {
|
|
/// Search the full provider catalog (Qobuz API, etc.)
|
|
Catalog,
|
|
/// Search only within the user's saved library (favorites, playlists)
|
|
UserLibrary,
|
|
}
|
|
|
|
/// Type of media to search for
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum MediaSearchType {
|
|
/// All types — results grouped into navigable virtual containers
|
|
All,
|
|
Tracks,
|
|
Albums,
|
|
Artists,
|
|
Playlists,
|
|
}
|
|
|
|
/// Structured search request passed to `MusicSource::search()`
|
|
#[derive(Debug, Clone)]
|
|
pub struct SearchQuery {
|
|
pub text: String,
|
|
pub media_type: MediaSearchType,
|
|
pub scope: SearchScope,
|
|
pub limit: u32,
|
|
pub offset: u32,
|
|
}
|
|
|
|
/// Source statistics
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SourceStatistics {
|
|
/// Total number of items in the source
|
|
pub total_items: Option<usize>,
|
|
/// Total number of containers in the source
|
|
pub total_containers: Option<usize>,
|
|
/// Number of cached items
|
|
pub cached_items: Option<usize>,
|
|
/// Total cache size in bytes
|
|
pub cache_size_bytes: Option<u64>,
|
|
}
|
|
|
|
/// Result of a browse operation
|
|
#[derive(Debug, Clone)]
|
|
pub enum BrowseResult {
|
|
/// List of sub-containers only
|
|
Containers(Vec<Container>),
|
|
|
|
/// List of items only
|
|
Items(Vec<Item>),
|
|
|
|
/// Mixed: both containers and items
|
|
Mixed {
|
|
containers: Vec<Container>,
|
|
items: Vec<Item>,
|
|
},
|
|
}
|
|
|
|
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<Container> {
|
|
/// 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<BrowseResult> {
|
|
/// // Browse implementation
|
|
/// Ok(BrowseResult::Items(vec![]))
|
|
/// }
|
|
///
|
|
/// async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
|
/// // 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<Option<Item>> {
|
|
/// // Remove oldest track
|
|
/// Ok(None)
|
|
/// }
|
|
///
|
|
/// async fn update_id(&self) -> u32 {
|
|
/// 0
|
|
/// }
|
|
///
|
|
/// async fn last_change(&self) -> Option<SystemTime> {
|
|
/// None
|
|
/// }
|
|
///
|
|
/// async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
|
|
/// Ok(vec![])
|
|
/// }
|
|
///
|
|
/// async fn search(&self, query: &SearchQuery) -> Result<BrowseResult> {
|
|
/// 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<Container>;
|
|
|
|
/// 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<BrowseResult>;
|
|
|
|
/// Get detailed metadata for a specific item
|
|
///
|
|
/// Returns the full metadata of an item by its object_id.
|
|
/// This is useful for refreshing metadata during playback or
|
|
/// getting details of a specific track without browsing its parent.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `object_id` - The ID of the item to retrieve
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The complete `Item` with all metadata.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `MusicSourceError::ObjectNotFound` if the item doesn't exist.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```ignore
|
|
/// let item = source.get_item("track-123").await?;
|
|
/// println!("Now playing: {} by {}", item.title, item.artist.unwrap_or_default());
|
|
/// ```
|
|
async fn get_item(&self, _object_id: &str) -> Result<Item> {
|
|
// Default implementation: try to find it in parent's browse result
|
|
// This is inefficient and should be overridden by implementations
|
|
Err(MusicSourceError::NotSupported(
|
|
"get_item not implemented, override this method".to_string(),
|
|
))
|
|
}
|
|
|
|
/// 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<String>;
|
|
|
|
// ============= 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<Option<Item>>;
|
|
|
|
// ============= 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<SystemTime>;
|
|
|
|
// ============= 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<Vec<Item>>;
|
|
|
|
/// 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 q = SearchQuery { text: "Pink Floyd".into(), media_type: MediaSearchType::All,
|
|
/// scope: SearchScope::Catalog, limit: 50, offset: 0 };
|
|
/// let results = source.search(&q).await?;
|
|
/// ```
|
|
async fn search(&self, query: &SearchQuery) -> Result<BrowseResult> {
|
|
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<Vec<AudioFormat>> {
|
|
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<CacheStatus> {
|
|
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<CacheStatus> {
|
|
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<bool> {
|
|
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<Vec<Container>> {
|
|
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<usize> {
|
|
// 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<BrowseResult> {
|
|
// Default implementation: browse all then slice (inefficient)
|
|
let _ = (offset, limit);
|
|
self.browse(object_id).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<SourceStatistics> {
|
|
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<Container> {
|
|
Ok(Container {
|
|
id: "test-source".to_string(),
|
|
parent_id: "0".to_string(),
|
|
restricted: Some("1".to_string()),
|
|
child_count: Some("0".to_string()),
|
|
searchable: Some("1".to_string()),
|
|
title: "Test Source".to_string(),
|
|
class: "object.container".to_string(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
})
|
|
}
|
|
|
|
async fn browse(&self, _object_id: &str) -> Result<BrowseResult> {
|
|
Ok(BrowseResult::Items(vec![]))
|
|
}
|
|
|
|
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
|
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<Option<Item>> {
|
|
Err(MusicSourceError::FifoNotSupported)
|
|
}
|
|
|
|
async fn update_id(&self) -> u32 {
|
|
0
|
|
}
|
|
|
|
async fn last_change(&self) -> Option<SystemTime> {
|
|
None
|
|
}
|
|
|
|
async fn get_items(&self, _offset: usize, _count: usize) -> Result<Vec<Item>> {
|
|
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)));
|
|
}
|
|
}
|