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 namespace stripping, attribute parsing,
5//! and DIDL-Lite 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 with namespace stripping.
12///
13/// This function handles the common case of parsing UPnP XML that contains
14/// namespace prefixes. It strips namespace prefixes before parsing to allow
15/// simpler serde struct definitions.
16///
17/// # Arguments
18///
19/// * `xml` - The XML string to parse
20///
21/// # Returns
22///
23/// The parsed value of type `T`, or an error if parsing fails.
24pub fn parse<T: DeserializeOwned>(xml: &str) -> Result<T> {
25    let stripped = strip_namespaces(xml);
26    quick_xml::de::from_str(&stripped)
27        .map_err(|e| ApiError::ParseError(format!("XML deserialization failed: {e}")))
28}
29
30/// Strip namespace prefixes from XML content to simplify parsing.
31///
32/// UPnP XML often contains namespace prefixes like `e:`, `dc:`, `upnp:`, etc.
33/// This function removes these prefixes to simplify parsing with serde.
34///
35/// # Example
36///
37/// Input: `<e:propertyset><dc:title>Song</dc:title></e:propertyset>`
38/// Output: `<propertyset><title>Song</title></propertyset>`
39pub fn strip_namespaces(xml: &str) -> String {
40    let mut result = String::with_capacity(xml.len());
41    let mut chars = xml.chars().peekable();
42
43    while let Some(c) = chars.next() {
44        if c == '<' {
45            result.push(c);
46
47            // Check for closing tag or special tags
48            let is_closing = chars.peek() == Some(&'/');
49            if is_closing {
50                result.push(chars.next().unwrap());
51            }
52
53            // Check for special tags (?, !)
54            if let Some(&next) = chars.peek() {
55                if next == '?' || next == '!' {
56                    // Copy until '>'
57                    for ch in chars.by_ref() {
58                        result.push(ch);
59                        if ch == '>' {
60                            break;
61                        }
62                    }
63                    continue;
64                }
65            }
66
67            // Read the tag name (possibly with namespace prefix)
68            let mut tag_name = String::new();
69            while let Some(&ch) = chars.peek() {
70                if ch.is_whitespace() || ch == '>' || ch == '/' {
71                    break;
72                }
73                tag_name.push(chars.next().unwrap());
74            }
75
76            // Strip namespace prefix from tag name
77            if let Some(pos) = tag_name.find(':') {
78                result.push_str(&tag_name[pos + 1..]);
79            } else {
80                result.push_str(&tag_name);
81            }
82
83            // Process attributes
84            while let Some(&ch) = chars.peek() {
85                if ch == '>' {
86                    result.push(chars.next().unwrap());
87                    break;
88                }
89                if ch == '/' {
90                    result.push(chars.next().unwrap());
91                    continue;
92                }
93                if ch.is_whitespace() {
94                    result.push(chars.next().unwrap());
95                    continue;
96                }
97
98                // Read attribute name
99                let mut attr_name = String::new();
100                while let Some(&ach) = chars.peek() {
101                    if ach == '=' || ach.is_whitespace() || ach == '>' || ach == '/' {
102                        break;
103                    }
104                    attr_name.push(chars.next().unwrap());
105                }
106
107                // Strip namespace prefix from attribute name (but keep xmlns declarations)
108                if attr_name.starts_with("xmlns") {
109                    // Skip xmlns declarations entirely
110                    // Skip '='
111                    if chars.peek() == Some(&'=') {
112                        chars.next();
113                    }
114                    // Skip quoted value
115                    if let Some(&quote) = chars.peek() {
116                        if quote == '"' || quote == '\'' {
117                            chars.next();
118                            for ch in chars.by_ref() {
119                                if ch == quote {
120                                    break;
121                                }
122                            }
123                        }
124                    }
125                } else {
126                    // Keep the attribute, stripping namespace prefix
127                    if let Some(pos) = attr_name.find(':') {
128                        result.push_str(&attr_name[pos + 1..]);
129                    } else {
130                        result.push_str(&attr_name);
131                    }
132
133                    // Copy '=' and value
134                    while let Some(&ach) = chars.peek() {
135                        if ach == '>' || ach == '/' {
136                            break;
137                        }
138                        if ach == '"' || ach == '\'' {
139                            let quote = chars.next().unwrap();
140                            result.push(quote);
141                            for ch in chars.by_ref() {
142                                result.push(ch);
143                                if ch == quote {
144                                    break;
145                                }
146                            }
147                            break;
148                        }
149                        result.push(chars.next().unwrap());
150                    }
151                }
152            }
153        } else {
154            result.push(c);
155        }
156    }
157
158    result
159}
160
161/// Custom deserializer for nested XML content.
162///
163/// This deserializer handles elements where the text content is XML-escaped
164/// and needs to be parsed into a structured type. Used with serde's
165/// `deserialize_with` attribute.
166///
167/// # Example
168///
169/// ```rust,ignore
170/// #[derive(Deserialize)]
171/// struct Property {
172///     #[serde(deserialize_with = "deserialize_nested")]
173///     last_change: LastChangeEvent,
174/// }
175/// ```
176pub fn deserialize_nested<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
177where
178    D: Deserializer<'de>,
179    T: DeserializeOwned,
180{
181    let s = String::deserialize(deserializer)?;
182    parse::<T>(&s).map_err(serde::de::Error::custom)
183}
184
185/// Deserialize ZoneGroupState from nested XML string.
186///
187/// Similar to `deserialize_nested` but specifically for ZoneGroupState XML content
188/// that comes nested within the event XML structure.
189pub fn deserialize_zone_group_state<'de, D, T>(
190    deserializer: D,
191) -> std::result::Result<Option<T>, D::Error>
192where
193    D: Deserializer<'de>,
194    T: DeserializeOwned,
195{
196    let s = String::deserialize(deserializer)?;
197    if s.trim().is_empty() {
198        return Ok(None);
199    }
200    let parsed = parse::<T>(&s).map_err(serde::de::Error::custom)?;
201    Ok(Some(parsed))
202}
203
204/// Represents an XML element with a `val` attribute.
205///
206/// Many UPnP state variables are represented as empty elements with a `val` attribute:
207/// ```xml
208/// <TransportState val="PLAYING"/>
209/// <CurrentTrackDuration val="0:03:57"/>
210/// ```
211///
212/// This struct captures that pattern for easy deserialization.
213#[derive(Debug, Clone, Deserialize, Serialize, Default)]
214pub struct ValueAttribute {
215    /// The value from the `val` attribute
216    #[serde(rename = "@val", default)]
217    pub val: String,
218}
219
220/// Represents an XML element with a `val` attribute containing nested XML.
221///
222/// Some UPnP elements contain XML-escaped content in their `val` attribute that
223/// should be parsed into a structured type. For example, `CurrentTrackMetaData`
224/// contains escaped DIDL-Lite XML.
225///
226/// This struct automatically deserializes the escaped XML content into the
227/// specified type `T`.
228#[derive(Debug, Clone, Default, Serialize)]
229pub struct NestedAttribute<T> {
230    /// The parsed value from the nested XML, or None if empty/unparseable
231    pub val: Option<T>,
232}
233
234impl<'de, T: DeserializeOwned> Deserialize<'de> for NestedAttribute<T> {
235    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
236    where
237        D: Deserializer<'de>,
238    {
239        #[derive(Deserialize)]
240        struct RawAttr {
241            #[serde(rename = "@val", default)]
242            val: String,
243        }
244
245        let raw = RawAttr::deserialize(deserializer)?;
246
247        if raw.val.is_empty() {
248            return Ok(NestedAttribute { val: None });
249        }
250
251        // Try to parse the nested XML
252        match parse::<T>(&raw.val) {
253            Ok(parsed) => Ok(NestedAttribute { val: Some(parsed) }),
254            Err(_) => Ok(NestedAttribute { val: None }),
255        }
256    }
257}
258
259/// DIDL-Lite root structure for media metadata.
260///
261/// DIDL-Lite format example:
262/// ```xml
263/// <DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ...>
264///   <item id="-1" parentID="-1">
265///     <dc:title>Song Title</dc:title>
266///     <dc:creator>Artist Name</dc:creator>
267///     <upnp:album>Album Name</upnp:album>
268///     <res duration="0:03:58">uri</res>
269///   </item>
270/// </DIDL-Lite>
271/// ```
272#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
273#[serde(rename = "DIDL-Lite")]
274pub struct DidlLite {
275    /// The item elements containing track metadata
276    #[serde(rename = "item", default)]
277    pub items: Vec<DidlItem>,
278}
279
280impl DidlLite {
281    /// Parse DIDL-Lite XML content directly.
282    ///
283    /// # Arguments
284    ///
285    /// * `xml` - The raw DIDL-Lite XML string
286    ///
287    /// # Returns
288    ///
289    /// The parsed DIDL-Lite structure, or an error if parsing fails.
290    pub fn from_xml(xml: &str) -> Result<Self> {
291        parse(xml)
292    }
293}
294
295/// Individual item in DIDL-Lite metadata containing track information.
296#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
297pub struct DidlItem {
298    /// Item ID
299    #[serde(rename = "@id", default)]
300    pub id: String,
301
302    /// Parent ID
303    #[serde(rename = "@parentID", default)]
304    pub parent_id: String,
305
306    /// Whether the item is restricted
307    #[serde(rename = "@restricted", default)]
308    pub restricted: Option<String>,
309
310    /// Resource elements with URI and duration
311    #[serde(rename = "res", default)]
312    pub resources: Vec<DidlResource>,
313
314    /// Album art URI
315    #[serde(rename = "albumArtURI", default)]
316    pub album_art_uri: Option<String>,
317
318    /// Item class (e.g., object.item.audioItem.musicTrack)
319    #[serde(rename = "class", default)]
320    pub class: Option<String>,
321
322    /// Track title
323    #[serde(rename = "title", default)]
324    pub title: Option<String>,
325
326    /// Track creator/artist
327    #[serde(rename = "creator", default)]
328    pub creator: Option<String>,
329
330    /// Album name
331    #[serde(rename = "album", default)]
332    pub album: Option<String>,
333
334    /// Stream info
335    #[serde(rename = "streamInfo", default)]
336    pub stream_info: Option<String>,
337}
338
339/// Resource element in DIDL-Lite containing media resource information.
340#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
341pub struct DidlResource {
342    /// Duration in HH:MM:SS format
343    #[serde(rename = "@duration", default)]
344    pub duration: Option<String>,
345
346    /// Protocol info for the resource
347    #[serde(rename = "@protocolInfo", default)]
348    pub protocol_info: Option<String>,
349
350    /// The resource URI
351    #[serde(rename = "$value", default)]
352    pub uri: Option<String>,
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_strip_namespaces_basic() {
361        let input = r#"<e:propertyset><e:property>test</e:property></e:propertyset>"#;
362        let expected = r#"<propertyset><property>test</property></propertyset>"#;
363        assert_eq!(strip_namespaces(input), expected);
364    }
365
366    #[test]
367    fn test_strip_namespaces_with_attributes() {
368        let input = r#"<dc:title id="1">Song</dc:title>"#;
369        let expected = r#"<title id="1">Song</title>"#;
370        assert_eq!(strip_namespaces(input), expected);
371    }
372
373    #[test]
374    fn test_strip_namespaces_multiple() {
375        let input = r#"<dc:title>Song</dc:title><upnp:album>Album</upnp:album>"#;
376        let expected = r#"<title>Song</title><album>Album</album>"#;
377        assert_eq!(strip_namespaces(input), expected);
378    }
379
380    #[test]
381    fn test_value_attribute_deserialize() {
382        let xml = r#"<Root><TransportState val="PLAYING"/></Root>"#;
383
384        #[derive(Debug, Deserialize)]
385        struct Root {
386            #[serde(rename = "TransportState")]
387            transport_state: ValueAttribute,
388        }
389
390        let result: Root = parse(xml).unwrap();
391        assert_eq!(result.transport_state.val, "PLAYING");
392    }
393
394    #[test]
395    fn test_value_attribute_empty() {
396        let xml = r#"<Root><TransportState val=""/></Root>"#;
397
398        #[derive(Debug, Deserialize)]
399        struct Root {
400            #[serde(rename = "TransportState")]
401            transport_state: ValueAttribute,
402        }
403
404        let result: Root = parse(xml).unwrap();
405        assert_eq!(result.transport_state.val, "");
406    }
407
408    #[test]
409    fn test_value_attribute_default() {
410        let xml = r#"<Root><TransportState/></Root>"#;
411
412        #[derive(Debug, Deserialize)]
413        struct Root {
414            #[serde(rename = "TransportState")]
415            transport_state: ValueAttribute,
416        }
417
418        let result: Root = parse(xml).unwrap();
419        assert_eq!(result.transport_state.val, "");
420    }
421
422    #[test]
423    fn test_parse_didl_lite_basic() {
424        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>"#;
425
426        let result = DidlLite::from_xml(didl_xml);
427        assert!(
428            result.is_ok(),
429            "Failed to parse DIDL-Lite: {:?}",
430            result.err()
431        );
432
433        let didl = result.unwrap();
434        assert_eq!(didl.items.len(), 1);
435        let item = &didl.items[0];
436        assert_eq!(item.id, "-1");
437        assert_eq!(item.parent_id, "-1");
438        assert_eq!(item.title, Some("Test Song".to_string()));
439        assert_eq!(item.creator, Some("Test Artist".to_string()));
440        assert_eq!(item.album, Some("Test Album".to_string()));
441    }
442
443    #[test]
444    fn test_parse_didl_lite_with_resource() {
445        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>"#;
446
447        let result = DidlLite::from_xml(didl_xml);
448        assert!(
449            result.is_ok(),
450            "Failed to parse DIDL-Lite with resource: {:?}",
451            result.err()
452        );
453
454        let didl = result.unwrap();
455        let item = &didl.items[0];
456        assert_eq!(item.title, Some("Song".to_string()));
457        assert_eq!(item.creator, Some("Artist".to_string()));
458
459        let res = &item.resources[0];
460        assert_eq!(res.duration, Some("0:03:58".to_string()));
461        assert_eq!(
462            res.protocol_info,
463            Some("http-get:*:audio/mpeg:*".to_string())
464        );
465        assert_eq!(res.uri, Some("http://example.com/song.mp3".to_string()));
466    }
467
468    #[test]
469    fn test_parse_didl_lite_minimal() {
470        let didl_xml = r#"<DIDL-Lite><item id="1" parentID="0"></item></DIDL-Lite>"#;
471
472        let result = DidlLite::from_xml(didl_xml);
473        assert!(
474            result.is_ok(),
475            "Failed to parse minimal DIDL-Lite: {:?}",
476            result.err()
477        );
478
479        let didl = result.unwrap();
480        let item = &didl.items[0];
481        assert_eq!(item.id, "1");
482        assert_eq!(item.parent_id, "0");
483        assert_eq!(item.title, None);
484        assert_eq!(item.creator, None);
485        assert_eq!(item.album, None);
486    }
487}