❌ {{ channelBrowseError }}
@@ -198,6 +201,12 @@
:key="item.id"
:class="['track-card', { active: activeTrackId === item.id }]"
>
+
{{ item.title }}
{{ item.artist || item.creator || 'Unknown artist' }}
@@ -381,7 +390,11 @@ const channelBrowse = ref({
})
const channelBrowseLoading = ref(false)
const channelBrowseError = ref('')
+const channelBrowseLastUpdated = ref(null)
let refreshTimerId = null
+let channelRefreshTimerId = null
+const CHANNEL_TRACKS_REFRESH_INTERVAL = 5000
+let channelTracksInFlight = false
// Format duration from milliseconds to MM:SS
function formatDuration(ms) {
@@ -483,8 +496,52 @@ async function refreshNowPlaying() {
}
}
-async function fetchChannelTracks() {
- channelBrowseLoading.value = true
+function deriveTrackStatus(item) {
+ const url = item?.resources?.[0]?.url || ''
+ if (!url) {
+ return 'pending'
+ }
+ if (url.includes('/audio/flac/') && !url.includes('#')) {
+ return 'cached'
+ }
+ if (url.includes('#')) {
+ return 'downloading'
+ }
+ return 'external'
+}
+
+function trackStatusLabel(status) {
+ switch (status) {
+ case 'cached':
+ return 'Cached'
+ case 'downloading':
+ return 'Downloading'
+ case 'external':
+ return 'External'
+ default:
+ return 'Pending'
+ }
+}
+
+function trackStatusClass(status) {
+ return {
+ 'status-badge': true,
+ cached: status === 'cached',
+ downloading: status === 'downloading',
+ external: status === 'external',
+ pending: status === 'pending'
+ }
+}
+
+async function fetchChannelTracks(options = {}) {
+ const { silent = false } = options
+ if (channelTracksInFlight) {
+ return
+ }
+ channelTracksInFlight = true
+ if (!silent) {
+ channelBrowseLoading.value = true
+ }
channelBrowseError.value = ''
try {
@@ -507,11 +564,20 @@ async function fetchChannelTracks() {
total: data.total ?? ((data.containers?.length ?? 0) + (data.items?.length ?? 0)),
update_id: data.update_id ?? 0
}
+ // annotate each item with status for UI
+ channelBrowse.value.items = channelBrowse.value.items.map((item) => ({
+ ...item,
+ __status: deriveTrackStatus(item)
+ }))
+ channelBrowseLastUpdated.value = new Date()
} catch (e) {
channelBrowseError.value = `Failed to load channel tracks: ${e.message}`
console.error('Error fetching channel tracks:', e)
} finally {
- channelBrowseLoading.value = false
+ channelTracksInFlight = false
+ if (!silent) {
+ channelBrowseLoading.value = false
+ }
}
}
@@ -703,12 +769,22 @@ onMounted(async () => {
refreshNowPlaying()
}
}, 30000)
+
+ // Auto-refresh channel tracks every few seconds
+ channelRefreshTimerId = window.setInterval(() => {
+ if (!channelBrowseLoading.value) {
+ fetchChannelTracks({ silent: true })
+ }
+ }, CHANNEL_TRACKS_REFRESH_INTERVAL)
})
onUnmounted(() => {
if (refreshTimerId) {
clearInterval(refreshTimerId)
}
+ if (channelRefreshTimerId) {
+ clearInterval(channelRefreshTimerId)
+ }
})
@@ -1288,6 +1364,58 @@ onUnmounted(() => {
transition: border-color 0.2s, box-shadow 0.2s;
}
+.track-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: #bbb;
+}
+
+.status-badge.cached {
+ background: rgba(46, 204, 113, 0.18);
+ border-color: rgba(46, 204, 113, 0.45);
+ color: #2ecc71;
+}
+
+.status-badge.downloading {
+ background: rgba(255, 193, 7, 0.15);
+ border-color: rgba(255, 193, 7, 0.4);
+ color: #ffc107;
+}
+
+.status-badge.external {
+ background: rgba(0, 212, 255, 0.12);
+ border-color: rgba(0, 212, 255, 0.4);
+ color: #00d4ff;
+}
+
+.status-badge.pending {
+ background: rgba(255, 87, 34, 0.15);
+ border-color: rgba(255, 87, 34, 0.4);
+ color: #ff6d3a;
+}
+
+.track-metadata {
+ font-size: 0.7rem;
+ color: #666;
+}
+
.track-card.active {
border-color: rgba(46, 204, 113, 0.8);
box-shadow: 0 0 12px rgba(46, 204, 113, 0.25);
diff --git a/pmocache/src/download.rs b/pmocache/src/download.rs
index a6010b27..ab161a9c 100644
--- a/pmocache/src/download.rs
+++ b/pmocache/src/download.rs
@@ -346,7 +346,7 @@ async fn download_impl(
Ok(resp) => resp,
Err(e) => {
let mut s = state.write().await;
- let error = format!("Failed to fetch URL: {}", e);
+ let error = format!("Failed to fetch URL '{}': {}", url, e);
s.error = Some(error.clone());
s.finished = true;
return Err(error);
diff --git a/pmoparadise/src/client.rs b/pmoparadise/src/client.rs
index a2f9b0c2..423bc99f 100644
--- a/pmoparadise/src/client.rs
+++ b/pmoparadise/src/client.rs
@@ -6,6 +6,44 @@ use reqwest::Client;
use std::time::Duration;
use url::Url;
+fn normalize_base_url(base: &str) -> String {
+ let mut normalized = base.trim().to_string();
+
+ if normalized.is_empty() {
+ return "https://img.radioparadise.com/".to_string();
+ }
+
+ if normalized.starts_with("//") {
+ normalized = format!("https:{}", normalized);
+ } else if !(normalized.starts_with("http://") || normalized.starts_with("https://")) {
+ normalized = format!("https://{}", normalized.trim_start_matches('/'));
+ }
+
+ if !normalized.ends_with('/') {
+ normalized.push('/');
+ }
+
+ normalized
+}
+
+fn resolve_cover_with_base(base: &str, cover_path: &str) -> Result
{
+ let cover_path = cover_path.trim();
+
+ if cover_path.starts_with("http://") || cover_path.starts_with("https://") {
+ return Ok(Url::parse(cover_path)?);
+ }
+
+ if cover_path.starts_with("//") {
+ let url = format!("https:{}", cover_path);
+ return Ok(Url::parse(&url)?);
+ }
+
+ let base = normalize_base_url(base);
+ let base_url = Url::parse(&base)?;
+
+ Ok(base_url.join(cover_path)?)
+}
+
/// Default Radio Paradise API base URL
pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api";
@@ -13,10 +51,13 @@ pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api";
pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan/0";
/// Default image base URL
-pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/covers/l/";
+pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/";
-/// Default timeout for HTTP requests
-pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
+/// Default timeout for metadata HTTP requests
+pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
+
+/// Default timeout for large block downloads/streams
+pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 180;
/// Default User-Agent
pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
@@ -49,7 +90,8 @@ pub struct RadioParadiseClient {
image_base: String,
bitrate: Bitrate,
channel: u8,
- pub(crate) timeout: Duration,
+ pub(crate) request_timeout: Duration,
+ pub(crate) block_timeout: Duration,
next_block_url: Option,
}
@@ -74,10 +116,11 @@ impl RadioParadiseClient {
client,
api_base: DEFAULT_API_BASE.to_string(),
block_base: DEFAULT_BLOCK_BASE.to_string(),
- image_base: DEFAULT_IMAGE_BASE.to_string(),
+ image_base: normalize_base_url(DEFAULT_IMAGE_BASE),
bitrate: Bitrate::default(),
channel: 0,
- timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
+ request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
+ block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
next_block_url: None,
}
}
@@ -162,7 +205,12 @@ impl RadioParadiseClient {
#[cfg(feature = "logging")]
tracing::debug!("Fetching block: {}", url);
- let response = self.client.get(url).timeout(self.timeout).send().await?;
+ let response = self
+ .client
+ .get(url)
+ .timeout(self.request_timeout)
+ .send()
+ .await?;
if !response.status().is_success() {
return Err(Error::other(format!(
@@ -174,7 +222,9 @@ impl RadioParadiseClient {
let mut block: Block = response.json().await?;
// Set image_base if not provided
- if block.image_base.is_none() {
+ if let Some(ref mut base) = block.image_base {
+ *base = normalize_base_url(base);
+ } else {
block.image_base = Some(self.image_base.clone());
}
@@ -219,8 +269,7 @@ impl RadioParadiseClient {
/// # }
/// ```
pub fn cover_url(&self, cover_path: &str) -> Result {
- let url_str = format!("{}{}", self.image_base, cover_path);
- Ok(Url::parse(&url_str)?)
+ resolve_cover_with_base(&self.image_base, cover_path)
}
/// Prefetch metadata for the next block
@@ -270,7 +319,8 @@ pub struct ClientBuilder {
image_base: String,
bitrate: Bitrate,
channel: u8,
- timeout: Duration,
+ request_timeout: Duration,
+ block_timeout: Duration,
user_agent: String,
proxy: Option,
}
@@ -284,7 +334,8 @@ impl Default for ClientBuilder {
image_base: DEFAULT_IMAGE_BASE.to_string(),
bitrate: Bitrate::default(),
channel: 0,
- timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
+ request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
+ block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
user_agent: DEFAULT_USER_AGENT.to_string(),
proxy: None,
}
@@ -343,7 +394,13 @@ impl ClientBuilder {
/// Set the request timeout
pub fn timeout(mut self, timeout: Duration) -> Self {
- self.timeout = timeout;
+ self.request_timeout = timeout;
+ self
+ }
+
+ /// Set the timeout specifically for block downloads/streams
+ pub fn block_timeout(mut self, timeout: Duration) -> Self {
+ self.block_timeout = timeout;
self
}
@@ -366,7 +423,7 @@ impl ClientBuilder {
} else {
let mut builder = Client::builder()
.user_agent(&self.user_agent)
- .timeout(self.timeout);
+ .timeout(self.request_timeout);
if let Some(proxy_url) = &self.proxy {
let proxy = reqwest::Proxy::all(proxy_url)
@@ -382,15 +439,17 @@ impl ClientBuilder {
} else {
self.block_base.clone()
};
+ let image_base = normalize_base_url(&self.image_base);
Ok(RadioParadiseClient {
client,
api_base: self.api_base,
block_base,
- image_base: self.image_base,
+ image_base,
bitrate: self.bitrate,
channel: self.channel,
- timeout: self.timeout,
+ request_timeout: self.request_timeout,
+ block_timeout: self.block_timeout,
next_block_url: None,
})
}
diff --git a/pmoparadise/src/models.rs b/pmoparadise/src/models.rs
index 5eccc536..70d50748 100644
--- a/pmoparadise/src/models.rs
+++ b/pmoparadise/src/models.rs
@@ -3,6 +3,7 @@
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
@@ -306,9 +307,9 @@ impl Block {
/// Get the full URL for a cover image
pub fn cover_url(&self, cover_path: &str) -> Option {
- self.image_base
- .as_ref()
- .map(|base| format!("{}{}", base, cover_path))
+ 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)
diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs
index d1bc5def..33abe967 100644
--- a/pmoparadise/src/source.rs
+++ b/pmoparadise/src/source.rs
@@ -98,6 +98,39 @@ fn parse_track_identifier(track_id: &str) -> Option<(u8, u64, usize)> {
}
}
+fn resolve_cover_url(
+ image_base: Option<&str>,
+ client: &RadioParadiseClient,
+ cover: &str,
+) -> anyhow::Result {
+ if cover.starts_with("http://") || cover.starts_with("https://") {
+ return Url::parse(cover).map_err(|e| anyhow!("Invalid cover URL '{}': {}", cover, e));
+ }
+
+ if cover.starts_with("//") {
+ let url = format!("https:{}", cover);
+ return Url::parse(&url).map_err(|e| anyhow!("Invalid cover URL '{}': {}", cover, e));
+ }
+
+ if let Some(base) = image_base {
+ match Url::parse(base).and_then(|base_url| base_url.join(cover)) {
+ Ok(url) => return Ok(url),
+ Err(err) => {
+ tracing::debug!(
+ "Failed to join cover '{}' with image base '{}': {}",
+ cover,
+ base,
+ err
+ );
+ }
+ }
+ }
+
+ client
+ .cover_url(cover)
+ .map_err(|e| anyhow!("Invalid cover URL '{}': {}", cover, e))
+}
+
/// Radio Paradise music source with full MusicSource trait implementation
///
/// This struct combines a [`RadioParadiseClient`] for API access with a FIFO playlist
@@ -348,6 +381,70 @@ impl RadioParadiseSource {
Ok(())
}
+ async fn prepare_initial_track(
+ &self,
+ channel: Arc,
+ block: Arc,
+ ) -> Result<()> {
+ let ordered_songs = block.songs_ordered();
+ let (song_index, song) = match ordered_songs.first() {
+ Some(entry) => entry,
+ None => return Ok(()),
+ };
+
+ let track_id = track_identifier(channel.descriptor.id, block.event, *song_index);
+
+ if channel.playlist.has_track(&track_id).await {
+ return Ok(());
+ }
+
+ let placeholder_uri = format!("{}#{}", block.url, *song_index);
+
+ channel
+ .cache_manager
+ .update_metadata(
+ track_id.clone(),
+ pmosource::TrackMetadata {
+ original_uri: placeholder_uri.clone(),
+ cached_audio_pk: None,
+ cached_cover_pk: None,
+ },
+ )
+ .await;
+
+ let mut track = Track::new(
+ track_id.clone(),
+ song.title.clone(),
+ placeholder_uri.clone(),
+ );
+
+ if !song.artist.is_empty() {
+ track = track.with_artist(song.artist.clone());
+ }
+
+ if let Some(ref album) = song.album {
+ if !album.is_empty() {
+ track = track.with_album(album.clone());
+ }
+ }
+
+ let duration_ms = song_duration_ms(&block, &ordered_songs, 0);
+ if duration_ms > 0 {
+ track = track.with_duration((duration_ms / 1000) as u32);
+ }
+
+ if let Some(ref cover) = song.cover {
+ if let Ok(url) = resolve_cover_url(block.image_base.as_deref(), &channel.client, cover)
+ {
+ track = track.with_image(url.to_string());
+ }
+ }
+
+ channel.playlist.append_track(track).await;
+
+ Ok(())
+ }
+
async fn populate_channel_locked(&self, channel: Arc) -> Result<()> {
tracing::info!(
"📻 Fetching Radio Paradise block for channel {}",
@@ -361,7 +458,25 @@ impl RadioParadiseSource {
.map_err(|e| MusicSourceError::SourceUnavailable(e.to_string()))?;
let block = Arc::new(now_playing.block);
- self.ingest_block(channel, block).await
+ self.prepare_initial_track(channel.clone(), block.clone())
+ .await?;
+
+ let source_clone = self.clone();
+ tokio::spawn(async move {
+ if let Err(e) = source_clone
+ .ingest_block(channel.clone(), block.clone())
+ .await
+ {
+ tracing::error!(
+ "Failed to ingest block {} on channel {}: {}",
+ block.event,
+ channel.descriptor.name,
+ e
+ );
+ }
+ });
+
+ Ok(())
}
async fn ingest_block(&self, channel: Arc, block: Arc) -> Result<()> {
@@ -396,14 +511,25 @@ impl RadioParadiseSource {
for (position, (song_index, song)) in ordered_songs.iter().enumerate() {
let track_id = track_identifier(channel.descriptor.id, block.event, *song_index);
+ let placeholder_uri = format!("{}#{}", block.url, *song_index);
- if channel
- .cache_manager
- .get_metadata(&track_id)
- .await
- .is_some()
- {
- continue;
+ let existing_metadata = channel.cache_manager.get_metadata(&track_id).await;
+ if let Some(ref metadata) = existing_metadata {
+ if metadata.cached_audio_pk.is_some() {
+ continue;
+ }
+ } else {
+ channel
+ .cache_manager
+ .update_metadata(
+ track_id.clone(),
+ pmosource::TrackMetadata {
+ original_uri: placeholder_uri.clone(),
+ cached_audio_pk: None,
+ cached_cover_pk: None,
+ },
+ )
+ .await;
}
let duration_ms = song_duration_ms(&block, &ordered_songs, position);
@@ -451,23 +577,34 @@ impl RadioParadiseSource {
.cache_audio_from_reader(&audio_source_uri, reader, Some(data_len))
.await?;
- let cached_cover_pk = if let Some(ref image_base) = block.image_base {
- if let Some(ref cover) = song.cover {
- let image_url = format!("{}{}", image_base, cover);
- match channel.cache_manager.cache_cover(&image_url).await {
- Ok(pk) => Some(pk),
+ let resolved_cover_url =
+ song.cover.as_ref().and_then(|cover| {
+ match resolve_cover_url(block.image_base.as_deref(), &channel.client, cover) {
+ Ok(url) => Some(url.to_string()),
Err(e) => {
tracing::warn!(
- "Failed to cache cover {} on channel {}: {}",
- image_url,
+ "Failed to resolve cover '{}' for channel {}: {}",
+ cover,
channel.descriptor.name,
e
);
None
}
}
- } else {
- None
+ });
+
+ let cached_cover_pk = if let Some(ref cover_url) = resolved_cover_url {
+ match channel.cache_manager.cache_cover(cover_url).await {
+ Ok(pk) => Some(pk),
+ Err(e) => {
+ tracing::warn!(
+ "Failed to cache cover {} on channel {}: {}",
+ cover_url,
+ channel.descriptor.name,
+ e
+ );
+ None
+ }
}
} else {
None
@@ -479,7 +616,15 @@ impl RadioParadiseSource {
.update_metadata(
track_id.clone(),
pmosource::TrackMetadata {
- original_uri: block.url.clone(),
+ original_uri: existing_metadata
+ .and_then(|m| {
+ if m.original_uri.is_empty() {
+ None
+ } else {
+ Some(m.original_uri)
+ }
+ })
+ .unwrap_or_else(|| placeholder_uri.clone()),
cached_audio_pk: Some(audio_pk.clone()),
cached_cover_pk: metadata_cover_pk,
},
@@ -506,13 +651,25 @@ impl RadioParadiseSource {
if let Ok(url) = channel.cache_manager.cover_url(cover_pk, None) {
track = track.with_image(url);
}
- } else if let Some(ref cover) = song.cover {
- if let Some(ref image_base) = block.image_base {
- track = track.with_image(format!("{}{}", image_base, cover));
- }
+ } else if let Some(ref cover_url) = resolved_cover_url {
+ track = track.with_image(cover_url.clone());
}
- channel.playlist.append_track(track).await;
+ let updated = channel
+ .playlist
+ .update_track(&track_id, |existing| {
+ existing.title = track.title.clone();
+ existing.artist = track.artist.clone();
+ existing.album = track.album.clone();
+ existing.duration = track.duration;
+ existing.uri = track.uri.clone();
+ existing.image = track.image.clone();
+ })
+ .await;
+
+ if !updated {
+ channel.playlist.append_track(track).await;
+ }
let channel_for_wait = channel.clone();
let track_id_for_wait = track_id.clone();
diff --git a/pmoparadise/src/stream.rs b/pmoparadise/src/stream.rs
index 25456280..95f2e881 100644
--- a/pmoparadise/src/stream.rs
+++ b/pmoparadise/src/stream.rs
@@ -73,7 +73,7 @@ impl RadioParadiseClient {
let response = self
.client
.get(block_url.clone())
- .timeout(self.timeout)
+ .timeout(self.block_timeout)
.send()
.await?;
@@ -151,7 +151,7 @@ impl RadioParadiseClient {
let response = self
.client
.get(block_url.clone())
- .timeout(self.timeout)
+ .timeout(self.block_timeout)
.send()
.await?;
diff --git a/pmoplaylist/src/lib.rs b/pmoplaylist/src/lib.rs
index b55d507b..46824514 100644
--- a/pmoplaylist/src/lib.rs
+++ b/pmoplaylist/src/lib.rs
@@ -361,6 +361,31 @@ impl FifoPlaylist {
inner.queue.len()
}
+ /// Vérifie si un track existe déjà dans la playlist
+ pub async fn has_track(&self, track_id: &str) -> bool {
+ let inner = self.inner.read().await;
+ inner.queue.iter().any(|t| t.id == track_id)
+ }
+
+ /// Met à jour un track existant en appliquant une fonction de mise à jour.
+ ///
+ /// Retourne `true` si le track a été trouvé et modifié.
+ pub async fn update_track(&self, track_id: &str, updater: F) -> bool
+ where
+ F: FnOnce(&mut Track),
+ {
+ let mut inner = self.inner.write().await;
+
+ if let Some(track) = inner.queue.iter_mut().find(|t| t.id == track_id) {
+ updater(track);
+ inner.update_id = inner.update_id.wrapping_add(1);
+ inner.last_change = SystemTime::now();
+ true
+ } else {
+ false
+ }
+ }
+
/// Vérifie si la FIFO est vide
pub async fn is_empty(&self) -> bool {
let inner = self.inner.read().await;
diff --git a/pmoserver/src/logs/mod.rs b/pmoserver/src/logs/mod.rs
index 840d9a4c..e0bae9cd 100644
--- a/pmoserver/src/logs/mod.rs
+++ b/pmoserver/src/logs/mod.rs
@@ -125,7 +125,8 @@ pub async fn log_sse(
// Récupérer l'historique du buffer et le niveau actuel
let history = state.dump();
- let current_level = state.get_max_level();
+ let stream_state = state.clone();
+ let current_level = stream_state.get_max_level();
let stream = async_stream::stream! {
// 1. Envoyer d'abord tous les logs historiques filtrés par le niveau actuel
@@ -144,6 +145,10 @@ pub async fn log_sse(
// 2. Puis streamer les nouveaux logs en temps réel
while let Ok(entry) = rx.recv().await {
+ let max_level = stream_state.get_max_level();
+ if !is_level_allowed(&entry.level, max_level) {
+ continue;
+ }
if !filter_entry(&entry, ¶ms) {
continue;
}