diff --git a/yt_dlp/extractor/revry.py b/yt_dlp/extractor/revry.py index 29d693803..ea86f4b6c 100644 --- a/yt_dlp/extractor/revry.py +++ b/yt_dlp/extractor/revry.py @@ -3,9 +3,11 @@ from ..networking.exceptions import HTTPError from ..utils import ( ExtractorError, + int_or_none, random_uuidv4, smuggle_url, time_seconds, + traverse_obj, ) @@ -13,18 +15,28 @@ class RevryIE(InfoExtractor): _VALID_URL = r'https?://watch\.revry\.tv/player/(?P[0-9]+)' _GEO_COUNTRIES = ['US'] _TESTS = [{ - 'url': 'https://watch.revry.tv/player/43772/stream?assetType=episodes', + # Series test + 'url': 'https://watch.revry.tv/player/43767/stream?assetType=series', 'info_dict': { - 'id': '6368611770112', + 'id': '43767', + 'title': 'Unconventional', + 'description': 'Two eccentric queer siblings and their significant others try to start an unconventional family.', + }, + 'playlist_mincount': 9, + 'params': { + 'skip_download': True, + 'extract_flat': False, + }, + }, { + # Movie test + 'url': 'https://watch.revry.tv/player/43987/stream?assetType=movies', + 'info_dict': { + 'id': r're:\d+', 'ext': 'mp4', - 'title': 'Full Stop', - 'description': 'md5:4590409cef76b6500f96760c4b658aae', + 'title': 'Cowboys', + 'description': 'md5:b5c7d5f8a8a89f87e5adde6fb50c77c0', 'thumbnail': r're:^https?://.*\.jpg$', - 'timestamp': 1739235272, - 'upload_date': '20250211', 'uploader_id': '6122285389001', - 'duration': 1638.955, - 'tags': 'count:18', }, 'params': { 'skip_download': True, @@ -32,21 +44,22 @@ class RevryIE(InfoExtractor): }] _ACCOUNT_ID = '6122285389001' _AUTH_URL = 'https://beacon.playback.api.brightcove.com/revry/api/account/anonymous_login?device_type=web&duid={duid}' + _ASSET_URL = 'https://beacon.playback.api.brightcove.com/revry/api/assets/{asset_id}?device_type=web&device_layout=web&asset_id={asset_id}' _ASSET_INFO_URL = 'https://beacon.playback.api.brightcove.com/revry/api/account/{account_token}/asset_info/{video_id}?device_type=web&ngsw-bypass=1' + _EPISODES_URL = 'https://beacon.playback.api.brightcove.com/revry/api/tvshow/{series_id}/season/{season_id}/episodes?device_type=web&device_layout=web&layout_id=320&limit=1000' _BRIGHTCOVE_URL_TEMPLATE = 'https://players.brightcove.net/{account_id}/default_default/index.html?videoId={video_id}' _AUTH_CACHE_NAMESPACE = 'revry' - _AUTH_CACHE_KEY = 'auth_token' - _DEVICE_ID_CACHE_KEY = 'device_id' + _AUTH_CACHE_KEY = 'auth_data' def _get_auth_token(self): auth_data = self.cache.load(self._AUTH_CACHE_NAMESPACE, self._AUTH_CACHE_KEY) if auth_data and auth_data.get('expires_at', 0) > time_seconds(): return auth_data.get('auth_token'), auth_data.get('account_token') - device_id = self.cache.load(self._AUTH_CACHE_NAMESPACE, self._DEVICE_ID_CACHE_KEY) + # Generate or retrieve device ID + device_id = auth_data.get('device_id') if auth_data else None if not device_id: device_id = random_uuidv4() - self.cache.store(self._AUTH_CACHE_NAMESPACE, self._DEVICE_ID_CACHE_KEY, device_id) auth_response = self._download_json( self._AUTH_URL.format(duid=device_id), @@ -65,6 +78,7 @@ def _get_auth_token(self): expires_at = time_seconds(seconds=auth_response.get('expires_in', 604800)) # Default to 7 days auth_data = { + 'device_id': device_id, 'auth_token': auth_token, 'account_token': account_token, 'expires_at': expires_at, @@ -73,27 +87,31 @@ def _get_auth_token(self): return auth_token, account_token - def _real_extract(self, url): - video_id = self._match_id(url) - - auth_token, account_token = self._get_auth_token() - if not auth_token: - self.raise_login_required('Failed to get authentication token') + def _get_headers(self, auth_token): + return { + 'Authorization': f'Bearer {auth_token}', + 'Accept': 'application/json, text/plain, */*', + } + def _get_asset_data(self, asset_id, headers): try: - asset_info = self._download_json( - self._ASSET_INFO_URL.format(account_token=account_token, video_id=video_id), - video_id, 'Downloading asset info', - headers={ - 'Authorization': f'Bearer {auth_token}', - 'Accept': 'application/json, text/plain, */*', - }) + asset_data = self._download_json( + self._ASSET_URL.format(asset_id=asset_id), + asset_id, 'Downloading asset data', + headers=headers) + return traverse_obj(asset_data, ('data', 'asset'), default={}) except ExtractorError as e: if isinstance(e.cause, HTTPError) and e.cause.status == 404: self.raise_geo_restricted(countries=self._GEO_COUNTRIES) raise - video_playback_details = asset_info.get('data', {}).get('video_playback_details', []) + def _get_brightcove_url(self, brightcove_video_id): + return self._BRIGHTCOVE_URL_TEMPLATE.format( + account_id=self._ACCOUNT_ID, + video_id=brightcove_video_id) + + def _extract_video_id(self, asset_info): + video_playback_details = traverse_obj(asset_info, ('data', 'video_playback_details'), default=[]) if not video_playback_details: raise ExtractorError('No video playback details found', expected=True) @@ -101,10 +119,97 @@ def _real_extract(self, url): if not brightcove_video_id: raise ExtractorError('No Brightcove video ID found', expected=True) - brightcove_url = self._BRIGHTCOVE_URL_TEMPLATE.format( - account_id=self._ACCOUNT_ID, - video_id=brightcove_video_id) + return brightcove_video_id + + def _extract_series(self, asset_id, asset, url, headers, account_token): + entries = [] + seasons = traverse_obj(asset, 'seasons', default=[]) + if not seasons: + raise ExtractorError('No seasons found for series', expected=True) + + for season in seasons: + season_id = season.get('id') + if not season_id: + continue + + # Get episodes for this season + episodes_data = self._download_json( + self._EPISODES_URL.format(series_id=asset_id, season_id=season_id), + season_id, f'Downloading episodes for season {season.get("name", season_id)}', + headers=headers) + + episodes = traverse_obj(episodes_data, 'data', default=[]) + for episode in episodes: + episode_id = episode.get('id') + if not episode_id: + continue + + # Get video playback details for this episode + try: + episode_info = self._download_json( + self._ASSET_INFO_URL.format(account_token=account_token, video_id=episode_id), + episode_id, f'Downloading info for episode {episode.get("name", episode_id)}', + headers=headers) + except ExtractorError as e: + self.report_warning(f'Failed to get info for episode {episode_id}: {e}') + continue + + try: + brightcove_video_id = self._extract_video_id(episode_info) + except ExtractorError as e: + self.report_warning(f'{e.msg} for episode {episode_id}') + continue + + brightcove_url = self._get_brightcove_url(brightcove_video_id) + + entries.append({ + '_type': 'url_transparent', + 'url': smuggle_url(brightcove_url, {'referrer': url}), + 'ie_key': BrightcoveNewIE.ie_key(), + 'id': brightcove_video_id, + 'title': episode.get('name'), + 'description': episode.get('short_description'), + 'thumbnail': traverse_obj(episode, ('image', 'url')), + 'series': asset.get('name'), + 'season': season.get('name'), + 'season_number': int_or_none(episode.get('season_number')), + 'episode': episode.get('name'), + 'episode_number': int_or_none(episode.get('episode_number')), + 'duration': int_or_none(episode.get('length')), + }) + + return self.playlist_result(entries, asset_id, asset.get('name'), asset.get('short_description')) + + def _extract_movie(self, asset_id, url, headers, account_token): + try: + asset_info = self._download_json( + self._ASSET_INFO_URL.format(account_token=account_token, video_id=asset_id), + asset_id, 'Downloading asset info', + headers=headers) + except ExtractorError as e: + if isinstance(e.cause, HTTPError) and e.cause.status == 404: + self.raise_geo_restricted(countries=self._GEO_COUNTRIES) + raise + + brightcove_video_id = self._extract_video_id(asset_info) + brightcove_url = self._get_brightcove_url(brightcove_video_id) return self.url_result( smuggle_url(brightcove_url, {'referrer': url}), ie=BrightcoveNewIE.ie_key(), video_id=brightcove_video_id) + + def _real_extract(self, url): + asset_id = self._match_id(url) + + auth_token, account_token = self._get_auth_token() + if not auth_token: + self.raise_login_required('Failed to get authentication token') + + headers = self._get_headers(auth_token) + asset = self._get_asset_data(asset_id, headers) + asset_type = asset.get('type') + + if asset_type == 'series': + return self._extract_series(asset_id, asset, url, headers, account_token) + else: + return self._extract_movie(asset_id, url, headers, account_token)