Repository: streamlink/streamlink
Pull request #3988 (2021-09-03 16:56:57)

cli.output: fix unknown vars in --player-args / -a
Don't raise a `KeyError` when an invalid or unknown variable is set by the user in `--player-args` / `-a`.


Patch:
diff --git a/src/streamlink/plugin/plugin.py b/src/streamlink/plugin/plugin.py
index c4869508..de0ff492 100644
--- a/src/streamlink/plugin/plugin.py
+++ b/src/streamlink/plugin/plugin.py
@@ -430,6 +430,13 @@ class Plugin:
     def _get_streams(self):
         raise NotImplementedError
 
+    def get_metadata(self) -> Dict[str, Optional[str]]:
+        return dict(
+            author=self.get_author(),
+            category=self.get_category(),
+            title=self.get_title()
+        )
+
     def get_title(self) -> Optional[str]:
         return self.title
 
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index bfe2117f..f6bb845b 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -408,7 +408,10 @@ def handle_stream(plugin, streams, stream_name):
 
     # Print JSON representation of the stream
     elif args.json:
-        console.msg_json(stream)
+        console.msg_json(
+            stream,
+            metadata=plugin.get_metadata()
+        )
 
     elif args.stream_url:
         try:
@@ -583,11 +586,20 @@ def handle_url():
 
         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)
+            console.msg_json(
+                plugin=plugin.module,
+                metadata=plugin.get_metadata(),
+                streams=streams,
+                error=err
+            )
         else:
             console.exit(f"{err}.\n       Available streams: {validstreams}")
     elif args.json:
-        console.msg_json(plugin=plugin.module, streams=streams)
+        console.msg_json(
+            plugin=plugin.module,
+            metadata=plugin.get_metadata(),
+            streams=streams
+        )
     elif args.stream_url:
         try:
             console.msg(streams[list(streams)[-1]].to_manifest_url())
diff --git a/src/streamlink_cli/output.py b/src/streamlink_cli/output.py
index 24169014..eb406ff6 100644
--- a/src/streamlink_cli/output.py
+++ b/src/streamlink_cli/output.py
@@ -8,7 +8,7 @@ from time import sleep
 
 from streamlink_cli.compat import is_win32, stdout
 from streamlink_cli.constants import PLAYER_ARGS_INPUT_DEFAULT, PLAYER_ARGS_INPUT_FALLBACK, SUPPORTED_PLAYERS
-from streamlink_cli.utils import ignored
+from streamlink_cli.utils import Formatter, ignored
 
 if is_win32:
     import msvcrt
@@ -215,7 +215,12 @@ class PlayerOutput(Output):
                     self.title = self.title.replace('"', '')
                     filename = filename[:-1] + '\\' + self.title + filename[-1]
 
-        args = self.args.format(**{PLAYER_ARGS_INPUT_DEFAULT: filename, PLAYER_ARGS_INPUT_FALLBACK: filename})
+        # format args via the formatter, so that invalid/unknown variables don't raise a KeyError
+        argsformatter = Formatter({
+            PLAYER_ARGS_INPUT_DEFAULT: lambda: filename,
+            PLAYER_ARGS_INPUT_FALLBACK: lambda: filename
+        })
+        args = argsformatter.title(self.args)
         cmd = self.cmd
 
         # player command


Test patch:
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
index 10374468..35047bdd 100644
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -9,7 +9,6 @@ import freezegun
 
 import streamlink_cli.main
 import tests.resources
-from streamlink.plugin.plugin import Plugin
 from streamlink.session import Streamlink
 from streamlink.stream.stream import Stream
 from streamlink_cli.compat import DeprecatedPath, is_win32, stdout
@@ -26,11 +25,7 @@ from streamlink_cli.main import (
     setup_config_args
 )
 from streamlink_cli.output import FileOutput, PlayerOutput
-
-
-class FakePlugin(Plugin):
-    def _get_streams(self):
-        pass  # pragma: no cover
+from tests.plugin.testplugin import TestPlugin as FakePlugin
 
 
 class TestCLIMain(unittest.TestCase):
@@ -107,11 +102,21 @@ class TestCLIMainJsonAndStreamUrl(unittest.TestCase):
     def test_handle_stream_with_json_and_stream_url(self, console, args):
         stream = Mock()
         streams = dict(best=stream)
-        plugin = Mock(FakePlugin(""), module="fake", arguments=[], streams=Mock(return_value=streams))
+        plugin = FakePlugin("")
+        plugin.module = "fake"
+        plugin.arguments = []
+        plugin.streams = Mock(return_value=streams)
 
         handle_stream(plugin, streams, "best")
         self.assertEqual(console.msg.mock_calls, [])
-        self.assertEqual(console.msg_json.mock_calls, [call(stream)])
+        self.assertEqual(console.msg_json.mock_calls, [call(
+            stream,
+            metadata=dict(
+                author="Tѥst Āuƭhǿr",
+                category=None,
+                title="Test Title"
+            )
+        )])
         self.assertEqual(console.error.mock_calls, [])
         console.msg_json.mock_calls.clear()
 
@@ -133,12 +138,23 @@ class TestCLIMainJsonAndStreamUrl(unittest.TestCase):
     def test_handle_url_with_json_and_stream_url(self, console, args):
         stream = Mock()
         streams = dict(worst=Mock(), best=stream)
-        plugin = Mock(FakePlugin(""), module="fake", arguments=[], streams=Mock(return_value=streams))
+        plugin = FakePlugin("")
+        plugin.module = "fake"
+        plugin.arguments = []
+        plugin.streams = Mock(return_value=streams)
 
         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(plugin="fake", streams=streams)])
+            self.assertEqual(console.msg_json.mock_calls, [call(
+                plugin="fake",
+                metadata=dict(
+                    author="Tѥst Āuƭhǿr",
+                    category=None,
+                    title="Test Title"
+                ),
+                streams=streams
+            )])
             self.assertEqual(console.error.mock_calls, [])
             console.msg_json.mock_calls.clear()
 
diff --git a/tests/test_cli_playerout.py b/tests/test_cli_playerout.py
index 9f1cb6dd..cf0bcade 100644
--- a/tests/test_cli_playerout.py
+++ b/tests/test_cli_playerout.py
@@ -64,6 +64,12 @@ def test_output_args_posix():
     po_fallback = PlayerOutput("foo", args="--bar {filename}")
     assert po_fallback._create_arguments() == ["foo", "--bar", "-"]
 
+    po_fallback = PlayerOutput("foo", args="--bar {playerinput} {filename}")
+    assert po_fallback._create_arguments() == ["foo", "--bar", "-", "-"]
+
+    po_fallback = PlayerOutput("foo", args="--bar {qux}")
+    assert po_fallback._create_arguments() == ["foo", "--bar", "{qux}", "-"]
+
 
 @windows_only
 def test_output_args_windows():
@@ -78,3 +84,9 @@ def test_output_args_windows():
 
     po_fallback = PlayerOutput("foo", args="--bar {filename}")
     assert po_fallback._create_arguments() == "foo --bar -"
+
+    po_fallback = PlayerOutput("foo", args="--bar {playerinput} {filename}")
+    assert po_fallback._create_arguments() == "foo --bar - -"
+
+    po_fallback = PlayerOutput("foo", args="--bar {qux}")
+    assert po_fallback._create_arguments() == "foo --bar {qux} -"