Repository: streamlink/streamlink

Issue:
stream.ffmpegmux: named pipes don't get cleaned up
### 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

I've already mentioned this in https://github.com/streamlink/streamlink/pull/3709#issue-624558580 where I re-implemented the `NamedPipe` class.

`FFMPEGMuxer` doesn't gracefully close the stream and doesn't clean up the named pipes created for muxing the output stream.
https://github.com/streamlink/streamlink/blame/d0878493240b521c3b2682f47a13e8d42b2cb739/src/streamlink/stream/ffmpegmux.py#L60-L78

```
$ ls /tmp/streamlinkpipe-*
ls: cannot access '/tmp/streamlinkpipe-*': No such file or directory
```

```
$ streamlink -l debug 'https://www.youtube.com/watch?v=xcJtL7QggTI' best
[cli][debug] OS:         Linux-5.13.13-1-git-x86_64-with-glibc2.33
[cli][debug] Python:     3.9.6
[cli][debug] Streamlink: 2.3.0+48.gd087849
[cli][debug] Requests(2.26.0), Socks(1.7.1), Websocket(0.58.0)
[cli][debug] Arguments:
[cli][debug]  url=https://www.youtube.com/watch?v=xcJtL7QggTI
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player=mpv
[cli][info] Found matching plugin youtube for URL https://www.youtube.com/watch?v=xcJtL7QggTI
[plugins.youtube][debug] Using video ID: xcJtL7QggTI
[plugins.youtube][debug] MuxedStream: v 137 a 251 = 1080p
[plugins.youtube][debug] MuxedStream: v 135 a 251 = 480p
[plugins.youtube][debug] MuxedStream: v 133 a 251 = 240p
[plugins.youtube][debug] MuxedStream: v 160 a 251 = 144p
[cli][info] Available streams: audio_mp4a, audio_opus, 144p (worst), 240p, 360p, 480p, 720p, 1080p (best)
[cli][info] Opening stream: 1080p (muxed-stream)
[stream.ffmpegmux][debug] Opening http substream
[stream.ffmpegmux][debug] Opening http substream
[utils.named_pipe][info] Creating pipe streamlinkpipe-66128-1-1546
[utils.named_pipe][info] Creating pipe streamlinkpipe-66128-2-2853
[stream.ffmpegmux][debug] ffmpeg command: ffmpeg -nostats -y -i /tmp/streamlinkpipe-66128-1-1546 -i /tmp/streamlinkpipe-66128-2-2853 -c:v copy -c:a copy -map 0 -map 1 -f matroska pipe:1
[stream.ffmpegmux][debug] Starting copy to pipe: /tmp/streamlinkpipe-66128-1-1546
[stream.ffmpegmux][debug] Starting copy to pipe: /tmp/streamlinkpipe-66128-2-2853
[cli][debug] Pre-buffering 8192 bytes
[cli][info] Starting player: mpv
[cli.output][debug] Opening subprocess: mpv --force-media-title=https://www.youtube.com/watch?v=xcJtL7QggTI -
[cli][debug] Writing stream to output
[cli][info] Player closed
[stream.ffmpegmux][debug] Closing ffmpeg thread
[stream.ffmpegmux][error] Pipe copy aborted: /tmp/streamlinkpipe-66128-1-1546
[stream.ffmpegmux][error] Pipe copy aborted: /tmp/streamlinkpipe-66128-2-2853
[stream.ffmpegmux][debug] Closed all the substreams
[cli][info] Stream ended
[cli][info] Closing currently open stream...
```

```
$ ls /tmp/streamlinkpipe-*
/tmp/streamlinkpipe-66128-1-1546  /tmp/streamlinkpipe-66128-2-2853
```

### Debug log

```text
-
```



Discussion:

Resolving patch:
diff --git a/src/streamlink/stream/ffmpegmux.py b/src/streamlink/stream/ffmpegmux.py
--- a/src/streamlink/stream/ffmpegmux.py
+++ b/src/streamlink/stream/ffmpegmux.py
@@ -7,7 +7,7 @@
 from streamlink import StreamError
 from streamlink.compat import devnull
 from streamlink.stream.stream import Stream, StreamIO
-from streamlink.utils import NamedPipe
+from streamlink.utils.named_pipe import NamedPipe, NamedPipeBase
 
 log = logging.getLogger(__name__)
 
@@ -58,8 +58,8 @@ class FFMPEGMuxer(StreamIO):
     DEFAULT_AUDIO_CODEC = "copy"
 
     @staticmethod
-    def copy_to_pipe(self, stream, pipe):
-        log.debug("Starting copy to pipe: {0}".format(pipe.path))
+    def copy_to_pipe(stream: StreamIO, pipe: NamedPipeBase):
+        log.debug(f"Starting copy to pipe: {pipe.path}")
         pipe.open()
         while not stream.closed:
             try:
@@ -69,13 +69,13 @@ def copy_to_pipe(self, stream, pipe):
                 else:
                     break
             except OSError:
-                log.error("Pipe copy aborted: {0}".format(pipe.path))
-                return
+                log.error(f"Pipe copy aborted: {pipe.path}")
+                break
         try:
             pipe.close()
         except OSError:  # might fail closing, but that should be ok for the pipe
             pass
-        log.debug("Pipe copy complete: {0}".format(pipe.path))
+        log.debug(f"Pipe copy complete: {pipe.path}")
 
     def __init__(self, session, *streams, **options):
         if not self.is_usable(session):
@@ -86,7 +86,7 @@ def __init__(self, session, *streams, **options):
         self.streams = streams
 
         self.pipes = [NamedPipe() for _ in self.streams]
-        self.pipe_threads = [threading.Thread(target=self.copy_to_pipe, args=(self, stream, np))
+        self.pipe_threads = [threading.Thread(target=self.copy_to_pipe, args=(stream, np))
                              for stream, np in
                              zip(self.streams, self.pipes)]
 


Test patch: