]> jfr.im git - yt-dlp.git/blobdiff - yt_dlp/postprocessor/ffmpeg.py
[ie/mlbtv] Fix extraction (#10296)
[yt-dlp.git] / yt_dlp / postprocessor / ffmpeg.py
index f663cc28e2d1f88cfc706135f6c7701075f3c4e8..1ed37af5182042a718ef50823298164ba68effd4 100644 (file)
@@ -15,6 +15,7 @@
     Popen,
     PostProcessingError,
     _get_exe_version_output,
+    deprecation_warning,
     detect_exe_version,
     determine_ext,
     dfxp2srt,
@@ -30,7 +31,6 @@
     traverse_obj,
     variadic,
     write_json_file,
-    write_string,
 )
 
 EXT_TO_OUT_FORMATS = {
@@ -44,6 +44,7 @@
     'ts': 'mpegts',
     'wma': 'asf',
     'wmv': 'asf',
+    'weba': 'webm',
     'vtt': 'webvtt',
 }
 ACODECS = {
@@ -60,7 +61,7 @@
 
 
 def create_mapping_re(supported):
-    return re.compile(r'{0}(?:/{0})*$'.format(r'(?:\s*\w+\s*>)?\s*(?:%s)\s*' % '|'.join(supported)))
+    return re.compile(r'{0}(?:/{0})*$'.format(r'(?:\s*\w+\s*>)?\s*(?:{})\s*'.format('|'.join(supported))))
 
 
 def resolve_mapping(source, mapping):
@@ -113,15 +114,20 @@ def _determine_executables(self):
                 f'ffmpeg-location {location} does not exist! Continuing without ffmpeg', only_once=True)
             return {}
         elif os.path.isdir(location):
-            dirname, basename = location, None
+            dirname, basename, filename = location, None, None
         else:
-            basename = os.path.splitext(os.path.basename(location))[0]
-            basename = next((p for p in programs if basename.startswith(p)), 'ffmpeg')
+            filename = os.path.basename(location)
+            basename = next((p for p in programs if p in filename), 'ffmpeg')
             dirname = os.path.dirname(os.path.abspath(location))
-            if basename in self._ffmpeg_to_avconv.keys():
+            if basename in self._ffmpeg_to_avconv:
                 self._prefer_ffmpeg = True
 
         paths = {p: os.path.join(dirname, p) for p in programs}
+        if basename and basename in filename:
+            for p in programs:
+                path = os.path.join(dirname, filename.replace(basename, p))
+                if os.path.exists(path):
+                    paths[p] = path
         if basename:
             paths[basename] = location
         return paths
@@ -132,7 +138,7 @@ def _get_ffmpeg_version(self, prog):
         path = self._paths.get(prog)
         if path in self._version_cache:
             return self._version_cache[path], self._features_cache.get(path, {})
-        out = _get_exe_version_output(path, ['-bsfs'], to_screen=self.write_debug)
+        out = _get_exe_version_output(path, ['-bsfs'])
         ver = detect_exe_version(out) if out else False
         if ver:
             regexs = [
@@ -163,12 +169,12 @@ def _versions(self):
 
     @functools.cached_property
     def basename(self):
-        self._version  # run property
+        _ = self._version  # run property
         return self.basename
 
     @functools.cached_property
     def probe_basename(self):
-        self._probe_version  # run property
+        _ = self._probe_version  # run property
         return self.probe_basename
 
     def _get_version(self, kind):
@@ -182,8 +188,8 @@ def _get_version(self, kind):
         else:
             self.probe_basename = basename
         if basename == self._ffmpeg_to_avconv[kind]:
-            self.deprecation_warning(
-                f'Support for {self._ffmpeg_to_avconv[kind]} is deprecated and may be removed in a future version. Use {kind} instead')
+            self.deprecated_feature(f'Support for {self._ffmpeg_to_avconv[kind]} is deprecated and '
+                                    f'may be removed in a future version. Use {kind} instead')
         return version
 
     @functools.cached_property
@@ -296,6 +302,11 @@ def get_stream_number(self, path, keys, value):
             None)
         return num, len(streams)
 
+    def _fixup_chapters(self, info):
+        last_chapter = traverse_obj(info, ('chapters', -1))
+        if last_chapter and not last_chapter.get('end_time'):
+            last_chapter['end_time'] = self._get_real_video_duration(info['filepath'])
+
     def _get_real_video_duration(self, filepath, fatal=True):
         try:
             duration = float_or_none(
@@ -331,7 +342,7 @@ def real_run_ffmpeg(self, input_path_opts, output_path_opts, *, expected_retcode
             cmd += [encodeArgument('-loglevel'), encodeArgument('repeat+info')]
 
         def make_args(file, args, name, number):
-            keys = ['_%s%d' % (name, number), '_%s' % name]
+            keys = [f'_{name}{number}', f'_{name}']
             if name == 'o':
                 args += ['-movflags', '+faststart']
                 if number == 1:
@@ -348,7 +359,7 @@ def make_args(file, args, name, number):
                 make_args(path, list(opts), arg_type, i + 1)
                 for i, (path, opts) in enumerate(path_opts) if path)
 
-        self.write_debug('ffmpeg command line: %s' % shell_quote(cmd))
+        self.write_debug(f'ffmpeg command line: {shell_quote(cmd)}')
         _, stderr, returncode = Popen.run(
             cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
         if returncode not in variadic(expected_retcodes):
@@ -402,7 +413,7 @@ def concat_files(self, in_files, out_file, concat_opts=None):
         """
         concat_file = f'{out_file}.concat'
         self.write_debug(f'Writing concat spec to {concat_file}')
-        with open(concat_file, 'wt', encoding='utf-8') as f:
+        with open(concat_file, 'w', encoding='utf-8') as f:
             f.writelines(self._concat_spec(in_files, concat_opts))
 
         out_flags = list(self.stream_copy_opts(ext=determine_ext(out_file)))
@@ -426,7 +437,7 @@ def _concat_spec(cls, in_files, concat_opts=None):
 
 
 class FFmpegExtractAudioPP(FFmpegPostProcessor):
-    COMMON_AUDIO_EXTS = MEDIA_EXTENSIONS.common_audio + ('wma', )
+    COMMON_AUDIO_EXTS = (*MEDIA_EXTENSIONS.common_audio, 'wma')
     SUPPORTED_EXTS = tuple(ACODECS.keys())
     FORMAT_RE = create_mapping_re(('best', *SUPPORTED_EXTS))
 
@@ -463,7 +474,7 @@ def run_ffmpeg(self, path, out_path, codec, more_opts):
             acodec_opts = []
         else:
             acodec_opts = ['-acodec', codec]
-        opts = ['-vn'] + acodec_opts + more_opts
+        opts = ['-vn', *acodec_opts, *more_opts]
         try:
             FFmpegPostProcessor.run_ffmpeg(self, path, out_path, opts)
         except FFmpegPostProcessorError as err:
@@ -502,8 +513,7 @@ def run(self, information):
         if acodec != 'copy':
             more_opts = self._quality_args(acodec)
 
-        # not os.path.splitext, since the latter does not work on unicode in all setups
-        temp_path = new_path = f'{path.rpartition(".")[0]}.{extension}'
+        temp_path = new_path = replace_extension(path, extension, information['ext'])
 
         if new_path == path:
             if acodec == 'copy':
@@ -513,7 +523,7 @@ def run(self, information):
             temp_path = prepend_extension(path, 'temp')
         if (self._nopostoverwrites and os.path.exists(encodeFilename(new_path))
                 and os.path.exists(encodeFilename(orig_path))):
-            self.to_screen('Post-process file %s exists, skipping' % new_path)
+            self.to_screen(f'Post-process file {new_path} exists, skipping')
             return [], information
 
         self.to_screen(f'Destination: {new_path}')
@@ -533,7 +543,10 @@ def run(self, information):
 
 
 class FFmpegVideoConvertorPP(FFmpegPostProcessor):
-    SUPPORTED_EXTS = (*MEDIA_EXTENSIONS.common_video, *sorted(MEDIA_EXTENSIONS.common_audio + ('aac', 'vorbis')))
+    SUPPORTED_EXTS = (
+        *sorted((*MEDIA_EXTENSIONS.common_video, 'gif')),
+        *sorted((*MEDIA_EXTENSIONS.common_audio, 'aac', 'vorbis')),
+    )
     FORMAT_RE = create_mapping_re(SUPPORTED_EXTS)
     _ACTION = 'converting'
 
@@ -628,7 +641,7 @@ def run(self, info):
         if not sub_langs:
             return [], info
 
-        input_files = [filename] + sub_filenames
+        input_files = [filename, *sub_filenames]
 
         opts = [
             *self.stream_copy_opts(ext=info['ext']),
@@ -637,15 +650,15 @@ def run(self, info):
             '-map', '-0:s',
         ]
         for i, (lang, name) in enumerate(zip(sub_langs, sub_names)):
-            opts.extend(['-map', '%d:0' % (i + 1)])
+            opts.extend(['-map', f'{i + 1}:0'])
             lang_code = ISO639Utils.short2long(lang) or lang
-            opts.extend(['-metadata:s:s:%d' % i, 'language=%s' % lang_code])
+            opts.extend([f'-metadata:s:s:{i}', f'language={lang_code}'])
             if name:
-                opts.extend(['-metadata:s:s:%d' % i, 'handler_name=%s' % name,
-                             '-metadata:s:s:%d' % i, 'title=%s' % name])
+                opts.extend([f'-metadata:s:s:{i}', f'handler_name={name}',
+                             f'-metadata:s:s:{i}', f'title={name}'])
 
         temp_filename = prepend_extension(filename, 'temp')
-        self.to_screen('Embedding subtitles in "%s"' % filename)
+        self.to_screen(f'Embedding subtitles in "{filename}"')
         self.run_ffmpeg_multiple_files(input_files, temp_filename, opts)
         os.replace(temp_filename, filename)
 
@@ -670,6 +683,7 @@ def _options(target_ext):
 
     @PostProcessor._restrict_to(images=False)
     def run(self, info):
+        self._fixup_chapters(info)
         filename, metadata_filename = info['filepath'], None
         files_to_delete, options = [], []
         if self._add_chapters and info.get('chapters'):
@@ -693,7 +707,7 @@ def run(self, info):
             return [], info
 
         temp_filename = prepend_extension(filename, 'temp')
-        self.to_screen('Adding metadata to "%s"' % filename)
+        self.to_screen(f'Adding metadata to "{filename}"')
         self.run_ffmpeg_multiple_files(
             (filename, metadata_filename), temp_filename,
             itertools.chain(self._options(info['ext']), *options))
@@ -703,7 +717,7 @@ def run(self, info):
 
     @staticmethod
     def _get_chapter_opts(chapters, metadata_filename):
-        with open(metadata_filename, 'wt', encoding='utf-8') as f:
+        with open(metadata_filename, 'w', encoding='utf-8') as f:
             def ffmpeg_escape(text):
                 return re.sub(r'([\\=;#\n])', r'\\\1', text)
 
@@ -714,7 +728,7 @@ def ffmpeg_escape(text):
                 metadata_file_content += 'END=%d\n' % (chapter['end_time'] * 1000)
                 chapter_title = chapter.get('title')
                 if chapter_title:
-                    metadata_file_content += 'title=%s\n' % ffmpeg_escape(chapter_title)
+                    metadata_file_content += f'title={ffmpeg_escape(chapter_title)}\n'
             f.write(metadata_file_content)
         yield ('-map_metadata', '1')
 
@@ -724,9 +738,10 @@ def _get_metadata_opts(self, info):
 
         def add(meta_list, info_list=None):
             value = next((
-                str(info[key]) for key in [f'{meta_prefix}_'] + list(variadic(info_list or meta_list))
+                info[key] for key in [f'{meta_prefix}_', *variadic(info_list or meta_list)]
                 if info.get(key) is not None), None)
             if value not in ('', None):
+                value = ', '.join(map(str, variadic(value)))
                 value = value.replace('\0', '')  # nul character cannot be passed in command line
                 metadata['common'].update({meta_f: value for meta_f in variadic(meta_list)})
 
@@ -740,10 +755,11 @@ def add(meta_list, info_list=None):
         add(('description', 'synopsis'), 'description')
         add(('purl', 'comment'), 'webpage_url')
         add('track', 'track_number')
-        add('artist', ('artist', 'creator', 'uploader', 'uploader_id'))
-        add('genre')
+        add('artist', ('artist', 'artists', 'creator', 'creators', 'uploader', 'uploader_id'))
+        add('composer', ('composer', 'composers'))
+        add('genre', ('genre', 'genres'))
         add('album')
-        add('album_artist')
+        add('album_artist', ('album_artist', 'album_artists'))
         add('disc', 'disc_number')
         add('show', 'series')
         add('season_number')
@@ -766,7 +782,7 @@ def add(meta_list, info_list=None):
             yield ('-metadata', f'{name}={value}')
 
         stream_idx = 0
-        for fmt in info.get('requested_formats') or []:
+        for fmt in info.get('requested_formats') or [info]:
             stream_count = 2 if 'none' not in (fmt.get('vcodec'), fmt.get('acodec')) else 1
             lang = ISO639Utils.short2long(fmt.get('language') or '') or fmt.get('language')
             for i in range(stream_idx, stream_idx + stream_count):
@@ -791,11 +807,11 @@ def _get_infojson_opts(self, info, infofn):
 
         old_stream, new_stream = self.get_stream_number(info['filepath'], ('tags', 'mimetype'), 'application/json')
         if old_stream is not None:
-            yield ('-map', '-0:%d' % old_stream)
+            yield ('-map', f'-0:{old_stream}')
             new_stream -= 1
 
         yield (
-            '-attach', infofn,
+            '-attach', self._ffmpeg_filename_argument(infofn),
             f'-metadata:s:{new_stream}', 'mimetype=application/json',
             f'-metadata:s:{new_stream}', 'filename=info.json',
         )
@@ -818,8 +834,8 @@ def run(self, info):
                     args.extend([f'-bsf:a:{audio_streams}', 'aac_adtstoasc'])
                 audio_streams += 1
             if fmt.get('vcodec') != 'none':
-                args.extend(['-map', '%u:v:0' % (i)])
-        self.to_screen('Merging formats into "%s"' % filename)
+                args.extend(['-map', f'{i}:v:0'])
+        self.to_screen(f'Merging formats into "{filename}"')
         self.run_ffmpeg_multiple_files(info['__files_to_merge'], temp_filename, args)
         os.rename(encodeFilename(temp_filename), encodeFilename(filename))
         return info['__files_to_merge'], info
@@ -832,10 +848,9 @@ def can_merge(self):
         required_version = '10-0'
         if is_outdated_version(
                 self._versions[self.basename], required_version):
-            warning = ('Your copy of %s is outdated and unable to properly mux separate video and audio files, '
+            warning = (f'Your copy of {self.basename} is outdated and unable to properly mux separate video and audio files, '
                        'yt-dlp will download single file media. '
-                       'Update %s to version %s or newer to fix this.') % (
-                           self.basename, self.basename, required_version)
+                       f'Update {self.basename} to version {required_version} or newer to fix this.')
             self.report_warning(warning)
             return False
         return True
@@ -857,7 +872,7 @@ def run(self, info):
         stretched_ratio = info.get('stretched_ratio')
         if stretched_ratio not in (None, 1):
             self._fixup('Fixing aspect ratio', info['filepath'], [
-                *self.stream_copy_opts(), '-aspect', '%f' % stretched_ratio])
+                *self.stream_copy_opts(), '-aspect', f'{stretched_ratio:f}'])
         return [], info
 
 
@@ -884,8 +899,11 @@ def _needs_fixup(self, info):
     @PostProcessor._restrict_to(images=False)
     def run(self, info):
         if all(self._needs_fixup(info)):
+            args = ['-f', 'mp4']
+            if self.get_audio_codec(info['filepath']) == 'aac':
+                args.extend(['-bsf:a', 'aac_adtstoasc'])
             self._fixup('Fixing MPEG-TS in MP4 container', info['filepath'], [
-                *self.stream_copy_opts(), '-f', 'mp4', '-bsf:a', 'aac_adtstoasc'])
+                *self.stream_copy_opts(), *args])
         return [], info
 
 
@@ -906,7 +924,7 @@ def run(self, info):
             opts = ['-vf', 'setpts=PTS-STARTPTS']
         else:
             opts = ['-c', 'copy', '-bsf', 'setts=ts=TS-STARTPTS']
-        self._fixup('Fixing frame timestamp', info['filepath'], opts + [*self.stream_copy_opts(False), '-ss', self.trim])
+        self._fixup('Fixing frame timestamp', info['filepath'], [*opts, *self.stream_copy_opts(False), '-ss', self.trim])
         return [], info
 
 
@@ -951,7 +969,7 @@ def run(self, info):
                 continue
             ext = sub['ext']
             if ext == new_ext:
-                self.to_screen('Subtitle file for %s is already in the requested format' % new_ext)
+                self.to_screen(f'Subtitle file for {new_ext} is already in the requested format')
                 continue
             elif ext == 'json':
                 self.to_screen(
@@ -973,7 +991,7 @@ def run(self, info):
                 with open(dfxp_file, 'rb') as f:
                     srt_data = dfxp2srt(f.read())
 
-                with open(srt_file, 'wt', encoding='utf-8') as f:
+                with open(srt_file, 'w', encoding='utf-8') as f:
                     f.write(srt_data)
                 old_file = srt_file
 
@@ -1032,6 +1050,7 @@ def _ffmpeg_args_for_chapter(self, number, chapter, info):
 
     @PostProcessor._restrict_to(images=False)
     def run(self, info):
+        self._fixup_chapters(info)
         chapters = info.get('chapters') or []
         if not chapters:
             self.to_screen('Chapter information is unavailable')
@@ -1040,7 +1059,7 @@ def run(self, info):
         in_file = info['filepath']
         if self._force_keyframes and len(chapters) > 1:
             in_file = self.force_keyframes(in_file, (c['start_time'] for c in chapters))
-        self.to_screen('Splitting video by chapters; %d chapters found' % len(chapters))
+        self.to_screen(f'Splitting video by chapters; {len(chapters)} chapters found')
         for idx, chapter in enumerate(chapters):
             destination, opts = self._ffmpeg_args_for_chapter(idx + 1, chapter, info)
             self.real_run_ffmpeg([(in_file, opts)], [(destination, self.stream_copy_opts())])
@@ -1059,7 +1078,7 @@ def __init__(self, downloader=None, format=None):
 
     @classmethod
     def is_webp(cls, path):
-        write_string(f'DeprecationWarning: {cls.__module__}.{cls.__name__}.is_webp is deprecated')
+        deprecation_warning(f'{cls.__module__}.{cls.__name__}.is_webp is deprecated')
         return imghdr.what(path) == 'webp'
 
     def fixup_webp(self, info, idx=-1):
@@ -1067,7 +1086,7 @@ def fixup_webp(self, info, idx=-1):
         _, thumbnail_ext = os.path.splitext(thumbnail_filename)
         if thumbnail_ext:
             if thumbnail_ext.lower() != '.webp' and imghdr.what(thumbnail_filename) == 'webp':
-                self.to_screen('Correcting thumbnail "%s" extension to webp' % thumbnail_filename)
+                self.to_screen(f'Correcting thumbnail "{thumbnail_filename}" extension to webp')
                 webp_filename = replace_extension(thumbnail_filename, 'webp')
                 os.replace(thumbnail_filename, webp_filename)
                 info['thumbnails'][idx]['filepath'] = webp_filename
@@ -1076,9 +1095,9 @@ def fixup_webp(self, info, idx=-1):
 
     @staticmethod
     def _options(target_ext):
+        yield from ('-update', '1')
         if target_ext == 'jpg':
-            return ['-bsf:v', 'mjpeg2jpeg']
-        return []
+            yield from ('-bsf:v', 'mjpeg2jpeg')
 
     def convert_thumbnail(self, thumbnail_filename, target_ext):
         thumbnail_conv_filename = replace_extension(thumbnail_filename, target_ext)
@@ -1087,7 +1106,7 @@ def convert_thumbnail(self, thumbnail_filename, target_ext):
         _, source_ext = os.path.splitext(thumbnail_filename)
         self.real_run_ffmpeg(
             [(thumbnail_filename, [] if source_ext == '.gif' else ['-f', 'image2', '-pattern_type', 'none'])],
-            [(thumbnail_conv_filename.replace('%', '%%'), self._options(target_ext))])
+            [(thumbnail_conv_filename, self._options(target_ext))])
         return thumbnail_conv_filename
 
     def run(self, info):
@@ -1100,6 +1119,7 @@ def run(self, info):
                 continue
             has_thumbnail = True
             self.fixup_webp(info, idx)
+            original_thumbnail = thumbnail_dict['filepath']  # Path can change during fixup
             thumbnail_ext = os.path.splitext(original_thumbnail)[1][1:].lower()
             if thumbnail_ext == 'jpeg':
                 thumbnail_ext = 'jpg'