//! 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/"; /// Default timeout for metadata HTTP requests pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; /// Default timeout for large block downloads/streams pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 180; /// 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> { /// 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, bitrate: Bitrate, channel: u8, pub(crate) request_timeout: Duration, pub(crate) block_timeout: Duration, next_block_url: Option, } 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::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(), bitrate: Bitrate::default(), channel: 0, request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), block_timeout: Duration::from_secs(DEFAULT_BLOCK_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 } fn block_base_for_channel(channel: u8) -> String { format!("https://apps.radioparadise.com/blocks/chan/{}", channel) } /// Clone the client with a different channel while preserving other settings. pub fn clone_with_channel(&self, channel: u8) -> Self { let mut cloned = self.clone(); cloned.channel = channel; cloned.block_base = Self::block_base_for_channel(channel); cloned.next_block_url = None; cloned } /// Clone the client with a different bitrate while preserving other settings. pub fn clone_with_bitrate(&self, bitrate: Bitrate) -> Self { let mut cloned = self.clone(); cloned.bitrate = bitrate; cloned.next_block_url = None; cloned } /// 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> { /// 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) -> Result { 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") .append_pair("channel", &self.channel.to_string()); 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.request_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?; // Normalize protocol-relative URLs from API (//img.radioparadise.com/) if let Some(ref base) = block.image_base { if base.starts_with("//") { block.image_base = Some(format!("https:{}", base)); } } else { // Fallback if API doesn't provide image_base (should never happen) block.image_base = Some(DEFAULT_IMAGE_BASE.to_string()); } #[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 { let block = self.get_block(None).await?; Ok(NowPlaying::from_block(block)) } /// 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, api_base: String, block_base: String, bitrate: Bitrate, channel: u8, request_timeout: Duration, block_timeout: Duration, user_agent: String, proxy: Option, } impl Default for ClientBuilder { fn default() -> Self { Self { client: None, api_base: DEFAULT_API_BASE.to_string(), block_base: DEFAULT_BLOCK_BASE.to_string(), bitrate: Bitrate::default(), channel: 0, request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), block_timeout: Duration::from_secs(DEFAULT_BLOCK_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) -> Self { self.api_base = url.into(); self } /// Set the block base URL pub fn block_base(mut self, url: impl Into) -> Self { self.block_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.request_timeout = timeout; self } /// Set the timeout specifically for block downloads/streams pub fn block_timeout(mut self, timeout: Duration) -> Self { self.block_timeout = timeout; self } /// Set a custom User-Agent header pub fn user_agent(mut self, user_agent: impl Into) -> Self { self.user_agent = user_agent.into(); self } /// Set a proxy URL pub fn proxy(mut self, proxy: impl Into) -> Self { self.proxy = Some(proxy.into()); self } /// Build the client pub async fn build(self) -> Result { let client = if let Some(client) = self.client { client } else { let mut builder = Client::builder() .user_agent(&self.user_agent) .timeout(self.request_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()? }; let block_base = if self.block_base == DEFAULT_BLOCK_BASE { RadioParadiseClient::block_base_for_channel(self.channel) } else { self.block_base.clone() }; Ok(RadioParadiseClient { client, api_base: self.api_base, block_base, bitrate: self.bitrate, channel: self.channel, request_timeout: self.request_timeout, block_timeout: self.block_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); } }