Repository: streamlink/streamlink

Issue:
Plugin for zattoo.com
### Checklist

- [ ] This is a bug report.
- [ ] This is a feature request.
- [x] This is a plugin (improvement) request.
- [ ] I have read the contribution guidelines.

### Description
Could You please add a plugin for https://zattoo.com, a livestreaming platform on which many TV stations are streamed. There are about 70 channels on the free version and about 90 channels in the premium version.

Sorry for grammatic mistakes if done and thanks a lot.
...

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 08cddb4c..32487f7b 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,3 +1,114 @@
+streamlink 0.7.0 (2017-06-30)
+-----------------------------
+0.7.0 of Streamlink!
+
+Since our May release, we've incorporated quite a few changes!
+
+Outlined are the major features in this month's release:
+
+  - Stream types will now be sorted accordingly in terms of quality
+  - TeamLiquid.net Plugin added
+  - Numerous plugin & bug fixes
+  - Updated HomeBrew package
+  - Improved CLI documentation
+
+Many thanks to those who've contributed in this release!
+
+If you think that this application is helpful, please consider supporting the maintainers by [donating](https://streamlink.github.io/donate.html).
+
+
+::
+
+    Alex Shafer <shafer.alex@gmail.com> (1):
+          Return sorted list of streams. (#731)
+    
+    Alexandre Hitchcox <alexandre@hitchcox.me> (1):
+          Allow live channel links without '/c/' prefix
+    
+    Alexis Murzeau <amubtdx@outlook.fr> (1):
+          docs: fix typo: specifiying, neverthless
+    
+    CatKasha <CatKasha@users.noreply.github.com> (1):
+          Add MPC-HC x64 in streamlinkrc
+    
+    Forrest <gravyboat@users.noreply.github.com> (1):
+          Add a few more examples to the player option (#896)
+    
+    Jacob Malmberg <jacobma@kth.se> (3):
+          Here's the plugin I wrote for teamliquid.net (w/ some help from https://github.com/back-to)
+          Tests for teamliquid plugin
+          Now with RE!
+    
+    Mohamed El Morabity <melmorabity@fedoraproject.org> (9):
+          Update for live API changes
+          Add unit tests for Euronews plugin
+          Drop pcyourfreetv plugin
+          Add support for regional France 3 streams
+          Add support for TV5Monde
+          PEP8
+          Add support for VOD/audio streams
+          Add support for radio.net
+          Ignore unreliable stream status returned by radio.net
+    
+    Sebastian Meyer <mail@bastimeyer.de> (1):
+          Homebrew package (#929)
+    
+    back-to <back-to@users.noreply.github.com> (2):
+          [dailymotion] fix for broken .f4m file that is a .m3u8 file (only livestreams)
+          [arte] vod api url update & add new/missing languages
+    
+    bastimeyer <mail@bastimeyer.de> (2):
+          docs: fix parameters being linked in code blocks
+          Improve CLI documentation
+    
+    beardypig <beardypig@protonmail.com> (1):
+          plugins.hitbox: add support for smashcast.tv
+    
+    beardypig <beardypig@users.noreply.github.com> (21):
+          plugins.bbciplayer: update to reflect slight site layout change
+          plugins.bbciplayer: add option to login to a bbc account
+          http_server: handle socket closed exception for Python 2.7
+          docs: update Sphinx config to fix the rendering of --
+          docs: pin sphinx to 1.6.+ so that no future changes affect the docs
+          plugins.tvplayer: fix bug with some channels not loading
+          plugins.hitbox: fix new VOD urls, and add support for hosted streams
+          plugins.tvplayer: fix bug with some channels when not authenticated
+          setup: exclude requests version 2.16 through 2.17.1
+          win32: fix missing modules when using windows installer
+          bbciplayer: fix for api changes to iplayer
+          tvplayer: updated to match change token parameter name
+          plugins.looch: support for live and vod streams on looch.tv
+          plugins.webtv: decrypt the stream URL when applicable
+          plugins.dogan: small api change for teve2.com.tr
+          plugins.kanal7: fix for nested iframes
+          win32: update the dependencies for the windows installer
+          plugins.canlitv: simplified and fixed the m3u8 regex
+          plugins.picarto: support for VOD
+          plugins.ine: update to extract the relocated jwplayer config
+          plugin.ufctv: support for free and premium vod/live streams
+    
+    cirrus <nailzuk@gmail.com> (3):
+          Create arconia.py
+          Rename arconia.py to arconai.py
+          Create plugin_matrix.rst
+    
+    steven7851 <steven7851@msn.com> (4):
+          plugins.app17: fix hls url and support UID page
+          little change
+          plugins.app17: change ROOM_URL
+          [douyu] temporary fix by revert to previously commit (#1015)
+    
+    whizzoo <grenardus@gmail.com> (2):
+          Restore support for RTL XL
+          plugin.rtlxl: Remove spaces from line 14
+    
+    yhel <joel.delahayes@gmail.com> (1):
+          Don't return an error when the stream is offline
+    
+    yhel <yhelae@gmail.com> (1):
+          Add capability of extracting current sport.francetv stream
+    
+
 streamlink 0.6.0 (2017-05-11)
 -----------------------------
 Another release of Streamlink!
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index b2e12936..d8f676f7 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -154,18 +154,18 @@ playtv              playtv.fr            Yes   --    Streams may be geo-restrict
 pluzz               - france.tv          Yes   Yes   Streams may be geo-restricted to France, Andorra and Monaco.
                     - ludo.fr
                     - zouzous.fr
-		    - france3-reg.. [8]_
+                    - france3-reg.. [8]_
 powerapp            powerapp.com.tr      Yes   No
 radionet            - radio.net          Yes   --
                     - radio.at
-		    - radio.de
-		    - radio.dk
-		    - radio.es
-		    - radio.fr
-		    - radio.it
-		    - radio.pl
-		    - radio.pt
-		    - radio.se
+                    - radio.de
+                    - radio.dk
+                    - radio.es
+                    - radio.fr
+                    - radio.it
+                    - radio.pl
+                    - radio.pt
+                    - radio.se
 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.
 rte                 rte.ie/player        Yes   Yes
@@ -261,6 +261,9 @@ wwenetwork          network.wwe.com      Yes   Yes   Requires an account to acce
 younow              younow.com           Yes   --
 youtube             - youtube.com        Yes   Yes   Protected videos are not supported.
                     - youtu.be
+zattoo              - zattoo.com         Yes   Yes
+                    - nettv.net... [10]_
+                    - tvonline.ewe.de
 zdf_mediathek       zdf.de               Yes   Yes   Streams may be geo-restricted to Germany.
 zhanqitv            zhanqi.tv            Yes   No
 =================== ==================== ===== ===== ===========================
@@ -275,3 +278,4 @@ zhanqitv            zhanqi.tv            Yes   No
 .. [7] player.theplatform.com
 .. [8] france3-regions.francetvinfo.fr
 .. [9] tv5mondeplusafrique.com
+.. [10] nettv.netcologne.de
diff --git a/setup.py b/setup.py
index a5f9bc86..a79b1c52 100644
--- a/setup.py
+++ b/setup.py
@@ -23,7 +23,7 @@ if version_info[0] == 2:
 if version_info[0] == 2 or (version_info[0] == 3 and version_info[1] < 4):
     deps.append("singledispatch")
 
-deps.append("requests>=2.2,!=2.12.0,!=2.12.1,<3.0")
+deps.append("requests>=2.2,!=2.12.0,!=2.12.1,!=2.16.0,!=2.16.1,!=2.16.2,!=2.16.3,!=2.16.4,!=2.16.5,!=2.17.1,<3.0")
 
 # for encrypted streams
 if environ.get("STREAMLINK_USE_PYCRYPTO"):
@@ -53,7 +53,7 @@ srcdir = join(dirname(abspath(__file__)), "src/")
 sys_path.insert(0, srcdir)
 
 setup(name="streamlink",
-      version="0.6.0",
+      version="0.7.0",
       description="Streamlink is command-line utility that extracts streams "
                   "from various services and pipes them into a video player of "
                   "choice.",
diff --git a/src/streamlink/__init__.py b/src/streamlink/__init__.py
index 28dbb515..555b0cad 100644
--- a/src/streamlink/__init__.py
+++ b/src/streamlink/__init__.py
@@ -22,7 +22,7 @@ if version_info[:2] == (2, 6):
     )
 
 __title__ = "streamlink"
-__version__ = "0.6.0"
+__version__ = "0.7.0"
 __license__ = "Simplified BSD"
 __author__ = "Streamlink"
 __copyright__ = "Copyright 2016 Streamlink"
diff --git a/src/streamlink/plugin/plugin.py b/src/streamlink/plugin/plugin.py
index 2721dab0..381275cd 100644
--- a/src/streamlink/plugin/plugin.py
+++ b/src/streamlink/plugin/plugin.py
@@ -1,5 +1,7 @@
 import operator
 import re
+from collections import OrderedDict
+
 from functools import partial
 
 from ..cache import Cache
@@ -11,6 +13,8 @@ from ..options import Options
 # Someone who knows math, please fix.
 BIT_RATE_WEIGHT_RATIO = 2.8
 
+ALT_WEIGHT_MOD = 0.01
+
 QUALITY_WEIGTHS_EXTRA = {
     "other": {
         "live": 1080,
@@ -40,18 +44,26 @@ def stream_weight(stream):
         if stream in weights:
             return weights[stream], group
 
-    match = re.match(r"^(\d+)(k|p)?(\d+)?(\+)?(?:_(\d+)k)?$", stream)
+    match = re.match(r"^(\d+)(k|p)?(\d+)?(\+)?(?:_(\d+)k)?(?:_(alt)(\d)?)?$", stream)
 
     if match:
+        weight = 0
+
+        if match.group(6):
+            if match.group(7):
+                weight -= ALT_WEIGHT_MOD * int(match.group(7))
+            else:
+                weight -= ALT_WEIGHT_MOD
+
         name_type = match.group(2)
         if name_type == "k":  # bit rate
             bitrate = int(match.group(1))
-            weight = bitrate / BIT_RATE_WEIGHT_RATIO
+            weight += bitrate / BIT_RATE_WEIGHT_RATIO
 
             return weight, "bitrate"
 
         elif name_type == "p":  # resolution
-            weight = int(match.group(1))
+            weight += int(match.group(1))
 
             if match.group(3):  # fps eg. 60p or 50p
                 weight += int(match.group(3))
@@ -310,13 +322,18 @@ class Plugin(object):
         elif callable(sorting_excludes):
             sorted_streams = list(filter(sorting_excludes, sorted_streams))
 
+        final_sorted_streams = OrderedDict()
+        
+        for stream_name in sorted(streams, key=stream_weight_only):
+            final_sorted_streams[stream_name] = streams[stream_name]
+        
         if len(sorted_streams) > 0:
             best = sorted_streams[-1]
             worst = sorted_streams[0]
-            streams["best"] = streams[best]
-            streams["worst"] = streams[worst]
-
-        return streams
+            final_sorted_streams["worst"] = streams[worst]
+            final_sorted_streams["best"] = streams[best]
+        
+        return final_sorted_streams
 
     def get_streams(self, *args, **kwargs):
         """Deprecated since version 1.9.0.
diff --git a/src/streamlink/plugins/artetv.py b/src/streamlink/plugins/artetv.py
index 96c048c3..d5360b7c 100644
--- a/src/streamlink/plugins/artetv.py
+++ b/src/streamlink/plugins/artetv.py
@@ -12,7 +12,7 @@ from streamlink.stream import HDSStream
 from streamlink.stream import HLSStream
 from streamlink.stream import HTTPStream
 
-JSON_VOD_URL = "https://api.arte.tv/api/player/v1/config/{0}/{1}"
+JSON_VOD_URL = "https://api.arte.tv/api/player/v1/config/{0}/{1}?platform=ARTE_NEXT"
 JSON_LIVE_URL = "https://api.arte.tv/api/player/v1/livestream/{0}"
 
 _url_re = re.compile(r"""
@@ -53,13 +53,13 @@ class ArteTV(Plugin):
         stream_language = stream["versionShortLibelle"]
 
         if language == "de":
-            language = ["DE", "VOST-DE", "VA", "VOA", "Dt. Live"]
+            language = ["DE", "VOST-DE", "VA", "VOA", "Dt. Live", "OV", "OmU"]
         elif language == "en":
             language = ["ANG", "VOST-ANG"]
         elif language == "es":
             language = ["ESP", "VOST-ESP"]
         elif language == "fr":
-            language = ["FR", "VOST-FR", "VF", "VOF", "Frz. Live"]
+            language = ["FR", "VOST-FR", "VF", "VOF", "Frz. Live", "VO", "ST mal"]
         elif language == "pl":
             language = ["POL", "VOST-POL"]
 
diff --git a/src/streamlink/plugins/teamliquid.py b/src/streamlink/plugins/teamliquid.py
new file mode 100755
index 00000000..66d9f11c
--- /dev/null
+++ b/src/streamlink/plugins/teamliquid.py
@@ -0,0 +1,26 @@
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import http
+
+
+_url_re = re.compile(r'''https?://(?:www\.)?teamliquid\.net/video/streams/''')
+
+
+class Teamliquid(Plugin):
+    @classmethod
+    def can_handle_url(cls, url):
+        return _url_re.match(url)
+
+    def _get_streams(self):
+        res = http.get(self.url)     
+       
+        stream_address_re = re.compile(r'''href\s*=\s*"([^"]+)"\s*>\s*View on''')
+
+        stream_url_match = stream_address_re.search(res.text)
+        if stream_url_match:
+            stream_url = stream_url_match.group(1)
+            self.logger.info("Attempting to play streams from {0}", stream_url)
+            return self.session.streams(stream_url)
+            
+__plugin__ = Teamliquid
diff --git a/src/streamlink/plugins/zattoo.py b/src/streamlink/plugins/zattoo.py
new file mode 100644
index 00000000..966500eb
--- /dev/null
+++ b/src/streamlink/plugins/zattoo.py
@@ -0,0 +1,238 @@
+import re
+import time
+import uuid
+
+from streamlink.cache import Cache
+from streamlink.plugin import Plugin
+from streamlink.plugin import PluginOptions
+from streamlink.plugin.api import http
+from streamlink.plugin.api import useragents
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+
+
+class Zattoo(Plugin):
+    API_HELLO = '{0}/zapi/session/hello'
+    API_LOGIN = '{0}/zapi/v2/account/login'
+    API_CHANNELS = '{0}/zapi/v2/cached/channels/{1}?details=False'
+    API_WATCH = '{0}/zapi/watch'
+    API_WATCH_VOD = '{0}/zapi/avod/videos/{1}/watch'
+
+    _url_re = re.compile(r'''
+        https?://
+        (?P<base_url>
+        zattoo\.com
+        |
+        tvonline\.ewe\.de
+        |
+        nettv\.netcologne\.de
+        )/(?:watch/(?P<channel>[^/\s]+)
+        |
+        ondemand/watch/(?P<vod_id>[^-]+)-)
+        ''', re.VERBOSE)
+
+    _app_token_re = re.compile(r"""window\.appToken\s+=\s+'([^']+)'""")
+
+    _channels_schema = validate.Schema({
+        'success': int,
+        'channel_groups': [{
+            'channels': [
+                {
+                    'display_alias': validate.text,
+                    'cid': validate.text
+                },
+            ]
+        }]},
+        validate.get('channel_groups'),
+    )
+
+    options = PluginOptions({
+        'email': None,
+        'password': None,
+        'purge_credentials': None
+    })
+
+    def __init__(self, url):
+        super(Zattoo, self).__init__(url)
+        self._session_attributes = Cache(filename='plugin-cache.json', key_prefix='zattoo:attributes')
+        self._authed = self._session_attributes.get('beaker.session.id') and self._session_attributes.get('pzuid') and self._session_attributes.get('power_guide_hash')
+        self._uuid = self._session_attributes.get('uuid')
+        self._expires = self._session_attributes.get('expires')
+
+        self.base_url = 'https://{0}'.format(Zattoo._url_re.match(url).group('base_url'))
+        self.headers = {
+            'User-Agent': useragents.CHROME,
+            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
+            'X-Requested-With': 'XMLHttpRequest',
+            'Referer': self.base_url
+        }
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return Zattoo._url_re.match(url)
+
+    def _hello(self):
+        self.logger.debug('_hello ...')
+        res = http.get(self.base_url)
+        match = self._app_token_re.search(res.text)
+
+        app_token = match.group(1)
+        hello_url = self.API_HELLO.format(self.base_url)
+
+        if self._uuid:
+            __uuid = self._uuid
+        else:
+            __uuid = str(uuid.uuid4())
+            self._session_attributes.set('uuid', __uuid, expires=3600 * 24)
+
+        params = {
+            'client_app_token': app_token,
+            'uuid': __uuid,
+            'lang': 'en',
+            'format': 'json'
+        }
+        res = http.post(hello_url, headers=self.headers, data=params)
+        return res
+
+    def _login(self, email, password, _hello):
+        self.logger.debug('_login ... Attempting login as {0}'.format(email))
+
+        login_url = self.API_LOGIN.format(self.base_url)
+
+        params = {
+            'login': email,
+            'password': password,
+            'remember': 'true'
+        }
+
+        res = http.post(login_url, headers=self.headers, data=params, cookies=_hello.cookies)
+        data = http.json(res)
+
+        self._authed = data['success']
+        if self._authed:
+            self.logger.debug('New Session Data')
+            self._session_attributes.set('beaker.session.id', res.cookies.get('beaker.session.id'), expires=3600 * 24)
+            self._session_attributes.set('pzuid', res.cookies.get('pzuid'), expires=3600 * 24)
+            self._session_attributes.set('power_guide_hash', data['session']['power_guide_hash'], expires=3600 * 24)
+            return self._authed
+        else:
+            return None
+
+    def _watch(self):
+        self.logger.debug('_watch ...')
+        match = self._url_re.match(self.url)
+        if not match:
+            return
+        channel = match.group('channel')
+        vod_id = match.group('vod_id')
+
+        cookies = {
+            'beaker.session.id': self._session_attributes.get('beaker.session.id'),
+            'pzuid': self._session_attributes.get('pzuid')
+        }
+
+        watch_url = []
+        if channel:
+            params, watch_url = self._watch_live(channel, cookies)
+        elif vod_id:
+            params, watch_url = self._watch_vod(vod_id)
+
+        if not watch_url:
+            return
+
+        res = []
+        try:
+            res = http.post(watch_url, headers=self.headers, data=params, cookies=cookies)
+        except Exception as e:
+            if '404 Client Error' in str(e):
+                self.logger.error('Unfortunately streaming is not permitted in this country or this channel does not exist.')
+            elif '402 Client Error: Payment Required' in str(e):
+                self.logger.error('Paid subscription required for this channel.')
+                self.logger.info('If paid subscription exist, use --zattoo-purge-credentials to start a new session.')
+            else:
+                self.logger.error(str(e))
+            return
+
+        data = http.json(res)
+
+        if data['success']:
+            for hls_url in data['stream']['watch_urls']:
+                for s in HLSStream.parse_variant_playlist(self.session, hls_url['url']).items():
+                    yield s
+
+    def _watch_live(self, channel, cookies):
+        self.logger.debug('_watch_live ... Channel: {0}'.format(channel))
+        watch_url = self.API_WATCH.format(self.base_url)
+
+        channels_url = self.API_CHANNELS.format(self.base_url, self._session_attributes.get('power_guide_hash'))
+        res = http.get(channels_url, headers=self.headers, cookies=cookies)
+        data = http.json(res, schema=self._channels_schema)
+
+        c_list = []
+        for d in data:
+            for c in d['channels']:
+                c_list.append(c)
+
+        cid = []
+        zattoo_list = []
+        for c in c_list:
+            zattoo_list.append(c['display_alias'])
+            if c['display_alias'] == channel:
+                cid = c['cid']
+
+        self.logger.debug('Available zattoo channels in this country: {0}'.format(', '.join(sorted(zattoo_list))))
+
+        if not cid:
+            cid = channel
+
+        self.logger.debug('CHANNEL ID: {0}'.format(cid))
+
+        params = {
+            'cid': cid,
+            'https_watch_urls': True,
+            'stream_type': 'hls'
+        }
+        return params, watch_url
+
+    def _watch_vod(self, vod_id):
+        self.logger.debug('_watch_vod ...')
+        watch_url = self.API_WATCH_VOD.format(self.base_url, vod_id)
+        params = {
+            'https_watch_urls': True,
+            'stream_type': 'hls'
+        }
+        return params, watch_url
+
+    def _get_streams(self):
+        email = self.get_option('email')
+        password = self.get_option('password')
+
+        if self.options.get('purge_credentials'):
+            self._session_attributes.set('beaker.session.id', None, expires=0)
+            self._session_attributes.set('expires', None, expires=0)
+            self._session_attributes.set('power_guide_hash', None, expires=0)
+            self._session_attributes.set('pzuid', None, expires=0)
+            self._session_attributes.set('uuid', None, expires=0)
+            self._authed = False
+            self.logger.info('All credentials were successfully removed.')
+
+        if not self._authed and (not email and not password):
+            self.logger.error('A login for Zattoo is required, use --zattoo-email EMAIL --zattoo-password PASSWORD to set them')
+            return
+
+        if self._authed:
+            if self._expires < time.time():
+                # login after 24h
+                expires = time.time() + 3600 * 24
+                self._session_attributes.set('expires', expires, expires=3600 * 24)
+                self._authed = False
+
+        if not self._authed:
+            __hello = self._hello()
+            if not self._login(email, password, __hello):
+                self.logger.error('Failed to login, check your username/password')
+                return
+
+        return self._watch()
+
+__plugin__ = Zattoo
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 2628b15b..1c96a4e9 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -1285,6 +1285,28 @@ plugin.add_argument(
     A ufc.tv account password to use with --ufctv-username.
     """
 )
+plugin.add_argument(
+    "--zattoo-email",
+    metavar="EMAIL",
+    help="""
+    The email associated with your zattoo account, required to access any zattoo stream.
+    """
+)
+plugin.add_argument(
+    "--zattoo-password",
+    metavar="PASSWORD",
+    help="""
+    A zattoo account password to use with --zattoo-email.
+    """
+)
+plugin.add_argument(
+    "--zattoo-purge-credentials",
+    action="store_true",
+    help="""
+    Purge cached zattoo credentials to initiate a new session
+    and reauthenticate.
+    """
+)
 
 # Deprecated options
 stream.add_argument(
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 24195591..113bb38b 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -941,6 +941,19 @@ def setup_plugin_options():
     if ufctv_password:
         streamlink.set_plugin_option("ufctv", "password", ufctv_password)
 
+    if args.zattoo_email:
+        streamlink.set_plugin_option("zattoo", "email", args.zattoo_email)
+    if args.zattoo_email and not args.zattoo_password:
+        zattoo_password = console.askpass("Enter zattoo password: ")
+    else:
+        zattoo_password = args.zattoo_password
+    if zattoo_password:
+        streamlink.set_plugin_option("zattoo", "password", zattoo_password)
+
+    if args.zattoo_purge_credentials:
+        streamlink.set_plugin_option("zattoo", "purge_credentials",
+                                     args.zattoo_purge_credentials)
+
     # Deprecated options
     if args.jtv_legacy_names:
         console.logger.warning("The option --jtv/twitch-legacy-names is "


Test patch:
diff --git a/tests/test_plugin_teamliquid.py b/tests/test_plugin_teamliquid.py
new file mode 100644
index 00000000..c2ea01bb
--- /dev/null
+++ b/tests/test_plugin_teamliquid.py
@@ -0,0 +1,19 @@
+import unittest
+
+from streamlink.plugins.teamliquid import Teamliquid
+
+
+class TestPluginTeamliquid(unittest.TestCase):
+    def test_can_handle_url(self):
+        # should match
+        self.assertTrue(Teamliquid.can_handle_url("http://www.teamliquid.net/video/streams/Classic%20BW%20VODs"))
+        self.assertTrue(Teamliquid.can_handle_url("http://teamliquid.net/video/streams/iwl-fuNny"))
+        self.assertTrue(Teamliquid.can_handle_url("http://www.teamliquid.net/video/streams/OGamingTV%20SC2"))
+        self.assertTrue(Teamliquid.can_handle_url("http://www.teamliquid.net/video/streams/Check"))
+
+        # shouldn't match
+        self.assertFalse(Teamliquid.can_handle_url("http://www.teamliquid.net/Classic%20BW%20VODs"))
+        self.assertFalse(Teamliquid.can_handle_url("http://www.teamliquid.net/video/Check"))
+        self.assertFalse(Teamliquid.can_handle_url("http://www.teamliquid.com/video/streams/Check"))
+        self.assertFalse(Teamliquid.can_handle_url("http://www.teamliquid.net/video/stream/Check"))
+        
diff --git a/tests/test_plugin_zattoo.py b/tests/test_plugin_zattoo.py
new file mode 100644
index 00000000..0ad0dfcd
--- /dev/null
+++ b/tests/test_plugin_zattoo.py
@@ -0,0 +1,24 @@
+import unittest
+
+from streamlink.plugins.zattoo import Zattoo
+
+
+class TestPluginZattoo(unittest.TestCase):
+    def test_can_handle_url(self):
+        # ewe live
+        self.assertTrue(Zattoo.can_handle_url('http://tvonline.ewe.de/watch/daserste'))
+        self.assertTrue(Zattoo.can_handle_url('http://tvonline.ewe.de/watch/zdf'))
+        # netcologne live
+        self.assertTrue(Zattoo.can_handle_url('https://nettv.netcologne.de/watch/daserste'))
+        self.assertTrue(Zattoo.can_handle_url('https://nettv.netcologne.de/watch/zdf'))
+        # zattoo live
+        self.assertTrue(Zattoo.can_handle_url('https://zattoo.com/watch/daserste'))
+        self.assertTrue(Zattoo.can_handle_url('https://zattoo.com/watch/zdf'))
+        # zattoo vod
+        self.assertTrue(Zattoo.can_handle_url('https://zattoo.com/ondemand/watch/ibR2fpisWFZGvmPBRaKnFnuT-alarm-am-airport'))
+        self.assertTrue(Zattoo.can_handle_url('https://zattoo.com/ondemand/watch/G8S7JxcewY2jEwAgMzvFWK8c-berliner-schnauzen'))
+
+        # shouldn't match
+        self.assertFalse(Zattoo.can_handle_url('https://ewe.de'))
+        self.assertFalse(Zattoo.can_handle_url('https://netcologne.de'))
+        self.assertFalse(Zattoo.can_handle_url('https://zattoo.com'))