1# coding: utf-8 2from __future__ import unicode_literals 3 4import json 5import re 6 7from .common import InfoExtractor 8from ..compat import ( 9 compat_kwargs, 10 compat_str, 11 compat_urlparse, 12 compat_urllib_request, 13) 14from ..utils import ( 15 ExtractorError, 16 int_or_none, 17 try_get, 18 smuggle_url, 19 unsmuggle_url, 20) 21 22 23class ViuBaseIE(InfoExtractor): 24 def _real_initialize(self): 25 viu_auth_res = self._request_webpage( 26 'https://www.viu.com/api/apps/v2/authenticate', None, 27 'Requesting Viu auth', query={ 28 'acct': 'test', 29 'appid': 'viu_desktop', 30 'fmt': 'json', 31 'iid': 'guest', 32 'languageid': 'default', 33 'platform': 'desktop', 34 'userid': 'guest', 35 'useridtype': 'guest', 36 'ver': '1.0' 37 }, headers=self.geo_verification_headers()) 38 self._auth_token = viu_auth_res.info()['X-VIU-AUTH'] 39 40 def _call_api(self, path, *args, **kwargs): 41 headers = self.geo_verification_headers() 42 headers.update({ 43 'X-VIU-AUTH': self._auth_token 44 }) 45 headers.update(kwargs.get('headers', {})) 46 kwargs['headers'] = headers 47 response = self._download_json( 48 'https://www.viu.com/api/' + path, *args, 49 **compat_kwargs(kwargs))['response'] 50 if response.get('status') != 'success': 51 raise ExtractorError('%s said: %s' % ( 52 self.IE_NAME, response['message']), expected=True) 53 return response 54 55 56class ViuIE(ViuBaseIE): 57 _VALID_URL = r'(?:viu:|https?://[^/]+\.viu\.com/[a-z]{2}/media/)(?P<id>\d+)' 58 _TESTS = [{ 59 'url': 'https://www.viu.com/en/media/1116705532?containerId=playlist-22168059', 60 'info_dict': { 61 'id': '1116705532', 62 'ext': 'mp4', 63 'title': 'Citizen Khan - Ep 1', 64 'description': 'md5:d7ea1604f49e5ba79c212c551ce2110e', 65 }, 66 'params': { 67 'skip_download': 'm3u8 download', 68 }, 69 'skip': 'Geo-restricted to India', 70 }, { 71 'url': 'https://www.viu.com/en/media/1130599965', 72 'info_dict': { 73 'id': '1130599965', 74 'ext': 'mp4', 75 'title': 'Jealousy Incarnate - Episode 1', 76 'description': 'md5:d3d82375cab969415d2720b6894361e9', 77 }, 78 'params': { 79 'skip_download': 'm3u8 download', 80 }, 81 'skip': 'Geo-restricted to Indonesia', 82 }, { 83 'url': 'https://india.viu.com/en/media/1126286865', 84 'only_matching': True, 85 }] 86 87 def _real_extract(self, url): 88 video_id = self._match_id(url) 89 90 video_data = self._call_api( 91 'clip/load', video_id, 'Downloading video data', query={ 92 'appid': 'viu_desktop', 93 'fmt': 'json', 94 'id': video_id 95 })['item'][0] 96 97 title = video_data['title'] 98 99 m3u8_url = None 100 url_path = video_data.get('urlpathd') or video_data.get('urlpath') 101 tdirforwhole = video_data.get('tdirforwhole') 102 # #EXT-X-BYTERANGE is not supported by native hls downloader 103 # and ffmpeg (#10955) 104 # hls_file = video_data.get('hlsfile') 105 hls_file = video_data.get('jwhlsfile') 106 if url_path and tdirforwhole and hls_file: 107 m3u8_url = '%s/%s/%s' % (url_path, tdirforwhole, hls_file) 108 else: 109 # m3u8_url = re.sub( 110 # r'(/hlsc_)[a-z]+(\d+\.m3u8)', 111 # r'\1whe\2', video_data['href']) 112 m3u8_url = video_data['href'] 113 formats = self._extract_m3u8_formats(m3u8_url, video_id, 'mp4') 114 self._sort_formats(formats) 115 116 subtitles = {} 117 for key, value in video_data.items(): 118 mobj = re.match(r'^subtitle_(?P<lang>[^_]+)_(?P<ext>(vtt|srt))', key) 119 if not mobj: 120 continue 121 subtitles.setdefault(mobj.group('lang'), []).append({ 122 'url': value, 123 'ext': mobj.group('ext') 124 }) 125 126 return { 127 'id': video_id, 128 'title': title, 129 'description': video_data.get('description'), 130 'series': video_data.get('moviealbumshowname'), 131 'episode': title, 132 'episode_number': int_or_none(video_data.get('episodeno')), 133 'duration': int_or_none(video_data.get('duration')), 134 'formats': formats, 135 'subtitles': subtitles, 136 } 137 138 139class ViuPlaylistIE(ViuBaseIE): 140 IE_NAME = 'viu:playlist' 141 _VALID_URL = r'https?://www\.viu\.com/[^/]+/listing/playlist-(?P<id>\d+)' 142 _TEST = { 143 'url': 'https://www.viu.com/en/listing/playlist-22461380', 144 'info_dict': { 145 'id': '22461380', 146 'title': 'The Good Wife', 147 }, 148 'playlist_count': 16, 149 'skip': 'Geo-restricted to Indonesia', 150 } 151 152 def _real_extract(self, url): 153 playlist_id = self._match_id(url) 154 playlist_data = self._call_api( 155 'container/load', playlist_id, 156 'Downloading playlist info', query={ 157 'appid': 'viu_desktop', 158 'fmt': 'json', 159 'id': 'playlist-' + playlist_id 160 })['container'] 161 162 entries = [] 163 for item in playlist_data.get('item', []): 164 item_id = item.get('id') 165 if not item_id: 166 continue 167 item_id = compat_str(item_id) 168 entries.append(self.url_result( 169 'viu:' + item_id, 'Viu', item_id)) 170 171 return self.playlist_result( 172 entries, playlist_id, playlist_data.get('title')) 173 174 175class ViuOTTIE(InfoExtractor): 176 IE_NAME = 'viu:ott' 177 _NETRC_MACHINE = 'viu' 178 _VALID_URL = r'https?://(?:www\.)?viu\.com/ott/(?P<country_code>[a-z]{2})/(?P<lang_code>[a-z]{2}-[a-z]{2})/vod/(?P<id>\d+)' 179 _TESTS = [{ 180 'url': 'http://www.viu.com/ott/sg/en-us/vod/3421/The%20Prime%20Minister%20and%20I', 181 'info_dict': { 182 'id': '3421', 183 'ext': 'mp4', 184 'title': 'A New Beginning', 185 'description': 'md5:1e7486a619b6399b25ba6a41c0fe5b2c', 186 }, 187 'params': { 188 'skip_download': 'm3u8 download', 189 'noplaylist': True, 190 }, 191 'skip': 'Geo-restricted to Singapore', 192 }, { 193 'url': 'http://www.viu.com/ott/hk/zh-hk/vod/7123/%E5%A4%A7%E4%BA%BA%E5%A5%B3%E5%AD%90', 194 'info_dict': { 195 'id': '7123', 196 'ext': 'mp4', 197 'title': '這就是我的生活之道', 198 'description': 'md5:4eb0d8b08cf04fcdc6bbbeb16043434f', 199 }, 200 'params': { 201 'skip_download': 'm3u8 download', 202 'noplaylist': True, 203 }, 204 'skip': 'Geo-restricted to Hong Kong', 205 }, { 206 'url': 'https://www.viu.com/ott/hk/zh-hk/vod/68776/%E6%99%82%E5%B0%9A%E5%AA%BD%E5%92%AA', 207 'playlist_count': 12, 208 'info_dict': { 209 'id': '3916', 210 'title': '時尚媽咪', 211 }, 212 'params': { 213 'skip_download': 'm3u8 download', 214 'noplaylist': False, 215 }, 216 'skip': 'Geo-restricted to Hong Kong', 217 }] 218 219 _AREA_ID = { 220 'HK': 1, 221 'SG': 2, 222 'TH': 4, 223 'PH': 5, 224 } 225 _LANGUAGE_FLAG = { 226 'zh-hk': 1, 227 'zh-cn': 2, 228 'en-us': 3, 229 } 230 _user_info = None 231 232 def _detect_error(self, response): 233 code = response.get('status', {}).get('code') 234 if code > 0: 235 message = try_get(response, lambda x: x['status']['message']) 236 raise ExtractorError('%s said: %s (%s)' % ( 237 self.IE_NAME, message, code), expected=True) 238 return response['data'] 239 240 def _raise_login_required(self): 241 raise ExtractorError( 242 'This video requires login. ' 243 'Specify --username and --password or --netrc (machine: %s) ' 244 'to provide account credentials.' % self._NETRC_MACHINE, 245 expected=True) 246 247 def _login(self, country_code, video_id): 248 if not self._user_info: 249 username, password = self._get_login_info() 250 if username is None or password is None: 251 return 252 253 data = self._download_json( 254 compat_urllib_request.Request( 255 'https://www.viu.com/ott/%s/index.php' % country_code, method='POST'), 256 video_id, 'Logging in', errnote=False, fatal=False, 257 query={'r': 'user/login'}, 258 data=json.dumps({ 259 'username': username, 260 'password': password, 261 'platform_flag_label': 'web', 262 }).encode()) 263 self._user_info = self._detect_error(data)['user'] 264 265 return self._user_info 266 267 def _real_extract(self, url): 268 url, idata = unsmuggle_url(url, {}) 269 country_code, lang_code, video_id = self._match_valid_url(url).groups() 270 271 query = { 272 'r': 'vod/ajax-detail', 273 'platform_flag_label': 'web', 274 'product_id': video_id, 275 } 276 277 area_id = self._AREA_ID.get(country_code.upper()) 278 if area_id: 279 query['area_id'] = area_id 280 281 product_data = self._download_json( 282 'http://www.viu.com/ott/%s/index.php' % country_code, video_id, 283 'Downloading video info', query=query)['data'] 284 285 video_data = product_data.get('current_product') 286 if not video_data: 287 raise ExtractorError('This video is not available in your region.', expected=True) 288 289 series_id = video_data.get('series_id') 290 if not self.get_param('noplaylist') and not idata.get('force_noplaylist'): 291 self.to_screen('Downloading playlist %s - add --no-playlist to just download video' % series_id) 292 series = product_data.get('series', {}) 293 product = series.get('product') 294 if product: 295 entries = [] 296 for entry in sorted(product, key=lambda x: int_or_none(x.get('number', 0))): 297 item_id = entry.get('product_id') 298 if not item_id: 299 continue 300 item_id = compat_str(item_id) 301 entries.append(self.url_result( 302 smuggle_url( 303 'http://www.viu.com/ott/%s/%s/vod/%s/' % (country_code, lang_code, item_id), 304 {'force_noplaylist': True}), # prevent infinite recursion 305 'ViuOTT', 306 item_id, 307 entry.get('synopsis', '').strip())) 308 309 return self.playlist_result(entries, series_id, series.get('name'), series.get('description')) 310 311 if self.get_param('noplaylist'): 312 self.to_screen('Downloading just video %s because of --no-playlist' % video_id) 313 314 duration_limit = False 315 query = { 316 'ccs_product_id': video_data['ccs_product_id'], 317 'language_flag_id': self._LANGUAGE_FLAG.get(lang_code.lower()) or '3', 318 } 319 headers = { 320 'Referer': url, 321 'Origin': url, 322 } 323 try: 324 stream_data = self._download_json( 325 'https://d1k2us671qcoau.cloudfront.net/distribute_web_%s.php' % country_code, 326 video_id, 'Downloading stream info', query=query, headers=headers) 327 stream_data = self._detect_error(stream_data)['stream'] 328 except (ExtractorError, KeyError): 329 stream_data = None 330 if video_data.get('user_level', 0) > 0: 331 user = self._login(country_code, video_id) 332 if user: 333 query['identity'] = user['identity'] 334 stream_data = self._download_json( 335 'https://d1k2us671qcoau.cloudfront.net/distribute_web_%s.php' % country_code, 336 video_id, 'Downloading stream info', query=query, headers=headers) 337 stream_data = self._detect_error(stream_data).get('stream') 338 else: 339 # preview is limited to 3min for non-members 340 # try to bypass the duration limit 341 duration_limit = True 342 query['duration'] = '180' 343 stream_data = self._download_json( 344 'https://d1k2us671qcoau.cloudfront.net/distribute_web_%s.php' % country_code, 345 video_id, 'Downloading stream info', query=query, headers=headers) 346 try: 347 stream_data = self._detect_error(stream_data)['stream'] 348 except (ExtractorError, KeyError): # if still not working, give up 349 self._raise_login_required() 350 351 if not stream_data: 352 raise ExtractorError('Cannot get stream info', expected=True) 353 354 stream_sizes = stream_data.get('size', {}) 355 formats = [] 356 for vid_format, stream_url in stream_data.get('url', {}).items(): 357 height = int_or_none(self._search_regex( 358 r's(\d+)p', vid_format, 'height', default=None)) 359 360 # bypass preview duration limit 361 if duration_limit: 362 stream_url = compat_urlparse.urlparse(stream_url) 363 query = dict(compat_urlparse.parse_qsl(stream_url.query, keep_blank_values=True)) 364 time_duration = int_or_none(video_data.get('time_duration')) 365 query.update({ 366 'duration': time_duration if time_duration > 0 else '9999999', 367 'duration_start': '0', 368 }) 369 stream_url = stream_url._replace(query=compat_urlparse.urlencode(query)).geturl() 370 371 formats.append({ 372 'format_id': vid_format, 373 'url': stream_url, 374 'height': height, 375 'ext': 'mp4', 376 'filesize': int_or_none(stream_sizes.get(vid_format)) 377 }) 378 self._sort_formats(formats) 379 380 subtitles = {} 381 for sub in video_data.get('subtitle', []): 382 sub_url = sub.get('url') 383 if not sub_url: 384 continue 385 subtitles.setdefault(sub.get('name'), []).append({ 386 'url': sub_url, 387 'ext': 'srt', 388 }) 389 390 title = video_data['synopsis'].strip() 391 392 return { 393 'id': video_id, 394 'title': title, 395 'description': video_data.get('description'), 396 'series': product_data.get('series', {}).get('name'), 397 'episode': title, 398 'episode_number': int_or_none(video_data.get('number')), 399 'duration': int_or_none(stream_data.get('duration')), 400 'thumbnail': video_data.get('cover_image_url'), 401 'formats': formats, 402 'subtitles': subtitles, 403 } 404