Introduces the pmourlsource crate as a standard MusicSource that resolves HTTP/HTTPS URLs via a priority-ordered UrlHandler registry. Includes specialized handlers for Qobuz and Radio France alongside an SSRF-safe generic scraper supporting playlists, feeds, and HTML audio. Integrates with existing browse flows, REST endpoints, and Android Web Share targets while updating source capability flags to correctly route URL queries.
1065 lines
43 KiB
Rust
1065 lines
43 KiB
Rust
//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise
|
|
//!
|
|
//! This module provides a UPnP ContentDirectory source for Radio Paradise,
|
|
//! exposing live streams and historical playlists for all 4 channels.
|
|
|
|
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
|
|
use pmosource::pmodidl::{Container, Item, Resource};
|
|
use pmosource::{
|
|
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result,
|
|
SourceCapabilities,
|
|
};
|
|
use std::fmt;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant, SystemTime};
|
|
use tokio::sync::RwLock;
|
|
|
|
/// Default Radio Paradise image (embedded in binary)
|
|
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
|
|
|
#[cfg(feature = "playlist")]
|
|
const LIVE_PLAYLIST_MIN_READY_ITEMS: usize = 5;
|
|
#[cfg(feature = "playlist")]
|
|
const LIVE_PLAYLIST_READY_TIMEOUT: Duration = Duration::from_secs(10);
|
|
#[cfg(feature = "playlist")]
|
|
const LIVE_PLAYLIST_READY_POLL: Duration = Duration::from_millis(200);
|
|
|
|
/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
|
|
///
|
|
/// Provides access to:
|
|
/// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic)
|
|
/// - Historical playlists (FIFO) for each channel
|
|
///
|
|
/// # Object ID Schema
|
|
///
|
|
/// - Root: `radio-paradise`
|
|
/// - Channel container: `radio-paradise:channel:{slug}`
|
|
/// - Live stream item: `radio-paradise:channel:{slug}:live`
|
|
/// - Live playlist container: `radio-paradise:channel:{slug}:liveplaylist`
|
|
/// - Live playlist track: `radio-paradise:channel:{slug}:liveplaylist:track:{pk}`
|
|
/// - History container: `radio-paradise:channel:{slug}:history`
|
|
/// - History track: `radio-paradise:channel:{slug}:history:track:{pk}`
|
|
#[derive(Clone)]
|
|
pub struct RadioParadiseSource {
|
|
/// Base URL for streaming server (e.g., "http://localhost:8080")
|
|
base_url: String,
|
|
/// Update counter for change notifications
|
|
update_counter: Arc<RwLock<u32>>,
|
|
/// Last change timestamp
|
|
last_change: Arc<RwLock<SystemTime>>,
|
|
/// Tokens des callbacks enregistrés auprès du PlaylistManager
|
|
callback_tokens: Arc<std::sync::Mutex<Vec<u64>>>,
|
|
/// Notifier optionnel pour signaler les mises à jour de conteneurs au ContentDirectory
|
|
container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync + 'static>>,
|
|
}
|
|
|
|
impl fmt::Debug for RadioParadiseSource {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("RadioParadiseSource")
|
|
.field("base_url", &self.base_url)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl RadioParadiseSource {
|
|
/// Create a new RadioParadiseSource
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `base_url` - Base URL for streaming server (e.g., "http://localhost:8080")
|
|
///
|
|
/// # Note
|
|
///
|
|
/// With the "playlist" feature enabled, this source will use the global PlaylistManager
|
|
/// singleton to access history playlists.
|
|
pub fn new(base_url: impl Into<String>) -> Self {
|
|
Self {
|
|
base_url: base_url.into(),
|
|
update_counter: Arc::new(RwLock::new(0)),
|
|
last_change: Arc::new(RwLock::new(SystemTime::now())),
|
|
callback_tokens: Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
container_notifier: None,
|
|
}
|
|
}
|
|
|
|
/// Injecte un notifier pour propager les changements de playlists vers le ContentDirectory
|
|
pub fn with_container_notifier(
|
|
mut self,
|
|
notifier: Arc<dyn Fn(&[String]) + Send + Sync + 'static>,
|
|
) -> Self {
|
|
self.container_notifier = Some(notifier);
|
|
self
|
|
}
|
|
|
|
/// Build a live stream URL for a channel
|
|
fn build_live_url(&self, slug: &str) -> String {
|
|
format!("{}/radioparadise/stream/{}/flac", self.base_url, slug)
|
|
}
|
|
|
|
/// Build an OGG-FLAC live stream URL for clients that support it
|
|
fn build_live_ogg_url(&self, slug: &str) -> String {
|
|
format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug)
|
|
}
|
|
|
|
/// Incrémente l'update_counter et met à jour last_change
|
|
async fn bump_update_counter(&self) {
|
|
{
|
|
let mut c = self.update_counter.write().await;
|
|
*c = c.wrapping_add(1).max(1);
|
|
}
|
|
let mut lc = self.last_change.write().await;
|
|
*lc = SystemTime::now();
|
|
}
|
|
|
|
/// Enregistre des callbacks sur les playlists live/historique pour notifier les changements
|
|
pub fn attach_playlist_callbacks(self: &Arc<Self>) {
|
|
use pmoplaylist::PlaylistManager;
|
|
|
|
// Préparer les IDs de playlists à surveiller (live + history pour chaque canal)
|
|
let ids: Vec<String> = ALL_CHANNELS
|
|
.iter()
|
|
.flat_map(|ch| {
|
|
vec![
|
|
Self::live_playlist_id(ch.slug),
|
|
Self::history_playlist_id(ch.slug),
|
|
]
|
|
})
|
|
.collect();
|
|
|
|
let mgr = PlaylistManager();
|
|
let mut tokens = self.callback_tokens.lock().unwrap();
|
|
|
|
for pid in ids {
|
|
let weak = Arc::downgrade(self);
|
|
let pid_clone = pid.clone();
|
|
let token = mgr.register_callback(move |event| {
|
|
let pid = pid_clone.clone();
|
|
if event.playlist_id == pid {
|
|
// Ignorer PkUpdated - pas de notification UPnP (évite le reload côté control point)
|
|
if matches!(event.kind, pmoplaylist::PlaylistEventKind::PkUpdated { .. }) {
|
|
tracing::debug!(
|
|
"PK swap in playlist {} - no UPnP notification (prevents playback restart)",
|
|
event.playlist_id
|
|
);
|
|
return;
|
|
}
|
|
// On ne réagit qu'aux mises à jour structurelles (ajout/suppression)
|
|
if !matches!(event.kind, pmoplaylist::PlaylistEventKind::Updated) {
|
|
return;
|
|
}
|
|
if let Some(strong) = weak.upgrade() {
|
|
tokio::spawn(async move {
|
|
strong.bump_update_counter().await;
|
|
// Notifier ContentDirectory des conteneurs concernés
|
|
let containers: Vec<String> = if pid.contains("history") {
|
|
// history playlist -> container history
|
|
ALL_CHANNELS
|
|
.iter()
|
|
.find(|ch| pid.ends_with(ch.slug))
|
|
.map(|ch| {
|
|
vec![format!("radio-paradise:channel:{}:history", ch.slug)]
|
|
})
|
|
.unwrap_or_default()
|
|
} else {
|
|
// live playlist -> container liveplaylist
|
|
ALL_CHANNELS
|
|
.iter()
|
|
.find(|ch| pid.ends_with(ch.slug))
|
|
.map(|ch| {
|
|
vec![format!(
|
|
"radio-paradise:channel:{}:liveplaylist",
|
|
ch.slug
|
|
)]
|
|
})
|
|
.unwrap_or_default()
|
|
};
|
|
|
|
if !containers.is_empty() {
|
|
if let Some(notifier) = strong.container_notifier.as_ref() {
|
|
notifier(&containers);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
tokens.push(token);
|
|
}
|
|
}
|
|
|
|
/// URL de fallback pour l'image par défaut de la source
|
|
fn default_cover_url(&self) -> String {
|
|
format!("{}/api/sources/{}/image", self.base_url, self.id())
|
|
}
|
|
|
|
/// Fetch current metadata from the live stream
|
|
async fn fetch_live_metadata(&self, slug: &str) -> Result<Option<Item>> {
|
|
let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug);
|
|
|
|
// Try to fetch metadata via HTTP
|
|
match reqwest::get(&metadata_url).await {
|
|
Ok(response) if response.status().is_success() => {
|
|
match response.json::<serde_json::Value>().await {
|
|
Ok(json) => {
|
|
// Parse metadata from JSON and create an Item
|
|
let title = json["title"]
|
|
.as_str()
|
|
.unwrap_or("Unknown Title")
|
|
.to_string();
|
|
let artist = json["artist"].as_str().map(|s| s.to_string());
|
|
let album = json["album"].as_str().map(|s| s.to_string());
|
|
let year = json["year"].as_u64().map(|y| y as u32);
|
|
// Préférer l'URL de cache si cover_pk est fourni par le pipeline
|
|
let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string());
|
|
// Stocker la route relative (le handler REST appliquera base_url.url_for())
|
|
let cover_url = cover_pk
|
|
.as_ref()
|
|
.map(|pk| pmocache::covers_route_for(pk, None))
|
|
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string()))
|
|
.or_else(|| Some(self.default_cover_url()));
|
|
|
|
// Parse duration from JSON (in seconds as a float)
|
|
let duration = json["duration"]
|
|
.as_object()
|
|
.and_then(|d| d.get("secs"))
|
|
.and_then(|s| s.as_f64())
|
|
.or_else(|| json["duration"].as_f64())
|
|
.map(|secs| {
|
|
let total_secs = secs as u64;
|
|
format!(
|
|
"{}:{:02}:{:02}",
|
|
total_secs / 3600,
|
|
(total_secs % 3600) / 60,
|
|
total_secs % 60
|
|
)
|
|
});
|
|
|
|
// Create the item with current metadata
|
|
let item = Item {
|
|
id: format!("radio-paradise:channel:{}:live", slug),
|
|
parent_id: format!("radio-paradise:channel:{}", slug),
|
|
restricted: Some("1".to_string()),
|
|
title,
|
|
creator: artist.clone(),
|
|
class: "object.item.audioItem.audioBroadcast".to_string(),
|
|
artist,
|
|
album,
|
|
genre: Some("Radio".to_string()),
|
|
album_art: cover_url,
|
|
album_art_pk: cover_pk,
|
|
date: year.map(|y| y.to_string()),
|
|
original_track_number: None,
|
|
resources: vec![Resource {
|
|
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
|
bits_per_sample: None,
|
|
sample_frequency: None,
|
|
nr_audio_channels: Some("2".to_string()),
|
|
duration,
|
|
url: self.build_live_url(slug),
|
|
}],
|
|
descriptions: vec![],
|
|
};
|
|
|
|
Ok(Some(item))
|
|
}
|
|
Err(_) => Ok(None),
|
|
}
|
|
}
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// Get the playlist ID for a channel's history
|
|
#[cfg(feature = "playlist")]
|
|
fn history_playlist_id(slug: &str) -> String {
|
|
// Must match the prefix used in ParadiseHistoryBuilder
|
|
format!("radio-paradise-history-{}", slug)
|
|
}
|
|
|
|
/// Live playlist id for a channel
|
|
fn live_playlist_id(slug: &str) -> String {
|
|
format!("radio-paradise-live-{}", slug)
|
|
}
|
|
|
|
#[cfg(feature = "playlist")]
|
|
async fn wait_for_live_playlist_ready(&self, slug: &str) -> Result<()> {
|
|
let playlist_id = Self::live_playlist_id(slug);
|
|
let manager = pmoplaylist::PlaylistManager();
|
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
|
MusicSourceError::BrowseError(format!(
|
|
"Failed to get live playlist {}: {}",
|
|
playlist_id, e
|
|
))
|
|
})?;
|
|
let start = Instant::now();
|
|
loop {
|
|
match reader.remaining().await {
|
|
Ok(count) if count >= LIVE_PLAYLIST_MIN_READY_ITEMS => return Ok(()),
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err(MusicSourceError::BrowseError(format!(
|
|
"Failed to inspect live playlist {}: {}",
|
|
playlist_id, e
|
|
)));
|
|
}
|
|
}
|
|
|
|
if start.elapsed() >= LIVE_PLAYLIST_READY_TIMEOUT {
|
|
tracing::warn!(
|
|
"Timeout waiting for live playlist {} to reach {} items",
|
|
playlist_id,
|
|
LIVE_PLAYLIST_MIN_READY_ITEMS
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
tokio::time::sleep(LIVE_PLAYLIST_READY_POLL).await;
|
|
}
|
|
}
|
|
|
|
/// Get channel descriptor by slug
|
|
fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> {
|
|
ALL_CHANNELS.iter().find(|ch| ch.slug == slug)
|
|
}
|
|
|
|
/// Parse an object ID into its components
|
|
fn parse_object_id(id: &str) -> ObjectIdType {
|
|
let parts: Vec<&str> = id.split(':').collect();
|
|
match parts.as_slice() {
|
|
["radio-paradise"] => ObjectIdType::Root,
|
|
["radio-paradise", "channel", slug] => ObjectIdType::Channel {
|
|
slug: (*slug).to_string(),
|
|
},
|
|
["radio-paradise", "channel", slug, "live"] => ObjectIdType::LiveStream {
|
|
slug: (*slug).to_string(),
|
|
},
|
|
["radio-paradise", "channel", slug, "liveplaylist"] => ObjectIdType::LivePlaylist {
|
|
slug: (*slug).to_string(),
|
|
},
|
|
["radio-paradise", "channel", slug, "liveplaylist", "track", pk] => {
|
|
ObjectIdType::LivePlaylistTrack {
|
|
slug: (*slug).to_string(),
|
|
pk: (*pk).to_string(),
|
|
}
|
|
}
|
|
["radio-paradise", "channel", slug, "history"] => ObjectIdType::History {
|
|
slug: (*slug).to_string(),
|
|
},
|
|
["radio-paradise", "channel", slug, "history", "track", pk] => {
|
|
ObjectIdType::HistoryTrack {
|
|
slug: (*slug).to_string(),
|
|
pk: (*pk).to_string(),
|
|
}
|
|
}
|
|
_ => ObjectIdType::Unknown,
|
|
}
|
|
}
|
|
|
|
/// Build a channel container
|
|
fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
|
Container {
|
|
id: format!("radio-paradise:channel:{}", descriptor.slug),
|
|
parent_id: "radio-paradise".to_string(),
|
|
restricted: Some("1".to_string()),
|
|
child_count: None,
|
|
searchable: Some("1".to_string()),
|
|
title: descriptor.display_name.to_string(),
|
|
class: "object.container".to_string(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build the live playlist container for a channel
|
|
fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
|
Container {
|
|
id: format!("radio-paradise:channel:{}:liveplaylist", descriptor.slug),
|
|
parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
|
|
restricted: Some("1".to_string()),
|
|
child_count: None,
|
|
searchable: Some("0".to_string()),
|
|
title: format!("{} - Live Playlist", descriptor.display_name),
|
|
class: "object.container.playlistContainer".to_string(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build a live stream item for a channel
|
|
fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item {
|
|
let stream_url = self.build_live_url(descriptor.slug);
|
|
|
|
Item {
|
|
id: format!("radio-paradise:channel:{}:live", descriptor.slug),
|
|
parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
|
|
restricted: Some("1".to_string()),
|
|
title: format!("{} - Live Stream", descriptor.display_name),
|
|
creator: Some("Radio Paradise".to_string()),
|
|
class: "object.item.audioItem.audioBroadcast".to_string(),
|
|
artist: Some("Radio Paradise".to_string()),
|
|
album: Some(descriptor.display_name.to_string()),
|
|
genre: Some("Radio".to_string()),
|
|
album_art: Some(self.default_cover_url()),
|
|
album_art_pk: None,
|
|
date: None,
|
|
original_track_number: None,
|
|
resources: vec![
|
|
Resource {
|
|
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
|
bits_per_sample: Some("16".to_string()),
|
|
sample_frequency: Some("44100".to_string()),
|
|
nr_audio_channels: Some("2".to_string()),
|
|
duration: None,
|
|
url: stream_url.clone(),
|
|
},
|
|
Resource {
|
|
protocol_info: "http-get:*:audio/ogg:*".to_string(),
|
|
bits_per_sample: Some("16".to_string()),
|
|
sample_frequency: Some("44100".to_string()),
|
|
nr_audio_channels: Some("2".to_string()),
|
|
duration: None,
|
|
url: self.build_live_ogg_url(descriptor.slug),
|
|
},
|
|
],
|
|
descriptions: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build a history container for a channel
|
|
fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
|
Container {
|
|
id: format!("radio-paradise:channel:{}:history", descriptor.slug),
|
|
parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
|
|
restricted: Some("1".to_string()),
|
|
child_count: None,
|
|
searchable: Some("1".to_string()),
|
|
title: format!("{} - History", descriptor.display_name),
|
|
// Expose l'historique comme une playlist jouable
|
|
class: "object.container.playlistContainer".to_string(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
}
|
|
}
|
|
|
|
/// Build a history container with accurate child count from playlist
|
|
#[cfg(feature = "playlist")]
|
|
async fn build_history_container_with_count(
|
|
&self,
|
|
descriptor: &ChannelDescriptor,
|
|
) -> Container {
|
|
let mut container = self.build_history_container(descriptor);
|
|
|
|
// Try to get actual count from playlist
|
|
let playlist_id = Self::history_playlist_id(descriptor.slug);
|
|
let manager = pmoplaylist::PlaylistManager();
|
|
|
|
if let Ok(reader) = manager.get_read_handle(&playlist_id).await {
|
|
if let Ok(count) = reader.remaining().await {
|
|
container.child_count = Some(count.to_string());
|
|
}
|
|
}
|
|
|
|
container
|
|
}
|
|
|
|
/// Get items from history playlist
|
|
#[cfg(feature = "playlist")]
|
|
async fn get_history_items(
|
|
&self,
|
|
slug: &str,
|
|
_offset: usize,
|
|
count: usize,
|
|
) -> Result<Vec<Item>> {
|
|
let playlist_id = Self::history_playlist_id(slug);
|
|
|
|
// Get read handle for the playlist from the singleton
|
|
let manager = pmoplaylist::PlaylistManager();
|
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
|
MusicSourceError::BrowseError(format!("Failed to get playlist {}: {}", playlist_id, e))
|
|
})?;
|
|
|
|
// Get items from playlist (to_items starts from cursor position)
|
|
let mut items = reader.to_items(count).await.map_err(|e| {
|
|
MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e))
|
|
})?;
|
|
|
|
// Transform item IDs, parent_ids, and resource URLs to match Radio Paradise schema
|
|
// Expected: radio-paradise:channel:{slug}:history:track:{pk}
|
|
// Parent: radio-paradise:channel:{slug}:history
|
|
for item in items.iter_mut() {
|
|
// Extract cache_pk from the resource URL (last segment)
|
|
if let Some(resource) = item.resources.first_mut() {
|
|
if let Some(pk) = resource.url.split('/').last() {
|
|
// Update item ID and parent ID
|
|
item.id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk);
|
|
item.parent_id = format!("radio-paradise:channel:{}:history", slug);
|
|
|
|
// Convert relative URL to absolute URL
|
|
// From: /audio/flac/pk
|
|
// To: http://base_url/audio/flac/pk
|
|
if resource.url.starts_with('/') {
|
|
resource.url = format!("{}{}", self.base_url, resource.url);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fix: Ajouter un genre par défaut si absent
|
|
// Certains clients UPnP (comme gupnp-av-cp) requièrent le champ <upnp:genre>
|
|
// pour parser correctement les items de classe musicTrack, même si ce champ
|
|
// est optionnel selon la spec UPnP ContentDirectory.
|
|
if item.genre.is_none() {
|
|
item.genre = Some("Radio Paradise".to_string());
|
|
}
|
|
|
|
// Normaliser l'albumArtURI : rendre absolu si chemin relatif, sinon fallback par défaut
|
|
item.album_art = match item.album_art.take() {
|
|
Some(art) if art.starts_with('/') => Some(format!("{}{}", self.base_url, art)),
|
|
Some(_) => Some(self.default_cover_url()), // URL externe brute → fallback local
|
|
None => Some(self.default_cover_url()),
|
|
};
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
/// Get items from live playlist (current stream queue)
|
|
#[cfg(feature = "playlist")]
|
|
async fn get_live_playlist_items(
|
|
&self,
|
|
slug: &str,
|
|
_offset: usize,
|
|
count: usize,
|
|
) -> Result<Vec<Item>> {
|
|
#[cfg(all(feature = "playlist", feature = "pmoaudio"))]
|
|
if let Some(descriptor) = Self::get_channel_by_slug(slug) {
|
|
if let Some(manager) = crate::stream_channel::get_global_channel_manager() {
|
|
if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await {
|
|
tracing::warn!(
|
|
"Failed to prefetch live playlist for {}: {}",
|
|
descriptor.slug,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "playlist")]
|
|
if let Err(e) = self.wait_for_live_playlist_ready(slug).await {
|
|
tracing::warn!(
|
|
"Failed to wait for live playlist readiness on {}: {}",
|
|
slug,
|
|
e
|
|
);
|
|
}
|
|
|
|
let playlist_id = Self::live_playlist_id(slug);
|
|
|
|
let manager = pmoplaylist::PlaylistManager();
|
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
|
MusicSourceError::BrowseError(format!(
|
|
"Failed to get live playlist {}: {}",
|
|
playlist_id, e
|
|
))
|
|
})?;
|
|
|
|
let mut items = reader.to_items(count).await.map_err(|e| {
|
|
MusicSourceError::BrowseError(format!("Failed to read live playlist entries: {}", e))
|
|
})?;
|
|
|
|
for item in items.iter_mut() {
|
|
// Ajuster id/parent/url pour coller au schéma Radio Paradise
|
|
if let Some(resource) = item.resources.first_mut() {
|
|
if let Some(pk) = resource.url.split('/').last() {
|
|
item.id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
|
|
item.parent_id = format!("radio-paradise:channel:{}:liveplaylist", slug);
|
|
|
|
if resource.url.starts_with('/') {
|
|
resource.url = format!("{}{}", self.base_url, resource.url);
|
|
}
|
|
}
|
|
}
|
|
|
|
if item.genre.is_none() {
|
|
item.genre = Some("Radio Paradise".to_string());
|
|
}
|
|
|
|
item.album_art = match item.album_art.take() {
|
|
Some(art) if art.starts_with('/') => Some(format!("{}{}", self.base_url, art)),
|
|
Some(_) => Some(self.default_cover_url()),
|
|
None => Some(self.default_cover_url()),
|
|
};
|
|
}
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
/// Get a single item from the live playlist by pk
|
|
#[cfg(feature = "playlist")]
|
|
async fn get_live_playlist_item(&self, slug: &str, pk: &str) -> Result<Item> {
|
|
let items = self.get_live_playlist_items(slug, 0, 1000).await?;
|
|
let expected_id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
|
|
for item in items {
|
|
if item.id == expected_id {
|
|
return Ok(item);
|
|
}
|
|
}
|
|
Err(MusicSourceError::ObjectNotFound(format!(
|
|
"Track with pk {} not found in live playlist",
|
|
pk
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Types of object IDs in the Radio Paradise source
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum ObjectIdType {
|
|
Root,
|
|
Channel { slug: String },
|
|
LiveStream { slug: String },
|
|
LivePlaylist { slug: String },
|
|
LivePlaylistTrack { slug: String, pk: String },
|
|
History { slug: String },
|
|
HistoryTrack { slug: String, pk: String },
|
|
Unknown,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MusicSource for RadioParadiseSource {
|
|
fn name(&self) -> &str {
|
|
"Radio Paradise"
|
|
}
|
|
|
|
fn id(&self) -> &str {
|
|
"radio-paradise"
|
|
}
|
|
|
|
fn default_image(&self) -> &[u8] {
|
|
DEFAULT_IMAGE
|
|
}
|
|
|
|
async fn root_container(&self) -> Result<Container> {
|
|
Ok(Container {
|
|
id: "radio-paradise".to_string(),
|
|
parent_id: "0".to_string(),
|
|
restricted: Some("1".to_string()),
|
|
// childCount retiré pour éviter les soucis de compatibilité côté CP
|
|
child_count: None,
|
|
searchable: Some("1".to_string()),
|
|
title: "Radio Paradise".to_string(),
|
|
class: "object.container".to_string(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
})
|
|
}
|
|
|
|
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
|
|
match Self::parse_object_id(object_id) {
|
|
ObjectIdType::Root => {
|
|
// Return the 4 channel containers
|
|
let containers: Vec<Container> = ALL_CHANNELS
|
|
.iter()
|
|
.map(|ch| self.build_channel_container(ch))
|
|
.collect();
|
|
|
|
Ok(BrowseResult::Containers(containers))
|
|
}
|
|
|
|
ObjectIdType::Channel { slug } => {
|
|
// Return live stream item + history container
|
|
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
|
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
|
})?;
|
|
|
|
let live_item = self.build_live_stream_item(descriptor);
|
|
let live_playlist_container = self.build_live_playlist_container(descriptor);
|
|
|
|
#[cfg(feature = "playlist")]
|
|
let history_container = self.build_history_container_with_count(descriptor).await;
|
|
#[cfg(not(feature = "playlist"))]
|
|
let history_container = self.build_history_container(descriptor);
|
|
|
|
Ok(BrowseResult::Mixed {
|
|
containers: vec![live_playlist_container, history_container],
|
|
items: vec![live_item],
|
|
})
|
|
}
|
|
|
|
ObjectIdType::History { slug } => {
|
|
// Return history container (for BrowseMetadata) and items (for BrowseDirectChildren)
|
|
// The content_handler will filter out the container when browsing direct children
|
|
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
|
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
|
})?;
|
|
|
|
#[cfg(feature = "playlist")]
|
|
{
|
|
let history_container =
|
|
self.build_history_container_with_count(descriptor).await;
|
|
let items = self.get_history_items(&slug, 0, 100).await?;
|
|
Ok(BrowseResult::Mixed {
|
|
containers: vec![history_container],
|
|
items,
|
|
})
|
|
}
|
|
|
|
#[cfg(not(feature = "playlist"))]
|
|
{
|
|
// If playlist feature is disabled, return just the container
|
|
let history_container = self.build_history_container(descriptor);
|
|
Ok(BrowseResult::Containers(vec![history_container]))
|
|
}
|
|
}
|
|
|
|
ObjectIdType::LiveStream { slug } => {
|
|
// Return metadata for the live stream item
|
|
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
|
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
|
})?;
|
|
let item = self.build_live_stream_item(descriptor);
|
|
Ok(BrowseResult::Items(vec![item]))
|
|
}
|
|
|
|
ObjectIdType::LivePlaylist { slug } => {
|
|
// Playlist du live : container + items
|
|
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
|
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
|
})?;
|
|
|
|
#[cfg(feature = "playlist")]
|
|
{
|
|
let container = self.build_live_playlist_container(descriptor);
|
|
let items = self.get_live_playlist_items(&slug, 0, 100).await?;
|
|
Ok(BrowseResult::Mixed {
|
|
containers: vec![container],
|
|
items,
|
|
})
|
|
}
|
|
|
|
#[cfg(not(feature = "playlist"))]
|
|
{
|
|
let container = self.build_live_playlist_container(descriptor);
|
|
Ok(BrowseResult::Containers(vec![container]))
|
|
}
|
|
}
|
|
|
|
ObjectIdType::HistoryTrack { slug: _, pk: _ } => {
|
|
// Return metadata for the history track item
|
|
let item = self.get_item(object_id).await?;
|
|
Ok(BrowseResult::Items(vec![item]))
|
|
}
|
|
|
|
ObjectIdType::LivePlaylistTrack { slug, pk } => {
|
|
// Détails d'un titre du live (playlist live)
|
|
#[cfg(feature = "playlist")]
|
|
{
|
|
let item = self.get_live_playlist_item(&slug, &pk).await?;
|
|
Ok(BrowseResult::Items(vec![item]))
|
|
}
|
|
|
|
#[cfg(not(feature = "playlist"))]
|
|
{
|
|
let _ = (slug, pk);
|
|
Err(MusicSourceError::NotSupported(
|
|
"Playlist feature not enabled".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(format!(
|
|
"Unknown object ID: {}",
|
|
object_id
|
|
))),
|
|
}
|
|
}
|
|
|
|
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
|
match Self::parse_object_id(object_id) {
|
|
ObjectIdType::LiveStream { slug } => {
|
|
// Return live stream URL
|
|
Ok(self.build_live_url(&slug))
|
|
}
|
|
|
|
ObjectIdType::HistoryTrack { pk, .. } => {
|
|
// Return cached audio URL
|
|
Ok(format!("{}/cache/audio/{}", self.base_url, pk))
|
|
}
|
|
|
|
ObjectIdType::LivePlaylistTrack { pk, .. } => {
|
|
// Return cached audio URL
|
|
Ok(format!("{}/cache/audio/{}", self.base_url, pk))
|
|
}
|
|
|
|
_ => Err(MusicSourceError::ObjectNotFound(format!(
|
|
"Cannot resolve URI for object: {}",
|
|
object_id
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn capabilities(&self) -> SourceCapabilities {
|
|
SourceCapabilities {
|
|
supports_fifo: self.supports_fifo(),
|
|
supports_search: false,
|
|
supports_favorites: false,
|
|
supports_playlists: false,
|
|
supports_user_content: false,
|
|
supports_high_res_audio: true,
|
|
max_sample_rate: Some(44100),
|
|
supports_multiple_formats: true,
|
|
supports_advanced_search: false,
|
|
supports_pagination: false,
|
|
handles_url_input: false,
|
|
}
|
|
}
|
|
|
|
async fn get_available_formats(&self, object_id: &str) -> Result<Vec<AudioFormat>> {
|
|
match Self::parse_object_id(object_id) {
|
|
ObjectIdType::LiveStream { .. } => Ok(vec![
|
|
AudioFormat {
|
|
format_id: "flac".to_string(),
|
|
mime_type: "audio/flac".to_string(),
|
|
sample_rate: Some(44100),
|
|
bit_depth: Some(16),
|
|
bitrate: None,
|
|
channels: Some(2),
|
|
},
|
|
AudioFormat {
|
|
format_id: "ogg-flac".to_string(),
|
|
mime_type: "audio/ogg".to_string(),
|
|
sample_rate: Some(44100),
|
|
bit_depth: Some(16),
|
|
bitrate: None,
|
|
channels: Some(2),
|
|
},
|
|
]),
|
|
ObjectIdType::HistoryTrack { .. } => Ok(vec![AudioFormat {
|
|
format_id: "flac".to_string(),
|
|
mime_type: "audio/flac".to_string(),
|
|
sample_rate: Some(44100),
|
|
bit_depth: Some(16),
|
|
bitrate: None,
|
|
channels: Some(2),
|
|
}]),
|
|
ObjectIdType::LivePlaylistTrack { .. } => Ok(vec![AudioFormat {
|
|
format_id: "flac".to_string(),
|
|
mime_type: "audio/flac".to_string(),
|
|
sample_rate: Some(44100),
|
|
bit_depth: Some(16),
|
|
bitrate: None,
|
|
channels: Some(2),
|
|
}]),
|
|
_ => Err(MusicSourceError::ObjectNotFound(format!(
|
|
"Cannot list formats for object: {}",
|
|
object_id
|
|
))),
|
|
}
|
|
}
|
|
|
|
async fn get_item(&self, object_id: &str) -> Result<Item> {
|
|
match Self::parse_object_id(object_id) {
|
|
ObjectIdType::LiveStream { slug } => {
|
|
// Try to fetch current metadata from live stream
|
|
if let Ok(Some(item)) = self.fetch_live_metadata(&slug).await {
|
|
return Ok(item);
|
|
}
|
|
|
|
// Fallback to static item if metadata fetch fails
|
|
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
|
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
|
})?;
|
|
Ok(self.build_live_stream_item(descriptor))
|
|
}
|
|
|
|
ObjectIdType::HistoryTrack { slug, pk } => {
|
|
// Get from history playlist
|
|
#[cfg(feature = "playlist")]
|
|
{
|
|
let playlist_id = Self::history_playlist_id(&slug);
|
|
let manager = pmoplaylist::PlaylistManager();
|
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
|
MusicSourceError::BrowseError(format!(
|
|
"Failed to get playlist {}: {}",
|
|
playlist_id, e
|
|
))
|
|
})?;
|
|
|
|
// Try to find the item with this pk
|
|
let items = reader.to_items(1000).await.map_err(|e| {
|
|
MusicSourceError::BrowseError(format!(
|
|
"Failed to read playlist entries: {}",
|
|
e
|
|
))
|
|
})?;
|
|
|
|
// Ajuster les IDs/parent_id/URL pour coller au schéma Radio Paradise,
|
|
// comme dans get_history_items.
|
|
let mut adjusted = Vec::new();
|
|
for mut item in items {
|
|
if let Some(resource) = item.resources.first_mut() {
|
|
if let Some(pk2) = resource.url.split('/').last() {
|
|
item.id = format!(
|
|
"radio-paradise:channel:{}:history:track:{}",
|
|
slug, pk2
|
|
);
|
|
item.parent_id = format!("radio-paradise:channel:{}:history", slug);
|
|
|
|
if resource.url.starts_with('/') {
|
|
resource.url = format!("{}{}", self.base_url, resource.url);
|
|
}
|
|
}
|
|
}
|
|
if item.genre.is_none() {
|
|
item.genre = Some("Radio Paradise".to_string());
|
|
}
|
|
item.album_art = match item.album_art.take() {
|
|
Some(art) if art.starts_with('/') => {
|
|
Some(format!("{}{}", self.base_url, art))
|
|
}
|
|
Some(_) => Some(self.default_cover_url()),
|
|
None => Some(self.default_cover_url()),
|
|
};
|
|
adjusted.push(item);
|
|
}
|
|
|
|
// Find the item matching this pk in the item ID
|
|
let expected_id =
|
|
format!("radio-paradise:channel:{}:history:track:{}", slug, pk);
|
|
for item in adjusted {
|
|
if item.id == expected_id {
|
|
return Ok(item);
|
|
}
|
|
}
|
|
|
|
Err(MusicSourceError::ObjectNotFound(format!(
|
|
"Track with pk {} not found in history",
|
|
pk
|
|
)))
|
|
}
|
|
|
|
#[cfg(not(feature = "playlist"))]
|
|
{
|
|
let _ = (slug, pk);
|
|
Err(MusicSourceError::NotSupported(
|
|
"Playlist feature not enabled".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
ObjectIdType::LivePlaylistTrack { slug, pk } => {
|
|
#[cfg(feature = "playlist")]
|
|
{
|
|
let playlist_id = Self::live_playlist_id(&slug);
|
|
let manager = pmoplaylist::PlaylistManager();
|
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
|
MusicSourceError::BrowseError(format!(
|
|
"Failed to get live playlist {}: {}",
|
|
playlist_id, e
|
|
))
|
|
})?;
|
|
|
|
let items = reader.to_items(1000).await.map_err(|e| {
|
|
MusicSourceError::BrowseError(format!(
|
|
"Failed to read live playlist entries: {}",
|
|
e
|
|
))
|
|
})?;
|
|
|
|
for mut item in items {
|
|
if let Some(resource) = item.resources.first_mut() {
|
|
if let Some(pk2) = resource.url.split('/').last() {
|
|
item.id = format!(
|
|
"radio-paradise:channel:{}:liveplaylist:track:{}",
|
|
slug, pk2
|
|
);
|
|
item.parent_id =
|
|
format!("radio-paradise:channel:{}:liveplaylist", slug);
|
|
|
|
if resource.url.starts_with('/') {
|
|
resource.url = format!("{}{}", self.base_url, resource.url);
|
|
}
|
|
}
|
|
}
|
|
|
|
if item.genre.is_none() {
|
|
item.genre = Some("Radio Paradise".to_string());
|
|
}
|
|
|
|
item.album_art = match item.album_art.take() {
|
|
Some(art) if art.starts_with('/') => {
|
|
Some(format!("{}{}", self.base_url, art))
|
|
}
|
|
Some(_) => Some(self.default_cover_url()),
|
|
None => Some(self.default_cover_url()),
|
|
};
|
|
|
|
let expected_id =
|
|
format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
|
|
if item.id == expected_id {
|
|
return Ok(item);
|
|
}
|
|
}
|
|
|
|
Err(MusicSourceError::ObjectNotFound(format!(
|
|
"Track with pk {} not found in live playlist",
|
|
pk
|
|
)))
|
|
}
|
|
|
|
#[cfg(not(feature = "playlist"))]
|
|
{
|
|
let _ = (slug, pk);
|
|
Err(MusicSourceError::NotSupported(
|
|
"Playlist feature not enabled".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
_ => Err(MusicSourceError::ObjectNotFound(format!(
|
|
"Cannot get item for object: {}",
|
|
object_id
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn supports_fifo(&self) -> bool {
|
|
// History playlists are FIFO
|
|
cfg!(feature = "playlist")
|
|
}
|
|
|
|
async fn append_track(&self, _track: Item) -> Result<()> {
|
|
// Tracks are added automatically by FlacCacheSink
|
|
Err(MusicSourceError::NotSupported(
|
|
"Tracks are automatically added to history by the streaming system".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
|
// Managed automatically by playlist FIFO
|
|
Ok(None)
|
|
}
|
|
|
|
async fn update_id(&self) -> u32 {
|
|
*self.update_counter.read().await
|
|
}
|
|
|
|
async fn last_change(&self) -> Option<SystemTime> {
|
|
Some(*self.last_change.read().await)
|
|
}
|
|
|
|
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
|
|
// For Radio Paradise, we don't have a global FIFO
|
|
// Each channel has its own history
|
|
// Return empty for now - clients should browse specific channel histories
|
|
let _ = (offset, count);
|
|
Ok(vec![])
|
|
}
|
|
}
|