implemente pmoparadise

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

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

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