diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py index 34c98b537d..766cacad21 100644 --- a/yt_dlp/extractor/_extractors.py +++ b/yt_dlp/extractor/_extractors.py @@ -2253,6 +2253,7 @@ from .twitch import ( TwitchClipsIE, TwitchCollectionIE, + TwitchDirectoryClipsIE, TwitchStreamIE, TwitchVideosClipsIE, TwitchVideosCollectionsIE, diff --git a/yt_dlp/extractor/twitch.py b/yt_dlp/extractor/twitch.py index e4f2aec465..5ab33e43ec 100644 --- a/yt_dlp/extractor/twitch.py +++ b/yt_dlp/extractor/twitch.py @@ -42,6 +42,7 @@ class TwitchBaseIE(InfoExtractor): _OPERATION_HASHES = { 'CollectionSideBar': '27111f1b382effad0b6def325caef1909c733fe6a4fbabf54f8d491ef2cf2f14', 'FilterableVideoTower_Videos': 'a937f1d22e269e39a03b509f65a7490f9fc247d7f83d6ac1421523e3b68042cb', + 'ClipsCards__Game': 'ebcf54afb9aa5d6cec8aad2c35b84e2737a109dac5b184308aae73a27d176707', 'ClipsCards__User': 'b73ad2bfaecfd30a9e6c28fada15bd97032c83ec77a0440766a56fe0bd632777', 'ShareClipRenderStatus': 'f130048a462a0ac86bb54d653c968c514e9ab9ca94db52368c1179e97b0f16eb', 'ChannelCollectionsContent': '447aec6a0cc1e8d0a8d7732d47eb0762c336a2294fdb009e9c9d854e49d484b9', @@ -639,11 +640,12 @@ class TwitchPlaylistBaseIE(TwitchBaseIE): def _entries(self, channel_name, *args): """ Subclasses must define _make_variables() and _extract_entry(), - as well as set _OPERATION_NAME, _ENTRY_KIND, _EDGE_KIND, and _NODE_KIND + as well as set _OPERATION_NAME, _ENTRY_KIND, _DATA_KIND, _EDGE_KIND, and _NODE_KIND """ cursor = None variables_common = self._make_variables(channel_name, *args) entries_key = f'{self._ENTRY_KIND}s' + data_key = self._DATA_KIND for page_num in itertools.count(1): variables = variables_common.copy() variables['limit'] = self._PAGE_LIMIT @@ -659,7 +661,7 @@ def _entries(self, channel_name, *args): if not page: break edges = try_get( - page, lambda x: x[0]['data']['user'][entries_key]['edges'], list) + page, lambda x: x[0]['data'][data_key][entries_key]['edges'], list) if not edges: break for edge in edges: @@ -779,6 +781,7 @@ def suitable(cls, url): return (False if any(ie.suitable(url) for ie in ( TwitchVideosClipsIE, + TwitchDirectoryClipsIE, TwitchVideosCollectionsIE)) else super().suitable(url)) @@ -800,6 +803,87 @@ def _real_extract(self, url): f'sorted by {self._SORTED_BY.get(sort, self._DEFAULT_SORTED_BY)}')) +class TwitchDirectoryClipsIE(TwitchPlaylistBaseIE): + _VALID_URL = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/directory/category/(?P[^/]+)/(?:clips|videos/*?\?.*?)' + + _TESTS = [{ + # Clips (defaults to 7d) + 'url': 'https://www.twitch.tv/directory/category/starcraft/clips?range=7d', + 'info_dict': { + 'id': 'starcraft', + 'title': 'starcraft - Clips Top 7D', + }, + 'playlist_mincount': 3, + }, { + 'url': 'https://www.twitch.tv/directory/category/minecraft/clips?range=30d', + 'info_dict': { + 'id': 'minecraft', + 'title': 'minecraft - Clips Top 30D', + }, + 'playlist_mincount': 3, + }] + + Clip = collections.namedtuple('Clip', ['filter', 'label']) + + _DEFAULT_CLIP = Clip('LAST_WEEK', 'Top 7D') + _RANGE = { + '24hr': Clip('LAST_DAY', 'Top 24H'), + '7d': _DEFAULT_CLIP, + '30d': Clip('LAST_MONTH', 'Top 30D'), + 'all': Clip('ALL_TIME', 'Top All'), + } + + _PAGE_LIMIT = 20 + + _OPERATION_NAME = 'ClipsCards__Game' + _ENTRY_KIND = 'clip' + _DATA_KIND = 'game' + _EDGE_KIND = 'ClipEdge' + _NODE_KIND = 'Clip' + + @staticmethod + def _make_variables(game_name, channel_filter): + return { + 'categorySlug': game_name, + 'limit': 20, + 'criteria': { + 'filter': channel_filter, + }, + } + + @staticmethod + def _extract_entry(node): + assert isinstance(node, dict) + slug = node.get('slug') + broadcaster_name = traverse_obj(node, ('broadcaster', 'login')) + clip_url = f'https://www.twitch.tv/{broadcaster_name}/clip/{slug}' + if not clip_url: + return + return { + '_type': 'url_transparent', + 'ie_key': TwitchClipsIE.ie_key(), + 'id': node.get('id'), + 'url': clip_url, + 'title': node.get('title'), + 'thumbnail': node.get('thumbnailURL'), + 'duration': float_or_none(node.get('durationSeconds')), + 'timestamp': unified_timestamp(node.get('createdAt')), + 'view_count': int_or_none(node.get('viewCount')), + 'language': node.get('language'), + } + + def _real_extract(self, url): + game_name = self._match_id(url) + qs = parse_qs(url) + date_range = qs.get('range', ['7d'])[0] + clip = self._RANGE.get(date_range, self._DEFAULT_CLIP) + + return self.playlist_result( + self._entries(game_name, clip.filter), + playlist_id=game_name, + playlist_title=f'{game_name} - Clips {clip.label}') + + class TwitchVideosClipsIE(TwitchPlaylistBaseIE): _VALID_URL = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/(?P[^/]+)/(?:clips|videos/*?\?.*?\bfilter=clips)' @@ -831,6 +915,7 @@ class TwitchVideosClipsIE(TwitchPlaylistBaseIE): _OPERATION_NAME = 'ClipsCards__User' _ENTRY_KIND = 'clip' + _DATA_KIND = 'user' _EDGE_KIND = 'ClipEdge' _NODE_KIND = 'Clip' @@ -895,6 +980,7 @@ class TwitchVideosCollectionsIE(TwitchPlaylistBaseIE): _OPERATION_NAME = 'ChannelCollectionsContent' _ENTRY_KIND = 'collection' + _DATA_KIND = 'user' _EDGE_KIND = 'CollectionsItemEdge' _NODE_KIND = 'Collection'