Repository: yt-dlp/yt-dlp

Issue:
[vimeo] thumbnails not loading 
### 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)
- [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 I'm willing to share it if required

### Region

any

### Provide a description that is worded well enough to be understood

**downloads seem to be working just fine on Vimeo**, but it's impossible to get thumbnails to load. Also, while downloading or listing formats there's a warning saying _WARNING:  Failed to parse XML: not well-formed (invalid token): line 1, column 0_
Tried many links with same result. 

https://vimeo.com/226958858
https://vimeo.com/246621397


### 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 --list-thumbnails https://vimeo.com/226958858
[debug] Command-line config: ['-vU', '--list-thumbnails', 'https://vimeo.com/226958858']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2024.12.23 from yt-dlp/yt-dlp [65cf46cdd] (zip)
[debug] Python 3.12.2 (CPython arm64 64bit) - macOS-15.2-arm64-arm-64bit (OpenSSL 3.0.13 30 Jan 2024)
[debug] exe versions: phantomjs 2.1.1, rtmpdump 2.4
[debug] Optional libraries: Cryptodome-3.20.0, brotli-1.1.0, certifi-2024.07.04, curl_cffi-0.7.1, mutagen-1.47.0, requests-2.32.3, sqlite3-3.45.1, urllib3-2.2.2, websockets-12.0
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, curl_cffi
[debug] Extractor Plugins: SamplePluginIE
[debug] Post-Processor Plugins: SamplePluginPP
[debug] Loaded 1838 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: stable@2024.12.23 from yt-dlp/yt-dlp
yt-dlp is up to date (stable@2024.12.23 from yt-dlp/yt-dlp)
[vimeo] Extracting URL: https://vimeo.com/226958858
[vimeo] 226958858: Downloading webpage
[vimeo] 226958858: Downloading viewer info
[vimeo] 226958858: Downloading API JSON
[vimeo] 226958858: Downloading JSON metadata
[vimeo] 226958858: Downloading akfire_interconnect_quic m3u8 information
[vimeo] 226958858: Downloading fastly_skyfire m3u8 information
[vimeo] 226958858: Downloading akfire_interconnect_quic MPD information
WARNING: [vimeo] 226958858: Failed to parse XML: not well-formed (invalid token): line 1, column 0
[vimeo] 226958858: Downloading fastly_skyfire MPD information
WARNING: [vimeo] 226958858: Failed to parse XML: not well-formed (invalid token): line 1, column 0
[debug] Sort order given by extractor: quality, res, fps, hdr:12, source
[debug] Formats sorted by: hasvid, ie_pref, quality, res, fps, hdr:12(7), source, lang, vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, id
226958858 has no thumbnails
```


Discussion:
jixunmoe: There seems to be a bigger change in Vimeo side that needs adapting:

- Channel info extraction is not reliable anymore (no `config-url` in the html)
- uploader id changed from `str` handle to `int`
- uploader url not available anymore

Patch:
diff --git a/README.md b/README.md
index 56e4458dc..45c56434a 100644
--- a/README.md
+++ b/README.md
@@ -1760,7 +1760,7 @@ # Replace all spaces and "_" in title and uploader with a `-`
 
 # EXTRACTOR ARGUMENTS
 
-Some extractors accept additional arguments which can be passed using `--extractor-args KEY:ARGS`. `ARGS` is a `;` (semicolon) separated string of `ARG=VAL1,VAL2`. E.g. `--extractor-args "youtube:player-client=tv,mweb;formats=incomplete" --extractor-args "funimation:version=uncut"`
+Some extractors accept additional arguments which can be passed using `--extractor-args KEY:ARGS`. `ARGS` is a `;` (semicolon) separated string of `ARG=VAL1,VAL2`. E.g. `--extractor-args "youtube:player-client=tv,mweb;formats=incomplete" --extractor-args "twitter:api=syndication"`
 
 Note: In CLI, `ARG` can use `-` instead of `_`; e.g. `youtube:player-client"` becomes `youtube:player_client"`
 
@@ -1769,7 +1769,7 @@ # EXTRACTOR ARGUMENTS
 #### youtube
 * `lang`: Prefer translated metadata (`title`, `description` etc) of this language code (case-sensitive). By default, the video primary language metadata is preferred, with a fallback to `en` translated. See [youtube.py](https://github.com/yt-dlp/yt-dlp/blob/c26f9b991a0681fd3ea548d535919cec1fbbd430/yt_dlp/extractor/youtube.py#L381-L390) for list of supported content language codes
 * `skip`: One or more of `hls`, `dash` or `translated_subs` to skip extraction of the m3u8 manifests, dash manifests and [auto-translated subtitles](https://github.com/yt-dlp/yt-dlp/issues/4090#issuecomment-1158102032) respectively
-* `player_client`: Clients to extract video data from. The main clients are `web`, `ios` and `android`, with variants `_music` and `_creator` (e.g. `ios_creator`); and `mweb`, `android_vr`, `web_safari`, `web_embedded`, `tv` and `tv_embedded` with no variants. By default, `tv,ios,web` is used, or `tv,web` is used when authenticating with cookies. The `_music` variants may be added for `music.youtube.com` URLs. Some clients, such as `web` and `android`, require a `po_token` for their formats to be downloadable. Some clients, such as the `_creator` variants, will only work with authentication. Not all clients support authentication via cookies. You can use `default` for the default clients, or you can use `all` for all clients (not recommended). You can prefix a client with `-` to exclude it, e.g. `youtube:player_client=default,-ios`
+* `player_client`: Clients to extract video data from. The main clients are `web`, `ios` and `android`, with variants `_music` and `_creator` (e.g. `ios_creator`); and `mweb`, `android_vr`, `web_safari`, `web_embedded`, `tv` and `tv_embedded` with no variants. By default, `tv,ios,web` is used, or `tv,web` is used when authenticating with cookies. The `web_music` client is added for `music.youtube.com` URLs when logged-in cookies are used. The `tv_embedded` and `web_creator` clients are added for age-restricted videos if account age-verification is required. Some clients, such as `web` and `web_music`, require a `po_token` for their formats to be downloadable. Some clients, such as the `_creator` variants, will only work with authentication. Not all clients support authentication via cookies. You can use `default` for the default clients, or you can use `all` for all clients (not recommended). You can prefix a client with `-` to exclude it, e.g. `youtube:player_client=default,-ios`
 * `player_skip`: Skip some network requests that are generally needed for robust extraction. One or more of `configs` (skip client configs), `webpage` (skip initial webpage), `js` (skip js player). While these options can help reduce the number of requests needed or avoid some rate-limiting, they could cause some issues. See [#860](https://github.com/yt-dlp/yt-dlp/pull/860) for more details
 * `player_params`: YouTube player parameters to use for player requests. Will overwrite any default ones set by yt-dlp.
 * `comment_sort`: `top` or `new` (default) - choose comment sorting mode (on YouTube's side)
@@ -1781,7 +1781,7 @@ #### youtube
 * `raise_incomplete_data`: `Incomplete Data Received` raises an error instead of reporting a warning
 * `data_sync_id`: Overrides the account Data Sync ID used in Innertube API requests. This may be needed if you are using an account with `youtube:player_skip=webpage,configs` or `youtubetab:skip=webpage`
 * `visitor_data`: Overrides the Visitor Data used in Innertube API requests. This should be used with `player_skip=webpage,configs` and without cookies. Note: this may have adverse effects if used improperly. If a session from a browser is wanted, you should pass cookies instead (which contain the Visitor ID)
-* `po_token`:  Proof of Origin (PO) Token(s) to use for requesting video playback. Comma seperated list of PO Tokens in the format `CLIENT+PO_TOKEN`, e.g. `youtube:po_token=web+XXX,android+YYY`
+* `po_token`:  Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be either `gvs` (Google Video Server URLs) or `player` (Innertube player request)
 
 #### youtubetab (YouTube playlists, channels, feeds, etc.)
 * `skip`: One or more of `webpage` (skip initial webpage download), `authcheck` (allow the download of playlists requiring authentication when no initial webpage is downloaded. This may cause unwanted behavior, see [#1122](https://github.com/yt-dlp/yt-dlp/pull/1122) for more details)
@@ -1795,13 +1795,6 @@ #### generic
 * `is_live`: Bypass live HLS detection and manually set `live_status` - a value of `false` will set `not_live`, any other value (or no value) will set `is_live`
 * `impersonate`: Target(s) to try and impersonate with the initial webpage request; e.g. `generic:impersonate=safari,chrome-110`. Use `generic:impersonate` to impersonate any available target, and use `generic:impersonate=false` to disable impersonation (default)
 
-#### funimation
-* `language`: Audio languages to extract, e.g. `funimation:language=english,japanese`
-* `version`: The video version to extract - `uncut` or `simulcast`
-
-#### crunchyrollbeta (Crunchyroll)
-* `hardsub`: One or more hardsub versions to extract (in order of preference), or `all` (default: `None` = no hardsubs will be extracted), e.g. `crunchyrollbeta:hardsub=en-US,de-DE`
-
 #### vikichannel
 * `video_types`: Types of videos to download - one or more of `episodes`, `movies`, `clips`, `trailers`
 
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index f6155dd2e..b7b19cf6e 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -2121,7 +2121,7 @@ def _build_format_filter(self, filter_spec):
         m = operator_rex.fullmatch(filter_spec)
         if m:
             try:
-                comparison_value = int(m.group('value'))
+                comparison_value = float(m.group('value'))
             except ValueError:
                 comparison_value = parse_filesize(m.group('value'))
                 if comparison_value is None:
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index d42bce21b..2566a791c 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -256,6 +256,7 @@
     BilibiliCheeseIE,
     BilibiliCheeseSeasonIE,
     BilibiliCollectionListIE,
+    BiliBiliDynamicIE,
     BilibiliFavoritesListIE,
     BiliBiliIE,
     BiliBiliPlayerIE,
@@ -440,12 +441,6 @@
     CrowdBunkerIE,
 )
 from .crtvg import CrtvgIE
-from .crunchyroll import (
-    CrunchyrollArtistIE,
-    CrunchyrollBetaIE,
-    CrunchyrollBetaShowIE,
-    CrunchyrollMusicIE,
-)
 from .cspan import (
     CSpanCongressIE,
     CSpanIE,
@@ -585,6 +580,10 @@
     EggheadCourseIE,
     EggheadLessonIE,
 )
+from .eggs import (
+    EggsArtistIE,
+    EggsIE,
+)
 from .eighttracks import EightTracksIE
 from .eitb import EitbIE
 from .elementorembed import ElementorEmbedIE
@@ -700,11 +699,6 @@
     FrontendMastersLessonIE,
 )
 from .fujitv import FujiTVFODPlus7IE
-from .funimation import (
-    FunimationIE,
-    FunimationPageIE,
-    FunimationShowIE,
-)
 from .funk import FunkIE
 from .funker530 import Funker530IE
 from .fuyintv import FuyinTVIE
@@ -1279,6 +1273,10 @@
 )
 from .nekohacker import NekoHackerIE
 from .nerdcubed import NerdCubedFeedIE
+from .nest import (
+    NestClipIE,
+    NestIE,
+)
 from .neteasemusic import (
     NetEaseMusicAlbumIE,
     NetEaseMusicDjRadioIE,
@@ -1533,6 +1531,10 @@
     PinterestCollectionIE,
     PinterestIE,
 )
+from .piramidetv import (
+    PiramideTVChannelIE,
+    PiramideTVIE,
+)
 from .pixivsketch import (
     PixivSketchIE,
     PixivSketchUserIE,
@@ -1983,6 +1985,10 @@
 from .stretchinternet import StretchInternetIE
 from .stripchat import StripchatIE
 from .stv import STVPlayerIE
+from .subsplash import (
+    SubsplashIE,
+    SubsplashPlaylistIE,
+)
 from .substack import SubstackIE
 from .sunporno import SunPornoIE
 from .sverigesradio import (
diff --git a/yt_dlp/extractor/bilibili.py b/yt_dlp/extractor/bilibili.py
index 2db951a60..be6197aea 100644
--- a/yt_dlp/extractor/bilibili.py
+++ b/yt_dlp/extractor/bilibili.py
@@ -32,6 +32,7 @@
     parse_qs,
     parse_resolution,
     qualities,
+    sanitize_url,
     smuggle_url,
     srt_subtitles_timecode,
     str_or_none,
@@ -1299,30 +1300,43 @@ def _extract_playlist(self, fetch_page, get_metadata, get_entries):
 
 
 class BilibiliCollectionListIE(BilibiliSpaceListBaseIE):
-    _VALID_URL = r'https?://space\.bilibili\.com/(?P<mid>\d+)/channel/collectiondetail/?\?sid=(?P<sid>\d+)'
+    _VALID_URL = [
+        r'https?://space\.bilibili\.com/(?P<mid>\d+)/channel/collectiondetail/?\?sid=(?P<sid>\d+)',
+        r'https?://space\.bilibili\.com/(?P<mid>\d+)/lists/(?P<sid>\d+)',
+    ]
     _TESTS = [{
-        'url': 'https://space.bilibili.com/2142762/channel/collectiondetail?sid=57445',
+        'url': 'https://space.bilibili.com/2142762/lists/3662502?type=season',
         'info_dict': {
-            'id': '2142762_57445',
-            'title': '【完结】《底特律 变人》全结局流程解说',
-            'description': '',
+            'id': '2142762_3662502',
+            'title': '合集·《黑神话悟空》流程解说',
+            'description': '黑神话悟空 相关节目',
             'uploader': '老戴在此',
             'uploader_id': '2142762',
             'timestamp': int,
             'upload_date': str,
-            'thumbnail': 'https://archive.biliimg.com/bfs/archive/e0e543ae35ad3df863ea7dea526bc32e70f4c091.jpg',
+            'thumbnail': 'https://archive.biliimg.com/bfs/archive/22302e17dc849dd4533606d71bc89df162c3a9bf.jpg',
         },
-        'playlist_mincount': 31,
+        'playlist_mincount': 62,
+    }, {
+        'url': 'https://space.bilibili.com/2142762/lists/3662502',
+        'only_matching': True,
+    }, {
+        'url': 'https://space.bilibili.com/2142762/channel/collectiondetail?sid=57445',
+        'only_matching': True,
     }]
 
+    @classmethod
+    def suitable(cls, url):
+        return False if BilibiliSeriesListIE.suitable(url) else super().suitable(url)
+
     def _real_extract(self, url):
         mid, sid = self._match_valid_url(url).group('mid', 'sid')
         playlist_id = f'{mid}_{sid}'
 
         def fetch_page(page_idx):
             return self._download_json(
-                'https://api.bilibili.com/x/polymer/space/seasons_archives_list',
-                playlist_id, note=f'Downloading page {page_idx}',
+                'https://api.bilibili.com/x/polymer/web-space/seasons_archives_list',
+                playlist_id, note=f'Downloading page {page_idx}', headers={'Referer': url},
                 query={'mid': mid, 'season_id': sid, 'page_num': page_idx + 1, 'page_size': 30})['data']
 
         def get_metadata(page_data):
@@ -1349,9 +1363,12 @@ def get_entries(page_data):
 
 
 class BilibiliSeriesListIE(BilibiliSpaceListBaseIE):
-    _VALID_URL = r'https?://space\.bilibili\.com/(?P<mid>\d+)/channel/seriesdetail/?\?\bsid=(?P<sid>\d+)'
+    _VALID_URL = [
+        r'https?://space\.bilibili\.com/(?P<mid>\d+)/channel/seriesdetail/?\?\bsid=(?P<sid>\d+)',
+        r'https?://space\.bilibili\.com/(?P<mid>\d+)/lists/(?P<sid>\d+)/?\?(?:[^#]+&)?type=series(?:[&#]|$)',
+    ]
     _TESTS = [{
-        'url': 'https://space.bilibili.com/1958703906/channel/seriesdetail?sid=547718&ctype=0',
+        'url': 'https://space.bilibili.com/1958703906/lists/547718?type=series',
         'info_dict': {
             'id': '1958703906_547718',
             'title': '直播回放',
@@ -1364,6 +1381,9 @@ class BilibiliSeriesListIE(BilibiliSpaceListBaseIE):
             'modified_date': str,
         },
         'playlist_mincount': 513,
+    }, {
+        'url': 'https://space.bilibili.com/1958703906/channel/seriesdetail?sid=547718&ctype=0',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
@@ -1382,7 +1402,7 @@ def _real_extract(self, url):
         def fetch_page(page_idx):
             return self._download_json(
                 'https://api.bilibili.com/x/series/archives',
-                playlist_id, note=f'Downloading page {page_idx}',
+                playlist_id, note=f'Downloading page {page_idx}', headers={'Referer': url},
                 query={'mid': mid, 'series_id': sid, 'pn': page_idx + 1, 'ps': 30})['data']
 
         def get_metadata(page_data):
@@ -1861,6 +1881,47 @@ def _real_extract(self, url):
             ie=BiliBiliIE.ie_key(), video_id=video_id)
 
 
+class BiliBiliDynamicIE(InfoExtractor):
+    _VALID_URL = r'https?://(?:t\.bilibili\.com|(?:www\.)?bilibili\.com/opus)/(?P<id>\d+)'
+    _TESTS = [{
+        'url': 'https://t.bilibili.com/998134289197432852',
+        'info_dict': {
+            'id': 'BV1TAmBYVEJr',
+            'ext': 'mp4',
+            'uploader_id': '1192648858',
+            'comment_count': int,
+            '_old_archive_ids': ['bilibili 113457567568273_part1'],
+            'thumbnail': 'http://i2.hdslb.com/bfs/archive/50091efd965d9f13ff6814f7ad374f90ab21e77d.jpg',
+            'duration': 929.238,
+            'upload_date': '20241110',
+            'uploader': '何同学工作室',
+            'like_count': int,
+            'view_count': int,
+            'title': '美国小朋友就玩这个？！何同学工作室11月开箱',
+            'description': '本期产品信息：\n机器狗\n气味模拟器\nCloudboom Strike LS\n无弦吉他\n蓝牙磁带音箱\n神奇画板',
+            'timestamp': 1731232800,
+            'tags': list,
+            'chapters': list,
+        },
+    }]
+
+    def _real_extract(self, url):
+        post_id = self._match_id(url)
+        # Without the newer chrome UA, the API will return an error (-352)
+        post_data = self._download_json(
+            'https://api.bilibili.com/x/polymer/web-dynamic/v1/detail', post_id,
+            query={'id': post_id}, headers={
+                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
+            })
+        video_url = traverse_obj(post_data, (
+            'data', 'item', (None, 'orig'), 'modules', 'module_dynamic',
+            (('major', ('archive', 'pgc')), ('additional', ('reserve', 'common'))),
+            'jump_url', {url_or_none}, any, {sanitize_url}))
+        if not video_url or (self.suitable(video_url) and post_id == self._match_id(video_url)):
+            raise ExtractorError('No valid video URL found', expected=True)
+        return self.url_result(video_url)
+
+
 class BiliIntlBaseIE(InfoExtractor):
     _API_URL = 'https://api.bilibili.tv/intl/gateway'
     _NETRC_MACHINE = 'biliintl'
diff --git a/yt_dlp/extractor/bluesky.py b/yt_dlp/extractor/bluesky.py
index 0e58a0932..42dadf7b9 100644
--- a/yt_dlp/extractor/bluesky.py
+++ b/yt_dlp/extractor/bluesky.py
@@ -88,7 +88,7 @@ class BlueskyIE(InfoExtractor):
         },
     }, {
         'url': 'https://bsky.app/profile/de1.pds.tentacle.expert/post/3l3w4tnezek2e',
-        'md5': '1af9c7fda061cf7593bbffca89e43d1c',
+        'md5': 'cc0110ed1f6b0247caac8234cc1e861d',
         'info_dict': {
             'id': '3l3w4tnezek2e',
             'ext': 'mp4',
@@ -133,6 +133,8 @@ class BlueskyIE(InfoExtractor):
             'channel_follower_count': int,
             'categories': ['Entertainment'],
             'tags': [],
+            'chapters': list,
+            'heatmap': 'count:100',
         },
         'add_ie': ['Youtube'],
     }, {
@@ -184,14 +186,14 @@ class BlueskyIE(InfoExtractor):
             },
         },
     }, {
-        'url': 'https://bsky.app/profile/alt.bun.how/post/3l7rdfxhyds2f',
+        'url': 'https://bsky.app/profile/cinny.bun.how/post/3l7rdfxhyds2f',
         'md5': '8775118b235cf9fa6b5ad30f95cda75c',
         'info_dict': {
             'id': '3l7rdfxhyds2f',
             'ext': 'mp4',
             'uploader': 'cinnamon',
-            'uploader_id': 'alt.bun.how',
-            'uploader_url': 'https://bsky.app/profile/alt.bun.how',
+            'uploader_id': 'cinny.bun.how',
+            'uploader_url': 'https://bsky.app/profile/cinny.bun.how',
             'channel_id': 'did:plc:7x6rtuenkuvxq3zsvffp2ide',
             'channel_url': 'https://bsky.app/profile/did:plc:7x6rtuenkuvxq3zsvffp2ide',
             'thumbnail': r're:https://video.bsky.app/watch/.*\.jpg$',
@@ -341,6 +343,7 @@ def _extract_videos(self, root, video_id, embed_path='embed', record_path='recor
 
             formats.append({
                 'format_id': 'blob',
+                'quality': 1,
                 'url': update_url_query(
                     self._BLOB_URL_TMPL.format(endpoint), {'did': did, 'cid': video_cid}),
                 **traverse_obj(root, (*embed_path, 'aspectRatio', {
diff --git a/yt_dlp/extractor/crunchyroll.py b/yt_dlp/extractor/crunchyroll.py
deleted file mode 100644
index 8faed179b..000000000
--- a/yt_dlp/extractor/crunchyroll.py
+++ /dev/null
@@ -1,692 +0,0 @@
-import base64
-import uuid
-
-from .common import InfoExtractor
-from ..networking import Request
-from ..networking.exceptions import HTTPError
-from ..utils import (
-    ExtractorError,
-    float_or_none,
-    format_field,
-    int_or_none,
-    jwt_decode_hs256,
-    parse_age_limit,
-    parse_count,
-    parse_iso8601,
-    qualities,
-    time_seconds,
-    traverse_obj,
-    url_or_none,
-    urlencode_postdata,
-)
-
-
-class CrunchyrollBaseIE(InfoExtractor):
-    _BASE_URL = 'https://www.crunchyroll.com'
-    _API_BASE = 'https://api.crunchyroll.com'
-    _NETRC_MACHINE = 'crunchyroll'
-    _SWITCH_USER_AGENT = 'Crunchyroll/1.8.0 Nintendo Switch/12.3.12.0 UE4/4.27'
-    _REFRESH_TOKEN = None
-    _AUTH_HEADERS = None
-    _AUTH_EXPIRY = None
-    _API_ENDPOINT = None
-    _BASIC_AUTH = 'Basic ' + base64.b64encode(':'.join((
-        't-kdgp2h8c3jub8fn0fq',
-        'yfLDfMfrYvKXh4JXS1LEI2cCqu1v5Wan',
-    )).encode()).decode()
-    _IS_PREMIUM = None
-    _LOCALE_LOOKUP = {
-        'ar': 'ar-SA',
-        'de': 'de-DE',
-        '': 'en-US',
-        'es': 'es-419',
-        'es-es': 'es-ES',
-        'fr': 'fr-FR',
-        'it': 'it-IT',
-        'pt-br': 'pt-BR',
-        'pt-pt': 'pt-PT',
-        'ru': 'ru-RU',
-        'hi': 'hi-IN',
-    }
-
-    def _set_auth_info(self, response):
-        CrunchyrollBaseIE._IS_PREMIUM = 'cr_premium' in traverse_obj(response, ('access_token', {jwt_decode_hs256}, 'benefits', ...))
-        CrunchyrollBaseIE._AUTH_HEADERS = {'Authorization': response['token_type'] + ' ' + response['access_token']}
-        CrunchyrollBaseIE._AUTH_EXPIRY = time_seconds(seconds=traverse_obj(response, ('expires_in', {float_or_none}), default=300) - 10)
-
-    def _request_token(self, headers, data, note='Requesting token', errnote='Failed to request token'):
-        try:
-            return self._download_json(
-                f'{self._BASE_URL}/auth/v1/token', None, note=note, errnote=errnote,
-                headers=headers, data=urlencode_postdata(data), impersonate=True)
-        except ExtractorError as error:
-            if not isinstance(error.cause, HTTPError) or error.cause.status != 403:
-                raise
-            if target := error.cause.response.extensions.get('impersonate'):
-                raise ExtractorError(f'Got HTTP Error 403 when using impersonate target "{target}"')
-            raise ExtractorError(
-                'Request blocked by Cloudflare. '
-                'Install the required impersonation dependency if possible, '
-                'or else navigate to Crunchyroll in your browser, '
-                'then pass the fresh cookies (with --cookies-from-browser or --cookies) '
-                'and your browser\'s User-Agent (with --user-agent)', expected=True)
-
-    def _perform_login(self, username, password):
-        if not CrunchyrollBaseIE._REFRESH_TOKEN:
-            CrunchyrollBaseIE._REFRESH_TOKEN = self.cache.load(self._NETRC_MACHINE, username)
-        if CrunchyrollBaseIE._REFRESH_TOKEN:
-            return
-
-        try:
-            login_response = self._request_token(
-                headers={'Authorization': self._BASIC_AUTH}, data={
-                    'username': username,
-                    'password': password,
-                    'grant_type': 'password',
-                    'scope': 'offline_access',
-                }, note='Logging in', errnote='Failed to log in')
-        except ExtractorError as error:
-            if isinstance(error.cause, HTTPError) and error.cause.status == 401:
-                raise ExtractorError('Invalid username and/or password', expected=True)
-            raise
-
-        CrunchyrollBaseIE._REFRESH_TOKEN = login_response['refresh_token']
-        self.cache.store(self._NETRC_MACHINE, username, CrunchyrollBaseIE._REFRESH_TOKEN)
-        self._set_auth_info(login_response)
-
-    def _update_auth(self):
-        if CrunchyrollBaseIE._AUTH_HEADERS and CrunchyrollBaseIE._AUTH_EXPIRY > time_seconds():
-            return
-
-        auth_headers = {'Authorization': self._BASIC_AUTH}
-        if CrunchyrollBaseIE._REFRESH_TOKEN:
-            data = {
-                'refresh_token': CrunchyrollBaseIE._REFRESH_TOKEN,
-                'grant_type': 'refresh_token',
-                'scope': 'offline_access',
-            }
-        else:
-            data = {'grant_type': 'client_id'}
-            auth_headers['ETP-Anonymous-ID'] = uuid.uuid4()
-        try:
-            auth_response = self._request_token(auth_headers, data)
-        except ExtractorError as error:
-            username, password = self._get_login_info()
-            if not username or not isinstance(error.cause, HTTPError) or error.cause.status != 400:
-                raise
-            self.to_screen('Refresh token has expired. Re-logging in')
-            CrunchyrollBaseIE._REFRESH_TOKEN = None
-            self.cache.store(self._NETRC_MACHINE, username, None)
-            self._perform_login(username, password)
-            return
-
-        self._set_auth_info(auth_response)
-
-    def _locale_from_language(self, language):
-        config_locale = self._configuration_arg('metadata', ie_key=CrunchyrollBetaIE, casesense=True)
-        return config_locale[0] if config_locale else self._LOCALE_LOOKUP.get(language)
-
-    def _call_base_api(self, endpoint, internal_id, lang, note=None, query={}):
-        self._update_auth()
-
-        if not endpoint.startswith('/'):
-            endpoint = f'/{endpoint}'
-
-        query = query.copy()
-        locale = self._locale_from_language(lang)
-        if locale:
-            query['locale'] = locale
-
-        return self._download_json(
-            f'{self._BASE_URL}{endpoint}', internal_id, note or f'Calling API: {endpoint}',
-            headers=CrunchyrollBaseIE._AUTH_HEADERS, query=query)
-
-    def _call_api(self, path, internal_id, lang, note='api', query={}):
-        if not path.startswith(f'/content/v2/{self._API_ENDPOINT}/'):
-            path = f'/content/v2/{self._API_ENDPOINT}/{path}'
-
-        try:
-            result = self._call_base_api(
-                path, internal_id, lang, f'Downloading {note} JSON ({self._API_ENDPOINT})', query=query)
-        except ExtractorError as error:
-            if isinstance(error.cause, HTTPError) and error.cause.status == 404:
-                return None
-            raise
-
-        if not result:
-            raise ExtractorError(f'Unexpected response when downloading {note} JSON')
-        return result
-
-    def _extract_chapters(self, internal_id):
-        # if no skip events are available, a 403 xml error is returned
-        skip_events = self._download_json(
-            f'https://static.crunchyroll.com/skip-events/production/{internal_id}.json',
-            internal_id, note='Downloading chapter info', fatal=False, errnote=False)
-        if not skip_events:
-            return None
-
-        chapters = []
-        for event in ('recap', 'intro', 'credits', 'preview'):
-            start = traverse_obj(skip_events, (event, 'start', {float_or_none}))
-            end = traverse_obj(skip_events, (event, 'end', {float_or_none}))
-            # some chapters have no start and/or ending time, they will just be ignored
-            if start is None or end is None:
-                continue
-            chapters.append({'title': event.capitalize(), 'start_time': start, 'end_time': end})
-
-        return chapters
-
-    def _extract_stream(self, identifier, display_id=None):
-        if not display_id:
-            display_id = identifier
-
-        self._update_auth()
-        headers = {**CrunchyrollBaseIE._AUTH_HEADERS, 'User-Agent': self._SWITCH_USER_AGENT}
-        try:
-            stream_response = self._download_json(
-                f'https://cr-play-service.prd.crunchyrollsvc.com/v1/{identifier}/console/switch/play',
-                display_id, note='Downloading stream info', errnote='Failed to download stream info', headers=headers)
-        except ExtractorError as error:
-            if self.get_param('ignore_no_formats_error'):
-                self.report_warning(error.orig_msg)
-                return [], {}
-            elif isinstance(error.cause, HTTPError) and error.cause.status == 420:
-                raise ExtractorError(
-                    'You have reached the rate-limit for active streams; try again later', expected=True)
-            raise
-
-        available_formats = {'': ('', '', stream_response['url'])}
-        for hardsub_lang, stream in traverse_obj(stream_response, ('hardSubs', {dict.items}, lambda _, v: v[1]['url'])):
-            available_formats[hardsub_lang] = (f'hardsub-{hardsub_lang}', hardsub_lang, stream['url'])
-
-        requested_hardsubs = [('' if val == 'none' else val) for val in (self._configuration_arg('hardsub') or ['none'])]
-        hardsub_langs = [lang for lang in available_formats if lang]
-        if hardsub_langs and 'all' not in requested_hardsubs:
-            full_format_langs = set(requested_hardsubs)
-            self.to_screen(f'Available hardsub languages: {", ".join(hardsub_langs)}')
-            self.to_screen(
-                'To extract formats of a hardsub language, use '
-                '"--extractor-args crunchyrollbeta:hardsub=<language_code or all>". '
-                'See https://github.com/yt-dlp/yt-dlp#crunchyrollbeta-crunchyroll for more info',
-                only_once=True)
-        else:
-            full_format_langs = set(map(str.lower, available_formats))
-
-        audio_locale = traverse_obj(stream_response, ('audioLocale', {str}))
-        hardsub_preference = qualities(requested_hardsubs[::-1])
-        formats, subtitles = [], {}
-        for format_id, hardsub_lang, stream_url in available_formats.values():
-            if hardsub_lang.lower() in full_format_langs:
-                adaptive_formats, dash_subs = self._extract_mpd_formats_and_subtitles(
-                    stream_url, display_id, mpd_id=format_id, headers=CrunchyrollBaseIE._AUTH_HEADERS,
-                    fatal=False, note=f'Downloading {f"{format_id} " if hardsub_lang else ""}MPD manifest')
-                self._merge_subtitles(dash_subs, target=subtitles)
-            else:
-                continue  # XXX: Update this if meta mpd formats work; will be tricky with token invalidation
-            for f in adaptive_formats:
-                if f.get('acodec') != 'none':
-                    f['language'] = audio_locale
-                f['quality'] = hardsub_preference(hardsub_lang.lower())
-            formats.extend(adaptive_formats)
-
-        for locale, subtitle in traverse_obj(stream_response, (('subtitles', 'captions'), {dict.items}, ...)):
-            subtitles.setdefault(locale, []).append(traverse_obj(subtitle, {'url': 'url', 'ext': 'format'}))
-
-        # Invalidate stream token to avoid rate-limit
-        error_msg = 'Unable to invalidate stream token; you may experience rate-limiting'
-        if stream_token := stream_response.get('token'):
-            self._request_webpage(Request(
-                f'https://cr-play-service.prd.crunchyrollsvc.com/v1/token/{identifier}/{stream_token}/inactive',
-                headers=headers, method='PATCH'), display_id, 'Invalidating stream token', error_msg, fatal=False)
-        else:
-            self.report_warning(error_msg)
-
-        return formats, subtitles
-
-
-class CrunchyrollCmsBaseIE(CrunchyrollBaseIE):
-    _API_ENDPOINT = 'cms'
-    _CMS_EXPIRY = None
-
-    def _call_cms_api_signed(self, path, internal_id, lang, note='api'):
-        if not CrunchyrollCmsBaseIE._CMS_EXPIRY or CrunchyrollCmsBaseIE._CMS_EXPIRY <= time_seconds():
-            response = self._call_base_api('index/v2', None, lang, 'Retrieving signed policy')['cms_web']
-            CrunchyrollCmsBaseIE._CMS_QUERY = {
-                'Policy': response['policy'],
-                'Signature': response['signature'],
-                'Key-Pair-Id': response['key_pair_id'],
-            }
-            CrunchyrollCmsBaseIE._CMS_BUCKET = response['bucket']
-            CrunchyrollCmsBaseIE._CMS_EXPIRY = parse_iso8601(response['expires']) - 10
-
-        if not path.startswith('/cms/v2'):
-            path = f'/cms/v2{CrunchyrollCmsBaseIE._CMS_BUCKET}/{path}'
-
-        return self._call_base_api(
-            path, internal_id, lang, f'Downloading {note} JSON (signed cms)', query=CrunchyrollCmsBaseIE._CMS_QUERY)
-
-
-class CrunchyrollBetaIE(CrunchyrollCmsBaseIE):
-    IE_NAME = 'crunchyroll'
-    _VALID_URL = r'''(?x)
-        https?://(?:beta\.|www\.)?crunchyroll\.com/
-        (?:(?P<lang>\w{2}(?:-\w{2})?)/)?
-        watch/(?!concert|musicvideo)(?P<id>\w+)'''
-    _TESTS = [{
-        # Premium only
-        'url': 'https://www.crunchyroll.com/watch/GY2P1Q98Y/to-the-future',
-        'info_dict': {
-            'id': 'GY2P1Q98Y',
-            'ext': 'mp4',
-            'duration': 1380.241,
-            'timestamp': 1459632600,
-            'description': 'md5:a022fbec4fbb023d43631032c91ed64b',
-            'title': 'World Trigger Episode 73 – To the Future',
-            'upload_date': '20160402',
-            'series': 'World Trigger',
-            'series_id': 'GR757DMKY',
-            'season': 'World Trigger',
-            'season_id': 'GR9P39NJ6',
-            'season_number': 1,
-            'episode': 'To the Future',
-            'episode_number': 73,
-            'thumbnail': r're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
-            'chapters': 'count:2',
-            'age_limit': 14,
-            'like_count': int,
-            'dislike_count': int,
-        },
-        'params': {
-            'skip_download': 'm3u8',
-            'extractor_args': {'crunchyrollbeta': {'hardsub': ['de-DE']}},
-            'format': 'bv[format_id~=hardsub]',
-        },
-    }, {
-        # Premium only
-        'url': 'https://www.crunchyroll.com/watch/GYE5WKQGR',
-        'info_dict': {
-            'id': 'GYE5WKQGR',
-            'ext': 'mp4',
-            'duration': 366.459,
-            'timestamp': 1476788400,
-            'description': 'md5:74b67283ffddd75f6e224ca7dc031e76',
-            'title': 'SHELTER – Porter Robinson presents Shelter the Animation',
-            'upload_date': '20161018',
-            'series': 'SHELTER',
-            'series_id': 'GYGG09WWY',
-            'season': 'SHELTER',
-            'season_id': 'GR09MGK4R',
-            'season_number': 1,
-            'episode': 'Porter Robinson presents Shelter the Animation',
-            'episode_number': 0,
-            'thumbnail': r're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
-            'age_limit': 14,
-            'like_count': int,
-            'dislike_count': int,
-        },
-        'params': {'skip_download': True},
-    }, {
-        'url': 'https://www.crunchyroll.com/watch/GJWU2VKK3/cherry-blossom-meeting-and-a-coming-blizzard',
-        'info_dict': {
-            'id': 'GJWU2VKK3',
-            'ext': 'mp4',
-            'duration': 1420.054,
-            'description': 'md5:2d1c67c0ec6ae514d9c30b0b99a625cd',
-            'title': 'The Ice Guy and His Cool Female Colleague Episode 1 – Cherry Blossom Meeting and a Coming Blizzard',
-            'series': 'The Ice Guy and His Cool Female Colleague',
-            'series_id': 'GW4HM75NP',
-            'season': 'The Ice Guy and His Cool Female Colleague',
-            'season_id': 'GY9PC21VE',
-            'season_number': 1,
-            'episode': 'Cherry Blossom Meeting and a Coming Blizzard',
-            'episode_number': 1,
-            'chapters': 'count:2',
-            'thumbnail': r're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
-            'timestamp': 1672839000,
-            'upload_date': '20230104',
-            'age_limit': 14,
-            'like_count': int,
-            'dislike_count': int,
-        },
-        'params': {'skip_download': 'm3u8'},
-    }, {
-        'url': 'https://www.crunchyroll.com/watch/GM8F313NQ',
-        'info_dict': {
-            'id': 'GM8F313NQ',
-            'ext': 'mp4',
-            'title': 'Garakowa -Restore the World-',
-            'description': 'md5:8d2f8b6b9dd77d87810882e7d2ee5608',
-            'duration': 3996.104,
-            'age_limit': 13,
-            'thumbnail': r're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
-        },
-        'params': {'skip_download': 'm3u8'},
-        'skip': 'no longer exists',
-    }, {
-        'url': 'https://www.crunchyroll.com/watch/G62PEZ2E6',
-        'info_dict': {
-            'id': 'G62PEZ2E6',
-            'description': 'md5:8d2f8b6b9dd77d87810882e7d2ee5608',
-            'age_limit': 13,
-            'duration': 65.138,
-            'title': 'Garakowa -Restore the World-',
-        },
-        'playlist_mincount': 5,
-    }, {
-        'url': 'https://www.crunchyroll.com/de/watch/GY2P1Q98Y',
-        'only_matching': True,
-    }, {
-        'url': 'https://beta.crunchyroll.com/pt-br/watch/G8WUN8VKP/the-ruler-of-conspiracy',
-        'only_matching': True,
-    }]
-    # We want to support lazy playlist filtering and movie listings cannot be inside a playlist
-    _RETURN_TYPE = 'video'
-
-    def _real_extract(self, url):
-        lang, internal_id = self._match_valid_url(url).group('lang', 'id')
-
-        # We need to use unsigned API call to allow ratings query string
-        response = traverse_obj(self._call_api(
-            f'objects/{internal_id}', internal_id, lang, 'object info', {'ratings': 'true'}), ('data', 0, {dict}))
-        if not response:
-            raise ExtractorError(f'No video with id {internal_id} could be found (possibly region locked?)', expected=True)
-
-        object_type = response.get('type')
-        if object_type == 'episode':
-            result = self._transform_episode_response(response)
-
-        elif object_type == 'movie':
-            result = self._transform_movie_response(response)
-
-        elif object_type == 'movie_listing':
-            first_movie_id = traverse_obj(response, ('movie_listing_metadata', 'first_movie_id'))
-            if not self._yes_playlist(internal_id, first_movie_id):
-                return self.url_result(f'{self._BASE_URL}/{lang}watch/{first_movie_id}', CrunchyrollBetaIE, first_movie_id)
-
-            def entries():
-                movies = self._call_api(f'movie_listings/{internal_id}/movies', internal_id, lang, 'movie list')
-                for movie_response in traverse_obj(movies, ('data', ...)):
-                    yield self.url_result(
-                        f'{self._BASE_URL}/{lang}watch/{movie_response["id"]}',
-                        CrunchyrollBetaIE, **self._transform_movie_response(movie_response))
-
-            return self.playlist_result(entries(), **self._transform_movie_response(response))
-
-        else:
-            raise ExtractorError(f'Unknown object type {object_type}')
-
-        if not self._IS_PREMIUM and traverse_obj(response, (f'{object_type}_metadata', 'is_premium_only')):
-            message = f'This {object_type} is for premium members only'
-            if CrunchyrollBaseIE._REFRESH_TOKEN:
-                self.raise_no_formats(message, expected=True, video_id=internal_id)
-            else:
-                self.raise_login_required(message, method='password', metadata_available=True)
-        else:
-            result['formats'], result['subtitles'] = self._extract_stream(internal_id)
-
-        result['chapters'] = self._extract_chapters(internal_id)
-
-        def calculate_count(item):
-            return parse_count(''.join((item['displayed'], item.get('unit') or '')))
-
-        result.update(traverse_obj(response, ('rating', {
-            'like_count': ('up', {calculate_count}),
-            'dislike_count': ('down', {calculate_count}),
-        })))
-
-        return result
-
-    @staticmethod
-    def _transform_episode_response(data):
-        metadata = traverse_obj(data, (('episode_metadata', None), {dict}), get_all=False) or {}
-        return {
-            'id': data['id'],
-            'title': ' \u2013 '.join((
-                ('{}{}'.format(
-                    format_field(metadata, 'season_title'),
-                    format_field(metadata, 'episode', ' Episode %s'))),
-                format_field(data, 'title'))),
-            **traverse_obj(data, {
-                'episode': ('title', {str}),
-                'description': ('description', {str}, {lambda x: x.replace(r'\r\n', '\n')}),
-                'thumbnails': ('images', 'thumbnail', ..., ..., {
-                    'url': ('source', {url_or_none}),
-                    'width': ('width', {int_or_none}),
-                    'height': ('height', {int_or_none}),
-                }),
-            }),
-            **traverse_obj(metadata, {
-                'duration': ('duration_ms', {float_or_none(scale=1000)}),
-                'timestamp': ('upload_date', {parse_iso8601}),
-                'series': ('series_title', {str}),
-                'series_id': ('series_id', {str}),
-                'season': ('season_title', {str}),
-                'season_id': ('season_id', {str}),
-                'season_number': ('season_number', ({int}, {float_or_none})),
-                'episode_number': ('sequence_number', ({int}, {float_or_none})),
-                'age_limit': ('maturity_ratings', -1, {parse_age_limit}),
-                'language': ('audio_locale', {str}),
-            }, get_all=False),
-        }
-
-    @staticmethod
-    def _transform_movie_response(data):
-        metadata = traverse_obj(data, (('movie_metadata', 'movie_listing_metadata', None), {dict}), get_all=False) or {}
-        return {
-            'id': data['id'],
-            **traverse_obj(data, {
-                'title': ('title', {str}),
-                'description': ('description', {str}, {lambda x: x.replace(r'\r\n', '\n')}),
-                'thumbnails': ('images', 'thumbnail', ..., ..., {
-                    'url': ('source', {url_or_none}),
-                    'width': ('width', {int_or_none}),
-                    'height': ('height', {int_or_none}),
-                }),
-            }),
-            **traverse_obj(metadata, {
-                'duration': ('duration_ms', {float_or_none(scale=1000)}),
-                'age_limit': ('maturity_ratings', -1, {parse_age_limit}),
-            }),
-        }
-
-
-class CrunchyrollBetaShowIE(CrunchyrollCmsBaseIE):
-    IE_NAME = 'crunchyroll:playlist'
-    _VALID_URL = r'''(?x)
-        https?://(?:beta\.|www\.)?crunchyroll\.com/
-        (?P<lang>(?:\w{2}(?:-\w{2})?/)?)
-        series/(?P<id>\w+)'''
-    _TESTS = [{
-        'url': 'https://www.crunchyroll.com/series/GY19NQ2QR/Girl-Friend-BETA',
-        'info_dict': {
-            'id': 'GY19NQ2QR',
-            'title': 'Girl Friend BETA',
-            'description': 'md5:99c1b22ee30a74b536a8277ced8eb750',
-            # XXX: `thumbnail` does not get set from `thumbnails` in playlist
-            #  'thumbnail': r're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
-            'age_limit': 14,
-        },
-        'playlist_mincount': 10,
-    }, {
-        'url': 'https://beta.crunchyroll.com/it/series/GY19NQ2QR',
-        'only_matching': True,
-    }]
-
-    def _real_extract(self, url):
-        lang, internal_id = self._match_valid_url(url).group('lang', 'id')
-
-        def entries():
-            seasons_response = self._call_cms_api_signed(f'seasons?series_id={internal_id}', internal_id, lang, 'seasons')
-            for season in traverse_obj(seasons_response, ('items', ..., {dict})):
-                episodes_response = self._call_cms_api_signed(
-                    f'episodes?season_id={season["id"]}', season['id'], lang, 'episode list')
-                for episode_response in traverse_obj(episodes_response, ('items', ..., {dict})):
-                    yield self.url_result(
-                        f'{self._BASE_URL}/{lang}watch/{episode_response["id"]}',
-                        CrunchyrollBetaIE, **CrunchyrollBetaIE._transform_episode_response(episode_response))
-
-        return self.playlist_result(
-            entries(), internal_id,
-            **traverse_obj(self._call_api(f'series/{internal_id}', internal_id, lang, 'series'), ('data', 0, {
-                'title': ('title', {str}),
-                'description': ('description', {lambda x: x.replace(r'\r\n', '\n')}),
-                'age_limit': ('maturity_ratings', -1, {parse_age_limit}),
-                'thumbnails': ('images', ..., ..., ..., {
-                    'url': ('source', {url_or_none}),
-                    'width': ('width', {int_or_none}),
-                    'height': ('height', {int_or_none}),
-                }),
-            })))
-
-
-class CrunchyrollMusicIE(CrunchyrollBaseIE):
-    IE_NAME = 'crunchyroll:music'
-    _VALID_URL = r'''(?x)
-        https?://(?:www\.)?crunchyroll\.com/
-        (?P<lang>(?:\w{2}(?:-\w{2})?/)?)
-        watch/(?P<type>concert|musicvideo)/(?P<id>\w+)'''
-    _TESTS = [{
-        'url': 'https://www.crunchyroll.com/de/watch/musicvideo/MV5B02C79',
-        'info_dict': {
-            'ext': 'mp4',
-            'id': 'MV5B02C79',
-            'display_id': 'egaono-hana',
-            'title': 'Egaono Hana',
-            'track': 'Egaono Hana',
-            'artists': ['Goose house'],
-            'thumbnail': r're:(?i)^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
-            'genres': ['J-Pop'],
-        },
-        'params': {'skip_download': 'm3u8'},
-    }, {
-        'url': 'https://www.crunchyroll.com/watch/musicvideo/MV88BB7F2C',
-        'info_dict': {
-            'ext': 'mp4',
-            'id': 'MV88BB7F2C',
-            'display_id': 'crossing-field',
-            'title': 'Crossing Field',
-            'track': 'Crossing Field',
-            'artists': ['LiSA'],
-            'thumbnail': r're:(?i)^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
-            'genres': ['Anime'],
-        },
-        'params': {'skip_download': 'm3u8'},
-        'skip': 'no longer exists',
-    }, {
-        'url': 'https://www.crunchyroll.com/watch/concert/MC2E2AC135',
-        'info_dict': {
-            'ext': 'mp4',
-            'id': 'MC2E2AC135',
-            'display_id': 'live-is-smile-always-364joker-at-yokohama-arena',
-            'title': 'LiVE is Smile Always-364+JOKER- at YOKOHAMA ARENA',
-            'track': 'LiVE is Smile Always-364+JOKER- at YOKOHAMA ARENA',
-            'artists': ['LiSA'],
-            'thumbnail': r're:(?i)^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
-            'description': 'md5:747444e7e6300907b7a43f0a0503072e',
-            'genres': ['J-Pop'],
-        },
-        'params': {'skip_download': 'm3u8'},
-    }, {
-        'url': 'https://www.crunchyroll.com/de/watch/musicvideo/MV5B02C79/egaono-hana',
-        'only_matching': True,
-    }, {
-        'url': 'https://www.crunchyroll.com/watch/concert/MC2E2AC135/live-is-smile-always-364joker-at-yokohama-arena',
-        'only_matching': True,
-    }, {
-        'url': 'https://www.crunchyroll.com/watch/musicvideo/MV88BB7F2C/crossing-field',
-        'only_matching': True,
-    }]
-    _API_ENDPOINT = 'music'
-
-    def _real_extract(self, url):
-        lang, internal_id, object_type = self._match_valid_url(url).group('lang', 'id', 'type')
-        path, name = {
-            'concert': ('concerts', 'concert info'),
-            'musicvideo': ('music_videos', 'music video info'),
-        }[object_type]
-        response = traverse_obj(self._call_api(f'{path}/{internal_id}', internal_id, lang, name), ('data', 0, {dict}))
-        if not response:
-            raise ExtractorError(f'No video with id {internal_id} could be found (possibly region locked?)', expected=True)
-
-        result = self._transform_music_response(response)
-
-        if not self._IS_PREMIUM and response.get('isPremiumOnly'):
-            message = f'This {response.get("type") or "media"} is for premium members only'
-            if CrunchyrollBaseIE._REFRESH_TOKEN:
-                self.raise_no_formats(message, expected=True, video_id=internal_id)
-            else:
-                self.raise_login_required(message, method='password', metadata_available=True)
-        else:
-            result['formats'], _ = self._extract_stream(f'music/{internal_id}', internal_id)
-
-        return result
-
-    @staticmethod
-    def _transform_music_response(data):
-        return {
-            'id': data['id'],
-            **traverse_obj(data, {
-                'display_id': 'slug',
-                'title': 'title',
-                'track': 'title',
-                'artists': ('artist', 'name', all),
-                'description': ('description', {str}, {lambda x: x.replace(r'\r\n', '\n') or None}),
-                'thumbnails': ('images', ..., ..., {
-                    'url': ('source', {url_or_none}),
-                    'width': ('width', {int_or_none}),
-                    'height': ('height', {int_or_none}),
-                }),
-                'genres': ('genres', ..., 'displayValue'),
-                'age_limit': ('maturity_ratings', -1, {parse_age_limit}),
-            }),
-        }
-
-
-class CrunchyrollArtistIE(CrunchyrollBaseIE):
-    IE_NAME = 'crunchyroll:artist'
-    _VALID_URL = r'''(?x)
-        https?://(?:www\.)?crunchyroll\.com/
-        (?P<lang>(?:\w{2}(?:-\w{2})?/)?)
-        artist/(?P<id>\w{10})'''
-    _TESTS = [{
-        'url': 'https://www.crunchyroll.com/artist/MA179CB50D',
-        'info_dict': {
-            'id': 'MA179CB50D',
-            'title': 'LiSA',
-            'genres': ['Anime', 'J-Pop', 'Rock'],
-            'description': 'md5:16d87de61a55c3f7d6c454b73285938e',
-        },
-        'playlist_mincount': 83,
-    }, {
-        'url': 'https://www.crunchyroll.com/artist/MA179CB50D/lisa',
-        'only_matching': True,
-    }]
-    _API_ENDPOINT = 'music'
-
-    def _real_extract(self, url):
-        lang, internal_id = self._match_valid_url(url).group('lang', 'id')
-        response = traverse_obj(self._call_api(
-            f'artists/{internal_id}', internal_id, lang, 'artist info'), ('data', 0))
-
-        def entries():
-            for attribute, path in [('concerts', 'concert'), ('videos', 'musicvideo')]:
-                for internal_id in traverse_obj(response, (attribute, ...)):
-                    yield self.url_result(f'{self._BASE_URL}/watch/{path}/{internal_id}', CrunchyrollMusicIE, internal_id)
-
-        return self.playlist_result(entries(), **self._transform_artist_response(response))
-
-    @staticmethod
-    def _transform_artist_response(data):
-        return {
-            'id': data['id'],
-            **traverse_obj(data, {
-                'title': 'name',
-                'description': ('description', {str}, {lambda x: x.replace(r'\r\n', '\n')}),
-                'thumbnails': ('images', ..., ..., {
-                    'url': ('source', {url_or_none}),
-                    'width': ('width', {int_or_none}),
-                    'height': ('height', {int_or_none}),
-                }),
-                'genres': ('genres', ..., 'displayValue'),
-            }),
-        }
diff --git a/yt_dlp/extractor/eggs.py b/yt_dlp/extractor/eggs.py
new file mode 100644
index 000000000..6e032441c
--- /dev/null
+++ b/yt_dlp/extractor/eggs.py
@@ -0,0 +1,155 @@
+import secrets
+
+from .common import InfoExtractor
+from .youtube import YoutubeIE
+from ..utils import (
+    int_or_none,
+    parse_iso8601,
+    str_or_none,
+    url_or_none,
+)
+from ..utils.traversal import traverse_obj
+
+
+class EggsBaseIE(InfoExtractor):
+    _API_HEADERS = {
+        'Accept': '*/*',
+        'apVersion': '8.2.00',
+        'deviceName': 'Android',
+    }
+
+    def _real_initialize(self):
+        self._API_HEADERS['deviceId'] = secrets.token_hex(8)
+
+    def _call_api(self, endpoint, video_id):
+        return self._download_json(
+            f'https://app-front-api.eggs.mu/v1/{endpoint}', video_id,
+            headers=self._API_HEADERS)
+
+    def _extract_music_info(self, data):
+        if yt_url := traverse_obj(data, ('youtubeUrl', {url_or_none})):
+            return self.url_result(yt_url, ie=YoutubeIE)
+
+        artist_name = traverse_obj(data, ('artist', 'artistName', {str_or_none}))
+        music_id = traverse_obj(data, ('musicId', {str_or_none}))
+        webpage_url = None
+        if artist_name and music_id:
+            webpage_url = f'https://eggs.mu/artist/{artist_name}/song/{music_id}'
+
+        return {
+            'id': music_id,
+            'vcodec': 'none',
+            'webpage_url': webpage_url,
+            'extractor_key': EggsIE.ie_key(),
+            'extractor': EggsIE.IE_NAME,
+            **traverse_obj(data, {
+                'title': ('musicTitle', {str}),
+                'url': ('musicDataPath', {url_or_none}),
+                'uploader': ('artist', 'displayName', {str}),
+                'uploader_id': ('artist', 'artistId', {str_or_none}),
+                'thumbnail': ('imageDataPath', {url_or_none}),
+                'view_count': ('numberOfMusicPlays', {int_or_none}),
+                'like_count': ('numberOfLikes', {int_or_none}),
+                'comment_count': ('numberOfComments', {int_or_none}),
+                'composers': ('composer', {str}, all),
+                'tags': ('tags', ..., {str}),
+                'timestamp': ('releaseDate', {parse_iso8601}),
+                'artist': ('artist', 'displayName', {str}),
+            })}
+
+
+class EggsIE(EggsBaseIE):
+    IE_NAME = 'eggs:single'
+    _VALID_URL = r'https?://eggs\.mu/artist/[^/?#]+/song/(?P<id>[\da-f-]+)'
+
+    _TESTS = [{
+        'url': 'https://eggs.mu/artist/32_sunny_girl/song/0e95fd1d-4d61-4d5b-8b18-6092c551da90',
+        'info_dict': {
+            'id': '0e95fd1d-4d61-4d5b-8b18-6092c551da90',
+            'ext': 'm4a',
+            'title': 'シネマと信号',
+            'uploader': 'Sunny Girl',
+            'thumbnail': r're:https?://.*\.jpg(?:\?.*)?$',
+            'uploader_id': '1607',
+            'like_count': int,
+            'timestamp': 1731327327,
+            'composers': ['橘高連太郎'],
+            'view_count': int,
+            'comment_count': int,
+            'artists': ['Sunny Girl'],
+            'upload_date': '20241111',
+            'tags': ['SunnyGirl', 'シネマと信号'],
+        },
+    }, {
+        'url': 'https://eggs.mu/artist/KAMO_3pband/song/1d4bc45f-1af6-47a9-8b30-a70cae350b4f',
+        'info_dict': {
+            'id': '80cLKA2wnoA',
+            'ext': 'mp4',
+            'title': 'KAMO「いい女だから」Audio',
+            'uploader': 'KAMO',
+            'live_status': 'not_live',
+            'channel_id': 'UCsHLBw2__5Q9y55skXPotOg',
+            'channel_follower_count': int,
+            'description': 'md5:d260da711ecbec3e720293dc11401b87',
+            'availability': 'public',
+            'uploader_id': '@KAMO_band',
+            'upload_date': '20240925',
+            'thumbnail': 'https://i.ytimg.com/vi/80cLKA2wnoA/maxresdefault.jpg',
+            'comment_count': int,
+            'channel_url': 'https://www.youtube.com/channel/UCsHLBw2__5Q9y55skXPotOg',
+            'view_count': int,
+            'duration': 151,
+            'like_count': int,
+            'channel': 'KAMO',
+            'playable_in_embed': True,
+            'uploader_url': 'https://www.youtube.com/@KAMO_band',
+            'tags': [],
+            'timestamp': 1727271121,
+            'age_limit': 0,
+            'categories': ['People & Blogs'],
+        },
+        'add_ie': ['Youtube'],
+        'params': {'skip_download': 'Youtube'},
+    }]
+
+    def _real_extract(self, url):
+        song_id = self._match_id(url)
+        json_data = self._call_api(f'musics/{song_id}', song_id)
+        return self._extract_music_info(json_data)
+
+
+class EggsArtistIE(EggsBaseIE):
+    IE_NAME = 'eggs:artist'
+    _VALID_URL = r'https?://eggs\.mu/artist/(?P<id>\w+)/?(?:[?#&]|$)'
+
+    _TESTS = [{
+        'url': 'https://eggs.mu/artist/32_sunny_girl',
+        'info_dict': {
+            'id': '32_sunny_girl',
+            'thumbnail': 'https://image-pro.eggs.mu/profile/1607.jpeg?updated_at=2024-04-03T20%3A06%3A00%2B09%3A00',
+            'description': 'Muddy Mine / 東京高田馬場CLUB PHASE / Gt.Vo 橘高 連太郎 / Ba.Cho 小野 ゆうき / Dr 大森 りゅうひこ',
+            'title': 'Sunny Girl',
+        },
+        'playlist_mincount': 18,
+    }, {
+        'url': 'https://eggs.mu/artist/KAMO_3pband',
+        'info_dict': {
+            'id': 'KAMO_3pband',
+            'description': '川崎発３ピースバンド',
+            'thumbnail': 'https://image-pro.eggs.mu/profile/35217.jpeg?updated_at=2024-11-27T16%3A31%3A50%2B09%3A00',
+            'title': 'KAMO',
+        },
+        'playlist_mincount': 2,
+    }]
+
+    def _real_extract(self, url):
+        artist_id = self._match_id(url)
+        artist_data = self._call_api(f'artists/{artist_id}', artist_id)
+        song_data = self._call_api(f'artists/{artist_id}/musics', artist_id)
+        return self.playlist_result(
+            traverse_obj(song_data, ('data', ..., {dict}, {self._extract_music_info})),
+            playlist_id=artist_id, **traverse_obj(artist_data, {
+                'title': ('displayName', {str}),
+                'description': ('profile', {str}),
+                'thumbnail': ('imageDataPath', {url_or_none}),
+            }))
diff --git a/yt_dlp/extractor/firsttv.py b/yt_dlp/extractor/firsttv.py
index 2d47ee561..ac7697bb3 100644
--- a/yt_dlp/extractor/firsttv.py
+++ b/yt_dlp/extractor/firsttv.py
@@ -12,7 +12,7 @@
 class FirstTVIE(InfoExtractor):
     IE_NAME = '1tv'
     IE_DESC = 'Первый канал'
-    _VALID_URL = r'https?://(?:www\.)?1tv\.ru/(?:[^/]+/)+(?P<id>[^/?#]+)'
+    _VALID_URL = r'https?://(?:www\.)?(?:sport)?1tv\.ru/(?:[^/?#]+/)+(?P<id>[^/?#]+)'
 
     _TESTS = [{
         # single format
@@ -52,6 +52,9 @@ class FirstTVIE(InfoExtractor):
     }, {
         'url': 'http://www.1tv.ru/shows/tochvtoch-supersezon/vystupleniya/evgeniy-dyatlov-vladimir-vysockiy-koni-priveredlivye-toch-v-toch-supersezon-fragment-vypuska-ot-06-11-2016',
         'only_matching': True,
+    }, {
+        'url': 'https://www.sport1tv.ru/sport/chempionat-rossii-po-figurnomu-kataniyu-2025',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/funimation.py b/yt_dlp/extractor/funimation.py
deleted file mode 100644
index 01b53bcde..000000000
--- a/yt_dlp/extractor/funimation.py
+++ /dev/null
@@ -1,349 +0,0 @@
-import random
-import re
-import string
-
-from .common import InfoExtractor
-from ..networking.exceptions import HTTPError
-from ..utils import (
-    ExtractorError,
-    determine_ext,
-    int_or_none,
-    join_nonempty,
-    js_to_json,
-    make_archive_id,
-    orderedSet,
-    qualities,
-    str_or_none,
-    traverse_obj,
-    try_get,
-    urlencode_postdata,
-)
-
-
-class FunimationBaseIE(InfoExtractor):
-    _NETRC_MACHINE = 'funimation'
-    _REGION = None
-    _TOKEN = None
-
-    def _get_region(self):
-        region_cookie = self._get_cookies('https://www.funimation.com').get('region')
-        region = region_cookie.value if region_cookie else self.get_param('geo_bypass_country')
-        return region or traverse_obj(
-            self._download_json(
-                'https://geo-service.prd.funimationsvc.com/geo/v1/region/check', None, fatal=False,
-                note='Checking geo-location', errnote='Unable to fetch geo-location information'),
-            'region') or 'US'
-
-    def _perform_login(self, username, password):
-        if self._TOKEN:
-            return
-        try:
-            data = self._download_json(
-                'https://prod-api-funimationnow.dadcdigital.com/api/auth/login/',
-                None, 'Logging in', data=urlencode_postdata({
-                    'username': username,
-                    'password': password,
-                }))
-            FunimationBaseIE._TOKEN = data['token']
-        except ExtractorError as e:
-            if isinstance(e.cause, HTTPError) and e.cause.status == 401:
-                error = self._parse_json(e.cause.response.read().decode(), None)['error']
-                raise ExtractorError(error, expected=True)
-            raise
-
-
-class FunimationPageIE(FunimationBaseIE):
-    IE_NAME = 'funimation:page'
-    _VALID_URL = r'https?://(?:www\.)?funimation(?:\.com|now\.uk)/(?:(?P<lang>[^/]+)/)?(?:shows|v)/(?P<show>[^/]+)/(?P<episode>[^/?#&]+)'
-
-    _TESTS = [{
-        'url': 'https://www.funimation.com/shows/attack-on-titan-junior-high/broadcast-dub-preview/',
-        'info_dict': {
-            'id': '210050',
-            'ext': 'mp4',
-            'title': 'Broadcast Dub Preview',
-            # Other metadata is tested in FunimationIE
-        },
-        'params': {
-            'skip_download': 'm3u8',
-        },
-        'add_ie': ['Funimation'],
-    }, {
-        # Not available in US
-        'url': 'https://www.funimation.com/shows/hacksign/role-play/',
-        'only_matching': True,
-    }, {
-        # with lang code
-        'url': 'https://www.funimation.com/en/shows/hacksign/role-play/',
-        'only_matching': True,
-    }, {
-        'url': 'https://www.funimationnow.uk/shows/puzzle-dragons-x/drop-impact/simulcast/',
-        'only_matching': True,
-    }, {
-        'url': 'https://www.funimation.com/v/a-certain-scientific-railgun/super-powered-level-5',
-        'only_matching': True,
-    }]
-
-    def _real_initialize(self):
-        if not self._REGION:
-            FunimationBaseIE._REGION = self._get_region()
-
-    def _real_extract(self, url):
-        locale, show, episode = self._match_valid_url(url).group('lang', 'show', 'episode')
-
-        video_id = traverse_obj(self._download_json(
-            f'https://title-api.prd.funimationsvc.com/v1/shows/{show}/episodes/{episode}',
-            f'{show}_{episode}', query={
-                'deviceType': 'web',
-                'region': self._REGION,
-                'locale': locale or 'en',
-            }), ('videoList', ..., 'id'), get_all=False)
-
-        return self.url_result(f'https://www.funimation.com/player/{video_id}', FunimationIE.ie_key(), video_id)
-
-
-class FunimationIE(FunimationBaseIE):
-    _VALID_URL = r'https?://(?:www\.)?funimation\.com/player/(?P<id>\d+)'
-
-    _TESTS = [{
-        'url': 'https://www.funimation.com/player/210051',
-        'info_dict': {
-            'id': '210050',
-            'display_id': 'broadcast-dub-preview',
-            'ext': 'mp4',
-            'title': 'Broadcast Dub Preview',
-            'thumbnail': r're:https?://.*\.(?:jpg|png)',
-            'episode': 'Broadcast Dub Preview',
-            'episode_id': '210050',
-            'season': 'Extras',
-            'season_id': '166038',
-            'season_number': 99,
-            'series': 'Attack on Titan: Junior High',
-            'description': '',
-            'duration': 155,
-        },
-        'params': {
-            'skip_download': 'm3u8',
-        },
-    }, {
-        'note': 'player_id should be extracted with the relevent compat-opt',
-        'url': 'https://www.funimation.com/player/210051',
-        'info_dict': {
-            'id': '210051',
-            'display_id': 'broadcast-dub-preview',
-            'ext': 'mp4',
-            'title': 'Broadcast Dub Preview',
-            'thumbnail': r're:https?://.*\.(?:jpg|png)',
-            'episode': 'Broadcast Dub Preview',
-            'episode_id': '210050',
-            'season': 'Extras',
-            'season_id': '166038',
-            'season_number': 99,
-            'series': 'Attack on Titan: Junior High',
-            'description': '',
-            'duration': 155,
-        },
-        'params': {
-            'skip_download': 'm3u8',
-            'compat_opts': ['seperate-video-versions'],
-        },
-    }]
-
-    @staticmethod
-    def _get_experiences(episode):
-        for lang, lang_data in episode.get('languages', {}).items():
-            for video_data in lang_data.values():
-                for version, f in video_data.items():
-                    yield lang, version.title(), f
-
-    def _get_episode(self, webpage, experience_id=None, episode_id=None, fatal=True):
-        """ Extract the episode, season and show objects given either episode/experience id """
-        show = self._parse_json(
-            self._search_regex(
-                r'show\s*=\s*({.+?})\s*;', webpage, 'show data', fatal=fatal),
-            experience_id, transform_source=js_to_json, fatal=fatal) or []
-        for season in show.get('seasons', []):
-            for episode in season.get('episodes', []):
-                if episode_id is not None:
-                    if str(episode.get('episodePk')) == episode_id:
-                        return episode, season, show
-                    continue
-                for _, _, f in self._get_experiences(episode):
-                    if f.get('experienceId') == experience_id:
-                        return episode, season, show
-        if fatal:
-            raise ExtractorError('Unable to find episode information')
-        else:
-            self.report_warning('Unable to find episode information')
-        return {}, {}, {}
-
-    def _real_extract(self, url):
-        initial_experience_id = self._match_id(url)
-        webpage = self._download_webpage(
-            url, initial_experience_id, note=f'Downloading player webpage for {initial_experience_id}')
-        episode, season, show = self._get_episode(webpage, experience_id=int(initial_experience_id))
-        episode_id = str(episode['episodePk'])
-        display_id = episode.get('slug') or episode_id
-
-        formats, subtitles, thumbnails, duration = [], {}, [], 0
-        requested_languages, requested_versions = self._configuration_arg('language'), self._configuration_arg('version')
-        language_preference = qualities((requested_languages or [''])[::-1])
-        source_preference = qualities((requested_versions or ['uncut', 'simulcast'])[::-1])
-        only_initial_experience = 'seperate-video-versions' in self.get_param('compat_opts', [])
-
-        for lang, version, fmt in self._get_experiences(episode):
-            experience_id = str(fmt['experienceId'])
-            if ((only_initial_experience and experience_id != initial_experience_id)
-                    or (requested_languages and lang.lower() not in requested_languages)
-                    or (requested_versions and version.lower() not in requested_versions)):
-                continue
-            thumbnails.append({'url': fmt.get('poster')})
-            duration = max(duration, fmt.get('duration', 0))
-            format_name = f'{version} {lang} ({experience_id})'
-            self.extract_subtitles(
-                subtitles, experience_id, display_id=display_id, format_name=format_name,
-                episode=episode if experience_id == initial_experience_id else episode_id)
-
-            headers = {}
-            if self._TOKEN:
-                headers['Authorization'] = f'Token {self._TOKEN}'
-            page = self._download_json(
-                f'https://www.funimation.com/api/showexperience/{experience_id}/',
-                display_id, headers=headers, expected_status=403, query={
-                    'pinst_id': ''.join(random.choices(string.digits + string.ascii_letters, k=8)),
-                }, note=f'Downloading {format_name} JSON')
-            sources = page.get('items') or []
-            if not sources:
-                error = try_get(page, lambda x: x['errors'][0], dict)
-                if error:
-                    self.report_warning('{} said: Error {} - {}'.format(
-                        self.IE_NAME, error.get('code'), error.get('detail') or error.get('title')))
-                else:
-                    self.report_warning('No sources found for format')
-
-            current_formats = []
-            for source in sources:
-                source_url = source.get('src')
-                source_type = source.get('videoType') or determine_ext(source_url)
-                if source_type == 'm3u8':
-                    current_formats.extend(self._extract_m3u8_formats(
-                        source_url, display_id, 'mp4', m3u8_id='{}-{}'.format(experience_id, 'hls'), fatal=False,
-                        note=f'Downloading {format_name} m3u8 information'))
-                else:
-                    current_formats.append({
-                        'format_id': f'{experience_id}-{source_type}',
-                        'url': source_url,
-                    })
-                for f in current_formats:
-                    # TODO: Convert language to code
-                    f.update({
-                        'language': lang,
-                        'format_note': version,
-                        'source_preference': source_preference(version.lower()),
-                        'language_preference': language_preference(lang.lower()),
-                    })
-                formats.extend(current_formats)
-        if not formats and (requested_languages or requested_versions):
-            self.raise_no_formats(
-                'There are no video formats matching the requested languages/versions', expected=True, video_id=display_id)
-        self._remove_duplicate_formats(formats)
-
-        return {
-            'id': episode_id,
-            '_old_archive_ids': [make_archive_id(self, initial_experience_id)],
-            'display_id': display_id,
-            'duration': duration,
-            'title': episode['episodeTitle'],
-            'description': episode.get('episodeSummary'),
-            'episode': episode.get('episodeTitle'),
-            'episode_number': int_or_none(episode.get('episodeId')),
-            'episode_id': episode_id,
-            'season': season.get('seasonTitle'),
-            'season_number': int_or_none(season.get('seasonId')),
-            'season_id': str_or_none(season.get('seasonPk')),
-            'series': show.get('showTitle'),
-            'formats': formats,
-            'thumbnails': thumbnails,
-            'subtitles': subtitles,
-            '_format_sort_fields': ('lang', 'source'),
-        }
-
-    def _get_subtitles(self, subtitles, experience_id, episode, display_id, format_name):
-        if isinstance(episode, str):
-            webpage = self._download_webpage(
-                f'https://www.funimation.com/player/{experience_id}/', display_id,
-                fatal=False, note=f'Downloading player webpage for {format_name}')
-            episode, _, _ = self._get_episode(webpage, episode_id=episode, fatal=False)
-
-        for _, version, f in self._get_experiences(episode):
-            for source in f.get('sources'):
-                for text_track in source.get('textTracks'):
-                    if not text_track.get('src'):
-                        continue
-                    sub_type = text_track.get('type').upper()
-                    sub_type = sub_type if sub_type != 'FULL' else None
-                    current_sub = {
-                        'url': text_track['src'],
-                        'name': join_nonempty(version, text_track.get('label'), sub_type, delim=' '),
-                    }
-                    lang = join_nonempty(text_track.get('language', 'und'),
-                                         version if version != 'Simulcast' else None,
-                                         sub_type, delim='_')
-                    if current_sub not in subtitles.get(lang, []):
-                        subtitles.setdefault(lang, []).append(current_sub)
-        return subtitles
-
-
-class FunimationShowIE(FunimationBaseIE):
-    IE_NAME = 'funimation:show'
-    _VALID_URL = r'(?P<url>https?://(?:www\.)?funimation(?:\.com|now\.uk)/(?P<locale>[^/]+)?/?shows/(?P<id>[^/?#&]+))/?(?:[?#]|$)'
-
-    _TESTS = [{
-        'url': 'https://www.funimation.com/en/shows/sk8-the-infinity',
-        'info_dict': {
-            'id': '1315000',
-            'title': 'SK8 the Infinity',
-        },
-        'playlist_count': 13,
-        'params': {
-            'skip_download': True,
-        },
-    }, {
-        # without lang code
-        'url': 'https://www.funimation.com/shows/ouran-high-school-host-club/',
-        'info_dict': {
-            'id': '39643',
-            'title': 'Ouran High School Host Club',
-        },
-        'playlist_count': 26,
-        'params': {
-            'skip_download': True,
-        },
-    }]
-
-    def _real_initialize(self):
-        if not self._REGION:
-            FunimationBaseIE._REGION = self._get_region()
-
-    def _real_extract(self, url):
-        base_url, locale, display_id = self._match_valid_url(url).groups()
-
-        show_info = self._download_json(
-            'https://title-api.prd.funimationsvc.com/v2/shows/{}?region={}&deviceType=web&locale={}'.format(
-                display_id, self._REGION, locale or 'en'), display_id)
-        items_info = self._download_json(
-            'https://prod-api-funimationnow.dadcdigital.com/api/funimation/episodes/?limit=99999&title_id={}'.format(
-                show_info.get('id')), display_id)
-
-        vod_items = traverse_obj(items_info, ('items', ..., lambda k, _: re.match(r'(?i)mostRecent[AS]vod', k), 'item'))
-
-        return {
-            '_type': 'playlist',
-            'id': str_or_none(show_info['id']),
-            'title': show_info['name'],
-            'entries': orderedSet(
-                self.url_result(
-                    '{}/{}'.format(base_url, vod_item.get('episodeSlug')), FunimationPageIE.ie_key(),
-                    vod_item.get('episodeId'), vod_item.get('episodeName'))
-                for vod_item in sorted(vod_items, key=lambda x: x.get('episodeOrder', -1))),
-        }
diff --git a/yt_dlp/extractor/goodgame.py b/yt_dlp/extractor/goodgame.py
index c17ad56f4..6f61f18fd 100644
--- a/yt_dlp/extractor/goodgame.py
+++ b/yt_dlp/extractor/goodgame.py
@@ -1,40 +1,48 @@
 from .common import InfoExtractor
 from ..utils import (
-    clean_html,
     int_or_none,
     str_or_none,
     traverse_obj,
+    url_or_none,
 )
 
 
 class GoodGameIE(InfoExtractor):
     IE_NAME = 'goodgame:stream'
-    _VALID_URL = r'https?://goodgame\.ru/channel/(?P<id>\w+)'
+    _VALID_URL = r'https?://goodgame\.ru/(?!channel/)(?P<id>[\w.*-]+)'
     _TESTS = [{
-        'url': 'https://goodgame.ru/channel/Pomi/#autoplay',
+        'url': 'https://goodgame.ru/TGW#autoplay',
         'info_dict': {
-            'id': 'pomi',
+            'id': '7998',
             'ext': 'mp4',
-            'title': r're:Reynor vs Special \(1/2,bo3\) Wardi Spring EU \- playoff \(финальный день\) \d{4}-\d{2}-\d{2} \d{2}:\d{2}$',
-            'channel_id': '1644',
-            'channel': 'Pomi',
-            'channel_url': 'https://goodgame.ru/channel/Pomi/',
-            'description': 'md5:4a87b775ee7b2b57bdccebe285bbe171',
-            'thumbnail': r're:^https?://.*\.jpg$',
+            'channel_id': '7998',
+            'title': r're:шоуматч Happy \(NE\) vs Fortitude \(UD\), потом ладдер и дс \d{4}-\d{2}-\d{2} \d{2}:\d{2}$',
+            'channel_url': 'https://goodgame.ru/TGW',
+            'thumbnail': 'https://hls.goodgame.ru/previews/7998_240.jpg',
+            'uploader': 'TGW',
+            'channel': 'JosephStalin',
             'live_status': 'is_live',
-            'view_count': int,
+            'age_limit': 18,
+            'channel_follower_count': int,
+            'uploader_id': '2899',
+            'concurrent_view_count': int,
         },
         'params': {'skip_download': 'm3u8'},
-        'skip': 'May not be online',
+    }, {
+        'url': 'https://goodgame.ru/Mr.Gray',
+        'only_matching': True,
+    }, {
+        'url': 'https://goodgame.ru/HeDoPa3yMeHue*',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
         channel_name = self._match_id(url)
-        response = self._download_json(f'https://api2.goodgame.ru/v2/streams/{channel_name}', channel_name)
-        player_id = response['channel']['gg_player_src']
+        response = self._download_json(f'https://goodgame.ru/api/4/users/{channel_name}/stream', channel_name)
+        player_id = response['streamkey']
 
         formats, subtitles = [], {}
-        if response.get('status') == 'Live':
+        if response.get('status'):
             formats, subtitles = self._extract_m3u8_formats_and_subtitles(
                 f'https://hls.goodgame.ru/manifest/{player_id}_master.m3u8',
                 channel_name, 'mp4', live=True)
@@ -45,13 +53,17 @@ def _real_extract(self, url):
             'id': player_id,
             'formats': formats,
             'subtitles': subtitles,
-            'title': traverse_obj(response, ('channel', 'title')),
-            'channel': channel_name,
-            'channel_id': str_or_none(traverse_obj(response, ('channel', 'id'))),
-            'channel_url': response.get('url'),
-            'description': clean_html(traverse_obj(response, ('channel', 'description'))),
-            'thumbnail': traverse_obj(response, ('channel', 'thumb')),
             'is_live': bool(formats),
-            'view_count': int_or_none(response.get('viewers')),
-            'age_limit': 18 if traverse_obj(response, ('channel', 'adult')) else None,
+            **traverse_obj(response, {
+                'title': ('title', {str}),
+                'channel': ('channelkey', {str}),
+                'channel_id': ('id', {str_or_none}),
+                'channel_url': ('link', {url_or_none}),
+                'uploader': ('streamer', 'username', {str}),
+                'uploader_id': ('streamer', 'id', {str_or_none}),
+                'thumbnail': ('preview', {url_or_none}, {self._proto_relative_url}),
+                'concurrent_view_count': ('viewers', {int_or_none}),
+                'channel_follower_count': ('followers', {int_or_none}),
+                'age_limit': ('adult', {bool}, {lambda x: 18 if x else None}),
+            }),
         }
diff --git a/yt_dlp/extractor/nest.py b/yt_dlp/extractor/nest.py
new file mode 100644
index 000000000..3f8316b3e
--- /dev/null
+++ b/yt_dlp/extractor/nest.py
@@ -0,0 +1,117 @@
+from .common import InfoExtractor
+from ..utils import ExtractorError, float_or_none, update_url_query, url_or_none
+from ..utils.traversal import traverse_obj
+
+
+class NestIE(InfoExtractor):
+    _VALID_URL = r'https?://video\.nest\.com/(?:embedded/)?live/(?P<id>\w+)'
+    _EMBED_REGEX = [rf'<iframe [^>]*\bsrc=[\'"](?P<url>{_VALID_URL})']
+    _TESTS = [{
+        'url': 'https://video.nest.com/embedded/live/4fvYdSo8AX?autoplay=0',
+        'info_dict': {
+            'id': '4fvYdSo8AX',
+            'ext': 'mp4',
+            'title': 'startswith:Outside ',
+            'alt_title': 'Outside',
+            'description': '<null>',
+            'location': 'Los Angeles',
+            'availability': 'public',
+            'thumbnail': r're:https?://',
+            'live_status': 'is_live',
+        },
+        'params': {
+            # m3u8 download
+            'skip_download': True,
+        },
+    }, {
+        'url': 'https://video.nest.com/live/4fvYdSo8AX',
+        'only_matching': True,
+    }]
+    _WEBPAGE_TESTS = [{
+        'url': 'https://www.pacificblue.biz/noyo-harbor-webcam/',
+        'info_dict': {
+            'id': '4fvYdSo8AX',
+            'ext': 'mp4',
+            'title': 'startswith:Outside ',
+            'alt_title': 'Outside',
+            'description': '<null>',
+            'location': 'Los Angeles',
+            'availability': 'public',
+            'thumbnail': r're:https?://',
+            'live_status': 'is_live',
+        },
+        'params': {
+            # m3u8 download
+            'skip_download': True,
+        },
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        item = self._download_json(
+            'https://video.nest.com/api/dropcam/cameras.get_by_public_token',
+            video_id, query={'token': video_id})['items'][0]
+        uuid = item.get('uuid')
+        stream_domain = item.get('live_stream_host')
+        if not stream_domain or not uuid:
+            raise ExtractorError('Unable to construct playlist URL')
+
+        thumb_domain = item.get('nexus_api_nest_domain_host')
+        return {
+            'id': video_id,
+            **traverse_obj(item, {
+                'description': ('description', {str}),
+                'title': (('title', 'name', 'where'), {str}, filter, any),
+                'alt_title': ('name', {str}),
+                'location': ((('timezone', {lambda x: x.split('/')[1].replace('_', ' ')}), 'where'), {str}, filter, any),
+            }),
+            'thumbnail': update_url_query(
+                f'https://{thumb_domain}/get_image',
+                {'uuid': uuid, 'public': video_id}) if thumb_domain else None,
+            'availability': self._availability(is_private=item.get('is_public') is False),
+            'formats': self._extract_m3u8_formats(
+                f'https://{stream_domain}/nexus_aac/{uuid}/playlist.m3u8',
+                video_id, 'mp4', live=True, query={'public': video_id}),
+            'is_live': True,
+        }
+
+
+class NestClipIE(InfoExtractor):
+    _VALID_URL = r'https?://video\.nest\.com/(?:embedded/)?clip/(?P<id>\w+)'
+    _EMBED_REGEX = [rf'<iframe [^>]*\bsrc=[\'"](?P<url>{_VALID_URL})']
+    _TESTS = [{
+        'url': 'https://video.nest.com/clip/f34c9dd237a44eca9a0001af685e3dff',
+        'info_dict': {
+            'id': 'f34c9dd237a44eca9a0001af685e3dff',
+            'ext': 'mp4',
+            'title': 'NestClip video #f34c9dd237a44eca9a0001af685e3dff',
+            'thumbnail': 'https://clips.dropcam.com/f34c9dd237a44eca9a0001af685e3dff.jpg',
+            'timestamp': 1735413474.468,
+            'upload_date': '20241228',
+        },
+    }, {
+        'url': 'https://video.nest.com/embedded/clip/34e0432adc3c46a98529443d8ad5aa76',
+        'info_dict': {
+            'id': '34e0432adc3c46a98529443d8ad5aa76',
+            'ext': 'mp4',
+            'title': 'Shootout at Veterans Boulevard at Fleur De Lis Drive',
+            'thumbnail': 'https://clips.dropcam.com/34e0432adc3c46a98529443d8ad5aa76.jpg',
+            'upload_date': '20230817',
+            'timestamp': 1692262897.191,
+        },
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        data = self._download_json(
+            'https://video.nest.com/api/dropcam/videos.get_by_filename', video_id,
+            query={'filename': f'{video_id}.mp4'})
+        return {
+            'id': video_id,
+            **traverse_obj(data, ('items', 0, {
+                'title': ('title', {str}),
+                'thumbnail': ('thumbnail_url', {url_or_none}),
+                'url': ('download_url', {url_or_none}),
+                'timestamp': ('start_time', {float_or_none}),
+            })),
+        }
diff --git a/yt_dlp/extractor/nrk.py b/yt_dlp/extractor/nrk.py
index efc4a1734..1400f1536 100644
--- a/yt_dlp/extractor/nrk.py
+++ b/yt_dlp/extractor/nrk.py
@@ -28,6 +28,12 @@ class NRKBaseIE(InfoExtractor):
         )/'''
 
     def _extract_nrk_formats(self, asset_url, video_id):
+        asset_url = update_url_query(asset_url, {
+            # Remove 'adap' to return all streams (known values are: small, large, small_h265, large_h265)
+            'adap': [],
+            # Disable subtitles since they are fetched separately
+            's': 0,
+        })
         if re.match(r'https?://[^/]+\.akamaihd\.net/i/', asset_url):
             return self._extract_akamai_formats(asset_url, video_id)
         asset_url = re.sub(r'(?:bw_(?:low|high)=\d+|no_audio_only)&?', '', asset_url)
@@ -59,7 +65,10 @@ def _call_api(self, path, video_id, item=None, note=None, fatal=True, query=None
         return self._download_json(
             urljoin('https://psapi.nrk.no/', path),
             video_id, note or f'Downloading {item} JSON',
-            fatal=fatal, query=query)
+            fatal=fatal, query=query, headers={
+                # Needed for working stream URLs, see https://github.com/yt-dlp/yt-dlp/issues/12192
+                'Accept': 'application/vnd.nrk.psapi+json; version=9; player=tv-player; device=player-core',
+            })
 
 
 class NRKIE(NRKBaseIE):
@@ -78,13 +87,17 @@ class NRKIE(NRKBaseIE):
     _TESTS = [{
         # video
         'url': 'http://www.nrk.no/video/PS*150533',
-        'md5': 'f46be075326e23ad0e524edfcb06aeb6',
+        'md5': '2b88a652ad2e275591e61cf550887eec',
         'info_dict': {
             'id': '150533',
             'ext': 'mp4',
             'title': 'Dompap og andre fugler i Piip-Show',
             'description': 'md5:d9261ba34c43b61c812cb6b0269a5c8f',
             'duration': 262,
+            'upload_date': '20140325',
+            'thumbnail': r're:^https?://gfx\.nrk\.no/.*$',
+            'timestamp': 1395751833,
+            'alt_title': 'md5:d9261ba34c43b61c812cb6b0269a5c8f',
         },
     }, {
         # audio
@@ -96,6 +109,10 @@ class NRKIE(NRKBaseIE):
             'title': 'Slik høres internett ut når du er blind',
             'description': 'md5:a621f5cc1bd75c8d5104cb048c6b8568',
             'duration': 20,
+            'timestamp': 1398429565,
+            'alt_title': 'Cathrine Lie Wathne er blind, og bruker hurtigtaster for å navigere seg rundt på ulike nettsider.',
+            'thumbnail': 'https://gfx.nrk.no/urxQMSXF-WnbfjBH5ke2igLGyN27EdJVWZ6FOsEAclhA',
+            'upload_date': '20140425',
         },
     }, {
         'url': 'nrk:ecc1b952-96dc-4a98-81b9-5296dc7a98d9',
@@ -153,7 +170,7 @@ def call_playback_api(item, query=None):
                     return self._call_api(f'playback/{item}/{video_id}', video_id, item, query=query)
                 raise
 
-        # known values for preferredCdn: akamai, iponly, minicdn and telenor
+        # known values for preferredCdn: akamai, globalconnect and telenor
         manifest = call_playback_api('manifest', {'preferredCdn': 'akamai'})
 
         video_id = try_get(manifest, lambda x: x['id'], str) or video_id
@@ -172,8 +189,6 @@ def call_playback_api(item, query=None):
             format_url = url_or_none(asset.get('url'))
             if not format_url:
                 continue
-            # Remove the 'adap' query parameter
-            format_url = update_url_query(format_url, {'adap': []})
             asset_format = (asset.get('format') or '').lower()
             if asset_format == 'hls' or determine_ext(format_url) == 'm3u8':
                 formats.extend(self._extract_nrk_formats(format_url, video_id))
@@ -310,6 +325,13 @@ class NRKTVIE(InfoExtractor):
                     'ext': 'vtt',
                 }],
             },
+            'upload_date': '20170627',
+            'timestamp': 1498591822,
+            'thumbnail': 'https://gfx.nrk.no/myRSc4vuFlahB60P3n6swwRTQUZI1LqJZl9B7icZFgzA',
+            'alt_title': 'md5:46923a6e6510eefcce23d5ef2a58f2ce',
+        },
+        'params': {
+            'skip_download': True,
         },
     }, {
         'url': 'https://tv.nrk.no/serie/20-spoersmaal-tv/MUHH48000314/23-05-2014',
@@ -324,6 +346,13 @@ class NRKTVIE(InfoExtractor):
             'series': '20 spørsmål',
             'episode': '23. mai 2014',
             'age_limit': 0,
+            'timestamp': 1584593700,
+            'thumbnail': 'https://gfx.nrk.no/u7uCe79SEfPVGRAGVp2_uAZnNc4mfz_kjXg6Bgek8lMQ',
+            'season_id': '126936',
+            'upload_date': '20200319',
+            'season': 'Season 2014',
+            'season_number': 2014,
+            'episode_number': 3,
         },
     }, {
         'url': 'https://tv.nrk.no/program/mdfp15000514',
diff --git a/yt_dlp/extractor/piramidetv.py b/yt_dlp/extractor/piramidetv.py
new file mode 100644
index 000000000..29afa9b46
--- /dev/null
+++ b/yt_dlp/extractor/piramidetv.py
@@ -0,0 +1,99 @@
+from .common import InfoExtractor
+from ..utils import parse_iso8601, smuggle_url, unsmuggle_url, url_or_none
+from ..utils.traversal import traverse_obj
+
+
+class PiramideTVIE(InfoExtractor):
+    _VALID_URL = r'https?://piramide\.tv/video/(?P<id>[\w-]+)'
+    _TESTS = [{
+        'url': 'https://piramide.tv/video/wWtBAORdJUTh',
+        'info_dict': {
+            'id': 'wWtBAORdJUTh',
+            'ext': 'mp4',
+            'title': 'md5:79f9c8183ea6a35c836923142cf0abcc',
+            'description': '',
+            'thumbnail': 'https://cdn.jwplayer.com/v2/media/W86PgQDn/thumbnails/B9gpIxkH.jpg',
+            'channel': 'León Picarón',
+            'channel_id': 'leonpicaron',
+            'timestamp': 1696460362,
+            'upload_date': '20231004',
+        },
+    }, {
+        'url': 'https://piramide.tv/video/wcYn6li79NgN',
+        'info_dict': {
+            'id': 'wcYn6li79NgN',
+            'ext': 'mp4',
+            'title': 'ACEPTO TENER UN BEBE CON MI NOVIA\u2026? | Parte 1',
+            'description': '',
+            'channel': 'ARTA GAME',
+            'channel_id': 'arta_game',
+            'thumbnail': 'https://cdn.jwplayer.com/v2/media/cnEdGp5X/thumbnails/rHAaWfP7.jpg',
+            'timestamp': 1703434976,
+            'upload_date': '20231224',
+        },
+    }]
+
+    def _extract_video(self, video_id):
+        video_data = self._download_json(
+            f'https://hermes.piramide.tv/video/data/{video_id}', video_id, fatal=False)
+        formats, subtitles = self._extract_m3u8_formats_and_subtitles(
+            f'https://cdn.piramide.tv/video/{video_id}/manifest.m3u8', video_id, fatal=False)
+        next_video = traverse_obj(video_data, ('video', 'next_video', 'id', {str}))
+        return next_video, {
+            'id': video_id,
+            'formats': formats,
+            'subtitles': subtitles,
+            **traverse_obj(video_data, ('video', {
+                'id': ('id', {str}),
+                'title': ('title', {str}),
+                'description': ('description', {str}),
+                'thumbnail': ('media', 'thumbnail', {url_or_none}),
+                'channel': ('channel', 'name', {str}),
+                'channel_id': ('channel', 'id', {str}),
+                'timestamp': ('date', {parse_iso8601}),
+            })),
+        }
+
+    def _entries(self, video_id):
+        visited = set()
+        while True:
+            visited.add(video_id)
+            next_video, info = self._extract_video(video_id)
+            yield info
+            if not next_video or next_video in visited:
+                break
+            video_id = next_video
+
+    def _real_extract(self, url):
+        url, smuggled_data = unsmuggle_url(url, {})
+        video_id = self._match_id(url)
+        if self._yes_playlist(video_id, video_id, smuggled_data):
+            return self.playlist_result(self._entries(video_id), video_id)
+        return self._extract_video(video_id)[1]
+
+
+class PiramideTVChannelIE(InfoExtractor):
+    _VALID_URL = r'https?://piramide\.tv/channel/(?P<id>[\w-]+)'
+    _TESTS = [{
+        'url': 'https://piramide.tv/channel/thekalo',
+        'playlist_mincount': 10,
+        'info_dict': {
+            'id': 'thekalo',
+        },
+    }]
+
+    def _entries(self, channel_name):
+        videos = self._download_json(
+            f'https://hermes.piramide.tv/channel/list/{channel_name}/date/100000', channel_name)
+        for video in traverse_obj(videos, ('videos', lambda _, v: v['id'])):
+            yield self.url_result(smuggle_url(
+                f'https://piramide.tv/video/{video["id"]}', {'force_noplaylist': True}),
+                **traverse_obj(video, {
+                    'id': ('id', {str}),
+                    'title': ('title', {str}),
+                    'description': ('description', {str}),
+                }))
+
+    def _real_extract(self, url):
+        channel_name = self._match_id(url)
+        return self.playlist_result(self._entries(channel_name), channel_name)
diff --git a/yt_dlp/extractor/rtvslo.py b/yt_dlp/extractor/rtvslo.py
index 49bebb178..b190d179f 100644
--- a/yt_dlp/extractor/rtvslo.py
+++ b/yt_dlp/extractor/rtvslo.py
@@ -176,6 +176,8 @@ class RTVSLOShowIE(InfoExtractor):
         'info_dict': {
             'id': '173250997',
             'title': 'Ekipa Bled',
+            'description': 'md5:c88471e27a1268c448747a5325319ab7',
+            'thumbnail': 'https://img.rtvcdn.si/_up/ava/ava_misc/show_logos/173250997/logo_wide1.jpg',
         },
         'playlist_count': 18,
     }]
@@ -187,4 +189,7 @@ def _real_extract(self, url):
         return self.playlist_from_matches(
             re.findall(r'<a [^>]*\bhref="(/arhiv/[^"]+)"', webpage),
             playlist_id, self._html_extract_title(webpage),
-            getter=urljoin('https://365.rtvslo.si'), ie=RTVSLOIE)
+            getter=urljoin('https://365.rtvslo.si'), ie=RTVSLOIE,
+            description=self._og_search_description(webpage),
+            thumbnail=self._og_search_thumbnail(webpage),
+        )
diff --git a/yt_dlp/extractor/soundcloud.py b/yt_dlp/extractor/soundcloud.py
index 85779e91a..0b75c9a72 100644
--- a/yt_dlp/extractor/soundcloud.py
+++ b/yt_dlp/extractor/soundcloud.py
@@ -393,7 +393,7 @@ class SoundcloudIE(SoundcloudBaseIE):
                     (?:(?:(?:www\.|m\.)?soundcloud\.com/
                             (?!stations/track)
                             (?P<uploader>[\w\d-]+)/
-                            (?!(?:tracks|albums|sets(?:/.+?)?|reposts|likes|spotlight)/?(?:$|[?#]))
+                            (?!(?:tracks|albums|sets(?:/.+?)?|reposts|likes|spotlight|comments)/?(?:$|[?#]))
                             (?P<title>[\w\d-]+)
                             (?:/(?P<token>(?!(?:albums|sets|recommended))[^?]+?))?
                             (?:[?].*)?$)
@@ -776,7 +776,7 @@ class SoundcloudUserIE(SoundcloudPagedPlaylistBaseIE):
                             (?:(?:www|m)\.)?soundcloud\.com/
                             (?P<user>[^/]+)
                             (?:/
-                                (?P<rsrc>tracks|albums|sets|reposts|likes|spotlight)
+                                (?P<rsrc>tracks|albums|sets|reposts|likes|spotlight|comments)
                             )?
                             /?(?:[?#].*)?$
                     '''
@@ -830,6 +830,13 @@ class SoundcloudUserIE(SoundcloudPagedPlaylistBaseIE):
             'title': 'Grynpyret (Spotlight)',
         },
         'playlist_mincount': 1,
+    }, {
+        'url': 'https://soundcloud.com/one-thousand-and-one/comments',
+        'info_dict': {
+            'id': '992430331',
+            'title': '7x11x13-testing (Comments)',
+        },
+        'playlist_mincount': 1,
     }]
 
     _BASE_URL_MAP = {
@@ -840,6 +847,7 @@ class SoundcloudUserIE(SoundcloudPagedPlaylistBaseIE):
         'reposts': 'stream/users/%s/reposts',
         'likes': 'users/%s/likes',
         'spotlight': 'users/%s/spotlight',
+        'comments': 'users/%s/comments',
     }
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/subsplash.py b/yt_dlp/extractor/subsplash.py
new file mode 100644
index 000000000..33494e8db
--- /dev/null
+++ b/yt_dlp/extractor/subsplash.py
@@ -0,0 +1,199 @@
+import functools
+import math
+
+from .common import InfoExtractor
+from ..utils import (
+    InAdvancePagedList,
+    int_or_none,
+    parse_iso8601,
+    try_call,
+    url_or_none,
+)
+from ..utils.traversal import traverse_obj
+
+
+class SubsplashBaseIE(InfoExtractor):
+    def _get_headers(self, url, display_id):
+        token = try_call(lambda: self._get_cookies(url)['ss-token-guest'].value)
+        if not token:
+            webpage, urlh = self._download_webpage_handle(url, display_id)
+            token = (
+                try_call(lambda: self._get_cookies(url)['ss-token-guest'].value)
+                or urlh.get_header('x-api-token')
+                or self._search_json(
+                    r'<script[^>]+\bid="shoebox-tokens"[^>]*>', webpage, 'shoebox tokens',
+                    display_id, default={}).get('apiToken')
+                or self._search_regex(r'\\"tokens\\":{\\"guest\\":\\"([A-Za-z0-9._-]+)\\"', webpage, 'token', default=None))
+
+        if not token:
+            self.report_warning('Unable to extract auth token')
+            return None
+        return {'Authorization': f'Bearer {token}'}
+
+    def _extract_video(self, data, video_id):
+        formats = []
+        video_data = traverse_obj(data, ('_embedded', 'video', '_embedded', {dict}))
+        m3u8_url = traverse_obj(video_data, ('playlists', 0, '_links', 'related', 'href', {url_or_none}))
+        if m3u8_url:
+            formats.extend(self._extract_m3u8_formats(m3u8_url, video_id, 'mp4', m3u8_id='hls', fatal=False))
+        mp4_entry = traverse_obj(video_data, ('video-outputs', lambda _, v: url_or_none(v['_links']['related']['href']), any))
+        if mp4_entry:
+            formats.append({
+                'url': mp4_entry['_links']['related']['href'],
+                'format_id': 'direct',
+                'quality': 1,
+                **traverse_obj(mp4_entry, {
+                    'height': ('height', {int_or_none}),
+                    'width': ('width', {int_or_none}),
+                    'filesize': ('file_size', {int_or_none}),
+                }),
+            })
+        return {
+            'id': video_id,
+            'formats': formats,
+            **traverse_obj(data, {
+                'title': ('title', {str}),
+                'description': ('summary_text', {str}),
+                'thumbnail': ('_embedded', 'images', 0, '_links', 'related', 'href', {url_or_none}),
+                'duration': ('_embedded', 'video', 'duration', {int_or_none(scale=1000)}),
+                'timestamp': ('date', {parse_iso8601}),
+                'release_timestamp': ('published_at', {parse_iso8601}),
+                'modified_timestamp': ('updated_at', {parse_iso8601}),
+            }),
+        }
+
+
+class SubsplashIE(SubsplashBaseIE):
+    _VALID_URL = [
+        r'https?://(?:www\.)?subsplash\.com/(?:u/)?[^/?#]+/[^/?#]+/(?:d/|mi/\+)(?P<id>\w+)',
+        r'https?://(?:\w+\.)?subspla\.sh/(?P<id>\w+)',
+    ]
+    _TESTS = [{
+        'url': 'https://subsplash.com/u/skywatchtv/media/d/5whnx5s-the-grand-delusion-taking-place-right-now',
+        'md5': 'd468729814e533cec86f1da505dec82d',
+        'info_dict': {
+            'id': '5whnx5s',
+            'ext': 'mp4',
+            'title': 'THE GRAND DELUSION TAKING PLACE RIGHT NOW!',
+            'description': 'md5:220a630865c3697b0ec9dcb3a70cbc33',
+            'upload_date': '20240901',
+            'duration': 1710,
+            'thumbnail': r're:https?://.*\.(?:jpg|png)$',
+            'modified_date': '20240901',
+            'release_date': '20240901',
+            'release_timestamp': 1725195600,
+            'timestamp': 1725148800,
+            'modified_timestamp': 1725195657,
+        },
+    }, {
+        'url': 'https://subsplash.com/u/prophecywatchers/media/d/n4dr8b2-the-transhumanist-plan-for-humanity-billy-crone',
+        'md5': '01982d58021af81c969958459bd81f13',
+        'info_dict': {
+            'id': 'n4dr8b2',
+            'ext': 'mp4',
+            'title': 'The Transhumanist Plan for Humanity | Billy Crone',
+            'upload_date': '20240903',
+            'duration': 1709,
+            'thumbnail': r're:https?://.*\.(?:jpg|png)$',
+            'timestamp': 1725321600,
+            'modified_date': '20241010',
+            'release_date': '20240903',
+            'release_timestamp': 1725379200,
+            'modified_timestamp': 1728577804,
+        },
+    }, {
+        'url': 'https://subsplash.com/laiglesiadelcentro/vid/mi/+ecb6a6b?autoplay=true',
+        'md5': '013c9b1e391dd4b34d8612439445deef',
+        'info_dict': {
+            'id': 'ecb6a6b',
+            'ext': 'mp4',
+            'thumbnail': r're:https?://.*\.(?:jpg|png)$',
+            'release_timestamp': 1477095852,
+            'title': 'En el Principio Era el Verbo | EVANGELIO DE JUAN | Ps. Gadiel Ríos',
+            'timestamp': 1425772800,
+            'upload_date': '20150308',
+            'description': 'md5:f368221de93176654989ba66bb564798',
+            'modified_timestamp': 1730258864,
+            'modified_date': '20241030',
+            'release_date': '20161022',
+        },
+    }, {
+        'url': 'https://prophecywatchers.subspla.sh/8gps8cx',
+        'only_matching': True,
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        data = self._download_json(
+            'https://core.subsplash.com/media/v1/media-items',
+            video_id, headers=self._get_headers(url, video_id),
+            query={
+                'filter[short_code]': video_id,
+                'include': 'images,audio.audio-outputs,audio.video,video.video-outputs,video.playlists,document,broadcast',
+            })
+        return self._extract_video(traverse_obj(data, ('_embedded', 'media-items', 0)), video_id)
+
+
+class SubsplashPlaylistIE(SubsplashBaseIE):
+    IE_NAME = 'subsplash:playlist'
+    _VALID_URL = r'https?://(?:www\.)?subsplash\.com/[^/?#]+/(?:our-videos|media)/ms/\+(?P<id>\w+)'
+    _PAGE_SIZE = 15
+    _TESTS = [{
+        'url': 'https://subsplash.com/skywatchtv/our-videos/ms/+dbyjzp8',
+        'info_dict': {
+            'id': 'dbyjzp8',
+            'title': 'Five in Ten',
+        },
+        'playlist_mincount': 11,
+    }, {
+        'url': 'https://subsplash.com/prophecywatchers/media/ms/+n42mr48',
+        'info_dict': {
+            'id': 'n42mr48',
+            'title': 'Road to Zion Series',
+        },
+        'playlist_mincount': 13,
+    }, {
+        'url': 'https://subsplash.com/prophecywatchers/media/ms/+918b9f6',
+        'only_matching': True,
+    }]
+
+    def _entries(self, series_id, headers, page):
+        data = self._download_json(
+            'https://core.subsplash.com/media/v1/media-items', series_id, headers=headers,
+            query={
+                'filter[broadcast.status|broadcast.status]': 'null|on-demand',
+                'filter[media_series]': series_id,
+                'filter[status]': 'published',
+                'include': 'images,audio.audio-outputs,audio.video,video.video-outputs,video.playlists,document',
+                'page[number]': page + 1,
+                'page[size]': self._PAGE_SIZE,
+                'sort': '-position',
+            }, note=f'Downloading page {page + 1}')
+
+        for entry in traverse_obj(data, ('_embedded', 'media-items', lambda _, v: v['short_code'])):
+            entry_id = entry['short_code']
+            info = self._extract_video(entry, entry_id)
+            yield {
+                **info,
+                'webpage_url': f'https://subspla.sh/{entry_id}',
+                'extractor_key': SubsplashIE.ie_key(),
+                'extractor': SubsplashIE.IE_NAME,
+            }
+
+    def _real_extract(self, url):
+        display_id = self._match_id(url)
+        headers = self._get_headers(url, display_id)
+
+        data = self._download_json(
+            'https://core.subsplash.com/media/v1/media-series', display_id, headers=headers,
+            query={'filter[short_code]': display_id})
+        series_data = traverse_obj(data, ('_embedded', 'media-series', 0, {
+            'id': ('id', {str}),
+            'title': ('title', {str}),
+            'count': ('media_items_count', {int}),
+        }))
+        total_pages = math.ceil(series_data['count'] / self._PAGE_SIZE)
+
+        return self.playlist_result(
+            InAdvancePagedList(functools.partial(self._entries, series_data['id'], headers), total_pages, self._PAGE_SIZE),
+            display_id, series_data['title'])
diff --git a/yt_dlp/extractor/unsupported.py b/yt_dlp/extractor/unsupported.py
index 8b7ec1dd9..e8d6ae128 100644
--- a/yt_dlp/extractor/unsupported.py
+++ b/yt_dlp/extractor/unsupported.py
@@ -50,6 +50,7 @@ class KnownDRMIE(UnsupportedInfoExtractor):
         r'music\.amazon\.(?:\w{2}\.)?\w+',
         r'(?:watch|front)\.njpwworld\.com',
         r'qub\.ca/vrai',
+        r'(?:beta\.)?crunchyroll\.com',
     )
 
     _TESTS = [{
@@ -153,6 +154,12 @@ class KnownDRMIE(UnsupportedInfoExtractor):
     }, {
         'url': 'https://www.qub.ca/vrai/l-effet-bocuse-d-or/saison-1/l-effet-bocuse-d-or-saison-1-bande-annonce-1098225063',
         'only_matching': True,
+    }, {
+        'url': 'https://www.crunchyroll.com/watch/GY2P1Q98Y/to-the-future',
+        'only_matching': True,
+    }, {
+        'url': 'https://beta.crunchyroll.com/pt-br/watch/G8WUN8VKP/the-ruler-of-conspiracy',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/vimeo.py b/yt_dlp/extractor/vimeo.py
index 0ed7b9ec1..8a0aaaa46 100644
--- a/yt_dlp/extractor/vimeo.py
+++ b/yt_dlp/extractor/vimeo.py
@@ -28,6 +28,7 @@
     try_get,
     unified_timestamp,
     unsmuggle_url,
+    url_or_none,
     urlencode_postdata,
     urlhandle_detect_ext,
     urljoin,
@@ -211,11 +212,7 @@ def _parse_config(self, config, video_id):
                     'width': int_or_none(key),
                     'url': thumb,
                 })
-            thumbnail = video_data.get('thumbnail')
-            if thumbnail:
-                thumbnails.append({
-                    'url': thumbnail,
-                })
+            thumbnails.extend(traverse_obj(video_data, (('thumbnail', 'thumbnail_url'), {'url': {url_or_none}})))
 
         owner = video_data.get('owner') or {}
         video_uploader_url = owner.get('url')
@@ -388,7 +385,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'uploader_url': r're:https?://(?:www\.)?vimeo\.com/businessofsoftware',
                 'uploader_id': 'businessofsoftware',
                 'duration': 3610,
-                'thumbnail': 'https://i.vimeocdn.com/video/376682406-f34043e7b766af6bef2af81366eacd6724f3fc3173179a11a97a1e26587c9529-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/376682406-f34043e7b766af6bef2af81366eacd6724f3fc3173179a11a97a1e26587c9529-d',
             },
             'params': {
                 'format': 'best[protocol=https]',
@@ -413,7 +410,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'duration': 10,
                 'comment_count': int,
                 'like_count': int,
-                'thumbnail': 'https://i.vimeocdn.com/video/440665496-b2c5aee2b61089442c794f64113a8e8f7d5763c3e6b3ebfaf696ae6413f8b1f4-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/440665496-b2c5aee2b61089442c794f64113a8e8f7d5763c3e6b3ebfaf696ae6413f8b1f4-d',
             },
             'params': {
                 'format': 'best[protocol=https]',
@@ -437,7 +434,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'timestamp': 1380339469,
                 'upload_date': '20130928',
                 'duration': 187,
-                'thumbnail': 'https://i.vimeocdn.com/video/450239872-a05512d9b1e55d707a7c04365c10980f327b06d966351bc403a5d5d65c95e572-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/450239872-a05512d9b1e55d707a7c04365c10980f327b06d966351bc403a5d5d65c95e572-d',
                 'view_count': int,
                 'comment_count': int,
                 'like_count': int,
@@ -463,7 +460,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'duration': 62,
                 'comment_count': int,
                 'like_count': int,
-                'thumbnail': 'https://i.vimeocdn.com/video/452001751-8216e0571c251a09d7a8387550942d89f7f86f6398f8ed886e639b0dd50d3c90-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/452001751-8216e0571c251a09d7a8387550942d89f7f86f6398f8ed886e639b0dd50d3c90-d',
                 'subtitles': {
                     'de': 'count:3',
                     'en': 'count:3',
@@ -488,7 +485,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'uploader_url': r're:https?://(?:www\.)?vimeo\.com/user28849593',
                 'uploader_id': 'user28849593',
                 'duration': 118,
-                'thumbnail': 'https://i.vimeocdn.com/video/478636036-c18440305ef3df9decfb6bf207a61fe39d2d17fa462a96f6f2d93d30492b037d-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/478636036-c18440305ef3df9decfb6bf207a61fe39d2d17fa462a96f6f2d93d30492b037d-d',
             },
             'expected_warnings': ['Failed to parse XML: not well-formed'],
         },
@@ -509,7 +506,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'duration': 60,
                 'comment_count': int,
                 'view_count': int,
-                'thumbnail': 'https://i.vimeocdn.com/video/231174622-dd07f015e9221ff529d451e1cc31c982b5d87bfafa48c4189b1da72824ee289a-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/231174622-dd07f015e9221ff529d451e1cc31c982b5d87bfafa48c4189b1da72824ee289a-d',
                 'like_count': int,
                 'tags': 'count:11',
             },
@@ -531,7 +528,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'description': 'md5:f2edc61af3ea7a5592681ddbb683db73',
                 'upload_date': '20200225',
                 'duration': 176,
-                'thumbnail': 'https://i.vimeocdn.com/video/859377297-836494a4ef775e9d4edbace83937d9ad34dc846c688c0c419c0e87f7ab06c4b3-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/859377297-836494a4ef775e9d4edbace83937d9ad34dc846c688c0c419c0e87f7ab06c4b3-d',
                 'uploader_url': 'https://vimeo.com/frameworkla',
             },
             # 'params': {'format': 'source'},
@@ -556,7 +553,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'duration': 321,
                 'comment_count': int,
                 'view_count': int,
-                'thumbnail': 'https://i.vimeocdn.com/video/22728298-bfc22146f930de7cf497821c7b0b9f168099201ecca39b00b6bd31fcedfca7a6-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/22728298-bfc22146f930de7cf497821c7b0b9f168099201ecca39b00b6bd31fcedfca7a6-d',
                 'like_count': int,
                 'tags': ['[the shining', 'vimeohq', 'cv', 'vimeo tribute]'],
             },
@@ -596,7 +593,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'uploader_id': 'user18948128',
                 'uploader': 'Jaime Marquínez Ferrándiz',
                 'duration': 10,
-                'thumbnail': 'https://i.vimeocdn.com/video/440665496-b2c5aee2b61089442c794f64113a8e8f7d5763c3e6b3ebfaf696ae6413f8b1f4-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/440665496-b2c5aee2b61089442c794f64113a8e8f7d5763c3e6b3ebfaf696ae6413f8b1f4-d',
             },
             'params': {
                 'format': 'best[protocol=https]',
@@ -633,7 +630,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'description': str,  # FIXME: Dynamic SEO spam description
                 'upload_date': '20150209',
                 'timestamp': 1423518307,
-                'thumbnail': 'https://i.vimeocdn.com/video/default_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/default',
                 'duration': 10,
                 'like_count': int,
                 'uploader_url': 'https://vimeo.com/user20132939',
@@ -666,7 +663,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'license': 'by-nc',
                 'duration': 159,
                 'comment_count': int,
-                'thumbnail': 'https://i.vimeocdn.com/video/562802436-585eeb13b5020c6ac0f171a2234067938098f84737787df05ff0d767f6d54ee9-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/562802436-585eeb13b5020c6ac0f171a2234067938098f84737787df05ff0d767f6d54ee9-d',
                 'like_count': int,
                 'uploader_url': 'https://vimeo.com/aliniamedia',
                 'release_date': '20160329',
@@ -686,7 +683,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'uploader': 'Firework Champions',
                 'upload_date': '20150910',
                 'timestamp': 1441901895,
-                'thumbnail': 'https://i.vimeocdn.com/video/534715882-6ff8e4660cbf2fea68282876d8d44f318825dfe572cc4016e73b3266eac8ae3a-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/534715882-6ff8e4660cbf2fea68282876d8d44f318825dfe572cc4016e73b3266eac8ae3a-d',
                 'uploader_url': 'https://vimeo.com/fireworkchampions',
                 'tags': 'count:6',
                 'duration': 229,
@@ -715,7 +712,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'duration': 336,
                 'comment_count': int,
                 'view_count': int,
-                'thumbnail': 'https://i.vimeocdn.com/video/541243181-b593db36a16db2f0096f655da3f5a4dc46b8766d77b0f440df937ecb0c418347-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/541243181-b593db36a16db2f0096f655da3f5a4dc46b8766d77b0f440df937ecb0c418347-d',
                 'like_count': int,
                 'uploader_url': 'https://vimeo.com/karimhd',
                 'channel_url': 'https://vimeo.com/channels/staffpicks',
@@ -740,7 +737,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'release_timestamp': 1627621014,
                 'duration': 976,
                 'comment_count': int,
-                'thumbnail': 'https://i.vimeocdn.com/video/1202249320-4ddb2c30398c0dc0ee059172d1bd5ea481ad12f0e0e3ad01d2266f56c744b015-d_1280',
+                'thumbnail': 'https://i.vimeocdn.com/video/1202249320-4ddb2c30398c0dc0ee059172d1bd5ea481ad12f0e0e3ad01d2266f56c744b015-d',
                 'like_count': int,
                 'uploader_url': 'https://vimeo.com/txwestcapital',
                 'release_date': '20210730',
@@ -764,7 +761,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'uploader': 'Alex Howard',
                 'uploader_id': 'user54729178',
                 'uploader_url': 'https://vimeo.com/user54729178',
-                'thumbnail': r're:https://i\.vimeocdn\.com/video/1520099929-[\da-f]+-d_1280',
+                'thumbnail': r're:https://i\.vimeocdn\.com/video/1520099929-[\da-f]+-d',
                 'duration': 2636,
                 'chapters': [
                     {'start_time': 0, 'end_time': 10, 'title': '<Untitled Chapter 1>'},
@@ -807,7 +804,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'like_count': int,
                 'view_count': int,
                 'comment_count': int,
-                'thumbnail': r're:https://i\.vimeocdn\.com/video/1018638656-[\da-f]+-d_1280',
+                'thumbnail': r're:https://i\.vimeocdn\.com/video/1018638656-[\da-f]+-d',
             },
             # 'params': {'format': 'Original'},
             'expected_warnings': ['Failed to parse XML: not well-formed'],
@@ -824,7 +821,7 @@ class VimeoIE(VimeoBaseInfoExtractor):
                 'uploader_id': 'rajavirdi',
                 'uploader_url': 'https://vimeo.com/rajavirdi',
                 'duration': 309,
-                'thumbnail': r're:https://i\.vimeocdn\.com/video/1716727772-[\da-f]+-d_1280',
+                'thumbnail': r're:https://i\.vimeocdn\.com/video/1716727772-[\da-f]+-d',
             },
             # 'params': {'format': 'source'},
             'expected_warnings': ['Failed to parse XML: not well-formed'],
diff --git a/yt_dlp/extractor/xiaohongshu.py b/yt_dlp/extractor/xiaohongshu.py
index 46543b823..4a6129ba6 100644
--- a/yt_dlp/extractor/xiaohongshu.py
+++ b/yt_dlp/extractor/xiaohongshu.py
@@ -5,6 +5,7 @@
     int_or_none,
     js_to_json,
     url_or_none,
+    urlhandle_detect_ext,
 )
 from ..utils.traversal import traverse_obj
 
@@ -46,7 +47,7 @@ def _real_extract(self, url):
             r'window\.__INITIAL_STATE__\s*=', webpage, 'initial state', display_id, transform_source=js_to_json)
 
         note_info = traverse_obj(initial_state, ('note', 'noteDetailMap', display_id, 'note'))
-        video_info = traverse_obj(note_info, ('video', 'media', 'stream', ('h264', 'av1', 'h265'), ...))
+        video_info = traverse_obj(note_info, ('video', 'media', 'stream', ..., ...))
 
         formats = []
         for info in video_info:
@@ -56,18 +57,32 @@ def _real_extract(self, url):
                 'height': ('height', {int_or_none}),
                 'vcodec': ('videoCodec', {str}),
                 'acodec': ('audioCodec', {str}),
-                'abr': ('audioBitrate', {int_or_none}),
-                'vbr': ('videoBitrate', {int_or_none}),
+                'abr': ('audioBitrate', {int_or_none(scale=1000)}),
+                'vbr': ('videoBitrate', {int_or_none(scale=1000)}),
                 'audio_channels': ('audioChannels', {int_or_none}),
-                'tbr': ('avgBitrate', {int_or_none}),
+                'tbr': ('avgBitrate', {int_or_none(scale=1000)}),
                 'format': ('qualityType', {str}),
                 'filesize': ('size', {int_or_none}),
                 'duration': ('duration', {float_or_none(scale=1000)}),
             })
 
-            formats.extend(traverse_obj(info, (('mediaUrl', ('backupUrls', ...)), {
+            formats.extend(traverse_obj(info, (('masterUrl', ('backupUrls', ...)), {
                 lambda u: url_or_none(u) and {'url': u, **format_info}})))
 
+        if origin_key := traverse_obj(note_info, ('video', 'consumer', 'originVideoKey', {str})):
+            # Not using a head request because of false negatives
+            urlh = self._request_webpage(
+                f'https://sns-video-bd.xhscdn.com/{origin_key}', display_id,
+                'Checking original video availability', 'Original video is not available', fatal=False)
+            if urlh:
+                formats.append({
+                    'format_id': 'direct',
+                    'ext': urlhandle_detect_ext(urlh, default='mp4'),
+                    'filesize': int_or_none(urlh.get_header('Content-Length')),
+                    'url': urlh.url,
+                    'quality': 1,
+                })
+
         thumbnails = []
         for image_info in traverse_obj(note_info, ('imageList', ...)):
             thumbnail_info = traverse_obj(image_info, {
diff --git a/yt_dlp/extractor/youtube.py b/yt_dlp/extractor/youtube.py
index c23e65cc5..d84e8df33 100644
--- a/yt_dlp/extractor/youtube.py
+++ b/yt_dlp/extractor/youtube.py
@@ -1,4 +1,5 @@
 import base64
+import binascii
 import calendar
 import collections
 import copy
@@ -69,7 +70,14 @@
 )
 
 STREAMING_DATA_CLIENT_NAME = '__yt_dlp_client'
-STREAMING_DATA_PO_TOKEN = '__yt_dlp_po_token'
+STREAMING_DATA_INITIAL_PO_TOKEN = '__yt_dlp_po_token'
+PO_TOKEN_GUIDE_URL = 'https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide'
+
+
+class _PoTokenContext(enum.Enum):
+    PLAYER = 'player'
+    GVS = 'gvs'
+
 
 # any clients starting with _ cannot be explicitly requested by the user
 INNERTUBE_CLIENTS = {
@@ -81,7 +89,7 @@
             },
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 1,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'SUPPORTS_COOKIES': True,
     },
     # Safari UA returns pre-merged video+audio 144p/240p/360p/720p/1080p HLS formats
@@ -94,7 +102,7 @@
             },
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 1,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'SUPPORTS_COOKIES': True,
     },
     'web_embedded': {
@@ -116,7 +124,7 @@
             },
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 67,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'SUPPORTS_COOKIES': True,
     },
     # This client now requires sign-in for every video
@@ -128,7 +136,7 @@
             },
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 62,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'REQUIRE_AUTH': True,
         'SUPPORTS_COOKIES': True,
     },
@@ -145,7 +153,7 @@
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 3,
         'REQUIRE_JS_PLAYER': False,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
     },
     # This client now requires sign-in for every video
     'android_music': {
@@ -161,7 +169,7 @@
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 21,
         'REQUIRE_JS_PLAYER': False,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'REQUIRE_AUTH': True,
     },
     # This client now requires sign-in for every video
@@ -178,7 +186,7 @@
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 14,
         'REQUIRE_JS_PLAYER': False,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'REQUIRE_AUTH': True,
     },
     # YouTube Kids videos aren't returned on this client for some reason
@@ -204,17 +212,17 @@
         'INNERTUBE_CONTEXT': {
             'client': {
                 'clientName': 'IOS',
-                'clientVersion': '19.45.4',
+                'clientVersion': '20.03.02',
                 'deviceMake': 'Apple',
                 'deviceModel': 'iPhone16,2',
-                'userAgent': 'com.google.ios.youtube/19.45.4 (iPhone16,2; U; CPU iOS 18_1_0 like Mac OS X;)',
+                'userAgent': 'com.google.ios.youtube/20.03.02 (iPhone16,2; U; CPU iOS 18_2_1 like Mac OS X;)',
                 'osName': 'iPhone',
-                'osVersion': '18.1.0.22B83',
+                'osVersion': '18.2.1.22C161',
             },
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'REQUIRE_JS_PLAYER': False,
-        'REQUIRE_PO_TOKEN': True,
     },
     # This client now requires sign-in for every video
     'ios_music': {
@@ -231,7 +239,7 @@
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 26,
         'REQUIRE_JS_PLAYER': False,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'REQUIRE_AUTH': True,
     },
     # This client now requires sign-in for every video
@@ -249,7 +257,7 @@
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 15,
         'REQUIRE_JS_PLAYER': False,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'REQUIRE_AUTH': True,
     },
     # mweb has 'ultralow' formats
@@ -264,14 +272,15 @@
             },
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 2,
-        'REQUIRE_PO_TOKEN': True,
+        'PO_TOKEN_REQUIRED_CONTEXTS': [_PoTokenContext.GVS],
         'SUPPORTS_COOKIES': True,
     },
     'tv': {
         'INNERTUBE_CONTEXT': {
             'client': {
                 'clientName': 'TVHTML5',
-                'clientVersion': '7.20241201.18.00',
+                'clientVersion': '7.20250120.19.00',
+                'userAgent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
             },
         },
         'INNERTUBE_CONTEXT_CLIENT_NAME': 7,
@@ -317,7 +326,7 @@ def build_innertube_clients():
     for client, ytcfg in tuple(INNERTUBE_CLIENTS.items()):
         ytcfg.setdefault('INNERTUBE_HOST', 'www.youtube.com')
         ytcfg.setdefault('REQUIRE_JS_PLAYER', True)
-        ytcfg.setdefault('REQUIRE_PO_TOKEN', False)
+        ytcfg.setdefault('PO_TOKEN_REQUIRED_CONTEXTS', [])
         ytcfg.setdefault('REQUIRE_AUTH', False)
         ytcfg.setdefault('SUPPORTS_COOKIES', False)
         ytcfg.setdefault('PLAYER_PARAMS', None)
@@ -853,11 +862,15 @@ def _download_ytcfg(self, client, video_id):
             'web': 'https://www.youtube.com',
             'web_music': 'https://music.youtube.com',
             'web_embedded': f'https://www.youtube.com/embed/{video_id}?html5=1',
+            'tv': 'https://www.youtube.com/tv',
         }.get(client)
         if not url:
             return {}
         webpage = self._download_webpage(
-            url, video_id, fatal=False, note=f'Downloading {client.replace("_", " ").strip()} client config')
+            url, video_id, fatal=False, note=f'Downloading {client.replace("_", " ").strip()} client config',
+            headers=traverse_obj(self._get_default_ytcfg(client), {
+                'User-Agent': ('INNERTUBE_CONTEXT', 'client', 'userAgent', {str}),
+            }))
         return self.extract_ytcfg(video_id, webpage) or {}
 
     @staticmethod
@@ -3837,53 +3850,105 @@ def _generate_player_context(cls, sts=None):
             **cls._get_checkok_params(),
         }
 
-    def _get_config_po_token(self, client):
+    def _get_config_po_token(self, client: str, context: _PoTokenContext):
         po_token_strs = self._configuration_arg('po_token', [], ie_key=YoutubeIE, casesense=True)
         for token_str in po_token_strs:
-            po_token_client, sep, po_token = token_str.partition('+')
+            po_token_meta, sep, po_token = token_str.partition('+')
             if not sep:
                 self.report_warning(
-                    f'Invalid po_token configuration format. Expected "client+po_token", got "{token_str}"', only_once=True)
+                    f'Invalid po_token configuration format. '
+                    f'Expected "CLIENT.CONTEXT+PO_TOKEN", got "{token_str}"', only_once=True)
+                continue
+
+            po_token_client, sep, po_token_context = po_token_meta.partition('.')
+            if po_token_client.lower() != client:
+                continue
+
+            if not sep:
+                # TODO(future): deprecate the old format?
+                self.write_debug(
+                    f'po_token configuration for {client} client is missing a context; assuming GVS. '
+                    'You can provide a context with the format "CLIENT.CONTEXT+PO_TOKEN"',
+                    only_once=True)
+                po_token_context = _PoTokenContext.GVS.value
+
+            if po_token_context.lower() != context.value:
                 continue
-            if po_token_client == client:
-                return po_token
 
-    def fetch_po_token(self, client='web', visitor_data=None, data_sync_id=None, player_url=None, **kwargs):
-        # PO Token is bound to visitor_data / Visitor ID when logged out. Must have visitor_data for it to function.
-        if not visitor_data and not self.is_authenticated and player_url:
+            # Clean and validate the PO Token. This will strip invalid characters off
+            # (e.g. additional url params the user may accidentally include)
+            try:
+                return base64.urlsafe_b64encode(base64.urlsafe_b64decode(urllib.parse.unquote(po_token))).decode()
+            except (binascii.Error, ValueError):
+                self.report_warning(
+                    f'Invalid po_token configuration for {client} client: '
+                    f'{po_token_context} PO Token should be a base64url-encoded string.',
+                    only_once=True)
+                continue
+
+    def fetch_po_token(self, client='web', context=_PoTokenContext.GVS, ytcfg=None, visitor_data=None,
+                       data_sync_id=None, session_index=None, player_url=None, video_id=None, **kwargs):
+        """
+        Fetch a PO Token for a given client and context. This function will validate required parameters for a given context and client.
+
+        EXPERIMENTAL: This method is unstable and may change or be removed without notice.
+
+        @param client: The client to fetch the PO Token for.
+        @param context: The context in which the PO Token is used.
+        @param ytcfg: The ytcfg for the client.
+        @param visitor_data: visitor data.
+        @param data_sync_id: data sync ID.
+        @param session_index: session index.
+        @param player_url: player URL.
+        @param video_id: video ID.
+        @param kwargs: Additional arguments to pass down. May be more added in the future.
+        @return: The fetched PO Token. None if it could not be fetched.
+        """
+
+        # GVS WebPO Token is bound to visitor_data / Visitor ID when logged out.
+        # Must have visitor_data for it to function.
+        if player_url and context == _PoTokenContext.GVS and not visitor_data and not self.is_authenticated:
             self.report_warning(
-                f'Unable to fetch PO Token for {client} client: Missing required Visitor Data. '
+                f'Unable to fetch GVS PO Token for {client} client: Missing required Visitor Data. '
                 f'You may need to pass Visitor Data with --extractor-args "youtube:visitor_data=XXX"')
             return
 
-        config_po_token = self._get_config_po_token(client)
+        if context == _PoTokenContext.PLAYER and not video_id:
+            self.report_warning(
+                f'Unable to fetch Player PO Token for {client} client: Missing required Video ID')
+            return
+
+        config_po_token = self._get_config_po_token(client, context)
         if config_po_token:
-            # PO token is bound to data_sync_id / account Session ID when logged in. However, for the config po_token,
-            # if using first channel in an account then we don't need the data_sync_id anymore...
-            if not data_sync_id and self.is_authenticated and player_url:
+            # GVS WebPO token is bound to data_sync_id / account Session ID when logged in.
+            if player_url and context == _PoTokenContext.GVS and not data_sync_id and self.is_authenticated:
                 self.report_warning(
-                    f'Got a PO Token for {client} client, but missing Data Sync ID for account. Formats may not work.'
+                    f'Got a GVS PO Token for {client} client, but missing Data Sync ID for account. Formats may not work.'
                     f'You may need to pass a Data Sync ID with --extractor-args "youtube:data_sync_id=XXX"')
 
             return config_po_token
 
-        # Require PO Token if logged in for external fetching
-        if not data_sync_id and self.is_authenticated and player_url:
+        # Require GVS WebPO Token if logged in for external fetching
+        if player_url and context == _PoTokenContext.GVS and not data_sync_id and self.is_authenticated:
             self.report_warning(
-                f'Unable to fetch PO Token for {client} client: Missing required Data Sync ID for account. '
+                f'Unable to fetch GVS PO Token for {client} client: Missing required Data Sync ID for account. '
                 f'You may need to pass a Data Sync ID with --extractor-args "youtube:data_sync_id=XXX"')
             return
 
         return self._fetch_po_token(
             client=client,
+            context=context.value,
+            ytcfg=ytcfg,
             visitor_data=visitor_data,
             data_sync_id=data_sync_id,
+            session_index=session_index,
             player_url=player_url,
+            video_id=video_id,
             **kwargs,
         )
 
-    def _fetch_po_token(self, client, visitor_data=None, data_sync_id=None, player_url=None, **kwargs):
-        """External PO Token fetch stub"""
+    def _fetch_po_token(self, client, **kwargs):
+        """(Unstable) External PO Token fetch stub"""
 
     @staticmethod
     def _is_agegated(player_response):
@@ -3965,6 +4030,9 @@ def _get_requested_clients(self, url, smuggled_data):
             raise ExtractorError('No player clients have been requested', expected=True)
 
         if self.is_authenticated:
+            if (smuggled_data.get('is_music_url') or self.is_music_url(url)) and 'web_music' not in requested_clients:
+                requested_clients.append('web_music')
+
             unsupported_clients = [
                 client for client in requested_clients if not INNERTUBE_CLIENTS[client]['SUPPORTS_COOKIES']
             ]
@@ -4031,17 +4099,47 @@ def append_client(*client_names):
 
             visitor_data = visitor_data or self._extract_visitor_data(master_ytcfg, initial_pr, player_ytcfg)
             data_sync_id = data_sync_id or self._extract_data_sync_id(master_ytcfg, initial_pr, player_ytcfg)
-            po_token = self.fetch_po_token(
-                client=client, visitor_data=visitor_data,
-                data_sync_id=data_sync_id if self.is_authenticated else None,
-                player_url=player_url if require_js_player else None,
-            )
 
-            require_po_token = self._get_default_ytcfg(client).get('REQUIRE_PO_TOKEN')
-            if not po_token and require_po_token and 'missing_pot' in self._configuration_arg('formats'):
+            fetch_po_token_args = {
+                'client': client,
+                'visitor_data': visitor_data,
+                'video_id': video_id,
+                'data_sync_id': data_sync_id if self.is_authenticated else None,
+                'player_url': player_url if require_js_player else None,
+                'session_index': self._extract_session_index(master_ytcfg, player_ytcfg),
+                'ytcfg': player_ytcfg,
+            }
+
+            player_po_token = self.fetch_po_token(
+                context=_PoTokenContext.PLAYER, **fetch_po_token_args)
+
+            gvs_po_token = self.fetch_po_token(
+                context=_PoTokenContext.GVS, **fetch_po_token_args)
+
+            required_pot_contexts = self._get_default_ytcfg(client)['PO_TOKEN_REQUIRED_CONTEXTS']
+
+            if (
+                not player_po_token
+                and _PoTokenContext.PLAYER in required_pot_contexts
+            ):
+                # TODO: may need to skip player response request. Unsure yet..
+                self.report_warning(
+                    f'No Player PO Token provided for {client} client, '
+                    f'which may be required for working {client} formats. This client will be deprioritized'
+                    f'You can manually pass a Player PO Token for this client with --extractor-args "youtube:po_token={client}.player+XXX". '
+                    f'For more information, refer to {PO_TOKEN_GUIDE_URL} .', only_once=True)
+                deprioritize_pr = True
+
+            if (
+                not gvs_po_token
+                and _PoTokenContext.GVS in required_pot_contexts
+                and 'missing_pot' in self._configuration_arg('formats')
+            ):
+                # note: warning with help message is provided later during format processing
                 self.report_warning(
-                    f'No PO Token provided for {client} client, '
-                    f'which may be required for working {client} formats. This client will be deprioritized', only_once=True)
+                    f'No GVS PO Token provided for {client} client, '
+                    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
@@ -4054,7 +4152,7 @@ def append_client(*client_names):
                     initial_pr=initial_pr,
                     visitor_data=visitor_data,
                     data_sync_id=data_sync_id,
-                    po_token=po_token)
+                    po_token=player_po_token)
             except ExtractorError as e:
                 self.report_warning(e)
                 continue
@@ -4065,15 +4163,25 @@ def append_client(*client_names):
                 # Save client name for introspection later
                 sd = traverse_obj(pr, ('streamingData', {dict})) or {}
                 sd[STREAMING_DATA_CLIENT_NAME] = client
-                sd[STREAMING_DATA_PO_TOKEN] = po_token
+                sd[STREAMING_DATA_INITIAL_PO_TOKEN] = gvs_po_token
                 for f in traverse_obj(sd, (('formats', 'adaptiveFormats'), ..., {dict})):
                     f[STREAMING_DATA_CLIENT_NAME] = client
-                    f[STREAMING_DATA_PO_TOKEN] = po_token
+                    f[STREAMING_DATA_INITIAL_PO_TOKEN] = gvs_po_token
                 if deprioritize_pr:
                     deprioritized_prs.append(pr)
                 else:
                     prs.append(pr)
 
+            # 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):
+                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)
+                # tv_embedded can work around the age-verification requirement for embeddable videos
+                # web_creator may work around age-verification for all videos but requires PO token
+                append_client('tv_embedded', 'web_creator')
+
         prs.extend(deprioritized_prs)
 
         if skipped_clients:
@@ -4094,10 +4202,10 @@ def _needs_live_processing(self, live_status, duration):
 
     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. '
+            f'{video_id}: {client_name} client {proto} formats require a GVS 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 . '
+            f'You can manually pass a GVS PO Token for this client with --extractor-args "youtube:po_token={client_name}.gvs+XXX". '
+            f'For more information, refer to  {PO_TOKEN_GUIDE_URL} . '
             '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.
@@ -4227,13 +4335,17 @@ def build_fragments(f):
                     f'{video_id}: Some formats are possibly damaged. They will be deprioritized', only_once=True)
 
             client_name = fmt[STREAMING_DATA_CLIENT_NAME]
-            po_token = fmt.get(STREAMING_DATA_PO_TOKEN)
+            po_token = fmt.get(STREAMING_DATA_INITIAL_PO_TOKEN)
 
             if po_token:
                 fmt_url = update_url_query(fmt_url, {'pot': po_token})
 
             # Clients that require PO Token return videoplayback URLs that may return 403
-            require_po_token = (not po_token and self._get_default_ytcfg(client_name).get('REQUIRE_PO_TOKEN'))
+            require_po_token = (
+                not po_token
+                and _PoTokenContext.GVS in self._get_default_ytcfg(client_name)['PO_TOKEN_REQUIRED_CONTEXTS']
+                and itag not in ['18'])  # these formats do not 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
@@ -4322,7 +4434,11 @@ 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':
+            if (
+                not po_token
+                and _PoTokenContext.GVS in self._get_default_ytcfg(client_name)['PO_TOKEN_REQUIRED_CONTEXTS']
+                and proto != 'hls'
+            ):
                 if 'missing_pot' not in self._configuration_arg('formats'):
                     self._report_pot_format_skipped(video_id, client_name, proto)
                     return False
@@ -4363,7 +4479,7 @@ def process_manifest_format(f, proto, client_name, itag, po_token):
         subtitles = {}
         for sd in streaming_data:
             client_name = sd[STREAMING_DATA_CLIENT_NAME]
-            po_token = sd.get(STREAMING_DATA_PO_TOKEN)
+            po_token = sd.get(STREAMING_DATA_INITIAL_PO_TOKEN)
             hls_manifest_url = 'hls' not in skip_manifests and sd.get('hlsManifestUrl')
             if hls_manifest_url:
                 if po_token:
diff --git a/yt_dlp/utils/_utils.py b/yt_dlp/utils/_utils.py
index 699bf1e7f..17d8424c4 100644
--- a/yt_dlp/utils/_utils.py
+++ b/yt_dlp/utils/_utils.py
@@ -685,7 +685,8 @@ def _sanitize_path_parts(parts):
         elif part == '..':
             if sanitized_parts and sanitized_parts[-1] != '..':
                 sanitized_parts.pop()
-            sanitized_parts.append('..')
+            else:
+                sanitized_parts.append('..')
             continue
         # Replace invalid segments with `#`
         # - trailing dots and spaces (`asdf...` => `asdf..#`)
@@ -702,7 +703,8 @@ def sanitize_path(s, force=False):
         if not force:
             return s
         root = '/' if s.startswith('/') else ''
-        return root + '/'.join(_sanitize_path_parts(s.split('/')))
+        path = '/'.join(_sanitize_path_parts(s.split('/')))
+        return root + path if root or path else '.'
 
     normed = s.replace('/', '\\')
 
@@ -721,7 +723,8 @@ def sanitize_path(s, force=False):
         root = '\\' if normed[:1] == '\\' else ''
         parts = normed.split('\\')
 
-    return root + '\\'.join(_sanitize_path_parts(parts))
+    path = '\\'.join(_sanitize_path_parts(parts))
+    return root + path if root or path else '.'
 
 
 def sanitize_url(url, *, scheme='http'):


Test patch:
diff --git a/test/test_YoutubeDL.py b/test/test_YoutubeDL.py
index 6b022a7ea..17e081bc6 100644
--- a/test/test_YoutubeDL.py
+++ b/test/test_YoutubeDL.py
@@ -486,11 +486,11 @@ def assert_syntax_error(format_spec):
 
     def test_format_filtering(self):
         formats = [
-            {'format_id': 'A', 'filesize': 500, 'width': 1000},
-            {'format_id': 'B', 'filesize': 1000, 'width': 500},
-            {'format_id': 'C', 'filesize': 1000, 'width': 400},
-            {'format_id': 'D', 'filesize': 2000, 'width': 600},
-            {'format_id': 'E', 'filesize': 3000},
+            {'format_id': 'A', 'filesize': 500, 'width': 1000, 'aspect_ratio': 1.0},
+            {'format_id': 'B', 'filesize': 1000, 'width': 500, 'aspect_ratio': 1.33},
+            {'format_id': 'C', 'filesize': 1000, 'width': 400, 'aspect_ratio': 1.5},
+            {'format_id': 'D', 'filesize': 2000, 'width': 600, 'aspect_ratio': 1.78},
+            {'format_id': 'E', 'filesize': 3000, 'aspect_ratio': 0.56},
             {'format_id': 'F'},
             {'format_id': 'G', 'filesize': 1000000},
         ]
@@ -549,6 +549,31 @@ def test_format_filtering(self):
             ydl.process_ie_result(info_dict)
         self.assertEqual(ydl.downloaded_info_dicts, [])
 
+        ydl = YDL({'format': 'best[aspect_ratio=1]'})
+        ydl.process_ie_result(info_dict)
+        downloaded = ydl.downloaded_info_dicts[0]
+        self.assertEqual(downloaded['format_id'], 'A')
+
+        ydl = YDL({'format': 'all[aspect_ratio > 1.00]'})
+        ydl.process_ie_result(info_dict)
+        downloaded_ids = [info['format_id'] for info in ydl.downloaded_info_dicts]
+        self.assertEqual(downloaded_ids, ['D', 'C', 'B'])
+
+        ydl = YDL({'format': 'all[aspect_ratio < 1.00]'})
+        ydl.process_ie_result(info_dict)
+        downloaded_ids = [info['format_id'] for info in ydl.downloaded_info_dicts]
+        self.assertEqual(downloaded_ids, ['E'])
+
+        ydl = YDL({'format': 'best[aspect_ratio=1.5]'})
+        ydl.process_ie_result(info_dict)
+        downloaded = ydl.downloaded_info_dicts[0]
+        self.assertEqual(downloaded['format_id'], 'C')
+
+        ydl = YDL({'format': 'all[aspect_ratio!=1]'})
+        ydl.process_ie_result(info_dict)
+        downloaded_ids = [info['format_id'] for info in ydl.downloaded_info_dicts]
+        self.assertEqual(downloaded_ids, ['E', 'D', 'C', 'B'])
+
     @patch('yt_dlp.postprocessor.ffmpeg.FFmpegMergerPP.available', False)
     def test_default_format_spec_without_ffmpeg(self):
         ydl = YDL({})
diff --git a/test/test_utils.py b/test/test_utils.py
index b3de14198..8f81d0b1b 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -249,17 +249,36 @@ def _test_sanitize_path(self):
         self.assertEqual(sanitize_path('abc/def...'), 'abc\\def..#')
         self.assertEqual(sanitize_path('abc.../def'), 'abc..#\\def')
         self.assertEqual(sanitize_path('abc.../def...'), 'abc..#\\def..#')
-
-        self.assertEqual(sanitize_path('../abc'), '..\\abc')
-        self.assertEqual(sanitize_path('../../abc'), '..\\..\\abc')
-        self.assertEqual(sanitize_path('./abc'), 'abc')
-        self.assertEqual(sanitize_path('./../abc'), '..\\abc')
-
-        self.assertEqual(sanitize_path('\\abc'), '\\abc')
-        self.assertEqual(sanitize_path('C:abc'), 'C:abc')
-        self.assertEqual(sanitize_path('C:abc\\..\\'), 'C:..')
         self.assertEqual(sanitize_path('C:\\abc:%(title)s.%(ext)s'), 'C:\\abc#%(title)s.%(ext)s')
 
+        # Check with nt._path_normpath if available
+        try:
+            import nt
+
+            nt_path_normpath = getattr(nt, '_path_normpath', None)
+        except Exception:
+            nt_path_normpath = None
+
+        for test, expected in [
+            ('C:\\', 'C:\\'),
+            ('../abc', '..\\abc'),
+            ('../../abc', '..\\..\\abc'),
+            ('./abc', 'abc'),
+            ('./../abc', '..\\abc'),
+            ('\\abc', '\\abc'),
+            ('C:abc', 'C:abc'),
+            ('C:abc\\..\\', 'C:'),
+            ('C:abc\\..\\def\\..\\..\\', 'C:..'),
+            ('C:\\abc\\xyz///..\\def\\', 'C:\\abc\\def'),
+            ('abc/../', '.'),
+            ('./abc/../', '.'),
+        ]:
+            result = sanitize_path(test)
+            assert result == expected, f'{test} was incorrectly resolved'
+            assert result == sanitize_path(result), f'{test} changed after sanitizing again'
+            if nt_path_normpath:
+                assert result == nt_path_normpath(test), f'{test} does not match nt._path_normpath'
+
     def test_sanitize_url(self):
         self.assertEqual(sanitize_url('//foo.bar'), 'http://foo.bar')
         self.assertEqual(sanitize_url('httpss://foo.bar'), 'https://foo.bar')