streamlink/src/streamlink/stream/ffmpegmux.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

284 lines
10 KiB
Python
Raw Normal View History

import concurrent.futures
import logging
import re
import subprocess
import sys
import threading
from contextlib import suppress
from functools import lru_cache
from pathlib import Path
from shutil import which
from typing import List, Optional, TextIO, Union
2017-02-06 11:41:12 +01:00
from streamlink import StreamError
from streamlink.stream.stream import Stream, StreamIO
from streamlink.utils.named_pipe import NamedPipe, NamedPipeBase
from streamlink.utils.processoutput import ProcessOutput
log = logging.getLogger(__name__)
_lock_resolve_command = threading.Lock()
class MuxedStream(Stream):
"""
Muxes multiple streams into one output stream.
"""
__shortname__ = "muxed-stream"
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.
"""
super().__init__(session)
self.substreams = substreams
self.subtitles = options.pop("subtitles", {})
self.options = options
def open(self):
fds = []
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:
log.debug("Opening {0} substream".format(substream.shortname()))
if update_maps:
maps.append(len(fds))
fds.append(substream and substream.open())
for i, subtitle in enumerate(self.subtitles.items()):
language, substream = subtitle
log.debug("Opening {0} subtitle stream".format(substream.shortname()))
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
return FFMPEGMuxer(self.session, *fds, **self.options).open()
@classmethod
def is_usable(cls, session):
return FFMPEGMuxer.is_usable(session)
class FFMPEGMuxer(StreamIO):
2022-09-14 13:28:29 +02:00
__commands__ = ["ffmpeg"]
DEFAULT_OUTPUT_FORMAT = "matroska"
DEFAULT_VIDEO_CODEC = "copy"
DEFAULT_AUDIO_CODEC = "copy"
FFMPEG_VERSION: Optional[str] = None
FFMPEG_VERSION_TIMEOUT = 4.0
errorlog: Union[int, TextIO]
@classmethod
def is_usable(cls, session):
return cls.command(session) is not None
@classmethod
def command(cls, session):
with _lock_resolve_command:
return cls._resolve_command(
session.options.get("ffmpeg-ffmpeg"),
not session.options.get("ffmpeg-no-validation"),
)
@classmethod
@lru_cache(maxsize=128)
def _resolve_command(cls, command: Optional[str] = None, validate: bool = True) -> Optional[str]:
if command:
resolved = which(command)
else:
resolved = None
for cmd in cls.__commands__:
resolved = which(cmd)
if resolved:
break
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)
if not resolved:
log.warning("No valid FFmpeg binary was found. See the --ffmpeg-ffmpeg option.")
log.warning("Muxing streams is unsupported! Only a subset of the available streams can be returned!")
return resolved
@staticmethod
def copy_to_pipe(stream: StreamIO, pipe: NamedPipeBase):
log.debug(f"Starting copy to pipe: {pipe.path}")
# TODO: catch OSError when creating/opening pipe fails and close entire output stream
pipe.open()
while True:
try:
data = stream.read(8192)
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}")
break
with suppress(OSError):
pipe.close()
def __init__(self, session, *streams, **options):
if not self.is_usable(session):
raise StreamError("cannot use FFMPEG")
self.session = session
self.process = None
self.streams = streams
self.pipes = [NamedPipe() for _ in self.streams]
self.pipe_threads = [threading.Thread(target=self.copy_to_pipe, args=(stream, np))
for stream, np in
zip(self.streams, self.pipes)]
ofmt = session.options.get("ffmpeg-fout") or options.pop("format", self.DEFAULT_OUTPUT_FORMAT)
outpath = options.pop("outpath", "pipe:1")
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)
metadata = options.pop("metadata", {})
maps = options.pop("maps", [])
copyts = session.options.get("ffmpeg-copyts") or options.pop("copyts", False)
start_at_zero = session.options.get("ffmpeg-start-at-zero") or options.pop("start_at_zero", False)
self._cmd = [self.command(session), "-nostats", "-y"]
for np in self.pipes:
self._cmd.extend(["-i", str(np.path)])
self._cmd.extend(["-c:v", videocodec])
self._cmd.extend(["-c:a", audiocodec])
for m in maps:
self._cmd.extend(["-map", str(m)])
MPEG DASH Support (initial) (#1637) * stream.dash: parser for dash manifest files * stream.dash: stream player for dash with plugin to support dash:// prefixed urls * cli.main: make sure that streams are closed on errors * stream.dash: fix some parsing bugs * stream.dash: tidy up the segment number generation * plugins.dash: wip segment timeline * stream.dash: update to segment timeline parsing * stream.dash: py3 support * stream.dash: raise an error for DRM protected streams * stream.dash: fixes for timescaling and some segment templates * docs: add DASHStream to docs with other Stream classes * dash: fix for video only stream * plugins.dash: fix bug where all URLs were matched * stream.dash: fix issue with manifest reload * plugin.dash: add tests and fix a couple of bugs found in testing * stream.dash: add some tests to cover the DASHStream classes * WIP: audio only streams * add some debugging for threads and remove the thread joins * dash: startNumber should default to 1 * dash: follow redirects to get the base url * dash: fix bool parser, and segment template parser * dash: fixed some issues... ...with some segment templates, as well as improving the presentation delay handling * dash: add a back-off for checking for manifest changes * dash: fix broken tests * dash: incomplete support for Segment@r * dash: fixed audio/video sync issue Added a `copyts` option to the FFMPEG muxer class so that the timestamps given in the source files are maintained, this appears to fix the a/v sync issues. NB. The timestamp can get weird, but that's how it is :) * dash: support for Time _and_ Number in segment timeline * tests: add some dash parser tests + a little refactor * tests: add dash to built in plugins * tests: more coverage of dash_parser Added a new module for tests, `freezegun`, for mocking time. * dash: fix for missing publishTime * dash: update available_at times to be datetime This should fix any timezone or leap-second issues, etc. * fixed timing issue for 1tv.ru * dash: fix availability timeline for segment timeline * dash: flake8 tweaks * dash: add a few debug logging messages
2018-05-30 21:30:38 +02:00
if copyts:
self._cmd.extend(["-copyts"])
if start_at_zero:
self._cmd.extend(["-start_at_zero"])
MPEG DASH Support (initial) (#1637) * stream.dash: parser for dash manifest files * stream.dash: stream player for dash with plugin to support dash:// prefixed urls * cli.main: make sure that streams are closed on errors * stream.dash: fix some parsing bugs * stream.dash: tidy up the segment number generation * plugins.dash: wip segment timeline * stream.dash: update to segment timeline parsing * stream.dash: py3 support * stream.dash: raise an error for DRM protected streams * stream.dash: fixes for timescaling and some segment templates * docs: add DASHStream to docs with other Stream classes * dash: fix for video only stream * plugins.dash: fix bug where all URLs were matched * stream.dash: fix issue with manifest reload * plugin.dash: add tests and fix a couple of bugs found in testing * stream.dash: add some tests to cover the DASHStream classes * WIP: audio only streams * add some debugging for threads and remove the thread joins * dash: startNumber should default to 1 * dash: follow redirects to get the base url * dash: fix bool parser, and segment template parser * dash: fixed some issues... ...with some segment templates, as well as improving the presentation delay handling * dash: add a back-off for checking for manifest changes * dash: fix broken tests * dash: incomplete support for Segment@r * dash: fixed audio/video sync issue Added a `copyts` option to the FFMPEG muxer class so that the timestamps given in the source files are maintained, this appears to fix the a/v sync issues. NB. The timestamp can get weird, but that's how it is :) * dash: support for Time _and_ Number in segment timeline * tests: add some dash parser tests + a little refactor * tests: add dash to built in plugins * tests: more coverage of dash_parser Added a new module for tests, `freezegun`, for mocking time. * dash: fix for missing publishTime * dash: update available_at times to be datetime This should fix any timezone or leap-second issues, etc. * fixed timing issue for 1tv.ru * dash: fix availability timeline for segment timeline * dash: flake8 tweaks * dash: add a few debug logging messages
2018-05-30 21:30:38 +02:00
for stream, data in metadata.items():
for datum in data:
stream_id = ":{0}".format(stream) if stream else ""
self._cmd.extend(["-metadata{0}".format(stream_id), datum])
self._cmd.extend(["-f", ofmt, outpath])
log.debug("ffmpeg command: {0}".format(" ".join(self._cmd)))
if session.options.get("ffmpeg-verbose-path"):
self.errorlog = Path(session.options.get("ffmpeg-verbose-path")).expanduser().open("w")
elif session.options.get("ffmpeg-verbose"):
self.errorlog = sys.stderr
else:
self.errorlog = subprocess.DEVNULL
def open(self):
for t in self.pipe_threads:
t.daemon = True
t.start()
self.process = subprocess.Popen(self._cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=self.errorlog)
return self
def read(self, size=-1):
return self.process.stdout.read(size)
def close(self):
if self.closed:
return
log.debug("Closing ffmpeg thread")
if self.process:
# kill ffmpeg
self.process.kill()
self.process.stdout.close()
executor = concurrent.futures.ThreadPoolExecutor()
# close the substreams
futures = [
executor.submit(stream.close)
for stream in self.streams
if hasattr(stream, "close") and callable(stream.close)
]
concurrent.futures.wait(futures, return_when=concurrent.futures.ALL_COMPLETED)
log.debug("Closed all the substreams")
# 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)
if self.errorlog is not sys.stderr and self.errorlog is not subprocess.DEVNULL:
with suppress(OSError):
self.errorlog.close()
super().close()
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+)")
def __init__(self, *args, **kwargs) -> None:
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)