11 from .common
import InfoExtractor
12 from .naver
import NaverBaseIE
13 from .youtube
import YoutubeIE
14 from ..networking
.exceptions
import HTTPError
28 class WeverseBaseIE(InfoExtractor
):
29 _NETRC_MACHINE
= 'weverse'
30 _ACCOUNT_API_BASE
= 'https://accountapi.weverse.io/web/api/v2'
32 'Referer': 'https://weverse.io/',
33 'WEV-device-Id': str(uuid
.uuid4()),
36 def _perform_login(self
, username
, password
):
37 if self
._API
_HEADERS
.get('Authorization'):
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()),
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
:
52 raise ExtractorError('Invalid username provided', expected
=True)
54 headers
['content-type'] = 'application/json'
56 auth
= self
._download
_json
(
57 f
'{self._ACCOUNT_API_BASE}/auth/token/by-credentials', None, data
=json
.dumps({
60 }, separators
=(',', ':')).encode(), headers
=headers
, note
='Logging in')
61 except ExtractorError
as e
:
62 if isinstance(e
.cause
, HTTPError
) and e
.cause
.status
== 401:
63 raise ExtractorError('Invalid password provided', expected
=True)
66 WeverseBaseIE
._API
_HEADERS
['Authorization'] = f
'Bearer {auth["accessToken"]}'
68 def _real_initialize(self
):
69 if self
._API
_HEADERS
.get('Authorization'):
72 token
= try_call(lambda: self
._get
_cookies
('https://weverse.io/')['we2_access_token'].value
)
74 WeverseBaseIE
._API
_HEADERS
['Authorization'] = f
'Bearer {token}'
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',
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 {}
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
={
97 except ExtractorError
as e
:
98 if isinstance(e
.cause
, HTTPError
) and e
.cause
.status
== 401:
99 self
.raise_login_required(
100 'Session token has expired. Log in again or refresh cookies in browser')
101 elif isinstance(e
.cause
, HTTPError
) and e
.cause
.status
== 403:
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()
107 def _call_post_api(self
, video_id
):
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
)
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'])
116 def _get_formats(self
, data
, video_id
):
117 formats
= traverse_obj(data
, ('videos', 'list', lambda _
, v
: url_or_none(v
['source']), {
119 'width': ('encodingOption', 'width', {int_or_none}
),
120 'height': ('encodingOption', 'height', {int_or_none}
),
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}
),
128 for stream
in traverse_obj(data
, ('streams', lambda _
, v
: v
['type'] == 'HLS' and url_or_none(v
['source']))):
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
_m
3u8_formats
(
133 stream
['source'], video_id
, 'mp4', m3u8_id
='hls', fatal
=False, query
=query
)
136 fmt
['url'] = update_url_query(fmt
['url'], query
)
137 fmt
['extra_param_to_segment_url'] = urllib
.parse
.urlencode(query
)
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')]
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}
),
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)
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({
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'
184 class WeverseIE(WeverseBaseIE
):
185 _VALID_URL
= r
'https?://(?:www\.|m\.)?weverse\.io/(?P<artist>[^/?#]+)/live/(?P<id>[\d-]+)'
187 'url': 'https://weverse.io/billlie/live/0-107323480',
188 'md5': '1fa849f00181eef9100d3c8254c47979',
194 'uploader': 'Billlie',
195 'uploader_id': '5ae14aed7b7cdc65fa87c41fe06cc936',
196 'channel': 'billlie',
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',
205 'thumbnail': r
're:^https?://.*\.jpe?g$',
208 'comment_count': int,
209 'availability': 'needs_auth',
210 'live_status': 'was_live',
213 'url': 'https://weverse.io/lesserafim/live/2-102331763',
214 'md5': 'e46125c08b13a6c8c1f4565035cca987',
219 'description': '🎂김채원 생신🎂',
220 'uploader': 'LE SSERAFIM ',
221 'uploader_id': 'd26ddc1e258488a0a2b795218d14d59d',
222 'channel': 'lesserafim',
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',
231 'thumbnail': r
're:^https?://.*\.jpe?g$',
234 'comment_count': int,
235 'availability': 'needs_auth',
236 'live_status': 'was_live',
250 'url': 'https://weverse.io/treasure/live/2-117230416',
254 'title': r
're:스껄도려님 첫 스무살 생파🦋',
256 'uploader': 'TREASURE',
257 'uploader_id': '77eabbc449ca37f7970054a136f60082',
258 'channel': 'treasure',
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$',
269 'comment_count': int,
270 'availability': 'needs_auth',
271 'live_status': 'is_live',
273 'skip': 'Livestream has ended',
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
= {}, []
284 if live_status
== 'is_upcoming':
285 self
.raise_no_formats('Livestream has not yet started', expected
=True)
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
_m
3u8_formats
(m3u8_url
, video_id
, 'mp4', m3u8_id
='hls', live
=True)
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)
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']
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
={
312 'sid': traverse_obj(post
, ('extension', 'video', 'serviceId')) or '2070',
313 'pid': str(uuid
.uuid4()),
314 'nonce': int(time
.time() * 1000),
316 'prv': 'Y' if post
.get('membershipOnly') else 'N',
322 'adi': '[{"adSystem":"null"}]',
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
:
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
)
337 'channel_url': f
'https://weverse.io/{channel}',
339 'availability': availability
,
340 'live_status': live_status
,
341 **self
._parse
_post
_meta
(post
),
342 **NaverBaseIE
.process_subtitles(video_info
, self
._get
_subs
),
346 class WeverseMediaIE(WeverseBaseIE
):
347 _VALID_URL
= r
'https?://(?:www\.|m\.)?weverse\.io/(?P<artist>[^/?#]+)/media/(?P<id>[\d-]+)'
349 'url': 'https://weverse.io/billlie/media/4-116372884',
350 'md5': '8efc9cfd61b2f25209eb1a5326314d28',
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',
365 'playable_in_embed': True,
366 'live_status': 'not_live',
367 'availability': 'public',
369 'comment_count': int,
371 'channel_follower_count': int,
372 'thumbnail': 'https://i.ytimg.com/vi/e-C9wLSQs6o/maxresdefault.jpg',
373 'categories': ['Entertainment'],
377 'url': 'https://weverse.io/billlie/media/3-102914520',
378 'md5': '031551fcbd716bc4f080cb6174a43d8a',
382 'title': 'From. SUHYEON🌸',
383 'description': 'Billlie 멤버별 독점 영상 공개💙💜',
384 'uploader': 'Billlie_official',
385 'uploader_id': 'f569c6e92f7eaffef0a395037dcaa54f',
386 'channel': 'billlie',
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',
395 'thumbnail': r
're:^https?://.*\.jpe?g$',
398 'comment_count': int,
399 'availability': 'needs_auth',
400 'live_status': 'not_live',
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}
))
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)
417 raise ExtractorError(f
'Unsupported media type "{media_type}"')
419 self
.raise_no_formats('No video content found in webpage')
422 class WeverseMomentIE(WeverseBaseIE
):
423 _VALID_URL
= r
'https?://(?:www\.|m\.)?weverse\.io/(?P<artist>[^/?#]+)/moment/(?P<uid>[\da-f]+)/post/(?P<id>[\d-]+)'
425 'url': 'https://weverse.io/secretnumber/moment/66a07e164b56a696ee71c99315ffe27b/post/1-117229444',
426 'md5': '87733ac19a54081b7dfc2442036d282b',
430 'title': '今日もめっちゃいい天気☀️🌤️',
432 'uploader_id': '66a07e164b56a696ee71c99315ffe27b',
433 'channel': 'secretnumber',
435 'creator': 'SECRET NUMBER',
437 'upload_date': '20230405',
438 'timestamp': 1680653968,
439 'thumbnail': r
're:^https?://.*\.jpe?g$',
441 'comment_count': int,
442 'availability': 'needs_auth',
444 'skip': 'Moment has expired',
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']
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}
),
472 **NaverBaseIE
.process_subtitles(video_info
, self
._get
_subs
),
476 class WeverseTabBaseIE(WeverseBaseIE
):
482 def _entries(self
, channel_id
, channel
, first_page
):
483 query
= self
._QUERY
.copy()
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}')
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
))
498 query
['after'] = traverse_obj(posts
, ('paging', 'nextParams', 'after', {str}
))
499 if not query
['after']:
502 def _real_extract(self
, url
):
503 channel
= self
._match
_id
(url
)
504 channel_id
= self
._get
_community
_id
(channel
)
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')
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}
),
518 class WeverseLiveTabIE(WeverseTabBaseIE
):
519 _VALID_URL
= r
'https?://(?:www\.|m\.)?weverse\.io/(?P<id>[^/?#]+)/live/?(?:[?#]|$)'
521 'url': 'https://weverse.io/billlie/live/',
522 'playlist_mincount': 55,
524 'id': 'billlie-live',
526 'thumbnail': r
're:^https?://.*\.jpe?g$',
530 _ENDPOINT
= '/post/v1.0/community-%s/liveTabPosts'
532 _QUERY
= {'fieldSet': 'postsV1'}
533 _RESULT_IE
= WeverseIE
536 class WeverseMediaTabIE(WeverseTabBaseIE
):
537 _VALID_URL
= r
'https?://(?:www\.|m\.)?weverse\.io/(?P<id>[^/?#]+)/media(?:/|/all|/new)?(?:[?#]|$)'
539 'url': 'https://weverse.io/billlie/media/',
540 'playlist_mincount': 231,
542 'id': 'billlie-media',
544 'thumbnail': r
're:^https?://.*\.jpe?g$',
547 'url': 'https://weverse.io/lesserafim/media/all',
548 'only_matching': True,
550 'url': 'https://weverse.io/lesserafim/media/new',
551 'only_matching': True,
554 _ENDPOINT
= '/media/v1.0/community-%s/more'
556 _QUERY
= {'fieldSet': 'postsV1', 'filterType': 'RECENT'}
557 _RESULT_IE
= WeverseMediaIE
560 class WeverseLiveIE(WeverseBaseIE
):
561 _VALID_URL
= r
'https?://(?:www\.|m\.)?weverse\.io/(?P<id>[^/?#]+)/?(?:[?#]|$)'
563 'url': 'https://weverse.io/purplekiss',
567 'title': r
're:모하냥🫶🏻',
568 'description': '내일은 금요일~><',
570 'uploader_id': '1ffb1d9d904d6b3db2783f876eb9229d',
571 'channel': 'purplekiss',
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',
582 'comment_count': int,
583 'availability': 'needs_auth',
584 'live_status': 'is_live',
586 'skip': 'Livestream has ended',
588 'url': 'https://weverse.io/billlie/',
589 'only_matching': True,
592 def _real_extract(self
, url
):
593 channel
= self
._match
_id
(url
)
594 channel_id
= self
._get
_community
_id
(channel
)
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}
),
606 raise UserNotLive(video_id
=channel
)
608 return self
.url_result(f
'https://weverse.io/{channel}/live/{video_id}', WeverseIE
)