Repository: yt-dlp/yt-dlp
Pull request #12925 (2025-04-16 16:56:48)

[ie/panopto] Fix formats extraction
<!--
    **IMPORTANT**: PRs without the template will be CLOSED
    
    Due to the high volume of pull requests, it may be a while before your PR is reviewed.
    Please try to keep your pull request focused on a single bugfix or new feature.
    Pull requests with a vast scope and/or very large diff will take much longer to review.
    It is recommended for new contributors to stick to smaller pull requests, so you can receive much more immediate feedback as you familiarize yourself with the codebase.

    PLEASE AVOID FORCE-PUSHING after opening a PR, as it makes reviewing more difficult.
-->

### Description of your *pull request* and other information

ADD DETAILED DESCRIPTION HERE


<details open><summary>Template</summary> <!-- OPEN is intentional -->

<!--
    # PLEASE FOLLOW THE GUIDE BELOW

    - You will be asked some questions, please read them **carefully** and answer honestly
    - Put an `x` into all the boxes `[ ]` relevant to your *pull request* (like [x])
    - Use *Preview* tab to see what your *pull request* will actually look like
-->

### Before submitting a *pull request* make sure you have:
- [x] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [x] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check those that apply and remove the others:
- [x] I am the original author of the code in this PR, and I am willing to release it under [Unlicense](http://unlicense.org/)

### What is the purpose of your *pull request*? Check those that apply and remove the others:
- [x] Fix or improvement to an extractor (Make sure to add/update tests)


</details>



Patch:
diff --git a/README.md b/README.md
index c0329f539..0cc2cd7b2 100644
--- a/README.md
+++ b/README.md
@@ -1770,7 +1770,7 @@ #### 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 currently available clients are `web`, `web_safari`, `web_embedded`, `web_music`, `web_creator`, `mweb`, `ios`, `android`, `android_vr`, `tv` and `tv_embedded`. 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 `web_creator`, 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_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), `initial_data` (skip initial data/next ep request). While these options can help reduce the number of requests needed or avoid some rate-limiting, they could cause issues such as missing formats or metadata.  See [#860](https://github.com/yt-dlp/yt-dlp/pull/860) and [#12826](https://github.com/yt-dlp/yt-dlp/issues/12826) 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)
 * `max_comments`: Limit the amount of comments to gather. Comma-separated list of integers representing `max-comments,max-parents,max-replies,max-replies-per-thread`. Default is `all,all,all,all`
diff --git a/yt_dlp/extractor/cda.py b/yt_dlp/extractor/cda.py
index 96f25c22a..aa39bf382 100644
--- a/yt_dlp/extractor/cda.py
+++ b/yt_dlp/extractor/cda.py
@@ -353,7 +353,7 @@ def extract_format(page, version):
 
 class CDAFolderIE(InfoExtractor):
     _MAX_PAGE_SIZE = 36
-    _VALID_URL = r'https?://(?:www\.)?cda\.pl/(?P<channel>\w+)/folder/(?P<id>\d+)'
+    _VALID_URL = r'https?://(?:www\.)?cda\.pl/(?P<channel>[\w-]+)/folder/(?P<id>\d+)'
     _TESTS = [
         {
             'url': 'https://www.cda.pl/domino264/folder/31188385',
@@ -378,6 +378,9 @@ class CDAFolderIE(InfoExtractor):
                 'title': 'TESTY KOSMETYKÓW',
             },
             'playlist_mincount': 139,
+        }, {
+            'url': 'https://www.cda.pl/FILMY-SERIALE-ANIME-KRESKOWKI-BAJKI/folder/18493422',
+            'only_matching': True,
         }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/linkedin.py b/yt_dlp/extractor/linkedin.py
index c8c8ae52a..d25f0fe6a 100644
--- a/yt_dlp/extractor/linkedin.py
+++ b/yt_dlp/extractor/linkedin.py
@@ -82,7 +82,10 @@ def _get_video_id(self, video_data, course_slug, video_slug):
 
 
 class LinkedInIE(LinkedInBaseIE):
-    _VALID_URL = r'https?://(?:www\.)?linkedin\.com/posts/[^/?#]+-(?P<id>\d+)-\w{4}/?(?:[?#]|$)'
+    _VALID_URL = [
+        r'https?://(?:www\.)?linkedin\.com/posts/[^/?#]+-(?P<id>\d+)-\w{4}/?(?:[?#]|$)',
+        r'https?://(?:www\.)?linkedin\.com/feed/update/urn:li:activity:(?P<id>\d+)',
+    ]
     _TESTS = [{
         'url': 'https://www.linkedin.com/posts/mishalkhawaja_sendinblueviews-toronto-digitalmarketing-ugcPost-6850898786781339649-mM20',
         'info_dict': {
@@ -106,6 +109,9 @@ class LinkedInIE(LinkedInBaseIE):
             'like_count': int,
             'subtitles': 'mincount:1',
         },
+    }, {
+        'url': 'https://www.linkedin.com/feed/update/urn:li:activity:7016901149999955968/?utm_source=share&utm_medium=member_desktop',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/manyvids.py b/yt_dlp/extractor/manyvids.py
index 8caa8f87f..1356169bf 100644
--- a/yt_dlp/extractor/manyvids.py
+++ b/yt_dlp/extractor/manyvids.py
@@ -1,31 +1,38 @@
-import re
-
 from .common import InfoExtractor
 from ..utils import (
+    clean_html,
     determine_ext,
-    extract_attributes,
     int_or_none,
-    str_to_int,
+    join_nonempty,
+    parse_count,
+    parse_duration,
+    parse_iso8601,
     url_or_none,
-    urlencode_postdata,
 )
+from ..utils.traversal import traverse_obj
 
 
 class ManyVidsIE(InfoExtractor):
-    _WORKING = False
     _VALID_URL = r'(?i)https?://(?:www\.)?manyvids\.com/video/(?P<id>\d+)'
     _TESTS = [{
         # preview video
-        'url': 'https://www.manyvids.com/Video/133957/everthing-about-me/',
-        'md5': '03f11bb21c52dd12a05be21a5c7dcc97',
+        'url': 'https://www.manyvids.com/Video/530341/mv-tips-tricks',
+        'md5': '738dc723f7735ee9602f7ea352a6d058',
         'info_dict': {
-            'id': '133957',
+            'id': '530341-preview',
             'ext': 'mp4',
-            'title': 'everthing about me (Preview)',
-            'uploader': 'ellyxxix',
+            'title': 'MV Tips & Tricks (Preview)',
+            'description': r're:I will take you on a tour around .{1313}$',
+            'thumbnail': r're:https://cdn5\.manyvids\.com/php_uploads/video_images/DestinyDiaz/.+\.jpg',
+            'uploader': 'DestinyDiaz',
             'view_count': int,
             'like_count': int,
+            'release_timestamp': 1508419904,
+            'tags': ['AdultSchool', 'BBW', 'SFW', 'TeacherFetish'],
+            'release_date': '20171019',
+            'duration': 3167.0,
         },
+        'expected_warnings': ['Only extracting preview'],
     }, {
         # full video
         'url': 'https://www.manyvids.com/Video/935718/MY-FACE-REVEAL/',
@@ -34,129 +41,68 @@ class ManyVidsIE(InfoExtractor):
             'id': '935718',
             'ext': 'mp4',
             'title': 'MY FACE REVEAL',
-            'description': 'md5:ec5901d41808b3746fed90face161612',
+            'description': r're:Today is the day!! I am finally taking off my mask .{445}$',
+            'thumbnail': r're:https://ods\.manyvids\.com/1001061960/3aa5397f2a723ec4597e344df66ab845/screenshots/.+\.jpg',
             'uploader': 'Sarah Calanthe',
             'view_count': int,
             'like_count': int,
+            'release_date': '20181110',
+            'tags': ['EyeContact', 'Interviews', 'MaskFetish', 'MouthFetish', 'Redhead'],
+            'release_timestamp': 1541851200,
+            'duration': 224.0,
         },
     }]
+    _API_BASE = 'https://www.manyvids.com/bff/store/video'
 
     def _real_extract(self, url):
         video_id = self._match_id(url)
-
-        real_url = f'https://www.manyvids.com/video/{video_id}/gtm.js'
-        try:
-            webpage = self._download_webpage(real_url, video_id)
-        except Exception:
-            # probably useless fallback
-            webpage = self._download_webpage(url, video_id)
-
-        info = self._search_regex(
-            r'''(<div\b[^>]*\bid\s*=\s*(['"])pageMetaDetails\2[^>]*>)''',
-            webpage, 'meta details', default='')
-        info = extract_attributes(info)
-
-        player = self._search_regex(
-            r'''(<div\b[^>]*\bid\s*=\s*(['"])rmpPlayerStream\2[^>]*>)''',
-            webpage, 'player details', default='')
-        player = extract_attributes(player)
-
-        video_urls_and_ids = (
-            (info.get('data-meta-video'), 'video'),
-            (player.get('data-video-transcoded'), 'transcoded'),
-            (player.get('data-video-filepath'), 'filepath'),
-            (self._og_search_video_url(webpage, secure=False, default=None), 'og_video'),
-        )
-
-        def txt_or_none(s, default=None):
-            return (s.strip() or default) if isinstance(s, str) else default
-
-        uploader = txt_or_none(info.get('data-meta-author'))
-
-        def mung_title(s):
-            if uploader:
-                s = re.sub(rf'^\s*{re.escape(uploader)}\s+[|-]', '', s)
-            return txt_or_none(s)
-
-        title = (
-            mung_title(info.get('data-meta-title'))
-            or self._html_search_regex(
-                (r'<span[^>]+class=["\']item-title[^>]+>([^<]+)',
-                 r'<h2[^>]+class=["\']h2 m-0["\'][^>]*>([^<]+)'),
-                webpage, 'title', default=None)
-            or self._html_search_meta(
-                'twitter:title', webpage, 'title', fatal=True))
-
-        title = re.sub(r'\s*[|-]\s+ManyVids\s*$', '', title) or title
-
-        if any(p in webpage for p in ('preview_videos', '_preview.mp4')):
-            title += ' (Preview)'
-
-        mv_token = self._search_regex(
-            r'data-mvtoken=(["\'])(?P<value>(?:(?!\1).)+)\1', webpage,
-            'mv token', default=None, group='value')
-
-        if mv_token:
-            # Sets some cookies
-            self._download_webpage(
-                'https://www.manyvids.com/includes/ajax_repository/you_had_me_at_hello.php',
-                video_id, note='Setting format cookies', fatal=False,
-                data=urlencode_postdata({
-                    'mvtoken': mv_token,
-                    'vid': video_id,
-                }), headers={
-                    'Referer': url,
-                    'X-Requested-With': 'XMLHttpRequest',
-                })
-
-        formats = []
-        for v_url, fmt in video_urls_and_ids:
-            v_url = url_or_none(v_url)
-            if not v_url:
+        video_data = self._download_json(f'{self._API_BASE}/{video_id}/private', video_id)['data']
+        formats, preview_only = [], True
+
+        for format_id, path in [
+            ('preview', ['teaser', 'filepath']),
+            ('transcoded', ['transcodedFilepath']),
+            ('filepath', ['filepath']),
+        ]:
+            format_url = traverse_obj(video_data, (*path, {url_or_none}))
+            if not format_url:
                 continue
-            if determine_ext(v_url) == 'm3u8':
-                formats.extend(self._extract_m3u8_formats(
-                    v_url, video_id, 'mp4', entry_protocol='m3u8_native',
-                    m3u8_id='hls'))
+            if determine_ext(format_url) == 'm3u8':
+                formats.extend(self._extract_m3u8_formats(format_url, video_id, 'mp4', m3u8_id=format_id))
             else:
                 formats.append({
-                    'url': v_url,
-                    'format_id': fmt,
+                    'url': format_url,
+                    'format_id': format_id,
+                    'preference': -10 if format_id == 'preview' else None,
+                    'quality': 10 if format_id == 'filepath' else None,
+                    'height': int_or_none(
+                        self._search_regex(r'_(\d{2,3}[02468])_', format_url, 'height', default=None)),
                 })
+            if format_id != 'preview':
+                preview_only = False
 
-        self._remove_duplicate_formats(formats)
-
-        for f in formats:
-            if f.get('height') is None:
-                f['height'] = int_or_none(
-                    self._search_regex(r'_(\d{2,3}[02468])_', f['url'], 'video height', default=None))
-            if '/preview/' in f['url']:
-                f['format_id'] = '_'.join(filter(None, (f.get('format_id'), 'preview')))
-                f['preference'] = -10
-            if 'transcoded' in f['format_id']:
-                f['preference'] = f.get('preference', -1) - 1
-
-        def get_likes():
-            likes = self._search_regex(
-                rf'''(<a\b[^>]*\bdata-id\s*=\s*(['"]){video_id}\2[^>]*>)''',
-                webpage, 'likes', default='')
-            likes = extract_attributes(likes)
-            return int_or_none(likes.get('data-likes'))
+        metadata = traverse_obj(
+            self._download_json(f'{self._API_BASE}/{video_id}', video_id, fatal=False), 'data')
+        title = traverse_obj(metadata, ('title', {clean_html}))
 
-        def get_views():
-            return str_to_int(self._html_search_regex(
-                r'''(?s)<span\b[^>]*\bclass\s*=["']views-wrapper\b[^>]+>.+?<span\b[^>]+>\s*(\d[\d,.]*)\s*</span>''',
-                webpage, 'view count', default=None))
+        if preview_only:
+            title = join_nonempty(title, '(Preview)', delim=' ')
+            video_id += '-preview'
+            self.report_warning(
+                f'Only extracting preview. Video may be paid or subscription only. {self._login_hint()}')
 
         return {
             'id': video_id,
             'title': title,
             'formats': formats,
-            'description': txt_or_none(info.get('data-meta-description')),
-            'uploader': txt_or_none(info.get('data-meta-author')),
-            'thumbnail': (
-                url_or_none(info.get('data-meta-image'))
-                or url_or_none(player.get('data-video-screenshot'))),
-            'view_count': get_views(),
-            'like_count': get_likes(),
+            **traverse_obj(metadata, {
+                'description': ('description', {clean_html}),
+                'uploader': ('model', 'displayName', {clean_html}),
+                'thumbnail': (('screenshot', 'thumbnail'), {url_or_none}, any),
+                'view_count': ('views', {parse_count}),
+                'like_count': ('likes', {parse_count}),
+                'release_timestamp': ('launchDate', {parse_iso8601}),
+                'duration': ('videoDuration', {parse_duration}),
+                'tags': ('tagList', ..., 'label', {str}, filter, all, filter),
+            }),
         }
diff --git a/yt_dlp/extractor/panopto.py b/yt_dlp/extractor/panopto.py
index 91f105519..9f307a53e 100644
--- a/yt_dlp/extractor/panopto.py
+++ b/yt_dlp/extractor/panopto.py
@@ -14,8 +14,9 @@
     int_or_none,
     parse_qs,
     srt_subtitles_timecode,
-    traverse_obj,
+    url_or_none,
 )
+from ..utils.traversal import traverse_obj
 
 
 class PanoptoBaseIE(InfoExtractor):
@@ -345,21 +346,16 @@ def _extract_streams_formats_and_subtitles(self, video_id, streams, **fmt_kwargs
         subtitles = {}
         for stream in streams or []:
             stream_formats = []
-            http_stream_url = stream.get('StreamHttpUrl')
-            stream_url = stream.get('StreamUrl')
-
-            if http_stream_url:
-                stream_formats.append({'url': http_stream_url})
-
-            if stream_url:
+            for stream_url in set(traverse_obj(stream, (('StreamHttpUrl', 'StreamUrl'), {url_or_none}))):
                 media_type = stream.get('ViewerMediaFileTypeName')
                 if media_type in ('hls', ):
-                    m3u8_formats, stream_subtitles = self._extract_m3u8_formats_and_subtitles(stream_url, video_id)
-                    stream_formats.extend(m3u8_formats)
-                    subtitles = self._merge_subtitles(subtitles, stream_subtitles)
+                    fmts, subs = self._extract_m3u8_formats_and_subtitles(stream_url, video_id, m3u8_id='hls', fatal=False)
+                    stream_formats.extend(fmts)
+                    self._merge_subtitles(subs, target=subtitles)
                 else:
                     stream_formats.append({
                         'url': stream_url,
+                        'ext': media_type,
                     })
             for fmt in stream_formats:
                 fmt.update({
diff --git a/yt_dlp/extractor/tvp.py b/yt_dlp/extractor/tvp.py
index da3082907..416cbab3c 100644
--- a/yt_dlp/extractor/tvp.py
+++ b/yt_dlp/extractor/tvp.py
@@ -513,7 +513,7 @@ def _parse_video(self, video, with_url=True):
 
 class TVPVODVideoIE(TVPVODBaseIE):
     IE_NAME = 'tvp:vod'
-    _VALID_URL = r'https?://vod\.tvp\.pl/(?P<category>[a-z\d-]+,\d+)/[a-z\d-]+(?<!-odcinki)(?:-odcinki,\d+/odcinek-\d+,S\d+E\d+)?,(?P<id>\d+)/?(?:[?#]|$)'
+    _VALID_URL = r'https?://vod\.tvp\.pl/(?P<category>[a-z\d-]+,\d+)/[a-z\d-]+(?<!-odcinki)(?:-odcinki,\d+/odcinek--?\d+,S-?\d+E-?\d+)?,(?P<id>\d+)/?(?:[?#]|$)'
 
     _TESTS = [{
         'url': 'https://vod.tvp.pl/dla-dzieci,24/laboratorium-alchemika-odcinki,309338/odcinek-24,S01E24,311357',
@@ -568,6 +568,9 @@ class TVPVODVideoIE(TVPVODBaseIE):
             'live_status': 'is_live',
             'thumbnail': 're:https?://.+',
         },
+    }, {
+        'url': 'https://vod.tvp.pl/informacje-i-publicystyka,205/konskie-2025-debata-przedwyborcza-odcinki,2028435/odcinek--1,S01E-1,2028419',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/youtube/_tab.py b/yt_dlp/extractor/youtube/_tab.py
index 122300e60..c018ee8cf 100644
--- a/yt_dlp/extractor/youtube/_tab.py
+++ b/yt_dlp/extractor/youtube/_tab.py
@@ -524,10 +524,16 @@ def _entries(self, tab, item_id, ytcfg, delegated_session_id, visitor_data):
             response = self._extract_response(
                 item_id=f'{item_id} page {page_num}',
                 query=continuation, headers=headers, ytcfg=ytcfg,
-                check_get_keys=('continuationContents', 'onResponseReceivedActions', 'onResponseReceivedEndpoints'))
+                check_get_keys=(
+                    'continuationContents', 'onResponseReceivedActions', 'onResponseReceivedEndpoints',
+                    # Playlist recommendations may return with no data - ignore
+                    ('responseContext', 'serviceTrackingParams', ..., 'params', ..., lambda k, v: k == 'key' and v == 'GetRecommendedMusicPlaylists_rid'),
+                ))
 
             if not response:
                 break
+
+            continuation = None
             # Extracting updated visitor data is required to prevent an infinite extraction loop in some cases
             # See: https://github.com/ytdl-org/youtube-dl/issues/28702
             visitor_data = self._extract_visitor_data(response) or visitor_data
@@ -564,7 +570,13 @@ def _entries(self, tab, item_id, ytcfg, delegated_session_id, visitor_data):
                 yield from func(video_items_renderer)
                 continuation = continuation_list[0] or self._extract_continuation(video_items_renderer)
 
-            if not video_items_renderer:
+            # In the case only a continuation is returned, try to follow it.
+            # We extract this after trying to extract non-continuation items as otherwise this
+            # may be prioritized over other continuations.
+            # see: https://github.com/yt-dlp/yt-dlp/issues/12933
+            continuation = continuation or self._extract_continuation({'contents': [continuation_item]})
+
+            if not continuation and not video_items_renderer:
                 break
 
     @staticmethod
@@ -999,14 +1011,14 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'playlist_mincount': 94,
         'info_dict': {
             'id': 'UCqj7Cz7revf5maW9g5pgNcg',
-            'title': 'Igor Kleiner Ph.D. - Playlists',
+            'title': 'Igor Kleiner  - Playlists',
             'description': 'md5:15d7dd9e333cb987907fcb0d604b233a',
-            'uploader': 'Igor Kleiner Ph.D.',
+            'uploader': 'Igor Kleiner ',
             'uploader_id': '@IgorDataScience',
             'uploader_url': 'https://www.youtube.com/@IgorDataScience',
-            'channel': 'Igor Kleiner Ph.D.',
+            'channel': 'Igor Kleiner ',
             'channel_id': 'UCqj7Cz7revf5maW9g5pgNcg',
-            'tags': ['критическое мышление', 'наука просто', 'математика', 'анализ данных'],
+            'tags': 'count:23',
             'channel_url': 'https://www.youtube.com/channel/UCqj7Cz7revf5maW9g5pgNcg',
             'channel_follower_count': int,
         },
@@ -1016,18 +1028,19 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'playlist_mincount': 94,
         'info_dict': {
             'id': 'UCqj7Cz7revf5maW9g5pgNcg',
-            'title': 'Igor Kleiner Ph.D. - Playlists',
+            'title': 'Igor Kleiner  - Playlists',
             'description': 'md5:15d7dd9e333cb987907fcb0d604b233a',
-            'uploader': 'Igor Kleiner Ph.D.',
+            'uploader': 'Igor Kleiner ',
             'uploader_id': '@IgorDataScience',
             'uploader_url': 'https://www.youtube.com/@IgorDataScience',
-            'tags': ['критическое мышление', 'наука просто', 'математика', 'анализ данных'],
+            'tags': 'count:23',
             'channel_id': 'UCqj7Cz7revf5maW9g5pgNcg',
-            'channel': 'Igor Kleiner Ph.D.',
+            'channel': 'Igor Kleiner ',
             'channel_url': 'https://www.youtube.com/channel/UCqj7Cz7revf5maW9g5pgNcg',
             'channel_follower_count': int,
         },
     }, {
+        # TODO: fix channel_is_verified extraction
         'note': 'playlists, series',
         'url': 'https://www.youtube.com/c/3blue1brown/playlists?view=50&sort=dd&shelf_id=3',
         'playlist_mincount': 5,
@@ -1066,22 +1079,23 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'url': 'https://www.youtube.com/c/ChristophLaimer/playlists',
         'only_matching': True,
     }, {
+        # TODO: fix availability extraction
         'note': 'basic, single video playlist',
-        'url': 'https://www.youtube.com/playlist?list=PL4lCao7KL_QFVb7Iudeipvc2BCavECqzc',
+        'url': 'https://www.youtube.com/playlist?list=PLt5yu3-wZAlSLRHmI1qNm0wjyVNWw1pCU',
         'info_dict': {
-            'id': 'PL4lCao7KL_QFVb7Iudeipvc2BCavECqzc',
-            'title': 'youtube-dl public playlist',
+            'id': 'PLt5yu3-wZAlSLRHmI1qNm0wjyVNWw1pCU',
+            'title': 'single video playlist',
             'description': '',
             'tags': [],
             'view_count': int,
-            'modified_date': '20201130',
-            'channel': 'Sergey M.',
-            'channel_id': 'UCmlqkdCBesrv2Lak1mF_MxA',
-            'channel_url': 'https://www.youtube.com/channel/UCmlqkdCBesrv2Lak1mF_MxA',
+            'modified_date': '20250417',
+            'channel': 'cole-dlp-test-acc',
+            'channel_id': 'UCiu-3thuViMebBjw_5nWYrA',
+            'channel_url': 'https://www.youtube.com/channel/UCiu-3thuViMebBjw_5nWYrA',
             'availability': 'public',
-            'uploader': 'Sergey M.',
-            'uploader_url': 'https://www.youtube.com/@sergeym.6173',
-            'uploader_id': '@sergeym.6173',
+            'uploader': 'cole-dlp-test-acc',
+            'uploader_url': 'https://www.youtube.com/@coletdjnz',
+            'uploader_id': '@coletdjnz',
         },
         'playlist_count': 1,
     }, {
@@ -1171,11 +1185,11 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         },
         'playlist_mincount': 17,
     }, {
-        'note': 'Community tab',
+        'note': 'Posts tab',
         'url': 'https://www.youtube.com/channel/UCKfVa3S1e4PHvxWcwyMMg8w/community',
         'info_dict': {
             'id': 'UCKfVa3S1e4PHvxWcwyMMg8w',
-            'title': 'lex will - Community',
+            'title': 'lex will - Posts',
             'description': 'md5:2163c5d0ff54ed5f598d6a7e6211e488',
             'channel': 'lex will',
             'channel_url': 'https://www.youtube.com/channel/UCKfVa3S1e4PHvxWcwyMMg8w',
@@ -1188,30 +1202,14 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         },
         'playlist_mincount': 18,
     }, {
-        'note': 'Channels tab',
-        'url': 'https://www.youtube.com/channel/UCKfVa3S1e4PHvxWcwyMMg8w/channels',
-        'info_dict': {
-            'id': 'UCKfVa3S1e4PHvxWcwyMMg8w',
-            'title': 'lex will - Channels',
-            'description': 'md5:2163c5d0ff54ed5f598d6a7e6211e488',
-            'channel': 'lex will',
-            'channel_url': 'https://www.youtube.com/channel/UCKfVa3S1e4PHvxWcwyMMg8w',
-            'channel_id': 'UCKfVa3S1e4PHvxWcwyMMg8w',
-            'tags': ['bible', 'history', 'prophesy'],
-            'channel_follower_count': int,
-            'uploader_url': 'https://www.youtube.com/@lexwill718',
-            'uploader_id': '@lexwill718',
-            'uploader': 'lex will',
-        },
-        'playlist_mincount': 12,
-    }, {
+        # TODO: fix channel_is_verified extraction
         'note': 'Search tab',
         'url': 'https://www.youtube.com/c/3blue1brown/search?query=linear%20algebra',
         'playlist_mincount': 40,
         'info_dict': {
             'id': 'UCYO_jab_esuFRV4b17AJtAw',
             'title': '3Blue1Brown - Search - linear algebra',
-            'description': 'md5:4d1da95432004b7ba840ebc895b6b4c9',
+            'description': 'md5:602e3789e6a0cb7d9d352186b720e395',
             'channel_url': 'https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw',
             'tags': ['Mathematics'],
             'channel': '3Blue1Brown',
@@ -1232,6 +1230,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'url': 'https://music.youtube.com/channel/UCmlqkdCBesrv2Lak1mF_MxA',
         'only_matching': True,
     }, {
+        # TODO: fix availability extraction
         'note': 'Playlist with deleted videos (#651). As a bonus, the video #51 is also twice in this list.',
         'url': 'https://www.youtube.com/playlist?list=PLwP_SiAcdui0KVebT0mU9Apz359a4ubsC',
         'info_dict': {
@@ -1294,24 +1293,25 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         },
         'playlist_mincount': 21,
     }, {
+        # TODO: fix availability extraction
         'note': 'Playlist with "show unavailable videos" button',
-        'url': 'https://www.youtube.com/playlist?list=UUTYLiWFZy8xtPwxFwX9rV7Q',
+        'url': 'https://www.youtube.com/playlist?list=PLYwq8WOe86_xGmR7FrcJq8Sb7VW8K3Tt2',
         'info_dict': {
-            'title': 'Uploads from Phim Siêu Nhân Nhật Bản',
-            'id': 'UUTYLiWFZy8xtPwxFwX9rV7Q',
+            'title': 'The Memes Of 2010s.....',
+            'id': 'PLYwq8WOe86_xGmR7FrcJq8Sb7VW8K3Tt2',
             'view_count': int,
-            'channel': 'Phim Siêu Nhân Nhật Bản',
+            'channel': "I'm Not JiNxEd",
             'tags': [],
-            'description': '',
-            'channel_url': 'https://www.youtube.com/channel/UCTYLiWFZy8xtPwxFwX9rV7Q',
-            'channel_id': 'UCTYLiWFZy8xtPwxFwX9rV7Q',
+            'description': 'md5:44dc3b315ba69394feaafa2f40e7b2a1',
+            'channel_url': 'https://www.youtube.com/channel/UC5H5H85D1QE5-fuWWQ1hdNg',
+            'channel_id': 'UC5H5H85D1QE5-fuWWQ1hdNg',
             'modified_date': r're:\d{8}',
             'availability': 'public',
-            'uploader_url': 'https://www.youtube.com/@phimsieunhannhatban',
-            'uploader_id': '@phimsieunhannhatban',
-            'uploader': 'Phim Siêu Nhân Nhật Bản',
+            'uploader_url': 'https://www.youtube.com/@imnotjinxed1998',
+            'uploader_id': '@imnotjinxed1998',
+            'uploader': "I'm Not JiNxEd",
         },
-        'playlist_mincount': 200,
+        'playlist_mincount': 150,
         'expected_warnings': [r'[Uu]navailable videos (are|will be) hidden'],
     }, {
         'note': 'Playlist with unavailable videos in page 7',
@@ -1334,6 +1334,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'playlist_mincount': 1000,
         'expected_warnings': [r'[Uu]navailable videos (are|will be) hidden'],
     }, {
+        # TODO: fix availability extraction
         'note': 'https://github.com/ytdl-org/youtube-dl/issues/21844',
         'url': 'https://www.youtube.com/playlist?list=PLzH6n4zXuckpfMu_4Ff8E7Z1behQks5ba',
         'info_dict': {
@@ -1384,7 +1385,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
     }, {
         'url': 'https://www.youtube.com/channel/UCoMdktPbSTixAyNGwb-UYkQ/live',
         'info_dict': {
-            'id': 'hGkQjiJLjWQ',  # This will keep changing
+            'id': 'YDvsBbKfLPA',  # This will keep changing
             'ext': 'mp4',
             'title': str,
             'upload_date': r're:\d{8}',
@@ -1409,6 +1410,8 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
             'uploader_id': '@SkyNews',
             'uploader': 'Sky News',
             'channel_is_verified': True,
+            'media_type': 'livestream',
+            'timestamp': int,
         },
         'params': {
             'skip_download': True,
@@ -1496,6 +1499,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'url': 'https://music.youtube.com/browse/UC1a8OFewdjuLq6KlF8M_8Ng',
         'only_matching': True,
     }, {
+        # TODO: fix availability extraction
         'note': 'VLPL, should redirect to playlist?list=PL...',
         'url': 'https://music.youtube.com/browse/VLPLRBp0Fe2GpgmgoscNFLxNyBVSFVdYmFkq',
         'info_dict': {
@@ -1537,6 +1541,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
     }, {
         # Destination channel with only a hidden self tab (tab id is UCtFRv9O2AHqOZjjynzrv-xg)
         # Treat as a general feed
+        # TODO: fix extraction
         'url': 'https://www.youtube.com/channel/UCtFRv9O2AHqOZjjynzrv-xg',
         'info_dict': {
             'id': 'UCtFRv9O2AHqOZjjynzrv-xg',
@@ -1560,21 +1565,21 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'expected_warnings': ['YouTube Music is not directly supported'],
     }, {
         'note': 'unlisted single video playlist',
-        'url': 'https://www.youtube.com/playlist?list=PLwL24UFy54GrB3s2KMMfjZscDi1x5Dajf',
+        'url': 'https://www.youtube.com/playlist?list=PLt5yu3-wZAlQLfIN0MMgp0wVV6MP3bM4_',
         'info_dict': {
-            'id': 'PLwL24UFy54GrB3s2KMMfjZscDi1x5Dajf',
-            'title': 'yt-dlp unlisted playlist test',
+            'id': 'PLt5yu3-wZAlQLfIN0MMgp0wVV6MP3bM4_',
+            'title': 'unlisted playlist',
             'availability': 'unlisted',
             'tags': [],
-            'modified_date': '20220418',
-            'channel': 'colethedj',
+            'modified_date': '20250417',
+            'channel': 'cole-dlp-test-acc',
             'view_count': int,
             'description': '',
-            'channel_id': 'UC9zHu_mHU96r19o-wV5Qs1Q',
-            'channel_url': 'https://www.youtube.com/channel/UC9zHu_mHU96r19o-wV5Qs1Q',
-            'uploader_url': 'https://www.youtube.com/@colethedj1894',
-            'uploader_id': '@colethedj1894',
-            'uploader': 'colethedj',
+            'channel_id': 'UCiu-3thuViMebBjw_5nWYrA',
+            'channel_url': 'https://www.youtube.com/channel/UCiu-3thuViMebBjw_5nWYrA',
+            'uploader_url': 'https://www.youtube.com/@coletdjnz',
+            'uploader_id': '@coletdjnz',
+            'uploader': 'cole-dlp-test-acc',
         },
         'playlist': [{
             'info_dict': {
@@ -1596,6 +1601,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'playlist_count': 1,
         'params': {'extract_flat': True},
     }, {
+        # By default, recommended is always empty.
         'note': 'API Fallback: Recommended - redirects to home page. Requires visitorData',
         'url': 'https://www.youtube.com/feed/recommended',
         'info_dict': {
@@ -1603,7 +1609,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
             'title': 'recommended',
             'tags': [],
         },
-        'playlist_mincount': 50,
+        'playlist_count': 0,
         'params': {
             'skip_download': True,
             'extractor_args': {'youtubetab': {'skip': ['webpage']}},
@@ -1628,6 +1634,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         },
         'skip': 'Query for sorting no longer works',
     }, {
+        # TODO: fix 'unviewable' issue with this playlist when reloading with unavailable videos
         'note': 'API Fallback: Topic, should redirect to playlist?list=UU...',
         'url': 'https://music.youtube.com/browse/UC9ALqqC4aIeG5iDs7i90Bfw',
         'info_dict': {
@@ -1654,11 +1661,12 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'url': 'https://www.youtube.com/channel/UCwVVpHQ2Cs9iGJfpdFngePQ',
         'only_matching': True,
     }, {
+        # TODO: fix metadata extraction
         'note': 'collaborative playlist (uploader name in the form "by <uploader> and x other(s)")',
         'url': 'https://www.youtube.com/playlist?list=PLx-_-Kk4c89oOHEDQAojOXzEzemXxoqx6',
         'info_dict': {
             'id': 'PLx-_-Kk4c89oOHEDQAojOXzEzemXxoqx6',
-            'modified_date': '20220407',
+            'modified_date': '20250115',
             'channel_url': 'https://www.youtube.com/channel/UCKcqXmCcyqnhgpA5P0oHH_Q',
             'tags': [],
             'availability': 'unlisted',
@@ -1692,6 +1700,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'expected_warnings': ['Preferring "ja"'],
     }, {
         # XXX: this should really check flat playlist entries, but the test suite doesn't support that
+        # TODO: fix availability extraction
         'note': 'preferred lang set with playlist with translated video titles',
         'url': 'https://www.youtube.com/playlist?list=PLt5yu3-wZAlQAaPZ5Z-rJoTdbT-45Q7c0',
         'info_dict': {
@@ -1714,6 +1723,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
     }, {
         # shorts audio pivot for 2GtVksBMYFM.
         'url': 'https://www.youtube.com/feed/sfv_audio_pivot?bp=8gUrCikSJwoLMkd0VmtzQk1ZRk0SCzJHdFZrc0JNWUZNGgsyR3RWa3NCTVlGTQ==',
+        # TODO: fix extraction
         'info_dict': {
             'id': 'sfv_audio_pivot',
             'title': 'sfv_audio_pivot',
@@ -1751,6 +1761,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'playlist_mincount': 8,
     }, {
         # Should get three playlists for videos, shorts and streams tabs
+        # TODO: fix channel_is_verified extraction
         'url': 'https://www.youtube.com/channel/UCK9V2B22uJYu3N7eR_BT9QA',
         'info_dict': {
             'id': 'UCK9V2B22uJYu3N7eR_BT9QA',
@@ -1758,7 +1769,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
             'channel_follower_count': int,
             'channel_id': 'UCK9V2B22uJYu3N7eR_BT9QA',
             'channel_url': 'https://www.youtube.com/channel/UCK9V2B22uJYu3N7eR_BT9QA',
-            'description': 'md5:49809d8bf9da539bc48ed5d1f83c33f2',
+            'description': 'md5:01e53f350ab8ad6fcf7c4fedb3c1b99f',
             'channel': 'Polka Ch. 尾丸ポルカ',
             'tags': 'count:35',
             'uploader_url': 'https://www.youtube.com/@OmaruPolka',
@@ -1769,14 +1780,14 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'playlist_count': 3,
     }, {
         # Shorts tab with channel with handle
-        # TODO: fix channel description
+        # TODO: fix channel_is_verified extraction
         'url': 'https://www.youtube.com/@NotJustBikes/shorts',
         'info_dict': {
             'id': 'UC0intLFzLaudFG-xAvUEO-A',
             'title': 'Not Just Bikes - Shorts',
             'tags': 'count:10',
             'channel_url': 'https://www.youtube.com/channel/UC0intLFzLaudFG-xAvUEO-A',
-            'description': 'md5:5e82545b3a041345927a92d0585df247',
+            'description': 'md5:1d9fc1bad7f13a487299d1fe1712e031',
             'channel_follower_count': int,
             'channel_id': 'UC0intLFzLaudFG-xAvUEO-A',
             'channel': 'Not Just Bikes',
@@ -1797,7 +1808,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
             'channel_url': 'https://www.youtube.com/channel/UC3eYAvjCVwNHgkaGbXX3sig',
             'channel': '中村悠一',
             'channel_follower_count': int,
-            'description': 'md5:e744f6c93dafa7a03c0c6deecb157300',
+            'description': 'md5:e8fd705073a594f27d6d6d020da560dc',
             'uploader_url': 'https://www.youtube.com/@Yuichi-Nakamura',
             'uploader_id': '@Yuichi-Nakamura',
             'uploader': '中村悠一',
@@ -1815,6 +1826,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'only_matching': True,
     }, {
         # No videos tab but has a shorts tab
+        # TODO: fix metadata extraction
         'url': 'https://www.youtube.com/c/TKFShorts',
         'info_dict': {
             'id': 'UCgJ5_1F6yJhYLnyMszUdmUg',
@@ -1851,6 +1863,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
     }, {
         # Shorts url result in shorts tab
         # TODO: Fix channel id extraction
+        # TODO: fix test suite, 208163447408c78673b08c172beafe5c310fb167 broke this test
         'url': 'https://www.youtube.com/channel/UCiu-3thuViMebBjw_5nWYrA/shorts',
         'info_dict': {
             'id': 'UCiu-3thuViMebBjw_5nWYrA',
@@ -1879,6 +1892,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'params': {'extract_flat': True},
     }, {
         # Live video status should be extracted
+        # TODO: fix test suite, 208163447408c78673b08c172beafe5c310fb167 broke this test
         'url': 'https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/live',
         'info_dict': {
             'id': 'UCQvWX73GQygcwXOTSf_VDVg',
@@ -1907,6 +1921,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'playlist_mincount': 1,
     }, {
         # Channel renderer metadata. Contains number of videos on the channel
+        # TODO: channels tab removed, change this test to use another page with channel renderer
         'url': 'https://www.youtube.com/channel/UCiu-3thuViMebBjw_5nWYrA/channels',
         'info_dict': {
             'id': 'UCiu-3thuViMebBjw_5nWYrA',
@@ -1940,7 +1955,9 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
             },
         }],
         'params': {'extract_flat': True},
+        'skip': 'channels tab removed',
     }, {
+        # TODO: fix channel_is_verified extraction
         'url': 'https://www.youtube.com/@3blue1brown/about',
         'info_dict': {
             'id': '@3blue1brown',
@@ -1950,7 +1967,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
             'channel_id': 'UCYO_jab_esuFRV4b17AJtAw',
             'channel': '3Blue1Brown',
             'channel_url': 'https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw',
-            'description': 'md5:4d1da95432004b7ba840ebc895b6b4c9',
+            'description': 'md5:602e3789e6a0cb7d9d352186b720e395',
             'uploader_url': 'https://www.youtube.com/@3blue1brown',
             'uploader_id': '@3blue1brown',
             'uploader': '3Blue1Brown',
@@ -1976,6 +1993,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'playlist_count': 5,
     }, {
         # Releases tab, with rich entry playlistRenderers (same as Podcasts tab)
+        # TODO: fix channel_is_verified extraction
         'url': 'https://www.youtube.com/@AHimitsu/releases',
         'info_dict': {
             'id': 'UCgFwu-j5-xNJml2FtTrrB3A',
@@ -2015,6 +2033,7 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
         'playlist_mincount': 100,
         'expected_warnings': [r'[Uu]navailable videos (are|will be) hidden'],
     }, {
+        # TODO: fix channel_is_verified extraction
         'note': 'Tags containing spaces',
         'url': 'https://www.youtube.com/channel/UC7_YxT-KID8kRbqZo7MyscQ',
         'playlist_count': 3,
@@ -2035,6 +2054,24 @@ class YoutubeTabIE(YoutubeTabBaseInfoExtractor):
                      'challenges', 'sketches', 'scary games', 'funny games', 'rage games',
                      'mark fischbach'],
         },
+    }, {
+        # https://github.com/yt-dlp/yt-dlp/issues/12933
+        'note': 'streams tab, some scheduled streams. Empty intermediate response with only continuation - must follow',
+        'url': 'https://www.youtube.com/@sbcitygov/streams',
+        'playlist_mincount': 150,
+        'info_dict': {
+            'id': 'UCH6-qfQwlUgz9SAf05jvc_w',
+            'channel': 'sbcitygov',
+            'channel_id': 'UCH6-qfQwlUgz9SAf05jvc_w',
+            'title': 'sbcitygov - Live',
+            'channel_follower_count': int,
+            'description': 'md5:ca1a92059835c071e33b3db52f4a6d67',
+            'uploader_id': '@sbcitygov',
+            'uploader_url': 'https://www.youtube.com/@sbcitygov',
+            'uploader': 'sbcitygov',
+            'channel_url': 'https://www.youtube.com/channel/UCH6-qfQwlUgz9SAf05jvc_w',
+            'tags': [],
+        },
     }]
 
     @classmethod
diff --git a/yt_dlp/extractor/youtube/_video.py b/yt_dlp/extractor/youtube/_video.py
index 074a2a0d8..bcfe8b152 100644
--- a/yt_dlp/extractor/youtube/_video.py
+++ b/yt_dlp/extractor/youtube/_video.py
@@ -3646,6 +3646,8 @@ def feed_entry(name):
                 if 'sign in' in reason.lower():
                     reason = remove_end(reason, 'This helps protect our community. Learn more')
                     reason = f'{remove_end(reason.strip(), ".")}. {self._youtube_login_hint}'
+                elif get_first(playability_statuses, ('errorScreen', 'playerCaptchaViewModel', {dict})):
+                    reason += '. YouTube is requiring a captcha challenge before playback'
                 self.raise_no_formats(reason, expected=True)
 
         keywords = get_first(video_details, 'keywords', expected_type=list) or []
@@ -3874,7 +3876,7 @@ def process_language(container, base_url, lang_code, sub_name, query):
             if not traverse_obj(initial_data, 'contents'):
                 self.report_warning('Incomplete data received in embedded initial data; re-fetching using API.')
                 initial_data = None
-        if not initial_data:
+        if not initial_data and 'initial_data' not in self._configuration_arg('player_skip'):
             query = {'videoId': video_id}
             query.update(self._get_checkok_params())
             initial_data = self._extract_response(
diff --git a/yt_dlp/networking/__init__.py b/yt_dlp/networking/__init__.py
index 1eaa0ee5f..39158a8cc 100644
--- a/yt_dlp/networking/__init__.py
+++ b/yt_dlp/networking/__init__.py
@@ -3,6 +3,7 @@
 
 from .common import (
     HEADRequest,
+    PATCHRequest,
     PUTRequest,
     Request,
     RequestDirector,
diff --git a/yt_dlp/networking/common.py b/yt_dlp/networking/common.py
index ddceaa9a9..e33769422 100644
--- a/yt_dlp/networking/common.py
+++ b/yt_dlp/networking/common.py
@@ -505,6 +505,7 @@ def copy(self):
 
 
 HEADRequest = functools.partial(Request, method='HEAD')
+PATCHRequest = functools.partial(Request, method='PATCH')
 PUTRequest = functools.partial(Request, method='PUT')
 
 


Test patch:
diff --git a/test/helper.py b/test/helper.py
index 4169af799..e4cb478e2 100644
--- a/test/helper.py
+++ b/test/helper.py
@@ -136,7 +136,7 @@ def _iter_differences(got, expected, field):
             return
 
         if op == 'startswith':
-            if not val.startswith(got):
+            if not got.startswith(val):
                 yield field, f'should start with {val!r}, got {got!r}'
             return
 
diff --git a/test/test_networking.py b/test/test_networking.py
index 3ab60fe83..2f441fced 100644
--- a/test/test_networking.py
+++ b/test/test_networking.py
@@ -39,6 +39,7 @@
 from yt_dlp.dependencies import brotli, curl_cffi, requests, urllib3
 from yt_dlp.networking import (
     HEADRequest,
+    PATCHRequest,
     PUTRequest,
     Request,
     RequestDirector,
@@ -1856,6 +1857,7 @@ def test_method(self):
 
     def test_request_helpers(self):
         assert HEADRequest('http://example.com').method == 'HEAD'
+        assert PATCHRequest('http://example.com').method == 'PATCH'
         assert PUTRequest('http://example.com').method == 'PUT'
 
     def test_headers(self):