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#[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
65pub 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
92pub 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 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&provider=opensubsonic--EcQ6qYKn&size=0&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}