trovo.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import itertools
  2. import json
  3. import random
  4. import string
  5. from .common import InfoExtractor
  6. from ..utils import (
  7. ExtractorError,
  8. format_field,
  9. int_or_none,
  10. str_or_none,
  11. traverse_obj,
  12. try_get,
  13. )
  14. class TrovoBaseIE(InfoExtractor):
  15. _VALID_URL_BASE = r'https?://(?:www\.)?trovo\.live/'
  16. _HEADERS = {'Origin': 'https://trovo.live'}
  17. def _call_api(self, video_id, data):
  18. if 'persistedQuery' in data.get('extensions', {}):
  19. url = 'https://gql.trovo.live'
  20. else:
  21. url = 'https://api-web.trovo.live/graphql'
  22. resp = self._download_json(
  23. url, video_id, data=json.dumps([data]).encode(), headers={'Accept': 'application/json'},
  24. query={
  25. 'qid': ''.join(random.choices(string.ascii_uppercase + string.digits, k=16)),
  26. })[0]
  27. if 'errors' in resp:
  28. raise ExtractorError(f'Trovo said: {resp["errors"][0]["message"]}')
  29. return resp['data'][data['operationName']]
  30. def _extract_streamer_info(self, data):
  31. streamer_info = data.get('streamerInfo') or {}
  32. username = streamer_info.get('userName')
  33. return {
  34. 'uploader': streamer_info.get('nickName'),
  35. 'uploader_id': str_or_none(streamer_info.get('uid')),
  36. 'uploader_url': format_field(username, None, 'https://trovo.live/%s'),
  37. }
  38. class TrovoIE(TrovoBaseIE):
  39. _VALID_URL = TrovoBaseIE._VALID_URL_BASE + r'(?:s/)?(?!(?:clip|video)/)(?P<id>(?!s/)[^/?&#]+(?![^#]+[?&]vid=))'
  40. _TESTS = [{
  41. 'url': 'https://trovo.live/Exsl',
  42. 'only_matching': True,
  43. }, {
  44. 'url': 'https://trovo.live/s/SkenonSLive/549759191497',
  45. 'only_matching': True,
  46. }, {
  47. 'url': 'https://trovo.live/s/zijo987/208251706',
  48. 'info_dict': {
  49. 'id': '104125853_104125853_1656439572',
  50. 'ext': 'flv',
  51. 'uploader_url': 'https://trovo.live/zijo987',
  52. 'uploader_id': '104125853',
  53. 'thumbnail': 'https://livecover.trovo.live/screenshot/73846_104125853_104125853-2022-06-29-04-00-22-852x480.jpg',
  54. 'uploader': 'zijo987',
  55. 'title': '💥IGRAMO IGRICE UPADAJTE💥2500/5000 2022-06-28 22:01',
  56. 'live_status': 'is_live',
  57. },
  58. 'skip': 'May not be live',
  59. }]
  60. def _real_extract(self, url):
  61. username = self._match_id(url)
  62. live_info = self._call_api(username, data={
  63. 'operationName': 'live_LiveReaderService_GetLiveInfo',
  64. 'variables': {
  65. 'params': {
  66. 'userName': username,
  67. },
  68. },
  69. })
  70. if live_info.get('isLive') == 0:
  71. raise ExtractorError(f'{username} is offline', expected=True)
  72. program_info = live_info['programInfo']
  73. program_id = program_info['id']
  74. title = program_info['title']
  75. formats = []
  76. for stream_info in (program_info.get('streamInfo') or []):
  77. play_url = stream_info.get('playUrl')
  78. if not play_url:
  79. continue
  80. format_id = stream_info.get('desc')
  81. formats.append({
  82. 'format_id': format_id,
  83. 'height': int_or_none(format_id[:-1]) if format_id else None,
  84. 'url': play_url,
  85. 'tbr': stream_info.get('bitrate'),
  86. 'http_headers': self._HEADERS,
  87. })
  88. info = {
  89. 'id': program_id,
  90. 'title': title,
  91. 'formats': formats,
  92. 'thumbnail': program_info.get('coverUrl'),
  93. 'is_live': True,
  94. }
  95. info.update(self._extract_streamer_info(live_info))
  96. return info
  97. class TrovoVodIE(TrovoBaseIE):
  98. _VALID_URL = TrovoBaseIE._VALID_URL_BASE + r'(?:clip|video|s)/(?:[^/]+/\d+[^#]*[?&]vid=)?(?P<id>(?<!/s/)[^/?&#]+)'
  99. _TESTS = [{
  100. 'url': 'https://trovo.live/clip/lc-5285890818705062210?ltab=videos',
  101. 'params': {'getcomments': True},
  102. 'info_dict': {
  103. 'id': 'lc-5285890818705062210',
  104. 'ext': 'mp4',
  105. 'title': 'fatal moaning for a super good🤣🤣',
  106. 'uploader': 'OneTappedYou',
  107. 'timestamp': 1621628019,
  108. 'upload_date': '20210521',
  109. 'uploader_id': '100719456',
  110. 'duration': 31,
  111. 'view_count': int,
  112. 'like_count': int,
  113. 'comment_count': int,
  114. 'comments': 'mincount:1',
  115. 'categories': ['Call of Duty: Mobile'],
  116. 'uploader_url': 'https://trovo.live/OneTappedYou',
  117. 'thumbnail': r're:^https?://.*\.jpg',
  118. },
  119. }, {
  120. 'url': 'https://trovo.live/s/SkenonSLive/549759191497?vid=ltv-100829718_100829718_387702301737980280',
  121. 'info_dict': {
  122. 'id': 'ltv-100829718_100829718_387702301737980280',
  123. 'ext': 'mp4',
  124. 'timestamp': 1654909624,
  125. 'thumbnail': 'http://vod.trovo.live/1f09baf0vodtransger1301120758/ef9ea3f0387702301737980280/coverBySnapshot/coverBySnapshot_10_0.jpg',
  126. 'uploader_id': '100829718',
  127. 'uploader': 'SkenonSLive',
  128. 'title': 'Trovo u secanju, uz par modova i muzike :)',
  129. 'uploader_url': 'https://trovo.live/SkenonSLive',
  130. 'duration': 10830,
  131. 'view_count': int,
  132. 'like_count': int,
  133. 'upload_date': '20220611',
  134. 'comment_count': int,
  135. 'categories': ['Minecraft'],
  136. },
  137. 'skip': 'Not available',
  138. }, {
  139. 'url': 'https://trovo.live/s/Trovo/549756886599?vid=ltv-100264059_100264059_387702304241698583',
  140. 'info_dict': {
  141. 'id': 'ltv-100264059_100264059_387702304241698583',
  142. 'ext': 'mp4',
  143. 'timestamp': 1661479563,
  144. 'thumbnail': 'http://vod.trovo.live/be5ae591vodtransusw1301120758/cccb9915387702304241698583/coverBySnapshot/coverBySnapshot_10_0.jpg',
  145. 'uploader_id': '100264059',
  146. 'uploader': 'Trovo',
  147. 'title': 'Dev Corner 8/25',
  148. 'uploader_url': 'https://trovo.live/Trovo',
  149. 'duration': 3753,
  150. 'view_count': int,
  151. 'like_count': int,
  152. 'upload_date': '20220826',
  153. 'comment_count': int,
  154. 'categories': ['Talk Shows'],
  155. },
  156. }, {
  157. 'url': 'https://trovo.live/video/ltv-100095501_100095501_1609596043',
  158. 'only_matching': True,
  159. }, {
  160. 'url': 'https://trovo.live/s/SkenonSLive/549759191497?foo=bar&vid=ltv-100829718_100829718_387702301737980280',
  161. 'only_matching': True,
  162. }]
  163. def _real_extract(self, url):
  164. vid = self._match_id(url)
  165. # NOTE: It is also possible to extract this info from the Nuxt data on the website,
  166. # however that seems unreliable - sometimes it randomly doesn't return the data,
  167. # at least when using a non-residential IP.
  168. resp = self._call_api(vid, data={
  169. 'operationName': 'vod_VodReaderService_BatchGetVodDetailInfo',
  170. 'variables': {
  171. 'params': {
  172. 'vids': [vid],
  173. },
  174. },
  175. 'extensions': {},
  176. })
  177. vod_detail_info = traverse_obj(resp, ('VodDetailInfos', vid), expected_type=dict)
  178. if not vod_detail_info:
  179. raise ExtractorError('This video not found or not available anymore', expected=True)
  180. vod_info = vod_detail_info.get('vodInfo')
  181. title = vod_info.get('title')
  182. if try_get(vod_info, lambda x: x['playbackRights']['playbackRights'] != 'Normal'):
  183. playback_rights_setting = vod_info['playbackRights']['playbackRightsSetting']
  184. if playback_rights_setting == 'SubscriberOnly':
  185. raise ExtractorError('This video is only available for subscribers', expected=True)
  186. else:
  187. raise ExtractorError(f'This video is not available ({playback_rights_setting})', expected=True)
  188. language = vod_info.get('languageName')
  189. formats = []
  190. for play_info in (vod_info.get('playInfos') or []):
  191. play_url = play_info.get('playUrl')
  192. if not play_url:
  193. continue
  194. format_id = play_info.get('desc')
  195. formats.append({
  196. 'ext': 'mp4',
  197. 'filesize': int_or_none(play_info.get('fileSize')),
  198. 'format_id': format_id,
  199. 'height': int_or_none(format_id[:-1]) if format_id else None,
  200. 'language': language,
  201. 'protocol': 'm3u8_native',
  202. 'tbr': int_or_none(play_info.get('bitrate')),
  203. 'url': play_url,
  204. 'http_headers': self._HEADERS,
  205. })
  206. category = vod_info.get('categoryName')
  207. get_count = lambda x: int_or_none(vod_info.get(x + 'Num'))
  208. info = {
  209. 'id': vid,
  210. 'title': title,
  211. 'formats': formats,
  212. 'thumbnail': vod_info.get('coverUrl'),
  213. 'timestamp': int_or_none(vod_info.get('publishTs')),
  214. 'duration': int_or_none(vod_info.get('duration')),
  215. 'view_count': get_count('watch'),
  216. 'like_count': get_count('like'),
  217. 'comment_count': get_count('comment'),
  218. 'categories': [category] if category else None,
  219. '__post_extractor': self.extract_comments(vid),
  220. }
  221. info.update(self._extract_streamer_info(vod_detail_info))
  222. return info
  223. def _get_comments(self, vid):
  224. for page in itertools.count(1):
  225. comments_json = self._call_api(vid, data={
  226. 'operationName': 'public_CommentProxyService_GetCommentList',
  227. 'variables': {
  228. 'params': {
  229. 'appInfo': {
  230. 'postID': vid,
  231. },
  232. 'preview': {},
  233. 'pageSize': 99,
  234. 'page': page,
  235. },
  236. },
  237. 'extensions': {
  238. 'singleReq': 'true',
  239. },
  240. })
  241. for comment in comments_json['commentList']:
  242. content = comment.get('content')
  243. if not content:
  244. continue
  245. author = comment.get('author') or {}
  246. parent = comment.get('parentID')
  247. yield {
  248. 'author': author.get('nickName'),
  249. 'author_id': str_or_none(author.get('uid')),
  250. 'id': str_or_none(comment.get('commentID')),
  251. 'text': content,
  252. 'timestamp': int_or_none(comment.get('createdAt')),
  253. 'parent': 'root' if parent == 0 else str_or_none(parent),
  254. }
  255. if comments_json['lastPage']:
  256. break
  257. class TrovoChannelBaseIE(TrovoBaseIE):
  258. def _entries(self, spacename):
  259. for page in itertools.count(1):
  260. vod_json = self._call_api(spacename, data={
  261. 'operationName': self._OPERATION,
  262. 'variables': {
  263. 'params': {
  264. 'terminalSpaceID': {
  265. 'spaceName': spacename,
  266. },
  267. 'currPage': page,
  268. 'pageSize': 99,
  269. },
  270. },
  271. 'extensions': {
  272. 'singleReq': 'true',
  273. },
  274. })
  275. vods = vod_json.get('vodInfos', [])
  276. for vod in vods:
  277. vid = vod.get('vid')
  278. room = traverse_obj(vod, ('spaceInfo', 'roomID'))
  279. yield self.url_result(
  280. f'https://trovo.live/s/{spacename}/{room}?vid={vid}',
  281. ie=TrovoVodIE.ie_key())
  282. has_more = vod_json.get('hasMore')
  283. if not has_more:
  284. break
  285. def _real_extract(self, url):
  286. spacename = self._match_id(url)
  287. return self.playlist_result(self._entries(spacename), playlist_id=spacename)
  288. class TrovoChannelVodIE(TrovoChannelBaseIE):
  289. _VALID_URL = r'trovovod:(?P<id>[^\s]+)'
  290. IE_DESC = 'All VODs of a trovo.live channel; "trovovod:" prefix'
  291. _TESTS = [{
  292. 'url': 'trovovod:OneTappedYou',
  293. 'playlist_mincount': 24,
  294. 'info_dict': {
  295. 'id': 'OneTappedYou',
  296. },
  297. }]
  298. _OPERATION = 'vod_VodReaderService_GetChannelLtvVideoInfos'
  299. class TrovoChannelClipIE(TrovoChannelBaseIE):
  300. _VALID_URL = r'trovoclip:(?P<id>[^\s]+)'
  301. IE_DESC = 'All Clips of a trovo.live channel; "trovoclip:" prefix'
  302. _TESTS = [{
  303. 'url': 'trovoclip:OneTappedYou',
  304. 'playlist_mincount': 29,
  305. 'info_dict': {
  306. 'id': 'OneTappedYou',
  307. },
  308. }]
  309. _OPERATION = 'vod_VodReaderService_GetChannelClipVideoInfos'