sonos/
didl.rs

1use crate::{DecodeXml, EncodeXml, Error, Result};
2use instant_xml::{FromXml, ToXml};
3use std::time::Duration;
4
5const XMLNS_DIDL_LITE: &str = "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/";
6const XMLNS_DC_ELEMENTS: &str = "http://purl.org/dc/elements/1.1/";
7const XMLNS_UPNP: &str = "urn:schemas-upnp-org:metadata-1-0/upnp/";
8const XMLNS_RINCONN: &str = "urn:schemas-rinconnetworks-com:metadata-1-0/";
9
10/// Represents DIDL-Lite information but in a more ergonomic form.
11/// This type can be converted to/from the corresponding DIDL-Lite
12/// xml form.
13#[derive(Debug, Default, Clone, PartialEq, Eq)]
14pub struct TrackMetaData {
15    pub title: String,
16    pub creator: Option<String>,
17    pub album: Option<String>,
18    pub duration: Option<Duration>,
19    pub url: String,
20    pub mime_type: Option<String>,
21    pub art_url: Option<String>,
22    pub class: ObjectClass,
23}
24
25impl DecodeXml for TrackMetaData {
26    fn decode_xml(xml: &str) -> Result<Self> {
27        let mut list = Self::from_didl_str(xml)?;
28        if list.len() == 1 {
29            Ok(list.pop().expect("have 1"))
30        } else if list.is_empty() {
31            Err(Error::EmptyTrackMetaData)
32        } else {
33            Err(Error::MoreThanOneTrackMetaData)
34        }
35    }
36}
37
38impl EncodeXml for TrackMetaData {
39    fn encode_xml(&self) -> std::result::Result<String, instant_xml::Error> {
40        Ok(self.to_didl_string())
41    }
42}
43
44#[derive(Debug, Default, Clone, PartialEq, Eq)]
45pub struct TrackMetaDataList {
46    pub tracks: Vec<TrackMetaData>,
47}
48
49impl DecodeXml for TrackMetaDataList {
50    fn decode_xml(xml: &str) -> Result<Self> {
51        let tracks = TrackMetaData::from_didl_str(xml)?;
52        Ok(Self { tracks })
53    }
54}
55
56impl EncodeXml for TrackMetaDataList {
57    fn encode_xml(&self) -> std::result::Result<String, instant_xml::Error> {
58        let tracks: Vec<_> = self.tracks.iter().map(|t| t.to_didl_string()).collect();
59        Ok(tracks.join(""))
60    }
61}
62
63const HMS_FACTORS: &[u64] = &[86400, 3600, 60, 1];
64
65/// Convert a `Duration` into a string of the form `HH:MM:SS`,
66/// which is used in parts of UPNP to represent a duration.
67pub fn duration_to_hms(d: Duration) -> String {
68    use std::fmt::Write;
69    let mut seconds_total = d.as_secs();
70    let mut result = String::new();
71
72    for &factor in HMS_FACTORS {
73        let v = seconds_total / factor;
74        seconds_total -= v * factor;
75
76        if factor > 3600 && v == 0 {
77            continue;
78        }
79        if !result.is_empty() {
80            result.push(':');
81        }
82        if factor > 3600 {
83            write!(&mut result, "{v}").ok();
84        } else {
85            write!(&mut result, "{v:02}").ok();
86        }
87    }
88
89    result
90}
91
92/// Convert a string of the form `HH:MM:SS` into a Duration.
93pub fn hms_to_duration(hms: &str) -> Duration {
94    let mut result = Duration::ZERO;
95
96    for (field, factor) in hms.split(':').rev().zip(HMS_FACTORS.iter().rev()) {
97        let Ok(v) = field.parse::<u64>() else {
98            return Duration::ZERO;
99        };
100        result += Duration::from_secs(v * factor);
101    }
102
103    result
104}
105
106impl TrackMetaData {
107    pub fn to_didl_string(&self) -> String {
108        let didl = DidlLite {
109            item: vec![UpnpItem {
110                queue_item_id: None,
111                mime_type: self
112                    .mime_type
113                    .clone()
114                    .map(|mime_type| MimeType { mime_type }),
115                duration: None,
116                id: "-1".to_string(),
117                parent_id: "-1".to_string(),
118                restricted: Some(true),
119                res: Some(Res {
120                    // Note that this assumes that the URL is an HTTP URL
121                    protocol_info: Some(format!(
122                        "http-get:*:{}",
123                        self.mime_type.as_deref().unwrap_or("audio/mpeg")
124                    )),
125                    duration: self
126                        .duration
127                        .map(duration_to_hms)
128                        .unwrap_or_else(String::new),
129                    url: self.url.to_string(),
130                }),
131                title: Some(Title {
132                    title: self.title.to_string(),
133                }),
134                album_art: self.art_url.clone().map(|uri| AlbumArtUri { uri }),
135                album_title: self
136                    .album
137                    .clone()
138                    .map(|album_title| AlbumTitle { album_title }),
139                creator: self.creator.clone().map(|artist| Creator { artist }),
140                artist: self.creator.clone().map(|artist| Artist { artist }),
141                class: Some(ObjectClass::MusicTrack),
142            }],
143        };
144        instant_xml::to_string(&didl).expect("infallible xml encode!?")
145    }
146
147    pub fn from_didl_str(didl: &str) -> Result<Vec<Self>> {
148        let didl: DidlLite = instant_xml::from_str(didl)?;
149        let mut result = vec![];
150        for item in didl.item {
151            result.push(Self {
152                class: item.class.unwrap_or_default(),
153                album: item.album_title.map(|a| a.album_title),
154                creator: item.creator.map(|a| a.artist),
155                art_url: item.album_art.map(|a| a.uri),
156                title: item.title.map(|a| a.title).unwrap_or_else(String::new),
157                duration: match item.duration {
158                    Some(d) => Some(Duration::from_secs(d.duration)),
159                    None => item.res.as_ref().map(|r| hms_to_duration(&r.duration)),
160                },
161                url: item
162                    .res
163                    .as_ref()
164                    .map(|r| r.url.to_string())
165                    .unwrap_or_else(String::new),
166                mime_type: item.res.as_ref().and_then(|r| {
167                    let fields: Vec<&str> = r.protocol_info.as_ref()?.split(':').collect();
168                    fields.get(2).map(|mime_type| mime_type.to_string())
169                }),
170            });
171        }
172        Ok(result)
173    }
174}
175
176#[derive(Debug, FromXml, ToXml)]
177#[xml(rename="DIDL-Lite", ns(XMLNS_DIDL_LITE, dc=XMLNS_DC_ELEMENTS, upnp=XMLNS_UPNP, r=XMLNS_RINCONN))]
178pub struct DidlLite {
179    pub item: Vec<UpnpItem>,
180}
181
182#[derive(Debug, FromXml, ToXml)]
183#[xml(rename = "item", ns(XMLNS_DIDL_LITE))]
184pub struct UpnpItem {
185    #[xml(attribute)]
186    pub id: String,
187    #[xml(attribute, rename = "parentID")]
188    pub parent_id: String,
189    #[xml(attribute)]
190    pub restricted: Option<bool>,
191
192    pub res: Option<Res>,
193    pub duration: Option<UpnpDuration>,
194    pub album_art: Option<AlbumArtUri>,
195    pub album_title: Option<AlbumTitle>,
196    pub artist: Option<Artist>,
197    pub creator: Option<Creator>,
198    pub title: Option<Title>,
199    pub class: Option<ObjectClass>,
200    pub mime_type: Option<MimeType>,
201    pub queue_item_id: Option<QueueItemId>,
202}
203
204#[derive(Debug, FromXml, ToXml)]
205#[xml(rename = "res", ns(XMLNS_DIDL_LITE))]
206pub struct Res {
207    #[xml(attribute, rename = "protocolInfo")]
208    pub protocol_info: Option<String>,
209    #[xml(attribute)]
210    pub duration: String,
211    #[xml(direct)]
212    pub url: String,
213}
214
215#[derive(Debug, FromXml, ToXml)]
216#[xml(rename="mimeType", ns(XMLNS_UPNP, upnp=XMLNS_UPNP))]
217pub struct MimeType {
218    #[xml(direct)]
219    pub mime_type: String,
220}
221
222#[derive(Debug, FromXml, ToXml)]
223#[xml(rename="albumArtURI", ns(XMLNS_UPNP, upnp=XMLNS_UPNP))]
224pub struct AlbumArtUri {
225    #[xml(direct)]
226    pub uri: String,
227}
228
229#[derive(Debug, FromXml, ToXml)]
230#[xml(rename="album", ns(XMLNS_UPNP, upnp=XMLNS_UPNP))]
231pub struct AlbumTitle {
232    #[xml(direct)]
233    pub album_title: String,
234}
235
236#[derive(Debug, FromXml, ToXml)]
237#[xml(rename="artist", ns(XMLNS_UPNP, upnp=XMLNS_UPNP))]
238pub struct Artist {
239    #[xml(direct)]
240    pub artist: String,
241}
242
243#[derive(Debug, FromXml, ToXml)]
244#[xml(rename="duration", ns(XMLNS_UPNP, upnp=XMLNS_UPNP))]
245pub struct UpnpDuration {
246    #[xml(direct)]
247    pub duration: u64,
248}
249
250#[derive(Debug, FromXml, ToXml)]
251#[xml(rename="creator", ns(XMLNS_DC_ELEMENTS, dc=XMLNS_DC_ELEMENTS))]
252pub struct Creator {
253    #[xml(direct)]
254    pub artist: String,
255}
256
257#[derive(Debug, FromXml, ToXml)]
258#[xml(rename="title", ns(XMLNS_DC_ELEMENTS, dc=XMLNS_DC_ELEMENTS))]
259pub struct Title {
260    #[xml(direct)]
261    pub title: String,
262}
263
264#[derive(Debug, FromXml, ToXml)]
265#[xml(rename="queueItemId", ns(XMLNS_DC_ELEMENTS, dc=XMLNS_DC_ELEMENTS))]
266pub struct QueueItemId {
267    #[xml(direct)]
268    pub id: String,
269}
270
271#[derive(Debug, Clone, Default, PartialEq, Eq, FromXml, ToXml)]
272#[xml(rename="class", scalar, ns(XMLNS_UPNP, upnp=XMLNS_UPNP))]
273pub enum ObjectClass {
274    #[xml(rename = "object.item.audioItem.musicTrack")]
275    #[default]
276    MusicTrack,
277    #[xml(rename = "object.item.audioItem.audioBroadcast")]
278    AudioBroadcast,
279    #[xml(rename = "object.container.playlistContainer")]
280    PlayList,
281    #[xml(rename = "object.container")]
282    Container,
283}
284
285#[cfg(test)]
286mod test {
287    use super::*;
288
289    #[test]
290    fn test_didl() {
291        let didl = DidlLite {
292            item: vec![UpnpItem {
293                queue_item_id: None,
294                mime_type: None,
295                album_art: Some(AlbumArtUri {
296                    uri: "http://art".to_string(),
297                }),
298                album_title: Some(AlbumTitle {
299                    album_title: "My Album".to_string(),
300                }),
301                artist: None,
302                creator: Some(Creator {
303                    artist: "Some Guy".to_string(),
304                }),
305                class: Some(ObjectClass::MusicTrack),
306                id: "-1".to_string(),
307                parent_id: "-1".to_string(),
308                res: Some(Res {
309                    protocol_info: Some("http-get:*:audio/mpeg".to_string()),
310                    duration: "0:30:31".to_string(),
311                    url: "http://track.mp3".to_string(),
312                }),
313                duration: None,
314                restricted: Some(true),
315                title: Some(Title {
316                    title: "Track Title".to_string(),
317                }),
318            }],
319        };
320        k9::snapshot!(
321            instant_xml::to_string(&didl).unwrap(),
322            r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"><item id="-1" parentID="-1" restricted="true"><res protocolInfo="http-get:*:audio/mpeg" duration="0:30:31">http://track.mp3</res><upnp:albumArtURI>http://art</upnp:albumArtURI><upnp:album>My Album</upnp:album><dc:creator>Some Guy</dc:creator><dc:title>Track Title</dc:title><upnp:class>object.item.audioItem.musicTrack</upnp:class></item></DIDL-Lite>"#
323        );
324    }
325
326    #[test]
327    fn test_real_didl() {
328        let input = r#"<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"><item id="1" parentID="0" restricted="1"><dc:title>Late Nights and Sneaky Moms</dc:title><dc:creator>DJ Birchy</dc:creator><upnp:album>[Unknown Album]</upnp:album><upnp:artist>DJ Borchy</upnp:artist><upnp:duration>4364</upnp:duration><dc:queueItemId>http://192.168.1.214:8097/single/RINCON_XXX/51f8b02b9d3b4a88b97dd385ba2b572b.flac?ts=1716507641</dc:queueItemId><upnp:albumArtURI>http://192.168.1.214:8097/imageproxy?path=al-573b45a1bde2b333c07b41545898da44_59330182&amp;provider=opensubsonic--EcQ6qYKn&amp;size=0&amp;fmt=png</upnp:albumArtURI><upnp:class>object.item.audioItem.audioBroadcast</upnp:class><upnp:mimeType>audio/flac</upnp:mimeType><res duration="1:12:44.000" protocolInfo="http-get:*:audio/flac:DLNA.ORG_PN=FLAC;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">http://192.168.1.214:8097/single/RINCON_XXX/51f8b02b9d3b4a88b97dd385ba2b572b.flac?ts=1716507641</res></item></DIDL-Lite>"#;
329        let didl: DidlLite = instant_xml::from_str(&input).unwrap();
330        k9::snapshot!(
331            didl,
332            r#"
333DidlLite {
334    item: [
335        UpnpItem {
336            id: "1",
337            parent_id: "0",
338            restricted: Some(
339                true,
340            ),
341            res: Some(
342                Res {
343                    protocol_info: Some(
344                        "http-get:*:audio/flac:DLNA.ORG_PN=FLAC;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000",
345                    ),
346                    duration: "1:12:44.000",
347                    url: "http://192.168.1.214:8097/single/RINCON_XXX/51f8b02b9d3b4a88b97dd385ba2b572b.flac?ts=1716507641",
348                },
349            ),
350            duration: Some(
351                UpnpDuration {
352                    duration: 4364,
353                },
354            ),
355            album_art: Some(
356                AlbumArtUri {
357                    uri: "http://192.168.1.214:8097/imageproxy?path=al-573b45a1bde2b333c07b41545898da44_59330182&provider=opensubsonic--EcQ6qYKn&size=0&fmt=png",
358                },
359            ),
360            album_title: Some(
361                AlbumTitle {
362                    album_title: "[Unknown Album]",
363                },
364            ),
365            artist: Some(
366                Artist {
367                    artist: "DJ Borchy",
368                },
369            ),
370            creator: Some(
371                Creator {
372                    artist: "DJ Birchy",
373                },
374            ),
375            title: Some(
376                Title {
377                    title: "Late Nights and Sneaky Moms",
378                },
379            ),
380            class: Some(
381                AudioBroadcast,
382            ),
383            mime_type: Some(
384                MimeType {
385                    mime_type: "audio/flac",
386                },
387            ),
388            queue_item_id: Some(
389                QueueItemId {
390                    id: "http://192.168.1.214:8097/single/RINCON_XXX/51f8b02b9d3b4a88b97dd385ba2b572b.flac?ts=1716507641",
391                },
392            ),
393        },
394    ],
395}
396"#
397        );
398    }
399
400    #[test]
401    fn test_empty_album_art() {
402        let input = r#"<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="00080000A%3aTRACKS" parentID="-1" restricted="true"><dc:title>Tracks</dc:title><upnp:class>object.container</upnp:class><desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/"></desc><upnp:albumArtURI></upnp:albumArtURI></item></DIDL-Lite>"#;
403
404        let didl: DidlLite = instant_xml::from_str(&input).unwrap();
405        k9::snapshot!(
406            didl,
407            r#"
408DidlLite {
409    item: [
410        UpnpItem {
411            id: "00080000A%3aTRACKS",
412            parent_id: "-1",
413            restricted: Some(
414                true,
415            ),
416            res: None,
417            duration: None,
418            album_art: Some(
419                AlbumArtUri {
420                    uri: "",
421                },
422            ),
423            album_title: None,
424            artist: None,
425            creator: None,
426            title: Some(
427                Title {
428                    title: "Tracks",
429                },
430            ),
431            class: Some(
432                Container,
433            ),
434            mime_type: None,
435            queue_item_id: None,
436        },
437    ],
438}
439"#
440        );
441    }
442
443    #[test]
444    fn test_hms() {
445        fn r(hms: &str, s: u64) {
446            assert_eq!(hms_to_duration(hms), Duration::from_secs(s));
447            assert_eq!(duration_to_hms(Duration::from_secs(s)), hms);
448        }
449
450        r("00:02:31", 151);
451        r("01:00:31", 3631);
452        r("3:01:00:31", 262831);
453    }
454}