Fix unbound prefix recovery when xmlns:dlna is absent - #53
Conversation
The strict=False recovery added in StevenLooman#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.
for more information, see https://pre-commit.ci
StevenLooman
left a comment
There was a problem hiding this comment.
Thank you for this PR @sfortis. Apologies for not replying earlier, I must have missed the notification mail.
| f'xmlns:{prefix}="http://tempuri.org/{prefix}/"' for prefix in sorted(missing_prefixes) | ||
| ) | ||
| xml_string = re.sub( | ||
| r"<DIDL-Lite\b", |
There was a problem hiding this comment.
I think a DIDL-Lite XML can use a namespace prefix. E.g., <dlna:DIDL-Lite .... Then this won't work.
Perhaps it should match on <(\w+:)?DIDL-Lite\b.
There was a problem hiding this comment.
That is a real gap, thanks for catching it. I confirmed it against the branch: a document with
a prefixed root fails with unbound prefix exactly as before the fix, because <DIDL-Lite\b
never matches <didl:DIDL-Lite.
One detail came up while applying your suggestion. Changing only the pattern leaves the
replacement as a fixed string, so the prefix is dropped and the opening tag stops matching its
closing tag. The match therefore has to be captured and written back:
xml_string = re.sub(
r"<((?:[A-Za-z_][\w.-]*:)?DIDL-Lite)\b",
lambda match: f"<{match.group(1)} {injections}",
xml_string,
count=1,
)I used a callable rather than a string replacement so that the injected declarations are not run
through the backreference expansion of re.sub.
The prefix class is [A-Za-z_][\w.-]* rather than \w+ because a namespace prefix is an NCName,
which allows -, . and _ after the first character.
I ran the candidates over the same inputs to check both points:
root form | current branch | pattern only | captured, \w | captured, NCName
-----------------|----------------|-----------------|----------------|------------------
<DIDL-Lite> | pass | pass | pass | pass
<didl:DIDL-Lite> | unbound prefix | mismatched tag | pass | pass
<my-ns:DIDL-Lite>| unbound prefix | unbound prefix | unbound prefix | pass
While checking that, I noticed the two detection regexes above have the same limitation, so a
prefix like <my-song:subTitle> is never even reported as missing and recovery does not run at
all, regardless of the root form. Since this PR is about unbound prefix recovery I made all three
consistent:
used_prefixes = set(re.findall(r"<([A-Za-z_][\w.-]*):", xml_string))
defined_prefixes = set(re.findall(r"xmlns:([A-Za-z_][\w.-]*)=", xml_string))Happy to split that part into its own PR if you would rather keep this one scoped to the anchor.
Two tests are added, one for the prefixed root and one for prefixes containing -, . and _.
Both fail on the previous code and pass now, and the existing suite is unaffected.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #53 +/- ##
==========================================
+ Coverage 94.60% 94.82% +0.21%
==========================================
Files 3 3
Lines 408 406 -2
Branches 46 45 -1
==========================================
- Hits 386 385 -1
Misses 12 12
+ Partials 10 9 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Looks good to me. Can you add a Towncrier fragment, as is required for PRs? |
|
Oops, the fragment was already there but with the wrong name. Fixed. |
Summary
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 the path was otherwise supposed to recover.
Observable today: `python-didl-lite==1.5.0` is shipped via `async-upnp-client` into Music Assistant, where JBL Authentics 200 owners still see ~100 `unbound prefix` errors per hour and a broken DLNA state-sync loop (downstream tracking: music-assistant/support#4398).
Fix
Anchor the namespace injection on the DIDL-Lite root opening tag (`<DIDL-Lite`) instead of the optional `xmlns:dlna` declaration. The root element is guaranteed to be present in any valid DIDL-Lite document, so recovery works regardless of which namespace declarations the producer chose to include.
Also batches all missing prefix injections into a single regex substitution rather than looping per-prefix.
Test
Added `test_from_xml_string_unbound_prefix_without_dlna_namespace` that exercises the no-`xmlns:dlna` case. The existing `test_from_xml_string_unbound_prefix` (with `xmlns:dlna` present) still passes.
```
$ python3 -m pytest tests/ -v
...
26 passed in 0.18s
```
`ruff check` clean.
Compatibility
(Replaces #52, which was accidentally opened from the wrong account.)