Repository: streamlink/streamlink
Pull request #3108 (2020-08-13 07:37:18)

fix quantum tv streaming
quantum-tv had changed how their login worked:
- client token is hidden behind a obscure url
- they use zapi/v3
- session hello must only consist of token, uuid, app_version

codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/3108?src=pr&el=h1) Report
> Merging [#3108](https://codecov.io/gh/streamlink/streamlink/pull/3108?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/bf1460c8c0a75026a4824dfb5c32134e6daac293&el=desc) will **decrease** coverage by `0.02%`.
> The diff coverage is `15.78%`.


```diff
@@            Coverage Diff             @@
##           master    #3108      +/-   ##
==========================================
- Coverage   52.80%   52.78%   -0.03%     
==========================================
  Files         244      244              
  Lines       15559    15574      +15     
==========================================
+ Hits         8216     8220       +4     
- Misses       7343     7354      +11     
```


Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 3ff8460c..52dcaf6a 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -98,7 +98,8 @@ gulli                   replay.gulli.fr      Yes   Yes   Streams may be geo-rest
 hitbox                  - hitbox.tv          Yes   Yes
                         - smashcast.tv
 huajiao                 huajiao.com          Yes   No
-huomao                  huomao.com           Yes   No
+huomao                  - huomao.com         Yes   Yes
+                        - huomao.tv
 huya                    huya.com             Yes   No    Temporarily only HLS streams available.
 idf1                    idf1.fr              Yes   Yes
 ine                     ine.com              ---   Yes
@@ -119,6 +120,7 @@ mediaklikk              mediaklikk.hu        Yes   No    Streams may be geo-rest
 metube                  metube.id            Yes   Yes
 mitele                  mitele.es            Yes   No    Streams may be geo-restricted to Spain.
 mjunoon                 mjunoon.tv           Yes   Yes
+mrtmk                   play.mrt.com.mk      Yes   Yes   Streams may be geo-restricted to North Macedonia.
 n13tv                   13tv.co.il           Yes   Yes   Streams may be geo-restricted to Israel.
 nbc                     nbc.com              No    Yes   Streams are geo-restricted to USA. Authentication is not supported.
 nbcnews                 nbcnews.com          Yes   No
@@ -164,7 +166,8 @@ radionet                - radio.net          Yes   --
                         - radio.pt
                         - radio.se
 raiplay                 raiplay.it           Yes   No    Most streams are geo-restricted to Italy.
-reuters                 reuters.tv           Yes   Yes
+reuters                 - reuters.com        Yes   Yes
+                        - reuters.tv
 rotana                  rotana.net           Yes   --    Streams are geo-restricted to Saudi Arabia.
 rtbf                    - rtbf.be/auvio      Yes   Yes   Streams may be geo-restricted to Belgium or Europe.
                         - rtbfradioplayer.be
@@ -214,7 +217,7 @@ tigerdile               tigerdile.com        Yes   --
 tlctr                   tlctv.com.tr         Yes   No
 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.
-turkuvaz                - atv.com.tr         Yes   No
+turkuvaz                - atv.com.tr         Yes   No    Streams may be geo-restricted.
                         - a2tv.com.tr
                         - ahaber.com.tr
                         - anews.com.tr
diff --git a/src/streamlink/plugins/bloomberg.py b/src/streamlink/plugins/bloomberg.py
index aa9c55fb..14401a2f 100644
--- a/src/streamlink/plugins/bloomberg.py
+++ b/src/streamlink/plugins/bloomberg.py
@@ -37,7 +37,7 @@ class Bloomberg(Plugin):
     _live_stream_info_module_id_re = re.compile(
         r'{(?:(?:"[^"]+"|\w+):(?:\d+|"[^"]+"),)*"(?:[^"]*/)?config/livestreams":(\d+)(?:,(?:"[^"]+"|\w+):(?:\d+|"[^"]+"))*}'
     )
-    _live_stream_info_re = r'],{id}:\[function\(.+var n=({{.+}});r.default=n}},{{"[^"]+":\d+}}],{next}:\['
+    _live_stream_info_re = r'],{id}:\[function\(.+var n=({{.+}});r.default=n}},{{}}],{next}:\['
 
     _live_streams_schema = validate.Schema(
         validate.transform(_js_to_json_re),
diff --git a/src/streamlink/plugins/huomao.py b/src/streamlink/plugins/huomao.py
index e0d5770f..0ec58ef4 100644
--- a/src/streamlink/plugins/huomao.py
+++ b/src/streamlink/plugins/huomao.py
@@ -1,101 +1,198 @@
-"""
-NOTE: Since a documented API is nowhere to be found for Huomao; this plugin
-simply extracts the videos stream_id, stream_url and stream_quality by
-scraping the HTML and JS of one of Huomaos mobile webpages.
-
-When viewing a stream on huomao.com, the base URL references a room_id. This
-room_id is mapped one-to-one to a stream_id which references the actual .m3u8
-file. Both stream_id, stream_url and stream_quality can be found in the
-HTML and JS source of the mobile_page. Since one stream can occur in many
-different qualities, we scrape all stream_url and stream_quality occurrences
-and return each option to the user.
-"""
-
+import hashlib
+import logging
 import re
+import time
 
+from streamlink.compat import bytes
+from streamlink.exceptions import PluginError
 from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
 from streamlink.stream import HLSStream
+from streamlink.utils import parse_json
 
-# URL pattern for recognizing inputed Huomao.tv / Huomao.com URL.
-url_re = re.compile(r"""
-    (http(s)?://)?
-    (www\.)?
-    huomao
-    (\.tv|\.com)
-    /(?P<room_id>\d+)
-""", re.VERBOSE)
-
-# URL used to retrive the stream_id, stream_url and stream_quality based of
-# a room_id.
-mobile_url = "http://www.huomao.com/mobile/mob_live/{0}"
-
-# Pattern for extracting the stream_id from the mobile_url HTML.
-#
-# Example from HTML:
-#   <input id="html_stream" value="efmrCH" type="hidden">
-stream_id_pattern = re.compile(r'id=\"html_stream\" value=\"(?P<stream_id>\w+)\"')
-
-# Pattern for extracting each stream_url and
-# stream_quality_name used for quality naming.
-#
-# Example from HTML:
-#   src="http://live-ws-hls.huomaotv.cn/live/<stream_id>_720/playlist.m3u8"
-stream_info_pattern = re.compile(r"""
-    (?P<stream_url>(?:[\w\/\.\-:]+)
-    \/[^_\"]+(?:_(?P<stream_quality_name>\d+))
-    ?/playlist.m3u8)
-""", re.VERBOSE)
+log = logging.getLogger(__name__)
 
 
 class Huomao(Plugin):
+    magic_val = '6FE26D855E1AEAE090E243EB1AF73685'
+    mobile_url = 'https://m.huomao.com/mobile/mob_live/{0}'
+    live_data_url = 'https://m.huomao.com/swf/live_data'
+    vod_url = 'https://www.huomao.com/video/vreplay/{0}'
+
+    author = None
+    category = None
+    title = None
+
+    url_re = re.compile(r'''
+        (?:https?://)?(?:www\.)?huomao(?:\.tv|\.com)
+        (?P<path>/|/video/v/)
+        (?P<room_id>\d+)
+    ''', re.VERBOSE)
+
+    author_re = re.compile(
+        r'<p class="nickname_live">\s*<span>\s*(.*?)\s*</span>',
+        re.DOTALL,
+    )
+
+    title_re = re.compile(
+        r'<p class="title-name">\s*(.*?)\s*</p>',
+        re.DOTALL,
+    )
+
+    video_id_re = re.compile(r'var stream = "([^"]+)"')
+    video_res_re = re.compile(r'_([\d]+p?)\.m3u8')
+    vod_data_re = re.compile(r'var video = ({.*});')
+
+    _live_data_schema = validate.Schema({
+        'roomStatus': validate.transform(lambda x: int(x)),
+        'streamList': [{'list_hls': [{
+            'url': validate.url(),
+        }]}],
+    })
+
+    _vod_data_schema = validate.Schema({
+        'title': validate.text,
+        'username': validate.text,
+        'vaddress': validate.all(
+            validate.text,
+            validate.transform(parse_json),
+            [{
+                'url': validate.url(),
+                'vheight': int,
+            }],
+        ),
+    })
+
     @classmethod
-    def can_handle_url(self, url):
-        return url_re.match(url)
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_live_streams_data(self, video_id):
+        client_type = 'huomaomobileh5'
+        time_now = str(int(time.time()))
+
+        token_data = "{0}{1}{2}{3}".format(
+            video_id,
+            client_type,
+            time_now,
+            self.magic_val,
+        )
+
+        token = hashlib.md5(bytes(token_data, 'utf-8')).hexdigest()
+        log.debug("Token={0}".format(token))
+
+        post_data = {
+            'cdns': 1,
+            'streamtype': 'live',
+            'VideoIDS': video_id,
+            'from': client_type,
+            'time': time_now,
+            'token': token,
+        }
+        video_data = self.session.http.post(self.live_data_url, data=post_data)
+
+        return self.session.http.json(
+            video_data,
+            schema=self._live_data_schema,
+        )
+
+    def _get_vod_streams(self, vod_id):
+        res = self.session.http.get(self.vod_url.format(vod_id))
+        m = self.vod_data_re.search(res.text)
+        vod_json = m and m.group(1)
+
+        if vod_json is None:
+            raise PluginError("Failed to get VOD data")
+
+        vod_data = parse_json(vod_json, schema=self._vod_data_schema)
+
+        self.author = vod_data['username']
+        self.category = 'VOD'
+        self.title = vod_data['title']
+
+        vod_data = vod_data['vaddress']
 
-    def get_stream_id(self, html):
-        """Returns the stream_id contained in the HTML."""
-        stream_id = stream_id_pattern.search(html)
+        streams = {}
+        for stream in vod_data:
+            video_res = stream['vheight']
 
-        if not stream_id:
-            self.logger.error("Failed to extract stream_id.")
+            if 'p' not in str(video_res):
+                video_res = "{0}p".format(video_res)
 
-        return stream_id.group("stream_id")
+            if video_res in streams:
+                video_res = "{0}_alt".format(video_res)
 
-    def get_stream_info(self, html):
-        """
-        Returns a nested list of different stream options.
+            streams[video_res] = HLSStream(self.session, stream['url'])
 
-        Each entry in the list will contain a stream_url and stream_quality_name
-        for each stream occurrence that was found in the JS.
-        """
-        stream_info = stream_info_pattern.findall(html)
+        return streams
 
-        if not stream_info:
-            self.logger.error("Failed to extract stream_info.")
+    def _get_live_streams(self, room_id):
+        res = self.session.http.get(self.mobile_url.format(room_id))
 
-        # Rename the "" quality to "source" by transforming the tuples to a
-        # list and reassigning.
-        stream_info_list = []
-        for info in stream_info:
-            if not info[1]:
-                stream_info_list.append([info[0], "source"])
-            else:
-                stream_info_list.append(list(info))
+        m = self.author_re.search(res.text)
+        if m:
+            self.author = m.group(1)
 
-        return stream_info_list
+        self.category = 'Live'
 
-    def _get_streams(self):
-        room_id = url_re.search(self.url).group("room_id")
-        html = self.session.http.get(mobile_url.format(room_id))
-        stream_id = self.get_stream_id(html.text)
-        stream_info = self.get_stream_info(html.text)
+        m = self.title_re.search(res.text)
+        if m:
+            self.title = m.group(1)
+
+        m = self.video_id_re.search(res.text)
+        video_id = m and m.group(1)
+
+        if video_id is None:
+            raise PluginError("Failed to get video ID")
+        else:
+            log.debug("Video ID={0}".format(video_id))
+
+        streams_data = self._get_live_streams_data(video_id)
+
+        if streams_data['roomStatus'] == 0:
+            log.info("This room is currently inactive: {0}".format(room_id))
+            return
+
+        streams_data = streams_data['streamList'][0]['list_hls']
 
         streams = {}
-        for info in stream_info:
-            if stream_id in info[0]:
-                streams[info[1]] = HLSStream(self.session, info[0])
+        for stream in streams_data:
+            m = self.video_res_re.search(stream['url'])
+            video_res = m and m.group(1)
+            if video_res is None:
+                continue
+
+            if 'p' not in video_res:
+                video_res = "{0}p".format(video_res)
+
+            if video_res in streams:
+                video_res = "{0}_alt".format(video_res)
+
+            streams[video_res] = HLSStream(self.session, stream['url'])
 
         return streams
 
+    def get_author(self):
+        if self.author is not None:
+            return self.author
+
+    def get_category(self):
+        if self.category is not None:
+            return self.category
+
+    def get_title(self):
+        if self.title is not None:
+            return self.title
+
+    def _get_streams(self):
+        path, url_id = self.url_re.search(self.url).groups()
+        log.debug("Path={0}".format(path))
+        log.debug("URL ID={0}".format(url_id))
+
+        if path != '/':
+            return self._get_vod_streams(url_id)
+        else:
+            return self._get_live_streams(url_id)
+
 
 __plugin__ = Huomao
diff --git a/src/streamlink/plugins/mrtmk.py b/src/streamlink/plugins/mrtmk.py
new file mode 100644
index 00000000..1c323b3a
--- /dev/null
+++ b/src/streamlink/plugins/mrtmk.py
@@ -0,0 +1,48 @@
+import logging
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+
+log = logging.getLogger(__name__)
+
+
+class MRTmk(Plugin):
+    url_re = re.compile(r"""https?://play.mrt.com.mk/(live|play)/""")
+    file_re = re.compile(r"""(?P<url>https?://vod-[\d\w]+\.interspace\.com[^"',]+\.m3u8[^"',]*)""")
+
+    stream_schema = validate.Schema(
+        validate.all(
+            validate.transform(file_re.finditer),
+            validate.transform(list),
+            [validate.get("url")],
+            # remove duplicates
+            validate.transform(set),
+            validate.transform(list),
+        ),
+    )
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        res = self.session.http.get(self.url)
+        stream_urls = self.stream_schema.validate(res.text)
+        log.debug("Found streams: {0}".format(len(stream_urls)))
+        if not stream_urls:
+            return
+
+        for stream_url in stream_urls:
+            try:
+                for s in HLSStream.parse_variant_playlist(self.session, stream_url).items():
+                    yield s
+            except IOError as err:
+                if "403 Client Error" in str(err):
+                    log.error("Failed to access stream, may be due to geo-restriction")
+                else:
+                    raise err
+
+
+__plugin__ = MRTmk
diff --git a/src/streamlink/plugins/zattoo.py b/src/streamlink/plugins/zattoo.py
index 56404b2e..b845c305 100644
--- a/src/streamlink/plugins/zattoo.py
+++ b/src/streamlink/plugins/zattoo.py
@@ -19,7 +19,9 @@ log = logging.getLogger(__name__)
 class Zattoo(Plugin):
     API_CHANNELS = '{0}/zapi/v2/cached/channels/{1}?details=False'
     API_HELLO = '{0}/zapi/session/hello'
+    API_HELLO_V3 = '{0}/zapi/v3/session/hello'
     API_LOGIN = '{0}/zapi/v2/account/login'
+    API_LOGIN_V3 = '{0}/zapi/v3/account/login'
     API_SESSION = '{0}/zapi/v2/session'
     API_WATCH = '{0}/zapi/watch'
     API_WATCH_REC = '{0}/zapi/watch/recording/{1}'
@@ -157,13 +159,19 @@ class Zattoo(Plugin):
         self.session.http.cookies = cookiejar_from_dict({})
         if self.base_url == 'https://zattoo.com':
             app_token_url = 'https://zattoo.com/int/'
+        elif self.base_url == 'https://www.quantum-tv.com':
+            app_token_url = 'https://www.quantum-tv.com/token-4d0d61d4ce0bf8d9982171f349d19f34.json'
         else:
             app_token_url = self.base_url
         res = self.session.http.get(app_token_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.base_url == 'https://www.quantum-tv.com':
+            app_token = self.session.http.json(res)["session_token"]
+            hello_url = self.API_HELLO_V3.format(self.base_url)
+        else:
+            app_token = match.group(1)
+            hello_url = self.API_HELLO.format(self.base_url)
 
         if self._uuid:
             __uuid = self._uuid
@@ -175,21 +183,30 @@ class Zattoo(Plugin):
         params = {
             'client_app_token': app_token,
             'uuid': __uuid,
-            'lang': 'en',
-            'format': 'json'
         }
+
+        if self.base_url == 'https://www.quantum-tv.com':
+            params['app_version'] = '3.2028.3'
+        else:
+            params['lang'] = 'en'
+            params['format'] = 'json'
+
         res = self.session.http.post(hello_url, headers=self.headers, data=params)
 
     def _login(self, email, password):
         log.debug('_login ... Attempting login as {0}'.format(email))
 
-        login_url = self.API_LOGIN.format(self.base_url)
         params = {
             'login': email,
             'password': password,
             'remember': 'true'
         }
 
+        if self.base_url == 'https://quantum-tv.com':
+            login_url = self.API_LOGIN_V3.format(self.base_url)
+        else:
+            login_url = self.API_LOGIN.format(self.base_url)
+
         try:
             res = self.session.http.post(login_url, headers=self.headers, data=params)
         except Exception as e:


Test patch:
diff --git a/tests/plugins/test_huomao.py b/tests/plugins/test_huomao.py
index edc81ad6..86f4b016 100644
--- a/tests/plugins/test_huomao.py
+++ b/tests/plugins/test_huomao.py
@@ -4,85 +4,68 @@ from streamlink.plugins.huomao import Huomao
 
 
 class TestPluginHuomao(unittest.TestCase):
-
-    def setUp(self):
-
-        # Create a mock source HTML with some example data:
-        #   room_id             = 123456
-        #   stream_id           = 9qsvyF24659
-        #   stream_url          = http://live-ws.huomaotv.cn/live/
-        #   stream_quality_name = source, 720 and 480
-        self.mock_html = """
-            <input id="html_stream" value="9qsvyF24659" type="hidden">
-            <source  src="http://live-ws-hls.huomaotv.cn/live/9qsvyF24659/playlist.m3u8">
-            <source  src="http://live-ws-hls.huomaotv.cn/live/9qsvyF24659_720/playlist.m3u8">
-            <source  src="http://live-ws-hls.huomaotv.cn/live/9qsvyF24659_480/playlist.m3u8">
-        """
-
-        # Create a mock Huomao object.
-        self.mock_huomao = Huomao("http://www.huomao.com/123456/")
-
-    def tearDown(self):
-        self.mock_html = None
-        self.mock_huomao = None
-
-    def test_get_stream_id(self):
-
-        # Assert that the stream_id from is correctly extracted from the mock HTML.
-        self.assertEqual(self.mock_huomao.get_stream_id(self.mock_html), "9qsvyF24659")
-
-    def test_get_stream_quality(self):
-
-        # Assert that the stream_url, stream_quality and stream_quality_name
-        # is correctly extracted from the mock HTML.
-        self.assertEqual(self.mock_huomao.get_stream_info(self.mock_html), [
-            ["http://live-ws-hls.huomaotv.cn/live/9qsvyF24659/playlist.m3u8", "source"],
-            ["http://live-ws-hls.huomaotv.cn/live/9qsvyF24659_720/playlist.m3u8", "720"],
-            ["http://live-ws-hls.huomaotv.cn/live/9qsvyF24659_480/playlist.m3u8", "480"]
-        ])
-
     def test_can_handle_url(self):
-
-        # Assert that an URL containing the http:// prefix is correctly read.
-        self.assertTrue(Huomao.can_handle_url("http://www.huomao.com/123456"))
-        self.assertTrue(Huomao.can_handle_url("http://www.huomao.tv/123456"))
-        self.assertTrue(Huomao.can_handle_url("http://huomao.com/123456"))
-        self.assertTrue(Huomao.can_handle_url("http://huomao.tv/123456"))
-
-        # Assert that an URL containing the https:// prefix is correctly read.
-        self.assertTrue(Huomao.can_handle_url("https://www.huomao.com/123456"))
-        self.assertTrue(Huomao.can_handle_url("https://www.huomao.tv/123456"))
-        self.assertTrue(Huomao.can_handle_url("https://huomao.com/123456"))
-        self.assertTrue(Huomao.can_handle_url("https://huomao.tv/123456"))
-
-        # Assert that an URL without the http(s):// prefix is correctly read.
-        self.assertTrue(Huomao.can_handle_url("www.huomao.com/123456"))
-        self.assertTrue(Huomao.can_handle_url("www.huomao.tv/123456"))
-
-        # Assert that an URL without the www prefix is correctly read.
-        self.assertTrue(Huomao.can_handle_url("huomao.com/123456"))
-        self.assertTrue(Huomao.can_handle_url("huomao.tv/123456"))
-
-        # Assert that an URL without a room_id can't be read.
-        self.assertFalse(Huomao.can_handle_url("http://www.huomao.com/"))
-        self.assertFalse(Huomao.can_handle_url("http://www.huomao.tv/"))
-        self.assertFalse(Huomao.can_handle_url("http://huomao.com/"))
-        self.assertFalse(Huomao.can_handle_url("http://huomao.tv/"))
-        self.assertFalse(Huomao.can_handle_url("https://www.huomao.com/"))
-        self.assertFalse(Huomao.can_handle_url("https://www.huomao.tv/"))
-        self.assertFalse(Huomao.can_handle_url("https://huomao.com/"))
-        self.assertFalse(Huomao.can_handle_url("https://huomao.tv/"))
-        self.assertFalse(Huomao.can_handle_url("www.huomao.com/"))
-        self.assertFalse(Huomao.can_handle_url("www.huomao.tv/"))
-        self.assertFalse(Huomao.can_handle_url("huomao.tv/"))
-        self.assertFalse(Huomao.can_handle_url("huomao.tv/"))
-
-        # Assert that an URL without "huomao" can't be read.
-        self.assertFalse(Huomao.can_handle_url("http://www.youtube.com/123456"))
-        self.assertFalse(Huomao.can_handle_url("http://www.youtube.tv/123456"))
-        self.assertFalse(Huomao.can_handle_url("http://youtube.com/123456"))
-        self.assertFalse(Huomao.can_handle_url("http://youtube.tv/123456"))
-        self.assertFalse(Huomao.can_handle_url("https://www.youtube.com/123456"))
-        self.assertFalse(Huomao.can_handle_url("https://www.youtube.tv/123456"))
-        self.assertFalse(Huomao.can_handle_url("https://youtube.com/123456"))
-        self.assertFalse(Huomao.can_handle_url("https://youtube.tv/123456"))
+        should_match = [
+            # Assert that an URL containing the http:// prefix is correctly read.
+            "http://www.huomao.com/123456",
+            "http://www.huomao.tv/123456",
+            "http://huomao.com/123456",
+            "http://huomao.tv/123456",
+            "http://www.huomao.com/video/v/123456",
+            "http://www.huomao.tv/video/v/123456",
+            "http://huomao.com/video/v/123456",
+            "http://huomao.tv/video/v/123456",
+
+            # Assert that an URL containing the https:// prefix is correctly read.
+            "https://www.huomao.com/123456",
+            "https://www.huomao.tv/123456",
+            "https://huomao.com/123456",
+            "https://huomao.tv/123456",
+            "https://www.huomao.com/video/v/123456",
+            "https://www.huomao.tv/video/v/123456",
+            "https://huomao.com/video/v/123456",
+            "https://huomao.tv/video/v/123456",
+
+            # Assert that an URL without the http(s):// prefix is correctly read.
+            "www.huomao.com/123456",
+            "www.huomao.tv/123456",
+            "www.huomao.com/video/v/123456",
+            "www.huomao.tv/video/v/123456",
+
+            # Assert that an URL without the www prefix is correctly read.
+            "huomao.com/123456",
+            "huomao.tv/123456",
+            "huomao.com/video/v/123456",
+            "huomao.tv/video/v/123456",
+        ]
+        for url in should_match:
+            self.assertTrue(Huomao.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            # Assert that an URL without a room_id can't be read.
+            "http://www.huomao.com/",
+            "http://www.huomao.tv/",
+            "http://huomao.com/",
+            "http://huomao.tv/",
+            "https://www.huomao.com/",
+            "https://www.huomao.tv/",
+            "https://huomao.com/",
+            "https://huomao.tv/",
+            "www.huomao.com/",
+            "www.huomao.tv/",
+            "huomao.tv/",
+            "huomao.tv/",
+
+            # Assert that an URL without "huomao" can't be read.
+            "http://www.youtube.com/123456",
+            "http://www.youtube.tv/123456",
+            "http://youtube.com/123456",
+            "http://youtube.tv/123456",
+            "https://www.youtube.com/123456",
+            "https://www.youtube.tv/123456",
+            "https://youtube.com/123456",
+            "https://youtube.tv/123456",
+        ]
+        for url in should_not_match:
+            self.assertFalse(Huomao.can_handle_url(url))
diff --git a/tests/plugins/test_mrtmk.py b/tests/plugins/test_mrtmk.py
new file mode 100644
index 00000000..f45a2f36
--- /dev/null
+++ b/tests/plugins/test_mrtmk.py
@@ -0,0 +1,22 @@
+import unittest
+
+from streamlink.plugins.mrtmk import MRTmk
+
+
+class TestPluginMRTmk(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'http://play.mrt.com.mk/live/658323455489957',
+            'http://play.mrt.com.mk/live/47',
+            'http://play.mrt.com.mk/play/1581',
+        ]
+        for url in should_match:
+            self.assertTrue(MRTmk.can_handle_url(url), url)
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'http://play.mrt.com.mk/',
+            'http://play.mrt.com.mk/c/2',
+        ]
+        for url in should_not_match:
+            self.assertFalse(MRTmk.can_handle_url(url), url)