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