diff --git a/README.md b/README.md index 634cff5a52..6e27b0a348 100644 --- a/README.md +++ b/README.md @@ -1769,7 +1769,7 @@ The following extractors use this feature: #### youtube * `lang`: Prefer translated metadata (`title`, `description` etc) of this language code (case-sensitive). By default, the video primary language metadata is preferred, with a fallback to `en` translated. See [youtube.py](https://github.com/yt-dlp/yt-dlp/blob/c26f9b991a0681fd3ea548d535919cec1fbbd430/yt_dlp/extractor/youtube.py#L381-L390) for list of supported content language codes * `skip`: One or more of `hls`, `dash` or `translated_subs` to skip extraction of the m3u8 manifests, dash manifests and [auto-translated subtitles](https://github.com/yt-dlp/yt-dlp/issues/4090#issuecomment-1158102032) respectively -* `player_client`: Clients to extract video data from. The main clients are `web`, `ios` and `android`, with variants `_music` and `_creator` (e.g. `ios_creator`); and `mweb`, `android_vr`, `web_safari`, `web_embedded`, `tv` and `tv_embedded` with no variants. By default, `tv,ios,web` is used, or `tv,web` is used when authenticating with cookies. The `web_music` client is added for `music.youtube.com` URLs when logged-in cookies are used. The `tv_embedded` and `web_creator` clients are added for age-restricted videos if account age-verification is required. Some clients, such as `web` and `web_music`, require a `po_token` for their formats to be downloadable. Some clients, such as the `_creator` variants, will only work with authentication. Not all clients support authentication via cookies. You can use `default` for the default clients, or you can use `all` for all clients (not recommended). You can prefix a client with `-` to exclude it, e.g. `youtube:player_client=default,-ios` +* `player_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_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) diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py index 3ab0f5efa2..74a043b9c8 100644 --- a/yt_dlp/extractor/_extractors.py +++ b/yt_dlp/extractor/_extractors.py @@ -336,6 +336,7 @@ from .canal1 import Canal1IE from .canalalpha import CanalAlphaIE from .canalc2 import Canalc2IE from .canalplus import CanalplusIE +from .canalsurmas import CanalsurmasIE from .caracoltv import CaracolTvPlayIE from .cartoonnetwork import CartoonNetworkIE from .cbc import ( @@ -1882,6 +1883,8 @@ from .skyit import ( SkyItVideoIE, SkyItVideoLiveIE, TV8ItIE, + TV8ItLiveIE, + TV8ItPlaylistIE, ) from .skylinewebcams import SkylineWebcamsIE from .skynewsarabia import ( diff --git a/yt_dlp/extractor/canalsurmas.py b/yt_dlp/extractor/canalsurmas.py new file mode 100644 index 0000000000..210973a0b8 --- /dev/null +++ b/yt_dlp/extractor/canalsurmas.py @@ -0,0 +1,84 @@ +import json +import time + +from .common import InfoExtractor +from ..utils import ( + determine_ext, + float_or_none, + jwt_decode_hs256, + parse_iso8601, + url_or_none, + variadic, +) +from ..utils.traversal import traverse_obj + + +class CanalsurmasIE(InfoExtractor): + _VALID_URL = r'https?://(?:www\.)?canalsurmas\.es/videos/(?P\d+)' + _TESTS = [{ + 'url': 'https://www.canalsurmas.es/videos/44006-el-gran-queo-1-lora-del-rio-sevilla-20072014', + 'md5': '861f86fdc1221175e15523047d0087ef', + 'info_dict': { + 'id': '44006', + 'ext': 'mp4', + 'title': 'Lora del Río (Sevilla)', + 'description': 'md5:3d9ee40a9b1b26ed8259e6b71ed27b8b', + 'thumbnail': 'https://cdn2.rtva.interactvty.com/content_cards/00f3e8f67b0a4f3b90a4a14618a48b0d.jpg', + 'timestamp': 1648123182, + 'upload_date': '20220324', + }, + }] + _API_BASE = 'https://api-rtva.interactvty.com' + _access_token = None + + @staticmethod + def _is_jwt_expired(token): + return jwt_decode_hs256(token)['exp'] - time.time() < 300 + + def _call_api(self, endpoint, video_id, fields=None): + if not self._access_token or self._is_jwt_expired(self._access_token): + self._access_token = self._download_json( + f'{self._API_BASE}/jwt/token/', None, + 'Downloading access token', 'Failed to download access token', + headers={'Content-Type': 'application/json'}, + data=json.dumps({ + 'username': 'canalsur_demo', + 'password': 'dsUBXUcI', + }).encode())['access'] + + return self._download_json( + f'{self._API_BASE}/api/2.0/contents/{endpoint}/{video_id}/', video_id, + f'Downloading {endpoint} API JSON', f'Failed to download {endpoint} API JSON', + headers={'Authorization': f'jwtok {self._access_token}'}, + query={'optional_fields': ','.join(variadic(fields))} if fields else None) + + def _real_extract(self, url): + video_id = self._match_id(url) + video_info = self._call_api('content', video_id, fields=[ + 'description', 'image', 'duration', 'created_at', 'tags', + ]) + stream_info = self._call_api('content_resources', video_id, 'media_url') + + formats, subtitles = [], {} + for stream_url in traverse_obj(stream_info, ('results', ..., 'media_url', {url_or_none})): + if determine_ext(stream_url) == 'm3u8': + fmts, subs = self._extract_m3u8_formats_and_subtitles( + stream_url, video_id, m3u8_id='hls', fatal=False) + formats.extend(fmts) + self._merge_subtitles(subs, target=subtitles) + else: + formats.append({'url': stream_url}) + + return { + 'id': video_id, + 'formats': formats, + 'subtitles': subtitles, + **traverse_obj(video_info, { + 'title': ('name', {str.strip}), + 'description': ('description', {str}), + 'thumbnail': ('image', {url_or_none}), + 'duration': ('duration', {float_or_none}), + 'timestamp': ('created_at', {parse_iso8601}), + 'tags': ('tags', ..., {str}), + }), + } diff --git a/yt_dlp/extractor/cda.py b/yt_dlp/extractor/cda.py index b2738e492f..96f25c22a8 100644 --- a/yt_dlp/extractor/cda.py +++ b/yt_dlp/extractor/cda.py @@ -121,10 +121,7 @@ class CDAIE(InfoExtractor): }, **kwargs) def _perform_login(self, username, password): - app_version = random.choice(( - '1.2.88 build 15306', - '1.2.174 build 18469', - )) + app_version = '1.2.255 build 21541' android_version = random.randrange(8, 14) phone_model = random.choice(( # x-kom.pl top selling Android smartphones, as of 2022-12-26 @@ -190,7 +187,7 @@ class CDAIE(InfoExtractor): meta = self._download_json( f'{self._BASE_API_URL}/video/{video_id}', video_id, headers=self._API_HEADERS)['video'] - uploader = traverse_obj(meta, 'author', 'login') + uploader = traverse_obj(meta, ('author', 'login', {str})) formats = [{ 'url': quality['file'], diff --git a/yt_dlp/extractor/gigya.py b/yt_dlp/extractor/gigya.py deleted file mode 100644 index cc18ee67c2..0000000000 --- a/yt_dlp/extractor/gigya.py +++ /dev/null @@ -1,19 +0,0 @@ -from .common import InfoExtractor -from ..utils import ( - ExtractorError, - urlencode_postdata, -) - - -class GigyaBaseIE(InfoExtractor): - def _gigya_login(self, auth_data): - auth_info = self._download_json( - 'https://accounts.eu1.gigya.com/accounts.login', None, - note='Logging in', errnote='Unable to log in', - data=urlencode_postdata(auth_data)) - - error_message = auth_info.get('errorDetails') or auth_info.get('errorMessage') - if error_message: - raise ExtractorError( - f'Unable to login: {error_message}', expected=True) - return auth_info diff --git a/yt_dlp/extractor/msn.py b/yt_dlp/extractor/msn.py index dd864952c4..6ede7c5cfc 100644 --- a/yt_dlp/extractor/msn.py +++ b/yt_dlp/extractor/msn.py @@ -1,167 +1,215 @@ -import re - from .common import InfoExtractor from ..utils import ( ExtractorError, + clean_html, determine_ext, int_or_none, - unescapeHTML, + parse_iso8601, + url_or_none, ) +from ..utils.traversal import traverse_obj class MSNIE(InfoExtractor): - _WORKING = False - _VALID_URL = r'https?://(?:(?:www|preview)\.)?msn\.com/(?:[^/]+/)+(?P[^/]+)/[a-z]{2}-(?P[\da-zA-Z]+)' + _VALID_URL = r'https?://(?:(?:www|preview)\.)?msn\.com/(?P[a-z]{2}-[a-z]{2})/(?:[^/?#]+/)+(?P[^/?#]+)/[a-z]{2}-(?P[\da-zA-Z]+)' _TESTS = [{ - 'url': 'https://www.msn.com/en-in/money/video/7-ways-to-get-rid-of-chest-congestion/vi-BBPxU6d', - 'md5': '087548191d273c5c55d05028f8d2cbcd', + 'url': 'https://www.msn.com/en-gb/video/news/president-macron-interrupts-trump-over-ukraine-funding/vi-AA1zMcD7', 'info_dict': { - 'id': 'BBPxU6d', - 'display_id': '7-ways-to-get-rid-of-chest-congestion', + 'id': 'AA1zMcD7', 'ext': 'mp4', - 'title': 'Seven ways to get rid of chest congestion', - 'description': '7 Ways to Get Rid of Chest Congestion', - 'duration': 88, - 'uploader': 'Health', - 'uploader_id': 'BBPrMqa', + 'display_id': 'president-macron-interrupts-trump-over-ukraine-funding', + 'title': 'President Macron interrupts Trump over Ukraine funding', + 'description': 'md5:5fd3857ac25849e7a56cb25fbe1a2a8b', + 'uploader': 'k! News UK', + 'uploader_id': 'BB1hz5Rj', + 'duration': 59, + 'thumbnail': 'https://img-s-msn-com.akamaized.net/tenant/amp/entityid/AA1zMagX.img', + 'tags': 'count:14', + 'timestamp': 1740510914, + 'upload_date': '20250225', + 'release_timestamp': 1740513600, + 'release_date': '20250225', + 'modified_timestamp': 1741413241, + 'modified_date': '20250308', }, }, { - # Article, multiple Dailymotion Embeds - 'url': 'https://www.msn.com/en-in/money/sports/hottest-football-wags-greatest-footballers-turned-managers-and-more/ar-BBpc7Nl', + 'url': 'https://www.msn.com/en-gb/video/watch/films-success-saved-adam-pearsons-acting-career/vi-AA1znZGE?ocid=hpmsn', 'info_dict': { - 'id': 'BBpc7Nl', + 'id': 'AA1znZGE', + 'ext': 'mp4', + 'display_id': 'films-success-saved-adam-pearsons-acting-career', + 'title': "Films' success saved Adam Pearson's acting career", + 'description': 'md5:98c05f7bd9ab4f9c423400f62f2d3da5', + 'uploader': 'Sky News', + 'uploader_id': 'AA2eki', + 'duration': 52, + 'thumbnail': 'https://img-s-msn-com.akamaized.net/tenant/amp/entityid/AA1zo7nU.img', + 'timestamp': 1739993965, + 'upload_date': '20250219', + 'release_timestamp': 1739977753, + 'release_date': '20250219', + 'modified_timestamp': 1742076259, + 'modified_date': '20250315', }, - 'playlist_mincount': 4, - }, { - 'url': 'http://www.msn.com/en-ae/news/offbeat/meet-the-nine-year-old-self-made-millionaire/ar-BBt6ZKf', - 'only_matching': True, - }, { - 'url': 'http://www.msn.com/en-ae/video/watch/obama-a-lot-of-people-will-be-disappointed/vi-AAhxUMH', - 'only_matching': True, - }, { - # geo restricted - 'url': 'http://www.msn.com/en-ae/foodanddrink/joinourtable/the-first-fart-makes-you-laugh-the-last-fart-makes-you-cry/vp-AAhzIBU', - 'only_matching': True, - }, { - 'url': 'http://www.msn.com/en-ae/entertainment/bollywood/watch-how-salman-khan-reacted-when-asked-if-he-would-apologize-for-his-‘raped-woman’-comment/vi-AAhvzW6', - 'only_matching': True, }, { - # Vidible(AOL) Embed - 'url': 'https://www.msn.com/en-us/money/other/jupiter-is-about-to-come-so-close-you-can-see-its-moons-with-binoculars/vi-AACqsHR', - 'only_matching': True, + 'url': 'https://www.msn.com/en-us/entertainment/news/rock-frontman-replacements-you-might-not-know-happened/vi-AA1yLVcD', + 'info_dict': { + 'id': 'AA1yLVcD', + 'ext': 'mp4', + 'display_id': 'rock-frontman-replacements-you-might-not-know-happened', + 'title': 'Rock Frontman Replacements You Might Not Know Happened', + 'description': 'md5:451a125496ff0c9f6816055bb1808da9', + 'uploader': 'Grunge (Video)', + 'uploader_id': 'BB1oveoV', + 'duration': 596, + 'thumbnail': 'https://img-s-msn-com.akamaized.net/tenant/amp/entityid/AA1yM4OJ.img', + 'timestamp': 1739223456, + 'upload_date': '20250210', + 'release_timestamp': 1739219731, + 'release_date': '20250210', + 'modified_timestamp': 1741427272, + 'modified_date': '20250308', + }, }, { # Dailymotion Embed - 'url': 'https://www.msn.com/es-ve/entretenimiento/watch/winston-salem-paire-refait-des-siennes-en-perdant-sa-raquette-au-service/vp-AAG704L', - 'only_matching': True, + 'url': 'https://www.msn.com/de-de/nachrichten/other/the-first-descendant-gameplay-trailer-zu-serena-der-neuen-gefl%C3%BCgelten-nachfahrin/vi-AA1B1d06', + 'info_dict': { + 'id': 'x9g6oli', + 'ext': 'mp4', + 'title': 'The First Descendant: Gameplay-Trailer zu Serena, der neuen geflügelten Nachfahrin', + 'description': '', + 'uploader': 'MeinMMO', + 'uploader_id': 'x2mvqi4', + 'view_count': int, + 'like_count': int, + 'age_limit': 0, + 'duration': 60, + 'thumbnail': 'https://s1.dmcdn.net/v/Y3fO61drj56vPB9SS/x1080', + 'tags': ['MeinMMO', 'The First Descendant'], + 'timestamp': 1742124877, + 'upload_date': '20250316', + }, }, { - # YouTube Embed - 'url': 'https://www.msn.com/en-in/money/news/meet-vikram-%E2%80%94-chandrayaan-2s-lander/vi-AAGUr0v', - 'only_matching': True, + # Youtube Embed + 'url': 'https://www.msn.com/en-gb/video/webcontent/web-content/vi-AA1ybFaJ', + 'info_dict': { + 'id': 'kQSChWu95nE', + 'ext': 'mp4', + 'title': '7 Daily Habits to Nurture Your Personal Growth', + 'description': 'md5:6f233c68341b74dee30c8c121924e827', + 'uploader': 'TopThink', + 'uploader_id': '@TopThink', + 'uploader_url': 'https://www.youtube.com/@TopThink', + 'channel': 'TopThink', + 'channel_id': 'UCMlGmHokrQRp-RaNO7aq4Uw', + 'channel_url': 'https://www.youtube.com/channel/UCMlGmHokrQRp-RaNO7aq4Uw', + 'channel_is_verified': True, + 'channel_follower_count': int, + 'comment_count': int, + 'view_count': int, + 'like_count': int, + 'age_limit': 0, + 'duration': 705, + 'thumbnail': 'https://i.ytimg.com/vi/kQSChWu95nE/maxresdefault.jpg', + 'categories': ['Howto & Style'], + 'tags': ['topthink', 'top think', 'personal growth'], + 'timestamp': 1722711620, + 'upload_date': '20240803', + 'playable_in_embed': True, + 'availability': 'public', + 'live_status': 'not_live', + }, }, { - # NBCSports Embed - 'url': 'https://www.msn.com/en-us/money/football_nfl/week-13-preview-redskins-vs-panthers/vi-BBXsCDb', - 'only_matching': True, + # Article with social embed + 'url': 'https://www.msn.com/en-in/news/techandscience/watch-earth-sets-and-rises-behind-moon-in-breathtaking-blue-ghost-video/ar-AA1zKoAc', + 'info_dict': { + 'id': 'AA1zKoAc', + 'title': 'Watch: Earth sets and rises behind Moon in breathtaking Blue Ghost video', + 'description': 'md5:0ad51cfa77e42e7f0c46cf98a619dbbf', + 'uploader': 'India Today', + 'uploader_id': 'AAyFWG', + 'tags': 'count:11', + 'timestamp': 1740485034, + 'upload_date': '20250225', + 'release_timestamp': 1740484875, + 'release_date': '20250225', + 'modified_timestamp': 1740488561, + 'modified_date': '20250225', + }, + 'playlist_count': 1, }] def _real_extract(self, url): - display_id, page_id = self._match_valid_url(url).groups() - - webpage = self._download_webpage(url, display_id) + locale, display_id, page_id = self._match_valid_url(url).group('locale', 'display_id', 'id') - entries = [] - for _, metadata in re.findall(r'data-metadata\s*=\s*(["\'])(?P.+?)\1', webpage): - video = self._parse_json(unescapeHTML(metadata), display_id) + json_data = self._download_json( + f'https://assets.msn.com/content/view/v2/Detail/{locale}/{page_id}', page_id) - provider_id = video.get('providerId') - player_name = video.get('playerName') - if player_name and provider_id: - entry = None - if player_name == 'AOL': - if provider_id.startswith('http'): - provider_id = self._search_regex( - r'https?://delivery\.vidible\.tv/video/redirect/([0-9a-f]{24})', - provider_id, 'vidible id') - entry = self.url_result( - 'aol-video:' + provider_id, 'Aol', provider_id) - elif player_name == 'Dailymotion': - entry = self.url_result( - 'https://www.dailymotion.com/video/' + provider_id, - 'Dailymotion', provider_id) - elif player_name == 'YouTube': - entry = self.url_result( - provider_id, 'Youtube', provider_id) - elif player_name == 'NBCSports': - entry = self.url_result( - 'http://vplayer.nbcsports.com/p/BxmELC/nbcsports_embed/select/media/' + provider_id, - 'NBCSportsVPlayer', provider_id) - if entry: - entries.append(entry) - continue - - video_id = video['uuid'] - title = video['title'] + common_metadata = traverse_obj(json_data, { + 'title': ('title', {str}), + 'description': (('abstract', ('body', {clean_html})), {str}, filter, any), + 'timestamp': ('createdDateTime', {parse_iso8601}), + 'release_timestamp': ('publishedDateTime', {parse_iso8601}), + 'modified_timestamp': ('updatedDateTime', {parse_iso8601}), + 'thumbnail': ('thumbnail', 'image', 'url', {url_or_none}), + 'duration': ('videoMetadata', 'playTime', {int_or_none}), + 'tags': ('keywords', ..., {str}), + 'uploader': ('provider', 'name', {str}), + 'uploader_id': ('provider', 'id', {str}), + }) + page_type = json_data['type'] + source_url = traverse_obj(json_data, ('sourceHref', {url_or_none})) + if page_type == 'video': + if traverse_obj(json_data, ('thirdPartyVideoPlayer', 'enabled')) and source_url: + return self.url_result(source_url) formats = [] - for file_ in video.get('videoFiles', []): - format_url = file_.get('url') - if not format_url: - continue - if 'format=m3u8-aapl' in format_url: - # m3u8_native should not be used here until - # https://github.com/ytdl-org/youtube-dl/issues/9913 is fixed - formats.extend(self._extract_m3u8_formats( - format_url, display_id, 'mp4', - m3u8_id='hls', fatal=False)) - elif 'format=mpd-time-csf' in format_url: - formats.extend(self._extract_mpd_formats( - format_url, display_id, 'dash', fatal=False)) - elif '.ism' in format_url: - if format_url.endswith('.ism'): - format_url += '/manifest' - formats.extend(self._extract_ism_formats( - format_url, display_id, 'mss', fatal=False)) - else: - format_id = file_.get('formatCode') - formats.append({ - 'url': format_url, - 'ext': 'mp4', - 'format_id': format_id, - 'width': int_or_none(file_.get('width')), - 'height': int_or_none(file_.get('height')), - 'vbr': int_or_none(self._search_regex(r'_(\d+)\.mp4', format_url, 'vbr', default=None)), - 'quality': 1 if format_id == '1001' else None, - }) - subtitles = {} - for file_ in video.get('files', []): - format_url = file_.get('url') - format_code = file_.get('formatCode') - if not format_url or not format_code: - continue - if str(format_code) == '3100': - subtitles.setdefault(file_.get('culture', 'en'), []).append({ - 'ext': determine_ext(format_url, 'ttml'), - 'url': format_url, - }) + for file in traverse_obj(json_data, ('videoMetadata', 'externalVideoFiles', lambda _, v: url_or_none(v['url']))): + file_url = file['url'] + ext = determine_ext(file_url) + if ext == 'm3u8': + fmts, subs = self._extract_m3u8_formats_and_subtitles( + file_url, page_id, 'mp4', m3u8_id='hls', fatal=False) + formats.extend(fmts) + self._merge_subtitles(subs, target=subtitles) + elif ext == 'mpd': + fmts, subs = self._extract_mpd_formats_and_subtitles( + file_url, page_id, mpd_id='dash', fatal=False) + formats.extend(fmts) + self._merge_subtitles(subs, target=subtitles) + else: + formats.append( + traverse_obj(file, { + 'url': 'url', + 'format_id': ('format', {str}), + 'filesize': ('fileSize', {int_or_none}), + 'height': ('height', {int_or_none}), + 'width': ('width', {int_or_none}), + })) + for caption in traverse_obj(json_data, ('videoMetadata', 'closedCaptions', lambda _, v: url_or_none(v['href']))): + lang = caption.get('locale') or 'en-us' + subtitles.setdefault(lang, []).append({ + 'url': caption['href'], + 'ext': 'ttml', + }) - entries.append({ - 'id': video_id, + return { + 'id': page_id, 'display_id': display_id, - 'title': title, - 'description': video.get('description'), - 'thumbnail': video.get('headlineImage', {}).get('url'), - 'duration': int_or_none(video.get('durationSecs')), - 'uploader': video.get('sourceFriendly'), - 'uploader_id': video.get('providerId'), - 'creator': video.get('creator'), - 'subtitles': subtitles, 'formats': formats, - }) + 'subtitles': subtitles, + **common_metadata, + } + elif page_type == 'webcontent': + if not source_url: + raise ExtractorError('Could not find source URL') + return self.url_result(source_url) + elif page_type == 'article': + entries = [] + for embed_url in traverse_obj(json_data, ('socialEmbeds', ..., 'postUrl', {url_or_none})): + entries.append(self.url_result(embed_url)) - if not entries: - error = unescapeHTML(self._search_regex( - r'data-error=(["\'])(?P.+?)\1', - webpage, 'error', group='error')) - raise ExtractorError(f'{self.IE_NAME} said: {error}', expected=True) + return self.playlist_result(entries, page_id, **common_metadata) - return self.playlist_result(entries, page_id) + raise ExtractorError(f'Unsupported page type: {page_type}') diff --git a/yt_dlp/extractor/nbc.py b/yt_dlp/extractor/nbc.py index 8f6fb22b17..d9aded09ea 100644 --- a/yt_dlp/extractor/nbc.py +++ b/yt_dlp/extractor/nbc.py @@ -736,7 +736,7 @@ class NBCStationsIE(InfoExtractor): webpage = self._download_webpage(url, video_id) nbc_data = self._search_json( - r'