yt-dlp 2026.1.19.233146.dev0__py3-none-any.whl → 2026.1.27.233257.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. yt_dlp/extractor/_extractors.py +9 -2
  2. yt_dlp/extractor/boosty.py +45 -16
  3. yt_dlp/extractor/dailymotion.py +58 -2
  4. yt_dlp/extractor/err.py +68 -0
  5. yt_dlp/extractor/facebook.py +57 -1
  6. yt_dlp/extractor/francetv.py +21 -5
  7. yt_dlp/extractor/frontro.py +4 -4
  8. yt_dlp/extractor/lazy_extractors.py +39 -7
  9. yt_dlp/extractor/lbry.py +1 -0
  10. yt_dlp/extractor/neteasemusic.py +32 -7
  11. yt_dlp/extractor/patreon.py +118 -33
  12. yt_dlp/extractor/pbs.py +18 -0
  13. yt_dlp/extractor/rumble.py +1 -1
  14. yt_dlp/extractor/tiktok.py +83 -24
  15. yt_dlp/extractor/volejtv.py +149 -22
  16. yt_dlp/extractor/wat.py +1 -1
  17. yt_dlp/version.py +3 -3
  18. {yt_dlp-2026.1.19.233146.dev0.dist-info → yt_dlp-2026.1.27.233257.dev0.dist-info}/METADATA +1 -1
  19. {yt_dlp-2026.1.19.233146.dev0.dist-info → yt_dlp-2026.1.27.233257.dev0.dist-info}/RECORD +27 -27
  20. {yt_dlp-2026.1.19.233146.dev0.data → yt_dlp-2026.1.27.233257.dev0.data}/data/share/bash-completion/completions/yt-dlp +0 -0
  21. {yt_dlp-2026.1.19.233146.dev0.data → yt_dlp-2026.1.27.233257.dev0.data}/data/share/doc/yt_dlp/README.txt +0 -0
  22. {yt_dlp-2026.1.19.233146.dev0.data → yt_dlp-2026.1.27.233257.dev0.data}/data/share/fish/vendor_completions.d/yt-dlp.fish +0 -0
  23. {yt_dlp-2026.1.19.233146.dev0.data → yt_dlp-2026.1.27.233257.dev0.data}/data/share/man/man1/yt-dlp.1 +0 -0
  24. {yt_dlp-2026.1.19.233146.dev0.data → yt_dlp-2026.1.27.233257.dev0.data}/data/share/zsh/site-functions/_yt-dlp +0 -0
  25. {yt_dlp-2026.1.19.233146.dev0.dist-info → yt_dlp-2026.1.27.233257.dev0.dist-info}/WHEEL +0 -0
  26. {yt_dlp-2026.1.19.233146.dev0.dist-info → yt_dlp-2026.1.27.233257.dev0.dist-info}/entry_points.txt +0 -0
  27. {yt_dlp-2026.1.19.233146.dev0.dist-info → yt_dlp-2026.1.27.233257.dev0.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,5 @@
1
1
  import functools
2
2
  import itertools
3
- import urllib.parse
4
3
 
5
4
  from .common import InfoExtractor
6
5
  from .sproutvideo import VidsIoIE
@@ -11,15 +10,23 @@ from ..utils import (
11
10
  ExtractorError,
12
11
  clean_html,
13
12
  determine_ext,
13
+ extract_attributes,
14
+ float_or_none,
14
15
  int_or_none,
15
16
  mimetype2ext,
16
17
  parse_iso8601,
17
18
  smuggle_url,
18
19
  str_or_none,
20
+ update_url_query,
19
21
  url_or_none,
20
22
  urljoin,
21
23
  )
22
- from ..utils.traversal import require, traverse_obj, value
24
+ from ..utils.traversal import (
25
+ find_elements,
26
+ require,
27
+ traverse_obj,
28
+ value,
29
+ )
23
30
 
24
31
 
25
32
  class PatreonBaseIE(InfoExtractor):
@@ -121,6 +128,7 @@ class PatreonIE(PatreonBaseIE):
121
128
  'channel_is_verified': True,
122
129
  'chapters': 'count:4',
123
130
  'timestamp': 1423689666,
131
+ 'media_type': 'video',
124
132
  },
125
133
  'params': {
126
134
  'noplaylist': True,
@@ -161,7 +169,7 @@ class PatreonIE(PatreonBaseIE):
161
169
  'uploader_url': 'https://www.patreon.com/loish',
162
170
  'description': 'md5:e2693e97ee299c8ece47ffdb67e7d9d2',
163
171
  'title': 'VIDEO // sketchbook flipthrough',
164
- 'uploader': 'Loish ',
172
+ 'uploader': 'Loish',
165
173
  'tags': ['sketchbook', 'video'],
166
174
  'channel_id': '1641751',
167
175
  'channel_url': 'https://www.patreon.com/loish',
@@ -274,8 +282,73 @@ class PatreonIE(PatreonBaseIE):
274
282
  'channel_id': '9346307',
275
283
  },
276
284
  'params': {'getcomments': True},
285
+ }, {
286
+ # Inlined media in post; uses _extract_from_media_api
287
+ 'url': 'https://www.patreon.com/posts/scottfalco-146966245',
288
+ 'info_dict': {
289
+ 'id': '146966245',
290
+ 'ext': 'mp4',
291
+ 'title': 'scottfalco 1080',
292
+ 'description': 'md5:a3f29bbd0a46b4821ec3400957c98aa2',
293
+ 'uploader': 'Insanimate',
294
+ 'uploader_id': '2828146',
295
+ 'uploader_url': 'https://www.patreon.com/Insanimate',
296
+ 'channel_id': '6260877',
297
+ 'channel_url': 'https://www.patreon.com/Insanimate',
298
+ 'channel_follower_count': int,
299
+ 'comment_count': int,
300
+ 'like_count': int,
301
+ 'duration': 7.833333,
302
+ 'timestamp': 1767061800,
303
+ 'upload_date': '20251230',
304
+ },
277
305
  }]
278
306
  _RETURN_TYPE = 'video'
307
+ _HTTP_HEADERS = {
308
+ # Must be all-lowercase 'referer' so we can smuggle it to Generic, SproutVideo, and Vimeo.
309
+ # patreon.com URLs redirect to www.patreon.com; this matters when requesting mux.com m3u8s
310
+ 'referer': 'https://www.patreon.com/',
311
+ }
312
+
313
+ def _extract_from_media_api(self, media_id):
314
+ attributes = traverse_obj(
315
+ self._call_api(f'media/{media_id}', media_id, fatal=False),
316
+ ('data', 'attributes', {dict}))
317
+ if not attributes:
318
+ return None
319
+
320
+ info_dict = traverse_obj(attributes, {
321
+ 'title': ('file_name', {lambda x: x.rpartition('.')[0]}),
322
+ 'timestamp': ('created_at', {parse_iso8601}),
323
+ 'duration': ('display', 'duration', {float_or_none}),
324
+ })
325
+ info_dict['id'] = media_id
326
+
327
+ playback_url = traverse_obj(
328
+ attributes, ('display', (None, 'viewer_playback_data'), 'url', {url_or_none}, any))
329
+ download_url = traverse_obj(attributes, ('download_url', {url_or_none}))
330
+
331
+ if playback_url and mimetype2ext(attributes.get('mimetype')) == 'm3u8':
332
+ info_dict['formats'], info_dict['subtitles'] = self._extract_m3u8_formats_and_subtitles(
333
+ playback_url, media_id, 'mp4', fatal=False, headers=self._HTTP_HEADERS)
334
+ for f in info_dict['formats']:
335
+ f['http_headers'] = self._HTTP_HEADERS
336
+ if transcript_url := traverse_obj(attributes, ('display', 'transcript_url', {url_or_none})):
337
+ info_dict['subtitles'].setdefault('en', []).append({
338
+ 'url': transcript_url,
339
+ 'ext': 'vtt',
340
+ })
341
+ elif playback_url or download_url:
342
+ info_dict['formats'] = [{
343
+ # If playback_url is available, download_url is a duplicate lower resolution format
344
+ 'url': playback_url or download_url,
345
+ 'vcodec': 'none' if attributes.get('media_type') != 'video' else None,
346
+ }]
347
+
348
+ if not info_dict.get('formats'):
349
+ return None
350
+
351
+ return info_dict
279
352
 
280
353
  def _real_extract(self, url):
281
354
  video_id = self._match_id(url)
@@ -299,6 +372,7 @@ class PatreonIE(PatreonBaseIE):
299
372
  'comment_count': ('comment_count', {int_or_none}),
300
373
  })
301
374
 
375
+ seen_media_ids = set()
302
376
  entries = []
303
377
  idx = 0
304
378
  for include in traverse_obj(post, ('included', lambda _, v: v['type'])):
@@ -320,6 +394,8 @@ class PatreonIE(PatreonBaseIE):
320
394
  'url': download_url,
321
395
  'alt_title': traverse_obj(media_attributes, ('file_name', {str})),
322
396
  })
397
+ if media_id := traverse_obj(include, ('id', {str})):
398
+ seen_media_ids.add(media_id)
323
399
 
324
400
  elif include_type == 'user':
325
401
  info.update(traverse_obj(include, {
@@ -340,34 +416,29 @@ class PatreonIE(PatreonBaseIE):
340
416
  'channel_follower_count': ('attributes', 'patron_count', {int_or_none}),
341
417
  }))
342
418
 
343
- # Must be all-lowercase 'referer' so we can smuggle it to Generic, SproutVideo, and Vimeo.
344
- # patreon.com URLs redirect to www.patreon.com; this matters when requesting mux.com m3u8s
345
- headers = {'referer': 'https://www.patreon.com/'}
346
-
347
- # handle Vimeo embeds
348
- if traverse_obj(attributes, ('embed', 'provider')) == 'Vimeo':
349
- v_url = urllib.parse.unquote(self._html_search_regex(
350
- r'(https(?:%3A%2F%2F|://)player\.vimeo\.com.+app_id(?:=|%3D)+\d+)',
351
- traverse_obj(attributes, ('embed', 'html', {str})), 'vimeo url', fatal=False) or '')
352
- if url_or_none(v_url) and self._request_webpage(
353
- v_url, video_id, 'Checking Vimeo embed URL', headers=headers,
354
- fatal=False, errnote=False, expected_status=429): # 429 is TLS fingerprint rejection
355
- entries.append(self.url_result(
356
- VimeoIE._smuggle_referrer(v_url, headers['referer']),
357
- VimeoIE, url_transparent=True))
358
-
359
- embed_url = traverse_obj(attributes, ('embed', 'url', {url_or_none}))
360
- if embed_url and (urlh := self._request_webpage(
361
- embed_url, video_id, 'Checking embed URL', headers=headers,
362
- fatal=False, errnote=False, expected_status=403)):
363
- # Vimeo's Cloudflare anti-bot protection will return HTTP status 200 for 404, so we need
364
- # to check for "Sorry, we couldn’t find that page" in the meta description tag
365
- meta_description = clean_html(self._html_search_meta(
366
- 'description', self._webpage_read_content(urlh, embed_url, video_id, fatal=False), default=None))
367
- # Password-protected vids.io embeds return 403 errors w/o --video-password or session cookie
368
- if ((urlh.status != 403 and meta_description != 'Sorry, we couldn’t find that page')
369
- or VidsIoIE.suitable(embed_url)):
370
- entries.append(self.url_result(smuggle_url(embed_url, headers)))
419
+ if embed_url := traverse_obj(attributes, ('embed', 'url', {url_or_none})):
420
+ # Convert useless vimeo.com URLs to useful player.vimeo.com embed URLs
421
+ vimeo_id, vimeo_hash = self._search_regex(
422
+ r'//vimeo\.com/(\d+)(?:/([\da-f]+))?', embed_url,
423
+ 'vimeo id', group=(1, 2), default=(None, None))
424
+ if vimeo_id:
425
+ embed_url = update_url_query(
426
+ f'https://player.vimeo.com/video/{vimeo_id}',
427
+ {'h': vimeo_hash or []})
428
+ if VimeoIE.suitable(embed_url):
429
+ entry = self.url_result(
430
+ VimeoIE._smuggle_referrer(embed_url, self._HTTP_HEADERS['referer']),
431
+ VimeoIE, url_transparent=True)
432
+ else:
433
+ entry = self.url_result(smuggle_url(embed_url, self._HTTP_HEADERS))
434
+
435
+ if urlh := self._request_webpage(
436
+ embed_url, video_id, 'Checking embed URL', headers=self._HTTP_HEADERS,
437
+ fatal=False, errnote=False, expected_status=(403, 429), # Ignore Vimeo 429's
438
+ ):
439
+ # Password-protected vids.io embeds return 403 errors w/o --video-password or session cookie
440
+ if VidsIoIE.suitable(embed_url) or urlh.status != 403:
441
+ entries.append(entry)
371
442
 
372
443
  post_file = traverse_obj(attributes, ('post_file', {dict}))
373
444
  if post_file:
@@ -381,13 +452,27 @@ class PatreonIE(PatreonBaseIE):
381
452
  })
382
453
  elif name == 'video' or determine_ext(post_file.get('url')) == 'm3u8':
383
454
  formats, subtitles = self._extract_m3u8_formats_and_subtitles(
384
- post_file['url'], video_id, headers=headers)
455
+ post_file['url'], video_id, headers=self._HTTP_HEADERS)
456
+ for f in formats:
457
+ f['http_headers'] = self._HTTP_HEADERS
385
458
  entries.append({
386
459
  'id': video_id,
387
460
  'formats': formats,
388
461
  'subtitles': subtitles,
389
- 'http_headers': headers,
390
462
  })
463
+ if media_id := traverse_obj(post_file, ('media_id', {int}, {str_or_none})):
464
+ seen_media_ids.add(media_id)
465
+
466
+ for media_id in traverse_obj(attributes, (
467
+ 'content', {find_elements(attr='data-media-id', value=r'\d+', regex=True, html=True)},
468
+ ..., {extract_attributes}, 'data-media-id',
469
+ )):
470
+ # Inlined media may be duplicates of what was extracted above
471
+ if media_id in seen_media_ids:
472
+ continue
473
+ if media := self._extract_from_media_api(media_id):
474
+ entries.append(media)
475
+ seen_media_ids.add(media_id)
391
476
 
392
477
  can_view_post = traverse_obj(attributes, 'current_user_can_view')
393
478
  comments = None
yt_dlp/extractor/pbs.py CHANGED
@@ -453,6 +453,23 @@ class PBSIE(InfoExtractor):
453
453
  'url': 'https://player.pbs.org/portalplayer/3004638221/?uid=',
454
454
  'only_matching': True,
455
455
  },
456
+ {
457
+ # Next.js v13+, see https://github.com/yt-dlp/yt-dlp/issues/13299
458
+ 'url': 'https://www.pbs.org/video/caregiving',
459
+ 'info_dict': {
460
+ 'id': '3101776876',
461
+ 'ext': 'mp4',
462
+ 'title': 'Caregiving - Caregiving',
463
+ 'description': 'A documentary revealing America’s caregiving crisis through intimate stories and expert insight.',
464
+ 'display_id': 'caregiving',
465
+ 'duration': 6783,
466
+ 'thumbnail': 'https://image.pbs.org/video-assets/BSrSkcc-asset-mezzanine-16x9-nlcxQts.jpg',
467
+ 'chapters': [],
468
+ },
469
+ 'params': {
470
+ 'skip_download': True,
471
+ },
472
+ },
456
473
  ]
457
474
  _ERRORS = {
458
475
  101: 'We\'re sorry, but this video is not yet available.',
@@ -506,6 +523,7 @@ class PBSIE(InfoExtractor):
506
523
  r"(?s)window\.PBS\.playerConfig\s*=\s*{.*?id\s*:\s*'([0-9]+)',",
507
524
  r'<div[^>]+\bdata-cove-id=["\'](\d+)"', # http://www.pbs.org/wgbh/roadshow/watch/episode/2105-indianapolis-hour-2/
508
525
  r'<iframe[^>]+\bsrc=["\'](?:https?:)?//video\.pbs\.org/widget/partnerplayer/(\d+)', # https://www.pbs.org/wgbh/masterpiece/episodes/victoria-s2-e1/
526
+ r'\\"videoTPMediaId\\":\\\"(\d+)\\"', # Next.js v13, e.g. https://www.pbs.org/video/caregiving
509
527
  r'\bhttps?://player\.pbs\.org/[\w-]+player/(\d+)', # last pattern to avoid false positives
510
528
  ]
511
529
 
@@ -405,7 +405,7 @@ class RumbleChannelIE(InfoExtractor):
405
405
  for video_url in traverse_obj(
406
406
  get_elements_html_by_class('videostream__link', webpage), (..., {extract_attributes}, 'href'),
407
407
  ):
408
- yield self.url_result(urljoin('https://rumble.com', video_url))
408
+ yield self.url_result(urljoin('https://rumble.com', video_url), RumbleIE)
409
409
 
410
410
  def _real_extract(self, url):
411
411
  url, playlist_id = self._match_valid_url(url).groups()
@@ -1,4 +1,6 @@
1
+ import base64
1
2
  import functools
3
+ import hashlib
2
4
  import itertools
3
5
  import json
4
6
  import random
@@ -15,6 +17,7 @@ from ..utils import (
15
17
  UnsupportedError,
16
18
  UserNotLive,
17
19
  determine_ext,
20
+ extract_attributes,
18
21
  filter_dict,
19
22
  format_field,
20
23
  int_or_none,
@@ -25,13 +28,13 @@ from ..utils import (
25
28
  qualities,
26
29
  srt_subtitles_timecode,
27
30
  str_or_none,
28
- traverse_obj,
29
31
  truncate_string,
30
32
  try_call,
31
33
  try_get,
32
34
  url_or_none,
33
35
  urlencode_postdata,
34
36
  )
37
+ from ..utils.traversal import find_element, require, traverse_obj
35
38
 
36
39
 
37
40
  class TikTokBaseIE(InfoExtractor):
@@ -217,38 +220,94 @@ class TikTokBaseIE(InfoExtractor):
217
220
  raise ExtractorError('Unable to extract aweme detail info', video_id=aweme_id)
218
221
  return self._parse_aweme_video_app(aweme_detail)
219
222
 
223
+ def _solve_challenge_and_set_cookie(self, webpage):
224
+ challenge_data = traverse_obj(webpage, (
225
+ {find_element(id='cs', html=True)}, {extract_attributes}, 'class',
226
+ filter, {lambda x: f'{x}==='}, {base64.b64decode}, {json.loads}))
227
+
228
+ if not challenge_data:
229
+ if 'Please wait...' in webpage:
230
+ raise ExtractorError('Unable to extract challenge data')
231
+ raise ExtractorError('Unexpected response from webpage request')
232
+
233
+ self.to_screen('Solving JS challenge using native Python implementation')
234
+
235
+ expected_digest = traverse_obj(challenge_data, (
236
+ 'v', 'c', {str}, {base64.b64decode},
237
+ {require('challenge expected digest')}))
238
+
239
+ base_hash = traverse_obj(challenge_data, (
240
+ 'v', 'a', {str}, {base64.b64decode},
241
+ {hashlib.sha256}, {require('challenge base hash')}))
242
+
243
+ for i in range(1_000_001):
244
+ number = str(i).encode()
245
+ test_hash = base_hash.copy()
246
+ test_hash.update(number)
247
+ if test_hash.digest() == expected_digest:
248
+ challenge_data['d'] = base64.b64encode(number).decode()
249
+ break
250
+ else:
251
+ raise ExtractorError('Unable to solve JS challenge')
252
+
253
+ cookie_value = base64.b64encode(
254
+ json.dumps(challenge_data, separators=(',', ':')).encode()).decode()
255
+
256
+ # At time of writing, the cookie name was _wafchallengeid
257
+ cookie_name = traverse_obj(webpage, (
258
+ {find_element(id='wci', html=True)}, {extract_attributes},
259
+ 'class', {require('challenge cookie name')}))
260
+
261
+ # Actual JS sets Max-Age=1, but we need to adjust for --sleep-requests and Python slowness
262
+ expire_time = int(time.time()) + (self.get_param('sleep_interval_requests') or 0) + 2
263
+ self._set_cookie('.tiktok.com', cookie_name, cookie_value, expire_time=expire_time)
264
+
220
265
  def _extract_web_data_and_status(self, url, video_id, fatal=True):
221
266
  video_data, status = {}, -1
222
267
 
223
- res = self._download_webpage_handle(url, video_id, fatal=fatal, impersonate=True)
224
- if res is False:
225
- return video_data, status
268
+ def get_webpage(note='Downloading webpage'):
269
+ res = self._download_webpage_handle(url, video_id, note, fatal=fatal, impersonate=True)
270
+ if res is False:
271
+ return False
226
272
 
227
- webpage, urlh = res
228
- if urllib.parse.urlparse(urlh.url).path == '/login':
229
- message = 'TikTok is requiring login for access to this content'
230
- if fatal:
231
- self.raise_login_required(message)
232
- self.report_warning(f'{message}. {self._login_hint()}')
273
+ webpage, urlh = res
274
+ if urllib.parse.urlparse(urlh.url).path == '/login':
275
+ message = 'TikTok is requiring login for access to this content'
276
+ if fatal:
277
+ self.raise_login_required(message)
278
+ self.report_warning(f'{message}. {self._login_hint()}', video_id=video_id)
279
+ return False
280
+
281
+ return webpage
282
+
283
+ webpage = get_webpage()
284
+ if webpage is False:
233
285
  return video_data, status
234
286
 
235
- if universal_data := self._get_universal_data(webpage, video_id):
236
- self.write_debug('Found universal data for rehydration')
237
- status = traverse_obj(universal_data, ('webapp.video-detail', 'statusCode', {int})) or 0
238
- video_data = traverse_obj(universal_data, ('webapp.video-detail', 'itemInfo', 'itemStruct', {dict}))
287
+ universal_data = self._get_universal_data(webpage, video_id)
288
+ if not universal_data:
289
+ try:
290
+ self._solve_challenge_and_set_cookie(webpage)
291
+ except ExtractorError as e:
292
+ if fatal:
293
+ raise
294
+ self.report_warning(e.orig_msg, video_id=video_id)
295
+ return video_data, status
239
296
 
240
- elif sigi_data := self._get_sigi_state(webpage, video_id):
241
- self.write_debug('Found sigi state data')
242
- status = traverse_obj(sigi_data, ('VideoPage', 'statusCode', {int})) or 0
243
- video_data = traverse_obj(sigi_data, ('ItemModule', video_id, {dict}))
297
+ webpage = get_webpage(note='Downloading webpage with challenge cookie')
298
+ if webpage is False:
299
+ return video_data, status
300
+ universal_data = self._get_universal_data(webpage, video_id)
244
301
 
245
- elif next_data := self._search_nextjs_data(webpage, video_id, default={}):
246
- self.write_debug('Found next.js data')
247
- status = traverse_obj(next_data, ('props', 'pageProps', 'statusCode', {int})) or 0
248
- video_data = traverse_obj(next_data, ('props', 'pageProps', 'itemInfo', 'itemStruct', {dict}))
302
+ if not universal_data:
303
+ message = 'Unable to extract universal data for rehydration'
304
+ if fatal:
305
+ raise ExtractorError(message)
306
+ self.report_warning(message, video_id=video_id)
307
+ return video_data, status
249
308
 
250
- elif fatal:
251
- raise ExtractorError('Unable to extract webpage video data')
309
+ status = traverse_obj(universal_data, ('webapp.video-detail', 'statusCode', {int})) or 0
310
+ video_data = traverse_obj(universal_data, ('webapp.video-detail', 'itemInfo', 'itemStruct', {dict}))
252
311
 
253
312
  if not traverse_obj(video_data, ('video', {dict})) and traverse_obj(video_data, ('isContentClassified', {bool})):
254
313
  message = 'This post may not be comfortable for some audiences. Log in for access'
@@ -1,40 +1,167 @@
1
+ import functools
2
+
1
3
  from .common import InfoExtractor
4
+ from ..utils import (
5
+ InAdvancePagedList,
6
+ int_or_none,
7
+ join_nonempty,
8
+ orderedSet,
9
+ str_or_none,
10
+ strftime_or_none,
11
+ unified_timestamp,
12
+ url_or_none,
13
+ )
14
+ from ..utils.traversal import (
15
+ require,
16
+ traverse_obj,
17
+ )
18
+
19
+
20
+ class VolejTVBaseIE(InfoExtractor):
21
+ TBR_HEIGHT_MAPPING = {
22
+ '6000': 1080,
23
+ '2400': 720,
24
+ '1500': 480,
25
+ '800': 360,
26
+ }
27
+
28
+ def _call_api(self, endpoint, display_id, query=None):
29
+ return self._download_json(
30
+ f'https://api-volejtv-prod.apps.okd4.devopsie.cloud/api/{endpoint}',
31
+ display_id, query=query)
2
32
 
3
33
 
4
- class VolejTVIE(InfoExtractor):
5
- _VALID_URL = r'https?://volej\.tv/video/(?P<id>\d+)'
34
+ class VolejTVIE(VolejTVBaseIE):
35
+ IE_NAME = 'volejtv:match'
36
+ _VALID_URL = r'https?://volej\.tv/match/(?P<id>\d+)'
6
37
  _TESTS = [{
7
- 'url': 'https://volej.tv/video/725742/',
38
+ 'url': 'https://volej.tv/match/270579',
8
39
  'info_dict': {
9
- 'id': '725742',
40
+ 'id': '270579',
10
41
  'ext': 'mp4',
11
- 'description': 'Zápas VK Královo Pole vs VK Prostějov 10.12.2022 v 19:00 na Volej.TV',
12
- 'thumbnail': 'https://volej.tv/images/og/16/17186/og.png',
13
- 'title': 'VK Královo Pole vs VK Prostějov',
42
+ 'title': 'SWE-CZE (2024-06-16)',
43
+ 'categories': ['ženy'],
44
+ 'series': 'ZLATÁ EVROPSKÁ VOLEJBALOVÁ LIGA',
45
+ 'season': '2023-2024',
46
+ 'timestamp': 1718553600,
47
+ 'upload_date': '20240616',
14
48
  },
15
49
  }, {
16
- 'url': 'https://volej.tv/video/725605/',
50
+ 'url': 'https://volej.tv/match/487520',
17
51
  'info_dict': {
18
- 'id': '725605',
52
+ 'id': '487520',
19
53
  'ext': 'mp4',
20
- 'thumbnail': 'https://volej.tv/images/og/15/17185/og.png',
21
- 'title': 'VK Lvi Praha vs VK Euro Sitex Příbram',
22
- 'description': 'Zápas VK Lvi Praha vs VK Euro Sitex Příbram 11.12.2022 v 19:00 na Volej.TV',
54
+ 'thumbnail': r're:https://.+\.(png|jpeg)',
55
+ 'title': 'FRA-CZE (2024-09-06)',
56
+ 'categories': ['mládež'],
57
+ 'series': 'Mistrovství Evropy do 20 let',
58
+ 'season': '2024-2025',
59
+ 'timestamp': 1725627600,
60
+ 'upload_date': '20240906',
61
+
23
62
  },
24
63
  }]
25
64
 
26
65
  def _real_extract(self, url):
27
66
  video_id = self._match_id(url)
28
- webpage = self._download_webpage(url, video_id)
29
- json_data = self._search_json(
30
- r'<\s*!\[CDATA[^=]+=', webpage, 'CDATA', video_id)
31
- formats, subtitle = self._extract_m3u8_formats_and_subtitles(
32
- json_data['urls']['hls'], video_id)
33
- return {
67
+ json_data = self._call_api(f'match/{video_id}', video_id)
68
+
69
+ formats = []
70
+ for video in traverse_obj(json_data, ('videos', 0, 'qualities', lambda _, v: url_or_none(v['cloud_front_path']))):
71
+ formats.append(traverse_obj(video, {
72
+ 'url': 'cloud_front_path',
73
+ 'tbr': ('quality', {int_or_none}),
74
+ 'format_id': ('id', {str_or_none}),
75
+ 'height': ('quality', {self.TBR_HEIGHT_MAPPING.get}),
76
+ }))
77
+
78
+ data = {
34
79
  'id': video_id,
35
- 'title': self._html_search_meta(['og:title', 'twitter:title'], webpage),
36
- 'thumbnail': self._html_search_meta(['og:image', 'twitter:image'], webpage),
37
- 'description': self._html_search_meta(['description', 'og:description', 'twitter:description'], webpage),
80
+ **traverse_obj(json_data, {
81
+ 'series': ('competition_name', {str}),
82
+ 'season': ('season', {str}),
83
+ 'timestamp': ('match_time', {unified_timestamp}),
84
+ 'categories': ('category', ('title'), {str}, filter, all, filter),
85
+ 'thumbnail': ('poster', {url_or_none}),
86
+ }),
38
87
  'formats': formats,
39
- 'subtitles': subtitle,
40
88
  }
89
+
90
+ teams = orderedSet(traverse_obj(json_data, ('teams', ..., 'shortcut', {str})))
91
+ if len(teams) > 2 and 'FIN' in teams:
92
+ teams.remove('FIN')
93
+
94
+ data['title'] = join_nonempty(
95
+ join_nonempty(*teams, delim='-'),
96
+ strftime_or_none(data.get('timestamp'), '(%Y-%m-%d)'),
97
+ delim=' ')
98
+
99
+ return data
100
+
101
+
102
+ class VolejTVPlaylistBaseIE(VolejTVBaseIE):
103
+ """Subclasses must set _API_FILTER, _PAGE_SIZE"""
104
+
105
+ def _get_page(self, playlist_id, page):
106
+ return self._call_api(
107
+ f'match/{self._API_FILTER}/{playlist_id}', playlist_id,
108
+ query={'page': page + 1, 'take': self._PAGE_SIZE, 'order': 'DESC'})
109
+
110
+ def _entries(self, playlist_id, first_page_data, page):
111
+ entries = first_page_data if page == 0 else self._get_page(playlist_id, page)
112
+ for match_id in traverse_obj(entries, ('data', ..., 'id')):
113
+ yield self.url_result(f'https://volej.tv/match/{match_id}', VolejTVIE)
114
+
115
+
116
+ class VolejTVClubPlaylistIE(VolejTVPlaylistBaseIE):
117
+ IE_NAME = 'volejtv:club'
118
+ _VALID_URL = r'https?://volej\.tv/klub/(?P<id>\d+)'
119
+ _TESTS = [{
120
+ 'url': 'https://volej.tv/klub/1173',
121
+ 'info_dict': {
122
+ 'id': '1173',
123
+ 'title': 'VK Jihostroj České Budějovice',
124
+ },
125
+ 'playlist_mincount': 30,
126
+ }]
127
+ _API_FILTER = 'by-team-id-paginated'
128
+ _PAGE_SIZE = 6
129
+
130
+ def _real_extract(self, url):
131
+ playlist_id = self._match_id(url)
132
+ title = self._call_api(f'team/show/{playlist_id}', playlist_id)['title']
133
+ first_page_data = self._get_page(playlist_id, 0)
134
+ total_pages = traverse_obj(first_page_data, ('meta', 'pageCount', {int}, {require('page count')}))
135
+ return self.playlist_result(InAdvancePagedList(
136
+ functools.partial(self._entries, playlist_id, first_page_data),
137
+ total_pages, self._PAGE_SIZE), playlist_id, title)
138
+
139
+
140
+ class VolejTVCategoryPlaylistIE(VolejTVPlaylistBaseIE):
141
+ IE_NAME = 'volejtv:category'
142
+ _VALID_URL = r'https?://volej\.tv/kategorie/(?P<id>[^/$?]+)'
143
+ _TESTS = [{
144
+ 'url': 'https://volej.tv/kategorie/chance-cesky-pohar',
145
+ 'info_dict': {
146
+ 'id': 'chance-cesky-pohar',
147
+ 'title': 'Chance Český pohár',
148
+ },
149
+ 'playlist_mincount': 30,
150
+ }]
151
+ _API_FILTER = 'by-category-id-paginated'
152
+ _PAGE_SIZE = 10
153
+
154
+ def _get_category(self, playlist_id):
155
+ categories = self._call_api('category', playlist_id)
156
+ for category in traverse_obj(categories, (lambda _, v: v['slug'] and v['id'] and v['title'])):
157
+ if category['slug'] == playlist_id:
158
+ return category['id'], category['title']
159
+
160
+ def _real_extract(self, url):
161
+ playlist_id = self._match_id(url)
162
+ category_id, title = self._get_category(playlist_id)
163
+ first_page_data = self._get_page(category_id, 0)
164
+ total_pages = traverse_obj(first_page_data, ('meta', 'pageCount', {int}, {require('page count')}))
165
+ return self.playlist_result(InAdvancePagedList(
166
+ functools.partial(self._entries, category_id, first_page_data),
167
+ total_pages, self._PAGE_SIZE), playlist_id, title)
yt_dlp/extractor/wat.py CHANGED
@@ -76,7 +76,7 @@ class WatIE(InfoExtractor):
76
76
  if error_code == 'GEOBLOCKED':
77
77
  self.raise_geo_restricted(error_desc, video_info.get('geoList'))
78
78
  elif error_code == 'DELIVERY_ERROR':
79
- if traverse_obj(video_data, ('delivery', 'code')) == 500:
79
+ if traverse_obj(video_data, ('delivery', 'code')) in (403, 500):
80
80
  self.report_drm(video_id)
81
81
  error_desc = join_nonempty(
82
82
  error_desc, traverse_obj(video_data, ('delivery', 'error', {str})), delim=': ')
yt_dlp/version.py CHANGED
@@ -1,8 +1,8 @@
1
1
  # Autogenerated by devscripts/update-version.py
2
2
 
3
- __version__ = '2026.01.19.233146'
3
+ __version__ = '2026.01.27.233257'
4
4
 
5
- RELEASE_GIT_HEAD = 'c8680b65f79cfeb23b342b70ffe1e233902f7933'
5
+ RELEASE_GIT_HEAD = '1c739bf53e673e06d2a43feddb5a31ee8496fa6e'
6
6
 
7
7
  VARIANT = 'pip'
8
8
 
@@ -12,4 +12,4 @@ CHANNEL = 'nightly'
12
12
 
13
13
  ORIGIN = 'yt-dlp/yt-dlp-nightly-builds'
14
14
 
15
- _pkg_version = '2026.01.19.233146dev'
15
+ _pkg_version = '2026.01.27.233257dev'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yt-dlp
3
- Version: 2026.1.19.233146.dev0
3
+ Version: 2026.1.27.233257.dev0
4
4
  Summary: A feature-rich command-line audio/video downloader
5
5
  Project-URL: Documentation, https://github.com/yt-dlp/yt-dlp#readme
6
6
  Project-URL: Repository, https://github.com/yt-dlp/yt-dlp