debug lectueur générique

This commit is contained in:
2025-11-26 18:30:30 +01:00
parent d55c22a267
commit e21fa5948e
10 changed files with 583 additions and 92 deletions

View File

@@ -185,7 +185,7 @@
<div class="audio-player-container">
<audio
ref="audioPlayer"
controls
preload="auto"
@ended="handleAudioEnded"
@error="handleAudioError"
@play="handleAudioPlay"
@@ -193,6 +193,7 @@
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@durationchange="handleDurationChange"
@canplay="handleCanPlay"
></audio>
</div>
</div>
@@ -251,6 +252,12 @@ const audioPlayer = ref<HTMLAudioElement | null>(null)
const currentTime = ref(0)
const duration = ref(0)
// Retry logic for fragile browser decoders
const MAX_AUDIO_RETRIES = 3
const RETRY_DELAY_MS = 800 // Réduit pour des transitions plus rapides
const retryCount = ref(0)
let retryTimer: ReturnType<typeof setTimeout> | null = null
// Metadata refresh via SSE
let metadataEventSource: EventSource | null = null
@@ -262,6 +269,7 @@ onMounted(async () => {
// Cleanup on unmount
onUnmounted(() => {
stopMetadataRefresh()
clearRetryTimer()
})
/**
@@ -374,11 +382,19 @@ function startMetadataRefresh() {
metadataEventSource.onmessage = (event) => {
try {
const previous = currentTrack.value
const metadata = JSON.parse(event.data)
if (currentTrack.value && metadata.id === currentTrack.value.id) {
currentTrack.value = metadata
console.log('Metadata updated via SSE:', metadata.title)
// Si le morceau change (live stream), remettre la progression à zéro
// IMPORTANT: Ne pas recharger l'URI car c'est un flux continu !
if (hasTrackChanged(previous, metadata)) {
console.log('Nouveau morceau dans le flux:', metadata.title)
resetProgressForNewTrack(metadata)
}
// Mettre à jour la durée si elle a changé
const newDuration = metadata.resources?.[0]?.duration
if (newDuration) {
@@ -422,8 +438,10 @@ async function playTrack(item: BrowseItem) {
return
}
resetAudioRetries()
audioError.value = null
currentTrack.value = item
const preferredUri = pickPreferredResourceUri(item)
// Initialise la durée depuis les métadonnées du morceau si disponible
currentTime.value = 0
@@ -435,22 +453,35 @@ async function playTrack(item: BrowseItem) {
}
try {
// Résout l'URI du morceau
// Résout l'URI du morceau via l'API
const result = await resolveUri(selectedSource.value.id, item.id)
currentUri.value = result.uri
let uri = result.uri
// Attendre que le DOM soit mis à jour (création de l'élément audio)
await nextTick()
// Joue l'audio
if (audioPlayer.value) {
audioPlayer.value.src = result.uri
await audioPlayer.value.play()
isPlaying.value = true
// Start metadata refresh
startMetadataRefresh()
// Si resolveUri n'a pas retourné d'URI, utiliser notre URI préférée (basée sur le support navigateur)
if (!uri && preferredUri) {
uri = preferredUri
console.log('Utilisation de l\'URI préférée (auto-détectée):', uri)
}
// Si les deux sont disponibles, privilégier l'URI la mieux supportée
if (uri && preferredUri && uri !== preferredUri) {
// Vérifier si l'URI préférée est mieux supportée
const resolvedFormat = getAudioFormat(uri)
const preferredFormat = getAudioFormat(preferredUri)
if (preferredFormat && canPlayAudioType(preferredFormat)) {
if (!resolvedFormat || !canPlayAudioType(resolvedFormat)) {
console.log(`Remplacement de ${uri} par ${preferredUri} (meilleur support navigateur)`)
uri = preferredUri
}
}
}
if (!uri) {
throw new Error('Aucune URI audio disponible')
}
await startPlaybackFromUri(uri, true)
} catch (e: any) {
audioError.value = `Erreur lors de la lecture: ${e.message}`
console.error('Failed to play track:', e)
@@ -474,6 +505,7 @@ function stopPlayback() {
audioError.value = null
currentTime.value = 0
duration.value = 0
resetAudioRetries()
}
/**
@@ -481,6 +513,7 @@ function stopPlayback() {
*/
function handleAudioEnded() {
isPlaying.value = false
currentTime.value = 0
stopMetadataRefresh()
// On peut implémenter ici une logique de lecture automatique du prochain morceau
}
@@ -492,11 +525,20 @@ function handleAudioError() {
stopMetadataRefresh()
const audio = audioPlayer.value
if (audio?.error) {
audioError.value = `Erreur de lecture audio (code ${audio.error.code})`
} else {
audioError.value = 'Erreur de lecture audio inconnue'
const errorCode = audio?.error?.code
const baseMessage = errorCode
? `Erreur de lecture audio (code ${errorCode})`
: 'Erreur de lecture audio inconnue'
if (selectedSource.value && currentTrack.value && retryCount.value < MAX_AUDIO_RETRIES) {
isPlaying.value = false
retryCount.value += 1
audioError.value = `${baseMessage} - tentative de reprise (${retryCount.value}/${MAX_AUDIO_RETRIES})`
scheduleRetryPlayback()
return
}
audioError.value = baseMessage
isPlaying.value = false
}
@@ -505,6 +547,7 @@ function handleAudioError() {
*/
function handleAudioPlay() {
isPlaying.value = true
audioError.value = null
startMetadataRefresh()
}
@@ -543,6 +586,13 @@ function handleDurationChange() {
}
}
/**
* Gère l'événement canplay (audio prêt à être joué)
*/
function handleCanPlay() {
console.log('Audio ready to play (canplay event)')
}
/**
* Formatte le temps en MM:SS
*/
@@ -575,6 +625,247 @@ function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
/**
* Détecte un changement de morceau (utile pour les streams live)
*/
function hasTrackChanged(
previous: BrowseItem | null,
next: BrowseItem | null
): boolean {
if (!previous || !next) return false
return (
previous.title !== next.title ||
previous.album !== next.album ||
(previous.artist || previous.creator) !== (next.artist || next.creator) ||
previous.album_art !== next.album_art
)
}
/**
* Remet la progression à zéro pour un nouveau morceau
*/
function resetProgressForNewTrack(track: BrowseItem | null) {
currentTime.value = 0
if (audioPlayer.value) {
audioPlayer.value.currentTime = 0
}
const d = track?.resources?.[0]?.duration
duration.value = d ? parseDuration(d) : 0
}
/**
* Détecte si un type MIME audio est supporté par le navigateur
*/
function canPlayAudioType(mimeType: string): boolean {
const audio = document.createElement('audio')
const support = audio.canPlayType(mimeType)
return support === 'probably' || support === 'maybe'
}
/**
* Extrait le type MIME audio depuis une URL
*/
function getAudioFormat(url: string): string | null {
const urlLower = url.toLowerCase()
// Détection par extension ou contenu de l'URL
if (urlLower.includes('flac') || urlLower.endsWith('.flac')) {
return 'audio/flac'
}
if (urlLower.includes('ogg') || urlLower.endsWith('.ogg') || urlLower.includes('vorbis')) {
return 'audio/ogg'
}
if (urlLower.includes('mp3') || urlLower.endsWith('.mp3') || urlLower.includes('mpeg')) {
return 'audio/mpeg'
}
if (urlLower.includes('wav') || urlLower.endsWith('.wav')) {
return 'audio/wav'
}
if (urlLower.includes('opus')) {
return 'audio/ogg' // Opus est généralement dans un container OGG
}
return null
}
/**
* Sélectionne l'URI préférée en tenant compte du support navigateur
*/
function pickPreferredResourceUri(item: BrowseItem): string | null {
const resources = item.resources || []
if (resources.length === 0) return null
// Ordre de préférence des formats (du meilleur au plus compatible)
const preferredFormats = [
{ mime: 'audio/flac', keywords: ['flac'] },
{ mime: 'audio/ogg', keywords: ['ogg', 'vorbis', 'opus'] },
{ mime: 'audio/mpeg', keywords: ['mp3', 'mpeg'] },
{ mime: 'audio/wav', keywords: ['wav'] },
{ mime: 'audio/x-wav', keywords: ['wav'] },
]
// Tester chaque format dans l'ordre de préférence
for (const format of preferredFormats) {
// Vérifier d'abord si le navigateur supporte ce format
if (!canPlayAudioType(format.mime)) {
console.log(`Format ${format.mime} non supporté par ce navigateur`)
continue
}
// Chercher une ressource correspondant à ce format
const matchingRes = resources.find((res) => {
if (!res.url || !res.protocol_info) return false
const protocolLower = res.protocol_info.toLowerCase()
return format.keywords.some((keyword) => protocolLower.includes(keyword))
})
if (matchingRes?.url) {
console.log(`Format sélectionné: ${format.mime}`, matchingRes.url)
return matchingRes.url
}
}
// Fallback: prendre n'importe quelle URL disponible
const anyRes = resources.find((res) => !!res.url)
if (anyRes?.url) {
console.log('Fallback: utilisation de la première URL disponible', anyRes.url)
}
return anyRes?.url || null
}
/**
* Ajoute un cache-buster pour forcer un nouveau stream côté navigateur
*/
function addCacheBuster(uri: string): string {
const separator = uri.includes('?') ? '&' : '?'
return `${uri}${separator}ts=${Date.now()}`
}
/**
* Nettoie le timer de retry
*/
function clearRetryTimer() {
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
/**
* Réinitialise les compteurs de retry
*/
function resetAudioRetries() {
retryCount.value = 0
clearRetryTimer()
}
/**
* Lance un retry différé pour laisser le temps au backend de reproposer le flux
*/
function scheduleRetryPlayback() {
clearRetryTimer()
retryTimer = setTimeout(() => {
retryTimer = null
void retryPlayback()
}, RETRY_DELAY_MS)
}
/**
* (Ré)initialise la source audio et démarre la lecture
*/
async function startPlaybackFromUri(uri: string, waitForDom = false) {
if (waitForDom) {
await nextTick()
}
const player = audioPlayer.value
if (!player) {
throw new Error('Audio player not ready')
}
const finalUri = addCacheBuster(uri)
currentUri.value = finalUri
player.pause()
player.src = finalUri
player.load()
// Attendre que le navigateur ait bufferisé suffisamment de données
// pour éviter les gaps audio entre les morceaux
await waitForSufficientBuffer(player)
await player.play()
isPlaying.value = true
audioError.value = null
resetAudioRetries()
startMetadataRefresh()
}
/**
* Attend que le player ait suffisamment bufferisé avant de lancer la lecture
*/
async function waitForSufficientBuffer(player: HTMLAudioElement): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup()
console.warn('Buffer timeout - starting playback anyway')
resolve()
}, 5000) // Timeout de 5 secondes max
const cleanup = () => {
clearTimeout(timeout)
player.removeEventListener('canplay', onCanPlay)
player.removeEventListener('error', onError)
}
const onCanPlay = () => {
// Vérifier si on a au moins 1-2 secondes de buffer
if (player.buffered.length > 0) {
const bufferedEnd = player.buffered.end(0)
const currentTime = player.currentTime
const bufferedSeconds = bufferedEnd - currentTime
console.log(`Buffer disponible: ${bufferedSeconds.toFixed(2)}s`)
// Attendre au moins 0.5 secondes de buffer pour réduire les gaps
if (bufferedSeconds >= 0.5) {
cleanup()
resolve()
}
}
}
const onError = () => {
cleanup()
reject(new Error('Buffer error'))
}
player.addEventListener('canplay', onCanPlay)
player.addEventListener('error', onError)
// Check immédiatement au cas où c'est déjà prêt
if (player.readyState >= 3) {
onCanPlay()
}
})
}
/**
* Tente de relancer la lecture après une erreur (nouvelle résolution d'URI)
*/
async function retryPlayback() {
if (!selectedSource.value || !currentTrack.value) return
try {
const result = await resolveUri(selectedSource.value.id, currentTrack.value.id)
await startPlaybackFromUri(result.uri)
} catch (e: any) {
audioError.value = `Erreur lors de la reprise: ${e.message}`
isPlaying.value = false
}
}
</script>
<style scoped>
@@ -1096,13 +1387,7 @@ function handleImageError(event: Event) {
}
.audio-player-container {
width: 100%;
}
.audio-player-container audio {
width: 100%;
border-radius: 8px;
background: #0a0a0a;
display: none;
}
/* Debug Info */

View File

@@ -268,6 +268,7 @@ impl NodeLogic for PlaylistSourceLogic {
remaining
);
let track_start = std::time::Instant::now();
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
send_to_children(node_name, &output, boundary).await?;
@@ -284,7 +285,12 @@ impl NodeLogic for PlaylistSourceLogic {
}
};
tracing::debug!("PlaylistSourceLogic: decoding track: {:?}", file_path);
let elapsed = track_start.elapsed();
tracing::info!(
"PlaylistSourceLogic: gap after TrackBoundary = {:.3}s, decoding: {:?}",
elapsed.as_secs_f64(),
file_path
);
// Décoder et émettre les chunks PCM
// Passer le cache et pk pour gérer le cache progressif

View File

@@ -60,6 +60,8 @@ pub trait ParadiseStreamingExt {
impl ParadiseStreamingExt for pmoserver::Server {
async fn init_paradise_streaming(&mut self) -> Result<Arc<ParadiseChannelManager>> {
info!("🎵 Initializing Radio Paradise streaming channels...");
// Sentinel log pour vérifier qu'on exécute bien cette version du binaire
tracing::warn!("🔍 Rien de neuf: entering init_paradise_streaming with caches+history setup");
// Récupérer ou initialiser les caches singletons
info!("📦 Getting cache singletons...");
@@ -107,15 +109,36 @@ impl ParadiseStreamingExt for pmoserver::Server {
history_builder.replay_max_lead_seconds = 1.0;
// Créer le manager de canaux
info!("📡 Creating ParadiseChannelManager...");
let manager = Arc::new(
let base_url = Some(self.base_url());
info!(
"📡 Creating ParadiseChannelManager (base_url={:?})...",
base_url
);
// Si la création bloque (réseau RP lent), on coupe après 30s pour ne pas empêcher le serveur de démarrer.
let manager = match tokio::time::timeout(
std::time::Duration::from_secs(30),
ParadiseChannelManager::with_defaults_with_cover_cache(
Some(cover_cache.clone()),
Some(history_builder),
base_url,
),
)
.await
.context("Failed to create ParadiseChannelManager")?,
);
{
Ok(Ok(mgr)) => {
info!("✅ ParadiseChannelManager created");
Arc::new(mgr)
}
Ok(Err(e)) => {
tracing::warn!("⚠️ Failed to create ParadiseChannelManager: {}", e);
return Err(e).context("Failed to create ParadiseChannelManager");
}
Err(_) => {
let msg = "Timeout creating ParadiseChannelManager after 30s";
tracing::warn!("⚠️ {}", msg);
return Err(anyhow::anyhow!(msg));
}
};
let state = Arc::new(ParadiseStreamingState {
manager: manager.clone(),

View File

@@ -9,6 +9,6 @@ host:
logger:
buffer_capacity: 200
enable_console: true
min_level: INFO
min_level: trace
playlists:
directory: playlists

View File

@@ -62,10 +62,12 @@ async fn main() -> anyhow::Result<()> {
};
info!("Initializing Radio Paradise channels...");
let server_base_url = format!("http://localhost:{}", 8080);
let manager = Arc::new(
ParadiseChannelManager::with_defaults_with_cover_cache(
Some(cover_cache),
Some(history_builder),
Some(server_base_url),
)
.await?,
);

View File

@@ -5,7 +5,10 @@
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
use pmosource::pmodidl::{Container, Item, Resource};
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use pmosource::{
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result,
SourceCapabilities,
};
use std::sync::Arc;
use std::time::SystemTime;
use tokio::sync::RwLock;
@@ -19,7 +22,7 @@ const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
///
/// Provides access to:
/// - Live OGG streams for all 4 channels (Main, Mellow, Rock, Eclectic)
/// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic)
/// - Historical playlists (FIFO) for each channel
///
/// # Object ID Schema
@@ -60,9 +63,19 @@ impl RadioParadiseSource {
/// Build a live stream URL for a channel
fn build_live_url(&self, slug: &str) -> String {
format!("{}/radioparadise/stream/{}/flac", self.base_url, slug)
}
/// Build an OGG-FLAC live stream URL for clients that support it
fn build_live_ogg_url(&self, slug: &str) -> String {
format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug)
}
/// URL de fallback pour l'image par défaut de la source
fn default_cover_url(&self) -> String {
format!("{}/api/sources/{}/image", self.base_url, self.id())
}
/// Fetch current metadata from the live stream
async fn fetch_live_metadata(&self, slug: &str) -> Result<Option<Item>> {
let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug);
@@ -77,7 +90,13 @@ impl RadioParadiseSource {
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);
let cover_url = json["cover_url"].as_str().map(|s| s.to_string());
// 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()
.map(|pk| format!("{}/covers/image/{}", self.base_url, pk))
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string()))
.or_else(|| Some(self.default_cover_url()));
// Parse duration from JSON (in seconds as a float)
let duration = json["duration"]
@@ -105,11 +124,11 @@ impl RadioParadiseSource {
album,
genre: Some("Radio".to_string()),
album_art: cover_url,
album_art_pk: None,
album_art_pk: cover_pk,
date: year.map(|y| y.to_string()),
original_track_number: None,
resources: vec![Resource {
protocol_info: "http-get:*:audio/ogg:*".to_string(),
protocol_info: "http-get:*:audio/flac:*".to_string(),
bits_per_sample: None,
sample_frequency: None,
nr_audio_channels: Some("2".to_string()),
@@ -192,18 +211,28 @@ impl RadioParadiseSource {
artist: Some("Radio Paradise".to_string()),
album: Some(descriptor.display_name.to_string()),
genre: Some("Radio".to_string()),
album_art: None,
album_art: Some(self.default_cover_url()),
album_art_pk: None,
date: None,
original_track_number: None,
resources: vec![Resource {
protocol_info: "http-get:*:audio/ogg:*".to_string(),
bits_per_sample: None,
sample_frequency: None,
resources: vec![
Resource {
protocol_info: "http-get:*:audio/flac:*".to_string(),
bits_per_sample: Some("16".to_string()),
sample_frequency: Some("44100".to_string()),
nr_audio_channels: Some("2".to_string()),
duration: None,
url: stream_url,
}],
url: stream_url.clone(),
},
Resource {
protocol_info: "http-get:*:audio/ogg:*".to_string(),
bits_per_sample: Some("16".to_string()),
sample_frequency: Some("44100".to_string()),
nr_audio_channels: Some("2".to_string()),
duration: None,
url: self.build_live_ogg_url(descriptor.slug),
},
],
descriptions: vec![],
}
}
@@ -415,6 +444,56 @@ impl MusicSource for RadioParadiseSource {
}
}
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),
}]),
_ => Err(MusicSourceError::ObjectNotFound(format!(
"Cannot list formats for object: {}",
object_id
))),
}
}
async fn get_item(&self, object_id: &str) -> Result<Item> {
match Self::parse_object_id(object_id) {
ObjectIdType::LiveStream { slug } => {

View File

@@ -12,7 +12,7 @@ use std::{
Arc,
},
task::{Context, Poll},
time::{Duration, SystemTime, UNIX_EPOCH},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use crate::{
@@ -48,14 +48,17 @@ pub struct ParadiseStreamChannelConfig {
pub flac_options: StreamingSinkOptions,
/// Options pour le flux OGG-FLAC.
pub ogg_options: StreamingSinkOptions,
/// URL de base du serveur (pour les métadonnées, covers...)
pub server_base_url: Option<String>,
}
impl Default for ParadiseStreamChannelConfig {
fn default() -> Self {
Self {
max_lead_seconds: 1.0,
max_lead_seconds: 3.0, // Compromis live/fluidité : assez pour absorber les transitions
flac_options: StreamingSinkOptions::flac_defaults(),
ogg_options: StreamingSinkOptions::ogg_defaults(),
server_base_url: None,
}
}
}
@@ -91,7 +94,7 @@ impl ParadiseHistoryBuilder {
playlist_title_prefix: Some("Radio Paradise History".into()),
max_history_tracks: Some(500),
collection_prefix: Some("radio-paradise".into()),
replay_max_lead_seconds: 1.0,
replay_max_lead_seconds: 3.0, // Aligné avec le live
}
}
@@ -145,6 +148,7 @@ impl ParadiseStreamChannelConfig {
max_lead_seconds: v.max(0.1),
flac_options: StreamingSinkOptions::flac_defaults(),
ogg_options: StreamingSinkOptions::ogg_defaults(),
server_base_url: None,
}
} else {
let default = Self::default();
@@ -159,6 +163,7 @@ impl ParadiseStreamChannelConfig {
max_lead_seconds: v.max(0.1),
flac_options: StreamingSinkOptions::flac_defaults(),
ogg_options: StreamingSinkOptions::ogg_defaults(),
server_base_url: None,
}
} else {
let default = Self::default();
@@ -195,6 +200,13 @@ impl ParadiseStreamChannel {
cover_cache: Option<Arc<CoverCache>>,
history: Option<ParadiseHistoryOptions>,
) -> Result<Self> {
// Propager server_base_url dans les options pour que les encoders injectent les covers du cache
let mut config = config;
if let Some(ref base) = config.server_base_url {
config.flac_options =
config.flac_options.clone().with_server_base_url(Some(base.clone()));
config.ogg_options = config.ogg_options.clone().with_server_base_url(Some(base.clone()));
}
let cover_cache = cover_cache
.or_else(|| history.as_ref().map(|opts| opts.cover_cache.clone()))
.or_else(|| get_cover_cache());
@@ -813,10 +825,31 @@ impl ParadiseChannelManager {
pub async fn with_defaults_with_cover_cache(
cover_cache: Option<Arc<CoverCache>>,
history_builder: Option<ParadiseHistoryBuilder>,
server_base_url: Option<String>,
) -> Result<Self> {
tracing::warn!(
"➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})",
ALL_CHANNELS.len(),
server_base_url
);
let mut map = HashMap::new();
for descriptor in ALL_CHANNELS.iter().copied() {
let mut config = ParadiseStreamChannelConfig::default();
config.server_base_url = server_base_url.clone();
let start = Instant::now();
tracing::warn!(
"⏳ Initializing Radio Paradise channel {} ({})...",
descriptor.display_name,
descriptor.slug
);
let history_opts = if let Some(builder) = &history_builder {
tracing::warn!(
" ⏳ Building history options for channel {} ({})",
descriptor.display_name,
descriptor.slug
);
Some(
builder
.build_for_channel(&descriptor)
@@ -826,20 +859,56 @@ impl ParadiseChannelManager {
} else {
None
};
let channel = ParadiseStreamChannel::new(
tracing::warn!(
" ⏩ History options ready for channel {} ({})",
descriptor.display_name,
descriptor.slug
);
let channel = match tokio::time::timeout(
Duration::from_secs(20),
ParadiseStreamChannel::new(
descriptor,
ParadiseStreamChannelConfig::default(),
config,
cover_cache.clone(),
history_opts,
),
)
.await?;
.await
{
Ok(Ok(ch)) => {
tracing::warn!(
"✅ Channel {} ({}) initialized in {:?}",
descriptor.display_name,
descriptor.slug,
start.elapsed()
);
ch
}
Ok(Err(e)) => {
tracing::error!(
"⚠️ Failed to initialize channel {} ({}): {}",
descriptor.display_name,
descriptor.slug,
e
);
continue;
}
Err(_) => {
tracing::error!(
"⚠️ Timeout initializing channel {} ({}) after 20s, skipping",
descriptor.display_name,
descriptor.slug
);
continue;
}
};
map.insert(descriptor.id, Arc::new(channel));
}
Ok(Self { channels: map })
}
pub async fn with_defaults() -> Result<Self> {
Self::with_defaults_with_cover_cache(None, None).await
Self::with_defaults_with_cover_cache(None, None, None).await
}
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {

View File

@@ -39,6 +39,7 @@ pub struct ParadiseStreamChannelConfig {
pub max_lead_seconds: f64,
pub flac_options: StreamingSinkOptions,
pub ogg_options: StreamingSinkOptions,
pub server_base_url: Option<String>,
}
impl Default for ParadiseStreamChannelConfig {
@@ -47,6 +48,7 @@ impl Default for ParadiseStreamChannelConfig {
max_lead_seconds: 1.0,
flac_options: StreamingSinkOptions::flac_defaults(),
ogg_options: StreamingSinkOptions::ogg_defaults(),
server_base_url: None,
}
}
}
@@ -143,6 +145,9 @@ impl ParadiseStreamChannelConfig {
if let Some(v) = num.as_f64() {
Self {
max_lead_seconds: v.max(0.1),
flac_options: StreamingSinkOptions::flac_defaults(),
ogg_options: StreamingSinkOptions::ogg_defaults(),
server_base_url: None,
}
} else {
let default = Self::default();
@@ -155,6 +160,9 @@ impl ParadiseStreamChannelConfig {
if let Ok(v) = s.parse::<f64>() {
Self {
max_lead_seconds: v.max(0.1),
flac_options: StreamingSinkOptions::flac_defaults(),
ogg_options: StreamingSinkOptions::ogg_defaults(),
server_base_url: None,
}
} else {
let default = Self::default();
@@ -666,9 +674,13 @@ impl ParadiseChannelManager {
pub async fn with_defaults_with_cover_cache(
cover_cache: Option<Arc<CoverCache>>,
history_builder: Option<ParadiseHistoryBuilder>,
server_base_url: Option<String>,
) -> Result<Self> {
let mut map = HashMap::new();
for descriptor in ALL_CHANNELS.iter().copied() {
let mut config = ParadiseStreamChannelConfig::default();
config.server_base_url = server_base_url.clone();
let history_opts = if let Some(builder) = &history_builder {
Some(
builder
@@ -681,7 +693,7 @@ impl ParadiseChannelManager {
};
let channel = ParadiseStreamChannel::new(
descriptor,
ParadiseStreamChannelConfig::default(),
config,
cover_cache.clone(),
history_opts,
)
@@ -692,7 +704,7 @@ impl ParadiseChannelManager {
}
pub async fn with_defaults() -> Result<Self> {
Self::with_defaults_with_cover_cache(None, None).await
Self::with_defaults_with_cover_cache(None, None, None).await
}
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {

View File

@@ -23,7 +23,8 @@ use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use tracing::Level;
use tracing_subscriber::{
Registry, filter::LevelFilter, layer::SubscriberExt, reload, util::SubscriberInitExt,
filter::EnvFilter, filter::LevelFilter, layer::SubscriberExt, reload, util::SubscriberInitExt,
Registry,
};
/// Représente une entrée de log
@@ -41,11 +42,11 @@ pub struct LogState {
buffer: Arc<RwLock<VecDeque<LogEntry>>>,
tx: broadcast::Sender<LogEntry>,
max_level: Arc<RwLock<Level>>,
reload_handle: Arc<RwLock<reload::Handle<LevelFilter, Registry>>>,
reload_handle: Arc<RwLock<reload::Handle<EnvFilter, Registry>>>,
}
impl LogState {
pub fn new(capacity: usize, reload_handle: reload::Handle<LevelFilter, Registry>) -> Self {
pub fn new(capacity: usize, reload_handle: reload::Handle<EnvFilter, Registry>) -> Self {
Self {
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
tx: broadcast::channel(1000).0,
@@ -57,16 +58,17 @@ impl LogState {
pub fn set_max_level(&self, level: Level) {
*self.max_level.write().unwrap() = level;
// Convertir Level en LevelFilter
let level_filter = level_to_levelfilter(level);
// Construire un filtre simple à partir du niveau global
let filter =
EnvFilter::try_new(level_to_string(level)).unwrap_or_else(|_| EnvFilter::new("trace"));
// Recharger le filtre dynamiquement
if let Err(e) = self.reload_handle.write().unwrap().reload(level_filter) {
if let Err(e) = self.reload_handle.write().unwrap().reload(filter) {
eprintln!("❌ Failed to reload log level filter: {}", e);
} else {
eprintln!(
"✅ Log level filter reloaded successfully to: {:?}",
level_filter
level
);
}
}
@@ -270,40 +272,52 @@ pub fn init_logging() -> LogState {
// Créer un filtre rechargeable qui commence au niveau déterminé par
// RUST_LOG (prioritaire) ou la configuration.
let (initial_level, level_source) = match std::env::var("RUST_LOG") {
let (env_filter, initial_level, level_source) = match std::env::var("RUST_LOG") {
Ok(value) => {
let trimmed = value.trim();
if let Some(level) = string_to_level(trimmed) {
(level, format!("RUST_LOG ({})", trimmed))
let filter =
EnvFilter::try_new(trimmed).unwrap_or_else(|_| EnvFilter::new("trace"));
(filter, level, format!("RUST_LOG ({})", trimmed))
} else {
match EnvFilter::try_new(trimmed) {
Ok(filter) => {
let level_hint =
filter.max_level_hint().and_then(levelfilter_to_level).unwrap_or(Level::TRACE);
(filter, level_hint, format!("RUST_LOG ({})", trimmed))
}
Err(e) => {
eprintln!(
"⚠️ Invalid RUST_LOG value '{}', falling back to configuration",
value
"⚠️ Invalid RUST_LOG value '{}': {}, falling back to configuration",
value, e
);
let fallback = config
let cfg_level = config
.get_log_min_level()
.ok()
.and_then(|cfg| string_to_level(cfg.trim()))
.unwrap_or(Level::TRACE);
(fallback, "config".to_string())
let filter = EnvFilter::new(level_to_string(cfg_level));
(filter, cfg_level, "config".to_string())
}
}
}
}
Err(_) => {
let level = config
let cfg_level = config
.get_log_min_level()
.ok()
.and_then(|cfg| string_to_level(cfg.trim()))
.unwrap_or(Level::TRACE);
(level, "config".to_string())
let filter = EnvFilter::new(level_to_string(cfg_level));
(filter, cfg_level, "config".to_string())
}
};
let log_level = level_to_levelfilter(initial_level);
eprintln!(
" Initial log level set to {:?} (source: {})",
initial_level, level_source
);
let (filter, reload_handle) = reload::Layer::new(log_level);
let (filter_layer, reload_handle) = reload::Layer::new(env_filter);
let buffer_capacity = match config.get_log_cache_size() {
Ok(c) => c,
@@ -317,7 +331,7 @@ pub fn init_logging() -> LogState {
// Construire le subscriber avec le filtre rechargeable AVANT le SseLayer
// L'ordre est important : le filtre doit être appliqué en premier
let subscriber = Registry::default()
.with(filter)
.with(filter_layer)
.with(SseLayer::new(log_state.clone()));
let enable_console = match config.get_log_enable_console() {
@@ -455,13 +469,14 @@ fn level_to_string(level: Level) -> String {
.to_string()
}
fn level_to_levelfilter(level: Level) -> LevelFilter {
match level {
Level::ERROR => LevelFilter::ERROR,
Level::WARN => LevelFilter::WARN,
Level::INFO => LevelFilter::INFO,
Level::DEBUG => LevelFilter::DEBUG,
Level::TRACE => LevelFilter::TRACE,
fn levelfilter_to_level(filter: LevelFilter) -> Option<Level> {
match filter {
LevelFilter::ERROR => Some(Level::ERROR),
LevelFilter::WARN => Some(Level::WARN),
LevelFilter::INFO => Some(Level::INFO),
LevelFilter::DEBUG => Some(Level::DEBUG),
LevelFilter::TRACE => Some(Level::TRACE),
_ => None,
}
}

View File

@@ -928,7 +928,7 @@ async fn stream_source_item_metadata(
Some(source) => {
let object_id = params.object_id.clone();
// Create a stream that fetches metadata every 3 seconds
// Create a stream that fetches metadata frequently for near-realtime updates
let stream = stream::repeat_with(move || {
let source = source.clone();
let object_id = object_id.clone();
@@ -946,7 +946,7 @@ async fn stream_source_item_metadata(
}
})
.then(|fut| fut)
.throttle(Duration::from_secs(3))
.throttle(Duration::from_millis(500))
.filter_map(|result| match result {
Ok(event) => Some(Ok::<_, Infallible>(event)),
Err(e) => {