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