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