From d47639fdf7ad326945d31669955234432979c26f Mon Sep 17 00:00:00 2001 From: sfortis Date: Sun, 21 Jun 2026 13:37:29 +0300 Subject: [PATCH 1/4] 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 `` 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. --- changes/52.bugfix.rst | 1 + didl_lite/didl_lite.py | 23 +++++++++++++++++------ tests/test_didl_lite.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 changes/52.bugfix.rst diff --git a/changes/52.bugfix.rst b/changes/52.bugfix.rst new file mode 100644 index 0000000..8164c67 --- /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``. diff --git a/didl_lite/didl_lite.py b/didl_lite/didl_lite.py index 116938b..b56f564 100644 --- a/didl_lite/didl_lite.py +++ b/didl_lite/didl_lite.py @@ -1063,12 +1063,23 @@ def from_xml_string(xml_string: str, strict: bool = True) -> List[Union[DidlObje # 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 `` without `xmlns:dlna`). + injections = " ".join( + f'xmlns:{prefix}="http://tempuri.org/{prefix}/"' + for prefix in sorted(missing_prefixes) + ) + xml_string = re.sub( + r" 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_music_track_artist_and_genre(self) -> None: """Test MusicTrack artist and genre properties.""" track = didl_lite.MusicTrack( From 5828b5e51be563a6748481463d4f53759b3a9fc6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:48:59 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- didl_lite/didl_lite.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/didl_lite/didl_lite.py b/didl_lite/didl_lite.py index b56f564..f48c6b8 100644 --- a/didl_lite/didl_lite.py +++ b/didl_lite/didl_lite.py @@ -1071,8 +1071,7 @@ def from_xml_string(xml_string: str, strict: bool = True) -> List[Union[DidlObje # declaration entirely (observed on JBL Authentics and # WiiM/LinkPlay players sending `` without `xmlns:dlna`). injections = " ".join( - f'xmlns:{prefix}="http://tempuri.org/{prefix}/"' - for prefix in sorted(missing_prefixes) + f'xmlns:{prefix}="http://tempuri.org/{prefix}/"' for prefix in sorted(missing_prefixes) ) xml_string = re.sub( r" Date: Thu, 13 Aug 2026 09:03:33 +0300 Subject: [PATCH 3/4] Handle prefixed DIDL-Lite root and NCName prefixes in unbound prefix recovery --- changes/52.bugfix.rst | 2 +- didl_lite/didl_lite.py | 18 +++++++------- tests/test_didl_lite.py | 53 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/changes/52.bugfix.rst b/changes/52.bugfix.rst index 8164c67..55f2866 100644 --- a/changes/52.bugfix.rst +++ b/changes/52.bugfix.rst @@ -1 +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``. +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 f48c6b8..ecd2d9e 100644 --- a/didl_lite/didl_lite.py +++ b/didl_lite/didl_lite.py @@ -1055,27 +1055,29 @@ 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"} if missing_prefixes: # Inject temporary namespace declarations into the DIDL-Lite root - # opening tag. Anchoring on `` 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" None: 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( From 77925492a8f70e22bc7dd644dded7a497aa38437 Mon Sep 17 00:00:00 2001 From: sfortis Date: Sat, 15 Aug 2026 12:49:18 +0300 Subject: [PATCH 4/4] Rename towncrier fragment to match PR number --- changes/{52.bugfix.rst => 53.bugfix.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changes/{52.bugfix.rst => 53.bugfix.rst} (100%) diff --git a/changes/52.bugfix.rst b/changes/53.bugfix.rst similarity index 100% rename from changes/52.bugfix.rst rename to changes/53.bugfix.rst