Repository: streamlink/streamlink

Issue:
Option to get audio with video, but save do disk audio only
### Checklist

- [X] This is a feature request 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 plugin requests](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22feature+request%22)

### Description

There was another ticket https://github.com/streamlink/streamlink/issues/1776, but this is a different one.

It is pretty clear to me that some sources are streaming audio with video only, but I would like to see that streamlink gets audio with video, extracts audio on the fly and writes it to audio file.

The purpose is to save disk space and SSD life.


Discussion:
You can already do this by piping the output into `ffmpeg` and supplying the right arguments to it.

As @mkbloke already pointed out, use ffmpeg. This can be achieved in multiple ways via the available [file output options](https://streamlink.github.io/latest/cli.html#file-output-options). Adding CLI options for that doesn't make any sense.

One of these options could be [`--record`](https://streamlink.github.io/latest/cli.html#cmdoption-record) with the value being set to `-`, but unfortunately, [`-` does not get interpreted as `stdout` in the `--record` logic](https://github.com/streamlink/streamlink/blob/aa91920b77636be376766c12cffc1ad146c13d2a/src/streamlink_cli/main.py#L134-L135), so piping the stream into ffmpeg and remuxing it while watching it in the player that gets spawned by streamlink at the same time is not possible right now. That could be easily added though.

```sh
streamlink --record=- --player=player "$INPUT" "$QUALITY" \
  | ffmpeg -i pipe:0 -vn -c:a copy "$file.m4a"
```

For now, you'll have to use `--stdout`, clone the output stream and pipe them into both ffmpeg and the video player.
I'm leaving this thread open the implementation of the `--record=-` value. I'll take a look at this tomorrow or so. Should be fairly simple from what it looks like.
A workaround for `--record=-` could of course also be writing to a named pipe on the file system.

```bash
mkfifo ./foo
streamlink --record=./foo --player=player "$INPUT" "$QUALITY" &
ffmpeg -i ./foo -vn -c:a copy "$file.m4a"
```
@mkbloke , @bastimeyer , thank you for showing this capability. Could you please make it clear for me whether such a piping uses disk as an intermediate media between streamlink and ffmpeg, or the data thansfer happens entirely in RAM preventing excessive SSD wear out?
https://en.wikipedia.org/wiki/Pipeline_(Unix)
Why are you even worried about SSD wear if you're trying to archive media files on it. This is complete non-sense. If you're worried about wear, which is rather ridiculous in these days anyway, then write to other storage mediums.
I only use SSD as an editing medium. I then store audios on a normal HDD, on a server.

Resolving patch:
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -538,7 +538,8 @@ def build_parser():
         "-o", "--output",
         metavar="FILENAME",
         help="""
-        Write stream data to FILENAME instead of playing it.
+        Write stream data to FILENAME instead of playing it. If FILENAME is set to - (dash), then the stream data will be
+        written to stdout, similar to the --stdout argument.
 
         You will be prompted if the file already exists.
 
@@ -577,7 +578,8 @@ def build_parser():
         "-r", "--record",
         metavar="FILENAME",
         help="""
-        Open the stream in the player, while at the same time writing it to FILENAME.
+        Open the stream in the player, while at the same time writing it to FILENAME. If FILENAME is set to - (dash), then the
+        stream data will be written to stdout, similar to the --stdout argument, while still opening the player.
 
         You will be prompted if the file already exists.
 
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
@@ -132,7 +132,10 @@ def create_output(formatter: Formatter):
             http = create_http_server()
 
         if args.record:
-            record = check_file_output(formatter.path(args.record, args.fs_safe_rules), args.force)
+            if args.record == "-":
+                record = FileOutput(fd=stdout)
+            else:
+                record = check_file_output(formatter.path(args.record, args.fs_safe_rules), args.force)
 
         log.info(f"Starting player: {args.player}")
 
@@ -1003,7 +1006,7 @@ def main():
 
     # Console output should be on stderr if we are outputting
     # a stream to stdout.
-    if args.stdout or args.output == "-" or args.record_and_pipe:
+    if args.stdout or args.output == "-" or args.record == "-" or args.record_and_pipe:
         console_out = sys.stderr
     else:
         console_out = sys.stdout


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
@@ -373,6 +373,33 @@ def test_create_output_record(self, mock_check_file_output: Mock, args: Mock):
         self.assertIsNone(output.record.fd)
         self.assertIsNone(output.record.record)
 
+    @patch("streamlink_cli.main.args")
+    @patch("streamlink_cli.main.DEFAULT_STREAM_METADATA", {"title": "bar"})
+    def test_create_output_record_stdout(self, args: Mock):
+        formatter = Formatter({
+            "author": lambda: "foo"
+        })
+        args.output = None
+        args.stdout = None
+        args.record = "-"
+        args.record_and_pipe = None
+        args.force = False
+        args.fs_safe_rules = None
+        args.title = "{author} - {title}"
+        args.url = "URL"
+        args.player = "mpv"
+        args.player_args = ""
+        args.player_fifo = None
+        args.player_http = None
+
+        output = create_output(formatter)
+        self.assertIsInstance(output, PlayerOutput)
+        self.assertEqual(output.title, "foo - bar")
+        self.assertIsInstance(output.record, FileOutput)
+        self.assertIsNone(output.record.filename)
+        self.assertEqual(output.record.fd, stdout)
+        self.assertIsNone(output.record.record)
+
     @patch("streamlink_cli.main.args")
     @patch("streamlink_cli.main.console")
     def test_create_output_record_and_other_file_output(self, console: Mock, args: Mock):
@@ -603,6 +630,36 @@ def subject(self, argv, stream=None):
         self.assertIs(childlogger.parent.handlers[0].stream, stream)
         self.assertIs(streamlink_cli.main.console.output, stream)
 
+    @patch("sys.stderr")
+    @patch("sys.stdout")
+    def test_stream_stdout(self, mock_stdout: Mock, mock_stderr: Mock):
+        self.subject(["streamlink", "--stdout"], mock_stderr)
+
+    @patch("sys.stderr")
+    @patch("sys.stdout")
+    def test_stream_output_eq_file(self, mock_stdout: Mock, mock_stderr: Mock):
+        self.subject(["streamlink", "--output=foo"], mock_stdout)
+
+    @patch("sys.stderr")
+    @patch("sys.stdout")
+    def test_stream_output_eq_dash(self, mock_stdout: Mock, mock_stderr: Mock):
+        self.subject(["streamlink", "--output=-"], mock_stderr)
+
+    @patch("sys.stderr")
+    @patch("sys.stdout")
+    def test_stream_record_eq_file(self, mock_stdout: Mock, mock_stderr: Mock):
+        self.subject(["streamlink", "--record=foo"], mock_stdout)
+
+    @patch("sys.stderr")
+    @patch("sys.stdout")
+    def test_stream_record_eq_dash(self, mock_stdout: Mock, mock_stderr: Mock):
+        self.subject(["streamlink", "--record=-"], mock_stderr)
+
+    @patch("sys.stderr")
+    @patch("sys.stdout")
+    def test_stream_record_and_pipe(self, mock_stdout: Mock, mock_stderr: Mock):
+        self.subject(["streamlink", "--record-and-pipe=foo"], mock_stderr)
+
     @patch("sys.stderr")
     @patch("sys.stdout")
     def test_no_pipe_no_json(self, mock_stdout: Mock, mock_stderr: Mock):