Repository: streamlink/streamlink
Pull request #439 (2017-01-17 12:58:15)

Add support for Play TV
This plugin adds support for the French TV streaming platform [Play TV](http://playtv.fr/).
Some channels may be geo-restricted.

codecov-io: ## [Current coverage](https://codecov.io/gh/streamlink/streamlink/pull/439?src=pr) is 47.32% (diff: 36.84%)
> Merging [#439](https://codecov.io/gh/streamlink/streamlink/pull/439?src=pr) into [master](https://codecov.io/gh/streamlink/streamlink/branch/master?src=pr) will increase coverage by **0.37%**

```diff
@@             master       #439   diff @@
==========================================
  Files            56         57     +1   
  Lines          4837       4875    +38   
  Methods           0          0          
  Messages          0          0          
  Branches          0          0          
==========================================
+ Hits           2271       2307    +36   
- Misses         2566       2568     +2   
  Partials          0          0          
```

> Powered by [Codecov](https://codecov.io?src=pr). Last update [8a66ee7...132e723](https://codecov.io/gh/streamlink/streamlink/compare/8a66ee7a6c10ed1584eab695c864936d9aadd46a...132e723aaf373eb5c43e9163f5d7302b5a244c5f?src=pr)

Patch:
diff --git a/docs/install.rst b/docs/install.rst
index 4a72c043..3804c1f4 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -18,6 +18,9 @@ Distribution                         Installing
                                         # pacaur -S streamlink-git
 
                                      `Installing AUR packages`_
+`Fedora`_                            .. code-block:: console
+
+                                        # dnf install streamlink
 `Gentoo Linux`_                      .. code-block:: console
 
                                         # emerge net-misc/streamlink
@@ -41,6 +44,7 @@ Distribution                         Installing
 
 .. _Arch Linux (aur): https://aur.archlinux.org/packages/streamlink/
 .. _Arch Linux (aur, git): https://aur.archlinux.org/packages/streamlink-git/
+.. _Fedora: https://apps.fedoraproject.org/packages/python-streamlink
 .. _Gentoo Linux: https://packages.gentoo.org/package/net-misc/streamlink
 .. _NetBSD (pkgsrc): http://pkgsrc.se/multimedia/streamlink
 .. _NixOS: https://github.com/NixOS/nixpkgs/tree/master/pkgs/applications/video/streamlink
@@ -76,6 +80,7 @@ Distribution/Platform                Maintainer
 ==================================== ===========================================
 Arch                                 Josip Ponjavic <josipponjavic at gmail.com>
 Chocolatey                           Scott Walters <me at scowalt.com>
+Fedora                               Mohamed El Morabity <melmorabity at fedoraproject.org>
 Gentoo                               soredake <fdsfgs at krutt.org>
 NetBSD                               Maya Rashish <maya at netbsd.org>
 NixOS                                Tuomas Tynkkynen <tuomas.tynkkynen at iki.fi>
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 94b84d2b..e204f7ce 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -109,6 +109,7 @@ orf_tvthek          tvthek.orf.at        Yes   Yes
 pandatv             panda.tv             Yes   ?
 periscope           periscope.tv         Yes   Yes   Replay/VOD is supported.
 picarto             picarto.tv           Yes   --
+playtv              playtv.fr            Yes   --    Streams may be geo-restricted to France.
 pluzz               pluzz.francetv.fr    Yes   Yes   Streams may be geo-restricted to France, Andorra and Monaco.
 powerapp            powerapp.com.tr      Yes   No
 raiplay             raiplay.it           Yes   No    Most streams are geo-restricted to Italy.
diff --git a/src/streamlink/plugins/adultswim.py b/src/streamlink/plugins/adultswim.py
index e3a7e873..df79df68 100644
--- a/src/streamlink/plugins/adultswim.py
+++ b/src/streamlink/plugins/adultswim.py
@@ -24,10 +24,12 @@ class AdultSwim(Plugin):
     live_schema = validate.Schema({
         u"streams": {
             validate.text: {u"stream": validate.text,
+                            u"isLive": bool,
                             u"archiveEpisodes": [{
                                 u"id": validate.text,
-                                u"slug": validate.text
+                                u"slug": validate.text,
                             }]}}
+
     })
     vod_id_schema = validate.Schema({u"show": {u"sluggedVideo": {u"id": validate.text}}},
                                     validate.transform(lambda x: x["show"]["sluggedVideo"]["id"]))
@@ -79,12 +81,25 @@ class AdultSwim(Plugin):
         stream_info = parse_json(stream_data.group(1), schema=self.live_schema)
         # get the stream ID
         stream_id = None
+        show_info = stream_info[u"streams"][show]
+
         if episode:
-            for epi in stream_info[u"streams"][show][u"archiveEpisodes"]:
+            self.logger.debug("Loading replay of episode: {0}/{1}", show, episode)
+            for epi in show_info[u"archiveEpisodes"]:
                 if epi[u"slug"] == episode:
                     stream_id = epi[u"id"]
-        else:
-            stream_id = stream_info[u"streams"][show][u"stream"]
+        elif show_info["isLive"] or not len(show_info[u"archiveEpisodes"]):
+            self.logger.debug("Loading LIVE streams for: {0}", show)
+            stream_id = show_info[u"stream"]
+        else:  # off-air
+            if len(show_info[u"archiveEpisodes"]):
+                epi = show_info[u"archiveEpisodes"][0]
+                self.logger.debug("Loading replay of episode: {0}/{1}", show, epi[u"slug"])
+                stream_id = epi[u"id"]
+            else:
+                self.logger.error("This stream is currently offline")
+                return
+
 
         if stream_id:
             api_url = self.API_URL.format(id=stream_id)
@@ -116,10 +131,6 @@ class AdultSwim(Plugin):
 
         if stream_data:
             if live_stream:
-                if episode_name:
-                    self.logger.debug("Loading replay of LIVE streams for: {0}/{1}", show_name, episode_name)
-                else:
-                    self.logger.debug("Loading LIVE streams for: {0}", show_name)
                 streams = self._get_live_stream(stream_data, show_name, episode_name)
             else:
                 self.logger.debug("Loading VOD streams for: {0}/{1}", show_name, episode_name)
diff --git a/src/streamlink/plugins/beattv.py b/src/streamlink/plugins/beattv.py
index ea7b2078..9b76f3e1 100644
--- a/src/streamlink/plugins/beattv.py
+++ b/src/streamlink/plugins/beattv.py
@@ -286,7 +286,7 @@ class BeatTV(Plugin):
 
     def _get_stream_info(self, url):
         res = http.get(url, headers=HEADERS)
-        match = re.search("embed.swf\?p=(\d+)", res.text)
+        match = re.search(r"embed.swf\?p=(\d+)", res.text)
         if not match:
             return
         program = match.group(1)
diff --git a/src/streamlink/plugins/bongacams.py b/src/streamlink/plugins/bongacams.py
index 23dc8d41..ae64e100 100644
--- a/src/streamlink/plugins/bongacams.py
+++ b/src/streamlink/plugins/bongacams.py
@@ -23,8 +23,8 @@ CONST_HEADERS = {}
 CONST_HEADERS['User-Agent'] = 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) '
 CONST_HEADERS['User-Agent'] += 'Chrome/54.0.2840.99 Safari/537.36 OPR/41.0.2353.69'
 
-url_re = re.compile("(http(s)?://)?(\w{2}.)?(bongacams.com)/([\w\d_-]+)")
-swf_re = re.compile("/swf/\w+/\w+.swf\?cache=\d+")
+url_re = re.compile(r"(http(s)?://)?(\w{2}.)?(bongacams.com)/([\w\d_-]+)")
+swf_re = re.compile(r"/swf/\w+/\w+.swf\?cache=\d+")
 
 amf_msg_schema = validate.Schema({
     "status": "success",
@@ -136,13 +136,10 @@ class bongacams(Plugin):
             "pageUrl": stream_page_url,
             "playpath": "%s?uid=%s" % (''.join(('stream_', stream_page_path)),
                                        self._get_stream_uid(stream_source_info['userData']['username'])),
-            # Multiple args with same name not supported.
-            # Details: https://github.com/streamlink/streamlink/issues/321
-            "conn": "S:{username} --conn=S:{access_key} --conn=B:0 --conn=S:{data_key}".format(
-                username=stream_source_info['userData']['username'],
-                access_key=stream_source_info['localData']['NC_AccessKey'],
-                data_key=stream_source_info['localData']['dataKey']
-            )
+            "conn": ["S:{0}".format(stream_source_info['userData']['username']),
+                     "S:{0}".format(stream_source_info['localData']['NC_AccessKey']),
+                     "B:0",
+                     "S:{0}".format(stream_source_info['localData']['dataKey'])]
         }
 
         self.logger.debug("Stream params:\n{}", stream_params)
diff --git a/src/streamlink/plugins/filmon_us.py b/src/streamlink/plugins/filmon_us.py
index 4e3dff5c..6e053e01 100644
--- a/src/streamlink/plugins/filmon_us.py
+++ b/src/streamlink/plugins/filmon_us.py
@@ -16,10 +16,10 @@ _live_export_re = re.compile(
 _live_json_re = re.compile(r"var startupChannel = (.+);")
 _replay_json_re = re.compile(r"var standByVideo = encodeURIComponent\('(.+)'\);")
 _history_re = re.compile(
-    "helpers.common.flash.flashplayerinstall\({url:'([^']+)',"
+    r"helpers.common.flash.flashplayerinstall\(\{url:'([^']+)',"
 )
 _video_flashvars_re = re.compile(
-    "<embed width=\"486\" height=\"326\" flashvars=\"([^\"]+)\""
+    r"<embed width=\"486\" height=\"326\" flashvars=\"([^\"]+)\""
 )
 
 _live_schema = validate.Schema({
diff --git a/src/streamlink/plugins/huajiao.py b/src/streamlink/plugins/huajiao.py
index 62bba075..e0f13ce6 100644
--- a/src/streamlink/plugins/huajiao.py
+++ b/src/streamlink/plugins/huajiao.py
@@ -15,7 +15,7 @@ USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTM
 HUAJIAO_URL = "http://www.huajiao.com/l/{}"
 LAPI_URL = "http://g2.live.360.cn/liveplay?stype=flv&channel={}&bid=huajiao&sn={}&sid={}&_rate=xd&ts={}&r={}&_ostype=flash&_delay=0&_sign=null&_ver=13"
 
-_url_re = re.compile("""
+_url_re = re.compile(r"""
         http(s)?://(www\.)?huajiao.com
         /l/(?P<channel>[^/]+)
 """, re.VERBOSE)
diff --git a/src/streamlink/plugins/media_ccc_de.py b/src/streamlink/plugins/media_ccc_de.py
index faa70715..41d10d85 100644
--- a/src/streamlink/plugins/media_ccc_de.py
+++ b/src/streamlink/plugins/media_ccc_de.py
@@ -30,16 +30,16 @@ API_URL_STREAMING_MEDIA = "https://streaming.media.ccc.de/streams/v1.json"
 
 # http(s)://media.ccc.de/path/to/talk.html
 _url_media_re = re.compile(r"(?P<scheme>http|https)"
-                           ":\/\/"
+                           "://"
                            "(?P<server>media\.ccc\.de)"
-                           "\/")
+                           "/")
 # https://streaming.media.ccc.de/room/
 _url_streaming_media_re = re.compile(r"(?P<scheme>http|https)"
-                                     ":\/\/"
+                                     "://"
                                      "(?P<server>streaming\.media\.ccc\.de)"
-                                     "\/"
+                                     "/"
                                      "(?P<room>.*)"
-                                     "\/")
+                                     "/")
 
 
 def get_event_id(url):
diff --git a/src/streamlink/plugins/playtv.py b/src/streamlink/plugins/playtv.py
new file mode 100644
index 00000000..8df9b75f
--- /dev/null
+++ b/src/streamlink/plugins/playtv.py
@@ -0,0 +1,71 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import HDSStream, HLSStream
+
+
+class PlayTV(Plugin):
+    FORMATS_URL = 'http://playtv.fr/player/initialize/{0}/'
+    API_URL = 'http://playtv.fr/player/play/{0}/?format={1}&language={2}&bitrate={3}'
+
+    _url_re = re.compile(r'http://playtv\.fr/television/(?P<channel>[^/]+)/?')
+
+    _formats_schema = validate.Schema({
+        'streams': validate.any(
+            [],
+            {
+                validate.text: validate.Schema({
+                    validate.text: {
+                        'bitrates': validate.all([
+                            validate.Schema({
+                                'value': int
+                            })
+                        ])
+                    }
+                })
+            }
+        )
+    })
+    _api_schema = validate.Schema({
+        'url': validate.url()
+    })
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return PlayTV._url_re.match(url)
+
+    def _get_streams(self):
+        match = self._url_re.match(self.url)
+        channel = match.group('channel')
+
+        res = http.get(self.FORMATS_URL.format(channel))
+        streams = http.json(res, schema=self._formats_schema)['streams']
+        if streams == []:
+            self.logger.error('Channel may be geo-restricted, not directly provided by PlayTV or not freely available')
+            return
+
+        for language in streams:
+            for protocol, bitrates in list(streams[language].items()):
+                # - Ignore non-supported protocols (RTSP, DASH)
+                # - Ignore deprecated Flash (RTMPE) streams (PlayTV doesn't provide anymore a Flash player)
+                if protocol in ['rtsp', 'flash', 'dash']:
+                    continue
+
+                for bitrate in bitrates['bitrates']:
+                    if bitrate['value'] == 0:
+                        continue
+                    api_url = self.API_URL.format(channel, protocol, language, bitrate['value'])
+                    res = http.get(api_url)
+                    video_url = http.json(res, schema=self._api_schema)['url']
+                    bs = '{0}k'.format(bitrate['value'])
+
+                    if protocol == 'hls':
+                        for _, stream in HLSStream.parse_variant_playlist(self.session, video_url).items():
+                            yield bs, stream
+                    elif protocol == 'hds':
+                        for _, stream in HDSStream.parse_manifest(self.session, video_url).items():
+                            yield bs, stream
+
+
+__plugin__ = PlayTV
diff --git a/src/streamlink/plugins/streamingvideoprovider.py b/src/streamlink/plugins/streamingvideoprovider.py
index 932fb4f5..c4a7a3ff 100644
--- a/src/streamlink/plugins/streamingvideoprovider.py
+++ b/src/streamlink/plugins/streamingvideoprovider.py
@@ -10,7 +10,7 @@ SWF_URL = "http://play.streamingvideoprovider.com/player2.swf"
 API_URL = "http://player.webvideocore.net/index.php"
 
 _url_re = re.compile(
-    "http(s)?://(\w+\.)?streamingvideoprovider.co.uk/(?P<channel>[^/&?]+)"
+    r"http(s)?://(\w+\.)?streamingvideoprovider.co.uk/(?P<channel>[^/&?]+)"
 )
 _hls_re = re.compile(r"'(http://.+\.m3u8)'")
 
diff --git a/src/streamlink/plugins/ustreamtv.py b/src/streamlink/plugins/ustreamtv.py
index d637e13f..e059b863 100644
--- a/src/streamlink/plugins/ustreamtv.py
+++ b/src/streamlink/plugins/ustreamtv.py
@@ -422,7 +422,7 @@ class UStreamTV(Plugin):
 
     @classmethod
     def stream_weight(cls, stream):
-        match = re.match("mobile_(\w+)", stream)
+        match = re.match(r"mobile_(\w+)", stream)
         if match:
             weight, group = Plugin.stream_weight(match.group(1))
             weight -= 1
diff --git a/src/streamlink/plugins/vgtv.py b/src/streamlink/plugins/vgtv.py
index ac50efe5..3a4ca33d 100644
--- a/src/streamlink/plugins/vgtv.py
+++ b/src/streamlink/plugins/vgtv.py
@@ -30,8 +30,8 @@ INFO_URL = "http://www.vgtv.no/data/actions/videostatus/"
 _url_re = re.compile(r"https?://(www\.)?(vgtv|vg).no")
 _content_id_re = re.compile(r"(?:data-videoid=\"|videostatus/\?id=)(\d+)")
 _url_id_re = re.compile((
-    "https?://(?:www\.)?vgtv.no/"
-    "(?:(?:#!/)?video/|(?:#!|\?)id=)(\d+)"
+    r"https?://(?:www\.)?vgtv.no/"
+    r"(?:(?:#!/)?video/|(?:#!|\?)id=)(\d+)"
 ))
 
 _video_schema = validate.Schema({
diff --git a/src/streamlink/plugins/youtube.py b/src/streamlink/plugins/youtube.py
index 4e154155..7907acab 100644
--- a/src/streamlink/plugins/youtube.py
+++ b/src/streamlink/plugins/youtube.py
@@ -140,8 +140,8 @@ class YouTube(Plugin):
 
     @classmethod
     def stream_weight(cls, stream):
-        match_3d = re.match("(\w+)_3d", stream)
-        match_hfr = re.match("(\d+p)(\d+)", stream)
+        match_3d = re.match(r"(\w+)_3d", stream)
+        match_hfr = re.match(r"(\d+p)(\d+)", stream)
         if match_3d:
             weight, group = Plugin.stream_weight(match_3d.group(1))
             weight -= 1
diff --git a/src/streamlink/plugins/zhanqi.py b/src/streamlink/plugins/zhanqi.py
index 06cf4600..1a9c83fe 100644
--- a/src/streamlink/plugins/zhanqi.py
+++ b/src/streamlink/plugins/zhanqi.py
@@ -17,7 +17,7 @@ STREAM_WEIGHTS = {
     "live": 1080
 }
 
-_url_re = re.compile("""
+_url_re = re.compile(r"""
     http(s)?://(www\.)?zhanqi.tv
     /(?P<channel>[^/]+)
 """, re.VERBOSE)


Test patch:
diff --git a/tests/test_plugin_playtv.py b/tests/test_plugin_playtv.py
new file mode 100644
index 00000000..06e089e1
--- /dev/null
+++ b/tests/test_plugin_playtv.py
@@ -0,0 +1,18 @@
+import unittest
+
+from streamlink.plugins.playtv import PlayTV
+
+
+class TestPluginPlayTV(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(PlayTV.can_handle_url("http://playtv.fr/television/arte"))
+        self.assertTrue(PlayTV.can_handle_url("http://playtv.fr/television/arte/"))
+        self.assertTrue(PlayTV.can_handle_url("http://playtv.fr/television/tv5-monde"))
+        self.assertTrue(PlayTV.can_handle_url("http://playtv.fr/television/france-24-english/"))
+
+        # shouldn't match
+        self.assertFalse(PlayTV.can_handle_url("http://playtv.fr/television/"))
+        self.assertFalse(PlayTV.can_handle_url("http://playtv.fr/replay-tv/"))
+        self.assertFalse(PlayTV.can_handle_url("http://tvcatchup.com/"))
+        self.assertFalse(PlayTV.can_handle_url("http://youtube.com/"))