1# coding: utf-8 2from __future__ import unicode_literals 3 4import itertools 5import json 6 7from .naver import NaverBaseIE 8from ..compat import ( 9 compat_HTTPError, 10 compat_str, 11) 12from ..utils import ( 13 ExtractorError, 14 int_or_none, 15 merge_dicts, 16 str_or_none, 17 strip_or_none, 18 try_get, 19 urlencode_postdata, 20) 21 22 23class VLiveBaseIE(NaverBaseIE): 24 _APP_ID = '8c6cc7b45d2568fb668be6e05b6e5a3b' 25 26 27class VLiveIE(VLiveBaseIE): 28 IE_NAME = 'vlive' 29 _VALID_URL = r'https?://(?:(?:www|m)\.)?vlive\.tv/(?:video|embed)/(?P<id>[0-9]+)' 30 _NETRC_MACHINE = 'vlive' 31 _TESTS = [{ 32 'url': 'http://www.vlive.tv/video/1326', 33 'md5': 'cc7314812855ce56de70a06a27314983', 34 'info_dict': { 35 'id': '1326', 36 'ext': 'mp4', 37 'title': "Girl's Day's Broadcast", 38 'creator': "Girl's Day", 39 'view_count': int, 40 'uploader_id': 'muploader_a', 41 }, 42 }, { 43 'url': 'http://www.vlive.tv/video/16937', 44 'info_dict': { 45 'id': '16937', 46 'ext': 'mp4', 47 'title': '첸백시 걍방', 48 'creator': 'EXO', 49 'view_count': int, 50 'subtitles': 'mincount:12', 51 'uploader_id': 'muploader_j', 52 }, 53 'params': { 54 'skip_download': True, 55 }, 56 }, { 57 'url': 'https://www.vlive.tv/video/129100', 58 'md5': 'ca2569453b79d66e5b919e5d308bff6b', 59 'info_dict': { 60 'id': '129100', 61 'ext': 'mp4', 62 'title': '[V LIVE] [BTS+] Run BTS! 2019 - EP.71 :: Behind the scene', 63 'creator': 'BTS+', 64 'view_count': int, 65 'subtitles': 'mincount:10', 66 }, 67 'skip': 'This video is only available for CH+ subscribers', 68 }, { 69 'url': 'https://www.vlive.tv/embed/1326', 70 'only_matching': True, 71 }, { 72 # works only with gcc=KR 73 'url': 'https://www.vlive.tv/video/225019', 74 'only_matching': True, 75 }] 76 77 def _real_initialize(self): 78 self._login() 79 80 def _login(self): 81 email, password = self._get_login_info() 82 if None in (email, password): 83 return 84 85 def is_logged_in(): 86 login_info = self._download_json( 87 'https://www.vlive.tv/auth/loginInfo', None, 88 note='Downloading login info', 89 headers={'Referer': 'https://www.vlive.tv/home'}) 90 return try_get( 91 login_info, lambda x: x['message']['login'], bool) or False 92 93 LOGIN_URL = 'https://www.vlive.tv/auth/email/login' 94 self._request_webpage( 95 LOGIN_URL, None, note='Downloading login cookies') 96 97 self._download_webpage( 98 LOGIN_URL, None, note='Logging in', 99 data=urlencode_postdata({'email': email, 'pwd': password}), 100 headers={ 101 'Referer': LOGIN_URL, 102 'Content-Type': 'application/x-www-form-urlencoded' 103 }) 104 105 if not is_logged_in(): 106 raise ExtractorError('Unable to log in', expected=True) 107 108 def _call_api(self, path_template, video_id, fields=None): 109 query = {'appId': self._APP_ID, 'gcc': 'KR', 'platformType': 'PC'} 110 if fields: 111 query['fields'] = fields 112 try: 113 return self._download_json( 114 'https://www.vlive.tv/globalv-web/vam-web/' + path_template % video_id, video_id, 115 'Downloading %s JSON metadata' % path_template.split('/')[-1].split('-')[0], 116 headers={'Referer': 'https://www.vlive.tv/'}, query=query) 117 except ExtractorError as e: 118 if isinstance(e.cause, compat_HTTPError) and e.cause.code == 403: 119 self.raise_login_required(json.loads(e.cause.read().decode('utf-8'))['message']) 120 raise 121 122 def _real_extract(self, url): 123 video_id = self._match_id(url) 124 125 post = self._call_api( 126 'post/v1.0/officialVideoPost-%s', video_id, 127 'author{nickname},channel{channelCode,channelName},officialVideo{commentCount,exposeStatus,likeCount,playCount,playTime,status,title,type,vodId}') 128 129 video = post['officialVideo'] 130 131 def get_common_fields(): 132 channel = post.get('channel') or {} 133 return { 134 'title': video.get('title'), 135 'creator': post.get('author', {}).get('nickname'), 136 'channel': channel.get('channelName'), 137 'channel_id': channel.get('channelCode'), 138 'duration': int_or_none(video.get('playTime')), 139 'view_count': int_or_none(video.get('playCount')), 140 'like_count': int_or_none(video.get('likeCount')), 141 'comment_count': int_or_none(video.get('commentCount')), 142 } 143 144 video_type = video.get('type') 145 if video_type == 'VOD': 146 inkey = self._call_api('video/v1.0/vod/%s/inkey', video_id)['inkey'] 147 vod_id = video['vodId'] 148 return merge_dicts( 149 get_common_fields(), 150 self._extract_video_info(video_id, vod_id, inkey)) 151 elif video_type == 'LIVE': 152 status = video.get('status') 153 if status == 'ON_AIR': 154 stream_url = self._call_api( 155 'old/v3/live/%s/playInfo', 156 video_id)['result']['adaptiveStreamUrl'] 157 formats = self._extract_m3u8_formats(stream_url, video_id, 'mp4') 158 self._sort_formats(formats) 159 info = get_common_fields() 160 info.update({ 161 'title': self._live_title(video['title']), 162 'id': video_id, 163 'formats': formats, 164 'is_live': True, 165 }) 166 return info 167 elif status == 'ENDED': 168 raise ExtractorError( 169 'Uploading for replay. Please wait...', expected=True) 170 elif status == 'RESERVED': 171 raise ExtractorError('Coming soon!', expected=True) 172 elif video.get('exposeStatus') == 'CANCEL': 173 raise ExtractorError( 174 'We are sorry, but the live broadcast has been canceled.', 175 expected=True) 176 else: 177 raise ExtractorError('Unknown status ' + status) 178 179 180class VLivePostIE(VLiveIE): 181 IE_NAME = 'vlive:post' 182 _VALID_URL = r'https?://(?:(?:www|m)\.)?vlive\.tv/post/(?P<id>\d-\d+)' 183 _TESTS = [{ 184 # uploadType = SOS 185 'url': 'https://www.vlive.tv/post/1-20088044', 186 'info_dict': { 187 'id': '1-20088044', 188 'title': 'Hola estrellitas la tierra les dice hola (si era así no?) Ha...', 189 'description': 'md5:fab8a1e50e6e51608907f46c7fa4b407', 190 }, 191 'playlist_count': 3, 192 }, { 193 # uploadType = V 194 'url': 'https://www.vlive.tv/post/1-20087926', 195 'info_dict': { 196 'id': '1-20087926', 197 'title': 'James Corden: And so, the baby becamos the Papa', 198 }, 199 'playlist_count': 1, 200 }] 201 _FVIDEO_TMPL = 'fvideo/v1.0/fvideo-%%s/%s' 202 _SOS_TMPL = _FVIDEO_TMPL % 'sosPlayInfo' 203 _INKEY_TMPL = _FVIDEO_TMPL % 'inKey' 204 205 def _real_extract(self, url): 206 post_id = self._match_id(url) 207 208 post = self._call_api( 209 'post/v1.0/post-%s', post_id, 210 'attachments{video},officialVideo{videoSeq},plainBody,title') 211 212 video_seq = str_or_none(try_get( 213 post, lambda x: x['officialVideo']['videoSeq'])) 214 if video_seq: 215 return self.url_result( 216 'http://www.vlive.tv/video/' + video_seq, 217 VLiveIE.ie_key(), video_seq) 218 219 title = post['title'] 220 entries = [] 221 for idx, video in enumerate(post['attachments']['video'].values()): 222 video_id = video.get('videoId') 223 if not video_id: 224 continue 225 upload_type = video.get('uploadType') 226 upload_info = video.get('uploadInfo') or {} 227 entry = None 228 if upload_type == 'SOS': 229 download = self._call_api( 230 self._SOS_TMPL, video_id)['videoUrl']['download'] 231 formats = [] 232 for f_id, f_url in download.items(): 233 formats.append({ 234 'format_id': f_id, 235 'url': f_url, 236 'height': int_or_none(f_id[:-1]), 237 }) 238 self._sort_formats(formats) 239 entry = { 240 'formats': formats, 241 'id': video_id, 242 'thumbnail': upload_info.get('imageUrl'), 243 } 244 elif upload_type == 'V': 245 vod_id = upload_info.get('videoId') 246 if not vod_id: 247 continue 248 inkey = self._call_api(self._INKEY_TMPL, video_id)['inKey'] 249 entry = self._extract_video_info(video_id, vod_id, inkey) 250 if entry: 251 entry['title'] = '%s_part%s' % (title, idx) 252 entries.append(entry) 253 return self.playlist_result( 254 entries, post_id, title, strip_or_none(post.get('plainBody'))) 255 256 257class VLiveChannelIE(VLiveBaseIE): 258 IE_NAME = 'vlive:channel' 259 _VALID_URL = r'https?://(?:channels\.vlive\.tv|(?:(?:www|m)\.)?vlive\.tv/channel)/(?P<id>[0-9A-Z]+)' 260 _TESTS = [{ 261 'url': 'http://channels.vlive.tv/FCD4B', 262 'info_dict': { 263 'id': 'FCD4B', 264 'title': 'MAMAMOO', 265 }, 266 'playlist_mincount': 110 267 }, { 268 'url': 'https://www.vlive.tv/channel/FCD4B', 269 'only_matching': True, 270 }] 271 272 def _call_api(self, path, channel_key_suffix, channel_value, note, query): 273 q = { 274 'app_id': self._APP_ID, 275 'channel' + channel_key_suffix: channel_value, 276 } 277 q.update(query) 278 return self._download_json( 279 'http://api.vfan.vlive.tv/vproxy/channelplus/' + path, 280 channel_value, note='Downloading ' + note, query=q)['result'] 281 282 def _real_extract(self, url): 283 channel_code = self._match_id(url) 284 285 channel_seq = self._call_api( 286 'decodeChannelCode', 'Code', channel_code, 287 'decode channel code', {})['channelSeq'] 288 289 channel_name = None 290 entries = [] 291 292 for page_num in itertools.count(1): 293 video_list = self._call_api( 294 'getChannelVideoList', 'Seq', channel_seq, 295 'channel list page #%d' % page_num, { 296 # Large values of maxNumOfRows (~300 or above) may cause 297 # empty responses (see [1]), e.g. this happens for [2] that 298 # has more than 300 videos. 299 # 1. https://github.com/ytdl-org/youtube-dl/issues/13830 300 # 2. http://channels.vlive.tv/EDBF. 301 'maxNumOfRows': 100, 302 'pageNo': page_num 303 } 304 ) 305 306 if not channel_name: 307 channel_name = try_get( 308 video_list, 309 lambda x: x['channelInfo']['channelName'], 310 compat_str) 311 312 videos = try_get( 313 video_list, lambda x: x['videoList'], list) 314 if not videos: 315 break 316 317 for video in videos: 318 video_id = video.get('videoSeq') 319 if not video_id: 320 continue 321 video_id = compat_str(video_id) 322 entries.append( 323 self.url_result( 324 'http://www.vlive.tv/video/%s' % video_id, 325 ie=VLiveIE.ie_key(), video_id=video_id)) 326 327 return self.playlist_result( 328 entries, channel_code, channel_name) 329