Repository: yt-dlp/yt-dlp

Issue:
[Funimation] User+pass login broken
### Checklist

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

Australia

### Description

I am trying to download Demon Slayer: Mugen Train ([LINK](https://www.funimation.com/v/demon-slayer-kimetsu-no-yaiba/demon-slayer-kimetsu-no-yaiba-the-movie-mugen-train)) from Funimation, but when trying to download the JSON data, it errors.

### Verbose log

```shell
[debug] Command-line config: ['--netrc', 'https://www.funimation.com/v/demon-slayer-kimetsu-no-yaiba/demon-slayer-kimetsu-no-yaiba-the-movie-mugen-train', '-F', '-Uv']
[debug] Encodings: locale UTF-8, fs utf-8, out utf-8, err utf-8, pref UTF-8[debug] yt-dlp version 2021.10.22
[debug] Python version 3.10.0 (CPython 64bit) - macOS-12.1-x86_64-i386-64bit
[debug] exe versions: avconv present, avprobe present, ffmpeg N-104454-gd92fdc7144, ffprobe N-104454-gd92fdc7144, rtmpdump 2.4
[debug] Optional libraries: Cryptodome, mutagen, sqlite, websockets
[debug] Proxy map: {}
yt-dlp is up to date (2021.10.22)
[funimation:page] Checking geo-location
[funimation:page] Logging in
[debug] [funimation:page] Extracting URL: https://www.funimation.com/v/demon-slayer-kimetsu-no-yaiba/demon-slayer-kimetsu-no-yaiba-the-movie-mugen-train
[funimation:page] demon-slayer-kimetsu-no-yaiba_demon-slayer-kimetsu-no-yaiba-the-movie-mugen-train: Downloading JSON metadata
[debug] [Funimation] Extracting URL: https://www.funimation.com/player/1621628
[Funimation] 1621628: Downloading player webpage for 1621628
[Funimation] demon-slayer-kimetsu-no-yaiba-the-movie-mugen-train: Downloading Uncut japanese (1751780) JSON
WARNING: [Funimation] Funimation said: Error 4 - No valid Free Avails for this experience
[Funimation] demon-slayer-kimetsu-no-yaiba-the-movie-mugen-train: Downloading Uncut english (1621628) JSON
WARNING: [Funimation] Funimation said: Error 4 - No valid Free Avails for this experience
ERROR: [Funimation] 1621627: No video formats found!; please report this issue on  https://github.com/yt-dlp/yt-dlp . Make sure you are using the latest version; see  https://github.com/yt-dlp/yt-dlp  on how to update. Be sure to call yt-dlp with the --verbose flag and include its complete output.
Traceback (most recent call last):
  File "/usr/local/Cellar/yt-dlp/2021.10.22/libexec/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 1280, in wrapper
    return func(self, *args, **kwargs)
  File "/usr/local/Cellar/yt-dlp/2021.10.22/libexec/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 1318, in __extract_info
    return self.process_ie_result(ie_result, download, extra_info)
  File "/usr/local/Cellar/yt-dlp/2021.10.22/libexec/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 1368, in process_ie_result
    ie_result = self.process_video_result(ie_result, download=download)
  File "/usr/local/Cellar/yt-dlp/2021.10.22/libexec/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 2237, in process_video_result
    self.raise_no_formats(info_dict)
  File "/usr/local/Cellar/yt-dlp/2021.10.22/libexec/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 882, in raise_no_formats
    raise ExtractorError(msg, video_id=info['id'], ie=info['extractor'],
yt_dlp.utils.ExtractorError: [Funimation] 1621627: No video formats found!; please report this issue on  https://github.com/yt-dlp/yt-dlp . Make sure you are using the latest version; see  https://github.com/yt-dlp/yt-dlp  on how to update. Be sure to call yt-dlp with the --verbose flag and include its complete output.
```


Discussion:

Patch:
diff --git a/README.md b/README.md
index 56e4458dc..da500ab98 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,10 +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`
 
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 c03d4b3f5..dea14794b 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -705,11 +705,6 @@
     FrontendMastersLessonIE,
 )
 from .fujitv import FujiTVFODPlus7IE
-from .funimation import (
-    FunimationIE,
-    FunimationPageIE,
-    FunimationShowIE,
-)
 from .funk import FunkIE
 from .funker530 import Funker530IE
 from .fuyintv import FuyinTVIE
@@ -1996,6 +1991,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/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/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/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/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:


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({})