Repository: yt-dlp/yt-dlp
Issue category: Bug Report

Issue:
[soop] ERROR: unsafe file extension: 'php' when downloading thumbnail
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

- [X] I understand that I will be **blocked** if I *intentionally* remove or skip any mandatory\* field

### Checklist

- [X] I'm reporting that yt-dlp is broken on a **supported** site
- [X] I've verified that I have **updated yt-dlp to nightly or master** ([update instructions](https://github.com/yt-dlp/yt-dlp#update-channels))
- [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/yt-dlp/yt-dlp/wiki/FAQ#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 [known issues](https://github.com/yt-dlp/yt-dlp/issues/3766) and 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)
- [ ] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and I'm willing to share it if required

### Region

_No response_

### Provide a description that is worded well enough to be understood

The thumbnail file is actually jpeg, but yt-dlp tries to download it as php. So it leads ERROR: unsafe file extension: 'php'

`yt-dlp --skip-download --write-thumbnail -vU https://vod.sooplive.co.kr/player/136504897`

### Provide verbose output that clearly demonstrates the problem

- [X] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [ ] If using API, add `'verbose': True` to `YoutubeDL` params instead
- [X] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
[debug] Command-line config: ['--skip-download', '--write-thumbnail', '-vU', 'https://vod.sooplive.co.kr/player/136504897']
[debug] Encodings: locale cp949, fs utf-8, pref cp949, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version master@2024.11.12.233022 from yt-dlp/yt-dlp-master-builds [f2a4983df] (win_exe)
[debug] Python 3.10.11 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 1.1.1t  7 Feb 2023)
[debug] exe versions: ffmpeg 7.1-full_build-www.gyan.dev (setts), ffprobe 7.1-full_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.21.0, brotli-1.1.0, certifi-2024.08.30, curl_cffi-0.5.10, mutagen-1.47.0, requests-2.32.3, sqlite3-3.40.1, urllib3-2.2.3, websockets-13.1
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets, curl_cffi
[debug] Loaded 1838 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp-master-builds/releases/latest
Latest version: master@2024.11.12.233022 from yt-dlp/yt-dlp-master-builds
yt-dlp is up to date (master@2024.11.12.233022 from yt-dlp/yt-dlp-master-builds)
[soop] Extracting URL: https://vod.sooplive.co.kr/player/136504897
[soop] 136504897: Downloading API JSON
[soop] 136504897: Downloading part 1 m3u8 information
[soop] 136504897: Downloading part 2 m3u8 information
[download] Downloading multi_video: [동출 99일차/유입 환영!]추석특집 신창섭 월드컵 엄선작
ERROR: unsafe file extension: 'php'
Traceback (most recent call last):
  File "yt_dlp\YoutubeDL.py", line 1625, in wrapper
  File "yt_dlp\YoutubeDL.py", line 1781, in __extract_info
  File "yt_dlp\YoutubeDL.py", line 1910, in process_ie_result
  File "yt_dlp\YoutubeDL.py", line 2010, in __process_playlist
  File "yt_dlp\YoutubeDL.py", line 4386, in _write_thumbnails
  File "yt_dlp\utils\_utils.py", line 2149, in _change_extension
  File "yt_dlp\utils\_utils.py", line 5232, in sanitize_extension
yt_dlp.utils._UnsafeExtensionError: unsafe file extension: 'php'
```



Hints:
This block, after assigning `common_info`, should prevent the problem (also import `utils.urlhandle_detect_ext`):
```
        thumbnail = common_info.pop('thumbnail', None)
        if thumbnail:
            common_info['thumbnails'] = [{
                'url': thumbnail,
                'ext': urlhandle_detect_ext(self._request_webpage(
                    HEADRequest(thumbnail), video_id, note='Getting thumbnail ext')),
            }]
```
But actually, since `check_thumbnails()` within `YoutubeDL._sanitize_thumbnails()` is already fetching each thumbnail URL, shouldn't it be doing something like the above to set default extensions? And then extractors wouldn't need to do so.
The above patch doesn't work

Patch:
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index 3186a999deb8..3130deda3162 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -4381,7 +4381,9 @@ def _write_thumbnails(self, label, info_dict, filename, thumb_filename_base=None
             return None
 
         for idx, t in list(enumerate(thumbnails))[::-1]:
-            thumb_ext = (f'{t["id"]}.' if multiple else '') + determine_ext(t['url'], 'jpg')
+            thumb_ext = t.get('ext') or determine_ext(t['url'], 'jpg')
+            if multiple:
+                thumb_ext = f'{t["id"]}.{thumb_ext}'
             thumb_display_id = f'{label} thumbnail {t["id"]}'
             thumb_filename = replace_extension(filename, thumb_ext, info_dict.get('ext'))
             thumb_filename_final = replace_extension(thumb_filename_base, thumb_ext, info_dict.get('ext'))
diff --git a/yt_dlp/extractor/afreecatv.py b/yt_dlp/extractor/afreecatv.py
index 6682a898179c..572d1a38936b 100644
--- a/yt_dlp/extractor/afreecatv.py
+++ b/yt_dlp/extractor/afreecatv.py
@@ -66,6 +66,14 @@ def _call_api(self, endpoint, display_id, data=None, headers=None, query=None):
             extensions={'legacy_ssl': True}), display_id,
             'Downloading API JSON', 'Unable to download API JSON')
 
+    @staticmethod
+    def _fixup_thumb(thumb_url):
+        if not url_or_none(thumb_url):
+            return None
+        # Core would determine_ext as 'php' from the url, so we need to provide the real ext
+        # See: https://github.com/yt-dlp/yt-dlp/issues/11537
+        return [{'url': thumb_url, 'ext': 'jpg'}]
+
 
 class AfreecaTVIE(AfreecaTVBaseIE):
     IE_NAME = 'soop'
@@ -155,7 +163,7 @@ def _real_extract(self, url):
             'uploader': ('writer_nick', {str}),
             'uploader_id': ('bj_id', {str}),
             'duration': ('total_file_duration', {int_or_none(scale=1000)}),
-            'thumbnail': ('thumb', {url_or_none}),
+            'thumbnails': ('thumb', {self._fixup_thumb}),
         })
 
         entries = []
@@ -226,8 +234,7 @@ def _real_extract(self, url):
 
         return self.playlist_result(self._entries(data), video_id)
 
-    @staticmethod
-    def _entries(data):
+    def _entries(self, data):
         # 'files' is always a list with 1 element
         yield from traverse_obj(data, (
             'data', lambda _, v: v['story_type'] == 'catch',
@@ -238,7 +245,7 @@ def _entries(data):
                 'title': ('title', {str}),
                 'uploader': ('writer_nick', {str}),
                 'uploader_id': ('writer_id', {str}),
-                'thumbnail': ('thumb', {url_or_none}),
+                'thumbnails': ('thumb', {self._fixup_thumb}),
                 'timestamp': ('write_timestamp', {int_or_none}),
             }))
 
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index 01915acf23be..23f6fc6c460f 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -279,6 +279,7 @@ class InfoExtractor:
     thumbnails:     A list of dictionaries, with the following entries:
                         * "id" (optional, string) - Thumbnail format ID
                         * "url"
+                        * "ext" (optional, string) - actual image extension if not given in URL
                         * "preference" (optional, int) - quality of the image
                         * "width" (optional, int)
                         * "height" (optional, int)


Test patch: