Repository: streamlink/streamlink
Pull request #3366 (2020-11-26 00:34:54)

session: replace usage of deprecated imp module
Refactor `utils.load_module`:
- move it to the top and add it to `__all__`
- move imports and loader_details to the global scope

Refactor `session`:
- replace plugin imports via `imp` with `utils.load_module`
- fix incorrect plugin module names
  "streamlink.plugin.{name}" -> "streamlink.plugins.{name}"
- use `log.exception()` to print plugin load failures
- remove unused `print_small_exception`

Add / fix tests:
- remove session dependency from test_plugins
- add proper assertions for plugin modules, methods and their signatures
- add tests for invalid and for overridden plugins


----

Based on the changes of #2570

**Things to note**

- I've decided to keep the implementation of `importlib.machinery.FileFinder` in the `utils` module and use that for loading the plugins, as I had some issues with the module names while trying out the `pkgutils`-based imports of #2570, which led to plugins log calls not being recognized by the StreamlinkLogger, as their module names did not begin with `streamlink.`.
- `test_session` is really messy as it imports everything in `tests.plugins`, including all plugin test modules, which are irrelevant there. The actual `testplugin`s should be moved somewhere else. Mocking certain things is also impossible when everything is run in the test's `setUp`, and I couldn't mock the log call of the plugin override without patching the whole test class which would require adding an unused argument to every test method. That's why plugin load failures are also not tested.
- Why does `plugin.api.support_plugin` even exist? It's not being used and it looks totally unnecessary. This module is the reason why `utils.load_module` existed, but now it's being used by `session` as well.


Patch:
diff --git a/docs/_themes/sphinx_rtd_theme_violet/static/css/theme.css b/docs/_themes/sphinx_rtd_theme_violet/static/css/theme.css
index 86f10b22..932607e1 100644
--- a/docs/_themes/sphinx_rtd_theme_violet/static/css/theme.css
+++ b/docs/_themes/sphinx_rtd_theme_violet/static/css/theme.css
@@ -4827,6 +4827,21 @@ table.table-custom-layout tbody tr td {
   white-space: unset;
 }
 
+/* fix CLI arguments */
+.rst-content dl.option:not(.docutils) > dt {
+  float: left;
+}
+.rst-content dl.option:not(.docutils) > dt,
+.rst-content dl.option:not(.docutils) > dd {
+  clear: left;
+}
+.rst-content dl.option:not(.docutils) > dt + dt {
+  margin-top: 0;
+}
+.rst-content dl.option:not(.docutils) > dt > code.descclassname:empty {
+  display: none;
+}
+
 /* github avatar pictures on the donate page */
 .github-avatar {
   float: left;
diff --git a/docs/cli.rst b/docs/cli.rst
index 6058c89c..a085a1ad 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -107,7 +107,7 @@ You can also specify the location yourself using the :option:`--config` option.
 
 .. note::
 
-  On Windows there is a default config created by the installer but on any
+  On Windows, there is a default config created by the installer, but on any
   other platform you must create the file yourself.
 
 
@@ -120,13 +120,13 @@ line in the format::
 
   option=value
 
-or for a option without value::
+or for an option without value::
 
   option
 
 .. note::
-    Any quotes used will be part of the value, so only use when the value needs them,
-    e.g. specifying a player with a path containing spaces.
+    Any quotes used will be part of the value, so only use them when the value needs them,
+    e.g. when specifying a player with a path which contains spaces.
 
 Example
 ^^^^^^^
@@ -141,6 +141,7 @@ Example
     Full player paths are supported via configuration file options such as
     ``player="C:\mpv-x86_64\mpv"``
 
+
 Plugin specific configuration file
 ----------------------------------
 
@@ -163,8 +164,27 @@ Unix-like (POSIX) - $XDG_CONFIG_HOME/streamlink/config\ **.twitch**
 Windows           %APPDATA%\\streamlink\\streamlinkrc\ **.youtube**
 ================= ====================================================
 
-Have a look at the :ref:`list of plugins <plugin_matrix:Plugins>` to see
-the name of each built-in plugin.
+Have a look at the :ref:`list of plugins <plugin_matrix:Plugins>`, or
+check the :option:`--plugins` option to see the name of each built-in plugin.
+
+
+Sideloading plugins
+-------------------
+
+Streamlink will attempt to load standalone plugins from these directories:
+
+================= ====================================================
+Platform          Location
+================= ====================================================
+Unix-like (POSIX) $XDG_CONFIG_HOME/streamlink/plugins
+Windows           %APPDATA%\\streamlink\\plugins
+================= ====================================================
+
+.. note::
+
+    If a plugin is added with the same name as a built-in plugin, then
+    the added plugin will take precedence. This is useful if you want
+    to upgrade plugins independently of the Streamlink version.
 
 
 Plugin specific usage
@@ -196,34 +216,32 @@ might be a good idea to save your username and password in your
 
 .. warning::
 
-    The API this plugin uses isn't supposed to be available to use it on
+    The API this plugin uses isn't supposed to be available on desktop
     computers. The plugin tries to blend in as a valid device using custom
-    headers and following the API usual flow (e.g. reusing credentials), but
+    headers and following the API's usual flow (e.g. reusing credentials), but
     this does not assure that your account will be safe from being spotted for
     unusual behavior.
 
 HTTP proxy with Crunchyroll
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^
-You can use the :option:`--http-proxy` **and** :option:`--https-proxy`
-options (you need both since the plugin uses both protocols) to access the
-Crunchyroll servers through a proxy to be able to stream region locked content.
+To be able to stream region locked content, you can use Streamlink's proxy
+options, which are described in the :ref:`Proxy Support <cli:Proxy Support>` section.
 
-When doing this, it's very probable that you will get denied to access the
-stream; this occurs because the session and credentials used by the plugin
-where obtained when logged from your own region, and the server still assumes
+When doing this, it's possible that access to the stream will still be denied;
+this can happen because the session and credentials used by the plugin
+were obtained while being logged from your own region, and the server still assumes
 you're in that region.
 
-For this, the plugin provides the :option:`--crunchyroll-purge-credentials`
+For cases like this, the plugin provides the :option:`--crunchyroll-purge-credentials`
 option, which removes your saved session and credentials and tries to log
 in again using your username and password.
 
 Authenticating with FunimationNow
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 Like Crunchyroll, the FunimationNow plugin requires authenticating with a premium account to access some
-content: :option:`--funimation-email`, :option:`--funimation-password`. In addition, this plugin requires a ``incap_ses`` cookie to be
-sent with each HTTP request (see issue #2088); this unique session cookie can be found in your browser and sent via the :option:`--http-cookie` option.
-
-For example:
+content: :option:`--funimation-email`, :option:`--funimation-password`. In addition, this plugin requires
+the ``incap_ses`` cookie to be sent with each HTTP request (see issue #2088). This unique session cookie
+can be found in your browser and sent via the :option:`--http-cookie` option.
 
 .. sourcecode:: console
 
@@ -231,29 +249,11 @@ For example:
 
 .. note::
 
-    There are multiple ways to retrieve the required cookie.  For more
+    There are multiple ways to retrieve the required cookie. For more
     information on browser cookies, please consult the following:
 
     - `What are cookies? <https://en.wikipedia.org/wiki/HTTP_cookie>`_
 
-Sideloading plugins
--------------------
-
-Streamlink will attempt to load standalone plugins from these directories:
-
-================= ====================================================
-Platform          Location
-================= ====================================================
-Unix-like (POSIX) $XDG_CONFIG_HOME/streamlink/plugins
-Windows           %APPDATA%\\streamlink\\plugins
-================= ====================================================
-
-.. note::
-
-    If a plugin is added with the same name as a built-in plugin then
-    the added plugin will take precedence. This is useful if you want
-    to upgrade plugins independently of the Streamlink version.
-
 
 Playing built-in streaming protocols directly
 ---------------------------------------------
@@ -274,35 +274,35 @@ Accessing a stream that requires extra parameters to be passed along
 
     $ streamlink "rtmp://streaming.server.net/playpath live=1 swfVfy=http://server.net/flashplayer.swf"
 
-When passing parameters to the built-in stream plugins the values will either be treated as plain
-strings, as is the case in the above example for ``swfVry``, or they will be interpreted as Python literals. For
-example you can pass a Python dict or Python list as one of the parameters.
+When passing parameters to the built-in stream plugins, the values will either
+be treated as plain strings, as is the case in the example above for ``swfVry``,
+or they will be interpreted as Python literals. For example, you can pass a
+Python dict or Python list as one of the parameters.
 
 .. code-block:: console
 
     $ streamlink "rtmp://streaming.server.net/playpath conn=['B:1', 'S:authMe', 'O:1', 'NN:code:1.23', 'NS:flag:ok', 'O:0']"
     $ streamlink "hls://streaming.server.net/playpath params={'token': 'magicToken'}"
 
-In the above examples ``conn`` will be passed as the Python list:
+In the examples above, ``conn`` will be passed as a Python list:
 
 .. code-block:: python
 
     ['B:1', 'S:authMe', 'O:1', 'NN:code:1.23', 'NS:flag:ok', 'O:0']
 
-and ``params`` will be passed as the Python dict:
+and ``params`` will be passed as a Python dict:
 
 .. code-block:: python
 
     {'token': 'magicToken'}
 
-Most streaming technologies simply requires you to pass a HTTP URL, this is
-a Adobe HDS stream:
+Most streaming protocols only require you to pass a simple URL.
+This is an Adobe HDS stream:
 
 .. code-block:: console
 
     $ streamlink hds://streaming.server.net/playpath/manifest.f4m
 
-
 Supported streaming protocols
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
@@ -320,28 +320,28 @@ Progressive HTTP, HTTPS, etc   httpstream:// [1]_
 .. [1] supports local files using the file:// protocol
 .. [2] Dynamic Adaptive Streaming over HTTP
 
+
 Proxy Support
 -------------
 
-You can use the :option:`--http-proxy` and :option:`--https-proxy` options to
+You can use the :option:`--http-proxy` or :option:`--https-proxy` options to
 change the proxy server that Streamlink will use for HTTP and HTTPS requests respectively.
-As HTTP and HTTPS requests can be handled by separate proxies, you may need to specify both
-options if the plugin you use makes HTTP and HTTPS requests.
+For convenience reasons, :option:`--http-proxy` will automatically set the
+value of :option:`--https-proxy` as well, if it has not been set by the user.
 
-Both HTTP and SOCKS proxies are supported, authentication is supported for both types.
+Both HTTP and SOCKS proxies are supported, as well as authentication in each of them.
 
 .. note::
-    When using a SOCKS proxy the ``socks4`` and ``socks5`` schemes mean that DNS lookups are done
+    When using a SOCKS proxy, the ``socks4`` and ``socks5`` schemes mean that DNS lookups are done
     locally, rather than on the proxy server. To have the proxy server perform the DNS lookups, the
     ``socks4a`` and ``socks5h`` schemes should be used instead.
 
-For example:
-
 .. code-block:: console
 
     $ streamlink --http-proxy "http://user:pass@10.10.1.10:3128/" --https-proxy "socks5://10.10.1.10:1242"
     $ streamlink --http-proxy "socks4a://10.10.1.10:1235" --https-proxy "socks5h://10.10.1.10:1234"
 
+
 Command-line usage
 ------------------
 
diff --git a/docs/ext_argparse.py b/docs/ext_argparse.py
index b81f2c74..2e721523 100644
--- a/docs/ext_argparse.py
+++ b/docs/ext_argparse.py
@@ -108,7 +108,9 @@ class ArgparseDirective(Directive):
             else:
                 options += action.option_strings
 
-            yield f".. option:: {', '.join(options)}"
+            directive = ".. option:: "
+            options = f"\n{' ' * len(directive)}".join(options)
+            yield f"{directive}{options}"
             yield ""
             for line in self.process_help(action.help).split("\n"):
                 yield line
diff --git a/docs/plugin_matrix.rst b/docs/plugin_matrix.rst
index 5eda7bb1..f5da6bf4 100644
--- a/docs/plugin_matrix.rst
+++ b/docs/plugin_matrix.rst
@@ -139,6 +139,7 @@ piczel                  piczel.tv            Yes   No
 pixiv                   sketch.pixiv.net     Yes   --
 playtv                  - playtv.fr          Yes   --    Streams may be geo-restricted to France.
                         - play.tv
+pluto                   pluto.tv             Yes   Yes
 pluzz                   - france.tv          Yes   Yes   Streams may be geo-restricted to France, Andorra and Monaco.
                         - ludo.fr
                         - zouzous.fr
diff --git a/src/streamlink/buffers.py b/src/streamlink/buffers.py
index 17e6a5c4..af56b9b4 100644
--- a/src/streamlink/buffers.py
+++ b/src/streamlink/buffers.py
@@ -15,7 +15,7 @@ class Chunk(BytesIO):
         return self.tell() == self.length
 
 
-class Buffer(object):
+class Buffer:
     """Simple buffer for use in single-threaded consumer/filler.
 
     Stores chunks in a deque to avoid inefficient reallocating
@@ -107,7 +107,7 @@ class RingBuffer(Buffer):
 
             # If the event is still not set it's a timeout
             if not self.event_used.is_set() and self.length == 0:
-                raise IOError("Read timeout")
+                raise OSError("Read timeout")
 
         return self._read(size)
 
diff --git a/src/streamlink/cache.py b/src/streamlink/cache.py
index 280068f0..a86aa0ce 100644
--- a/src/streamlink/cache.py
+++ b/src/streamlink/cache.py
@@ -14,7 +14,7 @@ else:
 cache_dir = os.path.join(xdg_cache, "streamlink")
 
 
-class Cache(object):
+class Cache:
     """Caches Python values as JSON and prunes expired entries."""
 
     def __init__(self, filename, key_prefix=""):
@@ -59,7 +59,7 @@ class Cache(object):
                 os.makedirs(os.path.dirname(self.filename))
 
             shutil.move(tempname, self.filename)
-        except (IOError, OSError):
+        except OSError:
             os.remove(tempname)
 
     def set(self, key, value, expires=60 * 60 * 24 * 7, expires_at=None):
diff --git a/src/streamlink/logger.py b/src/streamlink/logger.py
index 71ec7b4c..311431ab 100644
--- a/src/streamlink/logger.py
+++ b/src/streamlink/logger.py
@@ -4,9 +4,16 @@ from logging import CRITICAL, DEBUG, ERROR, INFO, NOTSET, WARN
 from threading import Lock
 
 TRACE = 5
-_levelToName = dict([(CRITICAL, "critical"), (ERROR, "error"), (WARN, "warning"), (INFO, "info"), (DEBUG, "debug"),
-                     (TRACE, "trace"), (NOTSET, "none")])
-_nameToLevel = dict([(name, level) for level, name in _levelToName.items()])
+_levelToName = {
+    CRITICAL: "critical",
+    ERROR: "error",
+    WARN: "warning",
+    INFO: "info",
+    DEBUG: "debug",
+    TRACE: "trace",
+    NOTSET: "none",
+}
+_nameToLevel = {name: level for level, name in _levelToName.items()}
 
 for level, name in _levelToName.items():
     logging.addLevelName(level, name)
diff --git a/src/streamlink/options.py b/src/streamlink/options.py
index 4c3f57a9..4798fad0 100644
--- a/src/streamlink/options.py
+++ b/src/streamlink/options.py
@@ -9,7 +9,7 @@ def _normalise_argument_name(name):
     return name.replace('_', '-').strip("-")
 
 
-class Options(object):
+class Options:
     """
     For storing options to be used by plugins, with default values.
 
@@ -43,7 +43,7 @@ class Options(object):
             self.set(key, value)
 
 
-class Argument(object):
+class Argument:
     """
         :class:`Argument` accepts most of the same parameters as :func:`ArgumentParser.add_argument`,
         except requires is a special case as in this case it is only enforced if the plugin is in use.
@@ -93,7 +93,7 @@ class Argument(object):
         return self._default
 
 
-class Arguments(object):
+class Arguments:
     """
     Provides a wrapper around a list of :class:`Argument`. For example
 
@@ -132,7 +132,7 @@ class Arguments(object):
 
         :return: list of dependant arguments
         """
-        results = set([name])
+        results = {name}
         argument = self.get(name)
         for reqname in argument.requires:
             required = self.get(reqname)
diff --git a/src/streamlink/plugin/api/mapper.py b/src/streamlink/plugin/api/mapper.py
index 6cd73837..a3a1a7d3 100644
--- a/src/streamlink/plugin/api/mapper.py
+++ b/src/streamlink/plugin/api/mapper.py
@@ -3,7 +3,7 @@ from itertools import product
 from operator import eq
 
 
-class StreamMapper(object):
+class StreamMapper:
     """The stream mapper can be used to simplify the process of creating
        stream objects from data.
 
diff --git a/src/streamlink/plugin/api/utils.py b/src/streamlink/plugin/api/utils.py
index d64f11ba..d0287ebf 100644
--- a/src/streamlink/plugin/api/utils.py
+++ b/src/streamlink/plugin/api/utils.py
@@ -24,5 +24,5 @@ def itertags(html, tag):
     """
     for match in tag_re.finditer(html):
         if match.group("tag") == tag:
-            attrs = dict((a.group("key").lower(), a.group("value")) for a in attr_re.finditer(match.group("attr")))
+            attrs = {a.group("key").lower(): a.group("value") for a in attr_re.finditer(match.group("attr"))}
             yield Tag(match.group("tag"), attrs, match.group("inner"))
diff --git a/src/streamlink/plugin/api/validate.py b/src/streamlink/plugin/api/validate.py
index 62eb2b77..3d2a0385 100644
--- a/src/streamlink/plugin/api/validate.py
+++ b/src/streamlink/plugin/api/validate.py
@@ -59,19 +59,19 @@ class all(tuple):
         return super().__new__(cls, args)
 
 
-class SchemaContainer(object):
+class SchemaContainer:
     def __init__(self, schema):
         self.schema = schema
 
 
-class transform(object):
+class transform:
     """Applies function to value to transform it."""
 
     def __init__(self, func):
         self.func = func
 
 
-class optional(object):
+class optional:
     """An optional key used in a dict or union-dict."""
 
     def __init__(self, key):
@@ -86,7 +86,7 @@ class attr(SchemaContainer):
     """Validates an object's attributes."""
 
 
-class xml_element(object):
+class xml_element:
     """A XML element."""
 
     def __init__(self, tag=None, text=None, attrib=None):
@@ -454,7 +454,7 @@ def validate_unions(schema, value):
     return validate_union(schema.schema, value)
 
 
-class Schema(object):
+class Schema:
     """Wraps a validator schema into a object."""
 
     def __init__(self, *schemas):
diff --git a/src/streamlink/plugin/plugin.py b/src/streamlink/plugin/plugin.py
index 11484dc2..4a8751cc 100644
--- a/src/streamlink/plugin/plugin.py
+++ b/src/streamlink/plugin/plugin.py
@@ -157,7 +157,7 @@ def parse_params(params):
     return rval
 
 
-class UserInputRequester(object):
+class UserInputRequester:
     """
     Base Class / Interface for requesting user input
 
@@ -184,7 +184,7 @@ class UserInputRequester(object):
         raise NotImplementedError
 
 
-class Plugin(object):
+class Plugin:
     """A plugin can retrieve stream information from the URL specified.
 
     :param url: URL that the plugin will operate on
@@ -323,7 +323,7 @@ class Plugin(object):
                 ostreams = list(ostreams)
         except NoStreamsError:
             return {}
-        except (IOError, OSError, ValueError) as err:
+        except (OSError, ValueError) as err:
             raise PluginError(err)
 
         if not ostreams:
@@ -510,7 +510,7 @@ class Plugin(object):
         if self._user_input_requester:
             try:
                 return self._user_input_requester.ask(prompt)
-            except IOError as e:
+            except OSError as e:
                 raise FatalPluginError("User input error: {0}".format(e))
             except NotImplementedError:  # ignore this and raise a FatalPluginError
                 pass
@@ -520,7 +520,7 @@ class Plugin(object):
         if self._user_input_requester:
             try:
                 return self._user_input_requester.ask_password(prompt)
-            except IOError as e:
+            except OSError as e:
                 raise FatalPluginError("User input error: {0}".format(e))
             except NotImplementedError:  # ignore this and raise a FatalPluginError
                 pass
diff --git a/src/streamlink/plugins/ard_mediathek.py b/src/streamlink/plugins/ard_mediathek.py
index deaba729..a9be8f2c 100644
--- a/src/streamlink/plugins/ard_mediathek.py
+++ b/src/streamlink/plugins/ard_mediathek.py
@@ -81,9 +81,8 @@ class ard_mediathek(Plugin):
                     log.error("Unexpected stream type: '{0}'".format(stream_))
 
                 try:
-                    for s in parser(stream):
-                        yield s
-                except IOError as err:
+                    yield from parser(stream)
+                except OSError as err:
                     log.error("Failed to extract {0} streams: {1}".format(parser_name, err))
 
 
diff --git a/src/streamlink/plugins/artetv.py b/src/streamlink/plugins/artetv.py
index 594a2e77..c6b2070b 100644
--- a/src/streamlink/plugins/artetv.py
+++ b/src/streamlink/plugins/artetv.py
@@ -54,7 +54,7 @@ class ArteTV(Plugin):
                     try:
                         streams = HLSStream.parse_variant_playlist(self.session, stream["url"])
                         yield from streams.items()
-                    except IOError as err:
+                    except OSError as err:
                         log.warning(f"Failed to extract HLS streams for {sname}/{stream['versionLibelle']}: {err}")
 
     def _get_streams(self):
diff --git a/src/streamlink/plugins/bbciplayer.py b/src/streamlink/plugins/bbciplayer.py
index de730087..c8be9c71 100644
--- a/src/streamlink/plugins/bbciplayer.py
+++ b/src/streamlink/plugins/bbciplayer.py
@@ -134,17 +134,11 @@ class BBCiPlayer(Plugin):
             for url in list(urls):
                 try:
                     if stream_type == "hds":
-                        for s in HDSStream.parse_manifest(self.session,
-                                                          url).items():
-                            yield s
+                        yield from HDSStream.parse_manifest(self.session, url).items()
                     if stream_type == "hls":
-                        for s in HLSStream.parse_variant_playlist(self.session,
-                                                                  url).items():
-                            yield s
+                        yield from HLSStream.parse_variant_playlist(self.session, url).items()
                     if stream_type == "dash":
-                        for s in DASHStream.parse_manifest(self.session,
-                                                           url).items():
-                            yield s
+                        yield from DASHStream.parse_manifest(self.session, url).items()
                     log.debug(f"  OK:   {url}")
                 except Exception:
                     log.debug(f"  FAIL: {url}")
@@ -208,8 +202,7 @@ class BBCiPlayer(Plugin):
             vpid = self.find_vpid(self.url)
             if vpid:
                 log.debug(f"Found VPID: {vpid}")
-                for s in self.mediaselector(vpid):
-                    yield s
+                yield from self.mediaselector(vpid)
             else:
                 log.error(f"Could not find VPID for episode {episode_id}")
         elif channel_name:
@@ -219,8 +212,7 @@ class BBCiPlayer(Plugin):
                 if tvip:
                     log.debug(f"Trying HD stream {tvip}...")
                     try:
-                        for s in self.mediaselector(tvip):
-                            yield s
+                        yield from self.mediaselector(tvip)
                     except PluginError:
                         log.error("Failed to get HD streams, falling back to SD")
                     else:
@@ -228,8 +220,7 @@ class BBCiPlayer(Plugin):
             tvip = self.find_tvip(self.url)
             if tvip:
                 log.debug(f"Found TVIP: {tvip}")
-                for s in self.mediaselector(tvip):
-                    yield s
+                yield from self.mediaselector(tvip)
 
 
 __plugin__ = BBCiPlayer
diff --git a/src/streamlink/plugins/brightcove.py b/src/streamlink/plugins/brightcove.py
index 6edb9cb7..eba7b9af 100644
--- a/src/streamlink/plugins/brightcove.py
+++ b/src/streamlink/plugins/brightcove.py
@@ -39,7 +39,7 @@ class ContentOverride(AMF3ObjectBase):
         self.contentRefId = contentRefId
 
 
-class BrightcovePlayer(object):
+class BrightcovePlayer:
     player_page = "http://players.brightcove.net/{account_id}/{player_id}/index.html"
     api_url = "https://edge.api.brightcove.com/playback/v1/"
     amf_broker = "http://c.brightcove.com/services/messagebroker/amf"
diff --git a/src/streamlink/plugins/canalplus.py b/src/streamlink/plugins/canalplus.py
index 02892787..21385137 100644
--- a/src/streamlink/plugins/canalplus.py
+++ b/src/streamlink/plugins/canalplus.py
@@ -75,16 +75,18 @@ class CanalPlus(Plugin):
             try:
                 # HDS streams don't seem to work for live videos
                 if '.f4m' in video_url and 'LIVE' not in videos['TYPE']:
-                    for stream in HDSStream.parse_manifest(self.session,
-                                                           video_url,
-                                                           params={'hdcore': self.HDCORE_VERSION},
-                                                           headers=headers).items():
-                        yield stream
+                    yield from HDSStream.parse_manifest(
+                        self.session,
+                        video_url,
+                        params={'hdcore': self.HDCORE_VERSION},
+                        headers=headers
+                    ).items()
                 elif '.m3u8' in video_url:
-                    for stream in HLSStream.parse_variant_playlist(self.session,
-                                                                   video_url,
-                                                                   headers=headers).items():
-                        yield stream
+                    yield from HLSStream.parse_variant_playlist(
+                        self.session,
+                        video_url,
+                        headers=headers
+                    ).items()
                 elif '.mp4' in video_url:
                     # Get bitrate from video filename
                     match = self._mp4_bitrate_re.match(video_url)
@@ -96,7 +98,7 @@ class CanalPlus(Plugin):
                                               video_url,
                                               params={'secret': self.SECRET},
                                               headers=headers)
-            except IOError as err:
+            except OSError as err:
                 if '403 Client Error' in str(err):
                     log.error('Failed to access stream, may be due to geo-restriction')
 
diff --git a/src/streamlink/plugins/ceskatelevize.py b/src/streamlink/plugins/ceskatelevize.py
index 63a4c45a..adb9edd8 100644
--- a/src/streamlink/plugins/ceskatelevize.py
+++ b/src/streamlink/plugins/ceskatelevize.py
@@ -160,7 +160,7 @@ class Ceskatelevize(Plugin):
         return 'http://ceskatelevize.cz/' + url
 
 
-class CeskatelevizeAPI2(object):
+class CeskatelevizeAPI2:
     _player_api = 'https://playlist.ceskatelevize.cz/'
     _url_re = re.compile(r'http(s)?://([^.]*.)?ceskatelevize.cz')
     _playlist_info_re = re.compile(r'{\s*"type":\s*"([a-z]+)",\s*"id":\s*"(\w+)"')
diff --git a/src/streamlink/plugins/common_swf.py b/src/streamlink/plugins/common_swf.py
index 6d444085..0848c66d 100644
--- a/src/streamlink/plugins/common_swf.py
+++ b/src/streamlink/plugins/common_swf.py
@@ -36,7 +36,7 @@ def read_tags(fd):
     while True:
         try:
             yield read_tag(fd)
-        except IOError:
+        except OSError:
             break
 
 
diff --git a/src/streamlink/plugins/crunchyroll.py b/src/streamlink/plugins/crunchyroll.py
index 0df1afef..26112074 100644
--- a/src/streamlink/plugins/crunchyroll.py
+++ b/src/streamlink/plugins/crunchyroll.py
@@ -97,7 +97,7 @@ class CrunchyrollAPIError(Exception):
         self.code = code
 
 
-class CrunchyrollAPI(object):
+class CrunchyrollAPI:
     _api_url = "https://api.crunchyroll.com/{0}.0.json"
     _default_locale = "en_US"
     _user_agent = "Dalvik/1.6.0 (Linux; U; Android 4.4.2; Android SDK built for x86 Build/KK)"
diff --git a/src/streamlink/plugins/delfi.py b/src/streamlink/plugins/delfi.py
index 88e5b739..6558d2e2 100644
--- a/src/streamlink/plugins/delfi.py
+++ b/src/streamlink/plugins/delfi.py
@@ -57,8 +57,7 @@ class Delfi(Plugin):
             if div.attributes.get("data-provider") == "dvideo":
                 video_id = div.attributes.get("data-id")
                 log.debug("Found video ID: {0}".format(video_id))
-                for s in self._get_streams_api(video_id):
-                    yield s
+                yield from self._get_streams_api(video_id)
 
 
 __plugin__ = Delfi
diff --git a/src/streamlink/plugins/filmon.py b/src/streamlink/plugins/filmon.py
index 42e28bf1..f61d8e68 100644
--- a/src/streamlink/plugins/filmon.py
+++ b/src/streamlink/plugins/filmon.py
@@ -123,7 +123,7 @@ class FilmOnHLS(HLSStream):
         return reader
 
 
-class FilmOnAPI(object):
+class FilmOnAPI:
     def __init__(self, session):
         self.session = session
 
@@ -149,17 +149,24 @@ class FilmOnAPI(object):
 
     def channel(self, channel):
         for _ in range(5):
+            if _ > 0:
+                log.debug("channel sleep {0}".format(_))
+                time.sleep(0.75)
+
             # retry for 50X errors
             try:
                 res = self.session.http.get(self.channel_url.format(channel))
                 if res:
-                    break
+                    # retry for invalid response data
+                    try:
+                        return self.session.http.json(res, schema=self.api_schema)
+                    except PluginError:
+                        log.debug("invalid or non-JSON data received")
+                        continue
             except Exception:
-                log.debug("channel sleep {0}".format(_))
-                time.sleep(0.75)
-        else:
-            raise PluginError("Unable to find 'self.api.channel' for {0}".format(channel))
-        return self.session.http.json(res, schema=self.api_schema)
+                log.debug("invalid server response")
+
+        raise PluginError("Unable to find 'self.api.channel' for {0}".format(channel))
 
     def vod(self, vod_id):
         res = self.session.http.get(self.vod_url.format(vod_id))
diff --git a/src/streamlink/plugins/funimationnow.py b/src/streamlink/plugins/funimationnow.py
index c7391e0e..370c6ba0 100644
--- a/src/streamlink/plugins/funimationnow.py
+++ b/src/streamlink/plugins/funimationnow.py
@@ -11,7 +11,7 @@ from streamlink.stream.ffmpegmux import MuxedStream
 log = logging.getLogger(__name__)
 
 
-class Experience(object):
+class Experience:
     CSRF_NAME = "csrfmiddlewaretoken"
     login_url = "https://www.funimation.com/log-in/"
     api_base = "https://www.funimation.com/api"
diff --git a/src/streamlink/plugins/gulli.py b/src/streamlink/plugins/gulli.py
index 00f86d6a..c41f8639 100644
--- a/src/streamlink/plugins/gulli.py
+++ b/src/streamlink/plugins/gulli.py
@@ -78,7 +78,7 @@ class Gulli(Plugin):
                     else:
                         bitrate = 'vod'
                     yield bitrate, HTTPStream(self.session, video_url)
-            except IOError as err:
+            except OSError as err:
                 if '403 Client Error' in str(err):
                     log.error('Failed to access stream, may be due to geo-restriction')
                 raise
diff --git a/src/streamlink/plugins/hitbox.py b/src/streamlink/plugins/hitbox.py
index ada776d5..11c79e34 100644
--- a/src/streamlink/plugins/hitbox.py
+++ b/src/streamlink/plugins/hitbox.py
@@ -101,7 +101,7 @@ class Hitbox(Plugin):
             try:
                 streams = HLSStream.parse_variant_playlist(self.session, url)
                 return streams.items()
-            except IOError as err:
+            except OSError as err:
                 log.warning("Failed to extract HLS streams: {0}".format(err))
         else:
             return quality, HLSStream(self.session, url)
@@ -152,7 +152,7 @@ class Hitbox(Plugin):
         if bitrate["label"].lower() == "auto":
             try:
                 return cls.parse_variant_playlist(self.session, url).items()
-            except IOError as err:
+            except OSError as err:
                 log.warning("Failed to extract HLS streams: {0}".format(err))
                 return
 
diff --git a/src/streamlink/plugins/ltv_lsm_lv.py b/src/streamlink/plugins/ltv_lsm_lv.py
index 71f8cc68..28b9a04f 100644
--- a/src/streamlink/plugins/ltv_lsm_lv.py
+++ b/src/streamlink/plugins/ltv_lsm_lv.py
@@ -40,9 +40,7 @@ class LtvLsmLv(Plugin):
                 stream_url = source.attributes.get("src")
                 url_path = urlparse(stream_url).path
                 if url_path.endswith(".m3u8"):
-                    for s in HLSStream.parse_variant_playlist(self.session,
-                                                              stream_url).items():
-                        yield s
+                    yield from HLSStream.parse_variant_playlist(self.session, stream_url).items()
                 else:
                     log.debug("Not used URL path: {0}".format(url_path))
 
diff --git a/src/streamlink/plugins/mrtmk.py b/src/streamlink/plugins/mrtmk.py
index 787f14a1..a671e251 100644
--- a/src/streamlink/plugins/mrtmk.py
+++ b/src/streamlink/plugins/mrtmk.py
@@ -37,7 +37,7 @@ class MRTmk(Plugin):
         for stream_url in stream_urls:
             try:
                 yield from HLSStream.parse_variant_playlist(self.session, stream_url).items()
-            except IOError as err:
+            except OSError as err:
                 if "403 Client Error" in str(err):
                     log.error("Failed to access stream, may be due to geo-restriction")
                 else:
diff --git a/src/streamlink/plugins/nicolive.py b/src/streamlink/plugins/nicolive.py
index 163f2222..5c307479 100644
--- a/src/streamlink/plugins/nicolive.py
+++ b/src/streamlink/plugins/nicolive.py
@@ -322,14 +322,14 @@ class NicoLive(Plugin):
                 except Exception as e:
                     _log.debug(e)
                     msg = "unknown reason"
-                _log.warn("Login failed. {0}".format(msg))
+                _log.warning("Login failed. {0}".format(msg))
                 return False
             else:
                 _log.info("Logged in.")
                 self.save_cookies()
                 return True
         else:
-            _log.warn(
+            _log.warning(
                 "Neither a email and password combination nor a user session "
                 "token is provided. Cannot attempt login.")
             return False
diff --git a/src/streamlink/plugins/onetv.py b/src/streamlink/plugins/onetv.py
index a62c2bb3..767f9999 100644
--- a/src/streamlink/plugins/onetv.py
+++ b/src/streamlink/plugins/onetv.py
@@ -51,7 +51,7 @@ class OneTV(Plugin):
         res = self.session.http.get(update_scheme(self.url, self._session_api))
         data = self.session.http.json(res)
         # the values are already quoted, we don't want them quoted
-        return dict((k, unquote(v)) for k, v in data.items())
+        return {k: unquote(v) for k, v in data.items()}
 
     @property
     def is_live(self):
diff --git a/src/streamlink/plugins/pixiv.py b/src/streamlink/plugins/pixiv.py
index d8b105de..cfe5122b 100644
--- a/src/streamlink/plugins/pixiv.py
+++ b/src/streamlink/plugins/pixiv.py
@@ -110,7 +110,10 @@ class Pixiv(Plugin):
         yield from HLSStream.parse_variant_playlist(self.session, hls_url).items()
 
     def get_streamer_data(self):
-        res = self.session.http.get(self.api_lives)
+        headers = {
+            "X-Requested-With": "https://sketch.pixiv.net/lives",
+        }
+        res = self.session.http.get(self.api_lives, headers=headers)
         data = self.session.http.json(res, schema=self._data_lives_schema)
         log.debug("Found {0} streams".format(len(data)))
 
diff --git a/src/streamlink/plugins/pluto.py b/src/streamlink/plugins/pluto.py
new file mode 100644
index 00000000..0bc70fa7
--- /dev/null
+++ b/src/streamlink/plugins/pluto.py
@@ -0,0 +1,107 @@
+import logging
+import re
+from uuid import uuid4
+
+from streamlink.plugin import Plugin
+from streamlink.plugin.api import validate
+from streamlink.stream import HLSStream
+from streamlink.stream.ffmpegmux import MuxedStream
+from streamlink.utils.url import update_qsd
+
+log = logging.getLogger(__name__)
+
+
+class Pluto(Plugin):
+    _re_url = re.compile(r'''^https?://(?:www\.)?pluto\.tv/(?:
+        live-tv/(?P<slug_live>[^/?]+)/?$
+        |
+        on-demand/series/(?P<slug_series>[^/]+)/season/\d+/episode/(?P<slug_episode>[^/]+)$
+        |
+        on-demand/movies/(?P<slug_movies>[^/]+)$
+    )''', re.VERBOSE)
+
+    title = None
+
+    @classmethod
+    def can_handle_url(cls, url):
+        return cls._re_url.match(url) is not None
+
+    def get_title(self):
+        return self.title
+
+    def _schema_media(self, slug):
+        return validate.Schema(
+            [{
+                'name': str,
+                'slug': str,
+                validate.optional('stitched'): {
+                    'urls': [
+                        {
+                            'type': str,
+                            'url': validate.url(),
+                        }
+                    ]
+                }
+            }],
+            validate.filter(lambda k: k['slug'].lower() == slug.lower()),
+            validate.get(0),
+        )
+
+    def _get_streams(self):
+        data = None
+
+        m = self._re_url.match(self.url).groupdict()
+        if m['slug_live']:
+            res = self.session.http.get('https://api.pluto.tv/v2/channels')
+            data = self.session.http.json(res,
+                                          schema=self._schema_media(m['slug_live']))
+        elif m['slug_series'] and m['slug_episode']:
+            res = self.session.http.get(f'http://api.pluto.tv/v3/vod/slugs/{m["slug_series"]}')
+            data = self.session.http.json(
+                res,
+                schema=validate.Schema(
+                    {'seasons': validate.all(
+                        [{'episodes': self._schema_media(m['slug_episode'])}],
+                        validate.filter(lambda k: k['episodes'] is not None))},
+                    validate.get('seasons'),
+                    validate.get(0),
+                    validate.any(None, validate.get('episodes'))
+                ),
+            )
+        elif m['slug_movies']:
+            res = self.session.http.get('https://api.pluto.tv/v3/vod/categories',
+                                        params={'includeItems': 'true', 'deviceType': 'web'})
+            data = self.session.http.json(
+                res,
+                schema=validate.Schema(
+                    {'categories': validate.all(
+                        [{'items': self._schema_media(m['slug_movies'])}],
+                        validate.filter(lambda k: k['items'] is not None))},
+                    validate.get('categories'),
+                    validate.get(0),
+                    validate.any(None, validate.get('items')),
+                ),
+            )
+
+        log.trace(f'{data!r}')
+        if data is None or not data.get('stitched'):
+            return
+
+        self.title = data['name']
+        stream_url_no_sid = data['stitched']['urls'][0]['url']
+        device_id = str(uuid4())
+        stream_url = update_qsd(stream_url_no_sid, {
+            'deviceId': device_id,
+            'sid': device_id,
+            'deviceType': 'web',
+            'deviceMake': 'Firefox',
+            'deviceModel': 'Firefox',
+            'appName': 'web',
+        })
+
+        self.session.set_option('ffmpeg-fout', 'mpegts')
+        for q, s in HLSStream.parse_variant_playlist(self.session, stream_url).items():
+            yield q, MuxedStream(self.session, s)
+
+
+__plugin__ = Pluto
diff --git a/src/streamlink/plugins/rtbf.py b/src/streamlink/plugins/rtbf.py
index d8727c4e..f9925f2c 100644
--- a/src/streamlink/plugins/rtbf.py
+++ b/src/streamlink/plugins/rtbf.py
@@ -170,7 +170,7 @@ class RTBF(Plugin):
                     dash_url = self.tokenize_stream(dash_url)
                 yield from DASHStream.parse_manifest(self.session, dash_url).items()
 
-        except IOError as err:
+        except OSError as err:
             if '403 Client Error' in str(err):
                 # Check whether video is expired
                 if 'startDate' in stream_data:
diff --git a/src/streamlink/plugins/rtve.py b/src/streamlink/plugins/rtve.py
index 357d5511..7054f9af 100644
--- a/src/streamlink/plugins/rtve.py
+++ b/src/streamlink/plugins/rtve.py
@@ -15,7 +15,7 @@ from streamlink.utils import parse_xml
 log = logging.getLogger(__name__)
 
 
-class ZTNRClient(object):
+class ZTNRClient:
     base_url = "http://ztnr.rtve.es/ztnr/res/"
     block_size = 16
 
@@ -142,7 +142,7 @@ class Rtve(Plugin):
                     if ".m3u8" in url:
                         try:
                             streams.extend(HLSStream.parse_variant_playlist(self.session, url).items())
-                        except (IOError, OSError) as err:
+                        except OSError as err:
                             log.error(str(err))
                     elif ((url.endswith("mp4") or url.endswith("mov") or url.endswith("avi"))
                           and self.session.http.head(url, raise_for_status=False).status_code == 200):
diff --git a/src/streamlink/plugins/sbscokr.py b/src/streamlink/plugins/sbscokr.py
index 15cd257c..59cf3d00 100644
--- a/src/streamlink/plugins/sbscokr.py
+++ b/src/streamlink/plugins/sbscokr.py
@@ -100,9 +100,7 @@ class SBScokr(Plugin):
 
         for media in res['source']['mediasourcelist']:
             if media['mediaurl']:
-                for s in HLSStream.parse_variant_playlist(self.session,
-                                                          media['mediaurl']).items():
-                    yield s
+                yield from HLSStream.parse_variant_playlist(self.session, media['mediaurl']).items()
         else:
             if res['info']['onair_yn'] != 'Y':
                 log.error('This channel is currently unavailable')
diff --git a/src/streamlink/plugins/schoolism.py b/src/streamlink/plugins/schoolism.py
index 910c3ee7..9c0ee93f 100644
--- a/src/streamlink/plugins/schoolism.py
+++ b/src/streamlink/plugins/schoolism.py
@@ -120,11 +120,14 @@ class Schoolism(Plugin):
                                                      headers={"User-Agent": useragents.SAFARI_8,
                                                               "Referer": self.url})
                         elif source['type'] == "application/x-mpegurl":
-                            for s in HLSStream.parse_variant_playlist(self.session,
-                                                                      source["src"],
-                                                                      headers={"User-Agent": useragents.SAFARI_8,
-                                                                               "Referer": self.url}).items():
-                                yield s
+                            yield from HLSStream.parse_variant_playlist(
+                                self.session,
+                                source["src"],
+                                headers={
+                                    "User-Agent": useragents.SAFARI_8,
+                                    "Referer": self.url
+                                }
+                            ).items()
 
             if not found:
                 log.error(f"Could not find {video_type} Part {part}")
diff --git a/src/streamlink/plugins/showroom.py b/src/streamlink/plugins/showroom.py
index a0efb693..9da07b56 100644
--- a/src/streamlink/plugins/showroom.py
+++ b/src/streamlink/plugins/showroom.py
@@ -61,7 +61,7 @@ _quality_weights = {
     "low": 160
 }
 # pages that definitely aren't rooms
-_info_pages = set((
+_info_pages = {
     "onlive",
     "campaign",
     "timetable",
@@ -78,7 +78,7 @@ _info_pages = set((
     "s",
     "organizer_registration",
     "lottery"
-))
+}
 
 
 class ShowroomHLSStreamWorker(HLSStreamWorker):
diff --git a/src/streamlink/plugins/tf1.py b/src/streamlink/plugins/tf1.py
index 42b63196..e174694d 100644
--- a/src/streamlink/plugins/tf1.py
+++ b/src/streamlink/plugins/tf1.py
@@ -41,18 +41,16 @@ class TF1(Plugin):
             for sformat, url in self.get_stream_urls(channel):
                 try:
                     if sformat == "dash":
-                        for s in DASHStream.parse_manifest(
+                        yield from DASHStream.parse_manifest(
                             self.session,
                             url,
                             headers={"User-Agent": useragents.CHROME}
-                        ).items():
-                            yield s
+                        ).items()
                     if sformat == "hls":
-                        for s in HLSStream.parse_variant_playlist(
+                        yield from HLSStream.parse_variant_playlist(
                             self.session,
                             url
-                        ).items():
-                            yield s
+                        ).items()
                 except PluginError as e:
                     log.error("Could not open {0} stream".format(sformat))
                     log.debug("Failed with error: {0}".format(e))
diff --git a/src/streamlink/plugins/tv4play.py b/src/streamlink/plugins/tv4play.py
index 763f5a58..8662986f 100644
--- a/src/streamlink/plugins/tv4play.py
+++ b/src/streamlink/plugins/tv4play.py
@@ -89,9 +89,7 @@ class TV4Play(Plugin):
         data = self.session.http.json(res)
         hls_url = data["playbackItem"]["manifestUrl"]
         log.debug("URL={0}".format(hls_url))
-        for s in HLSStream.parse_variant_playlist(self.session,
-                                                  hls_url).items():
-            yield s
+        yield from HLSStream.parse_variant_playlist(self.session, hls_url).items()
 
 
 __plugin__ = TV4Play
diff --git a/src/streamlink/plugins/tvplayer.py b/src/streamlink/plugins/tvplayer.py
index 0c3be530..b4c8ac8a 100644
--- a/src/streamlink/plugins/tvplayer.py
+++ b/src/streamlink/plugins/tvplayer.py
@@ -88,7 +88,7 @@ class TVPlayer(Plugin):
         return res_schema
 
     def _get_stream_attrs(self, page):
-        stream_attrs = dict((k.replace("-", "_"), v.strip('"')) for k, v in self.stream_attrs_re.findall(page.text))
+        stream_attrs = {k.replace("-", "_"): v.strip('"') for k, v in self.stream_attrs_re.findall(page.text)}
 
         log.debug(f"Got stream attributes: {str(stream_attrs)}")
         valid = True
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index 8ab4507c..47895568 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -144,7 +144,7 @@ class TwitchHLSStream(HLSStream):
         return load_hls_playlist(res.text, base_uri=res.url)
 
 
-class UsherService(object):
+class UsherService:
     def __init__(self, session):
         self.session = session
 
@@ -173,7 +173,7 @@ class UsherService(object):
         return self._create_url("/vod/{0}".format(video_id), **extra_params)
 
 
-class TwitchAPI(object):
+class TwitchAPI:
     # Streamlink's client-id used for public API calls (don't steal this and register your own application on Twitch)
     TWITCH_CLIENT_ID = "pwkzresl8kj2rdj6g7bvxl9ys1wly3j"
     # Twitch's client-id used for private API calls (see issue #2680 for why we are doing this)
@@ -232,24 +232,6 @@ class TwitchAPI(object):
             validate.transform(int)
         ))
 
-    def stream_rerun(self, channel_id):
-        return self.call("/kraken/streams/{0}".format(channel_id), schema=validate.Schema(
-            {
-                "stream": validate.any(None, validate.all({
-                    "stream_type": validate.text,
-                    "broadcast_platform": validate.text,
-                    "channel": validate.all({
-                        "broadcaster_software": validate.text
-                    }, validate.get("broadcaster_software"))
-                }, validate.union((
-                    validate.get("stream_type"),
-                    validate.get("broadcast_platform"),
-                    validate.get("channel")
-                ))))
-            },
-            validate.get("stream")
-        ))
-
     def metadata_video(self, video_id):
         return self.call("/kraken/videos/{0}".format(video_id), schema=validate.Schema(validate.any(
             validate.all(
@@ -321,7 +303,7 @@ class TwitchAPI(object):
                 "login": channel_or_vod if is_live else "",
                 "isVod": not is_live,
                 "vodID": channel_or_vod if not is_live else "",
-                "playerType": "site"
+                "playerType": "embed"
             }
         }
         subschema = {
@@ -407,6 +389,35 @@ class TwitchAPI(object):
             validate.get("clip")
         ))
 
+    def stream_metadata(self, channel):
+        request = {
+            "operationName": "StreamMetadata",
+            "extensions": {
+                "persistedQuery": {
+                    "version": 1,
+                    "sha256Hash": "1c719a40e481453e5c48d9bb585d971b8b372f8ebb105b17076722264dfa5b3e"
+                }
+            },
+            "variables": {
+                "channelLogin": channel
+            }
+        }
+
+        return self.call_gql(request, schema=validate.Schema(
+            {
+                "data": {
+                    "user": {
+                        "stream": {
+                            "type": str,
+                        }
+                    }
+                }
+            },
+            validate.get("data"),
+            validate.get("user"),
+            validate.get("stream")
+        ))
+
 
 class Twitch(Plugin):
     arguments = PluginArguments(
@@ -595,8 +606,8 @@ class Twitch(Plugin):
             return False
 
         try:
-            stream_type, broadcast_platform, broadcaster_software = self.api.stream_rerun(self.channel_id)
-            if stream_type != "live" or broadcast_platform == "rerun" or broadcaster_software == "watch_party_rerun":
+            stream = self.api.stream_metadata(self.channel)
+            if stream["type"] != "live":
                 log.info("Reruns were disabled by command line option")
                 return True
         except (PluginError, TypeError):
@@ -612,6 +623,10 @@ class Twitch(Plugin):
 
         # only get the token once the channel has been resolved
         log.debug("Getting live HLS streams for {0}".format(self.channel))
+        self.session.http.headers.update({
+            "referer": "https://player.twitch.tv",
+            "origin": "https://player.twitch.tv",
+        })
         sig, token, restricted_bitrates = self._access_token(True, self.channel)
         url = self.usher.channel(self.channel, sig=sig, token=token, fast_bread=True)
 
@@ -635,7 +650,7 @@ class Twitch(Plugin):
 
         try:
             streams = TwitchHLSStream.parse_variant_playlist(self.session, url, start_offset=time_offset, **extra_params)
-        except IOError as err:
+        except OSError as err:
             err = str(err)
             if "404 Client Error" in err or "Failed to parse playlist" in err:
                 return
diff --git a/src/streamlink/plugins/ustreamtv.py b/src/streamlink/plugins/ustreamtv.py
index c80e9c69..f45e2138 100644
--- a/src/streamlink/plugins/ustreamtv.py
+++ b/src/streamlink/plugins/ustreamtv.py
@@ -31,7 +31,7 @@ class ModuleInfoNoStreams(Exception):
     pass
 
 
-class UHSClient(object):
+class UHSClient:
     """
     API Client, reverse engineered by observing the interactions
     between the web browser and the ustream servers.
@@ -190,7 +190,7 @@ class UHSStreamWriter(SegmentedStreamWriter):
                     return
             else:
                 log.debug(f"Download of chunk {chunk.num} complete")
-        except IOError as err:
+        except OSError as err:
             log.error(f"Failed to read chunk {chunk.num}: {err}")
 
 
diff --git a/src/streamlink/plugins/ustvnow.py b/src/streamlink/plugins/ustvnow.py
index b33d48f0..4e626410 100644
--- a/src/streamlink/plugins/ustvnow.py
+++ b/src/streamlink/plugins/ustvnow.py
@@ -135,7 +135,7 @@ class USTVNow(Plugin):
                    "tenant-code": self.TENANT_CODE,
                    "content-type": "application/json"}
         res = self.session.http.post(self._api_url + path, data=json.dumps(post_data), headers=headers).json()
-        data = dict((k, v and json.loads(self.decrypt_data(v, key, iv)))for k, v in res.items())
+        data = {k: v and json.loads(self.decrypt_data(v, key, iv)) for k, v in res.items()}
         return data
 
     def login(self, username, password):
diff --git a/src/streamlink/plugins/viasat.py b/src/streamlink/plugins/viasat.py
index cc031cfd..dab93178 100644
--- a/src/streamlink/plugins/viasat.py
+++ b/src/streamlink/plugins/viasat.py
@@ -75,7 +75,7 @@ class Viasat(Plugin):
         try:
             streams = parser(self.session, video[1])
             return streams.items()
-        except IOError as err:
+        except OSError as err:
             log.error("Failed to extract {0} streams: {1}".format(stream_type, err))
 
     def _create_rtmp_stream(self, video):
diff --git a/src/streamlink/plugins/vk.py b/src/streamlink/plugins/vk.py
index 143cd57f..c969ae0a 100644
--- a/src/streamlink/plugins/vk.py
+++ b/src/streamlink/plugins/vk.py
@@ -36,7 +36,7 @@ class VK(Plugin):
         # with an video ID in the GET request, get that instead
         parsed_url = urlparse(url)
         if parsed_url.path.startswith('/videos'):
-            query = dict((v[0], v[1]) for v in [q.split('=') for q in parsed_url.query.split('&')] if v[0] == 'z')
+            query = {v[0]: v[1] for v in [q.split('=') for q in parsed_url.query.split('&')] if v[0] == 'z'}
             try:
                 true_path = unquote(query['z']).split('/')[0]
                 return parsed_url.scheme + '://' + parsed_url.netloc + '/' + true_path
diff --git a/src/streamlink/plugins/webtv.py b/src/streamlink/plugins/webtv.py
index 1b98fd8f..1a77a701 100644
--- a/src/streamlink/plugins/webtv.py
+++ b/src/streamlink/plugins/webtv.py
@@ -72,7 +72,7 @@ class WebTV(Plugin):
                         else:
                             # and if that fails, try it as a plain HLS stream
                             yield 'live', HLSStream(self.session, url, headers=headers)
-                    except IOError:
+                    except OSError:
                         log.warning("Could not open the stream, perhaps the channel is offline")
 
 
diff --git a/src/streamlink/plugins/wwenetwork.py b/src/streamlink/plugins/wwenetwork.py
index 69fd5303..2f9d489a 100644
--- a/src/streamlink/plugins/wwenetwork.py
+++ b/src/streamlink/plugins/wwenetwork.py
@@ -153,12 +153,11 @@ class WWENetwork(Plugin):
             log.debug("Found content ID: {0}".format(content_id))
             info = self._get_media_info(content_id)
             if info.get("hlsUrl"):
-                for s in HLSStream.parse_variant_playlist(
+                yield from HLSStream.parse_variant_playlist(
                     self.session,
                     info["hlsUrl"],
                     start_offset=start_point
-                ).items():
-                    yield s
+                ).items()
             else:
                 log.error("Could not find the HLS URL")
 
diff --git a/src/streamlink/plugins/youtube.py b/src/streamlink/plugins/youtube.py
index 3886577d..58dae326 100644
--- a/src/streamlink/plugins/youtube.py
+++ b/src/streamlink/plugins/youtube.py
@@ -249,7 +249,7 @@ class YouTube(Plugin):
                             log.debug("Video ID from videoRenderer (live)")
                             return x["videoId"]
 
-        if "/embed/live_stream" in url:
+        if urlparse(url).path.endswith(("/embed/live_stream", "/live")):
             for link in itertags(res.text, "link"):
                 if link.attributes.get("rel") == "canonical":
                     canon_link = link.attributes.get("href")
@@ -339,7 +339,7 @@ class YouTube(Plugin):
                     self.session, hls_manifest, name_key="pixels"
                 )
                 streams.update(hls_streams)
-            except IOError as err:
+            except OSError as err:
                 log.warning(f"Failed to extract HLS streams: {err}")
 
         if not streams and protected:
diff --git a/src/streamlink/plugins/zattoo.py b/src/streamlink/plugins/zattoo.py
index 83e5b9fc..2f774ff9 100644
--- a/src/streamlink/plugins/zattoo.py
+++ b/src/streamlink/plugins/zattoo.py
@@ -287,14 +287,10 @@ class Zattoo(Plugin):
             log.debug('Found data for {0}'.format(stream_type))
             if data['success'] and stream_type in ['hls', 'hls5']:
                 for url in data['stream']['watch_urls']:
-                    for s in HLSStream.parse_variant_playlist(
-                            self.session, url['url']).items():
-                        yield s
+                    yield from HLSStream.parse_variant_playlist(self.session, url['url']).items()
             elif data['success'] and stream_type == 'dash':
                 for url in data['stream']['watch_urls']:
-                    for s in DASHStream.parse_manifest(
-                            self.session, url['url']).items():
-                        yield s
+                    yield from DASHStream.parse_manifest(self.session, url['url']).items()
 
     def _get_params_cid(self, channel):
         log.debug('get channel ID for {0}'.format(channel))
diff --git a/src/streamlink/plugins/zdf_mediathek.py b/src/streamlink/plugins/zdf_mediathek.py
index 60f20e68..20e8d350 100644
--- a/src/streamlink/plugins/zdf_mediathek.py
+++ b/src/streamlink/plugins/zdf_mediathek.py
@@ -107,7 +107,7 @@ class zdf_mediathek(Plugin):
     def _parse_track(self, track, parser, name):
         try:
             return parser(self.session, track["uri"])
-        except IOError as err:
+        except OSError as err:
             log.error("Failed to extract {0} streams: {1}".format(name, err))
 
     def _extract_from_format(self, format_):
diff --git a/src/streamlink/session.py b/src/streamlink/session.py
index c8340839..4849336e 100644
--- a/src/streamlink/session.py
+++ b/src/streamlink/session.py
@@ -1,8 +1,5 @@
-import imp
 import logging
 import pkgutil
-import sys
-import traceback
 from collections import OrderedDict
 
 import requests
@@ -12,8 +9,8 @@ from streamlink.compat import is_win32
 from streamlink.exceptions import NoPluginError, PluginError
 from streamlink.logger import StreamlinkLogger
 from streamlink.options import Options
-from streamlink.plugin import api
-from streamlink.utils import memoize, update_scheme
+from streamlink.plugin import Plugin, api
+from streamlink.utils import load_module, memoize, update_scheme
 from streamlink.utils.l10n import Localization
 
 # Ensure that the Logger class returned is Streamslink's for using the API (for backwards compatibility)
@@ -21,31 +18,11 @@ logging.setLoggerClass(StreamlinkLogger)
 log = logging.getLogger(__name__)
 
 
-def print_small_exception(start_after):
-    type, value, traceback_ = sys.exc_info()
-
-    tb = traceback.extract_tb(traceback_)
-    index = 0
-
-    for i, trace in enumerate(tb):
-        if trace[2] == start_after:
-            index = i + 1
-            break
-
-    lines = traceback.format_list(tb[index:])
-    lines += traceback.format_exception_only(type, value)
-
-    for line in lines:
-        sys.stderr.write(line)
-
-    sys.stderr.write("\n")
-
-
 class PythonDeprecatedWarning(UserWarning):
     pass
 
 
-class Streamlink(object):
+class Streamlink:
     """A Streamlink session is used to keep track of plugins,
        options and log settings."""
 
@@ -429,41 +406,24 @@ class Streamlink(object):
         :param path: full path to a directory where to look for plugins
 
         """
+        user_input_requester = self.get_option("user-input-requester")
         for loader, name, ispkg in pkgutil.iter_modules([path]):
-            file, pathname, desc = imp.find_module(name, [path])
             # set the full plugin module name
-            module_name = "streamlink.plugin.{0}".format(name)
-
+            module_name = f"streamlink.plugins.{name}"
             try:
-                self.load_plugin(module_name, file, pathname, desc)
-            except Exception:
-                sys.stderr.write("Failed to load plugin {0}:\n".format(name))
-                print_small_exception("load_plugin")
-
+                mod = load_module(module_name, path)
+            except ImportError:
+                log.exception(f"Failed to load plugin {name} from {path}\n")
                 continue
 
-    def load_plugin(self, name, file, pathname, desc):
-        # Set the global http session for this plugin
-        user_input_requester = self.get_option("user-input-requester")
-        api.http = self.http
-
-        module = imp.load_module(name, file, pathname, desc)
-
-        if hasattr(module, "__plugin__"):
-            module_name = getattr(module, "__name__")
-            plugin_name = module_name.split(".")[-1]  # get the plugin part of the module name
-
-            plugin = getattr(module, "__plugin__")
-            plugin.bind(self, plugin_name, user_input_requester)
-
+            if not hasattr(mod, "__plugin__") or not issubclass(mod.__plugin__, Plugin):
+                continue
+            plugin = mod.__plugin__
+            plugin.bind(self, name, user_input_requester)
             if plugin.module in self.plugins:
-                log.debug("Plugin {0} is being overridden by {1}".format(plugin.module, pathname))
-
+                log.debug(f"Plugin {plugin.module} is being overridden by {mod.__file__}")
             self.plugins[plugin.module] = plugin
 
-        if file:
-            file.close()
-
     @property
     def version(self):
         return __version__
diff --git a/src/streamlink/stream/akamaihd.py b/src/streamlink/stream/akamaihd.py
index f59ac267..1a937a37 100644
--- a/src/streamlink/stream/akamaihd.py
+++ b/src/streamlink/stream/akamaihd.py
@@ -17,7 +17,7 @@ from streamlink.utils import swfdecompress
 log = logging.getLogger(__name__)
 
 
-class TokenGenerator(object):
+class TokenGenerator:
     def __init__(self, stream):
         self.stream = stream
 
diff --git a/src/streamlink/stream/dash_manifest.py b/src/streamlink/stream/dash_manifest.py
index 8354b703..17979413 100644
--- a/src/streamlink/stream/dash_manifest.py
+++ b/src/streamlink/stream/dash_manifest.py
@@ -30,7 +30,7 @@ log = logging.getLogger(__name__)
 epoch_start = datetime.datetime(1970, 1, 1, tzinfo=utc)
 
 
-class Segment(object):
+class Segment:
     def __init__(self, url, duration, init=False, content=True, available_at=epoch_start, range=None):
         self.url = url
         self.duration = duration
@@ -74,7 +74,7 @@ def sleep_until(walltime):
         time.sleep(time_to_wait)
 
 
-class MPDParsers(object):
+class MPDParsers:
     @staticmethod
     def bool_str(v):
         return v.lower() == "true"
@@ -133,7 +133,7 @@ class MPDParsingError(Exception):
     pass
 
 
-class MPDNode(object):
+class MPDNode:
     __tag__ = None
 
     def __init__(self, node, root=None, parent=None, *args, **kwargs):
@@ -141,7 +141,7 @@ class MPDNode(object):
         self.root = root
         self.parent = parent
         self._base_url = kwargs.get("base_url")
-        self.attributes = set([])
+        self.attributes = set()
         if self.__tag__ and self.node.tag.lower() != self.__tag__.lower():
             raise MPDParsingError("root tag did not match the expected tag: {}".format(self.__tag__))
 
@@ -504,8 +504,7 @@ class SegmentTemplate(MPDNode):
             available_iter = count_dt(available_start,
                                       step=datetime.timedelta(seconds=self.duration_seconds))
 
-        for number, available_at in zip(number_iter, available_iter):
-            yield number, available_at
+        yield from zip(number_iter, available_iter)
 
     def format_media(self, **kwargs):
         if self.segmentTimeline:
diff --git a/src/streamlink/stream/ffmpegmux.py b/src/streamlink/stream/ffmpegmux.py
index 16f5ed76..42ec05eb 100644
--- a/src/streamlink/stream/ffmpegmux.py
+++ b/src/streamlink/stream/ffmpegmux.py
@@ -70,12 +70,12 @@ class FFMPEGMuxer(StreamIO):
                     pipe.write(data)
                 else:
                     break
-            except IOError:
+            except OSError:
                 log.error("Pipe copy aborted: {0}".format(pipe.path))
                 return
         try:
             pipe.close()
-        except IOError:  # might fail closing, but that should be ok for the pipe
+        except OSError:  # might fail closing, but that should be ok for the pipe
             pass
         log.debug("Pipe copy complete: {0}".format(pipe.path))
 
diff --git a/src/streamlink/stream/flvconcat.py b/src/streamlink/stream/flvconcat.py
index f514b2ff..70967d8c 100644
--- a/src/streamlink/stream/flvconcat.py
+++ b/src/streamlink/stream/flvconcat.py
@@ -34,11 +34,11 @@ def iter_flv_tags(fd=None, buf=None, strict=False, skip_header=False):
                 tag = Tag.deserialize(fd, strict=strict)
             elif buf:
                 tag, offset = Tag.deserialize_from(buf, offset, strict=strict)
-        except (IOError, FLVError) as err:
+        except (OSError, FLVError) as err:
             if "Insufficient tag header" in str(err):
                 break
 
-            raise IOError(err)
+            raise OSError(err)
 
         yield tag
 
@@ -67,7 +67,7 @@ def extract_flv_header_tags(stream):
     return FLVHeaderTags(metadata, aac_header, avc_header)
 
 
-class FLVTagConcat(object):
+class FLVTagConcat:
     def __init__(self, duration=None, tags=[], has_video=True, has_audio=True,
                  flatten_timestamps=False, sync_headers=False):
         self.duration = duration
@@ -93,7 +93,7 @@ class FLVTagConcat(object):
 
     def verify_tag(self, tag):
         if tag.filter:
-            raise IOError("Tag has filter flag set, probably encrypted")
+            raise OSError("Tag has filter flag set, probably encrypted")
 
         # Only AAC and AVC has detectable headers
         if isinstance(tag.data, AudioData) and tag.data.codec != AUDIO_CODEC_ID_AAC:
@@ -193,8 +193,7 @@ class FLVTagConcat(object):
         tags_iterator = filter(None, self.tags)
         flv_iterator = iter_flv_tags(fd=fd, buf=buf, skip_header=skip_header)
 
-        for tag in chain(tags_iterator, flv_iterator):
-            yield tag
+        yield from chain(tags_iterator, flv_iterator)
 
     def iter_chunks(self, fd=None, buf=None, skip_header=None):
         """Reads FLV tags from fd or buf and returns them with adjusted
@@ -253,7 +252,7 @@ class FLVTagConcatWorker(Thread):
 
                     if not self.running:
                         return
-            except IOError as err:
+            except OSError as err:
                 self.error = err
                 break
 
diff --git a/src/streamlink/stream/hds.py b/src/streamlink/stream/hds.py
index 750be252..818f35c3 100644
--- a/src/streamlink/stream/hds.py
+++ b/src/streamlink/stream/hds.py
@@ -107,7 +107,7 @@ class HDSStreamWriter(SegmentedStreamWriter):
                     break
             else:
                 log.debug(f"Download of fragment {fragment.segment}-{fragment.fragment} complete")
-        except IOError as err:
+        except OSError as err:
             if "Unknown tag type" in str(err):
                 log.error("Unknown tag type found, this stream is probably encrypted")
                 self.close()
@@ -476,7 +476,7 @@ class HDSStream(Stream):
         pvtoken = manifest.findtext("pv-2.0")
         if pvtoken:
             if not pvswf:
-                raise IOError("This manifest requires the 'pvswf' parameter "
+                raise OSError("This manifest requires the 'pvswf' parameter "
                               "to verify the SWF")
 
             params = cls._pv_params(session, pvswf, pvtoken, **request_params)
diff --git a/src/streamlink/stream/hls.py b/src/streamlink/stream/hls.py
index b76e11ec..a31e02d4 100644
--- a/src/streamlink/stream/hls.py
+++ b/src/streamlink/stream/hls.py
@@ -432,7 +432,7 @@ class HLSStream(HTTPStream):
         try:
             parser = cls._get_variant_playlist(res)
         except ValueError as err:
-            raise IOError("Failed to parse playlist: {0}".format(err))
+            raise OSError("Failed to parse playlist: {0}".format(err))
 
         streams = OrderedDict()
         for playlist in filter(lambda p: not p.is_iframe, parser.playlists):
diff --git a/src/streamlink/stream/hls_filtered.py b/src/streamlink/stream/hls_filtered.py
index 44c50abf..9fd0c578 100644
--- a/src/streamlink/stream/hls_filtered.py
+++ b/src/streamlink/stream/hls_filtered.py
@@ -36,7 +36,7 @@ class FilteredHLSStreamReader(HLSStreamReader):
         while True:
             try:
                 return super().read(size)
-            except IOError:
+            except OSError:
                 # wait indefinitely until filtering ends
                 self.filter_event.wait()
                 if self.buffer.closed:
diff --git a/src/streamlink/stream/hls_playlist.py b/src/streamlink/stream/hls_playlist.py
index 366e834e..d0ab6fc3 100644
--- a/src/streamlink/stream/hls_playlist.py
+++ b/src/streamlink/stream/hls_playlist.py
@@ -42,7 +42,7 @@ Resolution = namedtuple("Resolution", "width height")
 Segment = namedtuple("Segment", "uri duration title key discontinuity byterange date map")
 
 
-class M3U8(object):
+class M3U8:
     def __init__(self):
         self.is_endlist = False
         self.is_master = False
@@ -77,7 +77,7 @@ class M3U8(object):
         return daterange.start_date <= date
 
 
-class M3U8Parser(object):
+class M3U8Parser:
     _extinf_re = re.compile(r"(?P<duration>\d+(\.\d+)?)(,(?P<title>.+))?")
     _attr_re = re.compile(r"([A-Z\-]+)=(\d+\.\d+|0x[0-9A-z]+|\d+x\d+|\d+|\"(.+?)\"|[0-9A-z\-]+)")
     _range_re = re.compile(r"(?P<range>\d+)(@(?P<offset>.+))?")
diff --git a/src/streamlink/stream/stream.py b/src/streamlink/stream/stream.py
index 7d3dd628..052a602a 100644
--- a/src/streamlink/stream/stream.py
+++ b/src/streamlink/stream/stream.py
@@ -5,7 +5,7 @@ import logging
 log = logging.getLogger(__name__)
 
 
-class Stream(object):
+class Stream:
     __shortname__ = "stream"
 
     """
diff --git a/src/streamlink/stream/streamprocess.py b/src/streamlink/stream/streamprocess.py
index 608920d9..9f126dd3 100644
--- a/src/streamlink/stream/streamprocess.py
+++ b/src/streamlink/stream/streamprocess.py
@@ -139,7 +139,7 @@ class StreamProcess(Stream):
 
         try:
             process = subprocess.Popen(cmd, stderr=stderr, stdout=subprocess.PIPE)
-        except (OSError, IOError) as err:
+        except OSError as err:
             raise StreamError("Failed to start process: {0} ({1})".format(self._check_cmd(), str(err)))
 
         if timeout:
diff --git a/src/streamlink/stream/wrappers.py b/src/streamlink/stream/wrappers.py
index 33370a2c..7e8565d1 100644
--- a/src/streamlink/stream/wrappers.py
+++ b/src/streamlink/stream/wrappers.py
@@ -65,7 +65,7 @@ class StreamIOThreadWrapper(io.IOBase):
             while self.running:
                 try:
                     data = self.fd.read(8192)
-                except IOError as error:
+                except OSError as error:
                     self.error = error
                     break
 
diff --git a/src/streamlink/utils/__init__.py b/src/streamlink/utils/__init__.py
index af6bc6a5..0403c394 100644
--- a/src/streamlink/utils/__init__.py
+++ b/src/streamlink/utils/__init__.py
@@ -3,6 +3,8 @@ import json
 import re
 import xml.etree.ElementTree as ET
 import zlib
+from importlib.machinery import FileFinder, SOURCE_SUFFIXES, SourceFileLoader
+from importlib.util import module_from_spec
 from urllib.parse import parse_qsl, urljoin, urlparse
 
 from streamlink.exceptions import PluginError
@@ -11,6 +13,19 @@ from streamlink.utils.named_pipe import NamedPipe
 from streamlink.utils.url import update_scheme, url_equal
 
 
+_loader_details = [(SourceFileLoader, SOURCE_SUFFIXES)]
+
+
+def load_module(name, path=None):
+    finder = FileFinder(path, *_loader_details)
+    spec = finder.find_spec(name)
+    if not spec or not spec.loader:
+        raise ImportError(f"no module named {name}")
+    mod = module_from_spec(spec)
+    spec.loader.exec_module(mod)
+    return mod
+
+
 def swfdecompress(data):
     if data[:3] == b"CWS":
         data = b"F" + data[1:8] + zlib.decompress(data[8:])
@@ -160,26 +175,10 @@ def search_dict(data, key):
         for dkey, value in data.items():
             if dkey == key:
                 yield value
-            for result in search_dict(value, key):
-                yield result
+            yield from search_dict(value, key)
     elif isinstance(data, list):
         for value in data:
-            for result in search_dict(value, key):
-                yield result
-
-
-def load_module(name, path=None):
-    import importlib.machinery
-    import importlib.util
-
-    loader_details = [(importlib.machinery.SourceFileLoader, importlib.machinery.SOURCE_SUFFIXES)]
-    finder = importlib.machinery.FileFinder(path, *loader_details)
-    spec = finder.find_spec(name)
-    if not spec or not spec.loader:
-        raise ImportError("no module named {0}".format(name))
-    mod = importlib.util.module_from_spec(spec)
-    spec.loader.exec_module(mod)
-    return mod
+            yield from search_dict(value, key)
 
 
 def escape_librtmp(value):  # pragma: no cover
@@ -195,7 +194,7 @@ def escape_librtmp(value):  # pragma: no cover
     return value
 
 
-__all__ = ["swfdecompress", "update_scheme", "url_equal",
+__all__ = ["load_module", "swfdecompress", "update_scheme", "url_equal",
            "verifyjson", "absolute_url", "parse_qsd", "parse_json",
            "parse_xml", "rtmpparse", "prepend_www", "NamedPipe",
            "escape_librtmp", "LazyFormatter"]
diff --git a/src/streamlink/utils/l10n.py b/src/streamlink/utils/l10n.py
index 8aed3fba..de2c8892 100644
--- a/src/streamlink/utils/l10n.py
+++ b/src/streamlink/utils/l10n.py
@@ -20,7 +20,7 @@ DEFAULT_LANGUAGE_CODE = "{0}_{1}".format(DEFAULT_LANGUAGE, DEFAULT_COUNTRY)
 log = logging.getLogger(__name__)
 
 
-class Country(object):
+class Country:
     def __init__(self, alpha2, alpha3, numeric, name, official_name=None):
         self.alpha2 = alpha2
         self.alpha3 = alpha3
@@ -57,7 +57,7 @@ class Country(object):
         )
 
 
-class Language(object):
+class Language:
     def __init__(self, alpha2, alpha3, name, bibliographic=None):
         self.alpha2 = alpha2
         self.alpha3 = alpha3
@@ -106,7 +106,7 @@ class Language(object):
         )
 
 
-class Localization(object):
+class Localization:
     def __init__(self, language_code=None):
         self._language_code = None
         self.country = None
diff --git a/src/streamlink/utils/lazy_formatter.py b/src/streamlink/utils/lazy_formatter.py
index 8515396b..7fe5ef1b 100644
--- a/src/streamlink/utils/lazy_formatter.py
+++ b/src/streamlink/utils/lazy_formatter.py
@@ -1,7 +1,7 @@
 import string
 
 
-class LazyFormatter(object):
+class LazyFormatter:
     def __init__(self, **lazy_props):
         self.lazy_props = lazy_props
 
diff --git a/src/streamlink/utils/named_pipe.py b/src/streamlink/utils/named_pipe.py
index 4e4c85d4..f4b1b874 100644
--- a/src/streamlink/utils/named_pipe.py
+++ b/src/streamlink/utils/named_pipe.py
@@ -14,7 +14,7 @@ if is_win32:
     INVALID_HANDLE_VALUE = -1
 
 
-class NamedPipe(object):
+class NamedPipe:
     def __init__(self, name):
         self.fifo = None
         self.pipe = None
@@ -42,7 +42,7 @@ class NamedPipe(object):
 
         if pipe == INVALID_HANDLE_VALUE:
             error_code = windll.kernel32.GetLastError()
-            raise IOError("Error code 0x{0:08X}".format(error_code))
+            raise OSError("Error code 0x{0:08X}".format(error_code))
 
         return pipe
 
diff --git a/src/streamlink_cli/console.py b/src/streamlink_cli/console.py
index 04b9f009..93e3acc3 100644
--- a/src/streamlink_cli/console.py
+++ b/src/streamlink_cli/console.py
@@ -20,16 +20,16 @@ class ConsoleUserInputRequester(UserInputRequester):
         if sys.stdin.isatty():
             return self.console.ask(prompt.strip() + ": ")
         else:
-            raise IOError("no TTY available")
+            raise OSError("no TTY available")
 
     def ask_password(self, prompt):
         if sys.stdin.isatty():
             return self.console.askpass(prompt.strip() + ": ")
         else:
-            raise IOError("no TTY available")
+            raise OSError("no TTY available")
 
 
-class ConsoleOutput(object):
+class ConsoleOutput:
     def __init__(self, output, json=False):
         self.json = json
         self.output = output
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index cd4108f2..d3378717 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -101,7 +101,7 @@ def create_output(plugin):
 
             try:
                 namedpipe = NamedPipe(pipename)
-            except IOError as err:
+            except OSError as err:
                 console.exit("Failed to create pipe: {0}", err)
         elif args.player_http:
             http = create_http_server()
@@ -280,7 +280,7 @@ def open_stream(stream):
     try:
         log.debug("Pre-buffering 8192 bytes")
         prebuffer = stream_fd.read(8192)
-    except IOError as err:
+    except OSError as err:
         stream_fd.close()
         raise StreamError("Failed to read data from stream: {0}".format(err))
 
@@ -312,7 +312,7 @@ def output_stream(plugin, stream):
 
     try:
         output.open()
-    except (IOError, OSError) as err:
+    except OSError as err:
         if isinstance(output, PlayerOutput):
             console.exit("Failed to start player: {0} ({1})",
                          args.player, err)
@@ -369,7 +369,7 @@ def read_stream(stream, output, prebuffer, chunk_size=8192):
 
             try:
                 output.write(data)
-            except IOError as err:
+            except OSError as err:
                 if is_player and err.errno in ACCEPTABLE_ERRNO:
                     log.info("Player closed")
                 elif is_http and err.errno in ACCEPTABLE_ERRNO:
@@ -378,7 +378,7 @@ def read_stream(stream, output, prebuffer, chunk_size=8192):
                     console.exit("Error when writing to output: {0}, exiting", err)
 
                 break
-    except IOError as err:
+    except OSError as err:
         console.exit("Error when reading from stream: {0}, exiting", err)
     finally:
         stream.close()
diff --git a/src/streamlink_cli/output.py b/src/streamlink_cli/output.py
index 60d1977b..71cbb5a0 100644
--- a/src/streamlink_cli/output.py
+++ b/src/streamlink_cli/output.py
@@ -16,7 +16,7 @@ if is_win32:
 log = logging.getLogger("streamlink.cli.output")
 
 
-class Output(object):
+class Output:
     def __init__(self):
         self.opened = False
 
@@ -32,7 +32,7 @@ class Output(object):
 
     def write(self, data):
         if not self.opened:
-            raise IOError("Output is not opened")
+            raise OSError("Output is not opened")
 
         return self._write(data)
 
diff --git a/src/streamlink_cli/utils/http_server.py b/src/streamlink_cli/utils/http_server.py
index 56409dee..6661abd6 100644
--- a/src/streamlink_cli/utils/http_server.py
+++ b/src/streamlink_cli/utils/http_server.py
@@ -19,7 +19,7 @@ class HTTPRequest(BaseHTTPRequestHandler):
         self.error_message = message
 
 
-class HTTPServer(object):
+class HTTPServer:
     def __init__(self):
         self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
         self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -54,7 +54,7 @@ class HTTPServer(object):
     def bind(self, host="127.0.0.1", port=0):
         try:
             self.socket.bind((host or "", port))
-        except socket.error as err:
+        except OSError as err:
             raise OSError(err)
 
         self.socket.listen(1)
@@ -74,7 +74,7 @@ class HTTPServer(object):
 
         try:
             req_data = conn.recv(1024)
-        except socket.error:
+        except OSError:
             raise OSError("Failed to read data from socket")
 
         req = HTTPRequest(req_data)
@@ -88,7 +88,7 @@ class HTTPServer(object):
             conn.send(b"Server: Streamlink\r\n")
             conn.send(b"Content-Type: video/unknown\r\n")
             conn.send(b"\r\n")
-        except socket.error:
+        except OSError:
             raise OSError("Failed to write data to socket")
 
         # We don't want to send any data on HEAD requests.
@@ -102,7 +102,7 @@ class HTTPServer(object):
 
     def write(self, data):
         if not self.conn:
-            raise IOError("No connection")
+            raise OSError("No connection")
 
         self.conn.sendall(data)
 
@@ -113,6 +113,6 @@ class HTTPServer(object):
         if not client_only:
             try:
                 self.socket.shutdown(2)
-            except (OSError, socket.error):
+            except OSError:
                 pass
             self.socket.close()


Test patch:
diff --git a/tests/mixins/stream_hls.py b/tests/mixins/stream_hls.py
index 156039ac..ac634252 100644
--- a/tests/mixins/stream_hls.py
+++ b/tests/mixins/stream_hls.py
@@ -10,7 +10,7 @@ from streamlink import Streamlink
 from streamlink.stream.hls import HLSStream
 
 
-class HLSItemBase(object):
+class HLSItemBase:
     path = ""
 
     def url(self, namespace):
@@ -132,7 +132,7 @@ class HLSStreamReadThread(Thread):
                     return
 
                 self.data.append(self.reader.read(-1))
-            except IOError as err:
+            except OSError as err:
                 self.error = err
                 return
             finally:
diff --git a/tests/plugins/test_pluto.py b/tests/plugins/test_pluto.py
new file mode 100644
index 00000000..2297338c
--- /dev/null
+++ b/tests/plugins/test_pluto.py
@@ -0,0 +1,37 @@
+import unittest
+
+from streamlink.plugins.pluto import Pluto
+
+
+class TestPluginPluto(unittest.TestCase):
+    def test_can_handle_url(self):
+        should_match = [
+            'http://www.pluto.tv/live-tv/channel-lineup',
+            'http://pluto.tv/live-tv/channel',
+            'http://pluto.tv/live-tv/channel/',
+            'https://pluto.tv/live-tv/red-bull-tv-2',
+            'https://pluto.tv/live-tv/4k-tv',
+            'http://www.pluto.tv/on-demand/series/leverage/season/1/episode/the-nigerian-job-2009-1-1',
+            'http://pluto.tv/on-demand/series/fear-factor-usa-(lf)/season/5/episode/underwater-safe-bob-car-ramp-2004-5-3',
+            'https://www.pluto.tv/on-demand/movies/dr.-no-1963-1-1',
+            'http://pluto.tv/on-demand/movies/the-last-dragon-(1985)-1-1',
+        ]
+        for url in should_match:
+            self.assertTrue(Pluto.can_handle_url(url), url)
+
+    def test_can_handle_url_negative(self):
+        should_not_match = [
+            'https://fake.pluto.tv/live-tv/hello',
+            'http://www.pluto.tv/live-tv/channel-lineup/extra',
+            'https://www.pluto.tv/live-tv',
+            'https://pluto.tv/live-tv',
+            'https://www.pluto.com/live-tv/swag',
+            'https://youtube.com/live-tv/swag.html',
+            'http://pluto.tv/movies/dr.-no-1963-1-1',
+            'http://pluto.tv/on-demand/movies/dr.-no-1/963-1-1',
+            'http://pluto.tv/on-demand/series/dr.-no-1963-1-1',
+            'http://pluto.tv/on-demand/movies/leverage/season/1/episode/the-nigerian-job-2009-1-1',
+            'http://pluto.tv/on-demand/fear-factor-usa-(lf)/season/5/episode/underwater-safe-bob-car-ramp-2004-5-3',
+        ]
+        for url in should_not_match:
+            self.assertFalse(Pluto.can_handle_url(url), url)
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
index fe7989f2..eb199178 100644
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -470,22 +470,8 @@ class TestTwitchReruns(unittest.TestCase):
     log_call = call("Reruns were disabled by command line option")
 
     def subject(self, **params):
-        with requests_mock.Mocker() as mock:
-            mock.get(
-                "https://api.twitch.tv/kraken/users?login=foo",
-                json={"users": [{"_id": 1234}]}
-            )
-            mock.get(
-                "https://api.twitch.tv/kraken/streams/1234",
-                json={"stream": None} if params.pop("offline", False) else {"stream": {
-                    "stream_type": params.pop("stream_type", "live"),
-                    "broadcast_platform": params.pop("broadcast_platform", "live"),
-                    "channel": {
-                        "broadcaster_software": params.pop("broadcaster_software", "")
-                    }
-                }}
-            )
-
+        with patch("streamlink.plugins.twitch.TwitchAPI.stream_metadata") as mock:
+            mock.return_value = None if params.pop("offline", False) else {"type": params.pop("stream_type", "live")}
             session = Streamlink()
             Twitch.bind(session, "tests.plugins.test_twitch")
             plugin = Twitch("https://www.twitch.tv/foo")
@@ -501,18 +487,6 @@ class TestTwitchReruns(unittest.TestCase):
         self.assertTrue(self.subject(stream_type="rerun"))
         self.assertIn(self.log_call, mock_log.info.call_args_list)
 
-    def test_disable_reruns_mixed(self, mock_log):
-        self.assertTrue(self.subject(stream_type="rerun", broadcast_platform="live"))
-        self.assertIn(self.log_call, mock_log.info.call_args_list)
-
-    def test_disable_reruns_mixed2(self, mock_log):
-        self.assertTrue(self.subject(stream_type="live", broadcast_platform="rerun"))
-        self.assertIn(self.log_call, mock_log.info.call_args_list)
-
-    def test_disable_reruns_broadcaster_software(self, mock_log):
-        self.assertTrue(self.subject(broadcaster_software="watch_party_rerun"))
-        self.assertIn(self.log_call, mock_log.info.call_args_list)
-
     def test_disable_reruns_offline(self, mock_log):
         self.assertFalse(self.subject(offline=True))
         self.assertNotIn(self.log_call, mock_log.info.call_args_list)
diff --git a/tests/plugins/testplugin_invalid.py b/tests/plugins/testplugin_invalid.py
new file mode 100644
index 00000000..b3c92ac0
--- /dev/null
+++ b/tests/plugins/testplugin_invalid.py
@@ -0,0 +1,6 @@
+class TestPluginInvalid:
+    pass
+
+
+# does not inherit from streamlink.plugin.plugin.Plugin
+__plugin__ = TestPluginInvalid
diff --git a/tests/plugins/testplugin_missing.py b/tests/plugins/testplugin_missing.py
new file mode 100644
index 00000000..624a2c1b
--- /dev/null
+++ b/tests/plugins/testplugin_missing.py
@@ -0,0 +1,9 @@
+from streamlink.plugin.plugin import Plugin
+
+
+class TestPluginMissing(Plugin):
+    pass
+
+
+# does not export plugin via __plugin__
+# __plugin__ = TestPluginMissing
diff --git a/tests/plugins/testplugin_override.py b/tests/plugins/testplugin_override.py
new file mode 100644
index 00000000..046dc649
--- /dev/null
+++ b/tests/plugins/testplugin_override.py
@@ -0,0 +1,11 @@
+from tests.plugins.testplugin import __plugin__ as TestPlugin
+
+
+class TestPluginOverride(TestPlugin):
+    @classmethod
+    def bind(cls, *args, **kwargs):
+        super().bind(*args, **kwargs)
+        cls.module = "testplugin"
+
+
+__plugin__ = TestPluginOverride
diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py
index 0a096e5c..1f0c1c0d 100644
--- a/tests/resources/__init__.py
+++ b/tests/resources/__init__.py
@@ -5,16 +5,12 @@ from contextlib import contextmanager
 from io import BytesIO
 
 import requests_mock
-import six
 
 __here__ = os.path.abspath(os.path.dirname(__file__))
 
 
 def _parse_xml(data, strip_ns=False):
-    if six.PY2 and isinstance(data, six.text_type):
-        data = data.encode("utf8")
-    elif six.PY3:
-        data = bytearray(data, "utf8")
+    data = bytearray(data, "utf8")
     try:
         it = ET.iterparse(BytesIO(data))
         for _, el in it:
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
index a0ad7e18..b3f9177e 100644
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -48,7 +48,7 @@ class TestPluginAPIValidate(unittest.TestCase):
 
     def test_list_tuple_set_frozenset(self):
         assert validate([int], [1, 2])
-        assert validate(set([int]), set([1, 2])) == set([1, 2])
+        assert validate({int}, {1, 2}) == {1, 2}
         assert validate(tuple([int]), tuple([1, 2])) == tuple([1, 2])
 
     def test_dict(self):
diff --git a/tests/test_console.py b/tests/test_console.py
index ddb85948..0d8c020a 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -5,7 +5,7 @@ from unittest.mock import patch
 from streamlink_cli.console import ConsoleOutput
 
 
-class _TestObj(object):
+class _TestObj:
     def __json__(self):
         return {"test": 1}
 
diff --git a/tests/test_localization.py b/tests/test_localization.py
index 037888c6..8f6cbde7 100644
--- a/tests/test_localization.py
+++ b/tests/test_localization.py
@@ -19,7 +19,7 @@ except ImportError:
     PYCOUNTRY = False
 
 
-class LocalizationTestsMixin(object):
+class LocalizationTestsMixin:
     def test_language_code_us(self):
         locale = l10n.Localization("en_US")
         self.assertEqual("en_US", locale.language_code)
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index 6eb18837..8229372b 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -1,12 +1,10 @@
-import imp
+import inspect
 import os.path
 import pkgutil
 import unittest
 
-import six
-
 import streamlink.plugins
-from streamlink import Streamlink
+from streamlink.plugins import Plugin
 from streamlink.utils import load_module
 
 
@@ -15,33 +13,30 @@ class PluginTestMeta(type):
         plugin_path = os.path.dirname(streamlink.plugins.__file__)
         plugins = []
         for loader, pname, ispkg in pkgutil.iter_modules([plugin_path]):
-            module = load_module(pname, plugin_path)
+            module = load_module(f"streamlink.plugins.{pname}", plugin_path)
             if hasattr(module, "__plugin__"):
-                plugins.append((pname))
-
-        session = Streamlink()
+                plugins.append((loader, pname))
 
-        def gentest(pname):
+        def gentest(loader, pname):
             def load_plugin_test(self):
-                # Reset file variable to ensure it is still open when doing
-                # load_plugin else python might open the plugin source .py
-                # using ascii encoding instead of utf-8.
-                # See also open() call here: imp._HackedGetData.get_data
-                file, pathname, desc = imp.find_module(pname, [plugin_path])
-                session.load_plugin(pname, file, pathname, desc)
-                # validate that can_handle_url does not fail
-                session.plugins[pname].can_handle_url("http://test.com")
+                plugin = loader.find_module(pname).load_module(pname)
+                assert hasattr(plugin, "__plugin__"), "It exports __plugin__"
+                assert issubclass(plugin.__plugin__, Plugin), "__plugin__ is an instance of the Plugin class"
+
+                assert callable(plugin.__plugin__._get_streams), "The plugin implements _get_streams"
+                assert callable(plugin.__plugin__.can_handle_url), "The plugin implements can_handle_url"
+                sig = inspect.signature(plugin.__plugin__.can_handle_url)
+                assert str(sig) == "(url)", "can_handle_url only accepts the url arg"
 
             return load_plugin_test
 
-        for pname in plugins:
-            dict['test_{0}_load'.format(pname)] = gentest(pname)
+        for loader, pname in plugins:
+            dict[f"test_{pname}_load"] = gentest(loader, pname)
 
         return type.__new__(mcs, name, bases, dict)
 
 
-@six.add_metaclass(PluginTestMeta)
-class TestPlugins(unittest.TestCase):
+class TestPlugins(unittest.TestCase, metaclass=PluginTestMeta):
     """
     Test that each plugin can be loaded and does not fail when calling can_handle_url.
     """
diff --git a/tests/test_session.py b/tests/test_session.py
index 488dba95..82d90ab1 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -1,11 +1,9 @@
 import os
 import unittest
-from unittest.mock import call, patch
 
 from streamlink import NoPluginError, Streamlink
 from streamlink.plugin.plugin import HIGH_PRIORITY, LOW_PRIORITY
 from streamlink.plugins import Plugin
-from streamlink.session import print_small_exception
 from streamlink.stream import AkamaiHDStream, HLSStream, HTTPStream, RTMPStream
 
 
@@ -21,11 +19,21 @@ class TestSession(unittest.TestCase):
 
     def test_load_plugins(self):
         plugins = self.session.get_plugins()
-        self.assertTrue(plugins["testplugin"])
+        self.assertIn("testplugin", plugins)
+        self.assertNotIn("testplugin_missing", plugins)
+        self.assertNotIn("testplugin_invalid", plugins)
 
     def test_builtin_plugins(self):
         plugins = self.session.get_plugins()
-        self.assertTrue("twitch" in plugins)
+        self.assertIn("twitch", plugins)
+        self.assertEqual(plugins["twitch"].__module__, "streamlink.plugins.twitch")
+
+    def test_override_plugins(self):
+        plugins = self.session.get_plugins()
+        self.assertIn("testplugin", plugins)
+        self.assertNotIn("testplugin_override", plugins)
+        self.assertEqual(plugins["testplugin"].__name__, "TestPluginOverride")
+        self.assertEqual(plugins["testplugin"].__module__, "streamlink.plugins.testplugin_override")
 
     def test_resolve_url(self):
         plugins = self.session.get_plugins()
@@ -149,16 +157,6 @@ class TestSession(unittest.TestCase):
         self.assertTrue("support" in streams)
         self.assertTrue(isinstance(streams["support"], HTTPStream))
 
-    @patch("streamlink.session.sys.stderr")
-    def test_short_exception(self, stderr):
-        try:
-            raise RuntimeError("test exception")
-        except RuntimeError:
-            print_small_exception("test_short_exception")
-            self.assertSequenceEqual(
-                [call('RuntimeError: test exception\n'), call('\n')],
-                stderr.write.mock_calls)
-
     def test_set_and_get_locale(self):
         session = Streamlink()
         session.set_option("locale", "en_US")