Implémentation d'un MediaRenderer UPnP privé par navigateur avec token
Ajout de la documentation et de l'architecture pour le MediaRenderer UPnP privé par navigateur avec token, ainsi que les améliorations du client et serveur SSDP pour gérer les réseaux bloquant le multicast.
This commit is contained in:
139
Blackboard/ToThinkAbout/webrenderer.md
Normal file
139
Blackboard/ToThinkAbout/webrenderer.md
Normal file
@@ -0,0 +1,139 @@
|
||||
Parfait. Voici un **schéma fonctionnel minimal** pour un **MediaRenderer UPnP privé par navigateur** avec **token**. L’idée est de rester fidèle à ton backend Rust existant et à la webapp Vue.js. En s'appuyant sur l'architecture de PMOMusic, j'aimerais que tu proposes un plan détaillé pour implémenter un tel système de Média Renderer.
|
||||
|
||||
- L'application web se trouve dans: [@webapp](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoapp/webapp)
|
||||
- Tu as un prototype de Média Renderer dans: [@pmomediarenderer](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmomediarenderer)
|
||||
- Le contrôle point est dans : [@pmocontrol](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol)
|
||||
- Tu implémenteras ce nouveau système de Média Renderer dans la CRATe pmowebrenderer
|
||||
|
||||
Tu mettras une version du plan en Markdown dans le répertoire Architecture.
|
||||
|
||||
---
|
||||
|
||||
## 1. Flow général
|
||||
|
||||
```
|
||||
Browser (Vue.js Control Point)
|
||||
┌───────────────┐
|
||||
│ UI / audio │
|
||||
│ WebSocket │
|
||||
└───────▲───────┘
|
||||
│ token
|
||||
│
|
||||
▼
|
||||
Rust backend (UPnP MediaRenderer)
|
||||
┌───────────────────────────┐
|
||||
│ Token → Renderer mapping │
|
||||
│ Device XML / SOAP endpoints│
|
||||
│ Play/Pause/Stop → WS → Browser │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Étapes détaillées
|
||||
|
||||
### a) Création du renderer
|
||||
|
||||
1. Le navigateur se connecte via WebSocket ou HTTP.
|
||||
2. Rust génère un token unique pour ce client :
|
||||
|
||||
```rust
|
||||
use uuid::Uuid;
|
||||
let token = Uuid::new_v4().to_string();
|
||||
```
|
||||
3. Rust crée une instance MediaRenderer **privée**, associée à ce token :
|
||||
|
||||
* Device description XML : `/renderer/<token>/desc.xml`
|
||||
* AVTransport SOAP : `/renderer/<token>/avtransport`
|
||||
* RenderingControl SOAP : `/renderer/<token>/renderingcontrol`
|
||||
|
||||
---
|
||||
|
||||
### b) Control Point
|
||||
|
||||
* La webapp Vue.js reçoit le token et la “déclare” au Control Point :
|
||||
|
||||
```js
|
||||
const renderer = {
|
||||
token: "abcd-1234-efgh",
|
||||
name: "Browser Renderer"
|
||||
};
|
||||
|
||||
// Ajout au control point local
|
||||
controlPoint.addRenderer(renderer);
|
||||
```
|
||||
|
||||
* Toutes les commandes Play/Pause/Stop incluent ce token :
|
||||
|
||||
```js
|
||||
ws.send(JSON.stringify({
|
||||
token: renderer.token,
|
||||
action: "play",
|
||||
uri: "http://localhost:8080/media.mp3"
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### c) Backend Rust : dispatcher les commandes
|
||||
|
||||
* Rust reçoit le JSON avec le token.
|
||||
* Vérifie que le token correspond à un renderer actif.
|
||||
* Transmet la commande au navigateur via WebSocket (ou HTTP push) :
|
||||
|
||||
```rust
|
||||
match msg.action.as_str() {
|
||||
"play" => send_ws_to_browser(&token, format!("play:{}", msg.uri)),
|
||||
"pause" => send_ws_to_browser(&token, "pause".to_string()),
|
||||
"stop" => send_ws_to_browser(&token, "stop".to_string()),
|
||||
_ => (),
|
||||
}
|
||||
```
|
||||
|
||||
* Rust met à jour l’état du renderer (AVTransport/RenderingControl) pour le Control Point.
|
||||
|
||||
---
|
||||
|
||||
### d) Lecture côté navigateur
|
||||
|
||||
* Le navigateur reçoit la commande via WebSocket et pilote `<audio>` :
|
||||
|
||||
```js
|
||||
ws.onmessage = (evt) => {
|
||||
const msg = evt.data;
|
||||
if(msg.startsWith("play:")) {
|
||||
audio.src = msg.split(":")[1];
|
||||
audio.play();
|
||||
} else if(msg === "pause") {
|
||||
audio.pause();
|
||||
} else if(msg === "stop") {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### e) Fermeture / cleanup
|
||||
|
||||
* Quand le navigateur se déconnecte :
|
||||
|
||||
* Rust supprime le renderer associé au token
|
||||
* Émet un **byebye virtuel** pour le Control Point (si nécessaire)
|
||||
* Libère toutes les ressources
|
||||
|
||||
---
|
||||
|
||||
## 3. Points clés
|
||||
|
||||
1. **Token unique** = session privée + sécurité
|
||||
2. **Pas besoin de SSDP / annonce** : le renderer est dédié à un navigateur connu
|
||||
3. **Control Point Vue.js** sait exactement quel renderer utiliser
|
||||
4. **Rust backend** reste seul responsable de l’implémentation UPnP
|
||||
5. **Lecture réelle** = navigateur via `<audio>` ou `<video>`
|
||||
|
||||
---
|
||||
|
||||
💡 Ce modèle est très proche de ce que font **Mopidy avec Iris**, **Kodi Remote**, ou **Chromecast / local cast** : le device est connu et dédié, pas besoin de découverte réseau.
|
||||
|
||||
@@ -57,6 +57,7 @@ pub enum SsdpEvent {
|
||||
#[derive(Clone)]
|
||||
pub struct SsdpClient {
|
||||
socket: Arc<UdpSocket>,
|
||||
loopback_socket: Arc<UdpSocket>,
|
||||
}
|
||||
|
||||
impl SsdpClient {
|
||||
@@ -81,26 +82,57 @@ impl SsdpClient {
|
||||
|
||||
for iface in get_if_addrs::get_if_addrs()? {
|
||||
if let std::net::IpAddr::V4(ipv4) = iface.ip() {
|
||||
if !ipv4.is_loopback() {
|
||||
match socket.join_multicast_v4(&SSDP_MULTICAST_ADDR.parse().unwrap(), &ipv4) {
|
||||
Ok(()) => {
|
||||
// Join multicast on ALL interfaces, including loopback for local development
|
||||
match socket.join_multicast_v4(&SSDP_MULTICAST_ADDR.parse().unwrap(), &ipv4) {
|
||||
Ok(()) => {
|
||||
if ipv4.is_loopback() {
|
||||
debug!(
|
||||
"SSDP: joined {} on {} (localhost - dev mode)",
|
||||
SSDP_MULTICAST_ADDR, ipv4
|
||||
);
|
||||
} else {
|
||||
debug!("SSDP: joined {} on {}", SSDP_MULTICAST_ADDR, ipv4);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"SSDP: failed to join {} on {}: {}",
|
||||
SSDP_MULTICAST_ADDR, ipv4, e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"SSDP: failed to join {} on {}: {}",
|
||||
SSDP_MULTICAST_ADDR, ipv4, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a second socket for loopback multicast (when network blocks multicast)
|
||||
let loopback_socket2 = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
loopback_socket2.set_reuse_address(true)?;
|
||||
|
||||
// Bind on any address with ephemeral port (like the main socket)
|
||||
let loopback_bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
loopback_socket2.bind(&loopback_bind.into())?;
|
||||
|
||||
let loopback_socket: UdpSocket = loopback_socket2.into();
|
||||
loopback_socket.set_multicast_loop_v4(true)?;
|
||||
|
||||
// Join multicast specifically on loopback interface (127.0.0.1)
|
||||
let loopback_addr: std::net::Ipv4Addr = "127.0.0.1".parse().unwrap();
|
||||
if let Err(e) =
|
||||
loopback_socket.join_multicast_v4(&SSDP_MULTICAST_ADDR.parse().unwrap(), &loopback_addr)
|
||||
{
|
||||
warn!("Failed to join multicast on loopback socket: {}", e);
|
||||
} else {
|
||||
debug!(
|
||||
"SSDP loopback socket: joined {} on 127.0.0.1",
|
||||
SSDP_MULTICAST_ADDR
|
||||
);
|
||||
}
|
||||
|
||||
info!("✅ SSDP client ready on {}", addr);
|
||||
|
||||
Ok(Self {
|
||||
socket: Arc::new(socket),
|
||||
loopback_socket: Arc::new(loopback_socket),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,19 +150,50 @@ impl SsdpClient {
|
||||
SSDP_MULTICAST_ADDR, SSDP_PORT, mx, st
|
||||
);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
||||
let multicast_addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
||||
.parse()
|
||||
.unwrap();
|
||||
|
||||
match self.socket.send_to(msg.as_bytes(), addr) {
|
||||
// Try multicast first
|
||||
match self.socket.send_to(msg.as_bytes(), multicast_addr) {
|
||||
Ok(_) => {
|
||||
info!("📤 M-SEARCH sent (ST={}, MX={})", st, mx);
|
||||
info!("📤 M-SEARCH sent to multicast (ST={}, MX={})", st, mx);
|
||||
debug!(
|
||||
"📨 M-SEARCH payload\n<details>\n\n```\n{}\n```\n</details>\n",
|
||||
msg
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(65) || e.raw_os_error() == Some(101) => {
|
||||
// Error 65 (EHOSTUNREACH) on macOS, 101 (ENETUNREACH) on Linux
|
||||
// Network blocks multicast (e.g., eduroam) → send to localhost unicast
|
||||
warn!(
|
||||
"⚠️ Multicast blocked on network ({}), sending to localhost for local devices",
|
||||
e
|
||||
);
|
||||
|
||||
// Send to localhost unicast (not multicast) - servers on 127.0.0.1:1900 will receive
|
||||
let localhost_addr: SocketAddr =
|
||||
format!("127.0.0.1:{}", SSDP_PORT).parse().unwrap();
|
||||
|
||||
match self.loopback_socket.send_to(msg.as_bytes(), localhost_addr) {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"📤 M-SEARCH sent to localhost unicast (ST={}, MX={})",
|
||||
st, mx
|
||||
);
|
||||
debug!(
|
||||
"📨 M-SEARCH localhost payload\n<details>\n\n```\n{}\n```\n</details>\n",
|
||||
msg
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(loopback_err) => {
|
||||
warn!("❌ Failed to send M-SEARCH to localhost: {}", loopback_err);
|
||||
Err(loopback_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("❌ Failed to send M-SEARCH: {}", e);
|
||||
Err(e)
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct SsdpServer {
|
||||
|
||||
/// Socket UDP pour SSDP
|
||||
socket: Option<Arc<UdpSocket>>,
|
||||
|
||||
/// Socket dédié pour loopback (quand le réseau bloque le multicast)
|
||||
loopback_socket: Option<Arc<UdpSocket>>,
|
||||
}
|
||||
|
||||
impl SsdpServer {
|
||||
@@ -23,6 +26,7 @@ impl SsdpServer {
|
||||
Self {
|
||||
devices: Arc::new(RwLock::new(HashMap::new())),
|
||||
socket: None,
|
||||
loopback_socket: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,22 +81,90 @@ impl SsdpServer {
|
||||
// Convertir en UdpSocket standard
|
||||
let socket: UdpSocket = socket2.into();
|
||||
|
||||
// Rejoindre le groupe multicast
|
||||
socket.join_multicast_v4(
|
||||
&SSDP_MULTICAST_ADDR.parse().unwrap(),
|
||||
&"0.0.0.0".parse().unwrap(),
|
||||
)?;
|
||||
// Rejoindre le groupe multicast sur toutes les interfaces (y compris loopback)
|
||||
// Ceci est essentiel pour le développement local avec plusieurs instances
|
||||
for iface in get_if_addrs::get_if_addrs()? {
|
||||
if let std::net::IpAddr::V4(ipv4) = iface.ip() {
|
||||
match socket.join_multicast_v4(&SSDP_MULTICAST_ADDR.parse().unwrap(), &ipv4) {
|
||||
Ok(()) => {
|
||||
if ipv4.is_loopback() {
|
||||
debug!(
|
||||
"SSDP server: joined {} on {} (localhost - dev mode)",
|
||||
SSDP_MULTICAST_ADDR, ipv4
|
||||
);
|
||||
} else {
|
||||
debug!("SSDP server: joined {} on {}", SSDP_MULTICAST_ADDR, ipv4);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"SSDP server: failed to join {} on {}: {}",
|
||||
SSDP_MULTICAST_ADDR, ipv4, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket.set_read_timeout(Some(Duration::from_secs(1)))?;
|
||||
socket.set_multicast_loop_v4(false)?;
|
||||
socket.set_multicast_loop_v4(true)?; // Important pour dev local
|
||||
|
||||
let socket = Arc::new(socket);
|
||||
self.socket = Some(socket.clone());
|
||||
|
||||
// Create a second socket for loopback multicast (when network blocks multicast)
|
||||
let loopback_socket2 = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
loopback_socket2.set_reuse_address(true)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = loopback_socket2.as_raw_fd();
|
||||
let optval: libc::c_int = 1;
|
||||
unsafe {
|
||||
let result = libc::setsockopt(
|
||||
fd,
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_REUSEPORT,
|
||||
&optval as *const _ as *const libc::c_void,
|
||||
std::mem::size_of_val(&optval) as libc::socklen_t,
|
||||
);
|
||||
if result != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind on any address with ephemeral port (not on port 1900 to avoid conflicts)
|
||||
let loopback_bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
loopback_socket2.bind(&loopback_bind.into())?;
|
||||
|
||||
let loopback_socket: UdpSocket = loopback_socket2.into();
|
||||
loopback_socket.set_multicast_loop_v4(true)?;
|
||||
|
||||
// Join multicast specifically on loopback interface (127.0.0.1)
|
||||
let loopback_addr: std::net::Ipv4Addr = "127.0.0.1".parse().unwrap();
|
||||
if let Err(e) =
|
||||
loopback_socket.join_multicast_v4(&SSDP_MULTICAST_ADDR.parse().unwrap(), &loopback_addr)
|
||||
{
|
||||
warn!(
|
||||
"SSDP server: failed to join multicast on loopback socket: {}",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"SSDP loopback server socket: joined {} on 127.0.0.1",
|
||||
SSDP_MULTICAST_ADDR
|
||||
);
|
||||
}
|
||||
|
||||
let loopback_socket = Arc::new(loopback_socket);
|
||||
self.loopback_socket = Some(loopback_socket.clone());
|
||||
|
||||
info!("✅ SSDP server started on {}", addr);
|
||||
|
||||
// Lancer les goroutines d'annonces périodiques et d'écoute M-SEARCH
|
||||
self.start_periodic_announcements(socket.clone());
|
||||
self.start_periodic_announcements(socket.clone(), loopback_socket.clone());
|
||||
self.start_msearch_listener(socket.clone());
|
||||
|
||||
Ok(())
|
||||
@@ -118,9 +190,10 @@ impl SsdpServer {
|
||||
|
||||
// Envoyer alive pour tous les NTs
|
||||
if let Some(ref socket) = self.socket {
|
||||
let loopback_socket = self.loopback_socket.as_ref();
|
||||
let nts = device.get_notification_types();
|
||||
for nt in nts.iter() {
|
||||
Self::send_alive(socket, &device, nt, false);
|
||||
Self::send_alive(socket, loopback_socket, &device, nt, false);
|
||||
// Petit délai pour éviter de saturer le buffer UDP sur macOS
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
@@ -149,7 +222,13 @@ impl SsdpServer {
|
||||
}
|
||||
|
||||
/// Envoie un NOTIFY alive
|
||||
fn send_alive(socket: &UdpSocket, device: &SsdpDevice, nt: &str, is_periodic: bool) {
|
||||
fn send_alive(
|
||||
socket: &UdpSocket,
|
||||
loopback_socket: Option<&Arc<UdpSocket>>,
|
||||
device: &SsdpDevice,
|
||||
nt: &str,
|
||||
is_periodic: bool,
|
||||
) {
|
||||
let usn = if nt.starts_with("uuid:") {
|
||||
format!("{}", nt)
|
||||
} else {
|
||||
@@ -169,11 +248,11 @@ impl SsdpServer {
|
||||
SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn
|
||||
);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
||||
let multicast_addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
||||
.parse()
|
||||
.unwrap();
|
||||
|
||||
match socket.send_to(msg.as_bytes(), addr) {
|
||||
match socket.send_to(msg.as_bytes(), multicast_addr) {
|
||||
Ok(_) => {
|
||||
let label = if is_periodic { " (periodic)" } else { "" };
|
||||
info!("✅ NOTIFY alive{}: {} (NT={})", label, usn, nt);
|
||||
@@ -182,7 +261,34 @@ impl SsdpServer {
|
||||
label, msg
|
||||
);
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(65) || e.raw_os_error() == Some(101) => {
|
||||
// Multicast bloqué sur le réseau, envoyer en unicast sur localhost
|
||||
if let Some(loopback_sock) = loopback_socket {
|
||||
// Send to localhost unicast - local clients will receive
|
||||
let localhost_addr: SocketAddr =
|
||||
format!("127.0.0.1:{}", SSDP_PORT).parse().unwrap();
|
||||
|
||||
match loopback_sock.send_to(msg.as_bytes(), localhost_addr) {
|
||||
Ok(_) => {
|
||||
let label = if is_periodic { " (periodic)" } else { "" };
|
||||
info!("✅ NOTIFY alive{} to localhost: {} (NT={})", label, usn, nt);
|
||||
}
|
||||
Err(loopback_err) => {
|
||||
let label = if is_periodic { "periodic " } else { "" };
|
||||
warn!(
|
||||
"❌ Failed to send {}NOTIFY alive to localhost for {}: {}",
|
||||
label, usn, loopback_err
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let label = if is_periodic { "periodic " } else { "" };
|
||||
warn!(
|
||||
"❌ Failed to send {}NOTIFY alive for {} (no loopback socket available): {}",
|
||||
label, usn, e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let label = if is_periodic { "periodic " } else { "" };
|
||||
warn!("❌ Failed to send {}NOTIFY alive for {}: {}", label, usn, e);
|
||||
@@ -225,7 +331,11 @@ impl SsdpServer {
|
||||
}
|
||||
|
||||
/// Démarre les annonces périodiques (toutes les MAX_AGE/2 secondes)
|
||||
fn start_periodic_announcements(&self, socket: Arc<UdpSocket>) {
|
||||
fn start_periodic_announcements(
|
||||
&self,
|
||||
socket: Arc<UdpSocket>,
|
||||
loopback_socket: Arc<UdpSocket>,
|
||||
) {
|
||||
let devices = Arc::clone(&self.devices);
|
||||
let period = Duration::from_secs((MAX_AGE / 2) as u64);
|
||||
|
||||
@@ -241,7 +351,7 @@ impl SsdpServer {
|
||||
};
|
||||
for device in &devices_snapshot {
|
||||
for nt in device.get_notification_types() {
|
||||
Self::send_alive(&socket, device, nt, true);
|
||||
Self::send_alive(&socket, Some(&loopback_socket), device, nt, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user