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:
@StevenLooman: Hi @pp81381. Thank you for this issue. I'm certainly not opposed this. In fact, you can use the `strict=False` argument to the `__init__()` methods of the didl lite objects for the purpose of work-arounds.

Feel free to create a PR where, if `strict=False`, the library behaves in a bit more lenient way.@pp81381: Thanks again for this.    Once this is released, it would be great if you could also bump the version of didl_lite used by async-upnp-client too please.   The ultimate goal is to get this into Home Assistant at some point.

Solution 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 @@ def test_property_case(self) -> None:
         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)