push-pqqsxyupswry #21
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -3158,6 +3158,13 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmocontrol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"pmoupnp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmocovers"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -19,5 +19,5 @@ members = [
|
||||
"pmosource",
|
||||
"pmoplaylist",
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmometadata", "pmocontrol",
|
||||
]
|
||||
|
||||
7
pmocontrol/Cargo.toml
Normal file
7
pmocontrol/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "pmocontrol"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoupnp = { path = "../pmoupnp" }
|
||||
218
pmocontrol/src/discovery.rs
Normal file
218
pmocontrol/src/discovery.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use pmoupnp::ssdp::SsdpEvent;
|
||||
|
||||
use crate::model::{MediaServerInfo, RendererInfo};
|
||||
use crate::registry::DeviceUpdate;
|
||||
|
||||
/// État connu pour un endpoint UPnP identifié par son UDN.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredEndpoint {
|
||||
/// UDN normalisé (ex: "uuid:xxxx", en minuscules).
|
||||
pub udn: String,
|
||||
/// Dernière URL de description device (LOCATION SSDP).
|
||||
pub location: String,
|
||||
/// Dernier header SERVER vu sur cet endpoint.
|
||||
pub server_header: String,
|
||||
/// Dernier max-age indiqué (TTL SSDP).
|
||||
pub max_age: u32,
|
||||
/// Date de dernière vue (Now lors du dernier Alive ou SearchResponse).
|
||||
pub last_seen: SystemTime,
|
||||
/// Indique si on a vu ce endpoint comme MediaRenderer.
|
||||
pub seen_as_renderer: bool,
|
||||
/// Indique si on a vu ce endpoint comme MediaServer.
|
||||
pub seen_as_server: bool,
|
||||
/// ST/NT vus (pour debug/diagnostic si utile).
|
||||
pub types_seen: HashSet<String>,
|
||||
}
|
||||
|
||||
impl DiscoveredEndpoint {
|
||||
pub fn new(udn: String, location: String, server_header: String, max_age: u32) -> Self {
|
||||
Self {
|
||||
udn,
|
||||
location,
|
||||
server_header,
|
||||
max_age,
|
||||
last_seen: SystemTime::now(),
|
||||
seen_as_renderer: false,
|
||||
seen_as_server: false,
|
||||
types_seen: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn touch(&mut self, location: String, server_header: String, max_age: u32) {
|
||||
self.location = location;
|
||||
self.server_header = server_header;
|
||||
self.max_age = max_age;
|
||||
self.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fournit les descriptions haut niveau à partir d’un endpoint découvert.
|
||||
/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location`
|
||||
/// et parser la description pour remplir RendererInfo / MediaServerInfo.
|
||||
pub trait DeviceDescriptionProvider: Send + Sync {
|
||||
/// Construit un RendererInfo pour cet endpoint, ou None s’il
|
||||
/// ne correspond pas à un renderer audio intéressant.
|
||||
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo>;
|
||||
|
||||
/// Construit un MediaServerInfo pour cet endpoint, ou None s’il
|
||||
/// ne correspond pas à un media server (ou pas intéressant).
|
||||
fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option<MediaServerInfo>;
|
||||
}
|
||||
|
||||
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
||||
pub struct DiscoveryManager<P>
|
||||
where
|
||||
P: DeviceDescriptionProvider,
|
||||
{
|
||||
endpoints: HashMap<String, DiscoveredEndpoint>,
|
||||
provider: P,
|
||||
}
|
||||
|
||||
impl<P> DiscoveryManager<P>
|
||||
where
|
||||
P: DeviceDescriptionProvider,
|
||||
{
|
||||
pub fn new(provider: P) -> Self {
|
||||
Self {
|
||||
endpoints: HashMap::new(),
|
||||
provider,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec<DeviceUpdate> {
|
||||
let mut updates = Vec::new();
|
||||
|
||||
match event {
|
||||
SsdpEvent::Alive {
|
||||
usn,
|
||||
nt,
|
||||
location,
|
||||
server,
|
||||
max_age,
|
||||
..
|
||||
} => {
|
||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||
self.handle_alive(udn, nt, location, server, max_age, &mut updates);
|
||||
}
|
||||
}
|
||||
SsdpEvent::SearchResponse {
|
||||
usn,
|
||||
st,
|
||||
location,
|
||||
server,
|
||||
max_age,
|
||||
..
|
||||
} => {
|
||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||
self.handle_search_response(udn, st, location, server, max_age, &mut updates);
|
||||
}
|
||||
}
|
||||
SsdpEvent::ByeBye { usn, nt, .. } => {
|
||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||
self.handle_byebye(udn, nt, &mut updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates
|
||||
}
|
||||
|
||||
fn handle_alive(
|
||||
&mut self,
|
||||
udn: String,
|
||||
nt: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
max_age: u32,
|
||||
updates: &mut Vec<DeviceUpdate>,
|
||||
) {
|
||||
self.update_endpoint(udn, nt, location, server_header, max_age, updates);
|
||||
}
|
||||
|
||||
fn handle_search_response(
|
||||
&mut self,
|
||||
udn: String,
|
||||
st: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
max_age: u32,
|
||||
updates: &mut Vec<DeviceUpdate>,
|
||||
) {
|
||||
self.update_endpoint(udn, st, location, server_header, max_age, updates);
|
||||
}
|
||||
|
||||
fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec<DeviceUpdate>) {
|
||||
if let Some(endpoint) = self.endpoints.get(&udn) {
|
||||
if endpoint.seen_as_renderer {
|
||||
updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone()));
|
||||
}
|
||||
if endpoint.seen_as_server {
|
||||
updates.push(DeviceUpdate::ServerOfflineByUdn(udn));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_endpoint(
|
||||
&mut self,
|
||||
udn: String,
|
||||
device_type: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
max_age: u32,
|
||||
updates: &mut Vec<DeviceUpdate>,
|
||||
) {
|
||||
let endpoint = self
|
||||
.endpoints
|
||||
.entry(udn.clone())
|
||||
.or_insert_with({
|
||||
let udn = udn.clone();
|
||||
let location = location.clone();
|
||||
let server_header = server_header.clone();
|
||||
move || DiscoveredEndpoint::new(udn, location, server_header, max_age)
|
||||
});
|
||||
|
||||
endpoint.touch(location, server_header, max_age);
|
||||
endpoint.types_seen.insert(device_type.clone());
|
||||
|
||||
if is_renderer_type(&device_type) {
|
||||
endpoint.seen_as_renderer = true;
|
||||
if let Some(info) = self.provider.build_renderer_info(endpoint) {
|
||||
updates.push(DeviceUpdate::RendererOnline(info));
|
||||
}
|
||||
}
|
||||
|
||||
if is_server_type(&device_type) {
|
||||
endpoint.seen_as_server = true;
|
||||
if let Some(info) = self.provider.build_server_info(endpoint) {
|
||||
updates.push(DeviceUpdate::ServerOnline(info));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_udn_from_usn(usn: &str) -> Option<String> {
|
||||
let lower = usn.trim().to_ascii_lowercase();
|
||||
if let Some(idx) = lower.find("uuid:") {
|
||||
let sub = &lower[idx..];
|
||||
if let Some(end) = sub.find("::") {
|
||||
Some(sub[..end].to_string())
|
||||
} else {
|
||||
Some(sub.to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_renderer_type(t: &str) -> bool {
|
||||
let t = t.to_ascii_lowercase();
|
||||
t.contains("urn:schemas-upnp-org:device:mediarenderer:")
|
||||
}
|
||||
|
||||
fn is_server_type(t: &str) -> bool {
|
||||
let t = t.to_ascii_lowercase();
|
||||
t.contains("urn:schemas-upnp-org:device:mediaserver:")
|
||||
}
|
||||
10
pmocontrol/src/lib.rs
Normal file
10
pmocontrol/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod discovery;
|
||||
pub mod model;
|
||||
pub mod registry;
|
||||
|
||||
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
||||
pub use model::{
|
||||
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
|
||||
RendererInfo, RendererProtocol,
|
||||
};
|
||||
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||
66
pmocontrol/src/model.rs
Normal file
66
pmocontrol/src/model.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct RendererId(pub String);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct MediaServerId(pub String);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RendererProtocol {
|
||||
UpnpAvOnly,
|
||||
OpenHomeOnly,
|
||||
Hybrid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RendererCapabilities {
|
||||
pub has_avtransport: bool,
|
||||
pub has_rendering_control: bool,
|
||||
pub has_connection_manager: bool,
|
||||
|
||||
pub has_oh_playlist: bool,
|
||||
pub has_oh_volume: bool,
|
||||
pub has_oh_info: bool,
|
||||
pub has_oh_time: bool,
|
||||
pub has_oh_radio: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RendererInfo {
|
||||
pub id: RendererId,
|
||||
pub udn: String,
|
||||
pub friendly_name: String,
|
||||
pub model_name: String,
|
||||
pub manufacturer: String,
|
||||
|
||||
pub protocol: RendererProtocol,
|
||||
pub capabilities: RendererCapabilities,
|
||||
|
||||
pub location: String,
|
||||
pub server_header: String,
|
||||
pub online: bool,
|
||||
pub last_seen: std::time::SystemTime,
|
||||
pub max_age: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MediaServerCapabilities {
|
||||
pub has_content_directory: bool,
|
||||
pub has_connection_manager: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MediaServerInfo {
|
||||
pub id: MediaServerId,
|
||||
pub udn: String,
|
||||
pub friendly_name: String,
|
||||
pub model_name: String,
|
||||
pub manufacturer: String,
|
||||
|
||||
pub capabilities: MediaServerCapabilities,
|
||||
|
||||
pub location: String,
|
||||
pub server_header: String,
|
||||
pub online: bool,
|
||||
pub last_seen: std::time::SystemTime,
|
||||
pub max_age: u32,
|
||||
}
|
||||
117
pmocontrol/src/registry.rs
Normal file
117
pmocontrol/src/registry.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::model::{MediaServerId, MediaServerInfo, RendererId, RendererInfo};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum DeviceKey {
|
||||
Renderer(RendererId),
|
||||
Server(MediaServerId),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DeviceRegistry {
|
||||
renderers: HashMap<RendererId, RendererInfo>,
|
||||
servers: HashMap<MediaServerId, MediaServerInfo>,
|
||||
udn_index: HashMap<String, DeviceKey>,
|
||||
}
|
||||
|
||||
pub trait DeviceRegistryRead {
|
||||
fn list_renderers(&self) -> Vec<RendererInfo>;
|
||||
fn list_servers(&self) -> Vec<MediaServerInfo>;
|
||||
|
||||
fn get_renderer(&self, id: &RendererId) -> Option<RendererInfo>;
|
||||
fn get_server(&self, id: &MediaServerId) -> Option<MediaServerInfo>;
|
||||
}
|
||||
|
||||
impl DeviceRegistryRead for DeviceRegistry {
|
||||
fn list_renderers(&self) -> Vec<RendererInfo> {
|
||||
self.renderers.values().cloned().collect()
|
||||
}
|
||||
|
||||
fn list_servers(&self) -> Vec<MediaServerInfo> {
|
||||
self.servers.values().cloned().collect()
|
||||
}
|
||||
|
||||
fn get_renderer(&self, id: &RendererId) -> Option<RendererInfo> {
|
||||
self.renderers.get(id).cloned()
|
||||
}
|
||||
|
||||
fn get_server(&self, id: &MediaServerId) -> Option<MediaServerInfo> {
|
||||
self.servers.get(id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DeviceUpdate {
|
||||
RendererOnline(RendererInfo),
|
||||
RendererOfflineById(RendererId),
|
||||
RendererOfflineByUdn(String),
|
||||
|
||||
ServerOnline(MediaServerInfo),
|
||||
ServerOfflineById(MediaServerId),
|
||||
ServerOfflineByUdn(String),
|
||||
}
|
||||
|
||||
impl DeviceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn apply_update(&mut self, update: DeviceUpdate) {
|
||||
match update {
|
||||
DeviceUpdate::RendererOnline(info) => {
|
||||
let udn = info.udn.to_ascii_lowercase();
|
||||
let id = info.id.clone();
|
||||
let mut info = info;
|
||||
|
||||
info.online = true;
|
||||
info.last_seen = SystemTime::now();
|
||||
|
||||
self.renderers.insert(id.clone(), info);
|
||||
self.udn_index.insert(udn, DeviceKey::Renderer(id));
|
||||
}
|
||||
DeviceUpdate::RendererOfflineById(id) => {
|
||||
if let Some(info) = self.renderers.get_mut(&id) {
|
||||
info.online = false;
|
||||
info.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
DeviceUpdate::RendererOfflineByUdn(udn) => {
|
||||
let lookup = udn.to_ascii_lowercase();
|
||||
if let Some(DeviceKey::Renderer(id)) = self.udn_index.get(&lookup) {
|
||||
if let Some(info) = self.renderers.get_mut(id) {
|
||||
info.online = false;
|
||||
info.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
DeviceUpdate::ServerOnline(info) => {
|
||||
let udn = info.udn.to_ascii_lowercase();
|
||||
let id = info.id.clone();
|
||||
let mut info = info;
|
||||
|
||||
info.online = true;
|
||||
info.last_seen = SystemTime::now();
|
||||
|
||||
self.servers.insert(id.clone(), info);
|
||||
self.udn_index.insert(udn, DeviceKey::Server(id));
|
||||
}
|
||||
DeviceUpdate::ServerOfflineById(id) => {
|
||||
if let Some(info) = self.servers.get_mut(&id) {
|
||||
info.online = false;
|
||||
info.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
DeviceUpdate::ServerOfflineByUdn(udn) => {
|
||||
let lookup = udn.to_ascii_lowercase();
|
||||
if let Some(DeviceKey::Server(id)) = self.udn_index.get(&lookup) {
|
||||
if let Some(info) = self.servers.get_mut(id) {
|
||||
info.online = false;
|
||||
info.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
pmoupnp/src/ssdp/client.rs
Normal file
253
pmoupnp/src/ssdp/client.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! Client SSDP pour la découverte des devices UPnP
|
||||
|
||||
use super::{MAX_AGE, SSDP_MULTICAST_ADDR, SSDP_PORT};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::collections::HashMap;
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Événements SSDP intéressants pour un control point
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SsdpEvent {
|
||||
Alive {
|
||||
usn: String,
|
||||
nt: String,
|
||||
location: String,
|
||||
server: String,
|
||||
max_age: u32,
|
||||
from: SocketAddr,
|
||||
},
|
||||
ByeBye {
|
||||
usn: String,
|
||||
nt: String,
|
||||
from: SocketAddr,
|
||||
},
|
||||
SearchResponse {
|
||||
usn: String,
|
||||
st: String,
|
||||
location: String,
|
||||
server: String,
|
||||
max_age: u32,
|
||||
from: SocketAddr,
|
||||
},
|
||||
}
|
||||
|
||||
/// Client SSDP pour envoyer des M-SEARCH et écouter les annonces
|
||||
pub struct SsdpClient {
|
||||
socket: Arc<UdpSocket>,
|
||||
}
|
||||
|
||||
impl SsdpClient {
|
||||
/// Crée un nouveau client SSDP
|
||||
pub fn new() -> std::io::Result<Self> {
|
||||
let addr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT);
|
||||
|
||||
let socket2 = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
socket2.set_reuse_address(true)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = 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());
|
||||
}
|
||||
}
|
||||
debug!("✅ SsdpClient SO_REUSEPORT enabled (Unix)");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
debug!("✅ SsdpClient SO_REUSEADDR enabled (Windows - SO_REUSEPORT not needed)");
|
||||
}
|
||||
|
||||
let bind_addr: SocketAddr = format!("0.0.0.0:{}", SSDP_PORT).parse().unwrap();
|
||||
socket2.bind(&bind_addr.into())?;
|
||||
|
||||
let socket: UdpSocket = socket2.into();
|
||||
socket.join_multicast_v4(
|
||||
&SSDP_MULTICAST_ADDR.parse().unwrap(),
|
||||
&"0.0.0.0".parse().unwrap(),
|
||||
)?;
|
||||
socket.set_read_timeout(Some(Duration::from_secs(1)))?;
|
||||
socket.set_multicast_loop_v4(false)?;
|
||||
|
||||
info!("✅ SSDP client ready on {}", addr);
|
||||
|
||||
Ok(Self {
|
||||
socket: Arc::new(socket),
|
||||
})
|
||||
}
|
||||
|
||||
/// Envoie un M-SEARCH pour un type donné
|
||||
pub fn send_msearch(&self, st: &str, mx: u32) -> std::io::Result<()> {
|
||||
let mx = mx.max(1); // MX doit être >= 1
|
||||
let msg = format!(
|
||||
"M-SEARCH * HTTP/1.1\r\n\
|
||||
HOST: {}:{}\r\n\
|
||||
MAN: \"ssdp:discover\"\r\n\
|
||||
MX: {}\r\n\
|
||||
ST: {}\r\n\
|
||||
USER-AGENT: PMOMusic SSDP Client\r\n\
|
||||
\r\n",
|
||||
SSDP_MULTICAST_ADDR, SSDP_PORT, mx, st
|
||||
);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
||||
.parse()
|
||||
.unwrap();
|
||||
|
||||
match self.socket.send_to(msg.as_bytes(), addr) {
|
||||
Ok(_) => {
|
||||
info!("📤 M-SEARCH sent (ST={}, MX={})", st, mx);
|
||||
debug!(
|
||||
"📨 M-SEARCH payload\n<details>\n\n```\n{}\n```\n</details>\n",
|
||||
msg
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("❌ Failed to send M-SEARCH: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Boucle de réception bloquante pour traiter les événements SSDP
|
||||
pub fn run_event_loop<F>(&self, mut on_event: F) -> !
|
||||
where
|
||||
F: FnMut(SsdpEvent) + Send + 'static,
|
||||
{
|
||||
let socket = Arc::clone(&self.socket);
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((n, from)) => {
|
||||
let data = String::from_utf8_lossy(&buf[..n]);
|
||||
if let Some(event) = parse_message(&data, from) {
|
||||
debug!(
|
||||
"📥 SSDP datagram from {}\n<details>\n\n```\n{}\n```\n</details>\n",
|
||||
from, data
|
||||
);
|
||||
on_event(event);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
// Timeout, recommencer
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("❌ SSDP client read error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_message(data: &str, from: SocketAddr) -> Option<SsdpEvent> {
|
||||
let mut lines = data.lines();
|
||||
let first_line = lines.next()?.trim();
|
||||
let headers = parse_headers(lines);
|
||||
|
||||
if first_line.to_ascii_uppercase().starts_with("NOTIFY") {
|
||||
handle_notify(&headers, from)
|
||||
} else if first_line.to_ascii_uppercase().starts_with("HTTP/1.1 200") {
|
||||
handle_search_response(&headers, from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notify(headers: &HashMap<String, String>, from: SocketAddr) -> Option<SsdpEvent> {
|
||||
let nts = headers.get("NTS")?.to_ascii_lowercase();
|
||||
let nt = headers.get("NT")?.to_string();
|
||||
let usn = headers.get("USN")?.to_string();
|
||||
|
||||
if nts == "ssdp:alive" {
|
||||
let location = headers.get("LOCATION")?.to_string();
|
||||
let server = headers.get("SERVER")?.to_string();
|
||||
let max_age = parse_max_age(headers.get("CACHE-CONTROL"));
|
||||
Some(SsdpEvent::Alive {
|
||||
usn,
|
||||
nt,
|
||||
location,
|
||||
server,
|
||||
max_age,
|
||||
from,
|
||||
})
|
||||
} else if nts == "ssdp:byebye" {
|
||||
Some(SsdpEvent::ByeBye { usn, nt, from })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_search_response(
|
||||
headers: &HashMap<String, String>,
|
||||
from: SocketAddr,
|
||||
) -> Option<SsdpEvent> {
|
||||
let st = headers.get("ST")?.to_string();
|
||||
let usn = headers.get("USN")?.to_string();
|
||||
let location = headers.get("LOCATION")?.to_string();
|
||||
let server = headers.get("SERVER")?.to_string();
|
||||
let max_age = parse_max_age(headers.get("CACHE-CONTROL"));
|
||||
|
||||
Some(SsdpEvent::SearchResponse {
|
||||
usn,
|
||||
st,
|
||||
location,
|
||||
server,
|
||||
max_age,
|
||||
from,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_headers<'a, I>(lines: I) -> HashMap<String, String>
|
||||
where
|
||||
I: Iterator<Item = &'a str>,
|
||||
{
|
||||
let mut headers = HashMap::new();
|
||||
for line in lines {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = line.split_once(':') {
|
||||
headers.insert(name.trim().to_ascii_uppercase(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn parse_max_age(value: Option<&String>) -> u32 {
|
||||
if let Some(v) = value {
|
||||
for part in v.split(',') {
|
||||
let part = part.trim();
|
||||
if let Some(rest) = part.strip_prefix("max-age=") {
|
||||
if let Ok(age) = rest.trim().parse::<u32>() {
|
||||
return age;
|
||||
}
|
||||
} else {
|
||||
let lower = part.to_ascii_lowercase();
|
||||
if let Some(idx) = lower.find("max-age=") {
|
||||
let raw = &part[idx + 8..];
|
||||
if let Ok(age) = raw.trim().parse::<u32>() {
|
||||
return age;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MAX_AGE
|
||||
}
|
||||
@@ -22,9 +22,11 @@
|
||||
//! - **Max-Age**: 1800 secondes (30 minutes)
|
||||
//! - **Announcement Period**: 900 secondes (15 minutes, Max-Age/2)
|
||||
|
||||
mod client;
|
||||
mod device;
|
||||
mod server;
|
||||
|
||||
pub use client::{SsdpClient, SsdpEvent};
|
||||
pub use device::SsdpDevice;
|
||||
pub use server::SsdpServer;
|
||||
|
||||
|
||||
@@ -290,7 +290,7 @@ impl UpnpServerExt for Server {
|
||||
limit: usize,
|
||||
) -> Result<Arc<CoverCache>, anyhow::Error> {
|
||||
// Délègue à l'implémentation pmocovers (qui enregistre WebP + JPEG + API)
|
||||
use pmocovers::CoverCacheExt;
|
||||
|
||||
let cache = pmocovers::CoverCacheExt::init_cover_cache(self, cache_dir, limit).await?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user