]> jfr.im git - yt-dlp.git/blame - yt_dlp/extractor/weverse.py
[extractor/youtube] Add `ios` to default clients used
[yt-dlp.git] / yt_dlp / extractor / weverse.py
CommitLineData
b844a3f8 1import base64
2import hashlib
3import hmac
4import itertools
5import json
6import re
7import time
8import urllib.error
9import urllib.parse
10import uuid
11
12from .common import InfoExtractor
13from .naver import NaverBaseIE
14from .youtube import YoutubeIE
15from ..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
28class 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):
fd5d93f7 37 if self._API_HEADERS.get('Authorization'):
38 return
39
b844a3f8 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
183class 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
345class 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
421class 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
475class 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
517class 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
535class 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
559class 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)