]> jfr.im git - yt-dlp.git/blame - yt_dlp/postprocessor/ffmpeg.py
Improve `traverse_obj`
[yt-dlp.git] / yt_dlp / postprocessor / ffmpeg.py
CommitLineData
3aa578ca
PH
1from __future__ import unicode_literals
2
e9fade72 3import io
7dde84f3 4import itertools
496c1923
PH
5import os
6import subprocess
496c1923 7import time
fa2a36d9 8import re
06167fbb 9import json
496c1923
PH
10
11
12from .common import AudioConversionError, PostProcessor
13
84601bb7 14from ..compat import compat_str, compat_numeric_types
8c25f81b 15from ..utils import (
f07b74fc 16 encodeArgument,
496c1923 17 encodeFilename,
95807118 18 get_exe_version,
48844745 19 is_outdated_version,
496c1923
PH
20 PostProcessingError,
21 prepend_extension,
22 shell_quote,
bf6427d2 23 dfxp2srt,
39672624 24 ISO639Utils,
f5b1bca9 25 process_communicate_or_kill,
06167fbb 26 replace_extension,
324ad820 27 traverse_obj,
6606817a 28 variadic,
496c1923
PH
29)
30
31
a755f825 32EXT_TO_OUT_FORMATS = {
21bfcd3d
PH
33 'aac': 'adts',
34 'flac': 'flac',
35 'm4a': 'ipod',
36 'mka': 'matroska',
37 'mkv': 'matroska',
38 'mpg': 'mpeg',
39 'ogv': 'ogg',
40 'ts': 'mpegts',
41 'wma': 'asf',
42 'wmv': 'asf',
43}
44ACODECS = {
45 'mp3': 'libmp3lame',
46 'aac': 'aac',
47 'flac': 'flac',
48 'm4a': 'aac',
d2ae7e24 49 'opus': 'libopus',
21bfcd3d
PH
50 'vorbis': 'libvorbis',
51 'wav': None,
a755f825 52}
53
54
496c1923
PH
55class FFmpegPostProcessorError(PostProcessingError):
56 pass
57
d799b47b 58
496c1923 59class FFmpegPostProcessor(PostProcessor):
d47aeb22 60 def __init__(self, downloader=None):
496c1923 61 PostProcessor.__init__(self, downloader)
73fac4e9 62 self._determine_executables()
496c1923 63
48844745 64 def check_version(self):
f740fae2 65 if not self.available:
beb4b92a 66 raise FFmpegPostProcessorError('ffmpeg not found. Please install or provide the path using --ffmpeg-location')
48844745 67
65bf37ef 68 required_version = '10-0' if self.basename == 'avconv' else '1.0'
48844745 69 if is_outdated_version(
73fac4e9 70 self._versions[self.basename], required_version):
3aa578ca 71 warning = 'Your copy of %s is outdated, update %s to version %s or newer if you encounter any errors.' % (
73fac4e9 72 self.basename, self.basename, required_version)
f446cc66 73 self.report_warning(warning)
48844745 74
496c1923 75 @staticmethod
73fac4e9
PH
76 def get_versions(downloader=None):
77 return FFmpegPostProcessor(downloader)._versions
6271f1ca 78
73fac4e9
PH
79 def _determine_executables(self):
80 programs = ['avprobe', 'avconv', 'ffmpeg', 'ffprobe']
d4a24f40 81 prefer_ffmpeg = True
73fac4e9 82
a64646e4
RA
83 def get_ffmpeg_version(path):
84 ver = get_exe_version(path, args=['-version'])
85 if ver:
86 regexs = [
cbdc688c 87 r'(?:\d+:)?([0-9.]+)-[0-9]+ubuntu[0-9.]+$', # Ubuntu, see [1]
5caa531a 88 r'n([0-9.]+)$', # Arch Linux
cbdc688c 89 # 1. http://www.ducea.com/2006/06/17/ubuntu-package-version-naming-explanation/
a64646e4
RA
90 ]
91 for regex in regexs:
92 mobj = re.match(regex, ver)
93 if mobj:
94 ver = mobj.group(1)
95 return ver
96
73fac4e9
PH
97 self.basename = None
98 self.probe_basename = None
99
100 self._paths = None
101 self._versions = None
102 if self._downloader:
f446cc66 103 prefer_ffmpeg = self.get_param('prefer_ffmpeg', True)
104 location = self.get_param('ffmpeg_location')
73fac4e9
PH
105 if location is not None:
106 if not os.path.exists(location):
f446cc66 107 self.report_warning(
73fac4e9 108 'ffmpeg-location %s does not exist! '
e4172ac9 109 'Continuing without ffmpeg.' % (location))
73fac4e9
PH
110 self._versions = {}
111 return
112 elif not os.path.isdir(location):
113 basename = os.path.splitext(os.path.basename(location))[0]
114 if basename not in programs:
f446cc66 115 self.report_warning(
73fac4e9 116 'Cannot identify executable %s, its basename should be one of %s. '
e4172ac9 117 'Continuing without ffmpeg.' %
73fac4e9
PH
118 (location, ', '.join(programs)))
119 self._versions = {}
120 return None
121 location = os.path.dirname(os.path.abspath(location))
122 if basename in ('ffmpeg', 'ffprobe'):
123 prefer_ffmpeg = True
124
125 self._paths = dict(
126 (p, os.path.join(location, p)) for p in programs)
127 self._versions = dict(
a64646e4 128 (p, get_ffmpeg_version(self._paths[p])) for p in programs)
73fac4e9
PH
129 if self._versions is None:
130 self._versions = dict(
a64646e4 131 (p, get_ffmpeg_version(p)) for p in programs)
73fac4e9
PH
132 self._paths = dict((p, p) for p in programs)
133
d4a24f40 134 if prefer_ffmpeg is False:
d28b5171 135 prefs = ('avconv', 'ffmpeg')
d4a24f40
S
136 else:
137 prefs = ('ffmpeg', 'avconv')
d28b5171
PH
138 for p in prefs:
139 if self._versions[p]:
73fac4e9
PH
140 self.basename = p
141 break
76b1bd67 142
d4a24f40 143 if prefer_ffmpeg is False:
1a253e13 144 prefs = ('avprobe', 'ffprobe')
d4a24f40
S
145 else:
146 prefs = ('ffprobe', 'avprobe')
1a253e13
PH
147 for p in prefs:
148 if self._versions[p]:
73fac4e9
PH
149 self.probe_basename = p
150 break
151
f740fae2 152 @property
73fac4e9
PH
153 def available(self):
154 return self.basename is not None
1a253e13 155
73fac4e9
PH
156 @property
157 def executable(self):
158 return self._paths[self.basename]
159
3da4b313
JMF
160 @property
161 def probe_available(self):
162 return self.probe_basename is not None
163
73fac4e9
PH
164 @property
165 def probe_executable(self):
166 return self._paths[self.probe_basename]
76b1bd67 167
30d9e209 168 def get_audio_codec(self, path):
eb35b163 169 if not self.probe_available and not self.available:
beb4b92a 170 raise PostProcessingError('ffprobe and ffmpeg not found. Please install or provide the path using --ffmpeg-location')
30d9e209 171 try:
eb35b163
RA
172 if self.probe_available:
173 cmd = [
174 encodeFilename(self.probe_executable, True),
175 encodeArgument('-show_streams')]
176 else:
177 cmd = [
178 encodeFilename(self.executable, True),
179 encodeArgument('-i')]
180 cmd.append(encodeFilename(self._ffmpeg_filename_argument(path), True))
f446cc66 181 self.write_debug('%s command line: %s' % (self.basename, shell_quote(cmd)))
eb35b163
RA
182 handle = subprocess.Popen(
183 cmd, stderr=subprocess.PIPE,
184 stdout=subprocess.PIPE, stdin=subprocess.PIPE)
f5b1bca9 185 stdout_data, stderr_data = process_communicate_or_kill(handle)
eb35b163
RA
186 expected_ret = 0 if self.probe_available else 1
187 if handle.wait() != expected_ret:
30d9e209
RA
188 return None
189 except (IOError, OSError):
190 return None
eb35b163
RA
191 output = (stdout_data if self.probe_available else stderr_data).decode('ascii', 'ignore')
192 if self.probe_available:
193 audio_codec = None
194 for line in output.split('\n'):
195 if line.startswith('codec_name='):
196 audio_codec = line.split('=')[1].strip()
197 elif line.strip() == 'codec_type=audio' and audio_codec is not None:
198 return audio_codec
199 else:
200 # Stream #FILE_INDEX:STREAM_INDEX[STREAM_ID](LANGUAGE): CODEC_TYPE: CODEC_NAME
201 mobj = re.search(
202 r'Stream\s*#\d+:\d+(?:\[0x[0-9a-f]+\])?(?:\([a-z]{3}\))?:\s*Audio:\s*([0-9a-z]+)',
203 output)
204 if mobj:
205 return mobj.group(1)
30d9e209
RA
206 return None
207
06167fbb 208 def get_metadata_object(self, path, opts=[]):
209 if self.probe_basename != 'ffprobe':
210 if self.probe_available:
211 self.report_warning('Only ffprobe is supported for metadata extraction')
beb4b92a 212 raise PostProcessingError('ffprobe not found. Please install or provide the path using --ffmpeg-location')
06167fbb 213 self.check_version()
214
215 cmd = [
216 encodeFilename(self.probe_executable, True),
217 encodeArgument('-hide_banner'),
218 encodeArgument('-show_format'),
219 encodeArgument('-show_streams'),
220 encodeArgument('-print_format'),
221 encodeArgument('json'),
222 ]
223
224 cmd += opts
225 cmd.append(encodeFilename(self._ffmpeg_filename_argument(path), True))
06869367 226 self.write_debug('ffprobe command line: %s' % shell_quote(cmd))
06167fbb 227 p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
228 stdout, stderr = p.communicate()
229 return json.loads(stdout.decode('utf-8', 'replace'))
230
231 def get_stream_number(self, path, keys, value):
232 streams = self.get_metadata_object(path)['streams']
233 num = next(
324ad820 234 (i for i, stream in enumerate(streams) if traverse_obj(stream, keys, casesense=False) == value),
06167fbb 235 None)
236 return num, len(streams)
237
496c1923 238 def run_ffmpeg_multiple_files(self, input_paths, out_path, opts):
e92caff5 239 return self.real_run_ffmpeg(
240 [(path, []) for path in input_paths],
241 [(out_path, opts)])
242
243 def real_run_ffmpeg(self, input_path_opts, output_path_opts):
48844745 244 self.check_version()
496c1923 245
52afb2ac 246 oldest_mtime = min(
7dde84f3 247 os.stat(encodeFilename(path)).st_mtime for path, _ in input_path_opts if path)
43bc8890 248
ce52c7c1
S
249 cmd = [encodeFilename(self.executable, True), encodeArgument('-y')]
250 # avconv does not have repeat option
251 if self.basename == 'ffmpeg':
252 cmd += [encodeArgument('-loglevel'), encodeArgument('repeat+info')]
5b1ecbb3 253
e92caff5 254 def make_args(file, args, name, number):
255 keys = ['_%s%d' % (name, number), '_%s' % name]
256 if name == 'o' and number == 1:
257 keys.append('')
258 args += self._configuration_args(self.basename, keys)
259 if name == 'i':
260 args.append('-i')
5b1ecbb3 261 return (
e92caff5 262 [encodeArgument(arg) for arg in args]
5b1ecbb3 263 + [encodeFilename(self._ffmpeg_filename_argument(file), True)])
264
e92caff5 265 for arg_type, path_opts in (('i', input_path_opts), ('o', output_path_opts)):
7dde84f3 266 cmd += itertools.chain.from_iterable(
267 make_args(path, list(opts), arg_type, i + 1)
268 for i, (path, opts) in enumerate(path_opts) if path)
496c1923 269
f446cc66 270 self.write_debug('ffmpeg command line: %s' % shell_quote(cmd))
cffcbc02 271 p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
f5b1bca9 272 stdout, stderr = process_communicate_or_kill(p)
496c1923 273 if p.returncode != 0:
06167fbb 274 stderr = stderr.decode('utf-8', 'replace').strip()
06869367 275 if self.get_param('verbose', False):
06167fbb 276 self.report_error(stderr)
277 raise FFmpegPostProcessorError(stderr.split('\n')[-1])
e92caff5 278 for out_path, _ in output_path_opts:
7dde84f3 279 if out_path:
280 self.try_utime(out_path, oldest_mtime, oldest_mtime)
06167fbb 281 return stderr.decode('utf-8', 'replace')
cc55d088 282
496c1923 283 def run_ffmpeg(self, path, out_path, opts):
ece8a2a1 284 return self.run_ffmpeg_multiple_files([path], out_path, opts)
496c1923
PH
285
286 def _ffmpeg_filename_argument(self, fn):
8a7bbd16
JMF
287 # Always use 'file:' because the filename may contain ':' (ffmpeg
288 # interprets that as a protocol) or can start with '-' (-- is broken in
289 # ffmpeg, see https://ffmpeg.org/trac/ffmpeg/ticket/2127 for details)
b9f2fdd3 290 # Also leave '-' intact in order not to break streaming to stdout.
06167fbb 291 if fn.startswith(('http://', 'https://')):
292 return fn
d868f43c 293 return 'file:' + fn if fn != '-' else fn
496c1923
PH
294
295
296class FFmpegExtractAudioPP(FFmpegPostProcessor):
81a23040 297 COMMON_AUDIO_EXTS = ('wav', 'flac', 'm4a', 'aiff', 'mp3', 'ogg', 'mka', 'opus', 'wma')
298 SUPPORTED_EXTS = ('best', 'aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav')
1de75fa1 299
496c1923
PH
300 def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, nopostoverwrites=False):
301 FFmpegPostProcessor.__init__(self, downloader)
81a23040 302 self._preferredcodec = preferredcodec or 'best'
496c1923
PH
303 self._preferredquality = preferredquality
304 self._nopostoverwrites = nopostoverwrites
305
496c1923 306 def run_ffmpeg(self, path, out_path, codec, more_opts):
496c1923
PH
307 if codec is None:
308 acodec_opts = []
309 else:
310 acodec_opts = ['-acodec', codec]
311 opts = ['-vn'] + acodec_opts + more_opts
312 try:
313 FFmpegPostProcessor.run_ffmpeg(self, path, out_path, opts)
314 except FFmpegPostProcessorError as err:
315 raise AudioConversionError(err.msg)
316
8326b00a 317 @PostProcessor._restrict_to(images=False)
496c1923
PH
318 def run(self, information):
319 path = information['filepath']
1de75fa1 320 orig_ext = information['ext']
321
81a23040 322 if self._preferredcodec == 'best' and orig_ext in self.COMMON_AUDIO_EXTS:
1de75fa1 323 self.to_screen('Skipping audio extraction since the file is already in a common audio format')
55b53b33 324 return [], information
496c1923
PH
325
326 filecodec = self.get_audio_codec(path)
327 if filecodec is None:
3aa578ca 328 raise PostProcessingError('WARNING: unable to obtain file audio codec with ffprobe')
496c1923
PH
329
330 more_opts = []
331 if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'):
332 if filecodec == 'aac' and self._preferredcodec in ['m4a', 'best']:
333 # Lossless, but in another container
334 acodec = 'copy'
335 extension = 'm4a'
467d3c9a 336 more_opts = ['-bsf:a', 'aac_adtstoasc']
21bfcd3d 337 elif filecodec in ['aac', 'flac', 'mp3', 'vorbis', 'opus']:
496c1923
PH
338 # Lossless if possible
339 acodec = 'copy'
340 extension = filecodec
341 if filecodec == 'aac':
342 more_opts = ['-f', 'adts']
343 if filecodec == 'vorbis':
344 extension = 'ogg'
345 else:
346 # MP3 otherwise.
347 acodec = 'libmp3lame'
348 extension = 'mp3'
349 more_opts = []
350 if self._preferredquality is not None:
351 if int(self._preferredquality) < 10:
467d3c9a 352 more_opts += ['-q:a', self._preferredquality]
496c1923 353 else:
467d3c9a 354 more_opts += ['-b:a', self._preferredquality + 'k']
496c1923 355 else:
21bfcd3d
PH
356 # We convert the audio (lossy if codec is lossy)
357 acodec = ACODECS[self._preferredcodec]
496c1923
PH
358 extension = self._preferredcodec
359 more_opts = []
360 if self._preferredquality is not None:
361 # The opus codec doesn't support the -aq option
362 if int(self._preferredquality) < 10 and extension != 'opus':
467d3c9a 363 more_opts += ['-q:a', self._preferredquality]
496c1923 364 else:
467d3c9a 365 more_opts += ['-b:a', self._preferredquality + 'k']
496c1923
PH
366 if self._preferredcodec == 'aac':
367 more_opts += ['-f', 'adts']
368 if self._preferredcodec == 'm4a':
467d3c9a 369 more_opts += ['-bsf:a', 'aac_adtstoasc']
496c1923
PH
370 if self._preferredcodec == 'vorbis':
371 extension = 'ogg'
372 if self._preferredcodec == 'wav':
373 extension = 'wav'
374 more_opts += ['-f', 'wav']
375
3aa578ca 376 prefix, sep, ext = path.rpartition('.') # not os.path.splitext, since the latter does not work on unicode in all setups
496c1923 377 new_path = prefix + sep + extension
0b94dbb1 378
2273e2c5
PM
379 information['filepath'] = new_path
380 information['ext'] = extension
496c1923
PH
381
382 # If we download foo.mp3 and convert it to... foo.mp3, then don't delete foo.mp3, silly.
3089bc74
S
383 if (new_path == path
384 or (self._nopostoverwrites and os.path.exists(encodeFilename(new_path)))):
1b77b347 385 self.to_screen('Post-process file %s exists, skipping' % new_path)
592e97e8 386 return [], information
496c1923
PH
387
388 try:
1b77b347 389 self.to_screen('Destination: ' + new_path)
ce81b141 390 self.run_ffmpeg(path, new_path, acodec, more_opts)
70a1165b
JMF
391 except AudioConversionError as e:
392 raise PostProcessingError(
393 'audio conversion failed: ' + e.msg)
394 except Exception:
395 raise PostProcessingError('error running ' + self.basename)
496c1923
PH
396
397 # Try to update the date time for extracted audio file.
398 if information.get('filetime') is not None:
dd29eb7f
S
399 self.try_utime(
400 new_path, time.time(), information['filetime'],
401 errnote='Cannot update utime of audio file')
496c1923 402
592e97e8 403 return [path], information
496c1923
PH
404
405
857f6313 406class FFmpegVideoConvertorPP(FFmpegPostProcessor):
81a23040 407 SUPPORTED_EXTS = ('mp4', 'mkv', 'flv', 'webm', 'mov', 'avi', 'mp3', 'mka', 'm4a', 'ogg', 'opus')
408 FORMAT_RE = re.compile(r'{0}(?:/{0})*$'.format(r'(?:\w+>)?(?:%s)' % '|'.join(SUPPORTED_EXTS)))
857f6313 409 _action = 'converting'
410
efe87a10 411 def __init__(self, downloader=None, preferedformat=None):
857f6313 412 super(FFmpegVideoConvertorPP, self).__init__(downloader)
06167fbb 413 self._preferedformats = preferedformat.lower().split('/')
efe87a10 414
857f6313 415 def _target_ext(self, source_ext):
06167fbb 416 for pair in self._preferedformats:
417 kv = pair.split('>')
857f6313 418 if len(kv) == 1 or kv[0].strip() == source_ext:
419 return kv[-1].strip()
06167fbb 420
857f6313 421 @staticmethod
422 def _options(target_ext):
423 if target_ext == 'avi':
424 return ['-c:v', 'libxvid', '-vtag', 'XVID']
425 return []
426
8326b00a 427 @PostProcessor._restrict_to(images=False)
857f6313 428 def run(self, information):
81a23040 429 path, source_ext = information['filepath'], information['ext'].lower()
430 target_ext = self._target_ext(source_ext)
06167fbb 431 _skip_msg = (
857f6313 432 'could not find a mapping for %s' if not target_ext
433 else 'already is in target format %s' if source_ext == target_ext
06167fbb 434 else None)
435 if _skip_msg:
81a23040 436 self.to_screen('Not %s media file "%s"; %s' % (self._action, path, _skip_msg % source_ext))
efe87a10 437 return [], information
06167fbb 438
06167fbb 439 prefix, sep, oldext = path.rpartition('.')
857f6313 440 outpath = prefix + sep + target_ext
441 self.to_screen('%s video from %s to %s; Destination: %s' % (self._action.title(), source_ext, target_ext, outpath))
442 self.run_ffmpeg(path, outpath, self._options(target_ext))
443
efe87a10 444 information['filepath'] = outpath
857f6313 445 information['format'] = information['ext'] = target_ext
efe87a10
FS
446 return [path], information
447
448
857f6313 449class FFmpegVideoRemuxerPP(FFmpegVideoConvertorPP):
450 _action = 'remuxing'
496c1923 451
857f6313 452 @staticmethod
453 def _options(target_ext):
454 options = ['-c', 'copy', '-map', '0', '-dn']
455 if target_ext in ['mp4', 'm4a', 'mov']:
456 options.extend(['-movflags', '+faststart'])
457 return options
496c1923
PH
458
459
460class FFmpegEmbedSubtitlePP(FFmpegPostProcessor):
cffab0ee 461 def __init__(self, downloader=None, already_have_subtitle=False):
462 super(FFmpegEmbedSubtitlePP, self).__init__(downloader)
463 self._already_have_subtitle = already_have_subtitle
464
8326b00a 465 @PostProcessor._restrict_to(images=False)
496c1923 466 def run(self, information):
40025ee2 467 if information['ext'] not in ('mp4', 'webm', 'mkv'):
1b77b347 468 self.to_screen('Subtitles can only be embedded in mp4, webm or mkv files')
592e97e8 469 return [], information
c84dd8a9
JMF
470 subtitles = information.get('requested_subtitles')
471 if not subtitles:
1b77b347 472 self.to_screen('There aren\'t any subtitles to embed')
592e97e8 473 return [], information
496c1923 474
496c1923 475 filename = information['filepath']
40025ee2
S
476
477 ext = information['ext']
2412044c 478 sub_langs, sub_names, sub_filenames = [], [], []
40025ee2 479 webm_vtt_warn = False
06167fbb 480 mp4_ass_warn = False
40025ee2
S
481
482 for lang, sub_info in subtitles.items():
483 sub_ext = sub_info['ext']
503d4a44 484 if sub_ext == 'json':
06167fbb 485 self.report_warning('JSON subtitles cannot be embedded')
503d4a44 486 elif ext != 'webm' or ext == 'webm' and sub_ext == 'vtt':
40025ee2 487 sub_langs.append(lang)
2412044c 488 sub_names.append(sub_info.get('name'))
dcf64d43 489 sub_filenames.append(sub_info['filepath'])
40025ee2
S
490 else:
491 if not webm_vtt_warn and ext == 'webm' and sub_ext != 'vtt':
492 webm_vtt_warn = True
06167fbb 493 self.report_warning('Only WebVTT subtitles can be embedded in webm files')
494 if not mp4_ass_warn and ext == 'mp4' and sub_ext == 'ass':
495 mp4_ass_warn = True
496 self.report_warning('ASS subtitles cannot be properly embedded in mp4 files; expect issues')
40025ee2
S
497
498 if not sub_langs:
499 return [], information
500
14523ed9 501 input_files = [filename] + sub_filenames
496c1923 502
e205db3b 503 opts = [
e0da59fe 504 '-c', 'copy', '-map', '0', '-dn',
e205db3b
JMF
505 # Don't copy the existing subtitles, we may be running the
506 # postprocessor a second time
507 '-map', '-0:s',
7f903dd8
T
508 # Don't copy Apple TV chapters track, bin_data (see #19042, #19024,
509 # https://trac.ffmpeg.org/ticket/6016)
510 '-map', '-0:d',
e205db3b 511 ]
083c1bb9
N
512 if information['ext'] == 'mp4':
513 opts += ['-c:s', 'mov_text']
2412044c 514 for i, (lang, name) in enumerate(zip(sub_langs, sub_names)):
2875cf01 515 opts.extend(['-map', '%d:0' % (i + 1)])
04fb6928
S
516 lang_code = ISO639Utils.short2long(lang) or lang
517 opts.extend(['-metadata:s:s:%d' % i, 'language=%s' % lang_code])
2412044c 518 if name:
519 opts.extend(['-metadata:s:s:%d' % i, 'handler_name=%s' % name,
520 '-metadata:s:s:%d' % i, 'title=%s' % name])
496c1923 521
2875cf01 522 temp_filename = prepend_extension(filename, 'temp')
06167fbb 523 self.to_screen('Embedding subtitles in "%s"' % filename)
496c1923
PH
524 self.run_ffmpeg_multiple_files(input_files, temp_filename, opts)
525 os.remove(encodeFilename(filename))
526 os.rename(encodeFilename(temp_filename), encodeFilename(filename))
527
cffab0ee 528 files_to_delete = [] if self._already_have_subtitle else sub_filenames
529 return files_to_delete, information
496c1923
PH
530
531
532class FFmpegMetadataPP(FFmpegPostProcessor):
7dde84f3 533
534 @staticmethod
535 def _options(target_ext):
536 yield from ('-map', '0', '-dn')
537 if target_ext == 'm4a':
538 yield from ('-vn', '-acodec', 'copy')
539 else:
540 yield from ('-c', 'copy')
541
8326b00a 542 @PostProcessor._restrict_to(images=False)
496c1923
PH
543 def run(self, info):
544 metadata = {}
4bd143a3
S
545
546 def add(meta_list, info_list=None):
84601bb7 547 if not meta_list:
548 return
6606817a 549 for info_f in variadic(info_list or meta_list):
84601bb7 550 if isinstance(info.get(info_f), (compat_str, compat_numeric_types)):
6606817a 551 for meta_f in variadic(meta_list):
4bd143a3
S
552 metadata[meta_f] = info[info_f]
553 break
554
2791e80b
S
555 # See [1-4] for some info on media metadata/metadata supported
556 # by ffmpeg.
557 # 1. https://kdenlive.org/en/project/adding-meta-data-to-mp4-video/
558 # 2. https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
559 # 3. https://kodi.wiki/view/Video_file_tagging
2791e80b 560
4bd143a3
S
561 add('title', ('track', 'title'))
562 add('date', 'upload_date')
cd9b384c 563 add(('description', 'synopsis'), 'description')
564 add(('purl', 'comment'), 'webpage_url')
4bd143a3
S
565 add('track', 'track_number')
566 add('artist', ('artist', 'creator', 'uploader', 'uploader_id'))
567 add('genre')
568 add('album')
569 add('album_artist')
570 add('disc', 'disc_number')
2791e80b
S
571 add('show', 'series')
572 add('season_number')
573 add('episode_id', ('episode', 'episode_id'))
574 add('episode_sort', 'episode_number')
496c1923 575
84601bb7 576 prefix = 'meta_'
577 for key in filter(lambda k: k.startswith(prefix), info.keys()):
578 add(key[len(prefix):], key)
579
7dde84f3 580 filename, metadata_filename = info['filepath'], None
581 options = [('-metadata', f'{name}={value}') for name, value in metadata.items()]
39c68260 582
7dde84f3 583 stream_idx = 0
584 for fmt in info.get('requested_formats') or []:
585 stream_count = 2 if 'none' not in (fmt.get('vcodec'), fmt.get('acodec')) else 1
586 if fmt.get('language'):
587 lang = ISO639Utils.short2long(fmt['language']) or fmt['language']
588 options.extend(('-metadata:s:%d' % (stream_idx + i), 'language=%s' % lang)
589 for i in range(stream_count))
590 stream_idx += stream_count
496c1923 591
fa2a36d9 592 chapters = info.get('chapters', [])
593 if chapters:
5192ee17 594 metadata_filename = replace_extension(filename, 'meta')
fa2a36d9 595 with io.open(metadata_filename, 'wt', encoding='utf-8') as f:
596 def ffmpeg_escape(text):
597 return re.sub(r'(=|;|#|\\|\n)', r'\\\1', text)
598
599 metadata_file_content = ';FFMETADATA1\n'
600 for chapter in chapters:
601 metadata_file_content += '[CHAPTER]\nTIMEBASE=1/1000\n'
602 metadata_file_content += 'START=%d\n' % (chapter['start_time'] * 1000)
603 metadata_file_content += 'END=%d\n' % (chapter['end_time'] * 1000)
604 chapter_title = chapter.get('title')
605 if chapter_title:
606 metadata_file_content += 'title=%s\n' % ffmpeg_escape(chapter_title)
607 f.write(metadata_file_content)
7dde84f3 608 options.append(('-map_metadata', '1'))
fa2a36d9 609
41712218 610 if ('no-attach-info-json' not in self.get_param('compat_opts', [])
611 and '__infojson_filename' in info and info['ext'] in ('mkv', 'mka')):
7dde84f3 612 old_stream, new_stream = self.get_stream_number(filename, ('tags', 'mimetype'), 'application/json')
06167fbb 613 if old_stream is not None:
7dde84f3 614 options.append(('-map', '-0:%d' % old_stream))
06167fbb 615 new_stream -= 1
616
7dde84f3 617 options.append((
de6000d9 618 '-attach', info['__infojson_filename'],
06167fbb 619 '-metadata:s:%d' % new_stream, 'mimetype=application/json'
7dde84f3 620 ))
06167fbb 621
7dde84f3 622 if not options:
623 self.to_screen('There isn\'t any metadata to add')
624 return [], info
625
626 temp_filename = prepend_extension(filename, 'temp')
627 self.to_screen('Adding metadata to "%s"' % filename)
628 self.run_ffmpeg_multiple_files(
629 (filename, metadata_filename), temp_filename,
630 itertools.chain(self._options(info['ext']), *options))
fa2a36d9 631 if chapters:
632 os.remove(metadata_filename)
496c1923
PH
633 os.remove(encodeFilename(filename))
634 os.rename(encodeFilename(temp_filename), encodeFilename(filename))
592e97e8 635 return [], info
496c1923
PH
636
637
638class FFmpegMergerPP(FFmpegPostProcessor):
8326b00a 639 @PostProcessor._restrict_to(images=False)
496c1923
PH
640 def run(self, info):
641 filename = info['filepath']
5b5fbc08 642 temp_filename = prepend_extension(filename, 'temp')
d03cfdce 643 args = ['-c', 'copy']
644 for (i, fmt) in enumerate(info['requested_formats']):
645 if fmt.get('acodec') != 'none':
646 args.extend(['-map', '%u:a:0' % (i)])
647 if fmt.get('vcodec') != 'none':
648 args.extend(['-map', '%u:v:0' % (i)])
1b77b347 649 self.to_screen('Merging formats into "%s"' % filename)
5b5fbc08
JMF
650 self.run_ffmpeg_multiple_files(info['__files_to_merge'], temp_filename, args)
651 os.rename(encodeFilename(temp_filename), encodeFilename(filename))
d47aeb22 652 return info['__files_to_merge'], info
496c1923 653
13763ce5
S
654 def can_merge(self):
655 # TODO: figure out merge-capable ffmpeg version
656 if self.basename != 'avconv':
657 return True
658
659 required_version = '10-0'
660 if is_outdated_version(
661 self._versions[self.basename], required_version):
662 warning = ('Your copy of %s is outdated and unable to properly mux separate video and audio files, '
7a5c1cfe 663 'yt-dlp will download single file media. '
13763ce5
S
664 'Update %s to version %s or newer to fix this.') % (
665 self.basename, self.basename, required_version)
f446cc66 666 self.report_warning(warning)
13763ce5
S
667 return False
668 return True
669
0c14e2fb 670
fd7cfb64 671class FFmpegFixupPostProcessor(FFmpegPostProcessor):
672 def _fixup(self, msg, filename, options):
6271f1ca
PH
673 temp_filename = prepend_extension(filename, 'temp')
674
f89b3e2d 675 self.to_screen(f'{msg} of "{filename}"')
6271f1ca
PH
676 self.run_ffmpeg(filename, temp_filename, options)
677
678 os.remove(encodeFilename(filename))
679 os.rename(encodeFilename(temp_filename), encodeFilename(filename))
680
fd7cfb64 681
682class FFmpegFixupStretchedPP(FFmpegFixupPostProcessor):
683 @PostProcessor._restrict_to(images=False, audio=False)
684 def run(self, info):
685 stretched_ratio = info.get('stretched_ratio')
686 if stretched_ratio not in (None, 1):
687 self._fixup('Fixing aspect ratio', info['filepath'], [
688 '-c', 'copy', '-map', '0', '-dn', '-aspect', '%f' % stretched_ratio])
592e97e8 689 return [], info
62cd676c
PH
690
691
fd7cfb64 692class FFmpegFixupM4aPP(FFmpegFixupPostProcessor):
8326b00a 693 @PostProcessor._restrict_to(images=False, video=False)
62cd676c 694 def run(self, info):
fd7cfb64 695 if info.get('container') == 'm4a_dash':
696 self._fixup('Correcting container', info['filepath'], [
697 '-c', 'copy', '-map', '0', '-dn', '-f', 'mp4'])
592e97e8 698 return [], info
e9fade72
JMF
699
700
fd7cfb64 701class FFmpegFixupM3u8PP(FFmpegFixupPostProcessor):
8326b00a 702 @PostProcessor._restrict_to(images=False)
f17f8651 703 def run(self, info):
fd7cfb64 704 if self.get_audio_codec(info['filepath']) == 'aac':
705 self._fixup('Fixing malformed AAC bitstream', info['filepath'], [
706 '-c', 'copy', '-map', '0', '-dn', '-f', 'mp4', '-bsf:a', 'aac_adtstoasc'])
f17f8651 707 return [], info
708
709
e36d50c5 710class FFmpegFixupTimestampPP(FFmpegFixupPostProcessor):
711
712 def __init__(self, downloader=None, trim=0.001):
713 # "trim" should be used when the video contains unintended packets
714 super(FFmpegFixupTimestampPP, self).__init__(downloader)
715 assert isinstance(trim, (int, float))
716 self.trim = str(trim)
717
718 @PostProcessor._restrict_to(images=False)
719 def run(self, info):
720 required_version = '4.4'
721 if is_outdated_version(self._versions[self.basename], required_version):
722 self.report_warning(
723 'A re-encode is needed to fix timestamps in older versions of ffmpeg. '
724 f'Please install ffmpeg {required_version} or later to fixup without re-encoding')
725 opts = ['-vf', 'setpts=PTS-STARTPTS']
726 else:
727 opts = ['-c', 'copy', '-bsf', 'setts=ts=TS-STARTPTS']
728 self._fixup('Fixing frame timestamp', info['filepath'], opts + ['-map', '0', '-dn', '-ss', self.trim])
729 return [], info
730
731
732class FFmpegFixupDurationPP(FFmpegFixupPostProcessor):
733 @PostProcessor._restrict_to(images=False)
734 def run(self, info):
735 self._fixup('Fixing video duration', info['filepath'], ['-c', 'copy', '-map', '0', '-dn'])
736 return [], info
737
738
e9fade72 739class FFmpegSubtitlesConvertorPP(FFmpegPostProcessor):
81a23040 740 SUPPORTED_EXTS = ('srt', 'vtt', 'ass', 'lrc')
741
e9fade72
JMF
742 def __init__(self, downloader=None, format=None):
743 super(FFmpegSubtitlesConvertorPP, self).__init__(downloader)
744 self.format = format
745
746 def run(self, info):
747 subs = info.get('requested_subtitles')
e9fade72
JMF
748 new_ext = self.format
749 new_format = new_ext
750 if new_format == 'vtt':
751 new_format = 'webvtt'
752 if subs is None:
1b77b347 753 self.to_screen('There aren\'t any subtitles to convert')
592e97e8 754 return [], info
1b77b347 755 self.to_screen('Converting subtitles')
e04398e3 756 sub_filenames = []
e9fade72
JMF
757 for lang, sub in subs.items():
758 ext = sub['ext']
759 if ext == new_ext:
1b77b347 760 self.to_screen('Subtitle file for %s is already in the requested format' % new_ext)
e9fade72 761 continue
503d4a44 762 elif ext == 'json':
1b77b347 763 self.to_screen(
764 'You have requested to convert json subtitles into another format, '
503d4a44 765 'which is currently not possible')
766 continue
dcf64d43 767 old_file = sub['filepath']
e04398e3 768 sub_filenames.append(old_file)
dcf64d43 769 new_file = replace_extension(old_file, new_ext)
bf6427d2 770
40fcba5e 771 if ext in ('dfxp', 'ttml', 'tt'):
f446cc66 772 self.report_warning(
1b77b347 773 'You have requested to convert dfxp (TTML) subtitles into another format, '
bf6427d2
YCH
774 'which results in style information loss')
775
e04398e3 776 dfxp_file = old_file
dcf64d43 777 srt_file = replace_extension(old_file, 'srt')
bf6427d2 778
3869028f 779 with open(dfxp_file, 'rb') as f:
bf6427d2
YCH
780 srt_data = dfxp2srt(f.read())
781
782 with io.open(srt_file, 'wt', encoding='utf-8') as f:
783 f.write(srt_data)
7e62c2eb 784 old_file = srt_file
bf6427d2 785
bf6427d2
YCH
786 subs[lang] = {
787 'ext': 'srt',
dcf64d43 788 'data': srt_data,
789 'filepath': srt_file,
bf6427d2
YCH
790 }
791
792 if new_ext == 'srt':
793 continue
7b8b007c
JMF
794 else:
795 sub_filenames.append(srt_file)
bf6427d2 796
e04398e3 797 self.run_ffmpeg(old_file, new_file, ['-f', new_format])
e9fade72
JMF
798
799 with io.open(new_file, 'rt', encoding='utf-8') as f:
800 subs[lang] = {
3547d265 801 'ext': new_ext,
e9fade72 802 'data': f.read(),
dcf64d43 803 'filepath': new_file,
e9fade72
JMF
804 }
805
dcf64d43 806 info['__files_to_move'][new_file] = replace_extension(
807 info['__files_to_move'][old_file], new_ext)
808
e04398e3 809 return sub_filenames, info
72755351 810
811
812class FFmpegSplitChaptersPP(FFmpegPostProcessor):
813
814 def _prepare_filename(self, number, chapter, info):
815 info = info.copy()
816 info.update({
817 'section_number': number,
818 'section_title': chapter.get('title'),
819 'section_start': chapter.get('start_time'),
820 'section_end': chapter.get('end_time'),
821 })
822 return self._downloader.prepare_filename(info, 'chapter')
823
824 def _ffmpeg_args_for_chapter(self, number, chapter, info):
825 destination = self._prepare_filename(number, chapter, info)
826 if not self._downloader._ensure_dir_exists(encodeFilename(destination)):
827 return
828
dcf64d43 829 chapter['filepath'] = destination
72755351 830 self.to_screen('Chapter %03d; Destination: %s' % (number, destination))
831 return (
832 destination,
833 ['-ss', compat_str(chapter['start_time']),
a94bfd6c 834 '-t', compat_str(chapter['end_time'] - chapter['start_time'])])
72755351 835
8326b00a 836 @PostProcessor._restrict_to(images=False)
72755351 837 def run(self, info):
838 chapters = info.get('chapters') or []
839 if not chapters:
beb4b92a 840 self.report_warning('Chapter information is unavailable')
72755351 841 return [], info
842
843 self.to_screen('Splitting video by chapters; %d chapters found' % len(chapters))
844 for idx, chapter in enumerate(chapters):
845 destination, opts = self._ffmpeg_args_for_chapter(idx + 1, chapter, info)
846 self.real_run_ffmpeg([(info['filepath'], opts)], [(destination, ['-c', 'copy'])])
847 return [], info
8fa43c73 848
849
850class FFmpegThumbnailsConvertorPP(FFmpegPostProcessor):
81a23040 851 SUPPORTED_EXTS = ('jpg', 'png')
852
8fa43c73 853 def __init__(self, downloader=None, format=None):
854 super(FFmpegThumbnailsConvertorPP, self).__init__(downloader)
855 self.format = format
856
857 @staticmethod
858 def is_webp(path):
859 with open(encodeFilename(path), 'rb') as f:
860 b = f.read(12)
861 return b[0:4] == b'RIFF' and b[8:] == b'WEBP'
862
863 def fixup_webp(self, info, idx=-1):
864 thumbnail_filename = info['thumbnails'][idx]['filepath']
865 _, thumbnail_ext = os.path.splitext(thumbnail_filename)
866 if thumbnail_ext:
867 thumbnail_ext = thumbnail_ext[1:].lower()
868 if thumbnail_ext != 'webp' and self.is_webp(thumbnail_filename):
869 self.to_screen('Correcting thumbnail "%s" extension to webp' % thumbnail_filename)
870 webp_filename = replace_extension(thumbnail_filename, 'webp')
871 if os.path.exists(webp_filename):
872 os.remove(webp_filename)
873 os.rename(encodeFilename(thumbnail_filename), encodeFilename(webp_filename))
874 info['thumbnails'][idx]['filepath'] = webp_filename
875 info['__files_to_move'][webp_filename] = replace_extension(
876 info['__files_to_move'].pop(thumbnail_filename), 'webp')
877
81a23040 878 @staticmethod
879 def _options(target_ext):
880 if target_ext == 'jpg':
881 return ['-bsf:v', 'mjpeg2jpeg']
882 return []
883
884 def convert_thumbnail(self, thumbnail_filename, target_ext):
81a23040 885 thumbnail_conv_filename = replace_extension(thumbnail_filename, target_ext)
337e0c62 886
887 self.to_screen('Converting thumbnail "%s" to %s' % (thumbnail_filename, target_ext))
888 self.real_run_ffmpeg(
889 [(thumbnail_filename, ['-f', 'image2', '-pattern_type', 'none'])],
890 [(thumbnail_conv_filename.replace('%', '%%'), self._options(target_ext))])
a927acb1 891 return thumbnail_conv_filename
8fa43c73 892
893 def run(self, info):
8fa43c73 894 files_to_delete = []
895 has_thumbnail = False
896
897 for idx, thumbnail_dict in enumerate(info['thumbnails']):
898 if 'filepath' not in thumbnail_dict:
899 continue
900 has_thumbnail = True
901 self.fixup_webp(info, idx)
902 original_thumbnail = thumbnail_dict['filepath']
903 _, thumbnail_ext = os.path.splitext(original_thumbnail)
904 if thumbnail_ext:
905 thumbnail_ext = thumbnail_ext[1:].lower()
15a4fd53 906 if thumbnail_ext == 'jpeg':
907 thumbnail_ext = 'jpg'
8fa43c73 908 if thumbnail_ext == self.format:
909 self.to_screen('Thumbnail "%s" is already in the requested format' % original_thumbnail)
910 continue
911 thumbnail_dict['filepath'] = self.convert_thumbnail(original_thumbnail, self.format)
912 files_to_delete.append(original_thumbnail)
913 info['__files_to_move'][thumbnail_dict['filepath']] = replace_extension(
914 info['__files_to_move'][original_thumbnail], self.format)
915
916 if not has_thumbnail:
917 self.to_screen('There aren\'t any thumbnails to convert')
918 return files_to_delete, info