Repository: streamlink/streamlink

Issue:
Using -j/--json option causes uncaught exception on 404 URLs
### Checklist

- [X] This is a bug report and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed bug reports](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest build from the master branch

### Description

When using the `-j`/`--json` option on 404 URLs, there is an uncaught exception.


### Debug log

```text
$ streamlink -l debug bbc.co.uk/iplayer/live/xxx
[cli][debug] OS:         Linux-5.4.0-110-generic-x86_64-with-glibc2.29
[cli][debug] Python:     3.8.10
[cli][debug] Streamlink: 4.1.0+13.g2a5b71fa
[cli][debug] Dependencies:
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.0
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.14.1
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.28.0
[cli][debug]  websocket-client: 1.3.2
[cli][debug] Arguments:
[cli][debug]  url=bbc.co.uk/iplayer/live/xxx
[cli][debug]  --loglevel=debug
[cli][debug]  --player=mpv
[cli][debug]  --bbciplayer-username=user@domain.tld
[cli][debug]  --bbciplayer-password=********
[cli][info] Found matching plugin bbciplayer for URL bbc.co.uk/iplayer/live/xxx
[plugins.bbciplayer][info] A TV License is required to watch BBC iPlayer streams, see the BBC website for more information: https://www.bbc.co.uk/iplayer/help/tvlicence
error: Unable to open URL: https://account.bbc.com/signin (404 Client Error: Not Found for url: https://www.bbc.co.uk/iplayer/live/xxx)

$ streamlink -j bbc.co.uk/iplayer/live/xxx
Traceback (most recent call last):
  File "/home/user/slx/lib/python3.8/site-packages/streamlink/plugin/api/http_session.py", line 214, in request
    res.raise_for_status()
  File "/home/user/slx/lib/python3.8/site-packages/requests/models.py", line 1022, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 404 Client Error: Not Found for url: https://www.bbc.co.uk/iplayer/live/xxx

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/user/slx/lib/python3.8/site-packages/streamlink_cli/main.py", line 599, in handle_url
    streams = fetch_streams(plugin)
  File "/home/user/slx/lib/python3.8/site-packages/streamlink_cli/main.py", line 493, in fetch_streams
    return plugin.streams(stream_types=args.stream_types,
  File "/home/user/slx/lib/python3.8/site-packages/streamlink/plugin/plugin.py", line 362, in streams
    ostreams = list(ostreams)
  File "/home/user/slx/lib/python3.8/site-packages/streamlink/plugins/bbciplayer.py", line 197, in _get_streams
    if not self.login(self.url):
  File "/home/user/slx/lib/python3.8/site-packages/streamlink/plugins/bbciplayer.py", line 175, in login
    res = self.session.http.post(
  File "/home/user/slx/lib/python3.8/site-packages/requests/sessions.py", line 635, in post
    return self.request("POST", url, data=data, json=json, **kwargs)
  File "/home/user/slx/lib/python3.8/site-packages/streamlink/plugin/api/http_session.py", line 222, in request
    raise err
streamlink.exceptions.PluginError: Unable to open URL: https://account.bbc.com/signin (404 Client Error: Not Found for url: https://www.bbc.co.uk/iplayer/live/xxx)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/user/slx/bin/streamlink", line 8, in <module>
    sys.exit(main())
  File "/home/user/slx/lib/python3.8/site-packages/streamlink_cli/main.py", line 1101, in main
    handle_url()
  File "/home/user/slx/lib/python3.8/site-packages/streamlink_cli/main.py", line 603, in handle_url
    console.exit(err)
  File "/home/user/slx/lib/python3.8/site-packages/streamlink_cli/console.py", line 91, in exit
    self.msg_json(error=msg)
  File "/home/user/slx/lib/python3.8/site-packages/streamlink_cli/console.py", line 83, in msg_json
    msg = dumps(out, cls=JSONEncoder, indent=2)
  File "/usr/lib/python3.8/json/__init__.py", line 234, in dumps
    return cls(
  File "/usr/lib/python3.8/json/encoder.py", line 201, in encode
    chunks = list(chunks)
  File "/usr/lib/python3.8/json/encoder.py", line 431, in _iterencode
    yield from _iterencode_dict(o, _current_indent_level)
  File "/usr/lib/python3.8/json/encoder.py", line 405, in _iterencode_dict
    yield from chunks
  File "/usr/lib/python3.8/json/encoder.py", line 438, in _iterencode
    o = _default(o)
  File "/home/user/slx/lib/python3.8/site-packages/streamlink_cli/utils/__init__.py", line 24, in default
    return json.JSONEncoder.default(self, obj)
  File "/usr/lib/python3.8/json/encoder.py", line 179, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type PluginError is not JSON serializable
```



Discussion:

Resolving patch:
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -600,7 +600,7 @@ def handle_url():
     except NoPluginError:
         console.exit(f"No plugin can handle URL: {args.url}")
     except PluginError as err:
-        console.exit(err)
+        console.exit(str(err))
 
     if not streams:
         console.exit(f"No playable streams found on this URL: {args.url}")


Test patch:
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -8,10 +8,11 @@
 from unittest.mock import Mock, call, patch
 
 import freezegun
+import pytest
 
 import streamlink_cli.main
 import tests.resources
-from streamlink.exceptions import StreamError
+from streamlink.exceptions import PluginError, StreamError
 from streamlink.session import Streamlink
 from streamlink.stream.stream import Stream
 from streamlink_cli.compat import DeprecatedPath, is_win32, stdout
@@ -111,6 +112,20 @@ def test_format_valid_streams(self):
         )
 
 
+class TestCLIMainHandleUrl:
+    @pytest.mark.parametrize("side_effect,expected", [
+        (NoPluginError("foo"), "No plugin can handle URL: fakeurl"),
+        (PluginError("bar"), "bar"),
+    ])
+    def test_error(self, side_effect, expected):
+        with patch("streamlink_cli.main.args", Mock(url="fakeurl")), \
+             patch("streamlink_cli.main.streamlink", resolve_url=Mock(side_effect=side_effect)), \
+             patch("streamlink_cli.main.console", exit=Mock(side_effect=SystemExit)) as mock_console:
+            with pytest.raises(SystemExit):
+                handle_url()
+            assert mock_console.exit.mock_calls == [call(expected)]
+
+
 class TestCLIMainJsonAndStreamUrl(unittest.TestCase):
     @patch("streamlink_cli.main.args", json=True, stream_url=True, subprocess_cmdline=False)
     @patch("streamlink_cli.main.console")