diff --git a/pmoaudio-ext/src/sinks/streaming_flac_sink.rs b/pmoaudio-ext/src/sinks/streaming_flac_sink.rs index eff97442..f79e277e 100644 --- a/pmoaudio-ext/src/sinks/streaming_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/streaming_flac_sink.rs @@ -65,7 +65,7 @@ use std::time::Duration; use super::{ broadcast_pacing::BroadcastPacer, flac_frame_utils, - timed_broadcast::{self, TimedPacket, TryRecvError}, + timed_broadcast::{self, SendError, TimedPacket, TryRecvError}, }; use async_trait::async_trait; use bytes::Bytes; @@ -487,7 +487,9 @@ impl AsyncRead for IcyClientStream { } else { // Header not yet captured - client will receive it via broadcast // 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; } } @@ -896,13 +898,17 @@ async fn broadcast_flac_stream( let bytes = Bytes::from(std::mem::take(&mut accumulator)); let audio_ts = *current_timestamp.read().await; let segment_dur = *current_duration.read().await; - if broadcast_tx + match broadcast_tx .send(bytes.clone(), audio_ts, segment_dur) .await - .is_err() { - trace!("Broadcast closed before sending final FLAC data"); - break; + Ok(_) => {} + 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); @@ -1027,11 +1033,23 @@ async fn broadcast_flac_stream( ); } - // Capture first chunk as header if it contains "fLaC" - if !header_captured && bytes.len() >= 4 && &bytes[0..4] == b"fLaC" { + // Detect FLAC header "fLaC" - indicates new track + 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_captured = true; - trace!("FLAC header captured ({} bytes), will also broadcast it", bytes.len()); + if !header_captured { + 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 // 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"); return Ok(()); } diff --git a/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs b/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs index d8c2173c..c3209ec4 100644 --- a/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/streaming_ogg_flac_sink.rs @@ -56,7 +56,7 @@ use std::task::{Context, Poll}; use super::{ broadcast_pacing::BroadcastPacer, flac_frame_utils, - timed_broadcast::{self, TimedPacket, TryRecvError}, + timed_broadcast::{self, SendError, TimedPacket, TryRecvError}, }; use async_trait::async_trait; use bytes::Bytes; @@ -828,6 +828,10 @@ async fn broadcast_ogg_flac_stream( // Extract sample rate from STREAMINFO for granule position calculation 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); // 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) - if broadcast_tx - .send(bos_bytes.clone(), 0.0, 0.0) - .await - .is_err() - { - trace!("No receivers for BOS page, terminating broadcast"); - return Ok(()); + match broadcast_tx.send(bos_bytes.clone(), 0.0, 0.0).await { + Ok(_) => {} + Err(SendError::Expired(_)) => { + trace!("Broadcast closed before sending BOS page (expired)"); + return Ok(()); + } + Err(SendError::Closed(_)) => { + trace!("No receivers for BOS page, terminating broadcast"); + return Ok(()); + } } total_ogg_bytes += comment_bytes.len() as u64; - if broadcast_tx - .send(comment_bytes.clone(), 0.0, 0.0) - .await - .is_err() - { - trace!("No receivers for comment page, terminating broadcast"); - return Ok(()); + match broadcast_tx.send(comment_bytes.clone(), 0.0, 0.0).await { + Ok(_) => {} + Err(SendError::Expired(_)) => { + trace!("Broadcast closed before sending comment page (expired)"); + return Ok(()); + } + Err(SendError::Closed(_)) => { + trace!("No receivers for comment page, terminating broadcast"); + return Ok(()); + } } // 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; let eos_ts = *current_timestamp.read().await; let eos_dur = *current_duration.read().await; - if broadcast_tx - .send(eos_bytes.clone(), eos_ts, eos_dur) - .await - .is_err() - { - trace!("Broadcast closed before sending final EOS page"); - break; + match broadcast_tx.send(eos_bytes.clone(), eos_ts, eos_dur).await { + Ok(_) => {} + Err(SendError::Expired(_)) => { + trace!("Broadcast closed before sending final EOS page (expired)"); + } + Err(SendError::Closed(_)) => { + trace!("Broadcast closed before sending final EOS page"); + } } trace!( "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); total_ogg_bytes += eos_bytes.len() as u64; let eos_ts = *current_timestamp.read().await; - if broadcast_tx - .send(eos_bytes.clone(), eos_ts, 0.0) - .await - .is_err() - { - trace!("Broadcast closed before sending empty EOS page"); - break; + match broadcast_tx.send(eos_bytes.clone(), eos_ts, 0.0).await { + Ok(_) => {} + Err(SendError::Expired(_)) => { + trace!("Broadcast closed before sending empty EOS page (expired)"); + } + Err(SendError::Closed(_)) => { + trace!("Broadcast closed before sending 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 ║ // ║ 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) if pacer.check_and_pace(audio_timestamp).await.is_err() { @@ -1057,7 +1084,6 @@ async fn broadcast_ogg_flac_stream( } // Envoyer au broadcast - let segment_duration = *current_duration.read().await; match broadcast_tx .send(ogg_bytes.clone(), audio_timestamp, segment_duration) .await @@ -1065,7 +1091,15 @@ async fn broadcast_ogg_flac_stream( 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); } - 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"); return Ok(()); } diff --git a/pmoaudio-ext/src/sinks/timed_broadcast.rs b/pmoaudio-ext/src/sinks/timed_broadcast.rs index 238875e8..5f30b0f0 100644 --- a/pmoaudio-ext/src/sinks/timed_broadcast.rs +++ b/pmoaudio-ext/src/sinks/timed_broadcast.rs @@ -15,7 +15,7 @@ use std::{ }; use tokio::sync::Notify; -use tracing::{trace, info, warn}; +use tracing::{info, trace, warn}; /// Tolérance pour détecter un timestamp à zéro (TopZero). const TOP_ZERO_EPSILON: f64 = 1e-9; @@ -66,7 +66,11 @@ pub enum RecvError { /// Erreur remontée par `Sender::send`. #[derive(Debug)] -pub struct SendError(pub T); +/// Erreur de diffusion détaillant la raison pour laquelle un paquet n'a pas été accepté. +pub enum SendError { + Closed(T), + Expired(T), +} struct Entry { seq: u64, @@ -250,7 +254,12 @@ impl Sender { /// 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 /// 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> + pub async fn send( + &self, + payload: T, + audio_timestamp: f64, + segment_duration: f64, + ) -> Result> where T: Clone, { @@ -265,7 +274,9 @@ impl Sender { .expect("timed broadcast mutex poisoned"); 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 @@ -286,7 +297,10 @@ impl Sender { state.epoch_start = now; state.epoch = 0; 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 { // TopZero = nouveau segment, toujours valide après l'initialisation // Continuité temporelle : nouveau segment commence après le précédent @@ -310,7 +324,8 @@ impl Sender { } // 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é // SAUF pour le premier paquet (initialisation) ou les paquets TopZero (nouveaux segments) @@ -325,7 +340,9 @@ impl Sender { state.epoch, now.duration_since(expires_at).as_millis() ); - return Err(SendError(payload.expect("payload already consumed"))); + return Err(SendError::Expired( + payload.expect("payload already consumed"), + )); } } diff --git a/pmoaudio/src/nodes/http_source.rs b/pmoaudio/src/nodes/http_source.rs index ae703caf..b6718336 100755 --- a/pmoaudio/src/nodes/http_source.rs +++ b/pmoaudio/src/nodes/http_source.rs @@ -237,7 +237,7 @@ impl NodeLogic for HttpSourceLogic { timestamp_sec, )?; - send_to_children(std::any::type_name::(), &output, segment).await?; + send_to_children(std::any::type_name::(), &output, segment).await?; chunk_index += 1; total_frames += frames_to_emit as u64; diff --git a/pmoaudio/src/nodes/timer_buffer_node.rs b/pmoaudio/src/nodes/timer_buffer_node.rs index 69379441..f35b4c1b 100644 --- a/pmoaudio/src/nodes/timer_buffer_node.rs +++ b/pmoaudio/src/nodes/timer_buffer_node.rs @@ -224,12 +224,8 @@ impl NodeLogic for TimerBufferNodeLogic { } // Propager le marker immédiatement - send_to_children( - std::any::type_name::(), - &output, - segment.clone(), - ) - .await?; + send_to_children(std::any::type_name::(), &output, segment.clone()) + .await?; } _AudioSegment::Chunk(chunk) => { diff --git a/pmoaudio/src/pipeline.rs b/pmoaudio/src/pipeline.rs index e5f539f3..eac59ebf 100755 --- a/pmoaudio/src/pipeline.rs +++ b/pmoaudio/src/pipeline.rs @@ -42,7 +42,7 @@ use crate::{nodes::AudioError, AudioSegment}; use once_cell::sync::Lazy; -use std::collections::HashSet; +use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio::sync::mpsc; @@ -311,8 +311,11 @@ pub trait NodeLogic: Send + 'static { /// /// Cette fonction gère la logique de clonage d'`Arc` et la /// conversion de l'erreur `mpsc::error::SendError` en `AudioError::ChildDied`. -static FIRST_AUDIO_CHUNK_TRACKER: Lazy>> = - Lazy::new(|| Mutex::new(HashSet::new())); + +/// Tracker pour vérifier que les chunks audio après TopZeroSync ont timestamp=0 +/// HashMap: true = attente du prochain chunk après TopZeroSync +static FIRST_AUDIO_CHUNK_TRACKER: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); const FIRST_CHUNK_EPSILON: f64 = 1e-6; @@ -321,28 +324,40 @@ fn record_first_audio_chunk_timestamp( outputs: &[mpsc::Sender>], segment: &Arc, ) { - if outputs.is_empty() || !segment.is_audio_chunk() { + if outputs.is_empty() { return; } + let key = outputs.as_ptr() as usize; let mut tracker = FIRST_AUDIO_CHUNK_TRACKER .lock() .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; } - if segment.timestamp_sec.abs() > FIRST_CHUNK_EPSILON { - tracing::warn!( - "First audio chunk emitted by {node_name} started at {:.6}s (order={}), expected 0s", - segment.timestamp_sec, - segment.order, - node_name = node_name, - ); - } + // Vérifier les audio chunks + if segment.is_audio_chunk() { + let waiting = tracker.get(&key).copied(); - 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( diff --git a/pmocache/src/cache.rs b/pmocache/src/cache.rs index 6ddc6b44..e3429c44 100755 --- a/pmocache/src/cache.rs +++ b/pmocache/src/cache.rs @@ -79,7 +79,7 @@ impl Cache { /// # Returns /// /// - `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 async fn check_cached_and_complete(&self, pk: &str) -> Result { if self.db.get(pk, false).is_ok() { @@ -92,14 +92,33 @@ impl Cache { tracing::debug!("File with pk {} is complete (marker exists)", pk); return Ok(true); } 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!( - "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 ); - // 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(&completion_marker); // Nettoyer aussi le marker s'il existe 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); } diff --git a/pmoparadise/src/playlist_feeder.rs b/pmoparadise/src/playlist_feeder.rs index 6b576d0e..4594743c 100644 --- a/pmoparadise/src/playlist_feeder.rs +++ b/pmoparadise/src/playlist_feeder.rs @@ -169,6 +169,11 @@ impl RadioParadisePlaylistFeeder { 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) pub async fn run(self: Arc) -> Result<()> { loop { diff --git a/pmoparadise/src/stream_channel.rs b/pmoparadise/src/stream_channel.rs index 9fe9a490..740be535 100644 --- a/pmoparadise/src/stream_channel.rs +++ b/pmoparadise/src/stream_channel.rs @@ -18,11 +18,11 @@ use std::{ use crate::{ channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, client::RadioParadiseClient, - models::Block, + models::{Block, EventId}, playlist_feeder::RadioParadisePlaylistFeeder, }; use anyhow::{anyhow, Result}; -use pmoaudio::AudioPipelineNode; +use pmoaudio::{AudioError, AudioPipelineNode}; use pmoaudio_ext::{ FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode, @@ -33,7 +33,7 @@ use pmoflac::EncoderOptions; use pmoplaylist::PlaylistManager; use thiserror::Error; use tokio::io::{AsyncRead, ReadBuf}; -use tokio::sync::Notify; +use tokio::sync::{Mutex, Notify}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; @@ -272,18 +272,7 @@ impl ParadiseStreamChannel { // 6. Lancer le pipeline audio let stop_token = CancellationToken::new(); let pipeline_stop = stop_token.clone(); - let pipeline_handle = tokio::spawn(async move { - 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 channel_display_name = descriptor.display_name; let state = Arc::new(ChannelState { descriptor, @@ -297,6 +286,19 @@ impl ParadiseStreamChannel { active_clients: AtomicUsize::new(0), activity_notify: Notify::new(), 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 @@ -482,6 +484,7 @@ struct ChannelState { active_clients: AtomicUsize, activity_notify: Notify, stop_token: CancellationToken, + current_block: Mutex>, } 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 { + 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) { let mut backoff = Duration::from_secs(5); 'scheduler: loop { @@ -574,6 +601,7 @@ impl ChannelState { "Channel {} streaming block {}", self.descriptor.display_name, block.event ); + self.set_current_block(block.event).await; self.feeder.push_block_id(block.event).await; let mut next_event = block.end_event; @@ -593,6 +621,7 @@ impl ChannelState { BlockReadiness::NoClients => break, BlockReadiness::Stopped => break 'scheduler, } + self.set_current_block(next_block.event).await; self.feeder.push_block_id(next_block.event).await; next_event = next_block.end_event; backoff = Duration::from_secs(5); diff --git a/pmoplaylist/src/persistence/mod.rs b/pmoplaylist/src/persistence/mod.rs index a0f95624..3d9c0891 100644 --- a/pmoplaylist/src/persistence/mod.rs +++ b/pmoplaylist/src/persistence/mod.rs @@ -256,26 +256,18 @@ impl PersistenceManager { let conn = self.conn.lock().unwrap(); // Activer les contraintes de clés étrangères (désactivées par défaut dans SQLite) - conn.execute("PRAGMA foreign_keys = ON", []) - .map_err(|e| { - crate::Error::PersistenceError(format!("Failed to enable foreign keys: {}", e)) - })?; + conn.execute("PRAGMA foreign_keys = ON", []).map_err(|e| { + crate::Error::PersistenceError(format!("Failed to enable foreign keys: {}", e)) + })?; // Vérifier l'intégrité des clés étrangères - let mut stmt = conn - .prepare("PRAGMA foreign_key_check") - .map_err(|e| { - crate::Error::PersistenceError(format!("Failed to prepare FK check: {}", e)) - })?; + let mut stmt = conn.prepare("PRAGMA foreign_key_check").map_err(|e| { + crate::Error::PersistenceError(format!("Failed to prepare FK check: {}", e)) + })?; let violations: Vec<(String, i64, String, i64)> = stmt .query_map([], |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - )) + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) }) .map_err(|e| { crate::Error::PersistenceError(format!("Failed to check foreign keys: {}", e)) @@ -298,7 +290,10 @@ impl PersistenceManager { [], ) .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 {