[ie/twitch] Add support for portrait clips

pull/12763/head
DmitryScaletta 1 month ago
parent 6eaa574c82
commit 92031c1a15
No known key found for this signature in database
GPG Key ID: 167A65222EDD4C2A

@ -42,10 +42,10 @@ class TwitchBaseIE(InfoExtractor):
'CollectionSideBar': '27111f1b382effad0b6def325caef1909c733fe6a4fbabf54f8d491ef2cf2f14', 'CollectionSideBar': '27111f1b382effad0b6def325caef1909c733fe6a4fbabf54f8d491ef2cf2f14',
'FilterableVideoTower_Videos': 'a937f1d22e269e39a03b509f65a7490f9fc247d7f83d6ac1421523e3b68042cb', 'FilterableVideoTower_Videos': 'a937f1d22e269e39a03b509f65a7490f9fc247d7f83d6ac1421523e3b68042cb',
'ClipsCards__User': 'b73ad2bfaecfd30a9e6c28fada15bd97032c83ec77a0440766a56fe0bd632777', 'ClipsCards__User': 'b73ad2bfaecfd30a9e6c28fada15bd97032c83ec77a0440766a56fe0bd632777',
'ShareClipRenderStatus': 'f130048a462a0ac86bb54d653c968c514e9ab9ca94db52368c1179e97b0f16eb',
'ChannelCollectionsContent': '447aec6a0cc1e8d0a8d7732d47eb0762c336a2294fdb009e9c9d854e49d484b9', 'ChannelCollectionsContent': '447aec6a0cc1e8d0a8d7732d47eb0762c336a2294fdb009e9c9d854e49d484b9',
'StreamMetadata': 'a647c2a13599e5991e175155f798ca7f1ecddde73f7f341f39009c14dbf59962', 'StreamMetadata': 'a647c2a13599e5991e175155f798ca7f1ecddde73f7f341f39009c14dbf59962',
'ComscoreStreamingQuery': 'e1edae8122517d013405f237ffcc124515dc6ded82480a88daef69c83b53ac01', 'ComscoreStreamingQuery': 'e1edae8122517d013405f237ffcc124515dc6ded82480a88daef69c83b53ac01',
'VideoAccessToken_Clip': '36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11',
'VideoPreviewOverlay': '3006e77e51b128d838fa4e835723ca4dc9a05c5efd4466c1085215c6e437e65c', 'VideoPreviewOverlay': '3006e77e51b128d838fa4e835723ca4dc9a05c5efd4466c1085215c6e437e65c',
'VideoMetadata': '49b5b8f268cdeb259d75b58dcb0c1a748e3b575003448a2333dc5cdafd49adad', 'VideoMetadata': '49b5b8f268cdeb259d75b58dcb0c1a748e3b575003448a2333dc5cdafd49adad',
'VideoPlayer_ChapterSelectButtonVideo': '8d2793384aac3773beab5e59bd5d6f585aedb923d292800119e03d40cd0f9b41', 'VideoPlayer_ChapterSelectButtonVideo': '8d2793384aac3773beab5e59bd5d6f585aedb923d292800119e03d40cd0f9b41',
@ -1083,17 +1083,45 @@ class TwitchClipsIE(TwitchBaseIE):
'url': 'https://clips.twitch.tv/FaintLightGullWholeWheat', 'url': 'https://clips.twitch.tv/FaintLightGullWholeWheat',
'md5': '761769e1eafce0ffebfb4089cb3847cd', 'md5': '761769e1eafce0ffebfb4089cb3847cd',
'info_dict': { 'info_dict': {
'id': '42850523', 'id': '396245304',
'display_id': 'FaintLightGullWholeWheat', 'display_id': 'FaintLightGullWholeWheat',
'ext': 'mp4', 'ext': 'mp4',
'title': 'EA Play 2016 Live from the Novo Theatre', 'title': 'EA Play 2016 Live from the Novo Theatre',
'duration': 32,
'view_count': int,
'thumbnail': r're:^https?://.*\.jpg', 'thumbnail': r're:^https?://.*\.jpg',
'timestamp': 1465767393, 'timestamp': 1465767393,
'upload_date': '20160612', 'upload_date': '20160612',
'creator': 'EA', 'creators': ['EA'],
'channel': 'EA',
'channel_id': '25163635',
'channel_is_verified': False,
'channel_follower_count': int,
'uploader': 'stereotype_', 'uploader': 'stereotype_',
'uploader_id': '43566419', 'uploader_id': '43566419',
}, },
}, {
'url': 'https://www.twitch.tv/xqc/clip/CulturedAmazingKuduDatSheffy-TiZ_-ixAGYR3y2Uy',
'md5': 'e90fe616b36e722a8cfa562547c543f0',
'info_dict': {
'id': '3207364882',
'display_id': 'CulturedAmazingKuduDatSheffy-TiZ_-ixAGYR3y2Uy',
'ext': 'mp4',
'title': 'A day in the life of xQc',
'duration': 60,
'view_count': int,
'thumbnail': r're:^https?://.*\.jpg',
'timestamp': 1742869615,
'upload_date': '20250325',
'creators': ['xQc'],
'channel': 'xQc',
'channel_id': '71092938',
'channel_is_verified': True,
'channel_follower_count': int,
'uploader': 'okSTFUdude',
'uploader_id': '744085721',
'categories': ['Just Chatting'],
},
}, { }, {
# multiple formats # multiple formats
'url': 'https://clips.twitch.tv/rflegendary/UninterestedBeeDAESuppy', 'url': 'https://clips.twitch.tv/rflegendary/UninterestedBeeDAESuppy',
@ -1116,16 +1144,14 @@ class TwitchClipsIE(TwitchBaseIE):
}] }]
def _real_extract(self, url): def _real_extract(self, url):
video_id = self._match_id(url) slug = self._match_id(url)
clip = self._download_gql( clip = self._download_gql(
video_id, [{ slug, [{
'operationName': 'VideoAccessToken_Clip', 'operationName': 'ShareClipRenderStatus',
'variables': { 'variables': {'slug': slug},
'slug': video_id,
},
}], }],
'Downloading clip access token GraphQL')[0]['data']['clip'] 'Downloading clip GraphQL')[0]['data']['clip']
if not clip: if not clip:
raise ExtractorError( raise ExtractorError(
@ -1135,81 +1161,68 @@ class TwitchClipsIE(TwitchBaseIE):
'sig': clip['playbackAccessToken']['signature'], 'sig': clip['playbackAccessToken']['signature'],
'token': clip['playbackAccessToken']['value'], 'token': clip['playbackAccessToken']['value'],
} }
asset_default = traverse_obj(clip, ('assets', 0), {})
data = self._download_base_gql( asset_portrait = traverse_obj(clip, ('assets', 1), {})
video_id, { game = traverse_obj(clip, ('game', 'displayName'))
'query': '''{
clip(slug: "%s") {
broadcaster {
displayName
}
createdAt
curator {
displayName
id
}
durationSeconds
id
tiny: thumbnailURL(width: 86, height: 45)
small: thumbnailURL(width: 260, height: 147)
medium: thumbnailURL(width: 480, height: 272)
title
videoQualities {
frameRate
quality
sourceURL
}
viewCount
}
}''' % video_id}, 'Downloading clip GraphQL', fatal=False) # noqa: UP031
if data:
clip = try_get(data, lambda x: x['data']['clip'], dict) or clip
formats = [] formats = []
for option in clip.get('videoQualities', []): for quality in traverse_obj(asset_default, ('videoQualities'), default=[]):
if not isinstance(option, dict): if source := traverse_obj(quality, ('sourceURL', {url_or_none})):
continue formats.append({
source = url_or_none(option.get('sourceURL')) 'url': update_url_query(source, access_query),
if not source: 'format_id': quality.get('quality'),
continue 'height': int_or_none(quality.get('quality')),
formats.append({ 'fps': float_or_none(quality.get('frameRate')),
'url': update_url_query(source, access_query), 'aspect_ratio': float_or_none(asset_default.get('aspectRatio')),
'format_id': option.get('quality'), })
'height': int_or_none(option.get('quality')),
'fps': int_or_none(option.get('frameRate')),
})
thumbnails = [] for i, quality in enumerate(traverse_obj(asset_portrait, ('videoQualities'), default=[])):
for thumbnail_id in ('tiny', 'small', 'medium'): if source := traverse_obj(quality, ('sourceURL', {url_or_none})):
thumbnail_url = clip.get(thumbnail_id) formats.append({
if not thumbnail_url: 'url': update_url_query(source, access_query),
continue 'format_id': 'portrait-' + ('best' if i == 0 else quality.get('quality')),
thumb = { 'height': int_or_none(quality.get('quality')),
'id': thumbnail_id, 'fps': float_or_none(quality.get('frameRate')),
'url': thumbnail_url, 'aspect_ratio': float_or_none(asset_portrait.get('aspectRatio')),
} 'quality': -1,
mobj = re.search(r'-(\d+)x(\d+)\.', thumbnail_url)
if mobj:
thumb.update({
'height': int(mobj.group(2)),
'width': int(mobj.group(1)),
}) })
thumbnails.append(thumb)
thumbnails = []
thumb_default = clip.get('thumbnailURL')
thumb_asset_default = asset_default.get('thumbnailURL')
thumbnails.append({
'id': 'default',
'url': thumb_asset_default,
})
if thumb_asset_default != thumb_default:
thumbnails.append({
'id': 'small',
'url': thumb_default,
})
if thumb_asset_portrait := asset_portrait.get('thumbnailURL'):
thumbnails.append({
'id': 'portrait',
'url': thumb_asset_portrait,
})
old_id = self._search_regex(r'%7C(\d+)(?:-\d+)?.mp4', formats[-1]['url'], 'old id', default=None) old_id = self._search_regex(r'%7C(\d+)(?:-\d+)?.mp4', formats[-1]['url'], 'old id', default=None)
return { return {
'id': clip.get('id') or video_id, 'id': clip.get('id') or slug,
'_old_archive_ids': [make_archive_id(self, old_id)] if old_id else None, '_old_archive_ids': [make_archive_id(self, old_id)] if old_id else None,
'display_id': video_id, 'display_id': slug,
'title': clip.get('title'), 'title': clip.get('title'),
'formats': formats, 'formats': formats,
'duration': int_or_none(clip.get('durationSeconds')), 'duration': traverse_obj(clip, ('durationSeconds', {int_or_none})),
'view_count': int_or_none(clip.get('viewCount')), 'view_count': traverse_obj(clip, ('viewCount', {int_or_none})),
'timestamp': unified_timestamp(clip.get('createdAt')), 'timestamp': traverse_obj(clip, ('createdAt', {parse_iso8601})),
'thumbnails': thumbnails, 'thumbnails': thumbnails,
'creator': try_get(clip, lambda x: x['broadcaster']['displayName'], str), 'creator': traverse_obj(clip, ('broadcaster', 'displayName')),
'uploader': try_get(clip, lambda x: x['curator']['displayName'], str), 'channel': traverse_obj(clip, ('broadcaster', 'displayName')),
'uploader_id': try_get(clip, lambda x: x['curator']['id'], str), 'channel_id': traverse_obj(clip, ('broadcaster', 'id')),
'channel_follower_count': traverse_obj(clip, ('broadcaster', 'followers', 'totalCount', {int_or_none})),
'channel_is_verified': traverse_obj(clip, ('broadcaster', 'isPartner')),
'uploader': traverse_obj(clip, ('curator', 'displayName')),
'uploader_id': traverse_obj(clip, ('curator', 'id')),
'categories': [game] if game else None,
} }

Loading…
Cancel
Save