Repository: streamlink/streamlink
Pull request #3547 (2021-02-08 08:54:02)

tests: fix test code coverage
- properly rewrite TestPluginAPIHTTPSession.test_read_timeout
- remove Python 2 compat code from TestBuffer.test_reuse_input
- add category variable to simple player --title tests and merge them
- ignore ImportError exceptions from code coverage
- remove unnecessary branch in unsupported_versions_1979
- annotate dead branches in tests/resources and remove unused mock_http

Current tests coverage on master (6646c72) is 99.62%:
https://codecov.io/gh/streamlink/streamlink/tree/6646c72/tests

This raises it to 100%:
https://app.codecov.io/gh/streamlink/streamlink/compare/3547/tree/tests

And also improves this:
https://app.codecov.io/gh/streamlink/streamlink/compare/3547/changes

----

Since this pushes the coverage of the tests to 100%, the threshold in the codecov config could be removed and a target of 100 set instead. This would then ensure that (new) tests are always fully covered.
https://github.com/streamlink/streamlink/blob/6646c726e1a1a7f85fbd0b0982aff86843d88179/.codecov.yml#L41

For some reason, the codecov PR status checks are missing since the outage on Github a couple of days ago.


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/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/__init__.py b/tests/resources/__init__.py
index 1f0c1c0d..40786e77 100644
--- a/tests/resources/__init__.py
+++ b/tests/resources/__init__.py
@@ -4,7 +4,6 @@ import xml.etree.ElementTree as ET
 from contextlib import contextmanager
 from io import BytesIO
 
-import requests_mock
 
 __here__ = os.path.abspath(os.path.dirname(__file__))
 
@@ -14,11 +13,11 @@ def _parse_xml(data, strip_ns=False):
     try:
         it = ET.iterparse(BytesIO(data))
         for _, el in it:
-            if '}' in el.tag and strip_ns:
+            if '}' in el.tag and strip_ns:  # pragma: no branch
                 # strip all namespaces
                 el.tag = el.tag.split('}', 1)[1]
         return it.root
-    except Exception as err:
+    except Exception as err:  # pragma: no cover
         snippet = repr(data)
         if len(snippet) > 35:
             snippet = snippet[:35] + " ..."
@@ -36,11 +35,3 @@ def text(path, encoding="utf8"):
 def xml(path, encoding="utf8"):
     with codecs.open(os.path.join(__here__, path), 'r', encoding=encoding) as resource_fh:
         yield _parse_xml(resource_fh.read(), strip_ns=True)
-
-
-@contextmanager
-def mock_http(path, url, encoding="utf8"):
-    with text(path, encoding) as resource:
-        with requests_mock.Mocker() as mock:
-            mock.get(url, text=resource.read())
-            yield mock
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)
diff --git a/tests/test_api_http_session.py b/tests/test_api_http_session.py
index b987794e..694e435f 100644
--- a/tests/test_api_http_session.py
+++ b/tests/test_api_http_session.py
@@ -1,5 +1,5 @@
 import unittest
-from unittest.mock import PropertyMock, patch
+from unittest.mock import PropertyMock, call, patch
 
 import requests
 
@@ -8,17 +8,26 @@ from streamlink.plugin.api.http_session import HTTPSession
 
 
 class TestPluginAPIHTTPSession(unittest.TestCase):
-    @patch('requests.sessions.Session.send')
-    def test_read_timeout(self, mock_send):
-        mock_send.side_effect = IOError
+    @patch("streamlink.plugin.api.http_session.time.sleep")
+    @patch("streamlink.plugin.api.http_session.Session.request")
+    def test_read_timeout(self, mock_request, mock_sleep):
+        mock_request.side_effect = requests.Timeout
         session = HTTPSession()
 
-        def stream_data():
-            res = session.get("http://httpbin.org/delay/6",
-                              timeout=3, stream=True)
-            next(res.iter_content(8192))
-
-        self.assertRaises(PluginError, stream_data)
+        with self.assertRaises(PluginError) as cm:
+            session.get("http://localhost/", timeout=123, retries=3, retry_backoff=2, retry_max_backoff=5)
+        self.assertTrue(str(cm.exception).startswith("Unable to open URL: http://localhost/"))
+        self.assertEqual(mock_request.mock_calls, [
+            call(session, "GET", "http://localhost/", headers={}, params={}, timeout=123, proxies={}, allow_redirects=True),
+            call(session, "GET", "http://localhost/", headers={}, params={}, timeout=123, proxies={}, allow_redirects=True),
+            call(session, "GET", "http://localhost/", headers={}, params={}, timeout=123, proxies={}, allow_redirects=True),
+            call(session, "GET", "http://localhost/", headers={}, params={}, timeout=123, proxies={}, allow_redirects=True),
+        ])
+        self.assertEqual(mock_sleep.mock_calls, [
+            call(2),
+            call(4),
+            call(5)
+        ])
 
     def test_json_encoding(self):
         json_str = "{\"test\": \"Α and Ω\"}"
diff --git a/tests/test_buffer.py b/tests/test_buffer.py
index 8f02a356..790de0ea 100644
--- a/tests/test_buffer.py
+++ b/tests/test_buffer.py
@@ -52,15 +52,7 @@ class TestBuffer(unittest.TestCase):
         """Objects should be reusable after write()"""
 
         original = b"original"
-        tests = [bytearray(original)]
-        try:
-            m = memoryview(bytearray(original))
-        except NameError:  # Python 2.6 does not have "memoryview"
-            pass
-        else:
-            # Python 2.7 doesn't do bytes(memoryview) properly
-            if bytes(m) == original:
-                tests.append(m)
+        tests = [bytearray(original), memoryview(bytearray(original))]
 
         for data in tests:
             self.buffer.write(data)
diff --git a/tests/test_cmdline_title.py b/tests/test_cmdline_title.py
index 8ff51081..ac9e63f9 100644
--- a/tests/test_cmdline_title.py
+++ b/tests/test_cmdline_title.py
@@ -7,12 +7,9 @@ from tests.test_cmdline import CommandLineTestCase
 @unittest.skipIf(is_win32, "test only applicable in a POSIX OS")
 class TestCommandLineWithTitlePOSIX(CommandLineTestCase):
     def test_open_player_with_title_vlc(self):
-        self._test_args(["streamlink", "-p", "/usr/bin/vlc", "--title", "{title}", "http://test.se", "test"],
-                        ["/usr/bin/vlc", "--input-title-format", 'Test Title', "-"])
-
-    def test_open_player_with_unicode_author_vlc(self):
-        self._test_args(["streamlink", "-p", "/usr/bin/vlc", "--title", "{author}", "http://test.se", "test"],
-                        ["/usr/bin/vlc", "--input-title-format", "Tѥst Āuƭhǿr", "-"])
+        self._test_args(["streamlink", "-p", "/usr/bin/vlc",
+                         "--title", "{title} - {author} - {category}", "http://test.se", "test"],
+                        ["/usr/bin/vlc", "--input-title-format", "Test Title - Tѥst Āuƭhǿr - No Category", "-"])
 
     def test_open_player_with_default_title_vlc(self):
         self._test_args(["streamlink", "-p", "/usr/bin/vlc", "http://test.se", "test"],
@@ -36,15 +33,8 @@ class TestCommandLineWithTitleWindows(CommandLineTestCase):
     def test_open_player_with_title_vlc(self):
         self._test_args(
             ["streamlink", "-p", "c:\\Program Files\\VideoLAN\\vlc.exe",
-             "--title", "{title}", "http://test.se", "test"],
-            "c:\\Program Files\\VideoLAN\\vlc.exe --input-title-format \"Test Title\" -"
-        )
-
-    def test_open_player_with_unicode_author_vlc_py3(self):
-        self._test_args(
-            ["streamlink", "-p", "c:\\Program Files\\VideoLAN\\vlc.exe",
-             "--title", "{author}", "http://test.se", "test"],
-            "c:\\Program Files\\VideoLAN\\vlc.exe --input-title-format \"Tѥst Āuƭhǿr\" -"
+             "--title", "{title} - {author} - {category}", "http://test.se", "test"],
+            "c:\\Program Files\\VideoLAN\\vlc.exe --input-title-format \"Test Title - Tѥst Āuƭhǿr - No Category\" -"
         )
 
     def test_open_player_with_default_title_vlc(self):
diff --git a/tests/test_localization.py b/tests/test_localization.py
index b9896a77..b133b7bf 100644
--- a/tests/test_localization.py
+++ b/tests/test_localization.py
@@ -8,14 +8,14 @@ try:
     import iso3166  # noqa: F401
 
     ISO639 = True
-except ImportError:
+except ImportError:  # pragma: no cover
     ISO639 = False
 
 try:
     import pycountry  # noqa: F401
 
     PYCOUNTRY = True
-except ImportError:
+except ImportError:  # pragma: no cover
     PYCOUNTRY = False
 
 
diff --git a/tests/test_plugin_utils.py b/tests/test_plugin_utils.py
index 1a9ebfc8..4dfc254f 100644
--- a/tests/test_plugin_utils.py
+++ b/tests/test_plugin_utils.py
@@ -11,15 +11,12 @@ def unsupported_versions_1979():
        - https://bugs.python.org/issue34294
     """
     v = sys.version_info
-    if (v.major == 3) and (
+    return (v.major == 3) and (
         # 3.7.0 - 3.7.2
         (v.minor == 7 and v.micro <= 2)
         # 3.8.0a1
         or (v.minor == 8 and v.micro == 0 and v.releaselevel == 'alpha' and v.serial <= 1)
-    ):
-        return True
-    else:
-        return False
+    )
 
 
 class TestPluginUtil(unittest.TestCase):