logo

oasis-root

Compiled tree of Oasis Linux based on own branch at <https://hacktivis.me/git/oasis/> git clone https://anongit.hacktivis.me/git/oasis-root.git

tiktok.py (70213B)


  1. import functools
  2. import itertools
  3. import json
  4. import random
  5. import re
  6. import string
  7. import time
  8. import urllib.parse
  9. import uuid
  10. from .common import InfoExtractor
  11. from ..networking import HEADRequest
  12. from ..utils import (
  13. ExtractorError,
  14. UnsupportedError,
  15. UserNotLive,
  16. determine_ext,
  17. filter_dict,
  18. format_field,
  19. int_or_none,
  20. join_nonempty,
  21. merge_dicts,
  22. mimetype2ext,
  23. parse_qs,
  24. qualities,
  25. srt_subtitles_timecode,
  26. str_or_none,
  27. traverse_obj,
  28. try_call,
  29. try_get,
  30. url_or_none,
  31. urlencode_postdata,
  32. )
  33. class TikTokBaseIE(InfoExtractor):
  34. _UPLOADER_URL_FORMAT = 'https://www.tiktok.com/@%s'
  35. _WEBPAGE_HOST = 'https://www.tiktok.com/'
  36. QUALITIES = ('360p', '540p', '720p', '1080p')
  37. _APP_INFO_DEFAULTS = {
  38. # unique "install id"
  39. 'iid': None,
  40. # TikTok (KR/PH/TW/TH/VN) = trill, TikTok (rest of world) = musical_ly, Douyin = aweme
  41. 'app_name': 'musical_ly',
  42. 'app_version': '35.1.3',
  43. 'manifest_app_version': '2023501030',
  44. # "app id": aweme = 1128, trill = 1180, musical_ly = 1233, universal = 0
  45. 'aid': '0',
  46. }
  47. _APP_INFO_POOL = None
  48. _APP_INFO = None
  49. _APP_USER_AGENT = None
  50. @functools.cached_property
  51. def _KNOWN_APP_INFO(self):
  52. # If we have a genuine device ID, we may not need any IID
  53. default = [''] if self._KNOWN_DEVICE_ID else []
  54. return self._configuration_arg('app_info', default, ie_key=TikTokIE)
  55. @functools.cached_property
  56. def _KNOWN_DEVICE_ID(self):
  57. return self._configuration_arg('device_id', [None], ie_key=TikTokIE)[0]
  58. @functools.cached_property
  59. def _DEVICE_ID(self):
  60. return self._KNOWN_DEVICE_ID or str(random.randint(7250000000000000000, 7351147085025500000))
  61. @functools.cached_property
  62. def _API_HOSTNAME(self):
  63. return self._configuration_arg(
  64. 'api_hostname', ['api16-normal-c-useast1a.tiktokv.com'], ie_key=TikTokIE)[0]
  65. def _get_next_app_info(self):
  66. if self._APP_INFO_POOL is None:
  67. defaults = {
  68. key: self._configuration_arg(key, [default], ie_key=TikTokIE)[0]
  69. for key, default in self._APP_INFO_DEFAULTS.items()
  70. if key != 'iid'
  71. }
  72. self._APP_INFO_POOL = [
  73. {**defaults, **dict(
  74. (k, v) for k, v in zip(self._APP_INFO_DEFAULTS, app_info.split('/')) if v
  75. )} for app_info in self._KNOWN_APP_INFO
  76. ]
  77. if not self._APP_INFO_POOL:
  78. return False
  79. self._APP_INFO = self._APP_INFO_POOL.pop(0)
  80. app_name = self._APP_INFO['app_name']
  81. version = self._APP_INFO['manifest_app_version']
  82. if app_name == 'musical_ly':
  83. package = f'com.zhiliaoapp.musically/{version}'
  84. else: # trill, aweme
  85. package = f'com.ss.android.ugc.{app_name}/{version}'
  86. self._APP_USER_AGENT = f'{package} (Linux; U; Android 13; en_US; Pixel 7; Build/TD1A.220804.031; Cronet/58.0.2991.0)'
  87. return True
  88. @staticmethod
  89. def _create_url(user_id, video_id):
  90. return f'https://www.tiktok.com/@{user_id or "_"}/video/{video_id}'
  91. def _get_sigi_state(self, webpage, display_id):
  92. return self._search_json(
  93. r'<script[^>]+\bid="(?:SIGI_STATE|sigi-persisted-data)"[^>]*>', webpage,
  94. 'sigi state', display_id, end_pattern=r'</script>', default={})
  95. def _get_universal_data(self, webpage, display_id):
  96. return traverse_obj(self._search_json(
  97. r'<script[^>]+\bid="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>', webpage,
  98. 'universal data', display_id, end_pattern=r'</script>', default={}),
  99. ('__DEFAULT_SCOPE__', {dict})) or {}
  100. def _call_api_impl(self, ep, video_id, query=None, data=None, headers=None, fatal=True,
  101. note='Downloading API JSON', errnote='Unable to download API page'):
  102. self._set_cookie(self._API_HOSTNAME, 'odin_tt', ''.join(random.choices('0123456789abcdef', k=160)))
  103. webpage_cookies = self._get_cookies(self._WEBPAGE_HOST)
  104. if webpage_cookies.get('sid_tt'):
  105. self._set_cookie(self._API_HOSTNAME, 'sid_tt', webpage_cookies['sid_tt'].value)
  106. return self._download_json(
  107. f'https://{self._API_HOSTNAME}/aweme/v1/{ep}/', video_id=video_id,
  108. fatal=fatal, note=note, errnote=errnote, headers={
  109. 'User-Agent': self._APP_USER_AGENT,
  110. 'Accept': 'application/json',
  111. **(headers or {}),
  112. }, query=query, data=data)
  113. def _build_api_query(self, query):
  114. return filter_dict({
  115. **query,
  116. 'device_platform': 'android',
  117. 'os': 'android',
  118. 'ssmix': 'a',
  119. '_rticket': int(time.time() * 1000),
  120. 'cdid': str(uuid.uuid4()),
  121. 'channel': 'googleplay',
  122. 'aid': self._APP_INFO['aid'],
  123. 'app_name': self._APP_INFO['app_name'],
  124. 'version_code': ''.join(f'{int(v):02d}' for v in self._APP_INFO['app_version'].split('.')),
  125. 'version_name': self._APP_INFO['app_version'],
  126. 'manifest_version_code': self._APP_INFO['manifest_app_version'],
  127. 'update_version_code': self._APP_INFO['manifest_app_version'],
  128. 'ab_version': self._APP_INFO['app_version'],
  129. 'resolution': '1080*2400',
  130. 'dpi': 420,
  131. 'device_type': 'Pixel 7',
  132. 'device_brand': 'Google',
  133. 'language': 'en',
  134. 'os_api': '29',
  135. 'os_version': '13',
  136. 'ac': 'wifi',
  137. 'is_pad': '0',
  138. 'current_region': 'US',
  139. 'app_type': 'normal',
  140. 'sys_region': 'US',
  141. 'last_install_time': int(time.time()) - random.randint(86400, 1123200),
  142. 'timezone_name': 'America/New_York',
  143. 'residence': 'US',
  144. 'app_language': 'en',
  145. 'timezone_offset': '-14400',
  146. 'host_abi': 'armeabi-v7a',
  147. 'locale': 'en',
  148. 'ac2': 'wifi5g',
  149. 'uoo': '1',
  150. 'carrier_region': 'US',
  151. 'op_region': 'US',
  152. 'build_number': self._APP_INFO['app_version'],
  153. 'region': 'US',
  154. 'ts': int(time.time()),
  155. 'iid': self._APP_INFO.get('iid'),
  156. 'device_id': self._DEVICE_ID,
  157. 'openudid': ''.join(random.choices('0123456789abcdef', k=16)),
  158. })
  159. def _call_api(self, ep, video_id, query=None, data=None, headers=None, fatal=True,
  160. note='Downloading API JSON', errnote='Unable to download API page'):
  161. if not self._APP_INFO and not self._get_next_app_info():
  162. message = 'No working app info is available'
  163. if fatal:
  164. raise ExtractorError(message, expected=True)
  165. else:
  166. self.report_warning(message)
  167. return
  168. max_tries = len(self._APP_INFO_POOL) + 1 # _APP_INFO_POOL + _APP_INFO
  169. for count in itertools.count(1):
  170. self.write_debug(str(self._APP_INFO))
  171. real_query = self._build_api_query(query or {})
  172. try:
  173. return self._call_api_impl(
  174. ep, video_id, query=real_query, data=data, headers=headers,
  175. fatal=fatal, note=note, errnote=errnote)
  176. except ExtractorError as e:
  177. if isinstance(e.cause, json.JSONDecodeError) and e.cause.pos == 0:
  178. message = str(e.cause or e.msg)
  179. if not self._get_next_app_info():
  180. if fatal:
  181. raise
  182. else:
  183. self.report_warning(message)
  184. return
  185. self.report_warning(f'{message}. Retrying... (attempt {count} of {max_tries})')
  186. continue
  187. raise
  188. def _extract_aweme_app(self, aweme_id):
  189. aweme_detail = traverse_obj(
  190. self._call_api('multi/aweme/detail', aweme_id, data=urlencode_postdata({
  191. 'aweme_ids': f'[{aweme_id}]',
  192. 'request_source': '0',
  193. }), headers={'X-Argus': ''}), ('aweme_details', 0, {dict}))
  194. if not aweme_detail:
  195. raise ExtractorError('Unable to extract aweme detail info', video_id=aweme_id)
  196. return self._parse_aweme_video_app(aweme_detail)
  197. def _extract_web_data_and_status(self, url, video_id, fatal=True):
  198. video_data, status = {}, -1
  199. res = self._download_webpage_handle(url, video_id, fatal=fatal, headers={'User-Agent': 'Mozilla/5.0'})
  200. if res is False:
  201. return video_data, status
  202. webpage, urlh = res
  203. if urllib.parse.urlparse(urlh.url).path == '/login':
  204. message = 'TikTok is requiring login for access to this content'
  205. if fatal:
  206. self.raise_login_required(message)
  207. self.report_warning(f'{message}. {self._login_hint()}')
  208. return video_data, status
  209. if universal_data := self._get_universal_data(webpage, video_id):
  210. self.write_debug('Found universal data for rehydration')
  211. status = traverse_obj(universal_data, ('webapp.video-detail', 'statusCode', {int})) or 0
  212. video_data = traverse_obj(universal_data, ('webapp.video-detail', 'itemInfo', 'itemStruct', {dict}))
  213. elif sigi_data := self._get_sigi_state(webpage, video_id):
  214. self.write_debug('Found sigi state data')
  215. status = traverse_obj(sigi_data, ('VideoPage', 'statusCode', {int})) or 0
  216. video_data = traverse_obj(sigi_data, ('ItemModule', video_id, {dict}))
  217. elif next_data := self._search_nextjs_data(webpage, video_id, default={}):
  218. self.write_debug('Found next.js data')
  219. status = traverse_obj(next_data, ('props', 'pageProps', 'statusCode', {int})) or 0
  220. video_data = traverse_obj(next_data, ('props', 'pageProps', 'itemInfo', 'itemStruct', {dict}))
  221. elif fatal:
  222. raise ExtractorError('Unable to extract webpage video data')
  223. return video_data, status
  224. def _get_subtitles(self, aweme_detail, aweme_id, user_name):
  225. # TODO: Extract text positioning info
  226. EXT_MAP = { # From lowest to highest preference
  227. 'creator_caption': 'json',
  228. 'srt': 'srt',
  229. 'webvtt': 'vtt',
  230. }
  231. preference = qualities(tuple(EXT_MAP.values()))
  232. subtitles = {}
  233. # aweme/detail endpoint subs
  234. captions_info = traverse_obj(
  235. aweme_detail, ('interaction_stickers', ..., 'auto_video_caption_info', 'auto_captions', ...), expected_type=dict)
  236. for caption in captions_info:
  237. caption_url = traverse_obj(caption, ('url', 'url_list', ...), expected_type=url_or_none, get_all=False)
  238. if not caption_url:
  239. continue
  240. caption_json = self._download_json(
  241. caption_url, aweme_id, note='Downloading captions', errnote='Unable to download captions', fatal=False)
  242. if not caption_json:
  243. continue
  244. subtitles.setdefault(caption.get('language', 'en'), []).append({
  245. 'ext': 'srt',
  246. 'data': '\n\n'.join(
  247. f'{i + 1}\n{srt_subtitles_timecode(line["start_time"] / 1000)} --> {srt_subtitles_timecode(line["end_time"] / 1000)}\n{line["text"]}'
  248. for i, line in enumerate(caption_json['utterances']) if line.get('text')),
  249. })
  250. # feed endpoint subs
  251. if not subtitles:
  252. for caption in traverse_obj(aweme_detail, ('video', 'cla_info', 'caption_infos', ...), expected_type=dict):
  253. if not caption.get('url'):
  254. continue
  255. subtitles.setdefault(caption.get('lang') or 'en', []).append({
  256. 'url': caption['url'],
  257. 'ext': EXT_MAP.get(caption.get('Format')),
  258. })
  259. # webpage subs
  260. if not subtitles:
  261. if user_name: # only _parse_aweme_video_app needs to extract the webpage here
  262. aweme_detail, _ = self._extract_web_data_and_status(
  263. self._create_url(user_name, aweme_id), aweme_id, fatal=False)
  264. for caption in traverse_obj(aweme_detail, ('video', 'subtitleInfos', lambda _, v: v['Url'])):
  265. subtitles.setdefault(caption.get('LanguageCodeName') or 'en', []).append({
  266. 'url': caption['Url'],
  267. 'ext': EXT_MAP.get(caption.get('Format')),
  268. })
  269. # Deprioritize creator_caption json since it can't be embedded or used by media players
  270. for lang, subs_list in subtitles.items():
  271. subtitles[lang] = sorted(subs_list, key=lambda x: preference(x['ext']))
  272. return subtitles
  273. def _parse_url_key(self, url_key):
  274. format_id, codec, res, bitrate = self._search_regex(
  275. r'v[^_]+_(?P<id>(?P<codec>[^_]+)_(?P<res>\d+p)_(?P<bitrate>\d+))', url_key,
  276. 'url key', default=(None, None, None, None), group=('id', 'codec', 'res', 'bitrate'))
  277. if not format_id:
  278. return {}, None
  279. return {
  280. 'format_id': format_id,
  281. 'vcodec': 'h265' if codec == 'bytevc1' else codec,
  282. 'tbr': int_or_none(bitrate, scale=1000) or None,
  283. 'quality': qualities(self.QUALITIES)(res),
  284. }, res
  285. def _parse_aweme_video_app(self, aweme_detail):
  286. aweme_id = aweme_detail['aweme_id']
  287. video_info = aweme_detail['video']
  288. known_resolutions = {}
  289. def audio_meta(url):
  290. ext = determine_ext(url, default_ext='m4a')
  291. return {
  292. 'format_note': 'Music track',
  293. 'ext': ext,
  294. 'acodec': 'aac' if ext == 'm4a' else ext,
  295. 'vcodec': 'none',
  296. 'width': None,
  297. 'height': None,
  298. } if ext == 'mp3' or '-music-' in url else {}
  299. def extract_addr(addr, add_meta={}):
  300. parsed_meta, res = self._parse_url_key(addr.get('url_key', ''))
  301. is_bytevc2 = parsed_meta.get('vcodec') == 'bytevc2'
  302. if res:
  303. known_resolutions.setdefault(res, {}).setdefault('height', int_or_none(addr.get('height')))
  304. known_resolutions[res].setdefault('width', int_or_none(addr.get('width')))
  305. parsed_meta.update(known_resolutions.get(res, {}))
  306. add_meta.setdefault('height', int_or_none(res[:-1]))
  307. return [{
  308. 'url': url,
  309. 'filesize': int_or_none(addr.get('data_size')),
  310. 'ext': 'mp4',
  311. 'acodec': 'aac',
  312. 'source_preference': -2 if 'aweme/v1' in url else -1, # Downloads from API might get blocked
  313. **add_meta, **parsed_meta,
  314. # bytevc2 is bytedance's own custom h266/vvc codec, as-of-yet unplayable
  315. 'preference': -100 if is_bytevc2 else -1,
  316. 'format_note': join_nonempty(
  317. add_meta.get('format_note'), '(API)' if 'aweme/v1' in url else None,
  318. '(UNPLAYABLE)' if is_bytevc2 else None, delim=' '),
  319. **audio_meta(url),
  320. } for url in addr.get('url_list') or []]
  321. # Hack: Add direct video links first to prioritize them when removing duplicate formats
  322. formats = []
  323. width = int_or_none(video_info.get('width'))
  324. height = int_or_none(video_info.get('height'))
  325. ratio = try_call(lambda: width / height) or 0.5625
  326. if video_info.get('play_addr'):
  327. formats.extend(extract_addr(video_info['play_addr'], {
  328. 'format_id': 'play_addr',
  329. 'format_note': 'Direct video',
  330. 'vcodec': 'h265' if traverse_obj(
  331. video_info, 'is_bytevc1', 'is_h265') else 'h264', # TODO: Check for "direct iOS" videos, like https://www.tiktok.com/@cookierun_dev/video/7039716639834656002
  332. 'width': width,
  333. 'height': height,
  334. }))
  335. if video_info.get('download_addr'):
  336. download_addr = video_info['download_addr']
  337. dl_width = int_or_none(download_addr.get('width'))
  338. formats.extend(extract_addr(download_addr, {
  339. 'format_id': 'download_addr',
  340. 'format_note': 'Download video%s' % (', watermarked' if video_info.get('has_watermark') else ''),
  341. 'vcodec': 'h264',
  342. 'width': dl_width,
  343. 'height': try_call(lambda: int(dl_width / ratio)), # download_addr['height'] is wrong
  344. 'preference': -2 if video_info.get('has_watermark') else -1,
  345. }))
  346. if video_info.get('play_addr_h264'):
  347. formats.extend(extract_addr(video_info['play_addr_h264'], {
  348. 'format_id': 'play_addr_h264',
  349. 'format_note': 'Direct video',
  350. 'vcodec': 'h264',
  351. }))
  352. if video_info.get('play_addr_bytevc1'):
  353. formats.extend(extract_addr(video_info['play_addr_bytevc1'], {
  354. 'format_id': 'play_addr_bytevc1',
  355. 'format_note': 'Direct video',
  356. 'vcodec': 'h265',
  357. }))
  358. for bitrate in video_info.get('bit_rate', []):
  359. if bitrate.get('play_addr'):
  360. formats.extend(extract_addr(bitrate['play_addr'], {
  361. 'format_id': bitrate.get('gear_name'),
  362. 'format_note': 'Playback video',
  363. 'tbr': try_get(bitrate, lambda x: x['bit_rate'] / 1000),
  364. 'vcodec': 'h265' if traverse_obj(
  365. bitrate, 'is_bytevc1', 'is_h265') else 'h264',
  366. 'fps': bitrate.get('FPS'),
  367. }))
  368. self._remove_duplicate_formats(formats)
  369. auth_cookie = self._get_cookies(self._WEBPAGE_HOST).get('sid_tt')
  370. if auth_cookie:
  371. for f in formats:
  372. self._set_cookie(urllib.parse.urlparse(f['url']).hostname, 'sid_tt', auth_cookie.value)
  373. stats_info = aweme_detail.get('statistics') or {}
  374. music_info = aweme_detail.get('music') or {}
  375. labels = traverse_obj(aweme_detail, ('hybrid_label', ..., 'text'), expected_type=str)
  376. contained_music_track = traverse_obj(
  377. music_info, ('matched_song', 'title'), ('matched_pgc_sound', 'title'), expected_type=str)
  378. contained_music_author = traverse_obj(
  379. music_info, ('matched_song', 'author'), ('matched_pgc_sound', 'author'), 'author', expected_type=str)
  380. is_generic_og_trackname = music_info.get('is_original_sound') and music_info.get('title') == 'original sound - {}'.format(music_info.get('owner_handle'))
  381. if is_generic_og_trackname:
  382. music_track, music_author = contained_music_track or 'original sound', contained_music_author
  383. else:
  384. music_track, music_author = music_info.get('title'), traverse_obj(music_info, ('author', {str}))
  385. author_info = traverse_obj(aweme_detail, ('author', {
  386. 'uploader': ('unique_id', {str}),
  387. 'uploader_id': ('uid', {str_or_none}),
  388. 'channel': ('nickname', {str}),
  389. 'channel_id': ('sec_uid', {str}),
  390. }))
  391. return {
  392. 'id': aweme_id,
  393. **traverse_obj(aweme_detail, {
  394. 'title': ('desc', {str}),
  395. 'description': ('desc', {str}),
  396. 'timestamp': ('create_time', {int_or_none}),
  397. }),
  398. **traverse_obj(stats_info, {
  399. 'view_count': 'play_count',
  400. 'like_count': 'digg_count',
  401. 'repost_count': 'share_count',
  402. 'comment_count': 'comment_count',
  403. }, expected_type=int_or_none),
  404. **author_info,
  405. 'channel_url': format_field(author_info, 'channel_id', self._UPLOADER_URL_FORMAT, default=None),
  406. 'uploader_url': format_field(
  407. author_info, ['uploader', 'uploader_id'], self._UPLOADER_URL_FORMAT, default=None),
  408. 'track': music_track,
  409. 'album': str_or_none(music_info.get('album')) or None,
  410. 'artists': re.split(r'(?:, | & )', music_author) if music_author else None,
  411. 'formats': formats,
  412. 'subtitles': self.extract_subtitles(
  413. aweme_detail, aweme_id, traverse_obj(author_info, 'uploader', 'uploader_id', 'channel_id')),
  414. 'thumbnails': [
  415. {
  416. 'id': cover_id,
  417. 'url': cover_url,
  418. 'preference': -1 if cover_id in ('cover', 'origin_cover') else -2,
  419. }
  420. for cover_id in (
  421. 'cover', 'ai_dynamic_cover', 'animated_cover',
  422. 'ai_dynamic_cover_bak', 'origin_cover', 'dynamic_cover')
  423. for cover_url in traverse_obj(video_info, (cover_id, 'url_list', ...))
  424. ],
  425. 'duration': (traverse_obj(video_info, (
  426. (None, 'download_addr'), 'duration', {int_or_none(scale=1000)}, any))
  427. or traverse_obj(music_info, ('duration', {int_or_none}))),
  428. 'availability': self._availability(
  429. is_private='Private' in labels,
  430. needs_subscription='Friends only' in labels,
  431. is_unlisted='Followers only' in labels),
  432. '_format_sort_fields': ('quality', 'codec', 'size', 'br'),
  433. }
  434. def _extract_web_formats(self, aweme_detail):
  435. COMMON_FORMAT_INFO = {
  436. 'ext': 'mp4',
  437. 'vcodec': 'h264',
  438. 'acodec': 'aac',
  439. }
  440. video_info = traverse_obj(aweme_detail, ('video', {dict})) or {}
  441. play_width = int_or_none(video_info.get('width'))
  442. play_height = int_or_none(video_info.get('height'))
  443. ratio = try_call(lambda: play_width / play_height) or 0.5625
  444. formats = []
  445. for bitrate_info in traverse_obj(video_info, ('bitrateInfo', lambda _, v: v['PlayAddr']['UrlList'])):
  446. format_info, res = self._parse_url_key(
  447. traverse_obj(bitrate_info, ('PlayAddr', 'UrlKey', {str})) or '')
  448. # bytevc2 is bytedance's own custom h266/vvc codec, as-of-yet unplayable
  449. is_bytevc2 = format_info.get('vcodec') == 'bytevc2'
  450. format_info.update({
  451. 'format_note': 'UNPLAYABLE' if is_bytevc2 else None,
  452. 'preference': -100 if is_bytevc2 else -1,
  453. 'filesize': traverse_obj(bitrate_info, ('PlayAddr', 'DataSize', {int_or_none})),
  454. })
  455. if dimension := (res and int(res[:-1])):
  456. if dimension == 540: # '540p' is actually 576p
  457. dimension = 576
  458. if ratio < 1: # portrait: res/dimension is width
  459. y = int(dimension / ratio)
  460. format_info.update({
  461. 'width': dimension,
  462. 'height': y - (y % 2),
  463. })
  464. else: # landscape: res/dimension is height
  465. x = int(dimension * ratio)
  466. format_info.update({
  467. 'width': x + (x % 2),
  468. 'height': dimension,
  469. })
  470. for video_url in traverse_obj(bitrate_info, ('PlayAddr', 'UrlList', ..., {url_or_none})):
  471. formats.append({
  472. **COMMON_FORMAT_INFO,
  473. **format_info,
  474. 'url': self._proto_relative_url(video_url),
  475. })
  476. # We don't have res string for play formats, but need quality for sorting & de-duplication
  477. play_quality = traverse_obj(formats, (lambda _, v: v['width'] == play_width, 'quality', any))
  478. for play_url in traverse_obj(video_info, ('playAddr', ((..., 'src'), None), {url_or_none})):
  479. formats.append({
  480. **COMMON_FORMAT_INFO,
  481. 'format_id': 'play',
  482. 'url': self._proto_relative_url(play_url),
  483. 'width': play_width,
  484. 'height': play_height,
  485. 'quality': play_quality,
  486. })
  487. for download_url in traverse_obj(video_info, (('downloadAddr', ('download', 'url')), {url_or_none})):
  488. formats.append({
  489. **COMMON_FORMAT_INFO,
  490. 'format_id': 'download',
  491. 'url': self._proto_relative_url(download_url),
  492. 'format_note': 'watermarked',
  493. 'preference': -2,
  494. })
  495. self._remove_duplicate_formats(formats)
  496. # Is it a slideshow with only audio for download?
  497. if not formats and traverse_obj(aweme_detail, ('music', 'playUrl', {url_or_none})):
  498. audio_url = aweme_detail['music']['playUrl']
  499. ext = traverse_obj(parse_qs(audio_url), (
  500. 'mime_type', -1, {lambda x: x.replace('_', '/')}, {mimetype2ext})) or 'm4a'
  501. formats.append({
  502. 'format_id': 'audio',
  503. 'url': self._proto_relative_url(audio_url),
  504. 'ext': ext,
  505. 'acodec': 'aac' if ext == 'm4a' else ext,
  506. 'vcodec': 'none',
  507. })
  508. # Filter out broken formats, see https://github.com/yt-dlp/yt-dlp/issues/11034
  509. return [f for f in formats if urllib.parse.urlparse(f['url']).hostname != 'www.tiktok.com']
  510. def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id, extract_flat=False):
  511. author_info = traverse_obj(aweme_detail, (('authorInfo', 'author', None), {
  512. 'channel': ('nickname', {str}),
  513. 'channel_id': (('authorSecId', 'secUid'), {str}),
  514. 'uploader': (('uniqueId', 'author'), {str}),
  515. 'uploader_id': (('authorId', 'uid', 'id'), {str_or_none}),
  516. }), get_all=False)
  517. return {
  518. 'id': video_id,
  519. 'formats': None if extract_flat else self._extract_web_formats(aweme_detail),
  520. 'subtitles': None if extract_flat else self.extract_subtitles(aweme_detail, video_id, None),
  521. 'http_headers': {'Referer': webpage_url},
  522. **author_info,
  523. 'channel_url': format_field(author_info, 'channel_id', self._UPLOADER_URL_FORMAT, default=None),
  524. 'uploader_url': format_field(
  525. author_info, ['uploader', 'uploader_id'], self._UPLOADER_URL_FORMAT, default=None),
  526. **traverse_obj(aweme_detail, ('music', {
  527. 'track': ('title', {str}),
  528. 'album': ('album', {str}, filter),
  529. 'artists': ('authorName', {str}, {lambda x: re.split(r'(?:, | & )', x) if x else None}),
  530. 'duration': ('duration', {int_or_none}),
  531. })),
  532. **traverse_obj(aweme_detail, {
  533. 'title': ('desc', {str}),
  534. 'description': ('desc', {str}),
  535. # audio-only slideshows have a video duration of 0 and an actual audio duration
  536. 'duration': ('video', 'duration', {int_or_none}, filter),
  537. 'timestamp': ('createTime', {int_or_none}),
  538. }),
  539. **traverse_obj(aweme_detail, ('stats', {
  540. 'view_count': 'playCount',
  541. 'like_count': 'diggCount',
  542. 'repost_count': 'shareCount',
  543. 'comment_count': 'commentCount',
  544. }), expected_type=int_or_none),
  545. 'thumbnails': [
  546. {
  547. 'id': cover_id,
  548. 'url': self._proto_relative_url(cover_url),
  549. 'preference': -2 if cover_id == 'dynamicCover' else -1,
  550. }
  551. for cover_id in ('thumbnail', 'cover', 'dynamicCover', 'originCover')
  552. for cover_url in traverse_obj(aweme_detail, ((None, 'video'), cover_id, {url_or_none}))
  553. ],
  554. }
  555. class TikTokIE(TikTokBaseIE):
  556. _VALID_URL = r'https?://www\.tiktok\.com/(?:embed|@(?P<user_id>[\w\.-]+)?/video)/(?P<id>\d+)'
  557. _EMBED_REGEX = [rf'<(?:script|iframe)[^>]+\bsrc=(["\'])(?P<url>{_VALID_URL})']
  558. _TESTS = [{
  559. 'url': 'https://www.tiktok.com/@leenabhushan/video/6748451240264420610',
  560. 'md5': '736bb7a466c6f0a6afeb597da1e6f5b7',
  561. 'info_dict': {
  562. 'id': '6748451240264420610',
  563. 'ext': 'mp4',
  564. 'title': '#jassmanak #lehanga #leenabhushan',
  565. 'description': '#jassmanak #lehanga #leenabhushan',
  566. 'duration': 13,
  567. 'height': 1024,
  568. 'width': 576,
  569. 'uploader': 'leenabhushan',
  570. 'uploader_id': '6691488002098119685',
  571. 'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAA_Eb4t1vodM1IuTy_cvp9CY22RAb59xqrO0Xtz9CYQJvgXaDvZxYnZYRzDWhhgJmy',
  572. 'creator': 'facestoriesbyleenabh',
  573. 'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
  574. 'upload_date': '20191016',
  575. 'timestamp': 1571246252,
  576. 'view_count': int,
  577. 'like_count': int,
  578. 'repost_count': int,
  579. 'comment_count': int,
  580. 'artist': 'Ysrbeats',
  581. 'album': 'Lehanga',
  582. 'track': 'Lehanga',
  583. },
  584. 'skip': '404 Not Found',
  585. }, {
  586. 'url': 'https://www.tiktok.com/@patroxofficial/video/6742501081818877190?langCountry=en',
  587. 'md5': 'f21112672ee4ce05ca390fb6522e1b6f',
  588. 'info_dict': {
  589. 'id': '6742501081818877190',
  590. 'ext': 'mp4',
  591. 'title': 'md5:5e2a23877420bb85ce6521dbee39ba94',
  592. 'description': 'md5:5e2a23877420bb85ce6521dbee39ba94',
  593. 'duration': 27,
  594. 'height': 1024,
  595. 'width': 576,
  596. 'uploader': 'patrox',
  597. 'uploader_id': '18702747',
  598. 'uploader_url': 'https://www.tiktok.com/@patrox',
  599. 'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAiFnldaILebi5heDoVU6bn4jBWWycX6-9U3xuNPqZ8Ws',
  600. 'channel_id': 'MS4wLjABAAAAiFnldaILebi5heDoVU6bn4jBWWycX6-9U3xuNPqZ8Ws',
  601. 'channel': 'patroX',
  602. 'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
  603. 'upload_date': '20190930',
  604. 'timestamp': 1569860870,
  605. 'view_count': int,
  606. 'like_count': int,
  607. 'repost_count': int,
  608. 'comment_count': int,
  609. 'artists': ['Evan Todd', 'Jessica Keenan Wynn', 'Alice Lee', 'Barrett Wilbert Weed', 'Jon Eidson'],
  610. 'track': 'Big Fun',
  611. },
  612. }, {
  613. # Banned audio, was available on the app, now works with web too
  614. 'url': 'https://www.tiktok.com/@barudakhb_/video/6984138651336838402',
  615. 'info_dict': {
  616. 'id': '6984138651336838402',
  617. 'ext': 'mp4',
  618. 'title': 'Balas @yolaaftwsr hayu yu ? #SquadRandom_ 🔥',
  619. 'description': 'Balas @yolaaftwsr hayu yu ? #SquadRandom_ 🔥',
  620. 'uploader': 'barudakhb_',
  621. 'channel': 'md5:29f238c49bc0c176cb3cef1a9cea9fa6',
  622. 'uploader_id': '6974687867511718913',
  623. 'uploader_url': 'https://www.tiktok.com/@barudakhb_',
  624. 'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAbhBwQC-R1iKoix6jDFsF-vBdfx2ABoDjaZrM9fX6arU3w71q3cOWgWuTXn1soZ7d',
  625. 'channel_id': 'MS4wLjABAAAAbhBwQC-R1iKoix6jDFsF-vBdfx2ABoDjaZrM9fX6arU3w71q3cOWgWuTXn1soZ7d',
  626. 'track': 'Boka Dance',
  627. 'artists': ['md5:29f238c49bc0c176cb3cef1a9cea9fa6'],
  628. 'timestamp': 1626121503,
  629. 'duration': 18,
  630. 'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
  631. 'upload_date': '20210712',
  632. 'view_count': int,
  633. 'like_count': int,
  634. 'repost_count': int,
  635. 'comment_count': int,
  636. },
  637. }, {
  638. # Sponsored video, only available with feed workaround
  639. 'url': 'https://www.tiktok.com/@MS4wLjABAAAATh8Vewkn0LYM7Fo03iec3qKdeCUOcBIouRk1mkiag6h3o_pQu_dUXvZ2EZlGST7_/video/7042692929109986561',
  640. 'info_dict': {
  641. 'id': '7042692929109986561',
  642. 'ext': 'mp4',
  643. 'title': 'Slap and Run!',
  644. 'description': 'Slap and Run!',
  645. 'uploader': 'user440922249',
  646. 'channel': 'Slap And Run',
  647. 'uploader_id': '7036055384943690754',
  648. 'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAATh8Vewkn0LYM7Fo03iec3qKdeCUOcBIouRk1mkiag6h3o_pQu_dUXvZ2EZlGST7_',
  649. 'channel_id': 'MS4wLjABAAAATh8Vewkn0LYM7Fo03iec3qKdeCUOcBIouRk1mkiag6h3o_pQu_dUXvZ2EZlGST7_',
  650. 'track': 'Promoted Music',
  651. 'timestamp': 1639754738,
  652. 'duration': 30,
  653. 'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
  654. 'upload_date': '20211217',
  655. 'view_count': int,
  656. 'like_count': int,
  657. 'repost_count': int,
  658. 'comment_count': int,
  659. },
  660. 'skip': 'This video is unavailable',
  661. }, {
  662. # Video without title and description
  663. 'url': 'https://www.tiktok.com/@pokemonlife22/video/7059698374567611694',
  664. 'info_dict': {
  665. 'id': '7059698374567611694',
  666. 'ext': 'mp4',
  667. 'title': 'TikTok video #7059698374567611694',
  668. 'description': '',
  669. 'uploader': 'pokemonlife22',
  670. 'channel': 'Pokemon',
  671. 'uploader_id': '6820838815978423302',
  672. 'uploader_url': 'https://www.tiktok.com/@pokemonlife22',
  673. 'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAA0tF1nBwQVVMyrGu3CqttkNgM68Do1OXUFuCY0CRQk8fEtSVDj89HqoqvbSTmUP2W',
  674. 'channel_id': 'MS4wLjABAAAA0tF1nBwQVVMyrGu3CqttkNgM68Do1OXUFuCY0CRQk8fEtSVDj89HqoqvbSTmUP2W',
  675. 'track': 'original sound',
  676. 'timestamp': 1643714123,
  677. 'duration': 6,
  678. 'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
  679. 'upload_date': '20220201',
  680. 'artists': ['Pokemon'],
  681. 'view_count': int,
  682. 'like_count': int,
  683. 'repost_count': int,
  684. 'comment_count': int,
  685. },
  686. }, {
  687. # hydration JSON is sent in a <script> element
  688. 'url': 'https://www.tiktok.com/@denidil6/video/7065799023130643713',
  689. 'info_dict': {
  690. 'id': '7065799023130643713',
  691. 'ext': 'mp4',
  692. 'title': '#denidil#денидил',
  693. 'description': '#denidil#денидил',
  694. 'uploader': 'denidil6',
  695. 'uploader_id': '7046664115636405250',
  696. 'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAsvMSzFdQ4ikl3uR2TEJwMBbB2yZh2Zxwhx-WCo3rbDpAharE3GQCrFuJArI3C8QJ',
  697. 'artist': 'Holocron Music',
  698. 'album': 'Wolf Sounds (1 Hour) Enjoy the Company of the Animal That Is the Majestic King of the Night',
  699. 'track': 'Wolf Sounds (1 Hour) Enjoy the Company of the Animal That Is the Majestic King of the Night',
  700. 'timestamp': 1645134536,
  701. 'duration': 26,
  702. 'upload_date': '20220217',
  703. 'view_count': int,
  704. 'like_count': int,
  705. 'repost_count': int,
  706. 'comment_count': int,
  707. },
  708. 'skip': 'This video is unavailable',
  709. }, {
  710. # slideshow audio-only mp3 format
  711. 'url': 'https://www.tiktok.com/@_le_cannibale_/video/7139980461132074283',
  712. 'info_dict': {
  713. 'id': '7139980461132074283',
  714. 'ext': 'mp3',
  715. 'title': 'TikTok video #7139980461132074283',
  716. 'description': '',
  717. 'channel': 'Antaura',
  718. 'uploader': '_le_cannibale_',
  719. 'uploader_id': '6604511138619654149',
  720. 'uploader_url': 'https://www.tiktok.com/@_le_cannibale_',
  721. 'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAoShJqaw_5gvy48y3azFeFcT4jeyKWbB0VVYasOCt2tTLwjNFIaDcHAM4D-QGXFOP',
  722. 'channel_id': 'MS4wLjABAAAAoShJqaw_5gvy48y3azFeFcT4jeyKWbB0VVYasOCt2tTLwjNFIaDcHAM4D-QGXFOP',
  723. 'artists': ['nathan !'],
  724. 'track': 'grahamscott canon',
  725. 'duration': 10,
  726. 'upload_date': '20220905',
  727. 'timestamp': 1662406249,
  728. 'view_count': int,
  729. 'like_count': int,
  730. 'repost_count': int,
  731. 'comment_count': int,
  732. 'thumbnail': r're:^https://.+\.(?:webp|jpe?g)',
  733. },
  734. }, {
  735. # only available via web
  736. 'url': 'https://www.tiktok.com/@moxypatch/video/7206382937372134662',
  737. 'md5': '4cdefa501ac8ac20bf04986e10916fea',
  738. 'info_dict': {
  739. 'id': '7206382937372134662',
  740. 'ext': 'mp4',
  741. 'title': 'md5:1d95c0b96560ca0e8a231af4172b2c0a',
  742. 'description': 'md5:1d95c0b96560ca0e8a231af4172b2c0a',
  743. 'channel': 'MoxyPatch',
  744. 'uploader': 'moxypatch',
  745. 'uploader_id': '7039142049363379205',
  746. 'uploader_url': 'https://www.tiktok.com/@moxypatch',
  747. 'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAFhqKnngMHJSsifL0w1vFOP5kn3Ndo1ODp0XuIBkNMBCkALTvwILdpu12g3pTtL4V',
  748. 'channel_id': 'MS4wLjABAAAAFhqKnngMHJSsifL0w1vFOP5kn3Ndo1ODp0XuIBkNMBCkALTvwILdpu12g3pTtL4V',
  749. 'artists': ['your worst nightmare'],
  750. 'track': 'original sound',
  751. 'upload_date': '20230303',
  752. 'timestamp': 1677866781,
  753. 'duration': 10,
  754. 'view_count': int,
  755. 'like_count': int,
  756. 'repost_count': int,
  757. 'comment_count': int,
  758. 'thumbnail': r're:^https://.+',
  759. 'thumbnails': 'count:3',
  760. },
  761. 'expected_warnings': ['Unable to find video in feed'],
  762. }, {
  763. # 1080p format
  764. 'url': 'https://www.tiktok.com/@tatemcrae/video/7107337212743830830', # FIXME: Web can only get audio
  765. 'md5': '982512017a8a917124d5a08c8ae79621',
  766. 'info_dict': {
  767. 'id': '7107337212743830830',
  768. 'ext': 'mp4',
  769. 'title': 'new music video 4 don’t come backkkk🧸🖤 i hope u enjoy !! @musicontiktok',
  770. 'description': 'new music video 4 don’t come backkkk🧸🖤 i hope u enjoy !! @musicontiktok',
  771. 'uploader': 'tatemcrae',
  772. 'uploader_id': '86328792343818240',
  773. 'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAA-0bQT0CqebTRr6I4IkYvMDMKSRSJHLNPBo5HrSklJwyA2psXLSZG5FP-LMNpHnJd',
  774. 'channel_id': 'MS4wLjABAAAA-0bQT0CqebTRr6I4IkYvMDMKSRSJHLNPBo5HrSklJwyA2psXLSZG5FP-LMNpHnJd',
  775. 'channel': 'tate mcrae',
  776. 'artists': ['tate mcrae'],
  777. 'track': 'original sound',
  778. 'upload_date': '20220609',
  779. 'timestamp': 1654805899,
  780. 'duration': 150,
  781. 'view_count': int,
  782. 'like_count': int,
  783. 'repost_count': int,
  784. 'comment_count': int,
  785. 'thumbnail': r're:^https://.+\.webp',
  786. },
  787. 'skip': 'Unavailable via feed API, only audio available via web',
  788. }, {
  789. # Slideshow, audio-only m4a format
  790. 'url': 'https://www.tiktok.com/@hara_yoimiya/video/7253412088251534594',
  791. 'md5': '2ff8fe0174db2dbf49c597a7bef4e47d',
  792. 'info_dict': {
  793. 'id': '7253412088251534594',
  794. 'ext': 'm4a',
  795. 'title': 'я ред флаг простите #переписка #щитпост #тревожныйтиппривязанности #рекомендации ',
  796. 'description': 'я ред флаг простите #переписка #щитпост #тревожныйтиппривязанности #рекомендации ',
  797. 'uploader': 'hara_yoimiya',
  798. 'uploader_id': '6582536342634676230',
  799. 'uploader_url': 'https://www.tiktok.com/@hara_yoimiya',
  800. 'channel_url': 'https://www.tiktok.com/@MS4wLjABAAAAIAlDxriiPWLE-p8p1R_0Bx8qWKfi-7zwmGhzU8Mv25W8sNxjfIKrol31qTczzuLB',
  801. 'channel_id': 'MS4wLjABAAAAIAlDxriiPWLE-p8p1R_0Bx8qWKfi-7zwmGhzU8Mv25W8sNxjfIKrol31qTczzuLB',
  802. 'channel': 'лампочка(!)',
  803. 'artists': ['Øneheart'],
  804. 'album': 'watching the stars',
  805. 'track': 'watching the stars',
  806. 'duration': 60,
  807. 'upload_date': '20230708',
  808. 'timestamp': 1688816612,
  809. 'view_count': int,
  810. 'like_count': int,
  811. 'comment_count': int,
  812. 'repost_count': int,
  813. 'thumbnail': r're:^https://.+\.(?:webp|jpe?g)',
  814. },
  815. }, {
  816. # Auto-captions available
  817. 'url': 'https://www.tiktok.com/@hankgreen1/video/7047596209028074758',
  818. 'only_matching': True,
  819. }]
  820. def _real_extract(self, url):
  821. video_id, user_id = self._match_valid_url(url).group('id', 'user_id')
  822. if self._KNOWN_APP_INFO:
  823. try:
  824. return self._extract_aweme_app(video_id)
  825. except ExtractorError as e:
  826. e.expected = True
  827. self.report_warning(f'{e}; trying with webpage')
  828. url = self._create_url(user_id, video_id)
  829. video_data, status = self._extract_web_data_and_status(url, video_id)
  830. if video_data and status == 0:
  831. return self._parse_aweme_video_web(video_data, url, video_id)
  832. elif status == 10216:
  833. raise ExtractorError('This video is private', expected=True)
  834. raise ExtractorError(f'Video not available, status code {status}', video_id=video_id)
  835. class TikTokUserIE(TikTokBaseIE):
  836. IE_NAME = 'tiktok:user'
  837. _VALID_URL = r'(?:tiktokuser:|https?://(?:www\.)?tiktok\.com/@)(?P<id>[\w.-]+)/?(?:$|[#?])'
  838. _TESTS = [{
  839. 'url': 'https://tiktok.com/@corgibobaa?lang=en',
  840. 'playlist_mincount': 45,
  841. 'info_dict': {
  842. 'id': 'MS4wLjABAAAAepiJKgwWhulvCpSuUVsp7sgVVsFJbbNaLeQ6OQ0oAJERGDUIXhb2yxxHZedsItgT',
  843. 'title': 'corgibobaa',
  844. },
  845. }, {
  846. 'url': 'https://www.tiktok.com/@6820838815978423302',
  847. 'playlist_mincount': 5,
  848. 'info_dict': {
  849. 'id': 'MS4wLjABAAAA0tF1nBwQVVMyrGu3CqttkNgM68Do1OXUFuCY0CRQk8fEtSVDj89HqoqvbSTmUP2W',
  850. 'title': '6820838815978423302',
  851. },
  852. }, {
  853. 'url': 'https://www.tiktok.com/@meme',
  854. 'playlist_mincount': 593,
  855. 'info_dict': {
  856. 'id': 'MS4wLjABAAAAiKfaDWeCsT3IHwY77zqWGtVRIy9v4ws1HbVi7auP1Vx7dJysU_hc5yRiGywojRD6',
  857. 'title': 'meme',
  858. },
  859. }, {
  860. 'url': 'tiktokuser:MS4wLjABAAAAM3R2BtjzVT-uAtstkl2iugMzC6AtnpkojJbjiOdDDrdsTiTR75-8lyWJCY5VvDrZ',
  861. 'playlist_mincount': 31,
  862. 'info_dict': {
  863. 'id': 'MS4wLjABAAAAM3R2BtjzVT-uAtstkl2iugMzC6AtnpkojJbjiOdDDrdsTiTR75-8lyWJCY5VvDrZ',
  864. },
  865. }]
  866. _USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:115.0) Gecko/20100101 Firefox/115.0'
  867. _API_BASE_URL = 'https://www.tiktok.com/api/creator/item_list/'
  868. def _build_web_query(self, sec_uid, cursor):
  869. return {
  870. 'aid': '1988',
  871. 'app_language': 'en',
  872. 'app_name': 'tiktok_web',
  873. 'browser_language': 'en-US',
  874. 'browser_name': 'Mozilla',
  875. 'browser_online': 'true',
  876. 'browser_platform': 'Win32',
  877. 'browser_version': '5.0 (Windows)',
  878. 'channel': 'tiktok_web',
  879. 'cookie_enabled': 'true',
  880. 'count': '15',
  881. 'cursor': cursor,
  882. 'device_id': self._DEVICE_ID,
  883. 'device_platform': 'web_pc',
  884. 'focus_state': 'true',
  885. 'from_page': 'user',
  886. 'history_len': '2',
  887. 'is_fullscreen': 'false',
  888. 'is_page_visible': 'true',
  889. 'language': 'en',
  890. 'os': 'windows',
  891. 'priority_region': '',
  892. 'referer': '',
  893. 'region': 'US',
  894. 'screen_height': '1080',
  895. 'screen_width': '1920',
  896. 'secUid': sec_uid,
  897. 'type': '1', # pagination type: 0 == oldest-to-newest, 1 == newest-to-oldest
  898. 'tz_name': 'UTC',
  899. 'verifyFp': f'verify_{"".join(random.choices(string.hexdigits, k=7))}',
  900. 'webcast_language': 'en',
  901. }
  902. def _entries(self, sec_uid, user_name):
  903. display_id = user_name or sec_uid
  904. seen_ids = set()
  905. cursor = int(time.time() * 1E3)
  906. for page in itertools.count(1):
  907. response = self._download_json(
  908. self._API_BASE_URL, display_id, f'Downloading page {page}',
  909. query=self._build_web_query(sec_uid, cursor), headers={'User-Agent': self._USER_AGENT})
  910. for video in traverse_obj(response, ('itemList', lambda _, v: v['id'])):
  911. video_id = video['id']
  912. if video_id in seen_ids:
  913. continue
  914. seen_ids.add(video_id)
  915. webpage_url = self._create_url(display_id, video_id)
  916. yield self.url_result(
  917. webpage_url, TikTokIE,
  918. **self._parse_aweme_video_web(video, webpage_url, video_id, extract_flat=True))
  919. old_cursor = cursor
  920. cursor = traverse_obj(
  921. response, ('itemList', -1, 'createTime', {lambda x: int(x * 1E3)}))
  922. if not cursor or old_cursor == cursor:
  923. # User may not have posted within this ~1 week lookback, so manually adjust cursor
  924. cursor = old_cursor - 7 * 86_400_000
  925. # In case 'hasMorePrevious' is wrong, break if we have gone back before TikTok existed
  926. if cursor < 1472706000000 or not traverse_obj(response, 'hasMorePrevious'):
  927. break
  928. def _get_sec_uid(self, user_url, user_name, msg):
  929. webpage = self._download_webpage(
  930. user_url, user_name, fatal=False, headers={'User-Agent': 'Mozilla/5.0'},
  931. note=f'Downloading {msg} webpage', errnote=f'Unable to download {msg} webpage') or ''
  932. return (traverse_obj(self._get_universal_data(webpage, user_name),
  933. ('webapp.user-detail', 'userInfo', 'user', 'secUid', {str}))
  934. or traverse_obj(self._get_sigi_state(webpage, user_name),
  935. ('LiveRoom', 'liveRoomUserInfo', 'user', 'secUid', {str}),
  936. ('UserModule', 'users', ..., 'secUid', {str}, any)))
  937. def _real_extract(self, url):
  938. user_name, sec_uid = self._match_id(url), None
  939. if mobj := re.fullmatch(r'MS4wLjABAAAA[\w-]{64}', user_name):
  940. user_name, sec_uid = None, mobj.group(0)
  941. else:
  942. sec_uid = (self._get_sec_uid(self._UPLOADER_URL_FORMAT % user_name, user_name, 'user')
  943. or self._get_sec_uid(self._UPLOADER_URL_FORMAT % f'{user_name}/live', user_name, 'live'))
  944. if not sec_uid:
  945. webpage = self._download_webpage(
  946. f'https://www.tiktok.com/embed/@{user_name}', user_name,
  947. note='Downloading user embed page', fatal=False) or ''
  948. data = traverse_obj(self._search_json(
  949. r'<script[^>]+\bid=[\'"]__FRONTITY_CONNECT_STATE__[\'"][^>]*>',
  950. webpage, 'data', user_name, default={}),
  951. ('source', 'data', f'/embed/@{user_name}', {dict}))
  952. for aweme_id in traverse_obj(data, ('videoList', ..., 'id', {str})):
  953. webpage_url = self._create_url(user_name, aweme_id)
  954. video_data, _ = self._extract_web_data_and_status(webpage_url, aweme_id, fatal=False)
  955. sec_uid = self._parse_aweme_video_web(
  956. video_data, webpage_url, aweme_id, extract_flat=True).get('channel_id')
  957. if sec_uid:
  958. break
  959. if not sec_uid:
  960. raise ExtractorError(
  961. 'Unable to extract secondary user ID. If you are able to get the channel_id '
  962. 'from a video posted by this user, try using "tiktokuser:channel_id" as the '
  963. 'input URL (replacing `channel_id` with its actual value)', expected=True)
  964. return self.playlist_result(self._entries(sec_uid, user_name), sec_uid, user_name)
  965. class TikTokBaseListIE(TikTokBaseIE): # XXX: Conventionally, base classes should end with BaseIE/InfoExtractor
  966. def _entries(self, list_id, display_id):
  967. query = {
  968. self._QUERY_NAME: list_id,
  969. 'cursor': 0,
  970. 'count': 20,
  971. 'type': 5,
  972. 'device_id': self._DEVICE_ID,
  973. }
  974. for page in itertools.count(1):
  975. for retry in self.RetryManager():
  976. try:
  977. post_list = self._call_api(
  978. self._API_ENDPOINT, display_id, query=query,
  979. note=f'Downloading video list page {page}',
  980. errnote='Unable to download video list')
  981. except ExtractorError as e:
  982. if isinstance(e.cause, json.JSONDecodeError) and e.cause.pos == 0:
  983. retry.error = e
  984. continue
  985. raise
  986. for video in post_list.get('aweme_list', []):
  987. yield {
  988. **self._parse_aweme_video_app(video),
  989. 'extractor_key': TikTokIE.ie_key(),
  990. 'extractor': 'TikTok',
  991. 'webpage_url': f'https://tiktok.com/@_/video/{video["aweme_id"]}',
  992. }
  993. if not post_list.get('has_more'):
  994. break
  995. query['cursor'] = post_list['cursor']
  996. def _real_extract(self, url):
  997. list_id = self._match_id(url)
  998. return self.playlist_result(self._entries(list_id, list_id), list_id)
  999. class TikTokSoundIE(TikTokBaseListIE):
  1000. IE_NAME = 'tiktok:sound'
  1001. _VALID_URL = r'https?://(?:www\.)?tiktok\.com/music/[\w\.-]+-(?P<id>[\d]+)[/?#&]?'
  1002. _WORKING = False
  1003. _QUERY_NAME = 'music_id'
  1004. _API_ENDPOINT = 'music/aweme'
  1005. _TESTS = [{
  1006. 'url': 'https://www.tiktok.com/music/Build-a-Btch-6956990112127585029?lang=en',
  1007. 'playlist_mincount': 100,
  1008. 'info_dict': {
  1009. 'id': '6956990112127585029',
  1010. },
  1011. 'expected_warnings': ['Retrying'],
  1012. }, {
  1013. # Actual entries are less than listed video count
  1014. 'url': 'https://www.tiktok.com/music/jiefei-soap-remix-7036843036118469381',
  1015. 'playlist_mincount': 2182,
  1016. 'info_dict': {
  1017. 'id': '7036843036118469381',
  1018. },
  1019. 'expected_warnings': ['Retrying'],
  1020. }]
  1021. class TikTokEffectIE(TikTokBaseListIE):
  1022. IE_NAME = 'tiktok:effect'
  1023. _VALID_URL = r'https?://(?:www\.)?tiktok\.com/sticker/[\w\.-]+-(?P<id>[\d]+)[/?#&]?'
  1024. _WORKING = False
  1025. _QUERY_NAME = 'sticker_id'
  1026. _API_ENDPOINT = 'sticker/aweme'
  1027. _TESTS = [{
  1028. 'url': 'https://www.tiktok.com/sticker/MATERIAL-GWOOORL-1258156',
  1029. 'playlist_mincount': 100,
  1030. 'info_dict': {
  1031. 'id': '1258156',
  1032. },
  1033. 'expected_warnings': ['Retrying'],
  1034. }, {
  1035. # Different entries between mobile and web, depending on region
  1036. 'url': 'https://www.tiktok.com/sticker/Elf-Friend-479565',
  1037. 'only_matching': True,
  1038. }]
  1039. class TikTokTagIE(TikTokBaseListIE):
  1040. IE_NAME = 'tiktok:tag'
  1041. _VALID_URL = r'https?://(?:www\.)?tiktok\.com/tag/(?P<id>[^/?#&]+)'
  1042. _WORKING = False
  1043. _QUERY_NAME = 'ch_id'
  1044. _API_ENDPOINT = 'challenge/aweme'
  1045. _TESTS = [{
  1046. 'url': 'https://tiktok.com/tag/hello2018',
  1047. 'playlist_mincount': 39,
  1048. 'info_dict': {
  1049. 'id': '46294678',
  1050. 'title': 'hello2018',
  1051. },
  1052. 'expected_warnings': ['Retrying'],
  1053. }, {
  1054. 'url': 'https://tiktok.com/tag/fypシ?is_copy_url=0&is_from_webapp=v1',
  1055. 'only_matching': True,
  1056. }]
  1057. def _real_extract(self, url):
  1058. display_id = self._match_id(url)
  1059. webpage = self._download_webpage(url, display_id, headers={
  1060. 'User-Agent': 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',
  1061. })
  1062. tag_id = self._html_search_regex(r'snssdk\d*://challenge/detail/(\d+)', webpage, 'tag ID')
  1063. return self.playlist_result(self._entries(tag_id, display_id), tag_id, display_id)
  1064. class TikTokCollectionIE(TikTokBaseIE):
  1065. IE_NAME = 'tiktok:collection'
  1066. _VALID_URL = r'https?://www\.tiktok\.com/@(?P<user_id>[\w.-]+)/collection/(?P<title>[^/?#]+)-(?P<id>\d+)/?(?:[?#]|$)'
  1067. _TESTS = [{
  1068. # playlist should have exactly 9 videos
  1069. 'url': 'https://www.tiktok.com/@imanoreotwe/collection/count-test-7371330159376370462',
  1070. 'info_dict': {
  1071. 'id': '7371330159376370462',
  1072. 'title': 'imanoreotwe-count-test',
  1073. },
  1074. 'playlist_count': 9,
  1075. }, {
  1076. # tests returning multiple pages of a large collection
  1077. 'url': 'https://www.tiktok.com/@imanoreotwe/collection/%F0%9F%98%82-7111887189571160875',
  1078. 'info_dict': {
  1079. 'id': '7111887189571160875',
  1080. 'title': 'imanoreotwe-%F0%9F%98%82',
  1081. },
  1082. 'playlist_mincount': 100,
  1083. }]
  1084. _API_BASE_URL = 'https://www.tiktok.com/api/collection/item_list/'
  1085. _PAGE_COUNT = 30
  1086. def _build_web_query(self, collection_id, cursor):
  1087. return {
  1088. 'aid': '1988',
  1089. 'collectionId': collection_id,
  1090. 'count': self._PAGE_COUNT,
  1091. 'cursor': cursor,
  1092. 'sourceType': '113',
  1093. }
  1094. def _entries(self, collection_id):
  1095. cursor = 0
  1096. for page in itertools.count(1):
  1097. response = self._download_json(
  1098. self._API_BASE_URL, collection_id, f'Downloading page {page}',
  1099. query=self._build_web_query(collection_id, cursor))
  1100. for video in traverse_obj(response, ('itemList', lambda _, v: v['id'])):
  1101. video_id = video['id']
  1102. author = traverse_obj(video, ('author', ('uniqueId', 'secUid', 'id'), {str}, any)) or '_'
  1103. webpage_url = self._create_url(author, video_id)
  1104. yield self.url_result(
  1105. webpage_url, TikTokIE,
  1106. **self._parse_aweme_video_web(video, webpage_url, video_id, extract_flat=True))
  1107. if not traverse_obj(response, 'hasMore'):
  1108. break
  1109. cursor += self._PAGE_COUNT
  1110. def _real_extract(self, url):
  1111. collection_id, title, user_name = self._match_valid_url(url).group('id', 'title', 'user_id')
  1112. return self.playlist_result(
  1113. self._entries(collection_id), collection_id, '-'.join((user_name, title)))
  1114. class DouyinIE(TikTokBaseIE):
  1115. _VALID_URL = r'https?://(?:www\.)?douyin\.com/video/(?P<id>[0-9]+)'
  1116. _TESTS = [{
  1117. 'url': 'https://www.douyin.com/video/6961737553342991651',
  1118. 'md5': '9ecce7bc5b302601018ecb2871c63a75',
  1119. 'info_dict': {
  1120. 'id': '6961737553342991651',
  1121. 'ext': 'mp4',
  1122. 'title': '#杨超越 小小水手带你去远航❤️',
  1123. 'description': '#杨超越 小小水手带你去远航❤️',
  1124. 'uploader': '6897520xka',
  1125. 'uploader_id': '110403406559',
  1126. 'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
  1127. 'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
  1128. 'channel': '杨超越',
  1129. 'duration': 19,
  1130. 'timestamp': 1620905839,
  1131. 'upload_date': '20210513',
  1132. 'track': '@杨超越创作的原声',
  1133. 'artists': ['杨超越'],
  1134. 'view_count': int,
  1135. 'like_count': int,
  1136. 'repost_count': int,
  1137. 'comment_count': int,
  1138. 'thumbnail': r're:https?://.+\.jpe?g',
  1139. },
  1140. }, {
  1141. 'url': 'https://www.douyin.com/video/6982497745948921092',
  1142. 'md5': '15c5e660b7048af3707304e3cc02bbb5',
  1143. 'info_dict': {
  1144. 'id': '6982497745948921092',
  1145. 'ext': 'mp4',
  1146. 'title': '这个夏日和小羊@杨超越 一起遇见白色幻想',
  1147. 'description': '这个夏日和小羊@杨超越 一起遇见白色幻想',
  1148. 'uploader': '0731chaoyue',
  1149. 'uploader_id': '408654318141572',
  1150. 'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAZJpnglcjW2f_CMVcnqA_6oVBXKWMpH0F8LIHuUu8-lA',
  1151. 'channel_id': 'MS4wLjABAAAAZJpnglcjW2f_CMVcnqA_6oVBXKWMpH0F8LIHuUu8-lA',
  1152. 'channel': '杨超越工作室',
  1153. 'duration': 42,
  1154. 'timestamp': 1625739481,
  1155. 'upload_date': '20210708',
  1156. 'track': '@杨超越工作室创作的原声',
  1157. 'artists': ['杨超越工作室'],
  1158. 'view_count': int,
  1159. 'like_count': int,
  1160. 'repost_count': int,
  1161. 'comment_count': int,
  1162. 'thumbnail': r're:https?://.+\.jpe?g',
  1163. },
  1164. }, {
  1165. 'url': 'https://www.douyin.com/video/6953975910773099811',
  1166. 'md5': '0e6443758b8355db9a3c34864a4276be',
  1167. 'info_dict': {
  1168. 'id': '6953975910773099811',
  1169. 'ext': 'mp4',
  1170. 'title': '#一起看海 出现在你的夏日里',
  1171. 'description': '#一起看海 出现在你的夏日里',
  1172. 'uploader': '6897520xka',
  1173. 'uploader_id': '110403406559',
  1174. 'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
  1175. 'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
  1176. 'channel': '杨超越',
  1177. 'duration': 17,
  1178. 'timestamp': 1619098692,
  1179. 'upload_date': '20210422',
  1180. 'track': '@杨超越创作的原声',
  1181. 'artists': ['杨超越'],
  1182. 'view_count': int,
  1183. 'like_count': int,
  1184. 'repost_count': int,
  1185. 'comment_count': int,
  1186. 'thumbnail': r're:https?://.+\.jpe?g',
  1187. },
  1188. }, {
  1189. 'url': 'https://www.douyin.com/video/6950251282489675042',
  1190. 'md5': 'b4db86aec367ef810ddd38b1737d2fed',
  1191. 'info_dict': {
  1192. 'id': '6950251282489675042',
  1193. 'ext': 'mp4',
  1194. 'title': '哈哈哈,成功了哈哈哈哈哈哈',
  1195. 'uploader': '杨超越',
  1196. 'upload_date': '20210412',
  1197. 'timestamp': 1618231483,
  1198. 'uploader_id': '110403406559',
  1199. 'view_count': int,
  1200. 'like_count': int,
  1201. 'repost_count': int,
  1202. 'comment_count': int,
  1203. },
  1204. 'skip': 'No longer available',
  1205. }, {
  1206. 'url': 'https://www.douyin.com/video/6963263655114722595',
  1207. 'md5': '1440bcf59d8700f8e014da073a4dfea8',
  1208. 'info_dict': {
  1209. 'id': '6963263655114722595',
  1210. 'ext': 'mp4',
  1211. 'title': '#哪个爱豆的105度最甜 换个角度看看我哈哈',
  1212. 'description': '#哪个爱豆的105度最甜 换个角度看看我哈哈',
  1213. 'uploader': '6897520xka',
  1214. 'uploader_id': '110403406559',
  1215. 'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
  1216. 'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
  1217. 'channel': '杨超越',
  1218. 'duration': 15,
  1219. 'timestamp': 1621261163,
  1220. 'upload_date': '20210517',
  1221. 'track': '@杨超越创作的原声',
  1222. 'artists': ['杨超越'],
  1223. 'view_count': int,
  1224. 'like_count': int,
  1225. 'repost_count': int,
  1226. 'comment_count': int,
  1227. 'thumbnail': r're:https?://.+\.jpe?g',
  1228. },
  1229. }]
  1230. _UPLOADER_URL_FORMAT = 'https://www.douyin.com/user/%s'
  1231. _WEBPAGE_HOST = 'https://www.douyin.com/'
  1232. def _real_extract(self, url):
  1233. video_id = self._match_id(url)
  1234. detail = traverse_obj(self._download_json(
  1235. 'https://www.douyin.com/aweme/v1/web/aweme/detail/', video_id,
  1236. 'Downloading web detail JSON', 'Failed to download web detail JSON',
  1237. query={'aweme_id': video_id}, fatal=False), ('aweme_detail', {dict}))
  1238. if not detail:
  1239. # TODO: Run verification challenge code to generate signature cookies
  1240. raise ExtractorError(
  1241. 'Fresh cookies (not necessarily logged in) are needed',
  1242. expected=not self._get_cookies(self._WEBPAGE_HOST).get('s_v_web_id'))
  1243. return self._parse_aweme_video_app(detail)
  1244. class TikTokVMIE(InfoExtractor):
  1245. _VALID_URL = r'https?://(?:(?:vm|vt)\.tiktok\.com|(?:www\.)tiktok\.com/t)/(?P<id>\w+)'
  1246. IE_NAME = 'vm.tiktok'
  1247. _TESTS = [{
  1248. 'url': 'https://www.tiktok.com/t/ZTRC5xgJp',
  1249. 'info_dict': {
  1250. 'id': '7170520270497680683',
  1251. 'ext': 'mp4',
  1252. 'title': 'md5:c64f6152330c2efe98093ccc8597871c',
  1253. 'uploader_id': '6687535061741700102',
  1254. 'upload_date': '20221127',
  1255. 'view_count': int,
  1256. 'like_count': int,
  1257. 'comment_count': int,
  1258. 'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAObqu3WCTXxmw2xwZ3iLEHnEecEIw7ks6rxWqOqOhaPja9BI7gqUQnjw8_5FSoDXX',
  1259. 'album': 'Wave of Mutilation: Best of Pixies',
  1260. 'thumbnail': r're:https://.+\.webp.*',
  1261. 'duration': 5,
  1262. 'timestamp': 1669516858,
  1263. 'repost_count': int,
  1264. 'artist': 'Pixies',
  1265. 'track': 'Where Is My Mind?',
  1266. 'description': 'md5:c64f6152330c2efe98093ccc8597871c',
  1267. 'uploader': 'sigmachaddeus',
  1268. 'creator': 'SigmaChad',
  1269. },
  1270. }, {
  1271. 'url': 'https://vm.tiktok.com/ZTR45GpSF/',
  1272. 'info_dict': {
  1273. 'id': '7106798200794926362',
  1274. 'ext': 'mp4',
  1275. 'title': 'md5:edc3e7ea587847f8537468f2fe51d074',
  1276. 'uploader_id': '6997695878846268418',
  1277. 'upload_date': '20220608',
  1278. 'view_count': int,
  1279. 'like_count': int,
  1280. 'comment_count': int,
  1281. 'thumbnail': r're:https://.+\.webp.*',
  1282. 'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAdZ_NcPPgMneaGrW0hN8O_J_bwLshwNNERRF5DxOw2HKIzk0kdlLrR8RkVl1ksrMO',
  1283. 'duration': 29,
  1284. 'timestamp': 1654680400,
  1285. 'repost_count': int,
  1286. 'artist': 'Akihitoko',
  1287. 'track': 'original sound',
  1288. 'description': 'md5:edc3e7ea587847f8537468f2fe51d074',
  1289. 'uploader': 'akihitoko1',
  1290. 'creator': 'Akihitoko',
  1291. },
  1292. }, {
  1293. 'url': 'https://vt.tiktok.com/ZSe4FqkKd',
  1294. 'only_matching': True,
  1295. }]
  1296. def _real_extract(self, url):
  1297. new_url = self._request_webpage(
  1298. HEADRequest(url), self._match_id(url), headers={'User-Agent': 'facebookexternalhit/1.1'}).url
  1299. if self.suitable(new_url): # Prevent infinite loop in case redirect fails
  1300. raise UnsupportedError(new_url)
  1301. return self.url_result(new_url)
  1302. class TikTokLiveIE(TikTokBaseIE):
  1303. _VALID_URL = r'''(?x)https?://(?:
  1304. (?:www\.)?tiktok\.com/@(?P<uploader>[\w.-]+)/live|
  1305. m\.tiktok\.com/share/live/(?P<id>\d+)
  1306. )'''
  1307. IE_NAME = 'tiktok:live'
  1308. _TESTS = [{
  1309. 'url': 'https://www.tiktok.com/@weathernewslive/live',
  1310. 'info_dict': {
  1311. 'id': '7210809319192726273',
  1312. 'ext': 'mp4',
  1313. 'title': r're:ウェザーニュースLiVE[\d\s:-]*',
  1314. 'creator': 'ウェザーニュースLiVE',
  1315. 'uploader': 'weathernewslive',
  1316. 'uploader_id': '6621496731283095554',
  1317. 'uploader_url': 'https://www.tiktok.com/@weathernewslive',
  1318. 'live_status': 'is_live',
  1319. 'concurrent_view_count': int,
  1320. },
  1321. 'params': {'skip_download': 'm3u8'},
  1322. }, {
  1323. 'url': 'https://www.tiktok.com/@pilarmagenta/live',
  1324. 'info_dict': {
  1325. 'id': '7209423610325322522',
  1326. 'ext': 'mp4',
  1327. 'title': str,
  1328. 'creator': 'Pilarmagenta',
  1329. 'uploader': 'pilarmagenta',
  1330. 'uploader_id': '6624846890674683909',
  1331. 'uploader_url': 'https://www.tiktok.com/@pilarmagenta',
  1332. 'live_status': 'is_live',
  1333. 'concurrent_view_count': int,
  1334. },
  1335. 'skip': 'Livestream',
  1336. }, {
  1337. 'url': 'https://m.tiktok.com/share/live/7209423610325322522/?language=en',
  1338. 'only_matching': True,
  1339. }, {
  1340. 'url': 'https://www.tiktok.com/@iris04201/live',
  1341. 'only_matching': True,
  1342. }]
  1343. def _call_api(self, url, param, room_id, uploader, key=None):
  1344. response = traverse_obj(self._download_json(
  1345. url, room_id, fatal=False, query={
  1346. 'aid': '1988',
  1347. param: room_id,
  1348. }), (key, {dict}), default={})
  1349. # status == 2 if live else 4
  1350. if int_or_none(response.get('status')) == 2:
  1351. return response
  1352. # If room_id is obtained via mobile share URL and cannot be refreshed, do not wait for live
  1353. elif not uploader:
  1354. raise ExtractorError('This livestream has ended', expected=True)
  1355. raise UserNotLive(video_id=uploader)
  1356. def _real_extract(self, url):
  1357. uploader, room_id = self._match_valid_url(url).group('uploader', 'id')
  1358. webpage = self._download_webpage(
  1359. url, uploader or room_id, headers={'User-Agent': 'Mozilla/5.0'}, fatal=not room_id)
  1360. if webpage:
  1361. data = self._get_sigi_state(webpage, uploader or room_id)
  1362. room_id = (
  1363. traverse_obj(data, ((
  1364. ('LiveRoom', 'liveRoomUserInfo', 'user'),
  1365. ('UserModule', 'users', ...)), 'roomId', {str}, any))
  1366. or self._search_regex(r'snssdk\d*://live\?room_id=(\d+)', webpage, 'room ID', default=room_id))
  1367. uploader = uploader or traverse_obj(
  1368. data, ('LiveRoom', 'liveRoomUserInfo', 'user', 'uniqueId'),
  1369. ('UserModule', 'users', ..., 'uniqueId'), get_all=False, expected_type=str)
  1370. if not room_id:
  1371. raise UserNotLive(video_id=uploader)
  1372. formats = []
  1373. live_info = self._call_api(
  1374. 'https://webcast.tiktok.com/webcast/room/info', 'room_id', room_id, uploader, key='data')
  1375. get_quality = qualities(('SD1', 'ld', 'SD2', 'sd', 'HD1', 'hd', 'FULL_HD1', 'uhd', 'ORIGION', 'origin'))
  1376. parse_inner = lambda x: self._parse_json(x, None)
  1377. for quality, stream in traverse_obj(live_info, (
  1378. 'stream_url', 'live_core_sdk_data', 'pull_data', 'stream_data',
  1379. {parse_inner}, 'data', {dict}), default={}).items():
  1380. sdk_params = traverse_obj(stream, ('main', 'sdk_params', {parse_inner}, {
  1381. 'vcodec': ('VCodec', {str}),
  1382. 'tbr': ('vbitrate', {int_or_none(scale=1000)}),
  1383. 'resolution': ('resolution', {lambda x: re.match(r'(?i)\d+x\d+|\d+p', x).group().lower()}),
  1384. }))
  1385. flv_url = traverse_obj(stream, ('main', 'flv', {url_or_none}))
  1386. if flv_url:
  1387. formats.append({
  1388. 'url': flv_url,
  1389. 'ext': 'flv',
  1390. 'format_id': f'flv-{quality}',
  1391. 'quality': get_quality(quality),
  1392. **sdk_params,
  1393. })
  1394. hls_url = traverse_obj(stream, ('main', 'hls', {url_or_none}))
  1395. if hls_url:
  1396. formats.append({
  1397. 'url': hls_url,
  1398. 'ext': 'mp4',
  1399. 'protocol': 'm3u8_native',
  1400. 'format_id': f'hls-{quality}',
  1401. 'quality': get_quality(quality),
  1402. **sdk_params,
  1403. })
  1404. def get_vcodec(*keys):
  1405. return traverse_obj(live_info, (
  1406. 'stream_url', *keys, {parse_inner}, 'VCodec', {str}))
  1407. for stream in ('hls', 'rtmp'):
  1408. stream_url = traverse_obj(live_info, ('stream_url', f'{stream}_pull_url', {url_or_none}))
  1409. if stream_url:
  1410. formats.append({
  1411. 'url': stream_url,
  1412. 'ext': 'mp4' if stream == 'hls' else 'flv',
  1413. 'protocol': 'm3u8_native' if stream == 'hls' else 'https',
  1414. 'format_id': f'{stream}-pull',
  1415. 'vcodec': get_vcodec(f'{stream}_pull_url_params'),
  1416. 'quality': get_quality('ORIGION'),
  1417. })
  1418. for f_id, f_url in traverse_obj(live_info, ('stream_url', 'flv_pull_url', {dict}), default={}).items():
  1419. if not url_or_none(f_url):
  1420. continue
  1421. formats.append({
  1422. 'url': f_url,
  1423. 'ext': 'flv',
  1424. 'format_id': f'flv-{f_id}'.lower(),
  1425. 'vcodec': get_vcodec('flv_pull_url_params', f_id),
  1426. 'quality': get_quality(f_id),
  1427. })
  1428. # If uploader is a guest on another's livestream, primary endpoint will not have m3u8 URLs
  1429. if not traverse_obj(formats, lambda _, v: v['ext'] == 'mp4'):
  1430. live_info = merge_dicts(live_info, self._call_api(
  1431. 'https://www.tiktok.com/api/live/detail/', 'roomID', room_id, uploader, key='LiveRoomInfo'))
  1432. if url_or_none(live_info.get('liveUrl')):
  1433. formats.append({
  1434. 'url': live_info['liveUrl'],
  1435. 'ext': 'mp4',
  1436. 'protocol': 'm3u8_native',
  1437. 'format_id': 'hls-fallback',
  1438. 'vcodec': 'h264',
  1439. 'quality': get_quality('origin'),
  1440. })
  1441. uploader = uploader or traverse_obj(live_info, ('ownerInfo', 'uniqueId'), ('owner', 'display_id'))
  1442. return {
  1443. 'id': room_id,
  1444. 'uploader': uploader,
  1445. 'uploader_url': format_field(uploader, None, self._UPLOADER_URL_FORMAT) or None,
  1446. 'is_live': True,
  1447. 'formats': formats,
  1448. '_format_sort_fields': ('quality', 'ext'),
  1449. **traverse_obj(live_info, {
  1450. 'title': 'title',
  1451. 'uploader_id': (('ownerInfo', 'owner'), 'id', {str_or_none}),
  1452. 'creator': (('ownerInfo', 'owner'), 'nickname'),
  1453. 'concurrent_view_count': (('user_count', ('liveRoomStats', 'userCount')), {int_or_none}),
  1454. }, get_all=False),
  1455. }