Repository: streamlink/streamlink

Issue:
[New Plugin] Add TV999
## Plugin Request
- [x] This is a plugin request and I have read the contribution guidelines and plugin request requirements.

### Description
Bulgarian TV channel that mainly features Russian content with Bulgarian subtitles.

### Example stream URLs
http://tv999.bg/live.html

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


```diff
@@            Coverage Diff             @@
##           master    #3199      +/-   ##
==========================================
+ Coverage   52.68%   52.71%   +0.02%     
==========================================
  Files         244      245       +1     
  Lines       15687    15706      +19     
==========================================
+ Hits         8265     8279      +14     
- Misses       7422     7427       +5     
```

gravyboat: @mkbloke Let's be careful about adding too many plugins. When people say content is "popular" I want to see actual stats showing the site is popular. As well as ensuring it's not just stolen content or VODs crushed together in to a live stream, since that doesn't really count.
mkbloke: @gravyboat, fair points, for sure.  I have done some digging following your comment and have found that it *seems* to be a live channel available via satellite and cable in Bulgaria, according to these pages (via translation in Chrome):

- http://tv999.bg/%D0%B7%D0%B0-%D0%BD%D0%B0%D1%81.html
- https://bg.wikipedia.org/wiki/999_%D0%A2%D0%92

As for popularity, that seems harder to ascertain.  :-/
gravyboat: @mkbloke Yeah when sites don't show viewers like many live streaming websites do it makes it hard to determine. The real problem I've been finding with people submitting these sites lately is what we consider to be "live" and people are trying to skirt the rules to get their crappy VOD site in because "but this is live TV in country/state XYZ!" which isn't live at all and I don't want to add more of these styles of plugins.
roshavagarga: @mkbloke @gravyboat The plugin works and I can confirm that it's a small TV station that is available nationally - I believe it was from the city of Yambol, might be a former regional TV that went from being public to being private. I'd say the amount of people using it wouldn't be high, but the amount of work it'll require will be minimal, as the website hasn't been changed in ages.

It's an entirely entertainment channel - a few documentaries here and there, but mostly movies and TV series, often foreign ones with Bulgarian subtitling.
mkbloke: @roshavagarga, I've just pushed some small cosmetic changes to the plugin, so if you could test again, it would be much appreciated.

By the way, I deleted the comment I made in your plugin request issue because I wanted to make another comment with the link to the newly pushed commit, rather than as a result of the conversation here.

Unfortunately, I can no longer seem to test directly myself.  I was using Tor configured with an exit node in Bulgaria, but perhaps I just got lucky yesterday as this no longer seems to be working for me now.  If the current commit works for you, I'll leave it as is and hopefully it can be merged, given the info you have provided above.

Cheers.

mkbloke: @gravyboat, I see that roshavagarga has another plugin request open (#2906) which looks fairly reasonable to me, but the above conversation makes me unsure about creating a plugin for it.

I also wonder about sites like Mico, which I created a plugin for in #3188.  There's another plugin request (much older) for what appears to be a similar chat site in issue #2331.  Obviously these are live streaming sites, but it could be a case that these will pop up more and more often, which I guess might result in reluctance to accept streamlink plugins for them.

Perhaps some of these old plugin request issues should be reviewed again and perhaps closed.  It might also help if additional labels were used on these plugin request issues, say ```pending review``` and ```approved``` to indicate which ones are actually going to be accepted and merged into master if they are created?
bastimeyer: > I see that roshavagarga has another plugin request open

Nothing unusual... :thinking:
https://github.com/streamlink/streamlink/issues?q=is%3Aissue+author%3Aroshavagarga+sort%3Aupdated-desc+label%3A%22plugin+request%22

There have been a ton of similar requests made by him over the past years and most of those small Bulgarian TV streams are covered by the `cdnbg` plugin. It's always the same or a similar structure:
https://github.com/streamlink/streamlink/blob/4fa902fc93470af30616e29e0fd49692a05ae84c/src/streamlink/plugins/cdnbg.py

Looks like this new plugin might be a duplicate and it could be added to the `cdnbg` plugin.
mkbloke: Maybe I should just close this PR and consider trying to add this site to the cdnbg plugin, which as it is now seems to require a fix and update as per #3201...
mkbloke: @bastimeyer, I've looked at the cdnbg plugin and that seems designed specifically for finding IFRAMEs that are sourced from cdn.bg.  While I could modify the IFRAME regex to include sources from elsewhere, would that really be the right thing to do?

Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 48371bf2..606522da 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -39,15 +39,14 @@ brightcove              players.brig... [6]_ Yes   Yes
 btsports                sport.bt.com         Yes   Yes   Requires subscription account
 btv                     btvplus.bg           Yes   No    Streams are geo-restricted to Bulgaria.
 canalplus               mycanal.fr           No    Yes   Streams may be geo-restricted to France.
-cdnbg                   - tv.bnt.bg          Yes   No    Streams may be geo-restricted to Bulgaria.
+cdnbg                   - armymedia.bg       Yes   No    Streams may be geo-restricted to Bulgaria
                         - bgonair.bg
                         - bloombergtv.bg
-                        - mmtvmusic.com
-                        - mu-vi.tv
-                        - nova.bg
-                        - armymedia.bg
-                        - chernomore.bg
+                        - bnt.bg
+                        - live.bstv.bg
                         - i.cdn.bg
+                        - nova.bg
+                        - mu-vi.tv
 ceskatelevize           ceskatelevize.cz     Yes   Yes   Streams may be geo-restricted to Czechia.
 cinergroup              - showtv.com.tr      Yes   No
                         - haberturk.com
@@ -118,6 +117,7 @@ lrt                     lrt.lt               Yes   No
 ltv_lsm_lv              ltv.lsm.lv           Yes   No    Streams may be geo-restricted to Latvia.
 mediaklikk              mediaklikk.hu        Yes   No    Streams may be geo-restricted to Hungary.
 metube                  metube.id            Yes   Yes
+mico                    micous.com           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.
@@ -233,6 +233,7 @@ tv5monde                - tv5monde.com       Yes   Yes   Streams may be geo-rest
                         - tv5mondepl... [8]_
 tv8                     tv8.com.tr           Yes   No
 tv360                   tv360.com.tr         Yes   No
+tv999                   tv999.bg             Yes   --    Streams are geo-restricted to Bulgaria
 tvibo                   player.tvibo.com     Yes   --
 tvp                     tvpstream.vod.tvp.pl Yes   No    Streams may be geo-restricted to Poland.
 tvplayer                tvplayer.com         Yes   No    Streams may be geo-restricted to Great Britain. Premium streams are not supported.
diff --git a/src/streamlink/plugins/cdnbg.py b/src/streamlink/plugins/cdnbg.py
index 145af3a7..49d8e2c7 100644
--- a/src/streamlink/plugins/cdnbg.py
+++ b/src/streamlink/plugins/cdnbg.py
@@ -14,16 +14,14 @@ log = logging.getLogger(__name__)
 class CDNBG(Plugin):
     url_re = re.compile(r"""
         https?://(?:www\.)?(?:
-            tv\.bnt\.bg/\w+(?:/\w+)?|
-            nova\.bg/live|
+            armymedia\.bg|
             bgonair\.bg/tvonline|
-            mmtvmusic\.com/live|
-            mu-vi\.tv/LiveStreams/pages/Live\.aspx|
+            bloombergtv\.bg/video|
+            (?:tv\.)?bnt\.bg/\w+(?:/\w+)?|
             live\.bstv\.bg|
-            bloombergtv.bg/video|
-            armymedia.bg|
-            chernomore.bg|
-            i.cdn.bg/live/
+            i\.cdn\.bg/live/|
+            nova\.bg/live|
+            mu-vi\.tv/LiveStreams/pages/Live\.aspx
         )/?
     """, re.VERBOSE)
     iframe_re = re.compile(r"iframe .*?src=\"((?:https?(?::|&#58;))?//(?:\w+\.)?cdn.bg/live[^\"]+)\"", re.DOTALL)
@@ -52,7 +50,7 @@ class CDNBG(Plugin):
                 log.debug("Found iframe: {0}", iframe_url)
                 iframe_url = iframe_url.replace("&#58;", ":")
                 if iframe_url.startswith("//"):
-                    return "{0}:{1}".format(p.scheme, iframe_url)
+                    return update_scheme(p.scheme, iframe_url)
                 else:
                     return iframe_url
 
diff --git a/src/streamlink/plugins/mico.py b/src/streamlink/plugins/mico.py
new file mode 100644
index 00000000..2f2b40bb
--- /dev/null
+++ b/src/streamlink/plugins/mico.py
@@ -0,0 +1,72 @@
+import logging
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+from streamlink.utils import parse_json
+from streamlink.utils.url import update_scheme
+
+log = logging.getLogger(__name__)
+
+
+class Mico(Plugin):
+    author = None
+    category = None
+    title = None
+
+    url_re = re.compile(r'https?://(?:www\.)?micous\.com/live/\d+')
+    json_data_re = re.compile(r'win._profile\s*=\s*({.*})')
+
+    _json_data_schema = validate.Schema(
+        validate.transform(json_data_re.search),
+        validate.any(None, validate.all(
+            validate.get(1),
+            validate.transform(parse_json),
+            validate.any(None, validate.all({
+                'mico_id': int,
+                'nickname': validate.text,
+                'h5_url': validate.all(
+                    validate.transform(lambda x: update_scheme('http:', x)),
+                    validate.url(),
+                ),
+                'is_live': bool,
+            })),
+        )),
+    )
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    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):
+        json_data = self.session.http.get(self.url, schema=self._json_data_schema)
+
+        if not json_data:
+            log.error('Failed to get JSON data')
+            return
+
+        if not json_data['is_live']:
+            log.info('This stream is no longer online')
+            return
+
+        self.author = json_data['mico_id']
+        self.category = 'Live'
+        self.title = json_data['nickname']
+
+        return HLSStream.parse_variant_playlist(self.session, json_data['h5_url'])
+
+
+__plugin__ = Mico
diff --git a/src/streamlink/plugins/tv999.py b/src/streamlink/plugins/tv999.py
new file mode 100644
index 00000000..a88ad131
--- /dev/null
+++ b/src/streamlink/plugins/tv999.py
@@ -0,0 +1,51 @@
+import logging
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+from streamlink.utils.url import update_scheme
+
+log = logging.getLogger(__name__)
+
+
+class TV999(Plugin):
+    url_re = re.compile(r'https?://(?:www\.)?tv999\.bg/live\.html')
+    iframe_re = re.compile(r'<iframe.*src="([^"]+)"')
+    hls_re = re.compile(r'src="([^"]+)"\s+type="application/x-mpegURL"')
+
+    iframe_schema = validate.Schema(
+        validate.transform(iframe_re.search),
+        validate.any(None, validate.all(
+            validate.get(1),
+            validate.url(),
+        )),
+    )
+
+    hls_schema = validate.Schema(
+        validate.transform(hls_re.search),
+        validate.any(None, validate.all(
+            validate.get(1),
+            validate.transform(lambda x: update_scheme('http:', x)),
+            validate.url(),
+        )),
+    )
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls.url_re.match(url) is not None
+
+    def _get_streams(self):
+        iframe_url = self.session.http.get(self.url, schema=self.iframe_schema)
+
+        if not iframe_url:
+            log.error('Failed to find IFRAME URL')
+            return
+
+        hls_url = self.session.http.get(iframe_url, schema=self.hls_schema)
+
+        if hls_url:
+            return {'live': HLSStream(self.session, hls_url)}
+
+
+__plugin__ = TV999
diff --git a/src/streamlink/plugins/zattoo.py b/src/streamlink/plugins/zattoo.py
index b845c305..ce79fe98 100644
--- a/src/streamlink/plugins/zattoo.py
+++ b/src/streamlink/plugins/zattoo.py
@@ -19,6 +19,7 @@ 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_V2 = '{0}/zapi/v2/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'
@@ -158,18 +159,21 @@ class Zattoo(Plugin):
         # a new session is required for the app_token
         self.session.http.cookies = cookiejar_from_dict({})
         if self.base_url == 'https://zattoo.com':
-            app_token_url = 'https://zattoo.com/int/'
+            app_token_url = 'https://zattoo.com/client/token-2fb69f883fea03d06c68c6e5f21ddaea.json'
         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)
 
+        res = self.session.http.get(app_token_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)
+        elif self.base_url == 'https://zattoo.com':
+            app_token = self.session.http.json(res)['app_tid']
+            hello_url = self.API_HELLO_V2.format(self.base_url)
         else:
+            match = self._app_token_re.search(res.text)
             app_token = match.group(1)
             hello_url = self.API_HELLO.format(self.base_url)
 
@@ -180,10 +184,17 @@ class Zattoo(Plugin):
             self._session_attributes.set(
                 'uuid', __uuid, expires=self.TIME_SESSION)
 
-        params = {
-            'client_app_token': app_token,
-            'uuid': __uuid,
-        }
+        if self.base_url == 'https://zattoo.com':
+            params = {
+                'uuid': __uuid,
+                'app_tid': app_token,
+                'app_version': '1.0.0'
+            }
+        else:
+            params = {
+                'client_app_token': app_token,
+                'uuid': __uuid,
+            }
 
         if self.base_url == 'https://www.quantum-tv.com':
             params['app_version'] = '3.2028.3'


Test patch:
diff --git a/tests/plugins/test_cdnbg.py b/tests/plugins/test_cdnbg.py
index af340c6d..e0010b47 100644
--- a/tests/plugins/test_cdnbg.py
+++ b/tests/plugins/test_cdnbg.py
@@ -1,40 +1,44 @@
-import pytest
+import unittest
 
 from streamlink.plugins.cdnbg import CDNBG
 
-valid_urls = [
-    ("http://bgonair.bg/tvonline",),
-    ("http://bgonair.bg/tvonline/",),
-    ("http://www.nova.bg/live",),
-    ("http://nova.bg/live",),
-    ("http://tv.bnt.bg/bnt1",),
-    ("http://tv.bnt.bg/bnt2",),
-    ("http://tv.bnt.bg/bnt3",),
-    ("http://tv.bnt.bg/bnt4",),
-    ("https://mmtvmusic.com/live/",),
-    ("http://mu-vi.tv/LiveStreams/pages/Live.aspx",),
-    ("http://live.bstv.bg/",),
-    ("https://www.bloombergtv.bg/video",),
-    ("https://i.cdn.bg/live/xfr3453g0d",)
 
-]
-invalid_urls = [
-    ("http://www.tvcatchup.com/",),
-    ("http://www.youtube.com/",),
-    ("https://www.tvevropa.com",),
-    ("http://www.kanal3.bg/live",),
-    ("http://inlife.bg/",),
-    ("http://videochanel.bstv.bg",),
-    ("http://video.bstv.bg/",),
-    ("http://bitelevision.com/live",)
-]
+class TestPluginCDNBG(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'http://bgonair.bg/tvonline',
+            'http://bgonair.bg/tvonline/',
+            'http://www.nova.bg/live',
+            'http://nova.bg/live',
+            'http://bnt.bg/live',
+            'http://bnt.bg/live/bnt1',
+            'http://bnt.bg/live/bnt2',
+            'http://bnt.bg/live/bnt3',
+            'http://bnt.bg/live/bnt4',
+            'http://tv.bnt.bg/bnt1',
+            'http://tv.bnt.bg/bnt2',
+            'http://tv.bnt.bg/bnt3',
+            'http://tv.bnt.bg/bnt4',
+            'http://mu-vi.tv/LiveStreams/pages/Live.aspx',
+            'http://live.bstv.bg/',
+            'https://www.bloombergtv.bg/video',
+            'https://i.cdn.bg/live/xfr3453g0d',
+        ]
+        for url in should_match:
+            self.assertTrue(CDNBG.can_handle_url(url))
 
-
-@pytest.mark.parametrize(["url"], valid_urls)
-def test_can_handle_url(url):
-    assert CDNBG.can_handle_url(url), "url should be handled"
-
-
-@pytest.mark.parametrize(["url"], invalid_urls)
-def test_can_handle_url_negative(url):
-    assert not CDNBG.can_handle_url(url), "url should not be handled"
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'http://www.tvcatchup.com/',
+            'http://www.youtube.com/',
+            'https://www.tvevropa.com',
+            'http://www.kanal3.bg/live',
+            'http://inlife.bg/',
+            'http://videochanel.bstv.bg',
+            'http://video.bstv.bg/',
+            'http://bitelevision.com/live',
+            'http://mmtvmusic.com/live/',
+            'http://chernomore.bg/',
+        ]
+        for url in should_not_match:
+            self.assertFalse(CDNBG.can_handle_url(url))
diff --git a/tests/plugins/test_mico.py b/tests/plugins/test_mico.py
new file mode 100644
index 00000000..cab44d96
--- /dev/null
+++ b/tests/plugins/test_mico.py
@@ -0,0 +1,23 @@
+import unittest
+
+from streamlink.plugins.mico import Mico
+
+
+class TestPluginMico(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'http://micous.com/live/73750760',
+            'http://www.micous.com/live/73750760',
+            'https://micous.com/live/73750760',
+            'https://www.micous.com/live/73750760',
+        ]
+        for url in should_match:
+            self.assertTrue(Mico.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'http://www.micous.com/73750760',
+            'https://example.com/index.html',
+        ]
+        for url in should_not_match:
+            self.assertFalse(Mico.can_handle_url(url))
diff --git a/tests/plugins/test_tv999.py b/tests/plugins/test_tv999.py
new file mode 100644
index 00000000..c904dbd2
--- /dev/null
+++ b/tests/plugins/test_tv999.py
@@ -0,0 +1,24 @@
+import unittest
+
+from streamlink.plugins.tv999 import TV999
+
+
+class TestPluginTV999(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'http://tv999.bg/live.html',
+            'http://www.tv999.bg/live.html',
+            'https://tv999.bg/live.html',
+            'https://www.tv999.bg/live.html',
+        ]
+        for url in should_match:
+            self.assertTrue(TV999.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'http://tv999.bg/',
+            'https://tv999.bg/live',
+            'https://example.com/index.html',
+        ]
+        for url in should_not_match:
+            self.assertFalse(TV999.can_handle_url(url))