push-mtksvvvquolu #74
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