Repository: streamlink/streamlink
Pull request #381 (2017-01-08 17:43:48)

plugins.tf1: added plugin to support tf1.fr and lci.fr
As requested in #376. 

Example URLs:
- tf1.fr/ft1/direct
- tf1.fr/nt1/direct
- tf1.fr/tmc/direct
- tf1.fr/lci/direct
- lci.fr/direct

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

```diff
@@             master       #381   diff @@
==========================================
  Files            43         44     +1   
  Lines          4156       4171    +15   
  Methods           0          0          
  Messages          0          0          
  Branches          0          0          
==========================================
+ Hits           2040       2055    +15   
  Misses         2116       2116          
  Partials          0          0          
```

> Powered by [Codecov](https://codecov.io?src=pr). Last update [325c5b7...a38ee9f](https://codecov.io/gh/streamlink/streamlink/compare/325c5b7a8a7b4dfc4b0d87b229d45ba80861135f...a38ee9f276e19153d856af5c14c2508ef9dd2fb4?src=pr)

Patch:
diff --git a/docs/install.rst b/docs/install.rst
index 76beec4b..1cae0039 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -61,13 +61,8 @@ Mac OS X                             .. code-block:: console
 
                                         # easy_install -U streamlink
 Microsoft Windows                    See `Windows binaries`_ and `Windows portable version`_.
-
-`Chocolatey`_                        .. code-block:: powershell
-
-                                        C:\> choco install streamlink
 ==================================== ===========================================
 
-.. _Chocolatey: https://chocolatey.org/packages/streamlink
 
 Package maintainers
 -------------------
@@ -75,7 +70,6 @@ Package maintainers
 Distribution/Platform                Maintainer
 ==================================== ===========================================
 Arch                                 Josip Ponjavic <josipponjavic at gmail.com>
-Chocolatey                           Scott Walters <me at scowalt.com>
 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 2fce94c8..d8ae7c41 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -25,12 +25,11 @@ atresplayer         atresplayer.com      Yes   No    Streams are geo-restricted
 azubutv             azubu.tv             Yes   No
 bambuser            bambuser.com         Yes   Yes
 beam                beam.pro             Yes   No
-beattv              be-at.tv             Yes   Yes   Playlist not implemented yet.
 bigo                - live.bigo.tv       Yes   --
                     - bigoweb.co
+beattv              be-at.tv             Yes   Yes   Playlist not implemented yet.
 bilibili            live.bilibili.com    Yes   ?
 bliptv              blip.tv              --    Yes
-bongacams           bongacams.com        Yes   No    Only RTMP streams are available.
 chaturbate          chaturbate.com       Yes   No
 cinergroup          - showtv.com.tr      Yes   No
                     - haberturk.com
@@ -118,6 +117,8 @@ streamupcom         streamup.com         Yes   --
 svtplay             - svtplay.se         Yes   Yes   Streams may be geo-restricted to Sweden.
                     - svtflow.se
                     - oppetarkiv.se
+tf1                 - tf1.fr             Yes   No    Streams may be geo-restricted to France.
+                    - lci.fr
 tga                 - star.plu.cn        Yes   No
                     - star.tga.plu.cn
 tigerdile           tigerdile.com        Yes   --
diff --git a/src/streamlink/plugin/api/useragents.py b/src/streamlink/plugin/api/useragents.py
new file mode 100644
index 00000000..30ee477c
--- /dev/null
+++ b/src/streamlink/plugin/api/useragents.py
@@ -0,0 +1,22 @@
+ANDROID = ("Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) "
+           "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.114 Mobile Safari/537.36")
+CHROME_OS = ("Mozilla/5.0 (X11; CrOS armv7l 4537.56.0) "
+             "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.38 Safari/537.36")
+IE_11 = "Mozilla/5.0 (MSIE 10.0; Windows NT 6.1; Trident/5.0)"
+IE_6 = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; WOW64; Trident/4.0; SLCC1)"
+IE_7 = "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; WOW64; Trident/4.0; SLCC1)"
+IE_8 = "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; WOW64; Trident/4.0; SLCC1)"
+IE_9 = "Mozilla/5.0 (MSIE 9.0; Windows NT 6.1; Trident/5.0)"
+IPHONE_6 = ("Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) "
+            "AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25")
+IPAD = ("Mozilla/5.0 (iPad; CPU OS 6_0 like Mac OS X) "
+        "AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25")
+WINDOWS_PHONE_8 = ("Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; "
+                   "Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 920)")
+SAFARI_8 = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_4) "
+            "AppleWebKit/600.7.12 (KHTML, like Gecko) Version/8.0.7 Safari/600.7.12")
+SAFARI_7 = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) "
+            "AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/7046A194A")
+FIREFOX = "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:33.0) Gecko/20120101 Firefox/33.0"
+FIREFOX_MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10; rv:33.0) Gecko/20100101 Firefox/33.0"
+OPERA = "Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14"
diff --git a/src/streamlink/plugins/bongacams.py b/src/streamlink/plugins/bongacams.py
deleted file mode 100644
index 1df258bd..00000000
--- a/src/streamlink/plugins/bongacams.py
+++ /dev/null
@@ -1,153 +0,0 @@
-import re
-import time
-
-from io import BytesIO
-from hashlib import md5
-
-from streamlink.compat import bytes, urljoin, urlparse, urlunparse
-from streamlink.exceptions import PluginError, NoStreamsError
-from streamlink.packages.flashmedia.types import AMF0String, AMF0Value, U32BE
-from streamlink.packages.flashmedia import AMFPacket, AMFMessage
-from streamlink.plugin.api import validate, http
-from streamlink.plugin import Plugin
-from streamlink.stream import RTMPStream
-
-
-CONST_FLASH_VER = "WIN 24,0,0,186"
-CONST_DEFAULT_SWF_LOCATION = '/swf/chat/BCamChat.swf?cache=20161226150'
-CONST_AMF_GATEWAY_LOCATION = '/tools/amf.php'
-CONST_AMF_GATEWAY_PARAM = 'x-country'
-CONST_DEFAULT_COUNTRY_CODE = 'en'
-
-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+")
-
-amf_msg_schema = validate.Schema({
-    "status" : "success",
-    "userData" : {
-        "username": validate.text
-    },
-    "localData" : {
-        "NC_ConnUrl" : validate.url(scheme="rtmp"),
-        "NC_AccessKey" : validate.length(32),
-        "dataKey" : validate.length(32),
-    },
-    "performerData" : {
-        "username" : validate.text,
-    }
-})
-
-
-class AMFMessage2(AMFMessage):
-    def _serialize(self, packet):
-        packet += AMF0String(self.target_uri)
-        packet += AMF0String(self.response_uri)
-        packet += U32BE(AMF0Value.size(self.value))
-        packet += AMF0Value.pack(self.value)
-
-
-class bongacams(Plugin):
-    @classmethod
-    def can_handle_url(self, url):
-        return url_re.match(url)
-
-    def _get_stream_uid(self, username):
-        m = md5(username.encode('utf-8') + str(time.time()).encode('utf-8'))
-        return m.hexdigest()
-
-    def _get_streams(self):
-        match = url_re.match(self.url)
-
-        stream_page_scheme = 'https'
-        stream_page_domain = match.group(4)
-        stream_page_path = match.group(5)
-        country_code = CONST_DEFAULT_COUNTRY_CODE
-        is_paid_show = False
-
-        # create http session and set headers
-        http_session = http
-        http_session.headers.update(CONST_HEADERS)
-
-        # get swf url and cookies
-        r = http_session.get(urlunparse((stream_page_scheme, stream_page_domain, stream_page_path, '', '', '')))
-
-        # redirect to profile page means stream is offlie
-        if '/profile/' in r.url:
-            raise NoStreamsError(self.url)
-        if not r.ok:
-            self.logger.debug("Status code for {}: {}", r.url, r.status_code)
-            raise NoStreamsError(self.url)
-        if len(http_session.cookies) == 0:
-            raise PluginError("Can't get a cookies")
-
-        if urlparse(r.url).netloc != stream_page_domain:
-            # then redirected to regional subdomain
-            country_code = urlparse(r.url).netloc.split('.')[0].lower()
-
-        # time to set variables
-        baseurl = urlunparse((stream_page_scheme, urlparse(r.url).netloc, '', '', '', ''))
-        amf_gateway_url = urljoin(baseurl, CONST_AMF_GATEWAY_LOCATION)
-        stream_page_url = urljoin(baseurl, stream_page_path)
-
-        match = swf_re.search(r.text)
-        if match:
-            swf_url = urljoin(baseurl, match.group())
-            self.logger.debug("swf url found: {}", swf_url)
-        else:
-            # most likely it means that country/region banned
-            # can try use default swf-url
-            swf_url = urljoin(baseurl, CONST_DEFAULT_SWF_LOCATION)
-            self.logger.debug("swf url not found. Will try {}", swf_url)
-
-        # create amf query
-        amf_message = AMFMessage2("svDirectAmf.getRoomData", "/1", [stream_page_path, is_paid_show])
-        amf_packet = AMFPacket(version=0)
-        amf_packet.messages.append(amf_message)
-
-        # send request and close http-session
-        r = http_session.post(url=amf_gateway_url,
-                              params={CONST_AMF_GATEWAY_PARAM : country_code},
-                              data=bytes(amf_packet.serialize()))
-        http_session.close()
-
-        if r.status_code != 200:
-            raise PluginError("unexpected status code for {}: {}", r.url, r.status_code)
-
-        amf_response = AMFPacket.deserialize(BytesIO(r.content))
-
-        if len(amf_response.messages) != 1 or amf_response.messages[0].target_uri != "/1/onResult":
-            raise PluginError("unexpected response from amf gate")
-
-        stream_source_info = amf_msg_schema.validate(amf_response.messages[0].value)
-        self.logger.debug("source stream info:\n{}", stream_source_info)
-
-        stream_params = {
-            "live": True,
-            "realtime": True,
-            "verbose": True,
-            "flashVer": CONST_FLASH_VER,
-            "swfUrl": swf_url,
-            "tcUrl": stream_source_info['localData']['NC_ConnUrl'],
-            "rtmp": stream_source_info['localData']['NC_ConnUrl'],
-            "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']
-            )
-        }
-
-        self.logger.debug("Stream params:\n{}", stream_params)
-        stream = RTMPStream(self.session, stream_params)
-
-        return {'live': stream}
-
-__plugin__ = bongacams
diff --git a/src/streamlink/plugins/tf1.py b/src/streamlink/plugins/tf1.py
new file mode 100644
index 00000000..337677e6
--- /dev/null
+++ b/src/streamlink/plugins/tf1.py
@@ -0,0 +1,60 @@
+from __future__ import print_function
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, useragents
+from streamlink.stream import HDSStream
+from streamlink.stream import HLSStream
+
+
+class TF1(Plugin):
+    url_re = re.compile(r"https?://(?:www\.)?(?:tf1\.fr/(\w+)/direct|(lci).fr/direct)/?")
+    embed_url = "http://www.wat.tv/embedframe/live{0}"
+    embed_re = re.compile(r"urlLive.*?:.*?\"(http.*?)\"", re.MULTILINE)
+    api_url = "http://www.wat.tv/get/androidlive{0}/591997"
+    swf_url = "http://www.wat.tv/images/v70/PlayerWat.swf?rev=04.00.861"
+    hds_channel_remap = {"tf1": "connect"}
+    hls_channel_remap = {"lci": "LCI", "tf1": "V4"}
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_hds_streams(self, channel):
+        channel = self.hds_channel_remap.get(channel, channel)
+        manifest_url = http.get(self.api_url.format(channel),
+                                params={"getURL": 1}).text
+
+        for s in HDSStream.parse_manifest(self.session,
+                                          manifest_url,
+                                          pvswf=self.swf_url).items():
+            yield s
+
+    def _get_hls_streams(self, channel):
+        channel = self.hls_channel_remap.get(channel, channel)
+        embed_url = self.embed_url.format(channel)
+        self.logger.debug("Found embed URL: {0}", embed_url)
+        # page needs to have a mobile user agent
+        embed_page = http.get(embed_url, headers={"User-Agent": useragents.ANDROID})
+
+        m = self.embed_re.search(embed_page.text)
+        if m:
+            hls_stream_url = m.group(1)
+
+            for s in HLSStream.parse_variant_playlist(self.session, hls_stream_url).items():
+                yield s
+
+    def _get_streams(self):
+        m = self.url_re.match(self.url)
+        if m:
+            channel = m.group(1) or m.group(2)
+            self.logger.debug("Found channel {0}", channel)
+            for s in self._get_hds_streams(channel):
+                yield s
+
+            for s in self._get_hls_streams(channel):
+                yield s
+
+
+
+__plugin__ = TF1


Test patch:
diff --git a/tests/test_plugin_tf1.py b/tests/test_plugin_tf1.py
new file mode 100644
index 00000000..e8783242
--- /dev/null
+++ b/tests/test_plugin_tf1.py
@@ -0,0 +1,18 @@
+import unittest
+
+from streamlink.plugins.tf1 import TF1
+
+
+class TestPluginTF1(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(TF1.can_handle_url("http://tf1.fr/tf1/direct/"))
+        self.assertTrue(TF1.can_handle_url("http://lci.fr/direct"))
+        self.assertTrue(TF1.can_handle_url("http://www.lci.fr/direct"))
+        self.assertTrue(TF1.can_handle_url("http://tf1.fr/tmc/direct"))
+
+        # shouldn't match
+        self.assertFalse(TF1.can_handle_url("http://tf1.fr/direct"))
+        self.assertFalse(TF1.can_handle_url("http://www.tf1.fr/direct"))
+        self.assertFalse(TF1.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(TF1.can_handle_url("http://www.youtube.com/"))