]>
Commit | Line | Data |
---|---|---|
1 | import functools | |
2 | import json | |
3 | ||
4 | from .common import InfoExtractor | |
5 | from ..utils import ( | |
6 | ExtractorError, | |
7 | OnDemandPagedList, | |
8 | filter_dict, | |
9 | int_or_none, | |
10 | parse_qs, | |
11 | str_or_none, | |
12 | traverse_obj, | |
13 | unified_timestamp, | |
14 | url_or_none, | |
15 | ) | |
16 | ||
17 | ||
18 | class NiconicoChannelPlusBaseIE(InfoExtractor): | |
19 | _WEBPAGE_BASE_URL = 'https://nicochannel.jp' | |
20 | ||
21 | def _call_api(self, path, item_id, **kwargs): | |
22 | return self._download_json( | |
23 | f'https://nfc-api.nicochannel.jp/fc/{path}', video_id=item_id, **kwargs) | |
24 | ||
25 | def _find_fanclub_site_id(self, channel_name): | |
26 | fanclub_list_json = self._call_api( | |
27 | 'content_providers/channels', item_id=f'channels/{channel_name}', | |
28 | note='Fetching channel list', errnote='Unable to fetch channel list', | |
29 | )['data']['content_providers'] | |
30 | fanclub_id = traverse_obj(fanclub_list_json, ( | |
31 | lambda _, v: v['domain'] == f'{self._WEBPAGE_BASE_URL}/{channel_name}', 'id'), | |
32 | get_all=False) | |
33 | if not fanclub_id: | |
34 | raise ExtractorError(f'Channel {channel_name} does not exist', expected=True) | |
35 | return fanclub_id | |
36 | ||
37 | def _get_channel_base_info(self, fanclub_site_id): | |
38 | return traverse_obj(self._call_api( | |
39 | f'fanclub_sites/{fanclub_site_id}/page_base_info', item_id=f'fanclub_sites/{fanclub_site_id}', | |
40 | note='Fetching channel base info', errnote='Unable to fetch channel base info', fatal=False, | |
41 | ), ('data', 'fanclub_site', {dict})) or {} | |
42 | ||
43 | def _get_channel_user_info(self, fanclub_site_id): | |
44 | return traverse_obj(self._call_api( | |
45 | f'fanclub_sites/{fanclub_site_id}/user_info', item_id=f'fanclub_sites/{fanclub_site_id}', | |
46 | note='Fetching channel user info', errnote='Unable to fetch channel user info', fatal=False, | |
47 | data=json.dumps('null').encode('ascii'), | |
48 | ), ('data', 'fanclub_site', {dict})) or {} | |
49 | ||
50 | ||
51 | class NiconicoChannelPlusIE(NiconicoChannelPlusBaseIE): | |
52 | IE_NAME = 'NiconicoChannelPlus' | |
53 | IE_DESC = 'ニコニコチャンネルプラス' | |
54 | _VALID_URL = r'https?://nicochannel\.jp/(?P<channel>[\w.-]+)/(?:video|live)/(?P<code>sm\w+)' | |
55 | _TESTS = [{ | |
56 | 'url': 'https://nicochannel.jp/kaorin/video/smsDd8EdFLcVZk9yyAhD6H7H', | |
57 | 'info_dict': { | |
58 | 'id': 'smsDd8EdFLcVZk9yyAhD6H7H', | |
59 | 'title': '前田佳織里はニコ生がしたい!', | |
60 | 'ext': 'mp4', | |
61 | 'channel': '前田佳織里の世界攻略計画', | |
62 | 'channel_id': 'kaorin', | |
63 | 'channel_url': 'https://nicochannel.jp/kaorin', | |
64 | 'live_status': 'not_live', | |
65 | 'thumbnail': 'https://nicochannel.jp/public_html/contents/video_pages/74/thumbnail_path', | |
66 | 'description': '2021年11月に放送された\n「前田佳織里はニコ生がしたい!」アーカイブになります。', | |
67 | 'timestamp': 1641360276, | |
68 | 'duration': 4097, | |
69 | 'comment_count': int, | |
70 | 'view_count': int, | |
71 | 'tags': [], | |
72 | 'upload_date': '20220105', | |
73 | }, | |
74 | 'params': { | |
75 | 'skip_download': True, | |
76 | }, | |
77 | }, { | |
78 | # age limited video; test purpose channel. | |
79 | 'url': 'https://nicochannel.jp/testman/video/smDXbcrtyPNxLx9jc4BW69Ve', | |
80 | 'info_dict': { | |
81 | 'id': 'smDXbcrtyPNxLx9jc4BW69Ve', | |
82 | 'title': 'test oshiro', | |
83 | 'ext': 'mp4', | |
84 | 'channel': '本番チャンネルプラステストマン', | |
85 | 'channel_id': 'testman', | |
86 | 'channel_url': 'https://nicochannel.jp/testman', | |
87 | 'age_limit': 18, | |
88 | 'live_status': 'was_live', | |
89 | 'timestamp': 1666344616, | |
90 | 'duration': 86465, | |
91 | 'comment_count': int, | |
92 | 'view_count': int, | |
93 | 'tags': [], | |
94 | 'upload_date': '20221021', | |
95 | }, | |
96 | 'params': { | |
97 | 'skip_download': True, | |
98 | }, | |
99 | }] | |
100 | ||
101 | def _real_extract(self, url): | |
102 | content_code, channel_id = self._match_valid_url(url).group('code', 'channel') | |
103 | fanclub_site_id = self._find_fanclub_site_id(channel_id) | |
104 | ||
105 | data_json = self._call_api( | |
106 | f'video_pages/{content_code}', item_id=content_code, headers={'fc_use_device': 'null'}, | |
107 | note='Fetching video page info', errnote='Unable to fetch video page info', | |
108 | )['data']['video_page'] | |
109 | ||
110 | live_status, session_id = self._get_live_status_and_session_id(content_code, data_json) | |
111 | ||
112 | release_timestamp_str = data_json.get('live_scheduled_start_at') | |
113 | ||
114 | formats = [] | |
115 | ||
116 | if live_status == 'is_upcoming': | |
117 | if release_timestamp_str: | |
118 | msg = f'This live event will begin at {release_timestamp_str} UTC' | |
119 | else: | |
120 | msg = 'This event has not started yet' | |
121 | self.raise_no_formats(msg, expected=True, video_id=content_code) | |
122 | else: | |
123 | formats = self._extract_m3u8_formats( | |
124 | # "authenticated_url" is a format string that contains "{session_id}". | |
125 | m3u8_url=data_json['video_stream']['authenticated_url'].format(session_id=session_id), | |
126 | video_id=content_code) | |
127 | ||
128 | return { | |
129 | 'id': content_code, | |
130 | 'formats': formats, | |
131 | '_format_sort_fields': ('tbr', 'vcodec', 'acodec'), | |
132 | 'channel': self._get_channel_base_info(fanclub_site_id).get('fanclub_site_name'), | |
133 | 'channel_id': channel_id, | |
134 | 'channel_url': f'{self._WEBPAGE_BASE_URL}/{channel_id}', | |
135 | 'age_limit': traverse_obj(self._get_channel_user_info(fanclub_site_id), ('content_provider', 'age_limit')), | |
136 | 'live_status': live_status, | |
137 | 'release_timestamp': unified_timestamp(release_timestamp_str), | |
138 | **traverse_obj(data_json, { | |
139 | 'title': ('title', {str}), | |
140 | 'thumbnail': ('thumbnail_url', {url_or_none}), | |
141 | 'description': ('description', {str}), | |
142 | 'timestamp': ('released_at', {unified_timestamp}), | |
143 | 'duration': ('active_video_filename', 'length', {int_or_none}), | |
144 | 'comment_count': ('video_aggregate_info', 'number_of_comments', {int_or_none}), | |
145 | 'view_count': ('video_aggregate_info', 'total_views', {int_or_none}), | |
146 | 'tags': ('video_tags', ..., 'tag', {str}), | |
147 | }), | |
148 | '__post_extractor': self.extract_comments( | |
149 | content_code=content_code, | |
150 | comment_group_id=traverse_obj(data_json, ('video_comment_setting', 'comment_group_id'))), | |
151 | } | |
152 | ||
153 | def _get_comments(self, content_code, comment_group_id): | |
154 | item_id = f'{content_code}/comments' | |
155 | ||
156 | if not comment_group_id: | |
157 | return None | |
158 | ||
159 | comment_access_token = self._call_api( | |
160 | f'video_pages/{content_code}/comments_user_token', item_id, | |
161 | note='Getting comment token', errnote='Unable to get comment token', | |
162 | )['data']['access_token'] | |
163 | ||
164 | comment_list = self._download_json( | |
165 | 'https://comm-api.sheeta.com/messages.history', video_id=item_id, | |
166 | note='Fetching comments', errnote='Unable to fetch comments', | |
167 | headers={'Content-Type': 'application/json'}, | |
168 | query={ | |
169 | 'sort_direction': 'asc', | |
170 | 'limit': int_or_none(self._configuration_arg('max_comments', [''])[0]) or 120, | |
171 | }, | |
172 | data=json.dumps({ | |
173 | 'token': comment_access_token, | |
174 | 'group_id': comment_group_id, | |
175 | }).encode('ascii')) | |
176 | ||
177 | for comment in traverse_obj(comment_list, ...): | |
178 | yield traverse_obj(comment, { | |
179 | 'author': ('nickname', {str}), | |
180 | 'author_id': ('sender_id', {str_or_none}), | |
181 | 'id': ('id', {str_or_none}), | |
182 | 'text': ('message', {str}), | |
183 | 'timestamp': (('updated_at', 'sent_at', 'created_at'), {unified_timestamp}), | |
184 | 'author_is_uploader': ('sender_id', {lambda x: x == '-1'}), | |
185 | }, get_all=False) | |
186 | ||
187 | def _get_live_status_and_session_id(self, content_code, data_json): | |
188 | video_type = data_json.get('type') | |
189 | live_finished_at = data_json.get('live_finished_at') | |
190 | ||
191 | payload = {} | |
192 | if video_type == 'vod': | |
193 | if live_finished_at: | |
194 | live_status = 'was_live' | |
195 | else: | |
196 | live_status = 'not_live' | |
197 | elif video_type == 'live': | |
198 | if not data_json.get('live_started_at'): | |
199 | return 'is_upcoming', '' | |
200 | ||
201 | if not live_finished_at: | |
202 | live_status = 'is_live' | |
203 | else: | |
204 | live_status = 'was_live' | |
205 | payload = {'broadcast_type': 'dvr'} | |
206 | ||
207 | video_allow_dvr_flg = traverse_obj(data_json, ('video', 'allow_dvr_flg')) | |
208 | video_convert_to_vod_flg = traverse_obj(data_json, ('video', 'convert_to_vod_flg')) | |
209 | ||
210 | self.write_debug(f'allow_dvr_flg = {video_allow_dvr_flg}, convert_to_vod_flg = {video_convert_to_vod_flg}.') | |
211 | ||
212 | if not (video_allow_dvr_flg and video_convert_to_vod_flg): | |
213 | raise ExtractorError( | |
214 | 'Live was ended, there is no video for download.', video_id=content_code, expected=True) | |
215 | else: | |
216 | raise ExtractorError(f'Unknown type: {video_type}', video_id=content_code, expected=False) | |
217 | ||
218 | self.write_debug(f'{content_code}: video_type={video_type}, live_status={live_status}') | |
219 | ||
220 | session_id = self._call_api( | |
221 | f'video_pages/{content_code}/session_ids', item_id=f'{content_code}/session', | |
222 | data=json.dumps(payload).encode('ascii'), headers={ | |
223 | 'Content-Type': 'application/json', | |
224 | 'fc_use_device': 'null', | |
225 | 'origin': 'https://nicochannel.jp', | |
226 | }, | |
227 | note='Getting session id', errnote='Unable to get session id', | |
228 | )['data']['session_id'] | |
229 | ||
230 | return live_status, session_id | |
231 | ||
232 | ||
233 | class NiconicoChannelPlusChannelBaseIE(NiconicoChannelPlusBaseIE): | |
234 | _PAGE_SIZE = 12 | |
235 | ||
236 | def _fetch_paged_channel_video_list(self, path, query, channel_name, item_id, page): | |
237 | response = self._call_api( | |
238 | path, item_id, query={ | |
239 | **query, | |
240 | 'page': (page + 1), | |
241 | 'per_page': self._PAGE_SIZE, | |
242 | }, | |
243 | headers={'fc_use_device': 'null'}, | |
244 | note=f'Getting channel info (page {page + 1})', | |
245 | errnote=f'Unable to get channel info (page {page + 1})') | |
246 | ||
247 | for content_code in traverse_obj(response, ('data', 'video_pages', 'list', ..., 'content_code')): | |
248 | # "video/{content_code}" works for both VOD and live, but "live/{content_code}" doesn't work for VOD | |
249 | yield self.url_result( | |
250 | f'{self._WEBPAGE_BASE_URL}/{channel_name}/video/{content_code}', NiconicoChannelPlusIE) | |
251 | ||
252 | ||
253 | class NiconicoChannelPlusChannelVideosIE(NiconicoChannelPlusChannelBaseIE): | |
254 | IE_NAME = 'NiconicoChannelPlus:channel:videos' | |
255 | IE_DESC = 'ニコニコチャンネルプラス - チャンネル - 動画リスト. nicochannel.jp/channel/videos' | |
256 | _VALID_URL = r'https?://nicochannel\.jp/(?P<id>[a-z\d\._-]+)/videos(?:\?.*)?' | |
257 | _TESTS = [{ | |
258 | # query: None | |
259 | 'url': 'https://nicochannel.jp/testman/videos', | |
260 | 'info_dict': { | |
261 | 'id': 'testman-videos', | |
262 | 'title': '本番チャンネルプラステストマン-videos', | |
263 | }, | |
264 | 'playlist_mincount': 18, | |
265 | }, { | |
266 | # query: None | |
267 | 'url': 'https://nicochannel.jp/testtarou/videos', | |
268 | 'info_dict': { | |
269 | 'id': 'testtarou-videos', | |
270 | 'title': 'チャンネルプラステスト太郎-videos', | |
271 | }, | |
272 | 'playlist_mincount': 2, | |
273 | }, { | |
274 | # query: None | |
275 | 'url': 'https://nicochannel.jp/testjirou/videos', | |
276 | 'info_dict': { | |
277 | 'id': 'testjirou-videos', | |
278 | 'title': 'チャンネルプラステスト二郎-videos', | |
279 | }, | |
280 | 'playlist_mincount': 12, | |
281 | }, { | |
282 | # query: tag | |
283 | 'url': 'https://nicochannel.jp/testman/videos?tag=%E6%A4%9C%E8%A8%BC%E7%94%A8', | |
284 | 'info_dict': { | |
285 | 'id': 'testman-videos', | |
286 | 'title': '本番チャンネルプラステストマン-videos', | |
287 | }, | |
288 | 'playlist_mincount': 6, | |
289 | }, { | |
290 | # query: vodType | |
291 | 'url': 'https://nicochannel.jp/testman/videos?vodType=1', | |
292 | 'info_dict': { | |
293 | 'id': 'testman-videos', | |
294 | 'title': '本番チャンネルプラステストマン-videos', | |
295 | }, | |
296 | 'playlist_mincount': 18, | |
297 | }, { | |
298 | # query: sort | |
299 | 'url': 'https://nicochannel.jp/testman/videos?sort=-released_at', | |
300 | 'info_dict': { | |
301 | 'id': 'testman-videos', | |
302 | 'title': '本番チャンネルプラステストマン-videos', | |
303 | }, | |
304 | 'playlist_mincount': 18, | |
305 | }, { | |
306 | # query: tag, vodType | |
307 | 'url': 'https://nicochannel.jp/testman/videos?tag=%E6%A4%9C%E8%A8%BC%E7%94%A8&vodType=1', | |
308 | 'info_dict': { | |
309 | 'id': 'testman-videos', | |
310 | 'title': '本番チャンネルプラステストマン-videos', | |
311 | }, | |
312 | 'playlist_mincount': 6, | |
313 | }, { | |
314 | # query: tag, sort | |
315 | 'url': 'https://nicochannel.jp/testman/videos?tag=%E6%A4%9C%E8%A8%BC%E7%94%A8&sort=-released_at', | |
316 | 'info_dict': { | |
317 | 'id': 'testman-videos', | |
318 | 'title': '本番チャンネルプラステストマン-videos', | |
319 | }, | |
320 | 'playlist_mincount': 6, | |
321 | }, { | |
322 | # query: vodType, sort | |
323 | 'url': 'https://nicochannel.jp/testman/videos?vodType=1&sort=-released_at', | |
324 | 'info_dict': { | |
325 | 'id': 'testman-videos', | |
326 | 'title': '本番チャンネルプラステストマン-videos', | |
327 | }, | |
328 | 'playlist_mincount': 18, | |
329 | }, { | |
330 | # query: tag, vodType, sort | |
331 | 'url': 'https://nicochannel.jp/testman/videos?tag=%E6%A4%9C%E8%A8%BC%E7%94%A8&vodType=1&sort=-released_at', | |
332 | 'info_dict': { | |
333 | 'id': 'testman-videos', | |
334 | 'title': '本番チャンネルプラステストマン-videos', | |
335 | }, | |
336 | 'playlist_mincount': 6, | |
337 | }] | |
338 | ||
339 | def _real_extract(self, url): | |
340 | """ | |
341 | API parameters: | |
342 | sort: | |
343 | -released_at 公開日が新しい順 (newest to oldest) | |
344 | released_at 公開日が古い順 (oldest to newest) | |
345 | -number_of_vod_views 再生数が多い順 (most play count) | |
346 | number_of_vod_views コメントが多い順 (most comments) | |
347 | vod_type (is "vodType" in "url"): | |
348 | 0 すべて (all) | |
349 | 1 会員限定 (members only) | |
350 | 2 一部無料 (partially free) | |
351 | 3 レンタル (rental) | |
352 | 4 生放送アーカイブ (live archives) | |
353 | 5 アップロード動画 (uploaded videos) | |
354 | """ | |
355 | ||
356 | channel_id = self._match_id(url) | |
357 | fanclub_site_id = self._find_fanclub_site_id(channel_id) | |
358 | channel_name = self._get_channel_base_info(fanclub_site_id).get('fanclub_site_name') | |
359 | qs = parse_qs(url) | |
360 | ||
361 | return self.playlist_result( | |
362 | OnDemandPagedList( | |
363 | functools.partial( | |
364 | self._fetch_paged_channel_video_list, f'fanclub_sites/{fanclub_site_id}/video_pages', | |
365 | filter_dict({ | |
366 | 'tag': traverse_obj(qs, ('tag', 0)), | |
367 | 'sort': traverse_obj(qs, ('sort', 0), default='-released_at'), | |
368 | 'vod_type': traverse_obj(qs, ('vodType', 0), default='0'), | |
369 | }), | |
370 | channel_id, f'{channel_id}/videos'), | |
371 | self._PAGE_SIZE), | |
372 | playlist_id=f'{channel_id}-videos', playlist_title=f'{channel_name}-videos') | |
373 | ||
374 | ||
375 | class NiconicoChannelPlusChannelLivesIE(NiconicoChannelPlusChannelBaseIE): | |
376 | IE_NAME = 'NiconicoChannelPlus:channel:lives' | |
377 | IE_DESC = 'ニコニコチャンネルプラス - チャンネル - ライブリスト. nicochannel.jp/channel/lives' | |
378 | _VALID_URL = r'https?://nicochannel\.jp/(?P<id>[a-z\d\._-]+)/lives' | |
379 | _TESTS = [{ | |
380 | 'url': 'https://nicochannel.jp/testman/lives', | |
381 | 'info_dict': { | |
382 | 'id': 'testman-lives', | |
383 | 'title': '本番チャンネルプラステストマン-lives', | |
384 | }, | |
385 | 'playlist_mincount': 18, | |
386 | }, { | |
387 | 'url': 'https://nicochannel.jp/testtarou/lives', | |
388 | 'info_dict': { | |
389 | 'id': 'testtarou-lives', | |
390 | 'title': 'チャンネルプラステスト太郎-lives', | |
391 | }, | |
392 | 'playlist_mincount': 2, | |
393 | }, { | |
394 | 'url': 'https://nicochannel.jp/testjirou/lives', | |
395 | 'info_dict': { | |
396 | 'id': 'testjirou-lives', | |
397 | 'title': 'チャンネルプラステスト二郎-lives', | |
398 | }, | |
399 | 'playlist_mincount': 6, | |
400 | }] | |
401 | ||
402 | def _real_extract(self, url): | |
403 | """ | |
404 | API parameters: | |
405 | live_type: | |
406 | 1 放送中 (on air) | |
407 | 2 放送予定 (scheduled live streams, oldest to newest) | |
408 | 3 過去の放送 - すべて (all ended live streams, newest to oldest) | |
409 | 4 過去の放送 - 生放送アーカイブ (all archives for live streams, oldest to newest) | |
410 | We use "4" instead of "3" because some recently ended live streams could not be downloaded. | |
411 | """ | |
412 | ||
413 | channel_id = self._match_id(url) | |
414 | fanclub_site_id = self._find_fanclub_site_id(channel_id) | |
415 | channel_name = self._get_channel_base_info(fanclub_site_id).get('fanclub_site_name') | |
416 | ||
417 | return self.playlist_result( | |
418 | OnDemandPagedList( | |
419 | functools.partial( | |
420 | self._fetch_paged_channel_video_list, f'fanclub_sites/{fanclub_site_id}/live_pages', | |
421 | { | |
422 | 'live_type': 4, | |
423 | }, | |
424 | channel_id, f'{channel_id}/lives'), | |
425 | self._PAGE_SIZE), | |
426 | playlist_id=f'{channel_id}-lives', playlist_title=f'{channel_name}-lives') |