From f510e59b1a09f0ecaa3db3539c55a402f45c0ec0 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 20 Oct 2025 16:23:50 +0200 Subject: [PATCH] Patch of the web logger --- .gitignore | 4 +- .pmomusic_audio/cache.db | Bin 20480 -> 0 bytes headers.txt | 6 +- pmoapp/webapp/src/components/LogView.vue | 4 +- .../src/components/RadioParadiseExplorer.vue | 134 +++++++++++- pmocache/src/download.rs | 2 +- pmoparadise/src/client.rs | 91 ++++++-- pmoparadise/src/models.rs | 7 +- pmoparadise/src/source.rs | 203 ++++++++++++++++-- pmoparadise/src/stream.rs | 4 +- pmoplaylist/src/lib.rs | 25 +++ pmoserver/src/logs/mod.rs | 7 +- 12 files changed, 432 insertions(+), 55 deletions(-) delete mode 100644 .pmomusic_audio/cache.db diff --git a/.gitignore b/.gitignore index 6c6f3612..4745374f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,11 @@ xxx /dcai/ **/.pmomusic.yml **/.pmomusic_covers/** +**/.pmomusic_audio/** .DS_Store /target/ -.pmomusic_covers +/.pmomusic_covers +/.pmomusic_audio/** C/src/soxr-0.1.3/Release/tests **/Release/ **/Debug/ diff --git a/.pmomusic_audio/cache.db b/.pmomusic_audio/cache.db deleted file mode 100644 index 8a5b022e2365825f0c11e9bac0f41800e44d9c3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 zcmeI(T~E_c7zgmvbqFJY5|!-s7_%smb$wf3W{gfKhQLr^CUCWMYbTR#9cvedTw%N* zCVT_${TO}^J_F&}3odLo31tHlLrjSIcWHLcdd_*C{o0#z+HSmEZ3GUjd!42gP?^jS zh9NH~C4>m!J{Ioh8ygmq=aulAiIuM_3WWc9cZ&N-l2hNw^f&Iu^noW448&2wF+7Bsw8e7NMF z6S(L!n#aYlxzCgtPiHdB7dEh}t`iRsFDHHnD;Hm`nnV9;k`8U{9a=Uw%k<6qa;dn! zMOVzN`H@Dx*X`7tO1I@)Nb;HneZJ+jviZtfr;FxN@!e{f3ZnyEs7G0i9&b8< zWm|z&`QUr4eymH$&S{A|>6Hva`X|TVb;G%Qcx41$zkc}|5#wgjTz(?D*rvjr;hKbN zP9IJmM!#T#00bZa0SG_<0uX=z1Rwwb2>d&N4m+ORpG}pBD(H%>E0%5>x@?K^b`Z4v zLM~VD@b8_#x0-F&sW)85=WAXwXL}!8u4mb~W?Rk;4rUcTtOSl9NP?8lib7U2%A#5j z#DXaEc}+27?gy{bE?ml5k#5QbAtXhI2=c{Vfw*Nk6K8iUR3m68Fz8^g>=!m3*$<@p^l?U9Z~x zl#_Hr6P4nUva~1*dQo1~B;6DjmSja!Wz$gkx@*;*_j^36Tdwb{Qrz_XWS`wdXqfR7XlD~00bZa0SG_<0uX=z1Rwx`+aWN)&N9)H5z+d< zSb}9qYB2vlCfqT1#2wv^ERh8SAOHafKmY;|fB*y_009U<;J+5gBugn~=!yN2w-%zg Y`=fZPD_=F3OoW3msiDC}9{*4O0ylYC_5c6? diff --git a/headers.txt b/headers.txt index 8398ae15..0d9ec33c 100644 --- a/headers.txt +++ b/headers.txt @@ -1,5 +1,5 @@ -HTTP/1.1 500 Internal Server Error +HTTP/1.1 200 OK content-type: text/xml; charset="utf-8" -content-length: 597 -date: Sun, 19 Oct 2025 19:06:41 GMT +content-length: 1593 +date: Mon, 20 Oct 2025 17:44:48 GMT diff --git a/pmoapp/webapp/src/components/LogView.vue b/pmoapp/webapp/src/components/LogView.vue index 9f01fc62..0652eeda 100644 --- a/pmoapp/webapp/src/components/LogView.vue +++ b/pmoapp/webapp/src/components/LogView.vue @@ -126,7 +126,7 @@ const filteredLogs = computed(() => { // Fonction pour mettre à jour le niveau de log côté serveur async function updateServerLogLevel() { try { - const response = await fetch('/api/log_setup', { + const response = await fetch('/api/logs/log_setup', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -164,7 +164,7 @@ async function updateServerLogLevel() { // Charger le niveau de log actuel au démarrage async function loadServerLogLevel() { try { - const response = await fetch('/api/log_setup') + const response = await fetch('/api/logs/log_setup') if (response.ok) { const data = await response.json() serverLogLevel.value = data.current_level diff --git a/pmoapp/webapp/src/components/RadioParadiseExplorer.vue b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue index 491a578d..cd81a975 100644 --- a/pmoapp/webapp/src/components/RadioParadiseExplorer.vue +++ b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue @@ -181,6 +181,9 @@ {{ channelBrowse.returned_items }} / {{ channelBrowse.total }} Update ID: {{ channelBrowse.update_id }} + + Last refresh: {{ formatTimestamp(channelBrowseLastUpdated) }} +
❌ {{ channelBrowseError }} @@ -198,6 +201,12 @@ :key="item.id" :class="['track-card', { active: activeTrackId === item.id }]" > +
+ + {{ trackStatusLabel(item.__status) }} + + +
{{ 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; }