import functools import itertools import json import urllib.parse from .common import InfoExtractor from ..utils import ( ExtractorError, UserNotLive, clean_html, determine_ext, int_or_none, join_nonempty, parse_iso8601, parse_qs, parse_resolution, smuggle_url, str_or_none, unified_timestamp, unsmuggle_url, url_basename, url_or_none, ) from ..utils.traversal import require, traverse_obj class KickBaseIE(InfoExtractor): _BASE_URL = 'https://kick.com' @functools.cached_property def _api_headers(self): token = traverse_obj( self._get_cookies(self._BASE_URL), ('session_token', 'value', {urllib.parse.unquote})) return {'Authorization': f'Bearer {token}'} if token else {} def _call_api(self, path, display_id, note='Downloading API JSON', headers={}, **kwargs): api_domain = { 'v1': 'web.kick.com', 'v2': 'kick.com', }[path.partition('/')[0]] return self._download_json( f'https://{api_domain}/api/{path}', display_id, note=note, headers={**self._api_headers, **headers}, impersonate=True, **kwargs) def _get_creator_info(self, channel_slug): channel_data = self._call_api( f'v2/channels/{channel_slug}', channel_slug, note='Downloading creator info') creator_id = traverse_obj(channel_data, ( 'id', {int}, {str_or_none}, {require('creator ID')})) username = traverse_obj(channel_data, ('user', 'username', {str}, filter)) return creator_id, username @staticmethod def _extract_vod_metadata(video_data): return { 'availability': { 'sub_only': 'subscriber_only', 'private': 'private', 'public': 'public', }.get(traverse_obj(video_data, ('status', {str}))), **traverse_obj(video_data, { 'title': ('title', {clean_html}, filter), 'age_limit': ('is_mature', {bool}, {lambda x: 18 if x else None}), 'categories': ('category', 'name', {clean_html}, filter, all, filter), 'duration': ('duration', {int_or_none}), 'is_live': ('is_live', {bool}), 'tags': ('tags', ..., {clean_html}, filter, all, filter), 'thumbnails': ( 'thumbnail', 'srcSet', {lambda x: x.split(',')}, ..., {str.strip}, {lambda x: x.rsplit(maxsplit=1)}, { 'url': (0, {url_or_none}), 'width': (1, {parse_resolution(lenient=True)}, 'width'), 'height': (0, {url_basename}, {lambda x: int_or_none(x.partition('.')[0])}), }, ), 'timestamp': ('start_time', {parse_iso8601}), 'view_count': ('viewer_count', {int_or_none}), }), **traverse_obj(video_data, ('channel', { 'channel': ('username', {str}, filter), 'uploader': ('username', {str}, filter), 'uploader_id': ('slug', {str}, filter), })), } @staticmethod def _extract_clip_metadata(clip_data): return { **traverse_obj(clip_data, { 'title': ('title', {clean_html}, filter), 'age_limit': ('is_mature', {bool}, {lambda x: 18 if x else None}), 'categories': ('category', 'name', {clean_html}, filter, all, filter), 'duration': ('duration', {int_or_none}), 'like_count': (('likes_count', 'likes'), {int_or_none}, any), 'thumbnail': ('thumbnail_url', {url_or_none}), 'timestamp': ('created_at', {parse_iso8601}), 'view_count': (('views_count', 'view_count', 'views'), {int_or_none}, any), }), **traverse_obj(clip_data, ('channel', { 'channel': ('username', {str}, filter), 'channel_id': ('id', {int}, {str_or_none}), 'uploader': ('username', {str}, filter), 'uploader_id': ('slug', {str}, filter), })), } class KickIE(KickBaseIE): IE_NAME = 'kick:live' _VALID_URL = r'https?://(?:www\.)?kick\.com/(?!(?:video|categories|search|auth)(?:[/?#]|$))(?P[\w-]+)' _TESTS = [{ 'url': 'https://kick.com/coringa', 'info_dict': { 'id': '123735729', 'ext': 'mp4', 'title': str, 'categories': 'count:1', 'channel': 'Coringa', 'channel_follower_count': int, 'channel_id': '4124791', 'channel_is_verified': True, 'concurrent_view_count': int, 'description': str, 'live_status': 'is_live', 'release_date': r're:\d{8}', 'release_timestamp': int, 'thumbnail': r're:https?://.+', 'timestamp': int, 'upload_date': r're:\d{8}', 'uploader': 'Coringa', 'uploader_id': 'coringa', }, 'skip': 'Livestream', }] @classmethod def suitable(cls, url): return False if ( KickVODIE.suitable(url) or KickClipIE.suitable(url) or KickVideosIE.suitable(url) or KickClipsIE.suitable(url) ) else super().suitable(url) def _real_extract(self, url): channel_slug = self._match_id(url).lower() channel_data = self._call_api(f'v2/channels/{channel_slug}', channel_slug) if not traverse_obj(channel_data, ('livestream', {dict})): raise UserNotLive(video_id=channel_slug) m3u8_url = traverse_obj(channel_data, ( 'playback_url', {url_or_none}, {require('m3u8 URL')})) return { 'formats': self._extract_m3u8_formats(m3u8_url, channel_slug, 'mp4'), 'is_live': True, 'uploader_id': channel_slug, **traverse_obj(channel_data, { 'channel_follower_count': ('followers_count', {int_or_none}), 'channel_id': ('id', {int}, {str_or_none}), 'channel_is_verified': ('verified', {bool}), }), **traverse_obj(channel_data, ('livestream', { 'id': ('id', {str_or_none}), 'title': ('session_title', {clean_html}, filter), 'age_limit': ('is_mature', {bool}, {lambda x: 18 if x else None}), 'categories': ('categories', ..., 'name', {clean_html}, filter, all, filter), 'concurrent_view_count': ('viewer_count', {int_or_none}), 'release_timestamp': ('start_time', {unified_timestamp}), 'tags': ('tags', ..., {clean_html}, filter, all, filter), 'thumbnail': ('thumbnail', 'url', {url_or_none}), 'timestamp': ('created_at', {unified_timestamp}), })), **traverse_obj(channel_data, ('user', { 'channel': ('username', {str}, filter), 'description': ('bio', {clean_html}, filter), 'uploader': ('username', {str}, filter), })), } class KickVODIE(KickBaseIE): IE_NAME = 'kick:vod' _VALID_URL = r'https?://(?:www\.)?kick\.com/[\w-]+/videos/(?P[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12})' _TESTS = [{ 'url': 'https://kick.com/xqc/videos/01a0251f-ef48-702d-9289-d66922da0e84', 'info_dict': { 'id': '01a0251f-ef48-702d-9289-d66922da0e84', 'ext': 'mp4', 'title': 'md5:03d5dbce5b2379d9c5e1229a8fb05e17', 'age_limit': 18, 'availability': 'public', 'categories': ['Just Chatting'], 'channel': 'xQc', 'channel_id': '668', 'duration': 42250, 'thumbnail': r're:https?://.+', 'timestamp': 1787329245, 'upload_date': '20260821', 'uploader': 'xQc', 'uploader_id': 'xqc', 'view_count': int, }, 'params': {'skip_download': 'm3u8'}, }, { # Ongoing livestream VOD 'url': 'https://kick.com/a-log-burner/videos/01a02882-5428-7ea1-9237-97f070aab4cc', 'info_dict': { 'id': '01a02882-5428-7ea1-9237-97f070aab4cc', 'ext': 'mp4', 'title': str, 'availability': 'public', 'categories': ['Meditation & Mindfulness'], 'channel': 'A_Log_Burner', 'channel_id': '63967687', 'live_status': 'is_live', 'tags': 'count:9', 'thumbnail': r're:https?://.+', 'timestamp': 1787386025, 'upload_date': '20260822', 'uploader': 'A_Log_Burner', 'uploader_id': 'a-log-burner', 'view_count': int, }, 'skip': 'Livestream', }] _subscription_cache = None def _is_subscribed(self, creator_id): if self._subscription_cache is None: self._subscription_cache = {} if creator_id not in self._subscription_cache: me = self._call_api( f'v1/channels/{creator_id}/me', creator_id, note='Checking subscription status') self._subscription_cache[creator_id] = bool( traverse_obj(me, ('data', 'subscription', {dict}))) return self._subscription_cache[creator_id] def _real_extract(self, url): url, smuggled_data = unsmuggle_url(url, {}) video_id = self._match_id(url) playback = self._call_api( f'v1/stream/{video_id}/playback', video_id, headers={ 'Content-Type': 'application/json', }, data=json.dumps({ 'video_player': {'player': {}}, 'video_session': {}, 'user_session': { 'non_personalised_ads': True, }, }).encode(), expected_status=404) if data := traverse_obj(playback, ('data', {dict})): err_msg = join_nonempty('type', 'details', delim=': ', from_dict=data) raise ExtractorError( err_msg or 'API returned an error response', expected=True) creator_id = traverse_obj(playback, ( 'video_session', 'creator_id', {str}, {require('creator ID')})) metadata = {} if 'availability' in smuggled_data: availability = smuggled_data['availability'] else: videos = self._call_api(f'v1/channels/{creator_id}/videos', creator_id) video_data = traverse_obj(videos, ( 'data', lambda _, v: v['id'] == video_id, {dict}, any)) metadata = self._extract_vod_metadata(video_data) availability = metadata['availability'] m3u8_url = traverse_obj(playback, ('playback_url', 'vod', {url_or_none})) if not m3u8_url: if availability == 'subscriber_only' and not self._is_subscribed(creator_id): self.raise_login_required( 'This video requires a channel subscription', metadata_available=True) return { 'id': video_id, 'channel_id': creator_id, 'formats': self._extract_m3u8_formats(m3u8_url, video_id, 'mp4'), **metadata, } class KickClipIE(KickBaseIE): IE_NAME = 'kick:clip' _VALID_URL = r'https?://(?:www\.)?kick\.com/[\w-]+(?:/clips/|/?\?(?:[^#]+&)?clip=)(?Pclip_[\w-]+)' _TESTS = [{ 'url': 'https://kick.com/mxddy?clip=clip_01GYXVB5Y8PWAPWCWMSBCFB05X', 'md5': 'a1014e8a26b6f45bc64bcef679dd19a7', 'info_dict': { 'id': 'clip_01GYXVB5Y8PWAPWCWMSBCFB05X', 'ext': 'mp4', 'title': 'Maddy detains Abd D:', 'age_limit': 18, 'categories': ['VALORANT'], 'channel': 'Mxddy', 'channel_id': '133789', 'duration': 35, 'like_count': int, 'thumbnail': r're:https?://.+', 'timestamp': 1682481453, 'upload_date': '20230426', 'uploader': 'Mxddy', 'uploader_id': 'mxddy', 'view_count': int, }, }, { 'url': 'https://kick.com/destiny?clip=clip_01H9SKET879NE7N9RJRRDS98J3', 'info_dict': { 'id': 'clip_01H9SKET879NE7N9RJRRDS98J3', 'ext': 'mp4', 'title': 'W jews', 'categories': ['Just Chatting'], 'channel': 'Destiny', 'channel_id': '1772249', 'duration': 49, 'like_count': int, 'thumbnail': r're:https?://.+', 'timestamp': 1694150180, 'upload_date': '20230908', 'uploader': 'Destiny', 'uploader_id': 'destiny', 'view_count': int, }, 'params': {'skip_download': 'm3u8'}, }, { 'url': 'https://kick.com/spreen/clips/clip_01J8RGZRKHXHXXKJEHGRM932A5', 'info_dict': { 'id': 'clip_01J8RGZRKHXHXXKJEHGRM932A5', 'ext': 'mp4', 'title': 'KLJASLDJKLJKASDLJKDAS', 'categories': ['Just Chatting'], 'channel': 'Spreen', 'channel_id': '5312671', 'duration': 43, 'like_count': int, 'thumbnail': r're:https?://.+', 'timestamp': 1727399987, 'upload_date': '20240927', 'uploader': 'Spreen', 'uploader_id': 'spreen', 'view_count': int, }, 'params': {'skip_download': 'm3u8'}, }] def _real_extract(self, url): clip_id = self._match_id(url) clip = self._call_api( f'v2/clips/{clip_id}', clip_id, expected_status=404) if err_msg := traverse_obj(clip, ( 'message', {clean_html}, filter, )): raise ExtractorError(err_msg, expected=True) clip_data = traverse_obj(clip, ('clip', {dict})) clip_url = traverse_obj(clip_data, ( ('playback_url', 'clip_url', 'video_url'), {url_or_none}, any, {require('clip source URL')})) if determine_ext(clip_url) == 'm3u8': formats = self._extract_m3u8_formats(clip_url, clip_id, 'mp4') else: formats = [{'url': clip_url}] return { 'id': clip_id, 'formats': formats, **self._extract_clip_metadata(clip_data), } class KickVideosIE(KickBaseIE): IE_NAME = 'kick:videos' _VALID_URL = r'https?://(?:www\.)?kick\.com/(?P[\w-]+)/videos/?(?:[?#]|$)' _TESTS = [{ 'url': 'https://kick.com/xqc/videos', 'info_dict': { 'id': 'xqc', 'title': 'xQc - Videos sorted by Date', }, 'playlist_mincount': 10, }, { 'url': 'https://kick.com/xqc/videos?sort=views', 'info_dict': { 'id': 'xqc', 'title': 'xQc - Videos sorted by Views', }, 'playlist_mincount': 10, }] def _entries(self, creator_id, channel_slug, sort_param): videos = self._call_api(f'v1/channels/{creator_id}/videos', channel_slug) if sort_param == 'views': videos['data'].sort(key=lambda v: v['viewer_count'], reverse=True) for video_data in traverse_obj(videos, ( 'data', lambda _, v: str_or_none(v['id']), )): video_id = video_data['id'] metadata = self._extract_vod_metadata(video_data) yield self.url_result(smuggle_url( f'{self._BASE_URL}/{channel_slug}/videos/{video_id}', {'availability': metadata['availability']}, ), KickVODIE, video_id, url_transparent=True, **metadata) def _real_extract(self, url): channel_slug = self._match_id(url).lower() creator_id, username = self._get_creator_info(channel_slug) sort_param = traverse_obj(parse_qs(url), ('sort', -1, {str}, filter)) sort_label = 'Views' if sort_param == 'views' else 'Date' return self.playlist_result( self._entries(creator_id, channel_slug, sort_param), channel_slug, f'{username or channel_slug} - Videos sorted by {sort_label}') class KickClipsIE(KickBaseIE): IE_NAME = 'kick:clips' _VALID_URL = r'https?://(?:www\.)?kick\.com/(?P[\w-]+)/clips/?(?:[?#]|$)' _TESTS = [{ 'url': 'https://kick.com/xqc/clips', 'info_dict': { 'id': 'xqc', 'title': 'xQc - Clips sorted by Views (Last Week)', }, 'playlist_mincount': 100, }, { 'url': 'https://kick.com/xqc/clips?sort=date&range=day', 'info_dict': { 'id': 'xqc', 'title': 'xQc - Clips sorted by Date (Last Day)', }, 'playlist_mincount': 10, }] def _entries(self, creator_id, channel_slug, sort_param, range_param): query = { 'sort': 'views' if sort_param == 'view' else 'date', 'time': range_param, } for page in itertools.count(1): clips = self._call_api( f'v1/channels/{creator_id}/clips', channel_slug, note=f'Downloading page {page}', query=query) for clip_data in traverse_obj(clips, ( 'data', 'clips', lambda _, v: str_or_none(v['id']), )): clip_id = clip_data['id'] yield self.url_result( f'{self._BASE_URL}/{channel_slug}/clips/{clip_id}', KickClipIE, clip_id, **self._extract_clip_metadata(clip_data)) cursor = traverse_obj(clips, ('data', 'cursor', {str}, filter)) if not cursor: break query['cursor'] = cursor def _real_extract(self, url): channel_slug = self._match_id(url).lower() creator_id, username = self._get_creator_info(channel_slug) query = parse_qs(url) sort_param = traverse_obj(query, ('sort', -1, {str}, filter)) if sort_param not in ('view', 'date'): sort_param = 'view' range_param = traverse_obj(query, ('range', -1, {str}, filter)) if range_param not in ('day', 'week', 'month', 'all'): range_param = 'week' sort_label = 'Views' if sort_param == 'view' else 'Date' range_label = { 'day': 'Last Day', 'month': 'Last Month', 'all': 'All Time', }.get(range_param, 'Last Week') return self.playlist_result( self._entries(creator_id, channel_slug, sort_param, range_param), channel_slug, f'{username or channel_slug} - Clips sorted by {sort_label} ({range_label})')