Repository: streamlink/streamlink
Pull request #3750 (2021-05-25 07:32:43)

docs: set man_make_section_directory to false
And fix inconsistent manpage build paths between recent sphinx
releases. False is the currently expected value, as setup.py
will check for a built manpage in `docs/_build/man/streamlink.1`
for inclusion in the release tarballs.

Even though the doc's HTML theme "furo" does currently limit the
used sphinx version to 3.x, this won't stop users building the
manpage using sphinx 4.x, eg. via a global system-package.

----

What a mess...
https://www.sphinx-doc.org/en/master/changes.html

Sphinx 4:
https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-man_make_section_directory

> **man_make_section_directory**
> If true, make a section directory on build man page. Default is `True`.
>
> *New in version 3.3.*
> *Changed in version 4.0:* The default is changed to False from `True`.
> *Changed in version 4.0.2:* The default is changed to True from `False` again.

Sphinx 3:
https://www.sphinx-doc.org/en/3.x/usage/configuration.html#confval-man_make_section_directory

> **man_make_section_directory**
> If true, make a section directory on build man page. Default is `False`.
>
> *New in version 3.3.*


Patch:
diff --git a/docs/conf.py b/docs/conf.py
index 9ad9b0f1..bacd67b1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -187,3 +187,7 @@ man_pages = [
 
 # If true, show URL addresses after external links.
 #man_show_urls = False
+
+# If true, make a section directory on build man page.
+# Always set this to false to fix inconsistencies between recent sphinx releases
+man_make_section_directory = False
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


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")