Skip to main content

tiny_dlna/
xml.rs

1//! SOAP / UPnP XML documents sent and served by tiny-dlna.
2
3pub const AVTRANSPORT_SCPD: &str = include_str!("../xml/AVTransport.xml");
4pub const RENDERING_CONTROL_SCPD: &str = include_str!("../xml/RenderingControl.xml");
5pub const CONNECTION_MANAGER_SCPD: &str = include_str!("../xml/ConnectionManager.xml");
6
7/// Escape text for use inside an XML element or attribute.
8pub fn escape(s: &str) -> String {
9    let mut out = String::with_capacity(s.len());
10    for c in s.chars() {
11        match c {
12            '&' => out.push_str("&"),
13            '<' => out.push_str("&lt;"),
14            '>' => out.push_str("&gt;"),
15            '"' => out.push_str("&quot;"),
16            _ => out.push(c),
17        }
18    }
19    out
20}
21
22/// Resolve entity references (`&amp;`, `&#38;`, `&#x26;`, ...).
23/// Unknown entities are kept as they are.
24pub fn unescape(s: &str) -> String {
25    let mut out = String::with_capacity(s.len());
26    let mut rest = s;
27    while let Some(pos) = rest.find('&') {
28        out.push_str(&rest[..pos]);
29        rest = &rest[pos..];
30        let decoded = rest[1..].find(';').filter(|&n| n <= 10).and_then(|n| {
31            let name = &rest[1..n + 1];
32            let c = match name {
33                "amp" => Some('&'),
34                "lt" => Some('<'),
35                "gt" => Some('>'),
36                "quot" => Some('"'),
37                "apos" => Some('\''),
38                "nbsp" => Some('\u{a0}'),
39                _ => {
40                    let code =
41                        if let Some(hex) = name.strip_prefix("#x").or(name.strip_prefix("#X")) {
42                            u32::from_str_radix(hex, 16).ok()
43                        } else if let Some(dec) = name.strip_prefix('#') {
44                            dec.parse().ok()
45                        } else {
46                            None
47                        };
48                    code.and_then(char::from_u32)
49                }
50            };
51            c.map(|c| (c, n + 2))
52        });
53        match decoded {
54            Some((c, len)) => {
55                out.push(c);
56                rest = &rest[len..];
57            }
58            None => {
59                out.push('&');
60                rest = &rest[1..];
61            }
62        }
63    }
64    out.push_str(rest);
65    out
66}
67
68/// DIDL-Lite metadata describing a video (and optional subtitle).
69pub fn didl_metadata(title: &str, url_video: &str, url_srt: Option<&str>) -> String {
70    let subtitle = match url_srt {
71        Some(srt) => {
72            let srt = escape(srt);
73            format!(
74                r#"
75    <res protocolInfo="http-get:*:text/srt:*">{srt}</res>
76    <sec:CaptionInfo sec:type="srt">{srt}</sec:CaptionInfo>
77    <sec:CaptionInfoEx sec:type="srt">{srt}</sec:CaptionInfoEx>
78    <pv:subtitleFileUri>{srt}</pv:subtitleFileUri>"#
79            )
80        }
81        None => String::new(),
82    };
83    format!(
84        r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
85  xmlns:dc="http://purl.org/dc/elements/1.1/"
86  xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"
87  xmlns:sec="http://www.sec.co.kr/"
88  xmlns:pv="http://www.pv.com/pvns/">
89  <item id="0" parentID="-1" restricted="false">
90    <dc:title>{title}</dc:title>
91    <upnp:class>object.item.videoItem</upnp:class>
92    <res protocolInfo="http-get:*:video/mp4:DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01700000000000000000000000000000">{url_video}</res>{subtitle}
93  </item>
94</DIDL-Lite>"#,
95        title = escape(title),
96        url_video = escape(url_video),
97    )
98}
99
100pub fn set_av_transport_uri(url_video: &str, metadata: &str) -> String {
101    format!(
102        r#"<?xml version='1.0' encoding='utf-8'?>
103<s:Envelope
104    s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
105    xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
106  <s:Body>
107    <u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
108      <InstanceID>0</InstanceID>
109      <CurrentURI>{}</CurrentURI>
110      <CurrentURIMetaData>{}</CurrentURIMetaData>
111    </u:SetAVTransportURI>
112  </s:Body>
113</s:Envelope>
114"#,
115        escape(url_video),
116        escape(metadata),
117    )
118}
119
120pub const PLAY: &str = r#"<?xml version='1.0' encoding='utf-8'?>
121<s:Envelope
122    xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
123    s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
124  <s:Body>
125    <u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
126      <InstanceID>0</InstanceID>
127      <Speed>1</Speed>
128    </u:Play>
129  </s:Body>
130</s:Envelope>
131"#;
132
133pub const STOP: &str = r#"<?xml version='1.0' encoding='utf-8'?>
134<s:Envelope
135    xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
136    s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
137  <s:Body>
138    <u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
139      <InstanceID>0</InstanceID>
140    </u:Stop>
141  </s:Body>
142</s:Envelope>
143"#;
144
145pub const GET_TRANSPORT_INFO: &str = r#"<?xml version='1.0' encoding='utf-8'?>
146<s:Envelope
147    xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
148    s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
149  <s:Body>
150    <u:GetTransportInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
151      <InstanceID>0</InstanceID>
152    </u:GetTransportInfo>
153  </s:Body>
154</s:Envelope>
155"#;
156
157pub const GET_MEDIA_INFO: &str = r#"<?xml version='1.0' encoding='utf-8'?>
158<s:Envelope
159    xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
160    s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
161  <s:Body>
162    <u:GetMediaInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
163      <InstanceID>0</InstanceID>
164    </u:GetMediaInfo>
165  </s:Body>
166</s:Envelope>
167"#;
168
169pub fn seek(target: &str) -> String {
170    format!(
171        r#"<?xml version="1.0" encoding="utf-8"?>
172<s:Envelope
173    xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
174    s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
175  <s:Body>
176    <u:Seek xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
177      <InstanceID>0</InstanceID>
178      <Unit>REL_TIME</Unit>
179      <Target>{}</Target>
180    </u:Seek>
181  </s:Body>
182</s:Envelope>
183"#,
184        escape(target)
185    )
186}
187
188/// Device description served at `/description.xml`.
189pub fn description(friendly_name: &str, uuid: &str) -> String {
190    format!(
191        r#"<?xml version="1.0" encoding="UTF-8"?>
192<root xmlns:dlna="urn:schemas-dlna-org:device-1-0" xmlns="urn:schemas-upnp-org:device-1-0">
193  <specVersion>
194    <major>1</major>
195    <minor>0</minor>
196  </specVersion>
197  <device>
198    <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
199    <friendlyName>{}</friendlyName>
200    <UDN>uuid:{}</UDN>
201    <manufacturer>mitnk</manufacturer>
202    <modelName>Tiny-Render</modelName>
203    <modelDescription>AVTransport Media Renderer</modelDescription>
204    <dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMR-1.50</dlna:X_DLNADOC>
205    <serviceList>
206      <service>
207        <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
208        <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
209        <controlURL>AVTransport/control</controlURL>
210        <SCPDURL>dlna/AVTransport.xml</SCPDURL>
211      </service>
212      <service>
213        <serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
214        <serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
215        <controlURL>RenderingControl/action</controlURL>
216        <eventSubURL>RenderingControl/event</eventSubURL>
217        <SCPDURL>dlna/RenderingControl.xml</SCPDURL>
218      </service>
219      <service>
220        <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
221        <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
222        <controlURL>ConnectionManager/action</controlURL>
223        <eventSubURL>ConnectionManager/event</eventSubURL>
224        <SCPDURL>dlna/ConnectionManager.xml</SCPDURL>
225      </service>
226    </serviceList>
227  </device>
228</root>"#,
229        escape(friendly_name),
230        escape(uuid),
231    )
232}
233
234pub const SET_AV_TRANSPORT_URI_RESPONSE: &str = r#"
235<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
236  <s:Body>
237    <u:SetAVTransportURIResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
238  </s:Body>
239</s:Envelope>
240"#;
241
242pub const PLAY_RESPONSE: &str = r#"
243<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
244  <s:Body>
245    <u:PlayResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
246  </s:Body>
247</s:Envelope>
248"#;
249
250pub const SEEK_RESPONSE: &str = r#"
251<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
252  <s:Body>
253    <u:SeekResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
254  </s:Body>
255</s:Envelope>
256"#;
257
258pub const STOP_RESPONSE: &str = r#"
259<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
260  <s:Body>
261    <u:StopResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
262  </s:Body>
263</s:Envelope>
264"#;
265
266/// `state`: `PLAYING`, `STOPPED` or `NO_MEDIA_PRESENT`.
267pub fn transport_info_response(state: &str) -> String {
268    format!(
269        r#"
270<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
271  <s:Body>
272    <u:GetTransportInfoResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
273      <CurrentTransportState>{state}</CurrentTransportState>
274      <CurrentTransportStatus>OK</CurrentTransportStatus>
275      <CurrentSpeed>1</CurrentSpeed>
276    </u:GetTransportInfoResponse>
277  </s:Body>
278</s:Envelope>
279"#
280    )
281}
282
283pub fn media_info_response(current_uri: &str) -> String {
284    format!(
285        r#"
286<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
287  <s:Body>
288    <u:GetMediaInfoResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
289      <NrTracks>{tracks}</NrTracks>
290      <MediaDuration>0:00:00</MediaDuration>
291      <CurrentURI>{uri}</CurrentURI>
292      <CurrentURIMetaData></CurrentURIMetaData>
293      <NextURI></NextURI>
294      <NextURIMetaData></NextURIMetaData>
295      <PlayMedium>NETWORK</PlayMedium>
296      <RecordMedium>NOT_IMPLEMENTED</RecordMedium>
297      <WriteStatus>NOT_IMPLEMENTED</WriteStatus>
298    </u:GetMediaInfoResponse>
299  </s:Body>
300</s:Envelope>
301"#,
302        tracks = u8::from(!current_uri.is_empty()),
303        uri = escape(current_uri),
304    )
305}
306
307pub fn position_info_response(rel_time: &str) -> String {
308    format!(
309        r#"
310<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
311  <s:Body>
312    <u:GetPositionInfoResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
313      <Track>0</Track>
314      <TrackDuration>01:23:45</TrackDuration>
315      <RelTime>{rel_time}</RelTime>
316      <AbsTime>{rel_time}</AbsTime>
317      <RelCount>2147483647</RelCount>
318      <AbsCount>2147483647</AbsCount>
319    </u:GetPositionInfoResponse>
320  </s:Body>
321</s:Envelope>
322"#
323    )
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn escape_roundtrip() {
332        let s = r#"a&b<c>"d"#;
333        assert_eq!(escape(s), "a&amp;b&lt;c&gt;&quot;d");
334        assert_eq!(unescape(&escape(s)), s);
335    }
336
337    #[test]
338    fn unescape_numeric_and_unknown() {
339        assert_eq!(unescape("&#38;&#x3C;&foo; & x"), "&<&foo; & x");
340    }
341
342    #[test]
343    fn static_documents_parse() {
344        for doc in [
345            AVTRANSPORT_SCPD,
346            RENDERING_CONTROL_SCPD,
347            CONNECTION_MANAGER_SCPD,
348        ] {
349            roxmltree::Document::parse(doc).unwrap();
350        }
351        roxmltree::Document::parse(&description("A & B <x>", "u-1")).unwrap();
352        roxmltree::Document::parse(GET_TRANSPORT_INFO).unwrap();
353        roxmltree::Document::parse(GET_MEDIA_INFO).unwrap();
354        roxmltree::Document::parse(&transport_info_response("STOPPED")).unwrap();
355        roxmltree::Document::parse(&media_info_response("http://h/v?a=1&b=2")).unwrap();
356        let meta = didl_metadata("t&t", "http://h/v?a=1&b=2", Some("http://h/s.srt"));
357        roxmltree::Document::parse(&meta).unwrap();
358        roxmltree::Document::parse(&set_av_transport_uri("http://h/v?a=1&b=2", &meta)).unwrap();
359    }
360}