Repository: streamlink/streamlink
Pull request #3508 (2021-01-24 02:47:51)

plugins.nimotv: new plugin
closes  #2502



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/MANIFEST.in b/MANIFEST.in
index b43d3bbb..d056e641 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -11,5 +11,7 @@ recursive-include examples *
 recursive-include tests *
 
 prune docs/_build
+include docs/_build/man/*
+
 prune */__pycache__
 global-exclude *.pyc *~ *.bak *.swp *.pyo
diff --git a/docs/_man.rst b/docs/_man.rst
new file mode 100644
index 00000000..fe9cdb37
--- /dev/null
+++ b/docs/_man.rst
@@ -0,0 +1,29 @@
+:orphan:
+
+
+Streamlink
+==========
+
+Synopsis
+--------
+
+.. code-block:: console
+
+   streamlink [OPTIONS] <URL> [STREAM]
+
+   streamlink --loglevel debug youtu.be/VIDEO-ID best
+   streamlink --player mpv --player-args '--no-border --no-keepaspect-window' twitch.tv/CHANNEL 1080p60
+   streamlink --player-external-http --player-external-http-port 8888 URL STREAM
+   streamlink --output /path/to/file --http-timeout 60 URL STREAM
+   streamlink --stdout URL STREAM | ffmpeg -i pipe:0 ...
+   streamlink --http-header 'Authorization=OAuth TOKEN' --http-header 'Referer=URL' URL STREAM
+   streamlink --hls-live-edge 5 --hls-segment-threads 5 'hls://https://host/playlist.m3u8' best
+   streamlink --twitch-low-latency -p mpv -a '--cache=yes --demuxer-max-bytes=750k' twitch.tv/CHANNEL best
+
+
+Options
+=======
+
+.. argparse::
+    :module: streamlink_cli.main
+    :attr: parser_helper
diff --git a/docs/conf.py b/docs/conf.py
index 9e407363..9ad9b0f1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -182,7 +182,7 @@ htmlhelp_basename = 'streamlinkdoc'
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
 man_pages = [
-    ('cli', 'streamlink', 'extracts streams from various services and pipes them into a video player of choice', ['Streamlink Contributors'], 1)
+    ('_man', 'streamlink', 'extracts streams from various services and pipes them into a video player of choice', ['Streamlink Contributors'], 1)
 ]
 
 # If true, show URL addresses after external links.
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/bloomberg.py b/src/streamlink/plugins/bloomberg.py
index 31026aca..eea41dcc 100644
--- a/src/streamlink/plugins/bloomberg.py
+++ b/src/streamlink/plugins/bloomberg.py
@@ -1,111 +1,87 @@
 import logging
 import re
-from functools import partial
+from urllib.parse import urlparse
 
 from streamlink.plugin import Plugin
 from streamlink.plugin.api import useragents, validate
 from streamlink.stream import HDSStream, HLSStream, HTTPStream
-from streamlink.utils import parse_json, update_scheme
+from streamlink.utils import parse_json
 
 log = logging.getLogger(__name__)
 
 
 class Bloomberg(Plugin):
-    VOD_API_URL = 'https://www.bloomberg.com/api/embed?id={0}'
-    PLAYER_URL = 'https://cdn.gotraffic.net/projector/latest/bplayer.js'
-    CHANNEL_MAP = {
-        'audio': 'BBG_RADIO',
-        'live/europe': 'EU',
-        'live/us': 'US',
-        'live/asia': 'ASIA',
-        'live/stream': 'EVENT',
-        'live/emea': 'EMEA_EVENT',
-        'live/asia_stream': 'ASIA_EVENT'
-    }
+    LIVE_API_URL = "https://cdn.gotraffic.net/projector/latest/assets/config/config.min.json?v=1"
+    VOD_API_URL = "https://www.bloomberg.com/api/embed?id={0}"
 
     _url_re = re.compile(r'''
         https?://(?:www\.)?bloomberg\.com/
         (?:
-            news/videos/[^/]+/[^/]+|
+            news/videos/[^/]+/[^/]+
+            |
             live/(?P<channel>.+)/?
         )
-''', re.VERBOSE)
-    _live_player_re = re.compile(r'{APP_BUNDLE:"(?P<live_player_url>.+?/app.js)"')
-    _js_to_json_re = partial(re.compile(r'(\w+):(["\']|\d?\.?\d+,|true|false|\[|{)').sub, r'"\1":\2')
-    _mp4_bitrate_re = re.compile(r'.*_(?P<bitrate>[0-9]+)\.mp4')
-    _preload_state_re = re.compile(r'<script>\s*window.__PRELOADED_STATE__\s*=\s*({.+});?\s*</script>')
-    _live_stream_info_module_id_re = re.compile(
-        r'{(?:(?:"[^"]+"|\w+):(?:\d+|"[^"]+"),)*"(?:[^"]*/)?config/livestreams":(\d+)(?:,(?:"[^"]+"|\w+):(?:\d+|"[^"]+"))*}'
+    ''', re.VERBOSE)
+
+    _re_preload_state = re.compile(r'<script>\s*window.__PRELOADED_STATE__\s*=\s*({.+});?\s*</script>')
+    _re_mp4_bitrate = re.compile(r'.*_(?P<bitrate>[0-9]+)\.mp4')
+
+    _schema_url = validate.all(
+        {"url": validate.url()},
+        validate.get("url")
     )
-    _live_stream_info_re = r'],{id}:\[function\(.+var n=({{.+}});r.default=n}},{{}}],{next}:\['
 
-    _live_streams_schema = validate.Schema(
-        validate.transform(_js_to_json_re),
-        validate.transform(lambda x: x.replace(':.', ':0.')),
+    _schema_live_list = validate.Schema(
         validate.transform(parse_json),
-        validate.Schema({
-            validate.text: {
-                validate.optional('cdns'): validate.all(
+        validate.get("live"),
+        validate.get("channels"),
+        validate.get("byChannelId"),
+        {
+            str: validate.all({"liveId": str}, validate.get("liveId"))
+        }
+    )
+    _schema_live_streams = validate.Schema(
+        validate.get("livestreams"),
+        {
+            str: {
+                validate.optional("cdns"): validate.all(
                     [
-                        validate.Schema(
-                            {
-                                'streams': validate.all([
-                                    validate.Schema(
-                                        {'url': validate.transform(lambda x: re.sub(r'(https?:/)([^/])', r'\1/\2', x))},
-                                        validate.get('url'),
-                                        validate.url()
-                                    )
-                                ]),
-                            },
-                            validate.get('streams')
-                        )
+                        validate.all({"streams": [_schema_url]}, validate.get("streams"))
                     ],
                     validate.transform(lambda x: [i for y in x for i in y])
                 )
             }
-        },
-        )
+        }
     )
 
-    _channel_list_schema = validate.Schema(
+    _schema_vod_list = validate.Schema(
         validate.transform(parse_json),
-        {"live": {"channels": {"byChannelId": {
-            validate.text: validate.all({"liveId": validate.text}, validate.get("liveId"))
-        }}}},
-        validate.get("live"),
-        validate.get("channels"),
-        validate.get("byChannelId"),
-    )
-
-    _vod_list_schema = validate.Schema(
-        validate.transform(parse_json),
-        validate.get("video"),
-        validate.get("videoList"),
-        validate.all([
-            validate.Schema({
-                "slug": validate.text,
-                "video": validate.Schema({"bmmrId": validate.text}, validate.get("bmmrId"))
-            })
-        ])
+        validate.any(
+            validate.all(
+                {"video": {"videoList": list}},
+                validate.get("video"),
+                validate.get("videoList")
+            ),
+            validate.all(
+                {"quicktakeVideo": {"videoStory": dict}},
+                validate.get("quicktakeVideo"),
+                validate.get("videoStory"),
+                validate.transform(lambda x: [x])
+            )
+        ),
+        [
+            {
+                "slug": str,
+                "video": validate.all({"bmmrId": str}, validate.get("bmmrId"))
+            }
+        ]
     )
-
-    _vod_api_schema = validate.Schema(
+    _schema_vod_streams = validate.Schema(
         {
-            'secureStreams': validate.all([
-                validate.Schema(
-                    {'url': validate.url()},
-                    validate.get('url')
-                )
-            ]),
-            'streams': validate.all([
-                validate.Schema(
-                    {'url': validate.url()},
-                    validate.get('url')
-                )
-            ]),
-            'contentLoc': validate.url(),
+            "secureStreams": validate.all([_schema_url]),
+            "streams": validate.all([_schema_url])
         },
-        validate.transform(lambda x: list(set(x['secureStreams'] + x['streams'] + [x['contentLoc']])))
+        validate.transform(lambda x: list(set(x["secureStreams"] + x["streams"])))
     )
 
     _headers = {
@@ -124,94 +100,66 @@ class Bloomberg(Plugin):
     def can_handle_url(cls, url):
         return Bloomberg._url_re.match(url)
 
-    def _get_live_streams(self):
-        # Get channel id
-        match = self._url_re.match(self.url)
-        channel = match.group('channel')
-
+    def _get_live_streams(self, channel):
         res = self.session.http.get(self.url, headers=self._headers)
         if "Are you a robot?" in res.text:
             log.error("Are you a robot?")
 
-        match = self._preload_state_re.search(res.text)
+        match = self._re_preload_state.search(res.text)
         if match is None:
             return
 
-        live_ids = self._channel_list_schema.validate(match.group(1))
+        live_ids = self._schema_live_list.validate(match.group(1))
         live_id = live_ids.get(channel)
         if not live_id:
-            log.error("Could not find liveId for channel '{0}'".format(channel))
-            return
-
-        log.debug("Found liveId: {0}".format(live_id))
-        # Retrieve live player URL
-        res = self.session.http.get(self.PLAYER_URL)
-        match = self._live_player_re.search(res.text)
-        if match is None:
-            return
-        live_player_url = update_scheme(self.url, match.group('live_player_url'))
-
-        # Extract streams from the live player page
-        log.debug("Player URL: {0}".format(live_player_url))
-        res = self.session.http.get(live_player_url)
-
-        # Get the ID of the webpack module containing the streams config from another module's dependency list
-        match = self._live_stream_info_module_id_re.search(res.text)
-        if match is None:
+            log.error(f"Could not find liveId for channel '{channel}'")
             return
-        webpack_module_id = int(match.group(1))
-        log.debug("Webpack module ID: {0}".format(webpack_module_id))
 
-        # Finally get the streams JSON data from the config/livestreams webpack module
-        regex = re.compile(self._live_stream_info_re.format(id=webpack_module_id, next=webpack_module_id + 1))
-        match = regex.search(res.text)
-        if match is None:
-            return
-        stream_info = self._live_streams_schema.validate(match.group(1))
-        data = stream_info.get(live_id, {})
+        log.debug(f"Found liveId: {live_id}")
+        res = self.session.http.get(self.LIVE_API_URL)
+        streams = self.session.http.json(res, schema=self._schema_live_streams)
+        data = streams.get(live_id, {})
 
-        return data.get('cdns')
+        return data.get("cdns")
 
     def _get_vod_streams(self):
-        # Retrieve URL page and search for video ID
         res = self.session.http.get(self.url, headers=self._headers)
 
-        match = self._preload_state_re.search(res.text)
+        match = self._re_preload_state.search(res.text)
         if match is None:
             return
 
-        videos = self._vod_list_schema.validate(match.group(1))
+        videos = self._schema_vod_list.validate(match.group(1))
         video_id = next((v["video"] for v in videos if v["slug"] in self.url), None)
         if video_id is None:
             return
 
-        log.debug("Found videoId: {0}".format(video_id))
+        log.debug(f"Found videoId: {video_id}")
         res = self.session.http.get(self.VOD_API_URL.format(video_id), headers=self._headers)
-        streams = self.session.http.json(res, schema=self._vod_api_schema)
+        streams = self.session.http.json(res, schema=self._schema_vod_streams)
 
         return streams
 
     def _get_streams(self):
+        match = self._url_re.match(self.url)
+        channel = match.group("channel")
+
         self.session.http.headers.update({"User-Agent": useragents.CHROME})
-        if '/news/videos/' in self.url:
-            # VOD
-            streams = self._get_vod_streams() or []
+        if channel:
+            streams = self._get_live_streams(channel) or []
         else:
-            # Live
-            streams = self._get_live_streams() or []
+            streams = self._get_vod_streams() or []
 
         for video_url in streams:
-            log.debug("Found stream: {0}".format(video_url))
-            if '.f4m' in video_url:
+            log.debug(f"Found stream: {video_url}")
+            parsed = urlparse(video_url)
+            if parsed.path.endswith(".f4m"):
                 yield from HDSStream.parse_manifest(self.session, video_url).items()
-            elif '.m3u8' in video_url:
+            elif parsed.path.endswith(".m3u8"):
                 yield from HLSStream.parse_variant_playlist(self.session, video_url).items()
-            if '.mp4' in video_url:
-                match = self._mp4_bitrate_re.match(video_url)
-                if match is not None:
-                    bitrate = match.group('bitrate') + 'k'
-                else:
-                    bitrate = 'vod'
+            elif parsed.path.endswith(".mp4"):
+                match = self._re_mp4_bitrate.match(video_url)
+                bitrate = "vod" if match is None else f"{match.group('bitrate')}k"
                 yield bitrate, HTTPStream(self.session, video_url)
 
 
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/oneplusone.py b/src/streamlink/plugins/oneplusone.py
index 4ac04e7e..df8f0379 100644
--- a/src/streamlink/plugins/oneplusone.py
+++ b/src/streamlink/plugins/oneplusone.py
@@ -55,9 +55,12 @@ class OnePlusOne(Plugin):
         parser = Online_Parser()
         parser.feed(res.text)
         url = parser.iframe_url
-        if url.startswith("//"):
+        log.trace(f"find_iframe url: {url}")
+        if url.startswith("/"):
             p = urlparse(self.url)
-            return "{0}:{1}".format(p.scheme, url)
+            if url.startswith("//"):
+                return "{0}:{1}".format(p.scheme, url)
+            return "{0}://{1}{2}".format(p.scheme, p.netloc, url)
         else:
             return url
 
diff --git a/src/streamlink/plugins/stadium.py b/src/streamlink/plugins/stadium.py
index 1ecfb52a..845eacd9 100644
--- a/src/streamlink/plugins/stadium.py
+++ b/src/streamlink/plugins/stadium.py
@@ -2,41 +2,68 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.plugin.api.utils import itertags
 from streamlink.stream import HLSStream
-from streamlink.utils import parse_json
 
 log = logging.getLogger(__name__)
 
 
 class Stadium(Plugin):
-    url_re = re.compile(r"""https?://(?:www\.)?watchstadium\.com/live""")
-    API_URL = "https://player-api.new.livestream.com/accounts/{account_id}/events/{event_id}/stream_info"
-    _stream_data_re = re.compile(r"var StadiumSiteData = (\{.*?});", re.M | re.DOTALL)
+    _url_re = re.compile(r"""https?://(?:www\.)?watchstadium\.com/live""")
+    _policy_key_re = re.compile(r"""options:\s*\{.+policyKey:\s*"([^"]+)""", re.DOTALL)
+    _API_URL = (
+        "https://edge.api.brightcove.com/playback/v1/accounts/{data_account}/videos/{data_video_id}"
+        "?ad_config_id={data_ad_config_id}"
+    )
+    _PLAYER_URL = "https://players.brightcove.net/{data_account}/{data_player}_default/index.min.js"
+
+    _streams_schema = validate.Schema(
+        {
+            "tags": ["live"],
+            "sources": [
+                {
+                    "src": validate.url(scheme="http"),
+                    validate.optional("ext_x_version"): str,
+                    "type": str,
+                }
+            ],
+        },
+        validate.get("sources"),
+    )
 
     @classmethod
     def can_handle_url(cls, url):
-        return cls.url_re.match(url) is not None
+        return cls._url_re.match(url) is not None
 
     def _get_streams(self):
         res = self.session.http.get(self.url)
-        m = self._stream_data_re.search(res.text)
-        if m:
-            data = parse_json(m.group(1))
-            if data['LivestreamEnabled'] == '1':
-                account_id = data['LivestreamArgs']['account_id']
-                event_id = data['LivestreamArgs']['event_id']
-                log.debug("Found account_id={0} and event_id={1}".format(account_id, event_id))
-
-                url = self.API_URL.format(account_id=account_id, event_id=event_id)
-                api_res = self.session.http.get(url)
-                api_data = self.session.http.json(api_res)
-                stream_url = api_data.get('secure_m3u8_url') or api_data.get('m3u8_url')
-                if stream_url:
-                    return HLSStream.parse_variant_playlist(self.session, stream_url)
-                else:
-                    log.error("Could not find m3u8_url")
-            else:
-                log.error("Stream is offline")
+        for tag in itertags(res.text, "video"):
+            if tag.attributes.get("id") == "brightcove_video_player":
+                data_video_id = tag.attributes.get("data-video-id")
+                data_account = tag.attributes.get("data-account")
+                data_ad_config_id = tag.attributes.get("data-ad-config-id")
+                data_player = tag.attributes.get("data-player")
+
+                url = self._PLAYER_URL.format(data_account=data_account, data_player=data_player)
+                res = self.session.http.get(url)
+                policy_key = self._policy_key_re.search(res.text).group(1)
+
+                headers = {
+                    "Accept": "application/json;pk={0}".format(policy_key),
+                }
+                url = self._API_URL.format(
+                    data_account=data_account, data_video_id=data_video_id, data_ad_config_id=data_ad_config_id
+                )
+                res = self.session.http.get(url, headers=headers)
+                streams = self.session.http.json(res, schema=self._streams_schema)
+
+                for stream in streams:
+                    if stream["type"] == "application/x-mpegURL":
+                        for s in HLSStream.parse_variant_playlist(self.session, stream["src"]).items():
+                            yield s
+                    else:
+                        log.warning("Unexpected stream type: '{0}'".format(stream["type"]))
 
 
 __plugin__ = Stadium
diff --git a/src/streamlink/plugins/tvtoya.py b/src/streamlink/plugins/tvtoya.py
index d37b1958..3080be0a 100644
--- a/src/streamlink/plugins/tvtoya.py
+++ b/src/streamlink/plugins/tvtoya.py
@@ -2,7 +2,6 @@ import logging
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
 from streamlink.utils import update_scheme
 
@@ -11,7 +10,7 @@ log = logging.getLogger(__name__)
 
 class TVToya(Plugin):
     _url_re = re.compile(r"https?://tvtoya.pl/live")
-    _playlist_re = re.compile(r'data-stream="([^"]+)"')
+    _playlist_re = re.compile(r'<source src="([^"]+)" type="application/x-mpegURL">')
 
     @classmethod
     def can_handle_url(cls, url):
@@ -26,7 +25,6 @@ class TVToya(Plugin):
             return HLSStream.parse_variant_playlist(
                 self.session,
                 update_scheme(self.url, playlist_m.group(1)),
-                headers={'Referer': self.url, 'User-Agent': useragents.ANDROID}
             )
         else:
             log.debug("Could not find stream data")
diff --git a/src/streamlink/plugins/twitcasting.py b/src/streamlink/plugins/twitcasting.py
index b33ab678..15f344f7 100644
--- a/src/streamlink/plugins/twitcasting.py
+++ b/src/streamlink/plugins/twitcasting.py
@@ -1,3 +1,4 @@
+import hashlib
 import logging
 import re
 from threading import Event, Thread
@@ -7,15 +8,25 @@ import websocket
 
 from streamlink import logger
 from streamlink.buffers import RingBuffer
-from streamlink.plugin import Plugin, PluginError
+from streamlink.plugin import Plugin, PluginArgument, PluginArguments, PluginError
 from streamlink.plugin.api import useragents, validate
 from streamlink.stream.stream import Stream
 from streamlink.stream.stream import StreamIO
+from streamlink.utils.url import update_qsd
+
 
 log = logging.getLogger(__name__)
 
 
 class TwitCasting(Plugin):
+    arguments = PluginArguments(
+        PluginArgument(
+            "password",
+            sensitive=True,
+            metavar="PASSWORD",
+            help="Password for private Twitcasting streams."
+        )
+    )
     _url_re = re.compile(r"http(s)?://twitcasting.tv/(?P<channel>[^/]+)", re.VERBOSE)
     _STREAM_INFO_URL = "https://twitcasting.tv/streamserver.php?target={channel}&mode=client"
     _STREAM_REAL_URL = "{proto}://{host}/ws.app/stream/{movie_id}/fmp4/bd/1/1500?mode={mode}"
@@ -66,6 +77,12 @@ class TwitCasting(Plugin):
             raise PluginError("No stream available for user {}".format(self.channel))
 
         real_stream_url = self._STREAM_REAL_URL.format(proto=proto, host=host, movie_id=movie_id, mode=mode)
+
+        password = self.options.get("password")
+        if password is not None:
+            password_hash = hashlib.md5(password.encode()).hexdigest()
+            real_stream_url = update_qsd(real_stream_url, {"word": password_hash})
+
         log.debug("Real stream url: {}".format(real_stream_url))
 
         return {mode: TwitCastingStream(session=self.session, url=real_stream_url)}
diff --git a/src/streamlink/plugins/wwenetwork.py b/src/streamlink/plugins/wwenetwork.py
index 2f9d489a..e1753c89 100644
--- a/src/streamlink/plugins/wwenetwork.py
+++ b/src/streamlink/plugins/wwenetwork.py
@@ -1,13 +1,13 @@
 import json
 import logging
 import re
+from functools import lru_cache
 from urllib.parse import parse_qsl, urlparse
 
 from streamlink import PluginError
 from streamlink.plugin import Plugin, PluginArgument, PluginArguments
 from streamlink.plugin.api import useragents
 from streamlink.stream import HLSStream
-from streamlink.utils import memoize
 from streamlink.utils.times import seconds_to_hhmmss
 
 log = logging.getLogger(__name__)
@@ -91,7 +91,7 @@ class WWENetwork(Plugin):
         return self.auth_token
 
     @property
-    @memoize
+    @lru_cache(maxsize=128)
     def item_config(self):
         log.debug("Loading page config")
         p = urlparse(self.url)
diff --git a/src/streamlink/session.py b/src/streamlink/session.py
index 2183c034..1cce091c 100644
--- a/src/streamlink/session.py
+++ b/src/streamlink/session.py
@@ -1,8 +1,12 @@
 import logging
 import pkgutil
 from collections import OrderedDict
+from functools import lru_cache
+from socket import AF_INET, AF_INET6
 
 import requests
+import requests.packages.urllib3.util.connection as urllib3_connection
+from requests.packages.urllib3.util.connection import allowed_gai_family
 
 from streamlink import __version__, plugins
 from streamlink.compat import is_win32
@@ -10,7 +14,7 @@ from streamlink.exceptions import NoPluginError, PluginError
 from streamlink.logger import StreamlinkLogger
 from streamlink.options import Options
 from streamlink.plugin import Plugin, api
-from streamlink.utils import load_module, memoize, update_scheme
+from streamlink.utils import load_module, update_scheme
 from streamlink.utils.l10n import Localization
 
 # Ensure that the Logger class returned is Streamslink's for using the API (for backwards compatibility)
@@ -29,6 +33,9 @@ class Streamlink:
     def __init__(self, options=None):
         self.http = api.HTTPSession()
         self.options = Options({
+            "interface": None,
+            "ipv4": False,
+            "ipv6": False,
             "hds-live-edge": 10.0,
             "hds-segment-attempts": 3,
             "hds-segment-threads": 1,
@@ -82,7 +89,13 @@ class Streamlink:
         **Available options**:
 
         ======================== =========================================
-        hds-live-edge            ( float) Specify the time live HDS
+        interface                (str) Set the network interface,
+                                 default: ``None``
+        ipv4                     (bool) Resolve address names to IPv4 only.
+                                 This option overrides ipv6, default: ``False``
+        ipv6                     (bool) Resolve address names to IPv6 only.
+                                 This option overrides ipv4, default: ``False``
+        hds-live-edge            (float) Specify the time live HDS
                                  streams will start from the edge of
                                  stream, default: ``10.0``
 
@@ -241,7 +254,29 @@ class Streamlink:
 
         """
 
-        if key == "http-proxy":
+        if key == "interface":
+            for scheme, adapter in self.http.adapters.items():
+                if scheme not in ("http://", "https://"):
+                    continue
+                if not value:
+                    adapter.poolmanager.connection_pool_kw.pop("source_address")
+                else:
+                    adapter.poolmanager.connection_pool_kw.update(
+                        # https://docs.python.org/3/library/socket.html#socket.create_connection
+                        source_address=(value, 0)
+                    )
+            self.options.set(key, None if not value else value)
+
+        elif key == "ipv4" or key == "ipv6":
+            self.options.set(key, value)
+            if value:
+                self.options.set("ipv6" if key == "ipv4" else "ipv4", False)
+                urllib3_connection.allowed_gai_family = \
+                    (lambda: AF_INET) if key == "ipv4" else (lambda: AF_INET6)
+            else:
+                urllib3_connection.allowed_gai_family = allowed_gai_family
+
+        elif key == "http-proxy":
             self.http.proxies["http"] = update_scheme("http://", value)
             if "https" not in self.http.proxies:
                 self.http.proxies["https"] = update_scheme("http://", value)
@@ -337,7 +372,7 @@ class Streamlink:
             plugin = self.plugins[plugin]
             return plugin.get_option(key)
 
-    @memoize
+    @lru_cache(maxsize=128)
     def resolve_url(self, url, follow_redirect=True):
         """Attempts to find a plugin that can use this URL.
 
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):
diff --git a/src/streamlink/utils/__init__.py b/src/streamlink/utils/__init__.py
index 0403c394..f189a5f8 100644
--- a/src/streamlink/utils/__init__.py
+++ b/src/streamlink/utils/__init__.py
@@ -1,4 +1,3 @@
-import functools
 import json
 import re
 import xml.etree.ElementTree as ET
@@ -151,18 +150,6 @@ def rtmpparse(url):
     return tcurl, playpath
 
 
-def memoize(obj):
-    cache = obj.cache = {}
-
-    @functools.wraps(obj)
-    def memoizer(*args, **kwargs):
-        key = str(args) + str(kwargs)
-        if key not in cache:
-            cache[key] = obj(*args, **kwargs)
-        return cache[key]
-    return memoizer
-
-
 def search_dict(data, key):
     """
     Search for a key in a nested dict, or list of nested dicts, and return the values.
diff --git a/src/streamlink/utils/l10n.py b/src/streamlink/utils/l10n.py
index de2c8892..86e2f109 100644
--- a/src/streamlink/utils/l10n.py
+++ b/src/streamlink/utils/l10n.py
@@ -1,6 +1,5 @@
 import locale
 import logging
-import re
 
 
 try:
@@ -68,9 +67,19 @@ class Language:
     def get(cls, language):
         try:
             if PYCOUNTRY:
-                # lookup workaround for alpha_2 language codes
-                lang = languages.get(alpha_2=language) if re.match(r"^[a-z]{2}$", language) else languages.lookup(language)
-                return Language(lang.alpha_2, lang.alpha_3, lang.name, getattr(lang, "bibliographic", None))
+                lang = (languages.get(alpha_2=language)
+                        or languages.get(alpha_3=language)
+                        or languages.get(bibliographic=language)
+                        or languages.get(name=language))
+                if not lang:
+                    raise KeyError(language)
+                return Language(
+                    # some languages don't have an alpha_2 code
+                    getattr(lang, "alpha_2", ""),
+                    lang.alpha_3,
+                    lang.name,
+                    getattr(lang, "bibliographic", "")
+                )
             else:
                 lang = None
                 if len(language) == 2:
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
index 96f3c99b..eb6bc809 100644
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -303,6 +303,28 @@ def build_parser():
         Default is system locale.
         """
     )
+    general.add_argument(
+        "--interface",
+        type=str,
+        metavar="INTERFACE",
+        help="""
+        Set the network interface.
+        """
+    )
+    general.add_argument(
+        "-4", "--ipv4",
+        action="store_true",
+        help="""
+        Resolve address names to IPv4 only. This option overrides :option:`-6`.
+        """
+    )
+    general.add_argument(
+        "-6", "--ipv6",
+        action="store_true",
+        help="""
+        Resolve address names to IPv6 only. This option overrides :option:`-4`.
+        """
+    )
 
     player = parser.add_argument_group("Player options")
     player.add_argument(
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 955fa9c0..0dde8761 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -751,6 +751,15 @@ def setup_streamlink():
 
 def setup_options():
     """Sets Streamlink options."""
+    if args.interface:
+        streamlink.set_option("interface", args.interface)
+
+    if args.ipv4:
+        streamlink.set_option("ipv4", args.ipv4)
+
+    if args.ipv6:
+        streamlink.set_option("ipv6", args.ipv6)
+
     if args.hls_live_edge:
         streamlink.set_option("hls-live-edge", args.hls_live_edge)
 


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)
diff --git a/tests/test_localization.py b/tests/test_localization.py
index 8f6cbde7..b9896a77 100644
--- a/tests/test_localization.py
+++ b/tests/test_localization.py
@@ -109,6 +109,14 @@ class LocalizationTestsMixin:
         b = l10n.Language("AA", None, "Test")
         self.assertNotEqual(a, b)
 
+    # issue #3517: language lookups without alpha2 but with alpha3 codes should not raise
+    def test_language_a3_no_a2(self):
+        a = l10n.Localization.get_language("des")
+        self.assertEqual(a.alpha2, "")
+        self.assertEqual(a.alpha3, "des")
+        self.assertEqual(a.name, "Desano")
+        self.assertEqual(a.bibliographic, "")
+
 
 @unittest.skipIf(not ISO639, "iso639+iso3166 modules are required to test iso639+iso3166 Localization")
 class TestLocalization(LocalizationTestsMixin, unittest.TestCase):
@@ -131,3 +139,14 @@ class TestLocalizationPyCountry(LocalizationTestsMixin, unittest.TestCase):
 
     def test_pycountry(self):
         self.assertEqual(True, l10n.PYCOUNTRY)
+
+    # issue #3057: generic "en" lookups via pycountry yield the "En" language, but not "English"
+    def test_language_en(self):
+        english_a = l10n.Localization.get_language("en")
+        english_b = l10n.Localization.get_language("eng")
+        english_c = l10n.Localization.get_language("English")
+        for lang in [english_a, english_b, english_c]:
+            self.assertEqual(lang.alpha2, "en")
+            self.assertEqual(lang.alpha3, "eng")
+            self.assertEqual(lang.name, "English")
+            self.assertEqual(lang.bibliographic, "")
diff --git a/tests/test_session.py b/tests/test_session.py
index 61bdd64f..57c84d0a 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -1,6 +1,9 @@
 import os
 import unittest
-from unittest.mock import call, patch
+from socket import AF_INET, AF_INET6
+from unittest.mock import Mock, call, patch
+
+from requests.packages.urllib3.util.connection import allowed_gai_family
 
 from streamlink import NoPluginError, Streamlink
 from streamlink.plugin.plugin import HIGH_PRIORITY, LOW_PRIORITY
@@ -68,6 +71,7 @@ class TestSession(unittest.TestCase):
         plugin = session.resolve_url("http://test.se/channel")
         self.assertTrue(isinstance(plugin, Plugin))
         self.assertTrue(isinstance(plugin, plugins["testplugin"]))
+        self.assertTrue(hasattr(session.resolve_url, "cache_info"), "resolve_url has a lookup cache")
 
     def test_resolve_url_priority(self):
         from tests.plugin.testplugin import TestPlugin
@@ -191,6 +195,66 @@ class TestSession(unittest.TestCase):
         self.assertEqual(session.localization.language.alpha2, "en")
         self.assertEqual(session.localization.language_code, "en_US")
 
+    @patch("streamlink.session.api")
+    def test_interface(self, mock_api):
+        adapter_http = Mock(poolmanager=Mock(connection_pool_kw={}))
+        adapter_https = Mock(poolmanager=Mock(connection_pool_kw={}))
+        adapter_foo = Mock(poolmanager=Mock(connection_pool_kw={}))
+        mock_api.HTTPSession.return_value = Mock(adapters={
+            "http://": adapter_http,
+            "https://": adapter_https,
+            "foo://": adapter_foo
+        })
+        session = self.subject(load_plugins=False)
+        self.assertEqual(session.get_option("interface"), None)
+
+        session.set_option("interface", "my-interface")
+        self.assertEqual(adapter_http.poolmanager.connection_pool_kw, {"source_address": ("my-interface", 0)})
+        self.assertEqual(adapter_https.poolmanager.connection_pool_kw, {"source_address": ("my-interface", 0)})
+        self.assertEqual(adapter_foo.poolmanager.connection_pool_kw, {})
+        self.assertEqual(session.get_option("interface"), "my-interface")
+
+        session.set_option("interface", None)
+        self.assertEqual(adapter_http.poolmanager.connection_pool_kw, {})
+        self.assertEqual(adapter_https.poolmanager.connection_pool_kw, {})
+        self.assertEqual(adapter_foo.poolmanager.connection_pool_kw, {})
+        self.assertEqual(session.get_option("interface"), None)
+
+    @patch("streamlink.session.urllib3_connection", allowed_gai_family=allowed_gai_family)
+    def test_ipv4_ipv6(self, mock_urllib3_connection):
+        session = self.subject(load_plugins=False)
+        self.assertEqual(session.get_option("ipv4"), False)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+
+        session.set_option("ipv4", True)
+        self.assertEqual(session.get_option("ipv4"), True)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertNotEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family(), AF_INET)
+
+        session.set_option("ipv4", False)
+        self.assertEqual(session.get_option("ipv4"), False)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+
+        session.set_option("ipv6", True)
+        self.assertEqual(session.get_option("ipv4"), False)
+        self.assertEqual(session.get_option("ipv6"), True)
+        self.assertNotEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family(), AF_INET6)
+
+        session.set_option("ipv6", False)
+        self.assertEqual(session.get_option("ipv4"), False)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+
+        session.set_option("ipv4", True)
+        session.set_option("ipv6", False)
+        self.assertEqual(session.get_option("ipv4"), True)
+        self.assertEqual(session.get_option("ipv6"), False)
+        self.assertEqual(mock_urllib3_connection.allowed_gai_family, allowed_gai_family)
+
     def test_https_proxy_default(self):
         session = self.subject(load_plugins=False)
         session.set_option("http-proxy", "http://testproxy.com")