diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py index 34c98b537..e4affc704 100644 --- a/yt_dlp/extractor/_extractors.py +++ b/yt_dlp/extractor/_extractors.py @@ -1971,6 +1971,7 @@ SproutVideoIE, VidsIoIE, ) +from .spwn import SPWNIE from .srgssr import ( SRGSSRIE, SRGSSRPlayIE, diff --git a/yt_dlp/extractor/spwn.py b/yt_dlp/extractor/spwn.py new file mode 100644 index 000000000..eab261025 --- /dev/null +++ b/yt_dlp/extractor/spwn.py @@ -0,0 +1,126 @@ +import json +import time + +from .wrestleuniverse import WrestleUniverseBaseIE +from ..utils import ( + ExtractorError, + clean_html, + parse_iso8601, + parse_qs, + update_url, + url_or_none, +) +from ..utils.traversal import require, traverse_obj + + +class SPWNIE(WrestleUniverseBaseIE): + _BASE_URL = 'https://spwn.jp' + _LOGIN_HEADERS = {'Content-Type': 'application/json'} + _LOGIN_HINT = ( + 'Use --username refresh --password , --username and --password, ' + '--netrc-cmd, or --netrc (spwn) to provide account credentials') + _LOGIN_QUERY = {'key': 'AIzaSyC-RDWv-QnYNWsJ6geLPpFYArlo2uPWCpA'} + _NETRC_MACHINE = 'spwn' + _VALID_URL = r'https?://spwn\.jp/events/(?P[^/?#]+)(/streaming)?' + _TESTS = [{ + 'url': 'https://spwn.jp/events/evt_wG62Svb4HkCQkK3QwDqI/streaming?vid=ki6OfjHeVlDGVKtEJwe4', + 'info_dict': { + 'id': 'ki6OfjHeVlDGVKtEJwe4', + 'ext': 'mp4', + 'title': '本編', + 'categories': 'count:1', + 'description': 'md5:7b6ef6bc591dc3e8641acf224a2345fe', + 'release_date': '20250606', + 'release_timestamp': 1749200400, + 'series': '劇場版「ポールプリンセス!!」【劇場版アニメ本編配信】', + 'series_id': 'evt_wG62Svb4HkCQkK3QwDqI', + 'tags': 'count:2', + 'thumbnail': r're:https?://public-web\.spwn\.jp/events/.+', + 'timestamp': 1749200400, + 'upload_date': '20250606', + }, + 'skip': 'Paid only', + }, { + 'url': 'https://spwn.jp/events/evt_wG62Svb4HkCQkK3QwDqI', + 'info_dict': { + 'id': 'evt_wG62Svb4HkCQkK3QwDqI', + 'title': '劇場版「ポールプリンセス!!」【劇場版アニメ本編配信】', + }, + 'playlist_count': 2, + 'skip': 'Paid only', + }] + + @WrestleUniverseBaseIE._TOKEN.getter + def _TOKEN(self): + if not self._REAL_TOKEN or self._TOKEN_EXPIRY <= int(time.time()): + if not self._REFRESH_TOKEN: + self.raise_login_required( + f'No refreshToken provided. {self._LOGIN_HINT}', method=None) + self._refresh_token() + return self._REAL_TOKEN + + def _perform_login(self, username, password): + if username.lower() == 'refresh': + self._REFRESH_TOKEN = password + return self._refresh_token() + return super()._perform_login(username, password) + + def _call_api(self, event_id, note, url=None, path=None): + if not url: + url = f'https://firestore.googleapis.com/v1/projects/spwn-balus/databases/(default)/documents/{path}' + + return traverse_obj(self._download_json( + url, event_id, f'Downloading {note}', fatal=False, + ), ('fields', {dict}), default={}) + + def _real_extract(self, url): + event_id = self._match_id(url) + event = self._call_api(event_id, 'event info', path=f'events/{event_id}') + info = self._download_json( + 'https://us-central1-spwn-balus.cloudfunctions.net/getStreamingKey/', + event_id, headers={ + 'Authorization': f'Bearer {self._TOKEN}', + 'Content-Type': 'application/json', + 'Origin': self._BASE_URL, + }, data=json.dumps({'eid': event_id}).encode()) + if traverse_obj(info, ('isError', {bool})): + msg = traverse_obj(info, ('msg', {str}, filter)) + raise ExtractorError( + msg or 'API returned an error response', expected=bool(msg)) + + video_id = traverse_obj(parse_qs(url), ('vid', 0, {str})) + if not video_id: + return self.playlist_result([self.url_result( + f'{self._BASE_URL}/events/{event_id}/streaming?vid={vid}', SPWNIE, + ) for vid in traverse_obj(info, ('videoIds', ..., {str}, filter))], + event_id, traverse_obj(event, ('title', 'stringValue', {str}))) + + video = self._call_api(event_id, 'video info', path=f'streaming/{event_id}/videos/{video_id}') + policy, signature, m3u8_url = traverse_obj(info, ('cookies', video_id, 'default', ( + ('cookie', 'CloudFront-Policy', {str}, {require('CloudFront policy')}), + ('cookie', 'CloudFront-Signature', {str}, {require('CloudFront signature')}), + ('url', {url_or_none}, {require('manifest URL')}), + ))) + + return { + 'id': video_id, + 'title': traverse_obj(video, ('name', 'stringValue', {str})), + 'formats': self._extract_m3u8_formats( + update_url(m3u8_url, netloc='vod.spwnlive.net'), video_id, + 'mp4', query={'policy': policy, 'signature': signature}), + 'series_id': event_id, + **traverse_obj(event, { + 'cast': ('artistRefs', 'arrayValue', 'values', ..., 'referenceValue', + {lambda x: self._call_api(event_id, 'artist info', url=f'{self._API_BASE}/{x}')}, + 'name', 'stringValue', {str}, filter, all, filter), + 'categories': ('categories', 'arrayValue', 'values', ..., 'stringValue', {str}, filter, all, filter), + 'description': ('description', 'stringValue', {clean_html}, {clean_html}, filter), + 'series': ('title', 'stringValue', {str}), + 'tags': ('twitterHashTag', 'arrayValue', 'values', ..., 'stringValue', {str}, filter, all, filter), + 'thumbnail': ('defaultImg', 'stringValue', {url_or_none}), + }), + **traverse_obj(event, ('parts', 'arrayValue', 'values', ..., 'mapValue', 'fields', { + 'release_timestamp': ('openTime', 'timestampValue', {parse_iso8601}), + 'timestamp': ('startTime', 'timestampValue', {parse_iso8601}), + }, any)), + }