2025-11-15 15:43:42 +01:00
|
|
|
//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise
|
2025-10-16 22:00:35 +02:00
|
|
|
//!
|
2025-11-15 15:43:42 +01:00
|
|
|
//! This module provides a UPnP ContentDirectory source for Radio Paradise,
|
|
|
|
|
//! exposing live streams and historical playlists for all 4 channels.
|
2025-10-16 22:00:35 +02:00
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
|
|
|
|
|
use pmosource::pmodidl::{Container, Item, Resource};
|
2025-11-26 18:30:30 +01:00
|
|
|
use pmosource::{
|
|
|
|
|
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result,
|
|
|
|
|
SourceCapabilities,
|
|
|
|
|
};
|
2025-11-29 14:19:16 +01:00
|
|
|
use std::fmt;
|
2025-11-15 15:43:42 +01:00
|
|
|
use std::sync::Arc;
|
2025-11-29 14:19:16 +01:00
|
|
|
use std::time::{Duration, Instant, SystemTime};
|
2025-11-15 15:43:42 +01:00
|
|
|
use tokio::sync::RwLock;
|
|
|
|
|
|
2025-11-05 07:40:36 +00:00
|
|
|
/// Default Radio Paradise image (embedded in binary)
|
2025-10-16 22:00:35 +02:00
|
|
|
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
|
|
|
|
|
2025-11-29 14:19:16 +01:00
|
|
|
#[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);
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
|
|
|
|
|
///
|
|
|
|
|
/// Provides access to:
|
2025-11-26 18:30:30 +01:00
|
|
|
/// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic)
|
2025-11-15 15:43:42 +01:00
|
|
|
/// - Historical playlists (FIFO) for each channel
|
2025-11-05 07:40:36 +00:00
|
|
|
///
|
2025-11-15 15:43:42 +01:00
|
|
|
/// # Object ID Schema
|
2025-11-05 07:40:36 +00:00
|
|
|
///
|
2025-11-15 15:43:42 +01:00
|
|
|
/// - Root: `radio-paradise`
|
|
|
|
|
/// - Channel container: `radio-paradise:channel:{slug}`
|
|
|
|
|
/// - Live stream item: `radio-paradise:channel:{slug}:live`
|
2025-11-29 01:08:07 +01:00
|
|
|
/// - Live playlist container: `radio-paradise:channel:{slug}:liveplaylist`
|
|
|
|
|
/// - Live playlist track: `radio-paradise:channel:{slug}:liveplaylist:track:{pk}`
|
2025-11-15 15:43:42 +01:00
|
|
|
/// - History container: `radio-paradise:channel:{slug}:history`
|
|
|
|
|
/// - History track: `radio-paradise:channel:{slug}:history:track:{pk}`
|
2025-11-29 01:56:40 +01:00
|
|
|
#[derive(Clone)]
|
2025-10-16 22:13:00 +02:00
|
|
|
pub struct RadioParadiseSource {
|
2025-11-15 15:43:42 +01:00
|
|
|
/// 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>>,
|
2025-11-29 01:08:07 +01:00
|
|
|
/// Tokens des callbacks enregistrés auprès du PlaylistManager
|
|
|
|
|
callback_tokens: Arc<std::sync::Mutex<Vec<u64>>>,
|
2025-11-29 01:56:40 +01:00
|
|
|
/// 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()
|
|
|
|
|
}
|
2025-10-16 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RadioParadiseSource {
|
2025-11-15 15:43:42 +01:00
|
|
|
/// Create a new RadioParadiseSource
|
2025-11-05 07:40:36 +00:00
|
|
|
///
|
2025-11-15 15:43:42 +01:00
|
|
|
/// # Arguments
|
2025-11-05 07:40:36 +00:00
|
|
|
///
|
2025-11-15 15:43:42 +01:00
|
|
|
/// * `base_url` - Base URL for streaming server (e.g., "http://localhost:8080")
|
2025-11-05 07:40:36 +00:00
|
|
|
///
|
2025-11-15 15:43:42 +01:00
|
|
|
/// # Note
|
2025-11-05 07:40:36 +00:00
|
|
|
///
|
2025-11-15 15:43:42 +01:00
|
|
|
/// 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())),
|
2025-11-29 01:08:07 +01:00
|
|
|
callback_tokens: Arc::new(std::sync::Mutex::new(Vec::new())),
|
2025-11-29 01:56:40 +01:00
|
|
|
container_notifier: None,
|
2025-11-15 15:43:42 +01:00
|
|
|
}
|
2025-11-05 07:40:36 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:56:40 +01:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
/// Build a live stream URL for a channel
|
|
|
|
|
fn build_live_url(&self, slug: &str) -> String {
|
2025-11-26 18:30:30 +01:00
|
|
|
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 {
|
2025-11-15 15:43:42 +01:00
|
|
|
format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug)
|
2025-11-05 07:40:36 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:08:07 +01:00
|
|
|
/// 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);
|
2025-11-29 01:56:40 +01:00
|
|
|
let pid_clone = pid.clone();
|
2025-11-29 12:55:21 +01:00
|
|
|
let token = mgr.register_callback(move |event| {
|
2025-11-29 01:56:40 +01:00
|
|
|
let pid = pid_clone.clone();
|
2025-11-29 12:55:21 +01:00
|
|
|
if event.playlist_id == pid {
|
2025-12-28 15:27:48 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
2025-11-29 12:55:21 +01:00
|
|
|
// On ne réagit qu'aux mises à jour structurelles (ajout/suppression)
|
|
|
|
|
if !matches!(event.kind, pmoplaylist::PlaylistEventKind::Updated) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-29 01:08:07 +01:00
|
|
|
if let Some(strong) = weak.upgrade() {
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
strong.bump_update_counter().await;
|
2025-11-29 01:56:40 +01:00
|
|
|
// 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))
|
2025-11-29 14:19:16 +01:00
|
|
|
.map(|ch| {
|
|
|
|
|
vec![format!("radio-paradise:channel:{}:history", ch.slug)]
|
|
|
|
|
})
|
2025-11-29 01:56:40 +01:00
|
|
|
.unwrap_or_default()
|
|
|
|
|
} else {
|
|
|
|
|
// live playlist -> container liveplaylist
|
|
|
|
|
ALL_CHANNELS
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|ch| pid.ends_with(ch.slug))
|
2025-11-29 14:19:16 +01:00
|
|
|
.map(|ch| {
|
|
|
|
|
vec![format!(
|
|
|
|
|
"radio-paradise:channel:{}:liveplaylist",
|
|
|
|
|
ch.slug
|
|
|
|
|
)]
|
|
|
|
|
})
|
2025-11-29 01:56:40 +01:00
|
|
|
.unwrap_or_default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if !containers.is_empty() {
|
|
|
|
|
if let Some(notifier) = strong.container_notifier.as_ref() {
|
|
|
|
|
notifier(&containers);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-29 01:08:07 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
tokens.push(token);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 18:30:30 +01:00
|
|
|
/// 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())
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 12:20:28 +01:00
|
|
|
/// 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
|
2025-11-29 14:19:16 +01:00
|
|
|
let title = json["title"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.unwrap_or("Unknown Title")
|
|
|
|
|
.to_string();
|
2025-11-26 12:20:28 +01:00
|
|
|
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);
|
2025-11-26 18:30:30 +01:00
|
|
|
// 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());
|
|
|
|
|
let cover_url = cover_pk
|
|
|
|
|
.as_ref()
|
2025-11-28 21:22:39 +01:00
|
|
|
.map(|pk| format!("{}/covers/jpeg/{}", self.base_url, pk))
|
2025-11-26 18:30:30 +01:00
|
|
|
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string()))
|
|
|
|
|
.or_else(|| Some(self.default_cover_url()));
|
2025-11-26 12:20:28 +01:00
|
|
|
|
|
|
|
|
// 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;
|
2025-11-29 14:19:16 +01:00
|
|
|
format!(
|
|
|
|
|
"{}:{:02}:{:02}",
|
2025-11-26 12:20:28 +01:00
|
|
|
total_secs / 3600,
|
|
|
|
|
(total_secs % 3600) / 60,
|
2025-11-29 14:19:16 +01:00
|
|
|
total_secs % 60
|
|
|
|
|
)
|
2025-11-26 12:20:28 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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,
|
2025-11-26 18:30:30 +01:00
|
|
|
album_art_pk: cover_pk,
|
2025-11-26 12:20:28 +01:00
|
|
|
date: year.map(|y| y.to_string()),
|
|
|
|
|
original_track_number: None,
|
|
|
|
|
resources: vec![Resource {
|
2025-11-26 18:30:30 +01:00
|
|
|
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
2025-11-26 12:20:28 +01:00
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
/// Get the playlist ID for a channel's history
|
|
|
|
|
#[cfg(feature = "playlist")]
|
|
|
|
|
fn history_playlist_id(slug: &str) -> String {
|
2025-11-27 09:50:21 +01:00
|
|
|
// Must match the prefix used in ParadiseHistoryBuilder
|
|
|
|
|
format!("radio-paradise-history-{}", slug)
|
2025-11-15 15:43:42 +01:00
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:08:07 +01:00
|
|
|
/// Live playlist id for a channel
|
|
|
|
|
fn live_playlist_id(slug: &str) -> String {
|
|
|
|
|
format!("radio-paradise-live-{}", slug)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 14:19:16 +01:00
|
|
|
#[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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
/// 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(),
|
|
|
|
|
},
|
2025-11-29 01:08:07 +01:00
|
|
|
["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(),
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-15 15:43:42 +01:00
|
|
|
["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()),
|
2025-11-28 22:15:44 +01:00
|
|
|
child_count: None,
|
2025-11-28 23:13:59 +01:00
|
|
|
searchable: Some("1".to_string()),
|
2025-11-15 15:43:42 +01:00
|
|
|
title: descriptor.display_name.to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-11-15 15:43:42 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:08:07 +01:00
|
|
|
/// 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(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-11-29 01:08:07 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
/// 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()),
|
2025-11-26 18:30:30 +01:00
|
|
|
album_art: Some(self.default_cover_url()),
|
2025-11-15 15:43:42 +01:00
|
|
|
album_art_pk: None,
|
|
|
|
|
date: None,
|
|
|
|
|
original_track_number: None,
|
2025-11-26 18:30:30 +01:00
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
],
|
2025-11-15 15:43:42 +01:00
|
|
|
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()),
|
2025-11-28 22:15:44 +01:00
|
|
|
child_count: None,
|
2025-11-15 15:43:42 +01:00
|
|
|
searchable: Some("1".to_string()),
|
|
|
|
|
title: format!("{} - History", descriptor.display_name),
|
2025-11-28 20:30:42 +01:00
|
|
|
// Expose l'historique comme une playlist jouable
|
|
|
|
|
class: "object.container.playlistContainer".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-11-15 15:43:42 +01:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-27 09:50:21 +01:00
|
|
|
/// Build a history container with accurate child count from playlist
|
|
|
|
|
#[cfg(feature = "playlist")]
|
2025-11-29 14:19:16 +01:00
|
|
|
async fn build_history_container_with_count(
|
|
|
|
|
&self,
|
|
|
|
|
descriptor: &ChannelDescriptor,
|
|
|
|
|
) -> Container {
|
2025-11-27 09:50:21 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
/// Get items from history playlist
|
|
|
|
|
#[cfg(feature = "playlist")]
|
|
|
|
|
async fn get_history_items(
|
|
|
|
|
&self,
|
|
|
|
|
slug: &str,
|
2025-11-26 21:40:24 +01:00
|
|
|
_offset: usize,
|
2025-11-15 15:43:42 +01:00
|
|
|
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();
|
2025-11-16 08:34:33 +01:00
|
|
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
|
|
|
|
MusicSourceError::BrowseError(format!("Failed to get playlist {}: {}", playlist_id, e))
|
|
|
|
|
})?;
|
2025-11-15 15:43:42 +01:00
|
|
|
|
2025-11-26 21:40:24 +01:00
|
|
|
// Get items from playlist (to_items starts from cursor position)
|
2025-11-27 09:50:21 +01:00
|
|
|
let mut items = reader.to_items(count).await.map_err(|e| {
|
2025-11-15 15:43:42 +01:00
|
|
|
MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e))
|
|
|
|
|
})?;
|
|
|
|
|
|
2025-11-27 09:50:21 +01:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-27 23:26:49 +01:00
|
|
|
|
|
|
|
|
// 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());
|
|
|
|
|
}
|
2025-11-29 00:27:44 +01:00
|
|
|
|
|
|
|
|
// Normaliser l'albumArtURI : rendre absolu si chemin relatif, sinon fallback par défaut
|
|
|
|
|
if let Some(art) = item.album_art.as_mut() {
|
|
|
|
|
if art.starts_with('/') {
|
|
|
|
|
*art = format!("{}{}", self.base_url, art);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
item.album_art = Some(self.default_cover_url());
|
|
|
|
|
}
|
2025-11-27 09:50:21 +01:00
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
Ok(items)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:08:07 +01:00
|
|
|
/// 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>> {
|
2025-11-29 14:19:16 +01:00
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:08:07 +01:00
|
|
|
let playlist_id = Self::live_playlist_id(slug);
|
|
|
|
|
|
|
|
|
|
let manager = pmoplaylist::PlaylistManager();
|
|
|
|
|
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
|
2025-11-29 14:19:16 +01:00
|
|
|
MusicSourceError::BrowseError(format!(
|
|
|
|
|
"Failed to get live playlist {}: {}",
|
|
|
|
|
playlist_id, e
|
|
|
|
|
))
|
2025-11-29 01:08:07 +01:00
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let mut items = reader.to_items(count).await.map_err(|e| {
|
2025-11-29 14:19:16 +01:00
|
|
|
MusicSourceError::BrowseError(format!("Failed to read live playlist entries: {}", e))
|
2025-11-29 01:08:07 +01:00
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
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() {
|
2025-11-29 14:19:16 +01:00
|
|
|
item.id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
|
|
|
|
|
item.parent_id = format!("radio-paradise:channel:{}:liveplaylist", slug);
|
2025-11-29 01:08:07 +01:00
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(art) = item.album_art.as_mut() {
|
|
|
|
|
if art.starts_with('/') {
|
|
|
|
|
*art = format!("{}{}", self.base_url, art);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
item.album_art = 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?;
|
2025-11-29 14:19:16 +01:00
|
|
|
let expected_id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
|
2025-11-29 01:08:07 +01:00
|
|
|
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
|
|
|
|
|
)))
|
|
|
|
|
}
|
2025-10-16 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
/// Types of object IDs in the Radio Paradise source
|
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
|
|
|
enum ObjectIdType {
|
|
|
|
|
Root,
|
|
|
|
|
Channel { slug: String },
|
|
|
|
|
LiveStream { slug: String },
|
2025-11-29 01:08:07 +01:00
|
|
|
LivePlaylist { slug: String },
|
|
|
|
|
LivePlaylistTrack { slug: String, pk: String },
|
2025-11-15 15:43:42 +01:00
|
|
|
History { slug: String },
|
|
|
|
|
HistoryTrack { slug: String, pk: String },
|
|
|
|
|
Unknown,
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
#[async_trait]
|
2025-10-16 22:00:35 +02:00
|
|
|
impl MusicSource for RadioParadiseSource {
|
|
|
|
|
fn name(&self) -> &str {
|
2025-11-15 15:43:42 +01:00
|
|
|
"Radio Paradise"
|
2025-10-16 22:00:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn id(&self) -> &str {
|
2025-11-15 15:43:42 +01:00
|
|
|
"radio-paradise"
|
2025-10-16 22:00:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_image(&self) -> &[u8] {
|
|
|
|
|
DEFAULT_IMAGE
|
|
|
|
|
}
|
2025-10-16 22:13:00 +02:00
|
|
|
|
|
|
|
|
async fn root_container(&self) -> Result<Container> {
|
2025-11-05 07:40:36 +00:00
|
|
|
Ok(Container {
|
2025-11-15 15:43:42 +01:00
|
|
|
id: "radio-paradise".to_string(),
|
2025-11-05 07:40:36 +00:00
|
|
|
parent_id: "0".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
2025-11-28 22:15:44 +01:00
|
|
|
// childCount retiré pour éviter les soucis de compatibilité côté CP
|
|
|
|
|
child_count: None,
|
2025-11-28 23:13:59 +01:00
|
|
|
searchable: Some("1".to_string()),
|
2025-11-15 15:43:42 +01:00
|
|
|
title: "Radio Paradise".to_string(),
|
2025-11-05 07:40:36 +00:00
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-11-05 07:40:36 +00:00
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
})
|
2025-10-16 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
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);
|
2025-11-29 01:08:07 +01:00
|
|
|
let live_playlist_container = self.build_live_playlist_container(descriptor);
|
2025-11-27 09:50:21 +01:00
|
|
|
|
|
|
|
|
#[cfg(feature = "playlist")]
|
|
|
|
|
let history_container = self.build_history_container_with_count(descriptor).await;
|
|
|
|
|
#[cfg(not(feature = "playlist"))]
|
2025-11-15 15:43:42 +01:00
|
|
|
let history_container = self.build_history_container(descriptor);
|
|
|
|
|
|
|
|
|
|
Ok(BrowseResult::Mixed {
|
2025-11-29 01:08:07 +01:00
|
|
|
containers: vec![live_playlist_container, history_container],
|
2025-11-15 15:43:42 +01:00
|
|
|
items: vec![live_item],
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ObjectIdType::History { slug } => {
|
2025-11-27 09:50:21 +01:00
|
|
|
// Return history container (for BrowseMetadata) and items (for BrowseDirectChildren)
|
|
|
|
|
// The content_handler will filter out the container when browsing direct children
|
2025-11-26 21:40:24 +01:00
|
|
|
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
|
|
|
|
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
|
|
|
|
})?;
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
#[cfg(feature = "playlist")]
|
|
|
|
|
{
|
2025-11-29 14:19:16 +01:00
|
|
|
let history_container =
|
|
|
|
|
self.build_history_container_with_count(descriptor).await;
|
2025-11-15 15:43:42 +01:00
|
|
|
let items = self.get_history_items(&slug, 0, 100).await?;
|
2025-11-26 21:40:24 +01:00
|
|
|
Ok(BrowseResult::Mixed {
|
|
|
|
|
containers: vec![history_container],
|
|
|
|
|
items,
|
|
|
|
|
})
|
2025-11-15 15:43:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(feature = "playlist"))]
|
|
|
|
|
{
|
2025-11-27 09:50:21 +01:00
|
|
|
// If playlist feature is disabled, return just the container
|
|
|
|
|
let history_container = self.build_history_container(descriptor);
|
2025-11-26 21:40:24 +01:00
|
|
|
Ok(BrowseResult::Containers(vec![history_container]))
|
2025-11-15 15:43:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 21:40:24 +01:00
|
|
|
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]))
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:08:07 +01:00
|
|
|
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]))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 12:55:21 +01:00
|
|
|
ObjectIdType::HistoryTrack { slug: _, pk: _ } => {
|
2025-11-26 21:40:24 +01:00
|
|
|
// Return metadata for the history track item
|
|
|
|
|
let item = self.get_item(object_id).await?;
|
|
|
|
|
Ok(BrowseResult::Items(vec![item]))
|
2025-11-15 15:43:42 +01:00
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:08:07 +01:00
|
|
|
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(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(format!(
|
|
|
|
|
"Unknown object ID: {}",
|
|
|
|
|
object_id
|
|
|
|
|
))),
|
|
|
|
|
}
|
2025-10-16 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:08:07 +01:00
|
|
|
ObjectIdType::LivePlaylistTrack { pk, .. } => {
|
|
|
|
|
// Return cached audio URL
|
|
|
|
|
Ok(format!("{}/cache/audio/{}", self.base_url, pk))
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
_ => Err(MusicSourceError::ObjectNotFound(format!(
|
|
|
|
|
"Cannot resolve URI for object: {}",
|
|
|
|
|
object_id
|
|
|
|
|
))),
|
|
|
|
|
}
|
2025-10-16 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
2025-11-26 18:30:30 +01:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
}]),
|
2025-11-29 01:08:07 +01:00
|
|
|
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),
|
|
|
|
|
}]),
|
2025-11-26 18:30:30 +01:00
|
|
|
_ => Err(MusicSourceError::ObjectNotFound(format!(
|
|
|
|
|
"Cannot list formats for object: {}",
|
|
|
|
|
object_id
|
|
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 12:20:28 +01:00
|
|
|
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
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
|
2025-11-26 21:40:24 +01:00
|
|
|
// Try to find the item with this pk
|
|
|
|
|
let items = reader.to_items(1000).await.map_err(|e| {
|
2025-11-26 12:20:28 +01:00
|
|
|
MusicSourceError::BrowseError(format!(
|
|
|
|
|
"Failed to read playlist entries: {}",
|
|
|
|
|
e
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
|
2025-11-28 20:06:53 +01:00
|
|
|
// 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() {
|
2025-11-29 14:19:16 +01:00
|
|
|
item.id = format!(
|
|
|
|
|
"radio-paradise:channel:{}:history:track:{}",
|
|
|
|
|
slug, pk2
|
|
|
|
|
);
|
|
|
|
|
item.parent_id = format!("radio-paradise:channel:{}:history", slug);
|
2025-11-28 20:06:53 +01:00
|
|
|
|
|
|
|
|
if resource.url.starts_with('/') {
|
2025-11-29 14:19:16 +01:00
|
|
|
resource.url = format!("{}{}", self.base_url, resource.url);
|
2025-11-28 20:06:53 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if item.genre.is_none() {
|
|
|
|
|
item.genre = Some("Radio Paradise".to_string());
|
|
|
|
|
}
|
|
|
|
|
adjusted.push(item);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 21:40:24 +01:00
|
|
|
// Find the item matching this pk in the item ID
|
2025-11-29 14:19:16 +01:00
|
|
|
let expected_id =
|
|
|
|
|
format!("radio-paradise:channel:{}:history:track:{}", slug, pk);
|
2025-11-28 20:06:53 +01:00
|
|
|
for item in adjusted {
|
2025-11-26 21:40:24 +01:00
|
|
|
if item.id == expected_id {
|
|
|
|
|
return Ok(item);
|
2025-11-26 12:20:28 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 01:08:07 +01:00
|
|
|
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
|
|
|
|
|
);
|
2025-11-29 14:19:16 +01:00
|
|
|
item.parent_id =
|
|
|
|
|
format!("radio-paradise:channel:{}:liveplaylist", slug);
|
2025-11-29 01:08:07 +01:00
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(art) = item.album_art.as_mut() {
|
|
|
|
|
if art.starts_with('/') {
|
|
|
|
|
*art = format!("{}{}", self.base_url, art);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
item.album_art = Some(self.default_cover_url());
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 14:19:16 +01:00
|
|
|
let expected_id =
|
|
|
|
|
format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
|
2025-11-29 01:08:07 +01:00
|
|
|
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(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 12:20:28 +01:00
|
|
|
_ => Err(MusicSourceError::ObjectNotFound(format!(
|
|
|
|
|
"Cannot get item for object: {}",
|
|
|
|
|
object_id
|
|
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-16 22:13:00 +02:00
|
|
|
fn supports_fifo(&self) -> bool {
|
2025-11-15 15:43:42 +01:00
|
|
|
// History playlists are FIFO
|
|
|
|
|
cfg!(feature = "playlist")
|
2025-10-19 18:14:28 +02:00
|
|
|
}
|
2025-10-16 22:13:00 +02:00
|
|
|
|
2025-10-19 18:14:28 +02:00
|
|
|
async fn append_track(&self, _track: Item) -> Result<()> {
|
2025-11-15 15:43:42 +01:00
|
|
|
// Tracks are added automatically by FlacCacheSink
|
|
|
|
|
Err(MusicSourceError::NotSupported(
|
|
|
|
|
"Tracks are automatically added to history by the streaming system".to_string(),
|
2025-11-05 07:40:36 +00:00
|
|
|
))
|
2025-10-16 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
2025-11-15 15:43:42 +01:00
|
|
|
// Managed automatically by playlist FIFO
|
2025-11-05 07:40:36 +00:00
|
|
|
Ok(None)
|
2025-10-16 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn update_id(&self) -> u32 {
|
2025-11-15 15:43:42 +01:00
|
|
|
*self.update_counter.read().await
|
2025-10-16 22:13:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn last_change(&self) -> Option<SystemTime> {
|
2025-11-15 15:43:42 +01:00
|
|
|
Some(*self.last_change.read().await)
|
2025-10-21 14:21:17 +02:00
|
|
|
}
|
|
|
|
|
|
2025-11-15 15:43:42 +01:00
|
|
|
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);
|
2025-11-05 07:40:36 +00:00
|
|
|
Ok(vec![])
|
2025-10-17 08:19:10 +02:00
|
|
|
}
|
2025-10-16 22:00:35 +02:00
|
|
|
}
|