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