From eedb0b6a0ed3842da566079e2fae2b6f8d2e6a05 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 28 Feb 2026 10:47:58 +0100 Subject: [PATCH 01/18] Handle continuous stream stoppage correctly When a continuous stream (like radio) stops naturally, do not auto-advance to the next track. Instead, clear the playback source and played flag to ensure proper state management. --- pmocontrol/src/music_renderer/musicrenderer.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 4de6e33b..efc929ed 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -639,6 +639,14 @@ impl MusicRenderer { ); self.set_playback_source(PlaybackSource::None); self.clear_has_played_flag(); + } else if self.is_playing_a_stream() { + // Continuous stream (radio) stopped naturally — never auto-advance + debug!( + renderer = self.info.friendly_name(), + "Renderer stopped during continuous stream; not auto-advancing" + ); + self.set_playback_source(PlaybackSource::None); + self.clear_has_played_flag(); } else if self.is_playing_from_queue() { // Only auto-advance if we have actually seen a PLAYING state // since the track was started. This prevents auto-advance on From f9056f92e01cf75c4efb01dc3168f7dd7141db65 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 28 Feb 2026 12:15:02 +0100 Subject: [PATCH 02/18] =?UTF-8?q?Impl=C3=A9mentation=20du=20OGG=20chaining?= =?UTF-8?q?=20pour=20les=20flux=20audio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cette modification introduit le support du OGG chaining dans le flux audio, permettant une transition transparente entre les pistes sans interruption. Cela inclut la gestion des encodeurs successifs dans le même canal Bytes, l'annulation des encodeurs précédents lors des transitions, et le maintien d'une connexion persistante avec backpressure TCP. Les modifications affectent les composants de sink et de pipeline audio, ainsi que les endpoints d'écoute. --- .../src/sinks/direct_ogg_flac_sink.rs | 242 +++++++++++++----- pmowebrenderer/src/pipeline.rs | 15 +- pmowebrenderer/src/registry.rs | 8 +- pmowebrenderer/src/stream.rs | 2 +- 4 files changed, 191 insertions(+), 76 deletions(-) diff --git a/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs b/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs index 4f94e248..343567f4 100644 --- a/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs @@ -5,15 +5,17 @@ //! //! # Cycle de vie //! -//! - **Play** : le navigateur appelle `GET /stream`. `connect()` crée un nouveau -//! canal PCM + pipe duplex + encodeur FLAC + wrapper OGG, installe le sender -//! dans le sink, et notifie le sink via `client_notify`. Le flux reste ouvert : -//! les morceaux s'enchaînent en gapless. -//! - **Stop** : le navigateur ferme la connexion. Le pipe se rompt, l'encodeur -//! s'arrête. Le sink voit `pcm_tx.send()` échouer, passe le sender à `None`, -//! et **bloque** sur `client_notify` jusqu'au prochain Play. -//! - **Play suivant** : `connect()` → nouveau pipe → `client_notify.notify_one()` -//! → le sink se débloque et reprend la consommation des segments. +//! - **Play** : le navigateur appelle `GET /stream`. `connect()` crée un channel +//! Bytes (bytes_tx/rx), une task de forwarding qui copie les Bytes dans un +//! DuplexStream, et lance le premier encodeur OGG-FLAC. Le `bytes_tx` est +//! stocké dans le sink pour le chaining TrackBoundary. +//! - **Stop** : le navigateur ferme la connexion. Le DuplexStream se rompt, +//! la task de forwarding se termine, le bytes_tx devient invalide. Le sink +//! voit `pcm_tx.send()` échouer et bloque sur `client_notify`. +//! - **TrackBoundary** : le sink ferme le `pcm_tx` courant (EOF → encodeur écrit +//! EOS OGG), attend la fin de l'encodeur, puis relance un nouvel encodeur +//! dans le même `bytes_tx` (OGG chaining : nouvelle BOS OGG dans le même flux HTTP). +//! - **Play suivant** : `connect()` → nouveau DuplexStream + channel → nouveau pipe. //! //! # Architecture //! @@ -21,11 +23,10 @@ //! AudioSegment I24 @ 96 kHz //! ↓ NodeLogic::process() [bloque si pas de client] //! chunk_to_pcm_bytes() → PCM 24-bit LE -//! ↓ Arc>>> -//! ByteStreamReader (AsyncRead) -//! ↓ encode_flac_stream() -//! ↓ broadcast_ogg_flac_stream() → wrapping OGG pages -//! ↓ tokio::io::duplex pipe (256 KB) +//! ↓ SharedPcmTx +//! ByteStreamReader → encode_flac_stream() → OGG pages +//! ↓ SharedBytesTx (mpsc::Sender) ← persistant entre encodeurs +//! [forwarding task] → tokio::io::DuplexStream //! ↓ DirectOggFlacStream (AsyncRead) → Body HTTP //! ``` @@ -43,6 +44,7 @@ use pmoaudio::{ use pmoflac::{EncoderOptions, PcmFormat}; use tokio::io::{AsyncRead, ReadBuf}; use tokio::sync::{mpsc, watch, Mutex}; +use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{debug, warn}; @@ -57,10 +59,17 @@ pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24; /// Capacité du pipe duplex (~256 KB). const PIPE_CAPACITY: usize = 256 * 1024; +/// Capacité du channel Bytes intermédiaire. +const BYTES_CHANNEL_CAPACITY: usize = 64; // ─── Shared state ───────────────────────────────────────────────────────────── type SharedPcmTx = Arc>>>; +/// Canal Bytes persistant entre les encodeurs successifs (OGG chaining). +/// Le sink y envoie les pages OGG ; une task de forwarding les copie dans le DuplexStream. +type SharedBytesTx = Arc>>>; +/// Handle de la task encodeur courante. +type SharedEncoderTask = Arc>>>; // ─── Handle public ──────────────────────────────────────────────────────────── @@ -72,48 +81,76 @@ pub struct DirectOggFlacHandle { client_notify_internal: Arc, first_byte_tx: Arc>, encoder_options: EncoderOptions, - /// Position de lecture courante (mise à jour par ByteStreamReader). current_timestamp: Arc>, + /// Canal Bytes persistant partagé avec la logic du sink pour le OGG chaining. + bytes_tx: SharedBytesTx, + /// Task encodeur courante partagée avec la logic du sink. + encoder_task: SharedEncoderTask, } impl DirectOggFlacHandle { - /// Crée un nouveau pipe OGG-FLAC et retourne le flux côté lecture. - /// Débloque le sink s'il attendait un client. + /// Crée un nouveau flux OGG-FLAC pour le client HTTP. + /// Remplace toute connexion précédente. pub async fn connect(&self) -> DirectOggFlacStream { let connect_count_before = *self.client_connect_tx.borrow(); debug!("DirectOggFlacHandle::connect() called, connect_count={}", connect_count_before); - let (pcm_tx, pcm_rx) = mpsc::channel::(8); - // Réinitialiser le timestamp à 0 pour la nouvelle connexion + // Annuler l'encodeur précédent s'il tourne encore + if let Some(old_task) = self.encoder_task.lock().await.take() { + old_task.abort(); + } + + // Créer le channel Bytes persistant (OGG chaining) + let (bytes_tx, bytes_rx) = mpsc::channel::(BYTES_CHANNEL_CAPACITY); + *self.bytes_tx.lock().await = Some(bytes_tx.clone()); + + // Créer le DuplexStream vers le client HTTP + let (mut pipe_writer, pipe_reader) = tokio::io::duplex(PIPE_CAPACITY); + + // Task de forwarding : Bytes → DuplexStream + // Se termine quand bytes_rx est fermé (bytes_tx droppé) ou pipe cassé + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + let mut rx = bytes_rx; + while let Some(bytes) = rx.recv().await { + if pipe_writer.write_all(&bytes).await.is_err() { + debug!("DirectOggFlacStream forwarder: pipe broken, client disconnected"); + break; + } + } + debug!("DirectOggFlacStream forwarder: done"); + }); + + // Réinitialiser les signaux + let _ = self.first_byte_tx.send(false); *self.current_timestamp.write().await = 0.0; + + // Créer le premier pcm_tx + encodeur + let (pcm_tx, pcm_rx) = mpsc::channel::(8); let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64)); - // Partager current_timestamp avec ByteStreamReader : il sera mis à jour - // avec le timestamp absolu du segment audio (position dans le fichier source). let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur); - let (pipe_writer, pipe_reader) = tokio::io::duplex(PIPE_CAPACITY); - - let _ = self.first_byte_tx.send(false); - debug!("DirectOggFlacHandle::connect() first_byte reset to false"); - *self.pcm_tx.lock().await = Some(pcm_tx); - debug!("DirectOggFlacHandle::connect() pcm_tx installed"); let new_count = connect_count_before.wrapping_add(1); let _ = self.client_connect_tx.send(new_count); - debug!("DirectOggFlacHandle::connect() client_connect_count -> {}", new_count); self.client_notify_internal.notify_one(); + debug!("DirectOggFlacHandle::connect() client_connect_count -> {}", new_count); let options = self.encoder_options.clone(); let current_timestamp = self.current_timestamp.clone(); - tokio::spawn(async move { - debug!("DirectOggFlacHandle: encoder+ogg task started"); - if let Err(e) = run_ogg_encoder(pcm_reader, pipe_writer, options, current_timestamp).await { - debug!("DirectOggFlacStream encoder stopped: {}", e); + let shared_bytes_tx = self.bytes_tx.clone(); + + let handle = tokio::spawn(async move { + debug!("DirectOggFlacHandle: initial encoder task started"); + if let Err(e) = run_ogg_encoder(pcm_reader, bytes_tx, shared_bytes_tx, options, current_timestamp).await { + debug!("DirectOggFlacHandle: initial encoder stopped: {}", e); } - debug!("DirectOggFlacHandle: encoder+ogg task ended"); + debug!("DirectOggFlacHandle: initial encoder task ended"); }); + *self.encoder_task.lock().await = Some(handle); + debug!("DirectOggFlacHandle::connect() returning DirectOggFlacStream"); DirectOggFlacStream { inner: pipe_reader, @@ -125,7 +162,6 @@ impl DirectOggFlacHandle { self.first_byte_tx.subscribe() } - /// Retourne la position de lecture courante en secondes. pub async fn current_position_sec(&self) -> f64 { *self.current_timestamp.read().await } @@ -172,6 +208,13 @@ impl AsyncRead for DirectOggFlacStream { struct DirectOggFlacSinkLogic { pcm_tx: SharedPcmTx, client_notify: Arc, + encoder_options: EncoderOptions, + encoder_task: SharedEncoderTask, + bytes_tx: SharedBytesTx, + current_timestamp: Arc>, + /// Vrai dès qu'au moins un chunk audio a été encodé dans le stream courant. + /// Empêche le OGG chaining sur le TrackBoundary initial (avant tout audio). + has_encoded_frames: bool, } #[async_trait] @@ -234,9 +277,21 @@ impl NodeLogic for DirectOggFlacSinkLogic { seg.timestamp_sec, ); *self.pcm_tx.lock().await = None; + self.has_encoded_frames = false; + } else { + self.has_encoded_frames = true; } } _AudioSegment::Sync(marker) => match marker.as_ref() { + SyncMarker::TrackBoundary { .. } => { + if self.has_encoded_frames { + debug!("DirectOggFlacSink: TrackBoundary — OGG chaining"); + self.has_encoded_frames = false; + self.do_track_boundary().await; + } else { + debug!("DirectOggFlacSink: TrackBoundary ignored (no frames encoded yet)"); + } + } SyncMarker::EndOfStream => { debug!("DirectOggFlacSink: EndOfStream"); } @@ -256,11 +311,69 @@ impl NodeLogic for DirectOggFlacSinkLogic { } } +impl DirectOggFlacSinkLogic { + /// OGG chaining : ferme l'encodeur courant (EOF → EOS OGG), attend sa fin, + /// puis relance un nouvel encodeur dans le même channel Bytes (nouvelle BOS OGG). + async fn do_track_boundary(&mut self) { + // 1. Fermer le pcm_tx courant → EOF dans ByteStreamReader → encodeur écrit EOS OGG + { + let mut guard = self.pcm_tx.lock().await; + *guard = None; + } + + // 2. Attendre la fin de la task encodeur courante + let old_task = self.encoder_task.lock().await.take(); + if let Some(handle) = old_task { + let _ = handle.await; + debug!("DirectOggFlacSink: previous encoder task joined"); + } + + // 3. Vérifier que le bytes_tx est encore valide (client pas déconnecté) + let bytes_tx = { + let guard = self.bytes_tx.lock().await; + guard.clone() + }; + let Some(bytes_tx) = bytes_tx else { + debug!("DirectOggFlacSink: bytes_tx gone (client disconnected), skip OGG chaining"); + return; + }; + + // 4. Nouveau pcm_tx + ByteStreamReader + let (pcm_tx, pcm_rx) = mpsc::channel::(8); + let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64)); + let pcm_reader = ByteStreamReader::new( + pcm_rx, + self.current_timestamp.clone(), + current_dur, + ); + *self.pcm_tx.lock().await = Some(pcm_tx); + + // 5. Relancer l'encodeur dans le même bytes_tx (OGG chaining : nouvelle BOS OGG) + let options = self.encoder_options.clone(); + let current_timestamp = self.current_timestamp.clone(); + let shared_bytes_tx = self.bytes_tx.clone(); + + let handle = tokio::spawn(async move { + debug!("DirectOggFlacSink: chained encoder task started"); + if let Err(e) = run_ogg_encoder(pcm_reader, bytes_tx, shared_bytes_tx, options, current_timestamp).await { + debug!("DirectOggFlacSink: chained encoder stopped: {}", e); + } + debug!("DirectOggFlacSink: chained encoder task ended"); + }); + + *self.encoder_task.lock().await = Some(handle); + debug!("DirectOggFlacSink: OGG chaining complete, new encoder started"); + } +} + // ─── Encodeur FLAC + wrapper OGG ───────────────────────────────────────────── +/// Encode PCM → FLAC → OGG et envoie les pages OGG dans `bytes_tx`. +/// Quand le channel devient invalide (client déconnecté), nettoie `shared_bytes_tx`. async fn run_ogg_encoder( pcm_reader: ByteStreamReader, - mut pipe_writer: tokio::io::DuplexStream, + bytes_tx: mpsc::Sender, + shared_bytes_tx: SharedBytesTx, options: EncoderOptions, _current_timestamp: Arc>, ) -> Result<(), AudioError> { @@ -274,56 +387,56 @@ async fn run_ogg_encoder( .await .map_err(|e| AudioError::ProcessingError(format!("FLAC encoder init: {}", e)))?; - // Lire le header FLAC et construire les pages OGG d'en-tête let flac_header = read_flac_header(&mut flac_stream).await?; - let sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?; + let _sample_rate = extract_sample_rate_from_streaminfo(&flac_header)?; let stream_serial: u32 = rand::random(); let mut ogg = OggPageWriter::new(stream_serial); - // Page BOS (identification OGG-FLAC) let ogg_flac_id = create_ogg_flac_identification(&flac_header)?; let bos_page = Bytes::from(ogg.create_page(&ogg_flac_id, true, false, false)); - - // Page Vorbis Comment let vorbis_comment = create_empty_vorbis_comment(); let comment_page = Bytes::from(ogg.create_page(&vorbis_comment, false, false, false)); - pipe_writer.write_all(&bos_page).await - .map_err(|e| AudioError::IoError(format!("OGG BOS write: {}", e)))?; - pipe_writer.write_all(&comment_page).await - .map_err(|e| AudioError::IoError(format!("OGG comment write: {}", e)))?; + macro_rules! send_or_cleanup { + ($page:expr) => { + if bytes_tx.send($page).await.is_err() { + debug!("DirectOggFlacSink: bytes_tx broken, client disconnected"); + *shared_bytes_tx.lock().await = None; + return Ok(()); + } + }; + } + + send_or_cleanup!(bos_page); + send_or_cleanup!(comment_page); + + use tokio::io::AsyncReadExt; + use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size}; - // Lire les frames FLAC et les encapsuler dans des pages OGG - let sample_rate_f64 = sample_rate as f64; let mut encoded_samples = 0u64; let mut read_buffer = vec![0u8; 16384]; let mut accumulator: Vec = Vec::with_capacity(32768); - use tokio::io::{AsyncReadExt, AsyncWriteExt}; loop { match flac_stream.read(&mut read_buffer).await { Ok(0) => { // EOF : page EOS finale let eos_page = Bytes::from(ogg.create_page(&accumulator, false, true, false)); - let _ = pipe_writer.write_all(&eos_page).await; + let _ = bytes_tx.send(eos_page).await; break; } Ok(n) => { accumulator.extend_from_slice(&read_buffer[..n]); loop { - if accumulator.len() < 4 { - break; - } + if accumulator.len() < 4 { break; } - // Trouver les positions de sync FLAC let mut sync_data: Vec<(usize, u32)> = Vec::new(); for i in 0..accumulator.len() - 1 { let b1 = accumulator[i]; let b2 = accumulator[i + 1]; if b1 == 0xFF && b2 >= 0xF8 && b2 <= 0xFE { - use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size}; if validate_frame_header_crc(&accumulator, i) { if let Some(samples) = parse_flac_block_size(&accumulator, i) { sync_data.push((i, samples)); @@ -332,9 +445,7 @@ async fn run_ogg_encoder( } } - if sync_data.len() < 2 { - break; - } + if sync_data.len() < 2 { break; } let first_start = sync_data[0].0; let first_samples = sync_data[0].1; @@ -346,19 +457,17 @@ async fn run_ogg_encoder( } let frame: Vec = accumulator.drain(0..second_start).collect(); - encoded_samples = encoded_samples.saturating_add(first_samples as u64); ogg.add_samples(first_samples as u64); let ogg_page = Bytes::from(ogg.create_page(&frame, false, false, false)); - if pipe_writer.write_all(&ogg_page).await.is_err() { - // Client déconnecté — le pipe HTTP s'est rompu + if bytes_tx.send(ogg_page).await.is_err() { warn!( - samples = encoded_samples, - "DirectOggFlacSink: OGG pipe broken after {} samples ({:.3}s), client disconnected", + "DirectOggFlacSink: bytes_tx broken after {} samples ({:.3}s), client disconnected", encoded_samples, encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64, ); + *shared_bytes_tx.lock().await = None; return Ok(()); } } @@ -375,7 +484,7 @@ async fn run_ogg_encoder( Ok(()) } -// ─── OGG helpers (copiés de streaming_ogg_flac_sink) ───────────────────────── +// ─── OGG helpers ───────────────────────────────────────────────────────────── struct OggPageWriter { stream_serial: u32, @@ -521,10 +630,17 @@ impl DirectOggFlacSink { let (first_byte_tx, _) = watch::channel(false); let first_byte_tx = Arc::new(first_byte_tx); let current_timestamp = Arc::new(tokio::sync::RwLock::new(0.0f64)); + let bytes_tx: SharedBytesTx = Arc::new(Mutex::new(None)); + let encoder_task: SharedEncoderTask = Arc::new(Mutex::new(None)); let logic = DirectOggFlacSinkLogic { pcm_tx: pcm_tx.clone(), client_notify: client_notify_internal.clone(), + encoder_options: encoder_options.clone(), + encoder_task: encoder_task.clone(), + bytes_tx: bytes_tx.clone(), + current_timestamp: current_timestamp.clone(), + has_encoded_frames: false, }; let sink = Self { @@ -538,6 +654,8 @@ impl DirectOggFlacSink { first_byte_tx, encoder_options, current_timestamp, + bytes_tx, + encoder_task, }; (sink, handle) diff --git a/pmowebrenderer/src/pipeline.rs b/pmowebrenderer/src/pipeline.rs index 4f8548d0..faa178a0 100644 --- a/pmowebrenderer/src/pipeline.rs +++ b/pmowebrenderer/src/pipeline.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use pmoaudio::{AudioSegment, PositionTrackerNode, ResamplingNode, ToI24Node}; use pmometadata::{MemoryTrackMetadata, TrackMetadata}; use pmoaudio_ext::sinks::{ - OggFlacStreamHandle, StreamingOggFlacSink, - DIRECT_OGG_FLAC_BITS_PER_SAMPLE, DIRECT_OGG_FLAC_SAMPLE_RATE, + DirectOggFlacHandle, DirectOggFlacSink, + DIRECT_OGG_FLAC_SAMPLE_RATE, }; use pmoaudio_ext::UriSource; use pmoflac::EncoderOptions; @@ -59,10 +59,10 @@ impl PipelineHandle { /// Pipeline audio complet pour une instance WebRenderer. /// /// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle` -/// (multi-clients, chaque `subscribe()` retourne un nouveau flux depuis le point courant). +/// (mono-client, chaque `connect()` crée un nouveau flux avec backpressure TCP). pub struct InstancePipeline { - /// Handle vers le sink OGG-FLAC — clonable, chaque subscribe() donne un flux live. - pub flac_handle: OggFlacStreamHandle, + /// Handle vers le sink OGG-FLAC — clonable, connect() crée un nouveau flux. + pub flac_handle: DirectOggFlacHandle, pub pipeline_handle: PipelineHandle, } @@ -82,10 +82,7 @@ impl InstancePipeline { // La backpressure remonte naturellement depuis le pipe duplex jusqu'à la source. use pmoaudio::pipeline::AudioPipelineNode; - let (sink, flac_handle) = StreamingOggFlacSink::new( - EncoderOptions::default(), - DIRECT_OGG_FLAC_BITS_PER_SAMPLE, - ); + let (sink, flac_handle) = DirectOggFlacSink::new(EncoderOptions::default()); // Nœud de suivi de position : lit le timestamp des chunks sortant vers le sink let (mut position_tracker, position_handle) = PositionTrackerNode::new(); diff --git a/pmowebrenderer/src/registry.rs b/pmowebrenderer/src/registry.rs index b49e2c06..41cb9fb7 100644 --- a/pmowebrenderer/src/registry.rs +++ b/pmowebrenderer/src/registry.rs @@ -29,8 +29,8 @@ pub struct WebRendererInstance { pub udn: String, pub device_instance: Arc, pub state: SharedState, - /// Handle vers le sink OGG-FLAC — clonable, chaque subscribe() donne un flux live. - pub flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle, + /// Handle vers le sink OGG-FLAC — clonable, connect() crée un nouveau flux avec backpressure. + pub flac_handle: pmoaudio_ext::sinks::DirectOggFlacHandle, pub pipeline: PipelineHandle, pub created_at: SystemTime, } @@ -116,11 +116,11 @@ impl RendererRegistry { Ok((stream_url, udn)) } - /// Retourne le OggFlacStreamHandle pour l'endpoint /stream (clonable). + /// Retourne le DirectOggFlacHandle pour l'endpoint /stream (clonable). pub fn get_flac_handle( &self, instance_id: &str, - ) -> Option { + ) -> Option { self.instances .read() .get(instance_id) diff --git a/pmowebrenderer/src/stream.rs b/pmowebrenderer/src/stream.rs index fa28a19a..b147c986 100644 --- a/pmowebrenderer/src/stream.rs +++ b/pmowebrenderer/src/stream.rs @@ -44,7 +44,7 @@ pub async fn stream_handler( } }; - let stream = handle.subscribe(); + let stream = handle.connect().await; info!(instance_id = %instance_id, "FLAC stream started"); From ca8702fd7f92f5a308133153d14116ae5d89df79 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 28 Feb 2026 12:45:55 +0100 Subject: [PATCH 03/18] Ajout du reporting de position audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un reporting de la position audio toutes les secondes depuis le navigateur vers le serveur pour une meilleure synchronisation. - Implémente un intervalle pour envoyer la position courante et la durée de l'audio - Ajoute les endpoints POST /api/webrenderer/{id}/position - Met à jour la logique de gestion de la position dans le registre - Supprime le tracking de position dans le pipeline audio (déplacé côté client) --- .../webapp/src/composables/useWebRenderer.ts | 27 +++++++++++++++++++ pmowebrenderer/src/config.rs | 6 +++-- pmowebrenderer/src/pipeline.rs | 27 ++----------------- pmowebrenderer/src/register.rs | 16 +++++++++++ pmowebrenderer/src/registry.rs | 14 ++++++++++ 5 files changed, 63 insertions(+), 27 deletions(-) diff --git a/pmoapp/webapp/src/composables/useWebRenderer.ts b/pmoapp/webapp/src/composables/useWebRenderer.ts index 6255ed8e..246fcca6 100644 --- a/pmoapp/webapp/src/composables/useWebRenderer.ts +++ b/pmoapp/webapp/src/composables/useWebRenderer.ts @@ -71,6 +71,30 @@ export function useWebRenderer() { let onConnectedCallback: (() => void) | null = null; let sseUnsubscribe: (() => void) | null = null; let pendingCanPlay: (() => void) | null = null; + let positionInterval: ReturnType | null = null; + + // ── Reporting de position ───────────────────────────────────────────────── + + function startPositionReporting(): void { + stopPositionReporting(); + positionInterval = setInterval(() => { + if (!audioEl || !instanceId) return; + const pos = audioEl.currentTime; + const dur = isFinite(audioEl.duration) ? audioEl.duration : null; + void fetch(`/api/webrenderer/${instanceId}/position`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ position_sec: pos, duration_sec: dur }), + }); + }, 1000); + } + + function stopPositionReporting(): void { + if (positionInterval !== null) { + clearInterval(positionInterval); + positionInterval = null; + } + } // ── Connexion / déconnexion du flux audio ───────────────────────────────── @@ -124,6 +148,7 @@ export function useWebRenderer() { el.removeEventListener("canplay", onCanPlay); el.play().then(() => { console.log("[WebRenderer] play() resolved OK"); + startPositionReporting(); }).catch((e: unknown) => { console.warn("[WebRenderer] play() rejected:", e); }); @@ -134,6 +159,7 @@ export function useWebRenderer() { function stopStream(): void { console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState); + stopPositionReporting(); if (!audioEl) return; if (pendingCanPlay) { audioEl.removeEventListener("canplay", pendingCanPlay); @@ -230,6 +256,7 @@ export function useWebRenderer() { onUnmounted(() => { sseUnsubscribe?.(); sseUnsubscribe = null; + stopPositionReporting(); stopStream(); void unregister(); if (audioEl) { diff --git a/pmowebrenderer/src/config.rs b/pmowebrenderer/src/config.rs index 105330fd..1fbfecaf 100644 --- a/pmowebrenderer/src/config.rs +++ b/pmowebrenderer/src/config.rs @@ -15,7 +15,7 @@ use pmocontrol::ControlPoint; #[cfg(feature = "pmoserver")] use crate::error::WebRendererError; #[cfg(feature = "pmoserver")] -use crate::register::{register_handler, unregister_handler}; +use crate::register::{position_update_handler, register_handler, unregister_handler}; #[cfg(feature = "pmoserver")] use crate::registry::RendererRegistry; #[cfg(feature = "pmoserver")] @@ -48,9 +48,10 @@ impl WebRendererExt for pmoserver::Server { ) .await; - // GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id} + // GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id} + POST /api/webrenderer/{id}/position let dynamic_router = Router::new() .route("/{id}/stream", get(stream_handler)) + .route("/{id}/position", post(position_update_handler)) .route("/{id}", delete(unregister_handler)) .with_state(registry.clone()); self.add_router("/api/webrenderer", dynamic_router).await; @@ -58,6 +59,7 @@ impl WebRendererExt for pmoserver::Server { tracing::info!("WebRenderer server-side streaming endpoints registered"); tracing::info!(" POST /api/webrenderer/register"); tracing::info!(" GET /api/webrenderer/{{id}}/stream"); + tracing::info!(" POST /api/webrenderer/{{id}}/position"); tracing::info!(" DELETE /api/webrenderer/{{id}}"); Ok(()) } diff --git a/pmowebrenderer/src/pipeline.rs b/pmowebrenderer/src/pipeline.rs index faa178a0..d8f36f61 100644 --- a/pmowebrenderer/src/pipeline.rs +++ b/pmowebrenderer/src/pipeline.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use pmoaudio::{AudioSegment, PositionTrackerNode, ResamplingNode, ToI24Node}; +use pmoaudio::{AudioSegment, ResamplingNode, ToI24Node}; use pmometadata::{MemoryTrackMetadata, TrackMetadata}; use pmoaudio_ext::sinks::{ DirectOggFlacHandle, DirectOggFlacSink, @@ -84,13 +84,9 @@ impl InstancePipeline { let (sink, flac_handle) = DirectOggFlacSink::new(EncoderOptions::default()); - // Nœud de suivi de position : lit le timestamp des chunks sortant vers le sink - let (mut position_tracker, position_handle) = PositionTrackerNode::new(); - position_tracker.register(sink.boxed()); - // Nœud de conversion de profondeur : tout type entier → I24 let mut to_i24 = ToI24Node::new(); - to_i24.register(position_tracker.boxed()); + to_i24.register(sink.boxed()); // Nœud de rééchantillonnage : n'importe quel sample rate → 96 kHz let mut resampler = ResamplingNode::new(DIRECT_OGG_FLAC_SAMPLE_RATE); @@ -113,25 +109,6 @@ impl InstancePipeline { debug!("Sink task terminated"); }); - // Task de mise à jour de la position (toutes les secondes) - { - let state_pos = state.clone(); - let pos_stop = stop_token.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - _ = pos_stop.cancelled() => break, - _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => { - let pos = position_handle.current_position_sec(); - if pos > 0.0 { - state_pos.write().position = Some(seconds_to_upnp_time(pos)); - } - } - } - } - }); - } - // Task pipeline : reçoit les commandes UPnP et pilote les sources let stop_token_clone = stop_token.clone(); let control_tx_clone = pipeline_handle.control_tx.clone(); diff --git a/pmowebrenderer/src/register.rs b/pmowebrenderer/src/register.rs index a78ef396..ce9bba75 100644 --- a/pmowebrenderer/src/register.rs +++ b/pmowebrenderer/src/register.rs @@ -61,6 +61,22 @@ pub async fn register_handler( } } +#[derive(Debug, Deserialize)] +pub struct PositionUpdateRequest { + pub position_sec: f64, + pub duration_sec: Option, +} + +/// POST /api/webrenderer/{id}/position +pub async fn position_update_handler( + State(registry): State>, + Path(instance_id): Path, + Json(req): Json, +) -> impl IntoResponse { + registry.update_position(&instance_id, req.position_sec, req.duration_sec); + StatusCode::NO_CONTENT +} + /// DELETE /api/webrenderer/{id} pub async fn unregister_handler( State(registry): State>, diff --git a/pmowebrenderer/src/registry.rs b/pmowebrenderer/src/registry.rs index 41cb9fb7..40cbc95e 100644 --- a/pmowebrenderer/src/registry.rs +++ b/pmowebrenderer/src/registry.rs @@ -151,6 +151,20 @@ impl RendererRegistry { .map(|i| i.device_instance.clone()) } + /// Met à jour la position et la durée depuis le navigateur (audio.currentTime). + pub fn update_position(&self, instance_id: &str, position_sec: f64, duration_sec: Option) { + let instances = self.instances.read(); + if let Some(instance) = instances.get(instance_id) { + let mut s = instance.state.write(); + s.position = Some(crate::pipeline::seconds_to_upnp_time(position_sec)); + if let Some(dur) = duration_sec { + if dur > 0.0 { + s.duration = Some(crate::pipeline::seconds_to_upnp_time(dur)); + } + } + } + } + pub fn schedule_unregister(self: &Arc, instance_id: &str) { use tokio_util::sync::CancellationToken; From de74c7b431ec12ecf9840daca02756ed53dcff14 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 28 Feb 2026 14:10:38 +0100 Subject: [PATCH 04/18] Migrate instance ID storage from localStorage to sessionStorage Switch instance ID storage from localStorage to sessionStorage in useWebRenderer composable to ensure better session management and prevent data persistence across browser sessions. Also update the DirectOggFlacSink implementation to improve backpressure handling, streamline the OGG chaining logic, and add proper Safari range header support for the stream endpoint. --- .DS_Store | Bin 18436 -> 18436 bytes .../webapp/src/composables/useWebRenderer.ts | 4 +- .../src/sinks/direct_ogg_flac_sink.rs | 401 +++++++----------- pmowebrenderer/src/registry.rs | 9 +- pmowebrenderer/src/stream.rs | 44 +- 5 files changed, 187 insertions(+), 271 deletions(-) diff --git a/.DS_Store b/.DS_Store index 8c67cf49f9d59ef73bc232093ccfd0f87fcec91d..39adbca77b0359db4eefe16c8f2a8b64fb334f54 100644 GIT binary patch delta 72 zcmZpfz}PZ@ae_Z%@5X@L{ETLsc?6O;*i0>rbQBCH`x(hjKB}Oyd8>pz8z%=t1sLj0 Z)-w}hV=7``V7<5brW_OFW^RR_>Hzym6f*z- delta 55 zcmV-70LcG@kO72{0gz7tjImI=4*@l^3=m=n2rV->EFdv6GLuj;8`F9wqo NANP@fFtZCF`6uEU5zqht diff --git a/pmoapp/webapp/src/composables/useWebRenderer.ts b/pmoapp/webapp/src/composables/useWebRenderer.ts index 246fcca6..93bb6a2a 100644 --- a/pmoapp/webapp/src/composables/useWebRenderer.ts +++ b/pmoapp/webapp/src/composables/useWebRenderer.ts @@ -34,10 +34,10 @@ function generateUUID(): string { function getOrCreateInstanceId(): string { try { - let id = localStorage.getItem(INSTANCE_ID_KEY); + let id = sessionStorage.getItem(INSTANCE_ID_KEY); if (!id) { id = generateUUID(); - localStorage.setItem(INSTANCE_ID_KEY, id); + sessionStorage.setItem(INSTANCE_ID_KEY, id); } return id; } catch { diff --git a/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs b/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs index 343567f4..92b4409b 100644 --- a/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs @@ -1,35 +1,28 @@ //! DirectOggFlacSink — nœud puits OGG-FLAC pour un seul client HTTP. //! -//! Combine la logique de backpressure/reconnexion de `DirectFlacSink` -//! avec l'encodage OGG-FLAC de `StreamingOggFlacSink`. -//! -//! # Cycle de vie -//! -//! - **Play** : le navigateur appelle `GET /stream`. `connect()` crée un channel -//! Bytes (bytes_tx/rx), une task de forwarding qui copie les Bytes dans un -//! DuplexStream, et lance le premier encodeur OGG-FLAC. Le `bytes_tx` est -//! stocké dans le sink pour le chaining TrackBoundary. -//! - **Stop** : le navigateur ferme la connexion. Le DuplexStream se rompt, -//! la task de forwarding se termine, le bytes_tx devient invalide. Le sink -//! voit `pcm_tx.send()` échouer et bloque sur `client_notify`. -//! - **TrackBoundary** : le sink ferme le `pcm_tx` courant (EOF → encodeur écrit -//! EOS OGG), attend la fin de l'encodeur, puis relance un nouvel encodeur -//! dans le même `bytes_tx` (OGG chaining : nouvelle BOS OGG dans le même flux HTTP). -//! - **Play suivant** : `connect()` → nouveau DuplexStream + channel → nouveau pipe. -//! //! # Architecture //! //! ```text //! AudioSegment I24 @ 96 kHz -//! ↓ NodeLogic::process() [bloque si pas de client] +//! ↓ NodeLogic::process() //! chunk_to_pcm_bytes() → PCM 24-bit LE -//! ↓ SharedPcmTx -//! ByteStreamReader → encode_flac_stream() → OGG pages -//! ↓ SharedBytesTx (mpsc::Sender) ← persistant entre encodeurs -//! [forwarding task] → tokio::io::DuplexStream -//! ↓ DirectOggFlacStream (AsyncRead) → Body HTTP +//! ↓ pcm_tx (cap=2) +//! ByteStreamReader → encode_flac_stream() → OGG pages (Bytes) +//! ↓ ogg_tx mpsc::Sender (cap=8, backpressure naturelle) +//! Arc>> → DirectOggFlacStream (AsyncRead) → HTTP → Safari //! ``` +//! +//! # Backpressure +//! +//! Safari lent → ogg_rx plein → ogg_tx.send() bloque → encodeur bloque +//! → pcm_tx.send() bloque → pipeline audio bloque. +//! +//! # OGG chaining (TrackBoundary) +//! +//! Fermer pcm_tx → encodeur termine (EOS) → lancer nouvel encodeur. +//! Le même ogg_tx est réutilisé : le stream HTTP est continu. +use std::collections::VecDeque; use std::io; use std::pin::Pin; use std::sync::Arc; @@ -46,7 +39,7 @@ use tokio::io::{AsyncRead, ReadBuf}; use tokio::sync::{mpsc, watch, Mutex}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use tracing::{debug, warn}; +use tracing::{debug, trace, warn}; use crate::sinks::byte_stream_reader::{ByteStreamReader, PcmChunk}; use crate::sinks::chunk_to_pcm::chunk_to_pcm_bytes; @@ -57,129 +50,54 @@ pub const DIRECT_OGG_FLAC_SAMPLE_RATE: u32 = 96_000; pub const DIRECT_OGG_FLAC_CHANNELS: u8 = 2; pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24; -/// Capacité du pipe duplex (~256 KB). -const PIPE_CAPACITY: usize = 256 * 1024; -/// Capacité du channel Bytes intermédiaire. -const BYTES_CHANNEL_CAPACITY: usize = 64; +/// Capacité du canal OGG → HTTP (en chunks de ~8 KB). +/// Fournit un petit buffer sans casser la backpressure TCP. +const OGG_CHANNEL_CAPACITY: usize = 8; -// ─── Shared state ───────────────────────────────────────────────────────────── +// ─── Types partagés ─────────────────────────────────────────────────────────── type SharedPcmTx = Arc>>>; -/// Canal Bytes persistant entre les encodeurs successifs (OGG chaining). -/// Le sink y envoie les pages OGG ; une task de forwarding les copie dans le DuplexStream. -type SharedBytesTx = Arc>>>; -/// Handle de la task encodeur courante. type SharedEncoderTask = Arc>>>; +type SharedOggRx = Arc>>; // ─── Handle public ──────────────────────────────────────────────────────────── -/// Handle vers le sink, cloneable, reconnectable à chaque Play. #[derive(Clone)] pub struct DirectOggFlacHandle { pcm_tx: SharedPcmTx, - client_connect_tx: Arc>, - client_notify_internal: Arc, - first_byte_tx: Arc>, + encoder_task: SharedEncoderTask, encoder_options: EncoderOptions, current_timestamp: Arc>, - /// Canal Bytes persistant partagé avec la logic du sink pour le OGG chaining. - bytes_tx: SharedBytesTx, - /// Task encodeur courante partagée avec la logic du sink. - encoder_task: SharedEncoderTask, + /// Canal OGG partagé avec le stream HTTP. + ogg_tx: mpsc::Sender, + ogg_rx: SharedOggRx, + /// Notifié quand get_stream() est appelé (premier client). + client_notify: Arc, } impl DirectOggFlacHandle { - /// Crée un nouveau flux OGG-FLAC pour le client HTTP. - /// Remplace toute connexion précédente. - pub async fn connect(&self) -> DirectOggFlacStream { - let connect_count_before = *self.client_connect_tx.borrow(); - debug!("DirectOggFlacHandle::connect() called, connect_count={}", connect_count_before); - - // Annuler l'encodeur précédent s'il tourne encore - if let Some(old_task) = self.encoder_task.lock().await.take() { - old_task.abort(); - } - - // Créer le channel Bytes persistant (OGG chaining) - let (bytes_tx, bytes_rx) = mpsc::channel::(BYTES_CHANNEL_CAPACITY); - *self.bytes_tx.lock().await = Some(bytes_tx.clone()); - - // Créer le DuplexStream vers le client HTTP - let (mut pipe_writer, pipe_reader) = tokio::io::duplex(PIPE_CAPACITY); - - // Task de forwarding : Bytes → DuplexStream - // Se termine quand bytes_rx est fermé (bytes_tx droppé) ou pipe cassé - tokio::spawn(async move { - use tokio::io::AsyncWriteExt; - let mut rx = bytes_rx; - while let Some(bytes) = rx.recv().await { - if pipe_writer.write_all(&bytes).await.is_err() { - debug!("DirectOggFlacStream forwarder: pipe broken, client disconnected"); - break; - } - } - debug!("DirectOggFlacStream forwarder: done"); - }); - - // Réinitialiser les signaux - let _ = self.first_byte_tx.send(false); - *self.current_timestamp.write().await = 0.0; - - // Créer le premier pcm_tx + encodeur - let (pcm_tx, pcm_rx) = mpsc::channel::(8); - let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64)); - let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur); - - *self.pcm_tx.lock().await = Some(pcm_tx); - - let new_count = connect_count_before.wrapping_add(1); - let _ = self.client_connect_tx.send(new_count); - self.client_notify_internal.notify_one(); - debug!("DirectOggFlacHandle::connect() client_connect_count -> {}", new_count); - - let options = self.encoder_options.clone(); - let current_timestamp = self.current_timestamp.clone(); - let shared_bytes_tx = self.bytes_tx.clone(); - - let handle = tokio::spawn(async move { - debug!("DirectOggFlacHandle: initial encoder task started"); - if let Err(e) = run_ogg_encoder(pcm_reader, bytes_tx, shared_bytes_tx, options, current_timestamp).await { - debug!("DirectOggFlacHandle: initial encoder stopped: {}", e); - } - debug!("DirectOggFlacHandle: initial encoder task ended"); - }); - - *self.encoder_task.lock().await = Some(handle); - - debug!("DirectOggFlacHandle::connect() returning DirectOggFlacStream"); + /// Retourne le stream OGG-FLAC pour le handler HTTP. + /// Lance le premier encodeur au premier appel. + pub fn get_stream(&self) -> DirectOggFlacStream { + debug!("DirectOggFlacHandle::get_stream()"); + self.client_notify.notify_one(); DirectOggFlacStream { - inner: pipe_reader, - first_byte_tx: Some(self.first_byte_tx.clone()), + ogg_rx: self.ogg_rx.clone(), + buffer: VecDeque::new(), } } - pub fn first_byte_ready(&self) -> watch::Receiver { - self.first_byte_tx.subscribe() - } - pub async fn current_position_sec(&self) -> f64 { *self.current_timestamp.read().await } - - pub async fn wait_for_client(&self) { - let seen = *self.client_connect_tx.borrow(); - debug!("DirectOggFlacHandle::wait_for_client() called, seen connect_count={}", seen); - let mut rx = self.client_connect_tx.subscribe(); - let _ = rx.wait_for(|v| *v > seen).await; - debug!("DirectOggFlacHandle::wait_for_client() unblocked"); - } } // ─── Stream public ──────────────────────────────────────────────────────────── +/// AsyncRead sur le canal OGG-FLAC. pub struct DirectOggFlacStream { - inner: tokio::io::DuplexStream, - first_byte_tx: Option>>, + ogg_rx: SharedOggRx, + buffer: VecDeque, } impl AsyncRead for DirectOggFlacStream { @@ -188,18 +106,36 @@ impl AsyncRead for DirectOggFlacStream { cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { - let filled_before = buf.filled().len(); - let result = Pin::new(&mut self.inner).poll_read(cx, buf); - if let Poll::Ready(Ok(())) = &result { - let filled_after = buf.filled().len(); - if filled_after > filled_before { - if let Some(tx) = self.first_byte_tx.take() { - debug!("DirectOggFlacStream: first {} bytes sent to HTTP client", filled_after - filled_before); - let _ = tx.send(true); - } - } + // Vider le buffer interne en premier + if !self.buffer.is_empty() { + let to_copy = self.buffer.len().min(buf.remaining()); + let chunk: Vec = self.buffer.drain(..to_copy).collect(); + buf.put_slice(&chunk); + return Poll::Ready(Ok(())); + } + + // Tenter de recevoir le prochain chunk OGG + let mut guard = match self.ogg_rx.try_lock() { + Ok(g) => g, + Err(_) => { + cx.waker().wake_by_ref(); + return Poll::Pending; + } + }; + + match guard.poll_recv(cx) { + Poll::Ready(Some(bytes)) => { + drop(guard); + let to_copy = bytes.len().min(buf.remaining()); + buf.put_slice(&bytes[..to_copy]); + if to_copy < bytes.len() { + self.buffer.extend(&bytes[to_copy..]); + } + Poll::Ready(Ok(())) + } + Poll::Ready(None) => Poll::Ready(Ok(())), // EOF + Poll::Pending => Poll::Pending, } - result } } @@ -207,13 +143,11 @@ impl AsyncRead for DirectOggFlacStream { struct DirectOggFlacSinkLogic { pcm_tx: SharedPcmTx, - client_notify: Arc, - encoder_options: EncoderOptions, encoder_task: SharedEncoderTask, - bytes_tx: SharedBytesTx, + encoder_options: EncoderOptions, current_timestamp: Arc>, - /// Vrai dès qu'au moins un chunk audio a été encodé dans le stream courant. - /// Empêche le OGG chaining sur le TrackBoundary initial (avant tout audio). + ogg_tx: mpsc::Sender, + client_notify: Arc, has_encoded_frames: bool, } @@ -229,6 +163,15 @@ impl NodeLogic for DirectOggFlacSinkLogic { AudioError::ProcessingError("DirectOggFlacSink requires an input".into()) })?; + // Attendre le premier client Safari avant de démarrer l'encodeur + debug!("DirectOggFlacSink: waiting for first client..."); + tokio::select! { + _ = stop_token.cancelled() => return Ok(()), + _ = self.client_notify.notified() => {} + } + debug!("DirectOggFlacSink: first client connected, starting encoder"); + self.start_encoder().await; + loop { tokio::select! { _ = stop_token.cancelled() => { @@ -244,25 +187,9 @@ impl NodeLogic for DirectOggFlacSinkLogic { } Some(seg) => match &seg.segment { _AudioSegment::Chunk(chunk) => { - // Attendre un client si nécessaire (backpressure quand pas de Play) - loop { - let tx_opt = self.pcm_tx.lock().await.clone(); - if tx_opt.is_some() { - break; - } - debug!("DirectOggFlacSink: no pcm_tx, waiting for client_notify..."); - tokio::select! { - _ = stop_token.cancelled() => { - debug!("DirectOggFlacSink: cancelled while waiting for client"); - return Ok(()); - } - _ = self.client_notify.notified() => { - debug!("DirectOggFlacSink: client_notify received, rechecking pcm_tx"); - } - } - } + let tx = self.pcm_tx.lock().await.clone(); + let Some(tx) = tx else { continue; }; - let tx = self.pcm_tx.lock().await.clone().unwrap(); let pcm_bytes = chunk_to_pcm_bytes(chunk, DIRECT_OGG_FLAC_BITS_PER_SAMPLE)?; let duration_sec = chunk.len() as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64; let pcm_chunk = PcmChunk { @@ -271,11 +198,7 @@ impl NodeLogic for DirectOggFlacSinkLogic { duration_sec, }; if tx.send(pcm_chunk).await.is_err() { - warn!( - ts = seg.timestamp_sec, - "DirectOggFlacSink: chunk dropped (client disconnected at {:.3}s), waiting for reconnect", - seg.timestamp_sec, - ); + warn!("DirectOggFlacSink: encoder gone at {:.3}s", seg.timestamp_sec); *self.pcm_tx.lock().await = None; self.has_encoded_frames = false; } else { @@ -289,11 +212,12 @@ impl NodeLogic for DirectOggFlacSinkLogic { self.has_encoded_frames = false; self.do_track_boundary().await; } else { - debug!("DirectOggFlacSink: TrackBoundary ignored (no frames encoded yet)"); + debug!("DirectOggFlacSink: TrackBoundary ignored (no frames)"); } } SyncMarker::EndOfStream => { debug!("DirectOggFlacSink: EndOfStream"); + self.stop_encoder().await; } _ => {} }, @@ -303,6 +227,7 @@ impl NodeLogic for DirectOggFlacSinkLogic { } } + self.stop_encoder().await; Ok(()) } @@ -312,71 +237,56 @@ impl NodeLogic for DirectOggFlacSinkLogic { } impl DirectOggFlacSinkLogic { - /// OGG chaining : ferme l'encodeur courant (EOF → EOS OGG), attend sa fin, - /// puis relance un nouvel encodeur dans le même channel Bytes (nouvelle BOS OGG). - async fn do_track_boundary(&mut self) { - // 1. Fermer le pcm_tx courant → EOF dans ByteStreamReader → encodeur écrit EOS OGG - { - let mut guard = self.pcm_tx.lock().await; - *guard = None; - } - - // 2. Attendre la fin de la task encodeur courante - let old_task = self.encoder_task.lock().await.take(); - if let Some(handle) = old_task { - let _ = handle.await; - debug!("DirectOggFlacSink: previous encoder task joined"); - } - - // 3. Vérifier que le bytes_tx est encore valide (client pas déconnecté) - let bytes_tx = { - let guard = self.bytes_tx.lock().await; - guard.clone() - }; - let Some(bytes_tx) = bytes_tx else { - debug!("DirectOggFlacSink: bytes_tx gone (client disconnected), skip OGG chaining"); - return; - }; - - // 4. Nouveau pcm_tx + ByteStreamReader - let (pcm_tx, pcm_rx) = mpsc::channel::(8); + async fn start_encoder(&mut self) { + let (pcm_tx, pcm_rx) = mpsc::channel::(2); let current_dur = Arc::new(tokio::sync::RwLock::new(0.0f64)); - let pcm_reader = ByteStreamReader::new( - pcm_rx, - self.current_timestamp.clone(), - current_dur, - ); + let pcm_reader = ByteStreamReader::new(pcm_rx, self.current_timestamp.clone(), current_dur); *self.pcm_tx.lock().await = Some(pcm_tx); - // 5. Relancer l'encodeur dans le même bytes_tx (OGG chaining : nouvelle BOS OGG) + let ogg_tx = self.ogg_tx.clone(); let options = self.encoder_options.clone(); let current_timestamp = self.current_timestamp.clone(); - let shared_bytes_tx = self.bytes_tx.clone(); - let handle = tokio::spawn(async move { - debug!("DirectOggFlacSink: chained encoder task started"); - if let Err(e) = run_ogg_encoder(pcm_reader, bytes_tx, shared_bytes_tx, options, current_timestamp).await { - debug!("DirectOggFlacSink: chained encoder stopped: {}", e); + debug!("DirectOggFlacSink: encoder task started"); + if let Err(e) = run_ogg_encoder(pcm_reader, ogg_tx, options, current_timestamp).await { + debug!("DirectOggFlacSink: encoder stopped: {}", e); } - debug!("DirectOggFlacSink: chained encoder task ended"); + debug!("DirectOggFlacSink: encoder task ended"); }); - *self.encoder_task.lock().await = Some(handle); - debug!("DirectOggFlacSink: OGG chaining complete, new encoder started"); + } + + async fn stop_encoder(&mut self) { + *self.pcm_tx.lock().await = None; + if let Some(handle) = self.encoder_task.lock().await.take() { + let _ = handle.await; + } + self.has_encoded_frames = false; + } + + async fn do_track_boundary(&mut self) { + // Fermer pcm_tx → encodeur écrit EOS, se termine + *self.pcm_tx.lock().await = None; + if let Some(handle) = self.encoder_task.lock().await.take() { + let _ = handle.await; + debug!("DirectOggFlacSink: previous encoder joined"); + } + // Démarrer le nouvel encodeur sur le même ogg_tx + self.start_encoder().await; + debug!("DirectOggFlacSink: OGG chaining complete"); } } // ─── Encodeur FLAC + wrapper OGG ───────────────────────────────────────────── -/// Encode PCM → FLAC → OGG et envoie les pages OGG dans `bytes_tx`. -/// Quand le channel devient invalide (client déconnecté), nettoie `shared_bytes_tx`. async fn run_ogg_encoder( pcm_reader: ByteStreamReader, - bytes_tx: mpsc::Sender, - shared_bytes_tx: SharedBytesTx, + ogg_tx: mpsc::Sender, options: EncoderOptions, _current_timestamp: Arc>, ) -> Result<(), AudioError> { + use tokio::io::AsyncReadExt; + let format = PcmFormat { sample_rate: DIRECT_OGG_FLAC_SAMPLE_RATE, channels: DIRECT_OGG_FLAC_CHANNELS, @@ -394,36 +304,34 @@ async fn run_ogg_encoder( let mut ogg = OggPageWriter::new(stream_serial); let ogg_flac_id = create_ogg_flac_identification(&flac_header)?; - let bos_page = Bytes::from(ogg.create_page(&ogg_flac_id, true, false, false)); + let bos_page = ogg.create_page(&ogg_flac_id, true, false, false); let vorbis_comment = create_empty_vorbis_comment(); - let comment_page = Bytes::from(ogg.create_page(&vorbis_comment, false, false, false)); + let comment_page = ogg.create_page(&vorbis_comment, false, false, false); - macro_rules! send_or_cleanup { - ($page:expr) => { - if bytes_tx.send($page).await.is_err() { - debug!("DirectOggFlacSink: bytes_tx broken, client disconnected"); - *shared_bytes_tx.lock().await = None; - return Ok(()); - } - }; + let mut header = Vec::new(); + header.extend_from_slice(&bos_page); + header.extend_from_slice(&comment_page); + if ogg_tx.send(Bytes::from(header)).await.is_err() { + debug!("DirectOggFlacSink: ogg_tx closed on headers"); + return Ok(()); } - send_or_cleanup!(bos_page); - send_or_cleanup!(comment_page); - - use tokio::io::AsyncReadExt; use crate::sinks::flac_frame_utils::{validate_frame_header_crc, parse_flac_block_size}; let mut encoded_samples = 0u64; - let mut read_buffer = vec![0u8; 16384]; + let mut read_buffer = vec![0u8; 65536]; let mut accumulator: Vec = Vec::with_capacity(32768); loop { match flac_stream.read(&mut read_buffer).await { Ok(0) => { - // EOF : page EOS finale - let eos_page = Bytes::from(ogg.create_page(&accumulator, false, true, false)); - let _ = bytes_tx.send(eos_page).await; + trace!( + "OggEncoder: EOF — accum={} B, encoded={:.3}s", + accumulator.len(), + encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64, + ); + let eos_page = ogg.create_page(&accumulator, false, true, false); + let _ = ogg_tx.send(Bytes::from(eos_page)).await; break; } Ok(n) => { @@ -460,14 +368,16 @@ async fn run_ogg_encoder( encoded_samples = encoded_samples.saturating_add(first_samples as u64); ogg.add_samples(first_samples as u64); - let ogg_page = Bytes::from(ogg.create_page(&frame, false, false, false)); - if bytes_tx.send(ogg_page).await.is_err() { - warn!( - "DirectOggFlacSink: bytes_tx broken after {} samples ({:.3}s), client disconnected", - encoded_samples, - encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64, - ); - *shared_bytes_tx.lock().await = None; + trace!( + "OggEncoder: frame {} B, {:.3}s total", + frame.len(), + encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64, + ); + + let ogg_page = ogg.create_page(&frame, false, false, false); + if ogg_tx.send(Bytes::from(ogg_page)).await.is_err() { + warn!("DirectOggFlacSink: ogg_tx closed after {:.3}s", + encoded_samples as f64 / DIRECT_OGG_FLAC_SAMPLE_RATE as f64); return Ok(()); } } @@ -624,22 +534,20 @@ pub struct DirectOggFlacSink { impl DirectOggFlacSink { pub fn new(encoder_options: EncoderOptions) -> (Self, DirectOggFlacHandle) { let pcm_tx: SharedPcmTx = Arc::new(Mutex::new(None)); - let client_notify_internal = Arc::new(tokio::sync::Notify::new()); - let (client_connect_tx, _) = watch::channel(0u64); - let client_connect_tx = Arc::new(client_connect_tx); - let (first_byte_tx, _) = watch::channel(false); - let first_byte_tx = Arc::new(first_byte_tx); - let current_timestamp = Arc::new(tokio::sync::RwLock::new(0.0f64)); - let bytes_tx: SharedBytesTx = Arc::new(Mutex::new(None)); let encoder_task: SharedEncoderTask = Arc::new(Mutex::new(None)); + let current_timestamp = Arc::new(tokio::sync::RwLock::new(0.0f64)); + let client_notify = Arc::new(tokio::sync::Notify::new()); + + let (ogg_tx, ogg_rx) = mpsc::channel::(OGG_CHANNEL_CAPACITY); + let ogg_rx = Arc::new(Mutex::new(ogg_rx)); let logic = DirectOggFlacSinkLogic { pcm_tx: pcm_tx.clone(), - client_notify: client_notify_internal.clone(), - encoder_options: encoder_options.clone(), encoder_task: encoder_task.clone(), - bytes_tx: bytes_tx.clone(), + encoder_options: encoder_options.clone(), current_timestamp: current_timestamp.clone(), + ogg_tx: ogg_tx.clone(), + client_notify: client_notify.clone(), has_encoded_frames: false, }; @@ -649,13 +557,12 @@ impl DirectOggFlacSink { let handle = DirectOggFlacHandle { pcm_tx, - client_connect_tx, - client_notify_internal, - first_byte_tx, + encoder_task, encoder_options, current_timestamp, - bytes_tx, - encoder_task, + ogg_tx, + ogg_rx, + client_notify, }; (sink, handle) diff --git a/pmowebrenderer/src/registry.rs b/pmowebrenderer/src/registry.rs index 40cbc95e..7e1c81eb 100644 --- a/pmowebrenderer/src/registry.rs +++ b/pmowebrenderer/src/registry.rs @@ -116,15 +116,16 @@ impl RendererRegistry { Ok((stream_url, udn)) } - /// Retourne le DirectOggFlacHandle pour l'endpoint /stream (clonable). - pub fn get_flac_handle( + /// Retourne un DirectOggFlacStream pour l'endpoint /stream. + /// Chaque appel retourne un wrapper sur le même reader persistant. + pub fn get_stream( &self, instance_id: &str, - ) -> Option { + ) -> Option { self.instances .read() .get(instance_id) - .map(|i| i.flac_handle.clone()) + .map(|i| i.flac_handle.get_stream()) } /// Retourne le PipelineHandle par UDN (pour les handlers UPnP) diff --git a/pmowebrenderer/src/stream.rs b/pmowebrenderer/src/stream.rs index b147c986..2e759ebb 100644 --- a/pmowebrenderer/src/stream.rs +++ b/pmowebrenderer/src/stream.rs @@ -1,15 +1,17 @@ //! Handler HTTP GET /api/webrenderer/{id}/stream //! -//! Sert le flux FLAC d'une instance WebRenderer via DirectFlacSink. +//! Sert le flux OGG-FLAC d'une instance WebRenderer. //! -//! Reconnectable : appelé à chaque Play, crée un nouveau pipe + encodeur. +//! Safari fait systématiquement une requête Range: bytes=0-1 avant de jouer. +//! On répond 206 avec 2 octets factices pour satisfaire la sonde, +//! puis la vraie requête (sans Range) reçoit le stream persistant. use axum::{ body::Body, extract::{Path, State}, http::{ - StatusCode, - header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, + HeaderMap, StatusCode, + header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, CONTENT_RANGE, CONTENT_LENGTH, ACCEPT_RANGES}, }, response::{IntoResponse, Response}, }; @@ -20,21 +22,31 @@ use tracing::info; use crate::registry::RendererRegistry; /// GET /api/webrenderer/{id}/stream -/// -/// Crée un nouveau pipe FLAC à chaque connexion (chaque Play). -/// Le flux reste ouvert jusqu'à ce que le client se déconnecte (Stop). -/// Les morceaux s'enchaînent en gapless dans le même flux. -/// -/// Safari envoie un Range header (bytes=0-) et exige Accept-Ranges: bytes. -/// On répond 206 Partial Content si un Range header est présent, sinon 200. pub async fn stream_handler( State(registry): State>, Path(instance_id): Path, + headers: HeaderMap, ) -> impl IntoResponse { info!(instance_id = %instance_id, "FLAC stream client connecting"); - let handle = match registry.get_flac_handle(&instance_id) { - Some(h) => h, + // Détecter la sonde Range: bytes=0-1 de Safari + if let Some(range) = headers.get("range") { + if range.as_bytes() == b"bytes=0-1" { + info!(instance_id = %instance_id, "Safari range probe — responding 206"); + return Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(CONTENT_TYPE, "audio/ogg; codecs=flac") + .header(ACCEPT_RANGES, "bytes") + .header(CONTENT_RANGE, "bytes 0-1/*") + .header(CONTENT_LENGTH, "2") + .body(Body::from(vec![0u8, 0u8])) + .unwrap() + .into_response(); + } + } + + let stream = match registry.get_stream(&instance_id) { + Some(s) => s, None => { return ( StatusCode::NOT_FOUND, @@ -44,18 +56,14 @@ pub async fn stream_handler( } }; - let stream = handle.connect().await; - info!(instance_id = %instance_id, "FLAC stream started"); - // Toujours 200 OK pour un flux live de taille inconnue. - // Les navigateurs (Safari inclus) acceptent 200 pour l'audio streaming. - // Un Content-Range invalide (bytes 0-*/*) ferait rejeter le flux. Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "audio/ogg; codecs=flac") .header(CACHE_CONTROL, "no-store, no-transform") .header(CONNECTION, "keep-alive") + .header(ACCEPT_RANGES, "bytes") .header("X-Content-Type-Options", "nosniff") .body(Body::from_stream(ReaderStream::new(stream))) .unwrap() From f9cd3063fb1788efaa340992d66087ab55a7ad78 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 1 Mar 2026 17:53:59 +0100 Subject: [PATCH 05/18] =?UTF-8?q?Ajout=20de=20la=20prise=20en=20charge=20d?= =?UTF-8?q?e=20l'h=C3=B4te=20du=20serveur=20pour=20les=20renderers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cette mise à jour ajoute la prise en charge de l'adresse IP du serveur hébergeant les renderers UPnP. Cela permet d'afficher l'adresse IP des renderers dans l'interface utilisateur lorsqu'ils ont des noms identiques. Les modifications incluent : - Mise à jour des types pour inclure le champ server_host - Extraction de l'adresse IP depuis la location UPnP - Affichage de l'adresse IP dans l'interface utilisateur pour les renderers avec des noms en double - Modification des appels à register_device pour désactiver le SSDP lors de l'enregistrement des renderers Cela améliore la clarté de l'interface utilisateur en permettant de distinguer les renderers avec des noms identiques mais hébergés sur des serveurs différents. --- PMOMusic/src/main.rs | 4 ++-- .../src/components/unified/RendererDrawer.vue | 13 +++++++++++-- pmoapp/webapp/src/services/pmocontrol/types.ts | 1 + pmocontrol/src/openapi.rs | 2 ++ pmocontrol/src/pmoserver_ext.rs | 8 ++++++++ pmoupnp/src/upnp_server.rs | 8 +++++--- pmowebrenderer/src/registry.rs | 2 +- 7 files changed, 30 insertions(+), 8 deletions(-) diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index 4b55a57a..bf0570ae 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -74,7 +74,7 @@ async fn main() -> Result<(), Box> { let renderer_instance = server .write() .await - .register_device(MEDIA_RENDERER.clone()) + .register_device(MEDIA_RENDERER.clone(), true) .await .expect("Failed to register MediaRenderer"); @@ -85,7 +85,7 @@ async fn main() -> Result<(), Box> { let server_instance = server .write() .await - .register_device(MEDIA_SERVER.clone()) + .register_device(MEDIA_SERVER.clone(), true) .await .expect("Failed to register MediaServer"); diff --git a/pmoapp/webapp/src/components/unified/RendererDrawer.vue b/pmoapp/webapp/src/components/unified/RendererDrawer.vue index fa7b561c..88a82dc7 100644 --- a/pmoapp/webapp/src/components/unified/RendererDrawer.vue +++ b/pmoapp/webapp/src/components/unified/RendererDrawer.vue @@ -116,6 +116,15 @@ const offlineRenderers = computed(() => allRenderers.value.filter((r: RendererSummary) => !r.online), ); +// Noms de renderers qui apparaissent plus d'une fois (online + offline) +const duplicateNames = computed(() => { + const counts = new Map(); + for (const r of allRenderers.value) { + counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1); + } + return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name)); +}); + function close() { emit("update:modelValue", false); } @@ -296,7 +305,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) { getProtocolLabel( renderer.protocol, ) - }} + }}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
@@ -383,7 +392,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {

- {{ renderer.friendly_name }} + {{ renderer.friendly_name }}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}

, } /// Protocole exposé par le renderer diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index f76ec5a6..eb5330b8 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -102,6 +102,13 @@ async fn list_renderers(State(state): State) -> Json) -> Json>() diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index 9ebe2d0f..467755b8 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -97,6 +97,7 @@ pub trait UpnpServerExt { async fn register_device( &mut self, device: Arc, + with_ssdp: bool, ) -> Result, DeviceError>; /// Retourne le nombre de devices enregistrés. @@ -232,6 +233,7 @@ impl UpnpServerExt for Server { async fn register_device( &mut self, device: Arc, + with_ssdp: bool, ) -> Result, DeviceError> { use tracing::info; @@ -259,8 +261,8 @@ impl UpnpServerExt for Server { .register(di.clone()) .map_err(|e| DeviceError::UrlRegistrationError(e))?; - // Annoncer via SSDP (si initialisé) - if self.ssdp_enabled() { + // Annoncer via SSDP (si initialisé et demandé) + if with_ssdp && self.ssdp_enabled() { let ssdp_opt = SSDP_SERVER.read().unwrap(); if let Some(ref ssdp) = *ssdp_opt { use crate::config_ext::UpnpConfigExt; @@ -507,7 +509,7 @@ mod tests { "Test Renderer".to_string(), )); - let instance = server.register_device(device).await.unwrap(); + let instance = server.register_device(device, false).await.unwrap(); // Vérifier que le device est dans le registre assert_eq!(server.device_count(), 1); diff --git a/pmowebrenderer/src/registry.rs b/pmowebrenderer/src/registry.rs index 7e1c81eb..7cb187b7 100644 --- a/pmowebrenderer/src/registry.rs +++ b/pmowebrenderer/src/registry.rs @@ -263,7 +263,7 @@ impl RendererRegistry { let mut server = server_arc.write().await; server - .register_device(Arc::new(device)) + .register_device(Arc::new(device), false) .await .map_err(|e| WebRendererError::RegistrationError(e.to_string()))? }; From 5b8642a70b339599f45042024fe8458c1c6dcf97 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 1 Mar 2026 21:43:00 +0100 Subject: [PATCH 06/18] Migrate pmoutils to registry and refactor dependencies Migrate pmoutils crate to registry version 0.1.2 and update all dependencies to use the registry version instead of local path references. Remove pmoutils from Cargo.lock and Cargo.toml files where it was previously used as a local path dependency. Move ToXmlElement trait to pmodidl crate and update all usages to import from pmodidl instead of pmoutils. Fix parameter order in find_process_using_port function call. --- Cargo.lock | 7 +- Cargo.toml | 1 - pmoconfig/Cargo.toml | 2 +- pmocontrol/Cargo.toml | 2 +- .../src/music_renderer/musicrenderer.rs | 2 +- pmodidl/Cargo.toml | 1 - pmodidl/src/lib.rs | 21 +- pmomediaserver/Cargo.toml | 2 +- pmomediaserver/src/content_handler.rs | 2 +- pmoupnp/Cargo.toml | 2 +- pmoupnp/src/upnp_server.rs | 2 +- pmoutils/Cargo.toml | 13 - pmoutils/Cargo.toml.backup | 13 - pmoutils/src/ip_utils.rs | 273 ------------------ pmoutils/src/lib.rs | 76 ----- pmoutils/src/process.rs | 88 ------ pmowebrenderer/Cargo.toml | 2 +- pmowebrenderer/src/handlers.rs | 2 +- 18 files changed, 31 insertions(+), 480 deletions(-) delete mode 100644 pmoutils/Cargo.toml delete mode 100644 pmoutils/Cargo.toml.backup delete mode 100644 pmoutils/src/ip_utils.rs delete mode 100644 pmoutils/src/lib.rs delete mode 100644 pmoutils/src/process.rs diff --git a/Cargo.lock b/Cargo.lock index fe7c03f8..e75089a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4047,7 +4047,6 @@ version = "0.1.0" dependencies = [ "bevy_reflect", "bevy_reflect_derive", - "pmoutils", "quick-xml", "serde", "utoipa", @@ -4362,15 +4361,15 @@ dependencies = [ [[package]] name = "pmoutils" -version = "0.1.0" +version = "0.1.2" +source = "sparse+https://gargoton.petite-maison-orange.fr/api/packages/pmo/cargo/" +checksum = "9ed68fb06ffdd1a30fc7792d883dcd6d61ad7705c3a744f54a0a5bcc341fc65e" dependencies = [ "get_if_addrs", "netstat2", "os_info", - "quick-xml", "sysinfo", "users", - "xmltree 0.10.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fd97a8da..135dbd7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "pmowebrenderer", "pmomediaserver", "pmoconfig", - "pmoutils", "pmodidl", "pmoserver", "pmoapp", diff --git a/pmoconfig/Cargo.toml b/pmoconfig/Cargo.toml index 81f85369..3a3790ad 100644 --- a/pmoconfig/Cargo.toml +++ b/pmoconfig/Cargo.toml @@ -5,7 +5,7 @@ version = "0.1.0" edition = "2021" [dependencies] -pmoutils ={ path = "../pmoutils" } +pmoutils = { version = "0.1.2", registry = "pmo" } serde = { workspace = true } serde_yaml = { workspace = true } diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index e91fb791..7b062861 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] pmoupnp = { path = "../pmoupnp" } pmodidl = { path = "../pmodidl" } -pmoutils = { path = "../pmoutils" } +pmoutils = { version = "0.1.2", registry = "pmo" } quick-xml = { workspace = true } thiserror = { workspace = true } ureq = "3.1.4" diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index efc929ed..0dfa758b 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -1562,7 +1562,7 @@ pub(crate) fn build_didl_lite_metadata( protocol_info: &str, ) -> String { use pmodidl::{DIDLLite, Item, Resource}; - use pmoutils::ToXmlElement; + use pmodidl::ToXmlElement; // Construire l'Item DIDL avec toutes les métadonnées let item = Item { diff --git a/pmodidl/Cargo.toml b/pmodidl/Cargo.toml index baf5a6bc..99f0ca59 100644 --- a/pmodidl/Cargo.toml +++ b/pmodidl/Cargo.toml @@ -10,5 +10,4 @@ utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } quick-xml = { workspace = true } bevy_reflect = "0.17.1" bevy_reflect_derive = "0.17.1" -pmoutils = { path = "../pmoutils" } xmltree = "0.10" diff --git a/pmodidl/src/lib.rs b/pmodidl/src/lib.rs index 3779add2..0f6af2c6 100644 --- a/pmodidl/src/lib.rs +++ b/pmodidl/src/lib.rs @@ -3,14 +3,31 @@ //! Parser et utilitaires pour le format DIDL-Lite utilisé dans UPnP/DLNA. use bevy_reflect::Reflect; -use pmoutils::ToXmlElement; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::HashSet; use std::fmt::Write; use std::io::Cursor; use std::time::SystemTime; -use xmltree::{Element, XMLNode}; +use xmltree::{Element, EmitterConfig, XMLNode}; + +/// Trait générique pour obtenir un élément XML (xmltree::Element). +pub trait ToXmlElement { + /// Convertit l'objet en élément XML. + fn to_xml_element(&self) -> Element; + + /// Sérialise en chaîne XML formatée. + fn to_xml(&self) -> String { + let elem = self.to_xml_element(); + let config = EmitterConfig::new() + .perform_indent(true) + .indent_string(" "); + let mut buf = Vec::new(); + elem.write_with_config(&mut buf, config) + .expect("Failed to write XML"); + String::from_utf8(buf).expect("Invalid UTF-8") + } +} // ============= Couche d'abstraction générique ============= diff --git a/pmomediaserver/Cargo.toml b/pmomediaserver/Cargo.toml index 27f46c4b..72dc6a97 100644 --- a/pmomediaserver/Cargo.toml +++ b/pmomediaserver/Cargo.toml @@ -18,7 +18,7 @@ quick-xml = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -pmoutils = { path = "../pmoutils" } +pmoutils = { version = "0.1.2", registry = "pmo" } # Optional dependencies axum = { version = "0.8", optional = true } diff --git a/pmomediaserver/src/content_handler.rs b/pmomediaserver/src/content_handler.rs index 652d241f..80ebffec 100644 --- a/pmomediaserver/src/content_handler.rs +++ b/pmomediaserver/src/content_handler.rs @@ -13,7 +13,7 @@ use pmodidl::{Container, DIDLLite}; use pmosource::api::{get_source as get_source_from_registry, list_all_sources}; use pmosource::{BrowseResult, MusicSource, MusicSourceError}; -use pmoutils::ToXmlElement; +use pmodidl::ToXmlElement; use std::collections::HashSet; use std::sync::Arc; diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index ab545720..844d6dd6 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] pmoconfig = { path = "../pmoconfig" } pmodidl = { path = "../pmodidl"} -pmoutils = { path = "../pmoutils" } +pmoutils = { version = "0.1.2", registry = "pmo" } pmoserver = { path = "../pmoserver" } pmocovers = { path = "../pmocovers", features = ["pmoserver"] } pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] } diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index 467755b8..8baeb70e 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -430,7 +430,7 @@ impl UpnpServerExt for Server { let kind = e.kind(); if kind == std::io::ErrorKind::AddrInUse { let port = crate::ssdp::SSDP_PORT; - if let Some(process) = find_process_using_port(port, TransportProtocol::Udp) { + if let Some(process) = find_process_using_port(TransportProtocol::Udp, port) { error!( "❌ SSDP initialization failed: port {} is already in use by \ PID {} ({}) owned by {}: {}", diff --git a/pmoutils/Cargo.toml b/pmoutils/Cargo.toml deleted file mode 100644 index dc3e22e3..00000000 --- a/pmoutils/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "pmoutils" -version = "0.1.0" -edition = "2024" - -[dependencies] -get_if_addrs = "0.5.3" -os_info = "3.8" -netstat2 = "0.11" -sysinfo = "0.30" -users = "0.11" -quick-xml = { workspace = true } -xmltree = "0.10" diff --git a/pmoutils/Cargo.toml.backup b/pmoutils/Cargo.toml.backup deleted file mode 100644 index 49e86a59..00000000 --- a/pmoutils/Cargo.toml.backup +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "pmoutils" -version = "0.1.0" -edition = "2024" - -[dependencies] -get_if_addrs = "0.5.3" -os_info = "3.8" -netstat2 = "0.11" -sysinfo = "0.30" -users = "0.11" -quick-xml = "0.38.3" -xmltree = "0.10" diff --git a/pmoutils/src/ip_utils.rs b/pmoutils/src/ip_utils.rs deleted file mode 100644 index c8ab423f..00000000 --- a/pmoutils/src/ip_utils.rs +++ /dev/null @@ -1,273 +0,0 @@ -use get_if_addrs::get_if_addrs; -use std::net::UdpSocket; - -/// Devine l'adresse IP locale de la machine. -/// -/// Cette fonction tente de déterminer l'adresse IP locale en créant une connexion UDP -/// vers un serveur DNS public (8.8.8.8). Cette technique permet d'identifier l'interface -/// réseau qui serait utilisée pour communiquer avec Internet. -/// -/// # Fonctionnement -/// -/// 1. Crée un socket UDP lié à `0.0.0.0:0` (n'importe quelle interface, port aléatoire) -/// 2. Tente une connexion (non effective pour UDP) vers `8.8.8.8:80` -/// 3. Récupère l'adresse IP locale du socket -/// 4. En cas d'échec à n'importe quelle étape, retourne `127.0.0.1` -/// -/// # Returns -/// -/// Retourne l'adresse IP locale sous forme de `String`, ou `"127.0.0.1"` en cas d'erreur. -/// -/// # Examples -/// -/// ``` -/// use pmoutils::guess_local_ip; -/// -/// let ip = guess_local_ip(); -/// println!("IP locale détectée: {}", ip); -/// // Affiche par exemple: "IP locale détectée: 192.168.1.42" -/// ``` -/// -/// # Note -/// -/// Cette méthode ne crée pas de véritable connexion réseau (UDP est sans connexion), -/// elle demande simplement au système d'exploitation quelle interface serait utilisée -/// pour joindre l'adresse cible. -pub fn guess_local_ip() -> String { - match UdpSocket::bind("0.0.0.0:0") { - Ok(socket) => { - if socket.connect("8.8.8.8:80").is_ok() { - if let Ok(local_addr) = socket.local_addr() { - return local_addr.ip().to_string(); - } - } - "127.0.0.1".to_string() - } - Err(_) => "127.0.0.1".to_string(), - } -} - -/// Liste toutes les adresses IP non-loopback des interfaces réseau. -/// -/// Parcourt toutes les interfaces réseau de la machine et collecte leurs adresses IPv4, -/// en excluant les adresses de loopback (127.0.0.1). -/// -/// # Returns -/// -/// Retourne une `HashMap` où : -/// - **Clé** : nom de l'interface réseau (ex: `"eth0"`, `"wlan0"`, `"en0"`) -/// - **Valeur** : vecteur des adresses IP (format String) associées à cette interface -/// -/// En cas d'erreur lors de la récupération des interfaces, retourne une HashMap -/// contenant une entrée `"error"` avec un message d'erreur. -/// -/// # Examples -/// -/// ``` -/// use pmoutils::ip_utils::list_all_ips; -/// -/// let ips = list_all_ips(); -/// for (interface, addresses) in ips { -/// println!("Interface {}: {:?}", interface, addresses); -/// } -/// // Affiche par exemple: -/// // Interface eth0: ["192.168.1.42"] -/// // Interface wlan0: ["10.0.0.15"] -/// ``` -/// -/// # Note -/// -/// - Seules les adresses IPv4 sont retournées -/// - Les adresses de loopback (127.x.x.x) sont filtrées -/// - Les adresses IPv6 sont ignorées -pub fn list_all_ips() -> std::collections::HashMap> { - let mut result = std::collections::HashMap::new(); - - if let Ok(interfaces) = get_if_addrs() { - for iface in interfaces { - let ip = iface.ip(); - if ip.is_loopback() { - continue; - } - if ip.is_ipv4() { - result - .entry(iface.name) - .or_insert_with(Vec::new) - .push(ip.to_string()); - } - } - } else { - result.insert( - "error".to_string(), - vec!["Failed to get interfaces".to_string()], - ); - } - - result -} - -#[cfg(test)] -mod tests { - use super::*; - use std::net::IpAddr; - - #[test] - fn test_guess_local_ip_returns_valid_ip() { - let ip = guess_local_ip(); - - // Vérifie que le résultat est parsable comme une IP - assert!( - ip.parse::().is_ok(), - "Should return a valid IP address" - ); - } - - #[test] - fn test_guess_local_ip_not_empty() { - let ip = guess_local_ip(); - - assert!(!ip.is_empty(), "IP should not be empty"); - } - - #[test] - fn test_guess_local_ip_is_ipv4() { - let ip = guess_local_ip(); - - if let Ok(parsed_ip) = ip.parse::() { - assert!(parsed_ip.is_ipv4(), "Should return an IPv4 address"); - } - } - - #[test] - fn test_guess_local_ip_fallback_is_localhost() { - // Ce test vérifie que si aucune IP n'est trouvée, on retourne 127.0.0.1 - // (difficile à tester sans mocker, mais on vérifie la cohérence) - let ip = guess_local_ip(); - let parsed = ip.parse::().unwrap(); - - // L'IP doit être soit locale (127.0.0.1) soit une IP privée valide - assert!( - parsed.is_loopback() || is_private_ip(&ip), - "IP should be either loopback or private" - ); - } - - #[test] - fn test_list_all_ips_no_loopback() { - let ips = list_all_ips(); - - // Vérifie qu'aucune adresse de loopback n'est présente - for (_, addresses) in ips.iter() { - for addr in addresses { - if let Ok(parsed_ip) = addr.parse::() { - assert!( - !parsed_ip.is_loopback(), - "Loopback addresses should be filtered out" - ); - } - } - } - } - - #[test] - fn test_list_all_ips_only_ipv4() { - let ips = list_all_ips(); - - // Vérifie que seules des adresses IPv4 sont retournées - for (iface_name, addresses) in ips.iter() { - if iface_name == "error" { - continue; // Skip error entries - } - - for addr in addresses { - if let Ok(parsed_ip) = addr.parse::() { - assert!( - parsed_ip.is_ipv4(), - "Only IPv4 addresses should be returned" - ); - } - } - } - } - - #[test] - fn test_list_all_ips_valid_format() { - let ips = list_all_ips(); - - // Vérifie que toutes les IPs sont dans un format valide - for (iface_name, addresses) in ips.iter() { - if iface_name == "error" { - continue; - } - - for addr in addresses { - assert!( - addr.parse::().is_ok(), - "Each IP should be in valid format: {}", - addr - ); - } - } - } - - #[test] - fn test_list_all_ips_interface_names_not_empty() { - let ips = list_all_ips(); - - // Vérifie que les noms d'interface ne sont pas vides - for (iface_name, _) in ips.iter() { - assert!( - !iface_name.is_empty(), - "Interface names should not be empty" - ); - } - } - - #[test] - fn test_list_all_ips_no_duplicate_ips_per_interface() { - let ips = list_all_ips(); - - // Vérifie qu'il n'y a pas de doublons par interface - for (iface_name, addresses) in ips.iter() { - if iface_name == "error" { - continue; - } - - let unique_addresses: std::collections::HashSet<_> = addresses.iter().collect(); - assert_eq!( - addresses.len(), - unique_addresses.len(), - "No duplicate IPs should exist for interface {}", - iface_name - ); - } - } - - // Fonction helper pour les tests - fn is_private_ip(ip_str: &str) -> bool { - if let Ok(ip) = ip_str.parse::() { - match ip { - IpAddr::V4(ipv4) => { - let octets = ipv4.octets(); - // Plages privées: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 - octets[0] == 10 - || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) - || (octets[0] == 192 && octets[1] == 168) - } - IpAddr::V6(_) => false, - } - } else { - false - } - } - - #[test] - fn test_helper_is_private_ip() { - // Tests pour la fonction helper - assert!(is_private_ip("10.0.0.1")); - assert!(is_private_ip("172.16.0.1")); - assert!(is_private_ip("192.168.1.1")); - assert!(!is_private_ip("8.8.8.8")); - assert!(!is_private_ip("127.0.0.1")); // loopback n'est pas "privé" au sens réseau local - } -} diff --git a/pmoutils/src/lib.rs b/pmoutils/src/lib.rs deleted file mode 100644 index 0e0a0279..00000000 --- a/pmoutils/src/lib.rs +++ /dev/null @@ -1,76 +0,0 @@ -/// Utilitaires pour la gestion des adresses IP réseau. -/// -/// Ce module fournit des fonctions pour détecter et lister les adresses IP -/// des interfaces réseau locales de la machine. -/// -/// # Fonctions principales -/// -/// - [`guess_local_ip`] : Devine l'adresse IP locale utilisée pour les connexions sortantes -/// -/// # Examples -/// -/// ``` -/// use pmoutils::guess_local_ip; -/// -/// let ip = guess_local_ip(); -/// println!("Adresse IP locale: {}", ip); -/// ``` -pub mod ip_utils; - -pub use ip_utils::guess_local_ip; -pub mod process; -pub use process::{ProcessPortInfo, TransportProtocol, find_process_using_port}; -use xmltree::{Element, EmitterConfig}; - -/// Retourne une chaîne décrivant le système d'exploitation et sa version. -/// -/// Utilise la crate `os_info` pour obtenir de manière portable et fiable -/// les informations sur le système d'exploitation courant. -/// -/// # Format -/// - macOS: "macOS/15.1" ou "Mac OS/10.15.7" -/// - Linux: "Linux/6.5.0" ou "Ubuntu/22.04" -/// - Windows: "Windows/10.0.19045" -/// - Autre: "{OS}/Unknown" -/// -/// # Exemples -/// -/// ``` -/// use pmoutils::get_os_string; -/// -/// let os = get_os_string(); -/// println!("OS: {}", os); // Ex: "Linux/6.5.0" -/// ``` -pub fn get_os_string() -> String { - let info = os_info::get(); - let os_type = format!("{:?}", info.os_type()); - - // Obtenir la version si disponible - let version = info.version(); - if version != &os_info::Version::Unknown { - format!("{}/{}", os_type, version) - } else { - format!("{}/Unknown", os_type) - } -} - -/// Trait générique pour obtenir un élément XML (xmltree::Element). -/// -/// Aligné sur la signature utilisée dans pmoupnp (UpnpObject::to_xml_element), -/// afin de pouvoir factoriser la sérialisation XML entre crates. -pub trait ToXmlElement { - /// Convertit l'objet en élément XML. - fn to_xml_element(&self) -> Element; - - /// Sérialise en chaîne XML formatée. - fn to_xml(&self) -> String { - let elem = self.to_xml_element(); - let config = EmitterConfig::new() - .perform_indent(true) - .indent_string(" "); - let mut buf = Vec::new(); - elem.write_with_config(&mut buf, config) - .expect("Failed to write XML"); - String::from_utf8(buf).expect("Invalid UTF-8") - } -} diff --git a/pmoutils/src/process.rs b/pmoutils/src/process.rs deleted file mode 100644 index 37b46f75..00000000 --- a/pmoutils/src/process.rs +++ /dev/null @@ -1,88 +0,0 @@ -use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, get_sockets_info}; -use sysinfo::{Pid, System}; - -/// Informations sur un processus utilisant un port réseau. -#[derive(Debug, Clone)] -pub struct ProcessPortInfo { - pub pid: u32, - pub process_name: String, - pub owner: String, - pub port: u16, -} - -/// Protocole de transport utilisé pour la recherche. -#[derive(Debug, Clone, Copy)] -pub enum TransportProtocol { - Tcp, - Udp, -} - -/// Tente de trouver le processus qui écoute sur `port` pour le protocole donné. -/// -/// Retourne `Some(ProcessPortInfo)` si un processus a pu être identifié, sinon `None`. -pub fn find_process_using_port(port: u16, protocol: TransportProtocol) -> Option { - let proto_flag = match protocol { - TransportProtocol::Tcp => ProtocolFlags::TCP, - TransportProtocol::Udp => ProtocolFlags::UDP, - }; - - let sockets = get_sockets_info( - AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6, - proto_flag, - ) - .ok()?; - - // Préparer l'inspection des processus. - let mut system = System::new_all(); - system.refresh_all(); - - for socket in sockets { - match socket.protocol_socket_info { - ProtocolSocketInfo::Tcp(ref tcp_info) - if matches!(protocol, TransportProtocol::Tcp) && tcp_info.local_port == port => - { - if let Some(info) = - build_process_info(&mut system, port, socket.associated_pids.first()) - { - return Some(info); - } - } - ProtocolSocketInfo::Udp(ref udp_info) - if matches!(protocol, TransportProtocol::Udp) && udp_info.local_port == port => - { - if let Some(info) = - build_process_info(&mut system, port, socket.associated_pids.first()) - { - return Some(info); - } - } - _ => continue, - } - } - - None -} - -fn build_process_info( - system: &mut System, - port: u16, - pid_opt: Option<&u32>, -) -> Option { - let pid = *pid_opt?; - let process = system.process(Pid::from_u32(pid))?; - let process_name = process.name().to_string(); - - let owner = process - .user_id() - .and_then(|uid| { - users::get_user_by_uid(**uid).map(|user| user.name().to_string_lossy().into_owned()) - }) - .unwrap_or_else(|| "unknown".to_string()); - - Some(ProcessPortInfo { - pid, - process_name, - owner, - port, - }) -} diff --git a/pmowebrenderer/Cargo.toml b/pmowebrenderer/Cargo.toml index 6809822b..71df801d 100644 --- a/pmowebrenderer/Cargo.toml +++ b/pmowebrenderer/Cargo.toml @@ -40,7 +40,7 @@ thiserror = { workspace = true } tracing = { workspace = true } pmodidl = { path = "../pmodidl" } -pmoutils = { path = "../pmoutils" } +pmoutils = { version = "0.1.2", registry = "pmo" } [features] default = [] diff --git a/pmowebrenderer/src/handlers.rs b/pmowebrenderer/src/handlers.rs index 48fd08d2..c29d8152 100644 --- a/pmowebrenderer/src/handlers.rs +++ b/pmowebrenderer/src/handlers.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use pmodidl::DIDLLite; use pmoupnp::actions::{ActionData, ActionError, ActionHandler, get_value}; use pmoupnp::{get, set}; -use pmoutils::ToXmlElement; +use pmodidl::ToXmlElement; use crate::messages::PlaybackState; use crate::pipeline::{PipelineControl, PipelineHandle, seconds_to_upnp_time, upnp_time_to_seconds}; From e33484b80279003467d82c513830d2f0933833bf Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 1 Mar 2026 22:46:00 +0100 Subject: [PATCH 07/18] Refactor OGG-FLAC streaming to support multi-client broadcast Migrate from single-client direct OGG-FLAC sink to multi-client broadcast streaming sink. - Replace DirectOggFlacHandle with OggFlacStreamHandle for multi-client support - Update sink implementation to use StreamingOggFlacSink with proper backpressure - Change connection model from 'connect()' to 'subscribe()' - Adjust stream handling to support independent client streams - Update stream endpoint to always respond with 200 chunked instead of range requests - Add tracing for stream lifecycle events - Reduce OGG channel capacity to strict backpressure (1) - Add Drop implementation for stream cleanup - Improve logging and error handling for concurrent access This change enables multiple simultaneous clients to connect to the same audio stream without interfering with each other, while maintaining proper TCP backpressure and stream lifecycle management. --- .../src/sinks/direct_ogg_flac_sink.rs | 21 +++++++++++---- pmowebrenderer/src/pipeline.rs | 17 ++++++------ pmowebrenderer/src/registry.rs | 12 ++++----- pmowebrenderer/src/stream.rs | 27 +++++++------------ 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs b/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs index 92b4409b..efef08fd 100644 --- a/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs @@ -50,9 +50,9 @@ pub const DIRECT_OGG_FLAC_SAMPLE_RATE: u32 = 96_000; pub const DIRECT_OGG_FLAC_CHANNELS: u8 = 2; pub const DIRECT_OGG_FLAC_BITS_PER_SAMPLE: u8 = 24; -/// Capacité du canal OGG → HTTP (en chunks de ~8 KB). -/// Fournit un petit buffer sans casser la backpressure TCP. -const OGG_CHANNEL_CAPACITY: usize = 8; +/// Capacité du canal OGG → HTTP. +/// Capacité 1 : backpressure stricte, l'encodeur ne produit pas en avance. +const OGG_CHANNEL_CAPACITY: usize = 1; // ─── Types partagés ─────────────────────────────────────────────────────────── @@ -79,7 +79,7 @@ impl DirectOggFlacHandle { /// Retourne le stream OGG-FLAC pour le handler HTTP. /// Lance le premier encodeur au premier appel. pub fn get_stream(&self) -> DirectOggFlacStream { - debug!("DirectOggFlacHandle::get_stream()"); + tracing::info!(target: "pmoaudio_ext::stream_connect", "get_stream() called — new HTTP client connecting"); self.client_notify.notify_one(); DirectOggFlacStream { ogg_rx: self.ogg_rx.clone(), @@ -100,6 +100,12 @@ pub struct DirectOggFlacStream { buffer: VecDeque, } +impl Drop for DirectOggFlacStream { + fn drop(&mut self) { + tracing::info!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream dropped — HTTP client disconnected"); + } +} + impl AsyncRead for DirectOggFlacStream { fn poll_read( mut self: Pin<&mut Self>, @@ -118,6 +124,7 @@ impl AsyncRead for DirectOggFlacStream { let mut guard = match self.ogg_rx.try_lock() { Ok(g) => g, Err(_) => { + tracing::warn!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream: try_lock() FAILED — concurrent access detected!"); cx.waker().wake_by_ref(); return Poll::Pending; } @@ -126,6 +133,7 @@ impl AsyncRead for DirectOggFlacStream { match guard.poll_recv(cx) { Poll::Ready(Some(bytes)) => { drop(guard); + tracing::info!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream: sending {} bytes to HTTP client", bytes.len()); let to_copy = bytes.len().min(buf.remaining()); buf.put_slice(&bytes[..to_copy]); if to_copy < bytes.len() { @@ -133,7 +141,10 @@ impl AsyncRead for DirectOggFlacStream { } Poll::Ready(Ok(())) } - Poll::Ready(None) => Poll::Ready(Ok(())), // EOF + Poll::Ready(None) => { + tracing::info!(target: "pmoaudio_ext::stream_connect", "DirectOggFlacStream: ogg_rx closed → EOF sent to HTTP client"); + Poll::Ready(Ok(())) + } Poll::Pending => Poll::Pending, } } diff --git a/pmowebrenderer/src/pipeline.rs b/pmowebrenderer/src/pipeline.rs index d8f36f61..37b27dfc 100644 --- a/pmowebrenderer/src/pipeline.rs +++ b/pmowebrenderer/src/pipeline.rs @@ -10,8 +10,7 @@ use std::sync::Arc; use pmoaudio::{AudioSegment, ResamplingNode, ToI24Node}; use pmometadata::{MemoryTrackMetadata, TrackMetadata}; use pmoaudio_ext::sinks::{ - DirectOggFlacHandle, DirectOggFlacSink, - DIRECT_OGG_FLAC_SAMPLE_RATE, + OggFlacStreamHandle, StreamingOggFlacSink, }; use pmoaudio_ext::UriSource; use pmoflac::EncoderOptions; @@ -59,10 +58,10 @@ impl PipelineHandle { /// Pipeline audio complet pour une instance WebRenderer. /// /// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle` -/// (mono-client, chaque `connect()` crée un nouveau flux avec backpressure TCP). +/// (multi-client broadcast, chaque `subscribe()` crée un flux indépendant). pub struct InstancePipeline { - /// Handle vers le sink OGG-FLAC — clonable, connect() crée un nouveau flux. - pub flac_handle: DirectOggFlacHandle, + /// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client. + pub flac_handle: OggFlacStreamHandle, pub pipeline_handle: PipelineHandle, } @@ -78,18 +77,18 @@ impl InstancePipeline { let stop_token = CancellationToken::new(); let (control_tx, control_rx) = mpsc::channel::(32); - // Chaîne de traitement : ResamplingNode(96kHz) → ToI24Node → DirectFlacSink - // La backpressure remonte naturellement depuis le pipe duplex jusqu'à la source. + // Chaîne de traitement : ResamplingNode(96kHz) → ToI24Node → StreamingOggFlacSink + // Le broadcast pacé à 0.5s max d'avance, chaque subscribe() est indépendant. use pmoaudio::pipeline::AudioPipelineNode; - let (sink, flac_handle) = DirectOggFlacSink::new(EncoderOptions::default()); + 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(DIRECT_OGG_FLAC_SAMPLE_RATE); + let mut resampler = ResamplingNode::new(96_000); resampler.register(to_i24.boxed()); // Le tx d'entrée du resampler est le point d'entrée du pipeline diff --git a/pmowebrenderer/src/registry.rs b/pmowebrenderer/src/registry.rs index 7cb187b7..cbc4b6be 100644 --- a/pmowebrenderer/src/registry.rs +++ b/pmowebrenderer/src/registry.rs @@ -29,8 +29,8 @@ pub struct WebRendererInstance { pub udn: String, pub device_instance: Arc, pub state: SharedState, - /// Handle vers le sink OGG-FLAC — clonable, connect() crée un nouveau flux avec backpressure. - pub flac_handle: pmoaudio_ext::sinks::DirectOggFlacHandle, + /// Handle vers le sink OGG-FLAC — clonable, subscribe() crée un flux indépendant par client. + pub flac_handle: pmoaudio_ext::sinks::OggFlacStreamHandle, pub pipeline: PipelineHandle, pub created_at: SystemTime, } @@ -116,16 +116,16 @@ impl RendererRegistry { Ok((stream_url, udn)) } - /// Retourne un DirectOggFlacStream pour l'endpoint /stream. - /// Chaque appel retourne un wrapper sur le même reader persistant. + /// Retourne un OggFlacClientStream indépendant pour l'endpoint /stream. + /// Chaque appel crée un nouveau subscriber broadcast — safe pour connexions simultanées. pub fn get_stream( &self, instance_id: &str, - ) -> Option { + ) -> Option { self.instances .read() .get(instance_id) - .map(|i| i.flac_handle.get_stream()) + .map(|i| i.flac_handle.subscribe()) } /// Retourne le PipelineHandle par UDN (pour les handlers UPnP) diff --git a/pmowebrenderer/src/stream.rs b/pmowebrenderer/src/stream.rs index 2e759ebb..97cf511d 100644 --- a/pmowebrenderer/src/stream.rs +++ b/pmowebrenderer/src/stream.rs @@ -2,16 +2,15 @@ //! //! Sert le flux OGG-FLAC d'une instance WebRenderer. //! -//! Safari fait systématiquement une requête Range: bytes=0-1 avant de jouer. -//! On répond 206 avec 2 octets factices pour satisfaire la sonde, -//! puis la vraie requête (sans Range) reçoit le stream persistant. +//! Safari envoie parfois Range: bytes=0-N avant de jouer. +//! On ignore ce header et on répond toujours 200 chunked (flux live infini). use axum::{ body::Body, extract::{Path, State}, http::{ HeaderMap, StatusCode, - header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, CONTENT_RANGE, CONTENT_LENGTH, ACCEPT_RANGES}, + header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE, TRANSFER_ENCODING}, }, response::{IntoResponse, Response}, }; @@ -29,20 +28,12 @@ pub async fn stream_handler( ) -> impl IntoResponse { info!(instance_id = %instance_id, "FLAC stream client connecting"); - // Détecter la sonde Range: bytes=0-1 de Safari + // Ignorer le header Range — flux live infini, non seekable. + // On ne répond jamais 206 ni 416 : toujours 200 chunked. + // Safari (et d'autres clients) envoient parfois Range: bytes=0-N ; + // répondre 416 ou 206 leur fait croire à une ressource finie. if let Some(range) = headers.get("range") { - if range.as_bytes() == b"bytes=0-1" { - info!(instance_id = %instance_id, "Safari range probe — responding 206"); - return Response::builder() - .status(StatusCode::PARTIAL_CONTENT) - .header(CONTENT_TYPE, "audio/ogg; codecs=flac") - .header(ACCEPT_RANGES, "bytes") - .header(CONTENT_RANGE, "bytes 0-1/*") - .header(CONTENT_LENGTH, "2") - .body(Body::from(vec![0u8, 0u8])) - .unwrap() - .into_response(); - } + info!(instance_id = %instance_id, "Range header ignored (live stream): {:?}", range); } let stream = match registry.get_stream(&instance_id) { @@ -63,7 +54,7 @@ pub async fn stream_handler( .header(CONTENT_TYPE, "audio/ogg; codecs=flac") .header(CACHE_CONTROL, "no-store, no-transform") .header(CONNECTION, "keep-alive") - .header(ACCEPT_RANGES, "bytes") + .header(TRANSFER_ENCODING, "chunked") .header("X-Content-Type-Options", "nosniff") .body(Body::from_stream(ReaderStream::new(stream))) .unwrap() From 3cb584646c53163fa83cda86d0649dc935da28bd Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 1 Mar 2026 23:09:28 +0100 Subject: [PATCH 08/18] =?UTF-8?q?Pipeline:=20Ajout=20de=20la=20reprise=20d?= =?UTF-8?q?epuis=20la=20position=20paus=C3=A9e=20et=20am=C3=A9lioration=20?= =?UTF-8?q?des=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente la reprise depuis la position pausée lors du redémarrage de la source, en conservant la position dans l'état. Met à jour les logs pour mieux refléter le comportement de pause et d'arrêt. Les modifications incluent : - Ajout du seek lors du redémarrage de la source - Mise à jour de la gestion de la pause pour conserver la position - Amélioration des messages de log pour plus de clarté --- pmowebrenderer/src/pipeline.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pmowebrenderer/src/pipeline.rs b/pmowebrenderer/src/pipeline.rs index 37b27dfc..71c4d2d7 100644 --- a/pmowebrenderer/src/pipeline.rs +++ b/pmowebrenderer/src/pipeline.rs @@ -210,7 +210,11 @@ async fn run_pipeline( // Redémarrer la source si elle n'est pas en cours if current_source_stop.is_none() { if let Some(uri) = current_uri.clone() { - info!(udn = %udn, uri = %uri, "Pipeline: Play — restarting source"); + // Reprendre depuis la position pausée (0.0 si pas de pause) + let seek = state.read().position.as_deref() + .map(upnp_time_to_seconds) + .unwrap_or(0.0); + info!(udn = %udn, uri = %uri, seek = seek, "Pipeline: Play — restarting source"); let src_stop = stop_token.child_token(); current_source_stop = Some(src_stop.clone()); let tx = segment_tx.clone(); @@ -221,7 +225,7 @@ async fn run_pipeline( let cp = control_point.clone(); tokio::spawn(async move { stream_source( - uri, 0.0, tx, src_stop, st, udn_c, + uri, seek, tx, src_stop, st, udn_c, #[cfg(feature = "pmoserver")] cp, ).await; @@ -236,7 +240,12 @@ async fn run_pipeline( } Some(PipelineControl::Pause) => { - debug!(udn = %udn, "Pipeline: Pause (not supported on live stream)"); + info!(udn = %udn, "Pipeline: Pause — stopping source, position preserved"); + if let Some(src_stop) = current_source_stop.take() { + src_stop.cancel(); + } + state.write().playback_state = PlaybackState::Paused; + // position reste dans state pour la reprise via Play } Some(PipelineControl::Stop) => { From f57754b5f8d8fa8f2bbea0810b230c016a2aea87 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 1 Mar 2026 23:40:07 +0100 Subject: [PATCH 09/18] =?UTF-8?q?Impl=C3=A9mentation=20du=20PlayerSource?= =?UTF-8?q?=20pour=20le=20contr=C3=B4le=20AVTransport=20UPnP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout de PlayerSource avec contrôle complet AVTransport (Play/Pause/Stop/Seek/LoadUri) dans le pipeline audio - Introduit PlayerSource comme nœud source audio avec gestion complète du cycle de vie UPnP - Implémente les commandes LoadUri, LoadNextUri, Play, Pause, Stop, Seek - Gestion des transitions gapless avec TrackBoundary pour un bitstream propre - Révision du pipeline audio pour utiliser PlayerSource au lieu de la logique de contrôle existante - Mise à jour des handlers UPnP pour utiliser PlayerHandle au lieu du canal de contrôle - Suppression des anciennes commandes PipelineControl et logique de gestion de source - Ajout de gestion d'événements PlayerEvent pour mise à jour de l'état UPnP - Migration vers StreamingOggFlacSink pour la diffusion audio --- pmoaudio-ext/src/lib.rs | 3 + pmoaudio-ext/src/sources/mod.rs | 6 + pmoaudio-ext/src/sources/player_source.rs | 540 ++++++++++++++++++++++ pmowebrenderer/src/handlers.rs | 8 +- pmowebrenderer/src/pipeline.rs | 408 ++++------------ 5 files changed, 644 insertions(+), 321 deletions(-) create mode 100644 pmoaudio-ext/src/sources/player_source.rs diff --git a/pmoaudio-ext/src/lib.rs b/pmoaudio-ext/src/lib.rs index 6dfc44b8..ce2b9a25 100755 --- a/pmoaudio-ext/src/lib.rs +++ b/pmoaudio-ext/src/lib.rs @@ -43,3 +43,6 @@ pub use sources::PlaylistSource; #[cfg(feature = "http-stream")] pub use sources::UriSource; + +#[cfg(feature = "http-stream")] +pub use sources::{PlayerCommand, PlayerEvent, PlayerHandle, PlayerSource}; diff --git a/pmoaudio-ext/src/sources/mod.rs b/pmoaudio-ext/src/sources/mod.rs index ce87b685..86d5174c 100644 --- a/pmoaudio-ext/src/sources/mod.rs +++ b/pmoaudio-ext/src/sources/mod.rs @@ -19,3 +19,9 @@ mod uri_source; #[cfg(feature = "http-stream")] pub use uri_source::UriSource; + +#[cfg(feature = "http-stream")] +mod player_source; + +#[cfg(feature = "http-stream")] +pub use player_source::{PlayerCommand, PlayerEvent, PlayerHandle, PlayerSource}; diff --git a/pmoaudio-ext/src/sources/player_source.rs b/pmoaudio-ext/src/sources/player_source.rs new file mode 100644 index 00000000..7fdfecfa --- /dev/null +++ b/pmoaudio-ext/src/sources/player_source.rs @@ -0,0 +1,540 @@ +//! PlayerSource — Source audio avec contrôle de transport AVTransport UPnP. +//! +//! Implémente un nœud `AudioPipelineNode` qui encapsule le cycle de vie complet +//! d'un renderer UPnP : LoadUri, LoadNextUri, Play, Pause, Stop, Seek. +//! +//! # Architecture +//! +//! ```text +//! PlayerHandle (clonable, envoyé aux handlers UPnP) +//! │ PlayerCommand (mpsc) +//! ▼ +//! PlayerSource (AudioPipelineNode — nœud source) +//! │ AudioSegment (mpsc) +//! ▼ +//! ResamplingNode → ToI24Node → StreamingOggFlacSink → HTTP clients +//! ``` +//! +//! # Gestion de la Pause +//! +//! La Pause ne coupe pas la source brutalement. Elle bloque l'émission de chunks. +//! À la reprise, un `TrackBoundary` est injecté avant les données — cela déclenche +//! EOS + nouveau BOS OGG dans `StreamingOggFlacSink`, garantissant un bitstream +//! propre aligné sur un frame boundary. +//! +//! # Transitions gapless +//! +//! Si `LoadNextUri` a été appelé avant la fin de la piste courante, la transition +//! se fait via un `TrackBoundary` sans interruption du flux OGG. + +use std::sync::Arc; + +use async_trait::async_trait; +use pmometadata::{MemoryTrackMetadata, TrackMetadata}; +use pmoaudio::{ + AudioSegment, SyncMarker, + nodes::AudioError, + pipeline::{AudioPipelineNode, Node, NodeLogic, send_to_children}, +}; +use tokio::sync::{broadcast, mpsc}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use super::uri_source::UriSource; + +// ─── Commandes de transport ─────────────────────────────────────────────────── + +/// Commandes de transport AVTransport UPnP +#[derive(Debug)] +pub enum PlayerCommand { + /// SetAVTransportURI — charge une URI (démarre pas encore) + LoadUri(String), + /// SetNextAVTransportURI — pré-charge pour transition gapless + LoadNextUri(String), + /// Play — démarre ou reprend la lecture + Play, + /// Pause — suspend la lecture, position préservée + Pause, + /// Stop — arrête la lecture, position remise à 0 + Stop, + /// Seek — reprend depuis la position donnée (en secondes) + Seek(f64), +} + +// ─── Événements remontés ────────────────────────────────────────────────────── + +/// Événements remontés par la PlayerSource vers les handlers UPnP +#[derive(Debug, Clone)] +pub enum PlayerEvent { + /// Lecture démarrée ou reprise + Playing { + uri: String, + duration_sec: Option, + }, + /// Lecture suspendue + Paused { + position_sec: f64, + }, + /// Lecture arrêtée + Stopped, + /// Fin de piste (pour que le ControlPoint avance la queue) + TrackEnded, + /// Erreur lors de l'ouverture ou de la lecture + Error(String), +} + +// ─── Handle de contrôle ─────────────────────────────────────────────────────── + +/// Handle de contrôle clonable, envoyé aux handlers UPnP +#[derive(Clone)] +pub struct PlayerHandle { + command_tx: mpsc::Sender, + event_tx: broadcast::Sender, +} + +impl PlayerHandle { + /// Charge une URI (sans démarrer la lecture) + pub async fn load_uri(&self, uri: impl Into) { + let _ = self.command_tx.send(PlayerCommand::LoadUri(uri.into())).await; + } + + /// Pré-charge l'URI suivante pour transition gapless + pub async fn load_next_uri(&self, uri: impl Into) { + let _ = self.command_tx.send(PlayerCommand::LoadNextUri(uri.into())).await; + } + + /// Démarre ou reprend la lecture + pub async fn play(&self) { + let _ = self.command_tx.send(PlayerCommand::Play).await; + } + + /// Suspend la lecture (position préservée) + pub async fn pause(&self) { + let _ = self.command_tx.send(PlayerCommand::Pause).await; + } + + /// Arrête la lecture (position remise à 0) + pub async fn stop(&self) { + let _ = self.command_tx.send(PlayerCommand::Stop).await; + } + + /// Reprend depuis la position donnée (en secondes) + pub async fn seek(&self, pos_sec: f64) { + let _ = self.command_tx.send(PlayerCommand::Seek(pos_sec)).await; + } + + /// Souscrit aux événements de transport + pub fn subscribe_events(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } +} + +// ─── État de transport ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +enum TransportState { + /// Aucune URI chargée + Idle, + /// URI chargée, en attente de Play + Loaded, + /// Lecture en cours + Playing, + /// Lecture suspendue + Paused, +} + +// ─── Logique interne ────────────────────────────────────────────────────────── + +struct PlayerSourceLogic { + command_rx: mpsc::Receiver, + event_tx: broadcast::Sender, +} + +#[async_trait] +impl NodeLogic for PlayerSourceLogic { + async fn process( + &mut self, + _input: Option>>, + output: Vec>>, + stop_token: CancellationToken, + ) -> Result<(), AudioError> { + let mut state = TransportState::Idle; + let mut current_uri: Option = None; + let mut next_uri: Option = None; + let mut paused_at_sec: f64 = 0.0; + + info!("PlayerSource: started"); + + loop { + match state { + TransportState::Idle | TransportState::Loaded | TransportState::Paused => { + // Attendre une commande + tokio::select! { + _ = stop_token.cancelled() => { + debug!("PlayerSource: stop requested"); + break; + } + cmd = self.command_rx.recv() => { + match cmd { + None => break, + Some(cmd) => { + self.handle_command( + cmd, &mut state, &mut current_uri, + &mut next_uri, &mut paused_at_sec, + &output, &stop_token, + ).await?; + } + } + } + } + } + + TransportState::Playing => { + let uri = match current_uri.clone() { + Some(u) => u, + None => { + state = TransportState::Idle; + continue; + } + }; + + // Ouvrir la source depuis la position courante + let source = match UriSource::open(&uri, paused_at_sec, stop_token.clone()).await { + Ok(s) => s, + Err(e) => { + warn!("PlayerSource: failed to open {:?}: {}", uri, e); + let _ = self.event_tx.send(PlayerEvent::Error(e.to_string())); + state = TransportState::Idle; + continue; + } + }; + + let duration_sec = source.duration_sec(); + let _ = self.event_tx.send(PlayerEvent::Playing { + uri: uri.clone(), + duration_sec, + }); + info!("PlayerSource: playing {:?} from {:.1}s", uri, paused_at_sec); + + // Pompe audio — s'arrête sur EOF, Pause, Stop, ou commande + let result = self.pump( + source, + &mut state, + &mut current_uri, + &mut next_uri, + &mut paused_at_sec, + &output, + &stop_token, + ).await; + + match result { + Ok(()) => {} + Err(AudioError::ChildDied) => { + debug!("PlayerSource: child died, stopping"); + break; + } + Err(e) => { + warn!("PlayerSource: pump error: {}", e); + let _ = self.event_tx.send(PlayerEvent::Error(e.to_string())); + state = TransportState::Idle; + } + } + } + } + } + + info!("PlayerSource: stopped"); + Ok(()) + } +} + +impl PlayerSourceLogic { + /// Traite une commande de transport dans les états non-Playing. + async fn handle_command( + &mut self, + cmd: PlayerCommand, + state: &mut TransportState, + current_uri: &mut Option, + next_uri: &mut Option, + paused_at_sec: &mut f64, + output: &[mpsc::Sender>], + stop_token: &CancellationToken, + ) -> Result<(), AudioError> { + match cmd { + PlayerCommand::LoadUri(uri) => { + info!("PlayerSource: LoadUri {:?}", uri); + *current_uri = Some(uri); + *next_uri = None; + *paused_at_sec = 0.0; + *state = TransportState::Loaded; + } + + PlayerCommand::LoadNextUri(uri) => { + debug!("PlayerSource: LoadNextUri {:?}", uri); + *next_uri = Some(uri); + } + + PlayerCommand::Play => { + match state { + TransportState::Paused => { + info!("PlayerSource: Play (resume from {:.1}s)", paused_at_sec); + // Injecter un TrackBoundary pour EOS + nouveau BOS OGG propre + send_track_boundary(current_uri.as_deref(), output, *paused_at_sec, stop_token).await?; + *state = TransportState::Playing; + } + TransportState::Loaded => { + info!("PlayerSource: Play (start)"); + *paused_at_sec = 0.0; + // TrackBoundary initial pour le premier BOS OGG + send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await?; + *state = TransportState::Playing; + } + TransportState::Idle => { + debug!("PlayerSource: Play ignored (no URI loaded)"); + } + TransportState::Playing => { + debug!("PlayerSource: Play ignored (already playing)"); + } + } + } + + PlayerCommand::Pause => { + if *state == TransportState::Playing { + // Géré dans pump() — ne devrait pas arriver ici + debug!("PlayerSource: Pause ignored in non-Playing state"); + } else { + debug!("PlayerSource: Pause ignored (not playing)"); + } + } + + PlayerCommand::Stop => { + info!("PlayerSource: Stop"); + *paused_at_sec = 0.0; + *state = TransportState::Idle; + let _ = self.event_tx.send(PlayerEvent::Stopped); + } + + PlayerCommand::Seek(pos) => { + if current_uri.is_some() { + info!("PlayerSource: Seek to {:.1}s", pos); + *paused_at_sec = pos; + send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await?; + *state = TransportState::Playing; + } + } + } + Ok(()) + } + + /// Pompe audio : émet les chunks depuis `source` vers `output`. + /// + /// Surveille simultanément les commandes de contrôle. + /// Se termine quand : EOF, Pause, Stop, cancel, ou erreur. + async fn pump( + &mut self, + source: UriSource, + state: &mut TransportState, + current_uri: &mut Option, + next_uri: &mut Option, + paused_at_sec: &mut f64, + output: &[mpsc::Sender>], + stop_token: &CancellationToken, + ) -> Result<(), AudioError> { + // Canal interne pour recevoir les chunks de UriSource + let (chunk_tx, mut chunk_rx) = mpsc::channel::>(16); + let source_stop = stop_token.child_token(); + let source_stop_clone = source_stop.clone(); + + // Spawner l'émission de la source dans une tâche séparée + let emit_task = tokio::spawn(async move { + source.emit_to_channel(&chunk_tx, &source_stop_clone).await + }); + + let mut result = Ok(()); + + loop { + tokio::select! { + _ = stop_token.cancelled() => { + source_stop.cancel(); + break; + } + + cmd = self.command_rx.recv() => { + match cmd { + None => { + source_stop.cancel(); + break; + } + Some(PlayerCommand::Pause) => { + info!("PlayerSource: Pause at {:.1}s", *paused_at_sec); + source_stop.cancel(); + *state = TransportState::Paused; + let _ = self.event_tx.send(PlayerEvent::Paused { + position_sec: *paused_at_sec, + }); + break; + } + Some(PlayerCommand::Stop) => { + info!("PlayerSource: Stop"); + source_stop.cancel(); + *paused_at_sec = 0.0; + *state = TransportState::Idle; + let _ = self.event_tx.send(PlayerEvent::Stopped); + break; + } + Some(PlayerCommand::LoadUri(uri)) => { + info!("PlayerSource: LoadUri (replacing current) {:?}", uri); + source_stop.cancel(); + *current_uri = Some(uri); + *next_uri = None; + *paused_at_sec = 0.0; + // TrackBoundary pour clore le bitstream OGG proprement + if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await { + result = Err(e); + } + *state = TransportState::Playing; + break; + } + Some(PlayerCommand::LoadNextUri(uri)) => { + debug!("PlayerSource: LoadNextUri {:?}", uri); + *next_uri = Some(uri); + } + Some(PlayerCommand::Seek(pos)) => { + info!("PlayerSource: Seek to {:.1}s", pos); + source_stop.cancel(); + *paused_at_sec = pos; + if let Err(e) = send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await { + result = Err(e); + break; + } + *state = TransportState::Playing; + break; + } + Some(PlayerCommand::Play) => { + debug!("PlayerSource: Play ignored (already playing)"); + } + } + } + + segment = chunk_rx.recv() => { + match segment { + None => { + // EOF de la source + debug!("PlayerSource: EOF"); + // Signaler la fin de piste dans tous les cas (gapless ou non) + // pour que le ControlPoint avance sa queue et mette à jour son état. + let _ = self.event_tx.send(PlayerEvent::TrackEnded); + if let Some(next) = next_uri.take() { + // Transition gapless vers la piste suivante + info!("PlayerSource: gapless transition to {:?}", next); + *current_uri = Some(next); + *paused_at_sec = 0.0; + if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await { + result = Err(e); + } + *state = TransportState::Playing; + } else { + *state = TransportState::Loaded; + *paused_at_sec = 0.0; + } + break; + } + Some(seg) => { + // Mettre à jour la position courante + if seg.is_audio_chunk() { + *paused_at_sec = seg.timestamp_sec; + } + // Envoyer au pipeline en aval + if let Err(e) = send_to_children("PlayerSource", output, seg).await { + source_stop.cancel(); + result = Err(e); + break; + } + } + } + } + } + } + + // Attendre que la tâche source se termine proprement + let _ = emit_task.await; + result + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/// Envoie un TrackBoundary à tous les enfants. +/// +/// Utilisé pour déclencher EOS + nouveau BOS OGG dans StreamingOggFlacSink, +/// garantissant un bitstream propre à chaque démarrage, reprise ou seek. +async fn send_track_boundary( + uri: Option<&str>, + output: &[mpsc::Sender>], + timestamp_sec: f64, + stop_token: &CancellationToken, +) -> Result<(), AudioError> { + if output.is_empty() || stop_token.is_cancelled() { + return Ok(()); + } + + let mut meta = MemoryTrackMetadata::new(); + if let Some(u) = uri { + let _ = meta.set_title(Some(u.to_string())).await; + } + let meta_arc = Arc::new(tokio::sync::RwLock::new(meta)); + let boundary = AudioSegment::new_track_boundary(0, timestamp_sec, meta_arc); + + send_to_children("PlayerSource", output, boundary).await +} + +// ─── Nœud public ───────────────────────────────────────────────────────────── + +/// Source audio avec contrôle de transport AVTransport UPnP. +/// +/// Utilisée comme nœud racine d'un pipeline audio. Le `PlayerHandle` retourné +/// permet d'envoyer des commandes (Play, Pause, Stop, Seek, LoadUri) depuis +/// les handlers UPnP. +pub struct PlayerSource { + inner: Node, +} + +impl PlayerSource { + /// Crée une nouvelle PlayerSource et son handle de contrôle. + pub fn new() -> (Self, PlayerHandle) { + let (command_tx, command_rx) = mpsc::channel::(32); + let (event_tx, _) = broadcast::channel::(16); + + let logic = PlayerSourceLogic { + command_rx, + event_tx: event_tx.clone(), + }; + + let handle = PlayerHandle { + command_tx, + event_tx, + }; + + ( + Self { + inner: Node::new_source(logic), + }, + handle, + ) + } +} + +#[async_trait] +impl AudioPipelineNode for PlayerSource { + fn get_tx(&self) -> Option>> { + self.inner.get_tx() + } + + fn register(&mut self, child: Box) { + self.inner.register(child); + } + + async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { + Box::new(self.inner).run(stop_token).await + } +} diff --git a/pmowebrenderer/src/handlers.rs b/pmowebrenderer/src/handlers.rs index c29d8152..97503cc9 100644 --- a/pmowebrenderer/src/handlers.rs +++ b/pmowebrenderer/src/handlers.rs @@ -226,13 +226,11 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler { // ─── RenderingControl Handlers ────────────────────────────────────────────── -pub fn set_volume_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler { +pub fn set_volume_handler(_pipeline: PipelineHandle, state: SharedState) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { - let pipeline = pipeline.clone(); let state = state.clone(); Box::pin(async move { let volume: u16 = get!(&data, "DesiredVolume", u16); - pipeline.send(PipelineControl::SetVolume(volume)).await; state.write().volume = volume; Ok(data) }) @@ -251,13 +249,11 @@ pub fn get_volume_handler(state: SharedState) -> ActionHandler { }) } -pub fn set_mute_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler { +pub fn set_mute_handler(_pipeline: PipelineHandle, state: SharedState) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { - let pipeline = pipeline.clone(); let state = state.clone(); Box::pin(async move { let mute: bool = get!(&data, "DesiredMute", bool); - pipeline.send(PipelineControl::SetMute(mute)).await; state.write().mute = mute; Ok(data) }) diff --git a/pmowebrenderer/src/pipeline.rs b/pmowebrenderer/src/pipeline.rs index 71c4d2d7..90edd380 100644 --- a/pmowebrenderer/src/pipeline.rs +++ b/pmowebrenderer/src/pipeline.rs @@ -1,55 +1,51 @@ //! Pipeline audio serveur par instance WebRenderer //! //! Chaque instance WebRenderer possède un pipeline indépendant : -//! - Un `StreamingFlacSink` qui encode et diffuse le flux FLAC aux clients HTTP -//! - Un canal de contrôle `PipelineControl` alimenté par les handlers UPnP -//! - Une task background qui orchestre sources et sink +//! - 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::{AudioSegment, ResamplingNode, ToI24Node}; -use pmometadata::{MemoryTrackMetadata, TrackMetadata}; -use pmoaudio_ext::sinks::{ - OggFlacStreamHandle, StreamingOggFlacSink, -}; -use pmoaudio_ext::UriSource; +use pmoaudio::{AudioPipelineNode, ResamplingNode, ToI24Node}; +use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource}; +use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink}; use pmoflac::EncoderOptions; -use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; +use tracing::{debug, warn}; -use crate::messages::PlaybackState; use crate::state::SharedState; -// ─── Commandes de contrôle ─────────────────────────────────────────────────── +// ─── Ré-export des commandes pour les handlers ──────────────────────────────── -/// Commandes envoyées au pipeline audio de l'instance -#[derive(Debug)] -pub enum PipelineControl { - LoadUri(String), - LoadNextUri(String), - Play, - Pause, - Stop, - Seek(f64), - SetVolume(u16), - SetMute(bool), - /// Notification interne : la source courante s'est terminée (EOF ou erreur) - SourceEnded, -} +/// 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 +/// 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 control_tx: mpsc::Sender, + 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) { - let _ = self.control_tx.send(cmd).await; + 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, + } } } @@ -75,12 +71,10 @@ impl InstancePipeline { udn: String, ) -> Self { let stop_token = CancellationToken::new(); - let (control_tx, control_rx) = mpsc::channel::(32); - // Chaîne de traitement : ResamplingNode(96kHz) → ToI24Node → StreamingOggFlacSink - // Le broadcast pacé à 0.5s max d'avance, chaque subscribe() est indépendant. 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 @@ -91,41 +85,41 @@ impl InstancePipeline { let mut resampler = ResamplingNode::new(96_000); resampler.register(to_i24.boxed()); - // Le tx d'entrée du resampler est le point d'entrée du pipeline - let segment_tx = resampler.get_tx().expect("ResamplingNode doit avoir un sender"); + // Source avec contrôle AVTransport complet + let (mut player_source, player_handle) = PlayerSource::new(); + player_source.register(resampler.boxed()); - let pipeline_handle = PipelineHandle { - control_tx, - stop_token: stop_token.clone(), - }; - - // Lancer la chaîne resampler → to_i24 → sink en background + // Lancer le pipeline en background let sink_stop = stop_token.clone(); tokio::spawn(async move { - if let Err(e) = resampler.boxed().run(sink_stop).await { + if let Err(e) = player_source.boxed().run(sink_stop).await { warn!("Audio pipeline error: {:?}", e); } - debug!("Sink task terminated"); - }); - - // Task pipeline : reçoit les commandes UPnP et pilote les sources - let stop_token_clone = stop_token.clone(); - let control_tx_clone = pipeline_handle.control_tx.clone(); - tokio::spawn(async move { - run_pipeline( - segment_tx, - control_rx, - control_tx_clone, - stop_token_clone, - state, - udn, - #[cfg(feature = "pmoserver")] - control_point, - ) - .await; 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, @@ -133,279 +127,63 @@ impl InstancePipeline { } } -// ─── Task principale du pipeline ───────────────────────────────────────────── +// ─── Listener d'événements ──────────────────────────────────────────────────── -async fn run_pipeline( - segment_tx: mpsc::Sender>, - mut control_rx: mpsc::Receiver, - control_tx: mpsc::Sender, - stop_token: CancellationToken, +async fn run_event_listener( + mut event_rx: tokio::sync::broadcast::Receiver, state: SharedState, udn: String, #[cfg(feature = "pmoserver")] control_point: Arc, ) { - let mut current_source_stop: Option = None; - let mut current_uri: Option = None; + use pmoaudio_ext::PlayerEvent; + use crate::messages::PlaybackState; loop { - tokio::select! { - _ = stop_token.cancelled() => { - info!(udn = %udn, "Pipeline stopping by cancellation"); - if let Some(src_stop) = current_source_stop.take() { - src_stop.cancel(); + 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; } - break; - } - - cmd = control_rx.recv() => { - match cmd { - None => { - info!(udn = %udn, "Pipeline control channel closed"); - break; - } - - Some(PipelineControl::SourceEnded) => { - // La source s'est terminée (EOF ou erreur) : libérer le slot - debug!(udn = %udn, "Pipeline: SourceEnded — source slot freed"); - current_source_stop = None; - } - - Some(PipelineControl::LoadUri(uri)) => { - info!(udn = %udn, uri = %uri, "Pipeline: LoadUri"); - if let Some(src_stop) = current_source_stop.take() { - src_stop.cancel(); - } - current_uri = Some(uri.clone()); - { - let mut s = state.write(); - s.playback_state = PlaybackState::Transitioning; - s.current_uri = Some(uri.clone()); - s.position = None; - } - let src_stop = stop_token.child_token(); - current_source_stop = Some(src_stop.clone()); - let tx = segment_tx.clone(); - let notify = control_tx.clone(); - let st = state.clone(); - let udn_c = udn.clone(); - #[cfg(feature = "pmoserver")] + 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::TrackEnded => { + #[cfg(feature = "pmoserver")] + { let cp = control_point.clone(); + let udn_c = udn.clone(); tokio::spawn(async move { - stream_source( - uri, 0.0, tx, src_stop, st, udn_c, - #[cfg(feature = "pmoserver")] - cp, - ).await; - let _ = notify.send(PipelineControl::SourceEnded).await; + cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_c)); }); } - - Some(PipelineControl::LoadNextUri(uri)) => { - debug!(udn = %udn, uri = %uri, "Pipeline: LoadNextUri"); - state.write().next_uri = Some(uri); - } - - Some(PipelineControl::Play) => { - // Redémarrer la source si elle n'est pas en cours - if current_source_stop.is_none() { - if let Some(uri) = current_uri.clone() { - // Reprendre depuis la position pausée (0.0 si pas de pause) - let seek = state.read().position.as_deref() - .map(upnp_time_to_seconds) - .unwrap_or(0.0); - info!(udn = %udn, uri = %uri, seek = seek, "Pipeline: Play — restarting source"); - let src_stop = stop_token.child_token(); - current_source_stop = Some(src_stop.clone()); - let tx = segment_tx.clone(); - let notify = control_tx.clone(); - let st = state.clone(); - let udn_c = udn.clone(); - #[cfg(feature = "pmoserver")] - let cp = control_point.clone(); - tokio::spawn(async move { - stream_source( - uri, seek, tx, src_stop, st, udn_c, - #[cfg(feature = "pmoserver")] - cp, - ).await; - let _ = notify.send(PipelineControl::SourceEnded).await; - }); - } else { - debug!(udn = %udn, "Pipeline: Play — no URI loaded, ignoring"); - } - } else { - debug!(udn = %udn, "Pipeline: Play — source already running"); - } - } - - Some(PipelineControl::Pause) => { - info!(udn = %udn, "Pipeline: Pause — stopping source, position preserved"); - if let Some(src_stop) = current_source_stop.take() { - src_stop.cancel(); - } - state.write().playback_state = PlaybackState::Paused; - // position reste dans state pour la reprise via Play - } - - Some(PipelineControl::Stop) => { - info!(udn = %udn, "Pipeline: Stop"); - if let Some(src_stop) = current_source_stop.take() { - src_stop.cancel(); - } - // Conserver current_uri pour permettre un Play ultérieur - let mut s = state.write(); - s.playback_state = PlaybackState::Stopped; - s.position = None; - } - - Some(PipelineControl::Seek(pos_sec)) => { - info!(udn = %udn, pos = pos_sec, "Pipeline: Seek"); - if let Some(uri) = current_uri.clone() { - if let Some(src_stop) = current_source_stop.take() { - src_stop.cancel(); - } - let src_stop = stop_token.child_token(); - current_source_stop = Some(src_stop.clone()); - let tx = segment_tx.clone(); - let notify = control_tx.clone(); - let st = state.clone(); - let udn_c = udn.clone(); - #[cfg(feature = "pmoserver")] - let cp = control_point.clone(); - tokio::spawn(async move { - stream_source( - uri, pos_sec, tx, src_stop, st, udn_c, - #[cfg(feature = "pmoserver")] - cp, - ).await; - let _ = notify.send(PipelineControl::SourceEnded).await; - }); - } - } - - Some(PipelineControl::SetVolume(vol)) => { - state.write().volume = vol; - } - - Some(PipelineControl::SetMute(mute)) => { - state.write().mute = mute; - } } - } - } - } -} - -// ─── Task source ───────────────────────────────────────────────────────────── - -async fn stream_source( - uri: String, - seek_sec: f64, - tx: mpsc::Sender>, - stop_token: CancellationToken, - state: SharedState, - udn: String, - #[cfg(feature = "pmoserver")] - control_point: Arc, -) { - info!(udn = %udn, uri = %uri, seek = seek_sec, "Source task: opening URI"); - - match UriSource::open(&uri, seek_sec, stop_token.clone()).await { - Ok(source) => { - debug!(udn = %udn, "Source task: URI opened, duration={:?}", source.duration_sec()); - if let Some(dur) = source.duration_sec() { - state.write().duration = Some(seconds_to_upnp_time(dur)); - } - - // TrackBoundary avec métadonnées minimales - let boundary = { - let mut meta = MemoryTrackMetadata::new(); - let _ = meta.set_title(Some(uri.clone())).await; - let meta_arc = Arc::new(tokio::sync::RwLock::new(meta)); - AudioSegment::new_track_boundary(0, seek_sec, meta_arc) - }; - let _ = tx.send(boundary).await; - - // L'URI est ouverte, le flux va commencer à couler. - // Le sink bloquera naturellement si aucun client HTTP n'est connecté. - { - let mut s = state.write(); - s.playback_state = PlaybackState::Playing; - if seek_sec > 0.0 { - s.position = Some(seconds_to_upnp_time(seek_sec)); - } - } - - match source.emit_to_channel(&tx, &stop_token).await { - Ok(true) => { - debug!(udn = %udn, "Source task: EOF → TrackEnded"); - handle_track_ended( - state, udn, tx, stop_token, - #[cfg(feature = "pmoserver")] - control_point, - ).await; - } - Ok(false) => { - debug!(udn = %udn, "Source task: cancelled"); - } - Err(e) => { - warn!(udn = %udn, error = %e, "Source task error"); + 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; } } - Err(e) => { - warn!(udn = %udn, error = %e, "Source task: failed to open URI"); - state.write().playback_state = PlaybackState::Stopped; - } - } -} - -// ─── TrackEnded : avancer current←next ─────────────────────────────────────── - -async fn handle_track_ended( - state: SharedState, - udn: String, - tx: mpsc::Sender>, - stop_token: CancellationToken, - #[cfg(feature = "pmoserver")] - control_point: Arc, -) { - let next_uri = { - let mut s = state.write(); - let uri = s.next_uri.take(); - let meta = s.next_metadata.take(); - s.current_uri = uri.clone(); - s.current_metadata = meta; - s.next_uri = None; - s.next_metadata = None; - s.position = None; - s.duration = None; - if uri.is_some() { - s.playback_state = PlaybackState::Playing; - } else { - s.playback_state = PlaybackState::Stopped; - } - uri - }; - - #[cfg(feature = "pmoserver")] - if next_uri.is_some() { - let cp = control_point.clone(); - let udn_clone = udn.clone(); - tokio::spawn(async move { - cp.advance_queue_and_prefetch(&pmocontrol::DeviceId(udn_clone)); - }); - } - - if let Some(uri) = next_uri { - info!(udn = %udn, uri = %uri, "TrackEnded: starting next track"); - Box::pin(stream_source( - uri, 0.0, tx, stop_token, state, udn, - #[cfg(feature = "pmoserver")] - control_point, - )).await; } } From 2b0def806a3bc16467ab39ef274eb97c6c6abbde Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 1 Mar 2026 23:55:40 +0100 Subject: [PATCH 10/18] =?UTF-8?q?Ajout=20de=20la=20gestion=20de=20la=20pos?= =?UTF-8?q?ition=20en=20temps=20r=C3=A9el=20et=20am=C3=A9lioration=20de=20?= =?UTF-8?q?la=20gestion=20de=20la=20dur=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout de l'événement Position pour envoyer la position courante ~1/s pendant la lecture. Modification de la gestion de la durée dans le registry pour ne pas écraser une durée déjà connue par la source (priorité à la source pour les flux radio). Mise à jour du handler position_update pour utiliser update_duration au lieu de update_position, et suppression de la gestion de position_sec qui est maintenant gérée par PlayerEvent::Position. Le serveur gère maintenant la position via les événements Position émis par PlayerSource, et la durée est uniquement utilisée comme fallback pour les sources sans durée connue. --- pmoaudio-ext/src/sources/player_source.rs | 13 +++++++++++++ pmowebrenderer/src/pipeline.rs | 3 +++ pmowebrenderer/src/register.rs | 4 +++- pmowebrenderer/src/registry.rs | 16 ++++++++++------ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/pmoaudio-ext/src/sources/player_source.rs b/pmoaudio-ext/src/sources/player_source.rs index 7fdfecfa..4e81fe2b 100644 --- a/pmoaudio-ext/src/sources/player_source.rs +++ b/pmoaudio-ext/src/sources/player_source.rs @@ -79,6 +79,8 @@ pub enum PlayerEvent { Stopped, /// Fin de piste (pour que le ControlPoint avance la queue) TrackEnded, + /// Position courante (émise ~1/s pendant la lecture) + Position { position_sec: f64 }, /// Erreur lors de l'ouverture ou de la lecture Error(String), } @@ -345,6 +347,9 @@ impl PlayerSourceLogic { let source_stop = stop_token.child_token(); let source_stop_clone = source_stop.clone(); + // Dernière seconde entière pour laquelle on a émis un Position + let mut last_reported_sec: i64 = -1; + // Spawner l'émission de la source dans une tâche séparée let emit_task = tokio::spawn(async move { source.emit_to_channel(&chunk_tx, &source_stop_clone).await @@ -443,6 +448,14 @@ impl PlayerSourceLogic { // Mettre à jour la position courante if seg.is_audio_chunk() { *paused_at_sec = seg.timestamp_sec; + // Émettre Position ~1/s + let sec = paused_at_sec.floor() as i64; + if sec != last_reported_sec { + last_reported_sec = sec; + let _ = self.event_tx.send(PlayerEvent::Position { + position_sec: *paused_at_sec, + }); + } } // Envoyer au pipeline en aval if let Err(e) = send_to_children("PlayerSource", output, seg).await { diff --git a/pmowebrenderer/src/pipeline.rs b/pmowebrenderer/src/pipeline.rs index 90edd380..843f79c6 100644 --- a/pmowebrenderer/src/pipeline.rs +++ b/pmowebrenderer/src/pipeline.rs @@ -162,6 +162,9 @@ async fn run_event_listener( 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")] { diff --git a/pmowebrenderer/src/register.rs b/pmowebrenderer/src/register.rs index ce9bba75..96a156f5 100644 --- a/pmowebrenderer/src/register.rs +++ b/pmowebrenderer/src/register.rs @@ -68,12 +68,14 @@ pub struct PositionUpdateRequest { } /// POST /api/webrenderer/{id}/position +/// position_sec est ignoré (géré par PlayerEvent::Position côté serveur). +/// duration_sec est utilisé comme fallback si la source ne connaît pas la durée (flux radio). pub async fn position_update_handler( State(registry): State>, Path(instance_id): Path, Json(req): Json, ) -> impl IntoResponse { - registry.update_position(&instance_id, req.position_sec, req.duration_sec); + registry.update_duration(&instance_id, req.duration_sec); StatusCode::NO_CONTENT } diff --git a/pmowebrenderer/src/registry.rs b/pmowebrenderer/src/registry.rs index cbc4b6be..25650dc5 100644 --- a/pmowebrenderer/src/registry.rs +++ b/pmowebrenderer/src/registry.rs @@ -152,15 +152,19 @@ impl RendererRegistry { .map(|i| i.device_instance.clone()) } - /// Met à jour la position et la durée depuis le navigateur (audio.currentTime). - pub fn update_position(&self, instance_id: &str, position_sec: f64, duration_sec: Option) { + /// Met à jour la durée depuis le navigateur. + /// La position est gérée par PlayerSource via PlayerEvent::Position. + /// On n'utilise duration_sec que si la source ne la connaît pas (flux radio sans durée). + pub fn update_duration(&self, instance_id: &str, duration_sec: Option) { let instances = self.instances.read(); if let Some(instance) = instances.get(instance_id) { let mut s = instance.state.write(); - s.position = Some(crate::pipeline::seconds_to_upnp_time(position_sec)); - if let Some(dur) = duration_sec { - if dur > 0.0 { - s.duration = Some(crate::pipeline::seconds_to_upnp_time(dur)); + // N'écraser la durée que si elle n'est pas déjà connue (la source est prioritaire) + if s.duration.is_none() { + if let Some(dur) = duration_sec { + if dur > 0.0 { + s.duration = Some(crate::pipeline::seconds_to_upnp_time(dur)); + } } } } From d1be71ff47a510e87807438e03991649053653ab Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 2 Mar 2026 20:02:25 +0100 Subject: [PATCH 11/18] Fix stream header invalidation and source task cleanup Invalidate the cached OGG header immediately when restarting the FLAC encoder to prevent clients from receiving stale data. Also improve the player source cleanup by draining the chunk receiver and adding a timeout when waiting for the emit task to finish, preventing potential hangs when the downstream pipeline is saturated. --- pmoaudio-ext/src/sinks/streaming_sink_common.rs | 5 +++++ pmoaudio-ext/src/sources/player_source.rs | 13 +++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pmoaudio-ext/src/sinks/streaming_sink_common.rs b/pmoaudio-ext/src/sinks/streaming_sink_common.rs index e2e9142f..952fdd48 100644 --- a/pmoaudio-ext/src/sinks/streaming_sink_common.rs +++ b/pmoaudio-ext/src/sinks/streaming_sink_common.rs @@ -490,6 +490,11 @@ impl SharedSinkContext { debug!("Restarting FLAC encoder for new track"); + // Invalider le header en cache immédiatement : tout nouveau client qui se connecte + // pendant le restart attendra (Poll::Pending) jusqu'à ce que le nouveau header OGG + // soit prêt, évitant qu'il reçoive l'ancien header suivi des nouvelles données. + *self.header.write().await = None; + let last_timestamp = *self.current_timestamp.read().await; debug!("Last timestamp before restart: {:.3}s", last_timestamp); diff --git a/pmoaudio-ext/src/sources/player_source.rs b/pmoaudio-ext/src/sources/player_source.rs index 4e81fe2b..06d4a428 100644 --- a/pmoaudio-ext/src/sources/player_source.rs +++ b/pmoaudio-ext/src/sources/player_source.rs @@ -469,8 +469,17 @@ impl PlayerSourceLogic { } } - // Attendre que la tâche source se termine proprement - let _ = emit_task.await; + // Vider chunk_rx pour débloquer emit_task si elle est bloquée sur un send + // (peut arriver si le pipeline en aval est saturé au moment du cancel) + while chunk_rx.try_recv().is_ok() {} + + // Attendre que la tâche source se termine, avec timeout de sécurité + tokio::select! { + _ = emit_task => {} + _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => { + warn!("PlayerSource: emit_task did not finish in time after cancel"); + } + } result } } From 0cca9229ada62ab6830b1d837ce37fc3e24a1e77 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 24 Mar 2026 10:01:23 +0100 Subject: [PATCH 12/18] feat: implement livemeta/pull API and station mapping - Replace old /api/live endpoint with new livemeta/pull API (api.radiofrance.fr) - Add hardcoded station ID/stream mappings with runtime-updatable mapping - Implement station rediscovery on unknown slug or stream failure - Add persistent caching for station mappings (30-day TTL) via pmoconfig - Update live_metadata to use numeric IDs and handle ID mismatches - Add station validation (API + stream accessibility check) - Refactor metadata caching with fetched_at and fallback TTL - Update discover_local_radios to scrape SvelteKit page data - Add new models: PullResponse, PullStep, EmbedImage for livemeta format - Improve live_metadata.rs example to safely truncate intros - Enable parallel DIDL generation for playlist groups --- .DS_Store | Bin 18436 -> 18436 bytes .gitignore | 3 +- pmoradiofrance/examples/live_metadata.rs | 9 +- pmoradiofrance/src/client.rs | 589 +++++++++++++++++++++-- pmoradiofrance/src/config_ext.rs | 135 ++++++ pmoradiofrance/src/lib.rs | 2 +- pmoradiofrance/src/metadata_cache.rs | 212 ++++++-- pmoradiofrance/src/models.rs | 111 ++++- pmoradiofrance/src/playlist.rs | 20 +- 9 files changed, 1001 insertions(+), 80 deletions(-) diff --git a/.DS_Store b/.DS_Store index 39adbca77b0359db4eefe16c8f2a8b64fb334f54..b20cdc7792968340936a0ef39b95502607f75e8b 100644 GIT binary patch delta 34 qcmZpfz}PZ@ae_Z%&&Gh={EViXc?1+$H^+*uYe}@{^Nt@{<@C V7z7v?7=Lb-6Wz Result<(), Box> { } if let Some(intro) = &metadata.now.intro { - let short_intro = if intro.len() > 100 { - format!("{}...", &intro[..100]) - } else { - intro.clone() - }; - println!(" Description: {}", short_intro); + let short_intro: String = intro.chars().take(100).collect(); + let suffix = if intro.chars().count() > 100 { "..." } else { "" }; + println!(" Description: {}{}", short_intro, suffix); } // Song info (for music stations) diff --git a/pmoradiofrance/src/client.rs b/pmoradiofrance/src/client.rs index d6653631..1d6de784 100644 --- a/pmoradiofrance/src/client.rs +++ b/pmoradiofrance/src/client.rs @@ -28,17 +28,27 @@ //! ``` use crate::error::{Error, Result}; -use crate::models::{ImageSize, LiveResponse, ShowMetadata, Station, StreamSource}; +use crate::models::{ + EmbedImage, ImageSize, Line, LiveResponse, Media, PullResponse, ShowMetadata, Song, Station, + StreamSource, +}; use regex::Regex; use reqwest::Client; use scraper::{Html, Selector}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; use std::time::Duration; use url::Url; +#[cfg(feature = "pmoconfig")] +use crate::config_ext::StationInfo; + /// Default Radio France base URL pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr"; +/// Livemeta API base URL (new API since 2026) +pub const LIVEMETA_API_URL: &str = "https://api.radiofrance.fr/livemeta/pull"; + /// Default timeout for HTTP requests (30 seconds) pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; @@ -56,6 +66,103 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[ ("francebleu", "France Bleu"), ]; +/// Mapping slug → numeric station ID for the livemeta/pull API +/// IDs extracted from www.radiofrance.fr SvelteKit page data (2026) +pub const STATION_IDS: &[(&str, u32)] = &[ + ("franceinter", 1), + ("franceinfo", 2), + ("francemusique", 4), + ("franceculture", 5), + ("mouv", 6), + ("fip", 7), + ("francebleu", 56), + // FIP webradios + ("fip_rock", 64), + ("fip_jazz", 65), + ("fip_groove", 66), + ("fip_reggae", 71), + ("fip_electro", 74), + ("fip_metal", 77), + ("fip_pop", 78), + ("fip_world", 69), + ("fip_nouveautes", 70), + ("fip_hiphop", 95), + ("fip_sacre_francais", 96), + ("fip_cultes", 709), +]; + +/// Icecast HiFi AAC stream URLs (stable, not from API) +/// Format: https://icecast.radiofrance.fr/{name}-hifi.aac +pub const STATION_STREAMS: &[(&str, &str)] = &[ + ( + "franceinter", + "https://icecast.radiofrance.fr/franceinter-hifi.aac", + ), + ( + "franceinfo", + "https://icecast.radiofrance.fr/franceinfo-hifi.aac", + ), + ( + "franceculture", + "https://icecast.radiofrance.fr/franceculture-hifi.aac", + ), + ( + "francemusique", + "https://icecast.radiofrance.fr/francemusique-hifi.aac", + ), + ("fip", "https://icecast.radiofrance.fr/fip-hifi.aac"), + ("mouv", "https://icecast.radiofrance.fr/mouv-hifi.aac"), + // FIP webradios + ( + "fip_rock", + "https://icecast.radiofrance.fr/fiprock-hifi.aac", + ), + ( + "fip_jazz", + "https://icecast.radiofrance.fr/fipjazz-hifi.aac", + ), + ( + "fip_groove", + "https://icecast.radiofrance.fr/fipgroove-hifi.aac", + ), + ( + "fip_reggae", + "https://icecast.radiofrance.fr/fipreggae-hifi.aac", + ), + ( + "fip_electro", + "https://icecast.radiofrance.fr/fipelectro-hifi.aac", + ), + ( + "fip_metal", + "https://icecast.radiofrance.fr/fipmetal-hifi.aac", + ), + ( + "fip_pop", + "https://icecast.radiofrance.fr/fippop-hifi.aac", + ), + ( + "fip_world", + "https://icecast.radiofrance.fr/fipworld-hifi.aac", + ), + ( + "fip_nouveautes", + "https://icecast.radiofrance.fr/fipnouveautes-hifi.aac", + ), + ( + "fip_hiphop", + "https://icecast.radiofrance.fr/fiphiphop-hifi.aac", + ), + ( + "fip_sacre_francais", + "https://icecast.radiofrance.fr/fipsacrefrancais-hifi.aac", + ), + ( + "fip_cultes", + "https://icecast.radiofrance.fr/fipcultes-hifi.aac", + ), +]; + /// Radio France HTTP client /// /// This client provides access to Radio France's public APIs for: @@ -63,13 +170,23 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[ /// - Live metadata (current show, next show, stream URLs) /// - Image URL construction (Pikapi) /// -/// The client is stateless and does not cache responses internally. -/// Caching should be handled by higher layers (e.g., config extension). +/// The client holds an in-memory station mapping (slug → numeric ID + stream URL) +/// seeded from hardcoded constants. Use `with_station_mapping()` to inject a +/// mapping loaded from persistent storage (pmoconfig). #[derive(Debug, Clone)] pub struct RadioFranceClient { pub(crate) client: Client, base_url: String, timeout: Duration, + /// Mapping slug → { station_id, stream_url } — thread-safe, updatable at runtime + station_mapping: Arc>>, +} + +/// Entry in the station mapping +#[derive(Debug, Clone)] +pub struct StationMappingEntry { + pub station_id: u32, + pub stream_url: String, } impl RadioFranceClient { @@ -91,6 +208,7 @@ impl RadioFranceClient { client, base_url: DEFAULT_BASE_URL.to_string(), timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), + station_mapping: Arc::new(RwLock::new(Self::default_station_mapping())), } } @@ -104,6 +222,89 @@ impl RadioFranceClient { &self.client } + /// Build the default station mapping from hardcoded constants + fn default_station_mapping() -> HashMap { + // Build a lookup for stream URLs + let stream_map: HashMap<&str, &str> = STATION_STREAMS.iter().copied().collect(); + + STATION_IDS + .iter() + .map(|(slug, id)| { + let stream_url = stream_map + .get(slug) + .copied() + .unwrap_or_default() + .to_string(); + ( + slug.to_string(), + StationMappingEntry { + station_id: *id, + stream_url, + }, + ) + }) + .collect() + } + + /// Remplace le mapping en mémoire par un mapping chargé depuis le cache persistant + /// + /// Appelé au démarrage par MetadataCache pour charger le mapping pmoconfig. + #[cfg(feature = "pmoconfig")] + pub fn set_station_mapping(&self, mapping: HashMap) { + let mut m = self.station_mapping.write().unwrap(); + m.clear(); + for (slug, info) in mapping { + m.insert( + slug, + StationMappingEntry { + station_id: info.station_id, + stream_url: info.stream_url, + }, + ); + } + } + + /// Récupère l'ensemble du mapping courant (pour persistance dans pmoconfig) + #[cfg(feature = "pmoconfig")] + pub fn get_station_mapping(&self) -> HashMap { + let m = self.station_mapping.read().unwrap(); + m.iter() + .map(|(slug, entry)| { + ( + slug.clone(), + StationInfo { + station_id: entry.station_id, + stream_url: entry.stream_url.clone(), + }, + ) + }) + .collect() + } + + /// Met à jour une entrée dans le mapping (utilisé après re-découverte unitaire) + pub fn update_station_entry(&self, slug: &str, station_id: u32, stream_url: String) { + let mut m = self.station_mapping.write().unwrap(); + m.insert( + slug.to_string(), + StationMappingEntry { + station_id, + stream_url, + }, + ); + } + + /// Retourne l'ID numérique pour un slug, ou None si inconnu + pub fn station_id_for_slug(&self, slug: &str) -> Option { + let m = self.station_mapping.read().unwrap(); + m.get(slug).map(|e| e.station_id) + } + + /// Retourne l'URL du stream HiFi pour un slug, ou None si inconnu + pub fn stream_url_for_slug(&self, slug: &str) -> Option { + let m = self.station_mapping.read().unwrap(); + m.get(slug).map(|e| e.stream_url.clone()) + } + // ======================================================================== // Station Discovery // ======================================================================== @@ -254,20 +455,71 @@ impl RadioFranceClient { .collect()) } - /// Discover local France Bleu radios via API + /// Discover local France Bleu radios via SvelteKit page data /// - /// Uses the France Bleu /api/live? endpoint which includes - /// a `localRadios` array in the `now` field. + /// Scrapes francebleu page for local station slugs and discovers their IDs. + /// Met à jour le station_mapping avec les IDs trouvés. pub async fn discover_local_radios(&self) -> Result> { - let response = self.live_metadata("francebleu").await?; + let url = format!("{}/francebleu/__data.json", self.base_url); + let resp = self + .client + .get(&url) + .timeout(self.timeout) + .send() + .await? + .text() + .await?; - Ok(response - .local_radios() - .cloned() - .unwrap_or_default() + // Extraire les slugs "francebleu_xxx" et les paires (slug, id) de la page + let re_slug_id = Regex::new(r#""(francebleu_[a-z_]+)","(\d+)""#)?; + let mut stations = Vec::new(); + let mut found_any = false; + + for cap in re_slug_id.captures_iter(&resp) { + let slug = cap[1].to_string(); + if let Ok(id) = cap[2].parse::() { + let stream_url = Self::derive_stream_url(&slug); + self.update_station_entry(&slug, id, stream_url); + let name = Self::slug_to_display_name(&slug); + stations.push(Station::new(slug, name)); + found_any = true; + } + } + + if found_any { + return Ok(stations); + } + + // Fallback : scraper la page HTML pour les noms "ICI ..." + let html_url = format!("{}/francebleu", self.base_url); + let html = self + .client + .get(&html_url) + .timeout(self.timeout) + .send() + .await? + .text() + .await?; + + let re_ici = Regex::new(r#"ICI ([A-Za-z\u00C0-\u017E][A-Za-z\u00C0-\u017E\s-]+)"#)?; + let re_slug = Regex::new(r#""(francebleu_[a-z_]+)""#)?; + + let mut slugs: HashSet = HashSet::new(); + for cap in re_slug.captures_iter(&html) { + slugs.insert(cap[1].to_string()); + } + + let _names: Vec = re_ici + .captures_iter(&html) + .map(|c| c[1].trim().to_string()) + .collect(); + + Ok(slugs .into_iter() - .filter(|local| local.is_on_air) - .map(|local| Station::new(local.name, local.title)) + .map(|slug| { + let name = Self::slug_to_display_name(&slug); + Station::new(slug, name) + }) .collect()) } @@ -370,28 +622,42 @@ impl RadioFranceClient { /// # } /// ``` pub async fn live_metadata(&self, station: &str) -> Result { - let (base_station, webradio) = Self::parse_station_slug(station); + let station_id = self + .station_id_for_slug(station) + .ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", station)))?; - let mut url = Url::parse(&format!("{}/{}/api/live", self.base_url, base_station))?; - - // Add webradio parameter if needed - if let Some(wr) = webradio { - url.query_pairs_mut().append_pair("webradio", wr); - } + let url = format!("{}/{}", LIVEMETA_API_URL, station_id); #[cfg(feature = "logging")] tracing::debug!("Fetching live metadata: {}", url); - let response = self.client.get(url).timeout(self.timeout).send().await?; + let response = self.client.get(&url).timeout(self.timeout).send().await?; if !response.status().is_success() { return Err(Error::ApiError(format!( - "API returned status: {}", - response.status() + "livemeta API returned status {} for station {} (id={})", + response.status(), + station, + station_id ))); } - let live: LiveResponse = response.json().await?; + let pull: PullResponse = response.json().await?; + + // Fix 1: Vérifier que l'ID retourné correspond bien à la station demandée. + // Si Radio France réassigne les IDs, on déclenche une redécouverte. + if pull.station_id != station_id { + #[cfg(feature = "logging")] + tracing::warn!( + "Station ID mismatch for {}: expected {}, API returned {} — triggering rediscovery", + station, station_id, pull.station_id + ); + // Mettre à jour le mapping avec le nouvel ID retourné par l'API + let stream_url = self.stream_url_for_slug(station).unwrap_or_default(); + self.update_station_entry(station, pull.station_id, stream_url); + } + + let live = self.pull_to_live_response(station, &pull); #[cfg(feature = "logging")] tracing::debug!( @@ -404,6 +670,268 @@ impl RadioFranceClient { Ok(live) } + /// Convertit une PullResponse (nouvelle API) en LiveResponse (format interne) + fn pull_to_live_response(&self, station_slug: &str, pull: &PullResponse) -> LiveResponse { + let now = if let Some(step) = pull.current_step() { + self.step_to_show_metadata(station_slug, step) + } else { + ShowMetadata::default() + }; + + // delayToRefresh : on utilise end_time - now comme indicateur (min 30s) + let delay_to_refresh = if let Some(end) = now.end_time { + let current = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + if end > current { + ((end - current) * 1000).min(60_000) // max 60s, en ms + } else { + 30_000 + } + } else { + 30_000 + }; + + LiveResponse { + station_name: station_slug.to_string(), + delay_to_refresh, + migrated: true, + now, + next: None, + } + } + + /// Convertit un PullStep en ShowMetadata + fn step_to_show_metadata(&self, station_slug: &str, step: &crate::models::PullStep) -> ShowMetadata { + // Stream : injecté depuis le mapping en mémoire + let stream_url = self.stream_url_for_slug(station_slug).unwrap_or_default(); + let sources = if !stream_url.is_empty() { + vec![StreamSource { + url: stream_url, + broadcast_type: crate::models::BroadcastType::Live, + format: crate::models::StreamFormat::Aac, + bitrate: 192, + }] + } else { + vec![] + }; + + // Visual : la nouvelle API donne directement l'UUID (pas une URL complète) + let visual_background = step.visual.as_deref().map(|uuid| EmbedImage { + model: "EmbedImage".to_string(), + src: ImageSize::Large.build_url(uuid), + width: None, + height: None, + dominant: None, + copyright: None, + }); + + if step.is_song() { + // === Radio musicale === + let artists = step.artists_display(); + let song = Some(Song { + id: step.song_id.clone().unwrap_or_default(), + year: step.annee_edition_musique, + interpreters: if artists.is_empty() { + vec![] + } else { + vec![artists.clone()] + }, + release: crate::models::Release { + label: step.label.clone(), + title: step.titre_album.clone(), + reference: None, + }, + }); + + ShowMetadata { + start_time: step.start, + end_time: step.end, + producer: step.disc_jockey.clone(), + first_line: Line { + title: Some(step.title.clone()), + id: None, + path: step.path.clone(), + }, + second_line: Line { + title: if artists.is_empty() { None } else { Some(artists) }, + id: None, + path: None, + }, + song, + media: Media { sources }, + visual_background, + ..ShowMetadata::default() + } + } else { + // === Radio parlée / émission === + let show_title = step.title_concept.as_deref() + .unwrap_or(step.title.as_str()) + .to_string(); + let episode_title = if step.title_concept.is_some() { + step.title.clone() + } else { + String::new() + }; + let producer = step.disc_jockey.clone() + .or_else(|| step.producers.first().map(|p| p.name.clone())); + + ShowMetadata { + start_time: step.start, + end_time: step.end, + producer, + first_line: Line { + title: Some(show_title), + id: None, + path: step.path.clone(), + }, + second_line: Line { + title: if episode_title.is_empty() { None } else { Some(episode_title) }, + id: None, + path: None, + }, + intro: step.expression_description.clone() + .or_else(|| step.description.clone()), + media: Media { sources }, + visual_background, + ..ShowMetadata::default() + } + } + } + + /// Tente de re-découvrir l'ID et l'URL stream d'une station depuis le web + /// + /// Utilisé quand un stream échoue ou qu'un slug est inconnu. + /// Met à jour le mapping en mémoire si la découverte réussit. + pub async fn rediscover_station(&self, slug: &str) -> Result<(u32, String)> { + #[cfg(feature = "logging")] + tracing::warn!("Rediscovering station mapping for: {}", slug); + + // Construire l'URL de la page de la station principale + let base_slug = slug.split('_').next().unwrap_or(slug); + let page_url = format!("{}/{}/__data.json", self.base_url, base_slug); + + let resp = self + .client + .get(&page_url) + .timeout(self.timeout) + .send() + .await?; + + if !resp.status().is_success() { + return Err(Error::ApiError(format!( + "Cannot rediscover station {}: page returned {}", + slug, + resp.status() + ))); + } + + let text = resp.text().await?; + + // Fix 2: chercher l'ID spécifique à la station via son brandEnum. + // Ex: "fip_rock" → "FIP_ROCK" → cherche `"FIP_ROCK","64"` dans le JSON SvelteKit. + // Pour les stations principales, on cherche aussi le pattern `"Brand",""`. + let brand_enum = slug.to_uppercase(); // fip_rock → FIP_ROCK + let re_enum = Regex::new(&format!(r#""{}","(\d+)""#, regex::escape(&brand_enum)))?; + let brand_id: Option = re_enum + .captures_iter(&text) + .filter_map(|c| c[1].parse::().ok()) + .next(); + + // Fallback : premier "Brand","" dans la page (pour stations principales) + let brand_id = if brand_id.is_some() { + brand_id + } else { + let re_brand = Regex::new(r#""Brand","(\d+)""#)?; + let ids: Vec = re_brand + .captures_iter(&text) + .filter_map(|c| c[1].parse::().ok()) + .collect(); + ids.into_iter().next() + }; + + let station_id = brand_id.ok_or_else(|| { + Error::ApiError(format!("Could not find station_id for {} in page data", slug)) + })?; + + // Dériver l'URL stream depuis le slug (format icecast connu) + let stream_url = Self::derive_stream_url(slug); + + self.update_station_entry(slug, station_id, stream_url.clone()); + + #[cfg(feature = "logging")] + tracing::info!( + "Rediscovered station {}: id={}, stream={}", + slug, + station_id, + stream_url + ); + + Ok((station_id, stream_url)) + } + + /// Valide qu'une station existe toujours et que son stream est accessible + /// + /// Vérifie deux choses : + /// 1. L'API livemeta répond avec un stationId valide (station connue de Radio France) + /// 2. L'URL icecast répond HTTP 200 (stream physiquement disponible) + /// + /// Retourne Ok(()) si tout est OK, Err si la station est invalide ou le stream mort. + pub async fn validate_station(&self, slug: &str) -> Result<()> { + // 1. Vérifier que l'API livemeta répond pour cette station + let station_id = self + .station_id_for_slug(slug) + .ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", slug)))?; + + let url = format!("{}/{}", LIVEMETA_API_URL, station_id); + let response = self.client.get(&url).timeout(self.timeout).send().await?; + + if !response.status().is_success() { + return Err(Error::ApiError(format!( + "Station {} (id={}) no longer exists: livemeta returned {}", + slug, station_id, response.status() + ))); + } + + let pull: PullResponse = response.json().await?; + + // Vérifier cohérence de l'ID — si mismatch, la station a peut-être été réassignée + if pull.station_id != station_id { + return Err(Error::ApiError(format!( + "Station {} ID mismatch: stored={}, API returned={} — mapping is stale", + slug, station_id, pull.station_id + ))); + } + + // 2. Vérifier que l'URL icecast répond + if let Some(stream_url) = self.stream_url_for_slug(slug) { + if !stream_url.is_empty() { + let stream_resp = self + .client + .head(&stream_url) + .timeout(Duration::from_secs(10)) + .send() + .await?; + if !stream_resp.status().is_success() { + return Err(Error::ApiError(format!( + "Stream for {} is not accessible: {} returned {}", + slug, stream_url, stream_resp.status() + ))); + } + } + } + + Ok(()) + } + + /// Dérive l'URL icecast depuis le slug selon le pattern Radio France + pub fn derive_stream_url(slug: &str) -> String { + // fip_rock → fiprock, fip_sacre_francais → fipsacrefrancais + let name = slug.replace('_', ""); + format!("https://icecast.radiofrance.fr/{}-hifi.aac", name) + } + /// Get only the current show metadata pub async fn now_playing(&self, station: &str) -> Result { let response = self.live_metadata(station).await?; @@ -464,13 +992,7 @@ impl RadioFranceClient { /// # } /// ``` pub async fn get_hifi_stream_url(&self, station: &str) -> Result { - let metadata = self.live_metadata(station).await?; - - metadata - .now - .media - .best_hifi_stream() - .map(|s| s.url.clone()) + self.stream_url_for_slug(station) .ok_or_else(|| Error::NoHifiStream(station.to_string())) } @@ -619,6 +1141,7 @@ impl ClientBuilder { client, base_url: self.base_url, timeout: self.timeout, + station_mapping: Arc::new(RwLock::new(RadioFranceClient::default_station_mapping())), }) } } @@ -644,7 +1167,7 @@ mod tests { ); assert_eq!( RadioFranceClient::parse_station_slug("francebleu_alsace"), - ("francebleu_alsace", None) + ("francebleu", Some("francebleu_alsace")) ); assert_eq!( RadioFranceClient::parse_station_slug("franceculture"), diff --git a/pmoradiofrance/src/config_ext.rs b/pmoradiofrance/src/config_ext.rs index a7242eec..0826284a 100644 --- a/pmoradiofrance/src/config_ext.rs +++ b/pmoradiofrance/src/config_ext.rs @@ -37,11 +37,24 @@ use anyhow::Result; use pmoconfig::Config; use serde::{Deserialize, Serialize}; use serde_yaml::Value; +use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; /// Default TTL for station list cache (7 days in seconds) pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600; +/// Default TTL for station mapping cache (30 days — IDs and stream URLs are very stable) +pub const DEFAULT_STATION_MAPPING_TTL_SECS: u64 = 30 * 24 * 3600; + +/// Informations d'une station : ID numérique et URL du stream HiFi +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StationInfo { + /// ID numérique pour l'API livemeta/pull + pub station_id: u32, + /// URL du stream HiFi (icecast AAC ou MP3) + pub stream_url: String, +} + /// Cached station list (simplifié) #[derive(Debug, Clone, Serialize, Deserialize)] struct CachedStations { @@ -49,6 +62,14 @@ struct CachedStations { last_updated: u64, // Unix timestamp } +/// Mapping slug → StationInfo avec timestamp de mise à jour +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedStationMapping { + /// slug → { station_id, stream_url } + mapping: HashMap, + last_updated: u64, // Unix timestamp +} + /// Trait d'extension pour gérer la configuration Radio France dans pmoconfig /// /// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques @@ -113,6 +134,36 @@ pub trait RadioFranceConfigExt { /// Efface le cache des stations (force re-découverte) fn clear_radiofrance_station_cache(&self) -> Result<()>; + + // ======================================================================== + // Station Mapping Cache (slug → station_id + stream_url) + // ======================================================================== + + /// Récupère le mapping slug → StationInfo depuis le cache (si valide) + /// + /// # Returns + /// + /// - `Some(HashMap)` si le cache existe et n'est pas expiré + /// - `None` si absent ou TTL dépassé → il faut re-découvrir + fn get_radiofrance_station_mapping(&self) -> Result>>; + + /// Enregistre le mapping slug → StationInfo en cache + fn set_radiofrance_station_mapping(&self, mapping: &HashMap) + -> Result<()>; + + /// Récupère le TTL du mapping (en secondes, défaut 30 jours) + fn get_radiofrance_mapping_ttl(&self) -> Result; + + /// Définit le TTL du mapping (en secondes) + fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()>; + + /// Efface le cache du mapping (force re-découverte au prochain accès) + fn clear_radiofrance_station_mapping(&self) -> Result<()>; + + /// Met à jour une seule entrée dans le mapping (sans invalider tout le cache) + /// + /// Utilisé pour la mise à jour incrémentale lors d'une re-découverte unitaire. + fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()>; } impl RadioFranceConfigExt for Config { @@ -201,6 +252,90 @@ impl RadioFranceConfigExt for Config { // Set to null to clear self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null) } + + fn get_radiofrance_station_mapping(&self) -> Result>> { + let ttl = self.get_radiofrance_mapping_ttl()?; + + match self.get_value(&["sources", "radiofrance", "station_mapping"]) { + Ok(value) => { + let cached: CachedStationMapping = serde_yaml::from_value(value)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if now - cached.last_updated < ttl { + Ok(Some(cached.mapping)) + } else { + Ok(None) // Expiré + } + } + Err(_) => Ok(None), // Absent + } + } + + fn set_radiofrance_station_mapping( + &self, + mapping: &HashMap, + ) -> Result<()> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let cached = CachedStationMapping { + mapping: mapping.clone(), + last_updated: now, + }; + + let value = serde_yaml::to_value(&cached)?; + self.set_value(&["sources", "radiofrance", "station_mapping"], value) + } + + fn get_radiofrance_mapping_ttl(&self) -> Result { + match self.get_value(&["sources", "radiofrance", "station_mapping_ttl_secs"]) { + Ok(Value::Number(n)) => { + if let Some(ttl) = n.as_u64() { + Ok(ttl) + } else { + self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?; + Ok(DEFAULT_STATION_MAPPING_TTL_SECS) + } + } + _ => { + self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?; + Ok(DEFAULT_STATION_MAPPING_TTL_SECS) + } + } + } + + fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()> { + self.set_value( + &["sources", "radiofrance", "station_mapping_ttl_secs"], + Value::Number(serde_yaml::Number::from(ttl_secs)), + ) + } + + fn clear_radiofrance_station_mapping(&self) -> Result<()> { + self.set_value( + &["sources", "radiofrance", "station_mapping"], + Value::Null, + ) + } + + fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()> { + // Charger le mapping existant (même expiré) pour mise à jour incrémentale + let mut mapping = match self.get_value(&["sources", "radiofrance", "station_mapping"]) { + Ok(value) => serde_yaml::from_value::(value) + .map(|c| c.mapping) + .unwrap_or_default(), + Err(_) => HashMap::new(), + }; + + mapping.insert(slug.to_string(), info); + + self.set_radiofrance_station_mapping(&mapping) + } } #[cfg(test)] diff --git a/pmoradiofrance/src/lib.rs b/pmoradiofrance/src/lib.rs index 5eb491f6..1fb0788d 100644 --- a/pmoradiofrance/src/lib.rs +++ b/pmoradiofrance/src/lib.rs @@ -122,7 +122,7 @@ pub use models::{ }; #[cfg(feature = "pmoconfig")] -pub use config_ext::RadioFranceConfigExt; +pub use config_ext::{RadioFranceConfigExt, StationInfo}; #[cfg(feature = "pmoconfig")] pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback}; diff --git a/pmoradiofrance/src/metadata_cache.rs b/pmoradiofrance/src/metadata_cache.rs index 14fa67c8..6e88593e 100644 --- a/pmoradiofrance/src/metadata_cache.rs +++ b/pmoradiofrance/src/metadata_cache.rs @@ -12,9 +12,11 @@ //! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements use crate::client::RadioFranceClient; +use crate::config_ext::{RadioFranceConfigExt, StationInfo}; use crate::error::Result; use crate::models::{ImageSize, LiveResponse, Station}; use pmoconfig::Config; +#[cfg(feature = "playlist")] use pmodidl::{Container, Item, Resource}; use std::collections::HashMap; use std::sync::Arc; @@ -59,6 +61,9 @@ pub struct CachedMetadata { // TTL = end_time de l'API Radio France pub end_time: Option, // Unix timestamp + + /// Timestamp de la dernière récupération (pour TTL minimum) + pub fetched_at: u64, } impl CachedMetadata { @@ -89,6 +94,10 @@ impl CachedMetadata { // 4. TTL = end_time let end_time = live.now.end_time; + let fetched_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); Ok(Self { slug: station.slug.clone(), @@ -106,6 +115,7 @@ impl CachedMetadata { nr_audio_channels, duration, end_time, + fetched_at, }) } @@ -349,6 +359,7 @@ impl CachedMetadata { /// Construit un Container DIDL de playlist à un item /// /// La playlist et l'item ont EXACTEMENT les mêmes métadonnées + #[cfg(feature = "playlist")] pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container { // Calculer la duration dynamiquement (temps restant jusqu'à end_time) let duration = if let Some(end) = self.end_time { @@ -432,17 +443,23 @@ impl CachedMetadata { } } + /// TTL minimum quand l'API ne fournit pas de end_time (3 minutes) + pub const FALLBACK_TTL_SECS: u64 = 3 * 60; + /// Vérifie si le TTL est dépassé + /// + /// - Si `end_time` est fourni par l'API : expire exactement quand l'émission se termine + /// - Sinon : expire après `FALLBACK_TTL_SECS` depuis le dernier fetch pub fn is_expired(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + if let Some(end_time) = self.end_time { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); now >= end_time } else { - // Pas de end_time = toujours expiré (refresh systématique) - true + now >= self.fetched_at + Self::FALLBACK_TTL_SECS } } } @@ -478,6 +495,9 @@ pub struct MetadataCache { impl MetadataCache { /// Constructeur avec tous les paramètres obligatoires + /// + /// Charge automatiquement le mapping station depuis pmoconfig (si valide). + /// Si le cache est absent ou expiré, utilise les valeurs hardcodées du client. #[cfg(feature = "cache")] pub fn new( client: RadioFranceClient, @@ -485,6 +505,25 @@ impl MetadataCache { server_base_url: String, config: Arc, ) -> Self { + // Charger le mapping depuis pmoconfig et l'injecter dans le client + if let Ok(Some(mapping)) = config.get_radiofrance_station_mapping() { + #[cfg(feature = "logging")] + tracing::info!( + "Loaded station mapping from config ({} entries)", + mapping.len() + ); + client.set_station_mapping(mapping); + } else { + // Pas de cache persistant : persister le mapping par défaut + let mapping = client.get_station_mapping(); + let _ = config.set_radiofrance_station_mapping(&mapping); + #[cfg(feature = "logging")] + tracing::info!( + "Initialized station mapping from defaults ({} entries)", + mapping.len() + ); + } + Self { cache: Arc::new(RwLock::new(HashMap::new())), client, @@ -525,18 +564,46 @@ impl MetadataCache { let live_response = match self.client.live_metadata(slug).await { Ok(resp) => resp, Err(e) => { - // Graceful degradation: retourner les données expirées si API down - let cache = self.cache.read().await; - if let Some(metadata) = cache.get(slug) { + // Si station inconnue → tenter une re-découverte du mapping + let is_unknown = e.to_string().contains("Unknown station slug"); + if is_unknown { #[cfg(feature = "logging")] - tracing::warn!( - "API Radio France down for {}, using expired cache: {}", - slug, - e - ); - return Ok(metadata.clone()); + tracing::warn!("Unknown station '{}', attempting rediscovery", slug); + + match self.client.rediscover_station(slug).await { + Ok(_) => { + // Persister le mapping mis à jour + self.persist_station_mapping(); + // Réessayer la requête + match self.client.live_metadata(slug).await { + Ok(resp) => resp, + Err(e2) => return Err(e2), + } + } + Err(discover_err) => { + #[cfg(feature = "logging")] + tracing::error!( + "Rediscovery failed for '{}': {}", + slug, + discover_err + ); + return Err(e); + } + } + } else { + // Graceful degradation: retourner les données expirées si API down + let cache = self.cache.read().await; + if let Some(metadata) = cache.get(slug) { + #[cfg(feature = "logging")] + tracing::warn!( + "API Radio France down for {}, using expired cache: {}", + slug, + e + ); + return Ok(metadata.clone()); + } + return Err(e); } - return Err(e); } }; @@ -575,17 +642,34 @@ impl MetadataCache { Ok(metadata) } - /// Récupère la liste des stations (cache persistant via pmoconfig) + /// Récupère la liste des stations (cache persistant via pmoconfig, TTL 7 jours) /// /// # Logique /// - /// 1. Essaie de lire depuis pmoconfig - /// 2. Si cache valide (TTL 1 semaine), retourne - /// 3. Sinon, découvre via API et met à jour pmoconfig + /// 1. Essaie de lire depuis pmoconfig (cache TTL 7 jours) + /// 2. Si cache valide, retourne sans appel réseau + /// 3. Sinon, découvre via scraping et met à jour pmoconfig pub async fn get_stations(&self) -> Result> { - // TODO: Implémenter avec config_ext - // Pour l'instant, découvre directement - self.client.discover_all_stations().await + // 1. Essayer le cache persistant + if let Ok(Some(stations)) = self.config.get_radiofrance_stations_cached() { + #[cfg(feature = "logging")] + tracing::debug!("Using cached station list ({} stations)", stations.len()); + return Ok(stations); + } + + // 2. Cache miss : découverte via scraping + #[cfg(feature = "logging")] + tracing::info!("Station cache miss, discovering via web scraping..."); + + let stations = self.client.discover_all_stations().await?; + + // 3. Persister dans pmoconfig + if let Err(e) = self.config.set_radiofrance_cached_stations(&stations) { + #[cfg(feature = "logging")] + tracing::warn!("Failed to cache station list: {}", e); + } + + Ok(stations) } /// Récupère les métadonnées live brutes de l'API (sans cache) @@ -601,6 +685,60 @@ impl MetadataCache { self.client.get_hifi_stream_url(slug).await } + /// Force une re-découverte du mapping pour un slug donné + /// + /// Utilisé quand un stream retourne une erreur HTTP (404, connexion refusée, etc.) + /// Persiste le mapping mis à jour dans pmoconfig. + pub async fn handle_stream_failure(&self, slug: &str) { + #[cfg(feature = "logging")] + tracing::warn!("Stream failure for '{}', triggering rediscovery", slug); + + match self.client.rediscover_station(slug).await { + Ok((id, url)) => { + #[cfg(feature = "logging")] + tracing::info!( + "Re-discovered station '{}': id={}, url={}", + slug, + id, + url + ); + self.persist_station_mapping(); + // Invalider le cache mémoire pour forcer un refresh des métadonnées + let mut cache = self.cache.write().await; + cache.remove(slug); + } + Err(e) => { + #[cfg(feature = "logging")] + tracing::error!("Rediscovery failed for '{}': {}", slug, e); + } + } + } + + /// Persiste le mapping courant dans pmoconfig + fn persist_station_mapping(&self) { + let mapping = self.client.get_station_mapping(); + if let Err(e) = self.config.set_radiofrance_station_mapping(&mapping) { + #[cfg(feature = "logging")] + tracing::warn!("Failed to persist station mapping: {}", e); + } + } + + /// Met à jour manuellement le mapping d'une station et le persiste + pub fn update_station_mapping(&self, slug: &str, station_id: u32, stream_url: String) { + self.client + .update_station_entry(slug, station_id, stream_url.clone()); + + // Persister dans pmoconfig + let info = StationInfo { + station_id, + stream_url, + }; + if let Err(e) = self.config.upsert_radiofrance_station_info(slug, info) { + #[cfg(feature = "logging")] + tracing::warn!("Failed to upsert station info for '{}': {}", slug, e); + } + } + /// S'abonner aux changements de métadonnées /// /// Le callback sera appelé avec le slug chaque fois que @@ -634,7 +772,12 @@ mod tests { #[test] fn test_cached_metadata_is_expired() { - let metadata = CachedMetadata { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let base = CachedMetadata { slug: "test".to_string(), title: "Test".to_string(), creator: None, @@ -650,15 +793,26 @@ mod tests { nr_audio_channels: None, duration: None, end_time: Some(0), // Dans le passé + fetched_at: now, }; - assert!(metadata.is_expired()); + // end_time dans le passé → expiré + assert!(base.is_expired()); - let metadata_no_end = CachedMetadata { + // end_time dans le futur → non expiré + let not_expired = CachedMetadata { end_time: Some(now + 3600), ..base.clone() }; + assert!(!not_expired.is_expired()); + + // Pas de end_time, fetched_at récent → non expiré (fallback TTL) + let no_end_fresh = CachedMetadata { end_time: None, fetched_at: now, ..base.clone() }; + assert!(!no_end_fresh.is_expired()); + + // Pas de end_time, fetched_at ancien → expiré + let no_end_old = CachedMetadata { end_time: None, - ..metadata + fetched_at: now - CachedMetadata::FALLBACK_TTL_SECS - 1, + ..base }; - - assert!(metadata_no_end.is_expired()); + assert!(no_end_old.is_expired()); } } diff --git a/pmoradiofrance/src/models.rs b/pmoradiofrance/src/models.rs index 3e7a82e2..9777f590 100644 --- a/pmoradiofrance/src/models.rs +++ b/pmoradiofrance/src/models.rs @@ -31,14 +31,117 @@ impl Station { } // ============================================================================ -// Live API Response Models +// New livemeta/pull API Models (api.radiofrance.fr) // ============================================================================ -/// Response from the /api/live? endpoint +/// Response from https://api.radiofrance.fr/livemeta/pull/{stationId} +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PullResponse { + /// Numeric station ID + pub station_id: u32, + /// Map of stepId -> step metadata + pub steps: std::collections::HashMap, + /// Ordered levels (depth 1 = current show/song) + pub levels: Vec, +} + +impl PullResponse { + /// Get the current step at depth 1 (the "now playing" item) + pub fn current_step(&self) -> Option<&PullStep> { + // levels[0].items[0] is the current item at depth 1 (most relevant) + let level = self.levels.first()?; + let step_id = level.items.first()?; + self.steps.get(step_id) + } +} + +/// A level in the livemeta response (groups steps by depth) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PullLevel { + /// Ordered list of step IDs at this level + pub items: Vec, + /// Depth (1 = show/song, 2 = sub-item, 3 = deeper) + pub position: u32, +} + +/// A step (show, episode, or song) from the livemeta pull API +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PullStep { + pub uuid: String, + pub step_id: String, + pub title: String, + pub start: Option, + pub end: Option, + pub station_id: u32, + pub embed_type: Option, // "song", "expression", "concept" + pub depth: u32, + pub disc_jockey: Option, + /// For songs: authors + #[serde(default)] + pub authors: serde_json::Value, // can be String or Vec + #[serde(default)] + pub performers: Option, + #[serde(default)] + pub highlighted_artists: Vec, + pub song_id: Option, + pub titre_album: Option, + pub label: Option, + pub annee_edition_musique: Option, + pub cover_uuid: Option, + /// Direct UUID for visual (new API uses UUID directly, not URL) + pub visual: Option, + /// For shows: concept title (e.g. "La Science, CQFD") + pub title_concept: Option, + /// For shows: producers list + #[serde(default)] + pub producers: Vec, + pub path: Option, + pub expression_description: Option, + pub description: Option, +} + +impl PullStep { + /// Returns true if this step is a music track + pub fn is_song(&self) -> bool { + self.embed_type.as_deref() == Some("song") + } + + /// Get artist display string + pub fn artists_display(&self) -> String { + if !self.highlighted_artists.is_empty() { + return self.highlighted_artists.join(", "); + } + match &self.authors { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", "), + _ => String::new(), + } + } +} + +/// A producer in a PullStep +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PullProducer { + pub uuid: String, + pub name: String, +} + +// ============================================================================ +// Live API Response Models (internal representation) +// ============================================================================ + +/// Internal live metadata representation +/// Built from PullResponse (new API) or used directly in tests #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LiveResponse { - /// Station name (slug) + /// Station slug pub station_name: String, /// Recommended delay before next refresh (milliseconds) pub delay_to_refresh: u64, @@ -59,7 +162,7 @@ impl LiveResponse { } /// Metadata for a show or track currently playing -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ShowMetadata { /// Whether to display music program info diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs index a221ea52..5c0a2fd4 100644 --- a/pmoradiofrance/src/playlist.rs +++ b/pmoradiofrance/src/playlist.rs @@ -98,18 +98,26 @@ impl StationGroups { /// Niveau 0: to_didl() retourne le container "radiofrance" avec tous les groupes /// - /// Appelle to_stub() sur chaque StationGroup + /// Appelle to_stub() sur chaque StationGroup en parallèle pub async fn to_didl( &self, metadata_cache: &MetadataCache, server_base_url: &str, ) -> Result { - let mut containers = Vec::new(); + use futures::stream::{self, StreamExt}; - for group in &self.groups { - let container = group.to_stub(metadata_cache, server_base_url).await?; - containers.push(container); - } + let futures: Vec<_> = self + .groups + .iter() + .map(|group| group.to_stub(metadata_cache, server_base_url)) + .collect(); + + let results: Vec> = stream::iter(futures) + .buffer_unordered(8) + .collect() + .await; + + let containers: Vec = results.into_iter().collect::>>()?; Ok(Container { id: "radiofrance".to_string(), From 0f0a8d1c91e8fc20833a207df335120636fb2be9 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 24 Mar 2026 10:49:04 +0100 Subject: [PATCH 13/18] deps: update esbuild to 0.27.4, rollup to 4.60.0, vite to 7.3.1 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 --- pmoapp/webapp/package-lock.json | 496 ++++++++++-------- .../webapp/src/composables/useCoverImage.ts | 14 +- .../src/sinks/direct_ogg_flac_sink.rs | 2 +- pmoaudio-ext/src/sources/player_source.rs | 2 +- pmoaudio-ext/src/sources/playlist_source.rs | 4 +- pmocache/src/cache_trait.rs | 1 + pmocache/src/pmoserver_ext.rs | 25 +- pmocontrol/src/control_point.rs | 2 +- .../src/music_renderer/musicrenderer.rs | 2 +- pmoqobuz/src/api/mod.rs | 2 +- pmoradiofrance/src/client.rs | 1 - pmoradiofrance/src/metadata_cache.rs | 3 + pmoradiofrance/src/pmoserver_ext.rs | 1 + pmosource/src/api.rs | 2 +- pmowebrenderer/src/handlers.rs | 2 +- pmowebrenderer/src/pipeline.rs | 2 +- pmowebrenderer/src/registry.rs | 2 +- 17 files changed, 340 insertions(+), 223 deletions(-) diff --git a/pmoapp/webapp/package-lock.json b/pmoapp/webapp/package-lock.json index 617e7ac5..220494aa 100644 --- a/pmoapp/webapp/package-lock.json +++ b/pmoapp/webapp/package-lock.json @@ -74,9 +74,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", - "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "cpu": [ "ppc64" ], @@ -91,9 +91,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", - "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "cpu": [ "arm" ], @@ -108,9 +108,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", - "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "cpu": [ "arm64" ], @@ -125,9 +125,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", - "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "cpu": [ "x64" ], @@ -142,9 +142,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", - "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "cpu": [ "arm64" ], @@ -159,9 +159,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", - "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "cpu": [ "x64" ], @@ -176,9 +176,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", - "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "cpu": [ "arm64" ], @@ -193,9 +193,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", - "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "cpu": [ "x64" ], @@ -210,9 +210,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", - "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -227,9 +227,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", - "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -244,9 +244,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", - "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -261,9 +261,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", - "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -278,9 +278,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", - "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -295,9 +295,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", - "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -312,9 +312,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", - "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -329,9 +329,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", - "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -346,9 +346,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", - "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -363,9 +363,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", - "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -380,9 +380,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", - "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -397,9 +397,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", - "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -414,9 +414,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", - "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -431,9 +431,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", - "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -448,9 +448,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", - "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -465,9 +465,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", - "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -482,9 +482,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", - "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -499,9 +499,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", - "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -529,9 +529,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", - "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "cpu": [ "arm" ], @@ -543,9 +543,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", - "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "cpu": [ "arm64" ], @@ -557,9 +557,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", - "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -571,9 +571,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", - "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -585,9 +585,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", - "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -599,9 +599,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", - "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -613,13 +613,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", - "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -627,13 +630,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", - "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -641,13 +647,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", - "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -655,13 +664,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", - "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -669,13 +681,33 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", - "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -683,13 +715,33 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", - "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -697,13 +749,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", - "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -711,13 +766,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", - "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -725,13 +783,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", - "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -739,13 +800,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", - "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -753,9 +817,26 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", - "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", "cpu": [ "x64" ], @@ -763,13 +844,13 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", - "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", "cpu": [ "arm64" ], @@ -781,9 +862,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", - "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -795,9 +876,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", - "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -809,9 +890,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", - "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", "cpu": [ "x64" ], @@ -823,9 +904,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", - "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -1177,9 +1258,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -1198,9 +1279,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", - "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1211,32 +1292,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.10", - "@esbuild/android-arm": "0.25.10", - "@esbuild/android-arm64": "0.25.10", - "@esbuild/android-x64": "0.25.10", - "@esbuild/darwin-arm64": "0.25.10", - "@esbuild/darwin-x64": "0.25.10", - "@esbuild/freebsd-arm64": "0.25.10", - "@esbuild/freebsd-x64": "0.25.10", - "@esbuild/linux-arm": "0.25.10", - "@esbuild/linux-arm64": "0.25.10", - "@esbuild/linux-ia32": "0.25.10", - "@esbuild/linux-loong64": "0.25.10", - "@esbuild/linux-mips64el": "0.25.10", - "@esbuild/linux-ppc64": "0.25.10", - "@esbuild/linux-riscv64": "0.25.10", - "@esbuild/linux-s390x": "0.25.10", - "@esbuild/linux-x64": "0.25.10", - "@esbuild/netbsd-arm64": "0.25.10", - "@esbuild/netbsd-x64": "0.25.10", - "@esbuild/openbsd-arm64": "0.25.10", - "@esbuild/openbsd-x64": "0.25.10", - "@esbuild/openharmony-arm64": "0.25.10", - "@esbuild/sunos-x64": "0.25.10", - "@esbuild/win32-arm64": "0.25.10", - "@esbuild/win32-ia32": "0.25.10", - "@esbuild/win32-x64": "0.25.10" + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" } }, "node_modules/estree-walker": { @@ -1454,9 +1535,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", - "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1470,28 +1551,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.3", - "@rollup/rollup-android-arm64": "4.52.3", - "@rollup/rollup-darwin-arm64": "4.52.3", - "@rollup/rollup-darwin-x64": "4.52.3", - "@rollup/rollup-freebsd-arm64": "4.52.3", - "@rollup/rollup-freebsd-x64": "4.52.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", - "@rollup/rollup-linux-arm-musleabihf": "4.52.3", - "@rollup/rollup-linux-arm64-gnu": "4.52.3", - "@rollup/rollup-linux-arm64-musl": "4.52.3", - "@rollup/rollup-linux-loong64-gnu": "4.52.3", - "@rollup/rollup-linux-ppc64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-musl": "4.52.3", - "@rollup/rollup-linux-s390x-gnu": "4.52.3", - "@rollup/rollup-linux-x64-gnu": "4.52.3", - "@rollup/rollup-linux-x64-musl": "4.52.3", - "@rollup/rollup-openharmony-arm64": "4.52.3", - "@rollup/rollup-win32-arm64-msvc": "4.52.3", - "@rollup/rollup-win32-ia32-msvc": "4.52.3", - "@rollup/rollup-win32-x64-gnu": "4.52.3", - "@rollup/rollup-win32-x64-msvc": "4.52.3", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" } }, @@ -1564,13 +1648,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", - "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", diff --git a/pmoapp/webapp/src/composables/useCoverImage.ts b/pmoapp/webapp/src/composables/useCoverImage.ts index e7f2968d..74a1755c 100644 --- a/pmoapp/webapp/src/composables/useCoverImage.ts +++ b/pmoapp/webapp/src/composables/useCoverImage.ts @@ -10,7 +10,7 @@ export function useCoverImage( imageUrl: Ref, options: CoverImageOptions = {}, ) { - const { maxRetries = 3, retryDelay = 1000, forceReload = true } = options; + const { maxRetries = 5, retryDelay = 500, forceReload = true } = options; const imageLoaded = ref(false); const imageError = ref(false); @@ -66,6 +66,9 @@ export function useCoverImage( if (retryCount.value < maxRetries) { retryCount.value++; + // Backoff : 500ms, 1s, 2s, 4s, 8s — rapide au début pour les covers + // en cours de téléchargement, plus espacé ensuite pour les erreurs réseau + const delay = retryDelay * Math.pow(2, retryCount.value - 1); setTimeout(() => { if (!currentUrl.value) return; @@ -74,7 +77,7 @@ export function useCoverImage( currentUrl.value, retryCount.value, ); - }, retryDelay * retryCount.value); // Exponential backoff + }, delay); } else { console.error( `[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`, @@ -102,8 +105,15 @@ export function useCoverImage( // Retry if we haven't reached max retries if (retryCount.value < maxRetries) { + const delay = retryDelay * Math.pow(2, retryCount.value); + console.log( + `[useCoverImage] Scheduling retry ${retryCount.value + 1}/${maxRetries} in ${delay}ms for: ${currentUrl.value}`, + ); retryLoad(); } else { + console.error( + `[useCoverImage] Giving up after ${maxRetries} retries for: ${currentUrl.value}`, + ); imageError.value = true; } } diff --git a/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs b/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs index efef08fd..01629ba2 100644 --- a/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs +++ b/pmoaudio-ext/src/sinks/direct_ogg_flac_sink.rs @@ -36,7 +36,7 @@ use pmoaudio::{ }; use pmoflac::{EncoderOptions, PcmFormat}; use tokio::io::{AsyncRead, ReadBuf}; -use tokio::sync::{mpsc, watch, Mutex}; +use tokio::sync::{mpsc, Mutex}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{debug, trace, warn}; diff --git a/pmoaudio-ext/src/sources/player_source.rs b/pmoaudio-ext/src/sources/player_source.rs index 06d4a428..b4a366d4 100644 --- a/pmoaudio-ext/src/sources/player_source.rs +++ b/pmoaudio-ext/src/sources/player_source.rs @@ -32,7 +32,7 @@ use std::sync::Arc; use async_trait::async_trait; use pmometadata::{MemoryTrackMetadata, TrackMetadata}; use pmoaudio::{ - AudioSegment, SyncMarker, + AudioSegment, nodes::AudioError, pipeline::{AudioPipelineNode, Node, NodeLogic, send_to_children}, }; diff --git a/pmoaudio-ext/src/sources/playlist_source.rs b/pmoaudio-ext/src/sources/playlist_source.rs index 845b8541..f8d1629b 100644 --- a/pmoaudio-ext/src/sources/playlist_source.rs +++ b/pmoaudio-ext/src/sources/playlist_source.rs @@ -116,8 +116,8 @@ use pmoaudio::{ AudioSegment, }; use pmoaudiocache::Cache as AudioCache; -use pmoflac::{decode_audio_stream, StreamInfo}; -use pmoplaylist::{PlaylistRole, ReadHandle}; +use pmoflac::decode_audio_stream; +use pmoplaylist::ReadHandle; use super::pcm_decode::{bytes_to_segment, validate_stream}; use std::{path::PathBuf, sync::Arc, time::Duration}; diff --git a/pmocache/src/cache_trait.rs b/pmocache/src/cache_trait.rs index 42fe8630..97e422c4 100644 --- a/pmocache/src/cache_trait.rs +++ b/pmocache/src/cache_trait.rs @@ -9,6 +9,7 @@ use crate::{cache::is_lazy_pk, CacheConfig, DB}; /// Trait générique pour les caches de fichiers /// /// Définit l'interface commune pour tous les types de caches (images, audio, etc.) +#[allow(async_fn_in_trait)] pub trait FileCache: Send + Sync { fn get_cache_dir(&self) -> &Path; fn get_database(&self) -> Arc; diff --git a/pmocache/src/pmoserver_ext.rs b/pmocache/src/pmoserver_ext.rs index 817ea179..96943adb 100644 --- a/pmocache/src/pmoserver_ext.rs +++ b/pmocache/src/pmoserver_ext.rs @@ -92,9 +92,17 @@ async fn get_file( Path(pk): Path, request: axum::http::Request, ) -> Response { - // Utiliser le param par défaut let param = C::default_param(); - serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await + tracing::debug!( + "[{}] GET /{}/{} param={}", + C::cache_name(), C::cache_type(), pk, param + ); + let response = serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await; + tracing::debug!( + "[{}] GET /{}/{} → {}", + C::cache_name(), C::cache_type(), pk, response.status() + ); + response } /// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param} @@ -267,6 +275,16 @@ async fn serve_file_with_streaming( } // Fichier terminé ou pas de download en cours, servir normalement avec support Range + if !file_path.exists() { + // Fichier absent : soit download background pas encore terminé, + // soit transformation WebP/variant pas encore écrite sur disque + let in_db = cache.db.get(pk, false).is_ok(); + warn!( + "Requested file not on disk yet: pk={} in_db={} path={:?} — client should retry", + pk, in_db, file_path + ); + } + let response = serve_complete_file(file_path, content_type, request).await; if response.status().is_success() { @@ -287,7 +305,7 @@ async fn stream_file_progressive( request: axum::http::Request, ) -> Response { use axum::http::header; - use tokio::io::{AsyncReadExt, AsyncSeekExt}; + use tokio::io::AsyncSeekExt; // Attendre qu'au moins 64 KB soient disponibles avant de commencer const MIN_SIZE_TO_START: u64 = 64 * 1024; @@ -602,6 +620,7 @@ pub fn create_api_router(cache: Arc>) -> Rout /// /// Permet d'initialiser un cache générique avec routes HTTP complètes #[cfg(feature = "pmoserver")] +#[allow(async_fn_in_trait)] pub trait GenericCacheExt { /// Initialise un cache générique avec routes complètes /// diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index c2bd0704..224ccaad 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -26,7 +26,7 @@ use crate::openapi::{ CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView, RendererStateView, }; -use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot}; +use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot}; use crate::registry::DeviceRegistry; /// Control point minimal : diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 0dfa758b..8495c7a1 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -35,7 +35,7 @@ use crate::music_renderer::watcher::{ }; use crate::online::DeviceConnectionState; use crate::queue::{ - EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot, + EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot, }; use crate::{DeviceId, DeviceIdentity, DeviceOnline}; diff --git a/pmoqobuz/src/api/mod.rs b/pmoqobuz/src/api/mod.rs index 70bcb0d1..19fc62f7 100644 --- a/pmoqobuz/src/api/mod.rs +++ b/pmoqobuz/src/api/mod.rs @@ -15,7 +15,7 @@ use serde::de::DeserializeOwned; use serde_json::Value; use std::sync::RwLock; use std::time::Duration; -use tracing::{debug, warn}; +use tracing::debug; pub use spoofer::Spoofer; diff --git a/pmoradiofrance/src/client.rs b/pmoradiofrance/src/client.rs index 1d6de784..f4bace58 100644 --- a/pmoradiofrance/src/client.rs +++ b/pmoradiofrance/src/client.rs @@ -38,7 +38,6 @@ use scraper::{Html, Selector}; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, RwLock}; use std::time::Duration; -use url::Url; #[cfg(feature = "pmoconfig")] use crate::config_ext::StationInfo; diff --git a/pmoradiofrance/src/metadata_cache.rs b/pmoradiofrance/src/metadata_cache.rs index 6e88593e..f8151f4f 100644 --- a/pmoradiofrance/src/metadata_cache.rs +++ b/pmoradiofrance/src/metadata_cache.rs @@ -248,6 +248,9 @@ impl CachedMetadata { match cache.add_from_url(&cover_url, Some("radiofrance")).await { Ok(pk) => { // Construire l'URL publique + // Note: add_from_url() lance le téléchargement complet en arrière-plan. + // L'URL est valide immédiatement — si le fichier n'est pas encore prêt, + // le client web doit réessayer (retry avec backoff). let route = cache.route_for(&pk, None); let public_url = format!("{}{}", server_base_url.trim_end_matches('/'), route); diff --git a/pmoradiofrance/src/pmoserver_ext.rs b/pmoradiofrance/src/pmoserver_ext.rs index 6b3c94c0..5053fa0b 100644 --- a/pmoradiofrance/src/pmoserver_ext.rs +++ b/pmoradiofrance/src/pmoserver_ext.rs @@ -50,6 +50,7 @@ impl RadioFranceState { /// Ok(()) /// } /// ``` +#[allow(async_fn_in_trait)] pub trait RadioFranceExt { /// Initialise l'extension Radio France et enregistre les routes HTTP /// diff --git a/pmosource/src/api.rs b/pmosource/src/api.rs index 9beda562..162f039a 100644 --- a/pmosource/src/api.rs +++ b/pmosource/src/api.rs @@ -919,7 +919,7 @@ async fn stream_source_item_metadata( Query(params): Query, ) -> impl IntoResponse { use axum::response::sse::{Event, KeepAlive, Sse}; - use futures::stream::{self, Stream}; + use futures::stream; use std::convert::Infallible; use std::time::Duration; use tokio_stream::StreamExt as _; diff --git a/pmowebrenderer/src/handlers.rs b/pmowebrenderer/src/handlers.rs index 97503cc9..e0bc54ce 100644 --- a/pmowebrenderer/src/handlers.rs +++ b/pmowebrenderer/src/handlers.rs @@ -11,7 +11,7 @@ use pmoupnp::{get, set}; use pmodidl::ToXmlElement; use crate::messages::PlaybackState; -use crate::pipeline::{PipelineControl, PipelineHandle, seconds_to_upnp_time, upnp_time_to_seconds}; +use crate::pipeline::{PipelineControl, PipelineHandle, upnp_time_to_seconds}; use crate::state::SharedState; type ActionFuture = diff --git a/pmowebrenderer/src/pipeline.rs b/pmowebrenderer/src/pipeline.rs index 843f79c6..eeee9734 100644 --- a/pmowebrenderer/src/pipeline.rs +++ b/pmowebrenderer/src/pipeline.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use pmoaudio::{AudioPipelineNode, ResamplingNode, ToI24Node}; +use pmoaudio::{ResamplingNode, ToI24Node}; use pmoaudio_ext::{PlayerCommand, PlayerHandle, PlayerSource}; use pmoaudio_ext::sinks::{OggFlacStreamHandle, StreamingOggFlacSink}; use pmoflac::EncoderOptions; diff --git a/pmowebrenderer/src/registry.rs b/pmowebrenderer/src/registry.rs index 25650dc5..f0039621 100644 --- a/pmowebrenderer/src/registry.rs +++ b/pmowebrenderer/src/registry.rs @@ -14,7 +14,7 @@ use crate::error::WebRendererError; use crate::pipeline::{InstancePipeline, PipelineHandle}; use crate::renderer::WebRendererFactory; use crate::state::{RendererState, SharedState}; -use crate::messages::PlaybackState; + #[cfg(feature = "pmoserver")] use pmocontrol::{ControlPoint, DeviceId}; From 83f814c729fe098a497999ba6623b60e685fafcc Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 24 Mar 2026 11:00:44 +0100 Subject: [PATCH 14/18] Remove lazy loading attribute from cover image Removed the 'loading="lazy"' attribute from the album art image to ensure immediate loading for better UX in server drawer. --- pmoapp/webapp/src/components/unified/ServerDrawer.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/pmoapp/webapp/src/components/unified/ServerDrawer.vue b/pmoapp/webapp/src/components/unified/ServerDrawer.vue index c12c918c..676ac37d 100644 --- a/pmoapp/webapp/src/components/unified/ServerDrawer.vue +++ b/pmoapp/webapp/src/components/unified/ServerDrawer.vue @@ -502,7 +502,6 @@ function handleSettingsClick() { :src="item.album_art_uri" :alt="item.title" class="cover-image" - loading="lazy" @load="handleImageLoad(item.id)" @error="handleImageError(item.id)" /> From b690420550fd98718de49129619144b727d9a10a Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 24 Mar 2026 11:23:56 +0100 Subject: [PATCH 15/18] feat: add parallel cover caching and refactor browse methods Introduce async helper methods `cache_album_covers` and `cache_playlist_covers` to cache album/playlist covers in parallel using JoinSet. Refactor browse functions for favorites, discover, and genre pages to reuse these helpers and avoid duplicate logic. Simplify cover URL resolution by unifying caching path and removing redundant base URL concatenation. --- pmoqobuz/src/source.rs | 226 +++++++++++++++-------------------------- 1 file changed, 81 insertions(+), 145 deletions(-) diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index 765f76a4..6b47fc67 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -853,6 +853,47 @@ impl QobuzSource { } } + /// Cache les covers d'une liste d'albums en parallèle. + /// Retourne les albums avec `image_cached` mis à jour si la cover a pu être mise en cache. + async fn cache_album_covers(&self, albums: Vec) -> Vec { + let futs: Vec<_> = albums.into_iter().map(|mut album| { + let source = self.clone(); + async move { + if let Some(ref image_url) = album.image.clone() { + if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await { + if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { + album.image_cached = Some(url); + } + } + } + album + } + }).collect(); + tokio::task::JoinSet::from_iter(futs) + .join_all() + .await + } + + /// Cache les covers d'une liste de playlists en parallèle. + async fn cache_playlist_covers(&self, playlists: Vec) -> Vec { + let futs: Vec<_> = playlists.into_iter().map(|mut playlist| { + let source = self.clone(); + async move { + if let Some(ref image_url) = playlist.image.clone() { + if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await { + if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { + playlist.image_cached = Some(url); + } + } + } + playlist + } + }).collect(); + tokio::task::JoinSet::from_iter(futs) + .join_all() + .await + } + /// Browse Favourites - retourne 4 sous-containers async fn browse_favourites(&self) -> Result { let containers = vec![ @@ -873,6 +914,7 @@ impl QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let albums = self.cache_album_covers(albums).await; let containers: Vec = albums .into_iter() .filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok()) @@ -939,6 +981,7 @@ impl QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let playlists = self.cache_playlist_covers(playlists).await; let containers: Vec = playlists .into_iter() .filter_map(|playlist| playlist.to_didl_container("qobuz:favorites:playlists").ok()) @@ -1061,76 +1104,56 @@ impl QobuzSource { } } - /// Browse Discover Playlists (all featured playlists) - async fn browse_discover_playlists(&self) -> Result { + /// Helper : récupère des albums featured, cache les covers, retourne des containers DIDL + async fn browse_featured_albums(&self, genre_id: Option<&str>, filter: &str, parent_id: &str) -> Result { + let albums = self + .inner + .client + .get_featured_albums(genre_id, filter) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let albums = self.cache_album_covers(albums).await; + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container(parent_id).ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// Helper : récupère des playlists featured, cache les covers, retourne des containers DIDL + async fn browse_featured_playlists(&self, genre_id: Option<&str>, parent_id: &str) -> Result { let playlists = self .inner .client - .get_featured_playlists(None, None) + .get_featured_playlists(genre_id, None) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let playlists = self.cache_playlist_covers(playlists).await; let containers: Vec = playlists .into_iter() - .filter_map(|playlist| playlist.to_didl_container("qobuz:discover:playlists").ok()) + .filter_map(|playlist| playlist.to_didl_container(parent_id).ok()) .collect(); Ok(BrowseResult::Containers(containers)) } - /// Browse Discover Albums (Ideal Discography) + async fn browse_discover_playlists(&self) -> Result { + self.browse_featured_playlists(None, "qobuz:discover:playlists").await + } + async fn browse_discover_albums_ideal(&self) -> Result { - let albums = self - .inner - .client - .get_featured_albums(None, "ideal-discography") - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let containers: Vec = albums - .into_iter() - .filter_map(|album| album.to_didl_container("qobuz:discover:albums:ideal").ok()) - .collect(); - - Ok(BrowseResult::Containers(containers)) + self.browse_featured_albums(None, "ideal-discography", "qobuz:discover:albums:ideal").await } - /// Browse Discover Albums (Qobuzissime) async fn browse_discover_albums_qobuzissime(&self) -> Result { - let albums = self - .inner - .client - .get_featured_albums(None, "qobuzissims") - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let containers: Vec = albums - .into_iter() - .filter_map(|album| { - album - .to_didl_container("qobuz:discover:albums:qobuzissime") - .ok() - }) - .collect(); - - Ok(BrowseResult::Containers(containers)) + self.browse_featured_albums(None, "qobuzissims", "qobuz:discover:albums:qobuzissime").await } - /// Browse Discover Albums (New Releases) async fn browse_discover_albums_new(&self) -> Result { - let albums = self - .inner - .client - .get_featured_albums(None, "new-releases") - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let containers: Vec = albums - .into_iter() - .filter_map(|album| album.to_didl_container("qobuz:discover:albums:new").ok()) - .collect(); - - Ok(BrowseResult::Containers(containers)) + self.browse_featured_albums(None, "new-releases", "qobuz:discover:albums:new").await } /// Browse Discover Artists (Featured Artists) @@ -1324,112 +1347,28 @@ impl QobuzSource { } } - /// Browse Genre New Releases async fn browse_genre_new_releases(&self, genre_id: &str) -> Result { - let albums = self - .inner - .client - .get_featured_albums(Some(genre_id), "new-releases") - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let parent_id = format!("qobuz:genre:{}:new-releases", genre_id); - let containers: Vec = albums - .into_iter() - .filter_map(|album| album.to_didl_container(&parent_id).ok()) - .collect(); - - Ok(BrowseResult::Containers(containers)) + self.browse_featured_albums(Some(genre_id), "new-releases", &format!("qobuz:genre:{}:new-releases", genre_id)).await } - /// Browse Genre Ideal Discography async fn browse_genre_ideal_discography(&self, genre_id: &str) -> Result { - let albums = self - .inner - .client - .get_featured_albums(Some(genre_id), "ideal-discography") - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let parent_id = format!("qobuz:genre:{}:ideal", genre_id); - let containers: Vec = albums - .into_iter() - .filter_map(|album| album.to_didl_container(&parent_id).ok()) - .collect(); - - Ok(BrowseResult::Containers(containers)) + self.browse_featured_albums(Some(genre_id), "ideal-discography", &format!("qobuz:genre:{}:ideal", genre_id)).await } - /// Browse Genre Qobuzissime async fn browse_genre_qobuzissime(&self, genre_id: &str) -> Result { - let albums = self - .inner - .client - .get_featured_albums(Some(genre_id), "qobuzissims") - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let parent_id = format!("qobuz:genre:{}:qobuzissime", genre_id); - let containers: Vec = albums - .into_iter() - .filter_map(|album| album.to_didl_container(&parent_id).ok()) - .collect(); - - Ok(BrowseResult::Containers(containers)) + self.browse_featured_albums(Some(genre_id), "qobuzissims", &format!("qobuz:genre:{}:qobuzissime", genre_id)).await } - /// Browse Genre Editor Picks async fn browse_genre_editor_picks(&self, genre_id: &str) -> Result { - let albums = self - .inner - .client - .get_featured_albums(Some(genre_id), "editor-picks") - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let parent_id = format!("qobuz:genre:{}:editor-picks", genre_id); - let containers: Vec = albums - .into_iter() - .filter_map(|album| album.to_didl_container(&parent_id).ok()) - .collect(); - - Ok(BrowseResult::Containers(containers)) + self.browse_featured_albums(Some(genre_id), "editor-picks", &format!("qobuz:genre:{}:editor-picks", genre_id)).await } - /// Browse Genre Press Awards async fn browse_genre_press_awards(&self, genre_id: &str) -> Result { - let albums = self - .inner - .client - .get_featured_albums(Some(genre_id), "press-awards") - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let parent_id = format!("qobuz:genre:{}:press-awards", genre_id); - let containers: Vec = albums - .into_iter() - .filter_map(|album| album.to_didl_container(&parent_id).ok()) - .collect(); - - Ok(BrowseResult::Containers(containers)) + self.browse_featured_albums(Some(genre_id), "press-awards", &format!("qobuz:genre:{}:press-awards", genre_id)).await } - /// Browse Genre Playlists async fn browse_genre_playlists(&self, genre_id: &str) -> Result { - let playlists = self - .inner - .client - .get_featured_playlists(Some(genre_id), None) - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let parent_id = format!("qobuz:genre:{}:playlists", genre_id); - let containers: Vec = playlists - .into_iter() - .filter_map(|playlist| playlist.to_didl_container(&parent_id).ok()) - .collect(); - - Ok(BrowseResult::Containers(containers)) + self.browse_featured_playlists(Some(genre_id), &format!("qobuz:genre:{}:playlists", genre_id)).await } /// Parse object_id to determine what to browse @@ -1722,12 +1661,9 @@ impl MusicSource for QobuzSource { // Get cover URL from cache if available let cover_url = if let Some(ref album) = track.album { - if let Some(ref cached) = album.image_cached { - Some(format!("{}{}", self.inner.base_url, cached)) - } else if let Some(ref image) = album.image { - // Try to cache it + if let Some(ref image) = album.image { if let Ok(pk) = self.inner.cache_manager.cache_cover(image).await { - Some(format!("{}/covers/jpeg/{}", self.inner.base_url, pk)) + self.inner.cache_manager.cover_url(&pk, None).ok() } else { Some(image.clone()) } From 1164a114102a2ce3d25e55f2b9bb12066177e7e6 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 24 Mar 2026 12:02:53 +0100 Subject: [PATCH 16/18] feat(cache): centralize absolute URL generation with PMO_SERVER_URL Replace hardcoded relative URLs and manual base_url concatenation with a unified absolute URL API via pmocache::covers_absolute_url_for() and CacheTrait::absolute_url_for(). - Add pmocache as a required dependency to pmoparadise - Introduce absolute_url_for() and covers_absolute_url_for() helpers using PMO_SERVER_URL env var (default: http://localhost:8080) - Update all callers to use absolute URLs for covers and audio in streaming, playlists, Qobuz, Radio France, UPnP, and server startup - Remove redundant route_for() usage in URL construction - Add pmocache to Cargo.lock --- Cargo.lock | 1 + .../src/sinks/streaming_icyflac_sink.rs | 6 ++--- pmocache/src/cache_trait.rs | 19 ++++++++------- pmocache/src/lib.rs | 16 +++++++++++++ pmoparadise/Cargo.toml | 1 + pmoparadise/src/source.rs | 2 +- pmoplaylist/src/api.rs | 2 +- pmoplaylist/src/handle/read.rs | 4 ++-- pmoqobuz/src/api_rest.rs | 2 +- pmoradiofrance/src/metadata_cache.rs | 6 ++--- pmoserver/src/server.rs | 9 ++++++- pmoupnp/src/cache_registry.rs | 24 ++++--------------- 12 files changed, 51 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e75089a5..130a8bbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4147,6 +4147,7 @@ dependencies = [ "pmoaudio", "pmoaudio-ext", "pmoaudiocache", + "pmocache", "pmoconfig", "pmocovers", "pmoflac", diff --git a/pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs b/pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs index 57e9717d..85b7a5f5 100644 --- a/pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs +++ b/pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs @@ -88,10 +88,8 @@ impl IcyClientStream { // Add cover URL if we have a cover_pk if let Some(pk) = &meta.cover_pk { - // Use relative URL /covers/image/{pk}/256 - // This works when streaming from the same server that serves covers - // VLC and other players will resolve relative URLs correctly - metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk)); + let cover_url = pmocache::covers_absolute_url_for(pk, None); + metadata_str.push_str(&format!("StreamUrl='{}';", cover_url)); } else if let Some(url) = &meta.cover_url { // Fallback to external cover URL if no local pk metadata_str.push_str(&format!("StreamUrl='{}';", url)); diff --git a/pmocache/src/cache_trait.rs b/pmocache/src/cache_trait.rs index 97e422c4..344a94b0 100644 --- a/pmocache/src/cache_trait.rs +++ b/pmocache/src/cache_trait.rs @@ -69,14 +69,7 @@ pub trait FileCache: Send + Sync { /// Retourne la route relative pour accéder à un item du cache /// - /// # Arguments - /// - /// * `pk` - Clé primaire de la piste - /// * `param` - Paramètre optionnel (ex: "orig", "128k", etc.) - /// - /// # Returns - /// - /// Route relative (ex: "/audio/flac/abc123" ou "/audio/tracks/abc123/orig") + /// Format: `/{cache_name}/{cache_type}/{pk}[/{param}]` fn route_for(&self, pk: &str, param: Option<&str>) -> String { if let Some(p) = param { format!("/{}/{}/{}/{}", C::cache_name(), C::cache_type(), pk, p) @@ -85,6 +78,16 @@ pub trait FileCache: Send + Sync { } } + /// Retourne l'URL absolue pour accéder à un item du cache + /// + /// Utilise la variable d'environnement `PMO_SERVER_URL` comme base, + /// avec `http://localhost:8080` comme valeur par défaut. + fn absolute_url_for(&self, pk: &str, param: Option<&str>) -> String { + let base = std::env::var("PMO_SERVER_URL") + .unwrap_or_else(|_| "http://localhost:8080".to_string()); + format!("{}{}", base.trim_end_matches('/'), self.route_for(pk, param)) + } + /// Télécharge un fichier depuis une URL et l'ajoute au cache /// /// # Arguments diff --git a/pmocache/src/lib.rs b/pmocache/src/lib.rs index f6a97974..b1833226 100644 --- a/pmocache/src/lib.rs +++ b/pmocache/src/lib.rs @@ -144,6 +144,22 @@ pub use cache::{ CacheSubscription, }; pub use cache_trait::{pk_from_content_header, FileCache}; + +/// Retourne la route relative pour une cover: `/covers/image/{pk}[/{param}]` +pub fn covers_route_for(pk: &str, param: Option<&str>) -> String { + if let Some(p) = param { + format!("/covers/image/{}/{}", pk, p) + } else { + format!("/covers/image/{}", pk) + } +} + +/// Retourne l'URL absolue pour une cover via `PMO_SERVER_URL` +pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String { + let base = std::env::var("PMO_SERVER_URL") + .unwrap_or_else(|_| "http://localhost:8080".to_string()); + format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param)) +} pub use db::{CacheEntry, DB}; pub use download::{ download, download_with_transformer, ingest_with_transformer, peek_header, peek_reader_header, diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml index 79c970cf..e5bad271 100644 --- a/pmoparadise/Cargo.toml +++ b/pmoparadise/Cargo.toml @@ -63,6 +63,7 @@ pmoplaylist = { path = "../pmoplaylist" } pmoconfig = { path = "../pmoconfig", optional = true } # Cache support (OBLIGATOIRE - architecture refactorisée) +pmocache = { path = "../pmocache" } pmocovers = { path = "../pmocovers" } pmoaudiocache = { path = "../pmoaudiocache" } diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index eef35d71..dbd4f3b3 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -213,7 +213,7 @@ impl RadioParadiseSource { let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string()); let cover_url = cover_pk .as_ref() - .map(|pk| format!("{}/covers/jpeg/{}", self.base_url, pk)) + .map(|pk| pmocache::covers_absolute_url_for(pk, None)) .or_else(|| json["cover_url"].as_str().map(|s| s.to_string())) .or_else(|| Some(self.default_cover_url())); diff --git a/pmoplaylist/src/api.rs b/pmoplaylist/src/api.rs index 79953155..c8f6289c 100644 --- a/pmoplaylist/src/api.rs +++ b/pmoplaylist/src/api.rs @@ -517,7 +517,7 @@ fn playlist_track_to_response( } fn cover_url_from_pk(pk: &str) -> String { - format!("/covers/image/{}/256", pk) + pmocache::covers_absolute_url_for(pk, None) } fn normalize_cover_pk(input: Option) -> Option { diff --git a/pmoplaylist/src/handle/read.rs b/pmoplaylist/src/handle/read.rs index 085f2d39..e3d4e672 100644 --- a/pmoplaylist/src/handle/read.rs +++ b/pmoplaylist/src/handle/read.rs @@ -182,7 +182,7 @@ impl ReadHandle { let _remaining = self.remaining().await?; // Convertir cover_pk en URL si présent - let album_art = cover_pk.map(|pk| format!("/cover/{}", pk)); + let album_art = cover_pk.map(|pk| pmocache::covers_absolute_url_for(&pk, None)); Ok(Container { id: self.playlist.id.clone(), @@ -253,7 +253,7 @@ impl ReadHandle { let track_number = meta.get_track_number().await.ok().flatten(); let cover_pk = meta.get_cover_pk().await.ok().flatten(); let cover_url = if let Some(pk) = cover_pk.as_ref() { - Some(format!("/covers/jpeg/{}/256", pk)) + Some(pmocache::covers_absolute_url_for(pk, None)) } else { meta.get_cover_url().await.ok().flatten() }; diff --git a/pmoqobuz/src/api_rest.rs b/pmoqobuz/src/api_rest.rs index 70a05a18..d3f568cd 100644 --- a/pmoqobuz/src/api_rest.rs +++ b/pmoqobuz/src/api_rest.rs @@ -299,7 +299,7 @@ async fn cache_album_image(mut album: Album, cover_cache: &Arc if let Some(ref image_url) = album.image { match cover_cache.add_from_url(image_url, None).await { Ok(pk) => { - album.image_cached = Some(format!("/covers/images/{}", pk)); + album.image_cached = Some(pmocache::covers_absolute_url_for(&pk, None)); } Err(e) => { tracing::warn!("Failed to cache album image: {}", e); diff --git a/pmoradiofrance/src/metadata_cache.rs b/pmoradiofrance/src/metadata_cache.rs index f8151f4f..0da2f191 100644 --- a/pmoradiofrance/src/metadata_cache.rs +++ b/pmoradiofrance/src/metadata_cache.rs @@ -251,15 +251,13 @@ impl CachedMetadata { // Note: add_from_url() lance le téléchargement complet en arrière-plan. // L'URL est valide immédiatement — si le fichier n'est pas encore prêt, // le client web doit réessayer (retry avec backoff). - let route = cache.route_for(&pk, None); - let public_url = format!("{}{}", server_base_url.trim_end_matches('/'), route); + let public_url = pmocache::covers_absolute_url_for(&pk, None); #[cfg(feature = "logging")] tracing::debug!( - "Cached cover - UUID: {}, PK: {}, route: {}, public_url: {}", + "Cached cover - UUID: {}, PK: {}, public_url: {}", uuid, pk, - route, public_url ); diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index a2e6c734..bd9e9852 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -114,6 +114,13 @@ impl Server { pub fn new(name: impl Into, base_url: impl Into, http_port: u16) -> Self { let api_registry = Arc::new(RwLock::new(Vec::new())); + let base_url = base_url.into(); + + // Initialiser PMO_SERVER_URL pour que tous les caches puissent construire des URLs absolues + // sans avoir besoin de propager base_url manuellement. + // SAFETY: appelé une seule fois au démarrage du serveur, avant tout thread concurrent. + unsafe { std::env::set_var("PMO_SERVER_URL", &base_url) }; + // Créer le router initial avec l'endpoint de registre let registry_route = Router::new() .route("/api/registry", get(get_api_registry)) @@ -121,7 +128,7 @@ impl Server { Self { name: name.into(), - base_url: base_url.into(), + base_url, http_port, router: Arc::new(RwLock::new(registry_route)), api_router: Arc::new(RwLock::new(None)), diff --git a/pmoupnp/src/cache_registry.rs b/pmoupnp/src/cache_registry.rs index 913381ca..572fa5f7 100644 --- a/pmoupnp/src/cache_registry.rs +++ b/pmoupnp/src/cache_registry.rs @@ -54,18 +54,10 @@ pub fn get_audio_cache() -> Option> { /// // url = "http://localhost:8080/covers/images/abc123/300" /// ``` pub fn build_cover_url(pk: &str, size: Option) -> anyhow::Result { - // Récupérer l'URL de base depuis la variable d'environnement ou une config - let base_url = - std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); - - let cache = get_cover_cache().ok_or_else(|| anyhow::anyhow!("No registered cover cache"))?; - - let param = match size { - Some(size_) => Some(size_.to_string()), - None => None, - }; - let route = cache.route_for(pk, param.as_deref()); - Ok(format!("{}{}", base_url, route)) + Ok(pmocache::covers_absolute_url_for( + pk, + size.map(|s| s.to_string()).as_deref(), + )) } /// Construit l'URL complète pour une piste audio @@ -84,12 +76,6 @@ pub fn build_cover_url(pk: &str, size: Option) -> anyhow::Result /// // url = "http://localhost:8080/audio/tracks/abc123/stream" /// ``` pub fn build_audio_url(pk: &str, param: Option<&str>) -> anyhow::Result { - // Récupérer l'URL de base depuis la variable d'environnement ou une config - let base_url = - std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); - let cache = get_audio_cache().ok_or_else(|| anyhow::anyhow!("No registered audio cache"))?; - - let route = cache.route_for(pk, param); - Ok(format!("{}{}", base_url, route)) + Ok(cache.absolute_url_for(pk, param)) } From 76b7c126c2fad2842c8ea16b00da29debdeb8c45 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 24 Mar 2026 15:02:14 +0100 Subject: [PATCH 17/18] refactor: replace absolute URLs with route-based cover URLs and generalize caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remplacer les appels à `covers_absolute_url_for` par `covers_route_for` pour générer des URLs relatives basées sur les routes - Introduire le trait `CoverCacheable` pour généraliser le cache des covers (Album, Playlist, Artist, Track) - Remplacer les fonctions spécifiques (`cache_album_covers`, etc.) par une fonction générique `cache_covers` - Simplifier le code en unifiant la logique de mise en cache des covers - Corriger l'initialisation de `PMO_SERVER_URL` pour utiliser `base_url()` (incluant le port) au lieu d'une URL brute --- pmoplaylist/src/api.rs | 2 +- pmoplaylist/src/handle/read.rs | 4 +- pmoqobuz/src/api_rest.rs | 2 +- pmoqobuz/src/source.rs | 142 ++++++++++++++++++++++++--------- pmoserver/src/server.rs | 16 ++-- 5 files changed, 118 insertions(+), 48 deletions(-) diff --git a/pmoplaylist/src/api.rs b/pmoplaylist/src/api.rs index c8f6289c..4ad29953 100644 --- a/pmoplaylist/src/api.rs +++ b/pmoplaylist/src/api.rs @@ -517,7 +517,7 @@ fn playlist_track_to_response( } fn cover_url_from_pk(pk: &str) -> String { - pmocache::covers_absolute_url_for(pk, None) + pmocache::covers_route_for(pk, None) } fn normalize_cover_pk(input: Option) -> Option { diff --git a/pmoplaylist/src/handle/read.rs b/pmoplaylist/src/handle/read.rs index e3d4e672..c07e325c 100644 --- a/pmoplaylist/src/handle/read.rs +++ b/pmoplaylist/src/handle/read.rs @@ -182,7 +182,7 @@ impl ReadHandle { let _remaining = self.remaining().await?; // Convertir cover_pk en URL si présent - let album_art = cover_pk.map(|pk| pmocache::covers_absolute_url_for(&pk, None)); + let album_art = cover_pk.map(|pk| pmocache::covers_route_for(&pk, None)); Ok(Container { id: self.playlist.id.clone(), @@ -253,7 +253,7 @@ impl ReadHandle { let track_number = meta.get_track_number().await.ok().flatten(); let cover_pk = meta.get_cover_pk().await.ok().flatten(); let cover_url = if let Some(pk) = cover_pk.as_ref() { - Some(pmocache::covers_absolute_url_for(pk, None)) + Some(pmocache::covers_route_for(pk, None)) } else { meta.get_cover_url().await.ok().flatten() }; diff --git a/pmoqobuz/src/api_rest.rs b/pmoqobuz/src/api_rest.rs index d3f568cd..e1bcb500 100644 --- a/pmoqobuz/src/api_rest.rs +++ b/pmoqobuz/src/api_rest.rs @@ -299,7 +299,7 @@ async fn cache_album_image(mut album: Album, cover_cache: &Arc if let Some(ref image_url) = album.image { match cover_cache.add_from_url(image_url, None).await { Ok(pk) => { - album.image_cached = Some(pmocache::covers_absolute_url_for(&pk, None)); + album.image_cached = Some(pmocache::covers_route_for(&pk, None)); } Err(e) => { tracing::warn!("Failed to cache album image: {}", e); diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index 6b47fc67..e73ec244 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -19,6 +19,39 @@ use std::time::{Duration, SystemTime}; /// TTL pour les playlists d'albums (7 jours) const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600); +/// Trait pour les types dont on peut cacher la cover image. +trait CoverCacheable { + fn image_url(&self) -> Option<&str>; + fn set_image_cached(&mut self, url: String); +} + +impl CoverCacheable for crate::models::Album { + fn image_url(&self) -> Option<&str> { self.image.as_deref() } + fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); } +} + +impl CoverCacheable for crate::models::Playlist { + fn image_url(&self) -> Option<&str> { self.image.as_deref() } + fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); } +} + +impl CoverCacheable for crate::models::Artist { + fn image_url(&self) -> Option<&str> { self.image.as_deref() } + fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); } +} + +/// Pour Track, la cover est celle de l'album. +impl CoverCacheable for crate::models::Track { + fn image_url(&self) -> Option<&str> { + self.album.as_ref()?.image.as_deref() + } + fn set_image_cached(&mut self, url: String) { + if let Some(ref mut album) = self.album { + album.image_cached = Some(url); + } + } +} + /// Default image for Qobuz (300x300 WebP, embedded in binary) const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); @@ -853,20 +886,50 @@ impl QobuzSource { } } - /// Cache les covers d'une liste d'albums en parallèle. - /// Retourne les albums avec `image_cached` mis à jour si la cover a pu être mise en cache. - async fn cache_album_covers(&self, albums: Vec) -> Vec { - let futs: Vec<_> = albums.into_iter().map(|mut album| { + /// Cache les covers d'une liste d'items en parallèle (générique via `CoverCacheable`). + async fn cache_covers(&self, items: Vec) -> Vec + where + T: CoverCacheable + Send + 'static, + { + let futs: Vec<_> = items.into_iter().map(|mut item| { let source = self.clone(); async move { - if let Some(ref image_url) = album.image.clone() { - if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await { + if let Some(image_url) = item.image_url().map(str::to_string) { + if let Ok(pk) = source.inner.cache_manager.cache_cover(&image_url).await { if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { - album.image_cached = Some(url); + item.set_image_cached(url); } } } - album + item + } + }).collect(); + tokio::task::JoinSet::from_iter(futs).join_all().await + } + + async fn cache_album_covers(&self, albums: Vec) -> Vec { + self.cache_covers(albums).await + } + + async fn cache_playlist_covers(&self, playlists: Vec) -> Vec { + self.cache_covers(playlists).await + } + + /// Cache les covers d'une liste de tracks en parallèle (via l'image de l'album). + async fn cache_track_covers(&self, tracks: Vec) -> Vec { + let futs: Vec<_> = tracks.into_iter().map(|mut track| { + let source = self.clone(); + async move { + if let Some(ref mut album) = track.album { + if let Some(ref image_url) = album.image.clone() { + if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await { + if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { + album.image_cached = Some(url); + } + } + } + } + track } }).collect(); tokio::task::JoinSet::from_iter(futs) @@ -874,19 +937,19 @@ impl QobuzSource { .await } - /// Cache les covers d'une liste de playlists en parallèle. - async fn cache_playlist_covers(&self, playlists: Vec) -> Vec { - let futs: Vec<_> = playlists.into_iter().map(|mut playlist| { + /// Cache les covers d'une liste d'artistes en parallèle. + async fn cache_artist_covers(&self, artists: Vec) -> Vec { + let futs: Vec<_> = artists.into_iter().map(|mut artist| { let source = self.clone(); async move { - if let Some(ref image_url) = playlist.image.clone() { + if let Some(ref image_url) = artist.image.clone() { if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await { if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { - playlist.image_cached = Some(url); + artist.image_cached = Some(url); } } } - playlist + artist } }).collect(); tokio::task::JoinSet::from_iter(futs) @@ -932,6 +995,7 @@ impl QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let tracks = self.cache_covers(tracks).await; let items: Vec = tracks .into_iter() .filter_map(|track| track.to_didl_item("qobuz:favorites:tracks").ok()) @@ -949,23 +1013,21 @@ impl QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let artists = self.cache_covers(artists).await; let containers: Vec = artists .into_iter() - .filter_map(|artist| { - // Créer un container pour chaque artiste - Some(Container { - id: format!("qobuz:artist:{}", artist.id), - parent_id: "qobuz:favorites:artists".to_string(), - restricted: Some("1".to_string()), - child_count: None, - searchable: Some("1".to_string()), - title: artist.name.clone(), - class: "object.container".to_string(), - artist: Some(artist.name.clone()), - album_art: artist.image_cached.clone().or_else(|| artist.image.clone()), - containers: vec![], - items: vec![], - }) + .map(|artist| Container { + id: format!("qobuz:artist:{}", artist.id), + parent_id: "qobuz:favorites:artists".to_string(), + restricted: Some("1".to_string()), + child_count: None, + searchable: Some("1".to_string()), + title: artist.name.clone(), + class: "object.container".to_string(), + artist: Some(artist.name.clone()), + album_art: artist.image_cached.clone(), + containers: vec![], + items: vec![], }) .collect(); @@ -1165,6 +1227,7 @@ impl QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let artists = self.cache_covers(artists).await; let containers: Vec = artists .into_iter() .map(|artist| Container { @@ -1176,7 +1239,7 @@ impl QobuzSource { title: artist.name.clone(), class: "object.container".to_string(), artist: Some(artist.name.clone()), - album_art: artist.image_cached.clone().or_else(|| artist.image.clone()), + album_art: artist.image_cached.clone(), containers: vec![], items: vec![], }) @@ -1194,6 +1257,7 @@ impl QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let playlists = self.cache_covers(playlists).await; let parent_id = format!("qobuz:discover:playlists:{}", tag); let containers: Vec = playlists .into_iter() @@ -1559,7 +1623,6 @@ impl MusicSource for QobuzSource { } ObjectIdType::Playlist(playlist_id) => { - // Get tracks in playlist let tracks = self .inner .client @@ -1567,6 +1630,7 @@ impl MusicSource for QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let tracks = self.cache_covers(tracks).await; let items: Vec = tracks .into_iter() .filter_map(|track| { @@ -1580,7 +1644,6 @@ impl MusicSource for QobuzSource { } ObjectIdType::Artist(artist_id) => { - // Get albums by artist let albums = self .inner .client @@ -1588,6 +1651,7 @@ impl MusicSource for QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let albums = self.cache_covers(albums).await; let containers: Vec = albums .into_iter() .filter_map(|album| { @@ -1738,6 +1802,7 @@ impl MusicSource for QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let all_tracks = self.cache_covers(all_tracks).await; let items: Vec = all_tracks .into_iter() .skip(offset) @@ -1757,15 +1822,16 @@ impl MusicSource for QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - // Convert albums to containers and tracks to items - let containers: Vec = results - .albums + let (albums, tracks) = tokio::join!( + self.cache_covers(results.albums), + self.cache_covers(results.tracks), + ); + let containers: Vec = albums .into_iter() .filter_map(|album| album.to_didl_container("qobuz").ok()) .collect(); - let items: Vec = results - .tracks + let items: Vec = tracks .into_iter() .filter_map(|track| track.to_didl_item("qobuz").ok()) .collect(); @@ -1979,6 +2045,7 @@ impl MusicSource for QobuzSource { .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; + let playlists = self.cache_covers(playlists).await; let containers: Vec = playlists .into_iter() .filter_map(|playlist| playlist.to_didl_container("qobuz").ok()) @@ -2061,6 +2128,7 @@ impl MusicSource for QobuzSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let albums = self.cache_covers(albums).await; let containers: Vec = albums .into_iter() .skip(offset) diff --git a/pmoserver/src/server.rs b/pmoserver/src/server.rs index bd9e9852..b43169ae 100644 --- a/pmoserver/src/server.rs +++ b/pmoserver/src/server.rs @@ -116,17 +116,12 @@ impl Server { let base_url = base_url.into(); - // Initialiser PMO_SERVER_URL pour que tous les caches puissent construire des URLs absolues - // sans avoir besoin de propager base_url manuellement. - // SAFETY: appelé une seule fois au démarrage du serveur, avant tout thread concurrent. - unsafe { std::env::set_var("PMO_SERVER_URL", &base_url) }; - // Créer le router initial avec l'endpoint de registre let registry_route = Router::new() .route("/api/registry", get(get_api_registry)) .with_state(api_registry.clone()); - Self { + let server = Self { name: name.into(), base_url, http_port, @@ -136,7 +131,14 @@ impl Server { log_state: None, api_registry, shutdown_token: CancellationToken::new(), - } + }; + + // Initialiser PMO_SERVER_URL avec l'URL complète (incluant le port). + // base_url() normalise l'URL en ajoutant le port si absent. + // SAFETY: appelé une seule fois au démarrage du serveur, avant tout thread concurrent. + unsafe { std::env::set_var("PMO_SERVER_URL", server.base_url()) }; + + server } pub fn new_configured() -> Self { From b08112699e570f16026a2377c33240b5d263f847 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 24 Mar 2026 15:24:20 +0100 Subject: [PATCH 18/18] feat: implement infinite scroll with pagination for media browsing Add infinite scroll functionality to MediaBrowser component using IntersectionObserver, introduce BrowseState and pagination support (offset/limit) in API and backend, update version to 0.3.24 --- PMOMusic/Cargo.toml | 2 +- .../components/pmocontrol/MediaBrowser.vue | 56 +++++++++++++--- .../src/components/unified/ServerDrawer.vue | 4 +- .../webapp/src/composables/useMediaServers.ts | 66 +++++++++++++++---- pmoapp/webapp/src/services/pmocontrol/api.ts | 4 +- .../webapp/src/services/pmocontrol/types.ts | 2 + pmocontrol/src/media_server.rs | 64 +++++++++++++++++- pmocontrol/src/openapi.rs | 6 +- pmocontrol/src/pmoserver_ext.rs | 33 +++++++--- version.txt | 2 +- 10 files changed, 204 insertions(+), 35 deletions(-) diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index 07676aaf..b179d32f 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "PMOMusic" -version = "0.3.23" +version = "0.3.24" edition = "2024" [dependencies] diff --git a/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue index a99badbc..c665b17a 100644 --- a/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue +++ b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue @@ -1,5 +1,5 @@