[ie/tiktok] Extract all web formats (#9960)

Closes #9506
Authored by: bashonly
This commit is contained in:
bashonly 2024-05-20 18:11:24 -05:00 committed by GitHub
parent 3584b8390b
commit 4ccd73fea0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -20,6 +20,8 @@ from ..utils import (
int_or_none, int_or_none,
join_nonempty, join_nonempty,
merge_dicts, merge_dicts,
mimetype2ext,
parse_qs,
qualities, qualities,
remove_start, remove_start,
srt_subtitles_timecode, srt_subtitles_timecode,
@ -250,11 +252,7 @@ class TikTokBaseIE(InfoExtractor):
}) })
return subtitles return subtitles
def _parse_aweme_video_app(self, aweme_detail): def _parse_url_key(self, url_key):
aweme_id = aweme_detail['aweme_id']
video_info = aweme_detail['video']
def parse_url_key(url_key):
format_id, codec, res, bitrate = self._search_regex( format_id, codec, res, bitrate = self._search_regex(
r'v[^_]+_(?P<id>(?P<codec>[^_]+)_(?P<res>\d+p)_(?P<bitrate>\d+))', url_key, r'v[^_]+_(?P<id>(?P<codec>[^_]+)_(?P<res>\d+p)_(?P<bitrate>\d+))', url_key,
'url key', default=(None, None, None, None), group=('id', 'codec', 'res', 'bitrate')) 'url key', default=(None, None, None, None), group=('id', 'codec', 'res', 'bitrate'))
@ -267,6 +265,9 @@ class TikTokBaseIE(InfoExtractor):
'quality': qualities(self.QUALITIES)(res), 'quality': qualities(self.QUALITIES)(res),
}, res }, res
def _parse_aweme_video_app(self, aweme_detail):
aweme_id = aweme_detail['aweme_id']
video_info = aweme_detail['video']
known_resolutions = {} known_resolutions = {}
def audio_meta(url): def audio_meta(url):
@ -281,7 +282,7 @@ class TikTokBaseIE(InfoExtractor):
} if ext == 'mp3' or '-music-' in url else {} } if ext == 'mp3' or '-music-' in url else {}
def extract_addr(addr, add_meta={}): def extract_addr(addr, add_meta={}):
parsed_meta, res = parse_url_key(addr.get('url_key', '')) parsed_meta, res = self._parse_url_key(addr.get('url_key', ''))
is_bytevc2 = parsed_meta.get('vcodec') == 'bytevc2' is_bytevc2 = parsed_meta.get('vcodec') == 'bytevc2'
if res: if res:
known_resolutions.setdefault(res, {}).setdefault('height', int_or_none(addr.get('height'))) known_resolutions.setdefault(res, {}).setdefault('height', int_or_none(addr.get('height')))
@ -295,7 +296,7 @@ class TikTokBaseIE(InfoExtractor):
'acodec': 'aac', 'acodec': 'aac',
'source_preference': -2 if 'aweme/v1' in url else -1, # Downloads from API might get blocked 'source_preference': -2 if 'aweme/v1' in url else -1, # Downloads from API might get blocked
**add_meta, **parsed_meta, **add_meta, **parsed_meta,
# bytevc2 is bytedance's proprietary (unplayable) video codec # bytevc2 is bytedance's own custom h266/vvc codec, as-of-yet unplayable
'preference': -100 if is_bytevc2 else -1, 'preference': -100 if is_bytevc2 else -1,
'format_note': join_nonempty( 'format_note': join_nonempty(
add_meta.get('format_note'), '(API)' if 'aweme/v1' in url else None, add_meta.get('format_note'), '(API)' if 'aweme/v1' in url else None,
@ -307,6 +308,7 @@ class TikTokBaseIE(InfoExtractor):
formats = [] formats = []
width = int_or_none(video_info.get('width')) width = int_or_none(video_info.get('width'))
height = int_or_none(video_info.get('height')) height = int_or_none(video_info.get('height'))
ratio = try_call(lambda: width / height) or 0.5625
if video_info.get('play_addr'): if video_info.get('play_addr'):
formats.extend(extract_addr(video_info['play_addr'], { formats.extend(extract_addr(video_info['play_addr'], {
'format_id': 'play_addr', 'format_id': 'play_addr',
@ -323,8 +325,8 @@ class TikTokBaseIE(InfoExtractor):
'format_id': 'download_addr', 'format_id': 'download_addr',
'format_note': 'Download video%s' % (', watermarked' if video_info.get('has_watermark') else ''), 'format_note': 'Download video%s' % (', watermarked' if video_info.get('has_watermark') else ''),
'vcodec': 'h264', 'vcodec': 'h264',
'width': dl_width or width, 'width': dl_width,
'height': try_call(lambda: int(dl_width / 0.5625)) or height, # download_addr['height'] is wrong 'height': try_call(lambda: int(dl_width / ratio)), # download_addr['height'] is wrong
'preference': -2 if video_info.get('has_watermark') else -1, 'preference': -2 if video_info.get('has_watermark') else -1,
})) }))
if video_info.get('play_addr_h264'): if video_info.get('play_addr_h264'):
@ -431,26 +433,88 @@ class TikTokBaseIE(InfoExtractor):
formats = [] formats = []
width = int_or_none(video_info.get('width')) width = int_or_none(video_info.get('width'))
height = int_or_none(video_info.get('height')) height = int_or_none(video_info.get('height'))
ratio = try_call(lambda: width / height) or 0.5625
COMMON_FORMAT_INFO = {
'ext': 'mp4',
'vcodec': 'h264',
'acodec': 'aac',
}
for bitrate_info in traverse_obj(video_info, ('bitrateInfo', lambda _, v: v['PlayAddr']['UrlList'])):
format_info, res = self._parse_url_key(
traverse_obj(bitrate_info, ('PlayAddr', 'UrlKey', {str})) or '')
# bytevc2 is bytedance's own custom h266/vvc codec, as-of-yet unplayable
is_bytevc2 = format_info.get('vcodec') == 'bytevc2'
format_info.update({
'format_note': 'UNPLAYABLE' if is_bytevc2 else None,
'preference': -100 if is_bytevc2 else -1,
'filesize': traverse_obj(bitrate_info, ('PlayAddr', 'DataSize', {int_or_none})),
})
if dimension := (res and int(res[:-1])):
if dimension == 540: # '540p' is actually 576p
dimension = 576
if ratio < 1: # portrait: res/dimension is width
y = int(dimension / ratio)
format_info.update({
'width': dimension,
'height': y - (y % 2),
})
else: # landscape: res/dimension is height
x = int(dimension * ratio)
format_info.update({
'width': x - (x % 2),
'height': dimension,
})
for video_url in traverse_obj(bitrate_info, ('PlayAddr', 'UrlList', ..., {url_or_none})):
formats.append({
**COMMON_FORMAT_INFO,
**format_info,
'url': self._proto_relative_url(video_url),
})
# We don't have res string for play formats, but need quality for sorting & de-duplication
play_quality = traverse_obj(formats, (lambda _, v: v['width'] == width, 'quality', any))
for play_url in traverse_obj(video_info, ('playAddr', ((..., 'src'), None), {url_or_none})): for play_url in traverse_obj(video_info, ('playAddr', ((..., 'src'), None), {url_or_none})):
formats.append({ formats.append({
**COMMON_FORMAT_INFO,
'format_id': 'play',
'url': self._proto_relative_url(play_url), 'url': self._proto_relative_url(play_url),
'ext': 'mp4',
'width': width, 'width': width,
'height': height, 'height': height,
'quality': play_quality,
}) })
for download_url in traverse_obj(video_info, (('downloadAddr', ('download', 'url')), {url_or_none})): for download_url in traverse_obj(video_info, (('downloadAddr', ('download', 'url')), {url_or_none})):
formats.append({ formats.append({
**COMMON_FORMAT_INFO,
'format_id': 'download', 'format_id': 'download',
'url': self._proto_relative_url(download_url), 'url': self._proto_relative_url(download_url),
'ext': 'mp4',
'width': width,
'height': height,
}) })
self._remove_duplicate_formats(formats) self._remove_duplicate_formats(formats)
for f in traverse_obj(formats, lambda _, v: 'unwatermarked' not in v['url']):
f.update({
'format_note': join_nonempty(f.get('format_note'), 'watermarked', delim=', '),
'preference': f.get('preference') or -2,
})
# Is it a slideshow with only audio for download?
if not formats and traverse_obj(music_info, ('playUrl', {url_or_none})):
audio_url = music_info['playUrl']
ext = traverse_obj(parse_qs(audio_url), (
'mime_type', -1, {lambda x: x.replace('_', '/')}, {mimetype2ext})) or 'm4a'
formats.append({
'format_id': 'audio',
'url': self._proto_relative_url(audio_url),
'ext': ext,
'acodec': 'aac' if ext == 'm4a' else ext,
'vcodec': 'none',
})
thumbnails = [] thumbnails = []
for thumb_url in traverse_obj(aweme_detail, ( for thumb_url in traverse_obj(aweme_detail, (
(None, 'video'), ('thumbnail', 'cover', 'dynamicCover', 'originCover'), {url_or_none})): (None, 'video'), ('thumbnail', 'cover', 'dynamicCover', 'originCover'), {url_or_none})):
@ -462,10 +526,17 @@ class TikTokBaseIE(InfoExtractor):
return { return {
'id': video_id, 'id': video_id,
**traverse_obj(music_info, {
'track': ('title', {str}),
'album': ('album', {str}, {lambda x: x or None}),
'artists': ('authorName', {str}, {lambda x: [x] if x else None}),
'duration': ('duration', {int_or_none}),
}),
**traverse_obj(aweme_detail, { **traverse_obj(aweme_detail, {
'title': ('desc', {str}), 'title': ('desc', {str}),
'description': ('desc', {str}), 'description': ('desc', {str}),
'duration': ('video', 'duration', {int_or_none}), # audio-only slideshows have a video duration of 0 and an actual audio duration
'duration': ('video', 'duration', {int_or_none}, {lambda x: x or None}),
'timestamp': ('createTime', {int_or_none}), 'timestamp': ('createTime', {int_or_none}),
}), }),
**traverse_obj(author_info or aweme_detail, { **traverse_obj(author_info or aweme_detail, {
@ -480,11 +551,6 @@ class TikTokBaseIE(InfoExtractor):
'repost_count': 'shareCount', 'repost_count': 'shareCount',
'comment_count': 'commentCount', 'comment_count': 'commentCount',
}, expected_type=int_or_none), }, expected_type=int_or_none),
**traverse_obj(music_info, {
'track': ('title', {str}),
'album': ('album', {str}, {lambda x: x or None}),
'artists': ('authorName', {str}, {lambda x: [x] if x else None}),
}),
'channel_id': channel_id, 'channel_id': channel_id,
'uploader_url': user_url, 'uploader_url': user_url,
'formats': formats, 'formats': formats,