Repository: streamlink/streamlink
Pull request #3984 (2021-09-02 17:04:25)

cli.console: refactor ConsoleOutput
### Motivation

1. `console.msg()` should only receive an interpolated string as sole parameter, same as all `log.{info,warning,debug,error,trace}()` calls.
2. `console.msg_json()` should be able to receive multiple args and keywords that will be merged in the output without altering the main object.
   This is required for an implementation of #3941 where a stream object output needs to include additional properties.
3. `console.{ask,askpass}()` should use the same output stream as the regular msg calls and the logger, namely `stdout`, and not use `stderr`.

### Commits

- Since (1) already forces almost all parameters of `console.*` calls to be changed, I fixed all strings in the entire cli.main module.
- (2) adds types to the global vars, so all console methods (and attrs/methods of the other vars) can be referenced. This reduces the number of type warnings from 321 to 228. The only problematic global var is `args`, which can't be typed properly (argparse issue). Without the `args` typing issue, the overall warnings get reduced to 18.
- (3) fixes a small issue with the json output checks, which should depend on `args` and not `console`.


Patch:
diff --git a/src/streamlink/stream/__init__.py b/src/streamlink/stream/__init__.py
index bef4ac42..dfb4f4ee 100644
--- a/src/streamlink/stream/__init__.py
+++ b/src/streamlink/stream/__init__.py
@@ -6,6 +6,6 @@ from streamlink.stream.hls import HLSStream
 from streamlink.stream.http import HTTPStream
 from streamlink.stream.playlist import FLVPlaylist, Playlist
 from streamlink.stream.rtmpdump import RTMPStream
-from streamlink.stream.stream import Stream
+from streamlink.stream.stream import Stream, StreamIO
 from streamlink.stream.streamprocess import StreamProcess
 from streamlink.stream.wrappers import StreamIOIterWrapper, StreamIOThreadWrapper, StreamIOWrapper
diff --git a/src/streamlink_cli/console.py b/src/streamlink_cli/console.py
index 93e3acc3..6540e804 100644
--- a/src/streamlink_cli/console.py
+++ b/src/streamlink_cli/console.py
@@ -1,13 +1,11 @@
-import json
-import logging
 import sys
 from getpass import getpass
+from json import dumps
+from typing import Any, IO, Union
 
 from streamlink.plugin.plugin import UserInputRequester
 from streamlink_cli.utils import JSONEncoder
 
-log = logging.getLogger("streamlink.cli")
-
 
 class ConsoleUserInputRequester(UserInputRequester):
     """
@@ -16,77 +14,81 @@ class ConsoleUserInputRequester(UserInputRequester):
     def __init__(self, console):
         self.console = console
 
-    def ask(self, prompt):
-        if sys.stdin.isatty():
-            return self.console.ask(prompt.strip() + ": ")
-        else:
+    def ask(self, prompt: str) -> str:
+        if not sys.stdin.isatty():
             raise OSError("no TTY available")
+        return self.console.ask(f"{prompt.strip()}: ")
 
-    def ask_password(self, prompt):
-        if sys.stdin.isatty():
-            return self.console.askpass(prompt.strip() + ": ")
-        else:
+    def ask_password(self, prompt: str) -> str:
+        if not sys.stdin.isatty():
             raise OSError("no TTY available")
+        return self.console.askpass(f"{prompt.strip()}: ")
 
 
 class ConsoleOutput:
-    def __init__(self, output, json=False):
+    def __init__(self, output: IO, json: bool = False):
         self.json = json
         self.output = output
 
-    def set_output(self, output):
-        self.output = output
+    def ask(self, prompt: str) -> Union[None, str]:
+        if not sys.stdin.isatty():
+            return
 
-    @classmethod
-    def ask(cls, msg, *args, **kwargs):
-        if sys.stdin.isatty():
-            formatted = msg.format(*args, **kwargs)
-            sys.stderr.write(formatted)
+        self.output.write(prompt)
 
-            try:
-                answer = input()
-            except Exception:
-                answer = ""
+        try:
+            return input().strip()
+        except Exception:
+            return
 
-            return answer.strip()
-        else:
-            return ""
+    def askpass(self, prompt: str) -> Union[None, str]:
+        if not sys.stdin.isatty():
+            return
 
-    @classmethod
-    def askpass(cls, msg, *args, **kwargs):
-        if sys.stdin.isatty():
-            return getpass(msg.format(*args, **kwargs))
-        else:
-            return ""
+        return getpass(prompt, self.output)
 
-    def msg(self, msg, *args, **kwargs):
-        formatted = f"{msg.format(*args, **kwargs)}\n"
-        self.output.write(formatted)
+    def msg(self, msg: str) -> None:
+        self.output.write(f"{msg}\n")
 
-    def msg_json(self, obj):
+    def msg_json(self, *objs: Any, **keywords: Any) -> None:
         if not self.json:
             return
 
-        if hasattr(obj, "__json__"):
-            obj = obj.__json__()
-
-        msg = json.dumps(obj, cls=JSONEncoder,
-                         indent=2)
-        self.msg("{0}", msg)
-
-        if isinstance(obj, dict) and obj.get("error"):
+        if objs and isinstance(objs[0], list):
+            out = []
+            for obj in objs:
+                if isinstance(obj, list):
+                    for item in obj:
+                        out.append(item)
+                else:
+                    if hasattr(obj, "__json__") and callable(obj.__json__):
+                        obj = obj.__json__()
+                    out.append(obj)
+            if keywords:
+                out.append(keywords)
+        else:
+            out = {}
+            for obj in objs:
+                if hasattr(obj, "__json__") and callable(obj.__json__):
+                    obj = obj.__json__()
+                if not isinstance(obj, dict):
+                    continue
+                out.update(**obj)
+            out.update(**keywords)
+
+        msg = dumps(out, cls=JSONEncoder, indent=2)
+        self.msg(msg)
+
+        if type(out) is dict and out.get("error"):
             sys.exit(1)
 
-    def exit(self, msg, *args, **kwargs):
-        formatted = msg.format(*args, **kwargs)
-
+    def exit(self, msg: str) -> None:
         if self.json:
-            obj = dict(error=formatted)
-            self.msg_json(obj)
+            self.msg_json(error=msg)
         else:
-            self.msg("{0}", f"error: {formatted}")
+            self.msg(f"error: {msg}")
 
         sys.exit(1)
 
 
-__all__ = ["ConsoleOutput"]
+__all__ = ["ConsoleOutput", "ConsoleUserInputRequester"]
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index a65715fa..bfe2117f 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -24,14 +24,14 @@ import streamlink.logger as logger
 from streamlink import NoPluginError, PluginError, StreamError, Streamlink, __version__ as streamlink_version
 from streamlink.cache import Cache
 from streamlink.exceptions import FatalPluginError
-from streamlink.plugin import PluginOptions
-from streamlink.stream import StreamProcess
+from streamlink.plugin import Plugin, PluginOptions
+from streamlink.stream import StreamIO, StreamProcess
 from streamlink.utils import NamedPipe
 from streamlink_cli.argparser import build_parser
 from streamlink_cli.compat import DeprecatedPath, is_win32, stdout
 from streamlink_cli.console import ConsoleOutput, ConsoleUserInputRequester
 from streamlink_cli.constants import CONFIG_FILES, DEFAULT_STREAM_METADATA, LOG_DIR, PLUGIN_DIRS, STREAM_SYNONYMS
-from streamlink_cli.output import FileOutput, PlayerOutput
+from streamlink_cli.output import FileOutput, Output, PlayerOutput
 from streamlink_cli.utils import Formatter, HTTPServer, ignored, progress, stream_to_url
 
 ACCEPTABLE_ERRNO = (errno.EPIPE, errno.EINVAL, errno.ECONNRESET)
@@ -41,7 +41,12 @@ except AttributeError:
     pass  # Not windows
 QUIET_OPTIONS = ("json", "stream_url", "subprocess_cmdline", "quiet")
 
-args = console = streamlink = plugin = stream_fd = output = None
+args = None
+console: ConsoleOutput = None
+output: Output = None
+plugin: Plugin = None
+stream_fd: StreamIO = None
+streamlink: Streamlink = None
 
 log = logging.getLogger("streamlink.cli")
 
@@ -100,14 +105,14 @@ def create_output(formatter: Formatter):
             try:
                 namedpipe = NamedPipe()
             except OSError as err:
-                console.exit("Failed to create pipe: {0}", err)
+                console.exit(f"Failed to create pipe: {err}")
         elif args.player_http:
             http = create_http_server()
 
         if args.record:
             record = check_file_output(formatter.filename(args.record, args.fs_safe_rules), args.force)
 
-        log.info("Starting player: {0}".format(args.player))
+        log.info(f"Starting player: {args.player}")
 
         out = PlayerOutput(
             args.player,
@@ -134,7 +139,7 @@ def create_http_server(*_args, **_kwargs):
         http = HTTPServer()
         http.bind(*_args, **_kwargs)
     except OSError as err:
-        console.exit("Failed to create HTTP server: {0}", err)
+        console.exit(f"Failed to create HTTP server: {err}")
 
     return http
 
@@ -173,12 +178,11 @@ def output_stream_http(plugin, initial_streams, formatter: Formatter, external=F
         )
 
         try:
-            log.info("Starting player: {0}".format(args.player))
+            log.info(f"Starting player: {args.player}")
             if player:
                 player.open()
         except OSError as err:
-            console.exit("Failed to start player: {0} ({1})",
-                         args.player, err)
+            console.exit(f"Failed to start player: {args.player} ({err})")
     else:
         server = create_http_server(host=None, port=port)
         player = None
@@ -189,7 +193,7 @@ def output_stream_http(plugin, initial_streams, formatter: Formatter, external=F
 
     for req in iter_http_requests(server, player):
         user_agent = req.headers.get("User-Agent") or "unknown player"
-        log.info("Got HTTP request from {0}".format(user_agent))
+        log.info(f"Got HTTP request from {user_agent}")
 
         stream_fd = prebuffer = None
         while not stream_fd and (not player or player.running):
@@ -202,8 +206,7 @@ def output_stream_http(plugin, initial_streams, formatter: Formatter, external=F
                         stream = streams[stream_name]
                         break
                 else:
-                    log.info("Stream not available, will re-fetch "
-                             "streams in 10 sec")
+                    log.info("Stream not available, will re-fetch streams in 10 sec")
                     sleep(10)
                     continue
             except PluginError as err:
@@ -211,11 +214,10 @@ def output_stream_http(plugin, initial_streams, formatter: Formatter, external=F
                 continue
 
             try:
-                log.info("Opening stream: {0} ({1})".format(stream_name,
-                                                            type(stream).shortname()))
+                log.info(f"Opening stream: {stream_name} ({type(stream).shortname()})")
                 stream_fd, prebuffer = open_stream(stream)
             except StreamError as err:
-                log.error("{0}".format(err))
+                log.error(err)
 
         if stream_fd and prebuffer:
             log.debug("Writing stream to player")
@@ -242,10 +244,10 @@ def output_stream_passthrough(stream, formatter: Formatter):
     )
 
     try:
-        log.info("Starting player: {0}".format(args.player))
+        log.info(f"Starting player: {args.player}")
         output.open()
     except OSError as err:
-        console.exit("Failed to start player: {0} ({1})", args.player, err)
+        console.exit(f"Failed to start player: {args.player} ({err})")
         return False
 
     return True
@@ -264,7 +266,7 @@ def open_stream(stream):
     try:
         stream_fd = stream.open()
     except StreamError as err:
-        raise StreamError("Could not open stream: {0}".format(err))
+        raise StreamError(f"Could not open stream: {err}")
 
     # Read 8192 bytes before proceeding to check for errors.
     # This is to avoid opening the output unnecessarily.
@@ -273,7 +275,7 @@ def open_stream(stream):
         prebuffer = stream_fd.read(8192)
     except OSError as err:
         stream_fd.close()
-        raise StreamError("Failed to read data from stream: {0}".format(err))
+        raise StreamError(f"Failed to read data from stream: {err}")
 
     if not prebuffer:
         stream_fd.close()
@@ -293,11 +295,10 @@ def output_stream(stream, formatter: Formatter):
             success_open = True
             break
         except StreamError as err:
-            log.error("Try {0}/{1}: Could not open stream {2} ({3})".format(
-                i + 1, args.retry_open, stream, err))
+            log.error(f"Try {i + 1}/{args.retry_open}: Could not open stream {stream} ({err})")
 
     if not success_open:
-        console.exit("Could not open stream {0}, tried {1} times, exiting", stream, args.retry_open)
+        console.exit(f"Could not open stream {stream}, tried {args.retry_open} times, exiting")
 
     output = create_output(formatter)
 
@@ -368,11 +369,11 @@ def read_stream(stream, output, prebuffer, formatter: Formatter, chunk_size=8192
                 elif is_http and err.errno in ACCEPTABLE_ERRNO:
                     log.info("HTTP connection closed")
                 else:
-                    console.exit("Error when writing to output: {0}, exiting", err)
+                    console.exit(f"Error when writing to output: {err}, exiting")
 
                 break
     except OSError as err:
-        console.exit("Error when reading from stream: {0}, exiting", err)
+        console.exit(f"Error when reading from stream: {err}, exiting")
     finally:
         stream.close()
         log.info("Stream ended")
@@ -399,19 +400,19 @@ def handle_stream(plugin, streams, stream_name):
             try:
                 cmdline = stream.cmdline()
             except StreamError as err:
-                console.exit("{0}", err)
+                console.exit(err)
 
-            console.msg("{0}", cmdline)
+            console.msg(cmdline)
         else:
             console.exit("The stream specified cannot be translated to a command")
 
     # Print JSON representation of the stream
-    elif console.json:
+    elif args.json:
         console.msg_json(stream)
 
     elif args.stream_url:
         try:
-            console.msg("{0}", stream.to_url())
+            console.msg(stream.to_url())
         except TypeError:
             console.exit("The stream specified cannot be translated to a URL")
 
@@ -469,8 +470,7 @@ def fetch_streams_with_retry(plugin, interval, count):
         streams = None
 
     if not streams:
-        log.info("Waiting for streams, retrying every {0} "
-                 "second(s)".format(interval))
+        log.info(f"Waiting for streams, retrying every {interval} second(s)")
     attempts = 0
 
     while not streams:
@@ -528,7 +528,7 @@ def format_valid_streams(plugin, streams):
 
         if len(synonyms) > 0:
             joined = delimiter.join(synonyms)
-            name = "{0} ({1})".format(name, joined)
+            name = f"{name} ({joined})"
 
         validstreams.append(name)
 
@@ -563,12 +563,12 @@ def handle_url():
         else:
             streams = fetch_streams(plugin)
     except NoPluginError:
-        console.exit("No plugin can handle URL: {0}", args.url)
+        console.exit(f"No plugin can handle URL: {args.url}")
     except PluginError as err:
-        console.exit("{0}", err)
+        console.exit(err)
 
     if not streams:
-        console.exit("No playable streams found on this URL: {0}", args.url)
+        console.exit(f"No playable streams found on this URL: {args.url}")
 
     if args.default_stream and not args.stream and not args.json:
         args.stream = args.default_stream
@@ -577,29 +577,25 @@ def handle_url():
         validstreams = format_valid_streams(plugin, streams)
         for stream_name in args.stream:
             if stream_name in streams:
-                log.info("Available streams: {0}".format(validstreams))
+                log.info(f"Available streams: {validstreams}")
                 handle_stream(plugin, streams, stream_name)
                 return
 
-        err = ("The specified stream(s) '{0}' could not be "
-               "found".format(", ".join(args.stream)))
-
-        if console.json:
-            console.msg_json(dict(streams=streams, plugin=plugin.module,
-                                  error=err))
+        err = f"The specified stream(s) '{', '.join(args.stream)}' could not be found"
+        if args.json:
+            console.msg_json(plugin=plugin.module, streams=streams, error=err)
         else:
-            console.exit("{0}.\n       Available streams: {1}",
-                         err, validstreams)
-    elif console.json:
-        console.msg_json(dict(plugin=plugin.module, streams=streams))
+            console.exit(f"{err}.\n       Available streams: {validstreams}")
+    elif args.json:
+        console.msg_json(plugin=plugin.module, streams=streams)
     elif args.stream_url:
         try:
-            console.msg("{0}", streams[list(streams)[-1]].to_manifest_url())
+            console.msg(streams[list(streams)[-1]].to_manifest_url())
         except TypeError:
             console.exit("The stream specified cannot be translated to a URL")
     else:
         validstreams = format_valid_streams(plugin, streams)
-        console.msg("Available streams: {0}", validstreams)
+        console.msg(f"Available streams: {validstreams}")
 
 
 def print_plugins():
@@ -608,10 +604,10 @@ def print_plugins():
     pluginlist = list(streamlink.get_plugins().keys())
     pluginlist_formatted = ", ".join(sorted(pluginlist))
 
-    if console.json:
+    if args.json:
         console.msg_json(pluginlist)
     else:
-        console.msg("Loaded plugins: {0}", pluginlist_formatted)
+        console.msg(f"Loaded plugins: {pluginlist_formatted}")
 
 
 def load_plugins(dirs: List[Path], showwarning: bool = True):
@@ -635,8 +631,8 @@ def setup_args(parser: argparse.ArgumentParser, config_files: List[Path] = None,
 
     args, unknown = parser.parse_known_args(configs + arglist)
     if unknown and not ignore_unknown:
-        msg = gettext('unrecognized arguments: %s')
-        parser.error(msg % ' '.join(unknown))
+        msg = gettext("unrecognized arguments: %s")
+        parser.error(msg % " ".join(unknown))
 
     # Force lowercase to allow case-insensitive lookup
     if args.stream:
@@ -887,11 +883,12 @@ def setup_plugin_options(session, plugin):
     if required:
         for req in required.values():
             if not session.get_plugin_option(pname, req.dest):
-                prompt = req.prompt or "Enter {0} {1}".format(pname, req.name)
-                session.set_plugin_option(pname, req.dest,
-                                          console.askpass(prompt + ": ")
-                                          if req.sensitive else
-                                          console.ask(prompt + ": "))
+                prompt = f"{req.prompt or f'Enter {pname} {req.name}'}: "
+                session.set_plugin_option(
+                    pname,
+                    req.dest,
+                    console.askpass(prompt) if req.sensitive else console.ask(prompt)
+                )
 
 
 def log_root_warning():
@@ -965,12 +962,10 @@ def check_version(force=False):
     latest_version = StrictVersion(latest_version)
 
     if latest_version > installed_version:
-        log.info("A new version of Streamlink ({0}) is "
-                 "available!".format(latest_version))
+        log.info(f"A new version of Streamlink ({latest_version}) is available!")
         cache.set("version_info_printed", True, (60 * 60 * 6))
     elif force:
-        log.info("Your Streamlink version ({0}) is up to date!".format(
-                 installed_version))
+        log.info(f"Your Streamlink version ({installed_version}) is up to date!")
 
     if force:
         sys.exit()
@@ -1081,11 +1076,10 @@ def main():
         parser.print_help()
     else:
         usage = parser.format_usage()
-        msg = (
-            "{usage}\nUse -h/--help to see the available options or "
-            "read the manual at https://streamlink.github.io"
-        ).format(usage=usage)
-        console.msg(msg)
+        console.msg(
+            f"{usage}\n"
+            f"Use -h/--help to see the available options or read the manual at https://streamlink.github.io"
+        )
 
     sys.exit(error_code)
 


Test patch:
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
index 9f763c96..10374468 100644
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -102,8 +102,8 @@ class TestCLIMain(unittest.TestCase):
 
 
 class TestCLIMainJsonAndStreamUrl(unittest.TestCase):
-    @patch("streamlink_cli.main.args", stream_url=True, subprocess_cmdline=False)
-    @patch("streamlink_cli.main.console", json=True)
+    @patch("streamlink_cli.main.args", json=True, stream_url=True, subprocess_cmdline=False)
+    @patch("streamlink_cli.main.console")
     def test_handle_stream_with_json_and_stream_url(self, console, args):
         stream = Mock()
         streams = dict(best=stream)
@@ -115,9 +115,9 @@ class TestCLIMainJsonAndStreamUrl(unittest.TestCase):
         self.assertEqual(console.error.mock_calls, [])
         console.msg_json.mock_calls.clear()
 
-        console.json = False
+        args.json = False
         handle_stream(plugin, streams, "best")
-        self.assertEqual(console.msg.mock_calls, [call("{0}", stream.to_url())])
+        self.assertEqual(console.msg.mock_calls, [call(stream.to_url())])
         self.assertEqual(console.msg_json.mock_calls, [])
         self.assertEqual(console.error.mock_calls, [])
         console.msg.mock_calls.clear()
@@ -128,8 +128,8 @@ class TestCLIMainJsonAndStreamUrl(unittest.TestCase):
         self.assertEqual(console.msg_json.mock_calls, [])
         self.assertEqual(console.exit.mock_calls, [call("The stream specified cannot be translated to a URL")])
 
-    @patch("streamlink_cli.main.args", stream_url=True, stream=[], default_stream=[], retry_max=0, retry_streams=0)
-    @patch("streamlink_cli.main.console", json=True)
+    @patch("streamlink_cli.main.args", json=True, stream_url=True, stream=[], default_stream=[], retry_max=0, retry_streams=0)
+    @patch("streamlink_cli.main.console")
     def test_handle_url_with_json_and_stream_url(self, console, args):
         stream = Mock()
         streams = dict(worst=Mock(), best=stream)
@@ -138,13 +138,13 @@ class TestCLIMainJsonAndStreamUrl(unittest.TestCase):
         with patch("streamlink_cli.main.streamlink", resolve_url=Mock(return_value=plugin)):
             handle_url()
             self.assertEqual(console.msg.mock_calls, [])
-            self.assertEqual(console.msg_json.mock_calls, [call(dict(plugin="fake", streams=streams))])
+            self.assertEqual(console.msg_json.mock_calls, [call(plugin="fake", streams=streams)])
             self.assertEqual(console.error.mock_calls, [])
             console.msg_json.mock_calls.clear()
 
-            console.json = False
+            args.json = False
             handle_url()
-            self.assertEqual(console.msg.mock_calls, [call("{0}", stream.to_manifest_url())])
+            self.assertEqual(console.msg.mock_calls, [call(stream.to_manifest_url())])
             self.assertEqual(console.msg_json.mock_calls, [])
             self.assertEqual(console.error.mock_calls, [])
             console.msg.mock_calls.clear()
@@ -330,12 +330,11 @@ class TestCLIMainCreateOutput(unittest.TestCase):
 class TestCLIMainHandleStream(unittest.TestCase):
     @patch("streamlink_cli.main.output_stream")
     @patch("streamlink_cli.main.args")
-    @patch("streamlink_cli.main.console")
-    def test_handle_stream_output_stream(self, console: Mock, args: Mock, mock_output_stream: Mock):
+    def test_handle_stream_output_stream(self, args: Mock, mock_output_stream: Mock):
         """
         Test that the formatter does define the correct variables
         """
-        console.json = False
+        args.json = False
         args.subprocess_cmdline = False
         args.stream_url = False
         args.output = False
diff --git a/tests/test_console.py b/tests/test_console.py
index 0d8c020a..61c17b80 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -1,29 +1,16 @@
 import unittest
 from io import StringIO
-from unittest.mock import patch
+from unittest.mock import Mock, patch
 
 from streamlink_cli.console import ConsoleOutput
 
 
-class _TestObj:
-    def __json__(self):
-        return {"test": 1}
-
-
-class TestConsole(unittest.TestCase):
+class TestConsoleOutput(unittest.TestCase):
     def test_msg_format(self):
         output = StringIO()
         console = ConsoleOutput(output)
-        console.msg("{0} - {1}", 1, 2)
-
-        self.assertEqual("1 - 2\n", output.getvalue())
-
-    def test_msg_format_kw(self):
-        output = StringIO()
-        console = ConsoleOutput(output)
-        console.msg("{test} - {what}", test=1, what=2)
-
-        self.assertEqual("1 - 2\n", output.getvalue())
+        console.msg("foo")
+        self.assertEqual("foo\n", output.getvalue())
 
     def test_msg_json_not_set(self):
         output = StringIO()
@@ -35,74 +22,120 @@ class TestConsole(unittest.TestCase):
         output = StringIO()
         console = ConsoleOutput(output, json=True)
         console.msg_json({"test": 1})
-        self.assertEqual('''{\n  "test": 1\n}\n''', output.getvalue())
+        self.assertEqual('{\n  "test": 1\n}\n', output.getvalue())
 
     def test_msg_json_object(self):
         output = StringIO()
-        test_obj = _TestObj()
         console = ConsoleOutput(output, json=True)
-        console.msg_json(test_obj)
-        self.assertEqual('''{\n  "test": 1\n}\n''', output.getvalue())
+        console.msg_json(Mock(__json__=Mock(return_value={"test": 1})))
+        self.assertEqual('{\n  "test": 1\n}\n', output.getvalue())
 
-    @patch('streamlink_cli.console.sys.exit')
+    def test_msg_json_list(self):
+        output = StringIO()
+        console = ConsoleOutput(output, json=True)
+        test_list = ["foo", "bar"]
+        console.msg_json(test_list)
+        self.assertEqual('[\n  "foo",\n  "bar"\n]\n', output.getvalue())
+
+    def test_msg_json_merge_object(self):
+        output = StringIO()
+        console = ConsoleOutput(output, json=True)
+        test_obj1 = {"test": 1, "foo": "foo"}
+        test_obj2 = Mock(__json__=Mock(return_value={"test": 2}))
+        console.msg_json(test_obj1, test_obj2, ["qux"], foo="bar", baz="qux")
+        self.assertEqual(
+            '{\n'
+            '  "test": 2,\n'
+            '  "foo": "bar",\n'
+            '  "baz": "qux"\n'
+            '}\n',
+            output.getvalue()
+        )
+        self.assertEqual([("test", 1), ("foo", "foo")], list(test_obj1.items()))
+
+    def test_msg_json_merge_list(self):
+        output = StringIO()
+        console = ConsoleOutput(output, json=True)
+        test_list1 = ["foo", "bar"]
+        test_list2 = Mock(__json__=Mock(return_value={"foo": "bar"}))
+        console.msg_json(test_list1, ["baz"], test_list2, {"foo": "bar"}, foo="bar", baz="qux")
+        self.assertEqual(
+            '[\n'
+            '  "foo",\n'
+            '  "bar",\n'
+            '  "baz",\n'
+            '  {\n    "foo": "bar"\n  },\n'
+            '  {\n    "foo": "bar"\n  },\n'
+            '  {\n    "foo": "bar",\n    "baz": "qux"\n  }\n'
+            ']\n',
+            output.getvalue()
+        )
+        self.assertEqual(["foo", "bar"], test_list1)
+
+    @patch("streamlink_cli.console.sys.exit")
     def test_msg_json_error(self, mock_exit):
         output = StringIO()
         console = ConsoleOutput(output, json=True)
         console.msg_json({"error": "bad"})
-        self.assertEqual('''{\n  "error": "bad"\n}\n''', output.getvalue())
+        self.assertEqual('{\n  "error": "bad"\n}\n', output.getvalue())
         mock_exit.assert_called_with(1)
 
-    @patch('streamlink_cli.console.sys.exit')
-    def test_exit(self, mock_exit):
+    @patch("streamlink_cli.console.sys.exit")
+    def test_exit(self, mock_exit: Mock):
         output = StringIO()
         console = ConsoleOutput(output)
         console.exit("error")
         self.assertEqual("error: error\n", output.getvalue())
         mock_exit.assert_called_with(1)
 
-    @patch('streamlink_cli.console.sys.exit')
-    def test_exit_json(self, mock_exit):
+    @patch("streamlink_cli.console.sys.exit")
+    def test_exit_json(self, mock_exit: Mock):
         output = StringIO()
         console = ConsoleOutput(output, json=True)
         console.exit("error")
-        self.assertEqual('''{\n  "error": "error"\n}\n''', output.getvalue())
+        self.assertEqual('{\n  "error": "error"\n}\n', output.getvalue())
         mock_exit.assert_called_with(1)
 
-    @patch('streamlink_cli.console.sys.stderr')
-    @patch('streamlink_cli.console.input')
-    @patch('streamlink_cli.console.sys.stdin.isatty')
-    def test_ask(self, isatty, input, stderr):
-        input.return_value = "hello"
-        isatty.return_value = True
-        self.assertEqual("hello", ConsoleOutput.ask("test: "))
-        stderr.write.assert_called_with("test: ")
-
-    @patch('streamlink_cli.console.sys.stderr')
-    @patch('streamlink_cli.console.input')
-    @patch('streamlink_cli.console.sys.stdin.isatty')
-    def test_ask_no_tty(self, isatty, input, stderr):
-        isatty.return_value = False
-        self.assertEqual("", ConsoleOutput.ask("test: "))
-        input.assert_not_called()
-        stderr.write.assert_not_called()
-
-    @patch('streamlink_cli.console.sys.stderr')
-    @patch('streamlink_cli.console.input')
-    @patch('streamlink_cli.console.sys.stdin.isatty')
-    def test_ask_input_exception(self, isatty, input, stderr):
-        isatty.return_value = True
-        input.side_effect = ValueError
-        self.assertEqual("", ConsoleOutput.ask("test: "))
-        stderr.write.assert_called_with("test: ")
-
-    @patch('streamlink_cli.console.getpass')
-    @patch('streamlink_cli.console.sys.stdin.isatty')
-    def test_askpass(self, isatty, getpass):
-        isatty.return_value = True
-        getpass.return_value = "hello"
-        self.assertEqual("hello", ConsoleOutput.askpass("test: "))
-
-    @patch('streamlink_cli.console.sys.stdin.isatty')
-    def test_askpass_no_tty(self, isatty):
-        isatty.return_value = False
-        self.assertEqual("", ConsoleOutput.askpass("test: "))
+    @patch("streamlink_cli.console.input", Mock(return_value="hello"))
+    @patch("streamlink_cli.console.sys.stdin.isatty", Mock(return_value=True))
+    def test_ask(self):
+        output = StringIO()
+        console = ConsoleOutput(output)
+        self.assertEqual("hello", console.ask("test: "))
+        self.assertEqual("test: ", output.getvalue())
+
+    @patch("streamlink_cli.console.input")
+    @patch("streamlink_cli.console.sys.stdin.isatty", Mock(return_value=False))
+    def test_ask_no_tty(self, mock_input: Mock):
+        output = StringIO()
+        console = ConsoleOutput(output)
+        self.assertIsNone(console.ask("test: "))
+        self.assertEqual("", output.getvalue())
+        mock_input.assert_not_called()
+
+    @patch("streamlink_cli.console.input", Mock(side_effect=ValueError))
+    @patch("streamlink_cli.console.sys.stdin.isatty", Mock(return_value=True))
+    def test_ask_input_exception(self):
+        output = StringIO()
+        console = ConsoleOutput(output)
+        self.assertIsNone(console.ask("test: "))
+        self.assertEqual("test: ", output.getvalue())
+
+    @patch("streamlink_cli.console.getpass")
+    @patch("streamlink_cli.console.sys.stdin.isatty", Mock(return_value=True))
+    def test_askpass(self, mock_getpass: Mock):
+        def getpass(prompt, stream):
+            stream.write(prompt)
+            return "hello"
+
+        output = StringIO()
+        console = ConsoleOutput(output)
+        mock_getpass.side_effect = getpass
+        self.assertEqual("hello", console.askpass("test: "))
+        self.assertEqual("test: ", output.getvalue())
+
+    @patch("streamlink_cli.console.sys.stdin.isatty", Mock(return_value=False))
+    def test_askpass_no_tty(self):
+        output = StringIO()
+        console = ConsoleOutput(output)
+        self.assertIsNone(console.askpass("test: "))