Repository: streamlink/streamlink
Pull request #3743 (2021-05-22 17:35:03)

plugins.onetv: split plugin
**plugins.onetv**
- uses **API v1** which is only 1tv.ru
- remove broken VOD support

**plugins.mediavitrina**
- uses **API v2**

closes https://github.com/streamlink/streamlink/issues/3587
closes https://github.com/streamlink/streamlink/pull/3669


Patch:
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index df8aa1dd..5355440e 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -104,6 +104,7 @@ livestream              livestream.com       Yes   --
 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.
+mediavitrina            mediavitrina.ru      Yes   No    Streams may be geo-restricted to Russia.
 mitele                  mitele.es            Yes   No    Streams may be geo-restricted to Spain.
 mjunoon                 mjunoon.tv           Yes   Yes   Streams may be geo-restricted to Pakistan.
 mrtmk                   play.mrt.com.mk      Yes   Yes   Streams may be geo-restricted to North Macedonia.
@@ -122,11 +123,7 @@ ntv                     ntv.ru               Yes   No
 okru                    ok.ru                Yes   Yes
 olympicchannel          olympicchannel.com   Yes   Yes   Only non-premium content is available.
 oneplusone              1plus1.video         Yes   No
-onetv                   - 1tv.ru             Yes   Yes   Streams may be geo-restricted to Russia. VOD only for 1tv.ru
-                        - ctc.ru
-                        - chetv.ru
-                        - ctclove.ru
-                        - domashny.ru
+onetv                   1tv.ru               Yes   No    Streams may be geo-restricted to Russia.
 openrectv               openrec.tv           Yes   Yes
 orf_tvthek              tvthek.orf.at        Yes   Yes
 periscope               periscope.tv         Yes   Yes   Replay/VOD is supported.
diff --git a/src/streamlink/plugins/mediavitrina.py b/src/streamlink/plugins/mediavitrina.py
new file mode 100644
index 00000000..0f3023bd
--- /dev/null
+++ b/src/streamlink/plugins/mediavitrina.py
@@ -0,0 +1,85 @@
+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_qsd
+
+log = logging.getLogger(__name__)
+
+
+class MediaVitrina(Plugin):
+    _re_url_1 = re.compile(r'https?://(?P<channel>ctc(?:love)?|chetv|domashniy|5-tv)\.ru/(?:online|live)')
+    _re_url_2 = re.compile(r'https?://(?P<channel>ren)\.tv/live')
+    _re_url_3 = re.compile(r'https?://player\.mediavitrina\.ru/(?P<channel>[^/?]+.)(?:/[^/]+)?/[\w_]+/player\.html')
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return (
+            cls._re_url_1.match(url) is not None
+            or cls._re_url_2.match(url) is not None
+            or cls._re_url_3.match(url) is not None
+        )
+
+    def _get_streams(self):
+        channel = (self._re_url_1.match(self.url)
+                   or self._re_url_2.match(self.url)
+                   or self._re_url_3.match(self.url)
+                   ).group("channel")
+
+        channels = [
+            # ((channels), (path, channel))
+            (("5-tv", "tv-5", "5tv"), ("tv5", "tv-5")),
+            (("chetv", "ctc-che", "che_ext"), ("ctc", "ctc-che")),
+            (("ctc"), ("ctc", "ctc")),
+            (("ctclove", "ctc-love", "ctc_love_ext"), ("ctc", "ctc-love")),
+            (("domashniy", "ctc-dom", "domashniy_ext"), ("ctc", "ctc-dom")),
+            (("iz"), ("iz", "iz")),
+            (("mir"), ("mtrkmir", "mir")),
+            (("muztv"), ("muztv", "muztv")),
+            (("ren", "ren-tv", "rentv"), ("nmg", "ren-tv")),
+            (("russia1"), ("vgtrk", "russia1")),
+            (("russia24"), ("vgtrk", "russia24")),
+            (("russiak", "kultura"), ("vgtrk", "russiak")),
+            (("spas"), ("spas", "spas")),
+            (("tvc"), ("tvc", "tvc")),
+            (("tvzvezda", "zvezda"), ("zvezda", "zvezda")),
+            (("u", "u_ott"), ("utv", "u_ott")),
+        ]
+        for c in channels:
+            if channel in c[0]:
+                path, channel = c[1]
+                break
+        else:
+            log.error(f"Unsupported channel: {channel}")
+            return
+
+        res_token = self.session.http.get(
+            "https://media.mediavitrina.ru/get_token",
+            schema=validate.Schema(
+                validate.transform(parse_json),
+                {"result": {"token": str}},
+                validate.get("result"),
+            ))
+        url = self.session.http.get(
+            update_qsd(f"https://media.mediavitrina.ru/api/v2/{path}/playlist/{channel}_as_array.json", qsd=res_token),
+            schema=validate.Schema(
+                validate.transform(parse_json),
+                {"hls": [validate.url()]},
+                validate.get("hls"),
+                validate.get(0),
+            ))
+
+        if not url:
+            return
+
+        if "georestrictions" in url:
+            log.error("Stream is geo-restricted")
+            return
+
+        yield from HLSStream.parse_variant_playlist(self.session, url, name_fmt="{pixels}_{bitrate}").items()
+
+
+__plugin__ = MediaVitrina
diff --git a/src/streamlink/plugins/onetv.py b/src/streamlink/plugins/onetv.py
index 767f9999..75c34e3c 100644
--- a/src/streamlink/plugins/onetv.py
+++ b/src/streamlink/plugins/onetv.py
@@ -1,27 +1,20 @@
 import logging
 import random
 import re
-from urllib.parse import unquote, urlencode, urljoin
+from urllib.parse import unquote
 
 from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
 from streamlink.plugin.plugin import stream_weight
-from streamlink.stream import DASHStream, HLSStream, HTTPStream
-from streamlink.utils import update_scheme
+from streamlink.stream import HLSStream
+from streamlink.utils import parse_json
+from streamlink.utils.url import update_qsd
 
 log = logging.getLogger(__name__)
 
 
 class OneTV(Plugin):
-    _url_re = re.compile(r"https?://(?:www\.)?(?P<channel>1tv|ctc|chetv|ctclove|domashny).(?:com|ru)/(?P<live>live|online)?")
-    _vod_re = re.compile(r"""/video_materials\.json[^'"]*""")
-    _vod_id_re = re.compile(r'''data-video-material-id="(\d+)"''')
-
-    _1tv_api = "//stream.1tv.ru/api/playlist/1tvch_as_array.json"
-    _ctc_api = "//media.1tv.ru/api/v1/ctc/playlist/{channel}_as_array.json"
-    _session_api = "//stream.1tv.ru/get_hls_session"
-    _channel_remap = {"chetv": "ctc-che",
-                      "ctclove": "ctc-love",
-                      "domashny": "ctc-dom"}
+    _url_re = re.compile(r"https?://(?:www\.)?1tv\.ru/live")
 
     @classmethod
     def can_handle_url(cls, url):
@@ -31,72 +24,33 @@ class OneTV(Plugin):
     def stream_weight(cls, stream):
         return dict(ld=(140, "pixels"), sd=(360, "pixels"), hd=(720, "pixels")).get(stream, stream_weight(stream))
 
-    @property
-    def channel(self):
-        match = self._url_re.match(self.url)
-        c = match and match.group("channel")
-        return self._channel_remap.get(c, c)
-
-    @property
-    def live_api_url(self):
-        channel = self.channel
-        if channel == "1tv":
-            url = self._1tv_api
-        else:
-            url = self._ctc_api.format(channel=channel)
-
-        return update_scheme(self.url, url)
-
-    def hls_session(self):
-        res = self.session.http.get(update_scheme(self.url, self._session_api))
-        data = self.session.http.json(res)
-        # the values are already quoted, we don't want them quoted
-        return {k: unquote(v) for k, v in data.items()}
-
-    @property
-    def is_live(self):
-        m = self._url_re.match(self.url)
-        return m and m.group("live") is not None
-
-    def vod_data(self, vid=None):
-        """
-        Get the VOD data path and the default VOD ID
-        :return:
-        """
-        page = self.session.http.get(self.url)
-        m = self._vod_re.search(page.text)
-        vod_data_url = m and urljoin(self.url, m.group(0))
-        if vod_data_url:
-            log.debug("Found VOD data url: {0}".format(vod_data_url))
-            res = self.session.http.get(vod_data_url)
-            return self.session.http.json(res)
-
     def _get_streams(self):
-        if self.is_live:
-            log.debug("Loading live stream for {0}...".format(self.channel))
-
-            res = self.session.http.get(self.live_api_url, data={"r": random.randint(1, 100000)})
-            live_data = self.session.http.json(res)
-
-            # all the streams are equal for each type, so pick a random one
-            hls_streams = live_data.get("hls")
-            if hls_streams:
-                url = random.choice(hls_streams)
-                url = url + '&' + urlencode(self.hls_session())  # TODO: use update_qsd
-                yield from HLSStream.parse_variant_playlist(self.session, url, name_fmt="{pixels}_{bitrate}").items()
-
-            mpd_streams = live_data.get("mpd")
-            if mpd_streams:
-                url = random.choice(mpd_streams)
-                yield from DASHStream.parse_manifest(self.session, url).items()
-
-        elif self.channel == "1tv":
-            log.debug("Attempting to find VOD stream for {0}...".format(self.channel))
-            vod_data = self.vod_data()
-            if vod_data:
-                log.info(f"Found VOD: {vod_data[0]['title']}")
-                for stream in vod_data[0]['mbr']:
-                    yield stream['name'], HTTPStream(self.session, update_scheme(self.url, stream['src']))
+        url = self.session.http.get(
+            "https://stream.1tv.ru/api/playlist/1tvch_as_array.json",
+            data={"r": random.randint(1, 100000)},
+            schema=validate.Schema(
+                validate.transform(parse_json),
+                {"hls": [validate.url()]},
+                validate.get("hls"),
+                validate.get(0),
+            ))
+
+        if not url:
+            return
+
+        if "georestrictions" in url:
+            log.error("Stream is geo-restricted")
+            return
+
+        hls_session = self.session.http.get(
+            "https://stream.1tv.ru/get_hls_session",
+            schema=validate.Schema(
+                validate.transform(parse_json),
+                {"s": validate.transform(unquote)},
+            ))
+        url = update_qsd(url, qsd=hls_session, safe="/:")
+
+        yield from HLSStream.parse_variant_playlist(self.session, url, name_fmt="{pixels}_{bitrate}").items()
 
 
 __plugin__ = OneTV
diff --git a/src/streamlink/plugins/tf1.py b/src/streamlink/plugins/tf1.py
index e174694d..1e600996 100644
--- a/src/streamlink/plugins/tf1.py
+++ b/src/streamlink/plugins/tf1.py
@@ -11,7 +11,7 @@ log = logging.getLogger(__name__)
 
 class TF1(Plugin):
     url_re = re.compile(r"https?://(?:www\.)?(?:tf1\.fr/([\w-]+)/direct|(lci).fr/direct)/?")
-    api_url = "https://player.tf1.fr/mediainfocombo/{}?context=MYTF1&pver=4001000"
+    api_url = "https://mediainfo.tf1.fr/mediainfocombo/{}?context=MYTF1&pver=4001000"
 
     def api_call(self, channel, useragent=useragents.CHROME):
         url = self.api_url.format("L_" + channel.upper())
@@ -49,7 +49,8 @@ class TF1(Plugin):
                     if sformat == "hls":
                         yield from HLSStream.parse_variant_playlist(
                             self.session,
-                            url
+                            url,
+                            headers={"User-Agent": useragents.IPHONE},
                         ).items()
                 except PluginError as e:
                     log.error("Could not open {0} stream".format(sformat))


Test patch:
diff --git a/tests/plugins/test_mediavitrina.py b/tests/plugins/test_mediavitrina.py
new file mode 100644
index 00000000..41084b08
--- /dev/null
+++ b/tests/plugins/test_mediavitrina.py
@@ -0,0 +1,37 @@
+from streamlink.plugins.mediavitrina import MediaVitrina
+from tests.plugins import PluginCanHandleUrl
+
+
+class TestPluginCanHandleUrlMediaVitrina(PluginCanHandleUrl):
+    __plugin__ = MediaVitrina
+
+    should_match = [
+        "https://chetv.ru/online/",
+        "https://ctc.ru/online/",
+        "https://ctclove.ru/online/",
+        "https://player.mediavitrina.ru/5tv/moretv_web/player.html",
+        "https://player.mediavitrina.ru/che/che_web/player.html",
+        "https://player.mediavitrina.ru/ctc_ext/moretv_web/player.html",
+        "https://player.mediavitrina.ru/ctc_love_ext/moretv_web/player.html",
+        "https://player.mediavitrina.ru/ctc_love/ctclove_web/player.html",
+        "https://player.mediavitrina.ru/ctc/ctcmedia_web/player.html?start=auto",
+        "https://player.mediavitrina.ru/domashniy_ext/moretv_web/player.html",
+        "https://player.mediavitrina.ru/domashniy/dom_web/player.html?start=auto",
+        "https://player.mediavitrina.ru/iz/moretv_web/player.html",
+        "https://player.mediavitrina.ru/kultura/limehd_web/player.html",
+        "https://player.mediavitrina.ru/kultura/moretv_web/player.html",
+        "https://player.mediavitrina.ru/mir/mir/moretv_web/player.html",
+        "https://player.mediavitrina.ru/muztv/moretv_web/player.html",
+        "https://player.mediavitrina.ru/rentv/moretv_web/player.html",
+        "https://player.mediavitrina.ru/russia1/moretv_web/player.html",
+        "https://player.mediavitrina.ru/russia24/moretv_web/player.html",
+        "https://player.mediavitrina.ru/russia24/vesti_ru_web/player.html?id",
+        "https://player.mediavitrina.ru/spas/moretv_web/player.html",
+        "https://player.mediavitrina.ru/tvc/tvc/moretv_web/player.html",
+        "https://player.mediavitrina.ru/tvzvezda/moretv_web/player.html",
+        "https://player.mediavitrina.ru/u_ott/u/moretv_web/player.html",
+    ]
+
+    should_not_match = [
+        "https://1tv.ru/live",
+    ]
diff --git a/tests/plugins/test_onetv.py b/tests/plugins/test_onetv.py
index 6c5aed73..c4a64bf9 100644
--- a/tests/plugins/test_onetv.py
+++ b/tests/plugins/test_onetv.py
@@ -1,5 +1,3 @@
-import unittest
-
 from streamlink.plugins.onetv import OneTV
 from tests.plugins import PluginCanHandleUrl
 
@@ -10,6 +8,9 @@ class TestPluginCanHandleUrlOneTV(PluginCanHandleUrl):
     should_match = [
         "https://www.1tv.ru/live",
         "http://www.1tv.ru/live",
+    ]
+
+    should_not_match = [
         "http://www.1tv.ru/some-show/some-programme-2018-03-10",
         "https://www.ctc.ru/online",
         "http://www.ctc.ru/online",
@@ -20,23 +21,3 @@ class TestPluginCanHandleUrlOneTV(PluginCanHandleUrl):
         "https://www.domashny.ru/online"
         "http://www.domashny.ru/online"
     ]
-
-
-class TestPluginOneTV(unittest.TestCase):
-    def test_channel(self):
-        self.assertEqual(OneTV("http://1tv.ru/live").channel,
-                         "1tv")
-        self.assertEqual(OneTV("http://www.ctclove.ru/online").channel,
-                         "ctc-love")
-        self.assertEqual(OneTV("http://domashny.ru/online").channel,
-                         "ctc-dom")
-
-    def test_live_api_url(self):
-        self.assertEqual(OneTV("http://1tv.ru/live").live_api_url,
-                         "http://stream.1tv.ru/api/playlist/1tvch_as_array.json")
-
-        self.assertEqual(OneTV("https://www.ctclove.ru/online").live_api_url,
-                         "https://media.1tv.ru/api/v1/ctc/playlist/ctc-love_as_array.json")
-
-        self.assertEqual(OneTV("http://domashny.ru/online").live_api_url,
-                         "http://media.1tv.ru/api/v1/ctc/playlist/ctc-dom_as_array.json")