Repository: streamlink/streamlink
Pull request #3544 (2021-02-07 01:53:02)

plugins.tvtoya: minor fixes
- update url_re for www. and literal dot escaping
- remove unnecessary update_scheme()



Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 892f5ef8..d346147f 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -29,6 +29,8 @@ jobs:
     timeout-minutes: 60
     steps:
       - uses: actions/checkout@v2
+        with:
+          fetch-depth: 300
       - uses: actions/setup-python@v2
         with:
           python-version: ${{ matrix.python }}
diff --git a/docs/install.rst b/docs/install.rst
index 7130ab05..4fc9961d 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -376,19 +376,11 @@ Windows portable version
 ==================================== ===========================================
 Maintainer                           Links
 ==================================== ===========================================
-RosadinTV                            `Latest precompiled stable release`__ |br|
-                                     `Latest builder`__ |br|
-                                     `More info`__
-
 Beardypig                            `Latest precompiled stable release`__ |br|
                                      `Latest builder`__ |br|
                                      `More info`__
 ==================================== ===========================================
 
-__ https://github.com/streamlink/streamlink-portable/releases/latest
-__ https://github.com/streamlink/streamlink-portable/archive/master.zip
-__ https://github.com/streamlink/streamlink-portable
-
 __ https://github.com/beardypig/streamlink-portable/releases/latest
 __ https://github.com/beardypig/streamlink-portable/archive/master.zip
 __ https://github.com/beardypig/streamlink-portable
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index dab3cbb9..84960282 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -116,6 +116,7 @@ nbcnews                 nbcnews.com          Yes   No
 nbcsports               nbcsports.com        No    Yes   Streams maybe be geo-restricted to USA. Authentication is not supported.
 nhkworld                nhk.or.jp/nhkworld   Yes   No
 nicolive                live.nicovideo.jp    Yes   Yes   Timeshift is supported. Some content may require login.
+nimotv                  nimo.tv              Yes   No
 nos                     nos.nl               Yes   Yes   Streams may be geo-restricted to Netherlands.
 nownews                 news.now.com         Yes   No
 nrk                     - tv.nrk.no          Yes   Yes   Streams may be geo-restricted to Norway.
diff --git a/src/streamlink/plugins/afreeca.py b/src/streamlink/plugins/afreeca.py
index 185bff64..4834cf07 100644
--- a/src/streamlink/plugins/afreeca.py
+++ b/src/streamlink/plugins/afreeca.py
@@ -106,8 +106,11 @@ class AfreecaTV(Plugin):
         data = {
             "bid": username,
             "bno": broadcast,
+            "from_api": "0",
             "mode": "landing",
             "player_type": "html5",
+            "pwd": "",
+            "stream_type": "common",
             "type": "live",
         }
         res = self.session.http.post(self.CHANNEL_API_URL, data=data)
@@ -117,29 +120,33 @@ class AfreecaTV(Plugin):
         data = {
             "bid": username,
             "bno": broadcast,
+            "from_api": "0",
+            "mode": "landing",
+            "player_type": "html5",
             "pwd": "",
             "quality": quality,
-            "type": "pwd"
+            "stream_type": "common",
+            "type": "aid",
         }
         res = self.session.http.post(self.CHANNEL_API_URL, data=data)
         return self.session.http.json(res, schema=self._schema_channel)
 
-    def _get_stream_info(self, broadcast, quality, cdn, rmd):
+    def _get_stream_info(self, broadcast, quality, rmd):
         params = {
-            "return_type": cdn,
-            "broad_key": f"{broadcast}-flash-{quality}-hls",
+            "return_type": "gs_cdn_pc_web",
+            "broad_key": f"{broadcast}-common-{quality}-hls",
         }
         res = self.session.http.get(f"{rmd}/broad_stream_assign.html", params=params)
         return self.session.http.json(res, schema=self._schema_stream)
 
-    def _get_hls_stream(self, broadcast, username, quality, cdn, rmd):
+    def _get_hls_stream(self, broadcast, username, quality, rmd):
         keyjson = self._get_hls_key(broadcast, username, quality)
 
         if keyjson["RESULT"] != self.CHANNEL_RESULT_OK:
             return
         key = keyjson["AID"]
 
-        info = self._get_stream_info(broadcast, quality, cdn, rmd)
+        info = self._get_stream_info(broadcast, quality, rmd)
 
         if "view_url" in info:
             return AfreecaHLSStream(self.session, info["view_url"], params={"aid": key})
@@ -168,7 +175,7 @@ class AfreecaTV(Plugin):
         login_username = self.get_option("username")
         login_password = self.get_option("password")
 
-        self.session.http.headers.update({"Referer": self.url})
+        self.session.http.headers.update({"Referer": self.url, "Origin": "http://play.afreecatv.com"})
 
         if self.options.get("purge_credentials"):
             self.clear_cookies()
@@ -206,12 +213,12 @@ class AfreecaTV(Plugin):
         elif channel.get("RESULT") != self.CHANNEL_RESULT_OK:
             return
 
-        (broadcast, rmd, cdn) = (channel["BNO"], channel["RMD"], channel["CDN"])
-        if not (broadcast and rmd and cdn):
+        (broadcast, rmd) = (channel["BNO"], channel["RMD"])
+        if not (broadcast and rmd):
             return
 
         for qkey in self.QUALITYS:
-            hls_stream = self._get_hls_stream(broadcast, username, qkey, cdn, rmd)
+            hls_stream = self._get_hls_stream(broadcast, username, qkey, rmd)
             if hls_stream:
                 yield qkey, hls_stream
 
diff --git a/src/streamlink/plugins/nimotv.py b/src/streamlink/plugins/nimotv.py
new file mode 100644
index 00000000..27f2562a
--- /dev/null
+++ b/src/streamlink/plugins/nimotv.py
@@ -0,0 +1,97 @@
+import logging
+import re
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import useragents, validate
+from streamlink.stream import HLSStream
+from streamlink.utils import parse_json
+
+log = logging.getLogger(__name__)
+
+
+class NimoTV(Plugin):
+    url_re = re.compile(r'https?://(?:www\.)?nimo\.tv/(?P<username>.*)')
+    data_url = 'https://m.nimo.tv/{0}'
+    data_re = re.compile(r'<script>var G_roomBaseInfo = ({.*?});</script>')
+
+    author = None
+    category = None
+    title = None
+
+    data_schema = validate.Schema(
+        validate.transform(data_re.search),
+        validate.any(None, validate.all(
+            validate.get(1),
+            validate.transform(parse_json), {
+                'title': str,
+                'nickname': str,
+                'game': str,
+                'roomLineInfo': validate.any(None, {
+                    'vCodeLines2': [{
+                        'iBitRate': int,
+                        'vCdns': [{
+                            'vCdnUrls': [{
+                                'smediaUrl': validate.url(),
+                            }],
+                        }],
+                    }],
+                }),
+            },
+        )),
+    )
+
+    video_qualities = {
+        250: '240p',
+        500: '360p',
+        1000: '480p',
+        2000: '720p',
+        6000: '1080p',
+    }
+
+    @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):
+        m = self.url_re.match(self.url)
+        if m and m.group('username'):
+            username = m.group('username')
+        else:
+            return
+
+        headers = {'User-Agent': useragents.ANDROID}
+        data = self.session.http.get(
+            self.data_url.format(username),
+            headers=headers,
+            schema=self.data_schema,
+        )
+
+        if data is None or data['roomLineInfo'] is None:
+            return
+
+        self.author = data['nickname']
+        self.category = data['game']
+        self.title = data['title']
+
+        for vcl2 in data['roomLineInfo']['vCodeLines2']:
+            q = self.video_qualities[vcl2['iBitRate']]
+            for vcdn in vcl2['vCdns']:
+                for vcdnurl in vcdn['vCdnUrls']:
+                    if 'tx.hls.nimo.tv' in vcdnurl['smediaUrl']:
+                        log.debug(f"HLS URL={vcdnurl['smediaUrl']} ({q})")
+                        yield q, HLSStream(self.session, vcdnurl['smediaUrl'])
+
+
+__plugin__ = NimoTV
diff --git a/src/streamlink/plugins/tvtoya.py b/src/streamlink/plugins/tvtoya.py
index 3080be0a..a49a0916 100644
--- a/src/streamlink/plugins/tvtoya.py
+++ b/src/streamlink/plugins/tvtoya.py
@@ -3,13 +3,12 @@ import re
 
 from streamlink.plugin import Plugin
 from streamlink.stream import HLSStream
-from streamlink.utils import update_scheme
 
 log = logging.getLogger(__name__)
 
 
 class TVToya(Plugin):
-    _url_re = re.compile(r"https?://tvtoya.pl/live")
+    _url_re = re.compile(r"https?://(?:www\.)?tvtoya\.pl/live")
     _playlist_re = re.compile(r'<source src="([^"]+)" type="application/x-mpegURL">')
 
     @classmethod
@@ -22,10 +21,7 @@ class TVToya(Plugin):
         playlist_m = self._playlist_re.search(res.text)
 
         if playlist_m:
-            return HLSStream.parse_variant_playlist(
-                self.session,
-                update_scheme(self.url, playlist_m.group(1)),
-            )
+            return HLSStream.parse_variant_playlist(self.session, playlist_m.group(1))
         else:
             log.debug("Could not find stream data")
 
diff --git a/src/streamlink/stream/dash.py b/src/streamlink/stream/dash.py
index 71c6cb37..93ba8145 100644
--- a/src/streamlink/stream/dash.py
+++ b/src/streamlink/stream/dash.py
@@ -91,6 +91,10 @@ class DASHStreamWorker(SegmentedStreamWorker):
             representation = self.get_representation(self.mpd, self.reader.representation_id, self.reader.mime_type)
             refresh_wait = max(self.mpd.minimumUpdatePeriod.total_seconds(),
                                self.mpd.periods[0].duration.total_seconds()) or 5
+
+            if self.mpd.type == "static":
+                refresh_wait = 5
+
             with sleeper(refresh_wait * back_off_factor):
                 if representation:
                     for segment in representation.segments(init=init):


Test patch:
diff --git a/tests/plugins/test_nimotv.py b/tests/plugins/test_nimotv.py
new file mode 100644
index 00000000..857cccf9
--- /dev/null
+++ b/tests/plugins/test_nimotv.py
@@ -0,0 +1,22 @@
+import unittest
+
+from streamlink.plugins.nimotv import NimoTV
+
+
+class TestPluginNimoTV(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'http://www.nimo.tv/live/737614',
+            'https://www.nimo.tv/live/737614',
+            'http://www.nimo.tv/sanz',
+            'https://www.nimo.tv/sanz',
+        ]
+        for url in should_match:
+            self.assertTrue(NimoTV.can_handle_url(url))
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'https://example.com/index.html',
+        ]
+        for url in should_not_match:
+            self.assertFalse(NimoTV.can_handle_url(url))
diff --git a/tests/resources/dash/test_11_static.mpd b/tests/resources/dash/test_11_static.mpd
new file mode 100644
index 00000000..5b92ccb3
--- /dev/null
+++ b/tests/resources/dash/test_11_static.mpd
@@ -0,0 +1,143 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<MPD xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:isoff-live:2011" type="static" mediaPresentationDuration="PT3M24.320S" minBufferTime="PT6.000S">
+    <BaseURL>../</BaseURL>
+    <Period start="PT0.000S" duration="PT3M24.320S">
+        <AdaptationSet bitstreamSwitching="true">
+            <ContentComponent contentType="video"></ContentComponent>
+            <Representation id="video-574992933" codecs="avc1.64001F" mimeType="video/mp4" width="960" height="540" frameRate="25" startWithSAP="1" bandwidth="1483000">
+                <SegmentTemplate timescale="48000" media="574992933/chop/segment-$Number$.m4s" initialization="574992933/chop/segment-0.mp4">
+                    <SegmentTimeline>
+                        <S t="0" d="288000" r="32"></S>
+                        <S t="9504000" d="303360"></S>
+                    </SegmentTimeline>
+                </SegmentTemplate>
+            </Representation>
+            <Representation id="video-1309483008" codecs="avc1.640815" mimeType="video/mp4" width="426" height="240" frameRate="25" startWithSAP="1" bandwidth="338000">
+                <SegmentTemplate timescale="24000" media="1309483008/chop/segment-$Number$.m4s" initialization="1309483008/chop/segment-0.mp4">
+                    <SegmentTimeline>
+                        <S t="0" d="145920" r="32"></S>
+                        <S t="4815360" d="88320"></S>
+                    </SegmentTimeline>
+                </SegmentTemplate>
+            </Representation>
+            <Representation id="video-574992928" codecs="avc1.4D401E" mimeType="video/mp4" width="640" height="360" frameRate="25" startWithSAP="1" bandwidth="542000">
+                <SegmentTemplate timescale="48000" media="574992928/chop/segment-$Number$.m4s" initialization="574992928/chop/segment-0.mp4">
+                    <SegmentTimeline>
+                        <S t="0" d="288000" r="32"></S>
+                        <S t="9504000" d="303360"></S>
+                    </SegmentTimeline>
+                </SegmentTemplate>
+            </Representation>
+            <Representation id="video-574992937" codecs="avc1.640028" mimeType="video/mp4" width="1920" height="1080" frameRate="25" startWithSAP="1" bandwidth="5002000">
+                <SegmentTemplate timescale="48000" media="574992937/chop/segment-$Number$.m4s" initialization="574992937/chop/segment-0.mp4">
+                    <SegmentTimeline>
+                        <S t="0" d="288000" r="32"></S>
+                        <S t="9504000" d="303360"></S>
+                    </SegmentTimeline>
+                </SegmentTemplate>
+            </Representation>
+            <Representation id="video-574992932" codecs="avc1.640020" mimeType="video/mp4" width="1280" height="720" frameRate="25" startWithSAP="1" bandwidth="2355000">
+                <SegmentTemplate timescale="48000" media="574992932/chop/segment-$Number$.m4s" initialization="574992932/chop/segment-0.mp4">
+                    <SegmentTimeline>
+                        <S t="0" d="288000" r="32"></S>
+                        <S t="9504000" d="303360"></S>
+                    </SegmentTimeline>
+                </SegmentTemplate>
+            </Representation>
+        </AdaptationSet>
+        <AdaptationSet>
+            <ContentComponent contentType="audio"></ContentComponent>
+            <Representation id="audio-1309483008" codecs="mp4a.40.2" mimeType="audio/mp4" startWithSAP="1" bandwidth="52000" audioSamplingRate="24000">
+                <AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="1"></AudioChannelConfiguration>
+                <SegmentTemplate timescale="24000" media="../audio/1309483008/chop/segment-$Number$.m4s" initialization="../audio/1309483008/chop/segment-0.mp4">
+                    <SegmentTimeline>
+                        <S t="0" d="146432"></S>
+                        <S t="146432" d="145408"></S>
+                        <S t="291840" d="146432"></S>
+                        <S t="438272" d="145408"></S>
+                        <S t="583680" d="146432"></S>
+                        <S t="730112" d="145408"></S>
+                        <S t="875520" d="146432"></S>
+                        <S t="1021952" d="145408"></S>
+                        <S t="1167360" d="146432"></S>
+                        <S t="1313792" d="145408"></S>
+                        <S t="1459200" d="146432"></S>
+                        <S t="1605632" d="145408"></S>
+                        <S t="1751040" d="146432"></S>
+                        <S t="1897472" d="145408"></S>
+                        <S t="2042880" d="146432"></S>
+                        <S t="2189312" d="145408"></S>
+                        <S t="2334720" d="146432"></S>
+                        <S t="2481152" d="145408"></S>
+                        <S t="2626560" d="146432"></S>
+                        <S t="2772992" d="145408"></S>
+                        <S t="2918400" d="146432"></S>
+                        <S t="3064832" d="145408"></S>
+                        <S t="3210240" d="146432"></S>
+                        <S t="3356672" d="145408"></S>
+                        <S t="3502080" d="146432"></S>
+                        <S t="3648512" d="145408"></S>
+                        <S t="3793920" d="146432"></S>
+                        <S t="3940352" d="145408"></S>
+                        <S t="4085760" d="146432"></S>
+                        <S t="4232192" d="145408"></S>
+                        <S t="4377600" d="146432"></S>
+                        <S t="4524032" d="145408"></S>
+                        <S t="4669440" d="146432"></S>
+                        <S t="4815872" d="89088"></S>
+                    </SegmentTimeline>
+                </SegmentTemplate>
+            </Representation>
+            <Representation id="audio-574992933" codecs="mp4a.40.2" mimeType="audio/mp4" startWithSAP="1" bandwidth="237000" audioSamplingRate="48000">
+                <AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"></AudioChannelConfiguration>
+                <SegmentTemplate timescale="48000" media="../audio/574992933/chop/segment-$Number$.m4s" initialization="../audio/574992933/chop/segment-0.mp4">
+                    <SegmentTimeline>
+                        <S t="0" d="287744"></S>
+                        <S t="287744" d="288768"></S>
+                        <S t="576512" d="287744" r="2"></S>
+                        <S t="1439744" d="288768"></S>
+                        <S t="1728512" d="287744" r="2"></S>
+                        <S t="2591744" d="288768"></S>
+                        <S t="2880512" d="287744" r="2"></S>
+                        <S t="3743744" d="288768"></S>
+                        <S t="4032512" d="287744" r="2"></S>
+                        <S t="4895744" d="288768"></S>
+                        <S t="5184512" d="287744" r="2"></S>
+                        <S t="6047744" d="288768"></S>
+                        <S t="6336512" d="287744" r="2"></S>
+                        <S t="7199744" d="288768"></S>
+                        <S t="7488512" d="287744" r="2"></S>
+                        <S t="8351744" d="288768"></S>
+                        <S t="8640512" d="287744" r="2"></S>
+                        <S t="9503744" d="307200"></S>
+                    </SegmentTimeline>
+                </SegmentTemplate>
+            </Representation>
+            <Representation id="audio-574992928" codecs="mp4a.40.2" mimeType="audio/mp4" startWithSAP="1" bandwidth="120000" audioSamplingRate="48000">
+                <AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"></AudioChannelConfiguration>
+                <SegmentTemplate timescale="48000" media="../audio/574992928/chop/segment-$Number$.m4s" initialization="../audio/574992928/chop/segment-0.mp4">
+                    <SegmentTimeline>
+                        <S t="0" d="287744"></S>
+                        <S t="287744" d="288768"></S>
+                        <S t="576512" d="287744" r="2"></S>
+                        <S t="1439744" d="288768"></S>
+                        <S t="1728512" d="287744" r="2"></S>
+                        <S t="2591744" d="288768"></S>
+                        <S t="2880512" d="287744" r="2"></S>
+                        <S t="3743744" d="288768"></S>
+                        <S t="4032512" d="287744" r="2"></S>
+                        <S t="4895744" d="288768"></S>
+                        <S t="5184512" d="287744" r="2"></S>
+                        <S t="6047744" d="288768"></S>
+                        <S t="6336512" d="287744" r="2"></S>
+                        <S t="7199744" d="288768"></S>
+                        <S t="7488512" d="287744" r="2"></S>
+                        <S t="8351744" d="288768"></S>
+                        <S t="8640512" d="287744" r="2"></S>
+                        <S t="9503744" d="307200"></S>
+                    </SegmentTimeline>
+                </SegmentTemplate>
+            </Representation>
+        </AdaptationSet>
+    </Period>
+</MPD>
diff --git a/tests/streams/test_dash.py b/tests/streams/test_dash.py
index 40d3a384..cb83c130 100644
--- a/tests/streams/test_dash.py
+++ b/tests/streams/test_dash.py
@@ -342,6 +342,41 @@ class TestDASHStreamWorker(unittest.TestCase):
         self.assertSequenceEqual(list(worker.iter_segments()), segments)
         representation.segments.assert_called_with(init=True)
 
+    @patch("streamlink.stream.dash_manifest.time.time")
+    @patch("streamlink.stream.dash_manifest.time.sleep")
+    def test_static_refresh_wait(self, sleep, time):
+        """
+            Verify the fix for https://github.com/streamlink/streamlink/issues/2873
+        """
+        time.return_value = 1
+        reader = MagicMock()
+        worker = DASHStreamWorker(reader)
+        reader.representation_id = 1
+        reader.mime_type = "video/mp4"
+
+        representation = Mock(id=1, mimeType="video/mp4", height=720)
+        segments = [Mock(url="init_segment"), Mock(url="first_segment"), Mock(url="second_segment")]
+        representation.segments.return_value = [segments[0]]
+        worker.mpd = Mock(dynamic=False,
+                          publishTime=1,
+                          periods=[
+                              Mock(adaptationSets=[
+                                  Mock(contentProtection=None,
+                                       representations=[
+                                           representation
+                                       ])
+                              ])
+                          ])
+        worker.mpd.type = "static"
+        for duration in (0, 204.32):
+            worker.mpd.minimumUpdatePeriod.total_seconds.return_value = 0
+            worker.mpd.periods[0].duration.total_seconds.return_value = duration
+
+            representation.segments.return_value = segments
+            self.assertSequenceEqual(list(worker.iter_segments()), segments)
+            representation.segments.assert_called_with(init=True)
+            sleep.assert_called_with(5)
+
     @patch("streamlink.stream.dash_manifest.time.sleep")
     def test_duplicate_rep_id(self, sleep):
         representation_vid = Mock(id=1, mimeType="video/mp4", height=720)
diff --git a/tests/streams/test_dash_parser.py b/tests/streams/test_dash_parser.py
index 25b15381..9bbc030c 100644
--- a/tests/streams/test_dash_parser.py
+++ b/tests/streams/test_dash_parser.py
@@ -249,3 +249,12 @@ class TestMPDParser(unittest.TestCase):
             representations_1 = mpd.periods[0].adaptationSets[0].representations[1]
             self.assertEqual(representations_1.height, 804)
             self.assertEqual(representations_1.bandwidth, 8000.0)
+
+    def test_segments_static_periods_duration(self):
+        """
+            Verify the fix for https://github.com/streamlink/streamlink/issues/2873
+        """
+        with xml("dash/test_11_static.mpd") as mpd_xml:
+            mpd = MPD(mpd_xml, base_url="http://test.se/", url="http://test.se/manifest.mpd")
+            duration = mpd.periods[0].duration.total_seconds()
+            self.assertEqual(duration, 204.32)