implemente pmoparadise
This commit is contained in:
386
pmoparadise/src/client.rs
Normal file
386
pmoparadise/src/client.rs
Normal 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
77
pmoparadise/src/error.rs
Normal 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
229
pmoparadise/src/lib.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
167
pmoparadise/src/mediaserver/connection_manager.rs
Normal file
167
pmoparadise/src/mediaserver/connection_manager.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
330
pmoparadise/src/mediaserver/content_directory.rs
Normal file
330
pmoparadise/src/mediaserver/content_directory.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
58
pmoparadise/src/mediaserver/mod.rs
Normal file
58
pmoparadise/src/mediaserver/mod.rs
Normal 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};
|
||||
197
pmoparadise/src/mediaserver/server.rs
Normal file
197
pmoparadise/src/mediaserver/server.rs
Normal 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
322
pmoparadise/src/models.rs
Normal 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
183
pmoparadise/src/stream.rs
Normal 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
387
pmoparadise/src/track.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user