deps: update esbuild to 0.27.4, rollup to 4.60.0, vite to 7.3.1 - bump esbuild and all platform-specific binaries from 0.25.10 to 0.27.4 - bump rollup and all platform-specific binaries from 4.52.3 to 4.60.0 - bump vite from 7.1.7 to 7.3.1 (esbuild peer dep updated to ^0.27.0) - bump dompurify from 3.2.7 to 3.3.3 web: improve cover image retry logic - increase maxRetries default from 3 to 5 - reduce initial retryDelay from 1000ms to 500ms - implement exponential backoff: delay = retryDelay * 2^(retryCount - 1) - add detailed logging for retries and final failure rust: minor cleanup - remove unused imports (watch, Url, AudioSegment, SyncMarker) - add tracing debug logs for cache file requests - handle missing files gracefully with warning + client retry hint - add #[allow(async_fn_in_trait)] where needed
212 lines
8.3 KiB
Rust
212 lines
8.3 KiB
Rust
//! Pipeline audio serveur par instance WebRenderer
|
|
//!
|
|
//! Chaque instance WebRenderer possède un pipeline indépendant :
|
|
//! - Une `PlayerSource` qui gère le cycle de vie AVTransport (Play/Pause/Stop/Seek/LoadUri)
|
|
//! - Un `StreamingOggFlacSink` qui encode et diffuse le flux OGG-FLAC aux clients HTTP
|
|
//! - Des nœuds de normalisation (resampling → 96 kHz, conversion → I24)
|
|
|
|
use std::sync::Arc;
|
|
|
|
use pmoaudio::{ResamplingNode, ToI24Node};
|
|
use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource};
|
|
use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink};
|
|
use pmoflac::EncoderOptions;
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::{debug, warn};
|
|
|
|
use crate::state::SharedState;
|
|
|
|
// ─── Ré-export des commandes pour les handlers ────────────────────────────────
|
|
|
|
/// Commandes de transport — alias vers PlayerCommand pour compatibilité handlers
|
|
pub use pmoaudio_ext::PlayerCommand as PipelineControl;
|
|
|
|
// ─── Handle vers le pipeline ─────────────────────────────────────────────────
|
|
|
|
/// Handle partageable vers le pipeline audio d'une instance.
|
|
///
|
|
/// Expose le `PlayerHandle` pour les commandes AVTransport et le `CancellationToken`
|
|
/// pour l'arrêt complet du pipeline.
|
|
#[derive(Clone)]
|
|
pub struct PipelineHandle {
|
|
pub player: PlayerHandle,
|
|
pub stop_token: CancellationToken,
|
|
/// Volume courant (géré localement, pas dans PlayerSource)
|
|
state: SharedState,
|
|
}
|
|
|
|
impl PipelineHandle {
|
|
/// Envoie une commande de transport. Gère SetVolume/SetMute localement.
|
|
pub async fn send(&self, cmd: PipelineControl) {
|
|
match cmd {
|
|
PlayerCommand::LoadUri(uri) => self.player.load_uri(uri).await,
|
|
PlayerCommand::LoadNextUri(uri) => self.player.load_next_uri(uri).await,
|
|
PlayerCommand::Play => self.player.play().await,
|
|
PlayerCommand::Pause => self.player.pause().await,
|
|
PlayerCommand::Stop => self.player.stop().await,
|
|
PlayerCommand::Seek(pos) => self.player.seek(pos).await,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Pipeline instancié ──────────────────────────────────────────────────────
|
|
|
|
/// Pipeline audio complet pour une instance WebRenderer.
|
|
///
|
|
/// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle`
|
|
/// (multi-client broadcast, chaque `subscribe()` crée un flux indépendant).
|
|
pub struct InstancePipeline {
|
|
/// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client.
|
|
pub flac_handle: OggFlacStreamHandle,
|
|
pub pipeline_handle: PipelineHandle,
|
|
}
|
|
|
|
impl InstancePipeline {
|
|
/// Crée et démarre le pipeline en background.
|
|
/// Retourne immédiatement avec les handles nécessaires.
|
|
pub fn start(
|
|
state: SharedState,
|
|
#[cfg(feature = "pmoserver")]
|
|
control_point: Arc<pmocontrol::ControlPoint>,
|
|
udn: String,
|
|
) -> Self {
|
|
let stop_token = CancellationToken::new();
|
|
|
|
use pmoaudio::pipeline::AudioPipelineNode;
|
|
|
|
// Sink broadcast OGG-FLAC (multi-client, pacé à 0.5s max d'avance)
|
|
let (sink, flac_handle) = StreamingOggFlacSink::new(EncoderOptions::default(), 24);
|
|
|
|
// Nœud de conversion de profondeur : tout type entier → I24
|
|
let mut to_i24 = ToI24Node::new();
|
|
to_i24.register(sink.boxed());
|
|
|
|
// Nœud de rééchantillonnage : n'importe quel sample rate → 96 kHz
|
|
let mut resampler = ResamplingNode::new(96_000);
|
|
resampler.register(to_i24.boxed());
|
|
|
|
// Source avec contrôle AVTransport complet
|
|
let (mut player_source, player_handle) = PlayerSource::new();
|
|
player_source.register(resampler.boxed());
|
|
|
|
// Lancer le pipeline en background
|
|
let sink_stop = stop_token.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = player_source.boxed().run(sink_stop).await {
|
|
warn!("Audio pipeline error: {:?}", e);
|
|
}
|
|
debug!("Pipeline task terminated");
|
|
});
|
|
|
|
// Écouter les événements PlayerSource pour mettre à jour le state UPnP
|
|
let event_rx = player_handle.subscribe_events();
|
|
let state_clone = state.clone();
|
|
let udn_clone = udn.clone();
|
|
#[cfg(feature = "pmoserver")]
|
|
let cp_clone = control_point.clone();
|
|
tokio::spawn(async move {
|
|
run_event_listener(
|
|
event_rx,
|
|
state_clone,
|
|
udn_clone,
|
|
#[cfg(feature = "pmoserver")]
|
|
cp_clone,
|
|
).await;
|
|
});
|
|
|
|
let pipeline_handle = PipelineHandle {
|
|
player: player_handle,
|
|
stop_token: stop_token.clone(),
|
|
state,
|
|
};
|
|
|
|
Self {
|
|
flac_handle,
|
|
pipeline_handle,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Listener d'événements ────────────────────────────────────────────────────
|
|
|
|
async fn run_event_listener(
|
|
mut event_rx: tokio::sync::broadcast::Receiver<pmoaudio_ext::PlayerEvent>,
|
|
state: SharedState,
|
|
udn: String,
|
|
#[cfg(feature = "pmoserver")]
|
|
control_point: Arc<pmocontrol::ControlPoint>,
|
|
) {
|
|
use pmoaudio_ext::PlayerEvent;
|
|
use crate::messages::PlaybackState;
|
|
|
|
loop {
|
|
match event_rx.recv().await {
|
|
Ok(event) => match event {
|
|
PlayerEvent::Playing { uri, duration_sec } => {
|
|
let mut s = state.write();
|
|
s.playback_state = PlaybackState::Playing;
|
|
s.current_uri = Some(uri);
|
|
s.duration = duration_sec.map(seconds_to_upnp_time);
|
|
s.position = None;
|
|
// Effacer next_uri/next_metadata : la nouvelle piste est maintenant courante
|
|
s.next_uri = None;
|
|
s.next_metadata = None;
|
|
}
|
|
PlayerEvent::Paused { position_sec } => {
|
|
let mut s = state.write();
|
|
s.playback_state = PlaybackState::Paused;
|
|
s.position = Some(seconds_to_upnp_time(position_sec));
|
|
}
|
|
PlayerEvent::Stopped => {
|
|
let mut s = state.write();
|
|
s.playback_state = PlaybackState::Stopped;
|
|
s.position = None;
|
|
}
|
|
PlayerEvent::Position { position_sec } => {
|
|
state.write().position = Some(seconds_to_upnp_time(position_sec));
|
|
}
|
|
PlayerEvent::TrackEnded => {
|
|
#[cfg(feature = "pmoserver")]
|
|
{
|
|
let cp = control_point.clone();
|
|
let udn_c = udn.clone();
|
|
tokio::spawn(async move {
|
|
cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_c));
|
|
});
|
|
}
|
|
}
|
|
PlayerEvent::Error(e) => {
|
|
tracing::warn!(udn = %udn, "PlayerSource error: {}", e);
|
|
state.write().playback_state = PlaybackState::Stopped;
|
|
}
|
|
},
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
tracing::warn!(udn = %udn, "Event listener lagged {} events", n);
|
|
}
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
pub fn seconds_to_upnp_time(s: f64) -> String {
|
|
let s = s as u64;
|
|
let h = s / 3600;
|
|
let m = (s % 3600) / 60;
|
|
let sec = s % 60;
|
|
format!("{}:{:02}:{:02}", h, m, sec)
|
|
}
|
|
|
|
pub fn upnp_time_to_seconds(t: &str) -> f64 {
|
|
let parts: Vec<f64> = t.split(':').filter_map(|p| p.parse().ok()).collect();
|
|
match parts.as_slice() {
|
|
[h, m, s] => h * 3600.0 + m * 60.0 + s,
|
|
[m, s] => m * 60.0 + s,
|
|
[s] => *s,
|
|
_ => 0.0,
|
|
}
|
|
}
|