]>
Commit | Line | Data |
---|---|---|
f0a1ff11 | 1 | import json |
2 | import time | |
3 | ||
4 | from .common import InfoExtractor | |
5 | from ..utils import ( | |
6 | ExtractorError, | |
7 | int_or_none, | |
8 | jwt_decode_hs256, | |
9 | str_or_none, | |
10 | traverse_obj, | |
11 | try_call, | |
12 | url_or_none, | |
13 | ) | |
14 | ||
15 | ||
16 | class QDanceIE(InfoExtractor): | |
17 | _NETRC_MACHINE = 'qdance' | |
18 | _VALID_URL = r'https?://(?:www\.)?q-dance\.com/network/(?:library|live)/(?P<id>\d+)' | |
19 | _TESTS = [{ | |
20 | 'note': 'vod', | |
21 | 'url': 'https://www.q-dance.com/network/library/146542138', | |
22 | 'info_dict': { | |
23 | 'id': '146542138', | |
24 | 'ext': 'mp4', | |
25 | 'title': 'Sound Rush [LIVE] | Defqon.1 Weekend Festival 2022 | Friday | RED', | |
26 | 'display_id': 'sound-rush-live-v3-defqon-1-weekend-festival-2022-friday-red', | |
27 | 'description': 'Relive Defqon.1 - Primal Energy 2022 with the sounds of Sound Rush LIVE at the RED on Friday! 🔥', | |
28 | 'season': 'Defqon.1 Weekend Festival 2022', | |
29 | 'season_id': '31840632', | |
30 | 'series': 'Defqon.1', | |
31 | 'series_id': '31840378', | |
32 | 'thumbnail': 'https://images.q-dance.network/1674829540-20220624171509-220624171509_delio_dn201093-2.jpg', | |
33 | 'availability': 'premium_only', | |
34 | 'duration': 1829, | |
35 | }, | |
36 | 'params': {'skip_download': 'm3u8'}, | |
37 | }, { | |
38 | 'note': 'livestream', | |
39 | 'url': 'https://www.q-dance.com/network/live/149170353', | |
40 | 'info_dict': { | |
41 | 'id': '149170353', | |
42 | 'ext': 'mp4', | |
43 | 'title': r're:^Defqon\.1 2023 - Friday - RED', | |
44 | 'display_id': 'defqon-1-2023-friday-red', | |
45 | 'description': 'md5:3c73fbbd4044e578e696adfc64019163', | |
46 | 'season': 'Defqon.1 Weekend Festival 2023', | |
47 | 'season_id': '141735599', | |
48 | 'series': 'Defqon.1', | |
49 | 'series_id': '31840378', | |
50 | 'thumbnail': 'https://images.q-dance.network/1686849069-area-thumbs_red.png', | |
51 | 'availability': 'subscriber_only', | |
52 | 'live_status': 'is_live', | |
53 | 'channel_id': 'qdancenetwork.video_149170353', | |
54 | }, | |
55 | 'skip': 'Completed livestream', | |
56 | }] | |
57 | ||
58 | _access_token = None | |
59 | _refresh_token = None | |
60 | ||
61 | def _call_login_api(self, data, note='Logging in'): | |
62 | login = self._download_json( | |
63 | 'https://members.id-t.com/api/auth/login', None, note, headers={ | |
64 | 'content-type': 'application/json', | |
65 | 'brand': 'qdance', | |
66 | 'origin': 'https://www.q-dance.com', | |
67 | 'referer': 'https://www.q-dance.com/', | |
68 | }, data=json.dumps(data, separators=(',', ':')).encode(), | |
69 | expected_status=lambda x: True) | |
70 | ||
71 | tokens = traverse_obj(login, ('data', { | |
72 | '_id-t-accounts-token': ('accessToken', {str}), | |
73 | '_id-t-accounts-refresh': ('refreshToken', {str}), | |
74 | '_id-t-accounts-id-token': ('idToken', {str}), | |
75 | })) | |
76 | ||
77 | if not tokens.get('_id-t-accounts-token'): | |
78 | error = ': '.join(traverse_obj(login, ('error', ('code', 'message'), {str}))) | |
79 | if 'validation_error' not in error: | |
80 | raise ExtractorError(f'Q-Dance API said "{error}"') | |
81 | msg = 'Invalid username or password' if 'email' in data else 'Refresh token has expired' | |
82 | raise ExtractorError(msg, expected=True) | |
83 | ||
84 | for name, value in tokens.items(): | |
85 | self._set_cookie('.q-dance.com', name, value) | |
86 | ||
87 | def _perform_login(self, username, password): | |
88 | self._call_login_api({'email': username, 'password': password}) | |
89 | ||
90 | def _real_initialize(self): | |
91 | cookies = self._get_cookies('https://www.q-dance.com/') | |
92 | self._refresh_token = try_call(lambda: cookies['_id-t-accounts-refresh'].value) | |
93 | self._access_token = try_call(lambda: cookies['_id-t-accounts-token'].value) | |
94 | if not self._access_token: | |
95 | self.raise_login_required() | |
96 | ||
97 | def _get_auth(self): | |
98 | if (try_call(lambda: jwt_decode_hs256(self._access_token)['exp']) or 0) <= int(time.time() - 120): | |
99 | if not self._refresh_token: | |
100 | raise ExtractorError( | |
101 | 'Cannot refresh access token, login with yt-dlp or refresh cookies in browser') | |
102 | self._call_login_api({'refreshToken': self._refresh_token}, note='Refreshing access token') | |
103 | self._real_initialize() | |
104 | ||
105 | return {'Authorization': self._access_token} | |
106 | ||
107 | def _real_extract(self, url): | |
108 | video_id = self._match_id(url) | |
109 | webpage = self._download_webpage(url, video_id) | |
110 | data = self._search_nuxt_data(webpage, video_id, traverse=('data', 0, 'data')) | |
111 | ||
112 | def extract_availability(level): | |
113 | level = int_or_none(level) or 0 | |
114 | return self._availability( | |
115 | needs_premium=(level >= 20), needs_subscription=(level >= 15), needs_auth=True) | |
116 | ||
117 | info = traverse_obj(data, { | |
118 | 'title': ('title', {str.strip}), | |
119 | 'description': ('description', {str.strip}), | |
120 | 'display_id': ('slug', {str}), | |
121 | 'thumbnail': ('thumbnail', {url_or_none}), | |
122 | 'duration': ('durationInSeconds', {int_or_none}, {lambda x: x or None}), | |
123 | 'availability': ('subscription', 'level', {extract_availability}), | |
124 | 'is_live': ('type', {lambda x: x.lower() == 'live'}), | |
125 | 'artist': ('acts', ..., {str}), | |
126 | 'series': ('event', 'title', {str.strip}), | |
127 | 'series_id': ('event', 'id', {str_or_none}), | |
128 | 'season': ('eventEdition', 'title', {str.strip}), | |
129 | 'season_id': ('eventEdition', 'id', {str_or_none}), | |
130 | 'channel_id': ('pubnub', 'channelName', {str}), | |
131 | }) | |
132 | ||
133 | stream = self._download_json( | |
134 | f'https://dc9h6qmsoymbq.cloudfront.net/api/content/videos/{video_id}/url', | |
135 | video_id, headers=self._get_auth(), expected_status=401) | |
136 | ||
137 | m3u8_url = traverse_obj(stream, ('data', 'url', {url_or_none})) | |
138 | if not m3u8_url and traverse_obj(stream, ('error', 'code')) == 'unauthorized': | |
139 | raise ExtractorError('Your account does not have access to this content', expected=True) | |
140 | ||
141 | formats = self._extract_m3u8_formats( | |
142 | m3u8_url, video_id, fatal=False, live=True) if m3u8_url else [] | |
143 | if not formats: | |
144 | self.raise_no_formats('No active streams found', expected=bool(info.get('is_live'))) | |
145 | ||
146 | return { | |
147 | **info, | |
148 | 'id': video_id, | |
149 | 'formats': formats, | |
150 | } |