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/
|
test_upnp*.cargo/
|
||||||
.cargo/
|
.cargo/
|
||||||
setup-env.sh
|
setup-env.sh
|
||||||
|
/cache
|
||||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -3211,11 +3211,14 @@ dependencies = [
|
|||||||
name = "pmomediaserver"
|
name = "pmomediaserver"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"pmoaudiocache",
|
||||||
"pmoconfig",
|
"pmoconfig",
|
||||||
|
"pmocovers",
|
||||||
"pmodidl",
|
"pmodidl",
|
||||||
"pmoparadise",
|
"pmoparadise",
|
||||||
"pmoqobuz",
|
"pmoqobuz",
|
||||||
@@ -3227,6 +3230,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use pmoapp::{WebAppExt, Webapp};
|
use pmoapp::{WebAppExt, Webapp};
|
||||||
use pmomediarenderer::MEDIA_RENDERER;
|
use pmomediarenderer::MEDIA_RENDERER;
|
||||||
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt};
|
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt, ParadiseStreamingExt};
|
||||||
use pmoserver::Server;
|
use pmoserver::Server;
|
||||||
use pmosource::MusicSourceExt;
|
use pmosource::MusicSourceExt;
|
||||||
use pmoupnp::UpnpServerExt;
|
use pmoupnp::UpnpServerExt;
|
||||||
@@ -36,13 +36,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
info!("🎵 Registering music sources...");
|
info!("🎵 Registering music sources...");
|
||||||
|
|
||||||
// // Enregistrer Qobuz
|
// // 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);
|
// 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)
|
||||||
if let Err(e) = server.write().await.register_paradise().await {
|
info!("📻 Initializing Radio Paradise streaming channels...");
|
||||||
tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e);
|
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
|
// 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==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1339,6 +1340,7 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -1353,6 +1355,7 @@
|
|||||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -1434,6 +1437,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.22",
|
"@vue/compiler-dom": "3.5.22",
|
||||||
"@vue/compiler-sfc": "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 {
|
impl AudioPipelineNode for TrackBoundaryCoverNode {
|
||||||
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||||
self.inner.get_tx()
|
self.inner.get_tx()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! - Paces broadcast to match audio playback rate
|
//! - Paces broadcast to match audio playback rate
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{trace, warn};
|
||||||
|
|
||||||
/// Error returned when a frame should be skipped (too late)
|
/// Error returned when a frame should be skipped (too late)
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -56,7 +56,7 @@ impl BroadcastPacer {
|
|||||||
let elapsed_since_start = self.start_time.elapsed().as_secs_f64();
|
let elapsed_since_start = self.start_time.elapsed().as_secs_f64();
|
||||||
if audio_timestamp < 0.1 && elapsed_since_start > 1.0 {
|
if audio_timestamp < 0.1 && elapsed_since_start > 1.0 {
|
||||||
self.start_time = Instant::now();
|
self.start_time = Instant::now();
|
||||||
info!(
|
trace!(
|
||||||
"{} broadcaster: TopZeroSync detected, resetting timer",
|
"{} broadcaster: TopZeroSync detected, resetting timer",
|
||||||
self.label
|
self.label
|
||||||
);
|
);
|
||||||
@@ -94,7 +94,7 @@ impl BroadcastPacer {
|
|||||||
|
|
||||||
// Log pour info si on est très en avance, mais on ne dort PAS
|
// 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 {
|
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",
|
"{} 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
|
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é
|
// Phase 1: Dispatcher jusqu'à ce que le prebuffer soit terminé
|
||||||
let mut end_of_stream_received = false;
|
let mut end_of_stream_received = false;
|
||||||
|
let mut early_track_boundary_received = false;
|
||||||
let mut track_tx_opt = Some(track_tx);
|
let mut track_tx_opt = Some(track_tx);
|
||||||
let pk = loop {
|
let pk = loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -221,9 +222,9 @@ impl NodeLogic for FlacCacheSinkLogic {
|
|||||||
result = rx.recv() => {
|
result = rx.recv() => {
|
||||||
match result {
|
match result {
|
||||||
Some(segment) => {
|
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
|
// et continuer à attendre cache_future
|
||||||
if end_of_stream_received {
|
if end_of_stream_received || early_track_boundary_received {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,10 +240,18 @@ impl NodeLogic for FlacCacheSinkLogic {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_AudioSegment::Sync(marker) => match &**marker {
|
_AudioSegment::Sync(marker) => match &**marker {
|
||||||
SyncMarker::TrackBoundary { .. } => {
|
SyncMarker::TrackBoundary { metadata } => {
|
||||||
// TrackBoundary avant fin du prebuffer - track trop courte
|
// TrackBoundary pendant le prebuffer - track courte (< 512KB)
|
||||||
tracing::error!("FlacCacheSink: TrackBoundary received before prebuffer complete - track too short");
|
tracing::warn!(
|
||||||
return Err(AudioError::ProcessingError("Track too short for prebuffer".to_string()));
|
"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 => {
|
SyncMarker::EndOfStream => {
|
||||||
tracing::debug!("FlacCacheSink: EndOfStream during prebuffer - closing pump and waiting for ingestion to complete");
|
tracing::debug!("FlacCacheSink: EndOfStream during prebuffer - closing pump and waiting for ingestion to complete");
|
||||||
@@ -262,7 +271,7 @@ impl NodeLogic for FlacCacheSinkLogic {
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// EOF sur rx pendant le prebuffer - attendre que cache_future se termine
|
// 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");
|
tracing::debug!("FlacCacheSink: EOF on rx during prebuffer, waiting for ingestion to complete");
|
||||||
track_tx_opt = None;
|
track_tx_opt = None;
|
||||||
end_of_stream_received = true;
|
end_of_stream_received = true;
|
||||||
@@ -296,26 +305,62 @@ 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(url) => {
|
Ok(Some(existing_pk)) => {
|
||||||
tracing::debug!("FlacCacheSink: Got cover URL for pk {}: {:?}", pk, url);
|
tracing::debug!(
|
||||||
url
|
"FlacCacheSink: cover_pk already set for audio asset {} ({})",
|
||||||
|
pk,
|
||||||
|
existing_pk
|
||||||
|
);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
Ok(None) => false,
|
||||||
Err(e) if e.is_transient() => {
|
Err(e) if e.is_transient() => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"FlacCacheSink: Transient error getting cover URL for pk {}: {}",
|
"FlacCacheSink: Transient error getting cover_pk for pk {}: {}",
|
||||||
pk,
|
pk,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
None
|
false
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"FlacCacheSink: Cannot obtain cover URL for audio asset {}: {}",
|
"FlacCacheSink: Cannot obtain cover_pk for audio asset {}: {}",
|
||||||
pk,
|
pk,
|
||||||
e
|
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)
|
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
|
// Phase 3: Continuer à dispatcher jusqu'au TrackBoundary
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)"
|
"FlacCacheSink: Continuing dispatch until TrackBoundary (pump runs in background)"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ use pmometadata::TrackMetadata;
|
|||||||
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
||||||
use tokio::sync::{mpsc, RwLock};
|
use tokio::sync::{mpsc, RwLock};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, error, info, trace, warn};
|
use tracing::{debug, error, trace, warn};
|
||||||
|
|
||||||
/// Default ICY metadata interval (bytes of audio between metadata blocks).
|
/// Default ICY metadata interval (bytes of audio between metadata blocks).
|
||||||
/// Standard value used by most streaming servers.
|
/// Standard value used by most streaming servers.
|
||||||
@@ -291,7 +291,7 @@ impl AsyncRead for FlacClientStream {
|
|||||||
|
|
||||||
if let Some(header) = header_opt {
|
if let Some(header) = header_opt {
|
||||||
self.buffer.extend(header.iter());
|
self.buffer.extend(header.iter());
|
||||||
info!(
|
debug!(
|
||||||
"Sending cached FLAC header to new client ({} bytes)",
|
"Sending cached FLAC header to new client ({} bytes)",
|
||||||
header.len()
|
header.len()
|
||||||
);
|
);
|
||||||
@@ -356,10 +356,10 @@ impl Drop for FlacClientStream {
|
|||||||
|
|
||||||
if count == 1 {
|
if count == 1 {
|
||||||
if self.handle.auto_stop.load(Ordering::SeqCst) {
|
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();
|
self.handle.stop_token.cancel();
|
||||||
} else {
|
} 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 {
|
if let Some(header) = header_opt {
|
||||||
self.buffer.extend(header.iter());
|
self.buffer.extend(header.iter());
|
||||||
info!(
|
debug!(
|
||||||
"Sending cached FLAC header to new ICY client ({} bytes)",
|
"Sending cached FLAC header to new ICY client ({} bytes)",
|
||||||
header.len()
|
header.len()
|
||||||
);
|
);
|
||||||
@@ -568,10 +568,10 @@ impl Drop for IcyClientStream {
|
|||||||
|
|
||||||
if count == 1 {
|
if count == 1 {
|
||||||
if self.handle.auto_stop.load(Ordering::SeqCst) {
|
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();
|
self.handle.stop_token.cancel();
|
||||||
} else {
|
} 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
|
return Ok(()); // Already initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
debug!(
|
||||||
"Initializing FLAC encoder with sample rate: {} Hz",
|
"Initializing FLAC encoder with sample rate: {} Hz",
|
||||||
sample_rate
|
sample_rate
|
||||||
);
|
);
|
||||||
@@ -634,7 +634,7 @@ impl StreamingFlacSinkLogic {
|
|||||||
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
|
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
|
// Spawn broadcaster task with timestamp for pacing
|
||||||
let flac_broadcast = self.flac_broadcast.clone();
|
let flac_broadcast = self.flac_broadcast.clone();
|
||||||
@@ -656,7 +656,7 @@ impl StreamingFlacSinkLogic {
|
|||||||
|
|
||||||
self.encoder_state = Some(EncoderState { broadcaster_task });
|
self.encoder_state = Some(EncoderState { broadcaster_task });
|
||||||
|
|
||||||
info!("Broadcaster task spawned");
|
debug!("Broadcaster task spawned");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -716,7 +716,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
AudioError::ProcessingError("StreamingFlacSink requires an input".into())
|
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
|
// We'll initialize the encoder lazily when we get the first chunk
|
||||||
// For now, just process segments
|
// For now, just process segments
|
||||||
@@ -724,7 +724,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = stop_token.cancelled() => {
|
_ = stop_token.cancelled() => {
|
||||||
info!("StreamingFlacSink stopped by cancellation");
|
debug!("StreamingFlacSink stopped by cancellation");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +737,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
if self.sample_rate.is_none() {
|
if self.sample_rate.is_none() {
|
||||||
let sample_rate = chunk.sample_rate();
|
let sample_rate = chunk.sample_rate();
|
||||||
self.sample_rate = Some(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
|
// Initialize the FLAC encoder now
|
||||||
self.initialize_encoder(sample_rate).await?;
|
self.initialize_encoder(sample_rate).await?;
|
||||||
@@ -765,11 +765,11 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
}
|
}
|
||||||
let send_duration = send_start.elapsed();
|
let send_duration = send_start.elapsed();
|
||||||
if send_duration.as_millis() >= 50 {
|
if send_duration.as_millis() >= 50 {
|
||||||
debug!(
|
trace!(
|
||||||
"StreamingFlacSink: pcm_tx send blocked for {:.3}s (ts={:.3}s)",
|
"StreamingFlacSink: pcm_tx send blocked for {:.3}s (ts={:.3}s)",
|
||||||
send_duration.as_secs_f64(),
|
send_duration.as_secs_f64(),
|
||||||
seg.timestamp_sec
|
seg.timestamp_sec
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,7 +782,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SyncMarker::EndOfStream => {
|
SyncMarker::EndOfStream => {
|
||||||
info!("End of stream marker received");
|
debug!("End of stream marker received");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,7 +800,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
None => {
|
None => {
|
||||||
info!("Input channel closed");
|
debug!("Input channel closed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -808,12 +808,12 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("StreamingFlacSink processing complete");
|
debug!("StreamingFlacSink processing complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> {
|
async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> {
|
||||||
info!("StreamingFlacSink cleanup: {:?}", reason);
|
debug!("StreamingFlacSink cleanup: {:?}", reason);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -828,7 +828,7 @@ async fn broadcast_flac_stream(
|
|||||||
current_timestamp: Arc<RwLock<f64>>,
|
current_timestamp: Arc<RwLock<f64>>,
|
||||||
broadcast_max_lead_time: f64,
|
broadcast_max_lead_time: f64,
|
||||||
) -> Result<(), AudioError> {
|
) -> Result<(), AudioError> {
|
||||||
info!(
|
trace!(
|
||||||
"Broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
|
"Broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
|
||||||
broadcast_max_lead_time
|
broadcast_max_lead_time
|
||||||
);
|
);
|
||||||
@@ -861,7 +861,7 @@ async fn broadcast_flac_stream(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info!("FLAC encoder stream ended, total bytes: {}", total_bytes);
|
trace!("FLAC encoder stream ended, total bytes: {}", total_bytes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
@@ -870,7 +870,7 @@ async fn broadcast_flac_stream(
|
|||||||
total_read_time += read_duration;
|
total_read_time += read_duration;
|
||||||
|
|
||||||
if read_duration > 0.01 {
|
if read_duration > 0.01 {
|
||||||
debug!(
|
trace!(
|
||||||
"FLAC: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
|
"FLAC: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
|
||||||
read_duration,
|
read_duration,
|
||||||
n,
|
n,
|
||||||
@@ -924,7 +924,7 @@ async fn broadcast_flac_stream(
|
|||||||
let audio_timestamp = *current_timestamp.read().await;
|
let audio_timestamp = *current_timestamp.read().await;
|
||||||
|
|
||||||
if stats_last_log.elapsed() >= Duration::from_secs(1) {
|
if stats_last_log.elapsed() >= Duration::from_secs(1) {
|
||||||
debug!(
|
trace!(
|
||||||
"Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={}",
|
"Broadcaster pacing snapshot: audio_ts={:.3}s buffer_bytes={}",
|
||||||
audio_timestamp,
|
audio_timestamp,
|
||||||
accumulator.len()
|
accumulator.len()
|
||||||
@@ -951,7 +951,7 @@ async fn broadcast_flac_stream(
|
|||||||
|
|
||||||
// Log if interval is unusual (too short = burst, too long = stall)
|
// Log if interval is unusual (too short = burst, too long = stall)
|
||||||
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
|
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
|
||||||
debug!(
|
trace!(
|
||||||
"FLAC: broadcast interval {:.3}s ({}ms) - size={} bytes (count={})",
|
"FLAC: broadcast interval {:.3}s ({}ms) - size={} bytes (count={})",
|
||||||
broadcast_interval,
|
broadcast_interval,
|
||||||
(broadcast_interval * 1000.0) as u32,
|
(broadcast_interval * 1000.0) as u32,
|
||||||
@@ -962,7 +962,7 @@ async fn broadcast_flac_stream(
|
|||||||
|
|
||||||
// Periodic stats
|
// Periodic stats
|
||||||
if broadcast_count % 100 == 0 {
|
if broadcast_count % 100 == 0 {
|
||||||
debug!(
|
trace!(
|
||||||
"FLAC: {} broadcasts sent, accumulator={} bytes remaining",
|
"FLAC: {} broadcasts sent, accumulator={} bytes remaining",
|
||||||
broadcast_count,
|
broadcast_count,
|
||||||
accumulator.len()
|
accumulator.len()
|
||||||
@@ -973,7 +973,7 @@ async fn broadcast_flac_stream(
|
|||||||
if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
|
if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
|
||||||
*header_cache.write().await = Some(bytes.clone());
|
*header_cache.write().await = Some(bytes.clone());
|
||||||
header_captured = true;
|
header_captured = true;
|
||||||
info!("FLAC header captured ({} bytes)", bytes.len());
|
trace!("FLAC header captured ({} bytes)", bytes.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
let num_receivers = broadcast_tx.receiver_count();
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,7 +1062,7 @@ impl StreamingFlacSink {
|
|||||||
|
|
||||||
// Calculate broadcast capacity based on max_lead_time
|
// Calculate broadcast capacity based on max_lead_time
|
||||||
let broadcast_capacity = calculate_broadcast_capacity(broadcast_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)",
|
"StreamingFlacSink: using broadcast capacity of {} items (max_lead_time={:.1}s)",
|
||||||
broadcast_capacity, broadcast_max_lead_time
|
broadcast_capacity, broadcast_max_lead_time
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ impl AsyncRead for OggFlacClientStream {
|
|||||||
|
|
||||||
if let Some(header) = header_opt {
|
if let Some(header) = header_opt {
|
||||||
self.buffer.extend(header.iter());
|
self.buffer.extend(header.iter());
|
||||||
info!(
|
debug!(
|
||||||
"Sending cached OGG-FLAC header to new client ({} bytes)",
|
"Sending cached OGG-FLAC header to new client ({} bytes)",
|
||||||
header.len()
|
header.len()
|
||||||
);
|
);
|
||||||
@@ -255,10 +255,10 @@ impl Drop for OggFlacClientStream {
|
|||||||
|
|
||||||
if count == 1 {
|
if count == 1 {
|
||||||
if self.handle.auto_stop.load(Ordering::SeqCst) {
|
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();
|
self.handle.stop_token.cancel();
|
||||||
} else {
|
} 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
|
return Ok(()); // Already initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
debug!(
|
||||||
"Initializing OGG-FLAC encoder with sample rate: {} Hz",
|
"Initializing OGG-FLAC encoder with sample rate: {} Hz",
|
||||||
sample_rate
|
sample_rate
|
||||||
);
|
);
|
||||||
@@ -321,7 +321,7 @@ impl StreamingOggFlacSinkLogic {
|
|||||||
AudioError::ProcessingError(format!("Failed to start FLAC encoder: {}", e))
|
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
|
// Spawn OGG wrapper + broadcaster task with timestamp for pacing
|
||||||
let ogg_broadcast = self.ogg_broadcast.clone();
|
let ogg_broadcast = self.ogg_broadcast.clone();
|
||||||
@@ -343,7 +343,7 @@ impl StreamingOggFlacSinkLogic {
|
|||||||
|
|
||||||
self.encoder_state = Some(EncoderState { broadcaster_task });
|
self.encoder_state = Some(EncoderState { broadcaster_task });
|
||||||
|
|
||||||
info!("OGG broadcaster task spawned");
|
debug!("OGG broadcaster task spawned");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -402,7 +402,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
AudioError::ProcessingError("StreamingOggFlacSink requires an input".into())
|
AudioError::ProcessingError("StreamingOggFlacSink requires an input".into())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
info!("StreamingOggFlacSink started");
|
debug!("StreamingOggFlacSink started");
|
||||||
|
|
||||||
// TODO: Implement OGG-FLAC encoding logic
|
// TODO: Implement OGG-FLAC encoding logic
|
||||||
// For now, just process segments without encoding
|
// For now, just process segments without encoding
|
||||||
@@ -410,7 +410,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = stop_token.cancelled() => {
|
_ = stop_token.cancelled() => {
|
||||||
info!("StreamingOggFlacSink stopped by cancellation");
|
debug!("StreamingOggFlacSink stopped by cancellation");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +423,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
if self.sample_rate.is_none() {
|
if self.sample_rate.is_none() {
|
||||||
let sample_rate = chunk.sample_rate();
|
let sample_rate = chunk.sample_rate();
|
||||||
self.sample_rate = Some(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
|
// Initialize the FLAC encoder now
|
||||||
self.initialize_encoder(sample_rate).await?;
|
self.initialize_encoder(sample_rate).await?;
|
||||||
@@ -460,7 +460,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SyncMarker::EndOfStream => {
|
SyncMarker::EndOfStream => {
|
||||||
info!("End of stream marker received");
|
debug!("End of stream marker received");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +478,7 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
None => {
|
None => {
|
||||||
info!("Input channel closed");
|
debug!("Input channel closed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,12 +486,12 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("StreamingOggFlacSink processing complete");
|
debug!("StreamingOggFlacSink processing complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> {
|
async fn cleanup(&mut self, reason: StopReason) -> Result<(), AudioError> {
|
||||||
info!("StreamingOggFlacSink cleanup: {:?}", reason);
|
debug!("StreamingOggFlacSink cleanup: {:?}", reason);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ impl StreamingOggFlacSink {
|
|||||||
// Broadcast channel for OGG-FLAC bytes
|
// Broadcast channel for OGG-FLAC bytes
|
||||||
// Capacity calculated from max_lead_time to ensure enough buffering
|
// Capacity calculated from max_lead_time to ensure enough buffering
|
||||||
let broadcast_capacity = calculate_broadcast_capacity(broadcast_max_lead_time);
|
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)",
|
"OGG-FLAC broadcast capacity: {} items (for {:.1}s max lead time)",
|
||||||
broadcast_capacity,
|
broadcast_capacity,
|
||||||
broadcast_max_lead_time
|
broadcast_max_lead_time
|
||||||
@@ -786,7 +786,7 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
current_timestamp: Arc<RwLock<f64>>,
|
current_timestamp: Arc<RwLock<f64>>,
|
||||||
broadcast_max_lead_time: f64,
|
broadcast_max_lead_time: f64,
|
||||||
) -> Result<(), AudioError> {
|
) -> Result<(), AudioError> {
|
||||||
info!(
|
trace!(
|
||||||
"OGG-FLAC broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
|
"OGG-FLAC broadcaster task started with FLAC frame boundary detection (max_lead={:.3}s)",
|
||||||
broadcast_max_lead_time
|
broadcast_max_lead_time
|
||||||
);
|
);
|
||||||
@@ -807,16 +807,16 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
|
|
||||||
// Step 1: Read FLAC header (fLaC + metadata blocks)
|
// Step 1: Read FLAC header (fLaC + metadata blocks)
|
||||||
let flac_header = read_flac_header(&mut flac_stream).await?;
|
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
|
// Extract sample rate from STREAMINFO for granule position calculation
|
||||||
let sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?;
|
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)
|
// Step 2: Create OGG-FLAC identification packet (BOS)
|
||||||
// Format according to https://xiph.org/flac/ogg_mapping.html
|
// Format according to https://xiph.org/flac/ogg_mapping.html
|
||||||
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
|
let ogg_flac_id = create_ogg_flac_identification(&flac_header)?;
|
||||||
info!(
|
trace!(
|
||||||
"Created OGG-FLAC identification packet: {} bytes",
|
"Created OGG-FLAC identification packet: {} bytes",
|
||||||
ogg_flac_id.len()
|
ogg_flac_id.len()
|
||||||
);
|
);
|
||||||
@@ -835,7 +835,7 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
cached_header.extend_from_slice(&comment_bytes);
|
cached_header.extend_from_slice(&comment_bytes);
|
||||||
*header_cache.write().await = Some(Bytes::from(cached_header));
|
*header_cache.write().await = Some(Bytes::from(cached_header));
|
||||||
header_captured = true;
|
header_captured = true;
|
||||||
info!(
|
trace!(
|
||||||
"OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)",
|
"OGG-FLAC header cached ({} bytes: BOS + Vorbis Comment)",
|
||||||
bos_bytes.len() + comment_bytes.len()
|
bos_bytes.len() + comment_bytes.len()
|
||||||
);
|
);
|
||||||
@@ -871,7 +871,7 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
trace!("Broadcast closed before sending final EOS page");
|
trace!("Broadcast closed before sending final EOS page");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
info!(
|
trace!(
|
||||||
"Sent final EOS page with {} bytes of data",
|
"Sent final EOS page with {} bytes of data",
|
||||||
flac_accumulator.len()
|
flac_accumulator.len()
|
||||||
);
|
);
|
||||||
@@ -885,10 +885,10 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
trace!("Broadcast closed before sending empty EOS page");
|
trace!("Broadcast closed before sending empty EOS page");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
info!("Sent empty EOS page");
|
trace!("Sent empty EOS page");
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
trace!(
|
||||||
"OGG-FLAC stream ended, total OGG bytes: {}",
|
"OGG-FLAC stream ended, total OGG bytes: {}",
|
||||||
total_ogg_bytes
|
total_ogg_bytes
|
||||||
);
|
);
|
||||||
@@ -900,7 +900,7 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
total_read_time += read_duration;
|
total_read_time += read_duration;
|
||||||
|
|
||||||
if read_duration > 0.01 {
|
if read_duration > 0.01 {
|
||||||
debug!(
|
trace!(
|
||||||
"OGG: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
|
"OGG: flac_stream.read() took {:.3}s for {} bytes (avg: {:.3}s over {} reads)",
|
||||||
read_duration,
|
read_duration,
|
||||||
n,
|
n,
|
||||||
@@ -1002,7 +1002,7 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
|
|
||||||
// Log if interval is unusual (too short = burst, too long = stall)
|
// Log if interval is unusual (too short = burst, too long = stall)
|
||||||
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
|
if broadcast_interval < 0.01 || broadcast_interval > 0.1 {
|
||||||
debug!(
|
trace!(
|
||||||
"OGG: broadcast interval {:.3}s ({}ms) - frame_size={} bytes, samples={} (count={})",
|
"OGG: broadcast interval {:.3}s ({}ms) - frame_size={} bytes, samples={} (count={})",
|
||||||
broadcast_interval,
|
broadcast_interval,
|
||||||
(broadcast_interval * 1000.0) as u32,
|
(broadcast_interval * 1000.0) as u32,
|
||||||
@@ -1014,7 +1014,7 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
|
|
||||||
// Periodic stats
|
// Periodic stats
|
||||||
if broadcast_count % 100 == 0 {
|
if broadcast_count % 100 == 0 {
|
||||||
debug!(
|
trace!(
|
||||||
"OGG: {} broadcasts sent, avg_interval={:.3}s, accumulator={} bytes",
|
"OGG: {} broadcasts sent, avg_interval={:.3}s, accumulator={} bytes",
|
||||||
broadcast_count,
|
broadcast_count,
|
||||||
last_broadcast_time.elapsed().as_secs_f64() / broadcast_count as f64,
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,7 +1177,7 @@ fn create_ogg_flac_identification(flac_header: &[u8]) -> Result<Vec<u8>, AudioEr
|
|||||||
let block_length =
|
let block_length =
|
||||||
u32::from_be_bytes([0, flac_header[5], flac_header[6], flac_header[7]]) as usize;
|
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
|
// STREAMINFO should be exactly 34 bytes of data
|
||||||
if block_length != 34 {
|
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)
|
// Extract just the STREAMINFO block (type + length + data)
|
||||||
let streaminfo = &flac_header[4..4 + streaminfo_size];
|
let streaminfo = &flac_header[4..4 + streaminfo_size];
|
||||||
|
|
||||||
info!(
|
trace!(
|
||||||
"Extracted STREAMINFO: {} bytes (type+length+data)",
|
"Extracted STREAMINFO: {} bytes (type+length+data)",
|
||||||
streaminfo.len()
|
streaminfo.len()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
use tracing::warn;
|
use tracing::{trace, warn};
|
||||||
|
|
||||||
/// Paquet diffusé contenant la charge utile + méta timing.
|
/// Paquet diffusé contenant la charge utile + méta timing.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -99,7 +99,7 @@ impl<T> State<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if purged > 0 {
|
if purged > 0 {
|
||||||
tracing::debug!(
|
trace!(
|
||||||
"TimedBroadcast: purged {} expired packet(s) (head_seq={})",
|
"TimedBroadcast: purged {} expired packet(s) (head_seq={})",
|
||||||
purged,
|
purged,
|
||||||
self.head_seq
|
self.head_seq
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ utoipa = { version = "5.3", optional = true }
|
|||||||
pmoqobuz = { path = "../pmoqobuz", optional = true }
|
pmoqobuz = { path = "../pmoqobuz", optional = true }
|
||||||
pmoparadise = { path = "../pmoparadise", optional = true }
|
pmoparadise = { path = "../pmoparadise", optional = true }
|
||||||
pmoconfig = { path = "../pmoconfig", 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]
|
[features]
|
||||||
default = ["pmosource/server"]
|
default = ["pmosource/server"]
|
||||||
@@ -33,6 +37,14 @@ api = ["dep:axum", "dep:utoipa", "pmosource/server"]
|
|||||||
# Feature pour activer le support Qobuz configuré
|
# Feature pour activer le support Qobuz configuré
|
||||||
qobuz = ["api", "dep:pmoqobuz", "dep:pmoconfig", "pmoqobuz/server"]
|
qobuz = ["api", "dep:pmoqobuz", "dep:pmoconfig", "pmoqobuz/server"]
|
||||||
# Feature pour activer le support Radio Paradise
|
# 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)
|
# Feature pour activer l'API REST de Radio Paradise (en plus de la source UPnP)
|
||||||
paradise-api = ["paradise", "pmoparadise/pmoserver"]
|
paradise-api = ["paradise", "pmoparadise/pmoserver"]
|
||||||
|
|||||||
@@ -75,12 +75,19 @@ pub mod sources;
|
|||||||
#[cfg(any(feature = "qobuz", feature = "paradise"))]
|
#[cfg(any(feature = "qobuz", feature = "paradise"))]
|
||||||
pub mod sources_api;
|
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 content_handler::ContentHandler;
|
||||||
pub use device::MEDIA_SERVER;
|
pub use device::MEDIA_SERVER;
|
||||||
pub use server_ext::{MediaServerExt, MusicSourceExt, get_source_registry};
|
pub use server_ext::{MediaServerExt, MusicSourceExt, get_source_registry};
|
||||||
pub use source_registry::SourceRegistry;
|
pub use source_registry::SourceRegistry;
|
||||||
pub use sources::{SourceInitError, SourcesExt};
|
pub use sources::{SourceInitError, SourcesExt};
|
||||||
|
|
||||||
|
#[cfg(feature = "paradise")]
|
||||||
|
pub use paradise_streaming::ParadiseStreamingExt;
|
||||||
|
|
||||||
// Re-export sources when features are enabled
|
// Re-export sources when features are enabled
|
||||||
#[cfg(feature = "qobuz")]
|
#[cfg(feature = "qobuz")]
|
||||||
pub use pmoqobuz;
|
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...");
|
tracing::info!("Initializing Radio Paradise source...");
|
||||||
|
|
||||||
// Créer le client (Radio Paradise ne nécessite pas d'authentification)
|
// Obtenir l'URL de base du serveur
|
||||||
let client = RadioParadiseClient::new().await.map_err(|e| {
|
let base_url = self.base_url();
|
||||||
SourceInitError::ParadiseError(format!("Failed to create client: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Créer la source depuis le registry avec capacité FIFO par défaut
|
// Créer la source Radio Paradise (utilise le singleton PlaylistManager)
|
||||||
let source = RadioParadiseSource::from_registry_default(client).map_err(|e| {
|
let source = RadioParadiseSource::new(base_url.to_string());
|
||||||
SourceInitError::ParadiseError(format!("Failed to create source: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Enregistrer la source
|
// Enregistrer la source
|
||||||
// Note: La FIFO sera peuplée automatiquement lors du premier browse
|
|
||||||
self.register_music_source(Arc::new(source)).await;
|
self.register_music_source(Arc::new(source)).await;
|
||||||
|
|
||||||
tracing::info!("✅ Radio Paradise source registered successfully");
|
tracing::info!("✅ Radio Paradise source registered successfully");
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ pub struct ParadiseParams {
|
|||||||
/// Capacité FIFO (optionnelle, 50 par défaut)
|
/// Capacité FIFO (optionnelle, 50 par défaut)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub fifo_capacity: Option<usize>,
|
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
|
/// 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 pmoparadise::{RadioParadiseClient, RadioParadiseSource};
|
||||||
use pmosource::api::register_source;
|
use pmosource::api::register_source;
|
||||||
|
|
||||||
// Créer le client (Radio Paradise ne nécessite pas d'auth)
|
// Utiliser l'URL de base depuis les params ou une valeur par défaut
|
||||||
let client = match RadioParadiseClient::new().await {
|
let base_url = params.base_url.unwrap_or_else(|| "http://localhost:8080".to_string());
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: format!("Failed to create Radio Paradise client: {}", e),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Créer et enregistrer la source depuis le registry
|
// Créer la source Radio Paradise (utilise le singleton PlaylistManager)
|
||||||
// Note: params.fifo_capacity is currently not used by from_registry
|
let source = Arc::new(RadioParadiseSource::new(base_url));
|
||||||
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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let source_id = source.as_ref().id().to_string();
|
let source_id = source.as_ref().id().to_string();
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ symphonia = { version = "0.5", features = ["all"] }
|
|||||||
claxon = "0.4"
|
claxon = "0.4"
|
||||||
|
|
||||||
# pmoaudio-ext with playlist support (optional for examples)
|
# 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
|
# Common music source traits
|
||||||
pmosource = { path = "../pmosource" }
|
pmosource = { path = "../pmosource" }
|
||||||
|
|||||||
@@ -4,21 +4,27 @@
|
|||||||
//! - `/radioparadise/stream/<slug>/flac`
|
//! - `/radioparadise/stream/<slug>/flac`
|
||||||
//! - `/radioparadise/stream/<slug>/ogg`
|
//! - `/radioparadise/stream/<slug>/ogg`
|
||||||
//! - `/radioparadise/stream/<slug>/icy`
|
//! - `/radioparadise/stream/<slug>/icy`
|
||||||
|
//! - `/radioparadise/stream/<slug>/historic/<client_id>/flac`
|
||||||
|
//! - `/radioparadise/stream/<slug>/historic/<client_id>/ogg`
|
||||||
//! - `/radioparadise/metadata/<slug>`
|
//! - `/radioparadise/metadata/<slug>`
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::{fs, sync::Arc};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::State,
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
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 pmoserver::{init_logging, ServerBuilder};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
use tracing::info;
|
use tracing::{error, info};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
@@ -29,8 +35,35 @@ struct AppState {
|
|||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let _ = init_logging();
|
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...");
|
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 {
|
let app_state = Arc::new(AppState {
|
||||||
manager: manager.clone(),
|
manager: manager.clone(),
|
||||||
});
|
});
|
||||||
@@ -42,6 +75,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let flac_path = format!("/radioparadise/stream/{}/flac", slug);
|
let flac_path = format!("/radioparadise/stream/{}/flac", slug);
|
||||||
let ogg_path = format!("/radioparadise/stream/{}/ogg", slug);
|
let ogg_path = format!("/radioparadise/stream/{}/ogg", slug);
|
||||||
let icy_path = format!("/radioparadise/stream/{}/icy", 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 meta_path = format!("/radioparadise/metadata/{}", slug);
|
||||||
let channel_id = descriptor.id;
|
let channel_id = descriptor.id;
|
||||||
|
|
||||||
@@ -78,6 +112,30 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
)
|
)
|
||||||
.await;
|
.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
|
server
|
||||||
.add_handler_with_state(
|
.add_handler_with_state(
|
||||||
&meta_path,
|
&meta_path,
|
||||||
@@ -95,7 +153,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
info!("Available channels:");
|
info!("Available channels:");
|
||||||
for descriptor in ALL_CHANNELS.iter() {
|
for descriptor in ALL_CHANNELS.iter() {
|
||||||
info!(
|
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
|
descriptor.display_name, descriptor.slug
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,3 +213,43 @@ async fn get_metadata(
|
|||||||
let metadata = channel.metadata().await;
|
let metadata = channel.metadata().await;
|
||||||
Ok(Json(metadata))
|
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`)
|
//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`)
|
||||||
//! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration
|
//! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration
|
||||||
//! - `pmoconfig`: Enable configuration integration with pmoconfig
|
//! - `pmoconfig`: Enable configuration integration with pmoconfig
|
||||||
//! - `server`: Enable RadioParadiseSource stub for backward compatibility (deprecated)
|
//! - `server`: Enable RadioParadiseSource for UPnP ContentDirectory integration
|
||||||
//!
|
//!
|
||||||
//! ## See Also
|
//! ## See Also
|
||||||
//!
|
//!
|
||||||
@@ -242,7 +242,9 @@ pub use radio_paradise_stream_source::{RadioParadiseStreamSource, END_OF_BLOCKS_
|
|||||||
|
|
||||||
#[cfg(feature = "pmoaudio")]
|
#[cfg(feature = "pmoaudio")]
|
||||||
pub use stream_channel::{
|
pub use stream_channel::{
|
||||||
ParadiseChannelManager, ParadiseStreamChannel, ParadiseStreamChannelConfig,
|
HistoryFlacStream, HistoryOggStream, HistoryStreamError, ParadiseChannelManager,
|
||||||
|
ParadiseHistoryBuilder, ParadiseHistoryOptions, ParadiseStreamChannel,
|
||||||
|
ParadiseStreamChannelConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
|
|||||||
@@ -161,6 +161,27 @@ pub struct Song {
|
|||||||
#[serde(default, deserialize_with = "deserialize_optional_string_or_f32")]
|
#[serde(default, deserialize_with = "deserialize_optional_string_or_f32")]
|
||||||
pub rating: Option<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
|
/// Additional metadata
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub extra: HashMap<String, serde_json::Value>,
|
pub extra: HashMap<String, serde_json::Value>,
|
||||||
|
|||||||
@@ -341,14 +341,12 @@ pub struct RadioParadiseApiDoc;
|
|||||||
|
|
||||||
/// Crée le router pour l'API Radio Paradise
|
/// Crée le router pour l'API Radio Paradise
|
||||||
pub fn create_api_router(state: RadioParadiseState) -> Router {
|
pub fn create_api_router(state: RadioParadiseState) -> Router {
|
||||||
let api = Router::new()
|
Router::new()
|
||||||
.route("/now-playing", get(get_now_playing))
|
.route("/now-playing", get(get_now_playing))
|
||||||
.route("/block/current", get(get_current_block))
|
.route("/block/current", get(get_current_block))
|
||||||
.route("/block/{event_id}", get(get_block_by_id))
|
.route("/block/{event_id}", get(get_block_by_id))
|
||||||
.route("/channels", get(get_channels))
|
.route("/channels", get(get_channels))
|
||||||
.with_state(state);
|
.with_state(state)
|
||||||
|
|
||||||
Router::new().nest("/radioparadise", api)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait d'extension pour pmoserver::Server
|
/// Trait d'extension pour pmoserver::Server
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ impl RadioParadiseStreamSourceLogic {
|
|||||||
if send_duration.as_millis() > 10 {
|
if send_duration.as_millis() > 10 {
|
||||||
let duration_ms = send_duration.as_millis() as u64;
|
let duration_ms = send_duration.as_millis() as u64;
|
||||||
self.stats.record_backpressure(duration_ms);
|
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)",
|
"send_to_children: Send to child {} BLOCKED for {:.3}s (channel capacity before send={}, timestamp={:.3}s)",
|
||||||
i,
|
i,
|
||||||
send_duration.as_secs_f64(),
|
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.**
|
//! This module provides a UPnP ContentDirectory source for Radio Paradise,
|
||||||
//!
|
//! exposing live streams and historical playlists for all 4 channels.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use crate::client::RadioParadiseClient;
|
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
|
||||||
use pmosource::pmodidl::{Container, Item};
|
use pmosource::pmodidl::{Container, Item, Resource};
|
||||||
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
use pmoplaylist::PlaylistManager;
|
||||||
|
|
||||||
/// Default Radio Paradise image (embedded in binary)
|
/// Default Radio Paradise image (embedded in binary)
|
||||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||||
|
|
||||||
/// 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
|
/// Provides access to:
|
||||||
/// implementations. It exists only to maintain API compatibility during the
|
/// - Live OGG streams for all 4 channels (Main, Mellow, Rock, Eclectic)
|
||||||
/// migration to RadioParadiseStreamSource.
|
/// - Historical playlists (FIFO) for each channel
|
||||||
///
|
///
|
||||||
/// **Do not use this in new code.** Use `RadioParadiseStreamSource` instead.
|
/// # Object ID Schema
|
||||||
#[derive(Clone, Debug)]
|
///
|
||||||
|
/// - 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 {
|
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 {
|
impl RadioParadiseSource {
|
||||||
/// DEPRECATED: Create a new RadioParadiseSource from registry
|
/// Create a new RadioParadiseSource
|
||||||
///
|
///
|
||||||
/// This method is deprecated and will always return an error indicating
|
/// # Arguments
|
||||||
/// that the orchestration-based source is no longer supported.
|
|
||||||
///
|
///
|
||||||
/// Use `RadioParadiseStreamSource` instead for audio streaming.
|
/// * `base_url` - Base URL for streaming server (e.g., "http://localhost:8080")
|
||||||
#[cfg(feature = "server")]
|
///
|
||||||
pub fn from_registry(_client: RadioParadiseClient) -> Result<Self> {
|
/// # Note
|
||||||
Err(MusicSourceError::SourceUnavailable(
|
///
|
||||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead.".to_string(),
|
/// 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
|
/// Build a live stream URL for a channel
|
||||||
///
|
fn build_live_url(&self, slug: &str) -> String {
|
||||||
/// This method creates a stub instance that will log deprecation warnings
|
format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug)
|
||||||
/// 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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DEPRECATED: Create a new RadioParadiseSource with default settings
|
/// Get the playlist ID for a channel's history
|
||||||
///
|
#[cfg(feature = "playlist")]
|
||||||
/// This method is deprecated and only exists for API compatibility.
|
fn history_playlist_id(slug: &str) -> String {
|
||||||
pub fn new_default(client: RadioParadiseClient) -> Self {
|
format!("radioparadise-history-{}", slug)
|
||||||
tracing::warn!(
|
|
||||||
"RadioParadiseSource::new_default is deprecated. \
|
|
||||||
Use RadioParadiseStreamSource for audio streaming."
|
|
||||||
);
|
|
||||||
Self { _client: client }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DEPRECATED: Create a new RadioParadiseSource with cache
|
/// Get channel descriptor by slug
|
||||||
///
|
fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> {
|
||||||
/// This method is deprecated and only exists for API compatibility.
|
ALL_CHANNELS.iter().find(|ch| ch.slug == slug)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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]
|
#[async_trait]
|
||||||
impl MusicSource for RadioParadiseSource {
|
impl MusicSource for RadioParadiseSource {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"Radio Paradise (DEPRECATED)"
|
"Radio Paradise"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn id(&self) -> &str {
|
fn id(&self) -> &str {
|
||||||
"radio-paradise-deprecated"
|
"radio-paradise"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_image(&self) -> &[u8] {
|
fn default_image(&self) -> &[u8] {
|
||||||
@@ -116,55 +267,124 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
|
|
||||||
async fn root_container(&self) -> Result<Container> {
|
async fn root_container(&self) -> Result<Container> {
|
||||||
Ok(Container {
|
Ok(Container {
|
||||||
id: "radio-paradise-deprecated".to_string(),
|
id: "radio-paradise".to_string(),
|
||||||
parent_id: "0".to_string(),
|
parent_id: "0".to_string(),
|
||||||
restricted: Some("1".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()),
|
searchable: Some("0".to_string()),
|
||||||
title: "Radio Paradise (DEPRECATED)".to_string(),
|
title: "Radio Paradise".to_string(),
|
||||||
class: "object.container".to_string(),
|
class: "object.container".to_string(),
|
||||||
containers: vec![],
|
containers: vec![],
|
||||||
items: vec![],
|
items: vec![],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn browse(&self, _object_id: &str) -> Result<BrowseResult> {
|
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
|
||||||
tracing::warn!("RadioParadiseSource::browse called but source is deprecated");
|
match Self::parse_object_id(object_id) {
|
||||||
Ok(BrowseResult::Mixed {
|
ObjectIdType::Root => {
|
||||||
containers: vec![],
|
// Return the 4 channel containers
|
||||||
items: vec![],
|
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> {
|
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
||||||
Err(MusicSourceError::SourceUnavailable(
|
match Self::parse_object_id(object_id) {
|
||||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead.".to_string(),
|
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 {
|
fn supports_fifo(&self) -> bool {
|
||||||
false
|
// History playlists are FIFO
|
||||||
|
cfg!(feature = "playlist")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn append_track(&self, _track: Item) -> Result<()> {
|
async fn append_track(&self, _track: Item) -> Result<()> {
|
||||||
Err(MusicSourceError::SourceUnavailable(
|
// Tracks are added automatically by FlacCacheSink
|
||||||
"RadioParadiseSource is deprecated and does not support FIFO operations.".to_string(),
|
Err(MusicSourceError::NotSupported(
|
||||||
|
"Tracks are automatically added to history by the streaming system".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
||||||
|
// Managed automatically by playlist FIFO
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_id(&self) -> u32 {
|
async fn update_id(&self) -> u32 {
|
||||||
0
|
*self.update_counter.read().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn last_change(&self) -> Option<SystemTime> {
|
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![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,18 @@ use crate::{
|
|||||||
client::RadioParadiseClient,
|
client::RadioParadiseClient,
|
||||||
radio_paradise_stream_source::RadioParadiseStreamSource,
|
radio_paradise_stream_source::RadioParadiseStreamSource,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use pmoaudio::AudioPipelineNode;
|
use pmoaudio::{nodes::DEFAULT_CHANNEL_SIZE, AudioPipelineNode};
|
||||||
use pmoaudio_ext::{
|
use pmoaudio_ext::{
|
||||||
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
|
FlacCacheSink, FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream,
|
||||||
StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode,
|
OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink,
|
||||||
|
TrackBoundaryCoverNode,
|
||||||
};
|
};
|
||||||
|
use pmoaudiocache::Cache as AudioCache;
|
||||||
use pmocovers::Cache as CoverCache;
|
use pmocovers::Cache as CoverCache;
|
||||||
use pmoflac::EncoderOptions;
|
use pmoflac::EncoderOptions;
|
||||||
|
use pmoplaylist::WriteHandle;
|
||||||
|
use thiserror::Error;
|
||||||
use tokio::io::{AsyncRead, ReadBuf};
|
use tokio::io::{AsyncRead, ReadBuf};
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
use tokio::task::JoinHandle;
|
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")]
|
#[cfg(feature = "pmoconfig")]
|
||||||
impl ParadiseStreamChannelConfig {
|
impl ParadiseStreamChannelConfig {
|
||||||
pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self {
|
pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self {
|
||||||
@@ -94,6 +174,7 @@ pub struct ParadiseStreamChannel {
|
|||||||
state: Arc<ChannelState>,
|
state: Arc<ChannelState>,
|
||||||
pipeline_handle: JoinHandle<()>,
|
pipeline_handle: JoinHandle<()>,
|
||||||
feeder_handle: JoinHandle<()>,
|
feeder_handle: JoinHandle<()>,
|
||||||
|
history: Option<HistoryState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParadiseStreamChannel {
|
impl ParadiseStreamChannel {
|
||||||
@@ -103,6 +184,7 @@ impl ParadiseStreamChannel {
|
|||||||
client: RadioParadiseClient,
|
client: RadioParadiseClient,
|
||||||
config: ParadiseStreamChannelConfig,
|
config: ParadiseStreamChannelConfig,
|
||||||
cover_cache: Option<Arc<CoverCache>>,
|
cover_cache: Option<Arc<CoverCache>>,
|
||||||
|
history: Option<ParadiseHistoryOptions>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut source = RadioParadiseStreamSource::new(client.clone());
|
let mut source = RadioParadiseStreamSource::new(client.clone());
|
||||||
let block_handle = source.block_handle();
|
let block_handle = source.block_handle();
|
||||||
@@ -118,14 +200,47 @@ impl ParadiseStreamChannel {
|
|||||||
config.max_lead_seconds,
|
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 {
|
if let Some(cache) = cover_cache {
|
||||||
let mut cover_node = TrackBoundaryCoverNode::new(cache);
|
let mut cover_node = TrackBoundaryCoverNode::new(cache);
|
||||||
cover_node.register(Box::new(flac_sink));
|
for child in downstream_children {
|
||||||
cover_node.register(Box::new(ogg_sink));
|
cover_node.register(child);
|
||||||
|
}
|
||||||
source.register(Box::new(cover_node));
|
source.register(Box::new(cover_node));
|
||||||
} else {
|
} else {
|
||||||
source.register(Box::new(flac_sink));
|
for child in downstream_children {
|
||||||
source.register(Box::new(ogg_sink));
|
source.register(child);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stream_handle.set_auto_stop(false);
|
stream_handle.set_auto_stop(false);
|
||||||
ogg_handle.set_auto_stop(false);
|
ogg_handle.set_auto_stop(false);
|
||||||
@@ -167,6 +282,7 @@ impl ParadiseStreamChannel {
|
|||||||
state,
|
state,
|
||||||
pipeline_handle,
|
pipeline_handle,
|
||||||
feeder_handle,
|
feeder_handle,
|
||||||
|
history: history_state,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,12 +291,19 @@ impl ParadiseStreamChannel {
|
|||||||
descriptor: ChannelDescriptor,
|
descriptor: ChannelDescriptor,
|
||||||
config: ParadiseStreamChannelConfig,
|
config: ParadiseStreamChannelConfig,
|
||||||
cover_cache: Option<Arc<CoverCache>>,
|
cover_cache: Option<Arc<CoverCache>>,
|
||||||
|
history: Option<ParadiseHistoryOptions>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let client = RadioParadiseClient::builder()
|
let client = RadioParadiseClient::builder()
|
||||||
.channel(descriptor.id)
|
.channel(descriptor.id)
|
||||||
.build()
|
.build()
|
||||||
.await?;
|
.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.
|
/// S'abonne au flux FLAC pur.
|
||||||
@@ -217,6 +340,78 @@ impl ParadiseStreamChannel {
|
|||||||
pub fn descriptor(&self) -> ChannelDescriptor {
|
pub fn descriptor(&self) -> ChannelDescriptor {
|
||||||
self.descriptor
|
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 {
|
impl Drop for ParadiseStreamChannel {
|
||||||
@@ -360,6 +555,96 @@ wrap_stream!(ChannelFlacStream, FlacClientStream);
|
|||||||
wrap_stream!(ChannelIcyStream, IcyClientStream);
|
wrap_stream!(ChannelIcyStream, IcyClientStream);
|
||||||
wrap_stream!(ChannelOggStream, OggFlacClientStream);
|
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.
|
/// Gestionnaire multi-canaux.
|
||||||
pub struct ParadiseChannelManager {
|
pub struct ParadiseChannelManager {
|
||||||
channels: HashMap<u8, Arc<ParadiseStreamChannel>>,
|
channels: HashMap<u8, Arc<ParadiseStreamChannel>>,
|
||||||
@@ -372,13 +657,25 @@ impl ParadiseChannelManager {
|
|||||||
|
|
||||||
pub async fn with_defaults_with_cover_cache(
|
pub async fn with_defaults_with_cover_cache(
|
||||||
cover_cache: Option<Arc<CoverCache>>,
|
cover_cache: Option<Arc<CoverCache>>,
|
||||||
|
history_builder: Option<ParadiseHistoryBuilder>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
for descriptor in ALL_CHANNELS.iter().copied() {
|
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(
|
let channel = ParadiseStreamChannel::new(
|
||||||
descriptor,
|
descriptor,
|
||||||
ParadiseStreamChannelConfig::default(),
|
ParadiseStreamChannelConfig::default(),
|
||||||
cover_cache.clone(),
|
cover_cache.clone(),
|
||||||
|
history_opts,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
map.insert(descriptor.id, Arc::new(channel));
|
map.insert(descriptor.id, Arc::new(channel));
|
||||||
@@ -387,7 +684,7 @@ impl ParadiseChannelManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn with_defaults() -> Result<Self> {
|
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>> {
|
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user