Repository: streamlink/streamlink
Pull request #2139 (2018-10-25 04:02:32)

Add ability to specify custom key URI override for HLS streams
This pull request adds the "--hls-segment-key-uri \<URI\>" CLI flag which allows you to override the URI used to retrieve HLS segment keys.

I also force requests to parse the key response body as `binary/octet-stream` as per the HLS spec. Not having this was actually giving me issues.


Patch:
diff --git a/docs/cli.rst b/docs/cli.rst
index 0ebcadf0..0dd5cc5d 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -106,7 +106,7 @@ You can also specify the location yourself using the :option:`--config` option.
 .. note::
 
   - `$XDG_CONFIG_HOME` is ``~/.config`` if it has not been overridden
-  - `%APPDATA%` is usually ``<your user directory>\Application Data``
+  - `%APPDATA%` is usually ``<your user directory>\AppData``
 
 .. note::
 
@@ -143,6 +143,9 @@ Example
     # Authenticate with Twitch
     twitch-oauth-token=mytoken
 
+.. note::
+    Full player paths are supported via configuration file options such as
+    ``player="C:\mpv-x86_64\mpv"``
 
 Plugin specific configuration file
 ----------------------------------
diff --git a/docs/players.rst b/docs/players.rst
index 7a888bc2..4a122f85 100644
--- a/docs/players.rst
+++ b/docs/players.rst
@@ -95,7 +95,19 @@ The stable version of VLC cannot play Youtube Live streams. This a known bug
 that is fixed in VLC 3, which is still in development. You can also try
 using a different player.
 
+Youtube Live does not work with Mplayer
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Some versions of Mplayer cannot play Youtube Live streams. And errors like:
+
+.. code-block:: console
+
+    Cannot seek backward in linear streams!
+    Seek failed
+
+Switching to a recent fork such as mpv resolves the issue.
+
 When using OMXPlayer the stream stops unexpectedly
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 When reading from a fifo pipe OMXPlayer will quit when there is no data, to fix
 this you can supply the timeout option to OMXPlayer using :option:`--player "omxplayer --timeout 20" <--player>`.
+For live streams it might be beneficial to also add the omxplayer parameter ``--live``.
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 664e80f6..657e1fab 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -108,6 +108,7 @@ livestream              new.livestream.com   Yes   --
 lrt                     lrt.lt               Yes   No
 ltv_lsm_lv              ltv.lsm.lv           Yes   No    Streams may be geo-restricted to Latvia.
 mediaklikk              mediaklikk.hu        Yes   No    Streams may be geo-restricted to Hungary.
+metube                  metube.id            Yes   Yes
 mitele                  mitele.es            Yes   No    Streams may be geo-restricted to Spain.
 mixer                   mixer.com            Yes   Yes
 mjunoon                 mjunoon.tv           Yes   Yes
@@ -164,6 +165,7 @@ rtpplay                 rtp.pt/play          Yes   Yes   Streams may be geo-rest
 rtve                    rtve.es              Yes   No
 rtvs                    rtvs.sk              Yes   No    Streams may be geo-restricted to Slovakia.
 ruv                     ruv.is               Yes   Yes   Streams may be geo-restricted to Iceland.
+sbscokr                 play.sbs.co.kr       Yes   No    Streams may be geo-restricted to South Korea.
 schoolism               schoolism.com        --    Yes   Requires a login and a subscription.
 senategov               senate.gov           --    Yes   Supports hearing streams.
 showroom                showroom-live.com    Yes   No    Only RTMP streams are available.
@@ -183,6 +185,7 @@ streamingvideoprovider  streamingvid... [2]_ Yes   --    RTMP streams requires r
                                                          K-S-V patches.
 streamme                stream.me            Yes   --
 streann                 ott.streann.com      Yes   Yes
+stv                     player.stv.tv        Yes   No    Streams are geo-restricted to Great Britain.
 svtplay                 - svtplay.se         Yes   Yes   Streams may be geo-restricted to Sweden.
                         - svtflow.se
                         - oppetarkiv.se
@@ -219,6 +222,7 @@ tv5monde                - tv5monde.com       Yes   Yes   Streams may be geo-rest
 tv8                     tv8.com.tr           Yes   No
 tv360                   tv360.com.tr         Yes   No
 tvcatchup               tvcatchup.com        Yes   No    Streams may be geo-restricted to Great Britain.
+tvibo                   player.tvibo.com     Yes   --
 tvnbg                   - tvn.bg             Yes   -
                         - live.tvn.bg
 tvp                     tvpstream.vod.tvp.pl Yes   No    Streams may be geo-restricted to Poland.
@@ -248,6 +252,7 @@ vidio                   vidio.com            Yes   Yes
 vinhlongtv              thvli.vn             Yes   No    Streams are geo-restricted to Vietnam
 vk                      vk.com               Yes   Yes
 vrtbe                   vrt.be/vrtnu         Yes   Yes
+vtvgo                   vtvgo.vn             Yes   No
 webcast_india_gov       webcast.gov.in       Yes   No    You can use #Channel to indicate CH number.
 webtv                   web.tv               Yes   --
 welt                    welt.de              Yes   Yes   Streams may be geo-restricted to Germany.
diff --git a/src/streamlink/plugin/plugin.py b/src/streamlink/plugin/plugin.py
index 1f5513d7..ef38ac47 100644
--- a/src/streamlink/plugin/plugin.py
+++ b/src/streamlink/plugin/plugin.py
@@ -384,6 +384,7 @@ class Plugin(object):
 
         stream_names = filter(stream_weight_only, streams.keys())
         sorted_streams = sorted(stream_names, key=stream_weight_only)
+        unfiltered_sorted_streams = sorted_streams
 
         if isinstance(sorting_excludes, list):
             for expr in sorting_excludes:
@@ -402,6 +403,11 @@ class Plugin(object):
             worst = sorted_streams[0]
             final_sorted_streams["worst"] = streams[worst]
             final_sorted_streams["best"] = streams[best]
+        elif len(unfiltered_sorted_streams) > 0:
+            best = unfiltered_sorted_streams[-1]
+            worst = unfiltered_sorted_streams[0]
+            final_sorted_streams["worst-unfiltered"] = streams[worst]
+            final_sorted_streams["best-unfiltered"] = streams[best]
 
         return final_sorted_streams
 
diff --git a/src/streamlink/plugins/bbciplayer.py b/src/streamlink/plugins/bbciplayer.py
index 8c79baf5..5e5d0f28 100644
--- a/src/streamlink/plugins/bbciplayer.py
+++ b/src/streamlink/plugins/bbciplayer.py
@@ -45,11 +45,11 @@ class BBCiPlayer(Plugin):
 
     mediator_schema = validate.Schema(
         {
-            "episode": {
+            "appStoreState": {
                 "versions": [{"id": validate.text}]
             }
         },
-        validate.get("episode"), validate.get("versions"), validate.get(0),
+        validate.get("appStoreState"), validate.get("versions"), validate.get(0),
         validate.get("id")
     )
     mediaselector_schema = validate.Schema(
diff --git a/src/streamlink/plugins/ine.py b/src/streamlink/plugins/ine.py
index 8ebc9109..e0389b7e 100644
--- a/src/streamlink/plugins/ine.py
+++ b/src/streamlink/plugins/ine.py
@@ -23,7 +23,7 @@ class INE(Plugin):
             validate.all(
                 validate.get(1),
                 validate.transform(json.loads),
-                {"playlist": str},
+                {"playlist": validate.text},
                 validate.get("playlist")
             )
         )
diff --git a/src/streamlink/plugins/metube.py b/src/streamlink/plugins/metube.py
new file mode 100644
index 00000000..8e78e929
--- /dev/null
+++ b/src/streamlink/plugins/metube.py
@@ -0,0 +1,58 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import useragents
+from streamlink.stream import HLSStream
+
+
+class MeTube(Plugin):
+
+    _url_re = re.compile(r"""https?://(?:www\.)?metube\.id/
+                         (?P<type>live|videos)/\w+(?:/.*)?""", re.VERBOSE)
+
+    # extracted from webpage source
+    _VOD_STREAM_NAMES = {
+        "3000k": "1080p",
+        "1800k": "720p",
+        "800k": "480p",
+        "300k": "240p"
+    }
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def _get_streams(self):
+        stream_type = self._url_re.match(self.url).group("type")
+        hls_re = re.compile(r"""["'](?P<url>[^"']+\.m3u8[^"']*?)["']""")
+
+        headers = {
+            "Origin": "https://www.metube.id",
+            "User-Agent": useragents.FIREFOX
+        }
+
+        res = self.session.http.get(self.url)
+        match = hls_re.search(res.text)
+
+        if not match:
+            return
+
+        stream_url = match.group("url")
+
+        if stream_type == "live":
+            return HLSStream.parse_variant_playlist(self.session, stream_url,
+                                                    headers=headers)
+        else:
+            streams = {}
+
+            for quality, stream in HLSStream.parse_variant_playlist(
+                                   self.session,
+                                   stream_url,
+                                   headers=headers).items():
+                    name = self._VOD_STREAM_NAMES.get(quality, quality)
+                    streams[name] = stream
+
+            return streams
+
+
+__plugin__ = MeTube
diff --git a/src/streamlink/plugins/npo.py b/src/streamlink/plugins/npo.py
index 7c63c44c..024f1029 100644
--- a/src/streamlink/plugins/npo.py
+++ b/src/streamlink/plugins/npo.py
@@ -5,9 +5,12 @@ Supports:
     - https://www.npo.nl/nos-journaal/07-07-2017/POW_03375651
     - https://www.zapp.nl/topdoks/gemist/VPWON_1276930
     - https://zappelin.nl/10-voor/gemist/VPWON_1271522
+    - https://www.npostart.nl/nos-journaal/07-07-2017/POW_03375651
    Live:
     - https://www.npo.nl/live/npo-1
     - https://zappelin.nl/tv-kijken
+    - https://www.npostart.nl/live/npo-1
+
 """
 
 import re
@@ -22,7 +25,7 @@ from streamlink.utils import parse_json
 
 class NPO(Plugin):
     api_url = "http://ida.omroep.nl/app.php/{endpoint}"
-    url_re = re.compile(r"https?://(\w+\.)?(npo\.nl|zapp\.nl|zappelin\.nl)/")
+    url_re = re.compile(r"https?://(\w+\.)?(npostart\.nl|npo\.nl|zapp\.nl|zappelin\.nl)/")
     media_id_re = re.compile(r'''<npo-player\smedia-id=["'](?P<media_id>[^"']+)["']''')
     prid_re = re.compile(r'''(?:data(-alt)?-)?prid\s*[=:]\s*(?P<q>["'])(\w+)(?P=q)''')
     react_re = re.compile(r'''data-react-props\s*=\s*(?P<q>["'])(?P<data>.*?)(?P=q)''')
diff --git a/src/streamlink/plugins/sbscokr.py b/src/streamlink/plugins/sbscokr.py
new file mode 100644
index 00000000..52865869
--- /dev/null
+++ b/src/streamlink/plugins/sbscokr.py
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+import logging
+import random
+import re
+
+from streamlink.plugin import Plugin, PluginArguments, PluginArgument
+from streamlink.plugin.api import useragents, validate
+from streamlink.stream import HLSStream
+
+log = logging.getLogger(__name__)
+
+
+class SBScokr(Plugin):
+
+    api_channel = 'http://apis.sbs.co.kr/play-api/1.0/onair/channel/{0}'
+    api_channels = 'http://static.apis.sbs.co.kr/play-api/1.0/onair/channels'
+
+    _url_re = re.compile(r'https?://play\.sbs\.co\.kr/onair/pc/index.html')
+
+    _channels_schema = validate.Schema({
+        'list': [{
+            'channelname': validate.all(
+                validate.text,
+            ),
+            'channelid': validate.text,
+            validate.optional('type'): validate.text,
+        }]},
+        validate.get('list'),
+    )
+
+    _channel_schema = validate.Schema(
+        {
+            'onair': {
+                'info': {
+                    'onair_yn': validate.text,
+                    'overseas_yn': validate.text,
+                    'overseas_text': validate.text,
+                },
+                'source': {
+                    'mediasourcelist': validate.any([{
+                        validate.optional('default'): validate.text,
+                        'mediaurl': validate.text,
+                    }], [])
+                },
+            }
+        },
+        validate.get('onair'),
+    )
+
+    arguments = PluginArguments(
+        PluginArgument(
+            'id',
+            metavar='CHANNELID',
+            type=str.upper,
+            help='''
+            Channel ID to play.
+
+            Example:
+
+                %(prog)s http://play.sbs.co.kr/onair/pc/index.html best --sbscokr-id S01
+
+            '''
+        )
+    )
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def _get_streams(self):
+        user_channel_id = self.get_option('id')
+
+        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
+        res = self.session.http.get(self.api_channels)
+        res = self.session.http.json(res, schema=self._channels_schema)
+
+        channels = {}
+        for channel in sorted(res, key=lambda x: x['channelid']):
+            if channel.get('type') in ('TV', 'Radio'):
+                channels[channel['channelid']] = channel['channelname']
+
+        log.info('Available IDs: {0}'.format(', '.join(
+            '{0} ({1})'.format(key, value) for key, value in channels.items())))
+        if not user_channel_id:
+            log.error('No channel selected, use --sbscokr-id CHANNELID')
+            return
+        elif user_channel_id and user_channel_id not in channels.keys():
+            log.error('Channel ID "{0}" is not available.'.format(user_channel_id))
+            return
+
+        params = {
+            'v_type': '2',
+            'platform': 'pcweb',
+            'protocol': 'hls',
+            'jwt-token': '',
+            'rnd': random.randint(50, 300)
+        }
+
+        res = self.session.http.get(self.api_channel.format(user_channel_id),
+                                    params=params)
+        res = self.session.http.json(res, schema=self._channel_schema)
+
+        for media in res['source']['mediasourcelist']:
+            if media['mediaurl']:
+                for s in HLSStream.parse_variant_playlist(self.session,
+                                                          media['mediaurl']).items():
+                    yield s
+        else:
+            if res['info']['onair_yn'] != 'Y':
+                log.error('This channel is currently unavailable')
+            elif res['info']['overseas_yn'] != 'Y':
+                log.error(res['info']['overseas_text'])
+
+
+__plugin__ = SBScokr
diff --git a/src/streamlink/plugins/skai.py b/src/streamlink/plugins/skai.py
index 61fba7ec..d43fcafd 100644
--- a/src/streamlink/plugins/skai.py
+++ b/src/streamlink/plugins/skai.py
@@ -3,20 +3,15 @@ import re
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import validate
 
-YOUTUBE_URL = "https://www.youtube.com/watch?v={0}"
-_url_re = re.compile(r'http(s)?://www\.skai.gr/.*')
-_youtube_id = re.compile(r'<span\s+itemprop="contentUrl"\s+href="(.*)"></span>', re.MULTILINE)
-_youtube_url_schema = validate.Schema(
-    validate.all(
-        validate.transform(_youtube_id.search),
-        validate.any(
-            None,
-            validate.all(
-                validate.get(1),
-                validate.text
-            )
-        )
-    )
+
+_url_re = re.compile(r'http(s)?://www\.skai(?:tv)?.gr/.*')
+_api_url = "http://www.skaitv.gr/json/live.php"
+_api_res_schema = validate.Schema(validate.all(
+    validate.get("now"),
+    {
+        "livestream": validate.url()
+    },
+    validate.get("livestream"))
 )
 
 
@@ -26,9 +21,10 @@ class Skai(Plugin):
         return _url_re.match(url)
 
     def _get_streams(self):
-        channel_id = self.session.http.get(self.url, schema=_youtube_url_schema)
-        if channel_id:
-            return self.session.streams(YOUTUBE_URL.format(channel_id))
+        api_res = self.session.http.get(_api_url)
+        yt_url = self.session.http.json(api_res, schema=_api_res_schema)
+        if yt_url:
+            return self.session.streams(yt_url)
 
 
 __plugin__ = Skai
diff --git a/src/streamlink/plugins/stv.py b/src/streamlink/plugins/stv.py
new file mode 100644
index 00000000..1198b1ef
--- /dev/null
+++ b/src/streamlink/plugins/stv.py
@@ -0,0 +1,48 @@
+import logging
+import re
+
+from streamlink.exceptions import PluginError
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import useragents
+from streamlink.stream import HLSStream
+
+log = logging.getLogger(__name__)
+
+
+class STV(Plugin):
+    _url_re = re.compile(r'https?://player\.stv\.tv/live')
+
+    API_URL = 'https://player.api.stv.tv/v1/streams/stv/'
+
+    title = None
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def get_title(self):
+        if self.title is None:
+            self._get_api_results()
+        return self.title
+
+    def _get_api_results(self):
+        self.session.http.headers.update({'User-Agent': useragents.FIREFOX})
+        res = self.session.http.get(self.API_URL)
+        data = self.session.http.json(res)
+
+        if data['success'] is False:
+            raise PluginError(data['reason']['message'])
+
+        try:
+            self.title = data['results']['now']['title']
+        except KeyError:
+            self.title = 'STV'
+
+        return data
+
+    def _get_streams(self):
+        hls_url = self._get_api_results()['results']['streamUrl']
+        return HLSStream.parse_variant_playlist(self.session, hls_url)
+
+
+__plugin__ = STV
diff --git a/src/streamlink/plugins/tvibo.py b/src/streamlink/plugins/tvibo.py
new file mode 100644
index 00000000..71ec03a7
--- /dev/null
+++ b/src/streamlink/plugins/tvibo.py
@@ -0,0 +1,34 @@
+import logging
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.stream import HLSStream
+
+log = logging.getLogger(__name__)
+
+
+class Tvibo(Plugin):
+
+    _url_re = re.compile(r"https?://player\.tvibo\.com/\w+/(?P<id>\d+)")
+    _api_url = "http://panel.tvibo.com/api/player/streamurl/{id}"
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def _get_streams(self):
+        channel_id = self._url_re.match(self.url).group("id")
+
+        api_response = self.session.http.get(
+            self._api_url.format(id=channel_id),
+            acceptable_status=(200, 404))
+
+        data = self.session.http.json(api_response)
+        log.trace("{0!r}".format(data))
+        if data.get("st"):
+            yield "source", HLSStream(self.session, data["st"])
+        elif data.get("error"):
+            log.error(data["error"]["message"])
+
+
+__plugin__ = Tvibo
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index 5cb3f41c..410a22b6 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -213,6 +213,12 @@ class TwitchAPI(object):
     def channel_info(self, channel, **params):
         return self.call("/kraken/channels/{0}".format(channel), **params)
 
+    def streams(self, channel_id, **params):
+        return self.call("/kraken/streams/{0}".format(channel_id), **params)
+
+    def clips(self, clip_name, **params):
+        return self.call("/kraken/clips/{0}".format(clip_name), **params)
+
     # Private API calls
 
     def access_token(self, endpoint, asset, **params):
@@ -286,6 +292,38 @@ class Twitch(Plugin):
     def can_handle_url(cls, url):
         return _url_re.match(url)
 
+    def _get_metadata(self):
+        if self.video_id:
+            api_res = self.api.videos(self.video_id)
+            self.title = api_res["title"]
+            self.author = api_res["channel"]["display_name"]
+            self.category = api_res["game"]
+        elif self.clip_name:
+            api_res = self.api.clips(self.clip_name)
+            self.title = api_res["title"]
+            self.author = api_res["broadcaster"]["display_name"]
+            self.category = api_res["game"]
+        elif self._channel:
+            api_res = self.api.streams(self.channel_id)["stream"]["channel"]
+            self.title = api_res["status"]
+            self.author = api_res["display_name"]
+            self.category = api_res["game"]
+
+    def get_title(self):
+        if self.title is None:
+            self._get_metadata()
+        return self.title
+
+    def get_author(self):
+        if self.author is None:
+            self._get_metadata()
+        return self.author
+
+    def get_category(self):
+        if self.category is None:
+            self._get_metadata()
+        return self.category
+
     def __init__(self, url):
         Plugin.__init__(self, url)
         self._hosted_chain = []
@@ -298,6 +336,9 @@ class Twitch(Plugin):
         self._channel_id = None
         self._channel = None
         self.clip_name = None
+        self.title = None
+        self.author = None
+        self.category = None
 
         if self.subdomain == "player":
             # pop-out player
diff --git a/src/streamlink/plugins/vtvgo.py b/src/streamlink/plugins/vtvgo.py
new file mode 100644
index 00000000..a2be57cd
--- /dev/null
+++ b/src/streamlink/plugins/vtvgo.py
@@ -0,0 +1,45 @@
+import logging
+import re
+
+from streamlink.exceptions import PluginError
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import useragents
+from streamlink.stream import HLSStream
+
+log = logging.getLogger(__name__)
+
+
+class VTVgo(Plugin):
+
+    AJAX_URL = 'https://vtvgo.vn/ajax-get-stream'
+
+    _url_re = re.compile(r'https://vtvgo\.vn/xem-truc-tuyen-kenh-')
+    _params_re = re.compile(r'''var\s(?P<key>(?:type_)?id|time|token)\s*=\s*["']?(?P<value>[^"']+)["']?;''')
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    def _get_streams(self):
+        self.session.http.headers.update({
+            'Origin': 'https://vtvgo.vn',
+            'Referer': self.url,
+            'User-Agent': useragents.FIREFOX,
+            'X-Requested-With': 'XMLHttpRequest',
+        })
+        res = self.session.http.get(self.url)
+
+        params = self._params_re.findall(res.text)
+        if not params:
+            raise PluginError('No POST data')
+        elif len(params) != 4:
+            raise PluginError('Not enough POST data: {0!r}'.format(params))
+
+        log.trace('{0!r}'.format(params))
+        res = self.session.http.post(self.AJAX_URL, data=dict(params))
+        hls_url = self.session.http.json(res)['stream_url'][0]
+
+        return HLSStream.parse_variant_playlist(self.session, hls_url)
+
+
+__plugin__ = VTVgo
diff --git a/src/streamlink/stream/hls.py b/src/streamlink/stream/hls.py
index 8a67f564..c0285f73 100644
--- a/src/streamlink/stream/hls.py
+++ b/src/streamlink/stream/hls.py
@@ -45,6 +45,8 @@ class HLSStreamWriter(SegmentedStreamWriter):
         self.byterange_offsets = defaultdict(int)
         self.key_data = None
         self.key_uri = None
+        self.key_uri_override = options.get("hls-segment-key-uri")
+
         if self.ignore_names:
             # creates a regex from a list of segment names,
             # this will be used to ignore segments.
@@ -57,15 +59,18 @@ class HLSStreamWriter(SegmentedStreamWriter):
         if key.method != "AES-128":
             raise StreamError("Unable to decrypt cipher {0}", key.method)
 
-        if not key.uri:
+        if not self.key_uri_override and not key.uri:
             raise StreamError("Missing URI to decryption key")
 
-        if self.key_uri != key.uri:
-            res = self.session.http.get(key.uri, exception=StreamError,
+        key_uri = self.key_uri_override if self.key_uri_override else key.uri
+
+        if self.key_uri != key_uri:
+            res = self.session.http.get(key_uri, exception=StreamError,
                                         retries=self.retries,
                                         **self.reader.request_params)
+            res.encoding = "binary/octet-stream"
             self.key_data = res.content
-            self.key_uri = key.uri
+            self.key_uri = key_uri
 
         iv = key.iv or num_to_iv(sequence)
 
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 0c77adb3..e018ccba 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -107,7 +107,7 @@ def build_parser():
         help="""
         Stream to play.
 
-        Use "best" or "worst" for selecting the highest or lowest available
+        Use ``best`` or ``worst`` for selecting the highest or lowest available
         quality.
 
         Fallback streams can be specified by using a comma-separated list:
@@ -523,7 +523,7 @@ def build_parser():
         help="""
         Stream to play.
 
-        Use "best" or "worst" for selecting the highest or lowest available
+        Use ``best`` or ``worst`` for selecting the highest or lowest available
         quality.
 
         Fallback streams can be specified by using a comma-separated list:
@@ -590,13 +590,17 @@ def build_parser():
         metavar="STREAMS",
         type=comma_list,
         help="""
-        Fine tune best/worst synonyms by excluding unwanted streams.
+        Fine tune the ``best`` and ``worst`` stream name synonyms by excluding unwanted streams.
+
+        If all of the available streams get excluded, ``best`` and ``worst`` will become
+        inaccessible and new special stream synonyms ``best-unfiltered`` and ``worst-unfiltered``
+        can be used as a fallback selection method.
 
         Uses a filter expression in the format:
 
           [operator]<value>
 
-        Valid operators are >, >=, < and <=. If no operator is specified then
+        Valid operators are ``>``, ``>=``, ``<`` and ``<=``. If no operator is specified then
         equality is tested.
 
         For example this will exclude streams ranked higher than "480p":
@@ -737,6 +741,19 @@ def build_parser():
         the ignored break.
         """
     )
+    transport.add_argument(
+        "--hls-segment-key-uri",
+        metavar="URI",
+        type=str,
+        help="""
+        URI to segment encryption key. If no URI is specified, the URI contained
+        in the segments will be used.
+
+        Example: --hls-segment-key-uri "https://example.com/hls/encryption_key"
+
+        Default is None.
+        """
+    )
     transport.add_argument(
         "--hls-audio-select",
         type=comma_list,
diff --git a/src/streamlink_cli/constants.py b/src/streamlink_cli/constants.py
index 05ec5dba..45eb7894 100644
--- a/src/streamlink_cli/constants.py
+++ b/src/streamlink_cli/constants.py
@@ -28,7 +28,7 @@ else:
     ]
     PLUGINS_DIR = os.path.expanduser(XDG_CONFIG_HOME + "/streamlink/plugins")
 
-STREAM_SYNONYMS = ["best", "worst"]
+STREAM_SYNONYMS = ["best", "worst", "best-unfiltered", "worst-unfiltered"]
 STREAM_PASSTHROUGH = ["hls", "http", "rtmp"]
 
 __all__ = [
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index b43175a6..4e812b5e 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -777,6 +777,9 @@ def setup_options():
     if args.hls_segment_ignore_names:
         streamlink.set_option("hls-segment-ignore-names", args.hls_segment_ignore_names)
 
+    if args.hls_segment_key_uri:
+        streamlink.set_option("hls-segment-key-uri", args.hls_segment_key_uri)
+
     if args.hls_timeout:
         streamlink.set_option("hls-timeout", args.hls_timeout)
 


Test patch:
diff --git a/tests/plugins/test_metube.py b/tests/plugins/test_metube.py
new file mode 100644
index 00000000..0848dac8
--- /dev/null
+++ b/tests/plugins/test_metube.py
@@ -0,0 +1,24 @@
+import unittest
+
+from streamlink.plugins.metube import MeTube
+
+
+class TestPluginMeTube(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'https://www.metube.id/live/METROTV',
+            'https://www.metube.id/live/GTV',
+            'https://www.metube.id/videos/16881738/yudi_28_bogor_-amazingakak',
+            'https://www.metube.id/videos/16873428/liverpool-vs-psg-3-2-goals-and-highlights-2018',
+        ]
+        for url in should_match:
+            self.assertTrue(MeTube.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'https://www.metube.id/me/IMAA2018',
+            'https://www.metube.id/auditions',
+            'https://www.twitch.tv/twitch'
+        ]
+        for url in should_not_match:
+            self.assertFalse(MeTube.can_handle_url(url))
diff --git a/tests/plugins/test_sbscokr.py b/tests/plugins/test_sbscokr.py
new file mode 100644
index 00000000..1dff264e
--- /dev/null
+++ b/tests/plugins/test_sbscokr.py
@@ -0,0 +1,19 @@
+import unittest
+
+from streamlink.plugins.sbscokr import SBScokr
+
+
+class TestPluginSBScokr(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'https://play.sbs.co.kr/onair/pc/index.html',
+            'http://play.sbs.co.kr/onair/pc/index.html',
+        ]
+        for url in should_match:
+            self.assertTrue(SBScokr.can_handle_url(url))
+
+        should_not_match = [
+            'https://example.com/index.html',
+        ]
+        for url in should_not_match:
+            self.assertFalse(SBScokr.can_handle_url(url))
diff --git a/tests/plugins/test_skai.py b/tests/plugins/test_skai.py
index f2347407..f07e61ee 100644
--- a/tests/plugins/test_skai.py
+++ b/tests/plugins/test_skai.py
@@ -7,6 +7,7 @@ class TestPluginSkai(unittest.TestCase):
     def test_can_handle_url(self):
         should_match = [
             'http://www.skai.gr/player/tvlive/',
+            'http://www.skaitv.gr/live',
         ]
         for url in should_match:
             self.assertTrue(Skai.can_handle_url(url))
diff --git a/tests/plugins/test_stv.py b/tests/plugins/test_stv.py
new file mode 100644
index 00000000..809bd2a2
--- /dev/null
+++ b/tests/plugins/test_stv.py
@@ -0,0 +1,12 @@
+import unittest
+
+from streamlink.plugins.stv import STV
+
+
+class TestPluginSTV(unittest.TestCase):
+    def test_can_handle_url(self):
+        self.assertTrue(STV.can_handle_url('https://player.stv.tv/live'))
+        self.assertTrue(STV.can_handle_url('http://player.stv.tv/live'))
+
+    def test_can_handle_url_negative(self):
+        self.assertFalse(STV.can_handle_url('http://example.com/live'))
diff --git a/tests/plugins/test_tvibo.py b/tests/plugins/test_tvibo.py
new file mode 100644
index 00000000..401bc70b
--- /dev/null
+++ b/tests/plugins/test_tvibo.py
@@ -0,0 +1,22 @@
+import unittest
+
+from streamlink.plugins.tvibo import Tvibo
+
+
+class TestPluginTvibo(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'http://player.tvibo.com/aztv/5929820',
+            'http://player.tvibo.com/aztv/6858270/',
+            'http://player.tvibo.com/aztv/3977238/',
+        ]
+        for url in should_match:
+            self.assertTrue(Tvibo.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'http://www.idmantv.az/',
+            'https://www.twitch.tv/twitch'
+        ]
+        for url in should_not_match:
+            self.assertFalse(Tvibo.can_handle_url(url))
diff --git a/tests/plugins/test_vtvgo.py b/tests/plugins/test_vtvgo.py
new file mode 100644
index 00000000..7cfacf5c
--- /dev/null
+++ b/tests/plugins/test_vtvgo.py
@@ -0,0 +1,32 @@
+import unittest
+
+from streamlink.plugins.vtvgo import VTVgo
+
+
+class TestPluginVTVgo(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv1-1.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv2-2.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv3-3.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv4-4.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv5-5.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv6-6.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv7-27.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv8-36.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv9-39.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv5-t%C3%A2y-nam-b%E1%BB%99-7.html',
+            'https://vtvgo.vn/xem-truc-tuyen-kenh-k%C3%AAnh-vtv5-t%C3%A2y-nguy%C3%AAn-163.html',
+        ]
+        for url in should_match:
+            self.assertTrue(VTVgo.can_handle_url(url))
+
+        should_not_match = [
+            'https://example.com/index.html',
+            # POST request will error with www.
+            'https://www.vtvgo.vn/xem-truc-tuyen-kenh-vtv1-1.html',
+            # POST request will error with http://
+            'http://vtvgo.vn/xem-truc-tuyen-kenh-vtv1-1.html',
+        ]
+        for url in should_not_match:
+            self.assertFalse(VTVgo.can_handle_url(url))
diff --git a/tests/plugins/testplugin.py b/tests/plugins/testplugin.py
index 06b693c4..66822ef4 100644
--- a/tests/plugins/testplugin.py
+++ b/tests/plugins/testplugin.py
@@ -37,6 +37,14 @@ class TestPlugin(Plugin):
     def _get_streams(self):
         if "empty" in self.url:
             return
+
+        if "UnsortableStreamNames" in self.url:
+            def gen():
+                for i in range(3):
+                    yield "vod", HTTPStream(self.session, "http://test.se/stream")
+
+            return gen()
+
         if "NoStreamsError" in self.url:
             raise NoStreamsError(self.url)
 
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
index dccd30a7..2b58ea5f 100644
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -3,8 +3,9 @@ import os.path
 import tempfile
 
 import streamlink_cli.main
-from streamlink_cli.main import resolve_stream_name, check_file_output
+from streamlink_cli.main import resolve_stream_name, format_valid_streams, check_file_output
 from streamlink_cli.output import FileOutput
+from streamlink.plugin.plugin import Plugin
 import unittest
 from tests.mock import Mock, patch
 
@@ -59,19 +60,71 @@ class TestCLIMain(unittest.TestCase):
             tmpfile.close()
 
     def test_resolve_stream_name(self):
-        high = Mock()
-        medium = Mock()
-        low = Mock()
+        a = Mock()
+        b = Mock()
+        c = Mock()
+        d = Mock()
+        e = Mock()
         streams = {
-            "low": low,
-            "medium": medium,
-            "high": high,
-            "worst": low,
-            "best": high
+            "160p": a,
+            "360p": b,
+            "480p": c,
+            "720p": d,
+            "1080p": e,
+            "worst": b,
+            "best": d,
+            "worst-unfiltered": a,
+            "best-unfiltered": e
         }
-        self.assertEqual("high", resolve_stream_name(streams, "best"))
-        self.assertEqual("low", resolve_stream_name(streams, "worst"))
-        self.assertEqual("medium", resolve_stream_name(streams, "medium"))
-        self.assertEqual("high", resolve_stream_name(streams, "high"))
-        self.assertEqual("low", resolve_stream_name(streams, "low"))
 
+        self.assertEqual(resolve_stream_name(streams, "unknown"), "unknown")
+        self.assertEqual(resolve_stream_name(streams, "160p"), "160p")
+        self.assertEqual(resolve_stream_name(streams, "360p"), "360p")
+        self.assertEqual(resolve_stream_name(streams, "480p"), "480p")
+        self.assertEqual(resolve_stream_name(streams, "720p"), "720p")
+        self.assertEqual(resolve_stream_name(streams, "1080p"), "1080p")
+        self.assertEqual(resolve_stream_name(streams, "worst"), "360p")
+        self.assertEqual(resolve_stream_name(streams, "best"), "720p")
+        self.assertEqual(resolve_stream_name(streams, "worst-unfiltered"), "160p")
+        self.assertEqual(resolve_stream_name(streams, "best-unfiltered"), "1080p")
+
+    def test_format_valid_streams(self):
+        class FakePlugin:
+            @classmethod
+            def stream_weight(cls, stream):
+                return Plugin.stream_weight(stream)
+        a = Mock()
+        b = Mock()
+        c = Mock()
+
+        streams = {
+            "audio": a,
+            "720p": b,
+            "1080p": c,
+            "worst": b,
+            "best": c
+        }
+        self.assertEqual(
+            format_valid_streams(FakePlugin, streams),
+            ", ".join([
+                "audio",
+                "720p (worst)",
+                "1080p (best)"
+            ])
+        )
+
+        streams = {
+            "audio": a,
+            "720p": b,
+            "1080p": c,
+            "worst-unfiltered": b,
+            "best-unfiltered": c
+        }
+        self.assertEqual(
+            format_valid_streams(FakePlugin, streams),
+            ", ".join([
+                "audio",
+                "720p (worst-unfiltered)",
+                "1080p (best-unfiltered)"
+            ])
+        )
diff --git a/tests/test_session.py b/tests/test_session.py
index 573aae37..e923ab53 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -99,12 +99,23 @@ class TestSession(unittest.TestCase):
         self.assertTrue(isinstance(streams["480p"], RTMPStream))
         self.assertTrue(isinstance(streams["480p_http"], HTTPStream))
 
-    def test_plugin_stream_sorted_excludes(self):
+    def test_plugin_stream_sorting_excludes(self):
         channel = self.session.resolve_url("http://test.se/channel")
-        streams = channel.streams(sorting_excludes=["1080p", "3000k"])
 
+        streams = channel.streams(sorting_excludes=[])
         self.assertTrue("best" in streams)
         self.assertTrue("worst" in streams)
+        self.assertFalse("best-unfiltered" in streams)
+        self.assertFalse("worst-unfiltered" in streams)
+        self.assertTrue(streams["worst"] is streams["350k"])
+        self.assertTrue(streams["best"] is streams["1080p"])
+
+        streams = channel.streams(sorting_excludes=["1080p", "3000k"])
+        self.assertTrue("best" in streams)
+        self.assertTrue("worst" in streams)
+        self.assertFalse("best-unfiltered" in streams)
+        self.assertFalse("worst-unfiltered" in streams)
+        self.assertTrue(streams["worst"] is streams["350k"])
         self.assertTrue(streams["best"] is streams["1500k"])
 
         streams = channel.streams(sorting_excludes=[">=1080p", ">1500k"])
@@ -113,6 +124,24 @@ class TestSession(unittest.TestCase):
         streams = channel.streams(sorting_excludes=lambda q: not q.endswith("p"))
         self.assertTrue(streams["best"] is streams["3000k"])
 
+        streams = channel.streams(sorting_excludes=lambda q: False)
+        self.assertFalse("best" in streams)
+        self.assertFalse("worst" in streams)
+        self.assertTrue("best-unfiltered" in streams)
+        self.assertTrue("worst-unfiltered" in streams)
+        self.assertTrue(streams["worst-unfiltered"] is streams["350k"])
+        self.assertTrue(streams["best-unfiltered"] is streams["1080p"])
+
+        channel = self.session.resolve_url("http://test.se/UnsortableStreamNames")
+        streams = channel.streams()
+        self.assertFalse("best" in streams)
+        self.assertFalse("worst" in streams)
+        self.assertFalse("best-unfiltered" in streams)
+        self.assertFalse("worst-unfiltered" in streams)
+        self.assertTrue("vod" in streams)
+        self.assertTrue("vod_alt" in streams)
+        self.assertTrue("vod_alt2" in streams)
+
     def test_plugin_support(self):
         channel = self.session.resolve_url("http://test.se/channel")
         streams = channel.streams()