]>
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 | |
1c6f4801 | 22 | _DEVICE_ID = '112395910d' |
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 | }, | |
89ee4cf8 | 138 | }, { |
cb9722cb | 139 | 'url': 'http://www.viki.com/videos/1023585v-heirs-episode-14', |
cb9722cb PH |
140 | 'info_dict': { |
141 | 'id': '1023585v', | |
142 | 'ext': 'mp4', | |
bc2ca1bb | 143 | 'title': 'Heirs - Episode 14', |
144 | 'uploader': 'SBS Contents Hub', | |
145 | 'timestamp': 1385047627, | |
cb9722cb PH |
146 | 'upload_date': '20131121', |
147 | 'age_limit': 13, | |
bc2ca1bb | 148 | 'duration': 3570, |
149 | 'episode_number': 14, | |
150 | }, | |
cb9722cb | 151 | 'skip': 'Blocked in the US', |
8e3df9df | 152 | }, { |
1a83c731 | 153 | # clip |
8e3df9df | 154 | 'url': 'http://www.viki.com/videos/1067139v-the-avengers-age-of-ultron-press-conference', |
10568217 | 155 | 'md5': '86c0b5dbd4d83a6611a79987cc7a1989', |
8e3df9df YCH |
156 | 'info_dict': { |
157 | 'id': '1067139v', | |
158 | 'ext': 'mp4', | |
1a83c731 | 159 | 'title': "'The Avengers: Age of Ultron' Press Conference", |
8e3df9df | 160 | 'description': 'md5:d70b2f9428f5488321bfe1db10d612ea', |
1a83c731 S |
161 | 'duration': 352, |
162 | 'timestamp': 1430380829, | |
8e3df9df | 163 | 'upload_date': '20150430', |
1a83c731 S |
164 | 'uploader': 'Arirang TV', |
165 | 'like_count': int, | |
166 | 'age_limit': 0, | |
bc2ca1bb | 167 | }, |
168 | 'skip': 'Sorry. There was an error loading this video', | |
d948e09b YCH |
169 | }, { |
170 | 'url': 'http://www.viki.com/videos/1048879v-ankhon-dekhi', | |
171 | 'info_dict': { | |
172 | 'id': '1048879v', | |
173 | 'ext': 'mp4', | |
d948e09b | 174 | 'title': 'Ankhon Dekhi', |
1a83c731 S |
175 | 'duration': 6512, |
176 | 'timestamp': 1408532356, | |
177 | 'upload_date': '20140820', | |
178 | 'uploader': 'Spuul', | |
179 | 'like_count': int, | |
180 | 'age_limit': 13, | |
d948e09b | 181 | }, |
94e5d6ae | 182 | 'skip': 'Blocked in the US', |
1a83c731 S |
183 | }, { |
184 | # episode | |
185 | 'url': 'http://www.viki.com/videos/44699v-boys-over-flowers-episode-1', | |
bc2ca1bb | 186 | 'md5': '0a53dc252e6e690feccd756861495a8c', |
1a83c731 S |
187 | 'info_dict': { |
188 | 'id': '44699v', | |
189 | 'ext': 'mp4', | |
190 | 'title': 'Boys Over Flowers - Episode 1', | |
c83b35d4 | 191 | 'description': 'md5:b89cf50038b480b88b5b3c93589a9076', |
a0566bbf | 192 | 'duration': 4172, |
1a83c731 S |
193 | 'timestamp': 1270496524, |
194 | 'upload_date': '20100405', | |
195 | 'uploader': 'group8', | |
196 | 'like_count': int, | |
197 | 'age_limit': 13, | |
bc2ca1bb | 198 | 'episode_number': 1, |
199 | }, | |
ac20d95f S |
200 | }, { |
201 | # youtube external | |
202 | 'url': 'http://www.viki.com/videos/50562v-poor-nastya-complete-episode-1', | |
f22ba4bd | 203 | 'md5': '63f8600c1da6f01b7640eee7eca4f1da', |
ac20d95f S |
204 | 'info_dict': { |
205 | 'id': '50562v', | |
f22ba4bd | 206 | 'ext': 'webm', |
ac20d95f S |
207 | 'title': 'Poor Nastya [COMPLETE] - Episode 1', |
208 | 'description': '', | |
f22ba4bd | 209 | 'duration': 606, |
ac20d95f S |
210 | 'timestamp': 1274949505, |
211 | 'upload_date': '20101213', | |
212 | 'uploader': 'ad14065n', | |
213 | 'uploader_id': 'ad14065n', | |
214 | 'like_count': int, | |
215 | 'age_limit': 13, | |
a0566bbf | 216 | }, |
217 | 'skip': 'Page not found!', | |
1a83c731 S |
218 | }, { |
219 | 'url': 'http://www.viki.com/player/44699v', | |
220 | 'only_matching': True, | |
41597d9b YCH |
221 | }, { |
222 | # non-English description | |
223 | 'url': 'http://www.viki.com/videos/158036v-love-in-magic', | |
bc2ca1bb | 224 | 'md5': '41faaba0de90483fb4848952af7c7d0d', |
41597d9b YCH |
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', | |
dc016bf5 | 233 | 'age_limit': 13, |
41597d9b | 234 | }, |
8e3df9df | 235 | }] |
382ed50e PH |
236 | |
237 | def _real_extract(self, url): | |
8ee34150 | 238 | video_id = self._match_id(url) |
73d829c1 | 239 | video = self._call_api(f'videos/{video_id}.json', video_id, 'Downloading video JSON', query={}) |
dc016bf5 | 240 | self._check_errors(video) |
241 | ||
73d829c1 | 242 | title = try_get(video, lambda x: x['titles']['en'], str) |
bc2ca1bb | 243 | episode_number = int_or_none(video.get('number')) |
1a83c731 | 244 | if not title: |
bc2ca1bb | 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 {} | |
b73b14f7 YCH |
247 | container_title = self.dict_selection(container_titles, 'en') |
248 | title = '%s - %s' % (container_title, title) | |
249 | ||
73d829c1 | 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( | |
1c6f4801 | 256 | 'playback_streams/%s.json?drms=dt3&device_id=%s' % (video_id, self._DEVICE_ID), |
73d829c1 | 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'] | |
1c6f4801 | 267 | # 720p is hidden in another MPD which can be found in the current manifest content |
73d829c1 | 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) | |
1c6f4801 | 271 | if 'mpdhd_high' not in mpd_url: |
272 | # Modify the URL to get 1080p | |
273 | mpd_url = mpd_url.replace('mpdhd', 'mpdhd_high') | |
73d829c1 | 274 | formats = self._extract_mpd_formats(mpd_url, video_id) |
275 | self._sort_formats(formats) | |
382ed50e | 276 | |
73d829c1 | 277 | return { |
382ed50e | 278 | 'id': video_id, |
73d829c1 | 279 | 'formats': formats, |
382ed50e | 280 | 'title': title, |
73d829c1 | 281 | 'description': self.dict_selection(video.get('descriptions', {}), 'en'), |
bc2ca1bb | 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'), | |
73d829c1 | 286 | 'like_count': int_or_none(try_get(video, lambda x: x['likes']['count'])), |
bc2ca1bb | 287 | 'age_limit': parse_age_limit(video.get('rating')), |
1a83c731 | 288 | 'thumbnails': thumbnails, |
1a83c731 | 289 | 'subtitles': subtitles, |
bc2ca1bb | 290 | 'episode_number': episode_number, |
382ed50e PH |
291 | } |
292 | ||
0d7f0364 | 293 | |
bc56355e | 294 | class VikiChannelIE(VikiBaseIE): |
8da0e0e9 | 295 | IE_NAME = 'viki:channel' |
53de95da | 296 | _VALID_URL = r'%s(?:tv|news|movies|artists)/(?P<id>[0-9]+c)' % VikiBaseIE._VALID_URL_BASE |
0d7f0364 | 297 | _TESTS = [{ |
298 | 'url': 'http://www.viki.com/tv/50c-boys-over-flowers', | |
299 | 'info_dict': { | |
300 | 'id': '50c', | |
301 | 'title': 'Boys Over Flowers', | |
bc2ca1bb | 302 | 'description': 'md5:804ce6e7837e1fd527ad2f25420f4d59', |
0d7f0364 | 303 | }, |
73d829c1 | 304 | 'playlist_mincount': 51, |
1c18de00 | 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, | |
bc2ca1bb | 313 | 'skip': 'Page not found', |
d01924f4 S |
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, | |
0d7f0364 | 323 | }] |
bc56355e | 324 | |
73d829c1 | 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 | |
0d7f0364 | 348 | |
349 | def _real_extract(self, url): | |
b0d619fd | 350 | channel_id = self._match_id(url) |
73d829c1 | 351 | channel = self._call_api('containers/%s.json' % channel_id, channel_id, 'Downloading channel JSON') |
dc016bf5 | 352 | self._check_errors(channel) |
73d829c1 | 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')) |