7 from .common
import InfoExtractor
8 from ..compat
import compat_str
20 class ViuBaseIE(InfoExtractor
):
21 def _call_api(self
, path
, *args
, headers
={}, **kwargs
):
22 response
= self
._download
_json
(
23 f
'https://www.viu.com/api/{path}', *args
, **kwargs
,
24 headers
={**self.geo_verification_headers(), **headers}
)['response']
25 if response
.get('status') != 'success':
26 raise ExtractorError(f
'{self.IE_NAME} said: {response["message"]}', expected
=True)
30 class ViuIE(ViuBaseIE
):
31 _VALID_URL
= r
'(?:viu:|https?://[^/]+\.viu\.com/[a-z]{2}/media/)(?P<id>\d+)'
33 'url': 'https://www.viu.com/en/media/1116705532?containerId=playlist-22168059',
37 'title': 'Citizen Khan - Ep 1',
38 'description': 'md5:d7ea1604f49e5ba79c212c551ce2110e',
41 'skip_download': 'm3u8 download',
43 'skip': 'Geo-restricted to India',
45 'url': 'https://www.viu.com/en/media/1130599965',
49 'title': 'Jealousy Incarnate - Episode 1',
50 'description': 'md5:d3d82375cab969415d2720b6894361e9',
53 'skip_download': 'm3u8 download',
55 'skip': 'Geo-restricted to Indonesia',
57 'url': 'https://india.viu.com/en/media/1126286865',
58 'only_matching': True,
61 def _real_extract(self
, url
):
62 video_id
= self
._match
_id
(url
)
64 video_data
= self
._call
_api
(
65 'clip/load', video_id
, 'Downloading video data', query
={
66 'appid': 'viu_desktop',
71 title
= video_data
['title']
74 url_path
= video_data
.get('urlpathd') or video_data
.get('urlpath')
75 tdirforwhole
= video_data
.get('tdirforwhole')
76 # #EXT-X-BYTERANGE is not supported by native hls downloader
78 # FIXME: It is supported in yt-dlp
79 # hls_file = video_data.get('hlsfile')
80 hls_file
= video_data
.get('jwhlsfile')
81 if url_path
and tdirforwhole
and hls_file
:
82 m3u8_url
= '%s/%s/%s' % (url_path
, tdirforwhole
, hls_file
)
85 # r'(/hlsc_)[a-z]+(\d+\.m3u8)',
86 # r'\1whe\2', video_data['href'])
87 m3u8_url
= video_data
['href']
88 formats
, subtitles
= self
._extract
_m
3u8_formats
_and
_subtitles
(m3u8_url
, video_id
, 'mp4')
89 self
._sort
_formats
(formats
)
91 for key
, value
in video_data
.items():
92 mobj
= re
.match(r
'^subtitle_(?P<lang>[^_]+)_(?P<ext>(vtt|srt))', key
)
95 subtitles
.setdefault(mobj
.group('lang'), []).append({
97 'ext': mobj
.group('ext')
103 'description': video_data
.get('description'),
104 'series': video_data
.get('moviealbumshowname'),
106 'episode_number': int_or_none(video_data
.get('episodeno')),
107 'duration': int_or_none(video_data
.get('duration')),
109 'subtitles': subtitles
,
113 class ViuPlaylistIE(ViuBaseIE
):
114 IE_NAME
= 'viu:playlist'
115 _VALID_URL
= r
'https?://www\.viu\.com/[^/]+/listing/playlist-(?P<id>\d+)'
117 'url': 'https://www.viu.com/en/listing/playlist-22461380',
120 'title': 'The Good Wife',
122 'playlist_count': 16,
123 'skip': 'Geo-restricted to Indonesia',
126 def _real_extract(self
, url
):
127 playlist_id
= self
._match
_id
(url
)
128 playlist_data
= self
._call
_api
(
129 'container/load', playlist_id
,
130 'Downloading playlist info', query
={
131 'appid': 'viu_desktop',
133 'id': 'playlist-' + playlist_id
137 for item
in playlist_data
.get('item', []):
138 item_id
= item
.get('id')
141 item_id
= compat_str(item_id
)
142 entries
.append(self
.url_result(
143 'viu:' + item_id
, 'Viu', item_id
))
145 return self
.playlist_result(
146 entries
, playlist_id
, playlist_data
.get('title'))
149 class ViuOTTIE(InfoExtractor
):
151 _NETRC_MACHINE
= 'viu'
152 _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+)'
154 'url': 'http://www.viu.com/ott/sg/en-us/vod/3421/The%20Prime%20Minister%20and%20I',
158 'title': 'A New Beginning',
159 'description': 'md5:1e7486a619b6399b25ba6a41c0fe5b2c',
162 'skip_download': 'm3u8 download',
165 'skip': 'Geo-restricted to Singapore',
167 'url': 'https://www.viu.com/ott/hk/zh-hk/vod/430078/%E7%AC%AC%E5%85%AD%E6%84%9F-3',
172 'description': 'md5:74d6db47ddd9ddb9c89a05739103ccdb',
175 'episode': '大韓民國的1%',
177 'thumbnail': 'https://d2anahhhmp1ffz.cloudfront.net/1313295781/d2b14f48d008ef2f3a9200c98d8e9b63967b9cc2',
180 'skip_download': 'm3u8 download',
183 'skip': 'Geo-restricted to Hong Kong',
185 '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',
186 'playlist_count': 16,
190 'description': 'md5:b42c95f2b4a316cdd6ae14ca695f33b9',
193 'skip_download': 'm3u8 download',
196 'skip': 'Geo-restricted to Hong Kong',
214 def _detect_error(self
, response
):
215 code
= try_get(response
, lambda x
: x
['status']['code'])
216 if code
and code
> 0:
217 message
= try_get(response
, lambda x
: x
['status']['message'])
218 raise ExtractorError(f
'{self.IE_NAME} said: {message} ({code})', expected
=True)
219 return response
.get('data') or {}
221 def _login(self
, country_code
, video_id
):
222 if self
._user
_token
is None:
223 username
, password
= self
._get
_login
_info
()
227 'Authorization': f
'Bearer {self._auth_codes[country_code]}',
228 'Content-Type': 'application/json'
230 data
= self
._download
_json
(
231 'https://api-gateway-global.viu.com/api/account/validate',
232 video_id
, 'Validating email address', headers
=headers
,
234 'principal': username
,
237 if not data
.get('exists'):
238 raise ExtractorError('Invalid email address')
240 data
= self
._download
_json
(
241 'https://api-gateway-global.viu.com/api/auth/login',
242 video_id
, 'Logging in', headers
=headers
,
245 'password': password
,
248 self
._detect
_error
(data
)
249 self
._user
_token
= data
.get('identity')
250 # need to update with valid user's token else will throw an error again
251 self
._auth
_codes
[country_code
] = data
.get('token')
252 return self
._user
_token
254 def _get_token(self
, country_code
, video_id
):
255 rand
= ''.join(random
.choice('0123456789') for _
in range(10))
256 return self
._download
_json
(
257 f
'https://api-gateway-global.viu.com/api/auth/token?v={rand}000', video_id
,
258 headers
={'Content-Type': 'application/json'}
, note
='Getting bearer token',
260 'countryCode': country_code
.upper(),
261 'platform': 'browser',
262 'platformFlagLabel': 'web',
264 'uuid': str(uuid
.uuid4()),
266 }).encode('utf-8'))['token']
268 def _real_extract(self
, url
):
269 url
, idata
= unsmuggle_url(url
, {})
270 country_code
, lang_code
, video_id
= self
._match
_valid
_url
(url
).groups()
273 'r': 'vod/ajax-detail',
274 'platform_flag_label': 'web',
275 'product_id': video_id
,
278 area_id
= self
._AREA
_ID
.get(country_code
.upper())
280 query
['area_id'] = area_id
282 product_data
= self
._download
_json
(
283 f
'http://www.viu.com/ott/{country_code}/index.php', video_id
,
284 'Downloading video info', query
=query
)['data']
286 video_data
= product_data
.get('current_product')
288 self
.raise_geo_restricted()
290 series_id
= video_data
.get('series_id')
291 if self
._yes
_playlist
(series_id
, video_id
, idata
):
292 series
= product_data
.get('series') or {}
293 product
= series
.get('product')
296 for entry
in sorted(product
, key
=lambda x
: int_or_none(x
.get('number', 0))):
297 item_id
= entry
.get('product_id')
300 entries
.append(self
.url_result(
301 smuggle_url(f
'http://www.viu.com/ott/{country_code}/{lang_code}/vod/{item_id}/',
302 {'force_noplaylist': True}
),
303 ViuOTTIE
, str(item_id
), entry
.get('synopsis', '').strip()))
305 return self
.playlist_result(entries
, series_id
, series
.get('name'), series
.get('description'))
307 duration_limit
= False
309 'ccs_product_id': video_data
['ccs_product_id'],
310 'language_flag_id': self
._LANGUAGE
_FLAG
.get(lang_code
.lower()) or '3',
313 def download_playback():
314 stream_data
= self
._download
_json
(
315 'https://api-gateway-global.viu.com/api/playback/distribute',
316 video_id
=video_id
, query
=query
, fatal
=False, note
='Downloading stream info',
318 'Authorization': f
'Bearer {self._auth_codes[country_code]}',
322 return self
._detect
_error
(stream_data
).get('stream')
324 if not self
._auth
_codes
.get(country_code
):
325 self
._auth
_codes
[country_code
] = self
._get
_token
(country_code
, video_id
)
329 stream_data
= download_playback()
330 except (ExtractorError
, KeyError):
331 token
= self
._login
(country_code
, video_id
)
332 if token
is not None:
333 query
['identity'] = token
335 # The content is Preview or for VIP only.
336 # We can try to bypass the duration which is limited to 3mins only
337 duration_limit
, query
['duration'] = True, '180'
339 stream_data
= download_playback()
340 except (ExtractorError
, KeyError):
341 if token
is not None:
343 self
.raise_login_required(method
='password')
345 raise ExtractorError('Cannot get stream info', expected
=True)
348 for vid_format
, stream_url
in (stream_data
.get('url') or {}).items():
349 height
= int(self
._search
_regex
(r
's(\d+)p', vid_format
, 'height', default
=None))
351 # bypass preview duration limit
353 old_stream_url
= urllib
.parse
.urlparse(stream_url
)
354 query
= dict(urllib
.parse
.parse_qsl(old_stream_url
.query
, keep_blank_values
=True))
356 'duration': video_data
.get('time_duration') or '9999999',
357 'duration_start': '0',
359 stream_url
= old_stream_url
._replace
(query
=urllib
.parse
.urlencode(query
)).geturl()
362 'format_id': vid_format
,
366 'filesize': try_get(stream_data
, lambda x
: x
['size'][vid_format
], int)
368 self
._sort
_formats
(formats
)
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
,