Repository: streamlink/streamlink
Pull request #3363 (2020-11-25 18:33:15)

plugins.pluto: new plugin for https://pluto.tv/
https://pluto.tv/live-tv/

- Livestreams will break during commercials without the `self.session.set_option('ffmpeg-fout', 'mpegts')` option enabled.
- VODs might work without it, but I did not test it.

closes https://github.com/streamlink/streamlink/issues/854
closes https://github.com/streamlink/streamlink/pull/2747
closes https://github.com/streamlink/streamlink/issues/3156

Co-authored-by: calculon-jr <54852718+calculon-jr@users.noreply.github.com>



Patch:
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..31cd885e 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
 
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/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..e329c5b0
--- /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,
+                '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:
+            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..dfb624db 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)
@@ -635,7 +635,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..f7e2eaa3 100644
--- a/src/streamlink/plugins/youtube.py
+++ b/src/streamlink/plugins/youtube.py
@@ -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..77b6d77d 100644
--- a/src/streamlink/session.py
+++ b/src/streamlink/session.py
@@ -45,7 +45,7 @@ class PythonDeprecatedWarning(UserWarning):
     pass
 
 
-class Streamlink(object):
+class Streamlink:
     """A Streamlink session is used to keep track of plugins,
        options and log settings."""
 
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..d89ec651 100644
--- a/src/streamlink/utils/__init__.py
+++ b/src/streamlink/utils/__init__.py
@@ -160,12 +160,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
+            yield from search_dict(value, key)
 
 
 def load_module(name, path=None):
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/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..2774ae7f 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -3,8 +3,6 @@ import os.path
 import pkgutil
 import unittest
 
-import six
-
 import streamlink.plugins
 from streamlink import Streamlink
 from streamlink.utils import load_module
@@ -40,8 +38,7 @@ class PluginTestMeta(type):
         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.
     """