Repository: streamlink/streamlink
Pull request #536 (2017-02-05 13:56:52)

plugins.pluzz: add support for ludo.fr and zouzous.fr
This PR improves the pluzz plugin to add support for the following VOD platforms for youth, provided by France Télévisions:

* ludo.fr (as requested in https://github.com/streamlink/streamlink/issues/534)
* zouzous.fr

As for the streams on pluzz.francetv.fr, videos on these two websites are georestricted to France, Andorra and Monaco.


codecov-io: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/536?src=pr&el=h1) Report
> Merging [#536](https://codecov.io/gh/streamlink/streamlink/pull/536?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/de5439953db92235b0399b3fe52fcba0a507bada?src=pr&el=desc) will **increase** coverage by `-0.1%`.


```diff
@@            Coverage Diff            @@
##           master     #536     +/-   ##
=========================================
- Coverage   47.62%   47.53%   -0.1%     
=========================================
  Files          62       62             
  Lines        5293     5308     +15     
=========================================
+ Hits         2521     2523      +2     
- Misses       2772     2785     +13
```

------

[Continue to review full report at Codecov](https://codecov.io/gh/streamlink/streamlink/pull/536?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/536?src=pr&el=footer). Last update [de54399...5f5053e](https://codecov.io/gh/streamlink/streamlink/compare/de5439953db92235b0399b3fe52fcba0a507bada...5f5053e447969cdfc2c4b827c7c54d4c7aad15b1?src=pr&el=footer&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).
Surfoo: Thanks for the PR @melmorabity!
gravyboat: With the latest change this looks good to go to me, any other concerns @beardypig?
beardypig: Just some possible issues with the stream weights for vod streams. 

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index ad34e8ab..3658b075 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -112,11 +112,14 @@ nrk                 - tv.nrk.no          Yes   Yes   Streams may be geo-restrict
 oldlivestream       original.liv... [3]_ Yes   No    Only mobile streams are supported.
 openrectv           openrec.tv           Yes   Yes
 orf_tvthek          tvthek.orf.at        Yes   Yes
+ovvatv              ovva.tv              Yes   No
 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.
+                    ludo.fr
+                    zouzous.fr
 powerapp            powerapp.com.tr      Yes   No
 raiplay             raiplay.it           Yes   No    Most streams are geo-restricted to Italy.
 rtlxl               rtlxl.nl             No    Yes   Streams may be geo-restricted to The Netherlands. Livestreams not supported.
@@ -125,6 +128,7 @@ rtvs                rtvs.sk              Yes   No    Streams may be geo-restrict
 ruv                 ruv.is               Yes   Yes   Streams may be geo-restricted to Iceland.
 schoolism           schoolism.com        --    Yes   Requires a login and a subscription.
 seemeplay           seemeplay.ru         Yes   Yes
+seetv               seetv.tv             Yes   No    Streams that are embedded from other sites will not work.
 servustv            servustv.com         ?     ?
 speedrunslive       speedrunslive.com    Yes   --    URL forwarder to Twitch channels.
 sportal             sportal.bg           Yes   No
@@ -149,6 +153,7 @@ tf1                 - tf1.fr             Yes   No    Streams may be geo-restrict
                     - lci.fr
 tga                 - star.plu.cn        Yes   No
                     - star.tga.plu.cn
+                    - star.longzhu.com
 tigerdile           tigerdile.com        Yes   --
 trt                 trt.net.tr           Yes   No    Some streams may be geo-restricted to Turkey.
 trtspor             trtspor.com          Yes   No    Some streams are geo-restricted to Turkey.
@@ -167,6 +172,7 @@ tv8cat              tv8.cat              Yes   No    Streams may be geo-restrict
 tv360               tv360.com.tr         Yes   No
 tvcatchup           - tvcatchup.com      Yes   No    Streams may be geo-restricted to Great Britain.
 tvplayer            tvplayer.com         Yes   No    Streams may be geo-restricted to Great Britain. Premium streams are not supported.
+tvrby               tvr.by               Yes   No    Streams may be geo-restricted to Belarus.
 tvrplus             tvrplus.ro           Yes   No    Streams may be geo-restricted to Romania.
 twitch              twitch.tv            Yes   Yes   Possible to authenticate for access to
                                                      subscription streams.
diff --git a/setup.py b/setup.py
index 7f0a1687..7f1bf125 100644
--- a/setup.py
+++ b/setup.py
@@ -34,8 +34,11 @@ if version_info[0] == 2:
     deps.append("backports.shutil_get_terminal_size")
 
 # for localization
-deps.append("iso-639")
-deps.append("iso3166")
+if environ.get("STREAMLINK_USE_PYCOUNTRY"):
+    deps.append("pycountry")
+else:
+    deps.append("iso-639")
+    deps.append("iso3166")
 
 # When we build an egg for the Win32 bootstrap we don't want dependency
 # information built into it.
diff --git a/src/streamlink/plugins/mitele.py b/src/streamlink/plugins/mitele.py
index 939b8969..67a8ae57 100644
--- a/src/streamlink/plugins/mitele.py
+++ b/src/streamlink/plugins/mitele.py
@@ -6,7 +6,6 @@ from base64 import b64decode
 from datetime import datetime
 from uuid import uuid4
 
-from streamlink.cache import Cache
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http
 from streamlink.plugin.api import validate
@@ -64,10 +63,6 @@ class Mitele(Plugin):
         }
     })
 
-    def __init__(self, url):
-        super(Mitele, self).__init__(url)
-        self.cache = Cache("mitele.cache")
-
     @classmethod
     def can_handle_url(cls, url):
         m = cls.url_re.match(url)
diff --git a/src/streamlink/plugins/ovvatv.py b/src/streamlink/plugins/ovvatv.py
new file mode 100644
index 00000000..928f8960
--- /dev/null
+++ b/src/streamlink/plugins/ovvatv.py
@@ -0,0 +1,63 @@
+from __future__ import print_function
+import re
+import string
+from base64 import b64decode
+from pprint import pprint
+
+from streamlink import PluginError
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugin.api import useragents
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+from streamlink.compat import urlparse
+from streamlink.utils import parse_json
+
+
+class ovvaTV(Plugin):
+    url_re = re.compile(r"https?://(?:www\.)?ovva.tv/(?:ua/)?tvguide/.*?/online")
+    iframe_re = re.compile(r"iframe .*?src=\"((?:https?:)?//(?:\w+\.)?ovva.tv/[^\"]+)\"", re.DOTALL)
+    data_re = re.compile(r"ovva\(\'(.*?)\'\);")
+    ovva_data_schema = validate.Schema({
+        "url": validate.url()
+    }, validate.get("url"))
+    ovva_redirect_schema = validate.Schema(validate.all(
+        validate.transform(lambda x: x.split("=")),
+        ['302', validate.url()],
+        validate.get(1)
+    ))
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def find_iframe(self, res):
+        for url in self.iframe_re.findall(res.text):
+            if url.startswith("//"):
+                p = urlparse(self.url)
+                return "{0}:{1}".format(p.scheme, url)
+            else:
+                return url
+
+    def _get_streams(self):
+        http.headers = {"User-Agent": useragents.ANDROID}
+        res = http.get(self.url)
+        iframe_url = self.find_iframe(res)
+
+        if iframe_url:
+            self.logger.debug("Found iframe: {0}", iframe_url)
+            res = http.get(iframe_url, headers={"Referer": self.url})
+            data = self.data_re.search(res.text)
+            if data:
+                try:
+                    ovva_url = parse_json(b64decode(data.group(1)).decode("utf8"), schema=self.ovva_data_schema)
+                    stream_url = http.get(ovva_url, schema=self.ovva_redirect_schema)
+                except PluginError as e:
+                    self.logger.error("Could not find stream URL: {0}", e)
+                else:
+                    return HLSStream.parse_variant_playlist(self.session, stream_url)
+            else:
+                self.logger.error("Could not find player data.")
+
+
+__plugin__ = ovvaTV
diff --git a/src/streamlink/plugins/pluzz.py b/src/streamlink/plugins/pluzz.py
index c4ef67c5..0e8f0a47 100644
--- a/src/streamlink/plugins/pluzz.py
+++ b/src/streamlink/plugins/pluzz.py
@@ -5,19 +5,21 @@ import time
 
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import http, validate
-from streamlink.stream import HDSStream, HLSStream
+from streamlink.stream import HDSStream, HLSStream, HTTPStream
 
 
 class Pluzz(Plugin):
     GEO_URL = 'http://geo.francetv.fr/ws/edgescape.json'
-    API_URL = 'http://sivideo.webservices.francetelevisions.fr/tools/getInfosOeuvre/v2/?idDiffusion={0}&catalogue=Pluzz'
+    API_URL = 'http://sivideo.webservices.francetelevisions.fr/tools/getInfosOeuvre/v2/?idDiffusion={0}&catalogue={1}'
     TOKEN_URL = 'http://hdfauthftv-a.akamaihd.net/esi/TA?url={0}'
 
-    _url_re = re.compile(r'http://pluzz\.francetv\.fr/(videos/.+\.html|[\w-]+)')
-    _video_id_re = re.compile(r'id="current_video" href="http://.+?\.(?:francetv|francetelevisions)\.fr/(?:video/|\?id-video=)(?P<video_id>.+?)"')
-    _player_re = re.compile(r'<script type="text/javascript" src="(?P<player>//staticftv-a\.akamaihd\.net/player/jquery\.player.+?-[0-9a-f]+?\.js)"></script>')
+    _url_re = re.compile(r'http://(pluzz\.francetv\.fr/(videos/.+\.html|[\w-]+)|www\.(ludo|zouzous)\.fr/heros/[\w-]+)')
+    _pluzz_video_id_re = re.compile(r'id="current_video" href="http://.+?\.(?:francetv|francetelevisions)\.fr/(?:video/|\?id-video=)(?P<video_id>.+?)"')
+    _other_video_id_re = re.compile(r'playlist: \[{.*?,"identity":"(?P<video_id>.+?)@(?P<catalogue>Ludo|Zouzous)"')
+    _player_re = re.compile(r'src="(?P<player>//staticftv-a\.akamaihd\.net/player/jquery\.player.+?-[0-9a-f]+?\.js)"></script>')
     _swf_re = re.compile(r'getUrl\("(?P<swf>/bower_components/player_flash/dist/FranceTVNVPVFlashPlayer\.akamai.+?\.swf)"\)')
     _hds_pv_data_re = re.compile(r"~data=.+?!")
+    _mp4_bitrate_re = re.compile(r'.*-(?P<bitrate>[0-9]+k)\.mp4')
 
     _geo_schema = validate.Schema({
         'reponse': {
@@ -69,10 +71,17 @@ class Pluzz(Plugin):
 
         # Retrieve URL page and search for video ID
         res = http.get(self.url)
-        match = self._video_id_re.search(res.text)
+        if 'pluzz.francetv.fr' in self.url:
+            video_re = self._pluzz_video_id_re
+        else:
+            video_re = self._other_video_id_re
+        match = video_re.search(res.text)
         if match is None:
             return
+        catalogue = 'Pluzz'
         video_id = match.group('video_id')
+        if 'catalogue' in match.groupdict():
+            catalogue = match.group('catalogue')
 
         # Retrieve SWF player URL
         match = self._player_re.search(res.text)
@@ -84,7 +93,7 @@ class Pluzz(Plugin):
             if match is not None:
                 swf_url = os.path.dirname(player_url) + match.group('swf')
 
-        res = http.get(self.API_URL.format(video_id))
+        res = http.get(self.API_URL.format(video_id, catalogue))
         videos = http.json(res, schema=self._api_schema)
         now = time.time()
 
@@ -92,6 +101,7 @@ class Pluzz(Plugin):
         geolocked = False
         drm = False
         expired = False
+
         for video in videos['videos']:
             video_url = video['url']
 
@@ -124,8 +134,9 @@ class Pluzz(Plugin):
             if '.mpd' in video_url:
                 continue
 
-            res = http.get(self.TOKEN_URL.format(video_url))
-            video_url = res.text
+            if catalogue == 'Pluzz' or '.f4m' in video_url:
+                res = http.get(self.TOKEN_URL.format(video_url))
+                video_url = res.text
 
             if '.f4m' in video_url and swf_url is not None:
                 for bitrate, stream in HDSStream.parse_manifest(self.session, video_url, pvswf=swf_url).items():
@@ -140,6 +151,16 @@ class Pluzz(Plugin):
             elif '.m3u8' in video_url:
                 for stream in HLSStream.parse_variant_playlist(self.session, video_url).items():
                     yield stream
+            # HBB TV streams are not provided anymore by France Televisions
+            elif '.mp4' in video_url and '/hbbtv/' not in video_url:
+                match = self._mp4_bitrate_re.match(video_url)
+                if match is not None:
+                    bitrate = match.group('bitrate')
+                else:
+                    # Fallback bitrate (seems all France Televisions MP4 videos
+                    # seem have such bitrate)
+                    bitrate = '1500k'
+                yield bitrate, HTTPStream(self.session, video_url)
 
         if offline:
             self.logger.error('Failed to access stream, may be due to offline content')
diff --git a/src/streamlink/plugins/seetv.py b/src/streamlink/plugins/seetv.py
new file mode 100644
index 00000000..432055aa
--- /dev/null
+++ b/src/streamlink/plugins/seetv.py
@@ -0,0 +1,78 @@
+import re
+
+import time
+
+from streamlink import PluginError
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugin.api import useragents
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+from streamlink.compat import urlparse, parse_qsl
+
+
+class SeeTV(Plugin):
+    _url_re = re.compile(r"""(http://seetv.tv/vse-tv-online/.*?)(#|$)""")
+    _api_url = "http://seetv.tv/get/player/{0}"
+    _main_source_re = re.compile(r'stream-active-main" rel="(\d+)"')
+    api_schema = validate.Schema(validate.any(
+        {
+            "status": False,
+            "text": validate.text
+        },
+        {
+            "status": True,
+            "file": validate.any(
+                validate.all(validate.url(), validate.transform(lambda x: x.replace("%3F", "?"))),
+                validate.all(validate.text, validate.startswith("<"))
+            ),
+            "height": validate.text,
+        }
+    ))
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._url_re.match(url) is not None
+
+    @property
+    def referer(self):
+        return self._url_re.match(self.url).group(1)
+
+    def _get_tv_link(self):
+        res = http.get(self.url)
+        link_m = self._main_source_re.search(res.text)
+        return link_m and link_m.group(1)
+
+    def _get_streams(self):
+        http.headers.update({"User-Agent": useragents.CHROME,
+                             "Referer": self.referer})
+        fragment = dict(parse_qsl(urlparse(self.url).fragment))
+        link = fragment.get("link")
+        if not link:
+            link = self._get_tv_link()
+
+        if not link:
+            self.logger.error("Missing link fragment: stream unavailable")
+            return
+
+        player_url = self._api_url.format(link)
+        self.logger.debug("Requesting player API: {0} (referer={1})", player_url, self.referer)
+        res = http.get(player_url,
+                       params={"_": int(time.time() * 1000)},
+                       headers={"X-Requested-With": "XMLHttpRequest"})
+
+        try:
+            data = http.json(res, schema=self.api_schema)
+        except PluginError as e:
+            print(e)
+            self.logger.error("Cannot play this stream type")
+        else:
+            if data["status"]:
+                if data["file"].startswith("<"):
+                    self.logger.error("Cannot play embedded streams")
+                else:
+                    return HLSStream.parse_variant_playlist(self.session, data["file"])
+            else:
+                self.logger.error(data["text"])
+
+__plugin__ = SeeTV
diff --git a/src/streamlink/plugins/tga.py b/src/streamlink/plugins/tga.py
index 2c665988..692a9bc6 100644
--- a/src/streamlink/plugins/tga.py
+++ b/src/streamlink/plugins/tga.py
@@ -9,8 +9,7 @@ from streamlink.stream import HLSStream, HTTPStream, RTMPStream
 
 CHANNEL_INFO_URL = "http://api.plu.cn/tga/streams/%s"
 QQ_STREAM_INFO_URL = "http://info.zb.qq.com/?cnlid=%d&cmd=2&stream=%d&system=1&sdtfrom=113"
-PLU_STREAM_INFO_URL = "http://star.api.plu.cn/live/GetLiveUrl?roomId=%d"
-
+PLU_STREAM_INFO_URL = "http://livestream.plu.cn/live/getlivePlayurl?roomId=%d"
 _quality_re = re.compile(r"\d+x(\d+)$")
 _url_re = re.compile(r"http://star\.longzhu\.(?:tv|com)/(m\/)?(?P<domain>[a-z0-9]+)")
 
@@ -18,10 +17,7 @@ _channel_schema = validate.Schema(
     {
         "data": validate.any(None, {
             "channel": validate.any(None, {
-                "id": validate.all(
-                    validate.text,
-                    validate.transform(int)
-                ),
+                "id": int,
                 "vid": int
             })
         })
@@ -31,11 +27,13 @@ _channel_schema = validate.Schema(
 
 _plu_schema = validate.Schema(
     {
-        "urls": [{
-            "securityUrl": validate.url(scheme=validate.any("rtmp", "http")),
-            "resolution": validate.text,
-            "ext": validate.text
-        }]
+        "playLines": [{
+            "urls": [{
+                "securityUrl": validate.url(scheme=validate.any("rtmp", "http")),
+                "resolution": validate.text,
+                "ext": validate.text
+                }]
+         }]
     }
 )
 
@@ -86,12 +84,12 @@ class Tga(Plugin):
 
         res = http.get(QQ_STREAM_INFO_URL % (vid, 2))
         info = http.json(res, schema=_qq_schema)
-        yield "live_http", HLSStream(self.session, info)
+        yield "live", HLSStream(self.session, info)
 
     def _get_plu_streams(self, cid):
         res = http.get(PLU_STREAM_INFO_URL % cid)
         info = http.json(res, schema=_plu_schema)
-        for source in info["urls"]:
+        for source in info["playLines"][0]["urls"]:
             quality = self._get_quality(source["resolution"])
             if source["ext"] == "m3u8":
                 yield quality, HLSStream(self.session, source["securityUrl"])
diff --git a/src/streamlink/plugins/tvrby.py b/src/streamlink/plugins/tvrby.py
new file mode 100644
index 00000000..722e452b
--- /dev/null
+++ b/src/streamlink/plugins/tvrby.py
@@ -0,0 +1,58 @@
+from __future__ import print_function
+import re
+from wsgiref import headers
+
+from streamlink import PluginError
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+from streamlink.plugin.api import useragents
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+from streamlink.compat import urlparse
+from streamlink.stream import RTMPStream
+
+
+class TVRBy(Plugin):
+    url_re = re.compile(r"""https?://(?:www\.)?tvr.by/televidenie/belarus""")
+    file_re = re.compile(r"""(?P<q1>["']?)file(?P=q1)\s*:\s*(?P<q2>["'])(?P<url>(?:http.+?m3u8.*?|rtmp://.*?))(?P=q2)""")
+
+    stream_schema = validate.Schema(
+        validate.all(
+            validate.transform(file_re.finditer),
+            validate.transform(list),
+            [validate.get("url")]
+        ),
+    )
+
+    def __init__(self, url):
+        # ensure the URL ends with a /
+        if not url.endswith("/"):
+            url += "/"
+        super(TVRBy, self).__init__(url)
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        res = http.get(self.url)
+        stream_urls = self.stream_schema.validate(res.text)
+        self.logger.debug("Found {0} stream URL{1}", len(stream_urls),
+                          "" if len(stream_urls) == 1 else "s")
+
+        for stream_url in stream_urls:
+            if "m3u8" in stream_url:
+                for _, s in HLSStream.parse_variant_playlist(self.session, stream_url).items():
+                    yield "live", s
+            if stream_url.startswith("rtmp://"):
+                a = stream_url.split("///")
+                s = RTMPStream(self.session, {
+                    "rtmp": a[0],
+                    "playpath": "live",
+                    "swfVfy": "http://www.tvr.by/plugines/uppod/uppod.swf",
+                    "pageUrl": self.url
+                })
+                yield "live", s
+
+
+__plugin__ = TVRBy
diff --git a/src/streamlink/stream/hls_playlist.py b/src/streamlink/stream/hls_playlist.py
index 76e65da5..bf916529 100644
--- a/src/streamlink/stream/hls_playlist.py
+++ b/src/streamlink/stream/hls_playlist.py
@@ -96,7 +96,7 @@ class M3U8Parser(object):
         match = re.match("#(?P<tag>[\w-]+)(:(?P<value>.+))?", line)
 
         if match:
-            return match.group("tag"), match.group("value").strip()
+            return match.group("tag"), (match.group("value") or "").strip()
 
         return None, None
 
diff --git a/src/streamlink_cli/utils/http_server.py b/src/streamlink_cli/utils/http_server.py
index e4effe40..42dedc94 100644
--- a/src/streamlink_cli/utils/http_server.py
+++ b/src/streamlink_cli/utils/http_server.py
@@ -23,6 +23,7 @@ class HTTPRequest(BaseHTTPRequestHandler):
 class HTTPServer(object):
     def __init__(self):
         self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
         self.conn = self.host = self.port = None
         self.bound = False
 
@@ -111,4 +112,8 @@ class HTTPServer(object):
             self.conn.close()
 
         if not client_only:
+            try:
+                self.socket.shutdown(2)
+            except OSError:
+                pass
             self.socket.close()


Test patch:
diff --git a/tests/test_plugin_pluzz.py b/tests/test_plugin_pluzz.py
index f536e175..70680220 100644
--- a/tests/test_plugin_pluzz.py
+++ b/tests/test_plugin_pluzz.py
@@ -15,8 +15,15 @@ class TestPluginPluzz(unittest.TestCase):
         self.assertTrue(Pluzz.can_handle_url("http://pluzz.francetv.fr/franceinfo"))
         self.assertTrue(Pluzz.can_handle_url("http://pluzz.francetv.fr/videos/jt20h.html"))
         self.assertTrue(Pluzz.can_handle_url("http://pluzz.francetv.fr/videos/jt_1213_franche_comte.html"))
+        self.assertTrue(Pluzz.can_handle_url("http://www.ludo.fr/heros/the-batman"))
+        self.assertTrue(Pluzz.can_handle_url("http://www.ludo.fr/heros/il-etait-une-fois-la-vie"))
+        self.assertTrue(Pluzz.can_handle_url("http://www.zouzous.fr/heros/oui-oui"))
+        self.assertTrue(Pluzz.can_handle_url("http://www.zouzous.fr/heros/marsupilami-1"))
 
         # shouldn't match
         self.assertFalse(Pluzz.can_handle_url("http://pluzz.francetv.fr/"))
+        self.assertFalse(Pluzz.can_handle_url("http://www.ludo.fr/"))
+        self.assertFalse(Pluzz.can_handle_url("http://www.ludo.fr/jeux"))
+        self.assertFalse(Pluzz.can_handle_url("http://www.zouzous.fr/"))
         self.assertFalse(Pluzz.can_handle_url("http://www.tvcatchup.com/"))
         self.assertFalse(Pluzz.can_handle_url("http://www.youtube.com/"))
diff --git a/tests/test_plugin_tvrby.py b/tests/test_plugin_tvrby.py
new file mode 100644
index 00000000..c1348a78
--- /dev/null
+++ b/tests/test_plugin_tvrby.py
@@ -0,0 +1,31 @@
+import unittest
+
+from streamlink.plugins.tvrby import TVRBy
+
+
+class TestPluginTVRBy(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(TVRBy.can_handle_url("http://www.tvr.by/televidenie/belarus-1/"))
+        self.assertTrue(TVRBy.can_handle_url("http://www.tvr.by/televidenie/belarus-1"))
+        self.assertTrue(TVRBy.can_handle_url("http://www.tvr.by/televidenie/belarus-24/"))
+        self.assertTrue(TVRBy.can_handle_url("http://www.tvr.by/televidenie/belarus-24"))
+
+        # shouldn't match
+        self.assertFalse(TVRBy.can_handle_url("http://www.tv8.cat/algo/"))
+        self.assertFalse(TVRBy.can_handle_url("http://www.tvcatchup.com/"))
+        self.assertFalse(TVRBy.can_handle_url("http://www.youtube.com/"))
+
+    def test_url_fix(self):
+        self.assertTrue(
+            "http://www.tvr.by/televidenie/belarus-1/",
+            TVRBy("http://www.tvr.by/televidenie/belarus-1/").url)
+        self.assertTrue(
+            "http://www.tvr.by/televidenie/belarus-1/",
+            TVRBy("http://www.tvr.by/televidenie/belarus-1").url)
+        self.assertTrue(
+            "http://www.tvr.by/televidenie/belarus-24/",
+            TVRBy("http://www.tvr.by/televidenie/belarus-24/").url)
+        self.assertTrue(
+            "http://www.tvr.by/televidenie/belarus-24/",
+            TVRBy("http://www.tvr.by/televidenie/belarus-24").url)