Changement du mécanisme d'attention sur les channels Radio Paradise.

This commit is contained in:
2025-11-29 14:19:16 +01:00
parent cf3f0afde4
commit 0a03f72467
44 changed files with 564 additions and 231 deletions

1
Cargo.lock generated
View File

@@ -3280,6 +3280,7 @@ dependencies = [
"futures", "futures",
"futures-util", "futures-util",
"hex", "hex",
"once_cell",
"pmoaudio", "pmoaudio",
"pmoaudio-ext", "pmoaudio-ext",
"pmoaudiocache", "pmoaudiocache",

View File

@@ -1,6 +1,8 @@
use pmoapp::{WebAppExt, Webapp}; use pmoapp::{WebAppExt, Webapp};
use pmomediarenderer::MEDIA_RENDERER; use pmomediarenderer::MEDIA_RENDERER;
use pmomediaserver::{MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt}; use pmomediaserver::{
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
};
use pmoserver::Server; use pmoserver::Server;
use pmosource::MusicSourceExt; use pmosource::MusicSourceExt;
use pmoupnp::UpnpServerExt; use pmoupnp::UpnpServerExt;

View File

@@ -1,7 +1,15 @@
use std::io; use std::io;
use std::{collections::VecDeque, pin::Pin, sync::Arc, task::{Context, Poll}}; use std::{
collections::VecDeque,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use tokio::{io::{AsyncRead, ReadBuf}, sync::{RwLock, mpsc}}; use tokio::{
io::{AsyncRead, ReadBuf},
sync::{mpsc, RwLock},
};
/// PCM chunk with audio data and timestamp for precise pacing. /// PCM chunk with audio data and timestamp for precise pacing.
#[derive(Debug)] #[derive(Debug)]

View File

@@ -1,7 +1,10 @@
use pmoaudio::{AudioChunk, AudioError}; use pmoaudio::{AudioChunk, AudioError};
/// Convert an AudioChunk to PCM bytes with specified bit depth. /// Convert an AudioChunk to PCM bytes with specified bit depth.
pub(crate) fn chunk_to_pcm_bytes(chunk: &AudioChunk, bits_per_sample: u8) -> Result<Vec<u8>, AudioError> { pub(crate) fn chunk_to_pcm_bytes(
chunk: &AudioChunk,
bits_per_sample: u8,
) -> Result<Vec<u8>, AudioError> {
match chunk { match chunk {
AudioChunk::F32(_) | AudioChunk::F64(_) => { AudioChunk::F32(_) | AudioChunk::F64(_) => {
return Err(AudioError::ProcessingError( return Err(AudioError::ProcessingError(

View File

@@ -298,28 +298,44 @@ impl NodeLogic for FlacCacheSinkLogic {
if let Some(sr) = transform.sample_rate { if let Some(sr) = transform.sample_rate {
if let Err(e) = meta.set_sample_rate(Some(sr)).await { if let Err(e) = meta.set_sample_rate(Some(sr)).await {
tracing::error!("FlacCacheSink: Failed to set sample_rate for pk {}: {:?}", pk, e); tracing::error!(
"FlacCacheSink: Failed to set sample_rate for pk {}: {:?}",
pk,
e
);
} else { } else {
tracing::debug!("FlacCacheSink: Set sample_rate={} for pk {}", sr, pk); tracing::debug!("FlacCacheSink: Set sample_rate={} for pk {}", sr, pk);
} }
} }
if let Some(bps) = transform.bits_per_sample { if let Some(bps) = transform.bits_per_sample {
if let Err(e) = meta.set_bits_per_sample(Some(bps)).await { if let Err(e) = meta.set_bits_per_sample(Some(bps)).await {
tracing::error!("FlacCacheSink: Failed to set bits_per_sample for pk {}: {:?}", pk, e); tracing::error!(
"FlacCacheSink: Failed to set bits_per_sample for pk {}: {:?}",
pk,
e
);
} else { } else {
tracing::debug!("FlacCacheSink: Set bits_per_sample={} for pk {}", bps, pk); tracing::debug!("FlacCacheSink: Set bits_per_sample={} for pk {}", bps, pk);
} }
} }
if let Some(ch) = transform.channels { if let Some(ch) = transform.channels {
if let Err(e) = meta.set_channels(Some(ch)).await { if let Err(e) = meta.set_channels(Some(ch)).await {
tracing::error!("FlacCacheSink: Failed to set channels for pk {}: {:?}", pk, e); tracing::error!(
"FlacCacheSink: Failed to set channels for pk {}: {:?}",
pk,
e
);
} else { } else {
tracing::debug!("FlacCacheSink: Set channels={} for pk {}", ch, pk); tracing::debug!("FlacCacheSink: Set channels={} for pk {}", ch, pk);
} }
} }
if let Some(ts) = transform.total_samples { if let Some(ts) = transform.total_samples {
if let Err(e) = meta.set_total_samples(Some(ts)).await { if let Err(e) = meta.set_total_samples(Some(ts)).await {
tracing::error!("FlacCacheSink: Failed to set total_samples for pk {}: {:?}", pk, e); tracing::error!(
"FlacCacheSink: Failed to set total_samples for pk {}: {:?}",
pk,
e
);
} else { } else {
tracing::debug!("FlacCacheSink: Set total_samples={} for pk {}", ts, pk); tracing::debug!("FlacCacheSink: Set total_samples={} for pk {}", ts, pk);
} }
@@ -329,10 +345,19 @@ impl NodeLogic for FlacCacheSinkLogic {
if sr > 0 { if sr > 0 {
use std::time::Duration; use std::time::Duration;
let secs = (ts as f64 / sr as f64).round() as u64; let secs = (ts as f64 / sr as f64).round() as u64;
if let Err(e) = meta.set_duration(Some(Duration::from_secs(secs))).await { if let Err(e) = meta.set_duration(Some(Duration::from_secs(secs))).await
tracing::error!("FlacCacheSink: Failed to set duration for pk {}: {:?}", pk, e); {
tracing::error!(
"FlacCacheSink: Failed to set duration for pk {}: {:?}",
pk,
e
);
} else { } else {
tracing::debug!("FlacCacheSink: Set duration={} secs for pk {}", secs, pk); tracing::debug!(
"FlacCacheSink: Set duration={} secs for pk {}",
secs,
pk
);
} }
} }
} }
@@ -340,7 +365,10 @@ impl NodeLogic for FlacCacheSinkLogic {
drop(meta); // Libérer le lock explicitement drop(meta); // Libérer le lock explicitement
} else { } else {
tracing::warn!("FlacCacheSink: No transform metadata available for pk {}", pk); tracing::warn!(
"FlacCacheSink: No transform metadata available for pk {}",
pk
);
} }
// Phase 2: Prebuffer terminé! Copier les métadonnées et pusher à la playlist // Phase 2: Prebuffer terminé! Copier les métadonnées et pusher à la playlist

View File

@@ -17,7 +17,6 @@ pub(crate) enum FlacStreamState {
Streaming, Streaming,
} }
/// Validate and parse FLAC block size from frame header /// Validate and parse FLAC block size from frame header
/// ///
/// Returns the number of samples in the frame if the header is valid, or None if: /// Returns the number of samples in the frame if the header is valid, or None if:
@@ -376,7 +375,6 @@ pub(crate) fn find_complete_frames_with_samples(data: &[u8]) -> (usize, u64) {
} }
} }
/// Extract sample rate from STREAMINFO block in FLAC header /// Extract sample rate from STREAMINFO block in FLAC header
pub(crate) fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioError> { pub(crate) fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<u32, AudioError> {
// Verify we have at least "fLaC" magic + STREAMINFO block header // Verify we have at least "fLaC" magic + STREAMINFO block header
@@ -424,7 +422,9 @@ pub(crate) fn extract_sample_rate_from_streaminfo(flac_header: &[u8]) -> Result<
} }
/// Read FLAC header (fLaC + all metadata blocks until first frame) /// Read FLAC header (fLaC + all metadata blocks until first frame)
pub(crate) async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<Vec<u8>, AudioError> { pub(crate) async fn read_flac_header(
stream: &mut FlacEncodedStream,
) -> Result<Vec<u8>, AudioError> {
let mut header = Vec::new(); let mut header = Vec::new();
let mut buffer = [0u8; 4]; let mut buffer = [0u8; 4];
@@ -472,8 +472,6 @@ pub(crate) async fn read_flac_header(stream: &mut FlacEncodedStream) -> Result<V
Ok(header) Ok(header)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -78,7 +78,7 @@ use tokio::sync::{mpsc, RwLock};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
use crate::byte_stream_reader::{PcmChunk}; use crate::byte_stream_reader::PcmChunk;
use crate::chunk_to_pcm::chunk_to_pcm_bytes; use crate::chunk_to_pcm::chunk_to_pcm_bytes;
use crate::sinks::streaming_sink_common::{ use crate::sinks::streaming_sink_common::{
MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner, MetadataSnapshot, SharedClientStream, SharedSinkContext, SharedStreamHandleInner,
@@ -143,7 +143,6 @@ impl StreamHandle {
} }
} }
pub struct FlacClientStream { pub struct FlacClientStream {
inner: SharedClientStream, inner: SharedClientStream,
} }

View File

@@ -1,8 +1,23 @@
use std::{collections::VecDeque, pin::Pin, sync::Arc, task::{Context, Poll}}; use std::{
collections::VecDeque,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use tokio::{io::{AsyncRead, ReadBuf}, sync::RwLock}; use tokio::{
io::{AsyncRead, ReadBuf},
sync::RwLock,
};
use crate::{MetadataSnapshot, sinks::{flac_frame_utils::FlacStreamState, streaming_sink_common::SharedStreamHandleInner, timed_broadcast::{self, TryRecvError}}}; use crate::{
sinks::{
flac_frame_utils::FlacStreamState,
streaming_sink_common::SharedStreamHandleInner,
timed_broadcast::{self, TryRecvError},
},
MetadataSnapshot,
};
use bytes::Bytes; use bytes::Bytes;
use std::io; use std::io;

View File

@@ -69,7 +69,7 @@ use tokio::sync::{mpsc, RwLock};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{debug, error, trace, warn}; use tracing::{debug, error, trace, warn};
use crate::byte_stream_reader::{PcmChunk}; use crate::byte_stream_reader::PcmChunk;
use crate::chunk_to_pcm::chunk_to_pcm_bytes; use crate::chunk_to_pcm::chunk_to_pcm_bytes;
use crate::sinks::flac_frame_utils::{extract_sample_rate_from_streaminfo, read_flac_header}; use crate::sinks::flac_frame_utils::{extract_sample_rate_from_streaminfo, read_flac_header};
use crate::sinks::streaming_sink_common::{ use crate::sinks::streaming_sink_common::{
@@ -421,7 +421,7 @@ impl StreamingOggFlacSink {
"Streaming Sink: using broadcast capacity of {} items (max_lead_time={:.1}s)", "Streaming Sink: using broadcast capacity of {} items (max_lead_time={:.1}s)",
broadcast_capacity, broadcast_max_lead_time broadcast_capacity, broadcast_max_lead_time
); );
let (broadcast, _) = timed_broadcast::channel("Ogg-Flac",broadcast_capacity); let (broadcast, _) = timed_broadcast::channel("Ogg-Flac", broadcast_capacity);
// OGG-FLAC header cache // OGG-FLAC header cache
let header = Arc::new(RwLock::new(None)); let header = Arc::new(RwLock::new(None));

View File

@@ -397,7 +397,9 @@ impl SharedSinkContext {
debug!( debug!(
"Encoder metadata: from TrackBoundary - duration={:?}s, total_samples={:?}", "Encoder metadata: from TrackBoundary - duration={:?}s, total_samples={:?}",
self.pending_track_duration.as_ref().map(|d| d.as_secs_f64()), self.pending_track_duration
.as_ref()
.map(|d| d.as_secs_f64()),
self.pending_total_samples self.pending_total_samples
); );

View File

@@ -181,7 +181,9 @@ impl<T> State<T> {
let entry = oentry.unwrap(); let entry = oentry.unwrap();
trace!( trace!(
"TimedBroadcast[{}]: pruning played packet (@{} epoch={})", "TimedBroadcast[{}]: pruning played packet (@{} epoch={})",
self.name, entry.seq, entry.epoch self.name,
entry.seq,
entry.epoch
); );
self.head_seq += 1; self.head_seq += 1;
@@ -311,15 +313,13 @@ impl<T> Sender<T> {
} }
// 2. Vérifier si un slot est disponible et insérer // 2. Vérifier si un slot est disponible et insérer
let is_top_zero = let is_top_zero = audio_timestamp.abs() < TOP_ZERO_EPSILON
audio_timestamp.abs() < TOP_ZERO_EPSILON
&& segment_duration >= TOP_ZERO_EPSILON; && segment_duration >= TOP_ZERO_EPSILON;
let is_zero_header = let is_zero_header =
audio_timestamp.abs() < TOP_ZERO_EPSILON audio_timestamp.abs() < TOP_ZERO_EPSILON && segment_duration < TOP_ZERO_EPSILON;
&& segment_duration < TOP_ZERO_EPSILON;
if state.buffer.len() < self.inner.capacity { if state.buffer.len() < self.inner.capacity {
if !state.initialized { if !state.initialized {
if !is_top_zero && segment_duration >= TOP_ZERO_EPSILON { if !is_top_zero && segment_duration >= TOP_ZERO_EPSILON {
warn!( warn!(
"TimedBroadcast[{}]: First packet has non-zero timestamp {:.1}ms - Duration={:.1}ms, treating as epoch start anyway", "TimedBroadcast[{}]: First packet has non-zero timestamp {:.1}ms - Duration={:.1}ms, treating as epoch start anyway",
state.name, state.name,
@@ -352,13 +352,12 @@ impl<T> Sender<T> {
state.name, state.name,
state.epoch, state.epoch,
state.last_segment_end.is_some(), state.last_segment_end.is_some(),
segment_duration*1000.0 segment_duration * 1000.0
); );
} }
let expires_at = state.epoch_start let expires_at = state.epoch_start
+ Duration::from_secs_f64(audio_timestamp + Duration::from_secs_f64(audio_timestamp + segment_duration);
+ segment_duration);
let is_first_packet = state.next_seq == 0; let is_first_packet = state.next_seq == 0;
if !is_first_packet && !is_top_zero && !is_zero_header && expires_at <= now { if !is_first_packet && !is_top_zero && !is_zero_header && expires_at <= now {

View File

@@ -322,9 +322,19 @@ impl NodeLogic for PlaylistSourceLogic {
Ok(()) => { Ok(()) => {
tracing::info!("PlaylistSource: finished track {} - {}", artist, title); tracing::info!("PlaylistSource: finished track {} - {}", artist, title);
// Piste décodée avec succès, transférer vers l'historique si configuré // Piste décodée avec succès, transférer vers l'historique si configuré
tracing::warn!("🔍 HISTORY DEBUG: history_playlist is {:?}", if self.history_playlist.is_some() { "Some" } else { "None" }); tracing::warn!(
"🔍 HISTORY DEBUG: history_playlist is {:?}",
if self.history_playlist.is_some() {
"Some"
} else {
"None"
}
);
if let Some(ref history) = self.history_playlist { if let Some(ref history) = self.history_playlist {
tracing::warn!("🔍 HISTORY DEBUG: Attempting to push cache_pk={} to history", cache_pk); tracing::warn!(
"🔍 HISTORY DEBUG: Attempting to push cache_pk={} to history",
cache_pk
);
if let Err(e) = history.push(cache_pk.to_string()).await { if let Err(e) = history.push(cache_pk.to_string()).await {
tracing::warn!( tracing::warn!(
"PlaylistSourceLogic: failed to add track to history: {}", "PlaylistSourceLogic: failed to add track to history: {}",

View File

@@ -82,10 +82,7 @@ pub async fn get_cover_url(
Ok(Some(cover_pk)) if !cover_pk.is_empty() => (cover_pk, "cover_pk".to_string()), Ok(Some(cover_pk)) if !cover_pk.is_empty() => (cover_pk, "cover_pk".to_string()),
_ => match metadata_guard.get_cover_url().await { _ => match metadata_guard.get_cover_url().await {
Ok(Some(url)) if !url.is_empty() => (url, "cover_url".to_string()), Ok(Some(url)) if !url.is_empty() => (url, "cover_url".to_string()),
_ => ( _ => (pmometadata::get_default_cover_url(), "default".to_string()),
pmometadata::get_default_cover_url(),
"default".to_string(),
),
}, },
}; };

View File

@@ -4,10 +4,10 @@
//! spécifiques aux fichiers audio : conversion FLAC automatique et stockage //! spécifiques aux fichiers audio : conversion FLAC automatique et stockage
//! des métadonnées en JSON dans la base de données. //! des métadonnées en JSON dans la base de données.
use anyhow::Result;
use crate::metadata_ext::AudioTrackMetadataExt; use crate::metadata_ext::AudioTrackMetadataExt;
use pmocache::CacheConfig; use anyhow::Result;
use pmocache::download::TransformMetadata; use pmocache::download::TransformMetadata;
use pmocache::CacheConfig;
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
@@ -277,8 +277,7 @@ fn parse_flac_streaminfo(data: &[u8]) -> Option<(u32, u8, u64)> {
let s = &data[8..8 + 34]; let s = &data[8..8 + 34];
// sample_rate: 20 bits: bytes 10..12 // sample_rate: 20 bits: bytes 10..12
let sample_rate = let sample_rate = ((s[10] as u32) << 12) | ((s[11] as u32) << 4) | ((s[12] as u32 & 0xF0) >> 4);
((s[10] as u32) << 12) | ((s[11] as u32) << 4) | ((s[12] as u32 & 0xF0) >> 4);
// bits_per_sample: 5 bits spanning byte 12 (lsb) and byte 13 (msb) // bits_per_sample: 5 bits spanning byte 12 (lsb) and byte 13 (msb)
let bps_raw = (((s[12] & 0x01) as u8) << 4) | ((s[13] & 0xF0) >> 4); let bps_raw = (((s[12] & 0x01) as u8) << 4) | ((s[13] & 0xF0) >> 4);

View File

@@ -231,7 +231,7 @@ impl AudioCacheExt for pmoserver::Server {
"/{pk}/cover-url", "/{pk}/cover-url",
axum::routing::get(crate::api::get_cover_url), axum::routing::get(crate::api::get_cover_url),
) )
.with_state(cache.clone()) .with_state(cache.clone()),
); );
let openapi = crate::ApiDoc::openapi(); let openapi = crate::ApiDoc::openapi();

View File

@@ -124,9 +124,24 @@ impl TrackMetadataDidlExt for dyn TrackMetadata {
pmodidl::Resource { pmodidl::Resource {
// Aligne sur Sink du renderer (audio/flac) avec PN explicite. // Aligne sur Sink du renderer (audio/flac) avec PN explicite.
protocol_info: "http-get:*:audio/flac:DLNA.ORG_PN=FLAC".to_string(), protocol_info: "http-get:*:audio/flac:DLNA.ORG_PN=FLAC".to_string(),
bits_per_sample: self.get_bits_per_sample().await.ok().flatten().map(|b| b.to_string()), bits_per_sample: self
sample_frequency: self.get_sample_rate().await.ok().flatten().map(|sr| sr.to_string()), .get_bits_per_sample()
nr_audio_channels: self.get_channels().await.ok().flatten().map(|ch| ch.to_string()), .await
.ok()
.flatten()
.map(|b| b.to_string()),
sample_frequency: self
.get_sample_rate()
.await
.ok()
.flatten()
.map(|sr| sr.to_string()),
nr_audio_channels: self
.get_channels()
.await
.ok()
.flatten()
.map(|ch| ch.to_string()),
duration, duration,
url, url,
} }

View File

@@ -237,22 +237,27 @@ impl<C: CacheConfig> Cache<C> {
); );
if let Some(sr) = transform.sample_rate { if let Some(sr) = transform.sample_rate {
self.db.set_a_metadata(pk, "sample_rate", serde_json::json!(sr))?; self.db
.set_a_metadata(pk, "sample_rate", serde_json::json!(sr))?;
} }
if let Some(bps) = transform.bits_per_sample { if let Some(bps) = transform.bits_per_sample {
self.db.set_a_metadata(pk, "bits_per_sample", serde_json::json!(bps))?; self.db
.set_a_metadata(pk, "bits_per_sample", serde_json::json!(bps))?;
} }
if let Some(ch) = transform.channels { if let Some(ch) = transform.channels {
self.db.set_a_metadata(pk, "channels", serde_json::json!(ch))?; self.db
.set_a_metadata(pk, "channels", serde_json::json!(ch))?;
} }
if let Some(ts) = transform.total_samples { if let Some(ts) = transform.total_samples {
self.db.set_a_metadata(pk, "total_samples", serde_json::json!(ts))?; self.db
.set_a_metadata(pk, "total_samples", serde_json::json!(ts))?;
// Calculer la durée à partir de total_samples et sample_rate // Calculer la durée à partir de total_samples et sample_rate
if let Some(sr) = transform.sample_rate { if let Some(sr) = transform.sample_rate {
if sr > 0 { if sr > 0 {
let secs = (ts as f64 / sr as f64).round() as u64; let secs = (ts as f64 / sr as f64).round() as u64;
self.db.set_a_metadata(pk, "duration_secs", serde_json::json!(secs))?; self.db
.set_a_metadata(pk, "duration_secs", serde_json::json!(secs))?;
} }
} }
} }

View File

@@ -269,7 +269,9 @@ fn test_get_pk_by_origin_url() {
assert_eq!(found_pk, Some(pk.to_string())); assert_eq!(found_pk, Some(pk.to_string()));
// Rechercher une URL qui n'existe pas // Rechercher une URL qui n'existe pas
let not_found = db.get_pk_by_origin_url("https://example.com/notfound.jpg").unwrap(); let not_found = db
.get_pk_by_origin_url("https://example.com/notfound.jpg")
.unwrap();
assert_eq!(not_found, None); assert_eq!(not_found, None);
} }

View File

@@ -54,7 +54,6 @@
pub mod cache; pub mod cache;
pub mod webp; pub mod webp;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
pub mod openapi; pub mod openapi;
@@ -201,7 +200,10 @@ async fn serve_jpeg_internal(
use image::ImageFormat; use image::ImageFormat;
use std::io::Cursor; use std::io::Cursor;
let path = cache.get_file_path_with_qualifier(&pk, <CoversConfig as pmocache::cache::CacheConfig>::default_param()); let path = cache.get_file_path_with_qualifier(
&pk,
<CoversConfig as pmocache::cache::CacheConfig>::default_param(),
);
if !path.exists() { if !path.exists() {
return (StatusCode::NOT_FOUND, "File not found").into_response(); return (StatusCode::NOT_FOUND, "File not found").into_response();
} }
@@ -218,12 +220,7 @@ async fn serve_jpeg_internal(
.await; .await;
match res { match res {
Ok(Ok(data)) => ( Ok(Ok(data)) => (StatusCode::OK, [("content-type", "image/jpeg")], data).into_response(),
StatusCode::OK,
[("content-type", "image/jpeg")],
data,
)
.into_response(),
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::warn!("JPEG transcode error for {}: {}", pk, e); tracing::warn!("JPEG transcode error for {}: {}", pk, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Transcode error").into_response() (StatusCode::INTERNAL_SERVER_ERROR, "Transcode error").into_response()
@@ -294,10 +291,7 @@ impl CoverCacheExt for pmoserver::Server {
// Router JPEG (transcodage à la volée depuis le WebP stocké) // Router JPEG (transcodage à la volée depuis le WebP stocké)
// Routes: GET /covers/jpeg/{pk} et GET /covers/jpeg/{pk}/{size} // Routes: GET /covers/jpeg/{pk} et GET /covers/jpeg/{pk}/{size}
let jpeg_router = axum::Router::new() let jpeg_router = axum::Router::new()
.route( .route("/covers/jpeg/{pk}", axum::routing::get(serve_cover_jpeg))
"/covers/jpeg/{pk}",
axum::routing::get(serve_cover_jpeg),
)
.route( .route(
"/covers/jpeg/{pk}/{size}", "/covers/jpeg/{pk}/{size}",
axum::routing::get(serve_cover_jpeg_with_size), axum::routing::get(serve_cover_jpeg_with_size),

View File

@@ -9,9 +9,9 @@ fn main() {
creator: Some("Test Artist".to_string()), creator: Some("Test Artist".to_string()),
class: "object.item.audioItem.musicTrack".to_string(), class: "object.item.audioItem.musicTrack".to_string(),
artist: Some("Test Artist".to_string()), artist: Some("Test Artist".to_string()),
album: None, // Pas d'album album: None, // Pas d'album
genre: None, genre: None,
album_art: None, // Pas d'albumArtURI album_art: None, // Pas d'albumArtURI
album_art_pk: None, album_art_pk: None,
date: None, date: None,
original_track_number: None, original_track_number: None,
@@ -42,7 +42,10 @@ fn main() {
println!("=== Output from quick_xml::se::to_string() ==="); println!("=== Output from quick_xml::se::to_string() ===");
println!("{}", xml); println!("{}", xml);
println!("\n=== Length: {} bytes ===", xml.len()); println!("\n=== Length: {} bytes ===", xml.len());
println!("\n=== Starts with '<?xml' ? {} ===", xml.starts_with("<?xml")); println!(
"\n=== Starts with '<?xml' ? {} ===",
xml.starts_with("<?xml")
);
println!("\n=== With manual XML declaration ==="); println!("\n=== With manual XML declaration ===");
let with_decl = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>{}", xml); let with_decl = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>{}", xml);
println!("{}", with_decl); println!("{}", with_decl);

View File

@@ -554,7 +554,8 @@ impl ToXmlElement for Container {
fn to_xml_element(&self) -> Element { fn to_xml_element(&self) -> Element {
let mut elem = Element::new("container"); let mut elem = Element::new("container");
elem.attributes.insert("id".into(), self.id.clone()); elem.attributes.insert("id".into(), self.id.clone());
elem.attributes.insert("parentID".into(), self.parent_id.clone()); elem.attributes
.insert("parentID".into(), self.parent_id.clone());
if let Some(ref r) = self.restricted { if let Some(ref r) = self.restricted {
elem.attributes.insert("restricted".into(), r.clone()); elem.attributes.insert("restricted".into(), r.clone());
} }
@@ -562,11 +563,14 @@ impl ToXmlElement for Container {
elem.attributes.insert("childCount".into(), cc.clone()); elem.attributes.insert("childCount".into(), cc.clone());
} }
if let Some(ref searchable) = self.searchable { if let Some(ref searchable) = self.searchable {
elem.attributes.insert("searchable".into(), searchable.clone()); elem.attributes
.insert("searchable".into(), searchable.clone());
} }
elem.children.push(XMLNode::Element(text_element("dc:title", &self.title))); elem.children
elem.children.push(XMLNode::Element(text_element("upnp:class", &self.class))); .push(XMLNode::Element(text_element("dc:title", &self.title)));
elem.children
.push(XMLNode::Element(text_element("upnp:class", &self.class)));
for c in &self.containers { for c in &self.containers {
elem.children.push(XMLNode::Element(c.to_xml_element())); elem.children.push(XMLNode::Element(c.to_xml_element()));
@@ -583,15 +587,18 @@ impl ToXmlElement for Item {
fn to_xml_element(&self) -> Element { fn to_xml_element(&self) -> Element {
let mut elem = Element::new("item"); let mut elem = Element::new("item");
elem.attributes.insert("id".into(), self.id.clone()); elem.attributes.insert("id".into(), self.id.clone());
elem.attributes.insert("parentID".into(), self.parent_id.clone()); elem.attributes
.insert("parentID".into(), self.parent_id.clone());
if let Some(ref r) = self.restricted { if let Some(ref r) = self.restricted {
elem.attributes.insert("restricted".into(), r.clone()); elem.attributes.insert("restricted".into(), r.clone());
} }
elem.children.push(XMLNode::Element(text_element("dc:title", &self.title))); elem.children
.push(XMLNode::Element(text_element("dc:title", &self.title)));
if let Some(ref c) = self.creator { if let Some(ref c) = self.creator {
elem.children.push(XMLNode::Element(text_element("dc:creator", c))); elem.children
.push(XMLNode::Element(text_element("dc:creator", c)));
} }
elem.children elem.children
@@ -644,7 +651,8 @@ impl ToXmlElement for Resource {
elem.attributes.insert("bitsPerSample".into(), bps.clone()); elem.attributes.insert("bitsPerSample".into(), bps.clone());
} }
if let Some(ref freq) = self.sample_frequency { if let Some(ref freq) = self.sample_frequency {
elem.attributes.insert("sampleFrequency".into(), freq.clone()); elem.attributes
.insert("sampleFrequency".into(), freq.clone());
} }
if let Some(ref ch) = self.nr_audio_channels { if let Some(ref ch) = self.nr_audio_channels {
elem.attributes.insert("nrAudioChannels".into(), ch.clone()); elem.attributes.insert("nrAudioChannels".into(), ch.clone());
@@ -678,7 +686,6 @@ impl ToXmlElement for Description {
} }
} }
// ============= Itérateurs personnalisés ============= // ============= Itérateurs personnalisés =============
struct AllContainersIter<'a> { struct AllContainersIter<'a> {

View File

@@ -554,7 +554,9 @@ fn run_encoder(
"set_total_samples_estimate failed", "set_total_samples_estimate failed",
)?; )?;
} else { } else {
tracing::warn!("FLAC encoder: total_samples is None, STREAMINFO will have total_samples=0"); tracing::warn!(
"FLAC encoder: total_samples is None, STREAMINFO will have total_samples=0"
);
} }
if let Some(block_size) = options.block_size { if let Some(block_size) = options.block_size {
ensure( ensure(

View File

@@ -10,10 +10,10 @@
//! - **Search** : Recherche dans les sources qui le supportent //! - **Search** : Recherche dans les sources qui le supportent
//! - **Update ID** : Suivi des changements pour les notifications UPnP //! - **Update ID** : Suivi des changements pour les notifications UPnP
use pmoutils::ToXmlElement;
use pmodidl::{Container, DIDLLite}; use pmodidl::{Container, DIDLLite};
use pmosource::api::{get_source as get_source_from_registry, list_all_sources}; use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
use pmosource::{BrowseResult, MusicSource, MusicSourceError}; use pmosource::{BrowseResult, MusicSource, MusicSourceError};
use pmoutils::ToXmlElement;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
@@ -123,20 +123,14 @@ impl ContentHandler {
container.child_count = None; // compatibilité CP container.child_count = None; // compatibilité CP
let didl = to_didl_lite(&[container], &[])?; let didl = to_didl_lite(&[container], &[])?;
let update_id = source.update_id().await.max(1); let update_id = source.update_id().await.max(1);
tracing::debug!( tracing::debug!("BrowseMetadata root (flatten) didl_len={}B", didl.len());
"BrowseMetadata root (flatten) didl_len={}B",
didl.len()
);
return Ok((didl, 1, 1, update_id)); return Ok((didl, 1, 1, update_id));
} }
// Sinon retourner le container racine agrégé // Sinon retourner le container racine agrégé
let root = self.build_root_container().await; let root = self.build_root_container().await;
let didl = to_didl_lite(&[root], &[])?; let didl = to_didl_lite(&[root], &[])?;
tracing::debug!( tracing::debug!("BrowseMetadata root (aggregate) didl_len={}B", didl.len());
"BrowseMetadata root (aggregate) didl_len={}B",
didl.len()
);
Ok((didl, 1, 1, 1)) Ok((didl, 1, 1, 1))
} else { } else {
// Essayer de trouver l'objet dans les sources // Essayer de trouver l'objet dans les sources
@@ -300,7 +294,13 @@ impl ContentHandler {
match source.browse(object_id).await { match source.browse(object_id).await {
Ok(result) => { Ok(result) => {
return self return self
.browse_result_to_didl(object_id, result, source, starting_index, requested_count) .browse_result_to_didl(
object_id,
result,
source,
starting_index,
requested_count,
)
.await; .await;
} }
Err(MusicSourceError::ObjectNotFound(_)) => continue, Err(MusicSourceError::ObjectNotFound(_)) => continue,

View File

@@ -76,14 +76,14 @@ use pmoupnp::define_service;
pub mod actions; pub mod actions;
pub mod handlers; pub mod handlers;
pub mod variables;
pub mod state; pub mod state;
pub mod variables;
use actions::{BROWSE, GETSEARCHCAPABILITIES, GETSORTCAPABILITIES, GETSYSTEMUPDATEID, SEARCH}; use actions::{BROWSE, GETSEARCHCAPABILITIES, GETSORTCAPABILITIES, GETSYSTEMUPDATEID, SEARCH};
use variables::{ use variables::{
A_ARG_TYPE_BROWSEFLAG, A_ARG_TYPE_COUNT, A_ARG_TYPE_FILTER, A_ARG_TYPE_INDEX, A_ARG_TYPE_BROWSEFLAG, A_ARG_TYPE_COUNT, A_ARG_TYPE_FILTER, A_ARG_TYPE_INDEX,
A_ARG_TYPE_OBJECTID, A_ARG_TYPE_RESULT, A_ARG_TYPE_SEARCHCRITERIA, A_ARG_TYPE_SORTCRITERIA, A_ARG_TYPE_OBJECTID, A_ARG_TYPE_RESULT, A_ARG_TYPE_SEARCHCRITERIA, A_ARG_TYPE_SORTCRITERIA,
A_ARG_TYPE_UPDATEID, SEARCHCAPABILITIES, SORTCAPABILITIES, SYSTEMUPDATEID, CONTAINERUPDATEIDS, A_ARG_TYPE_UPDATEID, CONTAINERUPDATEIDS, SEARCHCAPABILITIES, SORTCAPABILITIES, SYSTEMUPDATEID,
}; };
// Service ContentDirectory:1 conforme à la spécification UPnP AV pour MediaServer // Service ContentDirectory:1 conforme à la spécification UPnP AV pour MediaServer

View File

@@ -1,6 +1,9 @@
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use pmoupnp::{services::ServiceInstance, variable_types::StateValue}; use pmoupnp::{services::ServiceInstance, variable_types::StateValue};
use std::sync::{atomic::{AtomicU32, Ordering}, Arc, Weak, Mutex}; use std::sync::{
Arc, Mutex, Weak,
atomic::{AtomicU32, Ordering},
};
static CONTENTDIR_INSTANCE: OnceCell<Weak<ServiceInstance>> = OnceCell::new(); static CONTENTDIR_INSTANCE: OnceCell<Weak<ServiceInstance>> = OnceCell::new();
static SYSTEM_UPDATE_ID: AtomicU32 = AtomicU32::new(1); static SYSTEM_UPDATE_ID: AtomicU32 = AtomicU32::new(1);
@@ -17,7 +20,9 @@ pub fn register_instance(instance: &Arc<ServiceInstance>) {
/// Notifie une mise à jour en incrémentant SystemUpdateID et ContainerUpdateIDs. /// Notifie une mise à jour en incrémentant SystemUpdateID et ContainerUpdateIDs.
/// `container_ids` doit contenir les IDs des conteneurs impactés. /// `container_ids` doit contenir les IDs des conteneurs impactés.
pub fn notify_containers_updated(container_ids: &[&str]) { pub fn notify_containers_updated(container_ids: &[&str]) {
let new_id = SYSTEM_UPDATE_ID.fetch_add(1, Ordering::Relaxed).saturating_add(1); let new_id = SYSTEM_UPDATE_ID
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
set_system_update_id(new_id); set_system_update_id(new_id);
if !container_ids.is_empty() { if !container_ids.is_empty() {

View File

@@ -1,5 +1,4 @@
///! Extension trait pour initialiser le PMO Music MediaServer UPnP ///! Extension trait pour initialiser le PMO Music MediaServer UPnP
use pmoupnp::devices::DeviceInstance; use pmoupnp::devices::DeviceInstance;
use pmoupnp::variable_types::StateValue; use pmoupnp::variable_types::StateValue;
use std::sync::Arc; use std::sync::Arc;

View File

@@ -6,7 +6,6 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use axum::{ use axum::{
Json, Router,
body::Body, body::Body,
extract::{Path, State}, extract::{Path, State},
http::{ http::{
@@ -15,11 +14,17 @@ use axum::{
}, },
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::get,
Json, Router,
};
use pmoaudiocache::{get_audio_cache, register_audio_cache, AudioCacheExt, Cache as AudioCache};
use pmocovers::{get_cover_cache, register_cover_cache, Cache as CoverCache, CoverCacheExt};
use pmoparadise::{
channels::{ChannelDescriptor, ALL_CHANNELS},
stream_channel::register_global_channel_manager,
ParadiseChannelManager, ParadiseHistoryBuilder,
}; };
use pmoaudiocache::{AudioCacheExt, Cache as AudioCache, get_audio_cache, register_audio_cache};
use pmocovers::{Cache as CoverCache, CoverCacheExt, get_cover_cache, register_cover_cache};
use pmoparadise::{ParadiseChannelManager, ParadiseHistoryBuilder, channels::ALL_CHANNELS};
use pmoplaylist::register_audio_cache as register_playlist_audio_cache; use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
use pmoplaylist::{self, PlaylistEventKind};
use std::sync::Arc; use std::sync::Arc;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use tracing::{error, info}; use tracing::{error, info};
@@ -64,7 +69,9 @@ impl ParadiseStreamingExt for pmoserver::Server {
async fn init_paradise_streaming(&mut self) -> Result<Arc<ParadiseChannelManager>> { async fn init_paradise_streaming(&mut self) -> Result<Arc<ParadiseChannelManager>> {
info!("🎵 Initializing Radio Paradise streaming channels..."); info!("🎵 Initializing Radio Paradise streaming channels...");
// Sentinel log pour vérifier qu'on exécute bien cette version du binaire // 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"); tracing::warn!(
"🔍 Rien de neuf: entering init_paradise_streaming with caches+history setup"
);
// Récupérer ou initialiser les caches singletons // Récupérer ou initialiser les caches singletons
info!("📦 Getting cache singletons..."); info!("📦 Getting cache singletons...");
@@ -143,6 +150,9 @@ impl ParadiseStreamingExt for pmoserver::Server {
} }
}; };
register_global_channel_manager(manager.clone());
spawn_playlist_event_handler(manager.clone());
let state = Arc::new(ParadiseStreamingState { let state = Arc::new(ParadiseStreamingState {
manager: manager.clone(), manager: manager.clone(),
}); });
@@ -317,3 +327,56 @@ async fn stream_history_ogg(
.body(Body::from_stream(ReaderStream::new(stream))) .body(Body::from_stream(ReaderStream::new(stream)))
.unwrap()) .unwrap())
} }
fn spawn_playlist_event_handler(manager: Arc<ParadiseChannelManager>) {
tokio::spawn(async move {
let mut rx = pmoplaylist::subscribe_events();
while let Ok(envelope) = rx.recv().await {
if let PlaylistEventKind::TrackPlayed { cache_pk, .. } = envelope.event.kind {
if let Some(descriptor) = channel_from_live_playlist(&envelope.event.playlist_id) {
if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await {
tracing::warn!(
"Failed to prefetch for channel {}: {}",
descriptor.display_name,
e
);
}
if let Err(e) = append_track_to_history(descriptor, &cache_pk).await {
tracing::warn!(
"Failed to update history for channel {}: {}",
descriptor.display_name,
e
);
}
}
}
}
});
}
fn channel_from_live_playlist(playlist_id: &str) -> Option<&'static ChannelDescriptor> {
const PREFIX: &str = "radio-paradise-live-";
let slug = playlist_id.strip_prefix(PREFIX)?;
ALL_CHANNELS
.iter()
.find(|descriptor| descriptor.slug == slug)
}
async fn append_track_to_history(descriptor: &ChannelDescriptor, cache_pk: &str) -> Result<()> {
let playlist_id = format!("radio-paradise-history-{}", descriptor.slug);
let manager = pmoplaylist::PlaylistManager();
let handle = manager
.get_persistent_write_handle(playlist_id.clone())
.await
.with_context(|| format!("Failed to get history playlist {}", playlist_id))?;
if handle.contains_pk(cache_pk).await? {
return Ok(());
}
handle
.push(cache_pk.to_string())
.await
.with_context(|| format!("Failed to append {} to {}", cache_pk, playlist_id))?;
Ok(())
}

View File

@@ -173,8 +173,8 @@ impl SourcesExt for Server {
#[cfg(feature = "paradise")] #[cfg(feature = "paradise")]
async fn register_paradise(&mut self) -> Result<()> { async fn register_paradise(&mut self) -> Result<()> {
use pmoparadise::{RadioParadiseExt, RadioParadiseSource};
use crate::contentdirectory::state; use crate::contentdirectory::state;
use pmoparadise::{RadioParadiseExt, RadioParadiseSource};
tracing::info!("Initializing Radio Paradise source..."); tracing::info!("Initializing Radio Paradise source...");

View File

@@ -552,8 +552,23 @@ where
let src_guard = src.read().await; let src_guard = src.read().await;
copy_metadata!( copy_metadata!(
src_guard, dest, title, artist, album, year, duration, sample_rate, total_samples, src_guard,
bits_per_sample, track_id, channel_id, event, rating, cover_url, cover_pk, extra dest,
title,
artist,
album,
year,
duration,
sample_rate,
total_samples,
bits_per_sample,
track_id,
channel_id,
event,
rating,
cover_url,
cover_pk,
extra
); );
// Try to update the timestamp, but ignore transient errors // Try to update the timestamp, but ignore transient errors
@@ -828,8 +843,6 @@ impl TrackMetadata for MemoryTrackMetadata {
self.updated_at = Some(SystemTime::now()); self.updated_at = Some(SystemTime::now());
Ok(Some(())) Ok(Some(()))
} }
} }
#[cfg(test)] #[cfg(test)]
@@ -1248,10 +1261,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_get_cover_url_with_fallback_none() { async fn test_get_cover_url_with_fallback_none() {
let metadata = MemoryTrackMetadata::new(); let metadata = MemoryTrackMetadata::new();
assert_eq!( assert_eq!(metadata.get_cover_url_with_fallback().await.unwrap(), None);
metadata.get_cover_url_with_fallback().await.unwrap(),
None
);
} }
#[tokio::test] #[tokio::test]
@@ -1278,10 +1288,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!( assert_eq!(metadata.get_cover_url_or_default().await.unwrap(), "abc123");
metadata.get_cover_url_or_default().await.unwrap(),
"abc123"
);
} }
#[tokio::test] #[tokio::test]

View File

@@ -29,6 +29,7 @@ hex = "0.4"
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
async-stream = "0.3" async-stream = "0.3"
rusqlite = { version = "0.37", features = ["bundled"] } rusqlite = { version = "0.37", features = ["bundled"] }
once_cell = "1.20"
# Gestion des erreurs # Gestion des erreurs
thiserror = "2.0.17" thiserror = "2.0.17"

View File

@@ -16,16 +16,18 @@ use axum::{
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use pmoaudio_ext::StreamingSinkOptions;
use pmoaudiocache::{ use pmoaudiocache::{
new_cache_with_consolidation as new_audio_cache, new_cache_with_consolidation as new_audio_cache,
register_audio_cache as register_global_audio_cache, register_audio_cache as register_global_audio_cache,
}; };
use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache}; use pmocovers::{
new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache,
};
use pmoparadise::{ use pmoparadise::{
channels::{ChannelDescriptor, ALL_CHANNELS}, channels::{ChannelDescriptor, ALL_CHANNELS},
ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig, ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig,
}; };
use pmoaudio_ext::StreamingSinkOptions;
use pmoplaylist::register_audio_cache as register_playlist_audio_cache; use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
use std::{fs, net::SocketAddr, sync::Arc}; use std::{fs, net::SocketAddr, sync::Arc};
use tokio::net::TcpListener; use tokio::net::TcpListener;
@@ -44,9 +46,7 @@ async fn main() -> anyhow::Result<()> {
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
tracing_subscriber::fmt() tracing_subscriber::fmt().with_env_filter(env_filter).init();
.with_env_filter(env_filter)
.init();
let descriptor = pick_descriptor(std::env::args().nth(1))?; let descriptor = pick_descriptor(std::env::args().nth(1))?;
info!( info!(
@@ -206,22 +206,16 @@ async fn get_cover(
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
// Récupérer le chemin de la cover depuis le cache // Récupérer le chemin de la cover depuis le cache
// Le cache retourne un PathBuf pointant vers le fichier .webp // Le cache retourne un PathBuf pointant vers le fichier .webp
let cover_path = state let cover_path = state.cover_cache.get(&pk).await.map_err(|e| {
.cover_cache tracing::error!("Failed to get cover path for {}: {}", pk, e);
.get(&pk) StatusCode::NOT_FOUND
.await })?;
.map_err(|e| {
tracing::error!("Failed to get cover path for {}: {}", pk, e);
StatusCode::NOT_FOUND
})?;
// Lire le fichier // Lire le fichier
let cover_data = tokio::fs::read(&cover_path) let cover_data = tokio::fs::read(&cover_path).await.map_err(|e| {
.await tracing::error!("Failed to read cover file {:?}: {}", cover_path, e);
.map_err(|e| { StatusCode::NOT_FOUND
tracing::error!("Failed to read cover file {:?}: {}", cover_path, e); })?;
StatusCode::NOT_FOUND
})?;
Response::builder() Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)

View File

@@ -429,11 +429,7 @@ async fn get_cover_url(
})?; })?;
let song = block.get_song(song_index).ok_or_else(|| { let song = block.get_song(song_index).ok_or_else(|| {
tracing::warn!( tracing::warn!("Song index {} not found in block {}", song_index, event_id);
"Song index {} not found in block {}",
song_index,
event_id
);
StatusCode::NOT_FOUND StatusCode::NOT_FOUND
})?; })?;

View File

@@ -4,20 +4,26 @@
//! exposing live streams and historical playlists for all 4 channels. //! exposing live streams and historical playlists for all 4 channels.
use crate::channels::{ChannelDescriptor, ALL_CHANNELS}; use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
use std::fmt;
use pmosource::pmodidl::{Container, Item, Resource}; use pmosource::pmodidl::{Container, Item, Resource};
use pmosource::{ use pmosource::{
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result, async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result,
SourceCapabilities, SourceCapabilities,
}; };
use std::fmt;
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime; use std::time::{Duration, Instant, SystemTime};
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// Default Radio Paradise image (embedded in binary) /// Default Radio Paradise image (embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
#[cfg(feature = "playlist")]
const LIVE_PLAYLIST_MIN_READY_ITEMS: usize = 5;
#[cfg(feature = "playlist")]
const LIVE_PLAYLIST_READY_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(feature = "playlist")]
const LIVE_PLAYLIST_READY_POLL: Duration = Duration::from_millis(200);
/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise /// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
/// ///
/// Provides access to: /// Provides access to:
@@ -142,14 +148,21 @@ impl RadioParadiseSource {
ALL_CHANNELS ALL_CHANNELS
.iter() .iter()
.find(|ch| pid.ends_with(ch.slug)) .find(|ch| pid.ends_with(ch.slug))
.map(|ch| vec![format!("radio-paradise:channel:{}:history", ch.slug)]) .map(|ch| {
vec![format!("radio-paradise:channel:{}:history", ch.slug)]
})
.unwrap_or_default() .unwrap_or_default()
} else { } else {
// live playlist -> container liveplaylist // live playlist -> container liveplaylist
ALL_CHANNELS ALL_CHANNELS
.iter() .iter()
.find(|ch| pid.ends_with(ch.slug)) .find(|ch| pid.ends_with(ch.slug))
.map(|ch| vec![format!("radio-paradise:channel:{}:liveplaylist", ch.slug)]) .map(|ch| {
vec![format!(
"radio-paradise:channel:{}:liveplaylist",
ch.slug
)]
})
.unwrap_or_default() .unwrap_or_default()
}; };
@@ -181,7 +194,10 @@ impl RadioParadiseSource {
match response.json::<serde_json::Value>().await { match response.json::<serde_json::Value>().await {
Ok(json) => { Ok(json) => {
// Parse metadata from JSON and create an Item // Parse metadata from JSON and create an Item
let title = json["title"].as_str().unwrap_or("Unknown Title").to_string(); let title = json["title"]
.as_str()
.unwrap_or("Unknown Title")
.to_string();
let artist = json["artist"].as_str().map(|s| s.to_string()); let artist = json["artist"].as_str().map(|s| s.to_string());
let album = json["album"].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 year = json["year"].as_u64().map(|y| y as u32);
@@ -201,10 +217,12 @@ impl RadioParadiseSource {
.or_else(|| json["duration"].as_f64()) .or_else(|| json["duration"].as_f64())
.map(|secs| { .map(|secs| {
let total_secs = secs as u64; let total_secs = secs as u64;
format!("{}:{:02}:{:02}", format!(
"{}:{:02}:{:02}",
total_secs / 3600, total_secs / 3600,
(total_secs % 3600) / 60, (total_secs % 3600) / 60,
total_secs % 60) total_secs % 60
)
}); });
// Create the item with current metadata // Create the item with current metadata
@@ -254,6 +272,42 @@ impl RadioParadiseSource {
format!("radio-paradise-live-{}", slug) format!("radio-paradise-live-{}", slug)
} }
#[cfg(feature = "playlist")]
async fn wait_for_live_playlist_ready(&self, slug: &str) -> Result<()> {
let playlist_id = Self::live_playlist_id(slug);
let manager = pmoplaylist::PlaylistManager();
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
MusicSourceError::BrowseError(format!(
"Failed to get live playlist {}: {}",
playlist_id, e
))
})?;
let start = Instant::now();
loop {
match reader.remaining().await {
Ok(count) if count >= LIVE_PLAYLIST_MIN_READY_ITEMS => return Ok(()),
Ok(_) => {}
Err(e) => {
return Err(MusicSourceError::BrowseError(format!(
"Failed to inspect live playlist {}: {}",
playlist_id, e
)));
}
}
if start.elapsed() >= LIVE_PLAYLIST_READY_TIMEOUT {
tracing::warn!(
"Timeout waiting for live playlist {} to reach {} items",
playlist_id,
LIVE_PLAYLIST_MIN_READY_ITEMS
);
return Ok(());
}
tokio::time::sleep(LIVE_PLAYLIST_READY_POLL).await;
}
}
/// Get channel descriptor by slug /// Get channel descriptor by slug
fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> { fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> {
ALL_CHANNELS.iter().find(|ch| ch.slug == slug) ALL_CHANNELS.iter().find(|ch| ch.slug == slug)
@@ -380,7 +434,10 @@ impl RadioParadiseSource {
/// Build a history container with accurate child count from playlist /// Build a history container with accurate child count from playlist
#[cfg(feature = "playlist")] #[cfg(feature = "playlist")]
async fn build_history_container_with_count(&self, descriptor: &ChannelDescriptor) -> Container { async fn build_history_container_with_count(
&self,
descriptor: &ChannelDescriptor,
) -> Container {
let mut container = self.build_history_container(descriptor); let mut container = self.build_history_container(descriptor);
// Try to get actual count from playlist // Try to get actual count from playlist
@@ -466,32 +523,48 @@ impl RadioParadiseSource {
_offset: usize, _offset: usize,
count: usize, count: usize,
) -> Result<Vec<Item>> { ) -> Result<Vec<Item>> {
#[cfg(all(feature = "playlist", feature = "pmoaudio"))]
if let Some(descriptor) = Self::get_channel_by_slug(slug) {
if let Some(manager) = crate::stream_channel::get_global_channel_manager() {
if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await {
tracing::warn!(
"Failed to prefetch live playlist for {}: {}",
descriptor.slug,
e
);
}
}
}
#[cfg(feature = "playlist")]
if let Err(e) = self.wait_for_live_playlist_ready(slug).await {
tracing::warn!(
"Failed to wait for live playlist readiness on {}: {}",
slug,
e
);
}
let playlist_id = Self::live_playlist_id(slug); let playlist_id = Self::live_playlist_id(slug);
let manager = pmoplaylist::PlaylistManager(); let manager = pmoplaylist::PlaylistManager();
let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| {
MusicSourceError::BrowseError(format!("Failed to get live playlist {}: {}", playlist_id, e)) MusicSourceError::BrowseError(format!(
"Failed to get live playlist {}: {}",
playlist_id, e
))
})?; })?;
let mut items = reader.to_items(count).await.map_err(|e| { let mut items = reader.to_items(count).await.map_err(|e| {
MusicSourceError::BrowseError(format!( MusicSourceError::BrowseError(format!("Failed to read live playlist entries: {}", e))
"Failed to read live playlist entries: {}",
e
))
})?; })?;
for item in items.iter_mut() { for item in items.iter_mut() {
// Ajuster id/parent/url pour coller au schéma Radio Paradise // Ajuster id/parent/url pour coller au schéma Radio Paradise
if let Some(resource) = item.resources.first_mut() { if let Some(resource) = item.resources.first_mut() {
if let Some(pk) = resource.url.split('/').last() { if let Some(pk) = resource.url.split('/').last() {
item.id = format!( item.id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
"radio-paradise:channel:{}:liveplaylist:track:{}", item.parent_id = format!("radio-paradise:channel:{}:liveplaylist", slug);
slug, pk
);
item.parent_id = format!(
"radio-paradise:channel:{}:liveplaylist",
slug
);
if resource.url.starts_with('/') { if resource.url.starts_with('/') {
resource.url = format!("{}{}", self.base_url, resource.url); resource.url = format!("{}{}", self.base_url, resource.url);
@@ -519,10 +592,7 @@ impl RadioParadiseSource {
#[cfg(feature = "playlist")] #[cfg(feature = "playlist")]
async fn get_live_playlist_item(&self, slug: &str, pk: &str) -> Result<Item> { async fn get_live_playlist_item(&self, slug: &str, pk: &str) -> Result<Item> {
let items = self.get_live_playlist_items(slug, 0, 1000).await?; let items = self.get_live_playlist_items(slug, 0, 1000).await?;
let expected_id = format!( let expected_id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
"radio-paradise:channel:{}:liveplaylist:track:{}",
slug, pk
);
for item in items { for item in items {
if item.id == expected_id { if item.id == expected_id {
return Ok(item); return Ok(item);
@@ -533,7 +603,6 @@ impl RadioParadiseSource {
pk pk
))) )))
} }
} }
/// Types of object IDs in the Radio Paradise source /// Types of object IDs in the Radio Paradise source
@@ -619,7 +688,8 @@ impl MusicSource for RadioParadiseSource {
#[cfg(feature = "playlist")] #[cfg(feature = "playlist")]
{ {
let history_container = self.build_history_container_with_count(descriptor).await; let history_container =
self.build_history_container_with_count(descriptor).await;
let items = self.get_history_items(&slug, 0, 100).await?; let items = self.get_history_items(&slug, 0, 100).await?;
Ok(BrowseResult::Mixed { Ok(BrowseResult::Mixed {
containers: vec![history_container], containers: vec![history_container],
@@ -821,14 +891,14 @@ impl MusicSource for RadioParadiseSource {
for mut item in items { for mut item in items {
if let Some(resource) = item.resources.first_mut() { if let Some(resource) = item.resources.first_mut() {
if let Some(pk2) = resource.url.split('/').last() { if let Some(pk2) = resource.url.split('/').last() {
item.id = item.id = format!(
format!("radio-paradise:channel:{}:history:track:{}", slug, pk2); "radio-paradise:channel:{}:history:track:{}",
item.parent_id = slug, pk2
format!("radio-paradise:channel:{}:history", slug); );
item.parent_id = format!("radio-paradise:channel:{}:history", slug);
if resource.url.starts_with('/') { if resource.url.starts_with('/') {
resource.url = resource.url = format!("{}{}", self.base_url, resource.url);
format!("{}{}", self.base_url, resource.url);
} }
} }
} }
@@ -839,7 +909,8 @@ impl MusicSource for RadioParadiseSource {
} }
// Find the item matching this pk in the item ID // Find the item matching this pk in the item ID
let expected_id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk); let expected_id =
format!("radio-paradise:channel:{}:history:track:{}", slug, pk);
for item in adjusted { for item in adjusted {
if item.id == expected_id { if item.id == expected_id {
return Ok(item); return Ok(item);
@@ -887,10 +958,8 @@ impl MusicSource for RadioParadiseSource {
"radio-paradise:channel:{}:liveplaylist:track:{}", "radio-paradise:channel:{}:liveplaylist:track:{}",
slug, pk2 slug, pk2
); );
item.parent_id = format!( item.parent_id =
"radio-paradise:channel:{}:liveplaylist", format!("radio-paradise:channel:{}:liveplaylist", slug);
slug
);
if resource.url.starts_with('/') { if resource.url.starts_with('/') {
resource.url = format!("{}{}", self.base_url, resource.url); resource.url = format!("{}{}", self.base_url, resource.url);
@@ -910,10 +979,8 @@ impl MusicSource for RadioParadiseSource {
item.album_art = Some(self.default_cover_url()); item.album_art = Some(self.default_cover_url());
} }
let expected_id = format!( let expected_id =
"radio-paradise:channel:{}:liveplaylist:track:{}", format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk);
slug, pk
);
if item.id == expected_id { if item.id == expected_id {
return Ok(item); return Ok(item);
} }

View File

@@ -21,12 +21,13 @@ use crate::{
models::{Block, EventId}, models::{Block, EventId},
playlist_feeder::RadioParadisePlaylistFeeder, playlist_feeder::RadioParadisePlaylistFeeder,
}; };
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Context as AnyhowContext, Result};
use once_cell::sync::OnceCell;
use pmoaudio::{AudioError, AudioPipelineNode}; use pmoaudio::{AudioError, AudioPipelineNode};
use pmoaudio_ext::{ use pmoaudio_ext::{
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle, FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, StreamingSinkOptions,
StreamingSinkOptions, TrackBoundaryCoverNode,
}; };
use pmoaudiocache::{get_audio_cache, Cache as AudioCache}; use pmoaudiocache::{get_audio_cache, Cache as AudioCache};
use pmocovers::{get_cover_cache, Cache as CoverCache}; use pmocovers::{get_cover_cache, Cache as CoverCache};
@@ -203,9 +204,14 @@ impl ParadiseStreamChannel {
// Propager server_base_url dans les options pour que les encoders injectent les covers du cache // Propager server_base_url dans les options pour que les encoders injectent les covers du cache
let mut config = config; let mut config = config;
if let Some(ref base) = config.server_base_url { if let Some(ref base) = config.server_base_url {
config.flac_options = config.flac_options = config
config.flac_options.clone().with_server_base_url(Some(base.clone())); .flac_options
config.ogg_options = config.ogg_options.clone().with_server_base_url(Some(base.clone())); .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 let cover_cache = cover_cache
.or_else(|| history.as_ref().map(|opts| opts.cover_cache.clone())) .or_else(|| history.as_ref().map(|opts| opts.cover_cache.clone()))
@@ -312,6 +318,7 @@ impl ParadiseStreamChannel {
activity_notify: Notify::new(), activity_notify: Notify::new(),
stop_token, stop_token,
current_block: Mutex::new(None), current_block: Mutex::new(None),
prefetch_lock: Mutex::new(()),
}); });
let pipeline_state = state.clone(); let pipeline_state = state.clone();
@@ -496,6 +503,12 @@ impl Drop for ParadiseStreamChannel {
const MAX_BLOCK_LEAD: Duration = Duration::from_secs(3600); const MAX_BLOCK_LEAD: Duration = Duration::from_secs(3600);
const BLOCK_LEAD_CHECK_CHUNK: Duration = Duration::from_secs(300); const BLOCK_LEAD_CHECK_CHUNK: Duration = Duration::from_secs(300);
const LIVE_PREFETCH_MIN_TRACKS: usize = 5;
const LIVE_PREFETCH_TIMEOUT: Duration = Duration::from_secs(10);
const LIVE_PREFETCH_POLL_INTERVAL: Duration = Duration::from_millis(200);
const LIVE_PREFETCH_MAX_BLOCKS: usize = 4;
static GLOBAL_CHANNEL_MANAGER: OnceCell<std::sync::Weak<ParadiseChannelManager>> = OnceCell::new();
struct ChannelState { struct ChannelState {
descriptor: ChannelDescriptor, descriptor: ChannelDescriptor,
@@ -510,6 +523,7 @@ struct ChannelState {
activity_notify: Notify, activity_notify: Notify,
stop_token: CancellationToken, stop_token: CancellationToken,
current_block: Mutex<Option<EventId>>, current_block: Mutex<Option<EventId>>,
prefetch_lock: Mutex<()>,
} }
impl ChannelState { impl ChannelState {
@@ -580,6 +594,66 @@ impl ChannelState {
} }
} }
fn live_playlist_id(&self) -> String {
format!("radio-paradise-live-{}", self.descriptor.slug)
}
async fn prefetch_until_horizon(&self) -> Result<()> {
let _guard = self.prefetch_lock.lock().await;
let playlist_id = self.live_playlist_id();
let manager = PlaylistManager::get();
let reader = manager
.get_read_handle(&playlist_id)
.await
.with_context(|| format!("Failed to get live playlist {}", playlist_id))?;
let start = Instant::now();
let mut next_event: Option<EventId> = None;
let mut attempts = 0usize;
loop {
let available = reader
.remaining()
.await
.with_context(|| format!("Failed to inspect playlist {}", playlist_id))?;
if available >= LIVE_PREFETCH_MIN_TRACKS {
return Ok(());
}
if start.elapsed() >= LIVE_PREFETCH_TIMEOUT {
warn!(
"Prefetch timeout for channel {} ({} tracks available)",
self.descriptor.display_name, available
);
return Ok(());
}
if attempts >= LIVE_PREFETCH_MAX_BLOCKS {
warn!(
"Prefetch block limit reached for channel {} ({} tracks available)",
self.descriptor.display_name, available
);
return Ok(());
}
match self.client.get_block(next_event).await {
Ok(block) => {
attempts += 1;
next_event = Some(block.end_event);
self.feeder.push_block_id(block.event).await;
}
Err(e) => {
warn!(
"Failed to fetch block during prefetch for channel {}: {}",
self.descriptor.display_name, e
);
return Ok(());
}
}
tokio::time::sleep(LIVE_PREFETCH_POLL_INTERVAL).await;
}
}
async fn set_current_block(&self, event_id: EventId) { async fn set_current_block(&self, event_id: EventId) {
let mut guard = self.current_block.lock().await; let mut guard = self.current_block.lock().await;
*guard = Some(event_id); *guard = Some(event_id);
@@ -866,12 +940,7 @@ impl ParadiseChannelManager {
); );
let channel = match tokio::time::timeout( let channel = match tokio::time::timeout(
Duration::from_secs(20), Duration::from_secs(20),
ParadiseStreamChannel::new( ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts),
descriptor,
config,
cover_cache.clone(),
history_opts,
),
) )
.await .await
{ {
@@ -918,4 +987,25 @@ impl ParadiseChannelManager {
pub fn iter(&self) -> impl Iterator<Item = &Arc<ParadiseStreamChannel>> { pub fn iter(&self) -> impl Iterator<Item = &Arc<ParadiseStreamChannel>> {
self.channels.values() self.channels.values()
} }
pub async fn prefetch_until_horizon(&self, channel_id: u8) -> Result<()> {
let channel = self
.get(channel_id)
.ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?;
channel.prefetch_until_horizon().await
}
}
pub fn register_global_channel_manager(manager: Arc<ParadiseChannelManager>) {
let _ = GLOBAL_CHANNEL_MANAGER.set(Arc::downgrade(&manager));
}
pub fn get_global_channel_manager() -> Option<Arc<ParadiseChannelManager>> {
GLOBAL_CHANNEL_MANAGER.get().and_then(|weak| weak.upgrade())
}
impl ParadiseStreamChannel {
pub async fn prefetch_until_horizon(&self) -> Result<()> {
self.state.prefetch_until_horizon().await
}
} }

View File

@@ -222,7 +222,12 @@ impl ReadHandle {
let resource = meta.to_didl_resource(url).await; let resource = meta.to_didl_resource(url).await;
// Récupérer les métadonnées pour construire l'Item DIDL // Récupérer les métadonnées pour construire l'Item DIDL
let title = meta.get_title().await.ok().flatten().unwrap_or_else(|| "Unknown".to_string()); let title = meta
.get_title()
.await
.ok()
.flatten()
.unwrap_or_else(|| "Unknown".to_string());
let artist = meta.get_artist().await.ok().flatten(); let artist = meta.get_artist().await.ok().flatten();
let album = meta.get_album().await.ok().flatten(); let album = meta.get_album().await.ok().flatten();
let genre = meta.get_genre().await.ok().flatten(); let genre = meta.get_genre().await.ok().flatten();

View File

@@ -222,6 +222,16 @@ impl WriteHandle {
Ok(()) Ok(())
} }
/// Vérifie si la playlist contient déjà un pk
pub async fn contains_pk(&self, cache_pk: &str) -> Result<bool> {
if !self.playlist.is_alive() {
return Err(crate::Error::PlaylistDeleted(self.playlist.id.clone()));
}
let core = self.playlist.core.read().await;
Ok(core.tracks.iter().any(|record| record.cache_pk == cache_pk))
}
/// Change le TTL par défaut /// Change le TTL par défaut
pub async fn set_default_ttl(&self, ttl: Option<Duration>) -> Result<()> { pub async fn set_default_ttl(&self, ttl: Option<Duration>) -> Result<()> {
if !self.playlist.is_alive() { if !self.playlist.is_alive() {

View File

@@ -47,13 +47,13 @@
mod error; mod error;
mod handle; mod handle;
mod manager; mod manager;
mod persistence;
mod playlist;
mod track;
#[cfg(feature = "pmoserver")]
mod sse;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
pub mod openapi; pub mod openapi;
mod persistence;
mod playlist;
#[cfg(feature = "pmoserver")]
mod sse;
mod track;
#[cfg(feature = "pmoconfig")] #[cfg(feature = "pmoconfig")]
mod config_ext; mod config_ext;
@@ -62,10 +62,10 @@ mod config_ext;
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use handle::{ReadHandle, WriteHandle}; pub use handle::{ReadHandle, WriteHandle};
pub use manager::{register_audio_cache, PlaylistManager, PlaylistManager as Manager}; pub use manager::{register_audio_cache, PlaylistManager, PlaylistManager as Manager};
pub use manager::{PlaylistEvent, PlaylistEventEnvelope, PlaylistEventKind, subscribe_events}; pub use manager::{subscribe_events, PlaylistEvent, PlaylistEventEnvelope, PlaylistEventKind};
pub use track::PlaylistTrack;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
pub use sse::playlist_events_router; pub use sse::playlist_events_router;
pub use track::PlaylistTrack;
#[cfg(feature = "pmoconfig")] #[cfg(feature = "pmoconfig")]
pub use config_ext::PlaylistConfigExt; pub use config_ext::PlaylistConfigExt;

View File

@@ -5,15 +5,18 @@ use crate::persistence::PersistenceManager;
use crate::playlist::core::PlaylistConfig; use crate::playlist::core::PlaylistConfig;
use crate::playlist::Playlist; use crate::playlist::Playlist;
use crate::Result; use crate::Result;
use pmocache::{CacheBroadcastEvent, CacheSubscription};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use pmocache::{CacheBroadcastEvent, CacheSubscription};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, atomic::{AtomicU64, Ordering}};
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::sync::broadcast;
use std::sync::RwLock as StdRwLock; use std::sync::RwLock as StdRwLock;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use std::time::Duration;
use tokio::sync::broadcast;
use tokio::sync::RwLock;
/// Singleton PlaylistManager /// Singleton PlaylistManager
static PLAYLIST_MANAGER: OnceCell<PlaylistManager> = OnceCell::new(); static PLAYLIST_MANAGER: OnceCell<PlaylistManager> = OnceCell::new();
@@ -181,14 +184,16 @@ impl PlaylistManager {
/// Notifie tous les callbacks qu'une playlist a changé. /// Notifie tous les callbacks qu'une playlist a changé.
pub(crate) fn notify_playlist_changed(&self, id: &str) { pub(crate) fn notify_playlist_changed(&self, id: &str) {
self.notify_playlist_event( self.notify_playlist_event(id, PlaylistEventKind::Updated);
id,
PlaylistEventKind::Updated,
);
} }
/// Notifie les callbacks qu'un morceau a été joué pour une playlist donnée. /// Notifie les callbacks qu'un morceau a été joué pour une playlist donnée.
pub(crate) fn notify_playlist_track_played(&self, playlist_id: &str, cache_pk: &str, qualifier: &str) { pub(crate) fn notify_playlist_track_played(
&self,
playlist_id: &str,
cache_pk: &str,
qualifier: &str,
) {
self.notify_playlist_event( self.notify_playlist_event(
playlist_id, playlist_id,
PlaylistEventKind::TrackPlayed { PlaylistEventKind::TrackPlayed {

View File

@@ -3,14 +3,14 @@
//! Route type : `GET /api/playlists/events?playlist_id=foo` //! Route type : `GET /api/playlists/events?playlist_id=foo`
use crate::{subscribe_events, PlaylistEventKind}; use crate::{subscribe_events, PlaylistEventKind};
#[cfg(feature = "pmoserver")]
use async_stream::stream;
use axum::{ use axum::{
extract::Query, extract::Query,
response::sse::{Event, KeepAlive, Sse}, response::sse::{Event, KeepAlive, Sse},
response::IntoResponse, response::IntoResponse,
Router, Router,
}; };
#[cfg(feature = "pmoserver")]
use async_stream::stream;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
use tokio_stream::StreamExt; use tokio_stream::StreamExt;

View File

@@ -23,8 +23,8 @@ use serde::{Deserialize, Serialize};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::Level; use tracing::Level;
use tracing_subscriber::{ use tracing_subscriber::{
filter::EnvFilter, filter::LevelFilter, layer::SubscriberExt, reload, util::SubscriberInitExt, Registry, filter::EnvFilter, filter::LevelFilter, layer::SubscriberExt, reload,
Registry, util::SubscriberInitExt,
}; };
/// Représente une entrée de log /// Représente une entrée de log
@@ -66,10 +66,7 @@ impl LogState {
if let Err(e) = self.reload_handle.write().unwrap().reload(filter) { if let Err(e) = self.reload_handle.write().unwrap().reload(filter) {
eprintln!("❌ Failed to reload log level filter: {}", e); eprintln!("❌ Failed to reload log level filter: {}", e);
} else { } else {
eprintln!( eprintln!("✅ Log level filter reloaded successfully to: {:?}", level);
"✅ Log level filter reloaded successfully to: {:?}",
level
);
} }
} }
@@ -282,8 +279,10 @@ pub fn init_logging() -> LogState {
} else { } else {
match EnvFilter::try_new(trimmed) { match EnvFilter::try_new(trimmed) {
Ok(filter) => { Ok(filter) => {
let level_hint = let level_hint = filter
filter.max_level_hint().and_then(levelfilter_to_level).unwrap_or(Level::TRACE); .max_level_hint()
.and_then(levelfilter_to_level)
.unwrap_or(Level::TRACE);
(filter, level_hint, format!("RUST_LOG ({})", trimmed)) (filter, level_hint, format!("RUST_LOG ({})", trimmed))
} }
Err(e) => { Err(e) => {

View File

@@ -241,12 +241,8 @@ impl Server {
} }
/// Ajoute un handler qui accepte tous les verbes HTTP (ANY) avec état /// Ajoute un handler qui accepte tous les verbes HTTP (ANY) avec état
pub async fn add_any_handler_with_state<H, T, S>( pub async fn add_any_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
&mut self, where
path: &str,
handler: H,
state: S,
) where
H: Handler<T, S> + Clone + 'static, H: Handler<T, S> + Clone + 'static,
T: 'static, T: 'static,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,

View File

@@ -955,7 +955,9 @@ async fn stream_source_item_metadata(
} }
}); });
Sse::new(stream).keep_alive(KeepAlive::default()).into_response() Sse::new(stream)
.keep_alive(KeepAlive::default())
.into_response()
} }
None => ( None => (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,

View File

@@ -17,7 +17,10 @@ impl UpnpTyped for Argument {
impl UpnpObject for Argument { impl UpnpObject for Argument {
fn to_xml_element(&self) -> Element { fn to_xml_element(&self) -> Element {
// Compat: retourne le premier argument (utile si consommé isolément) // Compat: retourne le premier argument (utile si consommé isolément)
self.to_xml_elements().into_iter().next().unwrap_or_else(|| Element::new("argument")) self.to_xml_elements()
.into_iter()
.next()
.unwrap_or_else(|| Element::new("argument"))
} }
} }

View File

@@ -318,12 +318,14 @@ impl UpnpServerExt for Server {
// API playlists (SSE + OpenAPI) // API playlists (SSE + OpenAPI)
#[cfg(feature = "server")] #[cfg(feature = "server")]
{ {
use pmoplaylist::{playlist_events_router, openapi::ApiDoc}; use pmoplaylist::{openapi::ApiDoc, playlist_events_router};
// SSE /api/playlists/events // SSE /api/playlists/events
self.add_router("/api/playlists", playlist_events_router()).await; self.add_router("/api/playlists", playlist_events_router())
.await;
// OpenAPI pour playlists // OpenAPI pour playlists
let openapi = ApiDoc::openapi(); let openapi = ApiDoc::openapi();
self.add_openapi(axum::Router::new(), openapi, "playlists").await; self.add_openapi(axum::Router::new(), openapi, "playlists")
.await;
} }
// Enregistrer le cache dans le registre global // Enregistrer le cache dans le registre global