Files
pmomusic/Blackboard/ToThinkAbout/plant_webrenderer_server_side_streaming.md
Eric Coissac 4b3e8a961c Implémentation du streaming audio côté serveur pour WebRenderer
Cette mise à jour implémente le streaming audio côté serveur pour WebRenderer, en remplaçant l'ancienne architecture basée sur WebSocket par une nouvelle approche utilisant des endpoints HTTP. Les modifications incluent l'ajout de dépendances audio, la création de nouveaux modules pour le pipeline audio, la gestion des registres et des flux, ainsi que la modification des handlers UPnP pour utiliser le nouveau pipeline. Le frontend a également été mis à jour pour utiliser un seul élément <audio> pointant vers le nouveau endpoint de streaming.
2026-02-23 20:00:28 +01:00

49 KiB
Raw Blame History

Plan d'implémentation détaillé : WebRenderer streaming audio côté serveur

Synthèse de l'exploration du code

Architecture actuelle

L'architecture actuelle repose sur :

  1. pmowebrenderer/src/websocket.rs - Handler WebSocket : crée ou reconnecte un device UPnP par navigateur, envoie des commandes JSON au navigateur
  2. pmowebrenderer/src/handlers.rs - Action handlers UPnP qui forwardent vers le WebSocket via SharedSender
  3. pmowebrenderer/src/session.rs - SessionManager : HashMap token → session, persistance UDN/sender entre reconnexions
  4. pmowebrenderer/src/state.rs - RendererState + SharedSender (remplaçable à chaque reconnexion WS)
  5. pmowebrenderer/src/renderer.rs - WebRendererFactory : construit les services UPnP (AVTransport, RenderingControl, ConnectionManager)
  6. pmowebrenderer/src/config.rs - Trait WebRendererExt : enregistre la route WS dans pmoserver
  7. Frontend : useWebRenderer.ts - composable Vue.js gérant WebSocket + GaplessEngine (deux HTMLAudioElement en ping-pong)

Infrastructure réutilisable confirmée

  • StreamingFlacSink + StreamHandle dans pmoaudio-ext/src/sinks/streaming_flac_sink.rs : broadcast multi-clients, gapless, backpressure, header FLAC caché pour late-joiners
  • Pattern HTTP streaming dans pmomediaserver/src/paradise_streaming.rs : Body::from_stream(ReaderStream::new(stream)) avec headers corrects
  • PlaylistSource dans pmoaudio-ext/src/sources/playlist_source.rs : modèle pour source_loader.rs, incluant décodage FLAC via pmoflac::decode_audio_stream, gestion cache progressif
  • pmoserver::Server : API add_handler_with_state, add_post_handler_with_state, add_any_handler_with_state, add_router

Dépendances existantes de pmowebrenderer

Le crate actuel ne dépend pas de pmoaudio-ext, pmoaudio, pmoflac ou pmoaudiocache. Il faudra les ajouter.


Ordre d'implémentation

Les étapes sont organisées de façon à avoir un système compilable et testable à chaque jalon, en allant des fondations vers l'intégration.


Etape 1 : Préparer le Cargo.toml de pmowebrenderer

Fichier concerné : pmowebrenderer/Cargo.toml

Modifications :

[dependencies]
# Existants conservés
pmoupnp = { path = "../pmoupnp" }
pmomediarenderer = { path = "../pmomediarenderer" }
pmoserver = { path = "../pmoserver", optional = true }
pmocontrol = { path = "../pmocontrol", optional = true }
pmoconfig = { path = "../pmoconfig" }

# Nouveaux : pipeline audio
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["http-stream", "playlist"], optional = true }
pmoaudio = { path = "../pmoaudio", optional = true }
pmoflac = { path = "../pmoflac", optional = true }
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
pmometadata = { path = "../pmometadata", optional = true }

# Async runtime
tokio = { workspace = true, features = ["full"] }
async-trait = { workspace = true }

# HTTP streaming (on retire "ws" de axum si WebSocket supprimé en phase finale)
axum = { workspace = true, features = ["ws"] }  # garder "ws" pendant la migration
axum-extra = { version = "0.9", features = ["typed-header"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
futures = "0.3"
bytes = "1.0"
tokio-util = { version = "0.7", features = ["io"] }

# Serialization
serde = { workspace = true }
serde_json = { workspace = true }

# Utilities
uuid = { workspace = true, features = ["v4", "serde"] }
parking_lot = "0.12"
thiserror = { workspace = true }
tracing = { workspace = true }

pmodidl = { path = "../pmodidl" }
pmoutils = { path = "../pmoutils" }

[features]
default = []
pmoserver = ["dep:pmoserver", "dep:pmocontrol"]
audio-pipeline = ["dep:pmoaudio-ext", "dep:pmoaudio", "dep:pmoflac", "dep:pmoaudiocache", "dep:pmometadata"]

Note : Ajouter pmoaudio-ext dans le workspace Cargo.toml (il n'y est pas encore).

Piège : pmoaudio-ext n'est pas dans [workspace.members] du Cargo.toml racine. Il faut l'y ajouter. Vérifier aussi que pmoaudio-ext avec feature playlist n'introduit pas de dépendance circulaire via pmoplaylist.


Etape 2 : Créer pipeline.rs — canal de contrôle et état par instance

Fichier : pmowebrenderer/src/pipeline.rs (nouveau)

Ce module définit les types partagés entre tous les handlers sans logique de pipeline encore.

use tokio::sync::mpsc;

/// Commandes envoyées au pipeline audio de l'instance
#[derive(Debug)]
pub enum PipelineControl {
    LoadUri(String),
    LoadNextUri(String),
    Play,
    Pause,
    Stop,
    Seek(f64),        // secondes
    SetVolume(u16),   // 0-100
    SetMute(bool),
}

/// Handle vers le pipeline audio d'une instance WebRenderer serveur
#[derive(Clone)]
pub struct PipelineHandle {
    /// Canal de contrôle vers la task pipeline
    pub control_tx: mpsc::Sender<PipelineControl>,
    /// Token d'annulation pour stopper le pipeline
    pub stop_token: tokio_util::sync::CancellationToken,
}

impl PipelineHandle {
    pub async fn send(&self, cmd: PipelineControl) {
        let _ = self.control_tx.send(cmd).await;
    }
}

Points d'attention :

  • PipelineControl doit être Send (pas de Arc<RwLock<...>> dans les variantes).
  • Le CancellationToken de tokio_util est déjà utilisé dans pmoaudio-ext, importer depuis là.

Etape 3 : Modifier state.rs — ajouter stream handle et pipeline handle

Fichier : pmowebrenderer/src/state.rs (modifier)

Remplacer SharedSender (qui envoie vers le WS) par un SharedStreamHandle et un PipelineHandle.

use parking_lot::RwLock;
use std::sync::Arc;

#[cfg(feature = "audio-pipeline")]
use pmoaudio_ext::sinks::streaming_flac_sink::StreamHandle;

use crate::messages::PlaybackState;
#[cfg(feature = "audio-pipeline")]
use crate::pipeline::PipelineHandle;

/// État temps-réel du renderer (partagé backend ↔ pipeline)
#[derive(Debug, Clone)]
pub struct RendererState {
    pub playback_state: PlaybackState,
    pub current_uri: Option<String>,
    pub current_metadata: Option<String>,
    pub next_uri: Option<String>,
    pub next_metadata: Option<String>,
    pub position: Option<String>,   // mis à jour par la task de suivi position
    pub duration: Option<String>,
    pub volume: u16,
    pub mute: bool,
}

impl Default for RendererState { /* identique à l'existant */ }

pub type SharedState = Arc<RwLock<RendererState>>;

/// Handle vers le flux FLAC HTTP d'une instance (remplace SharedSender)
#[cfg(feature = "audio-pipeline")]
#[derive(Clone)]
pub struct SharedStreamHandle(Arc<RwLock<Option<StreamHandle>>>);

#[cfg(feature = "audio-pipeline")]
impl SharedStreamHandle {
    pub fn new(handle: StreamHandle) -> Self {
        Self(Arc::new(RwLock::new(Some(handle))))
    }

    pub fn get(&self) -> Option<StreamHandle> {
        self.0.read().clone()
    }

    pub fn clear(&self) {
        *self.0.write() = None;
    }
}

// Pour la compatibilité avec les handlers qui utilisent encore SharedSender
// pendant la phase de migration, on conserve SharedSender dans le module
// mais on la rend conditionnelle au feature "pmoserver" seul (sans audio-pipeline).

Points d'attention :

  • Conserver SharedSender derrière une feature flag pendant la migration. Les handlers UPnP existants (handlers.rs) l'utilisent encore.
  • La StreamHandle de pmoaudio-ext est Clone — on peut la partager sans Arc<RwLock<...>> en réalité, mais un wrapper permet de la remplacer à la reconnexion.

Etape 4 : Créer source_loader.rs — ouverture d'URI arbitraires

Fichier : pmowebrenderer/src/source_loader.rs (nouveau)

Ce module sait ouvrir une URI quelconque (URL HTTP, chemin fichier local, chemin Samba) et la transformer en AsyncRead de bytes PCM via pmoflac::decode_audio_stream.

use pmoflac::decode_audio_stream;
use std::path::Path;
use tokio::io::AsyncRead;

pub enum SourceKind {
    LocalFile(std::path::PathBuf),
    HttpUrl(String),
}

pub fn classify_uri(uri: &str) -> SourceKind {
    if uri.starts_with("http://") || uri.starts_with("https://") {
        SourceKind::HttpUrl(uri.to_string())
    } else {
        // Chemin fichier (absolu ou Samba monté)
        SourceKind::LocalFile(std::path::PathBuf::from(uri))
    }
}

/// Ouvre une source audio quelconque et retourne le stream PCM décodé.
/// Retourne (stream, sample_rate, bits_per_sample, channels)
pub async fn open_uri(
    uri: &str,
) -> Result<(impl AsyncRead + Send + Unpin, pmoflac::StreamInfo), SourceError> {
    match classify_uri(uri) {
        SourceKind::LocalFile(path) => {
            let file = tokio::fs::File::open(&path).await
                .map_err(|e| SourceError::Io(e.to_string()))?;
            let stream = decode_audio_stream(file).await
                .map_err(|e| SourceError::Decode(e.to_string()))?;
            let info = stream.info().clone();
            Ok((stream, info))
        }
        SourceKind::HttpUrl(url) => {
            // Utiliser reqwest pour streamer l'URL HTTP externe
            // Même approche que pmoaudiocache qui télécharge depuis URLs Qobuz
            let response = reqwest::get(&url).await
                .map_err(|e| SourceError::Http(e.to_string()))?;
            let byte_stream = response.bytes_stream();
            // Convertir en AsyncRead
            use tokio_util::io::StreamReader;
            use futures::TryStreamExt;
            let reader = StreamReader::new(
                byte_stream.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
            );
            let stream = decode_audio_stream(reader).await
                .map_err(|e| SourceError::Decode(e.to_string()))?;
            let info = stream.info().clone();
            Ok((stream, info))
        }
    }
}

#[derive(thiserror::Error, Debug)]
pub enum SourceError {
    #[error("IO error: {0}")]
    Io(String),
    #[error("Decode error: {0}")]
    Decode(String),
    #[error("HTTP error: {0}")]
    Http(String),
}

Points d'attention :

  • decode_audio_stream prend un AsyncRead + Send + Unpin. tokio::fs::File et StreamReader satisfont ces contraintes.
  • Pour les URLs Qobuz (URLs signées avec expiration), la source sera ouverte immédiatement par le handler SetAVTransportURI — pas de retry automatique. Si l'URL expire pendant la lecture, le pipeline s'arrêtera proprement via StopReason.
  • Les chemins Samba supposent que le partage est monté localement sur le serveur. Aucun traitement spécial nécessaire, tokio::fs::File::open suffit.
  • Ajouter reqwest en dépendance de pmowebrenderer avec feature stream.

Etape 5 : Créer pipeline.rs — logique complète du pipeline audio

Fichier : pmowebrenderer/src/pipeline.rs (compléter l'étape 2)

La task pipeline tourne en background pour chaque instance. Elle reçoit des PipelineControl, ouvre les sources via source_loader, alimente la StreamingFlacSink.

use pmoaudio::{
    AudioChunk, AudioChunkData, AudioError, AudioSegment, _AudioSegment,
    pipeline::{AudioPipelineNode, PipelineHandle as AudioPipelineHandle},
};
use pmoaudio_ext::sinks::streaming_flac_sink::{StreamHandle, StreamingFlacSink};
use pmoflac::EncoderOptions;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

pub struct InstancePipeline {
    pub stream_handle: StreamHandle,
    pub control_tx: mpsc::Sender<PipelineControl>,
    pub stop_token: CancellationToken,
}

impl InstancePipeline {
    /// Crée et démarre un pipeline pour une instance WebRenderer.
    /// Retourne immédiatement ; la task tourne en background.
    pub fn start() -> Self {
        let stop_token = CancellationToken::new();
        let (control_tx, mut control_rx) = mpsc::channel::<PipelineControl>(16);
        let (sink, stream_handle) = StreamingFlacSink::new(
            EncoderOptions::default(),
            24, // bits_per_sample — 24 bits pour haute qualité
        );

        // La task de pipeline gère le cycle de vie
        let stop_token_clone = stop_token.clone();
        let sink_arc: Arc<Mutex<Option<Box<dyn AudioPipelineNode>>>> =
            Arc::new(Mutex::new(None));

        tokio::spawn(pipeline_task(
            control_rx,
            stop_token_clone,
            sink,
        ));

        Self {
            stream_handle,
            control_tx,
            stop_token,
        }
    }
}

async fn pipeline_task(
    mut control_rx: mpsc::Receiver<PipelineControl>,
    stop_token: CancellationToken,
    sink: StreamingFlacSink,
) {
    // État interne de la task
    let mut current_pipeline_handle: Option<AudioPipelineHandle> = None;
    let mut pending_next_uri: Option<String> = None;

    // La StreamingFlacSink est démarrée une fois, tourne en continu.
    // Le canal PCM (sink.get_tx()) reçoit les AudioSegments.
    // Pour l'alimenter, on lance une task source séparée par piste.

    let sink_tx = sink.get_tx().expect("StreamingFlacSink must have a tx");
    let sink_stop = stop_token.clone();
    tokio::spawn(async move {
        Box::new(sink).run(sink_stop).await.ok();
    });

    loop {
        tokio::select! {
            _ = stop_token.cancelled() => {
                // Stopper la source courante
                if let Some(h) = current_pipeline_handle.take() {
                    h.stop();
                }
                break;
            }

            cmd = control_rx.recv() => {
                match cmd {
                    None => break,
                    Some(PipelineControl::LoadUri(uri)) => {
                        // Stopper la source courante si elle existe
                        if let Some(h) = current_pipeline_handle.take() {
                            h.stop();
                        }
                        // Lancer une nouvelle source
                        current_pipeline_handle = Some(
                            spawn_source_task(uri, sink_tx.clone(), stop_token.clone()).await
                        );
                    }
                    Some(PipelineControl::LoadNextUri(uri)) => {
                        pending_next_uri = Some(uri);
                    }
                    Some(PipelineControl::Play) => {
                        // Pipeline serveur : Play ne fait rien de spécial
                        // La source alimente automatiquement dès LoadUri
                    }
                    Some(PipelineControl::Stop) => {
                        if let Some(h) = current_pipeline_handle.take() {
                            h.stop();
                        }
                        // Envoyer EndOfStream au sink
                        let _ = sink_tx.send(Arc::new(AudioSegment::new_end_of_stream(0, 0.0))).await;
                    }
                    Some(PipelineControl::Seek(pos_sec)) => {
                        // Pour seek : stopper la source, relancer depuis la position
                        if let Some(current_uri) = get_current_uri() {
                            if let Some(h) = current_pipeline_handle.take() {
                                h.stop();
                            }
                            current_pipeline_handle = Some(
                                spawn_source_task_from(current_uri, pos_sec, sink_tx.clone(), stop_token.clone()).await
                            );
                        }
                    }
                    Some(PipelineControl::SetVolume(vol)) => {
                        // TODO : Volume DSP côté serveur (hors scope initial)
                    }
                    Some(PipelineControl::SetMute(mute)) => {
                        // TODO
                    }
                    _ => {}
                }
            }
        }
    }
}

Points d'attention critiques :

  1. Gapless : Le gapless avec StreamingFlacSink en mode restart_encoder_on_track_boundary: false signifie que le flux FLAC est continu. La frontière de piste est gérée par SyncMarker::TrackBoundary. Pour enchaîner deux sources indépendantes, il faut envoyer un SyncMarker::TrackBoundary entre les deux flux PCM vers le sink. La StreamingFlacSink ne restarte pas l'encodeur — le navigateur ne voit pas d'interruption.

  2. Architecture source : La source doit envoyer des Arc<AudioSegment> directement au sink_tx (le canal d'entrée de StreamingFlacSink). Ce n'est pas la même architecture que Node<PlaylistSourceLogic> qui utilise les children. Ici, on alimente directement le sink via son Sender<Arc<AudioSegment>>. C'est exact car sink.get_tx() retourne le Sender d'entrée du Node<StreamingFlacSinkLogic>.

  3. Position tracking : Sans retour du navigateur (le navigateur ne connaît que le flux FLAC), la position doit être trackée côté serveur. La StreamingFlacSink maintient un current_timestamp accessible via StreamHandle (indirectement via les métadonnées). Une task périodique lit ce timestamp et met à jour RendererState.position.

  4. Seek : Un seek implique de relancer la source depuis une position donnée. Pour les fichiers FLAC locaux, pmoflac::decode_audio_stream ne supporte pas le seek natif sur un stream. Il faudra rouvrir le fichier et lire en avançant les frames. Approche pragmatique : seek = stop + reopen + skip samples (coûteux mais simple). Le navigateur rebuffère ~1s comme indiqué dans l'archi.


Etape 6 : Créer register.rs — handlers POST /register et DELETE /{id}

Fichier : pmowebrenderer/src/register.rs (nouveau)

Ce module remplace websocket.rs. La logique de création du device UPnP est ici, adaptée du code de create_renderer_for_browser dans websocket.rs.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use crate::registry::RendererRegistry;
use crate::pipeline::InstancePipeline;

#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
    pub instance_id: String,
    pub user_agent: String,
}

#[derive(Debug, Serialize)]
pub struct RegisterResponse {
    pub stream_url: String,
}

/// POST /api/webrenderer/register
pub async fn register_handler(
    State(registry): State<Arc<RendererRegistry>>,
    Json(req): Json<RegisterRequest>,
) -> impl IntoResponse {
    match registry.register_or_reconnect(&req.instance_id, &req.user_agent).await {
        Ok(stream_url) => (StatusCode::OK, Json(RegisterResponse { stream_url })).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
    }
}

/// DELETE /api/webrenderer/{id}
pub async fn unregister_handler(
    State(registry): State<Arc<RendererRegistry>>,
    Path(instance_id): Path<String>,
) -> impl IntoResponse {
    registry.unregister(&instance_id).await;
    StatusCode::NO_CONTENT
}

Points d'attention :

  • La logique de create_renderer_for_browser dans websocket.rs gère 4 cas (reconnexion session active, device dans registry mais session expirée, première connexion, etc.). Cette logique doit être reprise quasi à l'identique dans RendererRegistry::register_or_reconnect.
  • La stream_url retournée est /api/webrenderer/{instance_id}/stream — URL relative, correcte en local et via proxy.
  • Plus de SharedSender dans WebRendererSession. À la place : stream_handle: SharedStreamHandle + pipeline: PipelineHandle.

Etape 7 : Créer stream.rs — handler HTTP GET /stream

Fichier : pmowebrenderer/src/stream.rs (nouveau)

Ce module suit exactement le pattern de pmomediaserver/src/paradise_streaming.rs.

use axum::{
    body::Body,
    extract::{Path, State},
    http::{
        StatusCode,
        header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
    },
    response::{IntoResponse, Response},
};
use std::sync::Arc;
use tokio_util::io::ReaderStream;

use crate::registry::RendererRegistry;

/// GET /api/webrenderer/{id}/stream
///
/// Retourne le flux FLAC continu de l'instance.
/// La déconnexion du client est détectée automatiquement par la coupure du flux.
pub async fn stream_handler(
    State(registry): State<Arc<RendererRegistry>>,
    Path(instance_id): Path<String>,
) -> impl IntoResponse {
    let handle = match registry.get_stream_handle(&instance_id).await {
        Some(h) => h,
        None => return StatusCode::NOT_FOUND.into_response(),
    };

    // Abonner ce client au flux FLAC
    let flac_stream = handle.subscribe_flac();

    Response::builder()
        .status(StatusCode::OK)
        .header(CONTENT_TYPE, "audio/flac")
        .header(CACHE_CONTROL, "no-store, no-transform")
        .header(CONNECTION, "keep-alive")
        .header(ACCEPT_RANGES, "none")
        .body(Body::from_stream(ReaderStream::new(flac_stream)))
        .unwrap()
        .into_response()
}

Points d'attention :

  • ReaderStream de tokio-util convertit un AsyncRead en Stream<Item=Result<Bytes>>. FlacClientStream implémente AsyncRead. Le pattern est identique à paradise_streaming.rs.
  • La détection de déconnexion se fait via FlacClientStream::Drop qui décrémente le compteur de clients dans StreamHandle::client_disconnected(). La StreamingFlacSink peut être configurée avec set_auto_stop(true) pour stopper le pipeline si plus aucun client n'écoute.
  • Un client qui se reconnecte (reload de page) reçoit d'abord le header FLAC caché dans SharedStreamHandleInner::header, puis la suite du flux courant via register_client(). Ce mécanisme est déjà dans SharedStreamHandleInner — vérifier que subscribe_flac() envoie bien le header en premier (c'est le cas dans l'implémentation actuelle via header_cache).

Etape 8 : Créer registry.rs — remplace session.rs

Fichier : pmowebrenderer/src/registry.rs (nouveau)

Le RendererRegistry remplace SessionManager. La session est maintenant liée au flux FLAC, pas au WebSocket.

use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};

use pmoupnp::devices::DeviceInstance;

#[cfg(feature = "audio-pipeline")]
use crate::pipeline::{InstancePipeline, PipelineHandle};
#[cfg(feature = "audio-pipeline")]
use crate::state::SharedStreamHandle;
use crate::state::SharedState;

/// Instance WebRenderer côté serveur
pub struct WebRendererInstance {
    pub instance_id: String,       // UUID stable du navigateur (localStorage)
    pub udn: String,               // "uuid:{instance_id}"
    pub device_instance: Arc<DeviceInstance>,
    pub state: SharedState,
    #[cfg(feature = "audio-pipeline")]
    pub stream_handle: SharedStreamHandle,
    #[cfg(feature = "audio-pipeline")]
    pub pipeline: PipelineHandle,
    pub created_at: SystemTime,
    pub last_stream_connect: Arc<RwLock<Option<SystemTime>>>,
}

/// Registre global des instances WebRenderer actives
pub struct RendererRegistry {
    instances: Arc<RwLock<HashMap<String, Arc<WebRendererInstance>>>>,
    /// Map UDN → instance, pour retrouver par UDN depuis les handlers UPnP
    by_udn: Arc<RwLock<HashMap<String, Arc<WebRendererInstance>>>>,
    #[cfg(feature = "pmoserver")]
    control_point: Arc<pmocontrol::ControlPoint>,
}

impl RendererRegistry {
    /// Enregistre ou reconnecte une instance.
    /// Retourne l'URL de stream relative.
    pub async fn register_or_reconnect(
        &self,
        instance_id: &str,
        user_agent: &str,
    ) -> Result<String, crate::error::WebRendererError> {
        // Vérifier si une instance existe déjà pour cet instance_id
        {
            let instances = self.instances.read();
            if let Some(existing) = instances.get(instance_id) {
                // Reconnexion : l'instance existe, le pipeline tourne toujours
                // Réannoncer auprès du ControlPoint
                #[cfg(feature = "pmoserver")]
                self.register_with_control_point(&existing.device_instance)?;

                tracing::info!(instance_id, "WebRenderer: reconnected");
                return Ok(format!("/api/webrenderer/{}/stream", instance_id));
            }
        }

        // Première connexion : créer device UPnP + pipeline
        // (Reprend la logique de create_renderer_for_browser dans websocket.rs)
        let instance = self.create_instance(instance_id, user_agent).await?;
        let stream_url = format!("/api/webrenderer/{}/stream", instance_id);

        let instance = Arc::new(instance);
        {
            let mut instances = self.instances.write();
            instances.insert(instance_id.to_string(), instance.clone());
        }
        {
            let mut by_udn = self.by_udn.write();
            by_udn.insert(instance.udn.clone(), instance.clone());
        }

        tracing::info!(instance_id, "WebRenderer: registered new instance");
        Ok(stream_url)
    }

    /// Retourne le StreamHandle pour l'endpoint /stream
    pub async fn get_stream_handle(&self, instance_id: &str) -> Option<StreamHandle> {
        self.instances.read()
            .get(instance_id)
            .and_then(|i| i.stream_handle.get())
    }

    /// Retourne le PipelineHandle pour les handlers UPnP
    pub fn get_pipeline_by_udn(&self, udn: &str) -> Option<PipelineHandle> {
        self.by_udn.read()
            .get(udn)
            .map(|i| i.pipeline.clone())
    }

    pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> {
        self.by_udn.read()
            .get(udn)
            .map(|i| i.state.clone())
    }

    pub async fn unregister(&self, instance_id: &str) {
        if let Some(instance) = self.instances.write().remove(instance_id) {
            self.by_udn.write().remove(&instance.udn);
            // Stopper le pipeline
            instance.pipeline.stop_token.cancel();
            // Annoncer SSDP byebye
            #[cfg(feature = "pmoserver")]
            if let Ok(mut registry) = self.control_point.registry().write() {
                registry.device_says_byebye(&instance.udn);
            }
            tracing::info!(instance_id, "WebRenderer: unregistered");
        }
    }
}

Points d'attention :

  • La méthode create_instance reprend quasi mot pour mot la logique de create_renderer_for_browser dans websocket.rs (vérifier registry DEVICE_REGISTRY, créer device model, server.register_device, register_with_control_point), mais au lieu de créer SharedSender, elle crée InstancePipeline::start().
  • Le cleanup "session expirée" de l'ancien SessionManager est remplacé par la détection de déconnexion du flux FLAC : quand FlacClientStream est droppé, client_disconnected() est appelé. Si auto_stop est activé, le pipeline s'arrête.
  • Cleanup proactif via une task de polling : si le dernier client FLAC s'est déconnecté depuis plus de 5 minutes et que le ControlPoint indique que le renderer n'est plus en usage, on peut unregister.

Etape 9 : Modifier handlers.rs — brancher sur le pipeline au lieu du WS

Fichier : pmowebrenderer/src/handlers.rs (modifier)

Les handlers UPnP ne font plus ws.send(...) mais pipeline.send(PipelineControl::...).

// Nouveau play_handler
pub fn play_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
    Arc::new(move |data: ActionData| -> ActionFuture {
        let pipeline = pipeline.clone();
        let state = state.clone();
        Box::pin(async move {
            pipeline.send(PipelineControl::Play).await;
            state.write().playback_state = PlaybackState::Playing;
            Ok(data)
        })
    })
}

// Nouveau set_uri_handler — la différence clé
pub fn set_uri_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 uri: String = get!(&data, "CurrentURI", String);
            let metadata: String = /* ... identique à l'existant ... */;

            // Envoyer au pipeline serveur (plus de WebSocket)
            pipeline.send(PipelineControl::LoadUri(uri.clone())).await;

            {
                let mut s = state.write();
                s.current_uri = Some(uri);
                s.current_metadata = Some(metadata);
                s.playback_state = PlaybackState::Transitioning;
            }
            Ok(data)
        })
    })
}

// set_next_uri_handler
pub fn set_next_uri_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 uri: String = get!(&data, "NextURI", String);
            let metadata: String = /* ... */;
            pipeline.send(PipelineControl::LoadNextUri(uri.clone())).await;
            {
                let mut s = state.write();
                s.next_uri = Some(uri);
                s.next_metadata = Some(metadata);
            }
            Ok(data)
        })
    })
}

// seek_handler
pub fn seek_handler(pipeline: PipelineHandle) -> ActionHandler {
    Arc::new(move |data: ActionData| -> ActionFuture {
        let pipeline = pipeline.clone();
        Box::pin(async move {
            let target: String = get!(&data, "Target", String);
            // Convertir "H:MM:SS" en secondes
            let pos_sec = upnp_time_to_seconds(&target);
            pipeline.send(PipelineControl::Seek(pos_sec)).await;
            Ok(data)
        })
    })
}

// Volume et Mute : côté serveur ou navigateur ?
// Phase initiale : volume = 100 fixe côté serveur, navigateur gère avec l'élément <audio>
// Donc set_volume_handler et set_mute_handler mettent à jour l'état mais n'envoient
// pas encore au pipeline (pas de DSP volume implémenté).
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)
        })
    })
}

Points d'attention :

  • Supprimer toutes les références à SharedSender et ServerMessage dans handlers.rs.
  • La conversion "H:MM:SS" → secondes était côté navigateur (dans GaplessEngine::seek). Il faut la déplacer côté serveur dans handlers.rs.
  • get_position_info_handler et get_transport_info_handler restent inchangés (lisent SharedState).

Etape 10 : Modifier renderer.rsWebRendererFactory avec pipeline

Fichier : pmowebrenderer/src/renderer.rs (modifier)

Remplacer la signature de create_device_with_name : au lieu de prendre un ws_sender, prendre un PipelineHandle.

impl WebRendererFactory {
    pub fn create_device_with_pipeline(
        device_name: &str,
        browser_ua: &str,
        pipeline: PipelineHandle,
        state: SharedState,
    ) -> Result<Device, FactoryError> {
        let avtransport = Self::build_avtransport(pipeline.clone(), state.clone())?;
        let renderingcontrol = Self::build_renderingcontrol(pipeline.clone(), state.clone())?;
        let connectionmanager = Self::build_connectionmanager()?;

        let short_name = extract_browser_name(browser_ua);
        let device = Device::new(
            device_name.to_string(),
            "MediaRenderer".to_string(),
            format!("Web Audio  {}", short_name),
        );
        // ...
        Ok(device)
    }
}

Points d'attention :

  • build_avtransport et build_renderingcontrol prennent maintenant PipelineHandle au lieu de SharedSender.
  • Le ConnectionManager déclarait des formats supportés dans get_protocol_info_handler. Mettre à jour pour n'annoncer que audio/flac puisque le navigateur ne gère plus que du FLAC serveur-side.

Etape 11 : Modifier config.rs — nouvelles routes, supprimer WS

Fichier : pmowebrenderer/src/config.rs (modifier)

Remplacer l'enregistrement de la route WebSocket par les trois nouvelles routes.

#[async_trait]
impl WebRendererExt for pmoserver::Server {
    async fn register_web_renderer(
        &mut self,
        control_point: Arc<ControlPoint>,
    ) -> Result<(), WebRendererError> {
        let registry = Arc::new(RendererRegistry::new(control_point));

        // POST /api/webrenderer/register
        self.add_post_handler_with_state(
            "/api/webrenderer/register",
            register_handler,
            registry.clone(),
        ).await;

        // GET /api/webrenderer/{id}/stream  (sous-router pour param dynamique)
        let stream_router = Router::new()
            .route("/{id}/stream", get(stream_handler))
            .with_state(registry.clone());
        self.add_router("/api/webrenderer", stream_router).await;

        // DELETE /api/webrenderer/{id}
        let delete_router = Router::new()
            .route("/{id}", axum::routing::delete(unregister_handler))
            .with_state(registry.clone());
        self.add_router("/api/webrenderer", delete_router).await;

        tracing::info!("WebRenderer server-side streaming endpoints registered");
        Ok(())
    }
}

Points d'attention :

  • add_post_handler_with_state existe dans pmoserver::Server (vu dans server.rs).
  • Pour les routes avec paramètres dynamiques (/{id}/stream), utiliser add_router avec un sous-router Axum, car add_handler_with_state utilise Router::route("/", ...) qui ne supporte pas les paramètres.
  • Supprimer l'import de websocket_handler et WebSocketState.

Etape 12 : Modifier lib.rs — mettre à jour les exports

Fichier : pmowebrenderer/src/lib.rs (modifier)

mod error;
mod handlers;
mod pipeline;
mod registry;
mod renderer;
mod source_loader;
mod state;
mod stream;
mod register;

#[cfg(feature = "pmoserver")]
mod config;

// Supprimer :
// mod messages;  (ou garder pour PlaybackState seulement)
// mod session;
// mod websocket;

pub use error::WebRendererError;
pub use pipeline::PipelineControl;
pub use renderer::WebRendererFactory;
pub use registry::{RendererRegistry, WebRendererInstance};
pub use state::{RendererState, SharedState};

// Garder PlaybackState depuis messages.rs si d'autres crates en dépendent
// Sinon le déplacer dans state.rs
pub use messages::PlaybackState;

#[cfg(feature = "pmoserver")]
pub use config::WebRendererExt;

Points d'attention :

  • Vérifier si PlaybackState est importé par d'autres crates depuis pmowebrenderer. Si oui, le conserver dans messages.rs ou le déplacer dans state.rs en mettant à jour les imports.
  • BrowserCapabilities, RendererInfo ne sont plus nécessaires côté serveur mais peuvent être gardées si le frontend les utilise encore via une API dédiée.

Etape 13 : Modifier le frontend — useWebRenderer.ts

Fichier : pmoapp/webapp/src/composables/useWebRenderer.ts (modifier)

Remplacer la connexion WebSocket et le GaplessEngine ping-pong par :

  1. Un POST /api/webrenderer/register au montage
  2. Un élément <audio> unique dont la src est la stream_url
  3. Plus de gestion de commandes (tout est côté serveur)
export function useWebRenderer() {
    const connected = ref(false);
    const rendererInfo = ref<RendererInfo | null>(null);
    const streamUrl = ref<string | null>(null);

    const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";

    function getOrCreateInstanceId(): string {
        // ... identique à l'existant ...
    }

    async function register() {
        const instanceId = getOrCreateInstanceId();

        try {
            const response = await fetch("/api/webrenderer/register", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({
                    instance_id: instanceId,
                    user_agent: navigator.userAgent,
                }),
            });

            if (!response.ok) throw new Error(`HTTP ${response.status}`);

            const data = await response.json() as { stream_url: string };
            streamUrl.value = data.stream_url;
            connected.value = true;

            // Ici on attend que le SSE global notifie l'apparition du renderer
            // (le ControlPoint émettra un event SSE "renderer_added")
            rendererInfo.value = {
                udn: `uuid:${instanceId.toLowerCase()}`,
                // friendly_name récupéré depuis le SSE ou une API
            };
            onConnectedCallback?.();

        } catch (e) {
            console.error("[WebRenderer] Failed to register:", e);
        }
    }

    async function unregister() {
        const instanceId = getOrCreateInstanceId();
        try {
            await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
        } catch { /* ignore */ }
        connected.value = false;
        streamUrl.value = null;
    }

    onMounted(async () => {
        await register();
        window.addEventListener("beforeunload", () => void unregister());
    });

    onUnmounted(() => void unregister());

    return {
        connected: readonly(connected),
        rendererInfo: readonly(rendererInfo),
        streamUrl: readonly(streamUrl),  // exposé pour le template <audio :src="streamUrl">
        onConnected(fn: () => void) { onConnectedCallback = fn; },
    };
}

Points d'attention :

  • L'élément <audio :src="streamUrl"> doit être dans le template du composant qui utilise useWebRenderer (probablement UnifiedControlView.vue). La src pointe vers /api/webrenderer/{instance_id}/stream.
  • Le navigateur doit démarrer la lecture automatiquement quand il reçoit le flux FLAC. Cependant, l'autoplay est bloqué sans interaction utilisateur. Solution : afficher un bouton "Activer l'audio" qui déclenche audioElement.play() au premier clic.
  • La position, durée, titre/artiste ne viennent plus du navigateur. Ils viennent du SSE existant du ControlPoint. Vérifier que les événements SSE state_changed, position_changed, metadata_changed sont bien émis par le serveur lors des transitions de piste.
  • Supprimer GaplessEngine, la classe TypeScript entière et ses états internes.
  • La détection de fermeture de page (beforeunload) déclenche un DELETE qui est synchrone ou en best-effort. Utiliser navigator.sendBeacon pour garantir l'envoi.

Etape 14 : Modifier UnifiedControlView.vue — intégrer le flux audio

Fichier : pmoapp/webapp/src/views/UnifiedControlView.vue (modifier)

Ajouter l'élément <audio> au template et connecter streamUrl.

<template>
  <!-- ... template existant ... -->

  <!-- Élément audio caché pour le flux WebRenderer serveur -->
  <audio
    v-if="webRenderer.streamUrl.value"
    ref="audioElement"
    :src="webRenderer.streamUrl.value"
    preload="none"
    style="display: none"
  />

  <!-- Bouton d'activation si autoplay bloqué -->
  <button
    v-if="webRenderer.connected.value && !audioStarted"
    @click="startAudio"
  >
    Activer l'audio
  </button>
</template>

<script setup lang="ts">
// ...
const audioElement = ref<HTMLAudioElement | null>(null);
const audioStarted = ref(false);

async function startAudio() {
    if (audioElement.value) {
        try {
            await audioElement.value.play();
            audioStarted.value = true;
        } catch (e) {
            console.warn("Cannot play audio:", e);
        }
    }
}
</script>

Points d'attention :

  • L'attribut preload="none" évite de démarrer le buffering avant que l'utilisateur ait interagi. Changer en preload="auto" après interaction initiale.
  • Une seule connexion au flux (un seul <audio>) par onglet suffit. Pas de ping-pong nécessaire.

Etape 15 : Tâche de suivi de position côté serveur

Fichier : pmowebrenderer/src/registry.rs ou pipeline.rs (modifier)

Ajouter une task background qui lit la position depuis la StreamingFlacSink et met à jour RendererState.position et les variables UPnP.

/// Spawn une task qui met à jour la position dans l'état partagé toutes les secondes.
fn spawn_position_tracker(
    stream_handle: StreamHandle,
    device_instance: Arc<DeviceInstance>,
    state: SharedState,
    stop_token: CancellationToken,
) {
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
        loop {
            tokio::select! {
                _ = stop_token.cancelled() => break,
                _ = interval.tick() => {
                    // La StreamHandle expose get_metadata() qui contient audio_timestamp_sec
                    let meta = stream_handle.get_metadata().await;
                    let pos_sec = meta.audio_timestamp_sec;
                    let pos_str = seconds_to_upnp_time(pos_sec);

                    {
                        let mut s = state.write();
                        s.position = Some(pos_str.clone());
                        // duration reste celle du dernier TrackBoundary metadata
                    }

                    // Mettre à jour les variables UPnP pour que GetPositionInfo retourne les bonnes valeurs
                    update_position_vars(&device_instance, &pos_str, "").await;
                }
            }
        }
    });
}

Points d'attention :

  • MetadataSnapshot::audio_timestamp_sec est mis à jour par la StreamingFlacSink via update_metadata. Vérifier que ce champ correspond bien à la position de lecture (timestamp courant du broadcaster).
  • La durée d'une piste n'est connue qu'à partir des métadonnées FLAC (STREAMINFO). Après open_uri, stream.info().total_samples divisé par sample_rate donne la durée. Stocker cette info dans RendererState.duration lors du LoadUri.

Etape 16 : Gestion du TrackEnded côté serveur

Dans l'ancienne architecture, le navigateur envoyait TrackEnded quand une piste se terminait, ce qui déclenchait advance_queue_and_prefetch. Maintenant, c'est le pipeline qui détecte la fin d'une source.

Dans pipeline_task, quand la source courante se termine naturellement (EOF sans interruption) :

// Quand la source termine (EOF détecté dans spawn_source_task)
let (next_uri, next_metadata) = {
    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.clone();
    s.next_uri = None;
    s.next_metadata = None;
    if uri.is_some() {
        s.playback_state = PlaybackState::Playing;
    } else {
        s.playback_state = PlaybackState::Stopped;
    }
    (uri, meta)
};

// Mettre à jour les variables UPnP
update_uri_vars(&device_instance, ...).await;
update_transport_state_var(&device_instance, &new_state).await;

// Notifier le ControlPoint (advance_queue_and_prefetch)
if let Some(cp) = control_point.as_ref() {
    let udn = device_instance.udn().to_string();
    let cp = cp.clone();
    tokio::spawn(async move {
        cp.advance_queue_and_prefetch(&DeviceId(udn));
    });
}

// Si next_uri existe, lancer automatiquement la piste suivante
if let Some(uri) = next_uri {
    spawn_source_task(uri, sink_tx.clone(), stop_token.clone()).await;
}

Points d'attention :

  • La logique de TrackEnded dans websocket.rs (lignes 195-256) doit être reprise presque à l'identique dans la task pipeline.
  • Le pipeline_task a besoin d'accéder à device_instance et control_point — les passer en paramètre à la création ou via un Arc<WebRendererInstance>.

Etape 17 : Cleanup — supprimer websocket.rs et messages.rs

Fichiers à supprimer : pmowebrenderer/src/websocket.rs, pmowebrenderer/src/messages.rs

Attention : messages.rs contient PlaybackState qui est utilisé dans state.rs, handlers.rs, etc. Avant de supprimer, déplacer PlaybackState dans state.rs ou créer un types.rs.

La suppression se fait en dernier, après que tout le reste compile.


Récapitulatif de l'ordre d'implémentation

Etape Fichier Action
1 pmowebrenderer/Cargo.toml Ajouter dépendances audio
1 Workspace Cargo.toml Ajouter pmoaudio-ext aux membres
2-3 pipeline.rs (squelette) + state.rs Types PipelineControl, SharedStreamHandle
4 source_loader.rs Ouverture URI arbitraires
5 pipeline.rs (complet) Task pipeline avec spawn_source_task
6 register.rs Handlers POST/DELETE
7 stream.rs Handler GET /stream
8 registry.rs Remplace session.rs
9 handlers.rs Brancher sur PipelineHandle
10 renderer.rs Factory avec PipelineHandle
11 config.rs Nouvelles routes, supprimer WS
12 lib.rs Mettre à jour exports
13 useWebRenderer.ts POST register + audio element
14 UnifiedControlView.vue Intégrer audio element
15 registry.rs ou pipeline.rs Position tracker task
16 pipeline.rs TrackEnded côté serveur
17 websocket.rs, messages.rs Supprimer

Points d'attention généraux (pièges à éviter)

Pièges Rust

  1. Dépendances cycliques : pmoaudio-ext avec feature playlist dépend de pmoplaylistpmoaudiocache. S'assurer que pmowebrenderer ne crée pas de cycle. La feature http-stream seule suffit pour StreamingFlacSink sans dépendre de pmoplaylist.

  2. Lifetime du StreamingFlacSink : La StreamingFlacSink doit vivre tant que des clients sont connectés. Elle ne peut pas être droppée tant que le StreamHandle existe. Stocker le PipelineHandle (contenant le CancellationToken et le JoinHandle) dans WebRendererInstance pour maintenir le pipeline en vie.

  3. spawn_source_task et formats audio hétérogènes : PlaylistSource produit du PCM hétérogène. StreamingFlacSink attend un type cohérent. Si la source est un FLAC 24bits/44100Hz, le sink l'encodera en FLAC 24bits. Si une piste suivante est 16bits/48000Hz, il faudrait un ResamplingNode. Pour la phase initiale, documenter cette limitation et accepter que des pistes de formats différents peuvent causer des artefacts dans le flux continu. La StreamingFlacSink détecte le sample_rate au premier chunk — un changement de sample_rate en cours de flux n'est pas géré proprement.

  4. Seek pour les fichiers locaux : pmoflac::decode_audio_stream retourne un stream séquentiel. Pour seek, ouvrir le fichier, décoder et dropper les frames jusqu'à la position cible. C'est O(n) mais acceptable pour les fichiers locaux. Pour les URLs HTTP (Qobuz), le seek implique une nouvelle requête HTTP avec Range: bytes=N- si le serveur le supporte, ou sinon un re-téléchargement depuis le début.

  5. add_post_handler_with_state vs add_handler_with_state : Vérifier la signature exacte dans pmoserver. add_post_handler_with_state utilise routing::post(handler) et add_handler_with_state utilise routing::get(handler). Il faut post pour le register.

Pièges Frontend

  1. Autoplay policy : Chrome/Firefox bloquent audio.play() sans interaction utilisateur. La solution la plus propre est de démarrer le flux seulement après un premier clic de l'utilisateur (bouton "Activer WebRenderer" ou premier clic sur Play).

  2. Reconnexion et header FLAC : Si le navigateur recharge la page, la stream_url est la même. La StreamingFlacSink envoie le header FLAC caché aux late-joiners via register_client(). Tester que cette mécanique fonctionne correctement quand le pipeline est en cours de lecture.

  3. Suppression de filterRenderers : La fonction filterRenderers dans UnifiedControlView.vue utilise model_name === "WebRenderer" et renderer.id === myUdn. Ces valeurs viennent de RendererInfo retourné par WebSocket. Dans la nouvelle architecture, le renderer UPnP est toujours créé avec model_name = "WebRenderer" (à conserver dans WebRendererFactory). L'UDN reste uuid:{instance_id}. Le filtre continuera de fonctionner si rendererInfo.value.udn est correctement alimenté depuis la réponse du POST /register ou du SSE.


Critical Files for Implementation

  • /Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmowebrenderer/src/websocket.rs - Logique de création de device UPnP à reprendre dans registry.rs et register.rs ; contient aussi register_with_control_point et update_*_vars à migrer
  • /Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoaudio-ext/src/sinks/streaming_flac_sink.rs - Infrastructure principale réutilisée : StreamingFlacSink::new(), StreamHandle::subscribe_flac(), gestion header cache et auto_stop
  • /Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoaudio-ext/src/sources/playlist_source.rs - Modèle pour source_loader.rs : pattern decode_and_emit_track, gestion EOF, envoi AudioSegment vers children
  • /Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmomediaserver/src/paradise_streaming.rs - Pattern exact du handler HTTP streaming à copier dans stream.rs : headers, Body::from_stream(ReaderStream::new(stream))
  • /Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoapp/webapp/src/composables/useWebRenderer.ts - Toute la logique frontend à remplacer : GaplessEngine, WebSocket, connect(), execCommand(), handleMessage() — remplacer par fetch POST /register + <audio src=streamUrl>