Crée la crate pmoplaylist
This commit is contained in:
460
pmosource/ARCHITECTURE.md
Normal file
460
pmosource/ARCHITECTURE.md
Normal 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
69
pmosource/CHANGELOG.md
Normal 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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
BIN
pmosource/assets/radio-paradise.webp
Normal file
BIN
pmosource/assets/radio-paradise.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
270
pmosource/examples/README.md
Normal file
270
pmosource/examples/README.md
Normal 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
|
||||
466
pmosource/examples/radio_paradise.rs
Normal file
466
pmosource/examples/radio_paradise.rs
Normal 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(())
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user