Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/53.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix unbound prefix recovery when ``xmlns:dlna`` is absent. Devices that emit unbound prefixes (e.g. ``<song:subTitle>``) without declaring ``xmlns:dlna`` are now handled correctly when ``strict=False``. Recovery also covers documents whose root element carries a prefix (``<didl:DIDL-Lite>``) and prefixes containing the NCName characters ``-``, ``.`` and ``_``.
28 changes: 20 additions & 8 deletions didl_lite/didl_lite.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,20 +1055,32 @@ def from_xml_string(xml_string: str, strict: bool = True) -> List[Union[DidlObje
"""Parse DIDL-Lite XML string."""
if not strict:
# Find all prefixes used in tags, e.g., <prefix:tag ...>
used_prefixes = set(re.findall(r"<([a-zA-Z0-9]+):", xml_string))
used_prefixes = set(re.findall(r"<([A-Za-z_][\w.-]*):", xml_string))

# Find all defined namespaces, e.g., xmlns:prefix=...
defined_prefixes = set(re.findall(r"xmlns:([a-zA-Z0-9]+)=", xml_string))
defined_prefixes = set(re.findall(r"xmlns:([A-Za-z_][\w.-]*)=", xml_string))

# Identify prefixes used but not defined.
missing_prefixes = used_prefixes - defined_prefixes - {"DIDL-Lite", "dc", "upnp", "dlna"}

# Remove the "if missing_prefixes:" line and just keep the for loop
for prefix in missing_prefixes:
dlna_ns = 'xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"'
if dlna_ns in xml_string:
replacement = f'{dlna_ns} xmlns:{prefix}="http://tempuri.org/{prefix}/"'
xml_string = xml_string.replace(dlna_ns, replacement)
if missing_prefixes:
# Inject temporary namespace declarations into the DIDL-Lite root
# opening tag. Anchoring on the root element (always present in
# valid DIDL-Lite documents) instead of an optional `xmlns:dlna`
# allows us to recover XML from devices that omit the dlna
# namespace declaration entirely (observed on JBL Authentics and
# WiiM/LinkPlay players sending `<song:*>` without `xmlns:dlna`).
# The root itself may carry a prefix, as in `<didl:DIDL-Lite>`, so
# the match is captured and written back unchanged.
injections = " ".join(
f'xmlns:{prefix}="http://tempuri.org/{prefix}/"' for prefix in sorted(missing_prefixes)
)
xml_string = re.sub(
r"<((?:[A-Za-z_][\w.-]*:)?DIDL-Lite)\b",
lambda match: f"<{match.group(1)} {injections}",
xml_string,
count=1,
)

# Proceed with parsing using the (potentially) patched xml_string
xml_el = defusedxml.ElementTree.fromstring(xml_string)
Expand Down
82 changes: 82 additions & 0 deletions tests/test_didl_lite.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,88 @@ def test_from_xml_string_unbound_prefix(self) -> None:
assert objs[0].sub_title == "Test Subtitle"
assert isinstance(objs[0], didl_lite.MusicTrack)

def test_from_xml_string_unbound_prefix_without_dlna_namespace(self) -> None:
"""Test unbound prefix recovery when xmlns:dlna is absent.

Regression: the previous implementation anchored the namespace
injection on an existing `xmlns:dlna` declaration. Devices such as
JBL Authentics and WiiM/LinkPlay players emit `<song:*>` tags
without declaring `xmlns:dlna`, leaving the unbound prefix in place
and breaking parsing even with strict=False.
"""
broken_xml = (
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/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="0" restricted="1">'
"<dc:title>Test Title</dc:title>"
"<song:subTitle>Test Subtitle</song:subTitle>"
"<upnp:class>object.item.audioItem.musicTrack</upnp:class>"
"</item>"
"</DIDL-Lite>"
)

objs = didl_lite.from_xml_string(broken_xml, strict=False)

assert len(objs) == 1
assert objs[0].title == "Test Title"
assert "sub_title" in objs[0].__dict__
assert objs[0].sub_title == "Test Subtitle"
assert isinstance(objs[0], didl_lite.MusicTrack)

def test_from_xml_string_unbound_prefix_with_prefixed_root(self) -> None:
"""Test unbound prefix recovery when the root element carries a prefix.

The DIDL-Lite root may be written as `<didl:DIDL-Lite>` rather than
`<DIDL-Lite>`. The namespace injection has to recognise that form and
write the matched name back unchanged, otherwise the opening tag no
longer matches its closing tag.
"""
broken_xml = (
'<didl:DIDL-Lite xmlns:didl="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" '
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">'
'<didl:item id="1" parentID="0" restricted="1">'
"<dc:title>Test Title</dc:title>"
"<song:subTitle>Test Subtitle</song:subTitle>"
"<upnp:class>object.item.audioItem.musicTrack</upnp:class>"
"</didl:item>"
"</didl:DIDL-Lite>"
)

objs = didl_lite.from_xml_string(broken_xml, strict=False)

assert len(objs) == 1
assert objs[0].title == "Test Title"
assert objs[0].sub_title == "Test Subtitle"
assert isinstance(objs[0], didl_lite.MusicTrack)

def test_from_xml_string_unbound_prefix_with_ncname_characters(self) -> None:
"""Test unbound prefix recovery for prefixes holding '-', '.' or '_'.

An XML namespace prefix is an NCName, so it may contain a hyphen, a
period or an underscore after its first character. Recovery has to
detect those prefixes as well, not only alphanumeric ones.
"""
for prefix in ("my-song", "my.song", "my_song"):
broken_xml = (
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/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="0" restricted="1">'
"<dc:title>Test Title</dc:title>"
f"<{prefix}:subTitle>Test Subtitle</{prefix}:subTitle>"
"<upnp:class>object.item.audioItem.musicTrack</upnp:class>"
"</item>"
"</DIDL-Lite>"
)

objs = didl_lite.from_xml_string(broken_xml, strict=False)

assert len(objs) == 1, f"prefix {prefix} was not recovered"
assert objs[0].title == "Test Title"
assert isinstance(objs[0], didl_lite.MusicTrack)

def test_music_track_artist_and_genre(self) -> None:
"""Test MusicTrack artist and genre properties."""
track = didl_lite.MusicTrack(
Expand Down
Loading