Repository: streamlink/streamlink

Issue:
Plugin: Rtve.es: Unable to parse XML: not well-formed (invalid token)
### Checklist

- [x] This is a bug report.

### Description

Rtve plugin always gives XML parsing error for all video urls.

### Reproduction steps / Explicit stream URLs to test

1. http://www.rtve.es/alacarta/videos/telediario/telediario-15-horas-26-03-18/4540424/
2. http://www.rtve.es/alacarta/videos/aguila-roja/aguila-roja-t9-capitulo-116/3771566/
3. http://www.rtve.es/directo/la-1

### Logs

```
[plugin.rtve][debug] Found content with id: 4540424
Plugin error: Unable to parse XML: not well-formed (invalid token): line 1, column 762 (b"<?xml version='1.0'?><quality><pr ...)

Process finished with exit code 1
```

### Comments, screenshots, etc.

xml contains `&` characters, replacing with `&amp;` gets it working.

Resolved by modifying streamlink\utils\__init__.py and adding `data = re.sub("&", "&amp;", data.decode('utf8'))`


```
[plugin.rtve][debug] Found content with id: 4540424
OrderedDict([('540p_http', <HTTPStream('http://mvod.lvlt.rtve.es/resources/TE_NGVA/mp4/3/8/1522075587483.mp4')>), ('360p_http', <HTTPStream('http://mvod.lvlt.rtve.es/resources/TE_NGVA/mp4/1/1/1522075683411.mp4')>), ('270p_http', <HTTPStream('http://mvod.lvlt.rtve.es/resources/TE_NGVA/mp4/3/0/1522075727303.mp4')>), ('270p_alt', <HLSStream('http://hlsvod.lvlt.rtve.es/resources/TE_NGVA/mp4/3/0/1522075727303.mp4/1522075727303-audio=48001-video=620000.m3u8?hls_minimum_fragment_length=6&hls_client_manifest_version=3')>), ('270p', <HLSStream('http://hlsvod2017b.akamaized.net/resources/TE_NGVA/mp4/3/0/1522075727303.mp4/1522075727303-audio=48001-video=620000.m3u8?hls_minimum_fragment_length=6&hls_client_manifest_version=3')>), ('360p_alt', <HLSStream('http://hlsvod.lvlt.rtve.es/resources/TE_NGVA/mp4/1/1/1522075683411.mp4/1522075683411-audio=64001-video=720000.m3u8?hls_minimum_fragment_length=6&hls_client_manifest_version=3')>), ('360p', <HLSStream('http://hlsvod2017b.akamaized.net/resources/TE_NGVA/mp4/1/1/1522075683411.mp4/1522075683411-audio=64001-video=720000.m3u8?hls_minimum_fragment_length=6&hls_client_manifest_version=3')>), ('540p_alt', <HLSStream('http://hlsvod.lvlt.rtve.es/resources/TE_NGVA/mp4/3/8/1522075587483.mp4/1522075587483-audio=64001-video=1400000.m3u8?hls_minimum_fragment_length=6&hls_client_manifest_version=3')>), ('540p', <HLSStream('http://hlsvod2017b.akamaized.net/resources/TE_NGVA/mp4/3/8/1522075587483.mp4/1522075587483-audio=64001-video=1400000.m3u8?hls_minimum_fragment_length=6&hls_client_manifest_version=3')>), ('worst', <HLSStream('http://hlsvod.lvlt.rtve.es/resources/TE_NGVA/mp4/3/0/1522075727303.mp4/1522075727303-audio=48001-video=620000.m3u8?hls_minimum_fragment_length=6&hls_client_manifest_version=3')>), ('best', <HLSStream('http://hlsvod2017b.akamaized.net/resources/TE_NGVA/mp4/3/8/1522075587483.mp4/1522075587483-audio=64001-video=1400000.m3u8?hls_minimum_fragment_length=6&hls_client_manifest_version=3')>)])

Process finished with exit code 0

```


**Don't know if this breaks other plugins as I only use this one.**



```
def parse_xml(data, name="XML", ignore_ns=False, exception=PluginError, schema=None):
    """Wrapper around ElementTree.fromstring with some extras

    Provides these extra features:
     - Handles incorrectly encoded XML
     - Allows stripping namespace information
     - Wraps errors in custom exception with a snippet of the data in the message
    """
    if is_py2 and isinstance(data, unicode):
        data = data.encode("utf8")

    if ignore_ns:
        data = re.sub(" xmlns=\"(.+?)\"", "", data)

    data = re.sub("&", "&amp;", data.decode('utf8'))

    try:
        tree = ET.fromstring(data)
    except Exception as err:
        snippet = repr(data)
        if len(snippet) > 35:
            snippet = snippet[:35] + " ..."

        raise exception("Unable to parse {0}: {1} ({2})".format(name, err, snippet))

    if schema:
        tree = schema.validate(tree, name=name, exception=exception)

    return tree

```


Discussion:
Hmm, that is annoying. Replacing `&` with `&amp;` will certainly break something else unfortunately... 
We might be able to replace any `&` that is not part of a character entity with `&amp;` though. 

Resolving patch:
diff --git a/src/streamlink/plugins/rtve.py b/src/streamlink/plugins/rtve.py
--- a/src/streamlink/plugins/rtve.py
+++ b/src/streamlink/plugins/rtve.py
@@ -1,5 +1,6 @@
 import base64
 import re
+from functools import partial
 
 from Crypto.Cipher import Blowfish
 
@@ -59,7 +60,7 @@ class Rtve(Plugin):
         https?://(?:www\.)?rtve\.es/(?:directo|noticias|television|deportes|alacarta|drmn)/.*?/?
     """, re.VERBOSE)
     cdn_schema = validate.Schema(
-        validate.transform(parse_xml),
+        validate.transform(partial(parse_xml, invalid_char_entities=True)),
         validate.xml_findall(".//preset"),
         [
             validate.union({
diff --git a/src/streamlink/utils/__init__.py b/src/streamlink/utils/__init__.py
--- a/src/streamlink/utils/__init__.py
+++ b/src/streamlink/utils/__init__.py
@@ -7,7 +7,7 @@
 except ImportError:  # pragma: no cover
     import xml.etree.ElementTree as ET
 
-from streamlink.compat import urljoin, urlparse, parse_qsl, is_py2, urlunparse
+from streamlink.compat import urljoin, urlparse, parse_qsl, is_py2, urlunparse, is_py3
 from streamlink.exceptions import PluginError
 from streamlink.utils.named_pipe import NamedPipe
 
@@ -67,7 +67,7 @@ def parse_json(data, name="JSON", exception=PluginError, schema=None):
     return json_data
 
 
-def parse_xml(data, name="XML", ignore_ns=False, exception=PluginError, schema=None):
+def parse_xml(data, name="XML", ignore_ns=False, exception=PluginError, schema=None, invalid_char_entities=False):
     """Wrapper around ElementTree.fromstring with some extras.
 
     Provides these extra features:
@@ -77,9 +77,14 @@ def parse_xml(data, name="XML", ignore_ns=False, exception=PluginError, schema=N
     """
     if is_py2 and isinstance(data, unicode):
         data = data.encode("utf8")
+    elif is_py3:
+        data = bytearray(data, "utf8")
 
     if ignore_ns:
-        data = re.sub(" xmlns=\"(.+?)\"", "", data)
+        data = re.sub(br" 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)
 
     try:
         tree = ET.fromstring(data)


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -76,6 +76,20 @@ def test_parse_xml_validate(self):
         self.assertEqual(expected.tag, actual.tag)
         self.assertEqual(expected.attrib, actual.attrib)
 
+    def test_parse_xml_entities_fail(self):
+        self.assertRaises(PluginError,
+                          parse_xml, u"""<test foo="bar &"/>""")
+
+
+    def test_parse_xml_entities(self):
+        expected = ET.Element("test", {"foo": "bar &"})
+        actual = parse_xml(u"""<test foo="bar &"/>""",
+                           schema=validate.Schema(xml_element(tag="test", attrib={"foo": text})),
+                           invalid_char_entities=True)
+        self.assertEqual(expected.tag, actual.tag)
+        self.assertEqual(expected.attrib, actual.attrib)
+
+
     def test_parse_qsd(self):
         self.assertEqual(
             {"test": "1", "foo": "bar"},