7 from .common
import InfoExtractor
22 class ViuBaseIE(InfoExtractor
):
23 def _call_api(self
, path
, *args
, headers
={}, **kwargs
):
24 response
= self
._download
_json
(
25 f
'https://www.viu.com/api/{path}', *args
, **kwargs
,
26 headers
={**self
.geo_verification_headers(), **headers
})['response']
27 if response
.get('status') != 'success':
28 raise ExtractorError(f
'{self.IE_NAME} said: {response["message"]}', expected
=True)
32 class ViuIE(ViuBaseIE
):
33 _VALID_URL
= r
'(?:viu:|https?://[^/]+\.viu\.com/[a-z]{2}/media/)(?P<id>\d+)'
35 'url': 'https://www.viu.com/en/media/1116705532?containerId=playlist-22168059',
39 'title': 'Citizen Khan - Ep 1',
40 'description': 'md5:d7ea1604f49e5ba79c212c551ce2110e',
43 'skip_download': 'm3u8 download',
45 'skip': 'Geo-restricted to India',
47 'url': 'https://www.viu.com/en/media/1130599965',
51 'title': 'Jealousy Incarnate - Episode 1',
52 'description': 'md5:d3d82375cab969415d2720b6894361e9',
55 'skip_download': 'm3u8 download',
57 'skip': 'Geo-restricted to Indonesia',
59 'url': 'https://india.viu.com/en/media/1126286865',
60 'only_matching': True,
63 def _real_extract(self
, url
):
64 video_id
= self
._match
_id
(url
)
66 video_data
= self
._call
_api
(
67 'clip/load', video_id
, 'Downloading video data', query
={
68 'appid': 'viu_desktop',
73 title
= video_data
['title']
76 url_path
= video_data
.get('urlpathd') or video_data
.get('urlpath')
77 tdirforwhole
= video_data
.get('tdirforwhole')
78 # #EXT-X-BYTERANGE is not supported by native hls downloader
80 # FIXME: It is supported in yt-dlp
81 # hls_file = video_data.get('hlsfile')
82 hls_file
= video_data
.get('jwhlsfile')
83 if url_path
and tdirforwhole
and hls_file
:
84 m3u8_url
= f
'{url_path}/{tdirforwhole}/{hls_file}'
87 # r'(/hlsc_)[a-z]+(\d+\.m3u8)',
88 # r'\1whe\2', video_data['href'])
89 m3u8_url
= video_data
['href']
90 formats
, subtitles
= self
._extract
_m
3u8_formats
_and
_subtitles
(m3u8_url
, video_id
, 'mp4')
92 for key
, value
in video_data
.items():
93 mobj
= re
.match(r
'subtitle_(?P<lang>[^_]+)_(?P<ext>(vtt|srt))', key
)
96 subtitles
.setdefault(mobj
.group('lang'), []).append({
98 'ext': mobj
.group('ext'),
104 'description': video_data
.get('description'),
105 'series': video_data
.get('moviealbumshowname'),
107 'episode_number': int_or_none(video_data
.get('episodeno')),
108 'duration': int_or_none(video_data
.get('duration')),
110 'subtitles': subtitles
,
114 class ViuPlaylistIE(ViuBaseIE
):
115 IE_NAME
= 'viu:playlist'
116 _VALID_URL
= r
'https?://www\.viu\.com/[^/]+/listing/playlist-(?P<id>\d+)'
118 'url': 'https://www.viu.com/en/listing/playlist-22461380',
121 'title': 'The Good Wife',
123 'playlist_count': 16,
124 'skip': 'Geo-restricted to Indonesia',
127 def _real_extract(self
, url
):
128 playlist_id
= self
._match
_id
(url
)
129 playlist_data
= self
._call
_api
(
130 'container/load', playlist_id
,
131 'Downloading playlist info', query
={
132 'appid': 'viu_desktop',
134 'id': 'playlist-' + playlist_id
,
138 for item
in playlist_data
.get('item', []):
139 item_id
= item
.get('id')
142 item_id
= str(item_id
)
143 entries
.append(self
.url_result(
144 'viu:' + item_id
, 'Viu', item_id
))
146 return self
.playlist_result(
147 entries
, playlist_id
, playlist_data
.get('title'))
150 class ViuOTTIE(InfoExtractor
):
152 _NETRC_MACHINE
= 'viu'
153 _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+)'
155 'url': 'http://www.viu.com/ott/sg/en-us/vod/3421/The%20Prime%20Minister%20and%20I',
159 'title': 'A New Beginning',
160 'description': 'md5:1e7486a619b6399b25ba6a41c0fe5b2c',
163 'skip_download': 'm3u8 download',
166 'skip': 'Geo-restricted to Singapore',
168 'url': 'https://www.viu.com/ott/hk/zh-hk/vod/430078/%E7%AC%AC%E5%85%AD%E6%84%9F-3',
173 'description': 'md5:74d6db47ddd9ddb9c89a05739103ccdb',
176 'episode': '大韓民國的1%',
178 'thumbnail': 'https://d2anahhhmp1ffz.cloudfront.net/1313295781/d2b14f48d008ef2f3a9200c98d8e9b63967b9cc2',
181 'skip_download': 'm3u8 download',
184 'skip': 'Geo-restricted to Hong Kong',
186 'url': 'https://www.viu.com/ott/hk/zh-hk/vod/444666/%E6%88%91%E7%9A%84%E5%AE%A4%E5%8F%8B%E6%98%AF%E4%B9%9D%E5%B0%BE%E7%8B%90',
187 'playlist_count': 16,
191 'description': 'md5:b42c95f2b4a316cdd6ae14ca695f33b9',
194 'skip_download': 'm3u8 download',
197 'skip': 'Geo-restricted to Hong Kong',
215 def _detect_error(self
, response
):
216 code
= try_get(response
, lambda x
: x
['status']['code'])
217 if code
and code
> 0:
218 message
= try_get(response
, lambda x
: x
['status']['message'])
219 raise ExtractorError(f
'{self.IE_NAME} said: {message} ({code})', expected
=True)
220 return response
.get('data') or {}
222 def _login(self
, country_code
, video_id
):
223 if self
._user
_token
is None:
224 username
, password
= self
._get
_login
_info
()
228 'Authorization': f
'Bearer {self._auth_codes[country_code]}',
229 'Content-Type': 'application/json',
231 data
= self
._download
_json
(
232 'https://api-gateway-global.viu.com/api/account/validate',
233 video_id
, 'Validating email address', headers
=headers
,
235 'principal': username
,
238 if not data
.get('exists'):
239 raise ExtractorError('Invalid email address')
241 data
= self
._download
_json
(
242 'https://api-gateway-global.viu.com/api/auth/login',
243 video_id
, 'Logging in', headers
=headers
,
246 'password': password
,
249 self
._detect
_error
(data
)
250 self
._user
_token
= data
.get('identity')
251 # need to update with valid user's token else will throw an error again
252 self
._auth
_codes
[country_code
] = data
.get('token')
253 return self
._user
_token
255 def _get_token(self
, country_code
, video_id
):
256 rand
= ''.join(random
.choices('0123456789', k
=10))
257 return self
._download
_json
(
258 f
'https://api-gateway-global.viu.com/api/auth/token?v={rand}000', video_id
,
259 headers
={'Content-Type': 'application/json'}, note
='Getting bearer token',
261 'countryCode': country_code
.upper(),
262 'platform': 'browser',
263 'platformFlagLabel': 'web',
265 'uuid': str(uuid
.uuid4()),
267 }).encode())['token']
269 def _real_extract(self
, url
):
270 url
, idata
= unsmuggle_url(url
, {})
271 country_code
, lang_code
, video_id
= self
._match
_valid
_url
(url
).groups()
274 'r': 'vod/ajax-detail',
275 'platform_flag_label': 'web',
276 'product_id': video_id
,
279 area_id
= self
._AREA
_ID
.get(country_code
.upper())
281 query
['area_id'] = area_id
283 product_data
= self
._download
_json
(
284 f
'http://www.viu.com/ott/{country_code}/index.php', video_id
,
285 'Downloading video info', query
=query
)['data']
287 video_data
= product_data
.get('current_product')
289 self
.raise_geo_restricted()
291 series_id
= video_data
.get('series_id')
292 if self
._yes
_playlist
(series_id
, video_id
, idata
):
293 series
= product_data
.get('series') or {}
294 product
= series
.get('product')
297 for entry
in sorted(product
, key
=lambda x
: int_or_none(x
.get('number', 0))):
298 item_id
= entry
.get('product_id')
301 entries
.append(self
.url_result(
302 smuggle_url(f
'http://www.viu.com/ott/{country_code}/{lang_code}/vod/{item_id}/',
303 {'force_noplaylist': True}),
304 ViuOTTIE
, str(item_id
), entry
.get('synopsis', '').strip()))
306 return self
.playlist_result(entries
, series_id
, series
.get('name'), series
.get('description'))
308 duration_limit
= False
310 'ccs_product_id': video_data
['ccs_product_id'],
311 'language_flag_id': self
._LANGUAGE
_FLAG
.get(lang_code
.lower()) or '3',
314 def download_playback():
315 stream_data
= self
._download
_json
(
316 'https://api-gateway-global.viu.com/api/playback/distribute',
317 video_id
=video_id
, query
=query
, fatal
=False, note
='Downloading stream info',
319 'Authorization': f
'Bearer {self._auth_codes[country_code]}',
323 return self
._detect
_error
(stream_data
).get('stream')
325 if not self
._auth
_codes
.get(country_code
):
326 self
._auth
_codes
[country_code
] = self
._get
_token
(country_code
, video_id
)
330 stream_data
= download_playback()
331 except (ExtractorError
, KeyError):
332 token
= self
._login
(country_code
, video_id
)
333 if token
is not None:
334 query
['identity'] = token
336 # The content is Preview or for VIP only.
337 # We can try to bypass the duration which is limited to 3mins only
338 duration_limit
, query
['duration'] = True, '180'
340 stream_data
= download_playback()
341 except (ExtractorError
, KeyError):
342 if token
is not None:
344 self
.raise_login_required(method
='password')
346 raise ExtractorError('Cannot get stream info', expected
=True)
349 for vid_format
, stream_url
in (stream_data
.get('url') or {}).items():
350 height
= int(self
._search
_regex
(r
's(\d+)p', vid_format
, 'height', default
=None))
352 # bypass preview duration limit
354 old_stream_url
= urllib
.parse
.urlparse(stream_url
)
355 query
= dict(urllib
.parse
.parse_qsl(old_stream_url
.query
, keep_blank_values
=True))
357 'duration': video_data
.get('time_duration') or '9999999',
358 'duration_start': '0',
360 stream_url
= old_stream_url
._replace
(query
=urllib
.parse
.urlencode(query
)).geturl()
363 'format_id': vid_format
,
367 'filesize': try_get(stream_data
, lambda x
: x
['size'][vid_format
], int),
371 for sub
in video_data
.get('subtitle') or []:
372 lang
= sub
.get('name') or 'und'
374 subtitles
.setdefault(lang
, []).append({
377 'name': f
'Spoken text for {lang}',
379 if sub
.get('second_subtitle_url'):
380 subtitles
.setdefault(f
'{lang}_ost', []).append({
381 'url': sub
['second_subtitle_url'],
383 'name': f
'On-screen text for {lang}',
386 title
= strip_or_none(video_data
.get('synopsis'))
390 'description': video_data
.get('description'),
391 'series': try_get(product_data
, lambda x
: x
['series']['name']),
393 'episode_number': int_or_none(video_data
.get('number')),
394 'duration': int_or_none(stream_data
.get('duration')),
395 'thumbnail': url_or_none(video_data
.get('cover_image_url')),
397 'subtitles': subtitles
,
401 class ViuOTTIndonesiaBaseIE(InfoExtractor
):
407 'appid': 'viu_desktop',
408 'platform': 'desktop',
411 _DEVICE_ID
= str(uuid
.uuid4())
412 _SESSION_ID
= str(uuid
.uuid4())
416 'x-session-id': _SESSION_ID
,
417 'x-client': 'browser',
420 _AGE_RATINGS_MAPPER
= {
425 def _real_initialize(self
):
426 ViuOTTIndonesiaBaseIE
._TOKEN
= self
._download
_json
(
427 'https://um.viuapi.io/user/identity', None,
428 headers
={'Content-type': 'application/json', **self
._HEADERS
},
429 query
={**self
._BASE
_QUERY
, 'iid': self
._DEVICE
_ID
},
430 data
=json
.dumps({'deviceId': self
._DEVICE
_ID
}).encode(),
431 note
='Downloading token information')['token']
434 class ViuOTTIndonesiaIE(ViuOTTIndonesiaBaseIE
):
435 _VALID_URL
= r
'https?://www\.viu\.com/ott/\w+/\w+/all/video-[\w-]+-(?P<id>\d+)'
437 'url': 'https://www.viu.com/ott/id/id/all/video-japanese-drama-tv_shows-detective_conan_episode_793-1165863142?containerId=playlist-26271226',
441 'episode_number': 793,
442 'episode': 'Episode 793',
443 'title': 'Detective Conan - Episode 793',
445 'description': 'md5:b79d55345bc1e0217ece22616267c9a5',
446 'thumbnail': 'https://vuclipi-a.akamaihd.net/p/cloudinary/h_171,w_304,dpr_1.5,f_auto,c_thumb,q_auto:low/1165863189/d-1',
447 'upload_date': '20210101',
448 'timestamp': 1609459200,
451 'url': 'https://www.viu.com/ott/id/id/all/video-korean-reality-tv_shows-entertainment_weekly_episode_1622-1118617054',
455 'episode_number': 1622,
456 'episode': 'Episode 1622',
457 'description': 'md5:6d68ca450004020113e9bf27ad99f0f8',
458 'title': 'Entertainment Weekly - Episode 1622',
460 'thumbnail': 'https://vuclipi-a.akamaihd.net/p/cloudinary/h_171,w_304,dpr_1.5,f_auto,c_thumb,q_auto:low/1120187848/d-1',
461 'timestamp': 1420070400,
462 'upload_date': '20150101',
463 'cast': ['Shin Hyun-joon', 'Lee Da-Hee'],
467 'url': 'https://www.viu.com/ott/id/id/all/video-japanese-trailer-tv_shows-trailer_jujutsu_kaisen_ver_01-1166044219?containerId=playlist-26273140',
471 'upload_date': '20200101',
472 'timestamp': 1577836800,
473 'title': 'Trailer \'Jujutsu Kaisen\' Ver.01',
475 'thumbnail': 'https://vuclipi-a.akamaihd.net/p/cloudinary/h_171,w_304,dpr_1.5,f_auto,c_thumb,q_auto:low/1166044240/d-1',
476 'description': 'Trailer \'Jujutsu Kaisen\' Ver.01',
477 'cast': ['Junya Enoki', ' Yûichi Nakamura', ' Yuma Uchida', 'Asami Seto'],
481 # json ld metadata type equal to Movie instead of TVEpisodes
482 'url': 'https://www.viu.com/ott/id/id/all/video-japanese-animation-movies-demon_slayer_kimetsu_no_yaiba_the_movie_mugen_train-1165892707?containerId=1675060691786',
486 'timestamp': 1577836800,
487 'upload_date': '20200101',
488 'title': 'Demon Slayer - Kimetsu no Yaiba - The Movie: Mugen Train',
491 'thumbnail': 'https://vuclipi-a.akamaihd.net/p/cloudinary/h_171,w_304,dpr_1.5,f_auto,c_thumb,q_auto:low/1165895279/d-1',
492 'description': 'md5:1ce9c35a3aeab384085533f746c87469',
497 def _real_extract(self
, url
):
498 display_id
= self
._match
_id
(url
)
499 webpage
= self
._download
_webpage
(url
, display_id
)
501 video_data
= self
._download
_json
(
502 f
'https://um.viuapi.io/drm/v1/content/{display_id}', display_id
, data
=b
'',
503 headers
={'Authorization': ViuOTTIndonesiaBaseIE
._TOKEN
, **self
._HEADERS
, 'ccode': 'ID'})
504 formats
, subtitles
= self
._extract
_m
3u8_formats
_and
_subtitles
(video_data
['playUrl'], display_id
)
506 initial_state
= self
._search
_json
(
507 r
'window\.__INITIAL_STATE__\s*=', webpage
, 'initial state',
508 display_id
)['content']['clipDetails']
509 for key
, url
in initial_state
.items():
510 lang
, ext
= self
._search
_regex
(
511 r
'^subtitle_(?P<lang>[\w-]+)_(?P<ext>\w+)$', key
, 'subtitle metadata',
512 default
=(None, None), group
=('lang', 'ext'))
514 subtitles
.setdefault(lang
, []).append({
520 subtitles
[lang
].append({
522 'url': f
'{remove_end(initial_state[key], "vtt")}srt',
525 episode
= traverse_obj(list(filter(
526 lambda x
: x
.get('@type') in ('TVEpisode', 'Movie'), self
._yield
_json
_ld
(webpage
, display_id
))), 0) or {}
529 'title': (traverse_obj(initial_state
, 'title', 'display_title')
530 or episode
.get('name')),
531 'description': initial_state
.get('description') or episode
.get('description'),
532 'duration': initial_state
.get('duration'),
533 'thumbnail': traverse_obj(episode
, ('image', 'url')),
534 'timestamp': unified_timestamp(episode
.get('dateCreated')),
536 'subtitles': subtitles
,
537 'episode_number': (traverse_obj(initial_state
, 'episode_no', 'episodeno', expected_type
=int_or_none
)
538 or int_or_none(episode
.get('episodeNumber'))),
539 'cast': traverse_obj(episode
, ('actor', ..., 'name'), default
=None),
540 'age_limit': self
._AGE
_RATINGS
_MAPPER
.get(initial_state
.get('internal_age_rating')),