4 from .common
import InfoExtractor
16 class TrillerBaseIE(InfoExtractor
):
17 _NETRC_MACHINE
= 'triller'
18 _API_BASE_URL
= 'https://social.triller.co/v1.5'
19 _API_HEADERS
= {'Origin': 'https://triller.co'}
21 def _perform_login(self
, username
, password
):
22 if self
._API
_HEADERS
.get('Authorization'):
25 user_check
= self
._download
_json
(
26 f
'{self._API_BASE_URL}/api/user/is-valid-username', None, note
='Checking username',
27 fatal
=False, expected_status
=400, headers
={
28 'Content-Type': 'application/json',
29 'Origin': 'https://triller.co',
30 }, data
=json
.dumps({'username': username}
, separators
=(',', ':')).encode('utf-8'))
31 if user_check
.get('status'): # endpoint returns "status":false if username exists
32 raise ExtractorError('Unable to login: Invalid username', expected
=True)
38 login
= self
._download
_json
(
39 f
'{self._API_BASE_URL}/user/auth', None, note
='Logging in',
40 fatal
=False, expected_status
=400, headers
={
41 'Content-Type': 'application/json',
42 'Origin': 'https://triller.co',
43 }, data
=json
.dumps(credentials
, separators
=(',', ':')).encode('utf-8'))
44 if not login
.get('auth_token'):
45 if login
.get('error') == 1008:
46 raise ExtractorError('Unable to login: Incorrect password', expected
=True)
47 raise ExtractorError('Unable to login')
49 self
._API
_HEADERS
['Authorization'] = f
'Bearer {login["auth_token"]}'
51 def _get_comments(self
, video_id
, limit
=15):
52 comment_info
= self
._download
_json
(
53 f
'{self._API_BASE_URL}/api/videos/{video_id}/comments_v2',
54 video_id
, fatal
=False, note
='Downloading comments API JSON',
55 headers
=self
._API
_HEADERS
, query
={'limit': limit}
) or {}
56 if not comment_info
.get('comments'):
58 for comment_dict
in comment_info
['comments']:
60 'author': traverse_obj(comment_dict
, ('author', 'username')),
61 'author_id': traverse_obj(comment_dict
, ('author', 'user_id')),
62 'id': comment_dict
.get('id'),
63 'text': comment_dict
.get('body'),
64 'timestamp': unified_timestamp(comment_dict
.get('timestamp')),
67 def _check_user_info(self
, user_info
):
69 self
.report_warning('Unable to extract user info')
70 elif user_info
.get('private') and not user_info
.get('followed_by_me'):
71 raise ExtractorError('This video is private', expected
=True)
72 elif traverse_obj(user_info
, 'blocked_by_user', 'blocking_user'):
73 raise ExtractorError('The author of the video is blocked', expected
=True)
76 def _parse_video_info(self
, video_info
, username
, user_info
=None):
77 video_uuid
= video_info
.get('video_uuid')
78 video_id
= video_info
.get('id')
81 video_url
= traverse_obj(video_info
, 'video_url', 'stream_url')
87 'width': video_info
.get('width'),
88 'height': video_info
.get('height'),
89 'format_id': url_basename(video_url
).split('.')[0],
90 'filesize': video_info
.get('filesize'),
92 video_set
= video_info
.get('video_set') or []
93 for video
in video_set
:
94 resolution
= video
.get('resolution') or ''
98 'vcodec': video
.get('codec'),
99 'vbr': int_or_none(video
.get('bitrate'), 1000),
100 'width': int_or_none(resolution
.split('x')[0]),
101 'height': int_or_none(resolution
.split('x')[1]),
102 'format_id': url_basename(video
['url']).split('.')[0],
104 audio_url
= video_info
.get('audio_url')
109 'format_id': url_basename(audio_url
).split('.')[0],
112 manifest_url
= video_info
.get('transcoded_url')
114 formats
.extend(self
._extract
_m
3u8_formats
(
115 manifest_url
, video_id
, 'mp4', entry_protocol
='m3u8_native',
116 m3u8_id
='hls', fatal
=False))
118 comment_count
= int_or_none(video_info
.get('comment_count'))
120 user_info
= user_info
or traverse_obj(video_info
, 'user', default
={})
123 'id': str_or_none(video_id
) or video_uuid
,
124 'title': video_info
.get('description') or f
'Video by {username}',
125 'thumbnail': video_info
.get('thumbnail_url'),
126 'description': video_info
.get('description'),
127 'uploader': str_or_none(username
),
128 'uploader_id': str_or_none(user_info
.get('user_id')),
129 'creator': str_or_none(user_info
.get('name')),
130 'timestamp': unified_timestamp(video_info
.get('timestamp')),
131 'upload_date': unified_strdate(video_info
.get('timestamp')),
132 'duration': int_or_none(video_info
.get('duration')),
133 'view_count': int_or_none(video_info
.get('play_count')),
134 'like_count': int_or_none(video_info
.get('likes_count')),
135 'artist': str_or_none(video_info
.get('song_artist')),
136 'track': str_or_none(video_info
.get('song_title')),
137 'webpage_url': f
'https://triller.co/@{username}/video/{video_uuid}',
138 'uploader_url': f
'https://triller.co/@{username}',
139 'extractor_key': TrillerIE
.ie_key(),
140 'extractor': TrillerIE
.IE_NAME
,
142 'comment_count': comment_count
,
143 '__post_extractor': self
.extract_comments(video_id
, comment_count
),
147 class TrillerIE(TrillerBaseIE
):
148 _VALID_URL
= r
'''(?x)
149 https?://(?:www\.)?triller\.co/
150 @(?P<username>[\w\._]+)/video/
151 (?P<id>[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})
154 'url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
155 'md5': '228662d783923b60d78395fedddc0a20',
159 'title': 'md5:9a2bf9435c5c4292678996a464669416',
160 'thumbnail': r
're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
161 'description': 'md5:9a2bf9435c5c4292678996a464669416',
162 'uploader': 'theestallion',
163 'uploader_id': '18992236',
164 'creator': 'Megan Thee Stallion',
165 'timestamp': 1660598222,
166 'upload_date': '20220815',
172 'artist': 'Megan Thee Stallion',
174 'webpage_url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
175 'uploader_url': 'https://triller.co/@theestallion',
176 'comment_count': int,
179 'url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
180 'md5': '874055f462af5b0699b9dbb527a505a0',
184 'title': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
185 'thumbnail': r
're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
186 'description': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
187 'uploader': 'charlidamelio',
188 'uploader_id': '1875551',
189 'creator': 'charli damelio',
190 'timestamp': 1660773354,
191 'upload_date': '20220817',
198 'track': 'Someone to Blame',
199 'webpage_url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
200 'uploader_url': 'https://triller.co/@charlidamelio',
201 'comment_count': int,
205 def _real_extract(self
, url
):
206 username
, video_uuid
= self
._match
_valid
_url
(url
).group('username', 'id')
208 video_info
= traverse_obj(self
._download
_json
(
209 f
'{self._API_BASE_URL}/api/videos/{video_uuid}',
210 video_uuid
, note
='Downloading video info API JSON',
211 errnote
='Unable to download video info API JSON',
212 headers
=self
._API
_HEADERS
), ('videos', 0))
214 raise ExtractorError('No video info found in API response')
216 user_info
= self
._check
_user
_info
(video_info
.get('user') or {})
217 return self
._parse
_video
_info
(video_info
, username
, user_info
)
220 class TrillerUserIE(TrillerBaseIE
):
221 _VALID_URL
= r
'https?://(?:www\.)?triller\.co/@(?P<id>[\w\._]+)/?(?:$|[#?])'
223 # first videos request only returns 2 videos
224 'url': 'https://triller.co/@theestallion',
225 'playlist_mincount': 9,
228 'title': 'theestallion',
229 'thumbnail': r
're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
232 'url': 'https://triller.co/@charlidamelio',
233 'playlist_mincount': 25,
236 'title': 'charlidamelio',
237 'thumbnail': r
're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
241 def _real_initialize(self
):
242 if not self
._API
_HEADERS
.get('Authorization'):
243 guest
= self
._download
_json
(
244 f
'{self._API_BASE_URL}/user/create_guest',
245 None, note
='Creating guest session', data
=b
'', headers
=self
._API
_HEADERS
, query
={
249 if not guest
.get('auth_token'):
250 raise ExtractorError('Unable to fetch required auth token for user extraction')
252 self
._API
_HEADERS
['Authorization'] = f
'Bearer {guest["auth_token"]}'
254 def _extract_video_list(self
, username
, user_id
, limit
=6):
258 for page
in itertools
.count(1):
259 for retry
in self
.RetryManager():
261 video_list
= self
._download
_json
(
262 f
'{self._API_BASE_URL}/api/users/{user_id}/videos',
263 username
, note
=f
'Downloading user video list page {page}',
264 errnote
='Unable to download user video list', headers
=self
._API
_HEADERS
,
266 except ExtractorError
as e
:
267 if isinstance(e
.cause
, json
.JSONDecodeError
) and e
.cause
.pos
== 0:
271 if not video_list
.get('videos'):
273 yield from video_list
['videos']
274 query
['before_time'] = traverse_obj(video_list
, ('videos', -1, 'timestamp'))
275 if not query
['before_time']:
278 def _entries(self
, videos
, username
, user_info
):
280 yield self
._parse
_video
_info
(video
, username
, user_info
)
282 def _real_extract(self
, url
):
283 username
= self
._match
_id
(url
)
284 user_info
= self
._check
_user
_info
(self
._download
_json
(
285 f
'{self._API_BASE_URL}/api/users/by_username/{username}',
286 username
, note
='Downloading user info',
287 errnote
='Failed to download user info', headers
=self
._API
_HEADERS
).get('user', {}))
289 user_id
= str_or_none(user_info
.get('user_id'))
290 videos
= self
._extract
_video
_list
(username
, user_id
)
291 thumbnail
= user_info
.get('avatar_url')
293 return self
.playlist_result(
294 self
._entries
(videos
, username
, user_info
), user_id
, username
, thumbnail
=thumbnail
)