Repository: streamlink/streamlink

Issue:
sportschau plugin fails with "Unable to parse manifest XML" error

## Plugin Issue


- [x ] This is a plugin issue and I have read the contribution guidelines.


### Description

streamlink errors out when trying to watch a stream on sportschau.de, e.g. https://www.sportschau.de/tourdefrance/live/videostream-livestream---die--etappe-der-tour-de-france-nach-privas-100.html. It errors out with: "error: Unable to parse manifest XML: syntax error: line 1, column 0 (b'#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X ...)"

### Reproduction steps / Explicit stream URLs to test

1. streamlink "https://www.sportschau.de/tourdefrance/live/videostream-livestream---die--etappe-der-tour-de-france-nach-privas-100.html"


### Log output

```
[14:25:23,464][cli][debug] OS:         Linux-5.8.4-x86_64-with-glibc2.2.5
[14:25:23,464][cli][debug] Python:     3.8.5
[14:25:23,464][cli][debug] Streamlink: 1.5.0
[14:25:23,464][cli][debug] Requests(2.24.0), Socks(1.7.1), Websocket(0.57.0)
[14:25:23,465][cli][info] Found matching plugin sportschau for URL https://www.sportschau.de/tourdefrance/live/videostream-livestream---die--etappe-der-tour-de-france-nach-privas-100.html
[14:25:23,734][plugin.sportschau][info] Found player js http://deviceids-medp.wdr.de/ondemand/221/2214170.js
error: Unable to parse manifest XML: syntax error: line 1, column 0 (b'#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X ...)
```


### Additional comments, screenshots, etc.

Not sure that I understand the cause of the error, especially as the problematic part seems truncated. This is what the .m3u file looks like:

```
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=5388416,AVERAGE-BANDWIDTH=4048000,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512/ardevent2_geo/master_3680.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5388416,AVERAGE-BANDWIDTH=4048000,CODECS="avc1.640020,mp4a.40.2",RESOLUTION=1280x720,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512-b/ardevent2_geo/master_3680.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2758800,AVERAGE-BANDWIDTH=2085600,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=960x540,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512/ardevent2_geo/master_1896.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2758800,AVERAGE-BANDWIDTH=2085600,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=960x540,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512-b/ardevent2_geo/master_1896.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1614976,AVERAGE-BANDWIDTH=1232000,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512/ardevent2_geo/master_1120.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1614976,AVERAGE-BANDWIDTH=1232000,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=640x360,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512-b/ardevent2_geo/master_1120.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=860288,AVERAGE-BANDWIDTH=668800,CODECS="avc1.77.30,mp4a.40.2",RESOLUTION=512x288,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512/ardevent2_geo/master_608.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=860288,AVERAGE-BANDWIDTH=668800,CODECS="avc1.77.30,mp4a.40.2",RESOLUTION=512x288,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512-b/ardevent2_geo/master_608.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=482944,AVERAGE-BANDWIDTH=387200,CODECS="avc1.66.30,mp4a.40.2",RESOLUTION=480x270,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512/ardevent2_geo/master_352.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=482944,AVERAGE-BANDWIDTH=387200,CODECS="avc1.66.30,mp4a.40.2",RESOLUTION=480x270,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512-b/ardevent2_geo/master_352.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=294272,AVERAGE-BANDWIDTH=246400,CODECS="avc1.42c015,mp4a.40.2",RESOLUTION=320x180,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512/ardevent2_geo/master_224.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=294272,AVERAGE-BANDWIDTH=246400,CODECS="avc1.42c015,mp4a.40.2",RESOLUTION=320x180,FRAME-RATE=50.000
https://ardevent2.akamaized.net/hls/live/681512-b/ardevent2_geo/master_224.m3u8
```


Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/sportschau.py b/src/streamlink/plugins/sportschau.py
--- a/src/streamlink/plugins/sportschau.py
+++ b/src/streamlink/plugins/sportschau.py
@@ -1,46 +1,52 @@
-import re
-import json
-
-from streamlink.plugin import Plugin
-from streamlink.stream import HDSStream
-from streamlink.utils import update_scheme
-
-_url_re = re.compile(r"http(s)?://(\w+\.)?sportschau.de/")
-_player_js = re.compile(r"https?://deviceids-medp.wdr.de/ondemand/.*\.js")
-
-
-class sportschau(Plugin):
-    @classmethod
-    def can_handle_url(cls, url):
-        return _url_re.match(url)
-
-    def _get_streams(self):
-        res = self.session.http.get(self.url)
-        match = _player_js.search(res.text)
-        if match:
-            player_js = match.group(0)
-            self.logger.info("Found player js {0}", player_js)
-        else:
-            self.logger.info("Didn't find player js. Probably this page doesn't contain a video")
-            return
-
-        res = self.session.http.get(player_js)
-
-        jsonp_start = res.text.find('(') + 1
-        jsonp_end = res.text.rfind(')')
-
-        if jsonp_start <= 0 or jsonp_end <= 0:
-            self.logger.info("Couldn't extract json metadata from player.js: {0}", player_js)
-            return
-
-        json_s = res.text[jsonp_start:jsonp_end]
-
-        stream_metadata = json.loads(json_s)
-
-        hds_url = stream_metadata['mediaResource']['dflt']['videoURL']
-        hds_url = update_scheme(self.url, hds_url)
-
-        return HDSStream.parse_manifest(self.session, hds_url).items()
-
-
-__plugin__ = sportschau
+import logging
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+from streamlink.utils import parse_json, update_scheme
+
+log = logging.getLogger(__name__)
+
+
+class Sportschau(Plugin):
+    _re_url = re.compile(r"https?://(?:\w+\.)*sportschau.de/")
+
+    _re_player = re.compile(r"https?:(//deviceids-medp.wdr.de/ondemand/\S+\.js)")
+    _re_json = re.compile(r"\$mediaObject.jsonpHelper.storeAndPlay\(({.+})\);?")
+
+    _schema_player = validate.Schema(
+        validate.transform(_re_player.search),
+        validate.any(None, validate.Schema(
+            validate.get(1),
+            validate.transform(lambda url: update_scheme("https:", url))
+        ))
+    )
+    _schema_json = validate.Schema(
+        validate.transform(_re_json.match),
+        validate.get(1),
+        validate.transform(parse_json),
+        validate.get("mediaResource"),
+        validate.get("dflt"),
+        validate.get("videoURL"),
+        validate.transform(lambda url: update_scheme("https:", url))
+    )
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._re_url.match(url) is not None
+
+    def _get_streams(self):
+        player_js = self.session.http.get(self.url, schema=self._schema_player)
+        if not player_js:
+            return
+
+        log.debug("Found player js {0}".format(player_js))
+
+        hls_url = self.session.http.get(player_js, schema=self._schema_json)
+
+        for stream in HLSStream.parse_variant_playlist(self.session, hls_url).items():
+            yield stream
+
+
+__plugin__ = Sportschau


Test patch:
diff --git a/tests/plugins/test_sportschau.py b/tests/plugins/test_sportschau.py
--- a/tests/plugins/test_sportschau.py
+++ b/tests/plugins/test_sportschau.py
@@ -1,20 +1,20 @@
 import unittest
 
-from streamlink.plugins.sportschau import sportschau
+from streamlink.plugins.sportschau import Sportschau
 
 
 class TestPluginSportschau(unittest.TestCase):
     def test_can_handle_url(self):
         should_match = [
             'http://www.sportschau.de/wintersport/videostream-livestream---wintersport-im-ersten-242.html',
-            'http://www.sportschau.de/weitere/allgemein/video-kite-surf-world-tour-100.html',
+            'https://www.sportschau.de/weitere/allgemein/video-kite-surf-world-tour-100.html',
         ]
         for url in should_match:
-            self.assertTrue(sportschau.can_handle_url(url))
+            self.assertTrue(Sportschau.can_handle_url(url))
 
     def test_can_handle_url_negative(self):
         should_not_match = [
             'https://example.com/index.html',
         ]
         for url in should_not_match:
-            self.assertFalse(sportschau.can_handle_url(url))
+            self.assertFalse(Sportschau.can_handle_url(url))