Skip to main content

koan_core/upnp/
description.rs

1//! A renderer's device description and the service descriptions it links to.
2
3use 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/// A MediaRenderer, as its description describes it.
12#[derive(Debug, Clone, PartialEq)]
13pub struct Renderer {
14    /// `uuid:…`, the one identity a device keeps across addresses and reboots.
15    pub udn: String,
16    pub name: String,
17    pub manufacturer: String,
18    pub model: String,
19    /// Where the description was fetched from.
20    pub location: Url,
21    pub av_transport: Service,
22    pub rendering_control: Option<Service>,
23    pub connection_manager: Option<Service>,
24    /// The AVTransport SCPD lists `SetNextAVTransportURI`, so the renderer can
25    /// be handed the next track before this one ends.
26    pub gapless: bool,
27    /// Advertises OpenHome services, which hold a playlist on the renderer.
28    /// Recorded for later; koan drives AVTransport either way.
29    pub openhome: bool,
30}
31
32impl Renderer {
33    /// The name it is known by as an output device, for what is attached to
34    /// one by name, such as a DSP profile. The UDN: a friendly name can be
35    /// changed on the device, and two can be the same.
36    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
49/// Read a device description fetched from `location`. `None` when it
50/// describes no device with an AVTransport service.
51pub 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        // A `:2` renderer still answers `:1` requests; match on the name.
64        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
108/// The device carrying AVTransport: the root itself, or one embedded in it.
109fn 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
130/// The action names a service description lists.
131pub 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}