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