Repository: streamlink/streamlink

Issue:
dash plugin: MPDParsingError: root tag did not match the expected tag: MPD
### Checklist

- [X] This is a plugin issue and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [ ] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest build from the master branch

### Description

The following steps worked until yesterday:

1. Connect to a channel livestream of Teleboy (teleboy.ch) or Zattoo (zattoo.com) with my web browser.
2. Get the .mpd URL link of the livestream.
3. Launch the command "streamlink "mpd-url-link best".
4. The livestream opens up in VLC.
 
Since today, the command fails.

Here is an example of the .mpd file content which streamlink fails to play: https://pastebin.com/jXD1NY1F


### Debug log

```text
streamlink "https://zh2-0-dash-live.zahs.tv/HD_orf1/m.mpd?z32=MF2WI2LPL5RW6ZDFMNZT2YLBMMTGG43JMQ6TCNSBII4DSMJSIZBTEMJVIQ2EKLJUGEZDORJSGVCDKOBTIE4ECOKEEZWWC6DSMF2GKPJVGAYDAJTNNFXHEYLUMU6TEMJQEZYHEZLGMVZHEZLEL5WGC3THOVQWOZJ5MZZCM43JM46TSX3FG4YDGMJXG5RGIMRVGNSTCMLBGI4GEMZRG5SDQN3DGRSGGYJXGETHK43FOJPWSZB5ORSWYZLCN54TUORYGM4DSMRXEZ3D2MA" best --loglevel debug
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.9.6
[cli][debug] Streamlink: 2.4.0
[cli][debug] Requests(2.26.0), Socks(1.7.1), Websocket(1.2.1)
[cli][debug] Arguments:
[cli][debug]  url=https://zh2-0-dash-live.zahs.tv/HD_orf1/m.mpd?z32=MF2WI2LPL5RW6ZDFMNZT2YLBMMTGG43JMQ6TCNSBII4DSMJSIZBTEMJVIQ2EKLJUGEZDORJSGVCDKOBTIE4ECOKEEZWWC6DSMF2GKPJVGAYDAJTNNFXHEYLUMU6TEMJQEZYHEZLGMVZHEZLEL5WGC3THOVQWOZJ5MZZCM43JM46TSX3FG4YDGMJXG5RGIMRVGNSTCMLBGI4GEMZRG5SDQN3DGRSGGYJXGETHK43FOJPWSZB5ORSWYZLCN54TUORYGM4DSMRXEZ3D2MA
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --rtmp-rtmpdump=rtmpdump.exe
[cli][debug]  --rtmpdump=C:\Program Files (x86)\Streamlink\rtmpdump\rtmpdump.exe
[cli][debug]  --ffmpeg-ffmpeg=C:\Program Files (x86)\Streamlink\ffmpeg\ffmpeg.exe
[cli][info] Found matching plugin dash for URL https://zh2-0-dash-live.zahs.tv/HD_orf1/m.mpd?z32=MF2WI2LPL5RW6ZDFMNZT2YLBMMTGG43JMQ6TCNSBII4DSMJSIZBTEMJVIQ2EKLJUGEZDORJSGVCDKOBTIE4ECOKEEZWWC6DSMF2GKPJVGAYDAJTNNFXHEYLUMU6TEMJQEZYHEZLGMVZHEZLEL5WGC3THOVQWOZJ5MZZCM43JM46TSX3FG4YDGMJXG5RGIMRVGNSTCMLBGI4GEMZRG5SDQN3DGRSGGYJXGETHK43FOJPWSZB5ORSWYZLCN54TUORYGM4DSMRXEZ3D2MA
[plugins.dash][debug] Parsing MPD URL: https://zh2-0-dash-live.zahs.tv/HD_orf1/m.mpd?z32=MF2WI2LPL5RW6ZDFMNZT2YLBMMTGG43JMQ6TCNSBII4DSMJSIZBTEMJVIQ2EKLJUGEZDORJSGVCDKOBTIE4ECOKEEZWWC6DSMF2GKPJVGAYDAJTNNFXHEYLUMU6TEMJQEZYHEZLGMVZHEZLEL5WGC3THOVQWOZJ5MZZCM43JM46TSX3FG4YDGMJXG5RGIMRVGNSTCMLBGI4GEMZRG5SDQN3DGRSGGYJXGETHK43FOJPWSZB5ORSWYZLCN54TUORYGM4DSMRXEZ3D2MA
Traceback (most recent call last):
  File "runpy.py", line 197, in _run_module_as_main
  File "runpy.py", line 87, in _run_code
  File "C:\Program Files (x86)\Streamlink\bin\streamlink.exe\__main__.py", line 18, in <module>
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink_cli\main.py", line 1082, in main
    handle_url()
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink_cli\main.py", line 576, in handle_url
    streams = fetch_streams(plugin)
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink_cli\main.py", line 470, in fetch_streams
    return plugin.streams(stream_types=args.stream_types,
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\plugin\plugin.py", line 335, in streams
    ostreams = self._get_streams()
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\plugins\dash.py", line 34, in _get_streams
    return DASHStream.parse_manifest(self.session, url)
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\stream\dash.py", line 185, in parse_manifest
    mpd = MPD(session.http.xml(res, ignore_ns=True), base_url=urlunparse(urlp), url=url)
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\stream\dash_manifest.py", line 224, in __init__
    super().__init__(node, root=self, *args, **kwargs)
  File "C:\Program Files (x86)\Streamlink\pkgs\streamlink\stream\dash_manifest.py", line 146, in __init__
    raise MPDParsingError("root tag did not match the expected tag: {}".format(self.__tag__))
streamlink.stream.dash_manifest.MPDParsingError: root tag did not match the expected tag: MPD
```



Discussion:
Looks like a bug in the DASH manifest parser / XML parser.

> ```xml
> <MPD xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
>  xmlns="urn:mpeg:dash:schema:mpd:2011"
>  ...
> ```

The root node's tag value includes the default namespace, which is `{urn:mpeg:dash:schema:mpd:2011}MPD`), and it doesn't match the expected `MPD` string.
https://github.com/streamlink/streamlink/blob/e38caa19a12363c60be1d73629543b810b6b9c99/src/streamlink/stream/dash_manifest.py#L145-L146

There's been some code refactoring in the last couple of weeks and Streamlink also switched to `lxml` for parsing XML (that shouldn't cause an issue though).

In the recently updated [`parse_xml`](https://github.com/streamlink/streamlink/blob/e38caa19a12363c60be1d73629543b810b6b9c99/src/streamlink/utils/parse.py#L78-L79) method (which is called by [`DASHStream.parse_manifest`](https://github.com/streamlink/streamlink/blob/e38caa19a12363c60be1d73629543b810b6b9c99/src/streamlink/stream/dash.py#L185) via `session.http.xml`) its `ignore_ns` parameter is set to True, which means it's supposed to strip off the default namespace attribute. It's possible that this is not working correctly. Let me have a look.

The DASH tests strip off the namespace information from the root node's tag value with custom logic, which is probably the reason why this issue hasn't been caught:
- https://github.com/streamlink/streamlink/blob/e38caa19a12363c60be1d73629543b810b6b9c99/tests/resources/__init__.py#L17-L19
- https://github.com/streamlink/streamlink/blob/e38caa19a12363c60be1d73629543b810b6b9c99/tests/resources/dash/test_1.mpd#L2
- https://github.com/streamlink/streamlink/blob/e38caa19a12363c60be1d73629543b810b6b9c99/tests/stream/test_dash_parser.py#L73-L74
Looks like the issue is the regex for stripping off the default namespace attribute. It's expecting a leading space or tab character, which is not the case in your input manifest, where it's using a new-line character instead. The regex did not get changed though in the recent code refactorings, which means the issue was already present in earlier versions of Streamlink.
https://github.com/streamlink/streamlink/blob/e38caa19a12363c60be1d73629543b810b6b9c99/src/streamlink/utils/parse.py#L79

That should fix it:
```diff
diff --git a/src/streamlink/utils/parse.py b/src/streamlink/utils/parse.py
index 8165a2c3..2a4de948 100644
--- a/src/streamlink/utils/parse.py
+++ b/src/streamlink/utils/parse.py
@@ -76,7 +76,7 @@ def parse_xml(
     if isinstance(data, str):
         data = bytes(data, "utf8")
     if ignore_ns:
-        data = re.sub(br"[\t ]xmlns=\"(.+?)\"", b"", data)
+        data = re.sub(br"\s+xmlns=\"(.+?)\"", b"", data)
     if invalid_char_entities:
         data = re.sub(br"&(?!(?:#(?:[0-9]+|[Xx][0-9A-Fa-f]+)|[A-Za-z0-9]+);)", b"&amp;", data)
```

Can you confirm this? It's parsing the manifest correctly for me.
```
$ streamlink -l debug 'dash://file://./foo'
[cli][debug] OS:         Linux-5.14.8-1-git-x86_64-with-glibc2.33
[cli][debug] Python:     3.9.7
[cli][debug] Streamlink: 2.4.0+37.ge38caa1.dirty
[cli][debug] Requests(2.26.0), Socks(1.7.1), Websocket(0.58.0)
[cli][debug] Arguments:
[cli][debug]  url=dash://file://./foo
[cli][debug]  --loglevel=debug
[cli][debug]  --player=mpv
[cli][info] Found matching plugin dash for URL dash://file://./foo
[plugins.dash][debug] Parsing MPD URL: file://./foo
[utils.l10n][debug] Language code: en_US
[stream.dash][debug] Available languages for DASH audio streams: mis, de (using: de)
Available streams: 224p (worst), 288p_alt, 288p, 432p, 720p_alt, 720p (best)
```

Resolving patch:
diff --git a/src/streamlink/utils/parse.py b/src/streamlink/utils/parse.py
--- a/src/streamlink/utils/parse.py
+++ b/src/streamlink/utils/parse.py
@@ -76,7 +76,7 @@ def parse_xml(
     if isinstance(data, str):
         data = bytes(data, "utf8")
     if ignore_ns:
-        data = re.sub(br"[\t ]xmlns=\"(.+?)\"", b"", data)
+        data = re.sub(br"\s+xmlns=\"(.+?)\"", b"", data)
     if invalid_char_entities:
         data = re.sub(br"&(?!(?:#(?:[0-9]+|[Xx][0-9A-Fa-f]+)|[A-Za-z0-9]+);)", b"&amp;", data)
 


Test patch:
diff --git a/tests/utils/test_parse.py b/tests/utils/test_parse.py
--- a/tests/utils/test_parse.py
+++ b/tests/utils/test_parse.py
@@ -29,12 +29,14 @@ def test_parse_xml_ns_ignore(self):
         self.assertEqual(expected.tag, actual.tag)
         self.assertEqual(expected.attrib, actual.attrib)
 
-    def test_parse_xml_ns_ignore_tab(self):
-        expected = Element("test", {"foo": "bar"})
         actual = parse_xml("""<test	foo="bar"	xmlns="foo:bar"/>""", ignore_ns=True)
         self.assertEqual(expected.tag, actual.tag)
         self.assertEqual(expected.attrib, actual.attrib)
 
+        actual = parse_xml("""<test\nfoo="bar"\nxmlns="foo:bar"/>""", ignore_ns=True)
+        self.assertEqual(expected.tag, actual.tag)
+        self.assertEqual(expected.attrib, actual.attrib)
+
     def test_parse_xml_ns(self):
         expected = Element("{foo:bar}test", {"foo": "bar"})
         actual = parse_xml("""<h:test foo="bar" xmlns:h="foo:bar"/>""")