yt-dlp 2025.11.1.73148.dev0__py3-none-any.whl → 2025.11.11.5312.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 (38) hide show
  1. yt_dlp/cookies.py +1 -1
  2. yt_dlp/downloader/external.py +41 -45
  3. yt_dlp/extractor/_extractors.py +6 -1
  4. yt_dlp/extractor/bunnycdn.py +20 -3
  5. yt_dlp/extractor/dplay.py +86 -8
  6. yt_dlp/extractor/firsttv.py +34 -1
  7. yt_dlp/extractor/floatplane.py +26 -27
  8. yt_dlp/extractor/goplay.py +7 -5
  9. yt_dlp/extractor/kika.py +48 -42
  10. yt_dlp/extractor/lazy_extractors.py +30 -7
  11. yt_dlp/extractor/mux.py +92 -0
  12. yt_dlp/extractor/nascar.py +60 -0
  13. yt_dlp/extractor/ntvru.py +94 -57
  14. yt_dlp/extractor/tubetugraz.py +9 -2
  15. yt_dlp/extractor/twitch.py +5 -2
  16. yt_dlp/extractor/xhamster.py +31 -0
  17. yt_dlp/extractor/youtube/_base.py +11 -0
  18. yt_dlp/extractor/youtube/_tab.py +3 -2
  19. yt_dlp/extractor/youtube/_video.py +156 -32
  20. yt_dlp/extractor/youtube/jsc/_builtin/vendor/_info.py +1 -1
  21. yt_dlp/networking/_curlcffi.py +4 -1
  22. yt_dlp/networking/_requests.py +14 -9
  23. yt_dlp/networking/_urllib.py +19 -1
  24. yt_dlp/networking/common.py +6 -2
  25. yt_dlp/options.py +12 -5
  26. yt_dlp/postprocessor/sponsorblock.py +1 -0
  27. yt_dlp/utils/_jsruntime.py +13 -4
  28. yt_dlp/version.py +3 -3
  29. {yt_dlp-2025.11.1.73148.dev0.data → yt_dlp-2025.11.11.5312.dev0.data}/data/share/doc/yt_dlp/README.txt +42 -29
  30. {yt_dlp-2025.11.1.73148.dev0.data → yt_dlp-2025.11.11.5312.dev0.data}/data/share/fish/vendor_completions.d/yt-dlp.fish +3 -3
  31. {yt_dlp-2025.11.1.73148.dev0.data → yt_dlp-2025.11.11.5312.dev0.data}/data/share/man/man1/yt-dlp.1 +23 -14
  32. {yt_dlp-2025.11.1.73148.dev0.data → yt_dlp-2025.11.11.5312.dev0.data}/data/share/zsh/site-functions/_yt-dlp +2 -2
  33. {yt_dlp-2025.11.1.73148.dev0.dist-info → yt_dlp-2025.11.11.5312.dev0.dist-info}/METADATA +35 -23
  34. {yt_dlp-2025.11.1.73148.dev0.dist-info → yt_dlp-2025.11.11.5312.dev0.dist-info}/RECORD +38 -36
  35. {yt_dlp-2025.11.1.73148.dev0.data → yt_dlp-2025.11.11.5312.dev0.data}/data/share/bash-completion/completions/yt-dlp +0 -0
  36. {yt_dlp-2025.11.1.73148.dev0.dist-info → yt_dlp-2025.11.11.5312.dev0.dist-info}/WHEEL +0 -0
  37. {yt_dlp-2025.11.1.73148.dev0.dist-info → yt_dlp-2025.11.11.5312.dev0.dist-info}/entry_points.txt +0 -0
  38. {yt_dlp-2025.11.1.73148.dev0.dist-info → yt_dlp-2025.11.11.5312.dev0.dist-info}/licenses/LICENSE +0 -0
yt_dlp/cookies.py CHANGED
@@ -557,7 +557,7 @@ class WindowsChromeCookieDecryptor(ChromeCookieDecryptor):
557
557
 
558
558
 
559
559
  def _extract_safari_cookies(profile, logger):
560
- if sys.platform != 'darwin':
560
+ if sys.platform not in ('darwin', 'ios'):
561
561
  raise ValueError(f'unsupported platform: {sys.platform}')
562
562
 
563
563
  if profile:
@@ -488,20 +488,6 @@ class FFmpegFD(ExternalFD):
488
488
  if not self.params.get('verbose'):
489
489
  args += ['-hide_banner']
490
490
 
491
- args += traverse_obj(info_dict, ('downloader_options', 'ffmpeg_args', ...))
492
-
493
- # These exists only for compatibility. Extractors should use
494
- # info_dict['downloader_options']['ffmpeg_args'] instead
495
- args += info_dict.get('_ffmpeg_args') or []
496
- seekable = info_dict.get('_seekable')
497
- if seekable is not None:
498
- # setting -seekable prevents ffmpeg from guessing if the server
499
- # supports seeking(by adding the header `Range: bytes=0-`), which
500
- # can cause problems in some cases
501
- # https://github.com/ytdl-org/youtube-dl/issues/11800#issuecomment-275037127
502
- # http://trac.ffmpeg.org/ticket/6125#comment:10
503
- args += ['-seekable', '1' if seekable else '0']
504
-
505
491
  env = None
506
492
  proxy = self.params.get('proxy')
507
493
  if proxy:
@@ -521,39 +507,10 @@ class FFmpegFD(ExternalFD):
521
507
  env['HTTP_PROXY'] = proxy
522
508
  env['http_proxy'] = proxy
523
509
 
524
- protocol = info_dict.get('protocol')
525
-
526
- if protocol == 'rtmp':
527
- player_url = info_dict.get('player_url')
528
- page_url = info_dict.get('page_url')
529
- app = info_dict.get('app')
530
- play_path = info_dict.get('play_path')
531
- tc_url = info_dict.get('tc_url')
532
- flash_version = info_dict.get('flash_version')
533
- live = info_dict.get('rtmp_live', False)
534
- conn = info_dict.get('rtmp_conn')
535
- if player_url is not None:
536
- args += ['-rtmp_swfverify', player_url]
537
- if page_url is not None:
538
- args += ['-rtmp_pageurl', page_url]
539
- if app is not None:
540
- args += ['-rtmp_app', app]
541
- if play_path is not None:
542
- args += ['-rtmp_playpath', play_path]
543
- if tc_url is not None:
544
- args += ['-rtmp_tcurl', tc_url]
545
- if flash_version is not None:
546
- args += ['-rtmp_flashver', flash_version]
547
- if live:
548
- args += ['-rtmp_live', 'live']
549
- if isinstance(conn, list):
550
- for entry in conn:
551
- args += ['-rtmp_conn', entry]
552
- elif isinstance(conn, str):
553
- args += ['-rtmp_conn', conn]
554
-
555
510
  start_time, end_time = info_dict.get('section_start') or 0, info_dict.get('section_end')
556
511
 
512
+ fallback_input_args = traverse_obj(info_dict, ('downloader_options', 'ffmpeg_args', ...))
513
+
557
514
  selected_formats = info_dict.get('requested_formats') or [info_dict]
558
515
  for i, fmt in enumerate(selected_formats):
559
516
  is_http = re.match(r'https?://', fmt['url'])
@@ -572,6 +529,44 @@ class FFmpegFD(ExternalFD):
572
529
  if end_time:
573
530
  args += ['-t', str(end_time - start_time)]
574
531
 
532
+ protocol = fmt.get('protocol')
533
+
534
+ if protocol == 'rtmp':
535
+ player_url = fmt.get('player_url')
536
+ page_url = fmt.get('page_url')
537
+ app = fmt.get('app')
538
+ play_path = fmt.get('play_path')
539
+ tc_url = fmt.get('tc_url')
540
+ flash_version = fmt.get('flash_version')
541
+ live = fmt.get('rtmp_live', False)
542
+ conn = fmt.get('rtmp_conn')
543
+ if player_url is not None:
544
+ args += ['-rtmp_swfverify', player_url]
545
+ if page_url is not None:
546
+ args += ['-rtmp_pageurl', page_url]
547
+ if app is not None:
548
+ args += ['-rtmp_app', app]
549
+ if play_path is not None:
550
+ args += ['-rtmp_playpath', play_path]
551
+ if tc_url is not None:
552
+ args += ['-rtmp_tcurl', tc_url]
553
+ if flash_version is not None:
554
+ args += ['-rtmp_flashver', flash_version]
555
+ if live:
556
+ args += ['-rtmp_live', 'live']
557
+ if isinstance(conn, list):
558
+ for entry in conn:
559
+ args += ['-rtmp_conn', entry]
560
+ elif isinstance(conn, str):
561
+ args += ['-rtmp_conn', conn]
562
+
563
+ elif protocol == 'http_dash_segments' and info_dict.get('is_live'):
564
+ # ffmpeg may try to read past the latest available segments for
565
+ # live DASH streams unless we pass `-re`. In modern ffmpeg, this
566
+ # is an alias of `-readrate 1`, but `-readrate` was not added
567
+ # until ffmpeg 5.0, so we must stick to using `-re`
568
+ args += ['-re']
569
+
575
570
  url = fmt['url']
576
571
  if self.params.get('enable_file_urls') and url.startswith('file:'):
577
572
  # The default protocol_whitelist is 'file,crypto,data' when reading local m3u8 URLs,
@@ -586,6 +581,7 @@ class FFmpegFD(ExternalFD):
586
581
  # https://trac.ffmpeg.org/ticket/2702
587
582
  url = re.sub(r'^file://(?:localhost)?/', 'file:' if os.name == 'nt' else 'file:/', url)
588
583
 
584
+ args += traverse_obj(fmt, ('downloader_options', 'ffmpeg_args', ...)) or fallback_input_args
589
585
  args += [*self._configuration_args((f'_i{i + 1}', '_i')), '-i', url]
590
586
 
591
587
  if not (start_time or end_time) or not self.params.get('force_keyframes_at_cuts'):
@@ -640,7 +640,10 @@ from .filmon import (
640
640
  FilmOnIE,
641
641
  )
642
642
  from .filmweb import FilmwebIE
643
- from .firsttv import FirstTVIE
643
+ from .firsttv import (
644
+ FirstTVIE,
645
+ FirstTVLiveIE,
646
+ )
644
647
  from .fivetv import FiveTVIE
645
648
  from .flextv import FlexTVIE
646
649
  from .flickr import FlickrIE
@@ -1197,6 +1200,7 @@ from .musicdex import (
1197
1200
  MusicdexPlaylistIE,
1198
1201
  MusicdexSongIE,
1199
1202
  )
1203
+ from .mux import MuxIE
1200
1204
  from .mx3 import (
1201
1205
  Mx3IE,
1202
1206
  Mx3NeoIE,
@@ -1218,6 +1222,7 @@ from .n1 import (
1218
1222
  N1InfoAssetIE,
1219
1223
  N1InfoIIE,
1220
1224
  )
1225
+ from .nascar import NascarClassicsIE
1221
1226
  from .nate import (
1222
1227
  NateIE,
1223
1228
  NateProgramIE,
@@ -16,7 +16,7 @@ from ..utils.traversal import find_element, traverse_obj
16
16
 
17
17
 
18
18
  class BunnyCdnIE(InfoExtractor):
19
- _VALID_URL = r'https?://(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)/(?:embed|play)/(?P<library_id>\d+)/(?P<id>[\da-f-]+)'
19
+ _VALID_URL = r'https?://(?:(?:iframe|player)\.mediadelivery\.net|video\.bunnycdn\.com)/(?:embed|play)/(?P<library_id>\d+)/(?P<id>[\da-f-]+)'
20
20
  _EMBED_REGEX = [rf'<iframe[^>]+src=[\'"](?P<url>{_VALID_URL}[^\'"]*)[\'"]']
21
21
  _TESTS = [{
22
22
  'url': 'https://iframe.mediadelivery.net/embed/113933/e73edec1-e381-4c8b-ae73-717a140e0924',
@@ -39,7 +39,7 @@ class BunnyCdnIE(InfoExtractor):
39
39
  'timestamp': 1691145748,
40
40
  'thumbnail': r're:^https?://.*\.b-cdn\.net/32e34c4b-0d72-437c-9abb-05e67657da34/thumbnail_9172dc16\.jpg',
41
41
  'duration': 106.0,
42
- 'description': 'md5:981a3e899a5c78352b21ed8b2f1efd81',
42
+ 'description': 'md5:11452bcb31f379ee3eaf1234d3264e44',
43
43
  'upload_date': '20230804',
44
44
  'title': 'Sanela ist Teil der #arbeitsmarktkraft',
45
45
  },
@@ -58,6 +58,23 @@ class BunnyCdnIE(InfoExtractor):
58
58
  'thumbnail': r're:^https?://.*\.b-cdn\.net/2e8545ec-509d-4571-b855-4cf0235ccd75/thumbnail\.jpg',
59
59
  },
60
60
  'params': {'skip_download': True},
61
+ }, {
62
+ # Requires any Referer
63
+ 'url': 'https://iframe.mediadelivery.net/embed/289162/6372f5a3-68df-4ef7-a115-e1110186c477',
64
+ 'info_dict': {
65
+ 'id': '6372f5a3-68df-4ef7-a115-e1110186c477',
66
+ 'ext': 'mp4',
67
+ 'title': '12-Creating Small Asset Blockouts -Timelapse.mp4',
68
+ 'description': '',
69
+ 'duration': 263.0,
70
+ 'timestamp': 1724485440,
71
+ 'upload_date': '20240824',
72
+ 'thumbnail': r're:^https?://.*\.b-cdn\.net/6372f5a3-68df-4ef7-a115-e1110186c477/thumbnail\.jpg',
73
+ },
74
+ 'params': {'skip_download': True},
75
+ }, {
76
+ 'url': 'https://player.mediadelivery.net/embed/519128/875880a9-bcc2-4038-9e05-e5024bba9b70',
77
+ 'only_matching': True,
61
78
  }]
62
79
  _WEBPAGE_TESTS = [{
63
80
  # Stream requires Referer
@@ -100,7 +117,7 @@ class BunnyCdnIE(InfoExtractor):
100
117
  video_id, library_id = self._match_valid_url(url).group('id', 'library_id')
101
118
  webpage = self._download_webpage(
102
119
  f'https://iframe.mediadelivery.net/embed/{library_id}/{video_id}', video_id,
103
- headers=traverse_obj(smuggled_data, {'Referer': 'Referer'}),
120
+ headers={'Referer': smuggled_data.get('Referer') or 'https://iframe.mediadelivery.net/'},
104
121
  query=traverse_obj(parse_qs(url), {'token': 'token', 'expires': 'expires'}))
105
122
 
106
123
  if html_title := self._html_extract_title(webpage, default=None) == '403':
yt_dlp/extractor/dplay.py CHANGED
@@ -13,6 +13,7 @@ from ..utils import (
13
13
  try_get,
14
14
  unified_timestamp,
15
15
  )
16
+ from ..utils.traversal import traverse_obj
16
17
 
17
18
 
18
19
  class DPlayBaseIE(InfoExtractor):
@@ -1053,7 +1054,7 @@ class DiscoveryPlusIndiaIE(DiscoveryPlusBaseIE):
1053
1054
 
1054
1055
 
1055
1056
  class DiscoveryNetworksDeIE(DiscoveryPlusBaseIE):
1056
- _VALID_URL = r'https?://(?:www\.)?(?P<domain>(?:tlc|dmax)\.de|dplay\.co\.uk)/(?:programme|show|sendungen)/(?P<programme>[^/]+)/(?:video/)?(?P<alternate_id>[^/]+)'
1057
+ _VALID_URL = r'https?://(?:www\.)?(?P<domain>(?:tlc|dmax)\.de)/(?:programme|show|sendungen)/(?P<programme>[^/?#]+)/(?:video/)?(?P<alternate_id>[^/?#]+)'
1057
1058
 
1058
1059
  _TESTS = [{
1059
1060
  'url': 'https://dmax.de/sendungen/goldrausch-in-australien/german-gold',
@@ -1074,6 +1075,7 @@ class DiscoveryNetworksDeIE(DiscoveryPlusBaseIE):
1074
1075
  'creators': ['DMAX'],
1075
1076
  'thumbnail': 'https://eu1-prod-images.disco-api.com/2023/05/09/f72fb510-7992-3b12-af7f-f16a2c22d1e3.jpeg',
1076
1077
  'tags': ['schatzsucher', 'schatz', 'nugget', 'bodenschätze', 'down under', 'australien', 'goldrausch'],
1078
+ 'categories': ['Gold', 'Schatzsucher'],
1077
1079
  },
1078
1080
  'params': {'skip_download': 'm3u8'},
1079
1081
  }, {
@@ -1100,20 +1102,96 @@ class DiscoveryNetworksDeIE(DiscoveryPlusBaseIE):
1100
1102
  }, {
1101
1103
  'url': 'https://www.dmax.de/programme/dmax-highlights/video/tuning-star-sidney-hoffmann-exklusiv-bei-dmax/191023082312316',
1102
1104
  'only_matching': True,
1103
- }, {
1104
- 'url': 'https://www.dplay.co.uk/show/ghost-adventures/video/hotel-leger-103620/EHD_280313B',
1105
- 'only_matching': True,
1106
1105
  }, {
1107
1106
  'url': 'https://tlc.de/sendungen/breaking-amish/die-welt-da-drauen/',
1108
1107
  'only_matching': True,
1108
+ }, {
1109
+ 'url': 'https://dmax.de/sendungen/feuerwache-3-alarm-in-muenchen/24-stunden-auf-der-feuerwache-3',
1110
+ 'info_dict': {
1111
+ 'id': '8873549',
1112
+ 'ext': 'mp4',
1113
+ 'title': '24 Stunden auf der Feuerwache 3',
1114
+ 'description': 'md5:f3084ef6170bfb79f9a6e0c030e09330',
1115
+ 'display_id': 'feuerwache-3-alarm-in-muenchen/24-stunden-auf-der-feuerwache-3',
1116
+ 'episode': 'Episode 1',
1117
+ 'episode_number': 1,
1118
+ 'season': 'Season 1',
1119
+ 'season_number': 1,
1120
+ 'series': 'Feuerwache 3 - Alarm in München',
1121
+ 'duration': 2632.0,
1122
+ 'upload_date': '20251016',
1123
+ 'timestamp': 1760645100,
1124
+ 'creators': ['DMAX'],
1125
+ 'thumbnail': 'https://eu1-prod-images.disco-api.com/2025/10/14/0bdee68c-a8d8-33d9-9204-16eb61108552.jpeg',
1126
+ 'tags': [],
1127
+ 'categories': ['DMAX Originals', 'Jobs', 'Blaulicht'],
1128
+ },
1129
+ 'params': {'skip_download': 'm3u8'},
1130
+ }, {
1131
+ 'url': 'https://tlc.de/sendungen/ghost-adventures/der-poltergeist-im-kostumladen',
1132
+ 'info_dict': {
1133
+ 'id': '4550602',
1134
+ 'ext': 'mp4',
1135
+ 'title': 'Der Poltergeist im Kostümladen',
1136
+ 'description': 'md5:20b52b9736a0a3a7873d19a238fad7fc',
1137
+ 'display_id': 'ghost-adventures/der-poltergeist-im-kostumladen',
1138
+ 'episode': 'Episode 1',
1139
+ 'episode_number': 1,
1140
+ 'season': 'Season 25',
1141
+ 'season_number': 25,
1142
+ 'series': 'Ghost Adventures',
1143
+ 'duration': 2493.0,
1144
+ 'upload_date': '20241223',
1145
+ 'timestamp': 1734948900,
1146
+ 'creators': ['TLC'],
1147
+ 'thumbnail': 'https://eu1-prod-images.disco-api.com/2023/04/05/59941d26-a81b-365f-829f-69d8cd81fd0f.jpeg',
1148
+ 'tags': [],
1149
+ 'categories': ['Paranormal', 'Gruselig!'],
1150
+ },
1151
+ 'params': {'skip_download': 'm3u8'},
1152
+ }, {
1153
+ 'url': 'https://tlc.de/sendungen/evil-gesichter-des-boesen/das-geheimnis-meines-bruders',
1154
+ 'info_dict': {
1155
+ 'id': '7792288',
1156
+ 'ext': 'mp4',
1157
+ 'title': 'Das Geheimnis meines Bruders',
1158
+ 'description': 'md5:3167550bb582eb9c92875c86a0a20882',
1159
+ 'display_id': 'evil-gesichter-des-boesen/das-geheimnis-meines-bruders',
1160
+ 'episode': 'Episode 1',
1161
+ 'episode_number': 1,
1162
+ 'season': 'Season 1',
1163
+ 'season_number': 1,
1164
+ 'series': 'Evil - Gesichter des Bösen',
1165
+ 'duration': 2626.0,
1166
+ 'upload_date': '20240926',
1167
+ 'timestamp': 1727388000,
1168
+ 'creators': ['TLC'],
1169
+ 'thumbnail': 'https://eu1-prod-images.disco-api.com/2024/11/29/e9f3e3ae-74ec-3631-81b7-fc7bbe844741.jpeg',
1170
+ 'tags': 'count:13',
1171
+ 'categories': ['True Crime', 'Mord'],
1172
+ },
1173
+ 'params': {'skip_download': 'm3u8'},
1109
1174
  }]
1110
1175
 
1111
1176
  def _real_extract(self, url):
1112
1177
  domain, programme, alternate_id = self._match_valid_url(url).groups()
1113
- country = 'GB' if domain == 'dplay.co.uk' else 'DE'
1114
- realm = 'questuk' if country == 'GB' else domain.replace('.', '')
1115
- return self._get_disco_api_info(
1116
- url, f'{programme}/{alternate_id}', 'eu1-prod.disco-api.com', realm, country)
1178
+ display_id = f'{programme}/{alternate_id}'
1179
+ meta = self._download_json(
1180
+ f'https://de-api.loma-cms.com/feloma/videos/{alternate_id}/',
1181
+ display_id, query={
1182
+ 'environment': domain.split('.')[0],
1183
+ 'v': '2',
1184
+ 'filter[show.slug]': programme,
1185
+ }, fatal=False)
1186
+ video_id = traverse_obj(meta, ('uid', {str}, {lambda s: s[-7:]})) or display_id
1187
+
1188
+ disco_api_info = self._get_disco_api_info(
1189
+ url, video_id, 'eu1-prod.disco-api.com', domain.replace('.', ''), 'DE')
1190
+ disco_api_info['display_id'] = display_id
1191
+ disco_api_info['categories'] = traverse_obj(meta, (
1192
+ 'taxonomies', lambda _, v: v['category'] == 'genre', 'title', {str.strip}, filter, all, filter))
1193
+
1194
+ return disco_api_info
1117
1195
 
1118
1196
  def _update_disco_api_headers(self, headers, disco_base, display_id, realm):
1119
1197
  headers.update({
@@ -10,7 +10,7 @@ from ..utils import (
10
10
  unified_strdate,
11
11
  url_or_none,
12
12
  )
13
- from ..utils.traversal import traverse_obj
13
+ from ..utils.traversal import require, traverse_obj
14
14
 
15
15
 
16
16
  class FirstTVIE(InfoExtractor):
@@ -129,3 +129,36 @@ class FirstTVIE(InfoExtractor):
129
129
  return self.playlist_result(
130
130
  self._entries(items), display_id, self._og_search_title(webpage, default=None),
131
131
  thumbnail=self._og_search_thumbnail(webpage, default=None))
132
+
133
+
134
+ class FirstTVLiveIE(InfoExtractor):
135
+ IE_NAME = '1tv:live'
136
+ IE_DESC = 'Первый канал (прямой эфир)'
137
+ _VALID_URL = r'https?://(?:www\.)?1tv\.ru/live'
138
+
139
+ _TESTS = [{
140
+ 'url': 'https://www.1tv.ru/live',
141
+ 'info_dict': {
142
+ 'id': 'live',
143
+ 'ext': 'mp4',
144
+ 'title': r're:ПЕРВЫЙ КАНАЛ ПРЯМОЙ ЭФИР СМОТРЕТЬ ОНЛАЙН \d{4}-\d{2}-\d{2} \d{2}:\d{2}$',
145
+ 'live_status': 'is_live',
146
+ },
147
+ 'params': {'skip_download': 'livestream'},
148
+ }]
149
+
150
+ def _real_extract(self, url):
151
+ display_id = 'live'
152
+ webpage = self._download_webpage(url, display_id, fatal=False)
153
+
154
+ streams_list = self._download_json('https://stream.1tv.ru/api/playlist/1tvch-v1_as_array.json', display_id)
155
+ mpd_url = traverse_obj(streams_list, ('mpd', ..., {url_or_none}, any, {require('mpd url')}))
156
+ # FFmpeg needs to be passed -re to not seek past live window. This is handled by core
157
+ formats, _ = self._extract_mpd_formats_and_subtitles(mpd_url, display_id, mpd_id='dash')
158
+
159
+ return {
160
+ 'id': display_id,
161
+ 'title': self._html_extract_title(webpage),
162
+ 'formats': formats,
163
+ 'is_live': True,
164
+ }
@@ -6,15 +6,15 @@ from ..utils import (
6
6
  OnDemandPagedList,
7
7
  clean_html,
8
8
  determine_ext,
9
+ float_or_none,
9
10
  format_field,
10
11
  int_or_none,
11
12
  join_nonempty,
12
- parse_codecs,
13
13
  parse_iso8601,
14
14
  url_or_none,
15
15
  urljoin,
16
16
  )
17
- from ..utils.traversal import traverse_obj
17
+ from ..utils.traversal import require, traverse_obj
18
18
 
19
19
 
20
20
  class FloatplaneBaseIE(InfoExtractor):
@@ -50,37 +50,31 @@ class FloatplaneBaseIE(InfoExtractor):
50
50
  media_id = media['id']
51
51
  media_typ = media.get('type') or 'video'
52
52
 
53
- metadata = self._download_json(
54
- f'{self._BASE_URL}/api/v3/content/{media_typ}', media_id, query={'id': media_id},
55
- note=f'Downloading {media_typ} metadata', impersonate=self._IMPERSONATE_TARGET)
56
-
57
53
  stream = self._download_json(
58
- f'{self._BASE_URL}/api/v2/cdn/delivery', media_id, query={
59
- 'type': 'vod' if media_typ == 'video' else 'aod',
60
- 'guid': metadata['guid'],
61
- }, note=f'Downloading {media_typ} stream data',
54
+ f'{self._BASE_URL}/api/v3/delivery/info', media_id,
55
+ query={'scenario': 'onDemand', 'entityId': media_id},
56
+ note=f'Downloading {media_typ} stream data',
62
57
  impersonate=self._IMPERSONATE_TARGET)
63
58
 
64
- path_template = traverse_obj(stream, ('resource', 'uri', {str}))
59
+ metadata = self._download_json(
60
+ f'{self._BASE_URL}/api/v3/content/{media_typ}', media_id,
61
+ f'Downloading {media_typ} metadata', query={'id': media_id},
62
+ fatal=False, impersonate=self._IMPERSONATE_TARGET)
65
63
 
66
- def format_path(params):
67
- path = path_template
68
- for i, val in (params or {}).items():
69
- path = path.replace(f'{{qualityLevelParams.{i}}}', val)
70
- return path
64
+ cdn_base_url = traverse_obj(stream, (
65
+ 'groups', 0, 'origins', ..., 'url', {url_or_none}, any, {require('cdn base url')}))
71
66
 
72
67
  formats = []
73
- for quality in traverse_obj(stream, ('resource', 'data', 'qualityLevels', ...)):
74
- url = urljoin(stream['cdn'], format_path(traverse_obj(
75
- stream, ('resource', 'data', 'qualityLevelParams', quality['name'], {dict}))))
76
- format_id = traverse_obj(quality, ('name', {str}))
68
+ for variant in traverse_obj(stream, ('groups', 0, 'variants', lambda _, v: v['url'])):
69
+ format_url = urljoin(cdn_base_url, variant['url'])
70
+ format_id = traverse_obj(variant, ('name', {str}))
77
71
  hls_aes = {}
78
72
  m3u8_data = None
79
73
 
80
74
  # If we need impersonation for the API, then we need it for HLS keys too: extract in advance
81
75
  if self._IMPERSONATE_TARGET is not None:
82
76
  m3u8_data = self._download_webpage(
83
- url, media_id, fatal=False, impersonate=self._IMPERSONATE_TARGET, headers=self._HEADERS,
77
+ format_url, media_id, fatal=False, impersonate=self._IMPERSONATE_TARGET, headers=self._HEADERS,
84
78
  note=join_nonempty('Downloading', format_id, 'm3u8 information', delim=' '),
85
79
  errnote=join_nonempty('Failed to download', format_id, 'm3u8 information', delim=' '))
86
80
  if not m3u8_data:
@@ -98,14 +92,19 @@ class FloatplaneBaseIE(InfoExtractor):
98
92
  hls_aes['key'] = urlh.read().hex()
99
93
 
100
94
  formats.append({
101
- **traverse_obj(quality, {
95
+ **traverse_obj(variant, {
102
96
  'format_note': ('label', {str}),
103
- 'width': ('width', {int}),
104
- 'height': ('height', {int}),
97
+ 'width': ('meta', 'video', 'width', {int_or_none}),
98
+ 'height': ('meta', 'video', 'height', {int_or_none}),
99
+ 'vcodec': ('meta', 'video', 'codec', {str}),
100
+ 'acodec': ('meta', 'audio', 'codec', {str}),
101
+ 'vbr': ('meta', 'video', 'bitrate', 'average', {int_or_none(scale=1000)}),
102
+ 'abr': ('meta', 'audio', 'bitrate', 'average', {int_or_none(scale=1000)}),
103
+ 'audio_channels': ('meta', 'audio', 'channelCount', {int_or_none}),
104
+ 'fps': ('meta', 'video', 'fps', {float_or_none}),
105
105
  }),
106
- **parse_codecs(quality.get('codecs')),
107
- 'url': url,
108
- 'ext': determine_ext(url.partition('/chunk.m3u8')[0], 'mp4'),
106
+ 'url': format_url,
107
+ 'ext': determine_ext(format_url.partition('/chunk.m3u8')[0], 'mp4'),
109
108
  'format_id': format_id,
110
109
  'hls_media_playlist_data': m3u8_data,
111
110
  'hls_aes': hls_aes or None,
@@ -13,12 +13,14 @@ from ..utils.traversal import get_first, traverse_obj
13
13
 
14
14
 
15
15
  class GoPlayIE(InfoExtractor):
16
- _VALID_URL = r'https?://(www\.)?goplay\.be/video/([^/?#]+/[^/?#]+/|)(?P<id>[^/#]+)'
16
+ IE_NAME = 'play.tv'
17
+ IE_DESC = 'PLAY (formerly goplay.be)'
18
+ _VALID_URL = r'https?://(www\.)?play\.tv/video/([^/?#]+/[^/?#]+/|)(?P<id>[^/#]+)'
17
19
 
18
20
  _NETRC_MACHINE = 'goplay'
19
21
 
20
22
  _TESTS = [{
21
- 'url': 'https://www.goplay.be/video/de-slimste-mens-ter-wereld/de-slimste-mens-ter-wereld-s22/de-slimste-mens-ter-wereld-s22-aflevering-1',
23
+ 'url': 'https://www.play.tv/video/de-slimste-mens-ter-wereld/de-slimste-mens-ter-wereld-s22/de-slimste-mens-ter-wereld-s22-aflevering-1',
22
24
  'info_dict': {
23
25
  'id': '2baa4560-87a0-421b-bffc-359914e3c387',
24
26
  'ext': 'mp4',
@@ -33,7 +35,7 @@ class GoPlayIE(InfoExtractor):
33
35
  'params': {'skip_download': True},
34
36
  'skip': 'This video is only available for registered users',
35
37
  }, {
36
- 'url': 'https://www.goplay.be/video/1917',
38
+ 'url': 'https://www.play.tv/video/1917',
37
39
  'info_dict': {
38
40
  'id': '40cac41d-8d29-4ef5-aa11-75047b9f0907',
39
41
  'ext': 'mp4',
@@ -43,7 +45,7 @@ class GoPlayIE(InfoExtractor):
43
45
  'params': {'skip_download': True},
44
46
  'skip': 'This video is only available for registered users',
45
47
  }, {
46
- 'url': 'https://www.goplay.be/video/de-mol/de-mol-s11/de-mol-s11-aflevering-1#autoplay',
48
+ 'url': 'https://www.play.tv/video/de-mol/de-mol-s11/de-mol-s11-aflevering-1#autoplay',
47
49
  'info_dict': {
48
50
  'id': 'ecb79672-92b9-4cd9-a0d7-e2f0250681ee',
49
51
  'ext': 'mp4',
@@ -101,7 +103,7 @@ class GoPlayIE(InfoExtractor):
101
103
  break
102
104
 
103
105
  api = self._download_json(
104
- f'https://api.goplay.be/web/v1/videos/long-form/{video_id}',
106
+ f'https://api.play.tv/web/v1/videos/long-form/{video_id}',
105
107
  video_id, headers={
106
108
  'Authorization': f'Bearer {self._id_token}',
107
109
  **self.geo_verification_headers(),
yt_dlp/extractor/kika.py CHANGED
@@ -17,57 +17,60 @@ class KikaIE(InfoExtractor):
17
17
  _GEO_COUNTRIES = ['DE']
18
18
 
19
19
  _TESTS = [{
20
- 'url': 'https://www.kika.de/logo/videos/logo-vom-samstag-einunddreissig-august-zweitausendvierundzwanzig-100',
21
- 'md5': 'fbfc8da483719ef06f396e5e5b938c69',
20
+ # Video without season/episode info
21
+ 'url': 'https://www.kika.de/logo/videos/logo-vom-dienstag-achtundzwanzig-oktober-zweitausendfuenfundzwanzig-100',
22
+ 'md5': '4a9f6e0f9c6bfcc82394c294f186d6db',
22
23
  'info_dict': {
23
- 'id': 'logo-vom-samstag-einunddreissig-august-zweitausendvierundzwanzig-100',
24
+ 'id': 'logo-vom-dienstag-achtundzwanzig-oktober-zweitausendfuenfundzwanzig-100',
24
25
  'ext': 'mp4',
25
- 'upload_date': '20240831',
26
- 'timestamp': 1725126600,
27
- 'season_number': 2024,
28
- 'modified_date': '20240831',
29
- 'episode': 'Episode 476',
30
- 'episode_number': 476,
31
- 'season': 'Season 2024',
32
- 'duration': 634,
33
- 'title': 'logo! vom Samstag, 31. August 2024',
34
- 'modified_timestamp': 1725129983,
26
+ 'title': 'logo! vom Dienstag, 28. Oktober 2025',
27
+ 'description': 'md5:4d28b92cef423bec99740ffaa3e7ec04',
28
+ 'duration': 651,
29
+ 'timestamp': 1761678000,
30
+ 'upload_date': '20251028',
31
+ 'modified_timestamp': 1761682624,
32
+ 'modified_date': '20251028',
35
33
  },
36
34
  }, {
35
+ # Video with season/episode info
36
+ # Also: Video with subtitles
37
37
  'url': 'https://www.kika.de/kaltstart/videos/video92498',
38
- 'md5': '710ece827e5055094afeb474beacb7aa',
38
+ 'md5': 'e58073070acb195906c55c4ad31dceb3',
39
39
  'info_dict': {
40
40
  'id': 'video92498',
41
41
  'ext': 'mp4',
42
42
  'title': '7. Wo ist Leo?',
43
43
  'description': 'md5:fb48396a5b75068bcac1df74f1524920',
44
44
  'duration': 436,
45
+ 'season': 'Season 1',
46
+ 'season_number': 1,
47
+ 'episode': 'Episode 7',
48
+ 'episode_number': 7,
45
49
  'timestamp': 1702926876,
46
50
  'upload_date': '20231218',
47
- 'episode_number': 7,
48
- 'modified_date': '20240319',
49
51
  'modified_timestamp': 1710880610,
50
- 'episode': 'Episode 7',
51
- 'season_number': 1,
52
- 'season': 'Season 1',
52
+ 'modified_date': '20240319',
53
+ 'subtitles': 'count:1',
53
54
  },
54
55
  }, {
55
- 'url': 'https://www.kika.de/bernd-das-brot/astrobrot/videos/video90088',
56
- 'md5': 'ffd1b700d7de0a6616a1d08544c77294',
56
+ # Video without subtitles
57
+ 'url': 'https://www.kika.de/die-pfefferkoerner/videos/abgezogen-102',
58
+ 'md5': '62e97961ce5343c19f0f330a1b6dd736',
57
59
  'info_dict': {
58
- 'id': 'video90088',
60
+ 'id': 'abgezogen-102',
59
61
  'ext': 'mp4',
60
- 'upload_date': '20221102',
61
- 'timestamp': 1667390580,
62
- 'duration': 197,
63
- 'modified_timestamp': 1711093771,
64
- 'episode_number': 8,
65
- 'title': 'Es ist nicht leicht, ein Astrobrot zu sein',
66
- 'modified_date': '20240322',
67
- 'description': 'md5:d3641deaf1b5515a160788b2be4159a9',
68
- 'season_number': 1,
69
- 'episode': 'Episode 8',
62
+ 'title': '1. Abgezogen',
63
+ 'description': 'md5:42d87963364391f9f8eba8affcb30bd2',
64
+ 'duration': 1574,
70
65
  'season': 'Season 1',
66
+ 'season_number': 1,
67
+ 'episode': 'Episode 1',
68
+ 'episode_number': 1,
69
+ 'timestamp': 1735382700,
70
+ 'upload_date': '20241228',
71
+ 'modified_timestamp': 1757344051,
72
+ 'modified_date': '20250908',
73
+ 'subtitles': 'count:0',
71
74
  },
72
75
  }]
73
76
 
@@ -78,16 +81,19 @@ class KikaIE(InfoExtractor):
78
81
  video_assets = self._download_json(doc['assets']['url'], video_id)
79
82
 
80
83
  subtitles = {}
81
- if ttml_resource := url_or_none(video_assets.get('videoSubtitle')):
82
- subtitles['de'] = [{
83
- 'url': ttml_resource,
84
- 'ext': 'ttml',
85
- }]
86
- if webvtt_resource := url_or_none(video_assets.get('webvttUrl')):
87
- subtitles.setdefault('de', []).append({
88
- 'url': webvtt_resource,
89
- 'ext': 'vtt',
90
- })
84
+ # Subtitle API endpoints may be present in the JSON even if there are no subtitles.
85
+ # They then return HTTP 200 with invalid data. So we must check explicitly.
86
+ if doc.get('hasSubtitle'):
87
+ if ttml_resource := url_or_none(video_assets.get('videoSubtitle')):
88
+ subtitles['de'] = [{
89
+ 'url': ttml_resource,
90
+ 'ext': 'ttml',
91
+ }]
92
+ if webvtt_resource := url_or_none(video_assets.get('webvttUrl')):
93
+ subtitles.setdefault('de', []).append({
94
+ 'url': webvtt_resource,
95
+ 'ext': 'vtt',
96
+ })
91
97
 
92
98
  return {
93
99
  'id': video_id,