Repository: streamlink/streamlink

Issue:
Support FacebookLive streaming
My own research shows that Facebook Live streams are MPEG-DASH format with the audio and video in separate streams that need to be remuxed.  Once this project supports DASH video, we should also be able to support Facebook Live streams.

Comments:

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 143f7f09..52c5f43f 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -95,6 +95,7 @@ earthcam            earthcam.com         Yes   Yes   Only works for the cams hos
 eurocom             eurocom.bg           Yes   No
 euronews            euronews.com         Yes   No
 expressen           expressen.se         Yes   Yes
+facebook            facebook.com         Yes   No    Only 360p HLS streams.
 filmon              filmon.com           Yes   Yes   Only SD quality streams.
 filmon_us           filmon.us            Yes   Yes
 foxtr               fox.com.tr           Yes   No
diff --git a/src/streamlink/plugins/facebook.py b/src/streamlink/plugins/facebook.py
new file mode 100644
index 00000000..ec1220d6
--- /dev/null
+++ b/src/streamlink/plugins/facebook.py
@@ -0,0 +1,24 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.stream import HLSStream
+
+_playlist_url = "https://www.facebook.com/video/playback/playlist.m3u8?v={0}"
+
+_url_re = re.compile(r"http(s)?://(www\.)?facebook\.com/[^/]+/videos/(?P<video_id>\d+)")
+
+
+class Facebook(Plugin):
+    @classmethod
+    def can_handle_url(cls, url):
+        return _url_re.match(url)
+
+    def _get_streams(self):
+        match = _url_re.match(self.url)
+        video = match.group("video_id")
+
+        playlist = _playlist_url.format(video)
+
+        return HLSStream.parse_variant_playlist(self.session, playlist)
+
+__plugin__ = Facebook
diff --git a/src/streamlink/plugins/pcyourfreetv.py b/src/streamlink/plugins/pcyourfreetv.py
index 15dde489..968ac7d3 100644
--- a/src/streamlink/plugins/pcyourfreetv.py
+++ b/src/streamlink/plugins/pcyourfreetv.py
@@ -1,16 +1,18 @@
 import re
 
+from streamlink.compat import parse_qsl, unquote
 from streamlink.plugin import Plugin, PluginOptions
 from streamlink.plugin.api import http
 from streamlink.stream import HLSStream
 
 
 class PCYourFreeTV(Plugin):
-    LIVE_TV_URL = 'http://pc-yourfreetv.com/index_livetv.php?page_id=1'
+    LIVE_TV_URL = 'http://pc-yourfreetv.com/indexlivetv.php?page_id=1'
 
     _login_url = 'http://pc-yourfreetv.com/home.php'
-    _url_re = re.compile(r'http://pc-yourfreetv\.com/index_player\.php\?channel=.+?&page_id=\d+')
-    _token_re = re.compile(r'\b(?P<token_key>auth_[0-9a-f]+)=(?P<token_value>[0-9a-f]+)\b')
+    _url_re = re.compile(r'http://pc-yourfreetv\.com/indexplayer\.php\?channel=.+?&page_id=\d+')
+    _token_re = re.compile(r'\bsrc="indexplayer\.php\?channel=.+?&(?P<tokens>.+?)"')
+    _player_re = re.compile(r"<script language=JavaScript>m='(?P<player>.+?)'", re.DOTALL)
     _video_url_re = re.compile(r"jwplayer\('.+?'\)\.setup\({.+?file: \"(?P<video_url>[^\"]+?)\".+?}\);", re.DOTALL)
 
     options = PluginOptions({
@@ -51,14 +53,21 @@ class PCYourFreeTV(Plugin):
         match = self._token_re.search(res.text)
         if match is None:
             return
-        token_key = match.group('token_key')
-        token_value = match.group('token_value')
 
         # Retrieve URL page and search for stream data
-        res = http.get(self.url, params={token_key: token_value})
-        match = self._video_url_re.search(res.text)
+        res = http.get(self.url, params=parse_qsl(match.group('tokens')))
+        match = self._player_re.search(res.text)
         if match is None:
             return
+
+        while match is not None:
+            player = unquote(match.group('player'))
+            match = self._player_re.search(player)
+
+        match = self._video_url_re.search(player)
+        if match is None:
+            return
+
         video_url = match.group('video_url')
         if '.m3u8' in video_url:
             streams = HLSStream.parse_variant_playlist(self.session, video_url)
diff --git a/src/streamlink/plugins/showroom.py b/src/streamlink/plugins/showroom.py
index 4618f07b..289e0074 100644
--- a/src/streamlink/plugins/showroom.py
+++ b/src/streamlink/plugins/showroom.py
@@ -2,7 +2,7 @@
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import http, validate
+from streamlink.plugin.api import http, validate, useragents
 from streamlink.stream import RTMPStream
 
 _url_re = re.compile(r'''^https?://
@@ -74,11 +74,6 @@ _info_pages = set((
 
 
 class Showroom(Plugin):
-    @staticmethod
-    def _get_stream_info(room_id):
-        res = http.get(_api_data_url.format(room_id=room_id))
-        return http.json(res, schema=_api_data_schema)
-
     @classmethod
     def can_handle_url(cls, url):
         match = _url_re.match(url)
@@ -95,6 +90,10 @@ class Showroom(Plugin):
 
     def __init__(self, url):
         Plugin.__init__(self, url)
+        self._headers = {
+            'Referer': self.url,
+            'User-Agent': useragents.FIREFOX
+        }
         self._room_id = None
         self._info = None
         self._title = None
@@ -112,6 +111,10 @@ class Showroom(Plugin):
             self._room_id = self._get_room_id()
         return self._room_id
 
+    def _get_stream_info(self, room_id):
+        res = http.get(_api_data_url.format(room_id=room_id), headers=self._headers)
+        return http.json(res, schema=_api_data_schema)
+
     def _get_room_id(self):
         """
         Locates unique identifier ("room_id") for the room.
@@ -123,7 +126,7 @@ class Showroom(Plugin):
         if match_dict['room_id'] is not None:
             return match_dict['room_id']
         else:
-            res = http.get(self.url)
+            res = http.get(self.url, headers=self._headers)
             match = _room_id_re.search(res.text)
             if not match:
                 title = self.url.rsplit('/', 1)[-1]
diff --git a/src/streamlink/plugins/tvplayer.py b/src/streamlink/plugins/tvplayer.py
index a7feeda7..13464272 100644
--- a/src/streamlink/plugins/tvplayer.py
+++ b/src/streamlink/plugins/tvplayer.py
@@ -8,25 +8,32 @@ from streamlink.stream import HLSStream
 
 
 class TVPlayer(Plugin):
+    context_url = "http://tvplayer.com/watch/context"
     api_url = "http://api.tvplayer.com/api/v2/stream/live"
     login_url = "https://tvplayer.com/account/login"
     update_url = "https://tvplayer.com/account/update-detail"
     dummy_postcode = "SE1 9LT"  # location of ITV HQ in London
 
     url_re = re.compile(r"https?://(?:www.)?tvplayer.com/(:?watch/?|watch/(.+)?)")
-    stream_attrs_re = re.compile(r'var\s+(validate|platform|resourceId|token)\s+=\s*(.*?);', re.S)
+    stream_attrs_re = re.compile(r'data-(resource|token)\s*=\s*"(.*?)"', re.S)
     login_token_re = re.compile(r'input.*?name="token".*?value="(\w+)"')
     stream_schema = validate.Schema({
         "tvplayer": validate.Schema({
             "status": u'200 OK',
             "response": validate.Schema({
-                    "stream": validate.url(scheme=validate.any("http")),
-                    validate.optional("drmToken"): validate.any(None, validate.text)
-                })
+                "stream": validate.url(scheme=validate.any("http")),
+                validate.optional("drmToken"): validate.any(None, validate.text)
             })
-        },
+        })
+    },
         validate.get("tvplayer"),
         validate.get("response"))
+    context_schema = validate.Schema({
+        "validate": validate.text,
+        "platform": {
+            "key": validate.text
+        }
+    })
     options = PluginOptions({
         "email": None,
         "password": None
@@ -50,6 +57,21 @@ class TVPlayer(Plugin):
         # there is a 302 redirect on a successful login
         return res2.status_code == 302
 
+    def _get_stream_data(self, resource, token, service=1):
+        # Get the context info (validation token and platform)
+        context_res = http.get(self.context_url, params={"resource": resource,
+                                                         "nonce": token})
+        context_data = http.json(context_res, schema=self.context_schema)
+
+        # get the stream urls
+        res = http.post(self.api_url, data=dict(
+            service=service,
+            id=resource,
+            validate=context_data["validate"],
+            platform=context_data["platform"]["key"]))
+
+        return http.json(res, schema=self.stream_schema)
+
     def _get_streams(self):
         if self.get_option("email") and self.get_option("password"):
             self.authenticate(self.get_option("email"), self.get_option("password"))
@@ -67,25 +89,19 @@ class TVPlayer(Plugin):
 
         stream_attrs = dict((k, v.strip('"')) for k, v in self.stream_attrs_re.findall(res.text))
 
-        if "resourceId" in stream_attrs and "validate" in stream_attrs and "platform" in stream_attrs:
-            # get the stream urls
-            res = http.post(self.api_url, data=dict(
-                service=1,
-                id=stream_attrs["resourceId"],
-                validate=stream_attrs["validate"],
-                platform=stream_attrs["platform"],
-                token=stream_attrs.get("token")))
-
-            stream_data = http.json(res, schema=self.stream_schema)
-
-            if stream_data.get("drmToken"):
-                self.logger.error("This stream is protected by DRM can cannot be played")
-                return
-            else:
-                return HLSStream.parse_variant_playlist(self.session, stream_data["stream"])
+        if "resource" in stream_attrs and "token" in stream_attrs:
+            stream_data = self._get_stream_data(**stream_attrs)
+
+            if stream_data:
+                if stream_data.get("drmToken"):
+                    self.logger.error("This stream is protected by DRM can cannot be played")
+                    return
+                else:
+                    return HLSStream.parse_variant_playlist(self.session, stream_data["stream"])
         else:
             if "need to login" in res.text:
-                self.logger.error("You need to login using --tvplayer-email/--tvplayer-password to view this stream")
+                self.logger.error(
+                    "You need to login using --tvplayer-email/--tvplayer-password to view this stream")
 
 
 __plugin__ = TVPlayer


Test patch:
diff --git a/tests/test_plugin_facebook.py b/tests/test_plugin_facebook.py
new file mode 100644
index 00000000..b6b171a1
--- /dev/null
+++ b/tests/test_plugin_facebook.py
@@ -0,0 +1,14 @@
+import unittest
+
+from streamlink.plugins.facebook import Facebook
+
+
+class TestPluginFacebook(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(Facebook.can_handle_url("https://www.facebook.com/nos/videos/1725546430794241/"))
+        self.assertTrue(Facebook.can_handle_url("https://www.facebook.com/nytfood/videos/1485091228202006/"))
+        self.assertTrue(Facebook.can_handle_url("https://www.facebook.com/SporTurkTR/videos/798553173631138/"))
+
+        # shouldn't match
+        self.assertFalse(Facebook.can_handle_url("https://www.facebook.com"))
diff --git a/tests/test_plugin_pcyourfreetv.py b/tests/test_plugin_pcyourfreetv.py
index d1ee96dc..f5664754 100644
--- a/tests/test_plugin_pcyourfreetv.py
+++ b/tests/test_plugin_pcyourfreetv.py
@@ -6,13 +6,13 @@ from streamlink.plugins.pcyourfreetv import PCYourFreeTV
 class TestPluginPCYourFreeTV(unittest.TestCase):
     def test_can_handle_url(self):
         # should match
-        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_player.php?channel=das%20erste&page_id=41"))
-        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_player.php?channel=srf%20eins&page_id=41"))
-        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_player.php?channel=bbc%20one&page_id=41"))
-        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_player.php?channel=tf1&page_id=41"))
+        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/indexplayer.php?channel=das%20erste&page_id=41"))
+        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/indexplayer.php?channel=srf%20eins&page_id=41"))
+        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/indexplayer.php?channel=bbc%20one&page_id=41"))
+        self.assertTrue(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/indexplayer.php?channel=tf1&page_id=41"))
 
         # shouldn't match
         self.assertFalse(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/home.php"))
-        self.assertFalse(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/index_livetv.php?page_id=1"))
+        self.assertFalse(PCYourFreeTV.can_handle_url("http://pc-yourfreetv.com/indexlivetv.php?page_id=1"))
         self.assertFalse(PCYourFreeTV.can_handle_url("http://tvcatchup.com/"))
         self.assertFalse(PCYourFreeTV.can_handle_url("http://youtube.com/"))
diff --git a/tests/test_plugin_tvplayer.py b/tests/test_plugin_tvplayer.py
index 13c7c363..52f27dc0 100644
--- a/tests/test_plugin_tvplayer.py
+++ b/tests/test_plugin_tvplayer.py
@@ -1,4 +1,3 @@
-import json
 import unittest
 
 from streamlink import Streamlink
@@ -31,28 +30,28 @@ class TestPluginTVPlayer(unittest.TestCase):
         self.assertFalse(TVPlayer.can_handle_url("http://www.tvcatchup.com/"))
         self.assertFalse(TVPlayer.can_handle_url("http://www.youtube.com/"))
 
+    @patch('streamlink.plugins.tvplayer.TVPlayer._get_stream_data')
     @patch('streamlink.plugins.tvplayer.http')
     @patch('streamlink.plugins.tvplayer.HLSStream')
-    def test_get_streams(self, hlsstream, mock_http):
-        api_data = {
-            "tvplayer": {
-                "status": "200 OK",
-                "response": {
+    def test_get_streams(self, hlsstream, mock_http, mock_get_stream_data):
+        mock_get_stream_data.return_value = {
                     "stream": "http://test.se/stream1"
                 }
-            }
-        }
+
         page_resp = Mock()
         page_resp.text = u"""
-        var validate = "foo";
-        var resourceId = "1234";
-        var platform = "test";
+                    <div class="video-js theoplayer-skin theo-seekbar-above-controls content-box vjs-fluid"
+                 data-resource= "89"
+                 data-token = "1324567894561268987948596154656418448489159"
+                                    data-content-type="live"
+                    data-environment="live"
+                    data-subscription="free"
+                    data-channel-id="89">
+                <div id="channel-info" class="channel-info">
+                    <div class="row visible-xs visible-sm">
         """
-        api_resp = Mock()
-        api_resp.text = json.dumps(api_data)
+
         mock_http.get.return_value = page_resp
-        mock_http.post.return_value = api_resp
-        mock_http.json.return_value = api_data["tvplayer"]["response"]
         hlsstream.parse_variant_playlist.return_value = {"test": HLSStream(self.session, "http://test.se/stream1")}
 
         plugin = TVPlayer("http://tvplayer.com/watch/dave")
@@ -64,11 +63,7 @@ class TestPluginTVPlayer(unittest.TestCase):
         # test the url is used correctly
         mock_http.get.assert_called_with("http://tvplayer.com/watch/dave")
         # test that the correct API call is made
-        mock_http.post.assert_called_with("http://api.tvplayer.com/api/v2/stream/live", data=dict(service=1,
-                                                                                                  id=u"1234",
-                                                                                                  validate=u"foo",
-                                                                                                  platform=u"test",
-                                                                                                  token=None))
+        mock_get_stream_data.assert_called_with(resource="89", token="1324567894561268987948596154656418448489159")
         # test that the correct URL is used for the HLSStream
         hlsstream.parse_variant_playlist.assert_called_with(ANY, "http://test.se/stream1")