koan_core/upnp/
description.rs1use url::Url;
4
5use super::xml::{self, Element};
6
7pub const AV_TRANSPORT: &str = "urn:schemas-upnp-org:service:AVTransport:1";
8pub const RENDERING_CONTROL: &str = "urn:schemas-upnp-org:service:RenderingControl:1";
9pub const CONNECTION_MANAGER: &str = "urn:schemas-upnp-org:service:ConnectionManager:1";
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct Renderer {
14 pub udn: String,
16 pub name: String,
17 pub manufacturer: String,
18 pub model: String,
19 pub location: Url,
21 pub av_transport: Service,
22 pub rendering_control: Option<Service>,
23 pub connection_manager: Option<Service>,
24 pub gapless: bool,
27 pub openhome: bool,
30}
31
32impl Renderer {
33 pub fn device_name(&self) -> &str {
37 &self.udn
38 }
39}
40
41#[derive(Debug, Clone, PartialEq)]
42pub struct Service {
43 pub service_type: String,
44 pub control: Url,
45 pub events: Url,
46 pub scpd: Url,
47}
48
49pub fn parse_device(doc: &str, location: &Url) -> Result<Option<Renderer>, String> {
52 let root = xml::parse(doc)?;
53 let base = root
54 .child_text("URLBase")
55 .filter(|b| !b.is_empty())
56 .and_then(|b| Url::parse(b).ok())
57 .unwrap_or_else(|| location.clone());
58 let openhome = mentions_openhome(&root);
59 let Some(device) = root.child("device").and_then(renderer_device) else {
60 return Ok(None);
61 };
62 let service = |kind: &str| -> Option<Service> {
63 let prefix = kind.trim_end_matches(":1");
65 device
66 .path(&["serviceList"])?
67 .children_named("service")
68 .find(|s| {
69 s.child_text("serviceType")
70 .is_some_and(|t| t.starts_with(prefix))
71 })
72 .and_then(|s| {
73 Some(Service {
74 service_type: s.child_text("serviceType")?.to_string(),
75 control: base.join(s.child_text("controlURL")?).ok()?,
76 events: base.join(s.child_text("eventSubURL")?).ok()?,
77 scpd: base.join(s.child_text("SCPDURL")?).ok()?,
78 })
79 })
80 };
81 let Some(av_transport) = service(AV_TRANSPORT) else {
82 return Ok(None);
83 };
84 Ok(Some(Renderer {
85 udn: device.child_text("UDN").unwrap_or_default().to_string(),
86 name: device
87 .child_text("friendlyName")
88 .filter(|n| !n.is_empty())
89 .unwrap_or("Renderer")
90 .to_string(),
91 manufacturer: device
92 .child_text("manufacturer")
93 .unwrap_or_default()
94 .to_string(),
95 model: device
96 .child_text("modelName")
97 .unwrap_or_default()
98 .to_string(),
99 location: location.clone(),
100 av_transport,
101 rendering_control: service(RENDERING_CONTROL),
102 connection_manager: service(CONNECTION_MANAGER),
103 gapless: false,
104 openhome,
105 }))
106}
107
108fn renderer_device(device: &Element) -> Option<&Element> {
110 let has_transport = device.path(&["serviceList"]).is_some_and(|list| {
111 list.children_named("service").any(|s| {
112 s.child_text("serviceType")
113 .is_some_and(|t| t.contains(":service:AVTransport:"))
114 })
115 });
116 if has_transport {
117 return Some(device);
118 }
119 device
120 .path(&["deviceList"])?
121 .children_named("device")
122 .find_map(renderer_device)
123}
124
125fn mentions_openhome(el: &Element) -> bool {
126 (el.name == "serviceType" && el.text.contains("av-openhome-org"))
127 || el.children.iter().any(mentions_openhome)
128}
129
130pub fn parse_actions(doc: &str) -> Result<Vec<String>, String> {
132 let root = xml::parse(doc)?;
133 Ok(root
134 .child("actionList")
135 .map(|list| {
136 list.children_named("action")
137 .filter_map(|a| a.child_text("name").map(str::to_string))
138 .collect()
139 })
140 .unwrap_or_default())
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 const KODI: &str = include_str!("fixtures/kodi-device.xml");
148 const EMBEDDED: &str = include_str!("fixtures/embedded-device.xml");
149 const AVT_SCPD: &str = include_str!("fixtures/avtransport-scpd.xml");
150
151 #[test]
152 fn a_root_renderer_resolves_its_urls_against_the_location() {
153 let location = Url::parse("http://192.168.1.20:1597/").unwrap();
154 let r = parse_device(KODI, &location).unwrap().unwrap();
155 assert_eq!(r.name, "Kodi (mac)");
156 assert_eq!(r.udn, "uuid:bc9d8c87-6b5c-4b62-b4c5-4b7d2c3a6f1e");
157 assert_eq!(
158 r.av_transport.control.as_str(),
159 "http://192.168.1.20:1597/AVTransport/bc9d8c87/control.xml"
160 );
161 assert!(r.rendering_control.is_some());
162 assert!(r.connection_manager.is_some());
163 assert!(!r.openhome);
164 }
165
166 #[test]
167 fn an_embedded_renderer_is_found_and_url_base_wins() {
168 let location = Url::parse("http://10.0.0.5:49152/description.xml").unwrap();
169 let r = parse_device(EMBEDDED, &location).unwrap().unwrap();
170 assert_eq!(r.name, "Living Room Amp");
171 assert_eq!(
172 r.av_transport.control.as_str(),
173 "http://10.0.0.5:8080/upnp/control/avt"
174 );
175 assert!(r.openhome);
176 }
177
178 #[test]
179 fn a_device_without_av_transport_is_not_a_renderer() {
180 let location = Url::parse("http://10.0.0.9/").unwrap();
181 let doc = r#"<root><device><deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType><UDN>uuid:x</UDN><serviceList><service><serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType><controlURL>/c</controlURL><eventSubURL>/e</eventSubURL><SCPDURL>/s</SCPDURL></service></serviceList></device></root>"#;
182 assert!(parse_device(doc, &location).unwrap().is_none());
183 }
184
185 #[test]
186 fn scpd_actions_include_set_next() {
187 let actions = parse_actions(AVT_SCPD).unwrap();
188 assert!(actions.iter().any(|a| a == "SetNextAVTransportURI"));
189 assert!(actions.iter().any(|a| a == "GetPositionInfo"));
190 }
191}