Patch of the web logger
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -181,6 +181,9 @@
|
||||
<strong>{{ channelBrowse.returned_items }}</strong>
|
||||
<span v-if="channelBrowse.total">/ {{ channelBrowse.total }}</span>
|
||||
<span v-if="channelBrowse.update_id">Update ID: {{ channelBrowse.update_id }}</span>
|
||||
<span v-if="channelBrowseLastUpdated">
|
||||
Last refresh: {{ formatTimestamp(channelBrowseLastUpdated) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="channelBrowseError" class="error-message">
|
||||
❌ {{ channelBrowseError }}
|
||||
@@ -198,6 +201,12 @@
|
||||
:key="item.id"
|
||||
:class="['track-card', { active: activeTrackId === item.id }]"
|
||||
>
|
||||
<div class="track-card-header">
|
||||
<span :class="trackStatusClass(item.__status)">
|
||||
{{ trackStatusLabel(item.__status) }}
|
||||
</span>
|
||||
<span v-if="item.update_id" class="track-metadata">update {{ item.update_id }}</span>
|
||||
</div>
|
||||
<div class="track-headline">
|
||||
<div class="track-title">{{ item.title }}</div>
|
||||
<div class="track-artist">{{ item.artist || item.creator || 'Unknown artist' }}</div>
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Url> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
@@ -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<Url> {
|
||||
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<String>,
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<u64, D::Error>
|
||||
@@ -306,9 +307,9 @@ impl Block {
|
||||
|
||||
/// Get the full URL for a cover image
|
||||
pub fn cover_url(&self, cover_path: &str) -> Option<String> {
|
||||
self.image_base
|
||||
.as_ref()
|
||||
.map(|base| format!("{}{}", base, cover_path))
|
||||
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)
|
||||
|
||||
@@ -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<Url> {
|
||||
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<ChannelState>,
|
||||
block: Arc<Block>,
|
||||
) -> 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<ChannelState>) -> 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<ChannelState>, block: Arc<Block>) -> 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();
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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<F>(&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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user