//! Data models for Radio Paradise API responses use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Number; use std::collections::HashMap; use url::Url; /// Deserialize a string or number into a u64 fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { use serde::de::Error; #[derive(Deserialize)] #[serde(untagged)] enum StringOrU64 { String(String), Number(u64), } match StringOrU64::deserialize(deserializer)? { StringOrU64::String(s) => s.parse::().map_err(D::Error::custom), StringOrU64::Number(n) => Ok(n), } } /// Deserialize a string or number into a f64, then convert to u64 milliseconds fn deserialize_length<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { use serde::de::Error; #[derive(Deserialize)] #[serde(untagged)] enum StringOrNumber { String(String), Number(Number), } fn to_milliseconds(value: f64) -> u64 { if value >= 100_000.0 { value.round() as u64 } else { (value * 1000.0).round() as u64 } } match StringOrNumber::deserialize(deserializer)? { StringOrNumber::String(s) => { let value = s.parse::().map_err(D::Error::custom)?; Ok(to_milliseconds(value)) } StringOrNumber::Number(n) => { if let Some(int_value) = n.as_u64() { Ok(to_milliseconds(int_value as f64)) } else if let Some(float_value) = n.as_f64() { Ok(to_milliseconds(float_value)) } else { Err(D::Error::custom("Invalid number for block length")) } } } } /// Deserialize an optional string or number into Option fn deserialize_optional_string_or_u32<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { use serde::de::Error; #[derive(Deserialize)] #[serde(untagged)] enum StringOrU32 { String(String), Number(u32), } let opt = Option::::deserialize(deserializer)?; match opt { None => Ok(None), Some(StringOrU32::String(s)) => { if s.is_empty() { Ok(None) } else { s.parse::().map(Some).map_err(D::Error::custom) } } Some(StringOrU32::Number(n)) => Ok(Some(n)), } } /// Deserialize an optional string or number into Option fn deserialize_optional_string_or_f32<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { use serde::de::Error; #[derive(Deserialize)] #[serde(untagged)] enum StringOrF32 { String(String), Float(f32), Int(i32), } let opt = Option::::deserialize(deserializer)?; match opt { None => Ok(None), Some(StringOrF32::String(s)) => { if s.is_empty() { Ok(None) } else { s.parse::().map(Some).map_err(D::Error::custom) } } Some(StringOrF32::Float(f)) => Ok(Some(f)), Some(StringOrF32::Int(i)) => Ok(Some(i as f32)), } } /// 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 (may be missing for promos/announcements) #[serde(default)] pub album: Option, /// Year of release /// Note: API returns this as a string, we deserialize to u32 #[serde(default, deserialize_with = "deserialize_optional_string_or_u32")] pub year: Option, /// 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, /// Rating (0-10) /// Note: API returns this as a string, we deserialize to f32 #[serde(default, deserialize_with = "deserialize_optional_string_or_f32")] pub rating: Option, /// Additional metadata #[serde(flatten)] pub extra: HashMap, } 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) /// Note: API returns this as a string, we deserialize to u64 #[serde(deserialize_with = "deserialize_string_or_u64")] pub event: EventId, /// Event ID for the next block (end event) /// Note: API returns this as a string, we deserialize to u64 #[serde(deserialize_with = "deserialize_string_or_u64")] pub end_event: EventId, /// Total length of the block in milliseconds /// Note: API returns this as a string in seconds (e.g., "1715.54"), we convert to ms #[serde(deserialize_with = "deserialize_length")] pub length: DurationMs, /// URL to stream this block pub url: String, /// Base URL for cover images #[serde(default)] pub image_base: Option, /// Map of song index (as string) to Song metadata /// Keys are "0", "1", "2", etc. #[serde(default)] pub song: HashMap, /// Additional metadata #[serde(flatten)] pub extra: HashMap, } 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::().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 { let base = self.image_base.as_ref()?; let base_url = Url::parse(base).ok()?; base_url.join(cover_path).ok().map(|url| url.to_string()) } /// 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/-.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::().ok()?; let end = parts.next()?.parse::().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, /// Current song pub current_song: Option, /// Approximate elapsed time in current block (ms) /// Note: This is estimated and may not be perfectly accurate pub block_elapsed_ms: Option, } 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_song_timing() { let song = Song { artist: "Test Artist".to_string(), title: "Test Song".to_string(), album: Some("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"); } #[test] fn test_block_length_from_seconds_string() { let json = serde_json::json!({ "event": 1, "end_event": 2, "length": "1715.54", "url": "https://example.com/block.flac", "song": {} }); let block: Block = serde_json::from_value(json).unwrap(); assert_eq!(block.length, 1_715_540); } #[test] fn test_block_length_from_seconds_integer() { let json = serde_json::json!({ "event": 1, "end_event": 2, "length": 1800, "url": "https://example.com/block.flac", "song": {} }); let block: Block = serde_json::from_value(json).unwrap(); assert_eq!(block.length, 1_800_000); } #[test] fn test_block_length_from_milliseconds_integer() { let json = serde_json::json!({ "event": 1, "end_event": 2, "length": 900_000, "url": "https://example.com/block.flac", "song": {} }); let block: Block = serde_json::from_value(json).unwrap(); assert_eq!(block.length, 900_000); } #[test] fn test_block_length_from_milliseconds_float() { let json = serde_json::json!({ "event": 1, "end_event": 2, "length": 900_000.0, "url": "https://example.com/block.flac", "song": {} }); let block: Block = serde_json::from_value(json).unwrap(); assert_eq!(block.length, 900_000); } }