Repository: streamlink/streamlink
Pull request #2156 (2018-11-08 14:24:26)

Update the documentation with comments for playing YouTube Live Streams


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..657e1fab 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -165,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.
@@ -184,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
@@ -250,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/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/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/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


Test patch:
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_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))