aboutsummaryrefslogtreecommitdiffstats
path: root/hypervideo_dl/postprocessor
diff options
context:
space:
mode:
authorJesús <heckyel@hyperbola.info>2022-04-06 03:37:17 +0800
committerJesús <heckyel@hyperbola.info>2022-04-06 03:37:17 +0800
commit1e5a50b71d8f0eae6007bedc329eecb24bb5aba3 (patch)
treea8611cda6596391cb6fb645e1469dcd356b63924 /hypervideo_dl/postprocessor
parentf52fb3bceeb9d22b5106c1796fecec474a0cc138 (diff)
downloadhypervideo-1e5a50b71d8f0eae6007bedc329eecb24bb5aba3.tar.lz
hypervideo-1e5a50b71d8f0eae6007bedc329eecb24bb5aba3.tar.xz
hypervideo-1e5a50b71d8f0eae6007bedc329eecb24bb5aba3.zip
update from upstream
Diffstat (limited to 'hypervideo_dl/postprocessor')
-rw-r--r--hypervideo_dl/postprocessor/__init__.py8
-rw-r--r--hypervideo_dl/postprocessor/common.py44
-rw-r--r--hypervideo_dl/postprocessor/embedthumbnail.py77
-rw-r--r--hypervideo_dl/postprocessor/exec.py21
-rw-r--r--hypervideo_dl/postprocessor/ffmpeg.py519
-rw-r--r--hypervideo_dl/postprocessor/metadataparser.py29
-rw-r--r--hypervideo_dl/postprocessor/modify_chapters.py22
-rw-r--r--hypervideo_dl/postprocessor/sponskrub.py13
-rw-r--r--hypervideo_dl/postprocessor/sponsorblock.py37
9 files changed, 498 insertions, 272 deletions
diff --git a/hypervideo_dl/postprocessor/__init__.py b/hypervideo_dl/postprocessor/__init__.py
index 07c87b7..e47631e 100644
--- a/hypervideo_dl/postprocessor/__init__.py
+++ b/hypervideo_dl/postprocessor/__init__.py
@@ -2,12 +2,16 @@
from ..utils import load_plugins
+from .common import PostProcessor
from .embedthumbnail import EmbedThumbnailPP
from .exec import ExecPP, ExecAfterDownloadPP
from .ffmpeg import (
FFmpegPostProcessor,
+ FFmpegCopyStreamPP,
+ FFmpegConcatPP,
FFmpegEmbedSubtitlePP,
FFmpegExtractAudioPP,
+ FFmpegFixupDuplicateMoovPP,
FFmpegFixupDurationPP,
FFmpegFixupStretchedPP,
FFmpegFixupTimestampPP,
@@ -39,5 +43,5 @@ def get_postprocessor(key):
return globals()[key + 'PP']
-__all__ = [name for name in globals().keys() if name.endswith('IE')]
-__all__.append('FFmpegPostProcessor')
+__all__ = [name for name in globals().keys() if name.endswith('PP')]
+__all__.extend(('PostProcessor', 'FFmpegPostProcessor'))
diff --git a/hypervideo_dl/postprocessor/common.py b/hypervideo_dl/postprocessor/common.py
index b491afb..3899646 100644
--- a/hypervideo_dl/postprocessor/common.py
+++ b/hypervideo_dl/postprocessor/common.py
@@ -1,14 +1,19 @@
from __future__ import unicode_literals
-import copy
import functools
+import itertools
+import json
import os
+import time
+import urllib.error
-from ..compat import compat_str
from ..utils import (
_configuration_args,
encodeFilename,
+ network_exceptions,
PostProcessingError,
+ sanitized_Request,
+ write_string,
)
@@ -17,7 +22,7 @@ class PostProcessorMetaClass(type):
def run_wrapper(func):
@functools.wraps(func)
def run(self, info, *args, **kwargs):
- info_copy = copy.deepcopy(self._copy_infodict(info))
+ info_copy = self._copy_infodict(info)
self._hook_progress({'status': 'started'}, info_copy)
ret = func(self, info, *args, **kwargs)
if ret is not None:
@@ -63,7 +68,7 @@ class PostProcessor(metaclass=PostProcessorMetaClass):
@classmethod
def pp_key(cls):
name = cls.__name__[:-2]
- return compat_str(name[6:]) if name[:6].lower() == 'ffmpeg' else name
+ return name[6:] if name[:6].lower() == 'ffmpeg' else name
def to_screen(self, text, prefix=True, *args, **kwargs):
tag = '[%s] ' % self.PP_NAME if prefix else ''
@@ -74,6 +79,11 @@ class PostProcessor(metaclass=PostProcessorMetaClass):
if self._downloader:
return self._downloader.report_warning(text, *args, **kwargs)
+ def deprecation_warning(self, text):
+ if self._downloader:
+ return self._downloader.deprecation_warning(text)
+ write_string(f'DeprecationWarning: {text}')
+
def report_error(self, text, *args, **kwargs):
# Exists only for compatibility. Do not use
if self._downloader:
@@ -98,12 +108,14 @@ class PostProcessor(metaclass=PostProcessorMetaClass):
return getattr(self._downloader, '_copy_infodict', dict)(info_dict)
@staticmethod
- def _restrict_to(*, video=True, audio=True, images=True):
+ def _restrict_to(*, video=True, audio=True, images=True, simulated=True):
allowed = {'video': video, 'audio': audio, 'images': images}
def decorator(func):
@functools.wraps(func)
def wrapper(self, info):
+ if not simulated and (self.get_param('simulate') or self.get_param('skip_download')):
+ return [], info
format_type = (
'video' if info.get('vcodec') != 'none'
else 'audio' if info.get('acodec') != 'none'
@@ -173,6 +185,28 @@ class PostProcessor(metaclass=PostProcessorMetaClass):
progress_template.get('postprocess-title') or 'hypervideo %(progress._default_template)s',
progress_dict))
+ def _download_json(self, url, *, expected_http_errors=(404,)):
+ # While this is not an extractor, it behaves similar to one and
+ # so obey extractor_retries and sleep_interval_requests
+ max_retries = self.get_param('extractor_retries', 3)
+ sleep_interval = self.get_param('sleep_interval_requests') or 0
+
+ self.write_debug(f'{self.PP_NAME} query: {url}')
+ for retries in itertools.count():
+ try:
+ rsp = self._downloader.urlopen(sanitized_Request(url))
+ return json.loads(rsp.read().decode(rsp.info().get_param('charset') or 'utf-8'))
+ except network_exceptions as e:
+ if isinstance(e, urllib.error.HTTPError) and e.code in expected_http_errors:
+ return None
+ if retries < max_retries:
+ self.report_warning(f'{e}. Retrying...')
+ if sleep_interval > 0:
+ self.to_screen(f'Sleeping {sleep_interval} seconds ...')
+ time.sleep(sleep_interval)
+ continue
+ raise PostProcessingError(f'Unable to communicate with {self.PP_NAME} API: {e}')
+
class AudioConversionError(PostProcessingError):
pass
diff --git a/hypervideo_dl/postprocessor/embedthumbnail.py b/hypervideo_dl/postprocessor/embedthumbnail.py
index 3139a63..815221d 100644
--- a/hypervideo_dl/postprocessor/embedthumbnail.py
+++ b/hypervideo_dl/postprocessor/embedthumbnail.py
@@ -26,9 +26,9 @@ from ..utils import (
encodeArgument,
encodeFilename,
error_to_compat_str,
+ Popen,
PostProcessingError,
prepend_extension,
- process_communicate_or_kill,
shell_quote,
)
@@ -108,7 +108,7 @@ class EmbedThumbnailPP(FFmpegPostProcessor):
self.run_ffmpeg_multiple_files([filename, thumbnail_filename], temp_filename, options)
elif info['ext'] in ['mkv', 'mka']:
- options = ['-c', 'copy', '-map', '0', '-dn']
+ options = list(self.stream_copy_opts())
mimetype = 'image/%s' % ('png' if thumbnail_ext == 'png' else 'jpeg')
old_stream, new_stream = self.get_stream_number(
@@ -145,11 +145,46 @@ class EmbedThumbnailPP(FFmpegPostProcessor):
self.report_warning('unable to embed using mutagen; %s' % error_to_compat_str(err))
success = False
- # Method 2: Use ffmpeg+ffprobe
- if not success and not prefer_atomicparsley:
+ # Method 2: Use AtomicParsley
+ if not success:
+ success = True
+ atomicparsley = next((
+ x for x in ['AtomicParsley', 'atomicparsley']
+ if check_executable(x, ['-v'])), None)
+ if atomicparsley is None:
+ self.to_screen('Neither mutagen nor AtomicParsley was found. Falling back to ffmpeg')
+ success = False
+ else:
+ if not prefer_atomicparsley:
+ self.to_screen('mutagen was not found. Falling back to AtomicParsley')
+ cmd = [encodeFilename(atomicparsley, True),
+ encodeFilename(filename, True),
+ encodeArgument('--artwork'),
+ encodeFilename(thumbnail_filename, True),
+ encodeArgument('-o'),
+ encodeFilename(temp_filename, True)]
+ cmd += [encodeArgument(o) for o in self._configuration_args('AtomicParsley')]
+
+ self._report_run('atomicparsley', filename)
+ self.write_debug('AtomicParsley command line: %s' % shell_quote(cmd))
+ p = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ stdout, stderr = p.communicate_or_kill()
+ if p.returncode != 0:
+ msg = stderr.decode('utf-8', 'replace').strip()
+ self.report_warning(f'Unable to embed thumbnails using AtomicParsley; {msg}')
+ # for formats that don't support thumbnails (like 3gp) AtomicParsley
+ # won't create to the temporary file
+ if b'No changes' in stdout:
+ self.report_warning('The file format doesn\'t support embedding a thumbnail')
+ success = False
+
+ # Method 3: Use ffmpeg+ffprobe
+ # Thumbnails attached using this method doesn't show up as cover in some cases
+ # See https://github.com/hypervideo/hypervideo/issues/2125, https://github.com/hypervideo/hypervideo/issues/411
+ if not success:
success = True
try:
- options = ['-c', 'copy', '-map', '0', '-dn', '-map', '1']
+ options = [*self.stream_copy_opts(), '-map', '1']
old_stream, new_stream = self.get_stream_number(
filename, ('disposition', 'attached_pic'), 1)
@@ -161,38 +196,8 @@ class EmbedThumbnailPP(FFmpegPostProcessor):
self._report_run('ffmpeg', filename)
self.run_ffmpeg_multiple_files([filename, thumbnail_filename], temp_filename, options)
except PostProcessingError as err:
- self.report_warning('unable to embed using ffprobe & ffmpeg; %s' % error_to_compat_str(err))
- success = False
-
- # Method 3: Use AtomicParsley
- if not success:
- success = True
- atomicparsley = next((
- x for x in ['AtomicParsley', 'atomicparsley']
- if check_executable(x, ['-v'])), None)
- if atomicparsley is None:
- raise EmbedThumbnailPPError('AtomicParsley was not found. Please install')
-
- cmd = [encodeFilename(atomicparsley, True),
- encodeFilename(filename, True),
- encodeArgument('--artwork'),
- encodeFilename(thumbnail_filename, True),
- encodeArgument('-o'),
- encodeFilename(temp_filename, True)]
- cmd += [encodeArgument(o) for o in self._configuration_args('AtomicParsley')]
-
- self._report_run('atomicparsley', filename)
- self.write_debug('AtomicParsley command line: %s' % shell_quote(cmd))
- p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- stdout, stderr = process_communicate_or_kill(p)
- if p.returncode != 0:
- msg = stderr.decode('utf-8', 'replace').strip()
- raise EmbedThumbnailPPError(msg)
- # for formats that don't support thumbnails (like 3gp) AtomicParsley
- # won't create to the temporary file
- if b'No changes' in stdout:
- self.report_warning('The file format doesn\'t support embedding a thumbnail')
success = False
+ raise EmbedThumbnailPPError(f'Unable to embed using ffprobe & ffmpeg; {err}')
elif info['ext'] in ['ogg', 'opus', 'flac']:
if not has_mutagen:
diff --git a/hypervideo_dl/postprocessor/exec.py b/hypervideo_dl/postprocessor/exec.py
index 7a3cb49..c0bd6df 100644
--- a/hypervideo_dl/postprocessor/exec.py
+++ b/hypervideo_dl/postprocessor/exec.py
@@ -22,11 +22,13 @@ class ExecPP(PostProcessor):
if tmpl_dict: # if there are no replacements, tmpl_dict = {}
return self._downloader.escape_outtmpl(tmpl) % tmpl_dict
- # If no replacements are found, replace {} for backard compatibility
- if '{}' not in cmd:
- cmd += ' {}'
- return cmd.replace('{}', compat_shlex_quote(
- info.get('filepath') or info['_filename']))
+ filepath = info.get('filepath', info.get('_filename'))
+ # If video, and no replacements are found, replace {} for backard compatibility
+ if filepath:
+ if '{}' not in cmd:
+ cmd += ' {}'
+ cmd = cmd.replace('{}', compat_shlex_quote(filepath))
+ return cmd
def run(self, info):
for tmpl in self.exec_cmd:
@@ -38,5 +40,10 @@ class ExecPP(PostProcessor):
return [], info
-class ExecAfterDownloadPP(ExecPP): # for backward compatibility
- pass
+# Deprecated
+class ExecAfterDownloadPP(ExecPP):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.deprecation_warning(
+ 'hypervideo_dl.postprocessor.ExecAfterDownloadPP is deprecated '
+ 'and may be removed in a future version. Use hypervideo_dl.postprocessor.ExecPP instead')
diff --git a/hypervideo_dl/postprocessor/ffmpeg.py b/hypervideo_dl/postprocessor/ffmpeg.py
index a6d6d78..3e6edcf 100644
--- a/hypervideo_dl/postprocessor/ffmpeg.py
+++ b/hypervideo_dl/postprocessor/ffmpeg.py
@@ -1,5 +1,6 @@
from __future__ import unicode_literals
+import collections
import io
import itertools
import os
@@ -12,21 +13,24 @@ from .common import AudioConversionError, PostProcessor
from ..compat import compat_str
from ..utils import (
+ determine_ext,
dfxp2srt,
encodeArgument,
encodeFilename,
float_or_none,
- get_exe_version,
+ _get_exe_version_output,
+ detect_exe_version,
is_outdated_version,
ISO639Utils,
orderedSet,
+ Popen,
PostProcessingError,
prepend_extension,
- process_communicate_or_kill,
replace_extension,
shell_quote,
traverse_obj,
variadic,
+ write_json_file,
)
@@ -41,6 +45,7 @@ EXT_TO_OUT_FORMATS = {
'ts': 'mpegts',
'wma': 'asf',
'wmv': 'asf',
+ 'vtt': 'webvtt',
}
ACODECS = {
'mp3': 'libmp3lame',
@@ -50,6 +55,7 @@ ACODECS = {
'opus': 'libopus',
'vorbis': 'libvorbis',
'wav': None,
+ 'alac': None,
}
@@ -74,15 +80,25 @@ class FFmpegPostProcessor(PostProcessor):
self.report_warning(warning)
@staticmethod
+ def get_versions_and_features(downloader=None):
+ pp = FFmpegPostProcessor(downloader)
+ return pp._versions, pp._features
+
+ @staticmethod
def get_versions(downloader=None):
- return FFmpegPostProcessor(downloader)._versions
+ return FFmpegPostProcessor.get_versions_and_features(downloader)[0]
+
+ _version_cache, _features_cache = {}, {}
def _determine_executables(self):
programs = ['avprobe', 'avconv', 'ffmpeg', 'ffprobe']
- prefer_ffmpeg = True
- def get_ffmpeg_version(path):
- ver = get_exe_version(path, args=['-version'])
+ def get_ffmpeg_version(path, prog):
+ if path in self._version_cache:
+ self._versions[prog], self._features = self._version_cache[path], self._features_cache.get(path, {})
+ return
+ out = _get_exe_version_output(path, ['-bsfs'], to_screen=self.write_debug)
+ ver = detect_exe_version(out) if out else False
if ver:
regexs = [
r'(?:\d+:)?([0-9.]+)-[0-9]+ubuntu[0-9.]+$', # Ubuntu, see [1]
@@ -93,60 +109,66 @@ class FFmpegPostProcessor(PostProcessor):
mobj = re.match(regex, ver)
if mobj:
ver = mobj.group(1)
- return ver
+ self._versions[prog] = self._version_cache[path] = ver
+ if prog != 'ffmpeg' or not out:
+ return
+
+ mobj = re.search(r'(?m)^\s+libavformat\s+(?:[0-9. ]+)\s+/\s+(?P<runtime>[0-9. ]+)', out)
+ lavf_runtime_version = mobj.group('runtime').replace(' ', '') if mobj else None
+ self._features = self._features_cache[path] = {
+ 'fdk': '--enable-libfdk-aac' in out,
+ 'setts': 'setts' in out.splitlines(),
+ 'needs_adtstoasc': is_outdated_version(lavf_runtime_version, '57.56.100', False),
+ }
self.basename = None
self.probe_basename = None
-
self._paths = None
self._versions = None
- if self._downloader:
- prefer_ffmpeg = self.get_param('prefer_ffmpeg', True)
- location = self.get_param('ffmpeg_location')
- if location is not None:
- if not os.path.exists(location):
- self.report_warning(
- 'ffmpeg-location %s does not exist! '
- 'Continuing without ffmpeg.' % (location))
- self._versions = {}
- return
- elif os.path.isdir(location):
- dirname, basename = location, None
- else:
- basename = os.path.splitext(os.path.basename(location))[0]
- basename = next((p for p in programs if basename.startswith(p)), 'ffmpeg')
- dirname = os.path.dirname(os.path.abspath(location))
- if basename in ('ffmpeg', 'ffprobe'):
- prefer_ffmpeg = True
-
- self._paths = dict(
- (p, os.path.join(dirname, p)) for p in programs)
- if basename:
- self._paths[basename] = location
- self._versions = dict(
- (p, get_ffmpeg_version(self._paths[p])) for p in programs)
- if self._versions is None:
- self._versions = dict(
- (p, get_ffmpeg_version(p)) for p in programs)
- self._paths = dict((p, p) for p in programs)
+ self._features = {}
- if prefer_ffmpeg is False:
- prefs = ('avconv', 'ffmpeg')
+ prefer_ffmpeg = self.get_param('prefer_ffmpeg', True)
+ location = self.get_param('ffmpeg_location')
+ if location is None:
+ self._paths = {p: p for p in programs}
else:
- prefs = ('ffmpeg', 'avconv')
- for p in prefs:
- if self._versions[p]:
- self.basename = p
- break
-
+ if not os.path.exists(location):
+ self.report_warning(
+ 'ffmpeg-location %s does not exist! '
+ 'Continuing without ffmpeg.' % (location))
+ self._versions = {}
+ return
+ elif os.path.isdir(location):
+ dirname, basename = location, None
+ else:
+ basename = os.path.splitext(os.path.basename(location))[0]
+ basename = next((p for p in programs if basename.startswith(p)), 'ffmpeg')
+ dirname = os.path.dirname(os.path.abspath(location))
+ if basename in ('ffmpeg', 'ffprobe'):
+ prefer_ffmpeg = True
+
+ self._paths = dict(
+ (p, os.path.join(dirname, p)) for p in programs)
+ if basename:
+ self._paths[basename] = location
+
+ self._versions = {}
+ executables = {'basename': ('ffmpeg', 'avconv'), 'probe_basename': ('ffprobe', 'avprobe')}
if prefer_ffmpeg is False:
- prefs = ('avprobe', 'ffprobe')
- else:
- prefs = ('ffprobe', 'avprobe')
- for p in prefs:
- if self._versions[p]:
- self.probe_basename = p
- break
+ executables = {k: v[::-1] for k, v in executables.items()}
+ for var, prefs in executables.items():
+ for p in prefs:
+ get_ffmpeg_version(self._paths[p], p)
+ if self._versions[p]:
+ setattr(self, var, p)
+ break
+
+ if self.basename == 'avconv':
+ self.deprecation_warning(
+ 'Support for avconv is deprecated and may be removed in a future version. Use ffmpeg instead')
+ if self.probe_basename == 'avprobe':
+ self.deprecation_warning(
+ 'Support for avprobe is deprecated and may be removed in a future version. Use ffprobe instead')
@property
def available(self):
@@ -164,6 +186,18 @@ class FFmpegPostProcessor(PostProcessor):
def probe_executable(self):
return self._paths[self.probe_basename]
+ @staticmethod
+ def stream_copy_opts(copy=True, *, ext=None):
+ yield from ('-map', '0')
+ # Don't copy Apple TV chapters track, bin_data
+ # See https://github.com/hypervideo/hypervideo/issues/2, #19042, #19024, https://trac.ffmpeg.org/ticket/6016
+ yield from ('-dn', '-ignore_unknown')
+ if copy:
+ yield from ('-c', 'copy')
+ # For some reason, '-c copy -map 0' is not enough to copy subtitles
+ if ext in ('mp4', 'mov'):
+ yield from ('-c:s', 'mov_text')
+
def get_audio_codec(self, path):
if not self.probe_available and not self.available:
raise PostProcessingError('ffprobe and ffmpeg not found. Please install or provide the path using --ffmpeg-location')
@@ -178,10 +212,8 @@ class FFmpegPostProcessor(PostProcessor):
encodeArgument('-i')]
cmd.append(encodeFilename(self._ffmpeg_filename_argument(path), True))
self.write_debug('%s command line: %s' % (self.basename, shell_quote(cmd)))
- handle = subprocess.Popen(
- cmd, stderr=subprocess.PIPE,
- stdout=subprocess.PIPE, stdin=subprocess.PIPE)
- stdout_data, stderr_data = process_communicate_or_kill(handle)
+ handle = Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ stdout_data, stderr_data = handle.communicate_or_kill()
expected_ret = 0 if self.probe_available else 1
if handle.wait() != expected_ret:
return None
@@ -223,7 +255,7 @@ class FFmpegPostProcessor(PostProcessor):
cmd += opts
cmd.append(encodeFilename(self._ffmpeg_filename_argument(path), True))
self.write_debug('ffprobe command line: %s' % shell_quote(cmd))
- p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
+ p = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
stdout, stderr = p.communicate()
return json.loads(stdout.decode('utf-8', 'replace'))
@@ -234,22 +266,23 @@ class FFmpegPostProcessor(PostProcessor):
None)
return num, len(streams)
- def _get_real_video_duration(self, info, fatal=True):
+ def _get_real_video_duration(self, filepath, fatal=True):
try:
- if '_real_duration' not in info:
- info['_real_duration'] = float_or_none(
- traverse_obj(self.get_metadata_object(info['filepath']), ('format', 'duration')))
- if not info['_real_duration']:
+ duration = float_or_none(
+ traverse_obj(self.get_metadata_object(filepath), ('format', 'duration')))
+ if not duration:
raise PostProcessingError('ffprobe returned empty duration')
+ return duration
except PostProcessingError as e:
if fatal:
- raise PostProcessingError(f'Unable to determine video duration; {e}')
- return info.setdefault('_real_duration', None)
+ raise PostProcessingError(f'Unable to determine video duration: {e.msg}')
def _duration_mismatch(self, d1, d2):
if not d1 or not d2:
return None
- return abs(d1 - d2) > 1
+ # The duration is often only known to nearest second. So there can be <1sec disparity natually.
+ # Further excuse an additional <1sec difference.
+ return abs(d1 - d2) > 2
def run_ffmpeg_multiple_files(self, input_paths, out_path, opts, **kwargs):
return self.real_run_ffmpeg(
@@ -269,8 +302,10 @@ class FFmpegPostProcessor(PostProcessor):
def make_args(file, args, name, number):
keys = ['_%s%d' % (name, number), '_%s' % name]
- if name == 'o' and number == 1:
- keys.append('')
+ if name == 'o':
+ args += ['-movflags', '+faststart']
+ if number == 1:
+ keys.append('')
args += self._configuration_args(self.basename, keys)
if name == 'i':
args.append('-i')
@@ -284,8 +319,8 @@ class FFmpegPostProcessor(PostProcessor):
for i, (path, opts) in enumerate(path_opts) if path)
self.write_debug('ffmpeg command line: %s' % shell_quote(cmd))
- p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
- stdout, stderr = process_communicate_or_kill(p)
+ p = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
+ stdout, stderr = p.communicate_or_kill()
if p.returncode not in variadic(expected_retcodes):
stderr = stderr.decode('utf-8', 'replace').strip()
self.write_debug(stderr)
@@ -324,8 +359,9 @@ class FFmpegPostProcessor(PostProcessor):
timestamps = timestamps[1:]
keyframe_file = prepend_extension(filename, 'keyframes.temp')
self.to_screen(f'Re-encoding "{filename}" with appropriate keyframes')
- self.run_ffmpeg(filename, keyframe_file, ['-force_key_frames', ','.join(
- f'{t:.6f}' for t in timestamps)])
+ self.run_ffmpeg(filename, keyframe_file, [
+ *self.stream_copy_opts(False, ext=determine_ext(filename)),
+ '-force_key_frames', ','.join(f'{t:.6f}' for t in timestamps)])
return keyframe_file
def concat_files(self, in_files, out_file, concat_opts=None):
@@ -340,17 +376,12 @@ class FFmpegPostProcessor(PostProcessor):
with open(concat_file, 'wt', encoding='utf-8') as f:
f.writelines(self._concat_spec(in_files, concat_opts))
- out_flags = ['-c', 'copy']
- if out_file.rpartition('.')[-1] in ('mp4', 'mov'):
- # For some reason, '-c copy' is not enough to copy subtitles
- out_flags.extend(['-c:s', 'mov_text', '-movflags', '+faststart'])
+ out_flags = list(self.stream_copy_opts(ext=determine_ext(out_file)))
- try:
- self.real_run_ffmpeg(
- [(concat_file, ['-hide_banner', '-nostdin', '-f', 'concat', '-safe', '0'])],
- [(out_file, out_flags)])
- finally:
- os.remove(concat_file)
+ self.real_run_ffmpeg(
+ [(concat_file, ['-hide_banner', '-nostdin', '-f', 'concat', '-safe', '0'])],
+ [(out_file, out_flags)])
+ os.remove(concat_file)
@classmethod
def _concat_spec(cls, in_files, concat_opts=None):
@@ -367,14 +398,36 @@ class FFmpegPostProcessor(PostProcessor):
class FFmpegExtractAudioPP(FFmpegPostProcessor):
COMMON_AUDIO_EXTS = ('wav', 'flac', 'm4a', 'aiff', 'mp3', 'ogg', 'mka', 'opus', 'wma')
- SUPPORTED_EXTS = ('best', 'aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav')
+ SUPPORTED_EXTS = ('aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav', 'alac')
def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, nopostoverwrites=False):
FFmpegPostProcessor.__init__(self, downloader)
self._preferredcodec = preferredcodec or 'best'
- self._preferredquality = preferredquality
+ self._preferredquality = float_or_none(preferredquality)
self._nopostoverwrites = nopostoverwrites
+ def _quality_args(self, codec):
+ if self._preferredquality is None:
+ return []
+ elif self._preferredquality > 10:
+ return ['-b:a', f'{self._preferredquality}k']
+
+ limits = {
+ 'libmp3lame': (10, 0),
+ 'libvorbis': (0, 10),
+ # FFmpeg's AAC encoder does not have an upper limit for the value of -q:a.
+ # Experimentally, with values over 4, bitrate changes were minimal or non-existent
+ 'aac': (0.1, 4),
+ 'libfdk_aac': (1, 5),
+ }.get(codec)
+ if not limits:
+ return []
+
+ q = limits[1] + (limits[0] - limits[1]) * (self._preferredquality / 10)
+ if codec == 'libfdk_aac':
+ return ['-vbr', f'{int(q)}']
+ return ['-q:a', f'{q}']
+
def run_ffmpeg(self, path, out_path, codec, more_opts):
if codec is None:
acodec_opts = []
@@ -388,7 +441,7 @@ class FFmpegExtractAudioPP(FFmpegPostProcessor):
@PostProcessor._restrict_to(images=False)
def run(self, information):
- path = information['filepath']
+ orig_path = path = information['filepath']
orig_ext = information['ext']
if self._preferredcodec == 'best' and orig_ext in self.COMMON_AUDIO_EXTS:
@@ -414,69 +467,74 @@ class FFmpegExtractAudioPP(FFmpegPostProcessor):
more_opts = ['-f', 'adts']
if filecodec == 'vorbis':
extension = 'ogg'
+ elif filecodec == 'alac':
+ acodec = None
+ extension = 'm4a'
+ more_opts += ['-acodec', 'alac']
else:
# MP3 otherwise.
acodec = 'libmp3lame'
extension = 'mp3'
- more_opts = []
- if self._preferredquality is not None:
- if int(self._preferredquality) < 10:
- more_opts += ['-q:a', self._preferredquality]
- else:
- more_opts += ['-b:a', self._preferredquality + 'k']
+ more_opts = self._quality_args(acodec)
else:
# We convert the audio (lossy if codec is lossy)
acodec = ACODECS[self._preferredcodec]
+ if acodec == 'aac' and self._features.get('fdk'):
+ acodec = 'libfdk_aac'
extension = self._preferredcodec
- more_opts = []
- if self._preferredquality is not None:
- # The opus codec doesn't support the -aq option
- if int(self._preferredquality) < 10 and extension != 'opus':
- more_opts += ['-q:a', self._preferredquality]
- else:
- more_opts += ['-b:a', self._preferredquality + 'k']
+ more_opts = self._quality_args(acodec)
if self._preferredcodec == 'aac':
more_opts += ['-f', 'adts']
- if self._preferredcodec == 'm4a':
+ elif self._preferredcodec == 'm4a':
more_opts += ['-bsf:a', 'aac_adtstoasc']
- if self._preferredcodec == 'vorbis':
+ elif self._preferredcodec == 'vorbis':
extension = 'ogg'
- if self._preferredcodec == 'wav':
+ elif self._preferredcodec == 'wav':
extension = 'wav'
more_opts += ['-f', 'wav']
+ elif self._preferredcodec == 'alac':
+ extension = 'm4a'
+ more_opts += ['-acodec', 'alac']
prefix, sep, ext = path.rpartition('.') # not os.path.splitext, since the latter does not work on unicode in all setups
- new_path = prefix + sep + extension
-
- information['filepath'] = new_path
- information['ext'] = extension
-
- # If we download foo.mp3 and convert it to... foo.mp3, then don't delete foo.mp3, silly.
- if (new_path == path
- or (self._nopostoverwrites and os.path.exists(encodeFilename(new_path)))):
+ temp_path = new_path = prefix + sep + extension
+
+ if new_path == path:
+ if acodec == 'copy':
+ self.to_screen(f'File is already in target format {self._preferredcodec}, skipping')
+ return [], information
+ orig_path = prepend_extension(path, 'orig')
+ temp_path = prepend_extension(path, 'temp')
+ if (self._nopostoverwrites and os.path.exists(encodeFilename(new_path))
+ and os.path.exists(encodeFilename(orig_path))):
self.to_screen('Post-process file %s exists, skipping' % new_path)
return [], information
try:
- self.to_screen('Destination: ' + new_path)
- self.run_ffmpeg(path, new_path, acodec, more_opts)
+ self.to_screen(f'Destination: {new_path}')
+ self.run_ffmpeg(path, temp_path, acodec, more_opts)
except AudioConversionError as e:
raise PostProcessingError(
'audio conversion failed: ' + e.msg)
except Exception:
raise PostProcessingError('error running ' + self.basename)
+ os.replace(path, orig_path)
+ os.replace(temp_path, new_path)
+ information['filepath'] = new_path
+ information['ext'] = extension
+
# Try to update the date time for extracted audio file.
if information.get('filetime') is not None:
self.try_utime(
new_path, time.time(), information['filetime'],
errnote='Cannot update utime of audio file')
- return [path], information
+ return [orig_path], information
class FFmpegVideoConvertorPP(FFmpegPostProcessor):
- SUPPORTED_EXTS = ('mp4', 'mkv', 'flv', 'webm', 'mov', 'avi', 'mp3', 'mka', 'm4a', 'ogg', 'opus')
+ SUPPORTED_EXTS = ('mp4', 'mkv', 'flv', 'webm', 'mov', 'avi', 'mka', 'ogg', *FFmpegExtractAudioPP.SUPPORTED_EXTS)
FORMAT_RE = re.compile(r'{0}(?:/{0})*$'.format(r'(?:\w+>)?(?:%s)' % '|'.join(SUPPORTED_EXTS)))
_ACTION = 'converting'
@@ -492,9 +550,9 @@ class FFmpegVideoConvertorPP(FFmpegPostProcessor):
@staticmethod
def _options(target_ext):
+ yield from FFmpegPostProcessor.stream_copy_opts(False)
if target_ext == 'avi':
- return ['-c:v', 'libxvid', '-vtag', 'XVID']
- return []
+ yield from ('-c:v', 'libxvid', '-vtag', 'XVID')
@PostProcessor._restrict_to(images=False)
def run(self, info):
@@ -505,7 +563,7 @@ class FFmpegVideoConvertorPP(FFmpegPostProcessor):
else f'already is in target format {source_ext}' if source_ext == target_ext
else None)
if _skip_msg:
- self.to_screen(f'Not {self._ACTION} media file {filename!r}; {_skip_msg}')
+ self.to_screen(f'Not {self._ACTION} media file "{filename}"; {_skip_msg}')
return [], info
outpath = replace_extension(filename, target_ext, source_ext)
@@ -522,10 +580,7 @@ class FFmpegVideoRemuxerPP(FFmpegVideoConvertorPP):
@staticmethod
def _options(target_ext):
- options = ['-c', 'copy', '-map', '0', '-dn']
- if target_ext in ['mp4', 'm4a', 'mov']:
- options.extend(['-movflags', '+faststart'])
- return options
+ return FFmpegPostProcessor.stream_copy_opts()
class FFmpegEmbedSubtitlePP(FFmpegPostProcessor):
@@ -534,22 +589,28 @@ class FFmpegEmbedSubtitlePP(FFmpegPostProcessor):
self._already_have_subtitle = already_have_subtitle
@PostProcessor._restrict_to(images=False)
- def run(self, information):
- if information['ext'] not in ('mp4', 'webm', 'mkv'):
+ def run(self, info):
+ if info['ext'] not in ('mp4', 'webm', 'mkv'):
self.to_screen('Subtitles can only be embedded in mp4, webm or mkv files')
- return [], information
- subtitles = information.get('requested_subtitles')
+ return [], info
+ subtitles = info.get('requested_subtitles')
if not subtitles:
self.to_screen('There aren\'t any subtitles to embed')
- return [], information
+ return [], info
+
+ filename = info['filepath']
- filename = information['filepath']
- if information.get('duration') and self._duration_mismatch(
- self._get_real_video_duration(information, False), information['duration']):
+ # Disabled temporarily. There needs to be a way to overide this
+ # in case of duration actually mismatching in extractor
+ # See: https://github.com/hypervideo/hypervideo/issues/1870, https://github.com/hypervideo/hypervideo/issues/1385
+ '''
+ if info.get('duration') and not info.get('__real_download') and self._duration_mismatch(
+ self._get_real_video_duration(filename, False), info['duration']):
self.to_screen(f'Skipping {self.pp_key()} since the real and expected durations mismatch')
- return [], information
+ return [], info
+ '''
- ext = information['ext']
+ ext = info['ext']
sub_langs, sub_names, sub_filenames = [], [], []
webm_vtt_warn = False
mp4_ass_warn = False
@@ -574,21 +635,16 @@ class FFmpegEmbedSubtitlePP(FFmpegPostProcessor):
self.report_warning('ASS subtitles cannot be properly embedded in mp4 files; expect issues')
if not sub_langs:
- return [], information
+ return [], info
input_files = [filename] + sub_filenames
opts = [
- '-c', 'copy', '-map', '0', '-dn',
+ *self.stream_copy_opts(ext=info['ext']),
# Don't copy the existing subtitles, we may be running the
# postprocessor a second time
'-map', '-0:s',
- # Don't copy Apple TV chapters track, bin_data (see #19042, #19024,
- # https://trac.ffmpeg.org/ticket/6016)
- '-map', '-0:d',
]
- if information['ext'] == 'mp4':
- opts += ['-c:s', 'mov_text']
for i, (lang, name) in enumerate(zip(sub_langs, sub_names)):
opts.extend(['-map', '%d:0' % (i + 1)])
lang_code = ISO639Utils.short2long(lang) or lang
@@ -603,34 +659,44 @@ class FFmpegEmbedSubtitlePP(FFmpegPostProcessor):
os.replace(temp_filename, filename)
files_to_delete = [] if self._already_have_subtitle else sub_filenames
- return files_to_delete, information
+ return files_to_delete, info
class FFmpegMetadataPP(FFmpegPostProcessor):
- def __init__(self, downloader, add_metadata=True, add_chapters=True):
+ def __init__(self, downloader, add_metadata=True, add_chapters=True, add_infojson='if_exists'):
FFmpegPostProcessor.__init__(self, downloader)
self._add_metadata = add_metadata
self._add_chapters = add_chapters
+ self._add_infojson = add_infojson
@staticmethod
def _options(target_ext):
- yield from ('-map', '0', '-dn')
- if target_ext == 'm4a':
+ audio_only = target_ext == 'm4a'
+ yield from FFmpegPostProcessor.stream_copy_opts(not audio_only)
+ if audio_only:
yield from ('-vn', '-acodec', 'copy')
- else:
- yield from ('-c', 'copy')
@PostProcessor._restrict_to(images=False)
def run(self, info):
filename, metadata_filename = info['filepath'], None
- options = []
+ files_to_delete, options = [], []
if self._add_chapters and info.get('chapters'):
metadata_filename = replace_extension(filename, 'meta')
options.extend(self._get_chapter_opts(info['chapters'], metadata_filename))
+ files_to_delete.append(metadata_filename)
if self._add_metadata:
options.extend(self._get_metadata_opts(info))
+ if self._add_infojson:
+ if info['ext'] in ('mkv', 'mka'):
+ infojson_filename = info.get('infojson_filename')
+ options.extend(self._get_infojson_opts(info, infojson_filename))
+ if not infojson_filename:
+ files_to_delete.append(info.get('infojson_filename'))
+ elif self._add_infojson is True:
+ self.to_screen('The info-json can only be attached to mkv/mka files')
+
if not options:
self.to_screen('There isn\'t any metadata to add')
return [], info
@@ -640,8 +706,8 @@ class FFmpegMetadataPP(FFmpegPostProcessor):
self.run_ffmpeg_multiple_files(
(filename, metadata_filename), temp_filename,
itertools.chain(self._options(info['ext']), *options))
- if metadata_filename:
- os.remove(metadata_filename)
+ for file in filter(None, files_to_delete):
+ os.remove(file) # Don't obey --keep-files
os.replace(temp_filename, filename)
return [], info
@@ -663,15 +729,15 @@ class FFmpegMetadataPP(FFmpegPostProcessor):
yield ('-map_metadata', '1')
def _get_metadata_opts(self, info):
- metadata = {}
- meta_prefix = 'meta_'
+ meta_prefix = 'meta'
+ metadata = collections.defaultdict(dict)
def add(meta_list, info_list=None):
value = next((
- str(info[key]) for key in [meta_prefix] + list(variadic(info_list or meta_list))
+ str(info[key]) for key in [f'{meta_prefix}_'] + list(variadic(info_list or meta_list))
if info.get(key) is not None), None)
if value not in ('', None):
- metadata.update({meta_f: value for meta_f in variadic(meta_list)})
+ metadata['common'].update({meta_f: value for meta_f in variadic(meta_list)})
# See [1-4] for some info on media metadata/metadata supported
# by ffmpeg.
@@ -693,32 +759,50 @@ class FFmpegMetadataPP(FFmpegPostProcessor):
add('season_number')
add('episode_id', ('episode', 'episode_id'))
add('episode_sort', 'episode_number')
+ if 'embed-metadata' in self.get_param('compat_opts', []):
+ add('comment', 'description')
+ metadata['common'].pop('synopsis', None)
+ meta_regex = rf'{re.escape(meta_prefix)}(?P<i>\d+)?_(?P<key>.+)'
for key, value in info.items():
- if value is not None and key != meta_prefix and key.startswith(meta_prefix):
- metadata[key[len(meta_prefix):]] = value
+ mobj = re.fullmatch(meta_regex, key)
+ if value is not None and mobj:
+ metadata[mobj.group('i') or 'common'][mobj.group('key')] = value
- for name, value in metadata.items():
+ for name, value in metadata['common'].items():
yield ('-metadata', f'{name}={value}')
stream_idx = 0
for fmt in info.get('requested_formats') or []:
stream_count = 2 if 'none' not in (fmt.get('vcodec'), fmt.get('acodec')) else 1
- if fmt.get('language'):
- lang = ISO639Utils.short2long(fmt['language']) or fmt['language']
- for i in range(stream_count):
- yield ('-metadata:s:%d' % (stream_idx + i), 'language=%s' % lang)
+ lang = ISO639Utils.short2long(fmt.get('language') or '') or fmt.get('language')
+ for i in range(stream_idx, stream_idx + stream_count):
+ if lang:
+ metadata[str(i)].setdefault('language', lang)
+ for name, value in metadata[str(i)].items():
+ yield (f'-metadata:s:{i}', f'{name}={value}')
stream_idx += stream_count
- if ('no-attach-info-json' not in self.get_param('compat_opts', [])
- and '__infojson_filename' in info and info['ext'] in ('mkv', 'mka')):
- old_stream, new_stream = self.get_stream_number(info['filepath'], ('tags', 'mimetype'), 'application/json')
- if old_stream is not None:
- yield ('-map', '-0:%d' % old_stream)
- new_stream -= 1
+ def _get_infojson_opts(self, info, infofn):
+ if not infofn or not os.path.exists(infofn):
+ if self._add_infojson is not True:
+ return
+ infofn = infofn or '%s.temp' % (
+ self._downloader.prepare_filename(info, 'infojson')
+ or replace_extension(self._downloader.prepare_filename(info), 'info.json', info['ext']))
+ if not self._downloader._ensure_dir_exists(infofn):
+ return
+ self.write_debug(f'Writing info-json to: {infofn}')
+ write_json_file(self._downloader.sanitize_info(info, self.get_param('clean_infojson', True)), infofn)
+ info['infojson_filename'] = infofn
+
+ old_stream, new_stream = self.get_stream_number(info['filepath'], ('tags', 'mimetype'), 'application/json')
+ if old_stream is not None:
+ yield ('-map', '-0:%d' % old_stream)
+ new_stream -= 1
- yield ('-attach', info['__infojson_filename'],
- '-metadata:s:%d' % new_stream, 'mimetype=application/json')
+ yield ('-attach', infofn,
+ '-metadata:s:%d' % new_stream, 'mimetype=application/json')
class FFmpegMergerPP(FFmpegPostProcessor):
@@ -775,7 +859,7 @@ class FFmpegFixupStretchedPP(FFmpegFixupPostProcessor):
stretched_ratio = info.get('stretched_ratio')
if stretched_ratio not in (None, 1):
self._fixup('Fixing aspect ratio', info['filepath'], [
- '-c', 'copy', '-map', '0', '-dn', '-aspect', '%f' % stretched_ratio])
+ *self.stream_copy_opts(), '-aspect', '%f' % stretched_ratio])
return [], info
@@ -783,17 +867,27 @@ class FFmpegFixupM4aPP(FFmpegFixupPostProcessor):
@PostProcessor._restrict_to(images=False, video=False)
def run(self, info):
if info.get('container') == 'm4a_dash':
- self._fixup('Correcting container', info['filepath'], [
- '-c', 'copy', '-map', '0', '-dn', '-f', 'mp4'])
+ self._fixup('Correcting container', info['filepath'], [*self.stream_copy_opts(), '-f', 'mp4'])
return [], info
class FFmpegFixupM3u8PP(FFmpegFixupPostProcessor):
+ def _needs_fixup(self, info):
+ yield info['ext'] in ('mp4', 'm4a')
+ yield info['protocol'].startswith('m3u8')
+ try:
+ metadata = self.get_metadata_object(info['filepath'])
+ except PostProcessingError as e:
+ self.report_warning(f'Unable to extract metadata: {e.msg}')
+ yield True
+ else:
+ yield traverse_obj(metadata, ('format', 'format_name'), casesense=False) == 'mpegts'
+
@PostProcessor._restrict_to(images=False)
def run(self, info):
- if self.get_audio_codec(info['filepath']) == 'aac':
- self._fixup('Fixing malformed AAC bitstream', info['filepath'], [
- '-c', 'copy', '-map', '0', '-dn', '-f', 'mp4', '-bsf:a', 'aac_adtstoasc'])
+ if all(self._needs_fixup(info)):
+ self._fixup('Fixing MPEG-TS in MP4 container', info['filepath'], [
+ *self.stream_copy_opts(), '-f', 'mp4', '-bsf:a', 'aac_adtstoasc'])
return [], info
@@ -807,25 +901,34 @@ class FFmpegFixupTimestampPP(FFmpegFixupPostProcessor):
@PostProcessor._restrict_to(images=False)
def run(self, info):
- required_version = '4.4'
- if is_outdated_version(self._versions[self.basename], required_version):
+ if not self._features.get('setts'):
self.report_warning(
'A re-encode is needed to fix timestamps in older versions of ffmpeg. '
- f'Please install ffmpeg {required_version} or later to fixup without re-encoding')
+ 'Please install ffmpeg 4.4 or later to fixup without re-encoding')
opts = ['-vf', 'setpts=PTS-STARTPTS']
else:
opts = ['-c', 'copy', '-bsf', 'setts=ts=TS-STARTPTS']
- self._fixup('Fixing frame timestamp', info['filepath'], opts + ['-map', '0', '-dn', '-ss', self.trim])
+ self._fixup('Fixing frame timestamp', info['filepath'], opts + [*self.stream_copy_opts(False), '-ss', self.trim])
return [], info
-class FFmpegFixupDurationPP(FFmpegFixupPostProcessor):
+class FFmpegCopyStreamPP(FFmpegFixupPostProcessor):
+ MESSAGE = 'Copying stream'
+
@PostProcessor._restrict_to(images=False)
def run(self, info):
- self._fixup('Fixing video duration', info['filepath'], ['-c', 'copy', '-map', '0', '-dn'])
+ self._fixup(self.MESSAGE, info['filepath'], self.stream_copy_opts())
return [], info
+class FFmpegFixupDurationPP(FFmpegCopyStreamPP):
+ MESSAGE = 'Fixing video duration'
+
+
+class FFmpegFixupDuplicateMoovPP(FFmpegCopyStreamPP):
+ MESSAGE = 'Fixing duplicate MOOV atoms'
+
+
class FFmpegSubtitlesConvertorPP(FFmpegPostProcessor):
SUPPORTED_EXTS = ('srt', 'vtt', 'ass', 'lrc')
@@ -942,14 +1045,14 @@ class FFmpegSplitChaptersPP(FFmpegPostProcessor):
self.to_screen('Splitting video by chapters; %d chapters found' % len(chapters))
for idx, chapter in enumerate(chapters):
destination, opts = self._ffmpeg_args_for_chapter(idx + 1, chapter, info)
- self.real_run_ffmpeg([(in_file, opts)], [(destination, ['-c', 'copy'])])
+ self.real_run_ffmpeg([(in_file, opts)], [(destination, self.stream_copy_opts())])
if in_file != info['filepath']:
os.remove(in_file)
return [], info
class FFmpegThumbnailsConvertorPP(FFmpegPostProcessor):
- SUPPORTED_EXTS = ('jpg', 'png')
+ SUPPORTED_EXTS = ('jpg', 'png', 'webp')
def __init__(self, downloader=None, format=None):
super(FFmpegThumbnailsConvertorPP, self).__init__(downloader)
@@ -993,12 +1096,12 @@ class FFmpegThumbnailsConvertorPP(FFmpegPostProcessor):
files_to_delete = []
has_thumbnail = False
- for idx, thumbnail_dict in enumerate(info['thumbnails']):
- if 'filepath' not in thumbnail_dict:
+ for idx, thumbnail_dict in enumerate(info.get('thumbnails') or []):
+ original_thumbnail = thumbnail_dict.get('filepath')
+ if not original_thumbnail:
continue
has_thumbnail = True
self.fixup_webp(info, idx)
- original_thumbnail = thumbnail_dict['filepath']
_, thumbnail_ext = os.path.splitext(original_thumbnail)
if thumbnail_ext:
thumbnail_ext = thumbnail_ext[1:].lower()
@@ -1015,3 +1118,57 @@ class FFmpegThumbnailsConvertorPP(FFmpegPostProcessor):
if not has_thumbnail:
self.to_screen('There aren\'t any thumbnails to convert')
return files_to_delete, info
+
+
+class FFmpegConcatPP(FFmpegPostProcessor):
+ def __init__(self, downloader, only_multi_video=False):
+ self._only_multi_video = only_multi_video
+ super().__init__(downloader)
+
+ def _get_codecs(self, file):
+ codecs = traverse_obj(self.get_metadata_object(file), ('streams', ..., 'codec_name'))
+ self.write_debug(f'Codecs = {", ".join(codecs)}')
+ return tuple(codecs)
+
+ def concat_files(self, in_files, out_file):
+ if not self._downloader._ensure_dir_exists(out_file):
+ return
+ if len(in_files) == 1:
+ if os.path.realpath(in_files[0]) != os.path.realpath(out_file):
+ self.to_screen(f'Moving "{in_files[0]}" to "{out_file}"')
+ os.replace(in_files[0], out_file)
+ return []
+
+ if len(set(map(self._get_codecs, in_files))) > 1:
+ raise PostProcessingError(
+ 'The files have different streams/codecs and cannot be concatenated. '
+ 'Either select different formats or --recode-video them to a common format')
+
+ self.to_screen(f'Concatenating {len(in_files)} files; Destination: {out_file}')
+ super().concat_files(in_files, out_file)
+ return in_files
+
+ @PostProcessor._restrict_to(images=False, simulated=False)
+ def run(self, info):
+ entries = info.get('entries') or []
+ if not any(entries) or (self._only_multi_video and info['_type'] != 'multi_video'):
+ return [], info
+ elif traverse_obj(entries, (..., 'requested_downloads', lambda _, v: len(v) > 1)):
+ raise PostProcessingError('Concatenation is not supported when downloading multiple separate formats')
+
+ in_files = traverse_obj(entries, (..., 'requested_downloads', 0, 'filepath')) or []
+ if len(in_files) < len(entries):
+ raise PostProcessingError('Aborting concatenation because some downloads failed')
+
+ ie_copy = self._downloader._playlist_infodict(info)
+ exts = traverse_obj(entries, (..., 'requested_downloads', 0, 'ext'), (..., 'ext'))
+ ie_copy['ext'] = exts[0] if len(set(exts)) == 1 else 'mkv'
+ out_file = self._downloader.prepare_filename(ie_copy, 'pl_video')
+
+ files_to_delete = self.concat_files(in_files, out_file)
+
+ info['requested_downloads'] = [{
+ 'filepath': out_file,
+ 'ext': ie_copy['ext'],
+ }]
+ return files_to_delete, info
diff --git a/hypervideo_dl/postprocessor/metadataparser.py b/hypervideo_dl/postprocessor/metadataparser.py
index 96aac9b..01ee6c1 100644
--- a/hypervideo_dl/postprocessor/metadataparser.py
+++ b/hypervideo_dl/postprocessor/metadataparser.py
@@ -1,5 +1,4 @@
import re
-
from enum import Enum
from .common import PostProcessor
@@ -16,7 +15,7 @@ class MetadataParserPP(PostProcessor):
for f in actions:
action = f[0]
assert isinstance(action, self.Actions)
- self._actions.append(getattr(self, action._value_)(*f[1:]))
+ self._actions.append(getattr(self, action.value)(*f[1:]))
@classmethod
def validate_action(cls, action, *data):
@@ -26,12 +25,17 @@ class MetadataParserPP(PostProcessor):
'''
if not isinstance(action, cls.Actions):
raise ValueError(f'{action!r} is not a valid action')
- getattr(cls, action._value_)(cls, *data)
+ getattr(cls, action.value)(cls, *data) # So this can raise error to validate
@staticmethod
def field_to_template(tmpl):
if re.match(r'[a-zA-Z_]+$', tmpl):
return f'%({tmpl})s'
+
+ from ..YoutubeDL import YoutubeDL
+ err = YoutubeDL.validate_outtmpl(tmpl)
+ if err:
+ raise err
return tmpl
@staticmethod
@@ -66,7 +70,7 @@ class MetadataParserPP(PostProcessor):
self.write_debug(f'Searching for {out_re.pattern!r} in {template!r}')
match = out_re.search(data_to_parse)
if match is None:
- self.report_warning(f'Could not interpret {inp!r} as {out!r}')
+ self.to_screen(f'Could not interpret {inp!r} as {out!r}')
return
for attribute, value in match.groupdict().items():
info[attribute] = value
@@ -80,7 +84,7 @@ class MetadataParserPP(PostProcessor):
def f(info):
val = info.get(field)
if val is None:
- self.report_warning(f'Video does not have a {field}')
+ self.to_screen(f'Video does not have a {field}')
return
elif not isinstance(val, str):
self.report_warning(f'Cannot replace in field {field} since it is a {type(val).__name__}')
@@ -99,18 +103,23 @@ class MetadataParserPP(PostProcessor):
class MetadataFromFieldPP(MetadataParserPP):
@classmethod
def to_action(cls, f):
- match = re.match(r'(?P<in>.*?)(?<!\\):(?P<out>.+)$', f)
+ match = re.match(r'(?s)(?P<in>.*?)(?<!\\):(?P<out>.+)$', f)
if match is None:
raise ValueError(f'it should be FROM:TO, not {f!r}')
return (
cls.Actions.INTERPRET,
match.group('in').replace('\\:', ':'),
- match.group('out'))
+ match.group('out'),
+ )
def __init__(self, downloader, formats):
- MetadataParserPP.__init__(self, downloader, [self.to_action(f) for f in formats])
+ super().__init__(downloader, [self.to_action(f) for f in formats])
-class MetadataFromTitlePP(MetadataParserPP): # for backward compatibility
+# Deprecated
+class MetadataFromTitlePP(MetadataParserPP):
def __init__(self, downloader, titleformat):
- MetadataParserPP.__init__(self, downloader, [(self.Actions.INTERPRET, 'title', titleformat)])
+ super().__init__(downloader, [(self.Actions.INTERPRET, 'title', titleformat)])
+ self.deprecation_warning(
+ 'hypervideo_dl.postprocessor.MetadataFromTitlePP is deprecated '
+ 'and may be removed in a future version. Use hypervideo_dl.postprocessor.MetadataFromFieldPP instead')
diff --git a/hypervideo_dl/postprocessor/modify_chapters.py b/hypervideo_dl/postprocessor/modify_chapters.py
index a0818c4..22506bc 100644
--- a/hypervideo_dl/postprocessor/modify_chapters.py
+++ b/hypervideo_dl/postprocessor/modify_chapters.py
@@ -24,19 +24,21 @@ class ModifyChaptersPP(FFmpegPostProcessor):
*, sponsorblock_chapter_title=DEFAULT_SPONSORBLOCK_CHAPTER_TITLE, force_keyframes=False):
FFmpegPostProcessor.__init__(self, downloader)
self._remove_chapters_patterns = set(remove_chapters_patterns or [])
- self._remove_sponsor_segments = set(remove_sponsor_segments or [])
+ self._remove_sponsor_segments = set(remove_sponsor_segments or []) - set(SponsorBlockPP.POI_CATEGORIES.keys())
self._ranges_to_remove = set(remove_ranges or [])
self._sponsorblock_chapter_title = sponsorblock_chapter_title
self._force_keyframes = force_keyframes
@PostProcessor._restrict_to(images=False)
def run(self, info):
+ # Chapters must be preserved intact when downloading multiple formats of the same video.
chapters, sponsor_chapters = self._mark_chapters_to_remove(
- info.get('chapters') or [], info.get('sponsorblock_chapters') or [])
+ copy.deepcopy(info.get('chapters')) or [],
+ copy.deepcopy(info.get('sponsorblock_chapters')) or [])
if not chapters and not sponsor_chapters:
return [], info
- real_duration = self._get_real_video_duration(info)
+ real_duration = self._get_real_video_duration(info['filepath'])
if not chapters:
chapters = [{'start_time': 0, 'end_time': real_duration, 'title': info['title']}]
@@ -55,6 +57,7 @@ class ModifyChaptersPP(FFmpegPostProcessor):
self.write_debug('Expected and actual durations mismatch')
concat_opts = self._make_concat_opts(cuts, real_duration)
+ self.write_debug('Concat spec = %s' % ', '.join(f'{c.get("inpoint", 0.0)}-{c.get("outpoint", "inf")}' for c in concat_opts))
def remove_chapters(file, is_sub):
return file, self.remove_chapters(file, cuts, concat_opts, self._force_keyframes and not is_sub)
@@ -65,12 +68,13 @@ class ModifyChaptersPP(FFmpegPostProcessor):
# Renaming should only happen after all files are processed
files_to_remove = []
for in_file, out_file in in_out_files:
+ mtime = os.stat(in_file).st_mtime
uncut_file = prepend_extension(in_file, 'uncut')
os.replace(in_file, uncut_file)
os.replace(out_file, in_file)
+ self.try_utime(in_file, mtime, mtime)
files_to_remove.append(uncut_file)
- info['_real_duration'] = info['chapters'][-1]['end_time']
return files_to_remove, info
def _mark_chapters_to_remove(self, chapters, sponsor_chapters):
@@ -126,7 +130,7 @@ class ModifyChaptersPP(FFmpegPostProcessor):
cuts = []
def append_cut(c):
- assert 'remove' in c
+ assert 'remove' in c, 'Not a cut is appended to cuts'
last_to_cut = cuts[-1] if cuts else None
if last_to_cut and last_to_cut['end_time'] >= c['start_time']:
last_to_cut['end_time'] = max(last_to_cut['end_time'], c['end_time'])
@@ -154,7 +158,7 @@ class ModifyChaptersPP(FFmpegPostProcessor):
new_chapters = []
def append_chapter(c):
- assert 'remove' not in c
+ assert 'remove' not in c, 'Cut is appended to chapters'
length = c['end_time'] - c['start_time'] - excess_duration(c)
# Chapter is completely covered by cuts or sponsors.
if length <= 0:
@@ -237,7 +241,7 @@ class ModifyChaptersPP(FFmpegPostProcessor):
heapq.heappush(chapters, (c['start_time'], i, c))
# (normal, sponsor) and (sponsor, sponsor)
else:
- assert '_categories' in c
+ assert '_categories' in c, 'Normal chapters overlap'
cur_chapter['_was_cut'] = True
c['_was_cut'] = True
# Push the part after the sponsor to PQ.
@@ -301,7 +305,7 @@ class ModifyChaptersPP(FFmpegPostProcessor):
'name': SponsorBlockPP.CATEGORIES[category],
'category_names': [SponsorBlockPP.CATEGORIES[c] for c in cats]
})
- c['title'] = self._downloader.evaluate_outtmpl(self._sponsorblock_chapter_title, c)
+ c['title'] = self._downloader.evaluate_outtmpl(self._sponsorblock_chapter_title, c.copy())
# Merge identically named sponsors.
if (new_chapters and 'categories' in new_chapters[-1]
and new_chapters[-1]['title'] == c['title']):
@@ -331,6 +335,6 @@ class ModifyChaptersPP(FFmpegPostProcessor):
continue
opts[-1]['outpoint'] = f'{s["start_time"]:.6f}'
# Do not create 0 duration chunk at the end.
- if s['end_time'] != duration:
+ if s['end_time'] < duration:
opts.append({'inpoint': f'{s["end_time"]:.6f}'})
return opts
diff --git a/hypervideo_dl/postprocessor/sponskrub.py b/hypervideo_dl/postprocessor/sponskrub.py
index 932555a..400cbcc 100644
--- a/hypervideo_dl/postprocessor/sponskrub.py
+++ b/hypervideo_dl/postprocessor/sponskrub.py
@@ -11,9 +11,9 @@ from ..utils import (
encodeFilename,
shell_quote,
str_or_none,
+ Popen,
PostProcessingError,
prepend_extension,
- process_communicate_or_kill,
)
@@ -22,13 +22,18 @@ class SponSkrubPP(PostProcessor):
_temp_ext = 'spons'
_exe_name = 'sponskrub'
- def __init__(self, downloader, path='', args=None, ignoreerror=False, cut=False, force=False):
+ def __init__(self, downloader, path='', args=None, ignoreerror=False, cut=False, force=False, _from_cli=False):
PostProcessor.__init__(self, downloader)
self.force = force
self.cutout = cut
self.args = str_or_none(args) or '' # For backward compatibility
self.path = self.get_exe(path)
+ if not _from_cli:
+ self.deprecation_warning(
+ 'hypervideo_dl.postprocessor.SponSkrubPP support is deprecated and may be removed in a future version. '
+ 'Use hypervideo_dl.postprocessor.SponsorBlock and hypervideo_dl.postprocessor.ModifyChaptersPP instead')
+
if not ignoreerror and self.path is None:
if path:
raise PostProcessingError('sponskrub not found in "%s"' % path)
@@ -81,8 +86,8 @@ class SponSkrubPP(PostProcessor):
self.write_debug('sponskrub command line: %s' % shell_quote(cmd))
pipe = None if self.get_param('verbose') else subprocess.PIPE
- p = subprocess.Popen(cmd, stdout=pipe)
- stdout = process_communicate_or_kill(p)[0]
+ p = Popen(cmd, stdout=pipe)
+ stdout = p.communicate_or_kill()[0]
if p.returncode == 0:
os.replace(temp_filename, filename)
diff --git a/hypervideo_dl/postprocessor/sponsorblock.py b/hypervideo_dl/postprocessor/sponsorblock.py
index 7265a9d..7943014 100644
--- a/hypervideo_dl/postprocessor/sponsorblock.py
+++ b/hypervideo_dl/postprocessor/sponsorblock.py
@@ -1,25 +1,29 @@
+from hashlib import sha256
import json
import re
-from hashlib import sha256
from .ffmpeg import FFmpegPostProcessor
-from ..compat import compat_urllib_parse_urlencode, compat_HTTPError
-from ..utils import PostProcessingError, network_exceptions, sanitized_Request
+from ..compat import compat_urllib_parse_urlencode
class SponsorBlockPP(FFmpegPostProcessor):
-
+ # https://wiki.sponsor.ajay.app/w/Types
EXTRACTORS = {
'Youtube': 'YouTube',
}
+ POI_CATEGORIES = {
+ 'poi_highlight': 'Highlight',
+ }
CATEGORIES = {
'sponsor': 'Sponsor',
'intro': 'Intermission/Intro Animation',
'outro': 'Endcards/Credits',
'selfpromo': 'Unpaid/Self Promotion',
- 'interaction': 'Interaction Reminder',
'preview': 'Preview/Recap',
- 'music_offtopic': 'Non-Music Section'
+ 'filler': 'Filler Tangent',
+ 'interaction': 'Interaction Reminder',
+ 'music_offtopic': 'Non-Music Section',
+ **POI_CATEGORIES,
}
def __init__(self, downloader, categories=None, api='https://sponsor.ajay.app'):
@@ -33,6 +37,7 @@ class SponsorBlockPP(FFmpegPostProcessor):
self.to_screen(f'SponsorBlock is not supported for {extractor}')
return [], info
+ self.to_screen('Fetching SponsorBlock segments')
info['sponsorblock_chapters'] = self._get_sponsor_chapters(info, info['duration'])
return [], info
@@ -41,9 +46,15 @@ class SponsorBlockPP(FFmpegPostProcessor):
def duration_filter(s):
start_end = s['segment']
+ # Ignore entire video segments (https://wiki.sponsor.ajay.app/w/Types).
+ if start_end == (0, 0):
+ return False
# Ignore milliseconds difference at the start.
if start_end[0] <= 1:
start_end[0] = 0
+ # Make POI chapters 1 sec so that we can properly mark them
+ if s['category'] in self.POI_CATEGORIES.keys():
+ start_end[1] += 1
# Ignore milliseconds difference at the end.
# Never allow the segment to exceed the video.
if duration and duration - start_end[1] <= 1:
@@ -78,19 +89,9 @@ class SponsorBlockPP(FFmpegPostProcessor):
url = f'{self._API_URL}/api/skipSegments/{hash[:4]}?' + compat_urllib_parse_urlencode({
'service': service,
'categories': json.dumps(self._categories),
+ 'actionTypes': json.dumps(['skip', 'poi'])
})
- for d in self._get_json(url):
+ for d in self._download_json(url) or []:
if d['videoID'] == video_id:
return d['segments']
return []
-
- def _get_json(self, url):
- self.write_debug(f'SponsorBlock query: {url}')
- try:
- rsp = self._downloader.urlopen(sanitized_Request(url))
- except network_exceptions as e:
- if isinstance(e, compat_HTTPError) and e.code == 404:
- return []
- raise PostProcessingError(f'Unable to communicate with SponsorBlock API - {e}')
-
- return json.loads(rsp.read().decode(rsp.info().get_param('charset') or 'utf-8'))