niconicochannelplus.py 18 KB

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