Repository: streamlink/streamlink
Pull request #514 (2017-02-01 01:51:57)

plugins.douyutv: fix room id regex
from https://github.com/soimort/you-get/issues/1657


Patch:
diff --git a/LICENSE b/LICENSE
index 5924cf28..e298ae80 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,5 @@
 Copyright (c) 2011-2016, Christopher Rosell
+Copyright (c) 2016-2017, Streamlink Team
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without
diff --git a/debian/copyright b/debian/copyright
index b6d03b8f..e6a628bf 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -5,7 +5,7 @@ Upstream-Name: streamlink
 
 Files: *
 Copyright: 2011-2016 Christopher Rosell <chrippa@tanuki.se>
-           2016 Streamlink Team
+           2016-2017 Streamlink Team
 License: BSD-2-clause
 
 Files: src/streamlink/packages/pbs.py
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index c5c08654..5179f5b4 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -40,6 +40,7 @@ cdnbg               - tv.bnt.bg          Yes   No    Streams may be geo-restrict
                     - kanal3.bg
                     - bitelevision.com
                     - nova.bg
+ceskatelevize       ceskatelevize.cz     Yes   Yes   Streams may be geo-restricted to Czechia.
 chaturbate          chaturbate.com       Yes   No
 cinergroup          - showtv.com.tr      Yes   No
                     - haberturk.com
@@ -81,6 +82,7 @@ furstream           furstre.am           Yes   No
 gaminglive          gaminglive.tv        Yes   Yes
 gomexp              gomexp.com           Yes   No
 goodgame            goodgame.ru          Yes   No    Only HLS streams are available.
+gulli               replay.gulli.fr      Yes   Yes   Streams may be geo-restricted to France.
 hitbox              hitbox.tv            Yes   Yes
 huajiao             huajiao.com          Yes   No    
 ine                 ine.com              ---   Yes
diff --git a/script/release.sh b/script/release.sh
index 76ddc592..3bf8fa59 100755
--- a/script/release.sh
+++ b/script/release.sh
@@ -43,13 +43,6 @@ requirements() {
   fi
 }
 
-windows_binary() {
-  cd $CLI
-  ./script/makeinstaller.sh
-  cd ..
-  mv $CLI/build/nsis/$CLI-$1.exe .
-}
-
 # Clone and then change to user's upstream repo for pushing to master / opening PR's :)
 clone() {
   git clone ssh://git@github.com/$UPSTREAM_REPO/$CLI.git
@@ -102,8 +95,7 @@ changelog_rst() {
   DATE=$(date +"%Y-%m-%d")
   CHANGELOG=$(cat CHANGELOG.rst)
   HEADER="$CLI $1 ($DATE)"
-  HEADER="${HEADER}\n$(for i in $(seq 1 ${#HEADER}); do echo '-'; done)"
-  echo -e "$HEADER\n\n$CHANGES\n\n$CHANGELOG" >CHANGELOG.rst
+  echo -e "$HEADER\n----------\n$CHANGES\n\n$CHANGELOG" >CHANGELOG.rst
   echo "Changes have been written to CHANGELOG.rst"
   cd ..
 }
@@ -173,19 +165,6 @@ push() {
         exit
   fi
 
-  github-release upload \
-      --user $UPSTREAM_REPO \
-      --repo $CLI \
-      --tag $1 \
-      --name "$CLI-$1.exe" \
-      --file $CLI-$1.exe
-  if [ $? -eq 0 ]; then
-        echo WINDOWS BINARY UPLOAD OK
-  else 
-        echo WINDOWS BINARY UPLOAD FAIL
-        exit
-  fi
-
   github-release upload \
       --user $UPSTREAM_REPO \
       --repo $CLI \
@@ -217,7 +196,6 @@ push() {
   echo "!!!"
   echo "https://github.com/$UPSTREAM_REPO/$CLI/releases/edit/$1"
   echo "!!!"
-  echo "REMEMBER TO UPDATE DOCKER BUILDS! :D"
 }
 
 upload_pypi_test() {
@@ -272,8 +250,7 @@ main() {
   "Generate changelog for release"
   "Create PR"
   "Tarball and sign - requires gpg key"
-  "Create Windows binary"
-  "Upload the tarball and Windows binary and push to Github release page"
+  "Upload the tarball and source code to GitHub release page"
   "Test upload to pypi"
   "Upload to pypi"
   "Clean"
@@ -300,10 +277,7 @@ main() {
           "Tarball and sign - requires gpg key")
               sign $VERSION
               ;;
-          "Create Windows binary")
-              windows_binary $VERSION
-              ;;
-          "Upload the tarball and Windows binary and push to Github release page")
+          "Upload the tarball and source code to GitHub release page")
               push $VERSION
               ;;
           "Test upload to pypi")
diff --git a/src/streamlink/plugins/bongacams.py b/src/streamlink/plugins/bongacams.py
index ae64e100..4e895970 100644
--- a/src/streamlink/plugins/bongacams.py
+++ b/src/streamlink/plugins/bongacams.py
@@ -42,14 +42,6 @@ amf_msg_schema = validate.Schema({
 })
 
 
-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):
@@ -75,7 +67,7 @@ class bongacams(Plugin):
         # 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
+        # redirect to profile page means stream is offline
         if '/profile/' in r.url:
             raise NoStreamsError(self.url)
         if not r.ok:
@@ -104,7 +96,7 @@ class bongacams(Plugin):
             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_message = AMFMessage("svDirectAmf.getRoomData", "/1", [stream_page_path, is_paid_show])
         amf_packet = AMFPacket(version=0)
         amf_packet.messages.append(amf_message)
 
@@ -128,7 +120,6 @@ class bongacams(Plugin):
         stream_params = {
             "live": True,
             "realtime": True,
-            "verbose": True,
             "flashVer": CONST_FLASH_VER,
             "swfUrl": swf_url,
             "tcUrl": stream_source_info['localData']['NC_ConnUrl'],
diff --git a/src/streamlink/plugins/ceskatelevize.py b/src/streamlink/plugins/ceskatelevize.py
new file mode 100644
index 00000000..44ac2239
--- /dev/null
+++ b/src/streamlink/plugins/ceskatelevize.py
@@ -0,0 +1,139 @@
+"""
+Plugin for Czech TV (Ceska televize).
+
+Following channels are working:
+    * CT1 - http://www.ceskatelevize.cz/ct1/zive/
+    * CT2 - http://www.ceskatelevize.cz/ct2/zive/
+    * CT24 - http://www.ceskatelevize.cz/ct24/
+    * CT sport - http://www.ceskatelevize.cz/sport/zive-vysilani/
+    * CT Decko - http://decko.ceskatelevize.cz/zive/
+    * CT Art - http://www.ceskatelevize.cz/art/zive/
+
+Additionaly, videos from iVysilani archive should work as well.
+"""
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import HLSStream
+from streamlink.exceptions import PluginError
+
+_url_re = re.compile(
+    r'http(s)?://([^.]*.)?ceskatelevize.cz'
+)
+_player_re = re.compile(
+    r'ivysilani/embed/iFramePlayer[^"]+'
+)
+_hash_re = re.compile(
+    r'hash:"([0-9a-z]+)"'
+)
+_playlist_info_re = re.compile(
+    r'{"type":"([a-z]+)","id":"([0-9]+)"'
+)
+_playlist_url_schema = validate.Schema({
+    "url": validate.any(
+                validate.url(),
+                "error_region"
+            )
+})
+_playlist_schema = validate.Schema({
+    "playlist": [{
+        "streamUrls": {
+            "main": validate.url(),
+        }
+    }]
+})
+
+
+def _find_playlist_info(response):
+    """
+    Finds playlist info (type, id) in HTTP response.
+
+    :param response: Response object.
+    :returns: Dictionary with type and id.
+    """
+    values = {}
+    matches = _playlist_info_re.search(response.text)
+    if matches:
+        values['type'] = matches.group(1)
+        values['id'] = matches.group(2)
+
+    return values
+
+
+def _find_player_url(response):
+    """
+    Finds embedded player url in HTTP response.
+
+    :param response: Response object.
+    :returns: Player url (str).
+    """
+    url = ''
+    matches = _player_re.search(response.text)
+    if matches:
+        tmp_url = matches.group(0).replace('&amp;', '&')
+        if 'hash' not in tmp_url:
+            # there's no hash in the URL, try to find it
+            matches = _hash_re.search(response.text)
+            if matches:
+                url = tmp_url+'&hash='+matches.group(1)
+        else:
+            url = tmp_url
+
+    return 'http://ceskatelevize.cz/'+url
+
+
+class Ceskatelevize(Plugin):
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return _url_re.match(url)
+
+    def _get_streams(self):
+        # fetch requested url and find playlist info
+        response = http.get(self.url)
+        info = _find_playlist_info(response)
+
+        if not info:
+            # playlist info not found, let's try to find player url
+            player_url = _find_player_url(response)
+            if not player_url:
+                raise PluginError('Cannot find playlist info or player url!')
+
+            # get player url and try to find playlist info in it
+            response = http.get(player_url)
+            info = _find_playlist_info(response)
+            if not info:
+                raise PluginError('Cannot find playlist info in the player url!')
+
+        data = {
+            'playlist[0][type]': info['type'],
+            'playlist[0][id]': info['id'],
+            'requestUrl': '/ivysilani/embed/iFramePlayerCT24.php',
+            'requestSource': 'iVysilani',
+            'type': 'html'
+        }
+        headers = {
+            'x-addr': '127.0.0.1',
+        }
+
+        # fetch playlist url
+        response = http.post(
+            'http://www.ceskatelevize.cz/ivysilani/ajax/get-client-playlist',
+            data=data,
+            headers=headers
+        )
+        json_data = http.json(response, schema=_playlist_url_schema)
+
+        if json_data['url'] == "error_region":
+            self.logger.error("This stream is not available in your territory")
+            return
+
+        # fetch playlist
+        response = http.post(json_data['url'])
+        json_data = http.json(response, schema=_playlist_schema)
+        playlist = json_data['playlist'][0]['streamUrls']['main']
+        return HLSStream.parse_variant_playlist(self.session, playlist)
+
+
+__plugin__ = Ceskatelevize
diff --git a/src/streamlink/plugins/douyutv.py b/src/streamlink/plugins/douyutv.py
index be28ee9a..975990e4 100644
--- a/src/streamlink/plugins/douyutv.py
+++ b/src/streamlink/plugins/douyutv.py
@@ -6,10 +6,9 @@ import uuid
 from requests.adapters import HTTPAdapter
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import http, validate
+from streamlink.plugin.api import http, validate, useragents
 from streamlink.stream import HTTPStream
 
-USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36"
 MAPI_URL = "https://m.douyu.com/html5/live?roomId={0}"
 LAPI_URL = "https://www.douyu.com/lapi/live/getPlay/{0}"
 LAPI_SECRET = "A12Svb&%1UUmf@hC"
@@ -26,7 +25,7 @@ _url_re = re.compile(r"""
     /(?P<channel>[^/]+)
 """, re.VERBOSE)
 
-_room_id_re = re.compile(r'"room_id"\s*:\s*(\d+),')
+_room_id_re = re.compile(r'"room_id\\*"\s*:\s*(\d+),')
 _room_id_alt_re = re.compile(r'data-room_id="(\d+)"')
 
 _room_id_schema = validate.Schema(
@@ -94,7 +93,7 @@ class Douyutv(Plugin):
         match = _url_re.match(self.url)
         channel = match.group("channel")
 
-        http.headers.update({'User-Agent': USER_AGENT})
+        http.headers.update({'User-Agent': useragents.CHROME})
         http.verify = False
         http.mount('https://', HTTPAdapter(max_retries=99))
 
@@ -109,9 +108,11 @@ class Douyutv(Plugin):
         res = http.get(MAPI_URL.format(channel))
         room = http.json(res, schema=_room_schema)
         if not room:
+            self.logger.info("Not a valid room url.")
             return
 
         if room["show_status"] != SHOW_STATUS_ONLINE:
+            self.logger.info("Stream currently unavailable.")
             return
 
         ts = int(time.time() / 60)
diff --git a/src/streamlink/plugins/filmon.py b/src/streamlink/plugins/filmon.py
index e7edd269..faec7498 100644
--- a/src/streamlink/plugins/filmon.py
+++ b/src/streamlink/plugins/filmon.py
@@ -1,150 +1,137 @@
 import re
 
-from streamlink.compat import urlparse
+import time
+
+from streamlink import StreamError
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http, validate
-from streamlink.stream import HLSStream, RTMPStream
-
-CHINFO_URL = "http://www.filmon.com/ajax/getChannelInfo"
-SWF_URL = "http://www.filmon.com/tv/modules/FilmOnTV/files/flashapp/filmon/FilmonPlayer.swf"
-VODINFO_URL = "http://www.filmon.com/vod/info/{0}"
-
-AJAX_HEADERS = {
-    "Referer": "http://www.filmon.com",
-    "X-Requested-With": "XMLHttpRequest",
-    "User-Agent": "Mozilla/5.0"
-}
-QUALITY_WEIGHTS = {
-    "high": 720,
-    "low": 480
-}
-STREAM_TYPES = ("hls", "rtmp")
-
-_url_re = re.compile(r"http(s)?://(\w+\.)?filmon.com/(channel|tv|vod)/")
-_channel_id_re = re.compile(r"/channels/(\d+)/extra_big_logo.png")
-_vod_id_re = re.compile(r"movie_id=(\d+)")
-
-_channel_schema = validate.Schema({
-    "streams": [{
-        "name": validate.text,
+from streamlink.stream import HLSStream
+
+
+class FilmOnHLS(HLSStream):
+    __shortname__ = "hls-filmon"
+
+    def __init__(self, session_, channel=None, vod_id=None, quality="high", **args):
+        super(FilmOnHLS, self).__init__(session_, None, **args)
+        self.logger = self.session.logger.new_module("stream.hls-filmon")
+        self.channel = channel
+        self.vod_id = vod_id
+        if self.channel is None and self.vod_id is None:
+            raise ValueError("channel or vod_id must be set")
+        self.quality = quality
+        self.api = FilmOnAPI()
+        self._url = None
+        self.watch_timeout = 0
+
+    def _get_stream_data(self):
+        if self.channel:
+            self.logger.debug("Reloading FilmOn channel playlist: {0}", self.channel)
+            data = self.api.channel(self.channel)
+            for stream in data["streams"]:
+                yield stream
+        elif self.vod_id:
+            self.logger.debug("Reloading FilmOn VOD playlist: {0}", self.vod_id)
+            data = self.api.vod(self.vod_id)
+            for _, stream in data["streams"].items():
+                yield stream
+
+    @property
+    def url(self):
+        # If the watch timeout has passed then refresh the playlist from the API
+        if int(time.time()) >= self.watch_timeout:
+            for stream in self._get_stream_data():
+                if stream["quality"] == self.quality:
+                    self.watch_timeout = int(time.time()) + stream["watch-timeout"]
+                    self._url = stream["url"]
+                    return self._url
+            raise StreamError("cannot refresh FilmOn HLS Stream playlist")
+        else:
+            return self._url
+
+
+class FilmOnAPI(object):
+    channel_url = "http://www.filmon.com/api-v2/channel/{0}?protocol=hls"
+    vod_url = "http://www.filmon.com/vod/info/{0}"
+
+    stream_schema = {
         "quality": validate.text,
-        "url": validate.url(scheme=validate.any("http", "rtmp"))
-    }]
-})
-_vod_schema = validate.Schema(
-    {
-        "data": {
-            "streams": {
-                validate.text: {
-                    "name": validate.text,
-                    "url": validate.url(scheme=validate.any("http", "rtmp"))
-                }
+        "url": validate.url(),
+        "watch-timeout": int
+    }
+    api_schema = validate.Schema(
+        {
+            "data": {
+                "streams": validate.any(
+                    {validate.text: stream_schema},
+                    [stream_schema]
+                )
             }
-        }
-    },
-    validate.get("data")
-)
+        },
+        validate.get("data")
+    )
 
+    def channel(self, channel):
+        res = http.get(self.channel_url.format(channel))
+        return http.json(res, schema=self.api_schema)
 
-def ajax(*args, **kwargs):
-    kwargs["headers"] = AJAX_HEADERS
-    return http.post(*args, **kwargs)
+    def vod(self, vod_id):
+        res = http.get(self.vod_url.format(vod_id))
+        return http.json(res, schema=self.api_schema)
 
 
 class Filmon(Plugin):
+    url_re = re.compile(r"""https?://(?:\w+\.)?filmon.(?:tv|com)/
+        (?:
+            (tv|channel)/(?P<channel>[^/]+)|
+            vod/view/(?P<vod_id>\d+)-|
+            group/
+        )
+    """, re.VERBOSE)
+
+    _channel_id_re = re.compile(r'channel_id\s*?=\s*"(\d+)"')
+    _channel_id_schema = validate.Schema(
+        validate.transform(_channel_id_re.search),
+        validate.any(None, validate.get(1))
+    )
+
+    quality_weights = {
+        "high": 720,
+        "low": 480
+    }
+
+    def __init__(self, url):
+        super(Filmon, self).__init__(url)
+        self.api = FilmOnAPI()
+
     @classmethod
     def can_handle_url(cls, url):
-        return _url_re.match(url)
+        return cls.url_re.match(url) is not None
 
     @classmethod
     def stream_weight(cls, key):
-        weight = QUALITY_WEIGHTS.get(key)
+        weight = cls.quality_weights.get(key)
         if weight:
             return weight, "filmon"
 
         return Plugin.stream_weight(key)
 
-    def _create_rtmp_stream(self, stream, live=True):
-        rtmp = stream["url"]
-        playpath = stream["name"]
-        parsed = urlparse(rtmp)
-        if parsed.query:
-            app = "{0}?{1}".format(parsed.path[1:], parsed.query)
-        else:
-            app = parsed.path[1:]
-
-        if playpath.endswith(".mp4"):
-            playpath = "mp4:" + playpath
-
-        params = {
-            "rtmp": rtmp,
-            "pageUrl": self.url,
-            "swfUrl": SWF_URL,
-            "playpath": playpath,
-            "app": app,
-        }
-        if live:
-            params["live"] = True
-
-        return RTMPStream(self.session, params)
-
-    def _get_live_streams(self, channel_id):
-        params = {"channel_id": channel_id}
-        for stream_type in STREAM_TYPES:
-            cookies = {"flash-player-type": stream_type}
-            res = ajax(CHINFO_URL, cookies=cookies, data=params)
-            channel = http.json(res, schema=_channel_schema)
-
-            # TODO: Replace with "yield from" when dropping Python 2.
-            for stream in self._parse_live_streams(channel):
-                yield stream
-
-    def _parse_live_streams(self, channel):
-        for stream in channel["streams"]:
-            name = stream["quality"]
-            scheme = urlparse(stream["url"]).scheme
-
-            if scheme == "http":
-                try:
-                    streams = HLSStream.parse_variant_playlist(self.session, stream["url"])
-                    for __, stream in streams.items():
-                        yield name, stream
-
-                except IOError as err:
-                    self.logger.error("Failed to extract HLS stream '{0}': {1}", name, err)
-
-            elif scheme == "rtmp":
-                yield name, self._create_rtmp_stream(stream)
-
-    def _get_vod_streams(self, movie_id):
-        for stream_type in STREAM_TYPES:
-            cookies = {"flash-player-type": stream_type}
-            res = ajax(VODINFO_URL.format(movie_id), cookies=cookies)
-            vod = http.json(res, schema=_vod_schema)
-
-            # TODO: Replace with "yield from" when dropping Python 2.
-            for stream in self._parse_vod_streams(vod):
-                yield stream
-
-    def _parse_vod_streams(self, vod):
-        for name, stream in vod["streams"].items():
-            scheme = urlparse(stream["url"]).scheme
-
-            if scheme == "http":
-                yield name, HLSStream(self.session, stream["url"])
-            elif scheme == "rtmp":
-                yield name, self._create_rtmp_stream(stream, live=False)
-
     def _get_streams(self):
-        res = http.get(self.url)
+        url_m = self.url_re.match(self.url)
 
-        match = _vod_id_re.search(res.text)
-        if match:
-            return self._get_vod_streams(match.group(1))
+        channel = url_m and url_m.group("channel")
+        vod_id = url_m and url_m.group("vod_id")
 
-        match = _channel_id_re.search(res.text)
-        if match:
-            return self._get_live_streams(match.group(1))
+        if vod_id:
+            data = self.api.vod(vod_id)
+            for _, stream in data["streams"].items():
+                yield stream["quality"], FilmOnHLS(self.session, vod_id=vod_id, quality=stream["quality"])
+
+        else:
+            if not channel:
+                channel = http.get(self.url, schema=self._channel_id_schema)
+            data = self.api.channel(channel)
+            for stream in data["streams"]:
+                yield stream["quality"], FilmOnHLS(self.session, channel=channel, quality=stream["quality"])
 
 
 __plugin__ = Filmon
diff --git a/src/streamlink/plugins/gulli.py b/src/streamlink/plugins/gulli.py
new file mode 100644
index 00000000..824fdeb9
--- /dev/null
+++ b/src/streamlink/plugins/gulli.py
@@ -0,0 +1,84 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http, validate
+from streamlink.stream import HLSStream, HTTPStream
+from streamlink.utils import parse_json
+
+
+class Gulli(Plugin):
+    LIVE_PLAYER_URL = 'http://replay.gulli.fr/jwplayer/embedstreamtv'
+    VOD_PLAYER_URL = 'http://replay.gulli.fr/jwplayer/embed/{0}'
+
+    _url_re = re.compile(r'http://replay\.gulli\.fr/(?:Direct|.+/(?P<video_id>VOD[0-9]+))')
+    _playlist_re = re.compile(r'sources: (\[.+?\])', re.DOTALL)
+    _vod_video_index_re = re.compile(r'jwplayer\(idplayer\).playlistItem\((?P<video_index>[0-9]+)\)')
+    _mp4_bitrate_re = re.compile(r'.*_(?P<bitrate>[0-9]+)\.mp4')
+
+    _video_schema = validate.Schema(
+        validate.all(
+            validate.transform(lambda x: re.sub(r'"?file"?:\s*[\'"](.+?)[\'"]', r'"file": "\1"', x, flags=re.DOTALL)),
+            validate.transform(parse_json),
+            [
+                validate.Schema({
+                    'file': validate.url()
+                })
+            ]
+        )
+    )
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return Gulli._url_re.match(url)
+
+    def _get_streams(self):
+        match = self._url_re.match(self.url)
+        video_id = match.group('video_id')
+        if video_id is not None:
+            # VOD
+            live = False
+            player_url = self.VOD_PLAYER_URL.format(video_id)
+        else:
+            # Live
+            live = True
+            player_url = self.LIVE_PLAYER_URL
+
+        res = http.get(player_url)
+        playlist = re.findall(self._playlist_re, res.text)
+        index = 0
+        if not live:
+            # Get the index for the video on the playlist
+            match = self._vod_video_index_re.search(res.text)
+            if match is None:
+                return
+            index = int(match.group('video_index'))
+
+        if not playlist:
+            return
+        videos = self._video_schema.validate(playlist[index])
+
+        for video in videos:
+            video_url = video['file']
+
+            # Ignore non-supported MSS streams
+            if 'isml/Manifest' in video_url:
+                continue
+
+            try:
+                if '.m3u8' in video_url:
+                    for stream in HLSStream.parse_variant_playlist(self.session, video_url).items():
+                        yield stream
+                elif '.mp4' in video_url:
+                    match = self._mp4_bitrate_re.match(video_url)
+                    if match is not None:
+                        bitrate = '%sk' % match.group('bitrate')
+                    else:
+                        bitrate = 'vod'
+                    yield bitrate, HTTPStream(self.session, video_url)
+            except IOError as err:
+                if '403 Client Error' in str(err):
+                    self.logger.error('Failed to access stream, may be due to geo-restriction')
+                raise
+
+
+__plugin__ = Gulli
diff --git a/src/streamlink_cli/console.py b/src/streamlink_cli/console.py
index 5db4ee33..aad08721 100644
--- a/src/streamlink_cli/console.py
+++ b/src/streamlink_cli/console.py
@@ -40,7 +40,7 @@ class ConsoleOutput(object):
 
     def msg(self, msg, *args, **kwargs):
         formatted = msg.format(*args, **kwargs)
-        formatted = "{0}\n".format(formatted)
+        formatted = u"{0}\n".format(formatted)
 
         self.output.write(formatted)
 
@@ -53,7 +53,7 @@ class ConsoleOutput(object):
 
         msg = json.dumps(obj, cls=JSONEncoder,
                          indent=2)
-        self.msg("{0}", msg)
+        self.msg(u"{0}", msg)
 
         if isinstance(obj, dict) and obj.get("error"):
             sys.exit(1)
@@ -65,8 +65,8 @@ class ConsoleOutput(object):
             obj = dict(error=formatted)
             self.msg_json(obj)
         else:
-            msg = "error: {0}".format(formatted)
-            self.msg("{0}", msg)
+            msg = u"error: {0}".format(formatted)
+            self.msg(u"{0}", msg)
 
         sys.exit(1)
 


Test patch:
diff --git a/tests/test_plugin_bongacams.py b/tests/test_plugin_bongacams.py
new file mode 100644
index 00000000..1d0b3100
--- /dev/null
+++ b/tests/test_plugin_bongacams.py
@@ -0,0 +1,89 @@
+import unittest
+
+from streamlink.plugins.bongacams import bongacams
+
+
+class TestPluginBongacams(unittest.TestCase):
+    def test_can_handle_url(self):
+        """ positive cases """
+        self.assertTrue(bongacams.can_handle_url('http://bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('http://bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('http://bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('http://bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('http://xx.bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('http://xx.bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('http://xx.bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('http://xx.bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('https://bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('https://bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('https://bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('https://bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('https://xx.bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('https://xx.bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('https://xx.bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('https://xx.bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('bongacams.com/TEst'))
+        self.assertTrue(bongacams.can_handle_url('xx.bongacams.com/1'))
+        self.assertTrue(bongacams.can_handle_url('xx.bongacams.com/0000000000test'))
+        self.assertTrue(bongacams.can_handle_url('xx.bongacams.com/_-_test_-'))
+        self.assertTrue(bongacams.can_handle_url('xx.bongacams.com/TEst'))
+
+        """ Negative cases """
+        # invalid schema
+        self.assertFalse(bongacams.can_handle_url("test://bongacams.com/test"))
+
+        # invalid stream path
+        self.assertFalse(bongacams.can_handle_url('http://bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('http://bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('http://bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('http://bongacams.com/\test'))
+        self.assertFalse(bongacams.can_handle_url('http://xx.bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('http://xx.bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('http://xx.bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('http://xx.bongacams.com/\\test'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.com/\test'))
+        self.assertFalse(bongacams.can_handle_url('https://xx.bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('https://xx.bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('https://xx.bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('https://xx.bongacams.com/\test'))
+        self.assertFalse(bongacams.can_handle_url('bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('bongacams.com/\test'))
+        self.assertFalse(bongacams.can_handle_url('xx.bongacams.com/ '))
+        self.assertFalse(bongacams.can_handle_url('xx.bongacams.com/'))
+        self.assertFalse(bongacams.can_handle_url('xx.bongacams.com/\\'))
+        self.assertFalse(bongacams.can_handle_url('xx.bongacams.com/\test'))
+
+        # invalid domain
+        self.assertFalse(bongacams.can_handle_url('https:// /test'))
+        self.assertFalse(bongacams.can_handle_url('https://xxx.bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https://x.bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https://x.bongacams/test'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.jp/test'))
+        self.assertFalse(bongacams.can_handle_url('https://test.domain/test'))
+        self.assertFalse(bongacams.can_handle_url('https://bongacams.co.uk/test'))
+
+        # invalid separator
+        self.assertFalse(bongacams.can_handle_url('http bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url(' bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('http//bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https//bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('//bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('http:///bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('https:///bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url(':///bongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('httptestbongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('httpstestbongacams.com/test'))
+        self.assertFalse(bongacams.can_handle_url('testbongacams.com/test'))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_plugin_gulli.py b/tests/test_plugin_gulli.py
new file mode 100644
index 00000000..21cb0837
--- /dev/null
+++ b/tests/test_plugin_gulli.py
@@ -0,0 +1,20 @@
+import unittest
+
+from streamlink.plugins.gulli import Gulli
+
+
+class TestPluginGulli(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(Gulli.can_handle_url("http://replay.gulli.fr/Direct"))
+        self.assertTrue(Gulli.can_handle_url("http://replay.gulli.fr/dessins-animes/My-Little-Pony-les-amies-c-est-magique/VOD68328764799000"))
+        self.assertTrue(Gulli.can_handle_url("http://replay.gulli.fr/emissions/In-Ze-Boite2/VOD68639028668000"))
+        self.assertTrue(Gulli.can_handle_url("http://replay.gulli.fr/series/Power-Rangers-Dino-Super-Charge/VOD68612908435000"))
+
+        # shouldn't match
+        self.assertFalse(Gulli.can_handle_url("http://replay.gulli.fr/"))
+        self.assertFalse(Gulli.can_handle_url("http://replay.gulli.fr/dessins-animes"))
+        self.assertFalse(Gulli.can_handle_url("http://replay.gulli.fr/emissions"))
+        self.assertFalse(Gulli.can_handle_url("http://replay.gulli.fr/series"))
+        self.assertFalse(Gulli.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(Gulli.can_handle_url("http://www.youtube.com/"))