[ie/dropout] Fix extraction (#12102)
[yt-dlp.git] / yt_dlp / extractor / weverse.py
blob53ad1100d26f9459bb83dd1f6182553acf16c37f
1 import base64
2 import hashlib
3 import hmac
4 import itertools
5 import json
6 import re
7 import time
8 import urllib.parse
9 import uuid
11 from .common import InfoExtractor
12 from .naver import NaverBaseIE
13 from .youtube import YoutubeIE
14 from ..networking.exceptions import HTTPError
15 from ..utils import (
16 ExtractorError,
17 UserNotLive,
18 float_or_none,
19 int_or_none,
20 str_or_none,
21 traverse_obj,
22 try_call,
23 update_url_query,
24 url_or_none,
28 class WeverseBaseIE(InfoExtractor):
29 _NETRC_MACHINE = 'weverse'
30 _ACCOUNT_API_BASE = 'https://accountapi.weverse.io/web/api'
31 _API_HEADERS = {
32 'Accept': 'application/json',
33 'Referer': 'https://weverse.io/',
34 'WEV-device-Id': str(uuid.uuid4()),
37 def _perform_login(self, username, password):
38 if self._API_HEADERS.get('Authorization'):
39 return
41 headers = {
42 'x-acc-app-secret': '5419526f1c624b38b10787e5c10b2a7a',
43 'x-acc-app-version': '3.3.6',
44 'x-acc-language': 'en',
45 'x-acc-service-id': 'weverse',
46 'x-acc-trace-id': str(uuid.uuid4()),
47 'x-clog-user-device-id': str(uuid.uuid4()),
49 valid_username = traverse_obj(self._download_json(
50 f'{self._ACCOUNT_API_BASE}/v2/signup/email/status', None, note='Checking username',
51 query={'email': username}, headers=headers, expected_status=(400, 404)), 'hasPassword')
52 if not valid_username:
53 raise ExtractorError('Invalid username provided', expected=True)
55 headers['content-type'] = 'application/json'
56 try:
57 auth = self._download_json(
58 f'{self._ACCOUNT_API_BASE}/v3/auth/token/by-credentials', None, data=json.dumps({
59 'email': username,
60 'otpSessionId': 'BY_PASS',
61 'password': password,
62 }, separators=(',', ':')).encode(), headers=headers, note='Logging in')
63 except ExtractorError as e:
64 if isinstance(e.cause, HTTPError) and e.cause.status == 401:
65 raise ExtractorError('Invalid password provided', expected=True)
66 raise
68 WeverseBaseIE._API_HEADERS['Authorization'] = f'Bearer {auth["accessToken"]}'
70 def _real_initialize(self):
71 if self._API_HEADERS.get('Authorization'):
72 return
74 token = try_call(lambda: self._get_cookies('https://weverse.io/')['we2_access_token'].value)
75 if token:
76 WeverseBaseIE._API_HEADERS['Authorization'] = f'Bearer {token}'
78 def _call_api(self, ep, video_id, data=None, note='Downloading API JSON'):
79 # Ref: https://ssl.pstatic.net/static/wevweb/2_3_2_11101725/public/static/js/2488.a09b41ff.chunk.js
80 # From https://ssl.pstatic.net/static/wevweb/2_3_2_11101725/public/static/js/main.e206f7c1.js:
81 key = b'1b9cb6378d959b45714bec49971ade22e6e24e42'
82 api_path = update_url_query(ep, {
83 # 'gcc': 'US',
84 'appId': 'be4d79eb8fc7bd008ee82c8ec4ff6fd4',
85 'language': 'en',
86 'os': 'WEB',
87 'platform': 'WEB',
88 'wpf': 'pc',
90 wmsgpad = int(time.time() * 1000)
91 wmd = base64.b64encode(hmac.HMAC(
92 key, f'{api_path[:255]}{wmsgpad}'.encode(), digestmod=hashlib.sha1).digest()).decode()
93 headers = {'Content-Type': 'application/json'} if data else {}
94 try:
95 return self._download_json(
96 f'https://global.apis.naver.com/weverse/wevweb{api_path}', video_id, note=note,
97 data=data, headers={**self._API_HEADERS, **headers}, query={
98 'wmsgpad': wmsgpad,
99 'wmd': wmd,
101 except ExtractorError as e:
102 if isinstance(e.cause, HTTPError) and e.cause.status == 401:
103 self.raise_login_required(
104 'Session token has expired. Log in again or refresh cookies in browser')
105 elif isinstance(e.cause, HTTPError) and e.cause.status == 403:
106 if 'Authorization' in self._API_HEADERS:
107 raise ExtractorError('Your account does not have access to this content', expected=True)
108 self.raise_login_required()
109 raise
111 def _call_post_api(self, video_id):
112 path = '' if 'Authorization' in self._API_HEADERS else '/preview'
113 return self._call_api(f'/post/v1.0/post-{video_id}{path}?fieldSet=postV1', video_id)
115 def _get_community_id(self, channel):
116 return str(self._call_api(
117 f'/community/v1.0/communityIdUrlPathByUrlPathArtistCode?keyword={channel}',
118 channel, note='Fetching community ID')['communityId'])
120 def _get_formats(self, data, video_id):
121 formats = traverse_obj(data, ('videos', 'list', lambda _, v: url_or_none(v['source']), {
122 'url': 'source',
123 'width': ('encodingOption', 'width', {int_or_none}),
124 'height': ('encodingOption', 'height', {int_or_none}),
125 'vcodec': 'type',
126 'vbr': ('bitrate', 'video', {int_or_none}),
127 'abr': ('bitrate', 'audio', {int_or_none}),
128 'filesize': ('size', {int_or_none}),
129 'format_id': ('encodingOption', 'id', {str_or_none}),
132 for stream in traverse_obj(data, ('streams', lambda _, v: v['type'] == 'HLS' and url_or_none(v['source']))):
133 query = {}
134 for param in traverse_obj(stream, ('keys', lambda _, v: v['type'] == 'param' and v['name'])):
135 query[param['name']] = param.get('value', '')
136 fmts = self._extract_m3u8_formats(
137 stream['source'], video_id, 'mp4', m3u8_id='hls', fatal=False, query=query)
138 if query:
139 for fmt in fmts:
140 fmt['url'] = update_url_query(fmt['url'], query)
141 fmt['extra_param_to_segment_url'] = urllib.parse.urlencode(query)
142 formats.extend(fmts)
144 return formats
146 def _get_subs(self, caption_url):
147 subs_ext_re = r'\.(?:ttml|vtt)'
148 replace_ext = lambda x, y: re.sub(subs_ext_re, y, x)
149 if re.search(subs_ext_re, caption_url):
150 return [replace_ext(caption_url, '.ttml'), replace_ext(caption_url, '.vtt')]
151 return [caption_url]
153 def _parse_post_meta(self, metadata):
154 return traverse_obj(metadata, {
155 'title': ((('extension', 'mediaInfo', 'title'), 'title'), {str}),
156 'description': ((('extension', 'mediaInfo', 'body'), 'body'), {str}),
157 'uploader': ('author', 'profileName', {str}),
158 'uploader_id': ('author', 'memberId', {str}),
159 'creators': ('community', 'communityName', {str}, all),
160 'channel_id': (('community', 'author'), 'communityId', {str_or_none}),
161 'duration': ('extension', 'video', 'playTime', {float_or_none}),
162 'timestamp': ('publishedAt', {int_or_none(scale=1000)}),
163 'release_timestamp': ('extension', 'video', 'onAirStartAt', {int_or_none(scale=1000)}),
164 'thumbnail': ('extension', (('mediaInfo', 'thumbnail', 'url'), ('video', 'thumb')), {url_or_none}),
165 'view_count': ('extension', 'video', 'playCount', {int_or_none}),
166 'like_count': ('extension', 'video', 'likeCount', {int_or_none}),
167 'comment_count': ('commentCount', {int_or_none}),
168 }, get_all=False)
170 def _extract_availability(self, data):
171 return self._availability(**traverse_obj(data, ((('extension', 'video'), None), {
172 'needs_premium': 'paid',
173 'needs_subscription': 'membershipOnly',
174 }), get_all=False, expected_type=bool), needs_auth=True)
176 def _extract_live_status(self, data):
177 data = traverse_obj(data, ('extension', 'video', {dict})) or {}
178 if data.get('type') == 'LIVE':
179 return traverse_obj({
180 'ONAIR': 'is_live',
181 'DONE': 'post_live',
182 'STANDBY': 'is_upcoming',
183 'DELAY': 'is_upcoming',
184 }, (data.get('status'), {str})) or 'is_live'
185 return 'was_live' if data.get('liveToVod') else 'not_live'
188 class WeverseIE(WeverseBaseIE):
189 _VALID_URL = r'https?://(?:www\.|m\.)?weverse\.io/(?P<artist>[^/?#]+)/live/(?P<id>[\d-]+)'
190 _TESTS = [{
191 'url': 'https://weverse.io/billlie/live/0-107323480',
192 'md5': '1fa849f00181eef9100d3c8254c47979',
193 'info_dict': {
194 'id': '0-107323480',
195 'ext': 'mp4',
196 'title': '행복한 평이루💜',
197 'description': '',
198 'uploader': 'Billlie',
199 'uploader_id': '5ae14aed7b7cdc65fa87c41fe06cc936',
200 'channel': 'billlie',
201 'channel_id': '72',
202 'channel_url': 'https://weverse.io/billlie',
203 'creators': ['Billlie'],
204 'timestamp': 1666262062,
205 'upload_date': '20221020',
206 'release_timestamp': 1666262058,
207 'release_date': '20221020',
208 'duration': 3102,
209 'thumbnail': r're:^https?://.*\.jpe?g$',
210 'view_count': int,
211 'like_count': int,
212 'comment_count': int,
213 'availability': 'needs_auth',
214 'live_status': 'was_live',
216 }, {
217 'url': 'https://weverse.io/lesserafim/live/2-102331763',
218 'md5': 'e46125c08b13a6c8c1f4565035cca987',
219 'info_dict': {
220 'id': '2-102331763',
221 'ext': 'mp4',
222 'title': '🎂김채원 생신🎂',
223 'description': '🎂김채원 생신🎂',
224 'uploader': 'LE SSERAFIM ',
225 'uploader_id': 'd26ddc1e258488a0a2b795218d14d59d',
226 'channel': 'lesserafim',
227 'channel_id': '47',
228 'channel_url': 'https://weverse.io/lesserafim',
229 'creators': ['LE SSERAFIM'],
230 'timestamp': 1659353400,
231 'upload_date': '20220801',
232 'release_timestamp': 1659353400,
233 'release_date': '20220801',
234 'duration': 3006,
235 'thumbnail': r're:^https?://.*\.jpe?g$',
236 'view_count': int,
237 'like_count': int,
238 'comment_count': int,
239 'availability': 'needs_auth',
240 'live_status': 'was_live',
241 'subtitles': {
242 'id_ID': 'count:2',
243 'en_US': 'count:2',
244 'es_ES': 'count:2',
245 'vi_VN': 'count:2',
246 'th_TH': 'count:2',
247 'zh_CN': 'count:2',
248 'zh_TW': 'count:2',
249 'ja_JP': 'count:2',
250 'ko_KR': 'count:2',
253 }, {
254 'url': 'https://weverse.io/treasure/live/2-117230416',
255 'info_dict': {
256 'id': '2-117230416',
257 'ext': 'mp4',
258 'title': r're:스껄도려님 첫 스무살 생파🦋',
259 'description': '',
260 'uploader': 'TREASURE',
261 'uploader_id': '77eabbc449ca37f7970054a136f60082',
262 'channel': 'treasure',
263 'channel_id': '20',
264 'channel_url': 'https://weverse.io/treasure',
265 'creator': 'TREASURE',
266 'timestamp': 1680667651,
267 'upload_date': '20230405',
268 'release_timestamp': 1680667639,
269 'release_date': '20230405',
270 'thumbnail': r're:^https?://.*\.jpe?g$',
271 'view_count': int,
272 'like_count': int,
273 'comment_count': int,
274 'availability': 'needs_auth',
275 'live_status': 'is_live',
277 'skip': 'Livestream has ended',
280 def _real_extract(self, url):
281 channel, video_id = self._match_valid_url(url).group('artist', 'id')
282 post = self._call_post_api(video_id)
283 api_video_id = post['extension']['video']['videoId']
284 availability = self._extract_availability(post)
285 live_status = self._extract_live_status(post)
286 video_info, formats = {}, []
288 if live_status == 'is_upcoming':
289 self.raise_no_formats('Livestream has not yet started', expected=True)
291 elif live_status == 'is_live':
292 video_info = self._call_api(
293 f'/video/v1.2/lives/{api_video_id}/playInfo?preview.format=json&preview.version=v2',
294 video_id, note='Downloading live JSON')
295 playback = self._parse_json(video_info['lipPlayback'], video_id)
296 m3u8_url = traverse_obj(playback, (
297 'media', lambda _, v: v['protocol'] == 'HLS', 'path', {url_or_none}), get_all=False)
298 formats = self._extract_m3u8_formats(m3u8_url, video_id, 'mp4', m3u8_id='hls', live=True)
300 elif live_status == 'post_live':
301 if availability in ('premium_only', 'subscriber_only'):
302 self.report_drm(video_id)
303 self.raise_no_formats(
304 'Livestream has ended and downloadable VOD is not available', expected=True)
306 else:
307 infra_video_id = post['extension']['video']['infraVideoId']
308 in_key = self._call_api(
309 f'/video/v1.1/vod/{api_video_id}/inKey?preview=false', video_id,
310 data=b'{}', note='Downloading VOD API key')['inKey']
312 video_info = self._download_json(
313 f'https://global.apis.naver.com/rmcnmv/rmcnmv/vod/play/v2.0/{infra_video_id}',
314 video_id, note='Downloading VOD JSON', query={
315 'key': in_key,
316 'sid': traverse_obj(post, ('extension', 'video', 'serviceId')) or '2070',
317 'pid': str(uuid.uuid4()),
318 'nonce': int(time.time() * 1000),
319 'devt': 'html5_pc',
320 'prv': 'Y' if post.get('membershipOnly') else 'N',
321 'aup': 'N',
322 'stpb': 'N',
323 'cpl': 'en',
324 'env': 'prod',
325 'lc': 'en',
326 'adi': '[{"adSystem":"null"}]',
327 'adu': '/',
330 formats = self._get_formats(video_info, video_id)
331 has_drm = traverse_obj(video_info, ('meta', 'provider', 'name', {str.lower})) == 'drm'
332 if has_drm and formats:
333 self.report_warning(
334 'Requested content is DRM-protected, only a 30-second preview is available', video_id)
335 elif has_drm and not formats:
336 self.report_drm(video_id)
338 return {
339 'id': video_id,
340 'channel': channel,
341 'channel_url': f'https://weverse.io/{channel}',
342 'formats': formats,
343 'availability': availability,
344 'live_status': live_status,
345 **self._parse_post_meta(post),
346 **NaverBaseIE.process_subtitles(video_info, self._get_subs),
350 class WeverseMediaIE(WeverseBaseIE):
351 _VALID_URL = r'https?://(?:www\.|m\.)?weverse\.io/(?P<artist>[^/?#]+)/media/(?P<id>[\d-]+)'
352 _TESTS = [{
353 'url': 'https://weverse.io/billlie/media/4-116372884',
354 'info_dict': {
355 'id': 'e-C9wLSQs6o',
356 'ext': 'mp4',
357 'title': 'Billlie | \'EUNOIA\' Performance Video (heartbeat ver.)',
358 'description': 'md5:6181caaf2a2397bca913ffe368c104e5',
359 'channel': 'Billlie',
360 'channel_id': 'UCyc9sUCxELTDK9vELO5Fzeg',
361 'channel_url': 'https://www.youtube.com/channel/UCyc9sUCxELTDK9vELO5Fzeg',
362 'uploader': 'Billlie',
363 'uploader_id': '@Billlie',
364 'uploader_url': 'https://www.youtube.com/@Billlie',
365 'upload_date': '20230403',
366 'timestamp': 1680533992,
367 'duration': 211,
368 'age_limit': 0,
369 'playable_in_embed': True,
370 'live_status': 'not_live',
371 'availability': 'public',
372 'view_count': int,
373 'comment_count': int,
374 'like_count': int,
375 'channel_follower_count': int,
376 'thumbnail': 'https://i.ytimg.com/vi/e-C9wLSQs6o/maxresdefault.jpg',
377 'categories': ['Entertainment'],
378 'tags': 'count:7',
379 'channel_is_verified': True,
380 'heatmap': 'count:100',
382 }, {
383 'url': 'https://weverse.io/billlie/media/3-102914520',
384 'md5': '031551fcbd716bc4f080cb6174a43d8a',
385 'info_dict': {
386 'id': '3-102914520',
387 'ext': 'mp4',
388 'title': 'From. SUHYEON🌸',
389 'description': 'Billlie 멤버별 독점 영상 공개💙💜',
390 'uploader': 'Billlie_official',
391 'uploader_id': 'f569c6e92f7eaffef0a395037dcaa54f',
392 'channel': 'billlie',
393 'channel_id': '72',
394 'channel_url': 'https://weverse.io/billlie',
395 'creators': ['Billlie'],
396 'timestamp': 1662174000,
397 'upload_date': '20220903',
398 'release_timestamp': 1662174000,
399 'release_date': '20220903',
400 'duration': 17.0,
401 'thumbnail': r're:^https?://.*\.jpe?g$',
402 'view_count': int,
403 'like_count': int,
404 'comment_count': int,
405 'availability': 'needs_auth',
406 'live_status': 'not_live',
410 def _real_extract(self, url):
411 channel, video_id = self._match_valid_url(url).group('artist', 'id')
412 post = self._call_post_api(video_id)
413 media_type = traverse_obj(post, ('extension', 'mediaInfo', 'mediaType', {str.lower}))
414 youtube_id = traverse_obj(post, ('extension', 'youtube', 'youtubeVideoId', {str}))
416 if media_type == 'vod':
417 return self.url_result(f'https://weverse.io/{channel}/live/{video_id}', WeverseIE)
418 elif media_type == 'youtube' and youtube_id:
419 return self.url_result(youtube_id, YoutubeIE)
420 elif media_type == 'image':
421 self.raise_no_formats('No video content found in webpage', expected=True)
422 elif media_type:
423 raise ExtractorError(f'Unsupported media type "{media_type}"')
425 self.raise_no_formats('No video content found in webpage')
428 class WeverseMomentIE(WeverseBaseIE):
429 _VALID_URL = r'https?://(?:www\.|m\.)?weverse\.io/(?P<artist>[^/?#]+)/moment/(?P<uid>[\da-f]+)/post/(?P<id>[\d-]+)'
430 _TESTS = [{
431 'url': 'https://weverse.io/secretnumber/moment/66a07e164b56a696ee71c99315ffe27b/post/1-117229444',
432 'md5': '87733ac19a54081b7dfc2442036d282b',
433 'info_dict': {
434 'id': '1-117229444',
435 'ext': 'mp4',
436 'title': '今日もめっちゃいい天気☀️🌤️',
437 'uploader': '레아',
438 'uploader_id': '66a07e164b56a696ee71c99315ffe27b',
439 'channel': 'secretnumber',
440 'channel_id': '56',
441 'creators': ['SECRET NUMBER'],
442 'duration': 10,
443 'upload_date': '20230405',
444 'timestamp': 1680653968,
445 'thumbnail': r're:^https?://.*\.jpe?g$',
446 'like_count': int,
447 'comment_count': int,
448 'availability': 'needs_auth',
452 def _real_extract(self, url):
453 channel, uploader_id, video_id = self._match_valid_url(url).group('artist', 'uid', 'id')
454 post = self._call_post_api(video_id)
455 api_video_id = post['extension']['moment']['video']['videoId']
456 video_info = self._call_api(
457 f'/cvideo/v1.0/cvideo-{api_video_id}/playInfo?videoId={api_video_id}', video_id,
458 note='Downloading moment JSON')['playInfo']
460 return {
461 'id': video_id,
462 'channel': channel,
463 'uploader_id': uploader_id,
464 'formats': self._get_formats(video_info, video_id),
465 'availability': self._extract_availability(post),
466 **traverse_obj(post, {
467 'title': ((('extension', 'moment', 'body'), 'body'), {str}),
468 'uploader': ('author', 'profileName', {str}),
469 'creator': (('community', 'author'), 'communityName', {str}),
470 'channel_id': (('community', 'author'), 'communityId', {str_or_none}),
471 'duration': ('extension', 'moment', 'video', 'uploadInfo', 'playTime', {float_or_none}),
472 'timestamp': ('publishedAt', {int_or_none(scale=1000)}),
473 'thumbnail': ('extension', 'moment', 'video', 'uploadInfo', 'imageUrl', {url_or_none}),
474 'like_count': ('emotionCount', {int_or_none}),
475 'comment_count': ('commentCount', {int_or_none}),
476 }, get_all=False),
477 **NaverBaseIE.process_subtitles(video_info, self._get_subs),
481 class WeverseTabBaseIE(WeverseBaseIE):
482 _ENDPOINT = None
483 _PATH = None
484 _QUERY = {}
485 _RESULT_IE = None
487 def _entries(self, channel_id, channel, first_page):
488 query = self._QUERY.copy()
490 for page in itertools.count(1):
491 posts = first_page if page == 1 else self._call_api(
492 update_url_query(self._ENDPOINT % channel_id, query), channel,
493 note=f'Downloading {self._PATH} tab page {page}')
495 for post in traverse_obj(posts, ('data', lambda _, v: v['postId'])):
496 yield self.url_result(
497 f'https://weverse.io/{channel}/{self._PATH}/{post["postId"]}',
498 self._RESULT_IE, post['postId'], **self._parse_post_meta(post),
499 channel=channel, channel_url=f'https://weverse.io/{channel}',
500 availability=self._extract_availability(post),
501 live_status=self._extract_live_status(post))
503 query['after'] = traverse_obj(posts, ('paging', 'nextParams', 'after', {str}))
504 if not query['after']:
505 break
507 def _real_extract(self, url):
508 channel = self._match_id(url)
509 channel_id = self._get_community_id(channel)
511 first_page = self._call_api(
512 update_url_query(self._ENDPOINT % channel_id, self._QUERY), channel,
513 note=f'Downloading {self._PATH} tab page 1')
515 return self.playlist_result(
516 self._entries(channel_id, channel, first_page), f'{channel}-{self._PATH}',
517 **traverse_obj(first_page, ('data', ..., {
518 'playlist_title': ('community', 'communityName', {str}),
519 'thumbnail': ('author', 'profileImageUrl', {url_or_none}),
520 }), get_all=False))
523 class WeverseLiveTabIE(WeverseTabBaseIE):
524 _VALID_URL = r'https?://(?:www\.|m\.)?weverse\.io/(?P<id>[^/?#]+)/live/?(?:[?#]|$)'
525 _TESTS = [{
526 'url': 'https://weverse.io/billlie/live/',
527 'playlist_mincount': 55,
528 'info_dict': {
529 'id': 'billlie-live',
530 'title': 'Billlie',
531 'thumbnail': r're:^https?://.*\.jpe?g$',
535 _ENDPOINT = '/post/v1.0/community-%s/liveTabPosts'
536 _PATH = 'live'
537 _QUERY = {'fieldSet': 'postsV1'}
538 _RESULT_IE = WeverseIE
541 class WeverseMediaTabIE(WeverseTabBaseIE):
542 _VALID_URL = r'https?://(?:www\.|m\.)?weverse\.io/(?P<id>[^/?#]+)/media(?:/|/all|/new)?(?:[?#]|$)'
543 _TESTS = [{
544 'url': 'https://weverse.io/billlie/media/',
545 'playlist_mincount': 231,
546 'info_dict': {
547 'id': 'billlie-media',
548 'title': 'Billlie',
549 'thumbnail': r're:^https?://.*\.jpe?g$',
551 }, {
552 'url': 'https://weverse.io/lesserafim/media/all',
553 'only_matching': True,
554 }, {
555 'url': 'https://weverse.io/lesserafim/media/new',
556 'only_matching': True,
559 _ENDPOINT = '/media/v1.0/community-%s/more'
560 _PATH = 'media'
561 _QUERY = {'fieldSet': 'postsV1', 'filterType': 'RECENT'}
562 _RESULT_IE = WeverseMediaIE
565 class WeverseLiveIE(WeverseBaseIE):
566 _VALID_URL = r'https?://(?:www\.|m\.)?weverse\.io/(?P<id>[^/?#]+)/?(?:[?#]|$)'
567 _TESTS = [{
568 'url': 'https://weverse.io/purplekiss',
569 'info_dict': {
570 'id': '3-116560493',
571 'ext': 'mp4',
572 'title': r're:모하냥🫶🏻',
573 'description': '내일은 금요일~><',
574 'uploader': '채인',
575 'uploader_id': '1ffb1d9d904d6b3db2783f876eb9229d',
576 'channel': 'purplekiss',
577 'channel_id': '35',
578 'channel_url': 'https://weverse.io/purplekiss',
579 'creators': ['PURPLE KISS'],
580 'timestamp': 1680780892,
581 'upload_date': '20230406',
582 'release_timestamp': 1680780883,
583 'release_date': '20230406',
584 'thumbnail': 'https://weverse-live.pstatic.net/v1.0/live/62044/thumb',
585 'view_count': int,
586 'like_count': int,
587 'comment_count': int,
588 'availability': 'needs_auth',
589 'live_status': 'is_live',
591 'skip': 'Livestream has ended',
592 }, {
593 'url': 'https://weverse.io/lesserafim',
594 'info_dict': {
595 'id': '4-181521628',
596 'ext': 'mp4',
597 'title': r're:심심해서요',
598 'description': '',
599 'uploader': '채채🤎',
600 'uploader_id': 'd49b8b06f3cc1d92d655b25ab27ac2e7',
601 'channel': 'lesserafim',
602 'channel_id': '47',
603 'creators': ['LE SSERAFIM'],
604 'channel_url': 'https://weverse.io/lesserafim',
605 'timestamp': 1728570273,
606 'upload_date': '20241010',
607 'release_timestamp': 1728570264,
608 'release_date': '20241010',
609 'thumbnail': r're:https://phinf\.wevpstatic\.net/.+\.png',
610 'view_count': int,
611 'like_count': int,
612 'comment_count': int,
613 'availability': 'needs_auth',
614 'live_status': 'is_live',
616 'skip': 'Livestream has ended',
617 }, {
618 'url': 'https://weverse.io/billlie/',
619 'only_matching': True,
622 def _real_extract(self, url):
623 channel = self._match_id(url)
624 channel_id = self._get_community_id(channel)
626 video_id = traverse_obj(
627 self._call_api(update_url_query(f'/post/v1.0/community-{channel_id}/liveTab', {
628 'debugMessage': 'true',
629 'fields': 'onAirLivePosts.fieldSet(postsV1).limit(10),reservedLivePosts.fieldSet(postsV1).limit(10)',
630 }), channel, note='Downloading live JSON'), (
631 ('onAirLivePosts', 'reservedLivePosts'), 'data',
632 lambda _, v: self._extract_live_status(v) in ('is_live', 'is_upcoming'), 'postId', {str}),
633 get_all=False)
635 if not video_id:
636 raise UserNotLive(video_id=channel)
638 return self.url_result(f'https://weverse.io/{channel}/live/{video_id}', WeverseIE)