Tentative de gestion d'un historique
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ upmpdcli/
|
||||
test_upnp*.cargo/
|
||||
.cargo/
|
||||
setup-env.sh
|
||||
/cache
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
if let Err(e) = server.write().await.register_paradise().await {
|
||||
tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e);
|
||||
// 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 source: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
|
||||
4
pmoapp/webapp/package-lock.json
generated
4
pmoapp/webapp/package-lock.json
generated
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,26 +305,62 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
))
|
||||
})?;
|
||||
|
||||
let url = match dest_metadata.read().await.get_cover_url().await {
|
||||
Ok(url) => {
|
||||
tracing::debug!("FlacCacheSink: Got cover URL for pk {}: {:?}", pk, url);
|
||||
url
|
||||
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 URL for pk {}: {}",
|
||||
"FlacCacheSink: Transient error getting cover_pk for pk {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}",
|
||||
"FlacCacheSink: Cannot obtain cover_pk for audio asset {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
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
|
||||
);
|
||||
url
|
||||
}
|
||||
Err(e) if e.is_transient() => {
|
||||
tracing::debug!(
|
||||
"FlacCacheSink: Transient error getting cover URL for pk {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}",
|
||||
pk,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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,11 +765,11 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
||||
}
|
||||
let send_duration = send_start.elapsed();
|
||||
if send_duration.as_millis() >= 50 {
|
||||
debug!(
|
||||
"StreamingFlacSink: pcm_tx send blocked for {:.3}s (ts={:.3}s)",
|
||||
send_duration.as_secs_f64(),
|
||||
seg.timestamp_sec
|
||||
);
|
||||
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
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
280
pmomediaserver/src/paradise_streaming.rs
Normal file
280
pmomediaserver/src/paradise_streaming.rs
Normal 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())
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
/// Build a live stream URL for a channel
|
||||
fn build_live_url(&self, slug: &str) -> String {
|
||||
format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug)
|
||||
}
|
||||
|
||||
/// 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."
|
||||
);
|
||||
Self { _client: client }
|
||||
/// Get the playlist ID for a channel's history
|
||||
#[cfg(feature = "playlist")]
|
||||
fn history_playlist_id(slug: &str) -> String {
|
||||
format!("radioparadise-history-{}", slug)
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// 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
|
||||
);
|
||||
|
||||
// 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![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
Ok(BrowseResult::Mixed {
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
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![history_container],
|
||||
items: vec![live_item],
|
||||
})
|
||||
}
|
||||
|
||||
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> {
|
||||
Err(MusicSourceError::SourceUnavailable(
|
||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead.".to_string(),
|
||||
))
|
||||
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![])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
Reference in New Issue
Block a user