Crée la crate pmoplaylist

This commit is contained in:
2025-10-16 22:13:00 +02:00
parent e0fbb11475
commit 5c06a0f0e9
25 changed files with 5482 additions and 189 deletions

460
pmosource/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,460 @@
# PMOSource Architecture
This document describes the architecture and design decisions for the `pmosource` crate.
## Overview
`pmosource` provides a unified abstraction layer for all music sources in the PMOMusic ecosystem. It defines the `MusicSource` trait that all concrete music sources (Radio Paradise, Qobuz, local playlists, etc.) must implement.
## Design Goals
1. **Unified Interface**: Single trait for all music source types
2. **UPnP/OpenHome Compatible**: Support ContentDirectory browsing and DIDL-Lite
3. **Cache Integration**: Seamless integration with `pmoaudiocache` and `pmocovers`
4. **Change Tracking**: Support for UPnP event notifications via `update_id` and `last_change`
5. **FIFO Support**: Dynamic sources (radios) can manage track queues
6. **Thread Safety**: All sources must be `Send + Sync` for async servers
7. **No Network Code**: Pure abstraction layer, no HTTP/network implementation
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ PMOMusic Server │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ MusicSource Registry │ │
│ │ - Manage multiple sources │ │
│ │ - Aggregate content for ContentDirectory │ │
│ │ - Handle browse/search requests │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ │ │ │ │
│ ┌────▼────┐ ┌─────▼────┐ ┌─────▼────┐ │
│ │ Radio │ │ Qobuz │ │ Local │ │
│ │Paradise │ │ Source │ │ Playlist │ │
│ └────┬────┘ └─────┬────┘ └─────┬────┘ │
│ │ │ │ │
│ └──────────────────┼──────────────────┘ │
│ │ │
│ implements MusicSource trait │
└─────────────────────────────┬───────────────────────────────────┘
┌────────────────────┴────────────────────┐
│ │
┌────▼─────┐ ┌─────▼──────┐
│pmoplaylist│ │ pmodidl │
│ FIFO │ │ DIDL-Lite │
└──────────┘ └────────────┘
│ │
┌────▼─────────┐ ┌────▼────────┐
│pmoaudiocache │ │ pmocovers │
│ Audio files │ │ Images │
└──────────────┘ └─────────────┘
```
## Core Trait: `MusicSource`
The `MusicSource` trait is divided into 5 logical sections:
### 1. Basic Information
```rust
fn name(&self) -> &str;
fn id(&self) -> &str;
fn default_image(&self) -> &[u8];
fn default_image_mime_type(&self) -> &str;
```
These methods provide basic metadata about the source:
- **name**: Human-readable display name
- **id**: Unique identifier for routing and container IDs
- **default_image**: Embedded WebP logo (300x300px)
- **default_image_mime_type**: Always "image/webp"
### 2. ContentDirectory Navigation
```rust
async fn root_container(&self) -> Result<Container>;
async fn browse(&self, object_id: &str) -> Result<BrowseResult>;
async fn resolve_uri(&self, object_id: &str) -> Result<String>;
```
These methods support UPnP ContentDirectory Service:
- **root_container**: Returns the top-level container for this source
- **browse**: Returns children of a given container (sub-containers or items)
- **resolve_uri**: Resolves the actual streaming URI for a track (checks caches)
### 3. FIFO Management
```rust
fn supports_fifo(&self) -> bool;
async fn append_track(&self, track: Item) -> Result<()>;
async fn remove_oldest(&self) -> Result<Option<Item>>;
```
For dynamic sources (radios, streaming services):
- **supports_fifo**: Indicates if source uses a FIFO queue
- **append_track**: Adds track to queue (auto-removes oldest if capacity reached)
- **remove_oldest**: Manually removes oldest track
### 4. Change Tracking
```rust
async fn update_id(&self) -> u32;
async fn last_change(&self) -> Option<SystemTime>;
```
For UPnP event notifications:
- **update_id**: Counter incremented on each change (wraps around)
- **last_change**: Timestamp of last modification
### 5. Pagination & Search
```rust
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>>;
async fn search(&self, query: &str) -> Result<BrowseResult>;
```
For efficient browsing and searching:
- **get_items**: Paginated access to items
- **search**: Optional search (default: not supported)
## Source Types
### Dynamic Sources (with FIFO)
Examples: Radio Paradise, streaming radios, live playlists
**Characteristics:**
- `supports_fifo() = true`
- Uses `pmoplaylist::FifoPlaylist` internally
- `update_id` changes when tracks are added/removed
- Limited capacity (e.g., last 50 tracks)
- Items have dynamic URIs that may change
**Implementation Pattern:**
```rust
struct RadioSource {
playlist: FifoPlaylist,
track_cache: RwLock<HashMap<String, (String, Option<String>)>>,
}
impl MusicSource for RadioSource {
fn supports_fifo(&self) -> bool {
true
}
async fn append_track(&self, track: Item) -> Result<()> {
// Convert Item to pmoplaylist::Track
// Add to playlist
self.playlist.append_track(pmo_track).await;
Ok(())
}
async fn update_id(&self) -> u32 {
self.playlist.update_id().await
}
}
```
### Static Sources (without FIFO)
Examples: Local albums, fixed playlists, Qobuz albums
**Characteristics:**
- `supports_fifo() = false`
- `append_track()` returns `FifoNotSupported` error
- `update_id` is constant (0)
- `last_change()` may be None
- Items have stable URIs
**Implementation Pattern:**
```rust
struct AlbumSource {
items: Vec<Item>,
}
impl MusicSource for AlbumSource {
fn supports_fifo(&self) -> bool {
false
}
async fn append_track(&self, _: Item) -> Result<()> {
Err(MusicSourceError::FifoNotSupported)
}
async fn update_id(&self) -> u32 {
0 // Never changes
}
}
```
## Integration with PMOMusic Ecosystem
### pmoplaylist Integration
`pmoplaylist` provides the `FifoPlaylist` struct for managing dynamic track lists:
```rust
use pmoplaylist::{FifoPlaylist, Track};
let playlist = FifoPlaylist::new(
"radio-id".to_string(),
"Radio Name".to_string(),
50, // capacity
DEFAULT_IMAGE,
);
// Add tracks
playlist.append_track(Track::new("id", "title", "uri")).await;
// Get tracks
let tracks = playlist.get_items(0, 10).await;
// Track changes
let update_id = playlist.update_id().await;
let last_change = playlist.last_change().await;
```
**Benefits:**
- Automatic capacity management (FIFO behavior)
- Built-in change tracking
- Thread-safe (Arc<RwLock<>>)
### pmodidl Integration
All sources use `pmodidl` for DIDL-Lite generation:
```rust
use pmodidl::{Container, Item, Resource};
// Containers for browsing
let container = Container {
id: "source-id".to_string(),
parent_id: "0".to_string(),
title: "My Source".to_string(),
class: "object.container.playlistContainer".to_string(),
child_count: Some("10".to_string()),
containers: vec![],
items: vec![],
};
// Items for tracks
let item = Item {
id: "track-1".to_string(),
parent_id: "source-id".to_string(),
title: "Track Title".to_string(),
artist: Some("Artist".to_string()),
class: "object.item.audioItem.musicTrack".to_string(),
resources: vec![Resource {
url: "http://server/audio/track-1".to_string(),
protocol_info: "http-get:*:audio/flac:*".to_string(),
duration: Some("0:03:45".to_string()),
..Default::default()
}],
..Default::default()
};
```
### pmoaudiocache Integration
Sources can use `pmoaudiocache` to cache audio files locally:
```rust
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
// Check if track is cached
if let Some(cached_pk) = self.get_cached_pk(object_id).await {
// Return cached URI (local FLAC file)
Ok(format!("{}/audio/cache/{}", self.cache_base_url, cached_pk))
} else {
// Return original streaming URI
Ok(self.get_original_uri(object_id))
}
}
```
**Benefits:**
- Local caching of streamed audio
- Automatic FLAC conversion
- Metadata extraction and merging
- Reduced bandwidth usage
### pmocovers Integration
Sources can use `pmocovers` to cache album art:
```rust
// Store cover art PK in track metadata
let album_art_url = format!("{}/covers/images/{}", base_url, cover_pk);
let item = Item {
album_art: Some(album_art_url),
..Default::default()
};
```
**Benefits:**
- Local caching of album art
- Automatic WebP conversion
- Multiple size variants
- Optimized delivery
## Error Handling
All fallible operations return `pmosource::Result<T>`:
```rust
pub enum MusicSourceError {
ImageLoadError(String),
InvalidImageFormat(String),
SourceUnavailable(String),
ObjectNotFound(String),
BrowseError(String),
SearchNotSupported,
FifoNotSupported,
CacheError(String),
UriResolutionError(String),
}
```
**Guidelines:**
- Use `ObjectNotFound` for invalid object IDs
- Use `BrowseError` for general browsing failures
- Use `SearchNotSupported` for sources without search
- Use `FifoNotSupported` for static sources
- Use `CacheError` for cache-related issues
## Thread Safety
All `MusicSource` implementations must be `Send + Sync`:
```rust
pub trait MusicSource: Debug + Send + Sync {
// ...
}
```
**Reasoning:**
- Sources may be shared across multiple async tasks
- UPnP server handles concurrent requests
- `Arc<dyn MusicSource>` enables efficient sharing
**Implementation:**
- Use `Arc<RwLock<>>` for mutable state
- Use `tokio::sync::RwLock` for async operations
- Avoid `Rc`, `RefCell`, or other non-thread-safe types
## Testing Strategy
### Unit Tests
Test each method independently:
```rust
#[tokio::test]
async fn test_root_container() {
let source = MySource::new();
let root = source.root_container().await.unwrap();
assert_eq!(root.id, "my-source");
}
```
### Integration Tests
Test complete workflows:
```rust
#[tokio::test]
async fn test_browse_and_resolve() {
let source = MySource::new();
let result = source.browse("container-1").await.unwrap();
for item in result.items() {
let uri = source.resolve_uri(&item.id).await.unwrap();
assert!(uri.starts_with("http://"));
}
}
```
### Example Tests
Run examples as integration tests:
```bash
cargo run --example radio_paradise
```
## Future Enhancements
Potential additions to the trait:
1. **Authentication**:
```rust
async fn authenticate(&mut self, credentials: Credentials) -> Result<()>;
fn is_authenticated(&self) -> bool;
```
2. **Quality Levels**:
```rust
fn available_qualities(&self) -> Vec<Quality>;
async fn set_quality(&mut self, quality: Quality) -> Result<()>;
```
3. **Favorites/Bookmarks**:
```rust
async fn add_favorite(&self, object_id: &str) -> Result<()>;
async fn list_favorites(&self) -> Result<Vec<Item>>;
```
4. **Recommendations**:
```rust
async fn get_recommendations(&self) -> Result<Vec<Item>>;
```
## Design Decisions
### Why async-trait?
- Native async traits don't support trait objects yet
- `async-trait` provides a clean macro-based solution
- Minimal performance overhead with good compiler optimizations
### Why separate FIFO methods?
- Clear distinction between dynamic and static sources
- Static sources can return `FifoNotSupported` immediately
- Allows future optimizations for FIFO-specific operations
### Why BrowseResult enum?
- Different sources return different types of results
- Some return only containers, some only items, some mixed
- Enum provides type-safe representation of all cases
### Why separate resolve_uri?
- Caching is a cross-cutting concern
- Separating resolution from browsing allows flexible caching strategies
- URI resolution may be expensive (check cache, fallback to original)
## Performance Considerations
1. **Caching**: Always check local caches before streaming
2. **Pagination**: Use `get_items(offset, count)` for large collections
3. **Lazy Loading**: Don't load all metadata upfront
4. **Arc Sharing**: Use `Arc<dyn MusicSource>` to avoid cloning
5. **RwLock Usage**: Prefer read locks when possible
## Versioning
The crate follows Semantic Versioning:
- **MAJOR**: Breaking changes to `MusicSource` trait
- **MINOR**: New trait methods (with default implementations)
- **PATCH**: Bug fixes, documentation, internal changes
Current version: **0.2.0**

69
pmosource/CHANGELOG.md Normal file
View File

@@ -0,0 +1,69 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.0] - 2025-01-16
### Added
- Extended `MusicSource` trait with comprehensive async methods:
- `root_container()`: Get root container for ContentDirectory
- `browse(object_id)`: Browse containers and items
- `resolve_uri(object_id)`: Resolve audio URIs (with cache support)
- `supports_fifo()`: Indicate FIFO support
- `append_track(track)`: Add track to FIFO
- `remove_oldest()`: Remove oldest track from FIFO
- `update_id()`: Get current update counter
- `last_change()`: Get last modification timestamp
- `get_items(offset, count)`: Paginated browsing
- `search(query)`: Optional search functionality
- New types:
- `BrowseResult`: Enum for browse results (Containers, Items, or Mixed)
- Extended `MusicSourceError` with more error variants
- Dependencies:
- `async-trait`: For async trait methods
- `tokio`: Async runtime
- `pmodidl`: DIDL-Lite support
- `pmoplaylist`: FIFO playlist management
- `pmoaudiocache` (optional): Audio caching
- `pmocovers` (optional): Cover art caching
- Complete Radio Paradise example (`examples/radio_paradise.rs`) demonstrating:
- FIFO management using `pmoplaylist`
- Cache integration simulation
- DIDL-Lite generation
- Change tracking
- Full trait implementation
- Comprehensive documentation:
- Updated README with architecture diagrams
- Usage examples for static and dynamic sources
- Integration guides for PMOMusic ecosystem
- Thread safety notes
### Changed
- `MusicSource` trait is now async (requires `#[async_trait]`)
- All implementations must be `Send + Sync`
- Trait is now much more comprehensive and ready for UPnP/OpenHome integration
### Removed
- Outdated `show_sources.rs` example
## [0.1.0] - Initial Release
### Added
- Basic `MusicSource` trait with:
- `name()`: Human-readable name
- `id()`: Unique identifier
- `default_image()`: Embedded WebP logo
- `default_image_mime_type()`: MIME type
- Basic error types
- Standard image size constant (300x300px)

View File

@@ -12,6 +12,24 @@ categories = ["multimedia"]
[dependencies]
# Gestion des erreurs
thiserror = "1.0"
anyhow = "1.0"
# Image format support
image = { version = "0.25", default-features = false, features = ["webp"] }
# Async traits
async-trait = "0.1"
# Async runtime
tokio = { version = "1.0", features = ["sync", "time"] }
# DIDL-Lite support
pmodidl = { path = "../pmodidl" }
# Playlist/FIFO support
pmoplaylist = { path = "../pmoplaylist" }
# Optional cache integrations
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
pmocovers = { path = "../pmocovers", optional = true }
[features]
default = ["cache"]
cache = ["pmoaudiocache", "pmocovers"]

View File

@@ -1,112 +1,295 @@
# pmosource
# pmosource - Music Source Abstraction for PMOMusic
Common traits and types for PMOMusic sources.
## Overview
`pmosource` provides the foundational abstractions for different music sources in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, and potentially others in the future.
This crate provides the foundational abstractions for different music sources in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, local playlists, etc.
## Features
- **`MusicSource` trait**: Common interface for all music sources
- **Default images**: Standardized 300x300px WebP images embedded in binaries
- **Source identification**: Consistent naming and ID scheme
- **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
## Usage
## Architecture
### Implementing the trait
The `MusicSource` trait provides a unified interface for all music sources:
```
┌─────────────────────────────────────┐
│ MusicSource Trait │
├─────────────────────────────────────┤
│ • Basic Info (name, id, image) │
│ • ContentDirectory (browse, search) │
│ • URI Resolution (with caching) │
│ • FIFO Management │
│ • Change Tracking │
└─────────────────────────────────────┘
▲ ▲ ▲
│ │ │
┌────┴───┐ ┌──┴────┐ ┌──┴─────┐
│ Radio │ │ Qobuz │ │ Local │
│Paradise│ │ │ │Playlist│
└────────┘ └───────┘ └────────┘
```
## Quick Start
### Implementing a Music Source
```rust
use pmosource::MusicSource;
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
use pmosource::{async_trait, MusicSource, BrowseResult, Result};
use pmodidl::{Container, Item};
use pmoplaylist::FifoPlaylist;
use std::time::SystemTime;
#[derive(Debug)]
pub struct MyMusicSource;
pub struct MyRadioSource {
playlist: FifoPlaylist,
// ... other fields
}
impl MusicSource for MyMusicSource {
#[async_trait]
impl MusicSource for MyRadioSource {
fn name(&self) -> &str {
"My Music Service"
"My Radio"
}
fn id(&self) -> &str {
"my-music-service"
"my-radio"
}
fn default_image(&self) -> &[u8] {
DEFAULT_IMAGE
include_bytes!("../assets/my-radio.webp")
}
async fn root_container(&self) -> Result<Container> {
Ok(self.playlist.as_container().await)
}
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
// Return items from FIFO
let tracks = self.playlist.get_items(0, 100).await;
// Convert tracks to Items...
Ok(BrowseResult::Items(items))
}
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
// Return cached URI if available, or original URI
Ok(format!("http://cache-server/audio/{}", object_id))
}
fn supports_fifo(&self) -> bool {
true
}
async fn append_track(&self, track: Item) -> Result<()> {
// Convert Item to Track and add to playlist
self.playlist.append_track(pmo_track).await;
Ok(())
}
async fn remove_oldest(&self) -> Result<Option<Item>> {
if let Some(track) = self.playlist.remove_oldest().await {
// Convert Track to Item and return
Ok(Some(item))
} else {
Ok(None)
}
}
async fn update_id(&self) -> u32 {
self.playlist.update_id().await
}
async fn last_change(&self) -> Option<SystemTime> {
Some(self.playlist.last_change().await)
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
let tracks = self.playlist.get_items(offset, count).await;
// Convert tracks to Items...
Ok(items)
}
}
```
### Using a music source
### Using a Music Source
```rust
use pmosource::MusicSource;
use pmoparadise::RadioParadiseSource;
use pmoqobuz::QobuzSource;
let rp = RadioParadiseSource;
let qobuz = QobuzSource;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let source = MyRadioSource::new("http://localhost:8080");
println!("Source: {} ({})", rp.name(), rp.id());
println!("Image size: {} bytes", rp.default_image().len());
// Get source info
println!("Source: {}", source.name());
println!("ID: {}", source.id());
// Get root container for ContentDirectory
let root = source.root_container().await?;
println!("Root: {} ({})", root.title, root.id);
// Browse items
let result = source.browse(&root.id).await?;
for item in result.items() {
println!("Track: {}", item.title);
}
// Resolve audio URI
let uri = source.resolve_uri("track-123").await?;
println!("Stream from: {}", uri);
// Track changes
println!("Update ID: {}", source.update_id().await);
Ok(())
}
```
## Image Format
## Trait Methods
All default images should be:
- **Format**: WebP
- **Dimensions**: 300x300 pixels (square)
- **Quality**: 85 (good balance between size and quality)
- **Location**: `<crate>/assets/default.webp`
### Basic Information
### Converting images
- `name() -> &str`: Human-readable name
- `id() -> &str`: Unique identifier (e.g., "radio-paradise")
- `default_image() -> &[u8]`: Embedded WebP logo (300x300px)
- `default_image_mime_type() -> &str`: MIME type (default: "image/webp")
Use the provided Python script or similar tool:
### ContentDirectory Navigation
```python
from PIL import Image
- `root_container() -> Container`: Root container for UPnP ContentDirectory
- `browse(object_id: &str) -> BrowseResult`: Browse containers/items
- `resolve_uri(object_id: &str) -> String`: Get audio URI (cached or original)
def convert_to_webp(input_path, output_path, size=300):
img = Image.open(input_path)
### FIFO Support (Dynamic Sources)
# Convert to RGB if necessary
if img.mode not in ('RGB', 'RGBA'):
img = img.convert('RGB')
- `supports_fifo() -> bool`: Whether this source uses a FIFO
- `append_track(track: Item)`: Add track to FIFO (auto-removes oldest if full)
- `remove_oldest() -> Option<Item>`: Remove oldest track from FIFO
# Make it square (center crop)
width, height = img.size
if width != height:
min_dim = min(width, height)
left = (width - min_dim) // 2
top = (height - min_dim) // 2
right = left + min_dim
bottom = top + min_dim
img = img.crop((left, top, right, bottom))
### Change Tracking
# Resize to target size
img = img.resize((size, size), Image.Resampling.LANCZOS)
- `update_id() -> u32`: Increments on each change (for UPnP notifications)
- `last_change() -> Option<SystemTime>`: Timestamp of last modification
# Save as WebP
img.save(output_path, 'WEBP', quality=85, method=6)
### Pagination & Search
- `get_items(offset: usize, count: usize) -> Vec<Item>`: Paginated browsing
- `search(query: &str) -> BrowseResult`: Search (optional, default: not supported)
## Integration with PMOMusic Ecosystem
### With pmoplaylist
Sources that support FIFO (radios, streaming services) use `pmoplaylist::FifoPlaylist` to manage dynamic track lists:
```rust
use pmoplaylist::{FifoPlaylist, Track};
let playlist = FifoPlaylist::new(
"my-radio".to_string(),
"My Radio".to_string(),
50, // capacity
DEFAULT_IMAGE,
);
// Add tracks
playlist.append_track(Track::new("id", "title", "uri")).await;
// Tracks automatically removed when capacity reached
```
## Current Implementations
### With pmoaudiocache
- **pmoparadise**: Radio Paradise
- **pmoqobuz**: Qobuz
When the `cache` feature is enabled, sources can integrate with `pmoaudiocache` to:
- Cache audio files locally (with FLAC conversion)
- Serve from local cache instead of re-streaming
- Extract and merge metadata
## Future Enhancements
```rust
// Resolve URI checks cache first
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
if let Some(cached_pk) = self.get_cached_pk(object_id).await {
Ok(format!("{}/audio/cache/{}", self.cache_base_url, cached_pk))
} else {
Ok(self.get_original_uri(object_id))
}
}
```
The `MusicSource` trait can be extended with additional methods such as:
### With pmocovers
- Authentication status
- Available quality levels
- Streaming capabilities
- Search functionality
- Playlist management
- And more...
When the `cache` feature is enabled, sources can integrate with `pmocovers` to:
- Cache album art locally (with WebP conversion)
- Generate multiple size variants
- Serve optimized images
### With pmodidl
All sources use `pmodidl` for DIDL-Lite generation compatible with UPnP/DLNA.
## Examples
### Radio Paradise
See [examples/radio_paradise.rs](examples/radio_paradise.rs) for a complete implementation of a streaming radio source with:
- FIFO management using `pmoplaylist`
- Simulated cache integration
- Full DIDL-Lite export
- Change tracking
Run the example:
```bash
cargo run --example radio_paradise
```
## Design Patterns
### Static Sources (Albums, Local Playlists)
```rust
impl MusicSource for LocalAlbum {
fn supports_fifo(&self) -> bool {
false // Static content
}
async fn append_track(&self, _: Item) -> Result<()> {
Err(MusicSourceError::FifoNotSupported)
}
async fn update_id(&self) -> u32 {
0 // Never changes
}
}
```
### Dynamic Sources (Radios, Streaming Services)
```rust
impl MusicSource for RadioSource {
fn supports_fifo(&self) -> bool {
true // Dynamic content
}
async fn append_track(&self, track: Item) -> Result<()> {
// Add to pmoplaylist::FifoPlaylist
self.playlist.append_track(converted_track).await;
Ok(())
}
async fn update_id(&self) -> u32 {
self.playlist.update_id().await
}
}
```
## Thread Safety
All `MusicSource` implementations must be `Send + Sync` for use in async servers.
## License

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,270 @@
# PMOSource Examples
This directory contains example implementations of the `MusicSource` trait.
## Available Examples
### radio_paradise.rs
A complete implementation of a streaming radio source demonstrating:
- **FIFO Management**: Using `pmoplaylist::FifoPlaylist` for dynamic track management
- **Cache Integration**: Simulated integration with `pmoaudiocache` and `pmocovers`
- **DIDL-Lite Export**: Proper conversion between `pmoplaylist::Track` and `pmodidl::Item`
- **Change Tracking**: `update_id` and `last_change` for UPnP notifications
- **URI Resolution**: Dynamic URI resolution with cache support
- **Pagination**: Efficient browsing with `get_items(offset, count)`
#### Running the Example
```bash
cargo run --example radio_paradise
```
#### Expected Output
```
Radio Paradise Source Example
==============================
Source: Radio Paradise
ID: radio-paradise
Supports FIFO: true
Default image size: 9774 bytes
Adding sample tracks...
Added 3 tracks
Root Container:
ID: radio-paradise
Title: Radio Paradise
Child Count: Some("3")
Browsing tracks:
- Wish You Were Here by Pink Floyd (Wish You Were Here)
- Bohemian Rhapsody by Queen (A Night at the Opera)
- Hotel California by Eagles (Hotel California)
Resolving URIs:
rp-001: http://stream.radioparadise.com/rp-001.mp3
rp-002: http://stream.radioparadise.com/rp-002.mp3
rp-003: http://stream.radioparadise.com/rp-003.mp3
Change Tracking:
Update ID: 3
Last Change: SystemTime { ... }
Simulating cache for rp-001...
Cached URI: http://localhost:8080/audio/cache/cached-abc123
Pagination (get items 1-2):
- Bohemian Rhapsody
- Hotel California
Removing oldest track...
Removed: Wish You Were Here
New Update ID: 4
Browsing after removal:
Tracks remaining: 2
- Bohemian Rhapsody
- Hotel California
```
## Creating Your Own Source
### 1. Define the Source Structure
```rust
use pmosource::{async_trait, MusicSource, BrowseResult, Result};
use pmodidl::{Container, Item};
use pmoplaylist::FifoPlaylist;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
pub struct MySource {
inner: Arc<MySourceInner>,
}
struct MySourceInner {
// For dynamic sources:
playlist: FifoPlaylist,
// For static sources:
// items: Vec<Item>,
// Other fields as needed
}
```
### 2. Implement Basic Information
```rust
#[async_trait]
impl MusicSource for MySource {
fn name(&self) -> &str {
"My Source Name"
}
fn id(&self) -> &str {
"my-source"
}
fn default_image(&self) -> &[u8] {
include_bytes!("../assets/my-source.webp")
}
}
```
### 3. Implement ContentDirectory Methods
```rust
async fn root_container(&self) -> Result<Container> {
Ok(Container {
id: self.id().to_string(),
parent_id: "0".to_string(),
title: self.name().to_string(),
class: "object.container.playlistContainer".to_string(),
child_count: Some("0".to_string()),
containers: vec![],
items: vec![],
})
}
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
// Return items for this container
Ok(BrowseResult::Items(vec![]))
}
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
// Return URI for track
Ok(format!("http://example.com/{}", object_id))
}
```
### 4. Implement FIFO Methods (if applicable)
```rust
fn supports_fifo(&self) -> bool {
true // or false for static sources
}
async fn append_track(&self, track: Item) -> Result<()> {
// For dynamic sources: convert and add to playlist
// For static sources: return FifoNotSupported error
Ok(())
}
async fn remove_oldest(&self) -> Result<Option<Item>> {
// For dynamic sources: remove from playlist
// For static sources: return FifoNotSupported error
Ok(None)
}
```
### 5. Implement Change Tracking
```rust
async fn update_id(&self) -> u32 {
// For dynamic sources: delegate to playlist
// For static sources: return 0
0
}
async fn last_change(&self) -> Option<std::time::SystemTime> {
// Return timestamp of last modification
None
}
```
### 6. Implement Pagination
```rust
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
// Return paginated items
Ok(vec![])
}
```
### 7. Implement Search (optional)
```rust
async fn search(&self, query: &str) -> Result<BrowseResult> {
// If search is not supported:
Err(pmosource::MusicSourceError::SearchNotSupported)
// If search is supported:
// let results = self.search_items(query)?;
// Ok(BrowseResult::Items(results))
}
```
## Best Practices
### Thread Safety
Always use `Arc<RwLock<>>` for mutable state:
```rust
use std::sync::Arc;
use tokio::sync::RwLock;
struct MySourceInner {
state: RwLock<HashMap<String, Track>>,
}
```
### Error Handling
Use appropriate error types:
```rust
if object_id_not_found {
return Err(MusicSourceError::ObjectNotFound(object_id.to_string()));
}
```
### Manual Debug Implementation
If your source contains non-Debug types, implement Debug manually:
```rust
impl std::fmt::Debug for MySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MySource")
.field("name", &self.name())
.finish()
}
}
```
### Testing
Create comprehensive tests:
```rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let source = MySource::new();
// Test basic info
println!("Source: {}", source.name());
// Test browsing
let result = source.browse("root").await?;
println!("Items: {}", result.count());
// Test URI resolution
let uri = source.resolve_uri("track-1").await?;
println!("URI: {}", uri);
Ok(())
}
```
## Further Reading
- [Main README](../README.md): Overview and quick start
- [ARCHITECTURE.md](../ARCHITECTURE.md): Detailed architecture documentation
- [CHANGELOG.md](../CHANGELOG.md): Version history and changes

View File

@@ -0,0 +1,466 @@
//! # Radio Paradise Example
//!
//! This example demonstrates how to implement a concrete `MusicSource` using
//! Radio Paradise as a streaming radio source with FIFO support.
//!
//! ## Features
//!
//! - **FIFO Playlist**: Uses `pmoplaylist::FifoPlaylist` for dynamic track management
//! - **Cache Integration**: Resolves URIs via `pmoaudiocache` and `pmocovers` (when enabled)
//! - **DIDL-Lite Export**: Generates proper UPnP-compatible containers and items
//! - **Change Tracking**: Tracks `update_id` and `last_change` for notifications
//!
//! ## Usage
//!
//! ```bash
//! cargo run --example radio_paradise
//! ```
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use pmodidl::{Container, Item, Resource};
use pmoplaylist::{FifoPlaylist, Track};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Default image for Radio Paradise (embedded WebP)
const RADIO_PARADISE_IMAGE: &[u8] = include_bytes!("../assets/radio-paradise.webp");
/// Default capacity for the FIFO (number of recent tracks to keep)
const DEFAULT_FIFO_CAPACITY: usize = 50;
/// Radio Paradise music source
///
/// This is a concrete implementation of `MusicSource` for Radio Paradise,
/// demonstrating how to:
/// - Use `pmoplaylist::FifoPlaylist` for dynamic track management
/// - Integrate with caches for URI resolution
/// - Implement ContentDirectory browsing
/// - Track changes via `update_id` and `last_change`
#[derive(Clone)]
pub struct RadioParadise {
inner: Arc<RadioParadiseInner>,
}
struct RadioParadiseInner {
/// FIFO playlist managed by pmoplaylist
playlist: FifoPlaylist,
/// Cache server base URL (for URI resolution)
cache_base_url: String,
/// Track metadata cache (object_id -> original_uri, cached_pk)
track_cache: RwLock<HashMap<String, (String, Option<String>)>>,
}
// Manual Debug implementation since FifoPlaylist doesn't derive Debug
impl std::fmt::Debug for RadioParadise {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RadioParadise")
.field("cache_base_url", &self.inner.cache_base_url)
.finish()
}
}
impl RadioParadise {
/// Create a new Radio Paradise source
///
/// # Arguments
///
/// * `cache_base_url` - Base URL for the cache server (e.g., "http://localhost:8080")
/// * `fifo_capacity` - Maximum number of tracks in the FIFO
///
/// # Examples
///
/// ```
/// use pmosource::RadioParadise;
///
/// let source = RadioParadise::new("http://localhost:8080", 50);
/// ```
pub fn new(cache_base_url: impl Into<String>, fifo_capacity: usize) -> Self {
let playlist = FifoPlaylist::new(
"radio-paradise".to_string(),
"Radio Paradise".to_string(),
fifo_capacity,
RADIO_PARADISE_IMAGE,
);
Self {
inner: Arc::new(RadioParadiseInner {
playlist,
cache_base_url: cache_base_url.into(),
track_cache: RwLock::new(HashMap::new()),
}),
}
}
/// Create with default settings
pub fn new_default(cache_base_url: impl Into<String>) -> Self {
Self::new(cache_base_url, DEFAULT_FIFO_CAPACITY)
}
/// Add a track to the Radio Paradise FIFO from raw data
///
/// This simulates receiving a new track from the Radio Paradise API.
///
/// # Arguments
///
/// * `id` - Unique track ID
/// * `title` - Track title
/// * `artist` - Artist name
/// * `album` - Album name
/// * `uri` - Original streaming URI
/// * `image_url` - URL for cover art (optional)
/// * `duration` - Track duration in seconds (optional)
pub async fn add_track(
&self,
id: String,
title: String,
artist: Option<String>,
album: Option<String>,
uri: String,
image_url: Option<String>,
duration: Option<u32>,
) -> Result<()> {
// Store the original URI for later resolution
{
let mut cache = self.inner.track_cache.write().await;
cache.insert(id.clone(), (uri.clone(), None));
}
// Create a Track for pmoplaylist
let mut track = Track::new(id, title, uri);
if let Some(artist) = artist {
track = track.with_artist(artist);
}
if let Some(album) = album {
track = track.with_album(album);
}
if let Some(duration) = duration {
track = track.with_duration(duration);
}
if let Some(image) = image_url {
track = track.with_image(image);
}
// Add to the FIFO (automatically handles capacity)
self.inner.playlist.append_track(track).await;
Ok(())
}
/// Simulate caching a track
///
/// In a real implementation, this would interact with `pmoaudiocache`
/// to download and cache the track, then store the cache key.
///
/// # Arguments
///
/// * `track_id` - The track ID to cache
/// * `cache_pk` - The cache primary key returned by pmoaudiocache
pub async fn cache_track(&self, track_id: &str, cache_pk: String) -> Result<()> {
let mut cache = self.inner.track_cache.write().await;
if let Some((_original_uri, cached_pk)) = cache.get_mut(track_id) {
*cached_pk = Some(cache_pk);
Ok(())
} else {
Err(MusicSourceError::ObjectNotFound(track_id.to_string()))
}
}
/// Convert pmoplaylist::Track to pmodidl::Item
fn track_to_item(&self, track: &Track) -> Item {
// Format duration
let duration_str = track.duration.map(|d| {
let hours = d / 3600;
let minutes = (d % 3600) / 60;
let seconds = d % 60;
format!("{}:{:02}:{:02}", hours, minutes, seconds)
});
// Create resource
let resource = Resource {
protocol_info: "http-get:*:audio/*:*".to_string(),
bits_per_sample: None,
sample_frequency: None,
nr_audio_channels: None,
duration: duration_str,
url: track.uri.clone(),
};
Item {
id: track.id.clone(),
parent_id: "radio-paradise".to_string(),
restricted: Some("1".to_string()),
title: track.title.clone(),
creator: track.artist.clone(),
class: "object.item.audioItem.musicTrack".to_string(),
artist: track.artist.clone(),
album: track.album.clone(),
genre: None,
album_art: track.image.clone(),
album_art_pk: None,
date: None,
original_track_number: None,
resources: vec![resource],
descriptions: vec![],
}
}
}
#[async_trait]
impl MusicSource for RadioParadise {
fn name(&self) -> &str {
"Radio Paradise"
}
fn id(&self) -> &str {
"radio-paradise"
}
fn default_image(&self) -> &[u8] {
RADIO_PARADISE_IMAGE
}
async fn root_container(&self) -> Result<Container> {
Ok(self.inner.playlist.as_container().await)
}
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
// For Radio Paradise, browsing the root returns all tracks in the FIFO
if object_id == "radio-paradise" || object_id == "0" {
let tracks = self.inner.playlist.get_items(0, 1000).await;
let items: Vec<Item> = tracks.iter().map(|t| self.track_to_item(t)).collect();
Ok(BrowseResult::Items(items))
} else {
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
}
}
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
let cache = self.inner.track_cache.read().await;
if let Some((original_uri, cached_pk)) = cache.get(object_id) {
// If cached, return the cached URI
if let Some(pk) = cached_pk {
Ok(format!("{}/audio/cache/{}", self.inner.cache_base_url, pk))
} else {
// Not cached yet, return original URI
Ok(original_uri.clone())
}
} else {
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
}
}
fn supports_fifo(&self) -> bool {
true
}
async fn append_track(&self, track: Item) -> Result<()> {
// Convert Item back to Track
let duration = track
.resources
.first()
.and_then(|r| r.duration.as_ref())
.and_then(|d| {
let parts: Vec<&str> = d.split(':').collect();
if parts.len() == 3 {
let h: u32 = parts[0].parse().ok()?;
let m: u32 = parts[1].parse().ok()?;
let s: u32 = parts[2].parse().ok()?;
Some(h * 3600 + m * 60 + s)
} else {
None
}
});
let uri = track
.resources
.first()
.map(|r| r.url.clone())
.unwrap_or_default();
let mut pmo_track = Track::new(track.id.clone(), track.title.clone(), uri.clone());
if let Some(artist) = track.artist {
pmo_track = pmo_track.with_artist(artist);
}
if let Some(album) = track.album {
pmo_track = pmo_track.with_album(album);
}
if let Some(dur) = duration {
pmo_track = pmo_track.with_duration(dur);
}
if let Some(img) = track.album_art {
pmo_track = pmo_track.with_image(img);
}
// Store in cache
{
let mut cache = self.inner.track_cache.write().await;
cache.insert(track.id.clone(), (uri, None));
}
self.inner.playlist.append_track(pmo_track).await;
Ok(())
}
async fn remove_oldest(&self) -> Result<Option<Item>> {
if let Some(track) = self.inner.playlist.remove_oldest().await {
// Remove from cache
{
let mut cache = self.inner.track_cache.write().await;
cache.remove(&track.id);
}
Ok(Some(self.track_to_item(&track)))
} else {
Ok(None)
}
}
async fn update_id(&self) -> u32 {
self.inner.playlist.update_id().await
}
async fn last_change(&self) -> Option<std::time::SystemTime> {
Some(self.inner.playlist.last_change().await)
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
let tracks = self.inner.playlist.get_items(offset, count).await;
Ok(tracks.iter().map(|t| self.track_to_item(t)).collect())
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("Radio Paradise Source Example");
println!("==============================\n");
// Create the source
let source = RadioParadise::new_default("http://localhost:8080");
println!("Source: {}", source.name());
println!("ID: {}", source.id());
println!("Supports FIFO: {}", source.supports_fifo());
println!("Default image size: {} bytes\n", source.default_image().len());
// Add some sample tracks
println!("Adding sample tracks...");
source
.add_track(
"rp-001".to_string(),
"Wish You Were Here".to_string(),
Some("Pink Floyd".to_string()),
Some("Wish You Were Here".to_string()),
"http://stream.radioparadise.com/rp-001.mp3".to_string(),
Some("http://img.radioparadise.com/covers/l/001.jpg".to_string()),
Some(334),
)
.await?;
source
.add_track(
"rp-002".to_string(),
"Bohemian Rhapsody".to_string(),
Some("Queen".to_string()),
Some("A Night at the Opera".to_string()),
"http://stream.radioparadise.com/rp-002.mp3".to_string(),
Some("http://img.radioparadise.com/covers/l/002.jpg".to_string()),
Some(354),
)
.await?;
source
.add_track(
"rp-003".to_string(),
"Hotel California".to_string(),
Some("Eagles".to_string()),
Some("Hotel California".to_string()),
"http://stream.radioparadise.com/rp-003.mp3".to_string(),
Some("http://img.radioparadise.com/covers/l/003.jpg".to_string()),
Some(391),
)
.await?;
println!("Added 3 tracks\n");
// Get root container
println!("Root Container:");
let root = source.root_container().await?;
println!(" ID: {}", root.id);
println!(" Title: {}", root.title);
println!(" Child Count: {:?}\n", root.child_count);
// Browse the source
println!("Browsing tracks:");
let result = source.browse("radio-paradise").await?;
for item in result.items() {
println!(
" - {} by {} ({})",
item.title,
item.artist.as_deref().unwrap_or("Unknown"),
item.album.as_deref().unwrap_or("Unknown Album")
);
}
println!();
// Resolve URIs
println!("Resolving URIs:");
for item in result.items() {
let uri = source.resolve_uri(&item.id).await?;
println!(" {}: {}", item.id, uri);
}
println!();
// Track changes
println!("Change Tracking:");
println!(" Update ID: {}", source.update_id().await);
println!(
" Last Change: {:?}\n",
source.last_change().await.unwrap()
);
// Simulate caching a track
println!("Simulating cache for rp-001...");
source.cache_track("rp-001", "cached-abc123".to_string()).await?;
let cached_uri = source.resolve_uri("rp-001").await?;
println!(" Cached URI: {}\n", cached_uri);
// Pagination
println!("Pagination (get items 1-2):");
let items = source.get_items(1, 2).await?;
for item in items {
println!(" - {}", item.title);
}
println!();
// Remove oldest track
println!("Removing oldest track...");
if let Some(removed) = source.remove_oldest().await? {
println!(" Removed: {}", removed.title);
}
println!(" New Update ID: {}\n", source.update_id().await);
// Browse again to see the change
println!("Browsing after removal:");
let result = source.browse("radio-paradise").await?;
println!(" Tracks remaining: {}", result.count());
for item in result.items() {
println!(" - {}", item.title);
}
Ok(())
}

View File

@@ -1,66 +0,0 @@
//! Example showing how to use the MusicSource trait
//!
//! This example demonstrates accessing source information and images
//! from different music sources (requires pmoparadise and pmoqobuz to be compiled).
use pmosource::{MusicSource, DEFAULT_IMAGE_SIZE};
// Mock implementations for demonstration
#[derive(Debug)]
struct RadioParadiseSource;
impl MusicSource for RadioParadiseSource {
fn name(&self) -> &str {
"Radio Paradise"
}
fn id(&self) -> &str {
"radio-paradise"
}
fn default_image(&self) -> &[u8] {
// This would normally be: include_bytes!("../../pmoparadise/assets/default.webp")
// For this example, we return an empty slice
&[]
}
}
#[derive(Debug)]
struct QobuzSource;
impl MusicSource for QobuzSource {
fn name(&self) -> &str {
"Qobuz"
}
fn id(&self) -> &str {
"qobuz"
}
fn default_image(&self) -> &[u8] {
// This would normally be: include_bytes!("../../pmoqobuz/assets/default.webp")
// For this example, we return an empty slice
&[]
}
}
fn main() {
println!("PMOMusic Sources\n");
println!("Standard image size: {}x{} pixels\n", DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_SIZE);
let sources: Vec<Box<dyn MusicSource>> = vec![
Box::new(RadioParadiseSource),
Box::new(QobuzSource),
];
for source in sources {
println!("Source: {}", source.name());
println!(" ID: {}", source.id());
println!(" Image MIME: {}", source.default_image_mime_type());
println!(" Image size: {} bytes", source.default_image().len());
println!();
}
println!("Note: In a real implementation, the images would be embedded in the binary");
println!(" and would be approximately 3-10 KB each in WebP format.");
}

View File

@@ -4,8 +4,22 @@
//!
//! 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.
//!
//! ## Usage
//!
//! See the [examples/radio_paradise.rs](../examples/radio_paradise.rs) for a complete implementation.
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;
@@ -21,19 +35,174 @@ pub enum MusicSourceError {
#[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),
}
/// Result type for music source operations
pub type Result<T> = std::result::Result<T, MusicSourceError>;
/// 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
/// - Other common operations (to be extended)
/// - 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: &str) -> 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
@@ -79,8 +248,212 @@ pub trait MusicSource: Debug + Send + Sync {
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>;
/// 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 results = source.search("Pink Floyd").await?;
/// for item in results.items() {
/// println!("Found: {}", item.title);
/// }
/// ```
async fn search(&self, query: &str) -> Result<BrowseResult> {
let _ = query;
Err(MusicSourceError::SearchNotSupported)
}
}
// Re-export commonly used types
pub use async_trait::async_trait;
pub use pmodidl;
pub use pmoplaylist;
#[cfg(test)]
mod tests {
use super::*;
@@ -88,6 +461,7 @@ mod tests {
#[derive(Debug)]
struct TestSource;
#[async_trait]
impl MusicSource for TestSource {
fn name(&self) -> &str {
"Test Source"
@@ -100,13 +474,112 @@ mod tests {
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()),
title: "Test Source".to_string(),
class: "object.container".to_string(),
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![])
}
}
#[test]
fn test_music_source_trait() {
#[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)));
}
}