]> jfr.im git - yt-dlp.git/blobdiff - yt_dlp/extractor/tiktok.py
[ie] Make `_search_nextjs_data` non fatal (#8937)
[yt-dlp.git] / yt_dlp / extractor / tiktok.py
index 1ecb4a26c2b9b8fe3c4c19a2ab6298fee4ffad05..3d965dd4529fe6b779552acc77f6d0ab1175f007 100644 (file)
@@ -4,6 +4,7 @@
 import re
 import string
 import time
+import uuid
 
 from .common import InfoExtractor
 from ..compat import compat_urllib_parse_urlparse
 
 
 class TikTokBaseIE(InfoExtractor):
-    _APP_VERSIONS = [('26.1.3', '260103'), ('26.1.2', '260102'), ('26.1.1', '260101'), ('25.6.2', '250602')]
-    _WORKING_APP_VERSION = None
-    _APP_NAME = 'trill'
-    _AID = 1180
     _UPLOADER_URL_FORMAT = 'https://www.tiktok.com/@%s'
     _WEBPAGE_HOST = 'https://www.tiktok.com/'
     QUALITIES = ('360p', '540p', '720p', '1080p')
 
+    _APP_INFO_DEFAULTS = {
+        # unique "install id"
+        'iid': None,
+        # TikTok (KR/PH/TW/TH/VN) = trill, TikTok (rest of world) = musical_ly, Douyin = aweme
+        'app_name': 'musical_ly',
+        'app_version': '34.1.2',
+        'manifest_app_version': '2023401020',
+        # "app id": aweme = 1128, trill = 1180, musical_ly = 1233, universal = 0
+        'aid': '0',
+    }
+    _KNOWN_APP_INFO = [
+        '7351144126450059040',
+        '7351149742343391009',
+        '7351153174894626592',
+    ]
+    _APP_INFO_POOL = None
+    _APP_INFO = None
+    _APP_USER_AGENT = None
+
     @property
     def _API_HOSTNAME(self):
         return self._configuration_arg(
-            'api_hostname', ['api16-normal-c-useast1a.tiktokv.com'], ie_key=TikTokIE)[0]
+            'api_hostname', ['api22-normal-c-useast2a.tiktokv.com'], ie_key=TikTokIE)[0]
+
+    def _get_next_app_info(self):
+        if self._APP_INFO_POOL is None:
+            defaults = {
+                key: self._configuration_arg(key, [default], ie_key=TikTokIE)[0]
+                for key, default in self._APP_INFO_DEFAULTS.items()
+                if key != 'iid'
+            }
+            app_info_list = (
+                self._configuration_arg('app_info', ie_key=TikTokIE)
+                or random.sample(self._KNOWN_APP_INFO, len(self._KNOWN_APP_INFO)))
+            self._APP_INFO_POOL = [
+                {**defaults, **dict(
+                    (k, v) for k, v in zip(self._APP_INFO_DEFAULTS, app_info.split('/')) if v
+                )} for app_info in app_info_list
+            ]
+
+        if not self._APP_INFO_POOL:
+            return False
+
+        self._APP_INFO = self._APP_INFO_POOL.pop(0)
+
+        app_name = self._APP_INFO['app_name']
+        version = self._APP_INFO['manifest_app_version']
+        if app_name == 'musical_ly':
+            package = f'com.zhiliaoapp.musically/{version}'
+        else:  # trill, aweme
+            package = f'com.ss.android.ugc.{app_name}/{version}'
+        self._APP_USER_AGENT = f'{package} (Linux; U; Android 13; en_US; Pixel 7; Build/TD1A.220804.031; Cronet/58.0.2991.0)'
+
+        return True
 
     @staticmethod
     def _create_url(user_id, video_id):
@@ -50,9 +97,15 @@ def _create_url(user_id, video_id):
     def _get_sigi_state(self, webpage, display_id):
         return self._search_json(
             r'<script[^>]+\bid="(?:SIGI_STATE|sigi-persisted-data)"[^>]*>', webpage,
-            'sigi state', display_id, end_pattern=r'</script>')
+            'sigi state', display_id, end_pattern=r'</script>', default={})
+
+    def _get_universal_data(self, webpage, display_id):
+        return traverse_obj(self._search_json(
+            r'<script[^>]+\bid="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>', webpage,
+            'universal data', display_id, end_pattern=r'</script>', default={}),
+            ('__DEFAULT_SCOPE__', {dict})) or {}
 
-    def _call_api_impl(self, ep, query, manifest_app_version, video_id, fatal=True,
+    def _call_api_impl(self, ep, query, video_id, fatal=True,
                        note='Downloading API JSON', errnote='Unable to download API page'):
         self._set_cookie(self._API_HOSTNAME, 'odin_tt', ''.join(random.choices('0123456789abcdef', k=160)))
         webpage_cookies = self._get_cookies(self._WEBPAGE_HOST)
@@ -61,80 +114,85 @@ def _call_api_impl(self, ep, query, manifest_app_version, video_id, fatal=True,
         return self._download_json(
             'https://%s/aweme/v1/%s/' % (self._API_HOSTNAME, ep), video_id=video_id,
             fatal=fatal, note=note, errnote=errnote, headers={
-                'User-Agent': f'com.ss.android.ugc.{self._APP_NAME}/{manifest_app_version} (Linux; U; Android 13; en_US; Pixel 7; Build/TD1A.220804.031; Cronet/58.0.2991.0)',
+                'User-Agent': self._APP_USER_AGENT,
                 'Accept': 'application/json',
             }, query=query)
 
-    def _build_api_query(self, query, app_version, manifest_app_version):
+    def _build_api_query(self, query):
         return {
             **query,
-            'version_name': app_version,
-            'version_code': manifest_app_version,
-            'build_number': app_version,
-            'manifest_version_code': manifest_app_version,
-            'update_version_code': manifest_app_version,
-            'openudid': ''.join(random.choices('0123456789abcdef', k=16)),
-            'uuid': ''.join(random.choices(string.digits, k=16)),
-            '_rticket': int(time.time() * 1000),
-            'ts': int(time.time()),
-            'device_brand': 'Google',
-            'device_type': 'Pixel 7',
             'device_platform': 'android',
+            'os': 'android',
+            'ssmix': 'a',
+            '_rticket': int(time.time() * 1000),
+            'cdid': str(uuid.uuid4()),
+            'channel': 'googleplay',
+            'aid': self._APP_INFO['aid'],
+            'app_name': self._APP_INFO['app_name'],
+            'version_code': ''.join((f'{int(v):02d}' for v in self._APP_INFO['app_version'].split('.'))),
+            'version_name': self._APP_INFO['app_version'],
+            'manifest_version_code': self._APP_INFO['manifest_app_version'],
+            'update_version_code': self._APP_INFO['manifest_app_version'],
+            'ab_version': self._APP_INFO['app_version'],
             'resolution': '1080*2400',
             'dpi': 420,
-            'os_version': '13',
+            'device_type': 'Pixel 7',
+            'device_brand': 'Google',
+            'language': 'en',
             'os_api': '29',
-            'carrier_region': 'US',
+            'os_version': '13',
+            'ac': 'wifi',
+            'is_pad': '0',
+            'current_region': 'US',
+            'app_type': 'normal',
             'sys_region': 'US',
-            'region': 'US',
-            'app_name': self._APP_NAME,
-            'app_language': 'en',
-            'language': 'en',
+            'last_install_time': int(time.time()) - random.randint(86400, 1123200),
             'timezone_name': 'America/New_York',
+            'residence': 'US',
+            'app_language': 'en',
             'timezone_offset': '-14400',
-            'channel': 'googleplay',
-            'ac': 'wifi',
-            'mcc_mnc': '310260',
-            'is_my_cn': 0,
-            'aid': self._AID,
-            'ssmix': 'a',
-            'as': 'a1qwert123',
-            'cp': 'cbfhckdckkde1',
+            'host_abi': 'armeabi-v7a',
+            'locale': 'en',
+            'ac2': 'wifi5g',
+            'uoo': '1',
+            'carrier_region': 'US',
+            'op_region': 'US',
+            'build_number': self._APP_INFO['app_version'],
+            'region': 'US',
+            'ts': int(time.time()),
+            'iid': self._APP_INFO['iid'],
+            'device_id': random.randint(7250000000000000000, 7351147085025500000),
+            'openudid': ''.join(random.choices('0123456789abcdef', k=16)),
         }
 
     def _call_api(self, ep, query, video_id, fatal=True,
                   note='Downloading API JSON', errnote='Unable to download API page'):
-        if not self._WORKING_APP_VERSION:
-            app_version = self._configuration_arg('app_version', [''], ie_key=TikTokIE.ie_key())[0]
-            manifest_app_version = self._configuration_arg('manifest_app_version', [''], ie_key=TikTokIE.ie_key())[0]
-            if app_version and manifest_app_version:
-                self._WORKING_APP_VERSION = (app_version, manifest_app_version)
-                self.write_debug('Imported app version combo from extractor arguments')
-            elif app_version or manifest_app_version:
-                self.report_warning('Only one of the two required version params are passed as extractor arguments', only_once=True)
-
-        if self._WORKING_APP_VERSION:
-            app_version, manifest_app_version = self._WORKING_APP_VERSION
-            real_query = self._build_api_query(query, app_version, manifest_app_version)
-            return self._call_api_impl(ep, real_query, manifest_app_version, video_id, fatal, note, errnote)
-
-        for count, (app_version, manifest_app_version) in enumerate(self._APP_VERSIONS, start=1):
-            real_query = self._build_api_query(query, app_version, manifest_app_version)
+        if not self._APP_INFO and not self._get_next_app_info():
+            message = 'No working app info is available'
+            if fatal:
+                raise ExtractorError(message, expected=True)
+            else:
+                self.report_warning(message)
+                return
+
+        max_tries = len(self._APP_INFO_POOL) + 1  # _APP_INFO_POOL + _APP_INFO
+        for count in itertools.count(1):
+            self.write_debug(str(self._APP_INFO))
+            real_query = self._build_api_query(query)
             try:
-                res = self._call_api_impl(ep, real_query, manifest_app_version, video_id, fatal, note, errnote)
-                self._WORKING_APP_VERSION = (app_version, manifest_app_version)
-                return res
+                return self._call_api_impl(ep, real_query, video_id, fatal, note, errnote)
             except ExtractorError as e:
                 if isinstance(e.cause, json.JSONDecodeError) and e.cause.pos == 0:
-                    if count == len(self._APP_VERSIONS):
+                    message = str(e.cause or e.msg)
+                    if not self._get_next_app_info():
                         if fatal:
-                            raise e
+                            raise
                         else:
-                            self.report_warning(str(e.cause or e.msg))
+                            self.report_warning(message)
                             return
-                    self.report_warning('%s. Retrying... (attempt %s of %s)' % (str(e.cause or e.msg), count, len(self._APP_VERSIONS)))
+                    self.report_warning(f'{message}. Retrying... (attempt {count} of {max_tries})')
                     continue
-                raise e
+                raise
 
     def _extract_aweme_app(self, aweme_id):
         feed_list = self._call_api(
@@ -217,6 +275,7 @@ def audio_meta(url):
 
         def extract_addr(addr, add_meta={}):
             parsed_meta, res = parse_url_key(addr.get('url_key', ''))
+            is_bytevc2 = parsed_meta.get('vcodec') == 'bytevc2'
             if res:
                 known_resolutions.setdefault(res, {}).setdefault('height', int_or_none(addr.get('height')))
                 known_resolutions[res].setdefault('width', int_or_none(addr.get('width')))
@@ -229,8 +288,11 @@ def extract_addr(addr, add_meta={}):
                 'acodec': 'aac',
                 'source_preference': -2 if 'aweme/v1' in url else -1,  # Downloads from API might get blocked
                 **add_meta, **parsed_meta,
+                # bytevc2 is bytedance's proprietary (unplayable) video codec
+                'preference': -100 if is_bytevc2 else -1,
                 'format_note': join_nonempty(
-                    add_meta.get('format_note'), '(API)' if 'aweme/v1' in url else None, delim=' '),
+                    add_meta.get('format_note'), '(API)' if 'aweme/v1' in url else None,
+                    '(UNPLAYABLE)' if is_bytevc2 else None, delim=' '),
                 **audio_meta(url),
             } for url in addr.get('url_list') or []]
 
@@ -314,13 +376,10 @@ def extract_addr(addr, add_meta={}):
         if is_generic_og_trackname:
             music_track, music_author = contained_music_track or 'original sound', contained_music_author
         else:
-            music_track, music_author = music_info.get('title'), music_info.get('author')
+            music_track, music_author = music_info.get('title'), traverse_obj(music_info, ('author', {str}))
 
         return {
             'id': aweme_id,
-            'extractor_key': TikTokIE.ie_key(),
-            'extractor': TikTokIE.IE_NAME,
-            'webpage_url': self._create_url(author_info.get('uid'), aweme_id),
             **traverse_obj(aweme_detail, {
                 'title': ('desc', {str}),
                 'description': ('desc', {str}),
@@ -333,15 +392,16 @@ def extract_addr(addr, add_meta={}):
                 'comment_count': 'comment_count',
             }, expected_type=int_or_none),
             **traverse_obj(author_info, {
-                'uploader': 'unique_id',
-                'uploader_id': 'uid',
-                'creator': 'nickname',
-                'channel_id': 'sec_uid',
-            }, expected_type=str_or_none),
+                'uploader': ('unique_id', {str}),
+                'uploader_id': ('uid', {str_or_none}),
+                'creators': ('nickname', {str}, {lambda x: [x] if x else None}),  # for compat
+                'channel': ('nickname', {str}),
+                'channel_id': ('sec_uid', {str}),
+            }),
             'uploader_url': user_url,
             'track': music_track,
             'album': str_or_none(music_info.get('album')) or None,
-            'artist': music_author or None,
+            'artists': re.split(r'(?:, | & )', music_author) if music_author else None,
             'formats': formats,
             'subtitles': self.extract_subtitles(aweme_detail, aweme_id),
             'thumbnails': thumbnails,
@@ -402,7 +462,8 @@ def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id):
                 'timestamp': ('createTime', {int_or_none}),
             }),
             **traverse_obj(author_info or aweme_detail, {
-                'creator': ('nickname', {str}),
+                'creators': ('nickname', {str}, {lambda x: [x] if x else None}),  # for compat
+                'channel': ('nickname', {str}),
                 'uploader': (('uniqueId', 'author'), {str}),
                 'uploader_id': (('authorId', 'uid', 'id'), {str_or_none}),
             }, get_all=False),
@@ -413,10 +474,10 @@ def _parse_aweme_video_web(self, aweme_detail, webpage_url, video_id):
                 'comment_count': 'commentCount',
             }, expected_type=int_or_none),
             **traverse_obj(music_info, {
-                'track': 'title',
-                'album': ('album', {lambda x: x or None}),
-                'artist': 'authorName',
-            }, expected_type=str),
+                '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,
             'uploader_url': user_url,
             'formats': formats,
@@ -473,7 +534,8 @@ class TikTokIE(TikTokBaseIE):
             'uploader_id': '18702747',
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAiFnldaILebi5heDoVU6bn4jBWWycX6-9U3xuNPqZ8Ws',
             'channel_id': 'MS4wLjABAAAAiFnldaILebi5heDoVU6bn4jBWWycX6-9U3xuNPqZ8Ws',
-            'creator': 'patroX',
+            'channel': 'patroX',
+            'creators': ['patroX'],
             'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
             'upload_date': '20190930',
             'timestamp': 1569860870,
@@ -481,7 +543,7 @@ class TikTokIE(TikTokBaseIE):
             'like_count': int,
             'repost_count': int,
             'comment_count': int,
-            'artist': 'Evan Todd, Jessica Keenan Wynn, Alice Lee, Barrett Wilbert Weed & Jon Eidson',
+            'artists': ['Evan Todd', 'Jessica Keenan Wynn', 'Alice Lee', 'Barrett Wilbert Weed', 'Jon Eidson'],
             'track': 'Big Fun',
         },
     }, {
@@ -493,12 +555,13 @@ class TikTokIE(TikTokBaseIE):
             'title': 'Balas @yolaaftwsr hayu yu ? #SquadRandom_ 🔥',
             'description': 'Balas @yolaaftwsr hayu yu ? #SquadRandom_ 🔥',
             'uploader': 'barudakhb_',
-            'creator': 'md5:29f238c49bc0c176cb3cef1a9cea9fa6',
+            'channel': 'md5:29f238c49bc0c176cb3cef1a9cea9fa6',
+            'creators': ['md5:29f238c49bc0c176cb3cef1a9cea9fa6'],
             'uploader_id': '6974687867511718913',
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAbhBwQC-R1iKoix6jDFsF-vBdfx2ABoDjaZrM9fX6arU3w71q3cOWgWuTXn1soZ7d',
             'channel_id': 'MS4wLjABAAAAbhBwQC-R1iKoix6jDFsF-vBdfx2ABoDjaZrM9fX6arU3w71q3cOWgWuTXn1soZ7d',
             'track': 'Boka Dance',
-            'artist': 'md5:29f238c49bc0c176cb3cef1a9cea9fa6',
+            'artists': ['md5:29f238c49bc0c176cb3cef1a9cea9fa6'],
             'timestamp': 1626121503,
             'duration': 18,
             'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
@@ -517,7 +580,8 @@ class TikTokIE(TikTokBaseIE):
             'title': 'Slap and Run!',
             'description': 'Slap and Run!',
             'uploader': 'user440922249',
-            'creator': 'Slap And Run',
+            'channel': 'Slap And Run',
+            'creators': ['Slap And Run'],
             'uploader_id': '7036055384943690754',
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAATh8Vewkn0LYM7Fo03iec3qKdeCUOcBIouRk1mkiag6h3o_pQu_dUXvZ2EZlGST7_',
             'channel_id': 'MS4wLjABAAAATh8Vewkn0LYM7Fo03iec3qKdeCUOcBIouRk1mkiag6h3o_pQu_dUXvZ2EZlGST7_',
@@ -541,7 +605,8 @@ class TikTokIE(TikTokBaseIE):
             'title': 'TikTok video #7059698374567611694',
             'description': '',
             'uploader': 'pokemonlife22',
-            'creator': 'Pokemon',
+            'channel': 'Pokemon',
+            'creators': ['Pokemon'],
             'uploader_id': '6820838815978423302',
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAA0tF1nBwQVVMyrGu3CqttkNgM68Do1OXUFuCY0CRQk8fEtSVDj89HqoqvbSTmUP2W',
             'channel_id': 'MS4wLjABAAAA0tF1nBwQVVMyrGu3CqttkNgM68Do1OXUFuCY0CRQk8fEtSVDj89HqoqvbSTmUP2W',
@@ -550,7 +615,7 @@ class TikTokIE(TikTokBaseIE):
             'duration': 6,
             'thumbnail': r're:^https?://[\w\/\.\-]+(~[\w\-]+\.image)?',
             'upload_date': '20220201',
-            'artist': 'Pokemon',
+            'artists': ['Pokemon'],
             'view_count': int,
             'like_count': int,
             'repost_count': int,
@@ -587,12 +652,13 @@ class TikTokIE(TikTokBaseIE):
             'ext': 'mp3',
             'title': 'TikTok video #7139980461132074283',
             'description': '',
-            'creator': 'Antaura',
+            'channel': 'Antaura',
+            'creators': ['Antaura'],
             'uploader': '_le_cannibale_',
             'uploader_id': '6604511138619654149',
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAoShJqaw_5gvy48y3azFeFcT4jeyKWbB0VVYasOCt2tTLwjNFIaDcHAM4D-QGXFOP',
             'channel_id': 'MS4wLjABAAAAoShJqaw_5gvy48y3azFeFcT4jeyKWbB0VVYasOCt2tTLwjNFIaDcHAM4D-QGXFOP',
-            'artist': 'nathan !',
+            'artists': ['nathan !'],
             'track': 'grahamscott canon',
             'upload_date': '20220905',
             'timestamp': 1662406249,
@@ -600,23 +666,24 @@ class TikTokIE(TikTokBaseIE):
             'like_count': int,
             'repost_count': int,
             'comment_count': int,
-            'thumbnail': r're:^https://.+\.webp',
+            'thumbnail': r're:^https://.+\.(?:webp|jpe?g)',
         },
     }, {
         # only available via web
-        'url': 'https://www.tiktok.com/@moxypatch/video/7206382937372134662',
+        'url': 'https://www.tiktok.com/@moxypatch/video/7206382937372134662',  # FIXME
         'md5': '6aba7fad816e8709ff2c149679ace165',
         'info_dict': {
             'id': '7206382937372134662',
             'ext': 'mp4',
             'title': 'md5:1d95c0b96560ca0e8a231af4172b2c0a',
             'description': 'md5:1d95c0b96560ca0e8a231af4172b2c0a',
-            'creator': 'MoxyPatch',
+            'channel': 'MoxyPatch',
+            'creators': ['MoxyPatch'],
             'uploader': 'moxypatch',
             'uploader_id': '7039142049363379205',
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAFhqKnngMHJSsifL0w1vFOP5kn3Ndo1ODp0XuIBkNMBCkALTvwILdpu12g3pTtL4V',
             'channel_id': 'MS4wLjABAAAAFhqKnngMHJSsifL0w1vFOP5kn3Ndo1ODp0XuIBkNMBCkALTvwILdpu12g3pTtL4V',
-            'artist': 'your worst nightmare',
+            'artists': ['your worst nightmare'],
             'track': 'original sound',
             'upload_date': '20230303',
             'timestamp': 1677866781,
@@ -631,7 +698,7 @@ class TikTokIE(TikTokBaseIE):
         'expected_warnings': ['Unable to find video in feed'],
     }, {
         # 1080p format
-        'url': 'https://www.tiktok.com/@tatemcrae/video/7107337212743830830',
+        'url': 'https://www.tiktok.com/@tatemcrae/video/7107337212743830830',  # FIXME
         'md5': '982512017a8a917124d5a08c8ae79621',
         'info_dict': {
             'id': '7107337212743830830',
@@ -642,8 +709,9 @@ class TikTokIE(TikTokBaseIE):
             'uploader_id': '86328792343818240',
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAA-0bQT0CqebTRr6I4IkYvMDMKSRSJHLNPBo5HrSklJwyA2psXLSZG5FP-LMNpHnJd',
             'channel_id': 'MS4wLjABAAAA-0bQT0CqebTRr6I4IkYvMDMKSRSJHLNPBo5HrSklJwyA2psXLSZG5FP-LMNpHnJd',
-            'creator': 'tate mcrae',
-            'artist': 'tate mcrae',
+            'channel': 'tate mcrae',
+            'creators': ['tate mcrae'],
+            'artists': ['tate mcrae'],
             'track': 'original sound',
             'upload_date': '20220609',
             'timestamp': 1654805899,
@@ -654,7 +722,7 @@ class TikTokIE(TikTokBaseIE):
             'comment_count': int,
             'thumbnail': r're:^https://.+\.webp',
         },
-        'params': {'format': 'bytevc1_1080p_808907-0'},
+        'skip': 'Unavailable via feed API, no formats available via web',
     }, {
         # Slideshow, audio-only m4a format
         'url': 'https://www.tiktok.com/@hara_yoimiya/video/7253412088251534594',
@@ -668,8 +736,9 @@ class TikTokIE(TikTokBaseIE):
             'uploader_id': '6582536342634676230',
             'uploader_url': 'https://www.tiktok.com/@MS4wLjABAAAAIAlDxriiPWLE-p8p1R_0Bx8qWKfi-7zwmGhzU8Mv25W8sNxjfIKrol31qTczzuLB',
             'channel_id': 'MS4wLjABAAAAIAlDxriiPWLE-p8p1R_0Bx8qWKfi-7zwmGhzU8Mv25W8sNxjfIKrol31qTczzuLB',
-            'creator': 'лампочка',
-            'artist': 'Øneheart',
+            'channel': 'лампочка',
+            'creators': ['лампочка'],
+            'artists': ['Øneheart'],
             'album': 'watching the stars',
             'track': 'watching the stars',
             'upload_date': '20230708',
@@ -678,7 +747,7 @@ class TikTokIE(TikTokBaseIE):
             'like_count': int,
             'comment_count': int,
             'repost_count': int,
-            'thumbnail': r're:^https://.+\.webp',
+            'thumbnail': r're:^https://.+\.(?:webp|jpe?g)',
         },
     }, {
         # Auto-captions available
@@ -691,24 +760,35 @@ def _real_extract(self, url):
         try:
             return self._extract_aweme_app(video_id)
         except ExtractorError as e:
+            e.expected = True
             self.report_warning(f'{e}; trying with webpage')
 
         url = self._create_url(user_id, video_id)
         webpage = self._download_webpage(url, video_id, headers={'User-Agent': 'Mozilla/5.0'})
-        next_data = self._search_nextjs_data(webpage, video_id, default='{}')
-        if next_data:
-            status = traverse_obj(next_data, ('props', 'pageProps', 'statusCode'), expected_type=int) or 0
-            video_data = traverse_obj(next_data, ('props', 'pageProps', 'itemInfo', 'itemStruct'), expected_type=dict)
+
+        if universal_data := self._get_universal_data(webpage, video_id):
+            self.write_debug('Found universal data for rehydration')
+            status = traverse_obj(universal_data, ('webapp.video-detail', 'statusCode', {int})) or 0
+            video_data = traverse_obj(universal_data, ('webapp.video-detail', 'itemInfo', 'itemStruct', {dict}))
+
+        elif sigi_data := self._get_sigi_state(webpage, video_id):
+            self.write_debug('Found sigi state data')
+            status = traverse_obj(sigi_data, ('VideoPage', 'statusCode', {int})) or 0
+            video_data = traverse_obj(sigi_data, ('ItemModule', video_id, {dict}))
+
+        elif next_data := self._search_nextjs_data(webpage, video_id, default={}):
+            self.write_debug('Found next.js data')
+            status = traverse_obj(next_data, ('props', 'pageProps', 'statusCode', {int})) or 0
+            video_data = traverse_obj(next_data, ('props', 'pageProps', 'itemInfo', 'itemStruct', {dict}))
+
         else:
-            sigi_data = self._get_sigi_state(webpage, video_id)
-            status = traverse_obj(sigi_data, ('VideoPage', 'statusCode'), expected_type=int) or 0
-            video_data = traverse_obj(sigi_data, ('ItemModule', video_id), expected_type=dict)
+            raise ExtractorError('Unable to extract webpage video data')
 
-        if status == 0:
+        if video_data and status == 0:
             return self._parse_aweme_video_web(video_data, url, video_id)
         elif status == 10216:
             raise ExtractorError('This video is private', expected=True)
-        raise ExtractorError('Video not available', video_id=video_id)
+        raise ExtractorError(f'Video not available, status code {status}', video_id=video_id)
 
 
 class TikTokUserIE(TikTokBaseIE):
@@ -934,7 +1014,7 @@ class DouyinIE(TikTokBaseIE):
             'uploader_id': '110403406559',
             'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
             'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
-            'creator': '杨超越',
+            'channel': '杨超越',
             'creators': ['杨超越'],
             'duration': 19,
             'timestamp': 1620905839,
@@ -959,7 +1039,7 @@ class DouyinIE(TikTokBaseIE):
             'uploader_id': '408654318141572',
             'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAZJpnglcjW2f_CMVcnqA_6oVBXKWMpH0F8LIHuUu8-lA',
             'channel_id': 'MS4wLjABAAAAZJpnglcjW2f_CMVcnqA_6oVBXKWMpH0F8LIHuUu8-lA',
-            'creator': '杨超越工作室',
+            'channel': '杨超越工作室',
             'creators': ['杨超越工作室'],
             'duration': 42,
             'timestamp': 1625739481,
@@ -984,7 +1064,7 @@ class DouyinIE(TikTokBaseIE):
             'uploader_id': '110403406559',
             'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
             'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
-            'creator': '杨超越',
+            'channel': '杨超越',
             'creators': ['杨超越'],
             'duration': 17,
             'timestamp': 1619098692,
@@ -1026,7 +1106,7 @@ class DouyinIE(TikTokBaseIE):
             'uploader_id': '110403406559',
             'uploader_url': 'https://www.douyin.com/user/MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
             'channel_id': 'MS4wLjABAAAAEKnfa654JAJ_N5lgZDQluwsxmY0lhfmEYNQBBkwGG98',
-            'creator': '杨超越',
+            'channel': '杨超越',
             'creators': ['杨超越'],
             'duration': 15,
             'timestamp': 1621261163,
@@ -1185,7 +1265,7 @@ def _real_extract(self, url):
             url, uploader or room_id, headers={'User-Agent': 'Mozilla/5.0'}, fatal=not room_id)
 
         if webpage:
-            data = try_call(lambda: self._get_sigi_state(webpage, uploader or room_id))
+            data = self._get_sigi_state(webpage, uploader or room_id)
             room_id = (traverse_obj(data, ('UserModule', 'users', ..., 'roomId', {str_or_none}), get_all=False)
                        or self._search_regex(r'snssdk\d*://live\?room_id=(\d+)', webpage, 'room ID', default=None)
                        or room_id)