aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDmitryScaletta <DmitryScaletta@users.noreply.github.com>2025-04-01 00:21:14 +0300
committerGitHub <noreply@github.com>2025-03-31 21:21:14 +0000
commit61046c31612b30c749cbdae934b7fe26abe659d7 (patch)
treec027495d7eb189eaef56f7ebd9aed206eda22a04
parent07f04005e40ebdb368920c511e36e98af0077ed3 (diff)
[ie/twitch:clips] Extract portrait formats (#12763)
Authored by: DmitryScaletta
-rw-r--r--yt_dlp/extractor/twitch.py171
1 files changed, 94 insertions, 77 deletions
diff --git a/yt_dlp/extractor/twitch.py b/yt_dlp/extractor/twitch.py
index 44b19ad13..a36de3c01 100644
--- a/yt_dlp/extractor/twitch.py
+++ b/yt_dlp/extractor/twitch.py
@@ -14,19 +14,20 @@ from ..utils import (
dict_get,
float_or_none,
int_or_none,
+ join_nonempty,
make_archive_id,
parse_duration,
parse_iso8601,
parse_qs,
qualities,
str_or_none,
- traverse_obj,
try_get,
unified_timestamp,
update_url_query,
url_or_none,
urljoin,
)
+from ..utils.traversal import traverse_obj, value
class TwitchBaseIE(InfoExtractor):
@@ -42,10 +43,10 @@ class TwitchBaseIE(InfoExtractor):
'CollectionSideBar': '27111f1b382effad0b6def325caef1909c733fe6a4fbabf54f8d491ef2cf2f14',
'FilterableVideoTower_Videos': 'a937f1d22e269e39a03b509f65a7490f9fc247d7f83d6ac1421523e3b68042cb',
'ClipsCards__User': 'b73ad2bfaecfd30a9e6c28fada15bd97032c83ec77a0440766a56fe0bd632777',
+ 'ShareClipRenderStatus': 'f130048a462a0ac86bb54d653c968c514e9ab9ca94db52368c1179e97b0f16eb',
'ChannelCollectionsContent': '447aec6a0cc1e8d0a8d7732d47eb0762c336a2294fdb009e9c9d854e49d484b9',
'StreamMetadata': 'a647c2a13599e5991e175155f798ca7f1ecddde73f7f341f39009c14dbf59962',
'ComscoreStreamingQuery': 'e1edae8122517d013405f237ffcc124515dc6ded82480a88daef69c83b53ac01',
- 'VideoAccessToken_Clip': '36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11',
'VideoPreviewOverlay': '3006e77e51b128d838fa4e835723ca4dc9a05c5efd4466c1085215c6e437e65c',
'VideoMetadata': '49b5b8f268cdeb259d75b58dcb0c1a748e3b575003448a2333dc5cdafd49adad',
'VideoPlayer_ChapterSelectButtonVideo': '8d2793384aac3773beab5e59bd5d6f585aedb923d292800119e03d40cd0f9b41',
@@ -1083,16 +1084,44 @@ class TwitchClipsIE(TwitchBaseIE):
'url': 'https://clips.twitch.tv/FaintLightGullWholeWheat',
'md5': '761769e1eafce0ffebfb4089cb3847cd',
'info_dict': {
- 'id': '42850523',
+ 'id': '396245304',
'display_id': 'FaintLightGullWholeWheat',
'ext': 'mp4',
'title': 'EA Play 2016 Live from the Novo Theatre',
+ 'duration': 32,
+ 'view_count': int,
'thumbnail': r're:^https?://.*\.jpg',
'timestamp': 1465767393,
'upload_date': '20160612',
- 'creator': 'EA',
- 'uploader': 'stereotype_',
- 'uploader_id': '43566419',
+ 'creators': ['EA'],
+ 'channel': 'EA',
+ 'channel_id': '25163635',
+ 'channel_is_verified': False,
+ 'channel_follower_count': int,
+ 'uploader': 'EA',
+ 'uploader_id': '25163635',
+ },
+ }, {
+ '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': 'xQc',
+ 'uploader_id': '71092938',
+ 'categories': ['Just Chatting'],
},
}, {
# multiple formats
@@ -1116,16 +1145,14 @@ class TwitchClipsIE(TwitchBaseIE):
}]
def _real_extract(self, url):
- video_id = self._match_id(url)
+ slug = self._match_id(url)
clip = self._download_gql(
- video_id, [{
- 'operationName': 'VideoAccessToken_Clip',
- 'variables': {
- 'slug': video_id,
- },
+ slug, [{
+ 'operationName': 'ShareClipRenderStatus',
+ 'variables': {'slug': slug},
}],
- 'Downloading clip access token GraphQL')[0]['data']['clip']
+ 'Downloading clip GraphQL')[0]['data']['clip']
if not clip:
raise ExtractorError(
@@ -1135,81 +1162,71 @@ class TwitchClipsIE(TwitchBaseIE):
'sig': clip['playbackAccessToken']['signature'],
'token': clip['playbackAccessToken']['value'],
}
-
- data = self._download_base_gql(
- video_id, {
- '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
+ asset_default = traverse_obj(clip, ('assets', 0, {dict})) or {}
+ asset_portrait = traverse_obj(clip, ('assets', 1, {dict})) or {}
formats = []
- for option in clip.get('videoQualities', []):
- if not isinstance(option, dict):
- continue
- source = url_or_none(option.get('sourceURL'))
- if not source:
- continue
+ default_aspect_ratio = float_or_none(asset_default.get('aspectRatio'))
+ formats.extend(traverse_obj(asset_default, ('videoQualities', lambda _, v: url_or_none(v['sourceURL']), {
+ 'url': ('sourceURL', {update_url_query(query=access_query)}),
+ 'format_id': ('quality', {str}),
+ 'height': ('quality', {int_or_none}),
+ 'fps': ('frameRate', {float_or_none}),
+ 'aspect_ratio': {value(default_aspect_ratio)},
+ })))
+ portrait_aspect_ratio = float_or_none(asset_portrait.get('aspectRatio'))
+ for source in traverse_obj(asset_portrait, ('videoQualities', lambda _, v: url_or_none(v['sourceURL']))):
formats.append({
- 'url': update_url_query(source, access_query),
- 'format_id': option.get('quality'),
- 'height': int_or_none(option.get('quality')),
- 'fps': int_or_none(option.get('frameRate')),
+ 'url': update_url_query(source['sourceURL'], access_query),
+ 'format_id': join_nonempty('portrait', source.get('quality')),
+ 'height': int_or_none(source.get('quality')),
+ 'fps': float_or_none(source.get('frameRate')),
+ 'aspect_ratio': portrait_aspect_ratio,
+ 'quality': -2,
})
thumbnails = []
- for thumbnail_id in ('tiny', 'small', 'medium'):
- thumbnail_url = clip.get(thumbnail_id)
- if not thumbnail_url:
- continue
- thumb = {
- 'id': thumbnail_id,
- 'url': thumbnail_url,
- }
- 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)
+ thumb_asset_default_url = url_or_none(asset_default.get('thumbnailURL'))
+ if thumb_asset_default_url:
+ thumbnails.append({
+ 'id': 'default',
+ 'url': thumb_asset_default_url,
+ 'preference': 0,
+ })
+ if thumb_asset_portrait_url := url_or_none(asset_portrait.get('thumbnailURL')):
+ thumbnails.append({
+ 'id': 'portrait',
+ 'url': thumb_asset_portrait_url,
+ 'preference': -1,
+ })
+ thumb_default_url = url_or_none(clip.get('thumbnailURL'))
+ if thumb_default_url and thumb_default_url != thumb_asset_default_url:
+ thumbnails.append({
+ 'id': 'small',
+ 'url': thumb_default_url,
+ 'preference': -2,
+ })
old_id = self._search_regex(r'%7C(\d+)(?:-\d+)?.mp4', formats[-1]['url'], 'old id', default=None)
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,
- 'display_id': video_id,
- 'title': clip.get('title'),
+ 'display_id': slug,
'formats': formats,
- 'duration': int_or_none(clip.get('durationSeconds')),
- 'view_count': int_or_none(clip.get('viewCount')),
- 'timestamp': unified_timestamp(clip.get('createdAt')),
'thumbnails': thumbnails,
- 'creator': try_get(clip, lambda x: x['broadcaster']['displayName'], str),
- 'uploader': try_get(clip, lambda x: x['curator']['displayName'], str),
- 'uploader_id': try_get(clip, lambda x: x['curator']['id'], str),
+ **traverse_obj(clip, {
+ 'title': ('title', {str}),
+ 'duration': ('durationSeconds', {int_or_none}),
+ 'view_count': ('viewCount', {int_or_none}),
+ 'timestamp': ('createdAt', {parse_iso8601}),
+ 'creators': ('broadcaster', 'displayName', {str}, filter, all),
+ 'channel': ('broadcaster', 'displayName', {str}),
+ 'channel_id': ('broadcaster', 'id', {str}),
+ 'channel_follower_count': ('broadcaster', 'followers', 'totalCount', {int_or_none}),
+ 'channel_is_verified': ('broadcaster', 'isPartner', {bool}),
+ 'uploader': ('broadcaster', 'displayName', {str}),
+ 'uploader_id': ('broadcaster', 'id', {str}),
+ 'categories': ('game', 'displayName', {str}, filter, all, filter),
+ }),
}