diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py index 34c98b537..e9e7a9652 100644 --- a/yt_dlp/extractor/_extractors.py +++ b/yt_dlp/extractor/_extractors.py @@ -638,6 +638,10 @@ FancodeLiveIE, FancodeVodIE, ) +from .fanicon import ( + FaniconFCIE, + FaniconIE, +) from .fathom import FathomIE from .faz import FazIE from .fc2 import ( diff --git a/yt_dlp/extractor/fanicon.py b/yt_dlp/extractor/fanicon.py new file mode 100644 index 000000000..8747f5066 --- /dev/null +++ b/yt_dlp/extractor/fanicon.py @@ -0,0 +1,170 @@ +import itertools + +from .common import InfoExtractor +from ..utils import ( + ExtractorError, + determine_ext, + filter_dict, + int_or_none, + parse_iso8601, + str_or_none, + url_or_none, +) +from ..utils.traversal import traverse_obj + + +class FaniconBaseIE(InfoExtractor): + _BASE_URL = 'https://fanicon.net' + _API_BASE = f'{_BASE_URL}/api/fan' + + +class FaniconIE(FaniconBaseIE): + _VALID_URL = r'https?://fanicon\.net/(?:fanilive|web/fancommunities)/(?P\d+)(?:/livearchive)?/(?P[\da-f]+)(?:/(?P[\da-f]+))?' + _TESTS = [{ + 'url': 'https://fanicon.net/fanilive/4937/1224907/83fd46f739df37d', + 'info_dict': { + 'id': '1224907', + 'ext': 'mp4', + 'title': 'YAYA RECORDS CREW', + 'channel': 'YAYA RECORDS CREW', + 'channel_id': '4937', + 'display_id': '83fd46f739df37d', + 'thumbnail': r're:https://.+\.jpg', + 'timestamp': 1679835004, + 'upload_date': '20230326', + 'uploader': 'YAYA RECORDS', + 'uploader_id': '819710', + }, + }, { + 'url': 'https://fanicon.net/web/fancommunities/5831/livearchive/1658622', + 'info_dict': { + 'id': '1658622', + 'ext': 'mp4', + 'title': 'ご相談配信❣️', + 'channel': 'ワンコインのりこ通信', + 'channel_id': '5831', + 'comment_count': int, + 'like_count': int, + 'modified_date': '20250403', + 'modified_timestamp': 1743686584, + 'thumbnail': r're:https://.+\.jpg', + 'timestamp': 1743678007, + 'upload_date': '20250403', + 'uploader': '芝崎典子', + 'uploader_id': '1147555', + }, + 'skip': 'Only available for subscribers', + }] + + def _parse_tron(self, key, webpage, video_id): + return self._search_json( + rf'window\.tron\.{key}\s*=\s*', webpage, f'{key} info', video_id, default={}) + + def _real_extract(self, url): + fc_id, video_id, slug = self._match_valid_url(url).groups() + webpage = self._download_webpage(url, video_id) + + if slug: + live_info = self._parse_tron('live', webpage, video_id) + file_url = live_info['url'] + is_live = not traverse_obj(live_info, ('isArchive', {bool})) + formats = self._extract_m3u8_formats(file_url, video_id, 'mp4', live=is_live) + + metadata = { + 'display_id': slug, + **traverse_obj(live_info, { + 'title': ('body', {str}), + 'timestamp': ('createdAt', {parse_iso8601}), + }), + } + else: + if not self._parse_tron('user', webpage, video_id): + self.raise_login_required() + + cards = self._download_json( + f'{self._API_BASE}/cards/{video_id}', video_id, query={'fanclubId': fc_id}) + if error := cards.get('error'): + raise ExtractorError(error['message'], expected=True) + + file_url = cards['card']['media']['fileUrl'] + is_live = traverse_obj(cards, ('card', 'media', 'isOnLive', {bool})) + + if not (ext := determine_ext(file_url)): + raise ExtractorError('Unexpected file URL') + elif ext == 'm3u8': + formats = self._extract_m3u8_formats(file_url, video_id, 'mp4', live=is_live) + elif ext in {'mp3', 'mp4'}: + formats = [{'ext': ext, 'url': file_url}] + else: + self.raise_no_formats(f'Unexpected file type: {ext}') + + metadata = traverse_obj(cards, ('card', { + 'title': ('body', {str.strip}), + 'comment_count': ('commentCount', {int}), + 'like_count': ('likeCount', {int}), + 'modified_timestamp': ('updatedAt', {parse_iso8601}), + 'tags': ('tags', ..., {str}), + 'timestamp': ('createdAt', {parse_iso8601}), + })) + + return { + 'id': video_id, + 'formats': formats, + 'is_live': is_live, + **metadata, + **traverse_obj(self._download_json( + f'{self._API_BASE}/fanclubs/{fc_id}', fc_id), ('fanclub', { + 'channel': ('name', {str}), + 'channel_id': ('id', {str_or_none}), + 'thumbnail': ('heroImageUrl', {url_or_none}), + 'uploader': ('owners', ..., 'name', {str}, any), + 'uploader_id': (('ownerId', ('owners', ..., 'id')), {str_or_none}, any), + }), + ), + } + + +class FaniconFCIE(FaniconBaseIE): + _VALID_URL = r'https?://fanicon\.net/web/fancommunities/(?P\d+)(?:/scene)?$' + _TESTS = [{ + 'url': 'https://fanicon.net/web/fancommunities/5831/scene', + 'info_dict': { + 'id': '5831', + }, + 'playlist_mincount': 14, + 'skip': 'Only available for subscribers', + }] + + def _entries(self, fc_id): + min_id = None + for page in itertools.count(1): + cards = self._download_json( + f'{self._API_BASE}/cards', fc_id, + f'Downloading page {page}', query=filter_dict({ + 'fanclubId': fc_id, + 'maxId': min_id, + }), + ) + if not cards: + break + if error := cards.get('error'): + raise ExtractorError(error['message'], expected=True) + + for media in traverse_obj(cards, ('cards', ..., 'media', {dict})): + if all([ + traverse_obj(media, ('fileUrl', {url_or_none})), + traverse_obj(media, ('isOnLive', {bool})) is not True, + 'images' not in media, + ]): + yield self.url_result( + f'{self._BASE_URL}/web/fancommunities/{fc_id}/livearchive/{media["cardId"]}', FaniconIE) + + if not (min_id := traverse_obj(cards, ( + 'cards', ..., 'id', {int_or_none}, filter, all, {min}, {str_or_none}, + ))): + break + + def _real_extract(self, url): + fc_id = self._match_id(url) + + return self.playlist_result(self._entries(fc_id), fc_id)