Repository: StevenLooman/python-didl-lite

Issue:
WiiM Pro issue
Hi,

I have a WiiM Pro and I've noticed that it doesn't quite comply with the UPnP standard in that it doesn't structure the DIDL-Lite event messages correctly.   The upnp:class element is in the wrong place in the schema.   It should be inside the item element but for some reason it is before the item element instead.   Example below.

I note that at line 1095 of didl_lite.py, python-didl-lite (correctly) looks for the upnp class within the item element.   I'm wondering whether an option could be added to make it less strict or perhaps specifically allow this for WiiM devices?

I raised a ticket with WiiM about this 6 months ago and they did acknowledge it but nothing has happened yet.  I have high hopes that they might fix it one day but I'm thinking that it would be good to have a workaround for us Home Assistant users until they do.

```
<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\"
    xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\"
    xmlns:song=\"www.wiimu.com/song/\"
    xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">
    <upnp:class>object.item.audioItem.musicTrack</upnp:class>
    <item id=\"0\">
        <song:subid></song:subid>
        <song:description></song:description>
        <song:skiplimit>0</song:skiplimit>
        <song:id></song:id>
        <song:like>0</song:like>
        <song:singerid>0</song:singerid>
        <song:albumid>0</song:albumid>
        <song:actualQuality>LOSSLESS</song:actualQuality>
        <song:atmos>0</song:atmos>
        <song:rate_hz>44100</song:rate_hz>
        <song:format_s>16</song:format_s>
        <song:bitrate>676</song:bitrate>
        <res protocolInfo=\"http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;\" duration=\"00:04:44.000\"></res>
        <dc:title>Sweet Lands Experience</dc:title>
        <dc:creator>Underworld</dc:creator>
        <upnp:artist>Underworld</upnp:artist>
        <upnp:album>Strawberry Hotel</upnp:album>
        <upnp:albumArtURI>https://resources.tidal.com/images/272e8ef1/dccc/4fab/ba67/31805d45740e/640x640.jpg</upnp:albumArtURI>
    </item>
</DIDL-Lite> `
```

FYI, here's my really ugly workaround:

```
        upnp_class = child_el.find("./upnp:class", NAMESPACES)
        if upnp_class is None or not upnp_class.text:
            upnp_class = xml_el.find("./upnp:class", NAMESPACES) # UGH
            if upnp_class is None or not upnp_class.text:
                continue

```

Would be keen to hear what you think.

Regards,

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 0ee3e22..221cd82 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,7 +1,7 @@
 Changes
 =======
 
-1.4.0 (unreleased)
+1.4.0 (2023-12-16)
 
 - Treat upnp:class as case-insensitive when in non-strict mode (@chishm)
 - Raise a `DidlLiteException` when upnp:class is invalid when in strict mode (@chishm)
diff --git a/didl_lite/didl_lite.py b/didl_lite/didl_lite.py
index 7eed89b..a890e44 100644
--- a/didl_lite/didl_lite.py
+++ b/didl_lite/didl_lite.py
@@ -1094,7 +1094,13 @@ def from_xml_el(
         # construct item
         upnp_class = child_el.find("./upnp:class", NAMESPACES)
         if upnp_class is None or not upnp_class.text:
-            continue
+            if strict:
+                continue
+            # WiiM Pro and possibly other Linkplay devices emit
+            # upnp_class above the item element instead of inside it
+            upnp_class = xml_el.find("./upnp:class", NAMESPACES)
+            if upnp_class is None or not upnp_class.text:
+                continue
         didl_object_type = type_by_upnp_class(upnp_class.text, strict)
         if didl_object_type is None:
             if strict:


Test patch:
diff --git a/tests/test_didl_lite.py b/tests/test_didl_lite.py
index dbbf1b5..f16787c 100644
--- a/tests/test_didl_lite.py
+++ b/tests/test_didl_lite.py
@@ -654,3 +654,29 @@ class TestDidlLite:
         assert hasattr(item, "otherItem")
         assert not hasattr(item, "other_item")
         assert item.otherItem == "otherItem"
+
+    def test_item_improper_class_nesting(self) -> None:
+        """
+        Test item from XML that has upnp_class element above item.
+
+        Cater for WiiM Pro and possibly other Linkplay devices that
+        emit upnp_class above the item element instead of inside it
+        """
+        didl_string = """
+<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/">
+    <upnp:class>object.item.audioItem.musicTrack</upnp:class>
+    <item id="0">
+        <res protocolInfo="protocol_info"></res>
+        <dc:title>Music Track Title</dc:title>
+        <dc:creator>Music Track Creator</dc:creator>
+        <upnp:artist>Artist</upnp:artist>
+        <upnp:album>Album</upnp:album>
+    </item>
+</DIDL-Lite>"""
+        items = didl_lite.from_xml_string(didl_string, strict=False)
+        assert len(items) == 1
+
+        item = items[0]
+        assert isinstance(item, didl_lite.MusicTrack)