2022-07-02 00:15:46 +02:00
|
|
|
import concurrent.futures
|
2020-10-07 14:45:54 +02:00
|
|
|
import logging
|
2022-10-02 14:43:26 +02:00
|
|
|
import re
|
2017-01-10 18:44:32 +01:00
|
|
|
import subprocess
|
|
|
|
import sys
|
2020-10-07 14:45:54 +02:00
|
|
|
import threading
|
2023-02-14 19:54:38 +01:00
|
|
|
from contextlib import suppress
|
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
|
2023-02-15 14:37:08 +01:00
|
|
|
from typing import List, Optional, TextIO, Union
|
2017-02-06 11:41:12 +01:00
|
|
|
|
|
|
|
from streamlink import StreamError
|
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
|
2022-10-02 14:43:26 +02:00
|
|
|
from streamlink.utils.processoutput import ProcessOutput
|
|
|
|
|
2018-05-30 03:15:11 +02:00
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
2017-01-10 18:44:32 +01:00
|
|
|
|
2022-10-02 14:43:26 +02:00
|
|
|
_lock_resolve_command = threading.Lock()
|
|
|
|
|
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.
|
|
|
|
"""
|
|
|
|
|
2017-01-10 18:44:32 +01:00
|
|
|
super().__init__(session)
|
|
|
|
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
|
2023-03-24 14:22:33 +01:00
|
|
|
for substream in 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-09-14 13:28:29 +02:00
|
|
|
__commands__ = ["ffmpeg"]
|
2022-07-17 21:12:08 +02:00
|
|
|
|
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-10-02 14:43:26 +02:00
|
|
|
FFMPEG_VERSION: Optional[str] = None
|
|
|
|
FFMPEG_VERSION_TIMEOUT = 4.0
|
|
|
|
|
2023-02-15 14:37:08 +01:00
|
|
|
errorlog: Union[int, TextIO]
|
|
|
|
|
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):
|
2022-10-02 14:43:26 +02:00
|
|
|
with _lock_resolve_command:
|
|
|
|
return cls._resolve_command(
|
|
|
|
session.options.get("ffmpeg-ffmpeg"),
|
|
|
|
not session.options.get("ffmpeg-no-validation"),
|
|
|
|
)
|
2022-07-17 21:12:08 +02:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
@lru_cache(maxsize=128)
|
2022-10-02 14:43:26 +02:00
|
|
|
def _resolve_command(cls, command: Optional[str] = None, validate: bool = True) -> Optional[str]:
|
2022-07-17 21:12:08 +02:00
|
|
|
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
|
2022-10-02 14:43:26 +02:00
|
|
|
|
|
|
|
if resolved and validate:
|
|
|
|
log.trace(f"Querying FFmpeg version: {[resolved, '-version']}") # type: ignore[attr-defined]
|
|
|
|
versionoutput = FFmpegVersionOutput([resolved, "-version"], timeout=cls.FFMPEG_VERSION_TIMEOUT)
|
|
|
|
if not versionoutput.run():
|
|
|
|
log.error("Could not validate FFmpeg!")
|
|
|
|
log.error(f"Unexpected FFmpeg version output while running {[resolved, '-version']}")
|
|
|
|
resolved = None
|
|
|
|
else:
|
|
|
|
cls.FFMPEG_VERSION = versionoutput.version
|
|
|
|
for i, line in enumerate(versionoutput.output):
|
|
|
|
log.debug(f" {line}" if i > 0 else line)
|
|
|
|
|
2022-08-28 14:15:08 +02:00
|
|
|
if not resolved:
|
2022-10-02 14:43:26 +02:00
|
|
|
log.warning("No valid FFmpeg binary was found. See the --ffmpeg-ffmpeg option.")
|
2022-08-28 14:15:08 +02:00
|
|
|
log.warning("Muxing streams is unsupported! Only a subset of the available streams can be returned!")
|
2022-10-02 14:43:26 +02:00
|
|
|
|
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}")
|
2023-02-14 19:54:38 +01:00
|
|
|
# TODO: catch OSError when creating/opening pipe fails and close entire output stream
|
2021-04-27 00:13:15 +02:00
|
|
|
pipe.open()
|
2023-02-14 19:54:38 +01:00
|
|
|
|
|
|
|
while True:
|
2017-01-10 18:44:32 +01:00
|
|
|
try:
|
|
|
|
data = stream.read(8192)
|
2023-02-14 19:54:38 +01:00
|
|
|
except (OSError, ValueError) as err:
|
|
|
|
log.error(f"Error while reading from substream: {err}")
|
|
|
|
break
|
|
|
|
|
|
|
|
if data == b"":
|
|
|
|
log.debug(f"Pipe copy complete: {pipe.path}")
|
|
|
|
break
|
|
|
|
|
|
|
|
try:
|
|
|
|
pipe.write(data)
|
|
|
|
except OSError as err:
|
|
|
|
log.error(f"Error while writing to pipe {pipe.path}: {err}")
|
2021-09-04 19:02:42 +02:00
|
|
|
break
|
2023-02-14 19:54:38 +01:00
|
|
|
|
|
|
|
with suppress(OSError):
|
2017-01-10 18:44:32 +01:00
|
|
|
pipe.close()
|
|
|
|
|
|
|
|
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
|
|
|
|
2023-02-15 14:37:08 +01:00
|
|
|
if 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")
|
2023-02-15 14:37:08 +01:00
|
|
|
elif session.options.get("ffmpeg-verbose"):
|
|
|
|
self.errorlog = sys.stderr
|
2017-01-10 18:44:32 +01:00
|
|
|
else:
|
2023-02-15 14:37:08 +01:00
|
|
|
self.errorlog = subprocess.DEVNULL
|
2017-01-10 18:44:32 +01:00
|
|
|
|
|
|
|
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):
|
2022-09-03 00:55:11 +02:00
|
|
|
return self.process.stdout.read(size)
|
2017-01-10 18:44:32 +01:00
|
|
|
|
|
|
|
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()
|
|
|
|
|
2022-07-02 00:15:46 +02:00
|
|
|
executor = concurrent.futures.ThreadPoolExecutor()
|
2023-02-14 19:54:38 +01:00
|
|
|
|
|
|
|
# close the substreams
|
2022-09-03 01:43:25 +02:00
|
|
|
futures = [
|
|
|
|
executor.submit(stream.close)
|
|
|
|
for stream in self.streams
|
|
|
|
if hasattr(stream, "close") and callable(stream.close)
|
|
|
|
]
|
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
|
|
|
|
2023-02-14 19:54:38 +01:00
|
|
|
# wait for substream copy-to-pipe threads to terminate and clean up the opened pipes
|
|
|
|
timeout = self.session.options.get("stream-timeout")
|
|
|
|
futures = [
|
|
|
|
executor.submit(thread.join, timeout=timeout)
|
|
|
|
for thread in self.pipe_threads
|
|
|
|
]
|
|
|
|
concurrent.futures.wait(futures, return_when=concurrent.futures.ALL_COMPLETED)
|
|
|
|
|
2023-02-15 14:37:08 +01:00
|
|
|
if self.errorlog is not sys.stderr and self.errorlog is not subprocess.DEVNULL:
|
|
|
|
with suppress(OSError):
|
|
|
|
self.errorlog.close()
|
2020-11-22 20:13:31 +01:00
|
|
|
|
|
|
|
super().close()
|
2022-10-02 14:43:26 +02:00
|
|
|
|
|
|
|
|
|
|
|
class FFmpegVersionOutput(ProcessOutput):
|
|
|
|
# The version output format of the fftools hasn't been changed since n0.7.1 (2011-04-23):
|
|
|
|
# https://github.com/FFmpeg/FFmpeg/blame/n5.1.1/fftools/ffmpeg.c#L110
|
|
|
|
# https://github.com/FFmpeg/FFmpeg/blame/n5.1.1/fftools/opt_common.c#L201
|
|
|
|
# https://github.com/FFmpeg/FFmpeg/blame/c99b93c5d53d8f4a4f1fafc90f3dfc51467ee02e/fftools/cmdutils.c#L1156
|
|
|
|
# https://github.com/FFmpeg/FFmpeg/commit/89b503b55f2b2713f1c3cc8981102c1a7b663281
|
|
|
|
_re_version = re.compile(r"ffmpeg version (?P<version>\S+)")
|
|
|
|
|
2022-11-07 20:19:36 +01:00
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
2022-10-02 14:43:26 +02:00
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.version: Optional[str] = None
|
|
|
|
self.output: List[str] = []
|
|
|
|
|
|
|
|
def onexit(self, code: int) -> bool:
|
|
|
|
return code == 0 and self.version is not None
|
|
|
|
|
|
|
|
def onstdout(self, idx: int, line: str) -> Optional[bool]:
|
|
|
|
# only validate the very first line of the stdout stream
|
|
|
|
if idx == 0:
|
|
|
|
match = self._re_version.match(line)
|
|
|
|
# abort if the very first line of stdout doesn't match the expected format
|
|
|
|
if not match:
|
|
|
|
return False
|
|
|
|
self.version = match["version"]
|
|
|
|
|
|
|
|
self.output.append(line)
|