Repository: streamlink/streamlink
Pull request #2186 (2018-12-01 22:50:37)

plugins.cdnbg: Update for extra channels
- http://inlife.bg/
- https://mmtvmusic.com/live/
- http://mu-vi.tv/LiveStreams/pages/Live.aspx

---

- use python logging
- removed SSL Verification for Stream URLs
- find iframes with `&#58;` instead of `:`

closes https://github.com/streamlink/streamlink/issues/1392


Patch:
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 81d3ec1d..d4f25cb9 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -39,8 +39,12 @@ btv                     btv.bg               Yes   No    Requires login, and geo
 canalplus               mycanal.fr           No    Yes   Streams may be geo-restricted to France.
 cdnbg                   - tv.bnt.bg          Yes   No    Streams may be geo-restricted to Bulgaria.
                         - bgonair.bg
-                        - kanal3.bg
                         - bitelevision.com
+                        - bloombergtv.bg
+                        - inlife.bg
+                        - kanal3.bg
+                        - mmtvmusic.com
+                        - mu-vi.tv
                         - nova.bg
 ceskatelevize           ceskatelevize.cz     Yes   Yes   Streams may be geo-restricted to Czechia.
 cinergroup              - showtv.com.tr      Yes   No
@@ -165,6 +169,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.
@@ -184,6 +189,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
@@ -250,6 +256,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/plugins/cdnbg.py b/src/streamlink/plugins/cdnbg.py
index 2c15eb2f..dfb5591d 100644
--- a/src/streamlink/plugins/cdnbg.py
+++ b/src/streamlink/plugins/cdnbg.py
@@ -1,5 +1,4 @@
-from __future__ import print_function
-
+import logging
 import re
 
 from streamlink.compat import urlparse
@@ -9,6 +8,8 @@ from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
 from streamlink.utils import update_scheme
 
+log = logging.getLogger(__name__)
+
 
 class CDNBG(Plugin):
     url_re = re.compile(r"""
@@ -18,11 +19,13 @@ class CDNBG(Plugin):
             nova\.bg/live|
             kanal3\.bg/live|
             bgonair\.bg/tvonline|
-            tvevropa\.com/na-zhivo|
+            inlife\.bg|
+            mmtvmusic\.com/live|
+            mu-vi\.tv/LiveStreams/pages/Live\.aspx|
             bloombergtv.bg/video
         )/?
     """, re.VERBOSE)
-    iframe_re = re.compile(r"iframe .*?src=\"((?:https?:)?//(?:\w+\.)?cdn.bg/live[^\"]+)\"", re.DOTALL)
+    iframe_re = re.compile(r"iframe .*?src=\"((?:https?(?::|&#58;))?//(?:\w+\.)?cdn.bg/live[^\"]+)\"", re.DOTALL)
     sdata_re = re.compile(r"sdata\.src.*?=.*?(?P<q>[\"'])(?P<url>http.*?)(?P=q)")
     hls_file_re = re.compile(r"(src|file): (?P<q>[\"'])(?P<url>(https?:)?//.+?m3u8.*?)(?P=q)")
     hls_src_re = re.compile(r"video src=(?P<url>http[^ ]+m3u8[^ ]*)")
@@ -43,23 +46,25 @@ class CDNBG(Plugin):
         p = urlparse(self.url)
         for url in self.iframe_re.findall(res.text):
             if "googletagmanager" not in url:
+                url = url.replace("&#58;", ":")
                 if url.startswith("//"):
                     return "{0}:{1}".format(p.scheme, url)
                 else:
                     return url
 
     def _get_streams(self):
-        self.session.http.headers = {"User-Agent": useragents.CHROME}
+        self.session.http.headers.update({"User-Agent": useragents.CHROME})
         res = self.session.http.get(self.url)
         iframe_url = self.find_iframe(res)
 
         if iframe_url:
-            self.logger.debug("Found iframe: {0}", iframe_url)
+            log.debug("Found iframe: {0}", 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,
-                                                    headers={"User-Agent": useragents.CHROME})
+                                                    verify=False)
 
 
 __plugin__ = CDNBG
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/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/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 6af91976..e018ccba 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -741,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/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_cdnbg.py b/tests/plugins/test_cdnbg.py
index 8b8c48d3..7b6026a6 100644
--- a/tests/plugins/test_cdnbg.py
+++ b/tests/plugins/test_cdnbg.py
@@ -19,7 +19,12 @@ class TestPluginCDNBG(unittest.TestCase):
         self.assertTrue(CDNBG.can_handle_url("http://tv.bnt.bg/bnt1/16x9"))
         self.assertTrue(CDNBG.can_handle_url("http://tv.bnt.bg/bnt2/16x9/"))
         self.assertTrue(CDNBG.can_handle_url("http://tv.bnt.bg/bnt2/16x9"))
+        self.assertTrue(CDNBG.can_handle_url("http://inlife.bg/"))
+        self.assertTrue(CDNBG.can_handle_url("https://mmtvmusic.com/live/"))
+        self.assertTrue(CDNBG.can_handle_url("http://mu-vi.tv/LiveStreams/pages/Live.aspx"))
+        self.assertTrue(CDNBG.can_handle_url("https://www.bloombergtv.bg/video"))
 
         # shouldn't match
         self.assertFalse(CDNBG.can_handle_url("http://www.tvcatchup.com/"))
         self.assertFalse(CDNBG.can_handle_url("http://www.youtube.com/"))
+        self.assertFalse(CDNBG.can_handle_url("https://www.tvevropa.com"))
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_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_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))