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
```

Comments:

Patch:
diff --git a/src/streamlink/plugins/crunchyroll.py b/src/streamlink/plugins/crunchyroll.py
index aebafe45..9f3bc698 100644
--- a/src/streamlink/plugins/crunchyroll.py
+++ b/src/streamlink/plugins/crunchyroll.py
@@ -10,7 +10,6 @@ from streamlink.stream import HLSStream
 
 log = logging.getLogger(__name__)
 
-
 STREAM_WEIGHTS = {
     "low": 240,
     "mid": 420,
@@ -72,7 +71,7 @@ _media_schema = validate.Schema(
     validate.get("stream_data")
 )
 _login_schema = validate.Schema({
-    "auth": validate.text,
+    "auth": validate.any(validate.text, None),
     "expires": validate.all(
         validate.text,
         validate.transform(parse_timestamp)
@@ -214,9 +213,16 @@ class CrunchyrollAPI(object):
         return login
 
     def authenticate(self):
-        data = self._api_call("authenticate", {"auth": self.auth}, schema=_login_schema)
-        self.auth = data["auth"]
-        self.cache.set("auth", data["auth"], expires_at=data["expires"])
+        try:
+            data = self._api_call("authenticate", {"auth": self.auth}, schema=_login_schema)
+        except CrunchyrollAPIError:
+            self.auth = None
+            self.cache.set("auth", None, expires_at=0)
+            log.warning("Saved credentials have expired")
+            return
+
+        log.debug("Credentials expire at: {}".format(data["expires"]))
+        self.cache.set("auth", self.auth, expires_at=data["expires"])
         return data
 
     def get_info(self, media_id, fields=None, schema=None):
@@ -321,7 +327,7 @@ class Crunchyroll(Plugin):
         # The adaptive quality stream sometimes a subset of all the other streams listed, ultra is no included
         has_adaptive = any([s[u"quality"] == u"adaptive" for s in info[u"streams"]])
         if has_adaptive:
-            self.logger.debug(u"Loading streams from adaptive playlist")
+            log.debug(u"Loading streams from adaptive playlist")
             for stream in filter(lambda x: x[u"quality"] == u"adaptive", info[u"streams"]):
                 for q, s in HLSStream.parse_variant_playlist(self.session, stream[u"url"]).items():
                     # rename the bitrates to low, mid, or high. ultra doesn't seem to appear in the adaptive streams
@@ -361,27 +367,28 @@ class Crunchyroll(Plugin):
                              locale=locale)
 
         if not self.get_option("session_id"):
-            self.logger.debug("Creating session with locale: {0}", locale)
+            log.debug("Creating session with locale: {0}", locale)
             api.start_session()
 
             if api.auth:
-                self.logger.debug("Using saved credentials")
+                log.debug("Using saved credentials")
                 login = api.authenticate()
-                self.logger.info("Successfully logged in as '{0}'",
-                                 login["user"]["username"] or login["user"]["email"])
-            elif self.options.get("username"):
+                if login:
+                    log.info("Successfully logged in as '{0}'",
+                             login["user"]["username"] or login["user"]["email"])
+            if not api.auth and self.options.get("username"):
                 try:
-                    self.logger.debug("Attempting to login using username and password")
+                    log.debug("Attempting to login using username and password")
                     api.login(self.options.get("username"),
                               self.options.get("password"))
                     login = api.authenticate()
-                    self.logger.info("Logged in as '{0}'",
-                                     login["user"]["username"] or login["user"]["email"])
+                    log.info("Logged in as '{0}'",
+                             login["user"]["username"] or login["user"]["email"])
 
                 except CrunchyrollAPIError as err:
                     raise PluginError(u"Authentication error: {0}".format(err.msg))
-            else:
-                self.logger.warning(
+            if not api.auth:
+                log.warning(
                     "No authentication provided, you won't be able to access "
                     "premium restricted content"
                 )
diff --git a/src/streamlink/plugins/nicolive.py b/src/streamlink/plugins/nicolive.py
index a03f4984..9393d1dc 100644
--- a/src/streamlink/plugins/nicolive.py
+++ b/src/streamlink/plugins/nicolive.py
@@ -200,7 +200,7 @@ class NicoLive(Plugin):
             "type": "startWatching",
             "data": {
                 "stream": {
-                    "quality": "high",
+                    "quality": "abr",
                     "protocol": "hls",
                     "latency": "high",
                     "chasePlay": False
diff --git a/src/streamlink/plugins/sportschau.py b/src/streamlink/plugins/sportschau.py
index 1b6d1599..e7309433 100644
--- 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
index e0ecbe39..37b008d1 100644
--- 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))