]>
Commit | Line | Data |
---|---|---|
1 | import hashlib | |
2 | import hmac | |
3 | import json | |
4 | import time | |
5 | ||
6 | from .common import InfoExtractor | |
7 | from ..utils import ( | |
8 | ExtractorError, | |
9 | int_or_none, | |
10 | parse_age_limit, | |
11 | parse_iso8601, | |
12 | try_get, | |
13 | ) | |
14 | ||
15 | ||
16 | class VikiBaseIE(InfoExtractor): | |
17 | _VALID_URL_BASE = r'https?://(?:www\.)?viki\.(?:com|net|mx|jp|fr)/' | |
18 | _API_URL_TEMPLATE = 'https://api.viki.io%s' | |
19 | ||
20 | _DEVICE_ID = '112395910d' | |
21 | _APP = '100005a' | |
22 | _APP_VERSION = '6.11.3' | |
23 | _APP_SECRET = 'd96704b180208dbb2efa30fe44c48bd8690441af9f567ba8fd710a72badc85198f7472' | |
24 | ||
25 | _GEO_BYPASS = False | |
26 | _NETRC_MACHINE = 'viki' | |
27 | ||
28 | _token = None | |
29 | ||
30 | _ERRORS = { | |
31 | 'geo': 'Sorry, this content is not available in your region.', | |
32 | 'upcoming': 'Sorry, this content is not yet available.', | |
33 | 'paywall': 'Sorry, this content is only available to Viki Pass Plus subscribers', | |
34 | } | |
35 | ||
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): | |
50 | path += '?' if '?' not in path else '&' | |
51 | query = f'/v{version}/{path}app={self._APP}' | |
52 | if self._token: | |
53 | query += '&token=%s' % self._token | |
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) | |
59 | sig = hmac.new( | |
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) | |
69 | resp = self._download_json( | |
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 | |
74 | else None), expected_status=400) or {} | |
75 | ||
76 | self._raise_error(resp.get('error'), fatal) | |
77 | return resp | |
78 | ||
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) | |
87 | ||
88 | def _check_errors(self, data): | |
89 | for reason, status in (data.get('blocking') or {}).items(): | |
90 | if status and reason in self._ERRORS: | |
91 | message = self._ERRORS[reason] | |
92 | if reason == 'geo': | |
93 | self.raise_geo_restricted(msg=message) | |
94 | elif reason == 'paywall': | |
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)') | |
97 | self.raise_login_required(message) | |
98 | self._raise_error(message) | |
99 | ||
100 | def _perform_login(self, username, password): | |
101 | self._token = self._call_api( | |
102 | 'sessions.json', None, 'Logging in', fatal=False, | |
103 | data={'username': username, 'password': password}).get('token') | |
104 | if not self._token: | |
105 | self.report_warning('Login Failed: Unable to get session token') | |
106 | ||
107 | @staticmethod | |
108 | def dict_selection(dict_obj, preferred_key): | |
109 | if preferred_key in dict_obj: | |
110 | return dict_obj[preferred_key] | |
111 | return (list(filter(None, dict_obj.values())) or [None])[0] | |
112 | ||
113 | ||
114 | class VikiIE(VikiBaseIE): | |
115 | IE_NAME = 'viki' | |
116 | _VALID_URL = r'%s(?:videos|player)/(?P<id>[0-9]+v)' % VikiBaseIE._VALID_URL_BASE | |
117 | _TESTS = [{ | |
118 | 'note': 'Free non-DRM video with storyboards in MPD', | |
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 | }, | |
129 | }, { | |
130 | 'url': 'http://www.viki.com/videos/1023585v-heirs-episode-14', | |
131 | 'info_dict': { | |
132 | 'id': '1023585v', | |
133 | 'ext': 'mp4', | |
134 | 'title': 'Heirs - Episode 14', | |
135 | 'uploader': 'SBS Contents Hub', | |
136 | 'timestamp': 1385047627, | |
137 | 'upload_date': '20131121', | |
138 | 'age_limit': 13, | |
139 | 'duration': 3570, | |
140 | 'episode_number': 14, | |
141 | }, | |
142 | 'skip': 'Blocked in the US', | |
143 | }, { | |
144 | # clip | |
145 | 'url': 'http://www.viki.com/videos/1067139v-the-avengers-age-of-ultron-press-conference', | |
146 | 'md5': '86c0b5dbd4d83a6611a79987cc7a1989', | |
147 | 'info_dict': { | |
148 | 'id': '1067139v', | |
149 | 'ext': 'mp4', | |
150 | 'title': "'The Avengers: Age of Ultron' Press Conference", | |
151 | 'description': 'md5:d70b2f9428f5488321bfe1db10d612ea', | |
152 | 'duration': 352, | |
153 | 'timestamp': 1430380829, | |
154 | 'upload_date': '20150430', | |
155 | 'uploader': 'Arirang TV', | |
156 | 'like_count': int, | |
157 | 'age_limit': 0, | |
158 | }, | |
159 | 'skip': 'Sorry. There was an error loading this video', | |
160 | }, { | |
161 | 'url': 'http://www.viki.com/videos/1048879v-ankhon-dekhi', | |
162 | 'info_dict': { | |
163 | 'id': '1048879v', | |
164 | 'ext': 'mp4', | |
165 | 'title': 'Ankhon Dekhi', | |
166 | 'duration': 6512, | |
167 | 'timestamp': 1408532356, | |
168 | 'upload_date': '20140820', | |
169 | 'uploader': 'Spuul', | |
170 | 'like_count': int, | |
171 | 'age_limit': 13, | |
172 | }, | |
173 | 'skip': 'Blocked in the US', | |
174 | }, { | |
175 | # episode | |
176 | 'url': 'http://www.viki.com/videos/44699v-boys-over-flowers-episode-1', | |
177 | 'md5': '0a53dc252e6e690feccd756861495a8c', | |
178 | 'info_dict': { | |
179 | 'id': '44699v', | |
180 | 'ext': 'mp4', | |
181 | 'title': 'Boys Over Flowers - Episode 1', | |
182 | 'description': 'md5:b89cf50038b480b88b5b3c93589a9076', | |
183 | 'duration': 4172, | |
184 | 'timestamp': 1270496524, | |
185 | 'upload_date': '20100405', | |
186 | 'uploader': 'group8', | |
187 | 'like_count': int, | |
188 | 'age_limit': 13, | |
189 | 'episode_number': 1, | |
190 | }, | |
191 | }, { | |
192 | # youtube external | |
193 | 'url': 'http://www.viki.com/videos/50562v-poor-nastya-complete-episode-1', | |
194 | 'md5': '63f8600c1da6f01b7640eee7eca4f1da', | |
195 | 'info_dict': { | |
196 | 'id': '50562v', | |
197 | 'ext': 'webm', | |
198 | 'title': 'Poor Nastya [COMPLETE] - Episode 1', | |
199 | 'description': '', | |
200 | 'duration': 606, | |
201 | 'timestamp': 1274949505, | |
202 | 'upload_date': '20101213', | |
203 | 'uploader': 'ad14065n', | |
204 | 'uploader_id': 'ad14065n', | |
205 | 'like_count': int, | |
206 | 'age_limit': 13, | |
207 | }, | |
208 | 'skip': 'Page not found!', | |
209 | }, { | |
210 | 'url': 'http://www.viki.com/player/44699v', | |
211 | 'only_matching': True, | |
212 | }, { | |
213 | # non-English description | |
214 | 'url': 'http://www.viki.com/videos/158036v-love-in-magic', | |
215 | 'md5': '41faaba0de90483fb4848952af7c7d0d', | |
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', | |
224 | 'age_limit': 13, | |
225 | }, | |
226 | }] | |
227 | ||
228 | def _real_extract(self, url): | |
229 | video_id = self._match_id(url) | |
230 | video = self._call_api(f'videos/{video_id}.json', video_id, 'Downloading video JSON', query={}) | |
231 | self._check_errors(video) | |
232 | ||
233 | title = try_get(video, lambda x: x['titles']['en'], str) | |
234 | episode_number = int_or_none(video.get('number')) | |
235 | if not title: | |
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 {} | |
238 | container_title = self.dict_selection(container_titles, 'en') | |
239 | title = '%s - %s' % (container_title, title) | |
240 | ||
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( | |
247 | 'playback_streams/%s.json?drms=dt3&device_id=%s' % (video_id, self._DEVICE_ID), | |
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'] | |
258 | # 720p is hidden in another MPD which can be found in the current manifest content | |
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) | |
262 | if 'mpdhd_high' not in mpd_url and 'sig=' not in mpd_url: | |
263 | # Modify the URL to get 1080p | |
264 | mpd_url = mpd_url.replace('mpdhd', 'mpdhd_high') | |
265 | formats = self._extract_mpd_formats(mpd_url, video_id) | |
266 | ||
267 | return { | |
268 | 'id': video_id, | |
269 | 'formats': formats, | |
270 | 'title': title, | |
271 | 'description': self.dict_selection(video.get('descriptions', {}), 'en'), | |
272 | 'duration': int_or_none(video.get('duration')), | |
273 | 'timestamp': parse_iso8601(video.get('created_at')), | |
274 | 'uploader': video.get('author'), | |
275 | 'uploader_url': video.get('author_url'), | |
276 | 'like_count': int_or_none(try_get(video, lambda x: x['likes']['count'])), | |
277 | 'age_limit': parse_age_limit(video.get('rating')), | |
278 | 'thumbnails': thumbnails, | |
279 | 'subtitles': subtitles, | |
280 | 'episode_number': episode_number, | |
281 | } | |
282 | ||
283 | ||
284 | class VikiChannelIE(VikiBaseIE): | |
285 | IE_NAME = 'viki:channel' | |
286 | _VALID_URL = r'%s(?:tv|news|movies|artists)/(?P<id>[0-9]+c)' % VikiBaseIE._VALID_URL_BASE | |
287 | _TESTS = [{ | |
288 | 'url': 'http://www.viki.com/tv/50c-boys-over-flowers', | |
289 | 'info_dict': { | |
290 | 'id': '50c', | |
291 | 'title': 'Boys Over Flowers', | |
292 | 'description': 'md5:804ce6e7837e1fd527ad2f25420f4d59', | |
293 | }, | |
294 | 'playlist_mincount': 51, | |
295 | }, { | |
296 | 'url': 'http://www.viki.com/tv/1354c-poor-nastya-complete', | |
297 | 'info_dict': { | |
298 | 'id': '1354c', | |
299 | 'title': 'Poor Nastya [COMPLETE]', | |
300 | 'description': 'md5:05bf5471385aa8b21c18ad450e350525', | |
301 | }, | |
302 | 'playlist_count': 127, | |
303 | 'skip': 'Page not found', | |
304 | }, { | |
305 | 'url': 'http://www.viki.com/news/24569c-showbiz-korea', | |
306 | 'only_matching': True, | |
307 | }, { | |
308 | 'url': 'http://www.viki.com/movies/22047c-pride-and-prejudice-2005', | |
309 | 'only_matching': True, | |
310 | }, { | |
311 | 'url': 'http://www.viki.com/artists/2141c-shinee', | |
312 | 'only_matching': True, | |
313 | }] | |
314 | ||
315 | _video_types = ('episodes', 'movies', 'clips', 'trailers') | |
316 | ||
317 | def _entries(self, channel_id): | |
318 | params = { | |
319 | 'app': self._APP, 'token': self._token, 'only_ids': 'true', | |
320 | 'direction': 'asc', 'sort': 'number', 'per_page': 30 | |
321 | } | |
322 | video_types = self._configuration_arg('video_types') or self._video_types | |
323 | for video_type in video_types: | |
324 | if video_type not in self._video_types: | |
325 | self.report_warning(f'Unknown video_type: {video_type}') | |
326 | page_num = 0 | |
327 | while True: | |
328 | page_num += 1 | |
329 | params['page'] = page_num | |
330 | res = self._call_api( | |
331 | f'containers/{channel_id}/{video_type}.json', channel_id, query=params, fatal=False, | |
332 | note='Downloading %s JSON page %d' % (video_type.title(), page_num)) | |
333 | ||
334 | for video_id in res.get('response') or []: | |
335 | yield self.url_result(f'https://www.viki.com/videos/{video_id}', VikiIE.ie_key(), video_id) | |
336 | if not res.get('more'): | |
337 | break | |
338 | ||
339 | def _real_extract(self, url): | |
340 | channel_id = self._match_id(url) | |
341 | channel = self._call_api('containers/%s.json' % channel_id, channel_id, 'Downloading channel JSON') | |
342 | self._check_errors(channel) | |
343 | return self.playlist_result( | |
344 | self._entries(channel_id), channel_id, | |
345 | self.dict_selection(channel['titles'], 'en'), | |
346 | self.dict_selection(channel['descriptions'], 'en')) |