triller.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import itertools
  2. import json
  3. from .common import InfoExtractor
  4. from ..utils import (
  5. ExtractorError,
  6. int_or_none,
  7. str_or_none,
  8. traverse_obj,
  9. unified_strdate,
  10. unified_timestamp,
  11. url_basename,
  12. )
  13. class TrillerBaseIE(InfoExtractor):
  14. _NETRC_MACHINE = 'triller'
  15. _API_BASE_URL = 'https://social.triller.co/v1.5'
  16. _API_HEADERS = {'Origin': 'https://triller.co'}
  17. def _perform_login(self, username, password):
  18. if self._API_HEADERS.get('Authorization'):
  19. return
  20. user_check = self._download_json(
  21. f'{self._API_BASE_URL}/api/user/is-valid-username', None, note='Checking username',
  22. fatal=False, expected_status=400, headers={
  23. 'Content-Type': 'application/json',
  24. 'Origin': 'https://triller.co',
  25. }, data=json.dumps({'username': username}, separators=(',', ':')).encode('utf-8'))
  26. if user_check.get('status'): # endpoint returns "status":false if username exists
  27. raise ExtractorError('Unable to login: Invalid username', expected=True)
  28. credentials = {
  29. 'username': username,
  30. 'password': password,
  31. }
  32. login = self._download_json(
  33. f'{self._API_BASE_URL}/user/auth', None, note='Logging in',
  34. fatal=False, expected_status=400, headers={
  35. 'Content-Type': 'application/json',
  36. 'Origin': 'https://triller.co',
  37. }, data=json.dumps(credentials, separators=(',', ':')).encode('utf-8'))
  38. if not login.get('auth_token'):
  39. if login.get('error') == 1008:
  40. raise ExtractorError('Unable to login: Incorrect password', expected=True)
  41. raise ExtractorError('Unable to login')
  42. self._API_HEADERS['Authorization'] = f'Bearer {login["auth_token"]}'
  43. def _get_comments(self, video_id, limit=15):
  44. comment_info = self._download_json(
  45. f'{self._API_BASE_URL}/api/videos/{video_id}/comments_v2',
  46. video_id, fatal=False, note='Downloading comments API JSON',
  47. headers=self._API_HEADERS, query={'limit': limit}) or {}
  48. if not comment_info.get('comments'):
  49. return
  50. for comment_dict in comment_info['comments']:
  51. yield {
  52. 'author': traverse_obj(comment_dict, ('author', 'username')),
  53. 'author_id': traverse_obj(comment_dict, ('author', 'user_id')),
  54. 'id': comment_dict.get('id'),
  55. 'text': comment_dict.get('body'),
  56. 'timestamp': unified_timestamp(comment_dict.get('timestamp')),
  57. }
  58. def _check_user_info(self, user_info):
  59. if not user_info:
  60. self.report_warning('Unable to extract user info')
  61. elif user_info.get('private') and not user_info.get('followed_by_me'):
  62. raise ExtractorError('This video is private', expected=True)
  63. elif traverse_obj(user_info, 'blocked_by_user', 'blocking_user'):
  64. raise ExtractorError('The author of the video is blocked', expected=True)
  65. return user_info
  66. def _parse_video_info(self, video_info, username, user_info=None):
  67. video_uuid = video_info.get('video_uuid')
  68. video_id = video_info.get('id')
  69. formats = []
  70. video_url = traverse_obj(video_info, 'video_url', 'stream_url')
  71. if video_url:
  72. formats.append({
  73. 'url': video_url,
  74. 'ext': 'mp4',
  75. 'vcodec': 'h264',
  76. 'width': video_info.get('width'),
  77. 'height': video_info.get('height'),
  78. 'format_id': url_basename(video_url).split('.')[0],
  79. 'filesize': video_info.get('filesize'),
  80. })
  81. video_set = video_info.get('video_set') or []
  82. for video in video_set:
  83. resolution = video.get('resolution') or ''
  84. formats.append({
  85. 'url': video['url'],
  86. 'ext': 'mp4',
  87. 'vcodec': video.get('codec'),
  88. 'vbr': int_or_none(video.get('bitrate'), 1000),
  89. 'width': int_or_none(resolution.split('x')[0]),
  90. 'height': int_or_none(resolution.split('x')[1]),
  91. 'format_id': url_basename(video['url']).split('.')[0],
  92. })
  93. audio_url = video_info.get('audio_url')
  94. if audio_url:
  95. formats.append({
  96. 'url': audio_url,
  97. 'ext': 'm4a',
  98. 'format_id': url_basename(audio_url).split('.')[0],
  99. })
  100. manifest_url = video_info.get('transcoded_url')
  101. if manifest_url:
  102. formats.extend(self._extract_m3u8_formats(
  103. manifest_url, video_id, 'mp4', entry_protocol='m3u8_native',
  104. m3u8_id='hls', fatal=False))
  105. comment_count = int_or_none(video_info.get('comment_count'))
  106. user_info = user_info or traverse_obj(video_info, 'user', default={})
  107. return {
  108. 'id': str_or_none(video_id) or video_uuid,
  109. 'title': video_info.get('description') or f'Video by {username}',
  110. 'thumbnail': video_info.get('thumbnail_url'),
  111. 'description': video_info.get('description'),
  112. 'uploader': str_or_none(username),
  113. 'uploader_id': str_or_none(user_info.get('user_id')),
  114. 'creator': str_or_none(user_info.get('name')),
  115. 'timestamp': unified_timestamp(video_info.get('timestamp')),
  116. 'upload_date': unified_strdate(video_info.get('timestamp')),
  117. 'duration': int_or_none(video_info.get('duration')),
  118. 'view_count': int_or_none(video_info.get('play_count')),
  119. 'like_count': int_or_none(video_info.get('likes_count')),
  120. 'artist': str_or_none(video_info.get('song_artist')),
  121. 'track': str_or_none(video_info.get('song_title')),
  122. 'webpage_url': f'https://triller.co/@{username}/video/{video_uuid}',
  123. 'uploader_url': f'https://triller.co/@{username}',
  124. 'extractor_key': TrillerIE.ie_key(),
  125. 'extractor': TrillerIE.IE_NAME,
  126. 'formats': formats,
  127. 'comment_count': comment_count,
  128. '__post_extractor': self.extract_comments(video_id, comment_count),
  129. }
  130. class TrillerIE(TrillerBaseIE):
  131. _VALID_URL = r'''(?x)
  132. https?://(?:www\.)?triller\.co/
  133. @(?P<username>[\w\._]+)/video/
  134. (?P<id>[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})
  135. '''
  136. _TESTS = [{
  137. 'url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
  138. 'md5': '228662d783923b60d78395fedddc0a20',
  139. 'info_dict': {
  140. 'id': '71595734',
  141. 'ext': 'mp4',
  142. 'title': 'md5:9a2bf9435c5c4292678996a464669416',
  143. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  144. 'description': 'md5:9a2bf9435c5c4292678996a464669416',
  145. 'uploader': 'theestallion',
  146. 'uploader_id': '18992236',
  147. 'creator': 'Megan Thee Stallion',
  148. 'timestamp': 1660598222,
  149. 'upload_date': '20220815',
  150. 'duration': 47,
  151. 'height': 3840,
  152. 'width': 2160,
  153. 'view_count': int,
  154. 'like_count': int,
  155. 'artist': 'Megan Thee Stallion',
  156. 'track': 'Her',
  157. 'webpage_url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
  158. 'uploader_url': 'https://triller.co/@theestallion',
  159. 'comment_count': int,
  160. }
  161. }, {
  162. 'url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
  163. 'md5': '874055f462af5b0699b9dbb527a505a0',
  164. 'info_dict': {
  165. 'id': '71621339',
  166. 'ext': 'mp4',
  167. 'title': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
  168. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  169. 'description': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
  170. 'uploader': 'charlidamelio',
  171. 'uploader_id': '1875551',
  172. 'creator': 'charli damelio',
  173. 'timestamp': 1660773354,
  174. 'upload_date': '20220817',
  175. 'duration': 16,
  176. 'height': 1920,
  177. 'width': 1080,
  178. 'view_count': int,
  179. 'like_count': int,
  180. 'artist': 'Dixie',
  181. 'track': 'Someone to Blame',
  182. 'webpage_url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
  183. 'uploader_url': 'https://triller.co/@charlidamelio',
  184. 'comment_count': int,
  185. }
  186. }]
  187. def _real_extract(self, url):
  188. username, video_uuid = self._match_valid_url(url).group('username', 'id')
  189. video_info = traverse_obj(self._download_json(
  190. f'{self._API_BASE_URL}/api/videos/{video_uuid}',
  191. video_uuid, note='Downloading video info API JSON',
  192. errnote='Unable to download video info API JSON',
  193. headers=self._API_HEADERS), ('videos', 0))
  194. if not video_info:
  195. raise ExtractorError('No video info found in API response')
  196. user_info = self._check_user_info(video_info.get('user') or {})
  197. return self._parse_video_info(video_info, username, user_info)
  198. class TrillerUserIE(TrillerBaseIE):
  199. _VALID_URL = r'https?://(?:www\.)?triller\.co/@(?P<id>[\w\._]+)/?(?:$|[#?])'
  200. _TESTS = [{
  201. # first videos request only returns 2 videos
  202. 'url': 'https://triller.co/@theestallion',
  203. 'playlist_mincount': 9,
  204. 'info_dict': {
  205. 'id': '18992236',
  206. 'title': 'theestallion',
  207. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  208. }
  209. }, {
  210. 'url': 'https://triller.co/@charlidamelio',
  211. 'playlist_mincount': 25,
  212. 'info_dict': {
  213. 'id': '1875551',
  214. 'title': 'charlidamelio',
  215. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  216. }
  217. }]
  218. def _real_initialize(self):
  219. if not self._API_HEADERS.get('Authorization'):
  220. guest = self._download_json(
  221. f'{self._API_BASE_URL}/user/create_guest',
  222. None, note='Creating guest session', data=b'', headers=self._API_HEADERS, query={
  223. 'platform': 'Web',
  224. 'app_version': '',
  225. })
  226. if not guest.get('auth_token'):
  227. raise ExtractorError('Unable to fetch required auth token for user extraction')
  228. self._API_HEADERS['Authorization'] = f'Bearer {guest["auth_token"]}'
  229. def _extract_video_list(self, username, user_id, limit=6):
  230. query = {
  231. 'limit': limit,
  232. }
  233. for page in itertools.count(1):
  234. for retry in self.RetryManager():
  235. try:
  236. video_list = self._download_json(
  237. f'{self._API_BASE_URL}/api/users/{user_id}/videos',
  238. username, note=f'Downloading user video list page {page}',
  239. errnote='Unable to download user video list', headers=self._API_HEADERS,
  240. query=query)
  241. except ExtractorError as e:
  242. if isinstance(e.cause, json.JSONDecodeError) and e.cause.pos == 0:
  243. retry.error = e
  244. continue
  245. raise
  246. if not video_list.get('videos'):
  247. break
  248. yield from video_list['videos']
  249. query['before_time'] = traverse_obj(video_list, ('videos', -1, 'timestamp'))
  250. if not query['before_time']:
  251. break
  252. def _entries(self, videos, username, user_info):
  253. for video in videos:
  254. yield self._parse_video_info(video, username, user_info)
  255. def _real_extract(self, url):
  256. username = self._match_id(url)
  257. user_info = self._check_user_info(self._download_json(
  258. f'{self._API_BASE_URL}/api/users/by_username/{username}',
  259. username, note='Downloading user info',
  260. errnote='Failed to download user info', headers=self._API_HEADERS).get('user', {}))
  261. user_id = str_or_none(user_info.get('user_id'))
  262. videos = self._extract_video_list(username, user_id)
  263. thumbnail = user_info.get('avatar_url')
  264. return self.playlist_result(
  265. self._entries(videos, username, user_info), user_id, username, thumbnail=thumbnail)