Repository: yt-dlp/yt-dlp (python)

Issue:
--get-filename yields inaccurate extension when the format will be merged
### Checklist

- [X] I'm reporting a bug unrelated to a specific site
- [X] I've verified that I'm running yt-dlp version **2022.05.18** ([update instructions](https://github.com/yt-dlp/yt-dlp#update)) or later (specify commit)
- [X] I've checked that all provided URLs are playable in a browser with the same IP and same login details
- [X] I've checked that all URLs and arguments with special characters are [properly quoted or escaped](https://github.com/ytdl-org/youtube-dl#video-url-contains-an-ampersand-and-im-getting-some-strange-output-1-2839-or-v-is-not-recognized-as-an-internal-or-external-command)
- [X] I've searched the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues including closed ones. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)

### Description

running --get-filename with a format specifier that requires combining streams produces inaccurate results

i suspect this issue has been around for a while but youtube shorts force it to occur more often than most due to their dimensions

### Verbose log

```shell
C:\Users\ej> yt-dlp -vU -f bv[height<=720]+ba --force-ipv4 --sleep-interval 5 --max-sleep-interval 30 --no-continue --no-overwrites --no-check-certificate --no-mtime --no-warnings https://youtu.be/nfsCkGJUl_8 --get-filename -o "%(title)s-%(id)s.%(ext)s"
[debug] Command-line config: ['-vU', '-f', 'bv[height<=720]+ba', '--force-ipv4', '--sleep-interval', '5', '--max-sleep-interval', '30', '--no-continue', '--no-overwrites', '--no-check-certificate', '--no-mtime', '--no-warnings', 'https://youtu.be/nfsCkGJUl_8', '--get-filename', '-o', '%(title)s-%(id)s.%(ext)s']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version 2022.05.18 [b14d523] (win_exe)
[debug] Python version 3.8.10 (CPython 64bit) - Windows-10-10.0.19044-SP0
[debug] Checking exe version: ffprobe -bsfs
[debug] Checking exe version: ffmpeg -bsfs
[debug] exe versions: ffmpeg 5.0-essentials_build-www.gyan.dev (setts), ffprobe 5.0-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.14.1, brotli-1.0.9, certifi-2021.10.08, mutagen-1.45.1, sqlite3-2.6.0, websockets-10.3
[debug] Proxy map: {}
Latest version: 2022.05.18, Current version: 2022.05.18
yt-dlp is up to date (2022.05.18)
[debug] [youtube] Extracting URL: https://youtu.be/nfsCkGJUl_8
[youtube] nfsCkGJUl_8: Downloading webpage
[youtube] nfsCkGJUl_8: Downloading android player API JSON
[debug] Sort order given by extractor: quality, res, fps, hdr:12, source, codec:vp9.2, lang, proto
[debug] Formats sorted by: hasvid, ie_pref, quality, res, fps, hdr:12(7), source, vcodec:vp9.2(10), acodec, lang, proto, filesize, fs_approx, tbr, vbr, abr, asr, vext, aext, hasaud, id
[info] nfsCkGJUl_8: Downloading 1 format(s): 136+251
Why You Should Always Wash Your Vegetables-nfsCkGJUl_8.mp4
C:\Users\ej> yt-dlp -vU -f bv[height<=720]+ba --force-ipv4 --sleep-interval 5 --max-sleep-interval 30 --no-continue --no-overwrites --no-check-certificate --no-mtime --no-warnings https://youtu.be/nfsCkGJUl_8 -o "%(title)s-%(id)s.%(ext)s"  [debug] Command-line config: ['-vU', '-f', 'bv[height<=720]+ba', '--force-ipv4', '--sleep-interval', '5', '--max-sleep-interval', '30', '--no-continue', '--no-overwrites', '--no-check-certificate', '--no-mtime', '--no-warnings', 'https://youtu.be/nfsCkGJUl_8', '-o', '%(title)s-%(id)s.%(ext)s']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version 2022.05.18 [b14d523] (win_exe)
[debug] Python version 3.8.10 (CPython 64bit) - Windows-10-10.0.19044-SP0
[debug] Checking exe version: ffprobe -bsfs
[debug] Checking exe version: ffmpeg -bsfs
[debug] exe versions: ffmpeg 5.0-essentials_build-www.gyan.dev (setts), ffprobe 5.0-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.14.1, brotli-1.0.9, certifi-2021.10.08, mutagen-1.45.1, sqlite3-2.6.0, websockets-10.3
[debug] Proxy map: {}
Latest version: 2022.05.18, Current version: 2022.05.18
yt-dlp is up to date (2022.05.18)
[debug] [youtube] Extracting URL: https://youtu.be/nfsCkGJUl_8
[youtube] nfsCkGJUl_8: Downloading webpage
[youtube] nfsCkGJUl_8: Downloading android player API JSON
[debug] Sort order given by extractor: quality, res, fps, hdr:12, source, codec:vp9.2, lang, proto
[debug] Formats sorted by: hasvid, ie_pref, quality, res, fps, hdr:12(7), source, vcodec:vp9.2(10), acodec, lang, proto, filesize, fs_approx, tbr, vbr, abr, asr, vext, aext, hasaud, id
[info] nfsCkGJUl_8: Downloading 1 format(s): 136+251
[debug] Invoking http downloader on "https://rr2---sn-3n4pcxg-pjul.googlevideo.com/videoplayback?expire=1655240708&ei=pKOoYr3LDKyHir4Pkc2K6A8&ip=199.87.203.248&id=o-AEyy8G9d_Wpeo2v4iabrZbBLRbQ5oLYGIfFR_w05LeCG&itag=136&source=youtube&requiressl=yes&mh=zx&mm=31%2C29&mn=sn-3n4pcxg-pjul%2Csn-vgqsrnsd&ms=au%2Crdu&mv=m&mvi=2&pl=21&ctier=SH&initcwndbps=967500&vprv=1&mime=video%2Fmp4&gir=yes&clen=1708677&dur=41.291&lmt=1655116620713722&mt=1655218620&fvip=1&keepalive=yes&fexp=24001373%2C24007246&c=ANDROID&txp=5432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cctier%2Cvprv%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgCVVjHe0-WOU_2ioZ3UPokwAqJ5vDBvHxqCW29QOsoYUCIQDmj5AMM8ZTL9SLl7Nf9coghK9E2kIw5PDJH1vAEN-RyQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhALl0e4tm-ohMXN_sSrvSVsGbM9j_mSCCN2QD8pb0ctvDAiBNzE8E2yruR3J7rYygG22EeoZRzjEEHlS6JgLwWIscbA%3D%3D"
[download] Sleeping 9.97 seconds ...
[debug] File locking is not supported on this platform. Proceeding without locking
[download] Destination: Why You Should Always Wash Your Vegetables-nfsCkGJUl_8.f136.mp4
[download] 100% of 1.63MiB in 00:00
[debug] Invoking http downloader on "https://rr2---sn-3n4pcxg-pjul.googlevideo.com/videoplayback?expire=1655240708&ei=pKOoYr3LDKyHir4Pkc2K6A8&ip=199.87.203.248&id=o-AEyy8G9d_Wpeo2v4iabrZbBLRbQ5oLYGIfFR_w05LeCG&itag=251&source=youtube&requiressl=yes&mh=zx&mm=31%2C29&mn=sn-3n4pcxg-pjul%2Csn-vgqsrnsd&ms=au%2Crdu&mv=m&mvi=2&pl=21&ctier=SH&initcwndbps=967500&vprv=1&mime=audio%2Fwebm&gir=yes&clen=539480&dur=41.321&lmt=1655116737253115&mt=1655218620&fvip=1&keepalive=yes&fexp=24001373%2C24007246&c=ANDROID&txp=5432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cctier%2Cvprv%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgPyukwntpjMQRkbkI2yP4cJbyA8E2VXSAvj3w5cHZF2kCIGl1Dw0gkQV_zYZ1MtTrb7Z2-HjpZmIA_3KAR6Vt3Npq&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhALl0e4tm-ohMXN_sSrvSVsGbM9j_mSCCN2QD8pb0ctvDAiBNzE8E2yruR3J7rYygG22EeoZRzjEEHlS6JgLwWIscbA%3D%3D"
[download] Sleeping 9.81 seconds ...
[download] Destination: Why You Should Always Wash Your Vegetables-nfsCkGJUl_8.f251.webm
[download] 100% of 526.84KiB in 00:00
[Merger] Merging formats into "Why You Should Always Wash Your Vegetables-nfsCkGJUl_8.mkv"
[debug] ffmpeg command line: ffmpeg -y -loglevel "repeat+info" -i "file:Why You Should Always Wash Your Vegetables-nfsCkGJUl_8.f136.mp4" -i "file:Why You Should Always Wash Your Vegetables-nfsCkGJUl_8.f251.webm" -c copy -map "0:v:0" -map "1:a:0" -movflags "+faststart" "file:Why You Should Always Wash Your Vegetables-nfsCkGJUl_8.temp.mkv"
Deleting original file Why You Should Always Wash Your Vegetables-nfsCkGJUl_8.f136.mp4 (pass -k to keep)
Deleting original file Why You Should Always Wash Your Vegetables-nfsCkGJUl_8.f251.webm (pass -k to keep)
C:\Users\ej>
```


Pull request description:
[core] merging formats by codecs, determining extension before download
## Please follow the guide below

- You will be asked some questions, please read them **carefully** and answer honestly
- Put an `x` into all the boxes [ ] relevant to your *pull request* (like that [x])
- Use *Preview* tab to see how your *pull request* will actually look like

---

### Before submitting a *pull request* make sure you have:
- [x] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [x] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests
- [x] Checked the code with [flake8](https://pypi.python.org/pypi/flake8)

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check one of the following options:
- [x] I am the original author of this code and I am willing to release it under [Unlicense](http://unlicense.org/)
- [ ] I am not the original author of this code but it is in public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)

### What is the purpose of your *pull request*?
- [ ] Bug fix
- [x] Improvement
- [ ] New extractor
- [ ] New feature

---

### Description of your *pull request* and other information

- determine the file extension of a merged video *before* downloading
- better file extension matching

fixes #4069

Patch:
diff --git a/README.md b/README.md
index 9fac6048e..4e806e14c 100644
--- a/README.md
+++ b/README.md
@@ -858,10 +858,10 @@ ## Video Format Options:
                                     downloadable
     -F, --list-formats              List available formats of each video.
                                     Simulate unless --no-simulate is used
-    --merge-output-format FORMAT    Container to use when merging formats (e.g.
-                                    bestvideo+bestaudio). Ignored if no merge is
-                                    required. (currently supported: avi, flv,
-                                    mkv, mov, mp4, webm)
+    --merge-output-format FORMAT    Containers that may be used when merging
+                                    formats, separated by "/" (Eg: "mp4/mkv").
+                                    Ignored if no merge is required. (currently
+                                    supported: avi, flv, mkv, mov, mp4, webm)
 
 ## Subtitle Options:
     --write-subs                    Write subtitle file
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index 0d7564088..25473611b 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -102,6 +102,7 @@
     format_decimal_suffix,
     format_field,
     formatSeconds,
+    get_compatible_ext,
     get_domain,
     int_or_none,
     iri_to_uri,
@@ -134,6 +135,7 @@
     timetuple_from_msec,
     to_high_limit_path,
     traverse_obj,
+    try_call,
     try_get,
     url_basename,
     variadic,
@@ -372,7 +374,7 @@ class YoutubeDL:
 
                        Progress hooks are guaranteed to be called at least twice
                        (with status "started" and "finished") if the processing is successful.
-    merge_output_format: Extension to use when merging formats.
+    merge_output_format: "/" separated list of extensions to use when merging formats.
     final_ext:         Expected final extension; used to detect when the file was
                        already downloaded and converted
     fixup:             Automatically correct known faults of the file.
@@ -2088,14 +2090,13 @@ def _merge(formats_pair):
             the_only_video = video_fmts[0] if len(video_fmts) == 1 else None
             the_only_audio = audio_fmts[0] if len(audio_fmts) == 1 else None
 
-            output_ext = self.params.get('merge_output_format')
-            if not output_ext:
-                if the_only_video:
-                    output_ext = the_only_video['ext']
-                elif the_only_audio and not video_fmts:
-                    output_ext = the_only_audio['ext']
-                else:
-                    output_ext = 'mkv'
+            output_ext = get_compatible_ext(
+                vcodecs=[f.get('vcodec') for f in video_fmts],
+                acodecs=[f.get('acodec') for f in audio_fmts],
+                vexts=[f['ext'] for f in video_fmts],
+                aexts=[f['ext'] for f in audio_fmts],
+                preferences=(try_call(lambda: self.params['merge_output_format'].split('/'))
+                             or self.params.get('prefer_free_formats') and ('webm', 'mkv')))
 
             filtered = lambda *keys: filter(None, (traverse_obj(fmt, *keys) for fmt in formats_info))
 
@@ -3067,33 +3068,9 @@ def existing_video_file(*filepaths):
                         return
 
                 if info_dict.get('requested_formats') is not None:
-
-                    def compatible_formats(formats):
-                        # TODO: some formats actually allow this (mkv, webm, ogg, mp4), but not all of them.
-                        video_formats = [format for format in formats if format.get('vcodec') != 'none']
-                        audio_formats = [format for format in formats if format.get('acodec') != 'none']
-                        if len(video_formats) > 2 or len(audio_formats) > 2:
-                            return False
-
-                        # Check extension
-                        exts = {format.get('ext') for format in formats}
-                        COMPATIBLE_EXTS = (
-                            {'mp3', 'mp4', 'm4a', 'm4p', 'm4b', 'm4r', 'm4v', 'ismv', 'isma'},
-                            {'webm'},
-                        )
-                        for ext_sets in COMPATIBLE_EXTS:
-                            if ext_sets.issuperset(exts):
-                                return True
-                        # TODO: Check acodec/vcodec
-                        return False
-
                     requested_formats = info_dict['requested_formats']
                     old_ext = info_dict['ext']
                     if self.params.get('merge_output_format') is None:
-                        if not compatible_formats(requested_formats):
-                            info_dict['ext'] = 'mkv'
-                            self.report_warning(
-                                'Requested formats are incompatible for merge and will be merged into mkv')
                         if (info_dict['ext'] == 'webm'
                                 and info_dict.get('thumbnails')
                                 # check with type instead of pp_key, __name__, or isinstance
diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py
index 4024b6ba1..317dd2623 100644
--- a/yt_dlp/__init__.py
+++ b/yt_dlp/__init__.py
@@ -228,7 +228,8 @@ def validate_minmax(min_val, max_val, min_name, max_name=None):
         validate_regex('format sorting', f, InfoExtractor.FormatSort.regex)
 
     # Postprocessor formats
-    validate_in('merge output format', opts.merge_output_format, FFmpegMergerPP.SUPPORTED_EXTS)
+    validate_regex('merge output format', opts.merge_output_format,
+                   r'({0})(/({0}))*'.format('|'.join(map(re.escape, FFmpegMergerPP.SUPPORTED_EXTS))))
     validate_regex('audio format', opts.audioformat, FFmpegExtractAudioPP.FORMAT_RE)
     validate_in('subtitle format', opts.convertsubtitles, FFmpegSubtitlesConvertorPP.SUPPORTED_EXTS)
     validate_regex('thumbnail format', opts.convertthumbnails, FFmpegThumbnailsConvertorPP.FORMAT_RE)
diff --git a/yt_dlp/cookies.py b/yt_dlp/cookies.py
index df8f97b44..1a164bb31 100644
--- a/yt_dlp/cookies.py
+++ b/yt_dlp/cookies.py
@@ -1,6 +1,5 @@
 import base64
 import contextlib
-import ctypes
 import http.cookiejar
 import json
 import os
@@ -876,10 +875,12 @@ def _decrypt_windows_dpapi(ciphertext, logger):
     References:
         - https://docs.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptunprotectdata
     """
-    from ctypes.wintypes import DWORD
+
+    import ctypes
+    import ctypes.wintypes
 
     class DATA_BLOB(ctypes.Structure):
-        _fields_ = [('cbData', DWORD),
+        _fields_ = [('cbData', ctypes.wintypes.DWORD),
                     ('pbData', ctypes.POINTER(ctypes.c_char))]
 
     buffer = ctypes.create_string_buffer(ciphertext)
diff --git a/yt_dlp/extractor/crunchyroll.py b/yt_dlp/extractor/crunchyroll.py
index 6fd74989e..bacdb8515 100644
--- a/yt_dlp/extractor/crunchyroll.py
+++ b/yt_dlp/extractor/crunchyroll.py
@@ -28,6 +28,7 @@
     join_nonempty,
     lowercase_escape,
     merge_dicts,
+    parse_iso8601,
     qualities,
     remove_end,
     sanitized_Request,
@@ -761,43 +762,23 @@ class CrunchyrollBetaIE(CrunchyrollBetaBaseIE):
     _TESTS = [{
         'url': 'https://beta.crunchyroll.com/watch/GY2P1Q98Y/to-the-future',
         'info_dict': {
-            'id': '696363',
+            'id': 'GY2P1Q98Y',
             'ext': 'mp4',
-            'timestamp': 1459610100,
+            'duration': 1380.241,
+            'timestamp': 1459632600,
             'description': 'md5:a022fbec4fbb023d43631032c91ed64b',
-            'uploader': 'Toei Animation',
             'title': 'World Trigger Episode 73 – To the Future',
             'upload_date': '20160402',
-            'episode_number': 73,
             'series': 'World Trigger',
-            'average_rating': 4.9,
-            'episode': 'To the Future',
+            'series_id': 'GR757DMKY',
             'season': 'World Trigger',
-            'thumbnail': 'https://img1.ak.crunchyroll.com/i/spire3-tmb/c870dedca1a83137c2d3d144984155ed1459527119_main.jpg',
+            'season_id': 'GR9P39NJ6',
             'season_number': 1,
+            'episode': 'To the Future',
+            'episode_number': 73,
+            'thumbnail': r're:^https://beta.crunchyroll.com/imgsrv/.*\.jpeg$',
         },
         'params': {'skip_download': 'm3u8'},
-        'expected_warnings': ['Unable to download XML']
-    }, {
-        'url': 'https://beta.crunchyroll.com/watch/GYK53DMPR/wicked-lord-shingan-reborn',
-        'info_dict': {
-            'id': '648781',
-            'ext': 'mp4',
-            'episode_number': 1,
-            'timestamp': 1389173400,
-            'series': 'Love, Chunibyo & Other Delusions - Heart Throb -',
-            'description': 'md5:5579d1a0355cc618558ba23d27067a62',
-            'uploader': 'TBS',
-            'episode': 'Wicked Lord Shingan... Reborn',
-            'average_rating': 4.9,
-            'season': 'Love, Chunibyo & Other Delusions - Heart Throb -',
-            'thumbnail': 'https://img1.ak.crunchyroll.com/i/spire3-tmb/2ba0384e225a5370d5f0ee9496d91ea51389046521_main.jpg',
-            'title': 'Love, Chunibyo & Other Delusions - Heart Throb - Episode 1 – Wicked Lord Shingan... Reborn',
-            'season_number': 2,
-            'upload_date': '20140108',
-        },
-        'params': {'skip_download': 'm3u8'},
-        'expected_warnings': ['Unable to download XML']
     }, {
         'url': 'https://beta.crunchyroll.com/watch/GY2P1Q98Y/',
         'only_matching': True,
@@ -859,6 +840,7 @@ def _real_extract(self, url):
                 episode_response.get('season_title'), episode_response.get('episode'), episode_response.get('title')),
             'description': try_get(episode_response, lambda x: x['description'].replace(r'\r\n', '\n')),
             'duration': float_or_none(episode_response.get('duration_ms'), 1000),
+            'timestamp': parse_iso8601(episode_response.get('upload_date')),
             'series': episode_response.get('series_title'),
             'series_id': episode_response.get('series_id'),
             'season': episode_response.get('season_title'),
@@ -887,17 +869,10 @@ class CrunchyrollBetaShowIE(CrunchyrollBetaBaseIE):
     _TESTS = [{
         'url': 'https://beta.crunchyroll.com/series/GY19NQ2QR/Girl-Friend-BETA',
         'info_dict': {
-            'id': 'girl-friend-beta',
+            'id': 'GY19NQ2QR',
             'title': 'Girl Friend BETA',
         },
         'playlist_mincount': 10,
-    }, {
-        'url': 'https://beta.crunchyroll.com/series/GYJQV73V6/love-chunibyo--other-delusions---heart-throb--',
-        'info_dict': {
-            'id': 'love-chunibyo-other-delusions-heart-throb-',
-            'title': 'Love, Chunibyo & Other Delusions - Heart Throb -',
-        },
-        'playlist_mincount': 10,
     }, {
         'url': 'https://beta.crunchyroll.com/it/series/GY19NQ2QR/Girl-Friend-BETA',
         'only_matching': True,
diff --git a/yt_dlp/options.py b/yt_dlp/options.py
index 236cc714b..b70f5798e 100644
--- a/yt_dlp/options.py
+++ b/yt_dlp/options.py
@@ -782,7 +782,8 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
         '--merge-output-format',
         action='store', dest='merge_output_format', metavar='FORMAT', default=None,
         help=(
-            'Container to use when merging formats (e.g. bestvideo+bestaudio). Ignored if no merge is required. '
+            'Containers that may be used when merging formats, separated by "/" (Eg: "mp4/mkv"). '
+            'Ignored if no merge is required. '
             f'(currently supported: {", ".join(sorted(FFmpegMergerPP.SUPPORTED_EXTS))})'))
     video_format.add_option(
         '--allow-unplayable-formats',
diff --git a/yt_dlp/utils.py b/yt_dlp/utils.py
index c0d9c6f79..d405ed3e3 100644
--- a/yt_dlp/utils.py
+++ b/yt_dlp/utils.py
@@ -6,7 +6,6 @@
 import codecs
 import collections
 import contextlib
-import ctypes
 import datetime
 import email.header
 import email.utils
@@ -1983,6 +1982,7 @@ def __init__(self):
 
 # Cross-platform file locking
 if sys.platform == 'win32':
+    import ctypes
     import ctypes.wintypes
     import msvcrt
 
@@ -2362,9 +2362,10 @@ def fix_xml_ampersands(xml_str):
 def setproctitle(title):
     assert isinstance(title, str)
 
-    # ctypes in Jython is not complete
-    # http://bugs.jython.org/issue2148
-    if sys.platform.startswith('java'):
+    # Workaround for https://github.com/yt-dlp/yt-dlp/issues/4541
+    try:
+        import ctypes
+    except ImportError:
         return
 
     try:
@@ -3455,6 +3456,46 @@ def parse_codecs(codecs_str):
     return {}
 
 
+def get_compatible_ext(*, vcodecs, acodecs, vexts, aexts, preferences=None):
+    assert len(vcodecs) == len(vexts) and len(acodecs) == len(aexts)
+
+    allow_mkv = not preferences or 'mkv' in preferences
+
+    if allow_mkv and max(len(acodecs), len(vcodecs)) > 1:
+        return 'mkv'  # TODO: any other format allows this?
+
+    # TODO: All codecs supported by parse_codecs isn't handled here
+    COMPATIBLE_CODECS = {
+        'mp4': {
+            'av1', 'hevc', 'avc1', 'mp4a',  # fourcc (m3u8, mpd)
+            'h264', 'aacl',  # Set in ISM
+        },
+        'webm': {
+            'av1', 'vp9', 'vp8', 'opus', 'vrbs',
+            'vp9x', 'vp8x',  # in the webm spec
+        },
+    }
+
+    sanitize_codec = functools.partial(try_get, getter=lambda x: x.split('.')[0].replace('0', ''))
+    vcodec, acodec = sanitize_codec(vcodecs[0]), sanitize_codec(acodecs[0])
+
+    for ext in preferences or COMPATIBLE_CODECS.keys():
+        codec_set = COMPATIBLE_CODECS.get(ext, set())
+        if ext == 'mkv' or codec_set.issuperset((vcodec, acodec)):
+            return ext
+
+    COMPATIBLE_EXTS = (
+        {'mp3', 'mp4', 'm4a', 'm4p', 'm4b', 'm4r', 'm4v', 'ismv', 'isma', 'mov'},
+        {'webm'},
+    )
+    for ext in preferences or vexts:
+        current_exts = {ext, *vexts, *aexts}
+        if ext == 'mkv' or current_exts == {ext} or any(
+                ext_sets.issuperset(current_exts) for ext_sets in COMPATIBLE_EXTS):
+            return ext
+    return 'mkv' if allow_mkv else preferences[-1]
+
+
 def urlhandle_detect_ext(url_handle):
     getheader = url_handle.headers.get
 


Test patch:
diff --git a/test/test_utils.py b/test/test_utils.py
index 8ec1413b8..989a99ea3 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -53,6 +53,7 @@
     fix_xml_ampersands,
     float_or_none,
     format_bytes,
+    get_compatible_ext,
     get_element_by_attribute,
     get_element_by_class,
     get_element_html_by_attribute,
@@ -1843,6 +1844,31 @@ def test_determine_file_encoding(self):
         self.assertEqual(determine_file_encoding('# coding: utf-32-be'.encode('utf-32-be')), ('utf-32-be', 0))
         self.assertEqual(determine_file_encoding('# coding: utf-16-le'.encode('utf-16-le')), ('utf-16-le', 0))
 
+    def test_get_compatible_ext(self):
+        self.assertEqual(get_compatible_ext(
+            vcodecs=[None], acodecs=[None, None], vexts=['mp4'], aexts=['m4a', 'm4a']), 'mkv')
+        self.assertEqual(get_compatible_ext(
+            vcodecs=[None], acodecs=[None], vexts=['flv'], aexts=['flv']), 'flv')
+
+        self.assertEqual(get_compatible_ext(
+            vcodecs=[None], acodecs=[None], vexts=['mp4'], aexts=['m4a']), 'mp4')
+        self.assertEqual(get_compatible_ext(
+            vcodecs=[None], acodecs=[None], vexts=['mp4'], aexts=['webm']), 'mkv')
+        self.assertEqual(get_compatible_ext(
+            vcodecs=[None], acodecs=[None], vexts=['webm'], aexts=['m4a']), 'mkv')
+        self.assertEqual(get_compatible_ext(
+            vcodecs=[None], acodecs=[None], vexts=['webm'], aexts=['webm']), 'webm')
+
+        self.assertEqual(get_compatible_ext(
+            vcodecs=['h264'], acodecs=['mp4a'], vexts=['mov'], aexts=['m4a']), 'mp4')
+        self.assertEqual(get_compatible_ext(
+            vcodecs=['av01.0.12M.08'], acodecs=['opus'], vexts=['mp4'], aexts=['webm']), 'webm')
+
+        self.assertEqual(get_compatible_ext(
+            vcodecs=['vp9'], acodecs=['opus'], vexts=['webm'], aexts=['webm'], preferences=['flv', 'mp4']), 'mp4')
+        self.assertEqual(get_compatible_ext(
+            vcodecs=['av1'], acodecs=['mp4a'], vexts=['webm'], aexts=['m4a'], preferences=('webm', 'mkv')), 'mkv')
+
 
 if __name__ == '__main__':
     unittest.main()