debug lectueur générique
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
.await
|
||||
.context("Failed to create ParadiseChannelManager")?,
|
||||
);
|
||||
base_url,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
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(),
|
||||
|
||||
@@ -9,6 +9,6 @@ host:
|
||||
logger:
|
||||
buffer_capacity: 200
|
||||
enable_console: true
|
||||
min_level: INFO
|
||||
min_level: trace
|
||||
playlists:
|
||||
directory: playlists
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
nr_audio_channels: Some("2".to_string()),
|
||||
duration: None,
|
||||
url: stream_url,
|
||||
}],
|
||||
resources: vec![
|
||||
Resource {
|
||||
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
||||
bits_per_sample: Some("16".to_string()),
|
||||
sample_frequency: Some("44100".to_string()),
|
||||
nr_audio_channels: Some("2".to_string()),
|
||||
duration: None,
|
||||
url: stream_url.clone(),
|
||||
},
|
||||
Resource {
|
||||
protocol_info: "http-get:*:audio/ogg:*".to_string(),
|
||||
bits_per_sample: Some("16".to_string()),
|
||||
sample_frequency: Some("44100".to_string()),
|
||||
nr_audio_channels: Some("2".to_string()),
|
||||
duration: None,
|
||||
url: self.build_live_ogg_url(descriptor.slug),
|
||||
},
|
||||
],
|
||||
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 } => {
|
||||
|
||||
@@ -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(
|
||||
descriptor,
|
||||
ParadiseStreamChannelConfig::default(),
|
||||
cover_cache.clone(),
|
||||
history_opts,
|
||||
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,
|
||||
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>> {
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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 {
|
||||
eprintln!(
|
||||
"⚠️ Invalid RUST_LOG value '{}', falling back to configuration",
|
||||
value
|
||||
);
|
||||
let fallback = config
|
||||
.get_log_min_level()
|
||||
.ok()
|
||||
.and_then(|cfg| string_to_level(cfg.trim()))
|
||||
.unwrap_or(Level::TRACE);
|
||||
(fallback, "config".to_string())
|
||||
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, e
|
||||
);
|
||||
let cfg_level = config
|
||||
.get_log_min_level()
|
||||
.ok()
|
||||
.and_then(|cfg| string_to_level(cfg.trim()))
|
||||
.unwrap_or(Level::TRACE);
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user