triller.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import itertools
  2. import json
  3. import re
  4. from .common import InfoExtractor
  5. from ..networking import HEADRequest
  6. from ..utils import (
  7. ExtractorError,
  8. UnsupportedError,
  9. determine_ext,
  10. int_or_none,
  11. parse_resolution,
  12. str_or_none,
  13. traverse_obj,
  14. unified_timestamp,
  15. url_basename,
  16. url_or_none,
  17. urljoin,
  18. )
  19. class TrillerBaseIE(InfoExtractor):
  20. _NETRC_MACHINE = 'triller'
  21. _API_BASE_URL = 'https://social.triller.co/v1.5'
  22. _API_HEADERS = {'Origin': 'https://triller.co'}
  23. def _perform_login(self, username, password):
  24. if self._API_HEADERS.get('Authorization'):
  25. return
  26. headers = {**self._API_HEADERS, 'Content-Type': 'application/json'}
  27. user_check = traverse_obj(self._download_json(
  28. f'{self._API_BASE_URL}/api/user/is-valid-username', None, note='Checking username',
  29. fatal=False, expected_status=400, headers=headers,
  30. data=json.dumps({'username': username}, separators=(',', ':')).encode()), 'status')
  31. if user_check: # endpoint returns `"status":false` if username exists
  32. raise ExtractorError('Unable to login: Invalid username', expected=True)
  33. login = self._download_json(
  34. f'{self._API_BASE_URL}/user/auth', None, note='Logging in', fatal=False,
  35. expected_status=400, headers=headers, data=json.dumps({
  36. 'username': username,
  37. 'password': password,
  38. }, separators=(',', ':')).encode()) or {}
  39. if not login.get('auth_token'):
  40. if login.get('error') == 1008:
  41. raise ExtractorError('Unable to login: Incorrect password', expected=True)
  42. raise ExtractorError('Unable to login')
  43. self._API_HEADERS['Authorization'] = f'Bearer {login["auth_token"]}'
  44. def _get_comments(self, video_id, limit=15):
  45. comment_info = self._download_json(
  46. f'{self._API_BASE_URL}/api/videos/{video_id}/comments_v2',
  47. video_id, fatal=False, note='Downloading comments API JSON',
  48. headers=self._API_HEADERS, query={'limit': limit}) or {}
  49. if not comment_info.get('comments'):
  50. return
  51. yield from traverse_obj(comment_info, ('comments', ..., {
  52. 'id': ('id', {str_or_none}),
  53. 'text': 'body',
  54. 'author': ('author', 'username'),
  55. 'author_id': ('author', 'user_id'),
  56. 'timestamp': ('timestamp', {unified_timestamp}),
  57. }))
  58. def _parse_video_info(self, video_info, username, user_id, display_id=None):
  59. video_id = str(video_info['id'])
  60. display_id = display_id or video_info.get('video_uuid')
  61. if traverse_obj(video_info, (
  62. None, ('transcoded_url', 'video_url', 'stream_url', 'audio_url'),
  63. {lambda x: re.search(r'/copyright/', x)}), get_all=False):
  64. self.raise_no_formats('This video has been removed due to licensing restrictions', expected=True)
  65. def format_info(url):
  66. return {
  67. 'url': url,
  68. 'ext': determine_ext(url),
  69. 'format_id': url_basename(url).split('.')[0],
  70. }
  71. formats = []
  72. if determine_ext(video_info.get('transcoded_url')) == 'm3u8':
  73. formats.extend(self._extract_m3u8_formats(
  74. video_info['transcoded_url'], video_id, 'mp4', m3u8_id='hls', fatal=False))
  75. for video in traverse_obj(video_info, ('video_set', lambda _, v: url_or_none(v['url']))):
  76. formats.append({
  77. **format_info(video['url']),
  78. **parse_resolution(video.get('resolution')),
  79. 'vcodec': video.get('codec'),
  80. 'vbr': int_or_none(video.get('bitrate'), 1000),
  81. })
  82. video_url = traverse_obj(video_info, 'video_url', 'stream_url', expected_type=url_or_none)
  83. if video_url:
  84. formats.append({
  85. **format_info(video_url),
  86. 'vcodec': 'h264',
  87. **traverse_obj(video_info, {
  88. 'width': 'width',
  89. 'height': 'height',
  90. 'filesize': 'filesize',
  91. }, expected_type=int_or_none),
  92. })
  93. audio_url = url_or_none(video_info.get('audio_url'))
  94. if audio_url:
  95. formats.append(format_info(audio_url))
  96. comment_count = traverse_obj(video_info, ('comment_count', {int_or_none}))
  97. return {
  98. 'id': video_id,
  99. 'display_id': display_id,
  100. 'uploader': username,
  101. 'uploader_id': user_id or traverse_obj(video_info, ('user', 'user_id', {str_or_none})),
  102. 'webpage_url': urljoin(f'https://triller.co/@{username}/video/', display_id),
  103. 'uploader_url': f'https://triller.co/@{username}',
  104. 'extractor_key': TrillerIE.ie_key(),
  105. 'extractor': TrillerIE.IE_NAME,
  106. 'formats': formats,
  107. 'comment_count': comment_count,
  108. '__post_extractor': self.extract_comments(video_id, comment_count),
  109. **traverse_obj(video_info, {
  110. 'title': ('description', {lambda x: x.replace('\r\n', ' ')}),
  111. 'description': 'description',
  112. 'creator': ((('user'), ('users', lambda _, v: str(v['user_id']) == user_id)), 'name'),
  113. 'thumbnail': ('thumbnail_url', {url_or_none}),
  114. 'timestamp': ('timestamp', {unified_timestamp}),
  115. 'duration': ('duration', {int_or_none}),
  116. 'view_count': ('play_count', {int_or_none}),
  117. 'like_count': ('likes_count', {int_or_none}),
  118. 'artist': 'song_artist',
  119. 'track': 'song_title',
  120. }, get_all=False),
  121. }
  122. class TrillerIE(TrillerBaseIE):
  123. _VALID_URL = r'''(?x)
  124. https?://(?:www\.)?triller\.co/
  125. @(?P<username>[\w.]+)/video/(?P<id>[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12})
  126. '''
  127. _TESTS = [{
  128. 'url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
  129. 'md5': '228662d783923b60d78395fedddc0a20',
  130. 'info_dict': {
  131. 'id': '71595734',
  132. 'ext': 'mp4',
  133. 'title': 'md5:9a2bf9435c5c4292678996a464669416',
  134. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  135. 'description': 'md5:9a2bf9435c5c4292678996a464669416',
  136. 'uploader': 'theestallion',
  137. 'uploader_id': '18992236',
  138. 'creator': 'Megan Thee Stallion',
  139. 'timestamp': 1660598222,
  140. 'upload_date': '20220815',
  141. 'duration': 47,
  142. 'view_count': int,
  143. 'like_count': int,
  144. 'artist': 'Megan Thee Stallion',
  145. 'track': 'Her',
  146. 'uploader_url': 'https://triller.co/@theestallion',
  147. 'comment_count': int,
  148. },
  149. 'skip': 'This video has been removed due to licensing restrictions',
  150. }, {
  151. 'url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
  152. 'md5': '874055f462af5b0699b9dbb527a505a0',
  153. 'info_dict': {
  154. 'id': '71621339',
  155. 'ext': 'mp4',
  156. 'title': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
  157. 'display_id': '46c6fcfa-aa9e-4503-a50c-68444f44cddc',
  158. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  159. 'description': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
  160. 'uploader': 'charlidamelio',
  161. 'uploader_id': '1875551',
  162. 'creator': 'charli damelio',
  163. 'timestamp': 1660773354,
  164. 'upload_date': '20220817',
  165. 'duration': 16,
  166. 'view_count': int,
  167. 'like_count': int,
  168. 'artist': 'Dixie',
  169. 'track': 'Someone to Blame',
  170. 'uploader_url': 'https://triller.co/@charlidamelio',
  171. 'comment_count': int,
  172. },
  173. }, {
  174. 'url': 'https://triller.co/@theestallion/video/07f35f38-1f51-48e2-8c5f-f7a8e829988f',
  175. 'md5': 'af7b3553e4b8bfca507636471ee2eb41',
  176. 'info_dict': {
  177. 'id': '71837829',
  178. 'ext': 'mp4',
  179. 'title': 'UNGRATEFUL VIDEO OUT NOW ๐Ÿ‘๐Ÿพ๐Ÿ‘๐Ÿพ๐Ÿ‘๐Ÿพ ๐Ÿ’™๐Ÿ’™ link my bio #womeninhiphop',
  180. 'display_id': '07f35f38-1f51-48e2-8c5f-f7a8e829988f',
  181. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  182. 'description': 'UNGRATEFUL VIDEO OUT NOW ๐Ÿ‘๐Ÿพ๐Ÿ‘๐Ÿพ๐Ÿ‘๐Ÿพ ๐Ÿ’™๐Ÿ’™ link my bio\r\n #womeninhiphop',
  183. 'uploader': 'theestallion',
  184. 'uploader_id': '18992236',
  185. 'creator': 'Megan Thee Stallion',
  186. 'timestamp': 1662486178,
  187. 'upload_date': '20220906',
  188. 'duration': 30,
  189. 'view_count': int,
  190. 'like_count': int,
  191. 'artist': 'Unknown',
  192. 'track': 'Unknown',
  193. 'uploader_url': 'https://triller.co/@theestallion',
  194. 'comment_count': int,
  195. },
  196. }]
  197. def _real_extract(self, url):
  198. username, display_id = self._match_valid_url(url).group('username', 'id')
  199. video_info = self._download_json(
  200. f'{self._API_BASE_URL}/api/videos/{display_id}', display_id,
  201. headers=self._API_HEADERS)['videos'][0]
  202. return self._parse_video_info(video_info, username, None, display_id)
  203. class TrillerUserIE(TrillerBaseIE):
  204. _VALID_URL = r'https?://(?:www\.)?triller\.co/@(?P<id>[\w.]+)/?(?:$|[#?])'
  205. _TESTS = [{
  206. 'url': 'https://triller.co/@theestallion',
  207. 'playlist_mincount': 12,
  208. 'info_dict': {
  209. 'id': '18992236',
  210. 'title': 'theestallion',
  211. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  212. },
  213. }, {
  214. 'url': 'https://triller.co/@charlidamelio',
  215. 'playlist_mincount': 150,
  216. 'info_dict': {
  217. 'id': '1875551',
  218. 'title': 'charlidamelio',
  219. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  220. },
  221. }]
  222. def _real_initialize(self):
  223. if not self._API_HEADERS.get('Authorization'):
  224. guest = self._download_json(
  225. f'{self._API_BASE_URL}/user/create_guest', None,
  226. note='Creating guest session', data=b'', headers=self._API_HEADERS, query={
  227. 'platform': 'Web',
  228. 'app_version': '',
  229. })
  230. if not guest.get('auth_token'):
  231. raise ExtractorError('Unable to fetch required auth token for user extraction')
  232. self._API_HEADERS['Authorization'] = f'Bearer {guest["auth_token"]}'
  233. def _entries(self, username, user_id, limit=6):
  234. query = {'limit': limit}
  235. for page in itertools.count(1):
  236. videos = 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. headers=self._API_HEADERS, query=query)
  240. for video in traverse_obj(videos, ('videos', ...)):
  241. yield self._parse_video_info(video, username, user_id)
  242. query['before_time'] = traverse_obj(videos, ('videos', -1, 'timestamp'))
  243. if not query['before_time']:
  244. break
  245. def _real_extract(self, url):
  246. username = self._match_id(url)
  247. user_info = traverse_obj(self._download_json(
  248. f'{self._API_BASE_URL}/api/users/by_username/{username}',
  249. username, note='Downloading user info', headers=self._API_HEADERS), ('user', {dict})) or {}
  250. if user_info.get('private') and user_info.get('followed_by_me') not in (True, 'true'):
  251. raise ExtractorError('This user profile is private', expected=True)
  252. elif traverse_obj(user_info, (('blocked_by_user', 'blocking_user'), {bool}), get_all=False):
  253. raise ExtractorError('The author of the video is blocked', expected=True)
  254. user_id = str_or_none(user_info.get('user_id'))
  255. if not user_id:
  256. raise ExtractorError('Unable to extract user ID')
  257. return self.playlist_result(
  258. self._entries(username, user_id), user_id, username, thumbnail=user_info.get('avatar_url'))
  259. class TrillerShortIE(InfoExtractor):
  260. _VALID_URL = r'https?://v\.triller\.co/(?P<id>\w+)'
  261. _TESTS = [{
  262. 'url': 'https://v.triller.co/WWZNWk',
  263. 'md5': '5eb8dc2c971bd8cd794ec9e8d5e9d101',
  264. 'info_dict': {
  265. 'id': '66210052',
  266. 'ext': 'mp4',
  267. 'title': 'md5:2dfc89d154cd91a4a18cd9582ba03e16',
  268. 'display_id': 'f4480e1f-fb4e-45b9-a44c-9e6c679ce7eb',
  269. 'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
  270. 'description': 'md5:2dfc89d154cd91a4a18cd9582ba03e16',
  271. 'uploader': 'statefairent',
  272. 'uploader_id': '487545193',
  273. 'creator': 'Officialย Summerย Fairย ofย LA',
  274. 'timestamp': 1629655457,
  275. 'upload_date': '20210822',
  276. 'duration': 19,
  277. 'view_count': int,
  278. 'like_count': int,
  279. 'artist': 'Unknown',
  280. 'track': 'Unknown',
  281. 'uploader_url': 'https://triller.co/@statefairent',
  282. 'comment_count': int,
  283. },
  284. }]
  285. def _real_extract(self, url):
  286. real_url = self._request_webpage(HEADRequest(url), self._match_id(url)).url
  287. if self.suitable(real_url): # Prevent infinite loop in case redirect fails
  288. raise UnsupportedError(real_url)
  289. return self.url_result(real_url)