Repository: streamlink/streamlink
Pull request #3325 (2020-11-08 11:21:16)

plugins.huya: use FLV stream with multiple mirrors
Not every mirror works good for every user location,
the users must select the mirror which works for them.

```
$ streamlink https://www.huya.com/kpl -l info
[cli][info] Found matching plugin huya for URL https://www.huya.com/kpl
[cli][info] Available streams: source_al13, source_al (worst), source_tx, source_bd, source_tx15 (best)
[cli][info] Opening stream: source_tx15 (http)
```
closes https://github.com/streamlink/streamlink/issues/3317


Patch:
diff --git a/docs/install.rst b/docs/install.rst
index 59382d5b..a9dc093d 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -177,6 +177,7 @@ Windows port. version                RosadinTV <RosadinTV at outlook.com> |br|
                                      beardypig <beardypig at protonmail.com>
 ==================================== ===========================================
 
+.. _PyPI package and source code:
 
 PyPI package and source code
 ----------------------------
@@ -285,6 +286,8 @@ using `virtualenv`_, which creates a user owned Python environment instead.
 
 .. _virtualenv: https://virtualenv.readthedocs.io/en/latest/
 
+.. _Dependencies:
+
 Dependencies
 ^^^^^^^^^^^^
 
@@ -357,6 +360,17 @@ Release                              Notes
                                      See the `commit log`_ for a list of changes since the last stable release.
 ==================================== ====================================
 
+.. note::
+
+    **The Streamlink installer for Windows is currently based on Python 3.8.** |br|
+    Versions of Windows prior to 7 are not supported.
+
+    Be aware that the packages for `Chocolatey`_ and the `Windows Package Manager`_ are just wrappers
+    around the stable installer and thus depend on Windows 7+ as well.
+
+    Alternatively, :ref:`Streamlink can be installed via python-pip<PyPI package and source code>`
+    in a :ref:`compatible<Dependencies>` Python environment.
+
 .. _Stable release:
 .. _GitHub releases page: https://github.com/streamlink/streamlink/releases/latest
 .. _Development build:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 06548f0f..ec807a79 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -97,7 +97,7 @@ hitbox                  - hitbox.tv          Yes   Yes
 huajiao                 huajiao.com          Yes   No
 huomao                  - huomao.com         Yes   Yes
                         - huomao.tv
-huya                    huya.com             Yes   No    Temporarily only HLS streams available.
+huya                    huya.com             Yes   No
 idf1                    idf1.fr              Yes   Yes
 ine                     ine.com              ---   Yes
 invintus                player.invintus.com  Yes   Yes
diff --git a/script/makeinstaller.sh b/script/makeinstaller.sh
index 6fd15a8f..38558e8b 100755
--- a/script/makeinstaller.sh
+++ b/script/makeinstaller.sh
@@ -33,7 +33,7 @@ cd "${ROOT}"
 STREAMLINK_VERSION=$(python setup.py --version)
 STREAMLINK_VERSION_PLAIN="${STREAMLINK_VERSION%%+*}"
 STREAMLINK_INSTALLER="${1:-"streamlink-${STREAMLINK_VERSION/\+/_}"}"
-STREAMLINK_PYTHON_VERSION=3.9.0
+STREAMLINK_PYTHON_VERSION=3.8.6
 STREAMLINK_ASSETS_REPO="${STREAMLINK_ASSETS_REPO:-streamlink/streamlink-assets}"
 STREAMLINK_ASSETS_RELEASE="${STREAMLINK_ASSETS_RELEASE:-latest}"
 
diff --git a/src/streamlink/plugins/cdnbg.py b/src/streamlink/plugins/cdnbg.py
index 38f77cd3..58351a5d 100644
--- a/src/streamlink/plugins/cdnbg.py
+++ b/src/streamlink/plugins/cdnbg.py
@@ -1,5 +1,6 @@
 import logging
 import re
+from html import unescape as html_unescape
 from urllib.parse import urlparse
 
 from streamlink.plugin import Plugin
@@ -43,29 +44,24 @@ class CDNBG(Plugin):
     def find_iframe(self, url):
         self.session.http.headers.update({"User-Agent": useragents.CHROME})
         res = self.session.http.get(self.url)
-        p = urlparse(url)
         for iframe_url in self.iframe_re.findall(res.text):
             if "googletagmanager" not in iframe_url:
-                log.debug(f"Found iframe: {iframe_url}")
-                iframe_url = iframe_url.replace("&#58;", ":")
-                if iframe_url.startswith("//"):
-                    return update_scheme(p.scheme, iframe_url)
-                else:
-                    return iframe_url
+                iframe_url = html_unescape(iframe_url)
+                return update_scheme(self.url, iframe_url)
 
     def _get_streams(self):
-        if "i.cdn.bg/live/" in self.url:
+        if "cdn.bg" in urlparse(self.url).netloc:
             iframe_url = self.url
         else:
             iframe_url = self.find_iframe(self.url)
 
-        if iframe_url:
-            res = self.session.http.get(iframe_url, headers={"Referer": self.url})
-            stream_url = update_scheme(self.url, self.stream_schema.validate(res.text))
-            log.warning("SSL Verification disabled.")
-            return HLSStream.parse_variant_playlist(self.session,
-                                                    stream_url,
-                                                    verify=False)
+        log.debug(f"Found iframe: {iframe_url}")
+        res = self.session.http.get(iframe_url, headers={"Referer": self.url})
+        stream_url = update_scheme(self.url, self.stream_schema.validate(res.text))
+        log.warning("SSL Verification disabled.")
+        return HLSStream.parse_variant_playlist(self.session,
+                                                stream_url,
+                                                verify=False)
 
 
 __plugin__ = CDNBG
diff --git a/src/streamlink/plugins/huya.py b/src/streamlink/plugins/huya.py
index f2ed8100..52a07aa5 100644
--- a/src/streamlink/plugins/huya.py
+++ b/src/streamlink/plugins/huya.py
@@ -1,37 +1,76 @@
+import base64
+import logging
 import re
+from html import unescape as html_unescape
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents, validate
-from streamlink.stream import HLSStream
-from streamlink.utils import update_scheme
+from streamlink.plugin.api import validate
+from streamlink.stream import HTTPStream
+from streamlink.utils import parse_json
 
-HUYA_URL = "http://m.huya.com/%s"
+log = logging.getLogger(__name__)
 
-_url_re = re.compile(r'https?://(www\.)?huya.com/(?P<channel>[^/]+)')
-_hls_re = re.compile(r'liveLineUrl\s*=\s*"(?P<url>[^"]+)"')
 
-_hls_schema = validate.Schema(
-    validate.transform(_hls_re.search),
-    validate.any(None, validate.get("url")),
-    validate.transform(lambda v: update_scheme("http://", v)),
-    validate.url()
-)
+class Huya(Plugin):
+    _re_url = re.compile(r'https?://(?:www\.)?huya\.com/(?P<channel>[^/]+)')
+    _re_stream = re.compile(r'"stream"\s?:\s?"([^"]+)"')
+    _schema_data = validate.Schema(
+        {
+            # 'status': int,
+            # 'msg': validate.any(None, str),
+            'data': [{
+                'gameStreamInfoList': [{
+                    'sCdnType': str,
+                    'sStreamName': str,
+                    'sFlvUrl': str,
+                    'sFlvUrlSuffix': str,
+                    'sFlvAntiCode': validate.all(str, validate.transform(lambda v: html_unescape(v))),
+                    # 'sHlsUrl': str,
+                    # 'sHlsUrlSuffix': str,
+                    # 'sHlsAntiCode': validate.all(str, validate.transform(lambda v: html_unescape(v))),
+                    validate.optional('iIsMultiStream'): int,
+                    'iPCPriorityRate': int,
+                }]
+            }],
+            # 'vMultiStreamInfo': [{
+            #    'sDisplayName': str,
+            #    'iBitRate': int,
+            # }],
+        },
+        validate.get('data'),
+        validate.get(0),
+        validate.get('gameStreamInfoList'),
+    )
+    QUALITY_WEIGHTS = {}
 
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._re_url.match(url) is not None
 
-class Huya(Plugin):
     @classmethod
-    def can_handle_url(self, url):
-        return _url_re.match(url)
+    def stream_weight(cls, key):
+        weight = cls.QUALITY_WEIGHTS.get(key)
+        if weight:
+            return weight, 'huya'
+
+        return Plugin.stream_weight(key)
 
     def _get_streams(self):
-        match = _url_re.match(self.url)
-        channel = match.group("channel")
+        res = self.session.http.get(self.url)
+        data = self._re_stream.search(res.text)
+
+        if not data:
+            return
 
-        self.session.http.headers.update({"User-Agent": useragents.IPAD})
-        # Some problem with SSL on huya.com now, do not use https
+        data = parse_json(base64.b64decode(data.group(1)), schema=self._schema_data)
+        for info in data:
+            log.trace(f'{info!r}')
+            flv_url = f'{info["sFlvUrl"]}/{info["sStreamName"]}.{info["sFlvUrlSuffix"]}?{info["sFlvAntiCode"]}'
+            name = f'source_{info["sCdnType"].lower()}'
+            self.QUALITY_WEIGHTS[name] = info['iPCPriorityRate']
+            yield name, HTTPStream(self.session, flv_url)
 
-        hls_url = self.session.http.get(HUYA_URL % channel, schema=_hls_schema)
-        yield "live", HLSStream(self.session, hls_url)
+        log.debug(f'QUALITY_WEIGHTS: {self.QUALITY_WEIGHTS!r}')
 
 
 __plugin__ = Huya
diff --git a/src/streamlink/plugins/nrk.py b/src/streamlink/plugins/nrk.py
index a0454215..09c39020 100644
--- a/src/streamlink/plugins/nrk.py
+++ b/src/streamlink/plugins/nrk.py
@@ -1,64 +1,106 @@
+import logging
 import re
 from urllib.parse import urljoin
 
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import validate
-from streamlink.stream import HLSStream
-
-COOKIE_PARAMS = (
-    "devicetype=desktop&"
-    "preferred-player-odm=hlslink&"
-    "preferred-player-live=hlslink"
-)
-
-_id_re = re.compile(r"/(?:program|direkte|serie/[^/]+)/([^/]+)")
-_url_re = re.compile(r"https?://(tv|radio).nrk.no/")
-_api_baseurl_re = re.compile(r'''apiBaseUrl:\s*["'](?P<baseurl>[^"']+)["']''')
-
-_schema = validate.Schema(
-    validate.transform(_api_baseurl_re.search),
-    validate.any(
-        None,
-        validate.all(
-            validate.get("baseurl"),
-            validate.url(
-                scheme="http"
-            )
-        )
-    )
-)
-
-_mediaelement_schema = validate.Schema({
-    "mediaUrl": validate.url(
-        scheme="http",
-        path=validate.endswith(".m3u8")
-    )
-})
+from streamlink.stream import HLSStream, HTTPStream
+
+log = logging.getLogger(__name__)
 
 
 class NRK(Plugin):
+    _psapi_url = 'https://psapi.nrk.no'
+    # Program type to manifest type mapping
+    _program_type_map = {
+        'direkte': 'channel',
+        'serie': 'program',
+        'program': 'program',
+        'podkast': 'podcast',
+    }
+
+    _program_id_re = re.compile(r'<meta property="nrk:program-id" content="([^"]+)"')
+    _url_re = re.compile(r"https?://(?:tv|radio)\.nrk\.no/(program|direkte|serie|podkast)(?:/.+)?/([^/]+)")
+
+    _playable_schema = validate.Schema(validate.any(
+        {
+            "playable": {
+                "assets": [{
+                    "url": validate.url(),
+                    "format": str,
+                }],
+            },
+            "statistics": {
+                validate.optional("luna"): validate.any(None, {
+                    "data": {
+                        "title": str,
+                        "category": validate.any(None, str),
+                    },
+                }),
+            },
+        },
+        {
+            "nonPlayable": {
+                "reason": str,
+            },
+        },
+    ))
+
+    category = None
+    title = None
+
     @classmethod
-    def can_handle_url(self, url):
-        return _url_re.match(url)
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def get_category(self):
+        return self.category
+
+    def get_title(self):
+        return self.title
 
     def _get_streams(self):
-        # Get the stream type from the url (tv/radio).
-        stream_type = _url_re.match(self.url).group(1).upper()
-        cookie = {
-            "NRK_PLAYER_SETTINGS_{0}".format(stream_type): COOKIE_PARAMS
-        }
+        # Construct manifest URL for this program.
+        program_type, program_id = self._url_re.match(self.url).groups()
+        manifest_type = self._program_type_map.get(program_type)
+        if manifest_type is None:
+            log.error(f"Unknown program type '{program_type}'")
+            return None
 
-        # Construct API URL for this program.
-        baseurl = self.session.http.get(self.url, cookies=cookie, schema=_schema)
-        program_id = _id_re.search(self.url).group(1)
+        # Fetch program_id.
+        res = self.session.http.get(self.url)
+        m = self._program_id_re.search(res.text)
+        if m is not None:
+            program_id = m.group(1)
+        elif program_id is None:
+            log.error("Could not extract program ID from URL")
+            return None
+
+        manifest_url = urljoin(self._psapi_url, f"playback/manifest/{manifest_type}/{program_id}")
 
         # Extract media URL.
-        json_url = urljoin(baseurl, "mediaelement/{0}".format(program_id))
-        res = self.session.http.get(json_url, cookies=cookie)
-        media_element = self.session.http.json(res, schema=_mediaelement_schema)
-        media_url = media_element["mediaUrl"]
+        res = self.session.http.get(manifest_url)
+        manifest = self.session.http.json(res, schema=self._playable_schema)
+        if 'nonPlayable' in manifest:
+            reason = manifest["nonPlayable"]["reason"]
+            log.error(f"Not playable ({reason})")
+            return None
+        self._set_metadata(manifest)
+        asset = manifest['playable']['assets'][0]
+
+        # Some streams such as podcasts are not HLS but plain files.
+        if asset['format'] == 'HLS':
+            return HLSStream.parse_variant_playlist(self.session, asset['url'])
+        else:
+            return [("live", HTTPStream(self.session, asset['url']))]
 
-        return HLSStream.parse_variant_playlist(self.session, media_url)
+    def _set_metadata(self, manifest):
+        luna = manifest.get("statistics").get("luna")
+        if not luna:
+            return
+        data = luna["data"]
+        self.category = data.get("category")
+        self.title = data.get("title")
 
 
 __plugin__ = NRK
diff --git a/src/streamlink/plugins/vlive.py b/src/streamlink/plugins/vlive.py
index 77cd3f9a..ea64311a 100644
--- a/src/streamlink/plugins/vlive.py
+++ b/src/streamlink/plugins/vlive.py
@@ -1,58 +1,106 @@
-import json
 import logging
 import re
 
-from streamlink.plugin import Plugin, PluginError
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
+from streamlink.utils import parse_json
 
 log = logging.getLogger(__name__)
 
 
 class Vlive(Plugin):
-    _url_re = re.compile(r"https?://(?:www.)vlive\.tv/video/(\d+)")
-    _video_status = re.compile(r'oVideoStatus = (.+)<', re.DOTALL)
-    _video_init_url = "https://www.vlive.tv/video/init/view"
+    _url_re = re.compile(r"https://www\.vlive\.tv/(?P<format>video|post)/(?P<id>[0-9\-]+)")
+    _page_info = re.compile(r'window\.__PRELOADED_STATE__\s*=\s*({.*})\s*<', re.DOTALL)
+    _playinfo_url = "https://www.vlive.tv/globalv-web/vam-web/old/v3/live/{0}/playInfo"
+
+    _schema_video = validate.Schema(
+        validate.transform(_page_info.search),
+        validate.any(None, validate.all(
+            validate.get(1),
+            validate.transform(parse_json),
+            validate.any(validate.all(
+                {"postDetail": {"post": {"officialVideo": {
+                    "type": str,
+                    "videoSeq": int,
+                    validate.optional("status"): str,
+                }}}},
+                validate.get("postDetail"),
+                validate.get("post"),
+                validate.get("officialVideo")),
+                validate.all(
+                    {"postDetail": {"error": {
+                        "errorCode": str,
+                    }}},
+                    validate.get("postDetail"),
+                    validate.get("error")))
+        ))
+    )
+
+    _schema_stream = validate.Schema(
+        validate.transform(parse_json),
+        validate.all(
+            {"result": {"streamList": [{
+                "streamName": str,
+                "serviceUrl": str,
+            }]}},
+            validate.get("result"),
+            validate.get("streamList")
+        )
+    )
 
     @classmethod
     def can_handle_url(cls, url):
         return cls._url_re.match(url) is not None
 
-    @property
-    def video_id(self):
-        return self._url_re.match(self.url).group(1)
-
     def _get_streams(self):
-        vinit_req = self.session.http.get(self._video_init_url,
-                                          params=dict(videoSeq=self.video_id),
-                                          headers=dict(referer=self.url))
-        if vinit_req.status_code != 200:
-            raise PluginError('Could not get video init page (HTTP Status {})'
-                              .format(vinit_req.status_code))
-
-        video_status_js = self._video_status.search(vinit_req.text)
-        if not video_status_js:
-            raise PluginError('Could not find video status information!')
-
-        video_status = json.loads(video_status_js.group(1))
-
-        if video_status['viewType'] == 'vod':
-            raise PluginError('VODs are not supported')
+        video_json = self.session.http.get(self.url, headers={"Referer": self.url},
+                                           schema=self._schema_video)
+        if video_json is None:
+            log.error('Could not parse video page')
+            return
+
+        err = video_json.get('errorCode')
+        if err == 'common_700':
+            log.error('Available only to members of the channel')
+            return
+        elif err == 'common_702':
+            log.error('Vlive+ VODs are not supported')
+            return
+        elif err == 'common_404':
+            log.error('Could not find video page')
+            return
+        elif err is not None:
+            log.error('Unknown error code: {0}'.format(err))
+            return
+
+        if video_json['type'] == 'VOD':
+            log.error('VODs are not supported')
+            return
+
+        url_format, video_id = self._url_re.match(self.url).groups()
+        if url_format == 'post':
+            video_id = str(video_json['videoSeq'])
+
+        video_status = video_json.get('status')
+        if video_status == 'ENDED':
+            log.error('Stream has ended')
+            return
+        elif video_status != 'ON_AIR':
+            log.error('Unknown video status: {0}'.format(video_status))
+            return
+
+        stream_info = self.session.http.get(self._playinfo_url.format(video_id),
+                                            headers={"Referer": self.url},
+                                            schema=self._schema_stream)
 
-        if 'liveStreamInfo' not in video_status:
-            raise PluginError('Stream is offline')
-
-        stream_info = json.loads(video_status['liveStreamInfo'])
-
-        streams = dict()
         # All "resolutions" have a variant playlist with only one entry, so just combine them
-        for i in stream_info['resolutions']:
-            res_streams = HLSStream.parse_variant_playlist(self.session, i['cdnUrl'])
+        for i in stream_info:
+            res_streams = HLSStream.parse_variant_playlist(self.session, i['serviceUrl'])
             if len(res_streams.values()) > 1:
                 log.warning('More than one stream in variant playlist, using first entry!')
 
-            streams[i['name']] = res_streams.popitem()[1]
-
-        return streams
+            yield i['streamName'], res_streams.popitem()[1]
 
 
 __plugin__ = Vlive


Test patch:
diff --git a/tests/plugins/test_huya.py b/tests/plugins/test_huya.py
index c2f9deca..7deb0c34 100644
--- a/tests/plugins/test_huya.py
+++ b/tests/plugins/test_huya.py
@@ -11,11 +11,11 @@ class TestPluginHuya(unittest.TestCase):
             'https://www.huya.com/123123123',
         ]
         for url in should_match:
-            self.assertTrue(Huya.can_handle_url(url))
+            self.assertTrue(Huya.can_handle_url(url), url)
 
     def test_can_handle_url_negative(self):
         should_not_match = [
             'http://www.huya.com',
         ]
         for url in should_not_match:
-            self.assertFalse(Huya.can_handle_url(url))
+            self.assertFalse(Huya.can_handle_url(url), url)
diff --git a/tests/plugins/test_nrk.py b/tests/plugins/test_nrk.py
index f053baff..54f7c3cc 100644
--- a/tests/plugins/test_nrk.py
+++ b/tests/plugins/test_nrk.py
@@ -10,14 +10,24 @@ class TestPluginNRK(unittest.TestCase):
             'https://tv.nrk.no/direkte/nrk2',
             'https://tv.nrk.no/direkte/nrk3',
             'https://tv.nrk.no/direkte/nrksuper',
+
+            'https://tv.nrk.no/serie/nytt-paa-nytt/2020/MUHH43003020',
+            'https://tv.nrk.no/serie/kongelige-fotografer/sesong/1/episode/2/avspiller',
+
+            'https://tv.nrk.no/program/NNFA51102617',
+
             'https://radio.nrk.no/direkte/p1',
             'https://radio.nrk.no/direkte/p2',
+
+            'https://radio.nrk.no/podkast/oppdatert/l_5005d62a-7f4f-4581-85d6-2a7f4f2581f2',
         ]
         for url in should_match:
             self.assertTrue(NRK.can_handle_url(url))
 
     def test_can_handle_url_negative(self):
         should_not_match = [
+            'https://tv.nrk.no/',
+            'https://radio.nrk.no/',
             'https://nrk.no/',
         ]
         for url in should_not_match:
diff --git a/tests/plugins/test_vlive.py b/tests/plugins/test_vlive.py
index 37b32be1..5e98e309 100644
--- a/tests/plugins/test_vlive.py
+++ b/tests/plugins/test_vlive.py
@@ -8,6 +8,7 @@ from streamlink.plugins.vlive import Vlive
 class TestPluginVlive:
     valid_urls = [
         ("https://www.vlive.tv/video/156824",),
+        ("https://www.vlive.tv/post/0-19740901",)
     ]
     invalid_urls = [
         ("https://www.vlive.tv/events/2019vheartbeat?lang=en",),