Merge pull request 'push-osyzsknqnpom' (#88) from push-osyzsknqnpom into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m55s

Reviewed-on: #88
This commit was merged in pull request #88.
This commit is contained in:
2026-04-06 00:22:38 +02:00
13 changed files with 155 additions and 56 deletions

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "PMOMusic" name = "PMOMusic"
version = "0.3.35" version = "0.3.36"
dependencies = [ dependencies = [
"axum 0.8.7", "axum 0.8.7",
"console-subscriber", "console-subscriber",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "PMOMusic" name = "PMOMusic"
version = "0.3.35" version = "0.3.37"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -36,10 +36,16 @@ const myUdn = computed(() => webRenderer.rendererUdn.value);
const filteredRenderers = computed(() => { const filteredRenderers = computed(() => {
const udn = myUdn.value; const udn = myUdn.value;
const normalizedUdn = udn?.replace(/^uuid:/, '') ?? null;
return allRenderers.value.filter((r: RendererSummary) => { return allRenderers.value.filter((r: RendererSummary) => {
if (r.model_name !== "WebRenderer") return true; // WebRenderers have "Web Audio " as friendly_name prefix
if (udn === null) return false; const isWebRenderer = r.friendly_name?.startsWith("Web Audio ") ?? false;
return r.id === udn; if (isWebRenderer) {
if (normalizedUdn === null) return false;
const normalizedId = r.id.replace(/^uuid:/, '');
return normalizedId === normalizedUdn;
}
return true;
}); });
}); });

View File

@@ -63,6 +63,7 @@ function ensureSSEInitialized() {
id: rendererId, id: rendererId,
friendly_name: event.friendly_name, friendly_name: event.friendly_name,
model_name: event.model_name, model_name: event.model_name,
manufacturer: event.manufacturer,
protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch
capabilities: { capabilities: {
has_avtransport: false, has_avtransport: false,
@@ -269,9 +270,12 @@ function selectRenderer(id: string | null) {
snapshotState.selectedRendererId = id; snapshotState.selectedRendererId = id;
} }
async function fetchRenderers(force = false) { async function fetchRenderers(force = false, retries = 2) {
ensureSSEInitialized(); ensureSSEInitialized();
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try { try {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@@ -287,12 +291,19 @@ async function fetchRenderers(force = false) {
renderersCache.value = new Map( renderersCache.value = new Map(
data.map((renderer) => [renderer.id, renderer]), data.map((renderer) => [renderer.id, renderer]),
); );
return;
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : "Erreur fetch renderers"; lastError = err instanceof Error ? err : new Error("Erreur fetch renderers");
console.error("[useRenderers] Erreur fetch:", err); console.error("[useRenderers] Erreur fetch (attempt " + (attempt + 1) + "):", lastError);
if (attempt < retries) {
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
}
} finally { } finally {
loading.value = false; loading.value = false;
} }
}
error.value = lastError?.message ?? "Erreur fetch renderers";
} }
async function fetchRendererSnapshot( async function fetchRendererSnapshot(

View File

@@ -22,6 +22,24 @@ function generateUUID(): string {
} }
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id"; const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
const RENDERER_UDN_KEY = "pmomusic_webrenderer_udn";
// Load persisted UDN on module load
function loadPersistedUdn(): string | null {
try {
return sessionStorage.getItem(RENDERER_UDN_KEY);
} catch {
return null;
}
}
function savePersistedUdn(udn: string) {
try {
sessionStorage.setItem(RENDERER_UDN_KEY, udn);
} catch {
// ignore storage errors
}
}
// Module-level singleton for PMOPlayer to prevent duplicate instances // Module-level singleton for PMOPlayer to prevent duplicate instances
let globalPlayer: PMOPlayer | null = null; let globalPlayer: PMOPlayer | null = null;
@@ -31,7 +49,7 @@ let registering = false;
// Module-level reactive state shared across all composable invocations // Module-level reactive state shared across all composable invocations
const sharedConnected = ref(false); const sharedConnected = ref(false);
const sharedStreamUrl = ref<string | null>(null); const sharedStreamUrl = ref<string | null>(null);
const sharedRendererUdn = ref<string | null>(null); const sharedRendererUdn = ref<string | null>(loadPersistedUdn());
function getOrCreateInstanceId(): string { function getOrCreateInstanceId(): string {
try { try {
@@ -55,17 +73,41 @@ export function useWebRenderer() {
let onConnectedCallback: (() => void) | null = null; let onConnectedCallback: (() => void) | null = null;
async function register(): Promise<void> { async function register(): Promise<void> {
// Prevent concurrent registrations (race condition → double player) // Allow re-registration if we have a stale player but no connection
if (globalPlayer || registering) { if (globalPlayer && !connected.value) {
console.log('[WebRenderer] stale globalPlayer, clearing');
globalPlayer = null;
}
if (globalPlayer) { if (globalPlayer) {
player = globalPlayer; player = globalPlayer;
connected.value = true; connected.value = true;
} console.log('[WebRenderer] reusing existing player');
return; return;
} }
if (registering) {
console.log('[WebRenderer] already registering, waiting...');
// Wait a bit and try once
await new Promise(r => setTimeout(r, 1000));
if (globalPlayer) {
player = globalPlayer;
connected.value = true;
return;
}
}
registering = true; registering = true;
const instanceId = getOrCreateInstanceId(); let instanceId;
try {
instanceId = getOrCreateInstanceId();
} catch (e) {
console.error('[WebRenderer] failed to get instanceId:', e);
registering = false;
return;
}
console.log('[WebRenderer] registering with instanceId:', instanceId); console.log('[WebRenderer] registering with instanceId:', instanceId);
try { try {
@@ -86,6 +128,7 @@ export function useWebRenderer() {
const data = await resp.json(); const data = await resp.json();
streamUrl.value = data.stream_url; streamUrl.value = data.stream_url;
rendererUdn.value = data.udn; rendererUdn.value = data.udn;
savePersistedUdn(data.udn);
player = new PMOPlayer(instanceId); player = new PMOPlayer(instanceId);
globalPlayer = player; globalPlayer = player;
@@ -103,6 +146,13 @@ export function useWebRenderer() {
connected.value = true; connected.value = true;
onConnectedCallback?.(); onConnectedCallback?.();
// Force refresh renderers list after registration
// The SSE might miss the initial event
setTimeout(() => {
console.log('[WebRenderer] forcing renderers refresh');
window.dispatchEvent(new CustomEvent('webrenderer-registered'));
}, 500);
} catch (e) { } catch (e) {
console.error("[WebRenderer] register error:", e); console.error("[WebRenderer] register error:", e);
} finally { } finally {

View File

@@ -29,6 +29,7 @@ export interface RendererSummary {
id: string; id: string;
friendly_name: string; friendly_name: string;
model_name: string; model_name: string;
manufacturer?: string;
protocol: RendererProtocolSummary; protocol: RendererProtocolSummary;
capabilities: RendererCapabilitiesSummary; capabilities: RendererCapabilitiesSummary;
online: boolean; online: boolean;

View File

@@ -77,9 +77,9 @@ const onlineServersCount = computed(
() => allServers.value.filter((s) => s.online).length, () => allServers.value.filter((s) => s.online).length,
); );
// Nombre de renderers online pour afficher dans le badge // Nombre de renderers online (filtrés) pour afficher dans le badge
const onlineRenderersCount = computed( const onlineRenderersCount = computed(
() => allRenderers.value.filter((r) => r.online).length, () => filterRenderers(allRenderers.value).filter((r) => r.online).length,
); );
// Gestion de l'ouverture du drawer depuis le bouton // Gestion de l'ouverture du drawer depuis le bouton
@@ -103,14 +103,19 @@ function handleRendererSelect(rendererId: string) {
} }
} }
// Filtre la liste des renderers pour exclure les WebRenderers d'autres navigateurs. // Filter to show only our own WebRenderer based on UDN match
// Seul le WebRenderer créé par ce navigateur (identifié par son UDN) reste visible.
function filterRenderers(renderers: typeof allRenderers.value) { function filterRenderers(renderers: typeof allRenderers.value) {
const myUdn = webRenderer.rendererUdn.value; const myUdn = webRenderer.rendererUdn.value;
const normalizedUdn = myUdn?.replace(/^uuid:/, '') ?? null;
return renderers.filter((r) => { return renderers.filter((r) => {
if (r.model_name !== "WebRenderer") return true; // renderer classique : toujours visible // WebRenderers have "Web Audio " as friendly_name prefix
if (myUdn === null) return false; // pas encore de session : masquer tous les WebRenderers const isWebRenderer = r.friendly_name?.startsWith("Web Audio ") ?? false;
return r.id === myUdn; // ne garder que le nôtre if (isWebRenderer) {
if (normalizedUdn === null) return false;
const normalizedId = r.id.replace(/^uuid:/, '');
return normalizedId === normalizedUdn;
}
return true;
}); });
} }

View File

@@ -65,6 +65,7 @@ impl MediaRendererRegistry {
instance_id: &str, instance_id: &str,
stream_url_base: &str, stream_url_base: &str,
renderer_name: &str, renderer_name: &str,
friendly_name: &str,
adapter_fn: impl FnOnce(SharedState) -> Arc<dyn DeviceAdapter>, adapter_fn: impl FnOnce(SharedState) -> Arc<dyn DeviceAdapter>,
) -> Result<(String, String, bool), MediaRendererError> { ) -> Result<(String, String, bool), MediaRendererError> {
if let Some(cancel) = self.pending_unregister.write().remove(instance_id) { if let Some(cancel) = self.pending_unregister.write().remove(instance_id) {
@@ -77,7 +78,7 @@ impl MediaRendererRegistry {
if let Some(existing) = instances.get(instance_id) { if let Some(existing) = instances.get(instance_id) {
tracing::info!(instance_id = %instance_id, "MediaRenderer: reconnecting existing instance"); tracing::info!(instance_id = %instance_id, "MediaRenderer: reconnecting existing instance");
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
self.register_with_control_point(&existing.device_instance, renderer_name)?; self.register_with_control_point(&existing.device_instance, renderer_name, &existing.udn)?;
let stream_url = format!("{}/{}/stream", stream_url_base, instance_id); let stream_url = format!("{}/{}/stream", stream_url_base, instance_id);
let should_play = { let should_play = {
let s = existing.state.read(); let s = existing.state.read();
@@ -90,7 +91,7 @@ impl MediaRendererRegistry {
} }
} }
let instance = self.create_instance_with_adapter(instance_id, stream_url_base, renderer_name, adapter_fn).await?; let instance = self.create_instance_with_adapter(instance_id, stream_url_base, renderer_name, friendly_name, adapter_fn).await?;
let instance = Arc::new(instance); let instance = Arc::new(instance);
let stream_url = format!("{}/{}/stream", stream_url_base, instance_id); let stream_url = format!("{}/{}/stream", stream_url_base, instance_id);
let udn = instance.udn.clone(); let udn = instance.udn.clone();
@@ -231,18 +232,14 @@ impl MediaRendererRegistry {
instance_id: &str, instance_id: &str,
stream_url_base: &str, stream_url_base: &str,
renderer_name: &str, renderer_name: &str,
friendly_name: &str,
adapter_fn: impl FnOnce(SharedState) -> Arc<dyn DeviceAdapter>, adapter_fn: impl FnOnce(SharedState) -> Arc<dyn DeviceAdapter>,
) -> Result<MediaRendererInstance, MediaRendererError> { ) -> Result<MediaRendererInstance, MediaRendererError> {
let candidate_udn = instance_id.to_ascii_lowercase(); let candidate_udn = instance_id.to_ascii_lowercase();
let full_udn = format!("uuid:{}", candidate_udn); let full_udn = format!("uuid:{}", candidate_udn);
if let Err(e) = pmoconfig::get_config().set_device_udn( // Note: WebRenderers don't need to persist UDN to config since they're tied to browser tabs
"MediaRenderer", // (instance_id comes from sessionStorage, not a persistent device)
instance_id,
candidate_udn.clone(),
) {
tracing::warn!("MediaRenderer: failed to persist UDN: {:?}", e);
}
let state: SharedState = Arc::new(parking_lot::RwLock::new(RendererState::default())); let state: SharedState = Arc::new(parking_lot::RwLock::new(RendererState::default()));
let adapter = adapter_fn(state.clone()); let adapter = adapter_fn(state.clone());
@@ -275,7 +272,7 @@ impl MediaRendererRegistry {
let device = MediaRendererFactory::create_device_with_pipeline( let device = MediaRendererFactory::create_device_with_pipeline(
instance_id, instance_id,
"MediaRenderer", "MediaRenderer",
"WebRenderer", friendly_name,
pipeline.clone(), pipeline.clone(),
state.clone(), state.clone(),
stream_url_base, stream_url_base,
@@ -289,7 +286,7 @@ impl MediaRendererRegistry {
.map_err(|e| MediaRendererError::RegistrationError(e.to_string()))? .map_err(|e| MediaRendererError::RegistrationError(e.to_string()))?
}; };
self.register_with_control_point(&di, renderer_name)?; self.register_with_control_point(&di, renderer_name, &full_udn)?;
(di, ip) (di, ip)
}; };
@@ -307,7 +304,7 @@ impl MediaRendererRegistry {
let device = MediaRendererFactory::create_device_with_pipeline( let device = MediaRendererFactory::create_device_with_pipeline(
instance_id, instance_id,
"MediaRenderer", "MediaRenderer",
"WebRenderer", friendly_name,
pipeline.clone(), pipeline.clone(),
state.clone(), state.clone(),
stream_url_base, stream_url_base,
@@ -334,9 +331,11 @@ impl MediaRendererRegistry {
&self, &self,
di: &Arc<DeviceInstance>, di: &Arc<DeviceInstance>,
renderer_name: &str, renderer_name: &str,
instance_udn: &str,
) -> Result<(), MediaRendererError> { ) -> Result<(), MediaRendererError> {
let base_url = di.base_url().to_string(); let base_url = di.base_url().to_string();
let udn = di.udn().to_ascii_lowercase(); // Use the instance's UDN, not the device's stored UDN
let udn = instance_udn.trim_start_matches("uuid:").to_ascii_lowercase();
let udn_with_prefix = format!("uuid:{}", udn); let udn_with_prefix = format!("uuid:{}", udn);
let device_route = di.route(); let device_route = di.route();
let model = di.get_model(); let model = di.get_model();
@@ -359,7 +358,7 @@ impl MediaRendererRegistry {
udn_with_prefix.clone(), udn_with_prefix.clone(),
model.friendly_name().to_string(), model.friendly_name().to_string(),
model.model_name().to_string(), model.model_name().to_string(),
"PMOMusic".to_string(), "PMOMusic-WebRenderer".to_string(),
RendererProtocol::UpnpAvOnly, RendererProtocol::UpnpAvOnly,
RendererCapabilities { RendererCapabilities {
has_avtransport: true, has_avtransport: true,

View File

@@ -87,19 +87,24 @@ impl MediaRendererFactory {
pub fn create_device_with_pipeline( pub fn create_device_with_pipeline(
device_name: &str, device_name: &str,
device_type: &str, device_type: &str,
friendly_name_suffix: &str, friendly_name: &str,
pipeline: PipelineHandle, pipeline: PipelineHandle,
state: SharedState, state: SharedState,
stream_url_base: &str, stream_url_base: &str,
) -> Result<Device, FactoryError> { ) -> Result<Device, FactoryError> {
let avtransport = Self::build_avtransport(pipeline.clone(), state.clone(), device_name, stream_url_base)?; let avtransport = Self::build_avtransport(
pipeline.clone(),
state.clone(),
device_name,
stream_url_base,
)?;
let renderingcontrol = Self::build_renderingcontrol(state.clone())?; let renderingcontrol = Self::build_renderingcontrol(state.clone())?;
let connectionmanager = Self::build_connectionmanager()?; let connectionmanager = Self::build_connectionmanager()?;
let device = Device::new_from_config( let device = Device::new(
device_name.to_string(), device_name.to_string(),
device_type.to_string(), device_type.to_string(),
friendly_name_suffix.to_string(), friendly_name.to_string(),
); );
device device
.add_service(Arc::new(avtransport)) .add_service(Arc::new(avtransport))

View File

@@ -0,0 +1,15 @@
pub fn extract_browser_name(ua: &str) -> &str {
if ua.contains("Edg/") || ua.contains("EdgA/") {
"Edge"
} else if ua.contains("OPR/") || ua.contains("Opera") {
"Opera"
} else if ua.contains("Chrome/") {
"Chrome"
} else if ua.contains("Firefox/") {
"Firefox"
} else if ua.contains("Safari/") {
"Safari"
} else {
"Browser"
}
}

View File

@@ -6,6 +6,7 @@
//! - Les commandes UPnP sont relayées vers le pipeline audio via PipelineControl //! - Les commandes UPnP sont relayées vers le pipeline audio via PipelineControl
mod adapter; mod adapter;
mod helpers;
mod register; mod register;
mod stream; mod stream;
@@ -13,6 +14,7 @@ mod stream;
mod config; mod config;
pub use adapter::BrowserAdapter; pub use adapter::BrowserAdapter;
pub use helpers::extract_browser_name;
#[cfg(feature = "pmoserver")] #[cfg(feature = "pmoserver")]
pub use config::WebRendererExt; pub use config::WebRendererExt;

View File

@@ -17,6 +17,7 @@ use pmomediarenderer::PipelineControl;
use pmomediarenderer::{DeviceCommand, MediaRendererRegistry}; use pmomediarenderer::{DeviceCommand, MediaRendererRegistry};
use crate::adapter::BrowserAdapter; use crate::adapter::BrowserAdapter;
use crate::helpers::extract_browser_name;
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct PlayerStateReport { pub struct PlayerStateReport {
@@ -51,11 +52,15 @@ pub async fn register_handler(
"WebRenderer: register request" "WebRenderer: register request"
); );
let browser_name = extract_browser_name(&req.user_agent);
let friendly_name = format!("Web Audio {}", browser_name);
match registry match registry
.register_or_reconnect( .register_or_reconnect(
&req.instance_id, &req.instance_id,
"/api/webrenderer", "/api/webrenderer",
"PMOMusic WebRenderer/2.0", "PMOMusic WebRenderer/2.0",
&friendly_name,
|state| Arc::new(BrowserAdapter::new(state)), |state| Arc::new(BrowserAdapter::new(state)),
) )
.await .await

View File

@@ -1 +1 @@
0.3.35 0.3.37