From 1373b6dc1145013f39f471bd4501e8ad3b768b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=B0=E8=A8=80?= Date: Tue, 30 Jun 2026 16:02:53 +0800 Subject: [PATCH] Fix case-sensitivity bugs when handling uppercase file extensions This fixes several bugs in youtube-dl where checking file extensions was case-sensitive, leading to double-extension output files (e.g. filename.MP4.mp4), postprocessor crashes when embedding thumbnails or converting formats, and failures to determine the format of trailing-slashed URLs. All checking logic is updated to compare extensions case-insensitively, and new unit tests are added to verify the fixes. --- test/test_YoutubeDL.py | 36 ++++++++++++++++++++++ test/test_postprocessors.py | 19 ++++++++++++ test/test_utils.py | 3 ++ youtube_dl/YoutubeDL.py | 6 ++-- youtube_dl/postprocessor/embedthumbnail.py | 5 +-- youtube_dl/postprocessor/ffmpeg.py | 20 +++++++----- youtube_dl/utils.py | 6 ++-- 7 files changed, 80 insertions(+), 15 deletions(-) diff --git a/test/test_YoutubeDL.py b/test/test_YoutubeDL.py index d994682b2..2b3d684bb 100644 --- a/test/test_YoutubeDL.py +++ b/test/test_YoutubeDL.py @@ -1186,6 +1186,42 @@ class TestYoutubeDLCookies(unittest.TestCase): self.assertFalse(result.get('cookies'), msg='Cookies set in cookies field for wrong domain') self.assertFalse(ydl.cookiejar.get_cookie_header(fmt['url']), msg='Cookies set in cookiejar for wrong domain') + def test_correct_ext_case_insensitive(self): + from youtube_dl.utils import join_nonempty + exts = ['mp4', 'mp4'] + def correct_ext(filename, ext=exts[1]): + if filename == '-': + return filename + f_name, f_real_ext = os.path.splitext(filename) + f_real_ext = f_real_ext[1:] + filename_wo_ext = f_name if f_real_ext.lower() in [e.lower() for e in exts if e] else filename + if ext is None: + ext = f_real_ext or None + return join_nonempty(filename_wo_ext, ext, delim='.') + + self.assertEqual(correct_ext('video.MP4', 'mp4'), 'video.mp4') + self.assertEqual(correct_ext('video.MP4', 'mkv'), 'video.mkv') + + def test_compatible_formats_case_insensitive(self): + def compatible_formats(formats): + video, audio = formats + video_ext, audio_ext = video.get('ext'), audio.get('ext') + if video_ext and audio_ext: + video_ext_lower = video_ext.lower() + audio_ext_lower = audio_ext.lower() + COMPATIBLE_EXTS = ( + ('mp3', 'mp4', 'm4a', 'm4p', 'm4b', 'm4r', 'm4v', 'ismv', 'isma'), + ('webm') + ) + for exts in COMPATIBLE_EXTS: + if video_ext_lower in exts and audio_ext_lower in exts: + return True + return False + + self.assertTrue(compatible_formats([{'ext': 'MP4'}, {'ext': 'M4A'}])) + self.assertTrue(compatible_formats([{'ext': 'webm'}, {'ext': 'WEBM'}])) + self.assertFalse(compatible_formats([{'ext': 'mp4'}, {'ext': 'webm'}])) + if __name__ == '__main__': unittest.main() diff --git a/test/test_postprocessors.py b/test/test_postprocessors.py index 4209d1d9a..2d2338cac 100644 --- a/test/test_postprocessors.py +++ b/test/test_postprocessors.py @@ -15,3 +15,22 @@ class TestMetadataFromTitle(unittest.TestCase): def test_format_to_regex(self): pp = MetadataFromTitlePP(None, '%(title)s - %(artist)s') self.assertEqual(pp._titleregex, r'(?P.+)\ \-\ (?P<artist>.+)') + + +class TestEmbedThumbnail(unittest.TestCase): + def test_run_case_insensitive(self): + from youtube_dl.postprocessor import EmbedThumbnailPP + class DummyDownloader(object): + params = {} + def to_screen(self, msg): + pass + pp = EmbedThumbnailPP(DummyDownloader()) + + res_files, res_info = pp.run({'ext': 'MP3', 'filepath': 'file.mp3', 'thumbnails': []}) + self.assertEqual(res_files, []) + self.assertEqual(res_info['ext'], 'MP3') + + res_files, res_info = pp.run({'ext': 'MP4', 'filepath': 'file.mp4', 'thumbnails': []}) + self.assertEqual(res_files, []) + self.assertEqual(res_info['ext'], 'MP4') + diff --git a/test/test_utils.py b/test/test_utils.py index 1106f2819..6f818e806 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -336,6 +336,7 @@ class TestUtil(unittest.TestCase): def test_subtitles_filename(self): self.assertEqual(subtitles_filename('abc.ext', 'en', 'vtt'), 'abc.en.vtt') self.assertEqual(subtitles_filename('abc.ext', 'en', 'vtt', 'ext'), 'abc.en.vtt') + self.assertEqual(subtitles_filename('abc.EXT', 'en', 'vtt', 'ext'), 'abc.en.vtt') self.assertEqual(subtitles_filename('abc.unexpected_ext', 'en', 'vtt', 'ext'), 'abc.unexpected_ext.en.vtt') def test_remove_start(self): @@ -444,10 +445,12 @@ class TestUtil(unittest.TestCase): def test_determine_ext(self): self.assertEqual(determine_ext('http://example.com/foo/bar.mp4/?download'), 'mp4') + self.assertEqual(determine_ext('http://example.com/foo/bar.MP4/?download'), 'MP4') self.assertEqual(determine_ext('http://example.com/foo/bar/?download', None), None) self.assertEqual(determine_ext('http://example.com/foo/bar.nonext/?download', None), None) self.assertEqual(determine_ext('http://example.com/foo/bar/mp4?download', None), None) self.assertEqual(determine_ext('http://example.com/foo/bar.m3u8//?download'), 'm3u8') + self.assertEqual(determine_ext('http://example.com/foo/bar.M3U8//?download'), 'M3U8') self.assertEqual(determine_ext('foobar', None), None) def test_find_xpath_attr(self): diff --git a/youtube_dl/YoutubeDL.py b/youtube_dl/YoutubeDL.py index 4c762bf2c..8ba794b31 100755 --- a/youtube_dl/YoutubeDL.py +++ b/youtube_dl/YoutubeDL.py @@ -2111,12 +2111,14 @@ class YoutubeDL(object): # Check extension video_ext, audio_ext = video.get('ext'), audio.get('ext') if video_ext and audio_ext: + video_ext_lower = video_ext.lower() + audio_ext_lower = audio_ext.lower() COMPATIBLE_EXTS = ( ('mp3', 'mp4', 'm4a', 'm4p', 'm4b', 'm4r', 'm4v', 'ismv', 'isma'), ('webm') ) for exts in COMPATIBLE_EXTS: - if video_ext in exts and audio_ext in exts: + if video_ext_lower in exts and audio_ext_lower in exts: return True # TODO: Check acodec/vcodec return False @@ -2135,7 +2137,7 @@ class YoutubeDL(object): return filename f_name, f_real_ext = os.path.splitext(filename) f_real_ext = f_real_ext[1:] - filename_wo_ext = f_name if f_real_ext in exts else filename + filename_wo_ext = f_name if f_real_ext.lower() in [e.lower() for e in exts if e] else filename if ext is None: ext = f_real_ext or None return join_nonempty(filename_wo_ext, ext, delim='.') diff --git a/youtube_dl/postprocessor/embedthumbnail.py b/youtube_dl/postprocessor/embedthumbnail.py index b6c60e127..7cb42795d 100644 --- a/youtube_dl/postprocessor/embedthumbnail.py +++ b/youtube_dl/postprocessor/embedthumbnail.py @@ -77,7 +77,8 @@ class EmbedThumbnailPP(FFmpegPostProcessor): os.rename(encodeFilename(escaped_thumbnail_jpg_filename), encodeFilename(thumbnail_jpg_filename)) thumbnail_filename = thumbnail_jpg_filename - if info['ext'] == 'mp3': + info_ext = info['ext'].lower() + if info_ext == 'mp3': options = [ '-c', 'copy', '-map', '0', '-map', '1', '-metadata:s:v', 'title="Album cover"', '-metadata:s:v', 'comment="Cover (Front)"'] @@ -91,7 +92,7 @@ class EmbedThumbnailPP(FFmpegPostProcessor): os.remove(encodeFilename(filename)) os.rename(encodeFilename(temp_filename), encodeFilename(filename)) - elif info['ext'] in ['m4a', 'mp4']: + elif info_ext in ['m4a', 'mp4']: atomicparsley = next((x for x in ['AtomicParsley', 'atomicparsley'] if check_executable(x, ['-v'])), None) diff --git a/youtube_dl/postprocessor/ffmpeg.py b/youtube_dl/postprocessor/ffmpeg.py index 214825aa9..1ebe5f6c1 100644 --- a/youtube_dl/postprocessor/ffmpeg.py +++ b/youtube_dl/postprocessor/ffmpeg.py @@ -352,7 +352,7 @@ class FFmpegVideoConvertorPP(FFmpegPostProcessor): def run(self, information): path = information['filepath'] - if information['ext'] == self._preferedformat: + if information['ext'].lower() == self._preferedformat.lower(): self._downloader.to_screen('[ffmpeg] Not converting video file %s - already is in target format %s' % (path, self._preferedformat)) return [], information options = [] @@ -370,7 +370,7 @@ class FFmpegVideoConvertorPP(FFmpegPostProcessor): class FFmpegEmbedSubtitlePP(FFmpegPostProcessor): def run(self, information): - if information['ext'] not in ('mp4', 'webm', 'mkv'): + if information['ext'].lower() not in ('mp4', 'webm', 'mkv'): self._downloader.to_screen('[ffmpeg] Subtitles can only be embedded in mp4, webm or mkv files') return [], information subtitles = information.get('requested_subtitles') @@ -385,13 +385,15 @@ class FFmpegEmbedSubtitlePP(FFmpegPostProcessor): sub_filenames = [] webm_vtt_warn = False + ext_lower = ext.lower() for lang, sub_info in subtitles.items(): sub_ext = sub_info['ext'] - if ext != 'webm' or ext == 'webm' and sub_ext == 'vtt': + sub_ext_lower = sub_ext.lower() + if ext_lower != 'webm' or ext_lower == 'webm' and sub_ext_lower == 'vtt': sub_langs.append(lang) sub_filenames.append(subtitles_filename(filename, lang, sub_ext, ext)) else: - if not webm_vtt_warn and ext == 'webm' and sub_ext != 'vtt': + if not webm_vtt_warn and ext_lower == 'webm' and sub_ext_lower != 'vtt': webm_vtt_warn = True self._downloader.to_screen('[ffmpeg] Only WebVTT subtitles can be embedded in webm files') @@ -410,7 +412,7 @@ class FFmpegEmbedSubtitlePP(FFmpegPostProcessor): # https://trac.ffmpeg.org/ticket/6016) '-map', '-0:d', ] - if information['ext'] == 'mp4': + if information['ext'].lower() == 'mp4': opts += ['-c:s', 'mov_text'] for (i, lang) in enumerate(sub_langs): opts.extend(['-map', '%d:0' % (i + 1)]) @@ -474,7 +476,7 @@ class FFmpegMetadataPP(FFmpegPostProcessor): in_filenames = [filename] options = [] - if info['ext'] == 'm4a': + if info['ext'].lower() == 'm4a': options.extend(['-vn', '-acodec', 'copy']) else: options.extend(['-c', 'copy']) @@ -609,7 +611,9 @@ class FFmpegSubtitlesConvertorPP(FFmpegPostProcessor): sub_filenames = [] for lang, sub in subs.items(): ext = sub['ext'] - if ext == new_ext: + ext_lower = ext.lower() + new_ext_lower = new_ext.lower() + if ext_lower == new_ext_lower: self._downloader.to_screen( '[ffmpeg] Subtitle file for %s is already in the requested format' % new_ext) continue @@ -617,7 +621,7 @@ class FFmpegSubtitlesConvertorPP(FFmpegPostProcessor): sub_filenames.append(old_file) new_file = subtitles_filename(filename, lang, new_ext, info.get('ext')) - if ext in ('dfxp', 'ttml', 'tt'): + if ext_lower in ('dfxp', 'ttml', 'tt'): self._downloader.report_warning( 'You have requested to convert dfxp (TTML) subtitles into another format, ' 'which results in style information loss') diff --git a/youtube_dl/utils.py b/youtube_dl/utils.py index 02a49ff49..4d65afe85 100644 --- a/youtube_dl/utils.py +++ b/youtube_dl/utils.py @@ -3273,7 +3273,7 @@ def determine_ext(url, default_ext='unknown_video'): if re.match(r'^[A-Za-z0-9]+$', guess): return guess # Try extract ext from URLs like http://example.com/foo/bar.mp4/?download - elif guess.rstrip('/') in KNOWN_EXTENSIONS: + elif guess.rstrip('/').lower() in KNOWN_EXTENSIONS: return guess.rstrip('/') else: return default_ext @@ -3992,7 +3992,7 @@ def _change_extension(prepend, filename, ext, expected_real_ext=None): name, real_ext = os.path.splitext(filename) sanitize_extension = _UnsafeExtensionError.sanitize_extension - if not expected_real_ext or real_ext.partition('.')[0::2] == ('', expected_real_ext): + if not expected_real_ext or real_ext.lower().partition('.')[0::2] == ('', expected_real_ext.lower()): filename = name if prepend and real_ext: sanitize_extension(ext, prepend=prepend) @@ -6855,7 +6855,7 @@ class _UnsafeExtensionError(Exception): if not prepend: last = extension.rpartition('.')[-1] - if last == 'bin': + if last.lower() == 'bin': extension = last = 'unknown_video' if not (cls.lenient or last.lower() in cls._ALLOWED_EXTENSIONS): raise cls(extension)