Skip to content

Commit d47639f

Browse files
committed
fix: handle unbound prefixes when xmlns:dlna is absent
The strict=False recovery added in #35 anchored its namespace injection on an existing `xmlns:dlna` declaration. Some real-world devices (JBL Authentics, WiiM/LinkPlay) emit `<song:*>` tags without declaring `xmlns:dlna`, so the injection was silently skipped and the unbound prefix remained, raising ParseError when otherwise recoverable. Anchor the injection on the DIDL-Lite root opening tag instead. That element is guaranteed to be present in any valid DIDL-Lite document, so the recovery works regardless of which namespace declarations the producer chose to include. Also batch all missing prefixes into a single regex substitution and add a regression test that exercises the no-dlna-namespace case.
1 parent bc2d7dc commit d47639f

3 files changed

Lines changed: 47 additions & 6 deletions

File tree

changes/52.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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``.

didl_lite/didl_lite.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,12 +1063,23 @@ def from_xml_string(xml_string: str, strict: bool = True) -> List[Union[DidlObje
10631063
# Identify prefixes used but not defined.
10641064
missing_prefixes = used_prefixes - defined_prefixes - {"DIDL-Lite", "dc", "upnp", "dlna"}
10651065

1066-
# Remove the "if missing_prefixes:" line and just keep the for loop
1067-
for prefix in missing_prefixes:
1068-
dlna_ns = 'xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/"'
1069-
if dlna_ns in xml_string:
1070-
replacement = f'{dlna_ns} xmlns:{prefix}="http://tempuri.org/{prefix}/"'
1071-
xml_string = xml_string.replace(dlna_ns, replacement)
1066+
if missing_prefixes:
1067+
# Inject temporary namespace declarations into the DIDL-Lite root
1068+
# opening tag. Anchoring on `<DIDL-Lite` (always present in valid
1069+
# DIDL-Lite documents) instead of an optional `xmlns:dlna` allows
1070+
# us to recover XML from devices that omit the dlna namespace
1071+
# declaration entirely (observed on JBL Authentics and
1072+
# WiiM/LinkPlay players sending `<song:*>` without `xmlns:dlna`).
1073+
injections = " ".join(
1074+
f'xmlns:{prefix}="http://tempuri.org/{prefix}/"'
1075+
for prefix in sorted(missing_prefixes)
1076+
)
1077+
xml_string = re.sub(
1078+
r"<DIDL-Lite\b",
1079+
f"<DIDL-Lite {injections}",
1080+
xml_string,
1081+
count=1,
1082+
)
10721083

10731084
# Proceed with parsing using the (potentially) patched xml_string
10741085
xml_el = defusedxml.ElementTree.fromstring(xml_string)

tests/test_didl_lite.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,35 @@ def test_from_xml_string_unbound_prefix(self) -> None:
696696
assert objs[0].sub_title == "Test Subtitle"
697697
assert isinstance(objs[0], didl_lite.MusicTrack)
698698

699+
def test_from_xml_string_unbound_prefix_without_dlna_namespace(self) -> None:
700+
"""Test unbound prefix recovery when xmlns:dlna is absent.
701+
702+
Regression: the previous implementation anchored the namespace
703+
injection on an existing `xmlns:dlna` declaration. Devices such as
704+
JBL Authentics and WiiM/LinkPlay players emit `<song:*>` tags
705+
without declaring `xmlns:dlna`, leaving the unbound prefix in place
706+
and breaking parsing even with strict=False.
707+
"""
708+
broken_xml = (
709+
'<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" '
710+
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
711+
'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">'
712+
'<item id="1" parentID="0" restricted="1">'
713+
"<dc:title>Test Title</dc:title>"
714+
"<song:subTitle>Test Subtitle</song:subTitle>"
715+
"<upnp:class>object.item.audioItem.musicTrack</upnp:class>"
716+
"</item>"
717+
"</DIDL-Lite>"
718+
)
719+
720+
objs = didl_lite.from_xml_string(broken_xml, strict=False)
721+
722+
assert len(objs) == 1
723+
assert objs[0].title == "Test Title"
724+
assert "sub_title" in objs[0].__dict__
725+
assert objs[0].sub_title == "Test Subtitle"
726+
assert isinstance(objs[0], didl_lite.MusicTrack)
727+
699728
def test_music_track_artist_and_genre(self) -> None:
700729
"""Test MusicTrack artist and genre properties."""
701730
track = didl_lite.MusicTrack(

0 commit comments

Comments
 (0)