Repository: yt-dlp/yt-dlp

Issue:
will yt-dlp support xiaohongshu format like this http://xhslink.com/a/qsoHVeD0Liw1?
### 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 a new site support request
- [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 none of provided URLs [violate any copyrights](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy) or contain any [DRM](https://en.wikipedia.org/wiki/Digital_rights_management) to the best of my knowledge
- [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)
- [X] 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 am willing to share it if required

### Region

China

### Example URLs

unsupported url: 
- http://xhslink.com/a/qsoHVeD0Liw1  

same context but on pc, supported url:  
- https://www.xiaohongshu.com/explore/674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65.0&share_from_user_hidden=true&xsec_source=app_share&type=video&xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=&author_share=1&xhsshare=CopyLink&shareRedId=ODhINzY6OkE2NzUyOTgwNjdIOThJSkc_&apptime=1734003953&share_id=26df27f0730f4131a4567fafdd90c092

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

yt-dlp -vU http://xhslink.com/a/qsoHVeD0Liw1                                                                                                                                  
[debug] Command-line config: ['-vU', 'http://xhslink.com/a/qsoHVeD0Liw1']
[debug] Encodings: locale cp936, fs utf-8, pref cp936, out utf-8, error utf-8, screen utf-8 
[debug] yt-dlp version stable@2024.11.18 from yt-dlp/yt-dlp [7ea278792] (pip) 
[debug] Python 3.11.5 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 3.0.10 1 Aug 2023) 
[debug] exe versions: ffmpeg 7.1-essentials_build-www.gyan.dev (setts), ffprobe 7.1-essentials_build-www.gyan.dev 
[debug] Optional libraries: brotli-None, certifi-2020.06.20, pycrypto-3.19.0, requests-2.24.0, sqlite3-3.41.2, urllib3-1.25.10, websockets-11.0.3 
[debug] Proxy map: {}
[debug] Request Handlers: urllib
[debug] Loaded 1837 extractors 
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest 
[debug] Downloading _update_spec from https://github.com/yt-dlp/yt-dlp/releases/latest/download/_update_spec 
Current version: stable@2024.11.18 from yt-dlp/yt-dlp 
Latest version: stable@2024.12.06 from yt-dlp/yt-dlp
ERROR: You installed yt-dlp with pip or using the wheel from PyPi; Use that to update 
[generic] Extracting URL: http://xhslink.com/a/qsoHVeD0Liw1 
[generic] qsoHVeD0Liw1: Downloading webpage 
[redirect] Following redirect to https://www.xiaohongshu.com/discovery/item/674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65.0&share_from_user_hidden=true&xsec_source=app_share&type=video&xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=&author_share=1&xhsshare=CopyLink&shareRedId=ODhINzY6OkE2NzUyOTgwNjdIOThJSkc_&apptime=1734003953&share_id=26df27f0730f4131a4567fafdd90c092
[generic] Extracting URL: https://www.xiaohongshu.com/discovery/item/674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65.0&share_from_user_hidden=true&xsec_source=app_share&type=video&xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=&author_share=1&xhsshare=CopyLink&shareRedId=ODhINzY6OkE2NzUyOTgwNjdIOThJSkc_&apptime=1734003953&share_id=26df27f0730f4131a4567fafdd90c092
[generic] 674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65: Downloading webpage
WARNING: [generic] Falling back on generic information extractor 
[generic] 674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65: Extracting information 
[debug] Looking for embeds 
ERROR: Unsupported URL: https://www.xiaohongshu.com/discovery/item/674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65.0&share_from_user_hidden=true&xsec_source=app_share&type=video&xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=&author_share=1&xhsshare=CopyLink&shareRedId=ODhINzY6OkE2NzUyOTgwNjdIOThJSkc_&apptime=1734003953&share_id=26df27f0730f4131a4567fafdd90c092
Traceback (most recent call last): 
  File "C:\Users\zhangjinyang\AppData\Local\anaconda3\Lib\site-packages\yt_dlp\YoutubeDL.py", line 1624, in wrapper
    return func(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\zhangjinyang\AppData\Local\anaconda3\Lib\site-packages\yt_dlp\YoutubeDL.py", line 1759, in __extract_info
    ie_result = ie.extract(url)
                ^^^^^^^^^^^^^^^
  File "C:\Users\zhangjinyang\AppData\Local\anaconda3\Lib\site-packages\yt_dlp\extractor\common.py", line 742, in extract
    ie_result = self._real_extract(url)
                ^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\zhangjinyang\AppData\Local\anaconda3\Lib\site-packages\yt_dlp\extractor\generic.py", line 2553, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://www.xiaohongshu.com/discovery/item/674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65.0&share_from_user_hidden=true&xsec_source=app_share&type=video&xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=&author_share=1&xhsshare=CopyLink&shareRedId=ODhINzY6OkE2NzUyOTgwNjdIOThJSkc_&apptime=1734003953&share_id=26df27f0730f4131a4567fafdd90c092

### Provide verbose output that clearly demonstrates the problem

- [X] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [X] 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
yt-dlp -vU http://xhslink.com/a/qsoHVeD0Liw1                                                                                                                                  
[debug] Command-line config: ['-vU', 'http://xhslink.com/a/qsoHVeD0Liw1']
[debug] Encodings: locale cp936, fs utf-8, pref cp936, out utf-8, error utf-8, screen utf-8 
[debug] yt-dlp version stable@2024.11.18 from yt-dlp/yt-dlp [7ea278792] (pip) 
[debug] Python 3.11.5 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 3.0.10 1 Aug 2023) 
[debug] exe versions: ffmpeg 7.1-essentials_build-www.gyan.dev (setts), ffprobe 7.1-essentials_build-www.gyan.dev 
[debug] Optional libraries: brotli-None, certifi-2020.06.20, pycrypto-3.19.0, requests-2.24.0, sqlite3-3.41.2, urllib3-1.25.10, websockets-11.0.3 
[debug] Proxy map: {}
[debug] Request Handlers: urllib
[debug] Loaded 1837 extractors 
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest 
[debug] Downloading _update_spec from https://github.com/yt-dlp/yt-dlp/releases/latest/download/_update_spec 
Current version: stable@2024.11.18 from yt-dlp/yt-dlp 
Latest version: stable@2024.12.06 from yt-dlp/yt-dlp
ERROR: You installed yt-dlp with pip or using the wheel from PyPi; Use that to update 
[generic] Extracting URL: http://xhslink.com/a/qsoHVeD0Liw1 
[generic] qsoHVeD0Liw1: Downloading webpage 
[redirect] Following redirect to https://www.xiaohongshu.com/discovery/item/674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65.0&share_from_user_hidden=true&xsec_source=app_share&type=video&xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=&author_share=1&xhsshare=CopyLink&shareRedId=ODhINzY6OkE2NzUyOTgwNjdIOThJSkc_&apptime=1734003953&share_id=26df27f0730f4131a4567fafdd90c092
[generic] Extracting URL: https://www.xiaohongshu.com/discovery/item/674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65.0&share_from_user_hidden=true&xsec_source=app_share&type=video&xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=&author_share=1&xhsshare=CopyLink&shareRedId=ODhINzY6OkE2NzUyOTgwNjdIOThJSkc_&apptime=1734003953&share_id=26df27f0730f4131a4567fafdd90c092
[generic] 674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65: Downloading webpage
WARNING: [generic] Falling back on generic information extractor 
[generic] 674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65: Extracting information 
[debug] Looking for embeds 
ERROR: Unsupported URL: https://www.xiaohongshu.com/discovery/item/674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65.0&share_from_user_hidden=true&xsec_source=app_share&type=video&xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=&author_share=1&xhsshare=CopyLink&shareRedId=ODhINzY6OkE2NzUyOTgwNjdIOThJSkc_&apptime=1734003953&share_id=26df27f0730f4131a4567fafdd90c092
Traceback (most recent call last): 
  File "C:\Users\zhangjinyang\AppData\Local\anaconda3\Lib\site-packages\yt_dlp\YoutubeDL.py", line 1624, in wrapper
    return func(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\zhangjinyang\AppData\Local\anaconda3\Lib\site-packages\yt_dlp\YoutubeDL.py", line 1759, in __extract_info
    ie_result = ie.extract(url)
                ^^^^^^^^^^^^^^^
  File "C:\Users\zhangjinyang\AppData\Local\anaconda3\Lib\site-packages\yt_dlp\extractor\common.py", line 742, in extract
    ie_result = self._real_extract(url)
                ^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\zhangjinyang\AppData\Local\anaconda3\Lib\site-packages\yt_dlp\extractor\generic.py", line 2553, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://www.xiaohongshu.com/discovery/item/674051740000000007027a15?app_platform=android&ignoreEngage=true&app_version=8.65.0&share_from_user_hidden=true&xsec_source=app_share&type=video&xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=&author_share=1&xhsshare=CopyLink&shareRedId=ODhINzY6OkE2NzUyOTgwNjdIOThJSkc_&apptime=1734003953&share_id=26df27f0730f4131a4567fafdd90c092
```

Discussion:

Solution patch:
diff --git a/Changelog.md b/Changelog.md
index 75e824033..22a9a6e4b 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -4,6 +4,22 @@ # Changelog
 # To create a release, dispatch the https://github.com/yt-dlp/yt-dlp/actions/workflows/release.yml workflow on master
 -->
 
+### 2024.12.23
+
+#### Core changes
+- [Don't sanitize filename on Unix when `--no-windows-filenames`](https://github.com/yt-dlp/yt-dlp/commit/6fc85f617a5850307fd5b258477070e6ee177796) ([#9591](https://github.com/yt-dlp/yt-dlp/issues/9591)) by [pukkandan](https://github.com/pukkandan)
+- **update**
+    - [Check 64-bitness when upgrading ARM builds](https://github.com/yt-dlp/yt-dlp/commit/b91c3925c2059970daa801cb131c0c2f4f302e72) ([#11819](https://github.com/yt-dlp/yt-dlp/issues/11819)) by [bashonly](https://github.com/bashonly)
+    - [Fix endless update loop for `linux_exe` builds](https://github.com/yt-dlp/yt-dlp/commit/3d3ee458c1fe49dd5ebd7651a092119d23eb7000) ([#11827](https://github.com/yt-dlp/yt-dlp/issues/11827)) by [bashonly](https://github.com/bashonly)
+
+#### Extractor changes
+- **soundcloud**: [Various fixes](https://github.com/yt-dlp/yt-dlp/commit/d298693b1b266d198e8eeecb90ea17c4a031268f) ([#11820](https://github.com/yt-dlp/yt-dlp/issues/11820)) by [bashonly](https://github.com/bashonly)
+- **youtube**
+    - [Add age-gate workaround for some embeddable videos](https://github.com/yt-dlp/yt-dlp/commit/09a6c687126f04e243fcb105a828787efddd1030) ([#11821](https://github.com/yt-dlp/yt-dlp/issues/11821)) by [bashonly](https://github.com/bashonly)
+    - [Fix `uploader_id` extraction](https://github.com/yt-dlp/yt-dlp/commit/1a8851b689763e5173b96f70f8a71df0e4a44b66) ([#11818](https://github.com/yt-dlp/yt-dlp/issues/11818)) by [bashonly](https://github.com/bashonly)
+    - [Player client maintenance](https://github.com/yt-dlp/yt-dlp/commit/65cf46cddd873fd229dbb0fc0689bca4c201c6b6) ([#11893](https://github.com/yt-dlp/yt-dlp/issues/11893)) by [bashonly](https://github.com/bashonly)
+    - [Skip iOS formats that require PO Token](https://github.com/yt-dlp/yt-dlp/commit/9f42e68a74f3f00b0253fe70763abd57cac4237b) ([#11890](https://github.com/yt-dlp/yt-dlp/issues/11890)) by [coletdjnz](https://github.com/coletdjnz)
+
 ### 2024.12.13
 
 #### Extractor changes
diff --git a/README.md b/README.md
index 1db4ed2a5..1c628d025 100644
--- a/README.md
+++ b/README.md
@@ -613,8 +613,7 @@ ## Filesystem Options:
     --no-restrict-filenames         Allow Unicode characters, "&" and spaces in
                                     filenames (default)
     --windows-filenames             Force filenames to be Windows-compatible
-    --no-windows-filenames          Make filenames Windows-compatible only if
-                                    using Windows (default)
+    --no-windows-filenames          Sanitize filenames only minimally
     --trim-filenames LENGTH         Limit the filename length (excluding
                                     extension) to the specified number of
                                     characters
@@ -1776,7 +1775,7 @@ #### youtube
 * `comment_sort`: `top` or `new` (default) - choose comment sorting mode (on YouTube's side)
 * `max_comments`: Limit the amount of comments to gather. Comma-separated list of integers representing `max-comments,max-parents,max-replies,max-replies-per-thread`. Default is `all,all,all,all`
     * E.g. `all,all,1000,10` will get a maximum of 1000 replies total, with up to 10 replies per thread. `1000,all,100` will get a maximum of 1000 comments, with a maximum of 100 replies total
-* `formats`: Change the types of formats to return. `dashy` (convert HTTP to DASH), `duplicate` (identical content but different URLs or protocol; includes `dashy`), `incomplete` (cannot be downloaded completely - live dash and post-live m3u8)
+* `formats`: Change the types of formats to return. `dashy` (convert HTTP to DASH), `duplicate` (identical content but different URLs or protocol; includes `dashy`), `incomplete` (cannot be downloaded completely - live dash and post-live m3u8), `missing_pot` (include formats that require a PO Token but are missing one)
 * `innertube_host`: Innertube API host to use for all API requests; e.g. `studio.youtube.com`, `youtubei.googleapis.com`. Note that cookies exported from one subdomain will not work on others
 * `innertube_key`: Innertube API key to use for all API requests. By default, no API key is used
 * `raise_incomplete_data`: `Incomplete Data Received` raises an error instead of reporting a warning
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index 65b72e026..764baf3a0 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -266,7 +266,9 @@ class YoutubeDL:
     outtmpl_na_placeholder: Placeholder for unavailable meta fields.
     restrictfilenames: Do not allow "&" and spaces in file names
     trim_file_name:    Limit length of filename (extension excluded)
-    windowsfilenames:  Force the filenames to be windows compatible
+    windowsfilenames:  True: Force filenames to be Windows compatible
+                       False: Sanitize filenames only minimally
+                       This option has no effect when running on Windows
     ignoreerrors:      Do not stop on download/postprocessing errors.
                        Can be 'only_download' to ignore only download errors.
                        Default is 'only_download' for CLI, but False for API
@@ -1192,8 +1194,7 @@ def _copy_infodict(info_dict):
 
     def prepare_outtmpl(self, outtmpl, info_dict, sanitize=False):
         """ Make the outtmpl and info_dict suitable for substitution: ydl.escape_outtmpl(outtmpl) % info_dict
-        @param sanitize    Whether to sanitize the output as a filename.
-                           For backward compatibility, a function can also be passed
+        @param sanitize    Whether to sanitize the output as a filename
         """
 
         info_dict.setdefault('epoch', int(time.time()))  # keep epoch consistent once set
@@ -1309,14 +1310,23 @@ def get_value(mdict):
 
         na = self.params.get('outtmpl_na_placeholder', 'NA')
 
-        def filename_sanitizer(key, value, restricted=self.params.get('restrictfilenames')):
+        def filename_sanitizer(key, value, restricted):
             return sanitize_filename(str(value), restricted=restricted, is_id=(
                 bool(re.search(r'(^|[_.])id(\.|$)', key))
                 if 'filename-sanitization' in self.params['compat_opts']
                 else NO_DEFAULT))
 
-        sanitizer = sanitize if callable(sanitize) else filename_sanitizer
-        sanitize = bool(sanitize)
+        if callable(sanitize):
+            self.deprecation_warning('Passing a callable "sanitize" to YoutubeDL.prepare_outtmpl is deprecated')
+        elif not sanitize:
+            pass
+        elif (sys.platform != 'win32' and not self.params.get('restrictfilenames')
+                and self.params.get('windowsfilenames') is False):
+            def sanitize(key, value):
+                return value.replace('/', '\u29F8').replace('\0', '')
+        else:
+            def sanitize(key, value):
+                return filename_sanitizer(key, value, restricted=self.params.get('restrictfilenames'))
 
         def _dumpjson_default(obj):
             if isinstance(obj, (set, LazyList)):
@@ -1399,13 +1409,13 @@ def create_key(outer_mobj):
 
             if sanitize:
                 # If value is an object, sanitize might convert it to a string
-                # So we convert it to repr first
+                # So we manually convert it before sanitizing
                 if fmt[-1] == 'r':
                     value, fmt = repr(value), str_fmt
                 elif fmt[-1] == 'a':
                     value, fmt = ascii(value), str_fmt
                 if fmt[-1] in 'csra':
-                    value = sanitizer(last_field, value)
+                    value = sanitize(last_field, value)
 
             key = '{}\0{}'.format(key.replace('%', '%\0'), outer_mobj.group('format'))
             TMPL_DICT[key] = value
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 967010826..bbd6d21bd 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -1551,6 +1551,7 @@
     PluralsightIE,
 )
 from .plutotv import PlutoTVIE
+from .plvideo import PlVideoIE
 from .podbayfm import (
     PodbayFMChannelIE,
     PodbayFMIE,
diff --git a/yt_dlp/extractor/plvideo.py b/yt_dlp/extractor/plvideo.py
new file mode 100644
index 000000000..9351af10a
--- /dev/null
+++ b/yt_dlp/extractor/plvideo.py
@@ -0,0 +1,130 @@
+from .common import InfoExtractor
+from ..utils import (
+    float_or_none,
+    int_or_none,
+    parse_iso8601,
+    parse_resolution,
+    url_or_none,
+)
+from ..utils.traversal import traverse_obj
+
+
+class PlVideoIE(InfoExtractor):
+    IE_DESC = 'Платформа'
+    _VALID_URL = r'https?://(?:www\.)?plvideo\.ru/(?:watch\?(?:[^#]+&)?v=|shorts/)(?P<id>[\w-]+)'
+    _TESTS = [{
+        'url': 'https://plvideo.ru/watch?v=Y5JzUzkcQTMK',
+        'md5': 'fe8e18aca892b3b31f3bf492169f8a26',
+        'info_dict': {
+            'id': 'Y5JzUzkcQTMK',
+            'ext': 'mp4',
+            'thumbnail': 'https://img.plvideo.ru/images/fp-2024-images/v/cover/37/dd/37dd00a4c96c77436ab737e85947abd7/original663a4a3bb713e5.33151959.jpg',
+            'title': 'Presidente de Cuba llega a Moscú en una visita de trabajo',
+            'channel': 'RT en Español',
+            'channel_id': 'ZH4EKqunVDvo',
+            'media_type': 'video',
+            'comment_count': int,
+            'tags': ['rusia', 'cuba', 'russia', 'miguel díaz-canel'],
+            'description': 'md5:a1a395d900d77a86542a91ee0826c115',
+            'released_timestamp': 1715096124,
+            'channel_is_verified': True,
+            'like_count': int,
+            'timestamp': 1715095911,
+            'duration': 44320,
+            'view_count': int,
+            'dislike_count': int,
+            'upload_date': '20240507',
+            'modified_date': '20240701',
+            'channel_follower_count': int,
+            'modified_timestamp': 1719824073,
+        },
+    }, {
+        'url': 'https://plvideo.ru/shorts/S3Uo9c-VLwFX',
+        'md5': '7d8fa2279406c69d2fd2a6fc548a9805',
+        'info_dict': {
+            'id': 'S3Uo9c-VLwFX',
+            'ext': 'mp4',
+            'channel': 'Romaatom',
+            'tags': 'count:22',
+            'dislike_count': int,
+            'upload_date': '20241130',
+            'description': 'md5:452e6de219bf2f32bb95806c51c3b364',
+            'duration': 58433,
+            'modified_date': '20241130',
+            'thumbnail': 'https://img.plvideo.ru/images/fp-2024-11-cover/S3Uo9c-VLwFX/f9318999-a941-482b-b700-2102a7049366.jpg',
+            'media_type': 'shorts',
+            'like_count': int,
+            'modified_timestamp': 1732961458,
+            'channel_is_verified': True,
+            'channel_id': 'erJyyTIbmUd1',
+            'timestamp': 1732961355,
+            'comment_count': int,
+            'title': 'Белоусов отменил приказы о кадровом резерве на гражданской службе',
+            'channel_follower_count': int,
+            'view_count': int,
+            'released_timestamp': 1732961458,
+        },
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+
+        video_data = self._download_json(
+            f'https://api.g1.plvideo.ru/v1/videos/{video_id}?Aud=18', video_id)
+
+        is_live = False
+        formats = []
+        subtitles = {}
+        automatic_captions = {}
+        for quality, data in traverse_obj(video_data, ('item', 'profiles', {dict.items}, lambda _, v: url_or_none(v[1]['hls']))):
+            formats.append({
+                'format_id': quality,
+                'ext': 'mp4',
+                'protocol': 'm3u8_native',
+                **traverse_obj(data, {
+                    'url': 'hls',
+                    'fps': ('fps', {float_or_none}),
+                    'aspect_ratio': ('aspectRatio', {float_or_none}),
+                }),
+                **parse_resolution(quality),
+            })
+        if livestream_url := traverse_obj(video_data, ('item', 'livestream', 'url', {url_or_none})):
+            is_live = True
+            formats.extend(self._extract_m3u8_formats(livestream_url, video_id, 'mp4', live=True))
+        for lang, url in traverse_obj(video_data, ('item', 'subtitles', {dict.items}, lambda _, v: url_or_none(v[1]))):
+            if lang.endswith('-auto'):
+                automatic_captions.setdefault(lang[:-5], []).append({
+                    'url': url,
+                })
+            else:
+                subtitles.setdefault(lang, []).append({
+                    'url': url,
+                })
+
+        return {
+            'id': video_id,
+            'formats': formats,
+            'subtitles': subtitles,
+            'automatic_captions': automatic_captions,
+            'is_live': is_live,
+            **traverse_obj(video_data, ('item', {
+                'id': ('id', {str}),
+                'title': ('title', {str}),
+                'description': ('description', {str}),
+                'thumbnail': ('cover', 'paths', 'original', 'src', {url_or_none}),
+                'duration': ('uploadFile', 'videoDuration', {int_or_none}),
+                'channel': ('channel', 'name', {str}),
+                'channel_id': ('channel', 'id', {str}),
+                'channel_follower_count': ('channel', 'stats', 'subscribers', {int_or_none}),
+                'channel_is_verified': ('channel', 'verified', {bool}),
+                'tags': ('tags', ..., {str}),
+                'timestamp': ('createdAt', {parse_iso8601}),
+                'released_timestamp': ('publishedAt', {parse_iso8601}),
+                'modified_timestamp': ('updatedAt', {parse_iso8601}),
+                'view_count': ('stats', 'viewTotalCount', {int_or_none}),
+                'like_count': ('stats', 'likeCount', {int_or_none}),
+                'dislike_count': ('stats', 'dislikeCount', {int_or_none}),
+                'comment_count': ('stats', 'commentCount', {int_or_none}),
+                'media_type': ('type', {str}),
+            })),
+        }
diff --git a/yt_dlp/extractor/soundcloud.py b/yt_dlp/extractor/soundcloud.py
index 66bc5f9c5..85779e91a 100644
--- a/yt_dlp/extractor/soundcloud.py
+++ b/yt_dlp/extractor/soundcloud.py
@@ -210,6 +210,7 @@ def _extract_info_dict(self, info, full_title=None, secret_token=None, extract_f
 
         format_urls = set()
         formats = []
+        has_drm = False
         query = {'client_id': self._CLIENT_ID}
         if secret_token:
             query['secret_token'] = secret_token
@@ -245,6 +246,7 @@ def _extract_info_dict(self, info, full_title=None, secret_token=None, extract_f
                         'url': format_url,
                         'quality': 10,
                         'format_note': 'Original',
+                        'vcodec': 'none',
                     })
 
         def invalid_url(url):
@@ -260,6 +262,7 @@ def invalid_url(url):
 
             protocol = traverse_obj(t, ('format', 'protocol', {str})) or 'http'
             if protocol.startswith(('ctr-', 'cbc-')):
+                has_drm = True
                 continue
             if protocol == 'progressive':
                 protocol = 'http'
@@ -317,8 +320,11 @@ def invalid_url(url):
                 'preference': -10 if is_preview else None,
             })
 
-        if not formats and info.get('policy') == 'BLOCK':
-            self.raise_geo_restricted(metadata_available=True)
+        if not formats:
+            if has_drm:
+                self.report_drm(track_id)
+            if info.get('policy') == 'BLOCK':
+                self.raise_geo_restricted(metadata_available=True)
 
         user = info.get('user') or {}
 
diff --git a/yt_dlp/extractor/xiaohongshu.py b/yt_dlp/extractor/xiaohongshu.py
index 1280ca6a9..46543b823 100644
--- a/yt_dlp/extractor/xiaohongshu.py
+++ b/yt_dlp/extractor/xiaohongshu.py
@@ -10,7 +10,7 @@
 
 
 class XiaoHongShuIE(InfoExtractor):
-    _VALID_URL = r'https?://www\.xiaohongshu\.com/explore/(?P<id>[\da-f]+)'
+    _VALID_URL = r'https?://www\.xiaohongshu\.com/(?:explore|discovery/item)/(?P<id>[\da-f]+)'
     IE_DESC = '小红书'
     _TESTS = [{
         'url': 'https://www.xiaohongshu.com/explore/6411cf99000000001300b6d9',
@@ -25,6 +25,18 @@ class XiaoHongShuIE(InfoExtractor):
             'duration': 101.726,
             'thumbnail': r're:https?://sns-webpic-qc\.xhscdn\.com/\d+/[a-z0-9]+/[\w]+',
         },
+    }, {
+        'url': 'https://www.xiaohongshu.com/discovery/item/674051740000000007027a15?xsec_token=CBgeL8Dxd1ZWBhwqRd568gAZ_iwG-9JIf9tnApNmteU2E=',
+        'info_dict': {
+            'id': '674051740000000007027a15',
+            'ext': 'mp4',
+            'title': '相互喜欢就可以了',
+            'uploader_id': '63439913000000001901f49a',
+            'duration': 28.073,
+            'description': '#广州[话题]# #深圳[话题]# #香港[话题]# #街头采访[话题]# #是你喜欢的类型[话题]#',
+            'thumbnail': r're:https?://sns-webpic-qc\.xhscdn\.com/\d+/[\da-f]+/[^/]+',
+            'tags': ['广州', '深圳', '香港', '街头采访', '是你喜欢的类型'],
+        },
     }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index fd9c7107c..1e83e41b8 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -162,7 +162,6 @@
         'REQUIRE_JS_PLAYER': False,
         'REQUIRE_PO_TOKEN': True,
         'REQUIRE_AUTH': True,
-        'SUPPORTS_COOKIES': True,
     },
     # This client now requires sign-in for every video
     'android_creator': {
@@ -197,7 +196,6 @@
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 28,
         'REQUIRE_JS_PLAYER': False,
-        'SUPPORTS_COOKIES': True,
     },
     # iOS clients have HLS live streams. Setting device model to get 60fps formats.
     # See: https://github.com/TeamNewPipe/NewPipeExtractor/issues/680#issuecomment-1002724558
@@ -214,6 +212,7 @@
             },
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
+        'REQUIRE_PO_TOKEN': True,
         'REQUIRE_JS_PLAYER': False,
     },
     # This client now requires sign-in for every video
@@ -232,7 +231,6 @@
         'INNERTUBE_CONTEXT_CLIENT_NAME': 26,
         'REQUIRE_JS_PLAYER': False,
         'REQUIRE_AUTH': True,
-        'SUPPORTS_COOKIES': True,
     },
     # This client now requires sign-in for every video
     'ios_creator': {
@@ -258,6 +256,7 @@
             'client': {
                 'clientName': 'MWEB',
                 'clientVersion': '2.20241202.07.00',
+                # mweb does not require PO Token with this UA
                 'userAgent': 'Mozilla/5.0 (iPad; CPU OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1,gzip(gfe)',
             },
         },
@@ -518,11 +517,12 @@ def ucid_or_none(self, ucid):
         return self._search_regex(rf'^({self._YT_CHANNEL_UCID_RE})$', ucid, 'UC-id', default=None)
 
     def handle_or_none(self, handle):
-        return self._search_regex(rf'^({self._YT_HANDLE_RE})$', handle, '@-handle', default=None)
+        return self._search_regex(rf'^({self._YT_HANDLE_RE})$', urllib.parse.unquote(handle or ''),
+                                  '@-handle', default=None)
 
     def handle_from_url(self, url):
         return self._search_regex(rf'^(?:https?://(?:www\.)?youtube\.com)?/({self._YT_HANDLE_RE})',
-                                  url, 'channel handle', default=None)
+                                  urllib.parse.unquote(url or ''), 'channel handle', default=None)
 
     def ucid_from_url(self, url):
         return self._search_regex(rf'^(?:https?://(?:www\.)?youtube\.com)?/({self._YT_CHANNEL_UCID_RE})',
@@ -1495,7 +1495,7 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
         },
         # Age-gate videos. See https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-888837000
         {
-            'note': 'Embed allowed age-gate video',
+            'note': 'Embed allowed age-gate video; works with web_embedded',
             'url': 'https://youtube.com/watch?v=HtVdAasjOgU',
             'info_dict': {
                 'id': 'HtVdAasjOgU',
@@ -1525,7 +1525,6 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'heatmap': 'count:100',
                 'timestamp': 1401991663,
             },
-            'skip': 'Age-restricted; requires authentication',
         },
         {
             'note': 'Age-gate video with embed allowed in public site',
@@ -2801,6 +2800,35 @@ class YoutubeIE(YoutubeBaseInfoExtractor):
                 'extractor_args': {'youtube': {'player_client': ['ios'], 'player_skip': ['webpage']}},
             },
         },
+        {
+            # uploader_id has non-ASCII characters that are percent-encoded in YT's JSON
+            'url': 'https://www.youtube.com/shorts/18NGQq7p3LY',
+            'info_dict': {
+                'id': '18NGQq7p3LY',
+                'ext': 'mp4',
+                'title': '아이브 이서 장원영 리즈 삐끼삐끼 챌린지',
+                'description': '',
+                'uploader': 'ㅇㅇ',
+                'uploader_id': '@으아-v1k',
+                'uploader_url': 'https://www.youtube.com/@으아-v1k',
+                'channel': 'ㅇㅇ',
+                'channel_id': 'UCC25oTm2J7ZVoi5TngOHg9g',
+                'channel_url': 'https://www.youtube.com/channel/UCC25oTm2J7ZVoi5TngOHg9g',
+                'thumbnail': r're:https?://.+/.+\.jpg',
+                'playable_in_embed': True,
+                'age_limit': 0,
+                'duration': 3,
+                'timestamp': 1724306170,
+                'upload_date': '20240822',
+                'availability': 'public',
+                'live_status': 'not_live',
+                'view_count': int,
+                'like_count': int,
+                'channel_follower_count': int,
+                'categories': ['People & Blogs'],
+                'tags': [],
+            },
+        },
     ]
 
     _WEBPAGE_TESTS = [
@@ -3944,13 +3972,10 @@ def append_client(*client_names):
             )
 
             require_po_token = self._get_default_ytcfg(client).get('REQUIRE_PO_TOKEN')
-            if not po_token and require_po_token:
+            if not po_token and require_po_token and 'missing_pot' in self._configuration_arg('formats'):
                 self.report_warning(
                     f'No PO Token provided for {client} client, '
-                    f'which is required for working {client} formats. '
-                    f'You can manually pass a PO Token for this client with '
-                    f'--extractor-args "youtube:po_token={client}+XXX"',
-                    only_once=True)
+                    f'which may be required for working {client} formats. This client will be deprioritized', only_once=True)
                 deprioritize_pr = True
 
             pr = initial_pr if client == 'web' else None
@@ -3983,15 +4008,24 @@ def append_client(*client_names):
                 else:
                     prs.append(pr)
 
+            # web_embedded can work around age-gate and age-verification for some embeddable videos
+            if self._is_agegated(pr) and variant != 'web_embedded':
+                append_client(f'web_embedded.{base_client}')
+            # Unauthenticated users will only get web_embedded client formats if age-gated
+            if self._is_agegated(pr) and not self.is_authenticated:
+                self.to_screen(
+                    f'{video_id}: This video is age-restricted; some formats may be missing '
+                    f'without authentication. {self._login_hint()}', only_once=True)
+
             ''' This code is pointless while web_creator is in _DEFAULT_AUTHED_CLIENTS
             # EU countries require age-verification for accounts to access age-restricted videos
             # If account is not age-verified, _is_agegated() will be truthy for non-embedded clients
-            if self.is_authenticated and self._is_agegated(pr):
+            embedding_is_disabled = variant == 'web_embedded' and self._is_unplayable(pr)
+            if self.is_authenticated and (self._is_agegated(pr) or embedding_is_disabled):
                 self.to_screen(
                     f'{video_id}: This video is age-restricted and YouTube is requiring '
                     'account age-verification; some formats may be missing', only_once=True)
                 # web_creator can work around the age-verification requirement
-                # android_vr may also be able to work around age-verification
                 # tv_embedded may(?) still work around age-verification if the video is embeddable
                 append_client('web_creator')
             '''
@@ -4014,6 +4048,21 @@ def _needs_live_processing(self, live_status, duration):
                 or (live_status == 'post_live' and (duration or 0) > 2 * 3600)):
             return live_status
 
+    def _report_pot_format_skipped(self, video_id, client_name, proto):
+        msg = (
+            f'{video_id}: {client_name} client {proto} formats require a PO Token which was not provided. '
+            'They will be skipped as they may yield HTTP Error 403. '
+            f'You can manually pass a PO Token for this client with --extractor-args "youtube:po_token={client_name}+XXX". '
+            'For more information, refer to  https://github.com/yt-dlp/yt-dlp/wiki/Extractors#po-token-guide . '
+            'To enable these broken formats anyway, pass --extractor-args "youtube:formats=missing_pot"')
+
+        # Only raise a warning for non-default clients, to not confuse users.
+        # iOS HLS formats still work without PO Token, so we don't need to warn about them.
+        if client_name in (*self._DEFAULT_CLIENTS, *self._DEFAULT_AUTHED_CLIENTS):
+            self.write_debug(msg, only_once=True)
+        else:
+            self.report_warning(msg, only_once=True)
+
     def _extract_formats_and_subtitles(self, streaming_data, video_id, player_url, live_status, duration):
         CHUNK_SIZE = 10 << 20
         PREFERRED_LANG_VALUE = 10
@@ -4140,11 +4189,10 @@ def build_fragments(f):
                 fmt_url = update_url_query(fmt_url, {'pot': po_token})
 
             # Clients that require PO Token return videoplayback URLs that may return 403
-            is_broken = (not po_token and self._get_default_ytcfg(client_name).get('REQUIRE_PO_TOKEN'))
-            if is_broken:
-                self.report_warning(
-                    f'{video_id}: {client_name} client formats require a PO Token which was not provided. '
-                    'They will be deprioritized as they may yield HTTP Error 403', only_once=True)
+            require_po_token = (not po_token and self._get_default_ytcfg(client_name).get('REQUIRE_PO_TOKEN'))
+            if require_po_token and 'missing_pot' not in self._configuration_arg('formats'):
+                self._report_pot_format_skipped(video_id, client_name, 'https')
+                continue
 
             name = fmt.get('qualityLabel') or quality.replace('audio_quality_', '') or ''
             fps = int_or_none(fmt.get('fps')) or 0
@@ -4157,7 +4205,7 @@ def build_fragments(f):
                     name, fmt.get('isDrc') and 'DRC',
                     try_get(fmt, lambda x: x['projectionType'].replace('RECTANGULAR', '').lower()),
                     try_get(fmt, lambda x: x['spatialAudioType'].replace('SPATIAL_AUDIO_TYPE_', '').lower()),
-                    is_damaged and 'DAMAGED', is_broken and 'BROKEN',
+                    is_damaged and 'DAMAGED', require_po_token and 'MISSING POT',
                     (self.get_param('verbose') or all_formats) and short_client_name(client_name),
                     delim=', '),
                 # Format 22 is likely to be damaged. See https://github.com/yt-dlp/yt-dlp/issues/3372
@@ -4174,7 +4222,7 @@ def build_fragments(f):
                 'language': join_nonempty(language_code, 'desc' if is_descriptive else '') or None,
                 'language_preference': PREFERRED_LANG_VALUE if is_original else 5 if is_default else -10 if is_descriptive else -1,
                 # Strictly de-prioritize broken, damaged and 3gp formats
-                'preference': -20 if is_broken else -10 if is_damaged else -2 if itag == '17' else None,
+                'preference': -20 if require_po_token else -10 if is_damaged else -2 if itag == '17' else None,
             }
             mime_mobj = re.match(
                 r'((?:[^/]+)/(?:[^;]+))(?:;\s*codecs="([^"]+)")?', fmt.get('mimeType') or '')
@@ -4224,7 +4272,6 @@ def process_manifest_format(f, proto, client_name, itag, po_token):
             key = (proto, f.get('language'))
             if not all_formats and key in itags[itag]:
                 return False
-            itags[itag].add(key)
 
             if f.get('source_preference') is None:
                 f['source_preference'] = -1
@@ -4232,12 +4279,14 @@ def process_manifest_format(f, proto, client_name, itag, po_token):
             # Clients that require PO Token return videoplayback URLs that may return 403
             # hls does not currently require PO Token
             if (not po_token and self._get_default_ytcfg(client_name).get('REQUIRE_PO_TOKEN')) and proto != 'hls':
-                self.report_warning(
-                    f'{video_id}: {client_name} client {proto} formats require a PO Token which was not provided. '
-                    'They will be deprioritized as they may yield HTTP Error 403', only_once=True)
-                f['format_note'] = join_nonempty(f.get('format_note'), 'BROKEN', delim=' ')
+                if 'missing_pot' not in self._configuration_arg('formats'):
+                    self._report_pot_format_skipped(video_id, client_name, proto)
+                    return False
+                f['format_note'] = join_nonempty(f.get('format_note'), 'MISSING POT', delim=' ')
                 f['source_preference'] -= 20
 
+            itags[itag].add(key)
+
             if itag and all_formats:
                 f['format_id'] = f'{itag}-{proto}'
             elif any(p != proto for p, _ in itags[itag]):
diff --git a/yt_dlp/options.py b/yt_dlp/options.py
index 930d9d4be..06b65e0ea 100644
--- a/yt_dlp/options.py
+++ b/yt_dlp/options.py
@@ -1370,12 +1370,12 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
         help='Allow Unicode characters, "&" and spaces in filenames (default)')
     filesystem.add_option(
         '--windows-filenames',
-        action='store_true', dest='windowsfilenames', default=False,
+        action='store_true', dest='windowsfilenames', default=None,
         help='Force filenames to be Windows-compatible')
     filesystem.add_option(
         '--no-windows-filenames',
         action='store_false', dest='windowsfilenames',
-        help='Make filenames Windows-compatible only if using Windows (default)')
+        help='Sanitize filenames only minimally')
     filesystem.add_option(
         '--trim-filenames', '--trim-file-names', metavar='LENGTH',
         dest='trim_file_name', default=0, type=int,
diff --git a/yt_dlp/update.py b/yt_dlp/update.py
index ca2ec5f37..360f5ad58 100644
--- a/yt_dlp/update.py
+++ b/yt_dlp/update.py
@@ -65,9 +65,14 @@ def _get_variant_and_executable_path():
             machine = '_legacy' if version_tuple(platform.mac_ver()[0]) < (10, 15) else ''
         else:
             machine = f'_{platform.machine().lower()}'
+            is_64bits = sys.maxsize > 2**32
             # Ref: https://en.wikipedia.org/wiki/Uname#Examples
             if machine[1:] in ('x86', 'x86_64', 'amd64', 'i386', 'i686'):
-                machine = '_x86' if platform.architecture()[0][:2] == '32' else ''
+                machine = '_x86' if not is_64bits else ''
+            # platform.machine() on 32-bit raspbian OS may return 'aarch64', so check "64-bitness"
+            # See: https://github.com/yt-dlp/yt-dlp/issues/11813
+            elif machine[1:] == 'aarch64' and not is_64bits:
+                machine = '_armv7l'
             # sys.executable returns a /tmp/ path for staticx builds (linux_static)
             # Ref: https://staticx.readthedocs.io/en/latest/usage.html#run-time-information
             if static_exe_path := os.getenv('STATICX_PROG_PATH'):
@@ -525,11 +530,16 @@ def filename(self):
     @functools.cached_property
     def cmd(self):
         """The command-line to run the executable, if known"""
+        argv = None
         # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
         if getattr(sys, 'orig_argv', None):
-            return sys.orig_argv
+            argv = sys.orig_argv
         elif getattr(sys, 'frozen', False):
-            return sys.argv
+            argv = sys.argv
+        # linux_static exe's argv[0] will be /tmp/staticx-NNNN/yt-dlp_linux if we don't fixup here
+        if argv and os.getenv('STATICX_PROG_PATH'):
+            argv = [self.filename, *argv[1:]]
+        return argv
 
     def restart(self):
         """Restart the executable"""
diff --git a/yt_dlp/version.py b/yt_dlp/version.py
index f696e1e9d..1ff43c611 100644
--- a/yt_dlp/version.py
+++ b/yt_dlp/version.py
@@ -1,8 +1,8 @@
 # Autogenerated by devscripts/update-version.py
 
-__version__ = '2024.12.13'
+__version__ = '2024.12.23'
 
-RELEASE_GIT_HEAD = '54216696261bc07cacd9a837c501d9e0b7fed09e'
+RELEASE_GIT_HEAD = '65cf46cddd873fd229dbb0fc0689bca4c201c6b6'
 
 VARIANT = None
 
@@ -12,4 +12,4 @@
 
 ORIGIN = 'yt-dlp/yt-dlp'
 
-_pkg_version = '2024.12.13'
+_pkg_version = '2024.12.23'


Test patch:
diff --git a/test/test_YoutubeDL.py b/test/test_YoutubeDL.py
index 966d27a49..6b022a7ea 100644
--- a/test/test_YoutubeDL.py
+++ b/test/test_YoutubeDL.py
@@ -761,6 +761,13 @@ def test(tmpl, expected, *, info=None, **params):
         test('%(width)06d.%%(ext)s', 'NA.%(ext)s')
         test('%%(width)06d.%(ext)s', '%(width)06d.mp4')
 
+        # Sanitization options
+        test('%(title3)s', (None, 'foo⧸bar⧹test'))
+        test('%(title5)s', (None, 'aei_A'), restrictfilenames=True)
+        test('%(title3)s', (None, 'foo_bar_test'), windowsfilenames=False, restrictfilenames=True)
+        if sys.platform != 'win32':
+            test('%(title3)s', (None, 'foo⧸bar\\test'), windowsfilenames=False)
+
         # ID sanitization
         test('%(id)s', '_abcd', info={'id': '_abcd'})
         test('%(some_id)s', '_abcd', info={'some_id': '_abcd'})