diff --git a/changes/52.bugfix.rst b/changes/52.bugfix.rst new file mode 100644 index 0000000..55f2866 --- /dev/null +++ b/changes/52.bugfix.rst @@ -0,0 +1 @@ +Fix unbound prefix recovery when ``xmlns:dlna`` is absent. Devices that emit unbound prefixes (e.g. ````) without declaring ``xmlns:dlna`` are now handled correctly when ``strict=False``. Recovery also covers documents whose root element carries a prefix (````) and prefixes containing the NCName characters ``-``, ``.`` and ``_``. diff --git a/didl_lite/didl_lite.py b/didl_lite/didl_lite.py index 116938b..ecd2d9e 100644 --- a/didl_lite/didl_lite.py +++ b/didl_lite/didl_lite.py @@ -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., - 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 `` without `xmlns:dlna`). + # The root itself may carry a prefix, as in ``, 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) diff --git a/tests/test_didl_lite.py b/tests/test_didl_lite.py index c60cf07..8e9454d 100644 --- a/tests/test_didl_lite.py +++ b/tests/test_didl_lite.py @@ -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 `` tags + without declaring `xmlns:dlna`, leaving the unbound prefix in place + and breaking parsing even with strict=False. + """ + broken_xml = ( + '' + '' + "Test Title" + "Test Subtitle" + "object.item.audioItem.musicTrack" + "" + "" + ) + + 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 `` rather than + ``. 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 = ( + '' + '' + "Test Title" + "Test Subtitle" + "object.item.audioItem.musicTrack" + "" + "" + ) + + 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 = ( + '' + '' + "Test Title" + f"<{prefix}:subTitle>Test Subtitle" + "object.item.audioItem.musicTrack" + "" + "" + ) + + 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(