Repository: streamlink/streamlink
Pull request #1531 (2018-03-03 22:14:32)

Add support for RTBF
This plugin adds support for the Belgian public TV video platform RTBF Auvio, as requested in https://github.com/streamlink/streamlink/issues/1367. It supports:

* live video streams (some streams may be georestricted to Belgium or EU): https://www.rtbf.be/auvio/direct#/
* VOD: https://www.rtbf.be/auvio/emissions
* live radio: http://www.rtbfradioplayer.be


Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 50c16930..fbb07e6d 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -167,6 +167,8 @@ radionet            - radio.net          Yes   --
                     - radio.pt
                     - radio.se
 raiplay             raiplay.it           Yes   No    Most streams are geo-restricted to Italy.
+rtbf                - rtbf.be/auvio      Yes   Yes   Streams may be geo-restricted to Belgium or Europe.
+                    - rtbfradioplayer.be
 rtlxl               rtlxl.nl             No    Yes   Streams may be geo-restricted to The Netherlands. Livestreams not supported.
 rte                 rte.ie/player        Yes   Yes
 rtpplay             rtp.pt/play          Yes   Yes   Streams may be geo-restricted to Portugal.
diff --git a/src/streamlink/plugins/rtbf.py b/src/streamlink/plugins/rtbf.py
new file mode 100644
index 00000000..e6e6f769
--- /dev/null
+++ b/src/streamlink/plugins/rtbf.py
@@ -0,0 +1,163 @@
+import datetime
+try:
+    from HTMLParser import HTMLParser
+except ImportError:
+    from html.parser import HTMLParser
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import HLSStream, HTTPStream
+from streamlink.utils import parse_json
+
+
+class RTBF(Plugin):
+    GEO_URL = 'https://www.rtbf.be/api/geoloc'
+    TOKEN_URL = 'https://token.rtbf.be/'
+    RADIO_STREAM_URL = 'http://www.rtbfradioplayer.be/radio/liveradio/rtbf/radios/{}/config.json'
+
+    _url_re = re.compile(r'https?://(?:www\.)?(?:rtbf\.be/auvio/.*\?l?id=(?P<video_id>[0-9]+)#?|rtbfradioplayer\.be/radio/liveradio/(?:webradio-)?(?P<radio>.+))')
+    _stream_size_re = re.compile(r'https?://.+-(?P<size>\d+p?)\..+?$')
+
+    _video_player_re = re.compile(r'<iframe\s+class="embed-responsive-item\s+js-embed-iframe".*src="(?P<player_url>.+?)".*?</iframe>', re.DOTALL)
+    _video_stream_data_re = re.compile(r'<div\s+id="js-embed-player"\s+class="js-embed-player\s+embed-player"\s+data-media="(.+?)"')
+
+    _geo_schema = validate.Schema(
+        {
+            'country': validate.text,
+            'zone': validate.text
+        }
+    )
+
+    _video_stream_schema = validate.Schema(
+        validate.transform(_video_stream_data_re.search),
+        validate.any(
+            None,
+            validate.all(
+                validate.get(1),
+                validate.transform(HTMLParser().unescape),
+                validate.transform(parse_json),
+                {
+                    'geoLocRestriction': validate.text,
+                    validate.optional('isLive'): bool,
+                    validate.optional('startDate'): validate.text,
+                    validate.optional('endDate'): validate.text,
+                    'sources': validate.any(
+                        [],
+                        validate.Schema({
+                            validate.text: validate.any(None, '', validate.url())
+                        })
+                    ),
+                    validate.optional('urlHls'): validate.any(None, '', validate.url()),
+                    validate.optional('urlDash'): validate.any(None, '', validate.url()),
+                    validate.optional('streamUrlHls'): validate.any(None, '', validate.url()),
+                    validate.optional('streamUrlDash'): validate.any(None, '', validate.url())
+                }
+            )
+        )
+    )
+
+    _radio_stream_schema = validate.Schema(
+        {
+            'audioUrls': validate.all(
+                [{
+                    'url': validate.url(),
+                    'mimeType': validate.text
+                }]
+            )
+        }
+    )
+
+    @classmethod
+    def check_geolocation(cls, geoloc_flag):
+        if geoloc_flag == 'open':
+            return True
+
+        res = http.get(cls.GEO_URL)
+        data = http.json(res, schema=cls._geo_schema)
+        return data['country'] == geoloc_flag or data['zone'] == geoloc_flag
+
+    @classmethod
+    def tokenize_stream(cls, url):
+        res = http.post(cls.TOKEN_URL, data={'streams[url]': url})
+        data = http.json(res)
+        return data['streams']['url']
+
+    @staticmethod
+    def iso8601_to_epoch(date):
+        # Convert an ISO 8601-formatted string date to datetime
+        return datetime.datetime.strptime(date[:-6], '%Y-%m-%dT%H:%M:%S') + \
+            datetime.timedelta(hours=int(date[-6:-3]), minutes=int(date[-2:]))
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return RTBF._url_re.match(url)
+
+    def _get_radio_streams(self, radio):
+        res = http.get(self.RADIO_STREAM_URL.format(radio.replace('-', '_')))
+        streams = http.json(res, schema=self._radio_stream_schema)
+
+        for stream in streams['audioUrls']:
+            match = self._stream_size_re.match(stream['url'])
+            if match is not None:
+                quality = '{}k'.format(match.group('size'))
+            else:
+                quality = stream['mimetype']
+            yield quality, HTTPStream(self.session, stream['url'])
+
+    def _get_video_streams(self):
+        res = http.get(self.url)
+        match = self._video_player_re.search(res.text)
+        if match is None:
+            return
+        player_url = match.group('player_url')
+        stream_data = http.get(player_url, schema=self._video_stream_schema)
+        if stream_data is None:
+            return
+
+        # Check geolocation to prevent further errors when stream is parsed
+        if not self.check_geolocation(stream_data['geoLocRestriction']):
+            self.logger.error('Stream is geo-restricted')
+            return
+
+        now = datetime.datetime.now()
+        try:
+            if isinstance(stream_data['sources'], dict):
+                urls = []
+                for profile, url in stream_data['sources'].items():
+                    if not url or url in urls:
+                        continue
+                    match = self._stream_size_re.match(url)
+                    if match is not None:
+                        quality = match.group('size')
+                    else:
+                        quality = profile
+                    yield quality, HTTPStream(self.session, url)
+                    urls.append(url)
+
+            hls_url = stream_data.get('urlHls') or stream_data.get('streamUrlHls')
+            if hls_url:
+                if stream_data.get('isLive', False):
+                    # Live streams require a token
+                    hls_url = self.tokenize_stream(hls_url)
+                for stream in HLSStream.parse_variant_playlist(self.session, hls_url).items():
+                    yield stream
+
+        except IOError as err:
+            if '403 Client Error' in str(err):
+                # Check whether video is expired
+                if 'startDate' in stream_data:
+                    if now < self.iso8601_to_epoch(stream_data['startDate']):
+                        self.logger.error('Stream is not yet available')
+                elif 'endDate' in stream_data:
+                    if now > self.iso8601_to_epoch(stream_data['endDate']):
+                        self.logger.error('Stream has expired')
+
+    def _get_streams(self):
+        match = self.can_handle_url(self.url)
+        if match.group('radio'):
+            return self._get_radio_streams(match.group('radio'))
+        return self._get_video_streams()
+
+
+__plugin__ = RTBF


Test patch:
diff --git a/tests/test_plugin_rtbf.py b/tests/test_plugin_rtbf.py
new file mode 100644
index 00000000..5d65612e
--- /dev/null
+++ b/tests/test_plugin_rtbf.py
@@ -0,0 +1,18 @@
+import unittest
+
+from streamlink.plugins.rtbf import RTBF
+
+
+class TestPluginRTBF(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(RTBF.can_handle_url("https://www.rtbf.be/auvio/direct_doc-shot?lid=122046#/"))
+        self.assertTrue(RTBF.can_handle_url("https://www.rtbf.be/auvio/emissions/detail_dans-la-toile?id=11493"))
+        self.assertTrue(RTBF.can_handle_url("http://www.rtbfradioplayer.be/radio/liveradio/purefm"))
+
+        # shouldn't match
+        self.assertFalse(RTBF.can_handle_url("http://www.rtbf.be/"))
+        self.assertFalse(RTBF.can_handle_url("http://www.rtbf.be/auvio"))
+        self.assertFalse(RTBF.can_handle_url("http://www.rtbfradioplayer.be/"))
+        self.assertFalse(RTBF.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(RTBF.can_handle_url("http://www.youtube.com/"))