weverse.py 25 KB

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