use crate::error::{VideoIpError, VideoIpResult};
use crate::types::{AudioFormat, VideoFormat};
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent, ServiceInfo};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use tokio::time::timeout;
pub const SERVICE_TYPE: &str = "_oximedia-videoip._udp.local.";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceInfo {
pub name: String,
pub address: IpAddr,
pub port: u16,
pub video_format: VideoFormat,
pub audio_format: AudioFormat,
pub metadata: HashMap<String, String>,
}
impl SourceInfo {
#[must_use]
pub const fn socket_addr(&self) -> SocketAddr {
SocketAddr::new(self.address, self.port)
}
}
pub struct DiscoveryClient {
daemon: ServiceDaemon,
}
impl DiscoveryClient {
pub fn new() -> VideoIpResult<Self> {
let daemon = ServiceDaemon::new()
.map_err(|e| VideoIpError::Discovery(format!("failed to create daemon: {e}")))?;
Ok(Self { daemon })
}
pub async fn discover_all(&self, timeout_secs: u64) -> VideoIpResult<Vec<SourceInfo>> {
let receiver = self
.daemon
.browse(SERVICE_TYPE)
.map_err(|e| VideoIpError::Discovery(format!("failed to browse: {e}")))?;
let mut sources = Vec::new();
let deadline = Duration::from_secs(timeout_secs);
let result = timeout(deadline, async {
while let Ok(event) = receiver.recv_async().await {
match event {
ServiceEvent::ServiceResolved(info) => {
if let Ok(source_info) = Self::parse_service_info(&info) {
sources.push(source_info);
}
}
ServiceEvent::SearchStopped(_) => break,
_ => {}
}
}
})
.await;
let _ = result;
Ok(sources)
}
pub async fn discover_by_name(
&self,
name: &str,
timeout_secs: u64,
) -> VideoIpResult<SourceInfo> {
let sources = self.discover_all(timeout_secs).await?;
sources
.into_iter()
.find(|s| s.name == name)
.ok_or_else(|| VideoIpError::ServiceNotFound(name.to_string()))
}
fn parse_service_info(info: &ResolvedService) -> VideoIpResult<SourceInfo> {
let name = info.get_fullname().trim_end_matches('.').to_string();
let addresses = info.get_addresses();
let port = info.get_port();
let scoped_ip = addresses
.iter()
.next()
.ok_or_else(|| VideoIpError::Discovery("no address found".to_string()))?;
let properties = info.get_properties();
let _codec = properties
.get("codec")
.map_or_else(|| "vp9".to_string(), |v| v.val_str().to_string());
let width: u32 = properties
.get("width")
.and_then(|v| v.val_str().parse::<u32>().ok())
.unwrap_or(1920);
let height: u32 = properties
.get("height")
.and_then(|v| v.val_str().parse::<u32>().ok())
.unwrap_or(1080);
let fps: f64 = properties
.get("fps")
.and_then(|v| v.val_str().parse::<f64>().ok())
.unwrap_or(30.0);
let video_format = VideoFormat::new(
crate::types::VideoCodec::Vp9,
crate::types::Resolution::new(width, height),
crate::types::FrameRate::from_float(fps)?,
);
let audio_format = AudioFormat::new(crate::types::AudioCodec::Opus, 48000, 2)?;
let mut metadata = HashMap::new();
for prop in properties.iter() {
let key = prop.key();
let val_str = prop.val_str();
metadata.insert(key.to_string(), val_str.to_string());
}
Ok(SourceInfo {
name,
address: scoped_ip.to_ip_addr(),
port,
video_format,
audio_format,
metadata,
})
}
}
pub struct DiscoveryServer {
daemon: ServiceDaemon,
service_info: Option<ServiceInfo>,
}
impl DiscoveryServer {
pub fn new() -> VideoIpResult<Self> {
let daemon = ServiceDaemon::new()
.map_err(|e| VideoIpError::Discovery(format!("failed to create daemon: {e}")))?;
Ok(Self {
daemon,
service_info: None,
})
}
pub fn announce(
&mut self,
name: &str,
port: u16,
video_format: &VideoFormat,
audio_format: &AudioFormat,
) -> VideoIpResult<()> {
let mut properties = HashMap::new();
properties.insert(
"codec".to_string(),
format!("{:?}", video_format.codec).to_lowercase(),
);
properties.insert(
"width".to_string(),
video_format.resolution.width.to_string(),
);
properties.insert(
"height".to_string(),
video_format.resolution.height.to_string(),
);
properties.insert(
"fps".to_string(),
video_format.frame_rate.to_float().to_string(),
);
properties.insert(
"audio_codec".to_string(),
format!("{:?}", audio_format.codec),
);
properties.insert(
"sample_rate".to_string(),
audio_format.sample_rate.to_string(),
);
properties.insert("channels".to_string(), audio_format.channels.to_string());
let service_info = ServiceInfo::new(SERVICE_TYPE, name, name, "", port, properties)
.map_err(|e| VideoIpError::Discovery(format!("failed to create service info: {e}")))?;
self.daemon
.register(service_info.clone())
.map_err(|e| VideoIpError::Discovery(format!("failed to register service: {e}")))?;
self.service_info = Some(service_info);
Ok(())
}
pub fn stop_announce(&mut self) -> VideoIpResult<()> {
if let Some(ref info) = self.service_info {
self.daemon
.unregister(info.get_fullname())
.map_err(|e| VideoIpError::Discovery(format!("failed to unregister: {e}")))?;
self.service_info = None;
}
Ok(())
}
}
impl Drop for DiscoveryServer {
fn drop(&mut self) {
let _ = self.stop_announce();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AudioCodec, FrameRate, Resolution, VideoCodec};
#[test]
fn test_discovery_client_creation() {
let client = DiscoveryClient::new();
assert!(client.is_ok());
}
#[test]
fn test_discovery_server_creation() {
let server = DiscoveryServer::new();
assert!(server.is_ok());
}
#[test]
fn test_source_info() {
let video_format =
VideoFormat::new(VideoCodec::Vp9, Resolution::HD_1080, FrameRate::FPS_60);
let audio_format =
AudioFormat::new(AudioCodec::Opus, 48000, 2).expect("should succeed in test");
let source = SourceInfo {
name: "Test Source".to_string(),
address: "192.168.1.100".parse().expect("should succeed in test"),
port: 5000,
video_format,
audio_format,
metadata: HashMap::new(),
};
let addr = source.socket_addr();
assert_eq!(addr.port(), 5000);
}
#[tokio::test]
async fn test_announce_and_discover() {
let mut server = DiscoveryServer::new().expect("should succeed in test");
let video_format =
VideoFormat::new(VideoCodec::Vp9, Resolution::HD_1080, FrameRate::FPS_30);
let audio_format =
AudioFormat::new(AudioCodec::Opus, 48000, 2).expect("should succeed in test");
server
.announce("testcamera.local.", 5000, &video_format, &audio_format)
.expect("should succeed in test");
tokio::time::sleep(Duration::from_secs(1)).await;
let client = DiscoveryClient::new().expect("should succeed in test");
let sources = client
.discover_all(2)
.await
.expect("should succeed in test");
if !sources.is_empty() {
assert!(sources.iter().any(|s| s.name.contains("testcamera")));
}
server.stop_announce().expect("should succeed in test");
}
}