]>
Commit | Line | Data |
---|---|---|
1a83c731 | 1 | import hashlib |
9338a0ea | 2 | import hmac |
9338a0ea | 3 | import json |
9338a0ea | 4 | import time |
382ed50e | 5 | |
5c2266df | 6 | from .common import InfoExtractor |
382ed50e | 7 | from ..utils import ( |
6d88bc37 | 8 | ExtractorError, |
1a83c731 S |
9 | int_or_none, |
10 | parse_age_limit, | |
11 | parse_iso8601, | |
bc2ca1bb | 12 | try_get, |
382ed50e | 13 | ) |
382ed50e PH |
14 | |
15 | ||
1a83c731 | 16 | class VikiBaseIE(InfoExtractor): |
53de95da | 17 | _VALID_URL_BASE = r'https?://(?:www\.)?viki\.(?:com|net|mx|jp|fr)/' |
73d829c1 | 18 | _API_URL_TEMPLATE = 'https://api.viki.io%s' |
1a83c731 | 19 | |
1c6f4801 | 20 | _DEVICE_ID = '112395910d' |
8251af63 | 21 | _APP = '100005a' |
73d829c1 | 22 | _APP_VERSION = '6.11.3' |
23 | _APP_SECRET = 'd96704b180208dbb2efa30fe44c48bd8690441af9f567ba8fd710a72badc85198f7472' | |
1a83c731 | 24 | |
4248dad9 | 25 | _GEO_BYPASS = False |
accf79b1 S |
26 | _NETRC_MACHINE = 'viki' |
27 | ||
16d6973f S |
28 | _token = None |
29 | ||
dc016bf5 | 30 | _ERRORS = { |
31 | 'geo': 'Sorry, this content is not available in your region.', | |
32 | 'upcoming': 'Sorry, this content is not yet available.', | |
bc2ca1bb | 33 | 'paywall': 'Sorry, this content is only available to Viki Pass Plus subscribers', |
dc016bf5 | 34 | } |
35 | ||
73d829c1 | 36 | def _stream_headers(self, timestamp, sig): |
37 | return { | |
38 | 'X-Viki-manufacturer': 'vivo', | |
39 | 'X-Viki-device-model': 'vivo 1606', | |
40 | 'X-Viki-device-os-ver': '6.0.1', | |
41 | 'X-Viki-connection-type': 'WIFI', | |
42 | 'X-Viki-carrier': '', | |
43 | 'X-Viki-as-id': '100005a-1625321982-3932', | |
44 | 'timestamp': str(timestamp), | |
45 | 'signature': str(sig), | |
46 | 'x-viki-app-ver': self._APP_VERSION | |
47 | } | |
48 | ||
49 | def _api_query(self, path, version=4, **kwargs): | |
1a83c731 | 50 | path += '?' if '?' not in path else '&' |
73d829c1 | 51 | query = f'/v{version}/{path}app={self._APP}' |
16d6973f S |
52 | if self._token: |
53 | query += '&token=%s' % self._token | |
73d829c1 | 54 | return query + ''.join(f'&{name}={val}' for name, val in kwargs.items()) |
55 | ||
56 | def _sign_query(self, path): | |
57 | timestamp = int(time.time()) | |
58 | query = self._api_query(path, version=5) | |
1a83c731 | 59 | sig = hmac.new( |
73d829c1 | 60 | self._APP_SECRET.encode('ascii'), f'{query}&t={timestamp}'.encode('ascii'), hashlib.sha1).hexdigest() |
61 | return timestamp, sig, self._API_URL_TEMPLATE % query | |
62 | ||
63 | def _call_api( | |
64 | self, path, video_id, note='Downloading JSON metadata', data=None, query=None, fatal=True): | |
65 | if query is None: | |
66 | timestamp, sig, url = self._sign_query(path) | |
67 | else: | |
68 | url = self._API_URL_TEMPLATE % self._api_query(path, version=4) | |
1a83c731 | 69 | resp = self._download_json( |
73d829c1 | 70 | url, video_id, note, fatal=fatal, query=query, |
71 | data=json.dumps(data).encode('utf-8') if data else None, | |
72 | headers=({'x-viki-app-ver': self._APP_VERSION} if data | |
73 | else self._stream_headers(timestamp, sig) if query is None | |
a38bd1de | 74 | else None), expected_status=400) or {} |
1a83c731 | 75 | |
73d829c1 | 76 | self._raise_error(resp.get('error'), fatal) |
1a83c731 S |
77 | return resp |
78 | ||
73d829c1 | 79 | def _raise_error(self, error, fatal=True): |
80 | if error is None: | |
81 | return | |
82 | msg = '%s said: %s' % (self.IE_NAME, error) | |
83 | if fatal: | |
84 | raise ExtractorError(msg, expected=True) | |
85 | else: | |
86 | self.report_warning(msg) | |
1a83c731 | 87 | |
dc016bf5 | 88 | def _check_errors(self, data): |
bc2ca1bb | 89 | for reason, status in (data.get('blocking') or {}).items(): |
dc016bf5 | 90 | if status and reason in self._ERRORS: |
5d3fbf77 S |
91 | message = self._ERRORS[reason] |
92 | if reason == 'geo': | |
93 | self.raise_geo_restricted(msg=message) | |
bc2ca1bb | 94 | elif reason == 'paywall': |
73d829c1 | 95 | if try_get(data, lambda x: x['paywallable']['tvod']): |
96 | self._raise_error('This video is for rent only or TVOD (Transactional Video On demand)') | |
bc2ca1bb | 97 | self.raise_login_required(message) |
73d829c1 | 98 | self._raise_error(message) |
dc016bf5 | 99 | |
52efa4b3 | 100 | def _perform_login(self, username, password): |
73d829c1 | 101 | self._token = self._call_api( |
102 | 'sessions.json', None, 'Logging in', fatal=False, | |
103 | data={'username': username, 'password': password}).get('token') | |
16d6973f | 104 | if not self._token: |
73d829c1 | 105 | self.report_warning('Login Failed: Unable to get session token') |
16d6973f | 106 | |
b73b14f7 | 107 | @staticmethod |
73d829c1 | 108 | def dict_selection(dict_obj, preferred_key): |
b73b14f7 | 109 | if preferred_key in dict_obj: |
73d829c1 | 110 | return dict_obj[preferred_key] |
111 | return (list(filter(None, dict_obj.values())) or [None])[0] | |
b73b14f7 | 112 | |
1a83c731 S |
113 | |
114 | class VikiIE(VikiBaseIE): | |
cb9722cb | 115 | IE_NAME = 'viki' |
53de95da | 116 | _VALID_URL = r'%s(?:videos|player)/(?P<id>[0-9]+v)' % VikiBaseIE._VALID_URL_BASE |
8e3df9df | 117 | _TESTS = [{ |
cdb19aa4 | 118 | 'note': 'Free non-DRM video with storyboards in MPD', |
89ee4cf8 | 119 | 'url': 'https://www.viki.com/videos/1175236v-choosing-spouse-by-lottery-episode-1', |
120 | 'info_dict': { | |
121 | 'id': '1175236v', | |
122 | 'ext': 'mp4', | |
123 | 'title': 'Choosing Spouse by Lottery - Episode 1', | |
124 | 'timestamp': 1606463239, | |
125 | 'age_limit': 13, | |
126 | 'uploader': 'FCC', | |
127 | 'upload_date': '20201127', | |
128 | }, | |
89ee4cf8 | 129 | }, { |
cb9722cb | 130 | 'url': 'http://www.viki.com/videos/1023585v-heirs-episode-14', |
cb9722cb PH |
131 | 'info_dict': { |
132 | 'id': '1023585v', | |
133 | 'ext': 'mp4', | |
bc2ca1bb | 134 | 'title': 'Heirs - Episode 14', |
135 | 'uploader': 'SBS Contents Hub', | |
136 | 'timestamp': 1385047627, | |
cb9722cb PH |
137 | 'upload_date': '20131121', |
138 | 'age_limit': 13, | |
bc2ca1bb | 139 | 'duration': 3570, |
140 | 'episode_number': 14, | |
141 | }, | |
cb9722cb | 142 | 'skip': 'Blocked in the US', |
8e3df9df | 143 | }, { |
1a83c731 | 144 | # clip |
8e3df9df | 145 | 'url': 'http://www.viki.com/videos/1067139v-the-avengers-age-of-ultron-press-conference', |
10568217 | 146 | 'md5': '86c0b5dbd4d83a6611a79987cc7a1989', |
8e3df9df YCH |
147 | 'info_dict': { |
148 | 'id': '1067139v', | |
149 | 'ext': 'mp4', | |
1a83c731 | 150 | 'title': "'The Avengers: Age of Ultron' Press Conference", |
8e3df9df | 151 | 'description': 'md5:d70b2f9428f5488321bfe1db10d612ea', |
1a83c731 S |
152 | 'duration': 352, |
153 | 'timestamp': 1430380829, | |
8e3df9df | 154 | 'upload_date': '20150430', |
1a83c731 S |
155 | 'uploader': 'Arirang TV', |
156 | 'like_count': int, | |
157 | 'age_limit': 0, | |
bc2ca1bb | 158 | }, |
159 | 'skip': 'Sorry. There was an error loading this video', | |
d948e09b YCH |
160 | }, { |
161 | 'url': 'http://www.viki.com/videos/1048879v-ankhon-dekhi', | |
162 | 'info_dict': { | |
163 | 'id': '1048879v', | |
164 | 'ext': 'mp4', | |
d948e09b | 165 | 'title': 'Ankhon Dekhi', |
1a83c731 S |
166 | 'duration': 6512, |
167 | 'timestamp': 1408532356, | |
168 | 'upload_date': '20140820', | |
169 | 'uploader': 'Spuul', | |
170 | 'like_count': int, | |
171 | 'age_limit': 13, | |
d948e09b | 172 | }, |
94e5d6ae | 173 | 'skip': 'Blocked in the US', |
1a83c731 S |
174 | }, { |
175 | # episode | |
176 | 'url': 'http://www.viki.com/videos/44699v-boys-over-flowers-episode-1', | |
bc2ca1bb | 177 | 'md5': '0a53dc252e6e690feccd756861495a8c', |
1a83c731 S |
178 | 'info_dict': { |
179 | 'id': '44699v', | |
180 | 'ext': 'mp4', | |
181 | 'title': 'Boys Over Flowers - Episode 1', | |
c83b35d4 | 182 | 'description': 'md5:b89cf50038b480b88b5b3c93589a9076', |
a0566bbf | 183 | 'duration': 4172, |
1a83c731 S |
184 | 'timestamp': 1270496524, |
185 | 'upload_date': '20100405', | |
186 | 'uploader': 'group8', | |
187 | 'like_count': int, | |
188 | 'age_limit': 13, | |
bc2ca1bb | 189 | 'episode_number': 1, |
190 | }, | |
ac20d95f S |
191 | }, { |
192 | # youtube external | |
193 | 'url': 'http://www.viki.com/videos/50562v-poor-nastya-complete-episode-1', | |
f22ba4bd | 194 | 'md5': '63f8600c1da6f01b7640eee7eca4f1da', |
ac20d95f S |
195 | 'info_dict': { |
196 | 'id': '50562v', | |
f22ba4bd | 197 | 'ext': 'webm', |
ac20d95f S |
198 | 'title': 'Poor Nastya [COMPLETE] - Episode 1', |
199 | 'description': '', | |
f22ba4bd | 200 | 'duration': 606, |
ac20d95f S |
201 | 'timestamp': 1274949505, |
202 | 'upload_date': '20101213', | |
203 | 'uploader': 'ad14065n', | |
204 | 'uploader_id': 'ad14065n', | |
205 | 'like_count': int, | |
206 | 'age_limit': 13, | |
a0566bbf | 207 | }, |
208 | 'skip': 'Page not found!', | |
1a83c731 S |
209 | }, { |
210 | 'url': 'http://www.viki.com/player/44699v', | |
211 | 'only_matching': True, | |
41597d9b YCH |
212 | }, { |
213 | # non-English description | |
214 | 'url': 'http://www.viki.com/videos/158036v-love-in-magic', | |
bc2ca1bb | 215 | 'md5': '41faaba0de90483fb4848952af7c7d0d', |
41597d9b YCH |
216 | 'info_dict': { |
217 | 'id': '158036v', | |
218 | 'ext': 'mp4', | |
219 | 'uploader': 'I Planet Entertainment', | |
220 | 'upload_date': '20111122', | |
221 | 'timestamp': 1321985454, | |
222 | 'description': 'md5:44b1e46619df3a072294645c770cef36', | |
223 | 'title': 'Love In Magic', | |
dc016bf5 | 224 | 'age_limit': 13, |
41597d9b | 225 | }, |
8e3df9df | 226 | }] |
382ed50e PH |
227 | |
228 | def _real_extract(self, url): | |
8ee34150 | 229 | video_id = self._match_id(url) |
73d829c1 | 230 | video = self._call_api(f'videos/{video_id}.json', video_id, 'Downloading video JSON', query={}) |
dc016bf5 | 231 | self._check_errors(video) |
232 | ||
73d829c1 | 233 | title = try_get(video, lambda x: x['titles']['en'], str) |
bc2ca1bb | 234 | episode_number = int_or_none(video.get('number')) |
1a83c731 | 235 | if not title: |
bc2ca1bb | 236 | title = 'Episode %d' % episode_number if video.get('type') == 'episode' else video.get('id') or video_id |
237 | container_titles = try_get(video, lambda x: x['container']['titles'], dict) or {} | |
b73b14f7 YCH |
238 | container_title = self.dict_selection(container_titles, 'en') |
239 | title = '%s - %s' % (container_title, title) | |
240 | ||
73d829c1 | 241 | thumbnails = [{ |
242 | 'id': thumbnail_id, | |
243 | 'url': thumbnail['url'], | |
244 | } for thumbnail_id, thumbnail in (video.get('images') or {}).items() if thumbnail.get('url')] | |
245 | ||
246 | resp = self._call_api( | |
1c6f4801 | 247 | 'playback_streams/%s.json?drms=dt3&device_id=%s' % (video_id, self._DEVICE_ID), |
73d829c1 | 248 | video_id, 'Downloading video streams JSON')['main'][0] |
249 | ||
250 | stream_id = try_get(resp, lambda x: x['properties']['track']['stream_id']) | |
251 | subtitles = dict((lang, [{ | |
252 | 'ext': ext, | |
253 | 'url': self._API_URL_TEMPLATE % self._api_query( | |
254 | f'videos/{video_id}/auth_subtitles/{lang}.{ext}', stream_id=stream_id) | |
255 | } for ext in ('srt', 'vtt')]) for lang in (video.get('subtitle_completions') or {}).keys()) | |
256 | ||
257 | mpd_url = resp['url'] | |
1c6f4801 | 258 | # 720p is hidden in another MPD which can be found in the current manifest content |
73d829c1 | 259 | mpd_content = self._download_webpage(mpd_url, video_id, note='Downloading initial MPD manifest') |
260 | mpd_url = self._search_regex( | |
261 | r'(?mi)<BaseURL>(http.+.mpd)', mpd_content, 'new manifest', default=mpd_url) | |
bdd60588 | 262 | if 'mpdhd_high' not in mpd_url and 'sig=' not in mpd_url: |
1c6f4801 | 263 | # Modify the URL to get 1080p |
264 | mpd_url = mpd_url.replace('mpdhd', 'mpdhd_high') | |
73d829c1 | 265 | formats = self._extract_mpd_formats(mpd_url, video_id) |
266 | self._sort_formats(formats) | |
382ed50e | 267 | |
73d829c1 | 268 | return { |
382ed50e | 269 | 'id': video_id, |
73d829c1 | 270 | 'formats': formats, |
382ed50e | 271 | 'title': title, |
73d829c1 | 272 | 'description': self.dict_selection(video.get('descriptions', {}), 'en'), |
bc2ca1bb | 273 | 'duration': int_or_none(video.get('duration')), |
274 | 'timestamp': parse_iso8601(video.get('created_at')), | |
275 | 'uploader': video.get('author'), | |
276 | 'uploader_url': video.get('author_url'), | |
73d829c1 | 277 | 'like_count': int_or_none(try_get(video, lambda x: x['likes']['count'])), |
bc2ca1bb | 278 | 'age_limit': parse_age_limit(video.get('rating')), |
1a83c731 | 279 | 'thumbnails': thumbnails, |
1a83c731 | 280 | 'subtitles': subtitles, |
bc2ca1bb | 281 | 'episode_number': episode_number, |
382ed50e PH |
282 | } |
283 | ||
0d7f0364 | 284 | |
bc56355e | 285 | class VikiChannelIE(VikiBaseIE): |
8da0e0e9 | 286 | IE_NAME = 'viki:channel' |
53de95da | 287 | _VALID_URL = r'%s(?:tv|news|movies|artists)/(?P<id>[0-9]+c)' % VikiBaseIE._VALID_URL_BASE |
0d7f0364 | 288 | _TESTS = [{ |
289 | 'url': 'http://www.viki.com/tv/50c-boys-over-flowers', | |
290 | 'info_dict': { | |
291 | 'id': '50c', | |
292 | 'title': 'Boys Over Flowers', | |
bc2ca1bb | 293 | 'description': 'md5:804ce6e7837e1fd527ad2f25420f4d59', |
0d7f0364 | 294 | }, |
73d829c1 | 295 | 'playlist_mincount': 51, |
1c18de00 | 296 | }, { |
297 | 'url': 'http://www.viki.com/tv/1354c-poor-nastya-complete', | |
298 | 'info_dict': { | |
299 | 'id': '1354c', | |
300 | 'title': 'Poor Nastya [COMPLETE]', | |
301 | 'description': 'md5:05bf5471385aa8b21c18ad450e350525', | |
302 | }, | |
303 | 'playlist_count': 127, | |
bc2ca1bb | 304 | 'skip': 'Page not found', |
d01924f4 S |
305 | }, { |
306 | 'url': 'http://www.viki.com/news/24569c-showbiz-korea', | |
307 | 'only_matching': True, | |
308 | }, { | |
309 | 'url': 'http://www.viki.com/movies/22047c-pride-and-prejudice-2005', | |
310 | 'only_matching': True, | |
311 | }, { | |
312 | 'url': 'http://www.viki.com/artists/2141c-shinee', | |
313 | 'only_matching': True, | |
0d7f0364 | 314 | }] |
bc56355e | 315 | |
73d829c1 | 316 | _video_types = ('episodes', 'movies', 'clips', 'trailers') |
317 | ||
318 | def _entries(self, channel_id): | |
319 | params = { | |
320 | 'app': self._APP, 'token': self._token, 'only_ids': 'true', | |
321 | 'direction': 'asc', 'sort': 'number', 'per_page': 30 | |
322 | } | |
323 | video_types = self._configuration_arg('video_types') or self._video_types | |
324 | for video_type in video_types: | |
325 | if video_type not in self._video_types: | |
326 | self.report_warning(f'Unknown video_type: {video_type}') | |
327 | page_num = 0 | |
328 | while True: | |
329 | page_num += 1 | |
330 | params['page'] = page_num | |
331 | res = self._call_api( | |
332 | f'containers/{channel_id}/{video_type}.json', channel_id, query=params, fatal=False, | |
333 | note='Downloading %s JSON page %d' % (video_type.title(), page_num)) | |
334 | ||
335 | for video_id in res.get('response') or []: | |
336 | yield self.url_result(f'https://www.viki.com/videos/{video_id}', VikiIE.ie_key(), video_id) | |
337 | if not res.get('more'): | |
338 | break | |
0d7f0364 | 339 | |
340 | def _real_extract(self, url): | |
b0d619fd | 341 | channel_id = self._match_id(url) |
73d829c1 | 342 | channel = self._call_api('containers/%s.json' % channel_id, channel_id, 'Downloading channel JSON') |
dc016bf5 | 343 | self._check_errors(channel) |
73d829c1 | 344 | return self.playlist_result( |
345 | self._entries(channel_id), channel_id, | |
346 | self.dict_selection(channel['titles'], 'en'), | |
347 | self.dict_selection(channel['descriptions'], 'en')) |