Repository: streamlink/streamlink
Pull request #1051 (2017-06-30 21:06:21)

Add support for RTP Play

codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/1051?src=pr&el=h1) Report
> Merging [#1051](https://codecov.io/gh/streamlink/streamlink/pull/1051?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/b657864819098128e1253a9ce0c7f4d3be0c9f24?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `n/a`.


```diff
@@           Coverage Diff           @@
##           master    #1051   +/-   ##
=======================================
  Coverage   42.79%   42.79%           
=======================================
  Files          93       93           
  Lines        7718     7718           
=======================================
  Hits         3303     3303           
  Misses       4415     4415
```

beardypig: Looks good to me :-)
gravyboat: This looks good to me as well, do you want to add tests (example here: https://github.com/streamlink/streamlink/blob/master/tests/test_plugin_eurocom.py) and update the plugin matrix (example here: https://github.com/streamlink/streamlink/pull/993/files) if not no worries.

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index b2e12936..1e09b3e8 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -40,6 +40,7 @@ bilibili            live.bilibili.com    Yes   ?
 bloomberg           bloomberg.com        Yes   Yes
 bongacams           bongacams.com        Yes   No    Only RTMP streams are available.
 brightcove          players.brig... [6]_ Yes   Yes
+brittv              brittv.co.uk         Yes   --
 btv                 btv.bg               Yes   No    Requires login, and geo-restricted to Bulgaria.
 cam4                cam4.com             Yes   No
 camsoda             camsoda.com          Yes   No
@@ -154,21 +155,22 @@ playtv              playtv.fr            Yes   --    Streams may be geo-restrict
 pluzz               - france.tv          Yes   Yes   Streams may be geo-restricted to France, Andorra and Monaco.
                     - ludo.fr
                     - zouzous.fr
-		    - france3-reg.. [8]_
+                    - france3-reg.. [8]_
 powerapp            powerapp.com.tr      Yes   No
 radionet            - radio.net          Yes   --
                     - radio.at
-		    - radio.de
-		    - radio.dk
-		    - radio.es
-		    - radio.fr
-		    - radio.it
-		    - radio.pl
-		    - radio.pt
-		    - radio.se
+                    - radio.de
+                    - radio.dk
+                    - radio.es
+                    - radio.fr
+                    - radio.it
+                    - radio.pl
+                    - radio.pt
+                    - radio.se
 raiplay             raiplay.it           Yes   No    Most streams are geo-restricted to Italy.
 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.
 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.
@@ -261,6 +263,9 @@ wwenetwork          network.wwe.com      Yes   Yes   Requires an account to acce
 younow              younow.com           Yes   --
 youtube             - youtube.com        Yes   Yes   Protected videos are not supported.
                     - youtu.be
+zattoo              - zattoo.com         Yes   Yes
+                    - nettv.net... [10]_
+                    - tvonline.ewe.de
 zdf_mediathek       zdf.de               Yes   Yes   Streams may be geo-restricted to Germany.
 zhanqitv            zhanqi.tv            Yes   No
 =================== ==================== ===== ===== ===========================
@@ -275,3 +280,4 @@ zhanqitv            zhanqi.tv            Yes   No
 .. [7] player.theplatform.com
 .. [8] france3-regions.francetvinfo.fr
 .. [9] tv5mondeplusafrique.com
+.. [10] nettv.netcologne.de
diff --git a/src/streamlink/plugins/brittv.py b/src/streamlink/plugins/brittv.py
new file mode 100644
index 00000000..d766295e
--- /dev/null
+++ b/src/streamlink/plugins/brittv.py
@@ -0,0 +1,31 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugin.api import useragents
+from streamlink.compat import urljoin
+from streamlink.stream import HLSStream
+
+
+class BritTV(Plugin):
+    url_re = re.compile(r"https?://(?:www\.)?brittv\.co.uk/watch/")
+    js_re = re.compile(r"""/js/brittv\.player\.js\.php\?key=([^'"]+)['"]""")
+    player_re = re.compile(r"file: '(http://[^']+)'")
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        res = http.get(self.url, headers={"User-Agent": useragents.CHROME})
+        m = self.js_re.search(res.text)
+        if m:
+            self.logger.debug("Found js key: {0}", m.group(1))
+            js_url = m.group(0)
+            res = http.get(urljoin(self.url, js_url))
+
+            for url in self.player_re.findall(res.text):
+                if "adblock" not in url:
+                    yield "live", HLSStream(self.session, url)
+
+__plugin__ = BritTV
diff --git a/src/streamlink/plugins/rtpplay.py b/src/streamlink/plugins/rtpplay.py
new file mode 100644
index 00000000..05c71231
--- /dev/null
+++ b/src/streamlink/plugins/rtpplay.py
@@ -0,0 +1,40 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, useragents
+from streamlink.stream import HLSStream
+
+
+class RTPPlay(Plugin):
+    _url_re = re.compile(r"https?:\/\/www.rtp.pt\/play\/")
+    _m3u8_re = re.compile(r"file:(?:\s+)?(?:\'|\")(?P<url>[^\"']+)(?:\'|\")")
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return RTPPlay._url_re.match(url)
+
+    def _get_streams(self):
+        
+        headers = {
+            "User-Agent": useragents.CHROME
+        }
+
+        res = http.get(self.url, headers=headers)
+
+        url_match = RTPPlay._m3u8_re.search(res.text)
+        
+        if url_match:
+            hls_url = url_match.group("url")
+
+            self.logger.debug("Found URL: {0}".format(hls_url))
+
+            try:
+                s = []
+                for s in HLSStream.parse_variant_playlist(self.session, hls_url).items():
+                    yield s
+                if not s:
+                    yield "live", HLSStream(self.session, hls_url)
+            except IOError as err:
+                self.logger.error("Failed to extract streams: {0}", err)
+
+__plugin__ = RTPPlay
diff --git a/src/streamlink/plugins/zattoo.py b/src/streamlink/plugins/zattoo.py
new file mode 100644
index 00000000..966500eb
--- /dev/null
+++ b/src/streamlink/plugins/zattoo.py
@@ -0,0 +1,238 @@
+import re
+import time
+import uuid
+
+from streamlink.cache import Cache
+from streamlink.plugin import Plugin
+from streamlink.plugin import PluginOptions
+from streamlink.plugin.api import http
+from streamlink.plugin.api import useragents
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+
+
+class Zattoo(Plugin):
+    API_HELLO = '{0}/zapi/session/hello'
+    API_LOGIN = '{0}/zapi/v2/account/login'
+    API_CHANNELS = '{0}/zapi/v2/cached/channels/{1}?details=False'
+    API_WATCH = '{0}/zapi/watch'
+    API_WATCH_VOD = '{0}/zapi/avod/videos/{1}/watch'
+
+    _url_re = re.compile(r'''
+        https?://
+        (?P<base_url>
+        zattoo\.com
+        |
+        tvonline\.ewe\.de
+        |
+        nettv\.netcologne\.de
+        )/(?:watch/(?P<channel>[^/\s]+)
+        |
+        ondemand/watch/(?P<vod_id>[^-]+)-)
+        ''', re.VERBOSE)
+
+    _app_token_re = re.compile(r"""window\.appToken\s+=\s+'([^']+)'""")
+
+    _channels_schema = validate.Schema({
+        'success': int,
+        'channel_groups': [{
+            'channels': [
+                {
+                    'display_alias': validate.text,
+                    'cid': validate.text
+                },
+            ]
+        }]},
+        validate.get('channel_groups'),
+    )
+
+    options = PluginOptions({
+        'email': None,
+        'password': None,
+        'purge_credentials': None
+    })
+
+    def __init__(self, url):
+        super(Zattoo, self).__init__(url)
+        self._session_attributes = Cache(filename='plugin-cache.json', key_prefix='zattoo:attributes')
+        self._authed = self._session_attributes.get('beaker.session.id') and self._session_attributes.get('pzuid') and self._session_attributes.get('power_guide_hash')
+        self._uuid = self._session_attributes.get('uuid')
+        self._expires = self._session_attributes.get('expires')
+
+        self.base_url = 'https://{0}'.format(Zattoo._url_re.match(url).group('base_url'))
+        self.headers = {
+            'User-Agent': useragents.CHROME,
+            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
+            'X-Requested-With': 'XMLHttpRequest',
+            'Referer': self.base_url
+        }
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return Zattoo._url_re.match(url)
+
+    def _hello(self):
+        self.logger.debug('_hello ...')
+        res = http.get(self.base_url)
+        match = self._app_token_re.search(res.text)
+
+        app_token = match.group(1)
+        hello_url = self.API_HELLO.format(self.base_url)
+
+        if self._uuid:
+            __uuid = self._uuid
+        else:
+            __uuid = str(uuid.uuid4())
+            self._session_attributes.set('uuid', __uuid, expires=3600 * 24)
+
+        params = {
+            'client_app_token': app_token,
+            'uuid': __uuid,
+            'lang': 'en',
+            'format': 'json'
+        }
+        res = http.post(hello_url, headers=self.headers, data=params)
+        return res
+
+    def _login(self, email, password, _hello):
+        self.logger.debug('_login ... Attempting login as {0}'.format(email))
+
+        login_url = self.API_LOGIN.format(self.base_url)
+
+        params = {
+            'login': email,
+            'password': password,
+            'remember': 'true'
+        }
+
+        res = http.post(login_url, headers=self.headers, data=params, cookies=_hello.cookies)
+        data = http.json(res)
+
+        self._authed = data['success']
+        if self._authed:
+            self.logger.debug('New Session Data')
+            self._session_attributes.set('beaker.session.id', res.cookies.get('beaker.session.id'), expires=3600 * 24)
+            self._session_attributes.set('pzuid', res.cookies.get('pzuid'), expires=3600 * 24)
+            self._session_attributes.set('power_guide_hash', data['session']['power_guide_hash'], expires=3600 * 24)
+            return self._authed
+        else:
+            return None
+
+    def _watch(self):
+        self.logger.debug('_watch ...')
+        match = self._url_re.match(self.url)
+        if not match:
+            return
+        channel = match.group('channel')
+        vod_id = match.group('vod_id')
+
+        cookies = {
+            'beaker.session.id': self._session_attributes.get('beaker.session.id'),
+            'pzuid': self._session_attributes.get('pzuid')
+        }
+
+        watch_url = []
+        if channel:
+            params, watch_url = self._watch_live(channel, cookies)
+        elif vod_id:
+            params, watch_url = self._watch_vod(vod_id)
+
+        if not watch_url:
+            return
+
+        res = []
+        try:
+            res = http.post(watch_url, headers=self.headers, data=params, cookies=cookies)
+        except Exception as e:
+            if '404 Client Error' in str(e):
+                self.logger.error('Unfortunately streaming is not permitted in this country or this channel does not exist.')
+            elif '402 Client Error: Payment Required' in str(e):
+                self.logger.error('Paid subscription required for this channel.')
+                self.logger.info('If paid subscription exist, use --zattoo-purge-credentials to start a new session.')
+            else:
+                self.logger.error(str(e))
+            return
+
+        data = http.json(res)
+
+        if data['success']:
+            for hls_url in data['stream']['watch_urls']:
+                for s in HLSStream.parse_variant_playlist(self.session, hls_url['url']).items():
+                    yield s
+
+    def _watch_live(self, channel, cookies):
+        self.logger.debug('_watch_live ... Channel: {0}'.format(channel))
+        watch_url = self.API_WATCH.format(self.base_url)
+
+        channels_url = self.API_CHANNELS.format(self.base_url, self._session_attributes.get('power_guide_hash'))
+        res = http.get(channels_url, headers=self.headers, cookies=cookies)
+        data = http.json(res, schema=self._channels_schema)
+
+        c_list = []
+        for d in data:
+            for c in d['channels']:
+                c_list.append(c)
+
+        cid = []
+        zattoo_list = []
+        for c in c_list:
+            zattoo_list.append(c['display_alias'])
+            if c['display_alias'] == channel:
+                cid = c['cid']
+
+        self.logger.debug('Available zattoo channels in this country: {0}'.format(', '.join(sorted(zattoo_list))))
+
+        if not cid:
+            cid = channel
+
+        self.logger.debug('CHANNEL ID: {0}'.format(cid))
+
+        params = {
+            'cid': cid,
+            'https_watch_urls': True,
+            'stream_type': 'hls'
+        }
+        return params, watch_url
+
+    def _watch_vod(self, vod_id):
+        self.logger.debug('_watch_vod ...')
+        watch_url = self.API_WATCH_VOD.format(self.base_url, vod_id)
+        params = {
+            'https_watch_urls': True,
+            'stream_type': 'hls'
+        }
+        return params, watch_url
+
+    def _get_streams(self):
+        email = self.get_option('email')
+        password = self.get_option('password')
+
+        if self.options.get('purge_credentials'):
+            self._session_attributes.set('beaker.session.id', None, expires=0)
+            self._session_attributes.set('expires', None, expires=0)
+            self._session_attributes.set('power_guide_hash', None, expires=0)
+            self._session_attributes.set('pzuid', None, expires=0)
+            self._session_attributes.set('uuid', None, expires=0)
+            self._authed = False
+            self.logger.info('All credentials were successfully removed.')
+
+        if not self._authed and (not email and not password):
+            self.logger.error('A login for Zattoo is required, use --zattoo-email EMAIL --zattoo-password PASSWORD to set them')
+            return
+
+        if self._authed:
+            if self._expires < time.time():
+                # login after 24h
+                expires = time.time() + 3600 * 24
+                self._session_attributes.set('expires', expires, expires=3600 * 24)
+                self._authed = False
+
+        if not self._authed:
+            __hello = self._hello()
+            if not self._login(email, password, __hello):
+                self.logger.error('Failed to login, check your username/password')
+                return
+
+        return self._watch()
+
+__plugin__ = Zattoo
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 2628b15b..1c96a4e9 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -1285,6 +1285,28 @@ plugin.add_argument(
     A ufc.tv account password to use with --ufctv-username.
     """
 )
+plugin.add_argument(
+    "--zattoo-email",
+    metavar="EMAIL",
+    help="""
+    The email associated with your zattoo account, required to access any zattoo stream.
+    """
+)
+plugin.add_argument(
+    "--zattoo-password",
+    metavar="PASSWORD",
+    help="""
+    A zattoo account password to use with --zattoo-email.
+    """
+)
+plugin.add_argument(
+    "--zattoo-purge-credentials",
+    action="store_true",
+    help="""
+    Purge cached zattoo credentials to initiate a new session
+    and reauthenticate.
+    """
+)
 
 # Deprecated options
 stream.add_argument(
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 24195591..113bb38b 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -941,6 +941,19 @@ def setup_plugin_options():
     if ufctv_password:
         streamlink.set_plugin_option("ufctv", "password", ufctv_password)
 
+    if args.zattoo_email:
+        streamlink.set_plugin_option("zattoo", "email", args.zattoo_email)
+    if args.zattoo_email and not args.zattoo_password:
+        zattoo_password = console.askpass("Enter zattoo password: ")
+    else:
+        zattoo_password = args.zattoo_password
+    if zattoo_password:
+        streamlink.set_plugin_option("zattoo", "password", zattoo_password)
+
+    if args.zattoo_purge_credentials:
+        streamlink.set_plugin_option("zattoo", "purge_credentials",
+                                     args.zattoo_purge_credentials)
+
     # Deprecated options
     if args.jtv_legacy_names:
         console.logger.warning("The option --jtv/twitch-legacy-names is "


Test patch:
diff --git a/tests/test_plugin_rtpplay.py b/tests/test_plugin_rtpplay.py
new file mode 100644
index 00000000..8651947e
--- /dev/null
+++ b/tests/test_plugin_rtpplay.py
@@ -0,0 +1,16 @@
+import unittest
+
+from streamlink.plugins.rtpplay import RTPPlay
+
+
+class TestPluginRTPPlay(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(RTPPlay.can_handle_url("http://www.rtp.pt/play/"))
+        self.assertTrue(RTPPlay.can_handle_url("https://www.rtp.pt/play/"))
+
+        # shouldn't match
+        self.assertFalse(RTPPlay.can_handle_url("https://www.rtp.pt/programa/"))
+        self.assertFalse(RTPPlay.can_handle_url("http://www.rtp.pt/programa/"))
+        self.assertFalse(RTPPlay.can_handle_url("https://media.rtp.pt/"))
+        self.assertFalse(RTPPlay.can_handle_url("http://media.rtp.pt/"))
diff --git a/tests/test_plugin_zattoo.py b/tests/test_plugin_zattoo.py
new file mode 100644
index 00000000..0ad0dfcd
--- /dev/null
+++ b/tests/test_plugin_zattoo.py
@@ -0,0 +1,24 @@
+import unittest
+
+from streamlink.plugins.zattoo import Zattoo
+
+
+class TestPluginZattoo(unittest.TestCase):
+    def test_can_handle_url(self):
+        # ewe live
+        self.assertTrue(Zattoo.can_handle_url('http://tvonline.ewe.de/watch/daserste'))
+        self.assertTrue(Zattoo.can_handle_url('http://tvonline.ewe.de/watch/zdf'))
+        # netcologne live
+        self.assertTrue(Zattoo.can_handle_url('https://nettv.netcologne.de/watch/daserste'))
+        self.assertTrue(Zattoo.can_handle_url('https://nettv.netcologne.de/watch/zdf'))
+        # zattoo live
+        self.assertTrue(Zattoo.can_handle_url('https://zattoo.com/watch/daserste'))
+        self.assertTrue(Zattoo.can_handle_url('https://zattoo.com/watch/zdf'))
+        # zattoo vod
+        self.assertTrue(Zattoo.can_handle_url('https://zattoo.com/ondemand/watch/ibR2fpisWFZGvmPBRaKnFnuT-alarm-am-airport'))
+        self.assertTrue(Zattoo.can_handle_url('https://zattoo.com/ondemand/watch/G8S7JxcewY2jEwAgMzvFWK8c-berliner-schnauzen'))
+
+        # shouldn't match
+        self.assertFalse(Zattoo.can_handle_url('https://ewe.de'))
+        self.assertFalse(Zattoo.can_handle_url('https://netcologne.de'))
+        self.assertFalse(Zattoo.can_handle_url('https://zattoo.com'))