2022-07-02 00:15:46 +02:00
|
|
|
import concurrent.futures
|
2020-10-07 14:45:54 +02:00
|
|
|
import logging
|
2017-01-10 18:44:32 +01:00
|
|
|
import subprocess
|
|
|
|
import sys
|
2020-10-07 14:45:54 +02:00
|
|
|
import threading
|
2022-07-17 21:12:08 +02:00
|
|
|
from functools import lru_cache
|
2022-07-30 05:56:57 +02:00
|
|
|
from pathlib import Path
|
2020-10-26 14:46:17 +01:00
|
|
|
from shutil import which
|
2022-07-17 21:12:08 +02:00
|
|
|
from typing import Optional
|
2017-02-06 11:41:12 +01:00
|
|
|
|
|
|
|
from streamlink import StreamError
|
2020-10-07 14:45:54 +02:00
|
|
|
from streamlink.compat import devnull
|
2020-10-26 14:46:17 +01:00
|
|
|
from streamlink.stream.stream import Stream, StreamIO
|
2021-09-04 19:02:42 +02:00
|
|
|
from streamlink.utils.named_pipe import NamedPipe, NamedPipeBase
|
2018-05-30 03:15:11 +02:00
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
2017-01-10 18:44:32 +01:00
|
|
|
|
2017-02-21 18:37:59 +01:00
|
|
|
|
2017-01-10 18:44:32 +01:00
|
|
|
class MuxedStream(Stream):
|
2022-04-03 20:58:13 +02:00
|
|
|
"""
|
|
|
|
Muxes multiple streams into one output stream.
|
|
|
|
"""
|
|
|
|
|
2017-01-10 18:44:32 +01:00
|
|
|
__shortname__ = "muxed-stream"
|
|
|
|
|
2022-04-03 20:58:13 +02:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
session,
|
|
|
|
*substreams: Stream,
|
|
|
|
**options
|
|
|
|
):
|
|
|
|
"""
|
|
|
|
:param streamlink.Streamlink session: Streamlink session instance
|
|
|
|
:param substreams: Video and/or audio streams
|
|
|
|
:param options: Additional keyword arguments passed to :class:`ffmpegmux.FFMPEGMuxer`.
|
|
|
|
Subtitle streams need to be set via the ``subtitles`` keyword.
|
|
|
|
"""
|
|
|
|
|
2020-10-30 19:06:48 +01:00
|
|
|
super().__init__(session)
|
2017-01-10 18:44:32 +01:00
|
|
|
self.substreams = substreams
|
2017-02-27 18:20:17 +01:00
|
|
|
self.subtitles = options.pop("subtitles", {})
|
2017-01-10 18:44:32 +01:00
|
|
|
self.options = options
|
|
|
|
|
|
|
|
def open(self):
|
|
|
|
fds = []
|
2017-02-27 18:20:17 +01:00
|
|
|
metadata = self.options.get("metadata", {})
|
|
|
|
maps = self.options.get("maps", [])
|
|
|
|
# only update the maps values if they haven't been set
|
|
|
|
update_maps = not maps
|
|
|
|
for i, substream in enumerate(self.substreams):
|
2018-05-30 03:15:11 +02:00
|
|
|
log.debug("Opening {0} substream".format(substream.shortname()))
|
2017-02-27 18:20:17 +01:00
|
|
|
if update_maps:
|
|
|
|
maps.append(len(fds))
|
2017-01-20 15:20:43 +01:00
|
|
|
fds.append(substream and substream.open())
|
2017-02-27 18:20:17 +01:00
|
|
|
|
|
|
|
for i, subtitle in enumerate(self.subtitles.items()):
|
|
|
|
language, substream = subtitle
|
2018-05-30 03:15:11 +02:00
|
|
|
log.debug("Opening {0} subtitle stream".format(substream.shortname()))
|
2017-02-27 18:20:17 +01:00
|
|
|
if update_maps:
|
|
|
|
maps.append(len(fds))
|
|
|
|
fds.append(substream and substream.open())
|
|
|
|
metadata["s:s:{0}".format(i)] = ["language={0}".format(language)]
|
|
|
|
|
|
|
|
self.options["metadata"] = metadata
|
|
|
|
self.options["maps"] = maps
|
|
|
|
|
2017-01-10 18:44:32 +01:00
|
|
|
return FFMPEGMuxer(self.session, *fds, **self.options).open()
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def is_usable(cls, session):
|
|
|
|
return FFMPEGMuxer.is_usable(session)
|
|
|
|
|
|
|
|
|
2017-01-27 18:35:24 +01:00
|
|
|
class FFMPEGMuxer(StreamIO):
|
2022-07-17 21:12:08 +02:00
|
|
|
__commands__ = ["ffmpeg", "avconv"]
|
|
|
|
|
2020-11-23 18:29:26 +01:00
|
|
|
DEFAULT_OUTPUT_FORMAT = "matroska"
|
|
|
|
DEFAULT_VIDEO_CODEC = "copy"
|
|
|
|
DEFAULT_AUDIO_CODEC = "copy"
|
2017-01-10 18:44:32 +01:00
|
|
|
|
2022-07-17 21:12:08 +02:00
|
|
|
@classmethod
|
|
|
|
def is_usable(cls, session):
|
|
|
|
return cls.command(session) is not None
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def command(cls, session):
|
|
|
|
return cls.resolve_command(session.options.get("ffmpeg-ffmpeg"))
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
@lru_cache(maxsize=128)
|
|
|
|
def resolve_command(cls, command: Optional[str] = None) -> Optional[str]:
|
|
|
|
if command:
|
2022-08-28 14:15:08 +02:00
|
|
|
resolved = which(command)
|
|
|
|
else:
|
|
|
|
resolved = None
|
|
|
|
for cmd in cls.__commands__:
|
|
|
|
resolved = which(cmd)
|
|
|
|
if resolved:
|
|
|
|
break
|
|
|
|
if not resolved:
|
|
|
|
log.warning("FFmpeg was not found. See the --ffmpeg-ffmpeg option.")
|
|
|
|
log.warning("Muxing streams is unsupported! Only a subset of the available streams can be returned!")
|
2022-07-17 21:12:08 +02:00
|
|
|
return resolved
|
|
|
|
|
2017-01-10 18:44:32 +01:00
|
|
|
@staticmethod
|
2021-09-04 19:02:42 +02:00
|
|
|
def copy_to_pipe(stream: StreamIO, pipe: NamedPipeBase):
|
|
|
|
log.debug(f"Starting copy to pipe: {pipe.path}")
|
2021-04-27 00:13:15 +02:00
|
|
|
pipe.open()
|
2017-01-10 18:44:32 +01:00
|
|
|
while not stream.closed:
|
|
|
|
try:
|
|
|
|
data = stream.read(8192)
|
|
|
|
if len(data):
|
|
|
|
pipe.write(data)
|
|
|
|
else:
|
|
|
|
break
|
2020-11-26 22:39:22 +01:00
|
|
|
except OSError:
|
2021-09-04 19:02:42 +02:00
|
|
|
log.error(f"Pipe copy aborted: {pipe.path}")
|
|
|
|
break
|
2017-01-10 18:44:32 +01:00
|
|
|
try:
|
|
|
|
pipe.close()
|
2020-11-26 22:39:22 +01:00
|
|
|
except OSError: # might fail closing, but that should be ok for the pipe
|
2017-01-10 18:44:32 +01:00
|
|
|
pass
|
2021-09-04 19:02:42 +02:00
|
|
|
log.debug(f"Pipe copy complete: {pipe.path}")
|
2017-01-10 18:44:32 +01:00
|
|
|
|
|
|
|
def __init__(self, session, *streams, **options):
|
|
|
|
if not self.is_usable(session):
|
2017-01-20 15:20:43 +01:00
|
|
|
raise StreamError("cannot use FFMPEG")
|
2017-01-10 18:44:32 +01:00
|
|
|
|
|
|
|
self.session = session
|
|
|
|
self.process = None
|
|
|
|
self.streams = streams
|
2017-01-20 15:20:43 +01:00
|
|
|
|
2021-04-27 00:13:15 +02:00
|
|
|
self.pipes = [NamedPipe() for _ in self.streams]
|
2021-09-04 19:02:42 +02:00
|
|
|
self.pipe_threads = [threading.Thread(target=self.copy_to_pipe, args=(stream, np))
|
2017-01-10 18:44:32 +01:00
|
|
|
for stream, np in
|
2017-01-20 15:20:43 +01:00
|
|
|
zip(self.streams, self.pipes)]
|
2017-01-10 18:44:32 +01:00
|
|
|
|
2020-11-23 18:29:26 +01:00
|
|
|
ofmt = session.options.get("ffmpeg-fout") or options.pop("format", self.DEFAULT_OUTPUT_FORMAT)
|
2017-01-10 18:44:32 +01:00
|
|
|
outpath = options.pop("outpath", "pipe:1")
|
2020-11-23 18:29:26 +01:00
|
|
|
videocodec = session.options.get("ffmpeg-video-transcode") or options.pop("vcodec", self.DEFAULT_VIDEO_CODEC)
|
|
|
|
audiocodec = session.options.get("ffmpeg-audio-transcode") or options.pop("acodec", self.DEFAULT_AUDIO_CODEC)
|
2017-01-20 15:20:43 +01:00
|
|
|
metadata = options.pop("metadata", {})
|
2017-02-21 18:37:59 +01:00
|
|
|
maps = options.pop("maps", [])
|
2020-12-16 17:52:10 +01:00
|
|
|
copyts = session.options.get("ffmpeg-copyts") or options.pop("copyts", False)
|
2020-12-15 16:57:50 +01:00
|
|
|
start_at_zero = session.options.get("ffmpeg-start-at-zero") or options.pop("start_at_zero", False)
|
2017-01-10 18:44:32 +01:00
|
|
|
|
|
|
|
self._cmd = [self.command(session), '-nostats', '-y']
|
|
|
|
for np in self.pipes:
|
2021-04-27 00:13:15 +02:00
|
|
|
self._cmd.extend(["-i", str(np.path)])
|
2017-01-10 18:44:32 +01:00
|
|
|
|
2017-01-20 15:20:43 +01:00
|
|
|
self._cmd.extend(['-c:v', videocodec])
|
|
|
|
self._cmd.extend(['-c:a', audiocodec])
|
|
|
|
|
2017-02-21 18:37:59 +01:00
|
|
|
for m in maps:
|
|
|
|
self._cmd.extend(["-map", str(m)])
|
|
|
|
|
2018-05-30 21:30:38 +02:00
|
|
|
if copyts:
|
|
|
|
self._cmd.extend(["-copyts"])
|
2020-11-23 18:29:26 +01:00
|
|
|
if start_at_zero:
|
|
|
|
self._cmd.extend(["-start_at_zero"])
|
2018-05-30 21:30:38 +02:00
|
|
|
|
2017-01-20 15:20:43 +01:00
|
|
|
for stream, data in metadata.items():
|
|
|
|
for datum in data:
|
2020-11-23 18:29:26 +01:00
|
|
|
stream_id = ":{0}".format(stream) if stream else ""
|
|
|
|
self._cmd.extend(["-metadata{0}".format(stream_id), datum])
|
2017-01-20 15:20:43 +01:00
|
|
|
|
|
|
|
self._cmd.extend(['-f', ofmt, outpath])
|
2018-05-30 03:15:11 +02:00
|
|
|
log.debug("ffmpeg command: {0}".format(' '.join(self._cmd)))
|
2017-01-10 18:44:32 +01:00
|
|
|
self.close_errorlog = False
|
|
|
|
|
|
|
|
if session.options.get("ffmpeg-verbose"):
|
|
|
|
self.errorlog = sys.stderr
|
|
|
|
elif session.options.get("ffmpeg-verbose-path"):
|
2022-07-30 05:56:57 +02:00
|
|
|
self.errorlog = Path(session.options.get("ffmpeg-verbose-path")).expanduser().open("w")
|
2017-01-10 18:44:32 +01:00
|
|
|
self.close_errorlog = True
|
|
|
|
else:
|
|
|
|
self.errorlog = devnull()
|
|
|
|
|
|
|
|
def open(self):
|
|
|
|
for t in self.pipe_threads:
|
|
|
|
t.daemon = True
|
|
|
|
t.start()
|
2018-02-22 16:41:23 +01:00
|
|
|
self.process = subprocess.Popen(self._cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=self.errorlog)
|
2017-01-10 18:44:32 +01:00
|
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
def read(self, size=-1):
|
|
|
|
data = self.process.stdout.read(size)
|
|
|
|
return data
|
|
|
|
|
|
|
|
def close(self):
|
2020-11-22 20:13:31 +01:00
|
|
|
if self.closed:
|
|
|
|
return
|
|
|
|
|
2018-05-30 03:15:11 +02:00
|
|
|
log.debug("Closing ffmpeg thread")
|
2017-01-10 18:44:32 +01:00
|
|
|
if self.process:
|
|
|
|
# kill ffmpeg
|
|
|
|
self.process.kill()
|
|
|
|
self.process.stdout.close()
|
|
|
|
|
|
|
|
# close the streams
|
2022-07-02 00:15:46 +02:00
|
|
|
futures = []
|
|
|
|
executor = concurrent.futures.ThreadPoolExecutor()
|
2017-01-10 18:44:32 +01:00
|
|
|
for stream in self.streams:
|
2020-11-22 20:13:31 +01:00
|
|
|
if hasattr(stream, "close") and callable(stream.close):
|
2022-07-02 00:15:46 +02:00
|
|
|
futures.append(executor.submit(stream.close))
|
2017-01-10 18:44:32 +01:00
|
|
|
|
2022-07-02 00:15:46 +02:00
|
|
|
concurrent.futures.wait(futures, return_when=concurrent.futures.ALL_COMPLETED)
|
2018-05-30 03:15:11 +02:00
|
|
|
log.debug("Closed all the substreams")
|
2020-11-22 20:13:31 +01:00
|
|
|
|
2017-01-10 18:44:32 +01:00
|
|
|
if self.close_errorlog:
|
|
|
|
self.errorlog.close()
|
|
|
|
self.errorlog = None
|
2020-11-22 20:13:31 +01:00
|
|
|
|
|
|
|
super().close()
|