Repository: streamlink/streamlink
Pull request #416 (2017-01-13 14:02:18)

Add support for Canal+ TV group channels
This plugin adds support for French Canal+ TV group channels:
* canalplus.fr
* c8.fr
* cstar.fr

Both live and VOD are supported.

melmorabity: Good catch, I've just fixed alignment.

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index c771574b..0a900a08 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -37,6 +37,9 @@ bnt                 - tv.bnt.bg          Yes   No    Streams may be geo-restrict
                     - nova.bg
 bongacams           bongacams.com        Yes   No    Only RTMP streams are available.
 btv                 btv.bg               Yes   No    Requires login, and geo-restricted to Bulgaria.
+canaplus            - canalplus.fr       Yes   Yes   Streams may be geo-restricted to France.
+                    - c8.fr
+                    - cstar.fr
 chaturbate          chaturbate.com       Yes   No
 cinergroup          - showtv.com.tr      Yes   No
                     - haberturk.com
diff --git a/src/streamlink/plugins/canalplus.py b/src/streamlink/plugins/canalplus.py
new file mode 100644
index 00000000..ee06516d
--- /dev/null
+++ b/src/streamlink/plugins/canalplus.py
@@ -0,0 +1,103 @@
+import re
+
+from streamlink.exceptions import PluginError
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, useragents, validate
+from streamlink.stream import HDSStream, HLSStream, HTTPStream
+
+
+class CanalPlus(Plugin):
+    API_URL = 'http://service.canal-plus.com/video/rest/getVideos/{0}/{1}?format=json'
+    CHANNEL_MAP = {'canalplus': 'cplus', 'c8': 'd8', 'cstar': 'd17'}
+    HDCORE_VERSION = '3.1.0'
+    # Secret parameter needed to download HTTP videos on canalplus.fr
+    SECRET = 'pqzerjlsmdkjfoiuerhsdlfknaes'
+
+    _url_re = re.compile(r'''
+        http://
+        (
+            www\.(?P<channel>canalplus|c8|cstar)\.fr/.*pid.+?\.html(\?(?P<video_id>[0-9]+))? |
+            replay\.(?P<replay_channel>c8|cstar)\.fr/video/(?P<replay_video_id>[0-9]+)
+        )
+''', re.VERBOSE)
+    _video_id_re = re.compile(r'\bdata-video="(?P<video_id>[0-9]+)"')
+    _mp4_bitrate_re = re.compile(r'.*_(?P<bitrate>[0-9]+k)\.mp4')
+    _api_schema = validate.Schema({
+        'MEDIA': validate.Schema({
+            'VIDEOS': validate.Schema({
+                validate.text: validate.any(
+                    validate.url(),
+                    ''
+                )
+            })
+        })
+    })
+    _user_agent = useragents.CHROME
+
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return CanalPlus._url_re.match(url)
+
+
+    def _get_streams(self):
+        # Get video ID and channel from URL
+        match = self._url_re.match(self.url)
+        channel = match.group('channel')
+        if channel is None:
+            # Replay website
+            channel = match.group('replay_channel')
+            video_id = match.group('replay_video_id')
+        else:
+            video_id = match.group('video_id')
+            if video_id is None:
+                # Retrieve URL page and search for video ID
+                res = http.get(self.url)
+                match = self._video_id_re.search(res.text)
+                if match is None:
+                    return
+                video_id = match.group('video_id')
+
+        res = http.get(self.API_URL.format(self.CHANNEL_MAP[channel], video_id))
+        videos = http.json(res, schema=self._api_schema)
+        parsed = []
+        headers = {'User-Agent': self._user_agent}
+        for quality, video_url in list(videos['MEDIA']['VIDEOS'].items()):
+            # Ignore empty URLs
+            if video_url == '':
+                continue
+
+            # Ignore duplicate video URLs
+            if video_url in parsed:
+                continue
+            parsed.append(video_url)
+
+            try:
+                if '.f4m' in video_url:
+                    for stream in HDSStream.parse_manifest(self.session,
+                                                           video_url,
+                                                           params={'hdcore': self.HDCORE_VERSION},
+                                                           headers=headers).items():
+                        yield stream
+                elif '.m3u8' in video_url:
+                    for stream in HLSStream.parse_variant_playlist(self.session,
+                                                                   video_url,
+                                                                   headers=headers).items():
+                        yield stream
+                elif '.mp4' in video_url:
+                    # Get bitrate from video filename
+                    match = self._mp4_bitrate_re.match(video_url)
+                    if match is not None:
+                        bitrate = match.group('bitrate')
+                    else:
+                        bitrate = quality
+                    yield bitrate, HTTPStream(self.session,
+                                              video_url,
+                                              params={'secret': self.SECRET},
+                                              headers=headers)
+            except PluginError:
+                self.logger.error('Failed to access stream, may be due to geo-restriction')
+                raise
+
+
+__plugin__ = CanalPlus


Test patch:
diff --git a/tests/test_plugin_canalplus.py b/tests/test_plugin_canalplus.py
new file mode 100644
index 00000000..7b841e26
--- /dev/null
+++ b/tests/test_plugin_canalplus.py
@@ -0,0 +1,28 @@
+import unittest
+
+from streamlink.plugins.canalplus import CanalPlus
+
+
+class TestPluginCanalPlus(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(CanalPlus.can_handle_url("http://www.canalplus.fr/pid3580-live-tv-clair.html"))
+        self.assertTrue(CanalPlus.can_handle_url("http://www.canalplus.fr/emissions/pid8596-the-tonight-show.html"))
+        self.assertTrue(CanalPlus.can_handle_url("http://www.canalplus.fr/c-divertissement/pid1787-c-groland.html?vid=1430239"))
+        self.assertTrue(CanalPlus.can_handle_url("http://www.c8.fr/pid5323-c8-live.html"))
+        self.assertTrue(CanalPlus.can_handle_url("http://www.c8.fr/c8-divertissement/pid8758-c8-la-folle-histoire-de-sophie-marceau.html"))
+        self.assertTrue(CanalPlus.can_handle_url("http://www.c8.fr/c8-sport/pid5224-c8-direct-auto.html?vid=1430292"))
+        self.assertTrue(CanalPlus.can_handle_url("http://replay.c8.fr/video/1431076"))
+        self.assertTrue(CanalPlus.can_handle_url("http://www.cstar.fr/pid5322-cstar-live.html"))
+        self.assertTrue(CanalPlus.can_handle_url("http://www.cstar.fr/emissions/pid8754-wild-transport.html"))
+        self.assertTrue(CanalPlus.can_handle_url("http://www.cstar.fr/musique/pid6282-les-tops.html?vid=1430143"))
+        self.assertTrue(CanalPlus.can_handle_url("http://replay.cstar.fr/video/1430245"))
+
+        # shouldn't match
+        self.assertFalse(CanalPlus.can_handle_url("http://www.canalplus.fr/"))
+        self.assertFalse(CanalPlus.can_handle_url("http://www.c8.fr/"))
+        self.assertFalse(CanalPlus.can_handle_url("http://replay.c8.fr/"))
+        self.assertFalse(CanalPlus.can_handle_url("http://www.cstar.fr/"))
+        self.assertFalse(CanalPlus.can_handle_url("http://replay.cstar.fr/"))
+        self.assertFalse(CanalPlus.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(CanalPlus.can_handle_url("http://www.youtube.com/"))