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