Tentative de gestion d'un historique

This commit is contained in:
2025-11-15 15:43:42 +01:00
parent 58c4383023
commit 97a383c079
25 changed files with 2195 additions and 912 deletions

BIN
.DS_Store vendored

Binary file not shown.

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ upmpdcli/
test_upnp*.cargo/
.cargo/
setup-env.sh
/cache

4
Cargo.lock generated
View File

@@ -3211,11 +3211,14 @@ dependencies = [
name = "pmomediaserver"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum 0.8.7",
"bevy_reflect",
"once_cell",
"pmoaudiocache",
"pmoconfig",
"pmocovers",
"pmodidl",
"pmoparadise",
"pmoqobuz",
@@ -3227,6 +3230,7 @@ dependencies = [
"serde_json",
"thiserror 1.0.69",
"tokio",
"tokio-util",
"tracing",
"utoipa",
]

View File

@@ -1,6 +1,6 @@
use pmoapp::{WebAppExt, Webapp};
use pmomediarenderer::MEDIA_RENDERER;
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt};
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt, ParadiseStreamingExt};
use pmoserver::Server;
use pmosource::MusicSourceExt;
use pmoupnp::UpnpServerExt;
@@ -36,13 +36,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
info!("🎵 Registering music sources...");
// // Enregistrer Qobuz
// if let Err(e) = server.register_qobuz().await {
// if let Err(e) = server.write().await.register_qobuz().await {
// tracing::warn!("⚠️ Failed to register Qobuz: {}", e);
// }
// Enregistrer Radio Paradise (inclut l'initialisation de l'API)
// Initialiser les canaux de streaming Radio Paradise (pipelines + routes HTTP)
info!("📻 Initializing Radio Paradise streaming channels...");
if let Err(e) = server.write().await.init_paradise_streaming().await {
tracing::warn!("⚠️ Failed to initialize Paradise streaming: {}", e);
} else {
// Enregistrer la source Radio Paradise UPnP (inclut l'initialisation de l'API)
if let Err(e) = server.write().await.register_paradise().await {
tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e);
tracing::warn!("⚠️ Failed to register Radio Paradise source: {}", e);
}
}
// Lister toutes les sources enregistrées

View File

@@ -1230,6 +1230,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1339,6 +1340,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -1353,6 +1355,7 @@
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -1434,6 +1437,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@ impl TrackBoundaryCoverNode {
}
}
#[async_trait::async_trait]
impl AudioPipelineNode for TrackBoundaryCoverNode {
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()

View File

@@ -6,7 +6,7 @@
//! - Paces broadcast to match audio playback rate
use std::time::Instant;
use tracing::{debug, info, warn};
use tracing::{trace, warn};
/// Error returned when a frame should be skipped (too late)
#[derive(Debug)]
@@ -56,7 +56,7 @@ impl BroadcastPacer {
let elapsed_since_start = self.start_time.elapsed().as_secs_f64();
if audio_timestamp < 0.1 && elapsed_since_start > 1.0 {
self.start_time = Instant::now();
info!(
trace!(
"{} broadcaster: TopZeroSync detected, resetting timer",
self.label
);
@@ -94,7 +94,7 @@ impl BroadcastPacer {
// Log pour info si on est très en avance, mais on ne dort PAS
if self.max_lead_time > 0.0 && lead_time > self.max_lead_time {
debug!(
trace!(
"{} broadcaster: lead_time={:.3}s > max={:.3}s (audio_ts={:.3}s, elapsed={:.3}s) - relying on natural backpressure",
self.label, lead_time, self.max_lead_time, audio_timestamp, elapsed
);

View File

@@ -200,6 +200,7 @@ impl NodeLogic for FlacCacheSinkLogic {
// Phase 1: Dispatcher jusqu'à ce que le prebuffer soit terminé
let mut end_of_stream_received = false;
let mut early_track_boundary_received = false;
let mut track_tx_opt = Some(track_tx);
let pk = loop {
tokio::select! {
@@ -221,9 +222,9 @@ impl NodeLogic for FlacCacheSinkLogic {
result = rx.recv() => {
match result {
Some(segment) => {
// Si EndOfStream a été reçu, ignorer tous les segments suivants
// Si EndOfStream ou TrackBoundary a été reçu, ignorer tous les segments suivants
// et continuer à attendre cache_future
if end_of_stream_received {
if end_of_stream_received || early_track_boundary_received {
continue;
}
@@ -239,10 +240,18 @@ impl NodeLogic for FlacCacheSinkLogic {
}
}
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { .. } => {
// TrackBoundary avant fin du prebuffer - track trop courte
tracing::error!("FlacCacheSink: TrackBoundary received before prebuffer complete - track too short");
return Err(AudioError::ProcessingError("Track too short for prebuffer".to_string()));
SyncMarker::TrackBoundary { metadata } => {
// TrackBoundary pendant le prebuffer - track courte (< 512KB)
tracing::warn!(
"FlacCacheSink: TrackBoundary received before prebuffer complete - track shorter than 512KB, closing pump and waiting for ingestion"
);
// Stocker les métadonnées pour la prochaine track
next_track_metadata = Some(metadata.clone());
// Fermer le track_tx pour que le pump se termine proprement
track_tx_opt = None;
// Marquer qu'on a reçu un TrackBoundary précoce
early_track_boundary_received = true;
// Continuer à attendre cache_future
}
SyncMarker::EndOfStream => {
tracing::debug!("FlacCacheSink: EndOfStream during prebuffer - closing pump and waiting for ingestion to complete");
@@ -262,7 +271,7 @@ impl NodeLogic for FlacCacheSinkLogic {
}
None => {
// EOF sur rx pendant le prebuffer - attendre que cache_future se termine
if !end_of_stream_received {
if !end_of_stream_received && !early_track_boundary_received {
tracing::debug!("FlacCacheSink: EOF on rx during prebuffer, waiting for ingestion to complete");
track_tx_opt = None;
end_of_stream_received = true;
@@ -296,9 +305,44 @@ impl NodeLogic for FlacCacheSinkLogic {
))
})?;
let url = match dest_metadata.read().await.get_cover_url().await {
let cover_pk_present = match dest_metadata.read().await.get_cover_pk().await {
Ok(Some(existing_pk)) => {
tracing::debug!(
"FlacCacheSink: cover_pk already set for audio asset {} ({})",
pk,
existing_pk
);
true
}
Ok(None) => false,
Err(e) if e.is_transient() => {
tracing::debug!(
"FlacCacheSink: Transient error getting cover_pk for pk {}: {}",
pk,
e
);
false
}
Err(e) => {
tracing::warn!(
"FlacCacheSink: Cannot obtain cover_pk for audio asset {}: {}",
pk,
e
);
false
}
};
let url = if cover_pk_present {
None
} else {
match dest_metadata.read().await.get_cover_url().await {
Ok(url) => {
tracing::debug!("FlacCacheSink: Got cover URL for pk {}: {:?}", pk, url);
tracing::debug!(
"FlacCacheSink: Got cover URL for pk {}: {:?}",
pk,
url
);
url
}
Err(e) if e.is_transient() => {
@@ -317,6 +361,7 @@ impl NodeLogic for FlacCacheSinkLogic {
);
None
}
}
};
if let Some(cover_url) = url {
@@ -378,6 +423,16 @@ impl NodeLogic for FlacCacheSinkLogic {
continue; // Passer à la track suivante (qui n'arrivera pas car EndOfStream)
}
// Si TrackBoundary précoce a été reçu pendant le prebuffer, passer à la track suivante
if early_track_boundary_received {
tracing::debug!(
"FlacCacheSink: TrackBoundary was received during prebuffer, track complete, moving to next track"
);
drop(pump_handle);
track_number += 1;
continue; // Passer à la track suivante (métadonnées déjà stockées dans next_track_metadata)
}
// Phase 3: Continuer à dispatcher jusqu'au TrackBoundary
tracing::debug!(
"FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)"

View File

@@ -79,7 +79,7 @@ use pmometadata::TrackMetadata;
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
use tokio::sync::{mpsc, RwLock};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, trace, warn};
use tracing::{debug, error, trace, warn};
/// Default ICY metadata interval (bytes of audio between metadata blocks).
/// Standard value used by most streaming servers.
@@ -291,7 +291,7 @@ impl AsyncRead for FlacClientStream {
if let Some(header) = header_opt {
self.buffer.extend(header.iter());
info!(
debug!(
"Sending cached FLAC header to new client ({} bytes)",
header.len()
);
@@ -356,10 +356,10 @@ impl Drop for FlacClientStream {
if count == 1 {
if self.handle.auto_stop.load(Ordering::SeqCst) {
info!("Last client disconnected, signaling pipeline stop");
debug!("Last client disconnected, signaling pipeline stop");
self.handle.stop_token.cancel();
} else {
info!("Last client disconnected, keeping pipeline alive");
debug!("Last client disconnected, keeping pipeline alive");
}
}
}
@@ -465,7 +465,7 @@ impl AsyncRead for IcyClientStream {
if let Some(header) = header_opt {
self.buffer.extend(header.iter());
info!(
debug!(
"Sending cached FLAC header to new ICY client ({} bytes)",
header.len()
);
@@ -568,10 +568,10 @@ impl Drop for IcyClientStream {
if count == 1 {
if self.handle.auto_stop.load(Ordering::SeqCst) {
info!("Last client disconnected, signaling pipeline stop");
debug!("Last client disconnected, signaling pipeline stop");
self.handle.stop_token.cancel();
} else {
info!("Last client disconnected, keeping pipeline alive");
debug!("Last client disconnected, keeping pipeline alive");
}
}
}
@@ -603,7 +603,7 @@ impl StreamingFlacSinkLogic {
return Ok(()); // Already initialized
}
info!(
debug!(
"Initializing FLAC encoder with sample rate: {} Hz",
sample_rate
);
@@ -634,7 +634,7 @@ impl StreamingFlacSinkLogic {
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
})?;
info!("FLAC encoder initialized successfully");
debug!("FLAC encoder initialized successfully");
// Spawn broadcaster task with timestamp for pacing
let flac_broadcast = self.flac_broadcast.clone();
@@ -656,7 +656,7 @@ impl StreamingFlacSinkLogic {
self.encoder_state = Some(EncoderState { broadcaster_task });
info!("Broadcaster task spawned");
debug!("Broadcaster task spawned");
Ok(())
}
@@ -716,7 +716,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
AudioError::ProcessingError("StreamingFlacSink requires an input".into())
})?;
info!("StreamingFlacSink started");
debug!("StreamingFlacSink started");
// We'll initialize the encoder lazily when we get the first chunk
// For now, just process segments
@@ -724,7 +724,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
loop {
tokio::select! {
_ = stop_token.cancelled() => {
info!("StreamingFlacSink stopped by cancellation");
debug!("StreamingFlacSink stopped by cancellation");
break;
}
@@ -737,7 +737,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
if self.sample_rate.is_none() {
let sample_rate = chunk.sample_rate();
self.sample_rate = Some(sample_rate);
info!("Detected sample rate: {} Hz", sample_rate);
debug!("Detected sample rate: {} Hz", sample_rate);
// Initialize the FLAC encoder now
self.initialize_encoder(sample_rate).await?;
@@ -765,7 +765,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
}
let send_duration = send_start.elapsed();
if send_duration.as_millis() >= 50 {
debug!(
trace!(
"StreamingFlacSink: pcm_tx send blocked for {:.3}s (ts={:.3}s)",
send_duration.as_secs_f64(),
seg.timestamp_sec
@@ -782,7 +782,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
}
SyncMarker::EndOfStream => {
info!("End of stream marker received");
debug!("End of stream marker received");
break;
}
@@ -800,7 +800,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
}
None => {
info!("Input channel closed");
debug!("Input channel closed");
break;
}
}
@@ -808,12 +808,12 @@ impl NodeLogic for StreamingFlacSinkLogic {
}
}
info!("StreamingFlacSink processing complete");
debug!("StreamingFlacSink processing complete");
Ok(())
}
async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> {
info!("StreamingFlacSink cleanup: {:?}", reason);
debug!("StreamingFlacSink cleanup: {:?}", reason);
Ok(())
}
}
@@ -828,7 +828,7 @@ async fn broadcast_flac_stream(
current_timestamp: Arc<RwLock<f64>>,
broadcast_max_lead_time: f64,
) -> Result<(), AudioError> {
info!(
trace!(
"Broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
broadcast_max_lead_time
);
@@ -861,7 +861,7 @@ async fn broadcast_flac_stream(
break;
}
}
info!("FLAC encoder stream ended, total bytes: {}", total_bytes);
trace!("FLAC encoder stream ended, total bytes: {}", total_bytes);
break;
}
Ok(n) => {
@@ -870,7 +870,7 @@ async fn broadcast_flac_stream(
total_read_time += read_duration;
if read_duration > 0.01 {
debug!(
trace!(
"FLAC: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
read_duration,
n,
@@ -924,7 +924,7 @@ async fn broadcast_flac_stream(
let audio_timestamp = *current_timestamp.read().await;
if stats_last_log.elapsed() >= Duration::from_secs(1) {
debug!(
trace!(
"Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={}",
audio_timestamp,
accumulator.len()
@@ -951,7 +951,7 @@ async fn broadcast_flac_stream(
// Log if interval is unusual (too short = burst, too long = stall)
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
debug!(
trace!(
"FLAC: broadcast interval {:.3}s ({}ms) - size={} bytes (count={})",
broadcast_interval,
(broadcast_interval * 1000.0) as u32,
@@ -962,7 +962,7 @@ async fn broadcast_flac_stream(
// Periodic stats
if broadcast_count % 100 == 0 {
debug!(
trace!(
"FLAC: {} broadcasts sent, accumulator={} bytes remaining",
broadcast_count,
accumulator.len()
@@ -973,7 +973,7 @@ async fn broadcast_flac_stream(
if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
*header_cache.write().await = Some(bytes.clone());
header_captured = true;
info!("FLAC header captured ({} bytes)", bytes.len());
trace!("FLAC header captured ({} bytes)", bytes.len());
}
let num_receivers = broadcast_tx.receiver_count();
@@ -1013,7 +1013,7 @@ async fn broadcast_flac_stream(
)));
}
info!("Broadcaster task completed successfully");
trace!("Broadcaster task completed successfully");
Ok(())
}
@@ -1062,7 +1062,7 @@ impl StreamingFlacSink {
// Calculate broadcast capacity based on max_lead_time
let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time);
info!(
debug!(
"StreamingFlacSink: using broadcast capacity of {} items (max_lead_time={:.1}s)",
broadcast_capacity, broadcast_max_lead_time
);

View File

@@ -191,7 +191,7 @@ impl AsyncRead for OggFlacClientStream {
if let Some(header) = header_opt {
self.buffer.extend(header.iter());
info!(
debug!(
"Sending cached OGG-FLAC header to new client ({} bytes)",
header.len()
);
@@ -255,10 +255,10 @@ impl Drop for OggFlacClientStream {
if count == 1 {
if self.handle.auto_stop.load(Ordering::SeqCst) {
info!("Last OGG-FLAC client disconnected, signaling pipeline stop");
debug!("Last OGG-FLAC client disconnected, signaling pipeline stop");
self.handle.stop_token.cancel();
} else {
info!("Last OGG-FLAC client disconnected, keeping pipeline alive");
debug!("Last OGG-FLAC client disconnected, keeping pipeline alive");
}
}
}
@@ -290,7 +290,7 @@ impl StreamingOggFlacSinkLogic {
return Ok(()); // Already initialized
}
info!(
debug!(
"Initializing OGG-FLAC encoder with sample rate: {} Hz",
sample_rate
);
@@ -321,7 +321,7 @@ impl StreamingOggFlacSinkLogic {
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
})?;
info!("OGG-FLAC encoder initialized successfully");
debug!("OGG-FLAC encoder initialized successfully");
// Spawn OGG wrapper + broadcaster task with timestamp for pacing
let ogg_broadcast = self.ogg_broadcast.clone();
@@ -343,7 +343,7 @@ impl StreamingOggFlacSinkLogic {
self.encoder_state = Some(EncoderState { broadcaster_task });
info!("OGG broadcaster task spawned");
debug!("OGG broadcaster task spawned");
Ok(())
}
@@ -402,7 +402,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
AudioError::ProcessingError("StreamingOggFlacSink requires an input".into())
})?;
info!("StreamingOggFlacSink started");
debug!("StreamingOggFlacSink started");
// TODO: Implement OGG-FLAC encoding logic
// For now, just process segments without encoding
@@ -410,7 +410,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
loop {
tokio::select! {
_ = stop_token.cancelled() => {
info!("StreamingOggFlacSink stopped by cancellation");
debug!("StreamingOggFlacSink stopped by cancellation");
break;
}
@@ -423,7 +423,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
if self.sample_rate.is_none() {
let sample_rate = chunk.sample_rate();
self.sample_rate = Some(sample_rate);
info!("Detected sample rate: {} Hz", sample_rate);
debug!("Detected sample rate: {} Hz", sample_rate);
// Initialize the FLAC encoder now
self.initialize_encoder(sample_rate).await?;
@@ -460,7 +460,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
}
SyncMarker::EndOfStream => {
info!("End of stream marker received");
debug!("End of stream marker received");
break;
}
@@ -478,7 +478,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
}
None => {
info!("Input channel closed");
debug!("Input channel closed");
break;
}
}
@@ -486,12 +486,12 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
}
}
info!("StreamingOggFlacSink processing complete");
debug!("StreamingOggFlacSink processing complete");
Ok(())
}
async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> {
info!("StreamingOggFlacSink cleanup: {:?}", reason);
debug!("StreamingOggFlacSink cleanup: {:?}", reason);
Ok(())
}
}
@@ -545,7 +545,7 @@ impl StreamingOggFlacSink {
// Broadcast channel for OGG-FLAC bytes
// Capacity calculated from max_lead_time to ensure enough buffering
let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time);
tracing::debug!(
tracing::trace!(
"OGG-FLAC broadcast capacity: {} items (for {:.1}s max lead time)",
broadcast_capacity,
broadcast_max_lead_time
@@ -786,7 +786,7 @@ async fn broadcast_ogg_flac_stream(
current_timestamp: Arc<RwLock<f64>>,
broadcast_max_lead_time: f64,
) -> Result<(), AudioError> {
info!(
trace!(
"OGG-FLAC broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
broadcast_max_lead_time
);
@@ -807,16 +807,16 @@ async fn broadcast_ogg_flac_stream(
// Step 1: Read FLAC header (fLaC + metadata blocks)
let flac_header = read_flac_header(&mut flac_stream).await?;
info!("Read FLAC header: {} bytes", flac_header.len());
trace!("Read FLAC header: {} bytes", flac_header.len());
// Extract sample rate from STREAMINFO for granule position calculation
let sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?;
info!("Extracted sample rate from STREAMINFO: {} Hz", sample_rate);
trace!("Extracted sample rate from STREAMINFO: {} Hz", sample_rate);
// Step 2: Create OGG-FLAC identification packet (BOS)
// Format according to https://xiph.org/flac/ogg_mapping.html
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
info!(
trace!(
"Created OGG-FLAC identification packet: {} bytes",
ogg_flac_id.len()
);
@@ -835,7 +835,7 @@ async fn broadcast_ogg_flac_stream(
cached_header.extend_from_slice(&comment_bytes);
*header_cache.write().await = Some(Bytes::from(cached_header));
header_captured = true;
info!(
trace!(
"OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)",
bos_bytes.len() + comment_bytes.len()
);
@@ -871,7 +871,7 @@ async fn broadcast_ogg_flac_stream(
trace!("Broadcast closed before sending final EOS page");
break;
}
info!(
trace!(
"Sent final EOS page with {} bytes of data",
flac_accumulator.len()
);
@@ -885,10 +885,10 @@ async fn broadcast_ogg_flac_stream(
trace!("Broadcast closed before sending empty EOS page");
break;
}
info!("Sent empty EOS page");
trace!("Sent empty EOS page");
}
info!(
trace!(
"OGG-FLAC stream ended, total OGG bytes: {}",
total_ogg_bytes
);
@@ -900,7 +900,7 @@ async fn broadcast_ogg_flac_stream(
total_read_time += read_duration;
if read_duration > 0.01 {
debug!(
trace!(
"OGG: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
read_duration,
n,
@@ -1002,7 +1002,7 @@ async fn broadcast_ogg_flac_stream(
// Log if interval is unusual (too short = burst, too long = stall)
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
debug!(
trace!(
"OGG: broadcast interval {:.3}s ({}ms) - frame_size={} bytes, samples={} (count={})",
broadcast_interval,
(broadcast_interval * 1000.0) as u32,
@@ -1014,7 +1014,7 @@ async fn broadcast_ogg_flac_stream(
// Periodic stats
if broadcast_count % 100 == 0 {
debug!(
trace!(
"OGG: {} broadcasts sent, avg_interval={:.3}s, accumulator={} bytes",
broadcast_count,
last_broadcast_time.elapsed().as_secs_f64() / broadcast_count as f64,
@@ -1053,7 +1053,7 @@ async fn broadcast_ogg_flac_stream(
)));
}
info!("OGG-FLAC broadcaster task completed successfully");
trace!("OGG-FLAC broadcaster task completed successfully");
Ok(())
}
@@ -1177,7 +1177,7 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
let block_length =
u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize;
info!("STREAMINFO block_length = {} bytes", block_length);
trace!("STREAMINFO block_length = {} bytes", block_length);
// STREAMINFO should be exactly 34 bytes of data
if block_length != 34 {
@@ -1194,7 +1194,7 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
// Extract just the STREAMINFO block (type + length + data)
let streaminfo = &flac_header[4..4 + streaminfo_size];
info!(
trace!(
"Extracted STREAMINFO: {} bytes (type+length+data)",
streaminfo.len()
);

View File

@@ -15,7 +15,7 @@ use std::{
};
use tokio::sync::Notify;
use tracing::warn;
use tracing::{trace, warn};
/// Paquet diffusé contenant la charge utile + méta timing.
#[derive(Clone)]
@@ -99,7 +99,7 @@ impl<T> State<T> {
}
}
if purged > 0 {
tracing::debug!(
trace!(
"TimedBroadcast: purged {} expired packet(s) (head_seq={})",
purged,
self.head_seq

View File

@@ -25,6 +25,10 @@ utoipa = { version = "5.3", optional = true }
pmoqobuz = { path = "../pmoqobuz", optional = true }
pmoparadise = { path = "../pmoparadise", optional = true }
pmoconfig = { path = "../pmoconfig", optional = true }
anyhow = { version = "1.0", optional = true }
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
pmocovers = { path = "../pmocovers", optional = true }
tokio-util = { version = "0.7", features = ["io"], optional = true }
[features]
default = ["pmosource/server"]
@@ -33,6 +37,14 @@ api = ["dep:axum", "dep:utoipa", "pmosource/server"]
# Feature pour activer le support Qobuz configuré
qobuz = ["api", "dep:pmoqobuz", "dep:pmoconfig", "pmoqobuz/server"]
# Feature pour activer le support Radio Paradise
paradise = ["api", "dep:pmoparadise", "pmoparadise/server"]
paradise = [
"api",
"dep:pmoparadise",
"pmoparadise/full",
"dep:anyhow",
"dep:pmoaudiocache",
"dep:pmocovers",
"dep:tokio-util"
]
# Feature pour activer l'API REST de Radio Paradise (en plus de la source UPnP)
paradise-api = ["paradise", "pmoparadise/pmoserver"]

View File

@@ -75,12 +75,19 @@ pub mod sources;
#[cfg(any(feature = "qobuz", feature = "paradise"))]
pub mod sources_api;
// Extension pour le streaming Paradise (requires feature paradise)
#[cfg(feature = "paradise")]
pub mod paradise_streaming;
pub use content_handler::ContentHandler;
pub use device::MEDIA_SERVER;
pub use server_ext::{MediaServerExt, MusicSourceExt, get_source_registry};
pub use source_registry::SourceRegistry;
pub use sources::{SourceInitError, SourcesExt};
#[cfg(feature = "paradise")]
pub use paradise_streaming::ParadiseStreamingExt;
// Re-export sources when features are enabled
#[cfg(feature = "qobuz")]
pub use pmoqobuz;

View File

@@ -0,0 +1,280 @@
//! Extension pour l'initialisation des canaux de streaming Radio Paradise
//!
//! Ce module fournit un trait d'extension pour démarrer les pipelines de streaming
//! Radio Paradise avec caching audio/covers et historique.
use anyhow::{Context, Result};
use async_trait::async_trait;
use axum::{
body::Body,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
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::ALL_CHANNELS, ParadiseChannelManager, ParadiseHistoryBuilder};
use std::sync::Arc;
use tokio_util::io::ReaderStream;
use tracing::{error, info};
/// État partagé pour les routes de streaming Paradise
#[derive(Clone)]
pub struct ParadiseStreamingState {
pub manager: Arc<ParadiseChannelManager>,
}
/// Extension trait pour initialiser les canaux de streaming Radio Paradise
#[async_trait]
pub trait ParadiseStreamingExt {
/// Initialise les canaux de streaming Radio Paradise avec caching
///
/// Cette méthode :
/// - Crée les caches audio et covers
/// - Initialise le ParadiseChannelManager avec historique
/// - Ajoute les routes de streaming HTTP (flac, ogg, history, metadata)
///
/// # Routes créées
///
/// Pour chaque canal (main, mellow, rock, eclectic) :
/// - `/radioparadise/stream/{slug}/flac` - Stream FLAC live
/// - `/radioparadise/stream/{slug}/ogg` - Stream OGG live
/// - `/radioparadise/stream/{slug}/historic/{client_id}/flac` - Historique FLAC
/// - `/radioparadise/stream/{slug}/historic/{client_id}/ogg` - Historique OGG
/// - `/radioparadise/metadata/{slug}` - Métadonnées en temps réel
///
/// # Exemples
///
/// ```ignore
/// use pmomediaserver::ParadiseStreamingExt;
///
/// server.init_paradise_streaming().await?;
/// ```
async fn init_paradise_streaming(&mut self) -> Result<Arc<ParadiseChannelManager>>;
}
#[async_trait]
impl ParadiseStreamingExt for pmoserver::Server {
async fn init_paradise_streaming(&mut self) -> Result<Arc<ParadiseChannelManager>> {
info!("🎵 Initializing Radio Paradise streaming channels...");
// Récupérer ou initialiser les caches singletons
info!("📦 Getting cache singletons...");
let cover_cache = match get_cover_cache() {
Some(cache) => {
info!(" ✅ Using existing cover cache singleton");
cache
}
None => {
info!(" 📦 Initializing new cover cache singleton");
let cache = self
.init_cover_cache_configured()
.await
.context("Failed to initialize cover cache")?;
register_cover_cache(cache.clone());
cache
}
};
let audio_cache = match get_audio_cache() {
Some(cache) => {
info!(" ✅ Using existing audio cache singleton");
cache
}
None => {
info!(" 📦 Initializing new audio cache singleton");
let cache = self
.init_audio_cache_configured()
.await
.context("Failed to initialize audio cache")?;
register_audio_cache(cache.clone());
cache
}
};
// Créer le builder d'historique
let history_builder = ParadiseHistoryBuilder {
audio_cache: audio_cache.clone(),
cover_cache: cover_cache.clone(),
playlist_prefix: "radioparadise-history".into(),
playlist_title_prefix: Some("Radio Paradise History".into()),
max_history_tracks: Some(500),
collection_prefix: Some("radioparadise".into()),
replay_max_lead_seconds: 1.0,
};
// Créer le manager de canaux
info!("📡 Creating ParadiseChannelManager...");
let manager = Arc::new(
ParadiseChannelManager::with_defaults_with_cover_cache(
Some(cover_cache.clone()),
Some(history_builder),
)
.await
.context("Failed to create ParadiseChannelManager")?,
);
let state = Arc::new(ParadiseStreamingState {
manager: manager.clone(),
});
// Ajouter les routes pour chaque canal
info!("🌐 Registering streaming routes...");
for descriptor in ALL_CHANNELS.iter() {
let slug = descriptor.slug;
let channel_id = descriptor.id;
// Route FLAC live
let flac_path = format!("/radioparadise/stream/{}/flac", slug);
self.add_handler_with_state(
&flac_path,
move |State(state): State<Arc<ParadiseStreamingState>>| {
let manager = state.manager.clone();
async move { stream_flac(manager, channel_id).await }
},
state.clone(),
)
.await;
// Route OGG live
let ogg_path = format!("/radioparadise/stream/{}/ogg", slug);
self.add_handler_with_state(
&ogg_path,
move |State(state): State<Arc<ParadiseStreamingState>>| {
let manager = state.manager.clone();
async move { stream_ogg(manager, channel_id).await }
},
state.clone(),
)
.await;
// Routes historique
let history_path = format!("/radioparadise/stream/{}/historic", slug);
let history_router = Router::new()
.route(
"/{client_id}/flac",
get({
let manager = manager.clone();
move |Path(client_id): Path<String>| {
let manager = manager.clone();
async move { stream_history_flac(manager, channel_id, client_id).await }
}
}),
)
.route(
"/{client_id}/ogg",
get({
let manager = manager.clone();
move |Path(client_id): Path<String>| {
let manager = manager.clone();
async move { stream_history_ogg(manager, channel_id, client_id).await }
}
}),
);
self.add_router(&history_path, history_router).await;
// Route métadonnées
let meta_path = format!("/radioparadise/metadata/{}", slug);
self.add_handler_with_state(
&meta_path,
move |State(state): State<Arc<ParadiseStreamingState>>| {
let manager = state.manager.clone();
async move { get_metadata(manager, channel_id).await }
},
state.clone(),
)
.await;
info!(
" ✅ {} - /radioparadise/stream/{}/{{flac,ogg}}",
descriptor.display_name, slug
);
}
info!("✅ Radio Paradise streaming channels initialized");
Ok(manager)
}
}
// ============================================================================
// Handlers de streaming
// ============================================================================
async fn stream_flac(
manager: Arc<ParadiseChannelManager>,
channel_id: u8,
) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.subscribe_flac();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "audio/flac")
.body(Body::from_stream(ReaderStream::new(stream)))
.unwrap())
}
async fn stream_ogg(
manager: Arc<ParadiseChannelManager>,
channel_id: u8,
) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.subscribe_ogg();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "audio/ogg")
.body(Body::from_stream(ReaderStream::new(stream)))
.unwrap())
}
async fn get_metadata(
manager: Arc<ParadiseChannelManager>,
channel_id: u8,
) -> Result<impl IntoResponse, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let metadata = channel.metadata().await;
Ok(Json(metadata))
}
async fn stream_history_flac(
manager: Arc<ParadiseChannelManager>,
channel_id: u8,
client_id: String,
) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.stream_history_flac(&client_id).await.map_err(|e| {
error!(
"Failed to start historical FLAC stream for channel {} (client_id={}): {}",
channel_id, client_id, e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "audio/flac")
.body(Body::from_stream(ReaderStream::new(stream)))
.unwrap())
}
async fn stream_history_ogg(
manager: Arc<ParadiseChannelManager>,
channel_id: u8,
client_id: String,
) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.stream_history_ogg(&client_id).await.map_err(|e| {
error!(
"Failed to start historical OGG stream for channel {} (client_id={}): {}",
channel_id, client_id, e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "audio/ogg")
.body(Body::from_stream(ReaderStream::new(stream)))
.unwrap())
}

View File

@@ -177,18 +177,13 @@ impl SourcesExt for Server {
tracing::info!("Initializing Radio Paradise source...");
// Créer le client (Radio Paradise ne nécessite pas d'authentification)
let client = RadioParadiseClient::new().await.map_err(|e| {
SourceInitError::ParadiseError(format!("Failed to create client: {}", e))
})?;
// Obtenir l'URL de base du serveur
let base_url = self.base_url();
// Créer la source depuis le registry avec capacité FIFO par défaut
let source = RadioParadiseSource::from_registry_default(client).map_err(|e| {
SourceInitError::ParadiseError(format!("Failed to create source: {}", e))
})?;
// Créer la source Radio Paradise (utilise le singleton PlaylistManager)
let source = RadioParadiseSource::new(base_url.to_string());
// Enregistrer la source
// Note: La FIFO sera peuplée automatiquement lors du premier browse
self.register_music_source(Arc::new(source)).await;
tracing::info!("✅ Radio Paradise source registered successfully");

View File

@@ -35,6 +35,10 @@ pub struct ParadiseParams {
/// Capacité FIFO (optionnelle, 50 par défaut)
#[serde(default)]
pub fifo_capacity: Option<usize>,
/// URL de base du serveur (optionnelle, "http://localhost:8080" par défaut)
#[serde(default)]
pub base_url: Option<String>,
}
/// Réponse d'enregistrement de source
@@ -136,34 +140,11 @@ async fn register_paradise(Json(params): Json<ParadiseParams>) -> impl IntoRespo
use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
use pmosource::api::register_source;
// Créer le client (Radio Paradise ne nécessite pas d'auth)
let client = match RadioParadiseClient::new().await {
Ok(c) => c,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Failed to create Radio Paradise client: {}", e),
}),
)
.into_response();
}
};
// Utiliser l'URL de base depuis les params ou une valeur par défaut
let base_url = params.base_url.unwrap_or_else(|| "http://localhost:8080".to_string());
// Créer et enregistrer la source depuis le registry
// Note: params.fifo_capacity is currently not used by from_registry
let source = match RadioParadiseSource::from_registry(client) {
Ok(s) => Arc::new(s),
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to create source: {}", e),
}),
)
.into_response();
}
};
// Créer la source Radio Paradise (utilise le singleton PlaylistManager)
let source = Arc::new(RadioParadiseSource::new(base_url));
let source_id = source.as_ref().id().to_string();

View File

@@ -52,7 +52,7 @@ symphonia = { version = "0.5", features = ["all"] }
claxon = "0.4"
# pmoaudio-ext with playlist support (optional for examples)
pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist", "http-stream"] }
pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist", "http-stream", "cache-sink"] }
# Common music source traits
pmosource = { path = "../pmosource" }

View File

@@ -4,21 +4,27 @@
//! - `/radioparadise/stream/<slug>/flac`
//! - `/radioparadise/stream/<slug>/ogg`
//! - `/radioparadise/stream/<slug>/icy`
//! - `/radioparadise/stream/<slug>/historic/<client_id>/flac`
//! - `/radioparadise/stream/<slug>/historic/<client_id>/ogg`
//! - `/radioparadise/metadata/<slug>`
use std::sync::Arc;
use std::{fs, sync::Arc};
use axum::{
body::Body,
extract::State,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
routing::get,
Json, Router,
};
use pmoparadise::{channels::ALL_CHANNELS, stream_channel::ParadiseChannelManager};
use pmoaudiocache::new_cache as new_audio_cache;
use pmocovers::new_cache as new_cover_cache;
use pmoparadise::{channels::ALL_CHANNELS, ParadiseChannelManager, ParadiseHistoryBuilder};
use pmoplaylist::register_audio_cache;
use pmoserver::{init_logging, ServerBuilder};
use tokio_util::io::ReaderStream;
use tracing::info;
use tracing::{error, info};
#[derive(Clone)]
struct AppState {
@@ -29,8 +35,35 @@ struct AppState {
async fn main() -> anyhow::Result<()> {
let _ = init_logging();
// Préparer les caches partagés
let cover_cache_dir = "./cache/rp_covers";
let audio_cache_dir = "./cache/rp_audio";
fs::create_dir_all(cover_cache_dir)?;
fs::create_dir_all(audio_cache_dir)?;
let cover_cache = Arc::new(new_cover_cache(cover_cache_dir, 500)?);
let audio_cache = Arc::new(new_audio_cache(audio_cache_dir, 1000)?);
register_audio_cache(audio_cache.clone());
let _playlist_manager = pmoplaylist::PlaylistManager();
let history_builder = ParadiseHistoryBuilder {
audio_cache: audio_cache.clone(),
cover_cache: cover_cache.clone(),
playlist_prefix: "radioparadise-history".into(),
playlist_title_prefix: Some("Radio Paradise History".into()),
max_history_tracks: Some(500),
collection_prefix: Some("radioparadise".into()),
replay_max_lead_seconds: 1.0,
};
info!("Initializing Radio Paradise channels...");
let manager = Arc::new(ParadiseChannelManager::with_defaults().await?);
let manager = Arc::new(
ParadiseChannelManager::with_defaults_with_cover_cache(
Some(cover_cache),
Some(history_builder),
)
.await?,
);
let app_state = Arc::new(AppState {
manager: manager.clone(),
});
@@ -42,6 +75,7 @@ async fn main() -> anyhow::Result<()> {
let flac_path = format!("/radioparadise/stream/{}/flac", slug);
let ogg_path = format!("/radioparadise/stream/{}/ogg", slug);
let icy_path = format!("/radioparadise/stream/{}/icy", slug);
let history_path = format!("/radioparadise/stream/{}/historic", slug);
let meta_path = format!("/radioparadise/metadata/{}", slug);
let channel_id = descriptor.id;
@@ -78,6 +112,30 @@ async fn main() -> anyhow::Result<()> {
)
.await;
let history_router = Router::new()
.route(
"/{client_id}/flac",
get({
let manager = manager.clone();
move |Path(client_id): Path<String>| {
let manager = manager.clone();
async move { stream_history_flac(manager, channel_id, client_id).await }
}
}),
)
.route(
"/{client_id}/ogg",
get({
let manager = manager.clone();
move |Path(client_id): Path<String>| {
let manager = manager.clone();
async move { stream_history_ogg(manager, channel_id, client_id).await }
}
}),
);
server.add_router(&history_path, history_router).await;
server
.add_handler_with_state(
&meta_path,
@@ -95,7 +153,7 @@ async fn main() -> anyhow::Result<()> {
info!("Available channels:");
for descriptor in ALL_CHANNELS.iter() {
info!(
" {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata)",
" {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic/<client_id>/(flac|ogg))",
descriptor.display_name, descriptor.slug
);
}
@@ -155,3 +213,43 @@ async fn get_metadata(
let metadata = channel.metadata().await;
Ok(Json(metadata))
}
async fn stream_history_flac(
manager: Arc<ParadiseChannelManager>,
channel_id: u8,
client_id: String,
) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.stream_history_flac(&client_id).await.map_err(|e| {
error!(
"Failed to start historical FLAC stream for channel {} (client_id={}): {}",
channel_id, client_id, e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "audio/flac")
.body(Body::from_stream(ReaderStream::new(stream)))
.unwrap())
}
async fn stream_history_ogg(
manager: Arc<ParadiseChannelManager>,
channel_id: u8,
client_id: String,
) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.stream_history_ogg(&client_id).await.map_err(|e| {
error!(
"Failed to start historical OGG stream for channel {} (client_id={}): {}",
channel_id, client_id, e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "audio/ogg")
.body(Body::from_stream(ReaderStream::new(stream)))
.unwrap())
}

View File

@@ -203,7 +203,7 @@
//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`)
//! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration
//! - `pmoconfig`: Enable configuration integration with pmoconfig
//! - `server`: Enable RadioParadiseSource stub for backward compatibility (deprecated)
//! - `server`: Enable RadioParadiseSource for UPnP ContentDirectory integration
//!
//! ## See Also
//!
@@ -242,7 +242,9 @@ pub use radio_paradise_stream_source::{RadioParadiseStreamSource, END_OF_BLOCKS_
#[cfg(feature = "pmoaudio")]
pub use stream_channel::{
ParadiseChannelManager, ParadiseStreamChannel, ParadiseStreamChannelConfig,
HistoryFlacStream, HistoryOggStream, HistoryStreamError, ParadiseChannelManager,
ParadiseHistoryBuilder, ParadiseHistoryOptions, ParadiseStreamChannel,
ParadiseStreamChannelConfig,
};
#[cfg(feature = "pmoserver")]

View File

@@ -161,6 +161,27 @@ pub struct Song {
#[serde(default, deserialize_with = "deserialize_optional_string_or_f32")]
pub rating: Option<f32>,
/// Gapless URL for individual song FLAC
/// This URL points to a FLAC file containing only this song
#[serde(default)]
pub gapless_url: Option<String>,
/// Scheduled playback time on Radio Paradise (Unix timestamp in milliseconds, UTC)
#[serde(default)]
pub sched_time_millis: Option<u64>,
/// Radio Paradise song ID (unique identifier)
#[serde(default)]
pub song_id: Option<String>,
/// Radio Paradise artist ID (for building artist URLs)
#[serde(default)]
pub artist_id: Option<String>,
/// Large cover image path (best quality)
#[serde(default)]
pub cover_large: Option<String>,
/// Additional metadata
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,

View File

@@ -341,14 +341,12 @@ pub struct RadioParadiseApiDoc;
/// Crée le router pour l'API Radio Paradise
pub fn create_api_router(state: RadioParadiseState) -> Router {
let api = Router::new()
Router::new()
.route("/now-playing", get(get_now_playing))
.route("/block/current", get(get_current_block))
.route("/block/{event_id}", get(get_block_by_id))
.route("/channels", get(get_channels))
.with_state(state);
Router::new().nest("/radioparadise", api)
.with_state(state)
}
/// Trait d'extension pour pmoserver::Server

View File

@@ -415,7 +415,7 @@ impl RadioParadiseStreamSourceLogic {
if send_duration.as_millis() > 10 {
let duration_ms = send_duration.as_millis() as u64;
self.stats.record_backpressure(duration_ms);
tracing::debug!(
tracing::trace!(
"send_to_children: Send to child {} BLOCKED for {:.3}s (channel capacity before send={}, timestamp={:.3}s)",
i,
send_duration.as_secs_f64(),

View File

@@ -1,113 +1,264 @@
//! DEPRECATED: Stub implementation of RadioParadiseSource
//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise
//!
//! **⚠️ This module is deprecated and will be removed in a future version.**
//!
//! The orchestration-based RadioParadiseSource has been replaced by
//! `RadioParadiseStreamSource`, which integrates directly with the pmoaudio
//! pipeline for streaming and decoding.
//!
//! ## Migration Guide
//!
//! **Old approach** (deprecated):
//! ```rust,ignore
//! use pmoparadise::RadioParadiseSource;
//! let source = RadioParadiseSource::from_registry(client)?;
//! ```
//!
//! **New approach** (recommended):
//! ```rust,ignore
//! use pmoparadise::RadioParadiseStreamSource;
//! use pmoaudio::pipeline::Node;
//!
//! let stream_source = RadioParadiseStreamSource::new(client, None).await?;
//! let node = Node::from_logic(stream_source);
//! // Use node in pmoaudio pipeline
//! ```
//!
//! This stub implementation is provided only for backward compatibility with
//! existing code (e.g., pmomediaserver) until it can be updated to use
//! RadioParadiseStreamSource.
//! This module provides a UPnP ContentDirectory source for Radio Paradise,
//! exposing live streams and historical playlists for all 4 channels.
use crate::client::RadioParadiseClient;
use pmosource::pmodidl::{Container, Item};
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
use pmosource::pmodidl::{Container, Item, Resource};
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use std::sync::Arc;
use std::time::SystemTime;
use tokio::sync::RwLock;
#[cfg(feature = "playlist")]
use pmoplaylist::PlaylistManager;
/// Default Radio Paradise image (embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
/// DEPRECATED: Stub implementation of RadioParadiseSource
/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
///
/// This is a minimal stub that implements the MusicSource trait with no-op
/// implementations. It exists only to maintain API compatibility during the
/// migration to RadioParadiseStreamSource.
/// Provides access to:
/// - Live OGG streams for all 4 channels (Main, Mellow, Rock, Eclectic)
/// - Historical playlists (FIFO) for each channel
///
/// **Do not use this in new code.** Use `RadioParadiseStreamSource` instead.
#[derive(Clone, Debug)]
/// # Object ID Schema
///
/// - Root: `radio-paradise`
/// - Channel container: `radio-paradise:channel:{slug}`
/// - Live stream item: `radio-paradise:channel:{slug}:live`
/// - History container: `radio-paradise:channel:{slug}:history`
/// - History track: `radio-paradise:channel:{slug}:history:track:{pk}`
#[derive(Debug, Clone)]
pub struct RadioParadiseSource {
_client: RadioParadiseClient,
/// Base URL for streaming server (e.g., "http://localhost:8080")
base_url: String,
/// Update counter for change notifications
update_counter: Arc<RwLock<u32>>,
/// Last change timestamp
last_change: Arc<RwLock<SystemTime>>,
}
impl RadioParadiseSource {
/// DEPRECATED: Create a new RadioParadiseSource from registry
/// Create a new RadioParadiseSource
///
/// This method is deprecated and will always return an error indicating
/// that the orchestration-based source is no longer supported.
/// # Arguments
///
/// Use `RadioParadiseStreamSource` instead for audio streaming.
#[cfg(feature = "server")]
pub fn from_registry(_client: RadioParadiseClient) -> Result<Self> {
Err(MusicSourceError::SourceUnavailable(
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead.".to_string(),
/// * `base_url` - Base URL for streaming server (e.g., "http://localhost:8080")
///
/// # Note
///
/// With the "playlist" feature enabled, this source will use the global PlaylistManager
/// singleton to access history playlists.
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
update_counter: Arc::new(RwLock::new(0)),
last_change: Arc::new(RwLock::new(SystemTime::now())),
}
}
/// Build a live stream URL for a channel
fn build_live_url(&self, slug: &str) -> String {
format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug)
}
/// Get the playlist ID for a channel's history
#[cfg(feature = "playlist")]
fn history_playlist_id(slug: &str) -> String {
format!("radioparadise-history-{}", slug)
}
/// Get channel descriptor by slug
fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> {
ALL_CHANNELS.iter().find(|ch| ch.slug == slug)
}
/// Parse an object ID into its components
fn parse_object_id(id: &str) -> ObjectIdType {
let parts: Vec<&str> = id.split(':').collect();
match parts.as_slice() {
["radio-paradise"] => ObjectIdType::Root,
["radio-paradise", "channel", slug] => ObjectIdType::Channel {
slug: (*slug).to_string(),
},
["radio-paradise", "channel", slug, "live"] => ObjectIdType::LiveStream {
slug: (*slug).to_string(),
},
["radio-paradise", "channel", slug, "history"] => ObjectIdType::History {
slug: (*slug).to_string(),
},
["radio-paradise", "channel", slug, "history", "track", pk] => {
ObjectIdType::HistoryTrack {
slug: (*slug).to_string(),
pk: (*pk).to_string(),
}
}
_ => ObjectIdType::Unknown,
}
}
/// Build a channel container
fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container {
Container {
id: format!("radio-paradise:channel:{}", descriptor.slug),
parent_id: "radio-paradise".to_string(),
restricted: Some("1".to_string()),
child_count: Some("2".to_string()), // Live + History
searchable: Some("0".to_string()),
title: descriptor.display_name.to_string(),
class: "object.container".to_string(),
containers: vec![],
items: vec![],
}
}
/// Build a live stream item for a channel
fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item {
let stream_url = self.build_live_url(descriptor.slug);
Item {
id: format!("radio-paradise:channel:{}:live", descriptor.slug),
parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
restricted: Some("1".to_string()),
title: format!("{} - Live Stream", descriptor.display_name),
creator: Some("Radio Paradise".to_string()),
class: "object.item.audioItem.audioBroadcast".to_string(),
artist: Some("Radio Paradise".to_string()),
album: Some(descriptor.display_name.to_string()),
genre: Some("Radio".to_string()),
album_art: None,
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,
}],
descriptions: vec![],
}
}
/// Build a history container for a channel
fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container {
Container {
id: format!("radio-paradise:channel:{}:history", descriptor.slug),
parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
restricted: Some("1".to_string()),
child_count: None, // Will be determined by playlist
searchable: Some("1".to_string()),
title: format!("{} - History", descriptor.display_name),
class: "object.container.playlistContainer".to_string(),
containers: vec![],
items: vec![],
}
}
/// Get items from history playlist
#[cfg(feature = "playlist")]
async fn get_history_items(
&self,
slug: &str,
offset: usize,
count: usize,
) -> Result<Vec<Item>> {
let playlist_id = Self::history_playlist_id(slug);
// Get read handle for the playlist from the singleton
let manager = pmoplaylist::PlaylistManager();
let reader = manager
.get_read_handle(&playlist_id)
.await
.map_err(|e| {
MusicSourceError::BrowseError(format!(
"Failed to get playlist {}: {}",
playlist_id, e
))
})?;
// Get entries from playlist
let entries = reader.get_entries(offset, count).await.map_err(|e| {
MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e))
})?;
// Convert entries to Items
let mut items = Vec::new();
for entry in entries {
if let Ok(item) = self.playlist_entry_to_item(slug, &entry).await {
items.push(item);
}
}
/// DEPRECATED: Create a new RadioParadiseSource from registry with defaults
///
/// This method creates a stub instance that will log deprecation warnings
/// but allows existing code to compile.
///
/// Use `RadioParadiseStreamSource` instead for audio streaming.
#[cfg(feature = "server")]
pub fn from_registry_default(client: RadioParadiseClient) -> Self {
tracing::warn!(
"RadioParadiseSource::from_registry_default is deprecated. \
Use RadioParadiseStreamSource for audio streaming."
);
Self { _client: client }
Ok(items)
}
/// DEPRECATED: Create a new RadioParadiseSource with default settings
///
/// This method is deprecated and only exists for API compatibility.
pub fn new_default(client: RadioParadiseClient) -> Self {
tracing::warn!(
"RadioParadiseSource::new_default is deprecated. \
Use RadioParadiseStreamSource for audio streaming."
/// Convert a playlist entry to a DIDL Item
#[cfg(feature = "playlist")]
async fn playlist_entry_to_item(
&self,
slug: &str,
entry: &pmoplaylist::PlaylistEntry,
) -> Result<Item> {
let metadata = &entry.metadata;
// Build audio URL from cache
let audio_url = format!(
"{}/cache/audio/{}",
self.base_url,
entry.pk
);
Self { _client: client }
// Build item
Ok(Item {
id: format!("radio-paradise:channel:{}:history:track:{}", slug, entry.pk),
parent_id: format!("radio-paradise:channel:{}:history", slug),
restricted: Some("1".to_string()),
title: metadata.title.clone().unwrap_or_else(|| "Unknown Title".to_string()),
creator: metadata.artist.clone(),
class: "object.item.audioItem.musicTrack".to_string(),
artist: metadata.artist.clone(),
album: metadata.album.clone(),
genre: metadata.genre.clone(),
album_art: None,
album_art_pk: metadata.cover_pk.clone(),
date: metadata.year.map(|y| y.to_string()),
original_track_number: metadata.track_number.map(|n| n.to_string()),
resources: vec![Resource {
protocol_info: "http-get:*:audio/flac:*".to_string(),
bits_per_sample: metadata.bits_per_sample.map(|b| b.to_string()),
sample_frequency: metadata.sample_rate.map(|s| s.to_string()),
nr_audio_channels: Some("2".to_string()),
duration: metadata.duration.map(|d| format!("{}:{:02}:{:02}", d / 3600, (d % 3600) / 60, d % 60)),
url: audio_url,
}],
descriptions: vec![],
})
}
}
/// DEPRECATED: Create a new RadioParadiseSource with cache
///
/// This method is deprecated and only exists for API compatibility.
pub fn new_with_cache(client: RadioParadiseClient, _cache_size: usize) -> Self {
tracing::warn!(
"RadioParadiseSource::new_with_cache is deprecated. \
Use RadioParadiseStreamSource for audio streaming."
);
Self { _client: client }
}
/// Types of object IDs in the Radio Paradise source
#[derive(Debug, Clone, PartialEq)]
enum ObjectIdType {
Root,
Channel { slug: String },
LiveStream { slug: String },
History { slug: String },
HistoryTrack { slug: String, pk: String },
Unknown,
}
#[async_trait]
impl MusicSource for RadioParadiseSource {
fn name(&self) -> &str {
"Radio Paradise (DEPRECATED)"
"Radio Paradise"
}
fn id(&self) -> &str {
"radio-paradise-deprecated"
"radio-paradise"
}
fn default_image(&self) -> &[u8] {
@@ -116,55 +267,124 @@ impl MusicSource for RadioParadiseSource {
async fn root_container(&self) -> Result<Container> {
Ok(Container {
id: "radio-paradise-deprecated".to_string(),
id: "radio-paradise".to_string(),
parent_id: "0".to_string(),
restricted: Some("1".to_string()),
child_count: Some("0".to_string()),
child_count: Some("4".to_string()), // 4 channels
searchable: Some("0".to_string()),
title: "Radio Paradise (DEPRECATED)".to_string(),
title: "Radio Paradise".to_string(),
class: "object.container".to_string(),
containers: vec![],
items: vec![],
})
}
async fn browse(&self, _object_id: &str) -> Result<BrowseResult> {
tracing::warn!("RadioParadiseSource::browse called but source is deprecated");
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
match Self::parse_object_id(object_id) {
ObjectIdType::Root => {
// Return the 4 channel containers
let containers: Vec<Container> = ALL_CHANNELS
.iter()
.map(|ch| self.build_channel_container(ch))
.collect();
Ok(BrowseResult::Containers(containers))
}
ObjectIdType::Channel { slug } => {
// Return live stream item + history container
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
})?;
let live_item = self.build_live_stream_item(descriptor);
let history_container = self.build_history_container(descriptor);
Ok(BrowseResult::Mixed {
containers: vec![],
items: vec![],
containers: vec![history_container],
items: vec![live_item],
})
}
async fn resolve_uri(&self, _object_id: &str) -> Result<String> {
Err(MusicSourceError::SourceUnavailable(
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead.".to_string(),
))
ObjectIdType::History { slug } => {
// Return items from history playlist
#[cfg(feature = "playlist")]
{
let items = self.get_history_items(&slug, 0, 100).await?;
Ok(BrowseResult::Items(items))
}
#[cfg(not(feature = "playlist"))]
{
let _ = slug;
Ok(BrowseResult::Items(vec![]))
}
}
ObjectIdType::LiveStream { .. } | ObjectIdType::HistoryTrack { .. } => {
// These are leaf nodes, cannot be browsed
Err(MusicSourceError::ObjectNotFound(format!(
"Object {} is not a container",
object_id
)))
}
ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(format!(
"Unknown object ID: {}",
object_id
))),
}
}
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
match Self::parse_object_id(object_id) {
ObjectIdType::LiveStream { slug } => {
// Return live stream URL
Ok(self.build_live_url(&slug))
}
ObjectIdType::HistoryTrack { pk, .. } => {
// Return cached audio URL
Ok(format!("{}/cache/audio/{}", self.base_url, pk))
}
_ => Err(MusicSourceError::ObjectNotFound(format!(
"Cannot resolve URI for object: {}",
object_id
))),
}
}
fn supports_fifo(&self) -> bool {
false
// History playlists are FIFO
cfg!(feature = "playlist")
}
async fn append_track(&self, _track: Item) -> Result<()> {
Err(MusicSourceError::SourceUnavailable(
"RadioParadiseSource is deprecated and does not support FIFO operations.".to_string(),
// Tracks are added automatically by FlacCacheSink
Err(MusicSourceError::NotSupported(
"Tracks are automatically added to history by the streaming system".to_string(),
))
}
async fn remove_oldest(&self) -> Result<Option<Item>> {
// Managed automatically by playlist FIFO
Ok(None)
}
async fn update_id(&self) -> u32 {
0
*self.update_counter.read().await
}
async fn last_change(&self) -> Option<SystemTime> {
None
Some(*self.last_change.read().await)
}
async fn get_items(&self, _offset: usize, _count: usize) -> Result<Vec<Item>> {
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
// For Radio Paradise, we don't have a global FIFO
// Each channel has its own history
// Return empty for now - clients should browse specific channel histories
let _ = (offset, count);
Ok(vec![])
}
}

View File

@@ -14,14 +14,18 @@ use crate::{
client::RadioParadiseClient,
radio_paradise_stream_source::RadioParadiseStreamSource,
};
use anyhow::Result;
use pmoaudio::AudioPipelineNode;
use anyhow::{anyhow, Result};
use pmoaudio::{nodes::DEFAULT_CHANNEL_SIZE, AudioPipelineNode};
use pmoaudio_ext::{
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode,
FlacCacheSink, FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream,
OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink,
TrackBoundaryCoverNode,
};
use pmoaudiocache::Cache as AudioCache;
use pmocovers::Cache as CoverCache;
use pmoflac::EncoderOptions;
use pmoplaylist::WriteHandle;
use thiserror::Error;
use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::Notify;
use tokio::task::JoinHandle;
@@ -43,6 +47,82 @@ impl Default for ParadiseStreamChannelConfig {
}
}
/// Options pour activer l'archivage/historique d'un canal.
pub struct ParadiseHistoryOptions {
pub audio_cache: Arc<AudioCache>,
pub cover_cache: Arc<CoverCache>,
pub playlist_id: String,
pub playlist_writer: WriteHandle,
pub collection: Option<String>,
pub replay_max_lead_seconds: f64,
}
/// Builder pratique pour configurer automatiquement les playlists historiques.
#[derive(Clone)]
pub struct ParadiseHistoryBuilder {
pub audio_cache: Arc<AudioCache>,
pub cover_cache: Arc<CoverCache>,
pub playlist_prefix: String,
pub playlist_title_prefix: Option<String>,
pub max_history_tracks: Option<usize>,
pub collection_prefix: Option<String>,
pub replay_max_lead_seconds: f64,
}
impl ParadiseHistoryBuilder {
pub fn new(audio_cache: Arc<AudioCache>, cover_cache: Arc<CoverCache>) -> Self {
Self {
audio_cache,
cover_cache,
playlist_prefix: "radio-paradise-history".into(),
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,
}
}
pub async fn build_for_channel(
&self,
descriptor: &ChannelDescriptor,
) -> Result<ParadiseHistoryOptions, pmoplaylist::Error> {
let playlist_id = format!("{}-{}", self.playlist_prefix, descriptor.slug);
let manager = pmoplaylist::PlaylistManager();
let writer = manager
.get_persistent_write_handle(playlist_id.clone())
.await?;
if let Some(prefix) = &self.playlist_title_prefix {
let title = format!("{} - {}", prefix, descriptor.display_name);
writer.set_title(title).await?;
}
if let Some(capacity) = self.max_history_tracks {
writer.set_capacity(Some(capacity)).await?;
}
let collection = self
.collection_prefix
.as_ref()
.map(|prefix| format!("{}-{}", prefix, descriptor.slug));
Ok(ParadiseHistoryOptions {
audio_cache: self.audio_cache.clone(),
cover_cache: self.cover_cache.clone(),
playlist_id,
playlist_writer: writer,
collection,
replay_max_lead_seconds: self.replay_max_lead_seconds,
})
}
}
struct HistoryState {
playlist_id: String,
audio_cache: Arc<AudioCache>,
replay_max_lead_seconds: f64,
}
#[cfg(feature = "pmoconfig")]
impl ParadiseStreamChannelConfig {
pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self {
@@ -94,6 +174,7 @@ pub struct ParadiseStreamChannel {
state: Arc<ChannelState>,
pipeline_handle: JoinHandle<()>,
feeder_handle: JoinHandle<()>,
history: Option<HistoryState>,
}
impl ParadiseStreamChannel {
@@ -103,6 +184,7 @@ impl ParadiseStreamChannel {
client: RadioParadiseClient,
config: ParadiseStreamChannelConfig,
cover_cache: Option<Arc<CoverCache>>,
history: Option<ParadiseHistoryOptions>,
) -> Self {
let mut source = RadioParadiseStreamSource::new(client.clone());
let block_handle = source.block_handle();
@@ -118,14 +200,47 @@ impl ParadiseStreamChannel {
config.max_lead_seconds,
);
let mut downstream_children: Vec<Box<dyn AudioPipelineNode>> = Vec::new();
downstream_children.push(Box::new(flac_sink));
downstream_children.push(Box::new(ogg_sink));
let mut history_state = None;
if let Some(history_opts) = history {
let ParadiseHistoryOptions {
audio_cache,
cover_cache,
playlist_id,
playlist_writer,
collection,
replay_max_lead_seconds,
} = history_opts;
let mut cache_sink = FlacCacheSink::with_config(
audio_cache.clone(),
cover_cache,
DEFAULT_CHANNEL_SIZE,
EncoderOptions::default(),
collection,
);
cache_sink.register_playlist(playlist_writer);
downstream_children.push(Box::new(cache_sink));
history_state = Some(HistoryState {
playlist_id,
audio_cache,
replay_max_lead_seconds,
});
}
if let Some(cache) = cover_cache {
let mut cover_node = TrackBoundaryCoverNode::new(cache);
cover_node.register(Box::new(flac_sink));
cover_node.register(Box::new(ogg_sink));
for child in downstream_children {
cover_node.register(child);
}
source.register(Box::new(cover_node));
} else {
source.register(Box::new(flac_sink));
source.register(Box::new(ogg_sink));
for child in downstream_children {
source.register(child);
}
}
stream_handle.set_auto_stop(false);
ogg_handle.set_auto_stop(false);
@@ -167,6 +282,7 @@ impl ParadiseStreamChannel {
state,
pipeline_handle,
feeder_handle,
history: history_state,
}
}
@@ -175,12 +291,19 @@ impl ParadiseStreamChannel {
descriptor: ChannelDescriptor,
config: ParadiseStreamChannelConfig,
cover_cache: Option<Arc<CoverCache>>,
history: Option<ParadiseHistoryOptions>,
) -> Result<Self> {
let client = RadioParadiseClient::builder()
.channel(descriptor.id)
.build()
.await?;
Ok(Self::with_client(descriptor, client, config, cover_cache))
Ok(Self::with_client(
descriptor,
client,
config,
cover_cache,
history,
))
}
/// S'abonne au flux FLAC pur.
@@ -217,6 +340,78 @@ impl ParadiseStreamChannel {
pub fn descriptor(&self) -> ChannelDescriptor {
self.descriptor
}
/// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client.
pub async fn stream_history_flac(
&self,
client_id: &str,
) -> Result<HistoryFlacStream, HistoryStreamError> {
let history = self
.history
.as_ref()
.ok_or(HistoryStreamError::HistoryDisabled)?;
tracing::info!(
"Starting historical FLAC replay for channel {} (client_id={})",
self.descriptor.display_name,
client_id
);
let reader = pmoplaylist::PlaylistManager()
.get_read_handle(&history.playlist_id)
.await
.map_err(|e| HistoryStreamError::Playlist(e.to_string()))?;
let mut source = PlaylistSource::new(reader, history.audio_cache.clone());
let (flac_sink, handle) = StreamingFlacSink::with_max_broadcast_lead(
EncoderOptions::default(),
16,
history.replay_max_lead_seconds,
);
source.register(Box::new(flac_sink));
let stop_token = CancellationToken::new();
let mut pipeline_source = source;
let stop_clone = stop_token.clone();
let pipeline = tokio::spawn(async move {
let _ = Box::new(pipeline_source).run(stop_clone).await;
});
let stream = handle.subscribe_flac();
Ok(HistoryFlacStream::new(stream, stop_token, pipeline))
}
/// Lance un pipeline dédié pour rejouer l'historique (OGG-FLAC) pour un client.
pub async fn stream_history_ogg(
&self,
client_id: &str,
) -> Result<HistoryOggStream, HistoryStreamError> {
let history = self
.history
.as_ref()
.ok_or(HistoryStreamError::HistoryDisabled)?;
tracing::info!(
"Starting historical OGG replay for channel {} (client_id={})",
self.descriptor.display_name,
client_id
);
let reader = pmoplaylist::PlaylistManager()
.get_read_handle(&history.playlist_id)
.await
.map_err(|e| HistoryStreamError::Playlist(e.to_string()))?;
let mut source = PlaylistSource::new(reader, history.audio_cache.clone());
let (ogg_sink, handle) = StreamingOggFlacSink::with_max_broadcast_lead(
EncoderOptions::default(),
16,
history.replay_max_lead_seconds,
);
source.register(Box::new(ogg_sink));
let stop_token = CancellationToken::new();
let mut pipeline_source = source;
let stop_clone = stop_token.clone();
let pipeline = tokio::spawn(async move {
let _ = Box::new(pipeline_source).run(stop_clone).await;
});
let stream = handle.subscribe();
Ok(HistoryOggStream::new(stream, stop_token, pipeline))
}
}
impl Drop for ParadiseStreamChannel {
@@ -360,6 +555,96 @@ wrap_stream!(ChannelFlacStream, FlacClientStream);
wrap_stream!(ChannelIcyStream, IcyClientStream);
wrap_stream!(ChannelOggStream, OggFlacClientStream);
#[derive(Debug, Error)]
pub enum HistoryStreamError {
#[error("history replay not enabled for this channel")]
HistoryDisabled,
#[error("playlist error: {0}")]
Playlist(String),
}
pub struct HistoryFlacStream {
inner: FlacClientStream,
stop_token: CancellationToken,
pipeline: Option<JoinHandle<()>>,
}
impl HistoryFlacStream {
fn new(
inner: FlacClientStream,
stop_token: CancellationToken,
pipeline: JoinHandle<()>,
) -> Self {
Self {
inner,
stop_token,
pipeline: Some(pipeline),
}
}
}
impl AsyncRead for HistoryFlacStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl Unpin for HistoryFlacStream {}
impl Drop for HistoryFlacStream {
fn drop(&mut self) {
self.stop_token.cancel();
if let Some(handle) = self.pipeline.take() {
handle.abort();
}
}
}
pub struct HistoryOggStream {
inner: OggFlacClientStream,
stop_token: CancellationToken,
pipeline: Option<JoinHandle<()>>,
}
impl HistoryOggStream {
fn new(
inner: OggFlacClientStream,
stop_token: CancellationToken,
pipeline: JoinHandle<()>,
) -> Self {
Self {
inner,
stop_token,
pipeline: Some(pipeline),
}
}
}
impl AsyncRead for HistoryOggStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl Unpin for HistoryOggStream {}
impl Drop for HistoryOggStream {
fn drop(&mut self) {
self.stop_token.cancel();
if let Some(handle) = self.pipeline.take() {
handle.abort();
}
}
}
/// Gestionnaire multi-canaux.
pub struct ParadiseChannelManager {
channels: HashMap<u8, Arc<ParadiseStreamChannel>>,
@@ -372,13 +657,25 @@ impl ParadiseChannelManager {
pub async fn with_defaults_with_cover_cache(
cover_cache: Option<Arc<CoverCache>>,
history_builder: Option<ParadiseHistoryBuilder>,
) -> Result<Self> {
let mut map = HashMap::new();
for descriptor in ALL_CHANNELS.iter().copied() {
let history_opts = if let Some(builder) = &history_builder {
Some(
builder
.build_for_channel(&descriptor)
.await
.map_err(|e| anyhow!("Failed to init history playlist: {}", e))?,
)
} else {
None
};
let channel = ParadiseStreamChannel::new(
descriptor,
ParadiseStreamChannelConfig::default(),
cover_cache.clone(),
history_opts,
)
.await?;
map.insert(descriptor.id, Arc::new(channel));
@@ -387,7 +684,7 @@ impl ParadiseChannelManager {
}
pub async fn with_defaults() -> Result<Self> {
Self::with_defaults_with_cover_cache(None).await
Self::with_defaults_with_cover_cache(None, None).await
}
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {