Gestion des morts prématurées.
This commit is contained in:
@@ -65,7 +65,7 @@ use std::time::Duration;
|
|||||||
use super::{
|
use super::{
|
||||||
broadcast_pacing::BroadcastPacer,
|
broadcast_pacing::BroadcastPacer,
|
||||||
flac_frame_utils,
|
flac_frame_utils,
|
||||||
timed_broadcast::{self, TimedPacket, TryRecvError},
|
timed_broadcast::{self, SendError, TimedPacket, TryRecvError},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -487,7 +487,9 @@ impl AsyncRead for IcyClientStream {
|
|||||||
} else {
|
} else {
|
||||||
// Header not yet captured - client will receive it via broadcast
|
// Header not yet captured - client will receive it via broadcast
|
||||||
// Skip directly to streaming to avoid blocking
|
// Skip directly to streaming to avoid blocking
|
||||||
debug!("FLAC header not yet available, ICY client will receive it via broadcast");
|
debug!(
|
||||||
|
"FLAC header not yet available, ICY client will receive it via broadcast"
|
||||||
|
);
|
||||||
self.state = FlacStreamState::Streaming;
|
self.state = FlacStreamState::Streaming;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -896,13 +898,17 @@ async fn broadcast_flac_stream(
|
|||||||
let bytes = Bytes::from(std::mem::take(&mut accumulator));
|
let bytes = Bytes::from(std::mem::take(&mut accumulator));
|
||||||
let audio_ts = *current_timestamp.read().await;
|
let audio_ts = *current_timestamp.read().await;
|
||||||
let segment_dur = *current_duration.read().await;
|
let segment_dur = *current_duration.read().await;
|
||||||
if broadcast_tx
|
match broadcast_tx
|
||||||
.send(bytes.clone(), audio_ts, segment_dur)
|
.send(bytes.clone(), audio_ts, segment_dur)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
|
||||||
{
|
{
|
||||||
trace!("Broadcast closed before sending final FLAC data");
|
Ok(_) => {}
|
||||||
break;
|
Err(SendError::Expired(_)) => {
|
||||||
|
trace!("Broadcast expired before sending final FLAC data");
|
||||||
|
}
|
||||||
|
Err(SendError::Closed(_)) => {
|
||||||
|
trace!("Broadcast closed before sending final FLAC data");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
trace!("FLAC encoder stream ended, total bytes: {}", total_bytes);
|
trace!("FLAC encoder stream ended, total bytes: {}", total_bytes);
|
||||||
@@ -1027,11 +1033,23 @@ async fn broadcast_flac_stream(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture first chunk as header if it contains "fLaC"
|
// Detect FLAC header "fLaC" - indicates new track
|
||||||
if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
|
if bytes.len() >= 4 && &bytes[0..4] == b"fLaC" {
|
||||||
|
// New track detected: reset sample counter
|
||||||
|
encoded_samples = 0;
|
||||||
*header_cache.write().await = Some(bytes.clone());
|
*header_cache.write().await = Some(bytes.clone());
|
||||||
header_captured = true;
|
if !header_captured {
|
||||||
trace!("FLAC header captured ({} bytes), will also broadcast it", bytes.len());
|
header_captured = true;
|
||||||
|
trace!(
|
||||||
|
"FLAC header captured ({} bytes), sample counter reset",
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
trace!(
|
||||||
|
"New FLAC header detected ({} bytes), sample counter reset for new track",
|
||||||
|
bytes.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
// Also broadcast the header so early-connecting clients receive it
|
// Also broadcast the header so early-connecting clients receive it
|
||||||
// Later-connecting clients will get it from the cache
|
// Later-connecting clients will get it from the cache
|
||||||
}
|
}
|
||||||
@@ -1052,7 +1070,15 @@ async fn broadcast_flac_stream(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(SendError::Expired(_)) => {
|
||||||
|
trace!(
|
||||||
|
"FLAC broadcast dropped expired packet (ts={:.3}s, dur={:.3}s)",
|
||||||
|
audio_timestamp,
|
||||||
|
segment_duration
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(SendError::Closed(_)) => {
|
||||||
trace!("No active receivers for FLAC broadcast, terminating");
|
trace!("No active receivers for FLAC broadcast, terminating");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ use std::task::{Context, Poll};
|
|||||||
use super::{
|
use super::{
|
||||||
broadcast_pacing::BroadcastPacer,
|
broadcast_pacing::BroadcastPacer,
|
||||||
flac_frame_utils,
|
flac_frame_utils,
|
||||||
timed_broadcast::{self, TimedPacket, TryRecvError},
|
timed_broadcast::{self, SendError, TimedPacket, TryRecvError},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -828,6 +828,10 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
|
|
||||||
// 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)?;
|
||||||
|
let sample_rate_f64 = sample_rate as f64;
|
||||||
|
|
||||||
|
// Sample counter for calculating accurate timestamps (reset on new headers)
|
||||||
|
let mut encoded_samples = 0u64;
|
||||||
trace!("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)
|
||||||
@@ -858,22 +862,28 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast header (BOS and comment are metadata, not audio, so duration=0.0)
|
// Broadcast header (BOS and comment are metadata, not audio, so duration=0.0)
|
||||||
if broadcast_tx
|
match broadcast_tx.send(bos_bytes.clone(), 0.0, 0.0).await {
|
||||||
.send(bos_bytes.clone(), 0.0, 0.0)
|
Ok(_) => {}
|
||||||
.await
|
Err(SendError::Expired(_)) => {
|
||||||
.is_err()
|
trace!("Broadcast closed before sending BOS page (expired)");
|
||||||
{
|
return Ok(());
|
||||||
trace!("No receivers for BOS page, terminating broadcast");
|
}
|
||||||
return Ok(());
|
Err(SendError::Closed(_)) => {
|
||||||
|
trace!("No receivers for BOS page, terminating broadcast");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
total_ogg_bytes += comment_bytes.len() as u64;
|
total_ogg_bytes += comment_bytes.len() as u64;
|
||||||
if broadcast_tx
|
match broadcast_tx.send(comment_bytes.clone(), 0.0, 0.0).await {
|
||||||
.send(comment_bytes.clone(), 0.0, 0.0)
|
Ok(_) => {}
|
||||||
.await
|
Err(SendError::Expired(_)) => {
|
||||||
.is_err()
|
trace!("Broadcast closed before sending comment page (expired)");
|
||||||
{
|
return Ok(());
|
||||||
trace!("No receivers for comment page, terminating broadcast");
|
}
|
||||||
return Ok(());
|
Err(SendError::Closed(_)) => {
|
||||||
|
trace!("No receivers for comment page, terminating broadcast");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Read FLAC stream and create OGG packets
|
// Step 4: Read FLAC stream and create OGG packets
|
||||||
@@ -893,13 +903,14 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
total_ogg_bytes += eos_bytes.len() as u64;
|
total_ogg_bytes += eos_bytes.len() as u64;
|
||||||
let eos_ts = *current_timestamp.read().await;
|
let eos_ts = *current_timestamp.read().await;
|
||||||
let eos_dur = *current_duration.read().await;
|
let eos_dur = *current_duration.read().await;
|
||||||
if broadcast_tx
|
match broadcast_tx.send(eos_bytes.clone(), eos_ts, eos_dur).await {
|
||||||
.send(eos_bytes.clone(), eos_ts, eos_dur)
|
Ok(_) => {}
|
||||||
.await
|
Err(SendError::Expired(_)) => {
|
||||||
.is_err()
|
trace!("Broadcast closed before sending final EOS page (expired)");
|
||||||
{
|
}
|
||||||
trace!("Broadcast closed before sending final EOS page");
|
Err(SendError::Closed(_)) => {
|
||||||
break;
|
trace!("Broadcast closed before sending final EOS page");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
trace!(
|
trace!(
|
||||||
"Sent final EOS page with {} bytes of data",
|
"Sent final EOS page with {} bytes of data",
|
||||||
@@ -911,13 +922,14 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
let eos_bytes = Bytes::from(eos_page);
|
let eos_bytes = Bytes::from(eos_page);
|
||||||
total_ogg_bytes += eos_bytes.len() as u64;
|
total_ogg_bytes += eos_bytes.len() as u64;
|
||||||
let eos_ts = *current_timestamp.read().await;
|
let eos_ts = *current_timestamp.read().await;
|
||||||
if broadcast_tx
|
match broadcast_tx.send(eos_bytes.clone(), eos_ts, 0.0).await {
|
||||||
.send(eos_bytes.clone(), eos_ts, 0.0)
|
Ok(_) => {}
|
||||||
.await
|
Err(SendError::Expired(_)) => {
|
||||||
.is_err()
|
trace!("Broadcast closed before sending empty EOS page (expired)");
|
||||||
{
|
}
|
||||||
trace!("Broadcast closed before sending empty EOS page");
|
Err(SendError::Closed(_)) => {
|
||||||
break;
|
trace!("Broadcast closed before sending empty EOS page");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
trace!("Sent empty EOS page");
|
trace!("Sent empty EOS page");
|
||||||
}
|
}
|
||||||
@@ -1014,7 +1026,22 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
// ║ Cela crée la backpressure vers TimerBufferNode tout en ║
|
// ║ Cela crée la backpressure vers TimerBufferNode tout en ║
|
||||||
// ║ permettant de dropper les chunks vraiment périmés. ║
|
// ║ permettant de dropper les chunks vraiment périmés. ║
|
||||||
// ╚═══════════════════════════════════════════════════════════════╝
|
// ╚═══════════════════════════════════════════════════════════════╝
|
||||||
let audio_timestamp = *current_timestamp.read().await;
|
|
||||||
|
// Detect FLAC header "fLaC" in frame - indicates new track
|
||||||
|
if first_frame.len() >= 4 && &first_frame[0..4] == b"fLaC" {
|
||||||
|
// New track detected: reset sample counter
|
||||||
|
encoded_samples = 0;
|
||||||
|
trace!(
|
||||||
|
"New FLAC header detected in OGG stream ({} bytes), sample counter reset for new track",
|
||||||
|
first_frame.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer le timestamp de cette FLAC frame
|
||||||
|
let frame_start_samples = encoded_samples;
|
||||||
|
encoded_samples = encoded_samples.saturating_add(first_frame_samples as u64);
|
||||||
|
let audio_timestamp = frame_start_samples as f64 / sample_rate_f64;
|
||||||
|
let segment_duration = first_frame_samples as f64 / sample_rate_f64;
|
||||||
|
|
||||||
// Check timing et apply pacing (skip si en retard)
|
// Check timing et apply pacing (skip si en retard)
|
||||||
if pacer.check_and_pace(audio_timestamp).await.is_err() {
|
if pacer.check_and_pace(audio_timestamp).await.is_err() {
|
||||||
@@ -1057,7 +1084,6 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Envoyer au broadcast
|
// Envoyer au broadcast
|
||||||
let segment_duration = *current_duration.read().await;
|
|
||||||
match broadcast_tx
|
match broadcast_tx
|
||||||
.send(ogg_bytes.clone(), audio_timestamp, segment_duration)
|
.send(ogg_bytes.clone(), audio_timestamp, segment_duration)
|
||||||
.await
|
.await
|
||||||
@@ -1065,7 +1091,15 @@ async fn broadcast_ogg_flac_stream(
|
|||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead) to {} receivers (ts={:.3}s, dur={:.3}s)", first_frame.len(), first_frame_samples, ogg_bytes.len(), n, audio_timestamp, segment_duration);
|
trace!("Broadcasted OGG page with 1 FLAC frame ({} bytes), {} samples ({} bytes total with OGG overhead) to {} receivers (ts={:.3}s, dur={:.3}s)", first_frame.len(), first_frame_samples, ogg_bytes.len(), n, audio_timestamp, segment_duration);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(SendError::Expired(_)) => {
|
||||||
|
trace!(
|
||||||
|
"OGG-FLAC broadcast dropped expired page (ts={:.3}s, dur={:.3}s)",
|
||||||
|
audio_timestamp,
|
||||||
|
segment_duration
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(SendError::Closed(_)) => {
|
||||||
trace!("No active receivers for OGG-FLAC broadcast, terminating loop");
|
trace!("No active receivers for OGG-FLAC broadcast, terminating loop");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
use tracing::{trace, info, warn};
|
use tracing::{info, trace, warn};
|
||||||
|
|
||||||
/// Tolérance pour détecter un timestamp à zéro (TopZero).
|
/// Tolérance pour détecter un timestamp à zéro (TopZero).
|
||||||
const TOP_ZERO_EPSILON: f64 = 1e-9;
|
const TOP_ZERO_EPSILON: f64 = 1e-9;
|
||||||
@@ -66,7 +66,11 @@ pub enum RecvError {
|
|||||||
|
|
||||||
/// Erreur remontée par `Sender::send`.
|
/// Erreur remontée par `Sender::send`.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SendError<T>(pub T);
|
/// Erreur de diffusion détaillant la raison pour laquelle un paquet n'a pas été accepté.
|
||||||
|
pub enum SendError<T> {
|
||||||
|
Closed(T),
|
||||||
|
Expired(T),
|
||||||
|
}
|
||||||
|
|
||||||
struct Entry<T> {
|
struct Entry<T> {
|
||||||
seq: u64,
|
seq: u64,
|
||||||
@@ -250,7 +254,12 @@ impl<T> Sender<T> {
|
|||||||
/// Le TTL de chaque paquet est calculé à partir du `epoch_start` courant et du
|
/// Le TTL de chaque paquet est calculé à partir du `epoch_start` courant et du
|
||||||
/// `audio_timestamp` fournis, ce qui signifie qu’un receiver en retard finira
|
/// `audio_timestamp` fournis, ce qui signifie qu’un receiver en retard finira
|
||||||
/// par recevoir un [`TryRecvError::Lagged`] lorsque `expires_at` est dépassé.
|
/// par recevoir un [`TryRecvError::Lagged`] lorsque `expires_at` est dépassé.
|
||||||
pub async fn send(&self, payload: T, audio_timestamp: f64, segment_duration: f64) -> Result<usize, SendError<T>>
|
pub async fn send(
|
||||||
|
&self,
|
||||||
|
payload: T,
|
||||||
|
audio_timestamp: f64,
|
||||||
|
segment_duration: f64,
|
||||||
|
) -> Result<usize, SendError<T>>
|
||||||
where
|
where
|
||||||
T: Clone,
|
T: Clone,
|
||||||
{
|
{
|
||||||
@@ -265,7 +274,9 @@ impl<T> Sender<T> {
|
|||||||
.expect("timed broadcast mutex poisoned");
|
.expect("timed broadcast mutex poisoned");
|
||||||
|
|
||||||
if state.closed {
|
if state.closed {
|
||||||
return Err(SendError(payload.expect("payload already consumed")));
|
return Err(SendError::Closed(
|
||||||
|
payload.expect("payload already consumed"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capturer le temps UNE SEULE FOIS pour cohérence temporelle
|
// Capturer le temps UNE SEULE FOIS pour cohérence temporelle
|
||||||
@@ -286,7 +297,10 @@ impl<T> Sender<T> {
|
|||||||
state.epoch_start = now;
|
state.epoch_start = now;
|
||||||
state.epoch = 0;
|
state.epoch = 0;
|
||||||
state.initialized = true;
|
state.initialized = true;
|
||||||
info!("TimedBroadcast: initialized (epoch=0, ts={:.3}s)", audio_timestamp);
|
info!(
|
||||||
|
"TimedBroadcast: initialized (epoch=0, ts={:.3}s)",
|
||||||
|
audio_timestamp
|
||||||
|
);
|
||||||
} else if is_top_zero {
|
} else if is_top_zero {
|
||||||
// TopZero = nouveau segment, toujours valide après l'initialisation
|
// TopZero = nouveau segment, toujours valide après l'initialisation
|
||||||
// Continuité temporelle : nouveau segment commence après le précédent
|
// Continuité temporelle : nouveau segment commence après le précédent
|
||||||
@@ -310,7 +324,8 @@ impl<T> Sender<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Calculer l'expiration du paquet actuel
|
// 2. Calculer l'expiration du paquet actuel
|
||||||
let expires_at = state.epoch_start + Duration::from_secs_f64(audio_timestamp + segment_duration);
|
let expires_at =
|
||||||
|
state.epoch_start + Duration::from_secs_f64(audio_timestamp + segment_duration);
|
||||||
|
|
||||||
// 3. Rejeter le paquet s'il est déjà expiré
|
// 3. Rejeter le paquet s'il est déjà expiré
|
||||||
// SAUF pour le premier paquet (initialisation) ou les paquets TopZero (nouveaux segments)
|
// SAUF pour le premier paquet (initialisation) ou les paquets TopZero (nouveaux segments)
|
||||||
@@ -325,7 +340,9 @@ impl<T> Sender<T> {
|
|||||||
state.epoch,
|
state.epoch,
|
||||||
now.duration_since(expires_at).as_millis()
|
now.duration_since(expires_at).as_millis()
|
||||||
);
|
);
|
||||||
return Err(SendError(payload.expect("payload already consumed")));
|
return Err(SendError::Expired(
|
||||||
|
payload.expect("payload already consumed"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ impl NodeLogic for HttpSourceLogic {
|
|||||||
timestamp_sec,
|
timestamp_sec,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
send_to_children(std::any::type_name::<Self>(), &output, segment).await?;
|
send_to_children(std::any::type_name::<Self>(), &output, segment).await?;
|
||||||
|
|
||||||
chunk_index += 1;
|
chunk_index += 1;
|
||||||
total_frames += frames_to_emit as u64;
|
total_frames += frames_to_emit as u64;
|
||||||
|
|||||||
@@ -224,12 +224,8 @@ impl NodeLogic for TimerBufferNodeLogic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Propager le marker immédiatement
|
// Propager le marker immédiatement
|
||||||
send_to_children(
|
send_to_children(std::any::type_name::<Self>(), &output, segment.clone())
|
||||||
std::any::type_name::<Self>(),
|
.await?;
|
||||||
&output,
|
|
||||||
segment.clone(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_AudioSegment::Chunk(chunk) => {
|
_AudioSegment::Chunk(chunk) => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
use crate::{nodes::AudioError, AudioSegment};
|
use crate::{nodes::AudioError, AudioSegment};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -311,8 +311,11 @@ pub trait NodeLogic: Send + 'static {
|
|||||||
///
|
///
|
||||||
/// Cette fonction gère la logique de clonage d'`Arc<AudioSegment>` et la
|
/// Cette fonction gère la logique de clonage d'`Arc<AudioSegment>` et la
|
||||||
/// conversion de l'erreur `mpsc::error::SendError` en `AudioError::ChildDied`.
|
/// conversion de l'erreur `mpsc::error::SendError` en `AudioError::ChildDied`.
|
||||||
static FIRST_AUDIO_CHUNK_TRACKER: Lazy<Mutex<HashSet<usize>>> =
|
|
||||||
Lazy::new(|| Mutex::new(HashSet::new()));
|
/// Tracker pour vérifier que les chunks audio après TopZeroSync ont timestamp=0
|
||||||
|
/// HashMap<key, waiting_for_chunk>: true = attente du prochain chunk après TopZeroSync
|
||||||
|
static FIRST_AUDIO_CHUNK_TRACKER: Lazy<Mutex<HashMap<usize, bool>>> =
|
||||||
|
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
const FIRST_CHUNK_EPSILON: f64 = 1e-6;
|
const FIRST_CHUNK_EPSILON: f64 = 1e-6;
|
||||||
|
|
||||||
@@ -321,28 +324,40 @@ fn record_first_audio_chunk_timestamp(
|
|||||||
outputs: &[mpsc::Sender<Arc<AudioSegment>>],
|
outputs: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||||
segment: &Arc<AudioSegment>,
|
segment: &Arc<AudioSegment>,
|
||||||
) {
|
) {
|
||||||
if outputs.is_empty() || !segment.is_audio_chunk() {
|
if outputs.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let key = outputs.as_ptr() as usize;
|
||||||
let mut tracker = FIRST_AUDIO_CHUNK_TRACKER
|
let mut tracker = FIRST_AUDIO_CHUNK_TRACKER
|
||||||
.lock()
|
.lock()
|
||||||
.expect("invariant tracker mutex poisoned");
|
.expect("invariant tracker mutex poisoned");
|
||||||
let key = outputs.as_ptr() as usize;
|
|
||||||
if tracker.contains(&key) {
|
// Détecter TopZeroSync: marquer qu'on attend le prochain chunk audio
|
||||||
|
if segment.is_top_zero_sync() {
|
||||||
|
tracker.insert(key, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if segment.timestamp_sec.abs() > FIRST_CHUNK_EPSILON {
|
// Vérifier les audio chunks
|
||||||
tracing::warn!(
|
if segment.is_audio_chunk() {
|
||||||
"First audio chunk emitted by {node_name} started at {:.6}s (order={}), expected 0s",
|
let waiting = tracker.get(&key).copied();
|
||||||
segment.timestamp_sec,
|
|
||||||
segment.order,
|
|
||||||
node_name = node_name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
tracker.insert(key);
|
// Vérifier ts=0 si c'est le premier chunk absolu (None) ou après TopZeroSync (Some(true))
|
||||||
|
if waiting.is_none() || waiting == Some(true) {
|
||||||
|
if segment.timestamp_sec.abs() > FIRST_CHUNK_EPSILON {
|
||||||
|
tracing::warn!(
|
||||||
|
"First audio chunk emitted by {node_name} {} started at {:.6}s (order={}), expected 0s",
|
||||||
|
if waiting == Some(true) { "after TopZeroSync" } else { "" },
|
||||||
|
segment.timestamp_sec,
|
||||||
|
segment.order,
|
||||||
|
node_name = node_name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Marquer comme "ne plus attendre" pour ce node
|
||||||
|
tracker.insert(key, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_to_children(
|
pub async fn send_to_children(
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// - `Ok(true)` si le fichier est en cache et complet (fichier .complete existe)
|
/// - `Ok(true)` si le fichier est en cache et complet (fichier .complete existe)
|
||||||
/// - `Ok(false)` si le fichier n'est pas en cache ou incomplet (et supprime les fichiers incomplets)
|
/// - `Ok(false)` si le fichier n'est pas en cache ou incomplet (et supprime les fichiers incomplets SI aucun download en cours)
|
||||||
/// - `Err` en cas d'erreur
|
/// - `Err` en cas d'erreur
|
||||||
async fn check_cached_and_complete(&self, pk: &str) -> Result<bool> {
|
async fn check_cached_and_complete(&self, pk: &str) -> Result<bool> {
|
||||||
if self.db.get(pk, false).is_ok() {
|
if self.db.get(pk, false).is_ok() {
|
||||||
@@ -92,14 +92,33 @@ impl<C: CacheConfig> Cache<C> {
|
|||||||
tracing::debug!("File with pk {} is complete (marker exists)", pk);
|
tracing::debug!("File with pk {} is complete (marker exists)", pk);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
} else {
|
} else {
|
||||||
|
// Vérifier si un download est en cours avant de supprimer
|
||||||
|
let is_downloading = {
|
||||||
|
let downloads = self.downloads.read().await;
|
||||||
|
downloads.contains_key(pk)
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_downloading {
|
||||||
|
tracing::debug!(
|
||||||
|
"File with pk {} has no completion marker but download is in progress, waiting",
|
||||||
|
pk
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"File with pk {} in cache has no completion marker, will re-download/re-ingest",
|
"File with pk {} in cache has no completion marker and no download in progress, will re-download/re-ingest",
|
||||||
pk
|
pk
|
||||||
);
|
);
|
||||||
// Supprimer le fichier incomplet ET l'entrée DB
|
// Supprimer le fichier incomplet ET l'entrée DB seulement si pas de download en cours
|
||||||
let _ = std::fs::remove_file(&file_path);
|
let _ = std::fs::remove_file(&file_path);
|
||||||
|
let _ = std::fs::remove_file(&completion_marker); // Nettoyer aussi le marker s'il existe
|
||||||
if let Err(e) = self.db.delete(pk) {
|
if let Err(e) = self.db.delete(pk) {
|
||||||
tracing::warn!("Failed to delete DB entry for incomplete file {}: {}", pk, e);
|
tracing::warn!(
|
||||||
|
"Failed to delete DB entry for incomplete file {}: {}",
|
||||||
|
pk,
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ impl RadioParadisePlaylistFeeder {
|
|||||||
recent.purge(event_id);
|
recent.purge(event_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn retry_block(&self, event_id: EventId) {
|
||||||
|
self.purge_block_state(event_id).await;
|
||||||
|
self.push_block_id(event_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Boucle principale de traitement (à exécuter dans une tâche tokio)
|
/// Boucle principale de traitement (à exécuter dans une tâche tokio)
|
||||||
pub async fn run(self: Arc<Self>) -> Result<()> {
|
pub async fn run(self: Arc<Self>) -> Result<()> {
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ use std::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS},
|
channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS},
|
||||||
client::RadioParadiseClient,
|
client::RadioParadiseClient,
|
||||||
models::Block,
|
models::{Block, EventId},
|
||||||
playlist_feeder::RadioParadisePlaylistFeeder,
|
playlist_feeder::RadioParadisePlaylistFeeder,
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use pmoaudio::AudioPipelineNode;
|
use pmoaudio::{AudioError, AudioPipelineNode};
|
||||||
use pmoaudio_ext::{
|
use pmoaudio_ext::{
|
||||||
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
|
FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle,
|
||||||
PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode,
|
PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode,
|
||||||
@@ -33,7 +33,7 @@ use pmoflac::EncoderOptions;
|
|||||||
use pmoplaylist::PlaylistManager;
|
use pmoplaylist::PlaylistManager;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::io::{AsyncRead, ReadBuf};
|
use tokio::io::{AsyncRead, ReadBuf};
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::{Mutex, Notify};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
@@ -272,18 +272,7 @@ impl ParadiseStreamChannel {
|
|||||||
// 6. Lancer le pipeline audio
|
// 6. Lancer le pipeline audio
|
||||||
let stop_token = CancellationToken::new();
|
let stop_token = CancellationToken::new();
|
||||||
let pipeline_stop = stop_token.clone();
|
let pipeline_stop = stop_token.clone();
|
||||||
let pipeline_handle = tokio::spawn(async move {
|
let channel_display_name = descriptor.display_name;
|
||||||
info!(
|
|
||||||
"RadioParadise stream pipeline started for channel {}",
|
|
||||||
descriptor.display_name
|
|
||||||
);
|
|
||||||
if let Err(e) = Box::new(source).run(pipeline_stop).await {
|
|
||||||
error!(
|
|
||||||
"Pipeline error for channel {}: {}",
|
|
||||||
descriptor.display_name, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let state = Arc::new(ChannelState {
|
let state = Arc::new(ChannelState {
|
||||||
descriptor,
|
descriptor,
|
||||||
@@ -297,6 +286,19 @@ impl ParadiseStreamChannel {
|
|||||||
active_clients: AtomicUsize::new(0),
|
active_clients: AtomicUsize::new(0),
|
||||||
activity_notify: Notify::new(),
|
activity_notify: Notify::new(),
|
||||||
stop_token,
|
stop_token,
|
||||||
|
current_block: Mutex::new(None),
|
||||||
|
});
|
||||||
|
|
||||||
|
let pipeline_state = state.clone();
|
||||||
|
let pipeline_handle = tokio::spawn(async move {
|
||||||
|
info!(
|
||||||
|
"RadioParadise stream pipeline started for channel {}",
|
||||||
|
channel_display_name
|
||||||
|
);
|
||||||
|
if let Err(e) = Box::new(source).run(pipeline_stop).await {
|
||||||
|
error!("Pipeline error for channel {}: {}", channel_display_name, e);
|
||||||
|
pipeline_state.handle_pipeline_error(&e).await;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Lancer le feeder qui traite les blocs
|
// 7. Lancer le feeder qui traite les blocs
|
||||||
@@ -482,6 +484,7 @@ struct ChannelState {
|
|||||||
active_clients: AtomicUsize,
|
active_clients: AtomicUsize,
|
||||||
activity_notify: Notify,
|
activity_notify: Notify,
|
||||||
stop_token: CancellationToken,
|
stop_token: CancellationToken,
|
||||||
|
current_block: Mutex<Option<EventId>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelState {
|
impl ChannelState {
|
||||||
@@ -552,6 +555,30 @@ impl ChannelState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_current_block(&self, event_id: EventId) {
|
||||||
|
let mut guard = self.current_block.lock().await;
|
||||||
|
*guard = Some(event_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn take_current_block(&self) -> Option<EventId> {
|
||||||
|
self.current_block.lock().await.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_pipeline_error(&self, err: &AudioError) {
|
||||||
|
if let Some(event_id) = self.take_current_block().await {
|
||||||
|
warn!(
|
||||||
|
"Pipeline error while streaming block {} on channel {}: {}. Rescheduling block.",
|
||||||
|
event_id, self.descriptor.display_name, err
|
||||||
|
);
|
||||||
|
self.feeder.retry_block(event_id).await;
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"Pipeline error for channel {} but no tracked block: {}",
|
||||||
|
self.descriptor.display_name, err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_scheduler(self: Arc<Self>) {
|
async fn run_scheduler(self: Arc<Self>) {
|
||||||
let mut backoff = Duration::from_secs(5);
|
let mut backoff = Duration::from_secs(5);
|
||||||
'scheduler: loop {
|
'scheduler: loop {
|
||||||
@@ -574,6 +601,7 @@ impl ChannelState {
|
|||||||
"Channel {} streaming block {}",
|
"Channel {} streaming block {}",
|
||||||
self.descriptor.display_name, block.event
|
self.descriptor.display_name, block.event
|
||||||
);
|
);
|
||||||
|
self.set_current_block(block.event).await;
|
||||||
self.feeder.push_block_id(block.event).await;
|
self.feeder.push_block_id(block.event).await;
|
||||||
let mut next_event = block.end_event;
|
let mut next_event = block.end_event;
|
||||||
|
|
||||||
@@ -593,6 +621,7 @@ impl ChannelState {
|
|||||||
BlockReadiness::NoClients => break,
|
BlockReadiness::NoClients => break,
|
||||||
BlockReadiness::Stopped => break 'scheduler,
|
BlockReadiness::Stopped => break 'scheduler,
|
||||||
}
|
}
|
||||||
|
self.set_current_block(next_block.event).await;
|
||||||
self.feeder.push_block_id(next_block.event).await;
|
self.feeder.push_block_id(next_block.event).await;
|
||||||
next_event = next_block.end_event;
|
next_event = next_block.end_event;
|
||||||
backoff = Duration::from_secs(5);
|
backoff = Duration::from_secs(5);
|
||||||
|
|||||||
@@ -256,26 +256,18 @@ impl PersistenceManager {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
// Activer les contraintes de clés étrangères (désactivées par défaut dans SQLite)
|
// Activer les contraintes de clés étrangères (désactivées par défaut dans SQLite)
|
||||||
conn.execute("PRAGMA foreign_keys = ON", [])
|
conn.execute("PRAGMA foreign_keys = ON", []).map_err(|e| {
|
||||||
.map_err(|e| {
|
crate::Error::PersistenceError(format!("Failed to enable foreign keys: {}", e))
|
||||||
crate::Error::PersistenceError(format!("Failed to enable foreign keys: {}", e))
|
})?;
|
||||||
})?;
|
|
||||||
|
|
||||||
// Vérifier l'intégrité des clés étrangères
|
// Vérifier l'intégrité des clés étrangères
|
||||||
let mut stmt = conn
|
let mut stmt = conn.prepare("PRAGMA foreign_key_check").map_err(|e| {
|
||||||
.prepare("PRAGMA foreign_key_check")
|
crate::Error::PersistenceError(format!("Failed to prepare FK check: {}", e))
|
||||||
.map_err(|e| {
|
})?;
|
||||||
crate::Error::PersistenceError(format!("Failed to prepare FK check: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let violations: Vec<(String, i64, String, i64)> = stmt
|
let violations: Vec<(String, i64, String, i64)> = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
Ok((
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||||
row.get(0)?,
|
|
||||||
row.get(1)?,
|
|
||||||
row.get(2)?,
|
|
||||||
row.get(3)?,
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
crate::Error::PersistenceError(format!("Failed to check foreign keys: {}", e))
|
crate::Error::PersistenceError(format!("Failed to check foreign keys: {}", e))
|
||||||
@@ -298,7 +290,10 @@ impl PersistenceManager {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
crate::Error::PersistenceError(format!("Failed to delete orphaned tracks: {}", e))
|
crate::Error::PersistenceError(format!(
|
||||||
|
"Failed to delete orphaned tracks: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if deleted > 0 {
|
if deleted > 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user