implemente pmoparadise

This commit is contained in:
2025-10-12 19:52:41 +02:00
parent b8154a4837
commit 664be97ea6
28 changed files with 5266 additions and 5 deletions

386
pmoparadise/src/client.rs Normal file
View File

@@ -0,0 +1,386 @@
//! HTTP client for Radio Paradise API
use crate::error::{Error, Result};
use crate::models::{Bitrate, Block, EventId, NowPlaying};
use reqwest::Client;
use std::time::Duration;
use url::Url;
/// Default Radio Paradise API base URL
pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api";
/// Default block base URL pattern
pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan/0";
/// Default image base URL
pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/covers/l/";
/// Default timeout for HTTP requests
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// Default User-Agent
pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
/// Radio Paradise HTTP client
///
/// This client provides access to Radio Paradise's streaming API,
/// including metadata retrieval and block streaming.
///
/// # Example
///
/// ```no_run
/// use pmoparadise::RadioParadiseClient;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let now_playing = client.now_playing().await?;
/// println!("Now playing: {} - {}",
/// now_playing.current_song.as_ref().unwrap().artist,
/// now_playing.current_song.as_ref().unwrap().title);
/// Ok(())
/// }
/// ```
#[derive(Debug, Clone)]
pub struct RadioParadiseClient {
pub(crate) client: Client,
api_base: String,
block_base: String,
image_base: String,
bitrate: Bitrate,
channel: u8,
pub(crate) timeout: Duration,
next_block_url: Option<String>,
}
impl RadioParadiseClient {
/// Create a new client with default settings
///
/// Uses FLAC quality (bitrate 4) and channel 0 (main mix)
pub async fn new() -> Result<Self> {
Self::builder().build().await
}
/// Create a builder for configuring the client
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
/// Create a client with a custom reqwest::Client
///
/// Useful for sharing HTTP connection pools or custom proxy settings
pub fn with_client(client: Client) -> Self {
Self {
client,
api_base: DEFAULT_API_BASE.to_string(),
block_base: DEFAULT_BLOCK_BASE.to_string(),
image_base: DEFAULT_IMAGE_BASE.to_string(),
bitrate: Bitrate::default(),
channel: 0,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
next_block_url: None,
}
}
/// Get the current bitrate setting
pub fn bitrate(&self) -> Bitrate {
self.bitrate
}
/// Get the current channel (0 = main mix)
pub fn channel(&self) -> u8 {
self.channel
}
/// Get a block by event ID
///
/// If `event` is None, returns the current block.
///
/// # Arguments
///
/// * `event` - Optional event ID to fetch a specific block
///
/// # Example
///
/// ```no_run
/// # use pmoparadise::RadioParadiseClient;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
///
/// // Get current block
/// let current = client.get_block(None).await?;
/// println!("Current block: {} songs", current.song_count());
///
/// // Get next block
/// let next = client.get_block(Some(current.end_event)).await?;
/// println!("Next block: {} songs", next.song_count());
/// # Ok(())
/// # }
/// ```
pub async fn get_block(&self, event: Option<EventId>) -> Result<Block> {
let mut url = Url::parse(&format!("{}/get_block", self.api_base))?;
url.query_pairs_mut()
.append_pair("bitrate", &self.bitrate.as_u8().to_string())
.append_pair("info", "true");
if let Some(event_id) = event {
url.query_pairs_mut()
.append_pair("event", &event_id.to_string());
}
#[cfg(feature = "logging")]
tracing::debug!("Fetching block: {}", url);
let response = self.client
.get(url)
.timeout(self.timeout)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::other(format!(
"API returned error status: {}",
response.status()
)));
}
let mut block: Block = response.json().await?;
// Set image_base if not provided
if block.image_base.is_none() {
block.image_base = Some(self.image_base.clone());
}
#[cfg(feature = "logging")]
tracing::debug!(
"Received block: event={}, songs={}",
block.event,
block.song_count()
);
Ok(block)
}
/// Get the currently playing block and song
///
/// Returns a `NowPlaying` struct with the current block and
/// an estimate of which song is currently playing (first song).
///
/// Note: Without real-time synchronization, we assume playback
/// starts from the beginning of the block.
pub async fn now_playing(&self) -> Result<NowPlaying> {
let block = self.get_block(None).await?;
Ok(NowPlaying::from_block(block))
}
/// Get the full URL for a cover image
///
/// # Arguments
///
/// * `cover_path` - The cover filename/path from song metadata
///
/// # Example
///
/// ```no_run
/// # use pmoparadise::RadioParadiseClient;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let url = client.cover_url("B00000I0JF.jpg")?;
/// println!("Cover URL: {}", url);
/// # Ok(())
/// # }
/// ```
pub fn cover_url(&self, cover_path: &str) -> Result<Url> {
let url_str = format!("{}{}", self.image_base, cover_path);
Ok(Url::parse(&url_str)?)
}
/// Prefetch metadata for the next block
///
/// Stores the next block URL internally for seamless transitions.
/// Call this before the current block finishes playing.
///
/// # Arguments
///
/// * `current` - The currently playing block
pub async fn prefetch_next(&mut self, current: &Block) -> Result<()> {
let next_block = self.get_block(Some(current.end_event)).await?;
self.next_block_url = Some(next_block.url.clone());
#[cfg(feature = "logging")]
tracing::debug!(
"Prefetched next block: {} -> {}",
current.end_event,
next_block.event
);
Ok(())
}
/// Get the prefetched next block URL
pub fn next_block_url(&self) -> Option<&str> {
self.next_block_url.as_deref()
}
/// Clear the prefetched next block URL
pub fn clear_next_block(&mut self) {
self.next_block_url = None;
}
/// Get the internal HTTP client
pub fn http_client(&self) -> &Client {
&self.client
}
}
/// Builder for configuring a RadioParadiseClient
#[derive(Debug)]
pub struct ClientBuilder {
client: Option<Client>,
api_base: String,
block_base: String,
image_base: String,
bitrate: Bitrate,
channel: u8,
timeout: Duration,
user_agent: String,
proxy: Option<String>,
}
impl Default for ClientBuilder {
fn default() -> Self {
Self {
client: None,
api_base: DEFAULT_API_BASE.to_string(),
block_base: DEFAULT_BLOCK_BASE.to_string(),
image_base: DEFAULT_IMAGE_BASE.to_string(),
bitrate: Bitrate::default(),
channel: 0,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
user_agent: DEFAULT_USER_AGENT.to_string(),
proxy: None,
}
}
}
impl ClientBuilder {
/// Create a new builder with default settings
pub fn new() -> Self {
Self::default()
}
/// Set a custom HTTP client
pub fn client(mut self, client: Client) -> Self {
self.client = Some(client);
self
}
/// Set the API base URL
pub fn api_base(mut self, url: impl Into<String>) -> Self {
self.api_base = url.into();
self
}
/// Set the block base URL
pub fn block_base(mut self, url: impl Into<String>) -> Self {
self.block_base = url.into();
self
}
/// Set the image base URL
pub fn image_base(mut self, url: impl Into<String>) -> Self {
self.image_base = url.into();
self
}
/// Set the bitrate/quality level
///
/// # Example
///
/// ```
/// # use pmoparadise::{RadioParadiseClient, Bitrate};
/// let builder = RadioParadiseClient::builder()
/// .bitrate(Bitrate::Aac320);
/// ```
pub fn bitrate(mut self, bitrate: Bitrate) -> Self {
self.bitrate = bitrate;
self
}
/// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc)
pub fn channel(mut self, channel: u8) -> Self {
self.channel = channel;
self
}
/// Set the request timeout
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Set a custom User-Agent header
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
/// Set a proxy URL
pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
self.proxy = Some(proxy.into());
self
}
/// Build the client
pub async fn build(self) -> Result<RadioParadiseClient> {
let client = if let Some(client) = self.client {
client
} else {
let mut builder = Client::builder()
.user_agent(&self.user_agent)
.timeout(self.timeout);
if let Some(proxy_url) = &self.proxy {
let proxy = reqwest::Proxy::all(proxy_url)
.map_err(|e| Error::other(format!("Invalid proxy: {}", e)))?;
builder = builder.proxy(proxy);
}
builder.build()?
};
Ok(RadioParadiseClient {
client,
api_base: self.api_base,
block_base: self.block_base,
image_base: self.image_base,
bitrate: self.bitrate,
channel: self.channel,
timeout: self.timeout,
next_block_url: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_defaults() {
let builder = ClientBuilder::default();
assert_eq!(builder.api_base, DEFAULT_API_BASE);
assert_eq!(builder.bitrate, Bitrate::Flac);
assert_eq!(builder.channel, 0);
}
#[test]
fn test_cover_url() {
let client = RadioParadiseClient::with_client(Client::new());
let url = client.cover_url("test.jpg").unwrap();
assert_eq!(url.as_str(), "https://img.radioparadise.com/covers/l/test.jpg");
}
}

77
pmoparadise/src/error.rs Normal file
View File

@@ -0,0 +1,77 @@
//! Error types for the Radio Paradise client
/// Result type alias for Radio Paradise operations
pub type Result<T> = std::result::Result<T, Error>;
/// Errors that can occur when using the Radio Paradise client
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// HTTP request failed
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
/// JSON parsing failed
#[error("JSON parsing failed: {0}")]
Json(#[from] serde_json::Error),
/// Invalid URL
#[error("Invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
/// IO error
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
/// Invalid track index
#[error("Invalid track index: {0} (block has {1} tracks)")]
InvalidIndex(usize, usize),
/// Invalid bitrate
#[error("Invalid bitrate value: {0} (must be 0-4)")]
InvalidBitrate(u8),
/// Invalid event ID
#[error("Invalid event ID: {0}")]
InvalidEvent(String),
/// FLAC decoding error (per-track feature)
#[cfg(feature = "per-track")]
#[error("FLAC decoding error: {0}")]
FlacDecode(String),
/// WAV encoding error (per-track feature)
#[cfg(feature = "per-track")]
#[error("WAV encoding error: {0}")]
WavEncode(#[from] hound::Error),
/// Track not found in block
#[error("Track not found at index {0}")]
TrackNotFound(usize),
/// Invalid elapsed time
#[error("Invalid elapsed time: {0}ms (exceeds block length)")]
InvalidElapsed(u64),
/// Timeout error
#[error("Request timeout")]
Timeout,
/// Generic error
#[error("{0}")]
Other(String),
}
impl Error {
/// Create a generic error from a string
pub fn other(msg: impl Into<String>) -> Self {
Self::Other(msg.into())
}
}
// Implement conversion from claxon errors for per-track feature
#[cfg(feature = "per-track")]
impl From<claxon::Error> for Error {
fn from(err: claxon::Error) -> Self {
Error::FlacDecode(err.to_string())
}
}

229
pmoparadise/src/lib.rs Normal file
View File

@@ -0,0 +1,229 @@
//! # pmoparadise - Radio Paradise Client for Rust
//!
//! `pmoparadise` is an idiomatic Rust client library for accessing Radio Paradise's
//! streaming API. It provides metadata retrieval, block streaming, and optional
//! per-track extraction from FLAC blocks.
//!
//! ## Features
//!
//! - **Metadata Access**: Get current and historical block metadata with song information
//! - **Block Streaming**: Stream continuous FLAC/AAC blocks with automatic prefetching
//! - **Multiple Quality Levels**: Support for MP3, AAC (64/128/320 kbps), and FLAC
//! - **Per-Track Extraction** (optional): Extract individual tracks from FLAC blocks
//! - **Async/Await**: Built on tokio for efficient async I/O
//! - **Type-Safe**: Strongly typed API with comprehensive error handling
//!
//! ## Quick Start
//!
//! ```no_run
//! use pmoparadise::RadioParadiseClient;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! // Create a client
//! let client = RadioParadiseClient::new().await?;
//!
//! // Get what's currently playing
//! let now_playing = client.now_playing().await?;
//!
//! if let Some(song) = &now_playing.current_song {
//! println!("Now Playing: {} - {}", song.artist, song.title);
//! println!("Album: {}", song.album);
//! }
//!
//! // Get all songs in the current block
//! for (index, song) in now_playing.block.songs_ordered() {
//! println!(" {}. {} - {} ({}s)",
//! index,
//! song.artist,
//! song.title,
//! song.duration / 1000);
//! }
//!
//! Ok(())
//! }
//! ```
//!
//! ## Streaming Blocks
//!
//! Radio Paradise broadcasts music in continuous "blocks" - each block is a single
//! FLAC or AAC file containing multiple songs with metadata indicating timing offsets.
//!
//! ```no_run
//! use pmoparadise::RadioParadiseClient;
//! use futures::StreamExt;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = RadioParadiseClient::new().await?;
//! let block = client.get_block(None).await?;
//!
//! // Stream the block
//! let mut stream = client.stream_block_from_metadata(&block).await?;
//!
//! while let Some(chunk) = stream.next().await {
//! let bytes = chunk?;
//! // Feed to audio player, write to file, etc.
//! }
//!
//! Ok(())
//! }
//! ```
//!
//! ## Quality Levels
//!
//! Radio Paradise offers multiple quality levels via the [`Bitrate`] enum:
//!
//! ```no_run
//! use pmoparadise::{RadioParadiseClient, Bitrate};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = RadioParadiseClient::builder()
//! .bitrate(Bitrate::Aac320)
//! .build()
//! .await?;
//!
//! Ok(())
//! }
//! ```
//!
//! ## Per-Track Extraction (Feature: `per-track`)
//!
//! **Important**: This is an advanced feature with significant tradeoffs.
//! See the [`track`] module documentation for details.
//!
//! Most applications should stream blocks and use player-based seeking instead.
//!
//! ```no_run
//! # #[cfg(feature = "per-track")]
//! # {
//! use pmoparadise::RadioParadiseClient;
//! use std::path::Path;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let client = RadioParadiseClient::new().await?;
//! let block = client.get_block(None).await?;
//!
//! // Extract first track to WAV
//! let mut track = client.open_track_stream(&block, 0).await?;
//! track.export_wav(Path::new("track.wav"))?;
//!
//! // Or get position for player-based seeking (recommended)
//! let (start, duration) = client.track_position_seconds(&block, 0)?;
//! println!("Play with: mpv --start={} --length={} {}", start, duration, block.url);
//!
//! Ok(())
//! }
//! # }
//! ```
//!
//! ## Architecture
//!
//! The API is organized into several modules:
//!
//! - [`client`]: Main HTTP client for API access
//! - [`models`]: Data structures for blocks, songs, and metadata
//! - [`stream`]: Block streaming functionality
//! - [`track`]: Per-track extraction (feature-gated)
//! - [`error`]: Error types and result aliases
//!
//! ## Radio Paradise Block Format
//!
//! Radio Paradise streams use a block-based format:
//!
//! - Each block is a single audio file (FLAC or AAC)
//! - Blocks contain multiple songs (typically 10-15 minutes total)
//! - Metadata includes timing offsets (`song[i].elapsed` in ms) for each song
//! - Block URLs follow the pattern: `https://apps.radioparadise.com/blocks/chan/0/4/<start>-<end>.flac`
//! - The `end_event` of one block is the `event` of the next, enabling seamless transitions
//!
//! ## Best Practices
//!
//! ### For Continuous Playback
//!
//! 1. Get current block with `get_block(None)`
//! 2. Stream block with `stream_block_from_metadata()`
//! 3. Use `prefetch_next()` to prepare the next block
//! 4. When current block ends, stream the next block seamlessly
//!
//! ### For Per-Song Seeking
//!
//! **Recommended approach** (efficient):
//! ```bash
//! # Use your audio player's seek capability
//! mpv --start=123.5 --length=234.0 <block_url>
//! ```
//!
//! **Alternative** (resource-intensive, requires `per-track` feature):
//! - Download and decode block
//! - Extract specific track to PCM/WAV
//!
//! ## Error Handling
//!
//! All operations return `Result<T, Error>` with detailed error types:
//!
//! ```no_run
//! use pmoparadise::{RadioParadiseClient, Error};
//!
//! #[tokio::main]
//! async fn main() {
//! let client = RadioParadiseClient::new().await.unwrap();
//!
//! match client.get_block(Some(99999999)).await {
//! Ok(block) => println!("Got block: {}", block.event),
//! Err(Error::Http(e)) => eprintln!("Network error: {}", e),
//! Err(Error::Json(e)) => eprintln!("Parse error: {}", e),
//! Err(e) => eprintln!("Other error: {}", e),
//! }
//! }
//! ```
//!
//! ## Cargo Features
//!
//! - `default = ["metadata-only"]`: Standard metadata and streaming (no FLAC decoding)
//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`)
//! - `logging`: Enable tracing logs for debugging
//! - `mediaserver`: Enable UPnP/DLNA Media Server (adds `pmoupnp`, `pmoserver`, `pmodidl`)
//!
//! ## See Also
//!
//! - [Radio Paradise](https://radioparadise.com) - Official website
//! - [Radio Paradise API](https://api.radioparadise.com) - API documentation
pub mod client;
pub mod error;
pub mod models;
pub mod stream;
#[cfg(feature = "per-track")]
pub mod track;
#[cfg(feature = "mediaserver")]
pub mod mediaserver;
// Re-exports for convenience
pub use client::{ClientBuilder, RadioParadiseClient};
pub use error::{Error, Result};
pub use models::{Bitrate, Block, DurationMs, EventId, NowPlaying, Song};
pub use stream::BlockStream;
#[cfg(feature = "per-track")]
pub use track::{TrackMetadata, TrackStream};
#[cfg(feature = "mediaserver")]
pub use mediaserver::{RadioParadiseMediaServer, MediaServerBuilder};
// Version information
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
assert!(!VERSION.is_empty());
}
}

View File

@@ -0,0 +1,167 @@
//! ConnectionManager service implementation
use pmoupnp::services::Service;
use pmoupnp::actions::Action;
use pmoupnp::state_variables::StateVariable;
use std::sync::Arc;
/// Create a ConnectionManager service
///
/// The ConnectionManager service provides information about supported
/// protocols and connections.
pub fn create_connection_manager_service() -> Service {
let mut service = Service::new("ConnectionManager".to_string());
service.set_service_type("urn:schemas-upnp-org:service:ConnectionManager:1".to_string());
service.set_service_id("urn:upnp-org:serviceId:ConnectionManager".to_string());
// State variables
let source_protocol_info = StateVariable::new(
"SourceProtocolInfo".to_string(),
"string".to_string(),
).with_send_events(true)
.with_default_value(get_protocol_info());
let sink_protocol_info = StateVariable::new(
"SinkProtocolInfo".to_string(),
"string".to_string(),
).with_send_events(true)
.with_default_value("".to_string());
let current_connection_ids = StateVariable::new(
"CurrentConnectionIDs".to_string(),
"string".to_string(),
).with_send_events(true)
.with_default_value("0".to_string());
service.add_state_variable(Arc::new(source_protocol_info));
service.add_state_variable(Arc::new(sink_protocol_info));
service.add_state_variable(Arc::new(current_connection_ids));
// GetProtocolInfo action
let mut get_protocol_info = Action::new("GetProtocolInfo".to_string());
get_protocol_info.add_output_argument(
"Source".to_string(),
"SourceProtocolInfo".to_string(),
);
get_protocol_info.add_output_argument(
"Sink".to_string(),
"SinkProtocolInfo".to_string(),
);
service.add_action(Arc::new(get_protocol_info));
// GetCurrentConnectionIDs action
let mut get_connection_ids = Action::new("GetCurrentConnectionIDs".to_string());
get_connection_ids.add_output_argument(
"ConnectionIDs".to_string(),
"CurrentConnectionIDs".to_string(),
);
service.add_action(Arc::new(get_connection_ids));
// GetCurrentConnectionInfo action
let mut get_connection_info = Action::new("GetCurrentConnectionInfo".to_string());
get_connection_info.add_input_argument(
"ConnectionID".to_string(),
"A_ARG_TYPE_ConnectionID".to_string(),
);
get_connection_info.add_output_argument(
"RcsID".to_string(),
"A_ARG_TYPE_RcsID".to_string(),
);
get_connection_info.add_output_argument(
"AVTransportID".to_string(),
"A_ARG_TYPE_AVTransportID".to_string(),
);
get_connection_info.add_output_argument(
"ProtocolInfo".to_string(),
"A_ARG_TYPE_ProtocolInfo".to_string(),
);
get_connection_info.add_output_argument(
"PeerConnectionManager".to_string(),
"A_ARG_TYPE_ConnectionManager".to_string(),
);
get_connection_info.add_output_argument(
"PeerConnectionID".to_string(),
"A_ARG_TYPE_ConnectionID".to_string(),
);
get_connection_info.add_output_argument(
"Direction".to_string(),
"A_ARG_TYPE_Direction".to_string(),
);
get_connection_info.add_output_argument(
"Status".to_string(),
"A_ARG_TYPE_ConnectionStatus".to_string(),
);
service.add_action(Arc::new(get_connection_info));
// Additional state variables for arguments
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ConnectionID".to_string(), "i4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_RcsID".to_string(), "i4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_AVTransportID".to_string(), "i4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ProtocolInfo".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ConnectionManager".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Direction".to_string(), "string".to_string())
.with_allowed_values(vec!["Input".to_string(), "Output".to_string()])
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ConnectionStatus".to_string(), "string".to_string())
.with_allowed_values(vec![
"OK".to_string(),
"ContentFormatMismatch".to_string(),
"InsufficientBandwidth".to_string(),
"UnreliableChannel".to_string(),
"Unknown".to_string(),
])
));
service
}
/// Get the protocol info string
///
/// Lists all supported protocols for Radio Paradise streaming.
fn get_protocol_info() -> String {
vec![
// HTTP FLAC
"http-get:*:audio/flac:*",
"http-get:*:audio/x-flac:*",
// HTTP AAC
"http-get:*:audio/aac:*",
"http-get:*:audio/aacp:*",
"http-get:*:audio/x-aac:*",
// HTTP MP3
"http-get:*:audio/mpeg:*",
"http-get:*:audio/mp3:*",
"http-get:*:audio/x-mp3:*",
].join(",")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_connection_manager() {
let service = create_connection_manager_service();
assert_eq!(service.service_type(), "urn:schemas-upnp-org:service:ConnectionManager:1");
assert_eq!(service.service_id(), "urn:upnp-org:serviceId:ConnectionManager");
}
#[test]
fn test_protocol_info() {
let info = get_protocol_info();
assert!(info.contains("audio/flac"));
assert!(info.contains("audio/aac"));
assert!(info.contains("audio/mpeg"));
}
}

View File

@@ -0,0 +1,330 @@
//! ContentDirectory service implementation
use crate::RadioParadiseClient;
use pmoupnp::services::Service;
use pmoupnp::actions::Action;
use pmoupnp::state_variables::StateVariable;
use pmodidl::{DIDLObject, DIDLContainer, DIDLItem, Resource};
use std::sync::Arc;
use tokio::sync::RwLock;
/// Create a ContentDirectory service for Radio Paradise
///
/// The ContentDirectory service allows browsing Radio Paradise blocks and songs.
pub fn create_content_directory_service(
client: Arc<RwLock<RadioParadiseClient>>,
) -> Service {
let mut service = Service::new("ContentDirectory".to_string());
service.set_service_type("urn:schemas-upnp-org:service:ContentDirectory:1".to_string());
service.set_service_id("urn:upnp-org:serviceId:ContentDirectory".to_string());
// State variables
let system_update_id = StateVariable::new(
"SystemUpdateID".to_string(),
"ui4".to_string(),
).with_send_events(true)
.with_default_value("0".to_string());
let container_update_ids = StateVariable::new(
"ContainerUpdateIDs".to_string(),
"string".to_string(),
).with_send_events(true)
.with_default_value("".to_string());
service.add_state_variable(Arc::new(system_update_id));
service.add_state_variable(Arc::new(container_update_ids));
// Browse action
let mut browse = Action::new("Browse".to_string());
browse.add_input_argument("ObjectID".to_string(), "A_ARG_TYPE_ObjectID".to_string());
browse.add_input_argument("BrowseFlag".to_string(), "A_ARG_TYPE_BrowseFlag".to_string());
browse.add_input_argument("Filter".to_string(), "A_ARG_TYPE_Filter".to_string());
browse.add_input_argument("StartingIndex".to_string(), "A_ARG_TYPE_Index".to_string());
browse.add_input_argument("RequestedCount".to_string(), "A_ARG_TYPE_Count".to_string());
browse.add_input_argument("SortCriteria".to_string(), "A_ARG_TYPE_SortCriteria".to_string());
browse.add_output_argument("Result".to_string(), "A_ARG_TYPE_Result".to_string());
browse.add_output_argument("NumberReturned".to_string(), "A_ARG_TYPE_Count".to_string());
browse.add_output_argument("TotalMatches".to_string(), "A_ARG_TYPE_Count".to_string());
browse.add_output_argument("UpdateID".to_string(), "A_ARG_TYPE_UpdateID".to_string());
// Store client reference for the action handler
let client_clone = client.clone();
browse.set_handler(Box::new(move |args| {
let client = client_clone.clone();
Box::pin(async move {
handle_browse(client, args).await
})
}));
service.add_action(Arc::new(browse));
// GetSearchCapabilities action
let mut get_search_caps = Action::new("GetSearchCapabilities".to_string());
get_search_caps.add_output_argument(
"SearchCaps".to_string(),
"A_ARG_TYPE_SearchCaps".to_string(),
);
get_search_caps.set_handler(Box::new(|_| {
Box::pin(async {
let mut result = std::collections::HashMap::new();
result.insert("SearchCaps".to_string(), "".to_string());
Ok(result)
})
}));
service.add_action(Arc::new(get_search_caps));
// GetSortCapabilities action
let mut get_sort_caps = Action::new("GetSortCapabilities".to_string());
get_sort_caps.add_output_argument(
"SortCaps".to_string(),
"A_ARG_TYPE_SortCaps".to_string(),
);
get_sort_caps.set_handler(Box::new(|_| {
Box::pin(async {
let mut result = std::collections::HashMap::new();
result.insert("SortCaps".to_string(), "dc:title".to_string());
Ok(result)
})
}));
service.add_action(Arc::new(get_sort_caps));
// GetSystemUpdateID action
let mut get_update_id = Action::new("GetSystemUpdateID".to_string());
get_update_id.add_output_argument("Id".to_string(), "SystemUpdateID".to_string());
get_update_id.set_handler(Box::new(|_| {
Box::pin(async {
let mut result = std::collections::HashMap::new();
result.insert("Id".to_string(), "0".to_string());
Ok(result)
})
}));
service.add_action(Arc::new(get_update_id));
// Argument state variables
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_ObjectID".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_BrowseFlag".to_string(), "string".to_string())
.with_allowed_values(vec![
"BrowseMetadata".to_string(),
"BrowseDirectChildren".to_string(),
])
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Filter".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Index".to_string(), "ui4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Count".to_string(), "ui4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_SortCriteria".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_Result".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_UpdateID".to_string(), "ui4".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_SearchCaps".to_string(), "string".to_string())
));
service.add_state_variable(Arc::new(
StateVariable::new("A_ARG_TYPE_SortCaps".to_string(), "string".to_string())
));
service
}
/// Handle Browse action
async fn handle_browse(
client: Arc<RwLock<RadioParadiseClient>>,
args: std::collections::HashMap<String, String>,
) -> Result<std::collections::HashMap<String, String>, String> {
let object_id = args.get("ObjectID").ok_or("Missing ObjectID")?;
let browse_flag = args.get("BrowseFlag").ok_or("Missing BrowseFlag")?;
let starting_index: usize = args.get("StartingIndex")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let requested_count: usize = args.get("RequestedCount")
.and_then(|s| s.parse().ok())
.unwrap_or(100);
let client = client.read().await;
let (didl_result, number_returned, total_matches) = match object_id.as_str() {
"0" => {
// Root container - show current block
if browse_flag == "BrowseMetadata" {
let root = create_root_container();
(serialize_didl(&[root]), 1, 1)
} else {
// BrowseDirectChildren - show current block as a container
let block = client.get_block(None).await
.map_err(|e| format!("Failed to get block: {}", e))?;
let block_container = create_block_container(&block);
(serialize_didl(&[block_container]), 1, 1)
}
}
id if id.starts_with("block:") => {
// Browse songs in a block
let event_id: u64 = id.strip_prefix("block:")
.and_then(|s| s.parse().ok())
.ok_or("Invalid block ID")?;
let block = client.get_block(Some(event_id)).await
.map_err(|e| format!("Failed to get block: {}", e))?;
if browse_flag == "BrowseMetadata" {
let container = create_block_container(&block);
(serialize_didl(&[container]), 1, 1)
} else {
// BrowseDirectChildren - show songs
let songs = block.songs_ordered();
let total = songs.len();
let songs_slice = songs.iter()
.skip(starting_index)
.take(requested_count)
.collect::<Vec<_>>();
let items: Vec<DIDLObject> = songs_slice.iter()
.map(|(idx, song)| create_song_item(&block, *idx, song))
.collect();
(serialize_didl(&items), items.len(), total)
}
}
_ => {
return Err(format!("Unknown ObjectID: {}", object_id));
}
};
let mut result = std::collections::HashMap::new();
result.insert("Result".to_string(), didl_result);
result.insert("NumberReturned".to_string(), number_returned.to_string());
result.insert("TotalMatches".to_string(), total_matches.to_string());
result.insert("UpdateID".to_string(), "0".to_string());
Ok(result)
}
/// Create the root container
fn create_root_container() -> DIDLObject {
let mut container = DIDLContainer::new("0".to_string(), "-1".to_string());
container.set_title("Radio Paradise".to_string());
container.set_class("object.container.storageFolder".to_string());
container.set_searchable(false);
container.set_child_count(Some(1));
DIDLObject::Container(container)
}
/// Create a container for a block
fn create_block_container(block: &crate::models::Block) -> DIDLObject {
let mut container = DIDLContainer::new(
format!("block:{}", block.event),
"0".to_string(),
);
container.set_title(format!("Block {} ({} songs)", block.event, block.song_count()));
container.set_class("object.container.album.musicAlbum".to_string());
container.set_searchable(false);
container.set_child_count(Some(block.song_count()));
// Add album art if available
if let Some(first_song) = block.get_song(0) {
if let Some(cover) = &first_song.cover {
if let Some(cover_url) = block.cover_url(cover) {
container.add_album_art_uri(cover_url);
}
}
}
DIDLObject::Container(container)
}
/// Create an item for a song
fn create_song_item(
block: &crate::models::Block,
index: usize,
song: &crate::models::Song,
) -> DIDLObject {
let mut item = DIDLItem::new(
format!("block:{}:song:{}", block.event, index),
format!("block:{}", block.event),
);
item.set_title(song.title.clone());
item.set_class("object.item.audioItem.musicTrack".to_string());
// Add metadata
item.add_artist(song.artist.clone());
item.add_album(song.album.clone());
if let Some(year) = song.year {
item.set_date(format!("{}-01-01", year));
}
// Add album art
if let Some(cover) = &song.cover {
if let Some(cover_url) = block.cover_url(cover) {
item.add_album_art_uri(cover_url);
}
}
// Add resource for streaming
let mut resource = Resource::new(block.url.clone());
resource.set_protocol_info("http-get:*:audio/flac:*".to_string());
resource.set_duration(format_duration(song.duration));
resource.set_size(None); // Unknown size
item.add_resource(resource);
DIDLObject::Item(item)
}
/// Format duration in H:MM:SS format
fn format_duration(duration_ms: u64) -> String {
let total_seconds = duration_ms / 1000;
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!("{}:{:02}:{:02}", hours, minutes, seconds)
}
/// Serialize DIDL objects to XML string
fn serialize_didl(objects: &[DIDLObject]) -> String {
let mut didl = String::from(r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#);
for obj in objects {
didl.push_str(&obj.to_didl());
}
didl.push_str("</DIDL-Lite>");
didl
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_duration() {
assert_eq!(format_duration(0), "0:00:00");
assert_eq!(format_duration(60000), "0:01:00");
assert_eq!(format_duration(3661000), "1:01:01");
}
#[test]
fn test_create_root_container() {
let root = create_root_container();
if let DIDLObject::Container(container) = root {
assert_eq!(container.id(), "0");
assert_eq!(container.parent_id(), "-1");
} else {
panic!("Expected Container");
}
}
}

View File

@@ -0,0 +1,58 @@
//! UPnP Media Server for Radio Paradise
//!
//! This module provides a UPnP/DLNA Media Server implementation that exposes
//! Radio Paradise blocks and songs as a browsable media library.
//!
//! # Features
//!
//! - ContentDirectory service for browsing blocks and songs
//! - ConnectionManager service for protocol info
//! - DIDL-Lite metadata for songs
//! - Support for multiple quality levels
//! - Live streaming URLs
//!
//! # Architecture
//!
//! ```text
//! RadioParadiseMediaServer
//! └── Device (urn:schemas-upnp-org:device:MediaServer:1)
//! ├── ContentDirectory service
//! │ ├── Browse action
//! │ ├── Search action (optional)
//! │ └── GetSearchCapabilities
//! └── ConnectionManager service
//! ├── GetProtocolInfo
//! └── GetCurrentConnectionIDs
//! ```
//!
//! # Example
//!
//! ```no_run
//! # #[cfg(feature = "mediaserver")]
//! # {
//! use pmoparadise::mediaserver::RadioParadiseMediaServer;
//! use pmoparadise::Bitrate;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let server = RadioParadiseMediaServer::new()
//! .with_bitrate(Bitrate::Flac)
//! .with_friendly_name("Radio Paradise FLAC")
//! .build()
//! .await?;
//!
//! server.run().await?;
//! Ok(())
//! }
//! # }
//! ```
#[cfg(feature = "mediaserver")]
mod server;
#[cfg(feature = "mediaserver")]
mod content_directory;
#[cfg(feature = "mediaserver")]
mod connection_manager;
#[cfg(feature = "mediaserver")]
pub use server::{RadioParadiseMediaServer, MediaServerBuilder};

View File

@@ -0,0 +1,197 @@
//! Radio Paradise UPnP Media Server implementation
use crate::error::{Error, Result};
use crate::models::Bitrate;
use crate::RadioParadiseClient;
use pmoupnp::devices::Device;
use pmoupnp::{UpnpServer};
use pmoserver::Server;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Radio Paradise UPnP Media Server
///
/// Exposes Radio Paradise blocks and songs as a browsable UPnP media library.
pub struct RadioParadiseMediaServer {
server: Server,
client: Arc<RwLock<RadioParadiseClient>>,
device_udn: String,
}
impl RadioParadiseMediaServer {
/// Create a new builder for the media server
pub fn builder() -> MediaServerBuilder {
MediaServerBuilder::default()
}
/// Create a new media server with default settings
pub async fn new() -> Result<Self> {
Self::builder().build().await
}
/// Run the media server
///
/// This will start the HTTP server and SSDP announcements.
pub async fn run(self) -> Result<()> {
self.server.run().await
.map_err(|e| Error::other(format!("Server error: {}", e)))
}
/// Get the device UDN
pub fn udn(&self) -> &str {
&self.device_udn
}
/// Get the Radio Paradise client
pub fn client(&self) -> Arc<RwLock<RadioParadiseClient>> {
self.client.clone()
}
}
/// Builder for RadioParadiseMediaServer
pub struct MediaServerBuilder {
friendly_name: String,
manufacturer: String,
model_name: String,
bitrate: Bitrate,
channel: u8,
port: u16,
}
impl Default for MediaServerBuilder {
fn default() -> Self {
Self {
friendly_name: "Radio Paradise Media Server".to_string(),
manufacturer: "PMOMusic".to_string(),
model_name: "Radio Paradise Adapter".to_string(),
bitrate: Bitrate::Flac,
channel: 0,
port: 8080,
}
}
}
impl MediaServerBuilder {
/// Create a new builder with default settings
pub fn new() -> Self {
Self::default()
}
/// Set the friendly name for the device
pub fn with_friendly_name(mut self, name: impl Into<String>) -> Self {
self.friendly_name = name.into();
self
}
/// Set the manufacturer name
pub fn with_manufacturer(mut self, name: impl Into<String>) -> Self {
self.manufacturer = name.into();
self
}
/// Set the model name
pub fn with_model_name(mut self, name: impl Into<String>) -> Self {
self.model_name = name.into();
self
}
/// Set the bitrate/quality level
pub fn with_bitrate(mut self, bitrate: Bitrate) -> Self {
self.bitrate = bitrate;
self
}
/// Set the Radio Paradise channel (0=main, 1=mellow, 2=rock, 3=world)
pub fn with_channel(mut self, channel: u8) -> Self {
self.channel = channel;
self
}
/// Set the HTTP server port
pub fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
/// Build the media server
pub async fn build(self) -> Result<RadioParadiseMediaServer> {
// Create Radio Paradise client
let client = RadioParadiseClient::builder()
.bitrate(self.bitrate)
.channel(self.channel)
.build()
.await?;
let client = Arc::new(RwLock::new(client));
// Create HTTP server
let mut server = pmoserver::ServerBuilder::new()
.with_port(self.port)
.build()
.map_err(|e| Error::other(format!("Failed to create server: {}", e)))?;
// Create UPnP device
let device_udn = format!("uuid:{}", uuid::Uuid::new_v4());
let mut device = Device::new(
"MediaServer".to_string(),
"MediaServer".to_string(),
self.friendly_name.clone(),
);
device.set_manufacturer(self.manufacturer);
device.set_model_name(self.model_name);
device.set_udn(device_udn.clone());
// Add ContentDirectory service
let content_directory = super::content_directory::create_content_directory_service(
client.clone()
);
device.add_service(Arc::new(content_directory))
.map_err(|e| Error::other(format!("Failed to add ContentDirectory: {:?}", e)))?;
// Add ConnectionManager service
let connection_manager = super::connection_manager::create_connection_manager_service();
device.add_service(Arc::new(connection_manager))
.map_err(|e| Error::other(format!("Failed to add ConnectionManager: {:?}", e)))?;
// Register device with server
server.register_device(Arc::new(device))
.await
.map_err(|e| Error::other(format!("Failed to register device: {:?}", e)))?;
Ok(RadioParadiseMediaServer {
server,
client,
device_udn,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_defaults() {
let builder = MediaServerBuilder::default();
assert_eq!(builder.friendly_name, "Radio Paradise Media Server");
assert_eq!(builder.bitrate, Bitrate::Flac);
assert_eq!(builder.channel, 0);
assert_eq!(builder.port, 8080);
}
#[test]
fn test_builder_customization() {
let builder = MediaServerBuilder::new()
.with_friendly_name("Custom Server")
.with_bitrate(Bitrate::Aac320)
.with_channel(1)
.with_port(9090);
assert_eq!(builder.friendly_name, "Custom Server");
assert_eq!(builder.bitrate, Bitrate::Aac320);
assert_eq!(builder.channel, 1);
assert_eq!(builder.port, 9090);
}
}

322
pmoparadise/src/models.rs Normal file
View File

@@ -0,0 +1,322 @@
//! Data models for Radio Paradise API responses
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Bitrate quality levels for Radio Paradise streams
///
/// Radio Paradise offers 5 quality levels:
/// - 0: 128 kbps MP3
/// - 1: AAC 64 kbps
/// - 2: AAC 128 kbps
/// - 3: AAC 320 kbps
/// - 4: FLAC lossless (CD quality or better)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(u8)]
pub enum Bitrate {
/// 128 kbps MP3
Mp3_128 = 0,
/// AAC 64 kbps
Aac64 = 1,
/// AAC 128 kbps
Aac128 = 2,
/// AAC 320 kbps
Aac320 = 3,
/// FLAC lossless
Flac = 4,
}
impl Bitrate {
/// Convert from u8 value
pub fn from_u8(value: u8) -> Result<Self, crate::error::Error> {
match value {
0 => Ok(Self::Mp3_128),
1 => Ok(Self::Aac64),
2 => Ok(Self::Aac128),
3 => Ok(Self::Aac320),
4 => Ok(Self::Flac),
_ => Err(crate::error::Error::InvalidBitrate(value)),
}
}
/// Convert to u8 value
pub fn as_u8(self) -> u8 {
self as u8
}
/// Get human-readable description
pub fn description(&self) -> &'static str {
match self {
Self::Mp3_128 => "MP3 128 kbps",
Self::Aac64 => "AAC 64 kbps",
Self::Aac128 => "AAC 128 kbps",
Self::Aac320 => "AAC 320 kbps",
Self::Flac => "FLAC Lossless",
}
}
}
impl Default for Bitrate {
fn default() -> Self {
Self::Flac
}
}
/// Duration in milliseconds
pub type DurationMs = u64;
/// Event ID for block identification
pub type EventId = u64;
/// Information about a song/track within a block
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Song {
/// Artist name
pub artist: String,
/// Song title
pub title: String,
/// Album name
pub album: String,
/// Year of release
#[serde(default)]
pub year: Option<u32>,
/// Elapsed time from start of block in milliseconds
pub elapsed: DurationMs,
/// Duration of the track in milliseconds
pub duration: DurationMs,
/// Cover image filename/path
#[serde(default)]
pub cover: Option<String>,
/// Rating (0-10)
#[serde(default)]
pub rating: Option<f32>,
/// Additional metadata
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl Song {
/// Get the end time of this song in the block (elapsed + duration)
pub fn end_time_ms(&self) -> DurationMs {
self.elapsed + self.duration
}
/// Check if a given timestamp (ms) falls within this song
pub fn contains_timestamp(&self, timestamp_ms: DurationMs) -> bool {
timestamp_ms >= self.elapsed && timestamp_ms < self.end_time_ms()
}
}
/// Image information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageInfo {
/// Base URL for images
pub base: String,
}
/// A block of songs from Radio Paradise
///
/// Radio Paradise streams music in "blocks" - continuous FLAC files
/// containing multiple songs. Each block contains metadata about all
/// songs within it and timing information for seeking.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Block {
/// Event ID for this block (start event)
pub event: EventId,
/// Event ID for the next block (end event)
pub end_event: EventId,
/// Total length of the block in milliseconds
pub length: DurationMs,
/// URL to stream this block
pub url: String,
/// Base URL for cover images
#[serde(default)]
pub image_base: Option<String>,
/// Map of song index (as string) to Song metadata
/// Keys are "0", "1", "2", etc.
#[serde(default)]
pub song: HashMap<String, Song>,
/// Additional metadata
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl Block {
/// Get songs in order by index
pub fn songs_ordered(&self) -> Vec<(usize, &Song)> {
let mut songs: Vec<_> = self.song
.iter()
.filter_map(|(k, v)| k.parse::<usize>().ok().map(|idx| (idx, v)))
.collect();
songs.sort_by_key(|(idx, _)| *idx);
songs
}
/// Get a song by index
pub fn get_song(&self, index: usize) -> Option<&Song> {
self.song.get(&index.to_string())
}
/// Get the number of songs in this block
pub fn song_count(&self) -> usize {
self.song.len()
}
/// Get the full URL for a cover image
pub fn cover_url(&self, cover_path: &str) -> Option<String> {
self.image_base.as_ref().map(|base| format!("{}{}", base, cover_path))
}
/// Find which song is playing at a given timestamp (ms from block start)
pub fn song_at_timestamp(&self, timestamp_ms: DurationMs) -> Option<(usize, &Song)> {
self.songs_ordered()
.into_iter()
.find(|(_, song)| song.contains_timestamp(timestamp_ms))
}
/// Parse the block URL to get start and end event IDs
///
/// Block URLs follow the pattern:
/// `https://apps.radioparadise.com/blocks/chan/0/4/<start>-<end>.flac`
pub fn parse_url_events(&self) -> Option<(EventId, EventId)> {
let url_path = self.url.split('/').last()?;
let filename = url_path.strip_suffix(".flac")?;
let mut parts = filename.split('-');
let start = parts.next()?.parse::<EventId>().ok()?;
let end = parts.next()?.parse::<EventId>().ok()?;
Some((start, end))
}
}
/// Currently playing information
#[derive(Debug, Clone)]
pub struct NowPlaying {
/// The current block
pub block: Block,
/// Current song index (if determinable)
pub current_song_index: Option<usize>,
/// Current song
pub current_song: Option<Song>,
/// Approximate elapsed time in current block (ms)
/// Note: This is estimated and may not be perfectly accurate
pub block_elapsed_ms: Option<DurationMs>,
}
impl NowPlaying {
/// Create from a block (assumes starting from beginning)
pub fn from_block(block: Block) -> Self {
let (current_song_index, current_song) = block.get_song(0)
.map(|s| (Some(0), Some(s.clone())))
.unwrap_or((None, None));
Self {
block,
current_song_index,
current_song,
block_elapsed_ms: Some(0),
}
}
/// Get URL for the current block stream
pub fn stream_url(&self) -> &str {
&self.block.url
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bitrate_conversion() {
assert_eq!(Bitrate::from_u8(0).unwrap(), Bitrate::Mp3_128);
assert_eq!(Bitrate::from_u8(4).unwrap(), Bitrate::Flac);
assert!(Bitrate::from_u8(5).is_err());
}
#[test]
fn test_song_timing() {
let song = Song {
artist: "Test Artist".to_string(),
title: "Test Song".to_string(),
album: "Test Album".to_string(),
year: Some(2024),
elapsed: 1000,
duration: 5000,
cover: None,
rating: None,
extra: HashMap::new(),
};
assert_eq!(song.end_time_ms(), 6000);
assert!(song.contains_timestamp(3000));
assert!(!song.contains_timestamp(7000));
assert!(!song.contains_timestamp(500));
}
#[test]
fn test_block_parse() {
let json = r#"{
"event": 1234,
"end_event": 5678,
"length": 900000,
"url": "https://apps.radioparadise.com/blocks/chan/0/4/1234-5678.flac",
"image_base": "https://img.radioparadise.com/covers/l/",
"song": {
"0": {
"artist": "Miles Davis",
"title": "So What",
"album": "Kind of Blue",
"year": 1959,
"elapsed": 0,
"duration": 540000,
"cover": "B00000I0JF.jpg"
},
"1": {
"artist": "John Coltrane",
"title": "Giant Steps",
"album": "Giant Steps",
"year": 1960,
"elapsed": 540000,
"duration": 360000,
"cover": "B000002I4U.jpg"
}
}
}"#;
let block: Block = serde_json::from_str(json).unwrap();
assert_eq!(block.event, 1234);
assert_eq!(block.end_event, 5678);
assert_eq!(block.song_count(), 2);
let songs = block.songs_ordered();
assert_eq!(songs.len(), 2);
assert_eq!(songs[0].1.title, "So What");
assert_eq!(songs[1].1.title, "Giant Steps");
let (start, end) = block.parse_url_events().unwrap();
assert_eq!(start, 1234);
assert_eq!(end, 5678);
let (idx, song) = block.song_at_timestamp(600000).unwrap();
assert_eq!(idx, 1);
assert_eq!(song.title, "Giant Steps");
}
}

183
pmoparadise/src/stream.rs Normal file
View File

@@ -0,0 +1,183 @@
//! Block streaming functionality
use crate::error::{Error, Result};
use crate::models::Block;
use crate::RadioParadiseClient;
use bytes::Bytes;
use futures::stream::Stream;
use std::pin::Pin;
use std::task::{Context, Poll};
use url::Url;
/// A stream of audio data from a Radio Paradise block
///
/// This wraps the HTTP response body and provides a `Stream<Item = Result<Bytes>>`
/// that can be consumed by audio players or written to a file.
pub struct BlockStream {
inner: Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>,
}
impl BlockStream {
/// Create a new block stream from a reqwest response
pub(crate) fn new(stream: impl Stream<Item = Result<Bytes>> + Send + 'static) -> Self {
Self {
inner: Box::pin(stream),
}
}
}
impl Stream for BlockStream {
type Item = Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.inner.as_mut().poll_next(cx)
}
}
impl RadioParadiseClient {
/// Stream a block from its URL
///
/// Returns a `Stream` of audio bytes that can be consumed by an audio player.
/// The stream will continue until the entire block is downloaded or an error occurs.
///
/// # Arguments
///
/// * `block_url` - The URL of the block to stream
///
/// # Example
///
/// ```no_run
/// use pmoparadise::RadioParadiseClient;
/// use futures::StreamExt;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// let mut stream = client.stream_block(&block.url.parse()?).await?;
///
/// while let Some(chunk) = stream.next().await {
/// let bytes = chunk?;
/// // Write bytes to audio player or file
/// println!("Received {} bytes", bytes.len());
/// }
///
/// Ok(())
/// }
/// ```
pub async fn stream_block(&self, block_url: &Url) -> Result<BlockStream> {
#[cfg(feature = "logging")]
tracing::debug!("Starting block stream: {}", block_url);
let response = self.client
.get(block_url.clone())
.timeout(self.timeout)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::other(format!(
"Failed to stream block: HTTP {}",
response.status()
)));
}
// Convert reqwest's byte stream to our Result type
let stream = response.bytes_stream();
let mapped = futures::stream::StreamExt::map(stream, |result| {
result.map_err(Error::from)
});
Ok(BlockStream::new(mapped))
}
/// Stream a block directly from a Block struct
///
/// Convenience method that parses the URL from the block.
///
/// # Example
///
/// ```no_run
/// use pmoparadise::RadioParadiseClient;
/// use futures::StreamExt;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// let mut stream = client.stream_block_from_metadata(&block).await?;
///
/// while let Some(chunk) = stream.next().await {
/// let bytes = chunk?;
/// // Process bytes...
/// }
///
/// Ok(())
/// }
/// ```
pub async fn stream_block_from_metadata(&self, block: &Block) -> Result<BlockStream> {
let url = Url::parse(&block.url)?;
self.stream_block(&url).await
}
/// Download a complete block to memory
///
/// **Warning**: Blocks can be large (50-100MB for FLAC). Use streaming
/// for playback instead of downloading the entire block to memory.
///
/// This is useful for the per-track feature which needs random access.
///
/// # Example
///
/// ```no_run
/// use pmoparadise::RadioParadiseClient;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// let data = client.download_block(&block.url.parse()?).await?;
/// println!("Downloaded {} bytes", data.len());
///
/// Ok(())
/// }
/// ```
pub async fn download_block(&self, block_url: &Url) -> Result<Bytes> {
#[cfg(feature = "logging")]
tracing::debug!("Downloading complete block: {}", block_url);
let response = self.client
.get(block_url.clone())
.timeout(self.timeout)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::other(format!(
"Failed to download block: HTTP {}",
response.status()
)));
}
let bytes = response.bytes().await?;
#[cfg(feature = "logging")]
tracing::debug!("Downloaded {} bytes", bytes.len());
Ok(bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_block_stream_creation() {
let stream = futures::stream::once(async { Ok(Bytes::from("test")) });
let _block_stream = BlockStream::new(stream);
}
}

387
pmoparadise/src/track.rs Normal file
View File

@@ -0,0 +1,387 @@
//! Per-track extraction from FLAC blocks (optional feature)
//!
//! **Important Notes:**
//!
//! Radio Paradise publishes *blocks* containing multiple songs, not individual
//! per-track files. This module provides experimental functionality to extract
//! individual tracks from FLAC blocks, but comes with significant tradeoffs:
//!
//! - **Storage**: Requires downloading the entire block (50-100MB) to disk
//! - **Latency**: Must download and decode before playback can start
//! - **CPU**: FLAC decoding is CPU-intensive
//! - **Complexity**: Seeking in FLAC requires decoding from the beginning
//!
//! ## Recommended Alternative
//!
//! For most use cases, it's better to:
//! 1. Stream the entire block to your audio player
//! 2. Use the `song[i].elapsed` metadata to seek within the player
//! 3. Let the player handle gapless transitions between tracks
//!
//! Modern players (mpv, VLC, ffmpeg) can seek in FLAC streams efficiently.
//!
//! ## When to Use This Module
//!
//! Only use per-track extraction when you need:
//! - Individual WAV files for further processing
//! - PCM data for custom audio analysis
//! - Separate files for non-streaming scenarios
//!
//! ## Block URL Pattern
//!
//! Blocks follow this URL pattern:
//! ```text
//! https://apps.radioparadise.com/blocks/chan/0/4/<start_event>-<end_event>.flac
//! ```
//!
//! The `song[i].elapsed` field (in milliseconds) indicates when each track
//! starts within the block.
#[cfg(feature = "per-track")]
use crate::error::{Error, Result};
#[cfg(feature = "per-track")]
use crate::models::Block;
#[cfg(feature = "per-track")]
use crate::RadioParadiseClient;
#[cfg(feature = "per-track")]
use std::io::Write;
#[cfg(feature = "per-track")]
use std::path::PathBuf;
/// Metadata for a decoded track stream
#[cfg(feature = "per-track")]
#[derive(Debug, Clone)]
pub struct TrackMetadata {
/// Sample rate in Hz (e.g., 44100)
pub sample_rate: u32,
/// Number of audio channels (1 = mono, 2 = stereo)
pub channels: u16,
/// Bits per sample (typically 16 or 24)
pub bits_per_sample: u16,
/// Total number of samples in this track
pub total_samples: u64,
}
/// A stream of decoded PCM audio for a single track
///
/// Provides access to decoded FLAC audio data for one track within a block.
/// The audio is decoded to 16-bit PCM format.
#[cfg(feature = "per-track")]
pub struct TrackStream {
/// Audio format metadata
pub metadata: TrackMetadata,
/// Path to the temporary FLAC file
temp_path: PathBuf,
/// FLAC reader
reader: Option<claxon::FlacReader<std::io::BufReader<std::fs::File>>>,
/// Current sample position
current_sample: u64,
/// End sample position (where this track ends)
end_sample: u64,
}
#[cfg(feature = "per-track")]
impl TrackStream {
/// Create a new track stream from a block
///
/// This will:
/// 1. Download the entire block to a temporary file
/// 2. Open it with a FLAC decoder
/// 3. Seek to the track's start position
/// 4. Prepare to decode samples
///
/// **Warning**: This is an expensive operation. Consider caching blocks.
async fn from_block_internal(
client: &RadioParadiseClient,
block: &Block,
track_index: usize,
) -> Result<Self> {
// Validate track index
let song = block.get_song(track_index)
.ok_or(Error::InvalidIndex(track_index, block.song_count()))?;
// Download block to temporary file
let url = block.url.parse()
.map_err(|e| Error::other(format!("Invalid block URL: {}", e)))?;
let block_data = client.download_block(&url).await?;
// Write to temp file
let mut temp_file = tempfile::NamedTempFile::new()?;
temp_file.write_all(&block_data)?;
temp_file.flush()?;
let temp_path = temp_file.into_temp_path();
let path_buf = temp_path.to_path_buf();
#[cfg(feature = "logging")]
tracing::debug!("Wrote block to temp file: {:?}", path_buf);
// Open FLAC reader
let file = std::fs::File::open(&path_buf)?;
let buffered = std::io::BufReader::new(file);
let mut reader = claxon::FlacReader::new(buffered)?;
let streaminfo = reader.streaminfo();
let sample_rate = streaminfo.sample_rate;
let channels = streaminfo.channels as u16;
let bits_per_sample = streaminfo.bits_per_sample as u16;
// Calculate start and end sample positions
let start_sample = Self::ms_to_samples(song.elapsed, sample_rate);
let duration_samples = Self::ms_to_samples(song.duration, sample_rate);
let end_sample = start_sample + duration_samples;
#[cfg(feature = "logging")]
tracing::debug!(
"Track {} spans samples {} to {} ({} ms to {} ms)",
track_index,
start_sample,
end_sample,
song.elapsed,
song.elapsed + song.duration
);
// Seek to start position by reading and discarding samples
// Note: FLAC doesn't support random access, so we must decode from beginning
if start_sample > 0 {
#[cfg(feature = "logging")]
tracing::debug!("Seeking to sample {}", start_sample);
Self::skip_samples(&mut reader, start_sample)?;
}
let metadata = TrackMetadata {
sample_rate,
channels,
bits_per_sample,
total_samples: duration_samples,
};
Ok(Self {
metadata,
temp_path: path_buf,
reader: Some(reader),
current_sample: start_sample,
end_sample,
})
}
/// Convert milliseconds to sample count
fn ms_to_samples(ms: u64, sample_rate: u32) -> u64 {
(ms * sample_rate as u64) / 1000
}
/// Skip samples by reading and discarding
fn skip_samples(
reader: &mut claxon::FlacReader<std::io::BufReader<std::fs::File>>,
count: u64,
) -> Result<()> {
let mut samples = reader.samples();
for _ in 0..count {
if samples.next().is_none() {
return Err(Error::other("Unexpected end of FLAC stream while seeking"));
}
}
Ok(())
}
/// Read decoded PCM samples
///
/// Returns samples as 16-bit signed integers (i16), interleaved by channel.
/// For stereo: [L, R, L, R, ...]. Returns None when track ends.
pub fn read_samples(&mut self, buffer: &mut [i16]) -> Result<Option<usize>> {
let reader = self.reader.as_mut()
.ok_or(Error::other("TrackStream already consumed"))?;
let mut samples_iter = reader.samples();
let mut count = 0;
for chunk in buffer.chunks_mut(self.metadata.channels as usize) {
if self.current_sample >= self.end_sample {
break;
}
// Read one sample per channel
for sample_slot in chunk.iter_mut() {
match samples_iter.next() {
Some(Ok(sample)) => {
// Claxon returns i32, convert to i16
*sample_slot = (sample >> (self.metadata.bits_per_sample - 16)) as i16;
count += 1;
}
Some(Err(e)) => {
return Err(Error::FlacDecode(e.to_string()));
}
None => {
return Ok(if count > 0 { Some(count) } else { None });
}
}
}
self.current_sample += 1;
}
Ok(if count > 0 { Some(count) } else { None })
}
/// Export track to a WAV file
///
/// Decodes the entire track and writes it as a WAV file.
///
/// # Example
///
/// ```no_run
/// # #[cfg(feature = "per-track")]
/// # {
/// use pmoparadise::RadioParadiseClient;
/// use std::path::Path;
///
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// let mut track_stream = client.open_track_stream(&block, 0).await?;
/// track_stream.export_wav(Path::new("track.wav"))?;
/// # Ok(())
/// # }
/// # }
/// ```
pub fn export_wav(&mut self, output_path: &std::path::Path) -> Result<()> {
let spec = hound::WavSpec {
channels: self.metadata.channels,
sample_rate: self.metadata.sample_rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(output_path, spec)?;
let mut buffer = vec![0i16; 8192 * self.metadata.channels as usize];
#[cfg(feature = "logging")]
tracing::info!("Exporting track to WAV: {:?}", output_path);
loop {
match self.read_samples(&mut buffer)? {
Some(count) => {
for &sample in &buffer[..count] {
writer.write_sample(sample)?;
}
}
None => break,
}
}
writer.finalize()?;
#[cfg(feature = "logging")]
tracing::info!("Successfully exported WAV file");
Ok(())
}
}
#[cfg(feature = "per-track")]
impl Drop for TrackStream {
fn drop(&mut self) {
// Close reader before removing temp file
self.reader.take();
// Clean up temporary file
if let Err(_e) = std::fs::remove_file(&self.temp_path) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to remove temp file {:?}: {}", self.temp_path, _e);
}
}
}
#[cfg(feature = "per-track")]
impl RadioParadiseClient {
/// Open a stream for a specific track within a block
///
/// **Warning**: This downloads the entire block to a temporary file
/// and performs FLAC decoding. See module documentation for alternatives.
///
/// # Arguments
///
/// * `block` - The block containing the track
/// * `track_index` - Index of the track (0-based)
///
/// # Example
///
/// ```no_run
/// # #[cfg(feature = "per-track")]
/// # {
/// use pmoparadise::RadioParadiseClient;
///
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// // Extract first track
/// let mut track = client.open_track_stream(&block, 0).await?;
/// println!("Track: {} Hz, {} channels",
/// track.metadata.sample_rate,
/// track.metadata.channels);
///
/// // Read some samples
/// let mut buffer = vec![0i16; 4096];
/// if let Some(count) = track.read_samples(&mut buffer)? {
/// println!("Read {} samples", count);
/// }
/// # Ok(())
/// # }
/// # }
/// ```
pub async fn open_track_stream(&self, block: &Block, track_index: usize) -> Result<TrackStream> {
TrackStream::from_block_internal(self, block, track_index).await
}
/// Helper: Get track position in seconds for player-based seeking
///
/// Instead of downloading and decoding, you can pass this information
/// to your audio player for efficient seeking.
///
/// Returns (start_seconds, duration_seconds)
///
/// # Example
///
/// ```no_run
/// use pmoparadise::RadioParadiseClient;
///
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = RadioParadiseClient::new().await?;
/// let block = client.get_block(None).await?;
///
/// let (start, duration) = client.track_position_seconds(&block, 1)?;
/// println!("Track 1 starts at {}s, duration {}s", start, duration);
/// println!("Play with: mpv --start={} --length={} {}", start, duration, block.url);
/// # Ok(())
/// # }
/// ```
pub fn track_position_seconds(&self, block: &Block, track_index: usize) -> Result<(f64, f64)> {
let song = block.get_song(track_index)
.ok_or(Error::InvalidIndex(track_index, block.song_count()))?;
let start_secs = song.elapsed as f64 / 1000.0;
let duration_secs = song.duration as f64 / 1000.0;
Ok((start_secs, duration_secs))
}
}
#[cfg(test)]
#[cfg(feature = "per-track")]
mod tests {
use super::*;
#[test]
fn test_ms_to_samples() {
assert_eq!(TrackStream::ms_to_samples(1000, 44100), 44100);
assert_eq!(TrackStream::ms_to_samples(500, 44100), 22050);
assert_eq!(TrackStream::ms_to_samples(0, 44100), 0);
}
}