386 lines
11 KiB
Rust
386 lines
11 KiB
Rust
//! 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"
|
|
);
|
|
}
|
|
}
|