Files
pmomusic/pmosource

pmosource - Music Source Abstraction for PMOMusic

Common traits and types for PMOMusic sources.

This crate provides the foundational abstractions for different music sources in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, local playlists, 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

Architecture

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

use pmosource::{async_trait, MusicSource, BrowseResult, Result};
use pmodidl::{Container, Item};
use pmoplaylist::FifoPlaylist;
use std::time::SystemTime;

#[derive(Debug)]
pub struct MyRadioSource {
    playlist: FifoPlaylist,
    // ... other fields
}

#[async_trait]
impl MusicSource for MyRadioSource {
    fn name(&self) -> &str {
        "My Radio"
    }

    fn id(&self) -> &str {
        "my-radio"
    }

    fn default_image(&self) -> &[u8] {
        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

use pmosource::MusicSource;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let source = MyRadioSource::new("http://localhost:8080");

    // 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(())
}

Trait Methods

Basic Information

  • 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")

ContentDirectory Navigation

  • 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)

FIFO Support (Dynamic Sources)

  • 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

Change Tracking

  • update_id() -> u32: Increments on each change (for UPnP notifications)
  • last_change() -> Option<SystemTime>: Timestamp of last modification
  • 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:

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

With pmoaudiocache

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
// 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))
    }
}

With pmocovers

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 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:

cargo run --example radio_paradise

Design Patterns

Static Sources (Albums, Local Playlists)

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)

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

MIT OR Apache-2.0