Refactor WebRenderer to use server-side streaming with OGG-FLAC sink
This commit refactors the WebRenderer to use a server-side streaming architecture with OGG-FLAC sink instead of the previous WebSocket-based approach. The changes include: - Replaced WebSocket communication with HTTP streaming using DirectOggFlacSink - Implemented a new pipeline architecture with dedicated handlers for UPnP commands - Added new modules for registration, registry, and streaming - Updated the renderer to work with a pipeline control system - Removed old WebSocket session management - Added support for HTTP streaming with gapless playback - Updated dependencies and features for the new architecture The WebRenderer now acts as a MediaRenderer UPnP device that serves audio streams via HTTP endpoints, with commands relayed to the audio pipeline through a new control system.
This commit is contained in:
@@ -1,24 +1,33 @@
|
||||
[package]
|
||||
name = "pmowebrenderer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
[package]
|
||||
name = "pmowebrenderer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pmoupnp = { path = "../pmoupnp" }
|
||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
pmocontrol = { path = "../pmocontrol", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
[dependencies]
|
||||
pmoupnp = { path = "../pmoupnp" }
|
||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
pmocontrol = { path = "../pmocontrol", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
|
||||
# Audio pipeline (toujours actif — c'est le cœur du renderer)
|
||||
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["http-stream"] }
|
||||
pmoaudio = { path = "../pmoaudio" }
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
pmometadata = { path = "../pmometadata" }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-util = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# WebSocket
|
||||
axum = { workspace = true, features = ["ws"] }
|
||||
# HTTP
|
||||
axum = { workspace = true }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
futures = "0.3"
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
bytes = "1.0"
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -2,21 +2,24 @@
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use axum::{Router, routing::{delete, get, post}};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmocontrol::ControlPoint;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::error::WebRendererError;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::session::SessionManager;
|
||||
use crate::register::{register_handler, unregister_handler};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::websocket::{websocket_handler, WebSocketState};
|
||||
use crate::registry::RendererRegistry;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::stream::stream_handler;
|
||||
|
||||
/// Trait pour étendre pmoserver::Server avec les routes WebRenderer
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -35,17 +38,27 @@ impl WebRendererExt for pmoserver::Server {
|
||||
&mut self,
|
||||
control_point: Arc<ControlPoint>,
|
||||
) -> Result<(), WebRendererError> {
|
||||
let session_manager = Arc::new(SessionManager::new(Duration::from_secs(30 * 60)));
|
||||
let registry = Arc::new(RendererRegistry::new(control_point));
|
||||
|
||||
let ws_state = WebSocketState {
|
||||
session_manager,
|
||||
control_point,
|
||||
};
|
||||
// POST /api/webrenderer/register
|
||||
self.add_post_handler_with_state(
|
||||
"/api/webrenderer/register",
|
||||
register_handler,
|
||||
registry.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
self.add_any_handler_with_state("/api/webrenderer/ws", websocket_handler, ws_state)
|
||||
.await;
|
||||
// GET /api/webrenderer/{id}/stream + DELETE /api/webrenderer/{id}
|
||||
let dynamic_router = Router::new()
|
||||
.route("/{id}/stream", get(stream_handler))
|
||||
.route("/{id}", delete(unregister_handler))
|
||||
.with_state(registry.clone());
|
||||
self.add_router("/api/webrenderer", dynamic_router).await;
|
||||
|
||||
tracing::info!("WebRenderer WebSocket endpoint registered at /api/webrenderer/ws");
|
||||
tracing::info!("WebRenderer server-side streaming endpoints registered");
|
||||
tracing::info!(" POST /api/webrenderer/register");
|
||||
tracing::info!(" GET /api/webrenderer/{{id}}/stream");
|
||||
tracing::info!(" DELETE /api/webrenderer/{{id}}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Action handlers SOAP → WebSocket pour le WebRenderer
|
||||
//! Action handlers SOAP → Pipeline pour le WebRenderer serveur
|
||||
//!
|
||||
//! Chaque handler bridge une action UPnP vers une commande WebSocket
|
||||
//! envoyée au navigateur, ou lit l'état partagé pour les requêtes GET.
|
||||
//! Chaque handler bridge une action UPnP vers une commande `PipelineControl`
|
||||
//! envoyée au pipeline audio serveur, ou lit l'état partagé pour les requêtes GET.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -10,106 +10,89 @@ use pmoupnp::actions::{ActionData, ActionError, ActionHandler, get_value};
|
||||
use pmoupnp::{get, set};
|
||||
use pmoutils::ToXmlElement;
|
||||
|
||||
use crate::messages::{CommandParams, PlaybackState, ServerMessage, TransportAction};
|
||||
use crate::state::{SharedSender, SharedState};
|
||||
use crate::messages::PlaybackState;
|
||||
use crate::pipeline::{PipelineControl, PipelineHandle, seconds_to_upnp_time, upnp_time_to_seconds};
|
||||
use crate::state::SharedState;
|
||||
|
||||
type ActionFuture =
|
||||
std::pin::Pin<Box<dyn std::future::Future<Output = Result<ActionData, ActionError>> + Send>>;
|
||||
|
||||
// ─── AVTransport Handlers ───────────────────────────────────────────────────
|
||||
|
||||
pub fn play_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
|
||||
pub fn play_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
ws.send(ServerMessage::Command {
|
||||
action: TransportAction::Play,
|
||||
params: None,
|
||||
});
|
||||
state.write().playback_state = PlaybackState::Playing;
|
||||
// Ne pas écrire Playing ici : c'est stream_source qui le fera
|
||||
// une fois que les premiers bytes FLAC ont été produits.
|
||||
// Écrire Transitioning pour signaler que la lecture va démarrer.
|
||||
state.write().playback_state = PlaybackState::Transitioning;
|
||||
pipeline.send(PipelineControl::Play).await;
|
||||
Ok(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stop_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
|
||||
pub fn stop_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
ws.send(ServerMessage::Command {
|
||||
action: TransportAction::Stop,
|
||||
params: None,
|
||||
});
|
||||
pipeline.send(PipelineControl::Stop).await;
|
||||
state.write().playback_state = PlaybackState::Stopped;
|
||||
Ok(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pause_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
|
||||
pub fn pause_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
ws.send(ServerMessage::Command {
|
||||
action: TransportAction::Pause,
|
||||
params: None,
|
||||
});
|
||||
pipeline.send(PipelineControl::Pause).await;
|
||||
state.write().playback_state = PlaybackState::Paused;
|
||||
Ok(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn next_handler(ws: SharedSender) -> ActionHandler {
|
||||
pub fn next_handler(pipeline: PipelineHandle) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
Box::pin(async move {
|
||||
ws.send(ServerMessage::Command {
|
||||
action: TransportAction::Play,
|
||||
params: None,
|
||||
});
|
||||
pipeline.send(PipelineControl::Play).await;
|
||||
Ok(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn previous_handler(ws: SharedSender) -> ActionHandler {
|
||||
pub fn previous_handler(pipeline: PipelineHandle) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
Box::pin(async move {
|
||||
ws.send(ServerMessage::Command {
|
||||
action: TransportAction::Play,
|
||||
params: None,
|
||||
});
|
||||
pipeline.send(PipelineControl::Play).await;
|
||||
Ok(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn seek_handler(ws: SharedSender) -> ActionHandler {
|
||||
pub fn seek_handler(pipeline: PipelineHandle) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
Box::pin(async move {
|
||||
let target: String = get!(&data, "Target", String);
|
||||
ws.send(ServerMessage::Command {
|
||||
action: TransportAction::Seek,
|
||||
params: Some(CommandParams {
|
||||
uri: None,
|
||||
metadata: None,
|
||||
position: Some(target),
|
||||
}),
|
||||
});
|
||||
let pos_sec = upnp_time_to_seconds(&target);
|
||||
pipeline.send(PipelineControl::Seek(pos_sec)).await;
|
||||
Ok(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
|
||||
pub fn set_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
let uri: String = get!(&data, "CurrentURI", String);
|
||||
@@ -119,14 +102,10 @@ pub fn set_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
|
||||
.map(|didl| didl.to_xml())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ws.send(ServerMessage::Command {
|
||||
action: TransportAction::SetUri,
|
||||
params: Some(CommandParams {
|
||||
uri: Some(uri.clone()),
|
||||
metadata: Some(metadata.clone()),
|
||||
position: None,
|
||||
}),
|
||||
});
|
||||
|
||||
// Envoyer l'URI au pipeline serveur (remplace l'envoi WebSocket)
|
||||
pipeline.send(PipelineControl::LoadUri(uri.clone())).await;
|
||||
|
||||
{
|
||||
let mut s = state.write();
|
||||
s.current_uri = Some(uri);
|
||||
@@ -138,9 +117,9 @@ pub fn set_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_next_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
|
||||
pub fn set_next_uri_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
let uri: String = get!(&data, "NextURI", String);
|
||||
@@ -150,14 +129,9 @@ pub fn set_next_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandl
|
||||
.map(|didl| didl.to_xml())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ws.send(ServerMessage::Command {
|
||||
action: TransportAction::SetNextUri,
|
||||
params: Some(CommandParams {
|
||||
uri: Some(uri.clone()),
|
||||
metadata: Some(metadata.clone()),
|
||||
position: None,
|
||||
}),
|
||||
});
|
||||
|
||||
pipeline.send(PipelineControl::LoadNextUri(uri.clone())).await;
|
||||
|
||||
{
|
||||
let mut s = state.write();
|
||||
s.next_uri = Some(uri);
|
||||
@@ -215,18 +189,14 @@ pub fn get_transport_info_handler(state: SharedState) -> ActionHandler {
|
||||
Box::pin(async move {
|
||||
let mut data = data;
|
||||
let s = state.read();
|
||||
tracing::info!("[WebRenderer] GetTransportInfo handler called, state={:?}", s.playback_state);
|
||||
tracing::info!("[WebRenderer] GetTransportInfo: state={:?}", s.playback_state);
|
||||
let transport_state = match s.playback_state {
|
||||
PlaybackState::Stopped => "STOPPED",
|
||||
PlaybackState::Playing => "PLAYING",
|
||||
PlaybackState::Paused => "PAUSED_PLAYBACK",
|
||||
PlaybackState::Transitioning => "TRANSITIONING",
|
||||
};
|
||||
set!(
|
||||
&mut data,
|
||||
"CurrentTransportState",
|
||||
transport_state.to_string()
|
||||
);
|
||||
set!(&mut data, "CurrentTransportState", transport_state.to_string());
|
||||
set!(&mut data, "CurrentTransportStatus", "OK".to_string());
|
||||
set!(&mut data, "CurrentSpeed", "1".to_string());
|
||||
Ok(data)
|
||||
@@ -245,26 +215,10 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
|
||||
"NrTracks",
|
||||
if s.current_uri.is_some() { 1u32 } else { 0u32 }
|
||||
);
|
||||
set!(
|
||||
&mut data,
|
||||
"CurrentURI",
|
||||
s.current_uri.clone().unwrap_or_default()
|
||||
);
|
||||
set!(
|
||||
&mut data,
|
||||
"CurrentURIMetaData",
|
||||
s.current_metadata.clone().unwrap_or_default()
|
||||
);
|
||||
set!(
|
||||
&mut data,
|
||||
"NextURI",
|
||||
s.next_uri.clone().unwrap_or_default()
|
||||
);
|
||||
set!(
|
||||
&mut data,
|
||||
"NextURIMetaData",
|
||||
s.next_metadata.clone().unwrap_or_default()
|
||||
);
|
||||
set!(&mut data, "CurrentURI", s.current_uri.clone().unwrap_or_default());
|
||||
set!(&mut data, "CurrentURIMetaData", s.current_metadata.clone().unwrap_or_default());
|
||||
set!(&mut data, "NextURI", s.next_uri.clone().unwrap_or_default());
|
||||
set!(&mut data, "NextURIMetaData", s.next_metadata.clone().unwrap_or_default());
|
||||
Ok(data)
|
||||
})
|
||||
})
|
||||
@@ -272,13 +226,13 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
|
||||
|
||||
// ─── RenderingControl Handlers ──────────────────────────────────────────────
|
||||
|
||||
pub fn set_volume_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
|
||||
pub fn set_volume_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
let volume: u16 = get!(&data, "DesiredVolume", u16);
|
||||
ws.send(ServerMessage::SetVolume { volume });
|
||||
pipeline.send(PipelineControl::SetVolume(volume)).await;
|
||||
state.write().volume = volume;
|
||||
Ok(data)
|
||||
})
|
||||
@@ -297,13 +251,13 @@ pub fn get_volume_handler(state: SharedState) -> ActionHandler {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_mute_handler(ws: SharedSender, state: SharedState) -> ActionHandler {
|
||||
pub fn set_mute_handler(pipeline: PipelineHandle, state: SharedState) -> ActionHandler {
|
||||
Arc::new(move |data: ActionData| -> ActionFuture {
|
||||
let ws = ws.clone();
|
||||
let pipeline = pipeline.clone();
|
||||
let state = state.clone();
|
||||
Box::pin(async move {
|
||||
let mute: bool = get!(&data, "DesiredMute", bool);
|
||||
ws.send(ServerMessage::SetMute { mute });
|
||||
pipeline.send(PipelineControl::SetMute(mute)).await;
|
||||
state.write().mute = mute;
|
||||
Ok(data)
|
||||
})
|
||||
@@ -329,10 +283,11 @@ pub fn get_protocol_info_handler() -> ActionHandler {
|
||||
Box::pin(async move {
|
||||
let mut data = data;
|
||||
set!(&mut data, "Source", String::new());
|
||||
// Serveur-side streaming : on produit du FLAC uniquement
|
||||
set!(
|
||||
&mut data,
|
||||
"Sink",
|
||||
"http-get:*:audio/mpeg:*,http-get:*:audio/mp4:*,http-get:*:audio/ogg:*,http-get:*:audio/flac:*,http-get:*:audio/wav:*,http-get:*:audio/x-flac:*,http-get:*:audio/aac:*,http-get:*:audio/webm:*".to_string()
|
||||
"http-get:*:audio/flac:*,http-get:*:audio/x-flac:*".to_string()
|
||||
);
|
||||
Ok(data)
|
||||
})
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
//! PMO Web Renderer - Transforme un navigateur en MediaRenderer UPnP privé
|
||||
//! PMO Web Renderer — Transforme un navigateur en MediaRenderer UPnP privé
|
||||
//!
|
||||
//! Architecture serveur-side streaming :
|
||||
//! - Le serveur ouvre la source audio (fichier/HTTP) et l'encode en FLAC
|
||||
//! - Le navigateur lit un flux FLAC via GET /api/webrenderer/{id}/stream
|
||||
//! - Les commandes UPnP sont relayées vers le pipeline audio via PipelineControl
|
||||
|
||||
mod error;
|
||||
mod handlers;
|
||||
mod messages;
|
||||
mod pipeline;
|
||||
mod register;
|
||||
mod registry;
|
||||
mod renderer;
|
||||
mod session;
|
||||
mod state;
|
||||
mod websocket;
|
||||
mod stream;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
mod config;
|
||||
|
||||
pub use error::WebRendererError;
|
||||
pub use messages::{
|
||||
BrowserCapabilities, ClientMessage, CommandParams, PlaybackState, RendererInfo, ServerMessage,
|
||||
TrackMetadata, TransportAction,
|
||||
};
|
||||
pub use messages::PlaybackState;
|
||||
pub use pipeline::{PipelineControl, PipelineHandle};
|
||||
pub use registry::{RendererRegistry, WebRendererInstance};
|
||||
pub use renderer::{FactoryError, WebRendererFactory};
|
||||
pub use session::{SessionManager, WebRendererSession};
|
||||
pub use state::{RendererState, SharedState};
|
||||
pub use websocket::{websocket_handler, WebSocketState};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub use config::WebRendererExt;
|
||||
|
||||
448
pmowebrenderer/src/pipeline.rs
Normal file
448
pmowebrenderer/src/pipeline.rs
Normal file
@@ -0,0 +1,448 @@
|
||||
//! 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
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pmoaudio::{AudioSegment, PositionHandle, PositionTrackerNode, ResamplingNode, TimerBufferNode, ToI24Node};
|
||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||
use pmoaudio_ext::sinks::{DirectOggFlacHandle, DirectOggFlacSink, DIRECT_OGG_FLAC_SAMPLE_RATE};
|
||||
use pmoaudio_ext::UriSource;
|
||||
use pmoflac::EncoderOptions;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::messages::PlaybackState;
|
||||
use crate::state::SharedState;
|
||||
|
||||
// ─── Commandes de contrôle ───────────────────────────────────────────────────
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
// ─── Handle vers le pipeline ─────────────────────────────────────────────────
|
||||
|
||||
/// Handle partageable vers le pipeline audio d'une instance
|
||||
#[derive(Clone)]
|
||||
pub struct PipelineHandle {
|
||||
pub control_tx: mpsc::Sender<PipelineControl>,
|
||||
pub stop_token: CancellationToken,
|
||||
}
|
||||
|
||||
impl PipelineHandle {
|
||||
pub async fn send(&self, cmd: PipelineControl) {
|
||||
let _ = self.control_tx.send(cmd).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pipeline instancié ──────────────────────────────────────────────────────
|
||||
|
||||
/// Pipeline audio complet pour une instance WebRenderer.
|
||||
///
|
||||
/// Créé au `POST /register`. Le flux OGG-FLAC est accessible via `flac_handle`
|
||||
/// (un seul client à la fois, reconnectable à chaque Play).
|
||||
pub struct InstancePipeline {
|
||||
/// Handle vers le sink OGG-FLAC — clonable, reconnectable à chaque Play.
|
||||
pub flac_handle: DirectOggFlacHandle,
|
||||
pub pipeline_handle: PipelineHandle,
|
||||
}
|
||||
|
||||
impl InstancePipeline {
|
||||
/// Crée et démarre le pipeline en background.
|
||||
/// Retourne immédiatement avec les handles nécessaires.
|
||||
pub fn start(
|
||||
state: SharedState,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point: Arc<pmocontrol::ControlPoint>,
|
||||
udn: String,
|
||||
) -> Self {
|
||||
let stop_token = CancellationToken::new();
|
||||
let (control_tx, control_rx) = mpsc::channel::<PipelineControl>(32);
|
||||
|
||||
// Chaîne de traitement : ResamplingNode(96kHz) → ToI24Node → DirectFlacSink
|
||||
// La backpressure remonte naturellement depuis le pipe duplex jusqu'à la source.
|
||||
use pmoaudio::pipeline::AudioPipelineNode;
|
||||
|
||||
let (sink, flac_handle) = DirectOggFlacSink::new(EncoderOptions::default());
|
||||
|
||||
// Nœud de suivi de position : lit le timestamp des chunks sortant du buffer
|
||||
let (mut position_tracker, position_handle) = PositionTrackerNode::new();
|
||||
position_tracker.register(Box::new(sink));
|
||||
|
||||
// Nœud de pacing : régule le débit pour éviter les rafales et pertes de segments
|
||||
// 2s de buffer absorbe les irrégularités de la source réseau
|
||||
let mut timer_buffer = TimerBufferNode::new(2.0);
|
||||
timer_buffer.register(Box::new(position_tracker));
|
||||
|
||||
// Nœud de conversion de profondeur : tout type entier → I24
|
||||
// Placé avant le buffer pour réduire la mémoire utilisée
|
||||
let mut to_i24 = ToI24Node::new();
|
||||
to_i24.register(Box::new(timer_buffer));
|
||||
|
||||
// Nœud de rééchantillonnage : n'importe quel sample rate → 96 kHz
|
||||
let mut resampler = ResamplingNode::new(DIRECT_OGG_FLAC_SAMPLE_RATE);
|
||||
resampler.register(to_i24);
|
||||
|
||||
// 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");
|
||||
|
||||
let pipeline_handle = PipelineHandle {
|
||||
control_tx,
|
||||
stop_token: stop_token.clone(),
|
||||
};
|
||||
|
||||
// Lancer la chaîne resampler → to_i24 → sink en background
|
||||
let sink_stop = stop_token.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = resampler.run(sink_stop).await {
|
||||
warn!("Audio pipeline error: {:?}", e);
|
||||
}
|
||||
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();
|
||||
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");
|
||||
});
|
||||
|
||||
Self {
|
||||
flac_handle,
|
||||
pipeline_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task principale du pipeline ─────────────────────────────────────────────
|
||||
|
||||
async fn run_pipeline(
|
||||
segment_tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||
mut control_rx: mpsc::Receiver<PipelineControl>,
|
||||
control_tx: mpsc::Sender<PipelineControl>,
|
||||
stop_token: CancellationToken,
|
||||
state: SharedState,
|
||||
udn: String,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point: Arc<pmocontrol::ControlPoint>,
|
||||
) {
|
||||
let mut current_source_stop: Option<CancellationToken> = None;
|
||||
let mut current_uri: Option<String> = None;
|
||||
|
||||
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();
|
||||
}
|
||||
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")]
|
||||
let cp = control_point.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;
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
info!(udn = %udn, uri = %uri, "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, 0.0, 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) => {
|
||||
debug!(udn = %udn, "Pipeline: Pause (not supported on live stream)");
|
||||
}
|
||||
|
||||
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<Arc<AudioSegment>>,
|
||||
stop_token: CancellationToken,
|
||||
state: SharedState,
|
||||
udn: String,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point: Arc<pmocontrol::ControlPoint>,
|
||||
) {
|
||||
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");
|
||||
state.write().playback_state = PlaybackState::Stopped;
|
||||
}
|
||||
}
|
||||
}
|
||||
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<Arc<AudioSegment>>,
|
||||
stop_token: CancellationToken,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point: Arc<pmocontrol::ControlPoint>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn seconds_to_upnp_time(s: f64) -> String {
|
||||
let s = s as u64;
|
||||
let h = s / 3600;
|
||||
let m = (s % 3600) / 60;
|
||||
let sec = s % 60;
|
||||
format!("{}:{:02}:{:02}", h, m, sec)
|
||||
}
|
||||
|
||||
pub fn upnp_time_to_seconds(t: &str) -> f64 {
|
||||
let parts: Vec<f64> = t.split(':').filter_map(|p| p.parse().ok()).collect();
|
||||
match parts.as_slice() {
|
||||
[h, m, s] => h * 3600.0 + m * 60.0 + s,
|
||||
[m, s] => m * 60.0 + s,
|
||||
[s] => *s,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
72
pmowebrenderer/src/register.rs
Normal file
72
pmowebrenderer/src/register.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Handlers HTTP pour l'enregistrement/désenregistrement des instances WebRenderer.
|
||||
//!
|
||||
//! - POST /api/webrenderer/register → crée ou reconnecte une instance
|
||||
//! - DELETE /api/webrenderer/{id} → désenregistrement explicite
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::registry::RendererRegistry;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub instance_id: String,
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RegisterResponse {
|
||||
pub stream_url: String,
|
||||
pub udn: String,
|
||||
}
|
||||
|
||||
/// POST /api/webrenderer/register
|
||||
pub async fn register_handler(
|
||||
State(registry): State<Arc<RendererRegistry>>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::info!(
|
||||
instance_id = %req.instance_id,
|
||||
user_agent = %req.user_agent,
|
||||
"WebRenderer: register request"
|
||||
);
|
||||
|
||||
match registry
|
||||
.register_or_reconnect(&req.instance_id, &req.user_agent)
|
||||
.await
|
||||
{
|
||||
Ok((stream_url, udn)) => {
|
||||
tracing::info!(
|
||||
instance_id = %req.instance_id,
|
||||
stream_url = %stream_url,
|
||||
udn = %udn,
|
||||
"WebRenderer: registered"
|
||||
);
|
||||
(StatusCode::OK, Json(RegisterResponse { stream_url, udn })).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
instance_id = %req.instance_id,
|
||||
error = %e,
|
||||
"WebRenderer: registration failed"
|
||||
);
|
||||
(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 {
|
||||
tracing::info!(instance_id = %instance_id, "WebRenderer: explicit unregister");
|
||||
registry.schedule_unregister(&instance_id);
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
346
pmowebrenderer/src/registry.rs
Normal file
346
pmowebrenderer/src/registry.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
//! Registre des instances WebRenderer actives.
|
||||
//!
|
||||
//! Remplace `SessionManager` et `websocket.rs`. La session est maintenant liée
|
||||
//! au flux FLAC HTTP, pas à une connexion WebSocket.
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use pmoupnp::devices::DeviceInstance;
|
||||
|
||||
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};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmocontrol::model::{RendererCapabilities, RendererProtocol};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmoupnp::UpnpTypedInstance;
|
||||
|
||||
/// Une instance WebRenderer côté serveur
|
||||
pub struct WebRendererInstance {
|
||||
pub instance_id: String,
|
||||
pub udn: String,
|
||||
pub device_instance: Arc<DeviceInstance>,
|
||||
pub state: SharedState,
|
||||
/// Handle vers le sink OGG-FLAC — clonable, reconnectable à chaque Play.
|
||||
pub flac_handle: pmoaudio_ext::sinks::DirectOggFlacHandle,
|
||||
pub pipeline: PipelineHandle,
|
||||
pub created_at: SystemTime,
|
||||
}
|
||||
|
||||
/// Registre global des instances WebRenderer
|
||||
pub struct RendererRegistry {
|
||||
/// Map instance_id → instance
|
||||
instances: RwLock<HashMap<String, Arc<WebRendererInstance>>>,
|
||||
/// Map udn → instance (pour retrouver depuis les handlers UPnP)
|
||||
by_udn: RwLock<HashMap<String, Arc<WebRendererInstance>>>,
|
||||
/// Tokens d'annulation des unregister différés (instance_id → token)
|
||||
pending_unregister: RwLock<HashMap<String, tokio_util::sync::CancellationToken>>,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
control_point: Arc<ControlPoint>,
|
||||
}
|
||||
|
||||
impl RendererRegistry {
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub fn new(control_point: Arc<ControlPoint>) -> Self {
|
||||
Self {
|
||||
instances: RwLock::new(HashMap::new()),
|
||||
by_udn: RwLock::new(HashMap::new()),
|
||||
pending_unregister: RwLock::new(HashMap::new()),
|
||||
control_point,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "pmoserver"))]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
instances: RwLock::new(HashMap::new()),
|
||||
by_udn: RwLock::new(HashMap::new()),
|
||||
pending_unregister: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistre ou reconnecte une instance.
|
||||
/// Retourne `(stream_url, udn)`.
|
||||
pub async fn register_or_reconnect(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
user_agent: &str,
|
||||
) -> Result<(String, String), WebRendererError> {
|
||||
// Annuler tout unregister différé pour cet instance_id
|
||||
if let Some(cancel) = self.pending_unregister.write().remove(instance_id) {
|
||||
tracing::info!(instance_id = %instance_id, "WebRenderer: cancelled pending unregister (page reload)");
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
// Reconnexion : l'instance existe déjà (ou vient d'être conservée)
|
||||
{
|
||||
let instances = self.instances.read();
|
||||
if let Some(existing) = instances.get(instance_id) {
|
||||
tracing::info!(instance_id = %instance_id, "WebRenderer: reconnecting existing instance");
|
||||
#[cfg(feature = "pmoserver")]
|
||||
self.register_with_control_point(&existing.device_instance)?;
|
||||
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
|
||||
return Ok((stream_url, existing.udn.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Première connexion : créer device UPnP + pipeline
|
||||
let instance = self.create_instance(instance_id, user_agent).await?;
|
||||
let instance = Arc::new(instance);
|
||||
let stream_url = format!("/api/webrenderer/{}/stream", instance_id);
|
||||
let udn = instance.udn.clone();
|
||||
|
||||
{
|
||||
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 = %instance_id,
|
||||
udn = %udn,
|
||||
"WebRenderer: new instance registered"
|
||||
);
|
||||
|
||||
Ok((stream_url, udn))
|
||||
}
|
||||
|
||||
/// Retourne le DirectOggFlacHandle pour l'endpoint /stream (clonable).
|
||||
pub fn get_flac_handle(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
) -> Option<pmoaudio_ext::sinks::DirectOggFlacHandle> {
|
||||
self.instances
|
||||
.read()
|
||||
.get(instance_id)
|
||||
.map(|i| i.flac_handle.clone())
|
||||
}
|
||||
|
||||
/// Retourne le PipelineHandle par UDN (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())
|
||||
}
|
||||
|
||||
/// Retourne le SharedState par UDN
|
||||
pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> {
|
||||
self.by_udn
|
||||
.read()
|
||||
.get(udn)
|
||||
.map(|i| i.state.clone())
|
||||
}
|
||||
|
||||
/// Retourne le DeviceInstance par UDN
|
||||
pub fn get_device_by_udn(&self, udn: &str) -> Option<Arc<DeviceInstance>> {
|
||||
self.by_udn
|
||||
.read()
|
||||
.get(udn)
|
||||
.map(|i| i.device_instance.clone())
|
||||
}
|
||||
|
||||
pub fn schedule_unregister(self: &Arc<Self>, instance_id: &str) {
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
// Ne pas détruire immédiatement : attendre 5s au cas où la page se recharge
|
||||
let cancel = CancellationToken::new();
|
||||
self.pending_unregister.write().insert(instance_id.to_string(), cancel.clone());
|
||||
|
||||
let instance_id_owned = instance_id.to_string();
|
||||
let registry = Arc::clone(self);
|
||||
|
||||
tracing::info!(instance_id = %instance_id, "WebRenderer: unregister scheduled (5s grace period)");
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!(instance_id = %instance_id_owned, "WebRenderer: deferred unregister cancelled (page reload)");
|
||||
}
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
|
||||
registry.pending_unregister.write().remove(&instance_id_owned);
|
||||
let instance = registry.instances.write().remove(&instance_id_owned);
|
||||
if let Some(instance) = instance {
|
||||
registry.by_udn.write().remove(&instance.udn);
|
||||
instance.pipeline.stop_token.cancel();
|
||||
#[cfg(feature = "pmoserver")]
|
||||
if let Ok(mut reg) = registry.control_point.registry().write() {
|
||||
reg.device_says_byebye(&instance.udn);
|
||||
}
|
||||
tracing::info!(
|
||||
instance_id = %instance_id_owned,
|
||||
udn = %instance.udn,
|
||||
"WebRenderer: instance unregistered"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Création d'instance ────────────────────────────────────────────────────
|
||||
|
||||
async fn create_instance(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
user_agent: &str,
|
||||
) -> Result<WebRendererInstance, WebRendererError> {
|
||||
// UDN stable dérivé de l'instance_id
|
||||
let candidate_udn = instance_id.to_ascii_lowercase();
|
||||
let full_udn = format!("uuid:{}", candidate_udn);
|
||||
|
||||
// Persister l'UDN dans la config (pour que device_instance.rs le retrouve)
|
||||
if let Err(e) = pmoconfig::get_config().set_device_udn(
|
||||
"MediaRenderer",
|
||||
instance_id,
|
||||
candidate_udn.clone(),
|
||||
) {
|
||||
tracing::warn!("WebRenderer: failed to persist UDN: {:?}", e);
|
||||
}
|
||||
|
||||
let state: SharedState = Arc::new(parking_lot::RwLock::new(RendererState::default()));
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
let (device_instance, pipeline) = {
|
||||
use pmoupnp::UpnpServerExt;
|
||||
|
||||
let server_arc = pmoserver::get_server()
|
||||
.ok_or(WebRendererError::ServerNotAvailable)?;
|
||||
|
||||
// Créer le pipeline d'abord pour avoir le PipelineHandle
|
||||
let ip = InstancePipeline::start(
|
||||
state.clone(),
|
||||
self.control_point.clone(),
|
||||
full_udn.clone(),
|
||||
);
|
||||
let pipeline = ip.pipeline_handle.clone();
|
||||
|
||||
// Vérifier si un device avec ce même UDN existe déjà
|
||||
let existing_di = {
|
||||
let server = server_arc.read().await;
|
||||
server.get_device(&candidate_udn)
|
||||
};
|
||||
|
||||
let di = if let Some(di) = existing_di {
|
||||
tracing::info!(udn = %candidate_udn, "WebRenderer: reusing device from registry");
|
||||
di
|
||||
} else {
|
||||
// Créer le device UPnP
|
||||
tracing::info!(udn = %candidate_udn, "WebRenderer: creating new device");
|
||||
let device = WebRendererFactory::create_device_with_pipeline(
|
||||
instance_id,
|
||||
user_agent,
|
||||
pipeline.clone(),
|
||||
state.clone(),
|
||||
)
|
||||
.map_err(|e| WebRendererError::DeviceCreationError(e.to_string()))?;
|
||||
|
||||
let mut server = server_arc.write().await;
|
||||
server
|
||||
.register_device(Arc::new(device))
|
||||
.await
|
||||
.map_err(|e| WebRendererError::RegistrationError(e.to_string()))?
|
||||
};
|
||||
|
||||
self.register_with_control_point(&di)?;
|
||||
(di, ip)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "pmoserver"))]
|
||||
let (device_instance, pipeline) = {
|
||||
use pmoupnp::UpnpModel;
|
||||
|
||||
let ip = InstancePipeline::start(state.clone(), full_udn.clone());
|
||||
let pipeline = ip.pipeline_handle.clone();
|
||||
|
||||
let device = WebRendererFactory::create_device_with_pipeline(
|
||||
instance_id,
|
||||
user_agent,
|
||||
pipeline.clone(),
|
||||
state.clone(),
|
||||
)
|
||||
.map_err(|e| WebRendererError::DeviceCreationError(e.to_string()))?;
|
||||
|
||||
(Arc::new(device).create_instance(), ip)
|
||||
};
|
||||
|
||||
Ok(WebRendererInstance {
|
||||
instance_id: instance_id.to_string(),
|
||||
udn: full_udn,
|
||||
device_instance,
|
||||
state,
|
||||
flac_handle: pipeline.flac_handle.clone(),
|
||||
pipeline: pipeline.pipeline_handle,
|
||||
created_at: SystemTime::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enregistre le device dans le ControlPoint
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn register_with_control_point(
|
||||
&self,
|
||||
di: &Arc<DeviceInstance>,
|
||||
) -> Result<(), WebRendererError> {
|
||||
let base_url = di.base_url().to_string();
|
||||
let udn = di.udn().to_ascii_lowercase();
|
||||
let udn_with_prefix = format!("uuid:{}", udn);
|
||||
let device_route = di.route();
|
||||
let model = di.get_model();
|
||||
|
||||
let avtransport_control_url = Some(format!(
|
||||
"{}{}/service/AVTransport/control",
|
||||
base_url, device_route
|
||||
));
|
||||
let rendering_control_url = Some(format!(
|
||||
"{}{}/service/RenderingControl/control",
|
||||
base_url, device_route
|
||||
));
|
||||
let connection_manager_url = Some(format!(
|
||||
"{}{}/service/ConnectionManager/control",
|
||||
base_url, device_route
|
||||
));
|
||||
|
||||
let renderer_info = pmocontrol::RendererInfo::make(
|
||||
DeviceId(udn_with_prefix.clone()),
|
||||
udn_with_prefix.clone(),
|
||||
model.friendly_name().to_string(),
|
||||
model.model_name().to_string(),
|
||||
"PMOMusic".to_string(),
|
||||
RendererProtocol::UpnpAvOnly,
|
||||
RendererCapabilities {
|
||||
has_avtransport: true,
|
||||
has_avtransport_set_next: true,
|
||||
has_rendering_control: true,
|
||||
has_connection_manager: true,
|
||||
..Default::default()
|
||||
},
|
||||
format!("{}{}", base_url, di.description_route()),
|
||||
"PMOMusic WebRenderer/2.0".to_string(),
|
||||
Some("urn:schemas-upnp-org:service:AVTransport:1".to_string()),
|
||||
avtransport_control_url,
|
||||
Some("urn:schemas-upnp-org:service:RenderingControl:1".to_string()),
|
||||
rendering_control_url,
|
||||
Some("urn:schemas-upnp-org:service:ConnectionManager:1".to_string()),
|
||||
connection_manager_url,
|
||||
None, None, None, None, None, None, None, None, None, None, None, None, None, None, None,
|
||||
);
|
||||
|
||||
if let Ok(mut registry) = self.control_point.registry().write() {
|
||||
registry.push_renderer(&renderer_info, 86400);
|
||||
}
|
||||
|
||||
tracing::info!(udn = %udn, "WebRenderer: registered with ControlPoint");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,13 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use pmoupnp::actions::{Action, Argument};
|
||||
use pmoupnp::devices::Device;
|
||||
use pmoupnp::services::Service;
|
||||
|
||||
use crate::handlers;
|
||||
use crate::messages::ServerMessage;
|
||||
use crate::state::{SharedSender, SharedState};
|
||||
use crate::pipeline::PipelineHandle;
|
||||
use crate::state::SharedState;
|
||||
|
||||
// ─── Réimport des variables statiques de pmomediarenderer ───────────────────
|
||||
// Variables AVTransport
|
||||
@@ -65,7 +63,7 @@ fn extract_browser_name(ua: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory pour créer des Device UPnP WebRenderer avec des handlers WebSocket
|
||||
/// Factory pour créer des Device UPnP WebRenderer avec un pipeline audio serveur
|
||||
pub struct WebRendererFactory;
|
||||
|
||||
impl WebRendererFactory {
|
||||
@@ -73,26 +71,23 @@ impl WebRendererFactory {
|
||||
///
|
||||
/// `device_name` sert de clé pour retrouver l'UDN persistant dans la config.
|
||||
/// `browser_ua` est le User-Agent complet (pour déterminer le nom affiché).
|
||||
///
|
||||
/// Retourne le Device et le `SharedSender` associé. Le `SharedSender` peut être
|
||||
/// mis à jour à chaque reconnexion WebSocket via `shared_sender.set(new_tx)`.
|
||||
pub fn create_device_with_name(
|
||||
pub fn create_device_with_pipeline(
|
||||
device_name: &str,
|
||||
browser_ua: &str,
|
||||
ws_sender: mpsc::UnboundedSender<ServerMessage>,
|
||||
pipeline: PipelineHandle,
|
||||
state: SharedState,
|
||||
) -> Result<(Device, SharedSender), FactoryError> {
|
||||
let shared_sender = SharedSender::new(ws_sender);
|
||||
let avtransport = Self::build_avtransport(shared_sender.clone(), state.clone())?;
|
||||
let renderingcontrol = Self::build_renderingcontrol(shared_sender.clone(), state.clone())?;
|
||||
) -> 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(
|
||||
let mut device = Device::new(
|
||||
device_name.to_string(),
|
||||
"MediaRenderer".to_string(),
|
||||
format!("Web Audio – {}", short_name),
|
||||
);
|
||||
device.set_model_name("WebRenderer".to_string());
|
||||
device
|
||||
.add_service(Arc::new(avtransport))
|
||||
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
|
||||
@@ -103,12 +98,12 @@ impl WebRendererFactory {
|
||||
.add_service(Arc::new(connectionmanager))
|
||||
.map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?;
|
||||
|
||||
Ok((device, shared_sender))
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
/// Construit le service AVTransport avec les handlers WebSocket
|
||||
/// Construit le service AVTransport avec les handlers pipeline
|
||||
fn build_avtransport(
|
||||
ws: SharedSender,
|
||||
pipeline: PipelineHandle,
|
||||
state: SharedState,
|
||||
) -> Result<Service, FactoryError> {
|
||||
let mut svc = Service::new("AVTransport".to_string());
|
||||
@@ -158,7 +153,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&TRANSPORTPLAYSPEED),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
play.set_handler(handlers::play_handler(ws.clone(), state.clone()));
|
||||
play.set_handler(handlers::play_handler(pipeline.clone(), state.clone()));
|
||||
add_action(&mut svc, Arc::new(play))?;
|
||||
|
||||
// Stop
|
||||
@@ -168,7 +163,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&AVT_INSTANCE_ID),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
stop.set_handler(handlers::stop_handler(ws.clone(), state.clone()));
|
||||
stop.set_handler(handlers::stop_handler(pipeline.clone(), state.clone()));
|
||||
add_action(&mut svc, Arc::new(stop))?;
|
||||
|
||||
// Pause
|
||||
@@ -179,7 +174,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&AVT_INSTANCE_ID),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
pause.set_handler(handlers::pause_handler(ws.clone(), state.clone()));
|
||||
pause.set_handler(handlers::pause_handler(pipeline.clone(), state.clone()));
|
||||
add_action(&mut svc, Arc::new(pause))?;
|
||||
|
||||
// Next
|
||||
@@ -189,7 +184,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&AVT_INSTANCE_ID),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
next.set_handler(handlers::next_handler(ws.clone()));
|
||||
next.set_handler(handlers::next_handler(pipeline.clone()));
|
||||
add_action(&mut svc, Arc::new(next))?;
|
||||
|
||||
// Previous
|
||||
@@ -200,7 +195,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&AVT_INSTANCE_ID),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
previous.set_handler(handlers::previous_handler(ws.clone()));
|
||||
previous.set_handler(handlers::previous_handler(pipeline.clone()));
|
||||
add_action(&mut svc, Arc::new(previous))?;
|
||||
|
||||
// Seek
|
||||
@@ -220,7 +215,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&SEEKMODE),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
seek.set_handler(handlers::seek_handler(ws.clone()));
|
||||
seek.set_handler(handlers::seek_handler(pipeline.clone()));
|
||||
add_action(&mut svc, Arc::new(seek))?;
|
||||
|
||||
// SetAVTransportURI
|
||||
@@ -243,7 +238,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&AVTRANSPORTURIMETADATA),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
set_uri.set_handler(handlers::set_uri_handler(ws.clone(), state.clone()));
|
||||
set_uri.set_handler(handlers::set_uri_handler(pipeline.clone(), state.clone()));
|
||||
add_action(&mut svc, Arc::new(set_uri))?;
|
||||
|
||||
// SetNextAVTransportURI
|
||||
@@ -266,7 +261,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&AVTRANSPORTNEXTURIMETADATA),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
set_next_uri.set_handler(handlers::set_next_uri_handler(ws.clone(), state.clone()));
|
||||
set_next_uri.set_handler(handlers::set_next_uri_handler(pipeline.clone(), state.clone()));
|
||||
add_action(&mut svc, Arc::new(set_next_uri))?;
|
||||
|
||||
// GetPositionInfo
|
||||
@@ -422,9 +417,9 @@ impl WebRendererFactory {
|
||||
Ok(svc)
|
||||
}
|
||||
|
||||
/// Construit le service RenderingControl avec les handlers WebSocket
|
||||
/// Construit le service RenderingControl avec les handlers pipeline
|
||||
fn build_renderingcontrol(
|
||||
ws: SharedSender,
|
||||
pipeline: PipelineHandle,
|
||||
state: SharedState,
|
||||
) -> Result<Service, FactoryError> {
|
||||
let mut svc = Service::new("RenderingControl".to_string());
|
||||
@@ -458,7 +453,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&VOLUME),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
set_vol.set_handler(handlers::set_volume_handler(ws.clone(), state.clone()));
|
||||
set_vol.set_handler(handlers::set_volume_handler(pipeline.clone(), state.clone()));
|
||||
svc.add_action(Arc::new(set_vol))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
|
||||
@@ -507,7 +502,7 @@ impl WebRendererFactory {
|
||||
Arc::clone(&MUTE),
|
||||
)))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
set_mute.set_handler(handlers::set_mute_handler(ws.clone(), state.clone()));
|
||||
set_mute.set_handler(handlers::set_mute_handler(pipeline.clone(), state.clone()));
|
||||
svc.add_action(Arc::new(set_mute))
|
||||
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//! Gestionnaire de sessions WebRenderer
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use pmoupnp::devices::DeviceInstance;
|
||||
|
||||
use crate::state::{SharedSender, SharedState};
|
||||
|
||||
/// Session WebSocket liée à un MediaRenderer privé
|
||||
pub struct WebRendererSession {
|
||||
pub token: String,
|
||||
pub udn: String,
|
||||
pub device_instance: Arc<DeviceInstance>,
|
||||
/// Sender partagé : mis à jour à chaque reconnexion WebSocket.
|
||||
pub shared_sender: SharedSender,
|
||||
pub state: SharedState,
|
||||
pub created_at: SystemTime,
|
||||
pub last_activity: Arc<RwLock<SystemTime>>,
|
||||
}
|
||||
|
||||
/// Gestionnaire global des sessions
|
||||
#[derive(Clone)]
|
||||
pub struct SessionManager {
|
||||
sessions: Arc<RwLock<HashMap<String, Arc<WebRendererSession>>>>,
|
||||
/// Map UDN → SharedSender, persiste même après suppression de la session.
|
||||
/// Permet de retrouver et mettre à jour le sender à la reconnexion.
|
||||
senders: Arc<RwLock<HashMap<String, SharedSender>>>,
|
||||
/// Map UDN → SharedState, persiste même après suppression de la session.
|
||||
/// Permet de réutiliser l'état partagé avec les handlers du device existant.
|
||||
states: Arc<RwLock<HashMap<String, SharedState>>>,
|
||||
timeout_duration: Duration,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new(timeout_duration: Duration) -> Self {
|
||||
let manager = Self {
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
senders: Arc::new(RwLock::new(HashMap::new())),
|
||||
states: Arc::new(RwLock::new(HashMap::new())),
|
||||
timeout_duration,
|
||||
};
|
||||
|
||||
manager.spawn_cleanup_task();
|
||||
manager
|
||||
}
|
||||
|
||||
pub fn add_session(&self, session: Arc<WebRendererSession>) {
|
||||
let token = session.token.clone();
|
||||
let udn = session.udn.clone();
|
||||
let sender = session.shared_sender.clone();
|
||||
let state = session.state.clone();
|
||||
self.sessions.write().insert(token.clone(), session);
|
||||
self.senders.write().insert(udn.clone(), sender);
|
||||
self.states.write().insert(udn, state);
|
||||
tracing::info!(token = %token, "WebRenderer session added");
|
||||
}
|
||||
|
||||
pub fn get_session(&self, token: &str) -> Option<Arc<WebRendererSession>> {
|
||||
let sessions = self.sessions.read();
|
||||
if let Some(session) = sessions.get(token) {
|
||||
*session.last_activity.write() = SystemTime::now();
|
||||
Some(session.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrouve une session par UDN du device (indépendant du token WebSocket).
|
||||
pub fn get_session_by_udn(&self, udn: &str) -> Option<Arc<WebRendererSession>> {
|
||||
let sessions = self.sessions.read();
|
||||
sessions.values().find(|s| s.udn == udn).cloned()
|
||||
}
|
||||
|
||||
/// Retrouve le SharedSender par UDN (persiste même après suppression de session).
|
||||
pub fn get_sender_by_udn(&self, udn: &str) -> Option<SharedSender> {
|
||||
self.senders.read().get(udn).cloned()
|
||||
}
|
||||
|
||||
/// Retrouve le SharedState par UDN (persiste même après suppression de session).
|
||||
pub fn get_state_by_udn(&self, udn: &str) -> Option<SharedState> {
|
||||
self.states.read().get(udn).cloned()
|
||||
}
|
||||
|
||||
pub fn remove_session(&self, token: &str) -> Option<Arc<WebRendererSession>> {
|
||||
let session = self.sessions.write().remove(token);
|
||||
if let Some(ref s) = session {
|
||||
tracing::info!(token = %s.token, udn = %s.udn, "WebRenderer session removed");
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<Arc<WebRendererSession>> {
|
||||
self.sessions.read().values().cloned().collect()
|
||||
}
|
||||
|
||||
fn spawn_cleanup_task(&self) {
|
||||
let sessions = self.sessions.clone();
|
||||
let timeout = self.timeout_duration;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let now = SystemTime::now();
|
||||
let mut to_remove = Vec::new();
|
||||
|
||||
{
|
||||
let sessions_guard = sessions.read();
|
||||
for (token, session) in sessions_guard.iter() {
|
||||
let last = session.last_activity.read();
|
||||
if let Ok(elapsed) = now.duration_since(*last) {
|
||||
if elapsed > timeout {
|
||||
to_remove.push(token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !to_remove.is_empty() {
|
||||
let mut sessions_guard = sessions.write();
|
||||
for token in to_remove {
|
||||
sessions_guard.remove(&token);
|
||||
tracing::info!(token = %token, "Session expired and removed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
//! État partagé du renderer (backend ↔ navigateur)
|
||||
//! État partagé du renderer (backend ↔ pipeline)
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::messages::{PlaybackState, ServerMessage};
|
||||
use crate::messages::PlaybackState;
|
||||
|
||||
/// État temps-réel du renderer (partagé backend ↔ navigateur)
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -38,34 +37,3 @@ impl Default for RendererState {
|
||||
|
||||
/// Alias pour l'état partagé
|
||||
pub type SharedState = Arc<RwLock<RendererState>>;
|
||||
|
||||
/// Sender WebSocket partagé et remplaçable entre les reconnexions.
|
||||
///
|
||||
/// Les handlers UPnP capturent ce `Arc` à la création du device. À chaque
|
||||
/// reconnexion WebSocket (reload de page), on remplace le sender interne via
|
||||
/// `set()`, sans avoir à recréer le device ni ses handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct SharedSender(Arc<RwLock<Option<mpsc::UnboundedSender<ServerMessage>>>>);
|
||||
|
||||
impl SharedSender {
|
||||
pub fn new(sender: mpsc::UnboundedSender<ServerMessage>) -> Self {
|
||||
Self(Arc::new(RwLock::new(Some(sender))))
|
||||
}
|
||||
|
||||
/// Envoie un message au navigateur. Ignore silencieusement si déconnecté.
|
||||
pub fn send(&self, msg: ServerMessage) {
|
||||
if let Some(tx) = self.0.read().as_ref() {
|
||||
let _ = tx.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remplace le sender (appelé à la reconnexion WebSocket).
|
||||
pub fn set(&self, sender: mpsc::UnboundedSender<ServerMessage>) {
|
||||
*self.0.write() = Some(sender);
|
||||
}
|
||||
|
||||
/// Retire le sender (appelé à la déconnexion).
|
||||
pub fn clear(&self) {
|
||||
*self.0.write() = None;
|
||||
}
|
||||
}
|
||||
|
||||
63
pmowebrenderer/src/stream.rs
Normal file
63
pmowebrenderer/src/stream.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Handler HTTP GET /api/webrenderer/{id}/stream
|
||||
//!
|
||||
//! Sert le flux FLAC d'une instance WebRenderer via DirectFlacSink.
|
||||
//!
|
||||
//! Reconnectable : appelé à chaque Play, crée un nouveau pipe + encodeur.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{
|
||||
StatusCode,
|
||||
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio_util::io::ReaderStream;
|
||||
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<Arc<RendererRegistry>>,
|
||||
Path(instance_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
info!(instance_id = %instance_id, "FLAC stream client connecting");
|
||||
|
||||
let handle = match registry.get_flac_handle(&instance_id) {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No WebRenderer instance for id={}", instance_id),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
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("X-Content-Type-Options", "nosniff")
|
||||
.body(Body::from_stream(ReaderStream::new(stream)))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
//! Handler WebSocket pour les connexions navigateur → WebRenderer
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use axum::extract::{State, WebSocketUpgrade};
|
||||
use axum::response::IntoResponse;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use pmoupnp::devices::DeviceInstance;
|
||||
use pmoupnp::variable_types::StateValue;
|
||||
#[cfg(not(feature = "pmoserver"))]
|
||||
use pmoupnp::UpnpModel;
|
||||
use pmoupnp::UpnpTypedInstance;
|
||||
|
||||
use crate::messages::*;
|
||||
use crate::renderer::WebRendererFactory;
|
||||
use crate::session::{SessionManager, WebRendererSession};
|
||||
use crate::state::{RendererState, SharedState};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmocontrol::model::{RendererCapabilities, RendererProtocol};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmocontrol::{ControlPoint, DeviceId};
|
||||
|
||||
/// État partagé du serveur WebSocket
|
||||
#[derive(Clone)]
|
||||
pub struct WebSocketState {
|
||||
pub session_manager: Arc<SessionManager>,
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub control_point: Arc<ControlPoint>,
|
||||
}
|
||||
|
||||
/// Handler pour la connexion WebSocket
|
||||
pub async fn websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<WebSocketState>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::info!("WebRenderer WebSocket upgrade request received");
|
||||
ws.on_upgrade(move |socket: WebSocket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
/// Gestion de la connexion WebSocket
|
||||
async fn handle_socket(socket: WebSocket, state: WebSocketState) {
|
||||
tracing::info!("WebRenderer WebSocket connection established");
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
|
||||
// Canal pour envoyer des messages au navigateur
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<ServerMessage>();
|
||||
|
||||
// Task pour envoyer les messages du canal vers le WebSocket
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
if sink.send(Message::Text(json.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut session_token: Option<String> = None;
|
||||
#[allow(unused_variables, unused_assignments, unused_mut)]
|
||||
let mut device_udn: Option<String> = None;
|
||||
|
||||
// Boucle de réception des messages du navigateur
|
||||
while let Some(msg_result) = stream.next().await {
|
||||
match msg_result {
|
||||
Ok(Message::Text(text)) => {
|
||||
tracing::info!("WebRenderer received text message: {}", &text);
|
||||
match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Init { capabilities }) => {
|
||||
tracing::info!("WebRenderer Init received, creating renderer...");
|
||||
// Créer ou reconnecter le renderer UPnP pour ce navigateur.
|
||||
match create_renderer_for_browser(&capabilities, tx.clone(), &state).await {
|
||||
Ok(session) => {
|
||||
tracing::info!("WebRenderer create_renderer_for_browser OK");
|
||||
let token = session.token.clone();
|
||||
let udn = session.udn.clone();
|
||||
|
||||
let model = session.device_instance.get_model();
|
||||
|
||||
// Envoyer la confirmation au navigateur
|
||||
let _ = tx.send(ServerMessage::SessionCreated {
|
||||
token: token.clone(),
|
||||
renderer_info: RendererInfo {
|
||||
udn: udn.clone(),
|
||||
friendly_name: model.friendly_name().to_string(),
|
||||
model_name: model.model_name().to_string(),
|
||||
description_url: format!(
|
||||
"{}{}",
|
||||
session.device_instance.base_url(),
|
||||
session.device_instance.description_route()
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// Si une URI est déjà chargée (reconnexion en cours de lecture),
|
||||
// envoyer l'état complet pour que le navigateur puisse reprendre.
|
||||
{
|
||||
let s = session.state.read();
|
||||
if s.current_uri.is_some() {
|
||||
let _ = tx.send(ServerMessage::StateSync {
|
||||
current_uri: s.current_uri.clone(),
|
||||
current_metadata: s.current_metadata.clone(),
|
||||
next_uri: s.next_uri.clone(),
|
||||
next_metadata: s.next_metadata.clone(),
|
||||
playback_state: s.playback_state.clone(),
|
||||
position: s.position.clone(),
|
||||
volume: s.volume,
|
||||
mute: s.mute,
|
||||
});
|
||||
tracing::info!(
|
||||
udn = %udn,
|
||||
state = ?s.playback_state,
|
||||
"WebRenderer: sent StateSync to reconnected browser"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
session_token = Some(token.clone());
|
||||
#[cfg(feature = "pmoserver")]
|
||||
{ device_udn = Some(udn.clone()); }
|
||||
|
||||
state.session_manager.add_session(session);
|
||||
|
||||
tracing::info!(
|
||||
token = %token,
|
||||
udn = %udn,
|
||||
"WebRenderer initialized for browser: {}",
|
||||
capabilities.user_agent
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create WebRenderer: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::StateUpdate { state: new_state }) => {
|
||||
if let Some(ref token) = session_token {
|
||||
if let Some(session) = state.session_manager.get_session(token) {
|
||||
{
|
||||
session.state.write().playback_state = new_state.clone();
|
||||
}
|
||||
update_transport_state_var(&session.device_instance, &new_state)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::PositionUpdate { position, duration }) => {
|
||||
if let Some(ref token) = session_token {
|
||||
if let Some(session) = state.session_manager.get_session(token) {
|
||||
{
|
||||
let mut s = session.state.write();
|
||||
s.position = Some(position.clone());
|
||||
s.duration = Some(duration.clone());
|
||||
}
|
||||
update_position_vars(
|
||||
&session.device_instance,
|
||||
&position,
|
||||
&duration,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::MetadataUpdate { metadata }) => {
|
||||
if let Some(ref token) = session_token {
|
||||
if let Some(session) = state.session_manager.get_session(token) {
|
||||
let didl = build_didl_from_metadata(&metadata);
|
||||
{
|
||||
session.state.write().current_metadata = Some(didl.clone());
|
||||
}
|
||||
update_metadata_var(&session.device_instance, &didl).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::VolumeUpdate { volume, mute }) => {
|
||||
if let Some(ref token) = session_token {
|
||||
if let Some(session) = state.session_manager.get_session(token) {
|
||||
{
|
||||
let mut s = session.state.write();
|
||||
s.volume = volume;
|
||||
s.mute = mute;
|
||||
}
|
||||
update_volume_vars(&session.device_instance, volume, mute).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::TrackEnded) => {
|
||||
if let Some(ref token) = session_token {
|
||||
if let Some(session) = state.session_manager.get_session(token) {
|
||||
let (next_uri, next_metadata, had_next) = {
|
||||
let mut s = session.state.write();
|
||||
let uri = s.next_uri.take();
|
||||
let meta = s.next_metadata.take();
|
||||
let had_next = uri.is_some();
|
||||
s.current_uri = uri.clone();
|
||||
s.current_metadata = meta.clone();
|
||||
s.next_uri = None;
|
||||
s.next_metadata = None;
|
||||
s.position = None;
|
||||
s.duration = None;
|
||||
// Si on avait une piste suivante (gapless), on reste en Playing.
|
||||
// Sinon, on reste en Stopped pour que le watcher déclenche l'auto-advance.
|
||||
if had_next {
|
||||
s.playback_state = PlaybackState::Playing;
|
||||
}
|
||||
// Si had_next == false, le navigateur a déjà envoyé state_update:STOPPED,
|
||||
// donc s.playback_state est déjà Stopped. On le laisse tel quel.
|
||||
(uri, meta, had_next)
|
||||
};
|
||||
let new_state = if had_next {
|
||||
PlaybackState::Playing
|
||||
} else {
|
||||
PlaybackState::Stopped
|
||||
};
|
||||
update_transport_state_var(
|
||||
&session.device_instance,
|
||||
&new_state,
|
||||
)
|
||||
.await;
|
||||
// Mettre à jour AVTransportURI pour que le ControlPoint
|
||||
// voie la nouvelle piste courante et envoie SetNextAVTransportURI
|
||||
update_uri_vars(
|
||||
&session.device_instance,
|
||||
next_uri.as_deref().unwrap_or(""),
|
||||
next_metadata.as_deref().unwrap_or(""),
|
||||
"", // next_uri vide : le ControlPoint le remplira
|
||||
"",
|
||||
)
|
||||
.await;
|
||||
// Si c'était une transition gapless (on avait une piste suivante),
|
||||
// avancer l'index de la queue dans le ControlPoint et prefetch la piste N+2.
|
||||
// Si pas de piste suivante, le watcher verra STOPPED et déclenchera l'auto-advance.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
if had_next {
|
||||
let udn = session.udn.clone();
|
||||
let cp = state.control_point.clone();
|
||||
tokio::spawn(async move {
|
||||
cp.advance_queue_and_prefetch(
|
||||
&pmocontrol::DeviceId(udn),
|
||||
);
|
||||
});
|
||||
}
|
||||
tracing::debug!(
|
||||
uri = ?next_uri,
|
||||
had_next,
|
||||
"WebRenderer TrackEnded: advanced to next track"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Pong) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Failed to parse client message");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Binary(b)) => {
|
||||
tracing::warn!("WebRenderer received binary message ({} bytes)", b.len());
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
tracing::info!("WebRenderer WebSocket closed by client");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup à la déconnexion
|
||||
tracing::info!("WebRenderer WebSocket handler exiting (session_token={:?})", session_token);
|
||||
if let Some(token) = session_token {
|
||||
state.session_manager.remove_session(&token);
|
||||
}
|
||||
|
||||
// Marquer le renderer comme offline dans le ControlPoint
|
||||
#[cfg(feature = "pmoserver")]
|
||||
if let Some(ref udn) = device_udn {
|
||||
if let Ok(mut registry) = state.control_point.registry().write() {
|
||||
registry.device_says_byebye(udn);
|
||||
}
|
||||
tracing::info!(udn = %udn, "WebRenderer disconnected and marked offline");
|
||||
}
|
||||
|
||||
send_task.abort();
|
||||
}
|
||||
|
||||
/// Crée ou reconnecte un DeviceInstance UPnP pour un navigateur.
|
||||
///
|
||||
/// - Première connexion : crée le device, l'enregistre, crée la session.
|
||||
/// - Reconnexion (reload) : retrouve la session existante par UDN, met à jour le
|
||||
/// `SharedSender` avec le nouveau tx WebSocket (les handlers continuent de fonctionner),
|
||||
/// et crée une nouvelle session avec un nouveau token.
|
||||
async fn create_renderer_for_browser(
|
||||
capabilities: &BrowserCapabilities,
|
||||
ws_sender: mpsc::UnboundedSender<ServerMessage>,
|
||||
ws_state: &WebSocketState,
|
||||
) -> Result<Arc<WebRendererSession>, crate::error::WebRendererError> {
|
||||
let token = Uuid::new_v4().to_string();
|
||||
|
||||
// Persister l'UDN dérivé de l'instance_id dans la config pour que device_instance.rs
|
||||
// le retrouve de façon déterministe. La clé ("MediaRenderer", instance_id) est unique
|
||||
// par onglet/navigateur et stable entre les reloads.
|
||||
let instance_udn = capabilities.instance_id.clone();
|
||||
if let Err(e) = pmoconfig::get_config().set_device_udn(
|
||||
"MediaRenderer",
|
||||
&instance_udn,
|
||||
instance_udn.clone(),
|
||||
) {
|
||||
tracing::warn!("WebRenderer: failed to persist UDN in config: {:?}", e);
|
||||
}
|
||||
|
||||
// UDN normalisé tel que stocké dans le DEVICE_REGISTRY (sans préfixe "uuid:")
|
||||
let candidate_udn = instance_udn.to_ascii_lowercase();
|
||||
// UDN avec préfixe "uuid:" pour le ControlPoint et la session
|
||||
let full_udn = format!("uuid:{}", candidate_udn);
|
||||
|
||||
// ── Reconnexion : session existante par UDN ───────────────────────────────
|
||||
// Si une session avec ce même UDN existe encore dans le SessionManager, on
|
||||
// met à jour son SharedSender (les handlers UPnP enverront vers le nouveau WS).
|
||||
if let Some(existing_session) = ws_state.session_manager.get_session_by_udn(&full_udn) {
|
||||
tracing::info!(udn = %full_udn, "WebRenderer: reconnecting via existing session");
|
||||
existing_session.shared_sender.set(ws_sender.clone());
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
register_with_control_point(&existing_session.device_instance, ws_state)?;
|
||||
|
||||
// Nouvelle session avec nouveau token, mais même device/state/sender partagés
|
||||
let session = Arc::new(WebRendererSession {
|
||||
token,
|
||||
udn: full_udn,
|
||||
device_instance: existing_session.device_instance.clone(),
|
||||
shared_sender: existing_session.shared_sender.clone(),
|
||||
state: existing_session.state.clone(),
|
||||
created_at: existing_session.created_at,
|
||||
last_activity: existing_session.last_activity.clone(),
|
||||
});
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
// ── Première connexion : création complète ────────────────────────────────
|
||||
|
||||
// Enregistrer le device via UpnpServerExt (gère base_url, register_urls, DEVICE_REGISTRY)
|
||||
// Retourne (DeviceInstance, SharedSender effectif, SharedState effective pour cette session)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
let (di, shared_sender, shared_state) = {
|
||||
use pmoupnp::UpnpServerExt;
|
||||
|
||||
tracing::info!("WebRenderer: candidate UDN = {}", candidate_udn);
|
||||
|
||||
// Vérifier si un device avec ce même UDN est déjà dans le DEVICE_REGISTRY
|
||||
// (cas où la session a expiré mais le device est encore enregistré).
|
||||
let server_arc =
|
||||
pmoserver::get_server().ok_or(crate::error::WebRendererError::ServerNotAvailable)?;
|
||||
let existing_di = {
|
||||
let server = server_arc.read().await;
|
||||
server.get_device(&candidate_udn)
|
||||
};
|
||||
|
||||
if let Some(di) = existing_di {
|
||||
tracing::info!(udn = %candidate_udn, "WebRenderer: reusing device from registry (session expired)");
|
||||
// Mettre à jour le SharedSender de ce device (session supprimée mais device toujours dans registry).
|
||||
// Le SharedSender et le SharedState sont ceux capturés dans les handlers du di existant.
|
||||
let effective_sender = if let Some(existing_sender) = ws_state.session_manager.get_sender_by_udn(&full_udn) {
|
||||
existing_sender.set(ws_sender);
|
||||
tracing::info!(udn = %full_udn, "WebRenderer: updated SharedSender for reused device");
|
||||
existing_sender
|
||||
} else {
|
||||
// Fallback : ne devrait pas arriver mais on crée un sender neuf
|
||||
tracing::warn!(udn = %full_udn, "WebRenderer: no SharedSender found for reused device");
|
||||
let new_state: SharedState = Arc::new(RwLock::new(RendererState::default()));
|
||||
let (_, new_sender) = WebRendererFactory::create_device_with_name(
|
||||
&instance_udn, &capabilities.user_agent, ws_sender, new_state.clone(),
|
||||
).map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?;
|
||||
new_sender
|
||||
};
|
||||
let effective_state = ws_state.session_manager.get_state_by_udn(&full_udn)
|
||||
.unwrap_or_else(|| Arc::new(RwLock::new(RendererState::default())));
|
||||
register_with_control_point(&di, ws_state)?;
|
||||
(di, effective_sender, effective_state)
|
||||
} else {
|
||||
// Véritablement première connexion : créer device + state + sender
|
||||
let new_state: SharedState = Arc::new(RwLock::new(RendererState::default()));
|
||||
tracing::info!("WebRenderer: creating device model...");
|
||||
let (device, new_sender) = WebRendererFactory::create_device_with_name(
|
||||
&instance_udn,
|
||||
&capabilities.user_agent,
|
||||
ws_sender,
|
||||
new_state.clone(),
|
||||
)
|
||||
.map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?;
|
||||
tracing::info!("WebRenderer: device model created");
|
||||
|
||||
let device = Arc::new(device);
|
||||
tracing::info!("WebRenderer: registering new device...");
|
||||
let di = {
|
||||
let mut server = server_arc.write().await;
|
||||
server
|
||||
.register_device(device)
|
||||
.await
|
||||
.map_err(|e| crate::error::WebRendererError::RegistrationError(e.to_string()))?
|
||||
};
|
||||
tracing::info!("WebRenderer: device registered");
|
||||
register_with_control_point(&di, ws_state)?;
|
||||
(di, new_sender, new_state)
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "pmoserver"))]
|
||||
let (di, shared_sender, shared_state) = {
|
||||
let new_state: SharedState = Arc::new(RwLock::new(RendererState::default()));
|
||||
let (device, new_sender) = WebRendererFactory::create_device_with_name(
|
||||
&instance_udn,
|
||||
&capabilities.user_agent,
|
||||
ws_sender,
|
||||
new_state.clone(),
|
||||
)
|
||||
.map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?;
|
||||
(Arc::new(device).create_instance(), new_sender, new_state)
|
||||
};
|
||||
|
||||
let session = Arc::new(WebRendererSession {
|
||||
token,
|
||||
udn: full_udn,
|
||||
device_instance: di,
|
||||
shared_sender,
|
||||
state: shared_state,
|
||||
created_at: SystemTime::now(),
|
||||
last_activity: Arc::new(RwLock::new(SystemTime::now())),
|
||||
});
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Enregistre le DeviceInstance auprès du ControlPoint comme un renderer
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn register_with_control_point(
|
||||
di: &Arc<DeviceInstance>,
|
||||
ws_state: &WebSocketState,
|
||||
) -> Result<(), crate::error::WebRendererError> {
|
||||
let base_url = di.base_url().to_string();
|
||||
let udn = di.udn().to_ascii_lowercase();
|
||||
// Préfixer avec "uuid:" pour correspondre au format SSDP et éviter les doublons
|
||||
let udn_with_prefix = format!("uuid:{}", udn);
|
||||
let device_route = di.route();
|
||||
let model = di.get_model();
|
||||
|
||||
let avtransport_control_url = Some(format!(
|
||||
"{}{}/service/AVTransport/control",
|
||||
base_url, device_route
|
||||
));
|
||||
let rendering_control_url = Some(format!(
|
||||
"{}{}/service/RenderingControl/control",
|
||||
base_url, device_route
|
||||
));
|
||||
let connection_manager_url = Some(format!(
|
||||
"{}{}/service/ConnectionManager/control",
|
||||
base_url, device_route
|
||||
));
|
||||
|
||||
let renderer_info = pmocontrol::RendererInfo::make(
|
||||
DeviceId(udn_with_prefix.clone()),
|
||||
udn_with_prefix.clone(),
|
||||
model.friendly_name().to_string(),
|
||||
model.model_name().to_string(),
|
||||
"PMOMusic".to_string(),
|
||||
RendererProtocol::UpnpAvOnly,
|
||||
RendererCapabilities {
|
||||
has_avtransport: true,
|
||||
has_avtransport_set_next: true,
|
||||
has_rendering_control: true,
|
||||
has_connection_manager: true,
|
||||
..Default::default()
|
||||
},
|
||||
format!("{}{}", base_url, di.description_route()),
|
||||
"PMOMusic WebRenderer/1.0".to_string(),
|
||||
Some("urn:schemas-upnp-org:service:AVTransport:1".to_string()),
|
||||
avtransport_control_url,
|
||||
Some("urn:schemas-upnp-org:service:RenderingControl:1".to_string()),
|
||||
rendering_control_url,
|
||||
Some("urn:schemas-upnp-org:service:ConnectionManager:1".to_string()),
|
||||
connection_manager_url,
|
||||
None, // oh_playlist_service_type
|
||||
None, // oh_playlist_control_url
|
||||
None, // oh_playlist_event_sub_url
|
||||
None, // oh_info_service_type
|
||||
None, // oh_info_control_url
|
||||
None, // oh_info_event_sub_url
|
||||
None, // oh_time_service_type
|
||||
None, // oh_time_control_url
|
||||
None, // oh_time_event_sub_url
|
||||
None, // oh_volume_service_type
|
||||
None, // oh_volume_control_url
|
||||
None, // oh_radio_service_type
|
||||
None, // oh_radio_control_url
|
||||
None, // oh_product_service_type
|
||||
None, // oh_product_control_url
|
||||
);
|
||||
|
||||
if let Ok(mut registry) = ws_state.control_point.registry().write() {
|
||||
// max_age élevé car pas de SSDP — cleanup à la déconnexion WS
|
||||
registry.push_renderer(&renderer_info, 86400);
|
||||
}
|
||||
|
||||
tracing::info!(udn = %udn, "WebRenderer registered with ControlPoint");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Mise à jour des StateVarInstance UPnP ──────────────────────────────────
|
||||
|
||||
async fn update_transport_state_var(di: &DeviceInstance, state: &PlaybackState) {
|
||||
let upnp_state = match state {
|
||||
PlaybackState::Stopped => "STOPPED",
|
||||
PlaybackState::Playing => "PLAYING",
|
||||
PlaybackState::Paused => "PAUSED_PLAYBACK",
|
||||
PlaybackState::Transitioning => "TRANSITIONING",
|
||||
};
|
||||
|
||||
if let Some(service) = di.get_service("AVTransport") {
|
||||
if let Some(var) = service.get_variable("TransportState") {
|
||||
let _ = var
|
||||
.set_value(StateValue::String(upnp_state.to_string()))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_position_vars(di: &DeviceInstance, position: &str, duration: &str) {
|
||||
if let Some(service) = di.get_service("AVTransport") {
|
||||
if let Some(var) = service.get_variable("RelativeTimePosition") {
|
||||
let _ = var
|
||||
.set_value(StateValue::String(position.to_string()))
|
||||
.await;
|
||||
}
|
||||
if let Some(var) = service.get_variable("AbsoluteTimePosition") {
|
||||
let _ = var
|
||||
.set_value(StateValue::String(position.to_string()))
|
||||
.await;
|
||||
}
|
||||
if let Some(var) = service.get_variable("CurrentTrackDuration") {
|
||||
let _ = var
|
||||
.set_value(StateValue::String(duration.to_string()))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_uri_vars(
|
||||
di: &DeviceInstance,
|
||||
current_uri: &str,
|
||||
current_metadata: &str,
|
||||
next_uri: &str,
|
||||
next_metadata: &str,
|
||||
) {
|
||||
if let Some(service) = di.get_service("AVTransport") {
|
||||
if let Some(var) = service.get_variable("AVTransportURI") {
|
||||
let _ = var
|
||||
.set_value(StateValue::String(current_uri.to_string()))
|
||||
.await;
|
||||
}
|
||||
if let Some(var) = service.get_variable("AVTransportURIMetaData") {
|
||||
let _ = var
|
||||
.set_value(StateValue::String(current_metadata.to_string()))
|
||||
.await;
|
||||
}
|
||||
if let Some(var) = service.get_variable("AVTransportNextURI") {
|
||||
let _ = var
|
||||
.set_value(StateValue::String(next_uri.to_string()))
|
||||
.await;
|
||||
}
|
||||
if let Some(var) = service.get_variable("AVTransportNextURIMetaData") {
|
||||
let _ = var
|
||||
.set_value(StateValue::String(next_metadata.to_string()))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_metadata_var(di: &DeviceInstance, didl: &str) {
|
||||
if let Some(service) = di.get_service("AVTransport") {
|
||||
if let Some(var) = service.get_variable("CurrentTrackMetaData") {
|
||||
let _ = var.set_value(StateValue::String(didl.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_volume_vars(di: &DeviceInstance, volume: u16, mute: bool) {
|
||||
if let Some(service) = di.get_service("RenderingControl") {
|
||||
if let Some(var) = service.get_variable("Volume") {
|
||||
let _ = var.set_value(StateValue::UI2(volume)).await;
|
||||
}
|
||||
if let Some(var) = service.get_variable("Mute") {
|
||||
let _ = var.set_value(StateValue::Boolean(mute)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_didl_from_metadata(metadata: &TrackMetadata) -> String {
|
||||
use pmodidl::{DIDLLite, Item, Resource};
|
||||
use pmoutils::ToXmlElement;
|
||||
|
||||
let item = Item {
|
||||
id: "0".to_string(),
|
||||
parent_id: "-1".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
title: metadata
|
||||
.title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
creator: None,
|
||||
class: "object.item.audioItem.musicTrack".to_string(),
|
||||
artist: metadata.artist.clone(),
|
||||
album: metadata.album.clone(),
|
||||
genre: None,
|
||||
album_art: metadata.album_art_uri.clone(),
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: None,
|
||||
resources: vec![Resource {
|
||||
protocol_info: "http-get:*:audio/*:*".to_string(),
|
||||
duration: metadata.duration.clone(),
|
||||
url: "".to_string(),
|
||||
bits_per_sample: None,
|
||||
sample_frequency: None,
|
||||
nr_audio_channels: None,
|
||||
}],
|
||||
descriptions: vec![],
|
||||
};
|
||||
|
||||
let didl = DIDLLite {
|
||||
items: vec![item],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
didl.to_xml()
|
||||
}
|
||||
Reference in New Issue
Block a user