Skip to main content

sonos_api/events/
xml_utils.rs

1//! XML parsing utilities for Sonos UPnP event processing.
2//!
3//! This module provides reusable XML parsing components that were consolidated
4//! from the sonos-parser crate. It includes attribute parsing and DIDL-Lite
5//! metadata structures.
6
7use crate::{ApiError, Result};
8use serde::de::{DeserializeOwned, Deserializer};
9use serde::{Deserialize, Serialize};
10
11/// Parse XML string into a deserializable type.
12///
13/// UPnP XML is heavily namespaced (`e:property`, `dc:title`, `upnp:album`), but
14/// quick-xml's serde deserializer matches on *local* names only, so struct fields
15/// and `#[serde(rename = "...")]` values are written without prefixes and no
16/// preprocessing is required.
17///
18/// # Arguments
19///
20/// * `xml` - The XML string to parse
21///
22/// # Returns
23///
24/// The parsed value of type `T`, or an error if parsing fails.
25pub fn parse<T: DeserializeOwned>(xml: &str) -> Result<T> {
26    quick_xml::de::from_str(xml)
27        .map_err(|e| ApiError::ParseError(format!("XML deserialization failed: {e}")))
28}
29
30/// Custom deserializer for nested XML content.
31///
32/// This deserializer handles elements where the text content is XML-escaped
33/// and needs to be parsed into a structured type. Used with serde's
34/// `deserialize_with` attribute.
35///
36/// # Example
37///
38/// ```rust,ignore
39/// #[derive(Deserialize)]
40/// struct Property {
41///     #[serde(deserialize_with = "deserialize_nested")]
42///     last_change: LastChangeEvent,
43/// }
44/// ```
45pub fn deserialize_nested<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
46where
47    D: Deserializer<'de>,
48    T: DeserializeOwned,
49{
50    let s = String::deserialize(deserializer)?;
51    parse::<T>(&s).map_err(serde::de::Error::custom)
52}
53
54/// Deserialize ZoneGroupState from nested XML string.
55///
56/// Similar to `deserialize_nested` but specifically for ZoneGroupState XML content
57/// that comes nested within the event XML structure.
58pub fn deserialize_zone_group_state<'de, D, T>(
59    deserializer: D,
60) -> std::result::Result<Option<T>, D::Error>
61where
62    D: Deserializer<'de>,
63    T: DeserializeOwned,
64{
65    let s = String::deserialize(deserializer)?;
66    if s.trim().is_empty() {
67        return Ok(None);
68    }
69    let parsed = parse::<T>(&s).map_err(serde::de::Error::custom)?;
70    Ok(Some(parsed))
71}
72
73/// Represents an XML element with a `val` attribute.
74///
75/// Many UPnP state variables are represented as empty elements with a `val` attribute:
76/// ```xml
77/// <TransportState val="PLAYING"/>
78/// <CurrentTrackDuration val="0:03:57"/>
79/// ```
80///
81/// This struct captures that pattern for easy deserialization.
82#[derive(Debug, Clone, Deserialize, Serialize, Default)]
83pub struct ValueAttribute {
84    /// The value from the `val` attribute
85    #[serde(rename = "@val", default)]
86    pub val: String,
87}
88
89/// Represents an XML element with a `val` attribute containing nested XML.
90///
91/// Some UPnP elements contain XML-escaped content in their `val` attribute that
92/// should be parsed into a structured type. For example, `CurrentTrackMetaData`
93/// contains escaped DIDL-Lite XML.
94///
95/// This struct automatically deserializes the escaped XML content into the
96/// specified type `T`.
97#[derive(Debug, Clone, Default, Serialize)]
98pub struct NestedAttribute<T> {
99    /// The parsed value from the nested XML, or None if empty/unparseable
100    pub val: Option<T>,
101}
102
103impl<'de, T: DeserializeOwned> Deserialize<'de> for NestedAttribute<T> {
104    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
105    where
106        D: Deserializer<'de>,
107    {
108        #[derive(Deserialize)]
109        struct RawAttr {
110            #[serde(rename = "@val", default)]
111            val: String,
112        }
113
114        let raw = RawAttr::deserialize(deserializer)?;
115
116        if raw.val.is_empty() {
117            return Ok(NestedAttribute { val: None });
118        }
119
120        // Try to parse the nested XML
121        match parse::<T>(&raw.val) {
122            Ok(parsed) => Ok(NestedAttribute { val: Some(parsed) }),
123            Err(_) => Ok(NestedAttribute { val: None }),
124        }
125    }
126}
127
128/// DIDL-Lite root structure for media metadata.
129///
130/// DIDL-Lite format example:
131/// ```xml
132/// <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ...>
133///   <item id="-1" parentID="-1">
134///     <dc:title>Song Title</dc:title>
135///     <dc:creator>Artist Name</dc:creator>
136///     <upnp:album>Album Name</upnp:album>
137///     <res duration="0:03:58">uri</res>
138///   </item>
139/// </DIDL-Lite>
140/// ```
141#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
142#[serde(rename = "DIDL-Lite")]
143pub struct DidlLite {
144    /// The item elements containing track metadata
145    #[serde(rename = "item", default)]
146    pub items: Vec<DidlItem>,
147}
148
149impl DidlLite {
150    /// Parse DIDL-Lite XML content directly.
151    ///
152    /// # Arguments
153    ///
154    /// * `xml` - The raw DIDL-Lite XML string
155    ///
156    /// # Returns
157    ///
158    /// The parsed DIDL-Lite structure, or an error if parsing fails.
159    pub fn from_xml(xml: &str) -> Result<Self> {
160        parse(xml)
161    }
162}
163
164/// Individual item in DIDL-Lite metadata containing track information.
165#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
166pub struct DidlItem {
167    /// Item ID
168    #[serde(rename = "@id", default)]
169    pub id: String,
170
171    /// Parent ID
172    #[serde(rename = "@parentID", default)]
173    pub parent_id: String,
174
175    /// Whether the item is restricted
176    #[serde(rename = "@restricted", default)]
177    pub restricted: Option<String>,
178
179    /// Resource elements with URI and duration
180    #[serde(rename = "res", default)]
181    pub resources: Vec<DidlResource>,
182
183    /// Album art URI
184    #[serde(rename = "albumArtURI", default)]
185    pub album_art_uri: Option<String>,
186
187    /// Item class (e.g., object.item.audioItem.musicTrack)
188    #[serde(rename = "class", default)]
189    pub class: Option<String>,
190
191    /// Track title
192    #[serde(rename = "title", default)]
193    pub title: Option<String>,
194
195    /// Track creator/artist
196    #[serde(rename = "creator", default)]
197    pub creator: Option<String>,
198
199    /// Album name
200    #[serde(rename = "album", default)]
201    pub album: Option<String>,
202
203    /// Stream info
204    #[serde(rename = "streamInfo", default)]
205    pub stream_info: Option<String>,
206}
207
208/// Resource element in DIDL-Lite containing media resource information.
209#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
210pub struct DidlResource {
211    /// Duration in HH:MM:SS format
212    #[serde(rename = "@duration", default)]
213    pub duration: Option<String>,
214
215    /// Protocol info for the resource
216    #[serde(rename = "@protocolInfo", default)]
217    pub protocol_info: Option<String>,
218
219    /// The resource URI
220    #[serde(rename = "$value", default)]
221    pub uri: Option<String>,
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    /// Comments and CDATA containing `>` used to be truncated at that `>` by the
229    /// hand-rolled namespace stripper, corrupting the document.
230    #[test]
231    fn test_parse_survives_cdata_and_comment_containing_gt() {
232        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0"><!-- a > b --><e:property><TransportState val="PLAYING"/><Note><![CDATA[3 > 2 && <ok>]]></Note></e:property></e:propertyset>"#;
233
234        #[derive(Debug, Deserialize)]
235        struct PropertySet {
236            property: Property,
237        }
238        #[derive(Debug, Deserialize)]
239        struct Property {
240            #[serde(rename = "TransportState")]
241            transport_state: ValueAttribute,
242            #[serde(rename = "Note")]
243            note: String,
244        }
245
246        let parsed: PropertySet = parse(xml).unwrap();
247        assert_eq!(parsed.property.transport_state.val, "PLAYING");
248        assert_eq!(parsed.property.note, "3 > 2 && <ok>");
249    }
250
251    #[test]
252    fn test_value_attribute_deserialize() {
253        let xml = r#"<Root><TransportState val="PLAYING"/></Root>"#;
254
255        #[derive(Debug, Deserialize)]
256        struct Root {
257            #[serde(rename = "TransportState")]
258            transport_state: ValueAttribute,
259        }
260
261        let result: Root = parse(xml).unwrap();
262        assert_eq!(result.transport_state.val, "PLAYING");
263    }
264
265    #[test]
266    fn test_value_attribute_empty() {
267        let xml = r#"<Root><TransportState val=""/></Root>"#;
268
269        #[derive(Debug, Deserialize)]
270        struct Root {
271            #[serde(rename = "TransportState")]
272            transport_state: ValueAttribute,
273        }
274
275        let result: Root = parse(xml).unwrap();
276        assert_eq!(result.transport_state.val, "");
277    }
278
279    #[test]
280    fn test_value_attribute_default() {
281        let xml = r#"<Root><TransportState/></Root>"#;
282
283        #[derive(Debug, Deserialize)]
284        struct Root {
285            #[serde(rename = "TransportState")]
286            transport_state: ValueAttribute,
287        }
288
289        let result: Root = parse(xml).unwrap();
290        assert_eq!(result.transport_state.val, "");
291    }
292
293    #[test]
294    fn test_parse_didl_lite_basic() {
295        let didl_xml = r#"<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"><item id="-1" parentID="-1"><dc:title>Test Song</dc:title><dc:creator>Test Artist</dc:creator><upnp:album>Test Album</upnp:album></item></DIDL-Lite>"#;
296
297        let result = DidlLite::from_xml(didl_xml);
298        assert!(
299            result.is_ok(),
300            "Failed to parse DIDL-Lite: {:?}",
301            result.err()
302        );
303
304        let didl = result.unwrap();
305        assert_eq!(didl.items.len(), 1);
306        let item = &didl.items[0];
307        assert_eq!(item.id, "-1");
308        assert_eq!(item.parent_id, "-1");
309        assert_eq!(item.title, Some("Test Song".to_string()));
310        assert_eq!(item.creator, Some("Test Artist".to_string()));
311        assert_eq!(item.album, Some("Test Album".to_string()));
312    }
313
314    #[test]
315    fn test_parse_didl_lite_with_resource() {
316        let didl_xml = r#"<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/"><item id="-1" parentID="-1"><dc:title>Song</dc:title><dc:creator>Artist</dc:creator><res duration="0:03:58" protocolInfo="http-get:*:audio/mpeg:*">http://example.com/song.mp3</res></item></DIDL-Lite>"#;
317
318        let result = DidlLite::from_xml(didl_xml);
319        assert!(
320            result.is_ok(),
321            "Failed to parse DIDL-Lite with resource: {:?}",
322            result.err()
323        );
324
325        let didl = result.unwrap();
326        let item = &didl.items[0];
327        assert_eq!(item.title, Some("Song".to_string()));
328        assert_eq!(item.creator, Some("Artist".to_string()));
329
330        let res = &item.resources[0];
331        assert_eq!(res.duration, Some("0:03:58".to_string()));
332        assert_eq!(
333            res.protocol_info,
334            Some("http-get:*:audio/mpeg:*".to_string())
335        );
336        assert_eq!(res.uri, Some("http://example.com/song.mp3".to_string()));
337    }
338
339    #[test]
340    fn test_parse_didl_lite_minimal() {
341        let didl_xml = r#"<DIDL-Lite><item id="1" parentID="0"></item></DIDL-Lite>"#;
342
343        let result = DidlLite::from_xml(didl_xml);
344        assert!(
345            result.is_ok(),
346            "Failed to parse minimal DIDL-Lite: {:?}",
347            result.err()
348        );
349
350        let didl = result.unwrap();
351        let item = &didl.items[0];
352        assert_eq!(item.id, "1");
353        assert_eq!(item.parent_id, "0");
354        assert_eq!(item.title, None);
355        assert_eq!(item.creator, None);
356        assert_eq!(item.album, None);
357    }
358}