Repository: streamlink/streamlink

Issue:
Streamlink is starting download before user's response for file overwrite
### 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 stable release

### Description

When file output option `-o` is used to an existing file, Streamlink prints confirmation message for file overwrite and asks for user's response.
However, before the response, Streamlink has already started downloading and continues. If the user responds with "n", this will result in a waste of bandwidth. It can also be confusing when used with `-l debug`, as the debug messages are printed as if confirmation is ignored.

It would be better to check the output file before opening the stream.

In the debug log below, I responded with "n" after `[stream.segmented][debug] Closing writer thread` message.

### Debug log

```text
[cli][debug] OS:         macOS 10.12.6
[cli][debug] Python:     3.9.9
[cli][debug] Streamlink: 3.0.3
[cli][debug] Requests(2.26.0), Socks(1.7.1), Websocket(1.2.3)
[cli][debug] Arguments:
[cli][debug]  url=https://www.dailymotion.com/video/x853fvr
[cli][debug]  stream=['480p']
[cli][debug]  --loglevel=debug
[cli][debug]  --output=video.ts
[cli][debug]  --hls-duration=20
[cli][info] Found matching plugin dailymotion for URL https://www.dailymotion.com/video/x853fvr
[plugins.dailymotion][debug] Found media ID: x853fvr
[utils.l10n][debug] Language code: en_US
[cli][info] Available streams: 112p_alt (worst), 112p, 184p_alt, 184p, 288p_alt, 288p, 480p_alt, 480p, 720p_alt, 720p, 1080p_alt, 1080p (best)
[cli][info] Opening stream: 480p (hls)
[stream.hls][debug] Reloading playlist
[cli][debug] Pre-buffering 8192 bytes
[stream.hls][debug] First Sequence: 0; Last Sequence: 53
[stream.hls][debug] Start offset: 0; Duration: 20; Start Sequence: 0; End Sequence: 53
[stream.hls][debug] Adding segment 0 to queue
[stream.hls][debug] Adding segment 1 to queue
[stream.hls][debug] Adding segment 2 to queue
[stream.hls][debug] Adding segment 3 to queue
[stream.hls][debug] Adding segment 4 to queue
[stream.hls][debug] Adding segment 5 to queue
[stream.hls][debug] Adding segment 6 to queue
[stream.hls][info] Stopping stream early after 20
[stream.segmented][debug] Closing worker thread
[stream.hls][debug] Segment 0 complete
[cli][debug] Checking file output
File video.ts already exists! Overwrite it? [y/N] [stream.hls][debug] Segment 1 complete
[stream.hls][debug] Segment 2 complete
[stream.hls][debug] Segment 3 complete
[stream.hls][debug] Segment 4 complete
[stream.hls][debug] Segment 5 complete
[stream.hls][debug] Segment 6 complete
[stream.segmented][debug] Closing writer thread
n
[cli][info] Closing currently open stream...
```



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
@@ -305,6 +305,9 @@ def output_stream(stream, formatter: Formatter):
     """Open stream, create output and finally write the stream to output."""
     global output
 
+    # create output before opening the stream, so file outputs can prompt on existing output
+    output = create_output(formatter)
+
     success_open = False
     for i in range(args.retry_open):
         try:
@@ -315,9 +318,7 @@ def output_stream(stream, formatter: Formatter):
             log.error(f"Try {i + 1}/{args.retry_open}: Could not open stream {stream} ({err})")
 
     if not success_open:
-        console.exit(f"Could not open stream {stream}, tried {args.retry_open} times, exiting")
-
-    output = create_output(formatter)
+        return console.exit(f"Could not open stream {stream}, tried {args.retry_open} times, exiting")
 
     try:
         output.open()


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
@@ -10,6 +10,7 @@
 
 import streamlink_cli.main
 import tests.resources
+from streamlink.exceptions import StreamError
 from streamlink.session import Streamlink
 from streamlink.stream.stream import Stream
 from streamlink_cli.compat import DeprecatedPath, is_win32, stdout
@@ -21,6 +22,7 @@
     format_valid_streams,
     handle_stream,
     handle_url,
+    output_stream,
     resolve_stream_name,
     setup_config_args
 )
@@ -412,6 +414,32 @@ def test_handle_stream_output_stream(self, args: Mock, mock_output_stream: Mock)
         )
 
 
+class TestCLIMainOutputStream(unittest.TestCase):
+    @patch("streamlink_cli.main.args", Mock(retry_open=2))
+    @patch("streamlink_cli.main.log")
+    @patch("streamlink_cli.main.console")
+    def test_stream_failure_no_output_open(self, mock_console: Mock, mock_log: Mock):
+        output = Mock()
+        stream = Mock(
+            __str__=lambda _: "fake-stream",
+            open=Mock(side_effect=StreamError("failure"))
+        )
+        formatter = Formatter({})
+
+        with patch("streamlink_cli.main.output", Mock()), \
+             patch("streamlink_cli.main.create_output", return_value=output):
+            output_stream(stream, formatter)
+
+        self.assertEqual(mock_log.error.call_args_list, [
+            call("Try 1/2: Could not open stream fake-stream (Could not open stream: failure)"),
+            call("Try 2/2: Could not open stream fake-stream (Could not open stream: failure)"),
+        ])
+        self.assertEqual(mock_console.exit.call_args_list, [
+            call("Could not open stream fake-stream, tried 2 times, exiting")
+        ])
+        self.assertFalse(output.open.called, "Does not open the output on stream error")
+
+
 @patch("streamlink_cli.main.log")
 class TestCLIMainSetupConfigArgs(unittest.TestCase):
     configdir = Path(tests.resources.__path__[0], "cli", "config")