14 KiB
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
- Unified Interface: Single trait for all music source types
- UPnP/OpenHome Compatible: Support ContentDirectory browsing and DIDL-Lite
- Cache Integration: Seamless integration with
pmoaudiocacheandpmocovers - Change Tracking: Support for UPnP event notifications via
update_idandlast_change - FIFO Support: Dynamic sources (radios) can manage track queues
- Thread Safety: All sources must be
Send + Syncfor async servers - 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
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
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
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
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
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::FifoPlaylistinternally update_idchanges when tracks are added/removed- Limited capacity (e.g., last 50 tracks)
- Items have dynamic URIs that may change
Implementation Pattern:
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() = falseappend_track()returnsFifoNotSupportederrorupdate_idis constant (0)last_change()may be None- Items have stable URIs
Implementation Pattern:
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:
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:
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:
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:
// 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>:
pub enum MusicSourceError {
ImageLoadError(String),
InvalidImageFormat(String),
SourceUnavailable(String),
ObjectNotFound(String),
BrowseError(String),
SearchNotSupported,
FifoNotSupported,
CacheError(String),
UriResolutionError(String),
}
Guidelines:
- Use
ObjectNotFoundfor invalid object IDs - Use
BrowseErrorfor general browsing failures - Use
SearchNotSupportedfor sources without search - Use
FifoNotSupportedfor static sources - Use
CacheErrorfor cache-related issues
Thread Safety
All MusicSource implementations must be Send + Sync:
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::RwLockfor async operations - Avoid
Rc,RefCell, or other non-thread-safe types
Testing Strategy
Unit Tests
Test each method independently:
#[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:
#[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:
cargo run --example radio_paradise
Future Enhancements
Potential additions to the trait:
-
Authentication:
async fn authenticate(&mut self, credentials: Credentials) -> Result<()>; fn is_authenticated(&self) -> bool; -
Quality Levels:
fn available_qualities(&self) -> Vec<Quality>; async fn set_quality(&mut self, quality: Quality) -> Result<()>; -
Favorites/Bookmarks:
async fn add_favorite(&self, object_id: &str) -> Result<()>; async fn list_favorites(&self) -> Result<Vec<Item>>; -
Recommendations:
async fn get_recommendations(&self) -> Result<Vec<Item>>;
Design Decisions
Why async-trait?
- Native async traits don't support trait objects yet
async-traitprovides 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
FifoNotSupportedimmediately - 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
- Caching: Always check local caches before streaming
- Pagination: Use
get_items(offset, count)for large collections - Lazy Loading: Don't load all metadata upfront
- Arc Sharing: Use
Arc<dyn MusicSource>to avoid cloning - RwLock Usage: Prefer read locks when possible
Versioning
The crate follows Semantic Versioning:
- MAJOR: Breaking changes to
MusicSourcetrait - MINOR: New trait methods (with default implementations)
- PATCH: Bug fixes, documentation, internal changes
Current version: 0.2.0