]> jfr.im git - yt-dlp.git/blame - yt_dlp/extractor/trovo.py
[extractor/tencent] Add Iflix extractor (#4829)
[yt-dlp.git] / yt_dlp / extractor / trovo.py
CommitLineData
974208e1 1import itertools
a820dc72 2import json
6ef5ad9e 3import random
4import string
a820dc72
RA
5
6from .common import InfoExtractor
7from ..utils import (
8 ExtractorError,
e0ddbd02 9 format_field,
a820dc72
RA
10 int_or_none,
11 str_or_none,
12 try_get,
13)
14
15
16class TrovoBaseIE(InfoExtractor):
17 _VALID_URL_BASE = r'https?://(?:www\.)?trovo\.live/'
36147a63 18 _HEADERS = {'Origin': 'https://trovo.live'}
a820dc72 19
6ef5ad9e 20 def _call_api(self, video_id, data):
21 if 'persistedQuery' in data.get('extensions', {}):
22 url = 'https://gql.trovo.live'
23 else:
24 url = 'https://api-web.trovo.live/graphql'
25
26 resp = self._download_json(
27 url, video_id, data=json.dumps([data]).encode(), headers={'Accept': 'application/json'},
28 query={
29 'qid': ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)),
30 })[0]
31 if 'errors' in resp:
32 raise ExtractorError(f'Trovo said: {resp["errors"][0]["message"]}')
33 return resp['data'][data['operationName']]
0cbed930 34
a820dc72
RA
35 def _extract_streamer_info(self, data):
36 streamer_info = data.get('streamerInfo') or {}
37 username = streamer_info.get('userName')
38 return {
39 'uploader': streamer_info.get('nickName'),
40 'uploader_id': str_or_none(streamer_info.get('uid')),
a70635b8 41 'uploader_url': format_field(username, None, 'https://trovo.live/%s'),
a820dc72
RA
42 }
43
44
45class TrovoIE(TrovoBaseIE):
660c0c4e 46 _VALID_URL = TrovoBaseIE._VALID_URL_BASE + r'(?:s/)?(?!(?:clip|video)/)(?P<id>(?!s/)[^/?&#]+(?![^#]+[?&]vid=))'
47 _TESTS = [{
48 'url': 'https://trovo.live/Exsl',
49 'only_matching': True,
50 }, {
51 'url': 'https://trovo.live/s/SkenonSLive/549759191497',
52 'only_matching': True,
53 }, {
54 'url': 'https://trovo.live/s/zijo987/208251706',
55 'info_dict': {
56 'id': '104125853_104125853_1656439572',
57 'ext': 'flv',
58 'uploader_url': 'https://trovo.live/zijo987',
59 'uploader_id': '104125853',
60 'thumbnail': 'https://livecover.trovo.live/screenshot/73846_104125853_104125853-2022-06-29-04-00-22-852x480.jpg',
61 'uploader': 'zijo987',
62 'title': 'šŸ’„IGRAMO IGRICE UPADAJTEšŸ’„2500/5000 2022-06-28 22:01',
63 'live_status': 'is_live',
64 },
65 'skip': 'May not be live'
66 }]
a820dc72
RA
67
68 def _real_extract(self, url):
69 username = self._match_id(url)
6ef5ad9e 70 live_info = self._call_api(username, data={
71 'operationName': 'live_LiveReaderService_GetLiveInfo',
72 'variables': {
73 'params': {
74 'userName': username,
75 },
76 },
77 })
a820dc72
RA
78 if live_info.get('isLive') == 0:
79 raise ExtractorError('%s is offline' % username, expected=True)
80 program_info = live_info['programInfo']
81 program_id = program_info['id']
39ca3b5c 82 title = program_info['title']
a820dc72
RA
83
84 formats = []
85 for stream_info in (program_info.get('streamInfo') or []):
86 play_url = stream_info.get('playUrl')
87 if not play_url:
88 continue
89 format_id = stream_info.get('desc')
90 formats.append({
91 'format_id': format_id,
92 'height': int_or_none(format_id[:-1]) if format_id else None,
93 'url': play_url,
660c0c4e 94 'tbr': stream_info.get('bitrate'),
36147a63 95 'http_headers': self._HEADERS,
a820dc72
RA
96 })
97 self._sort_formats(formats)
98
99 info = {
100 'id': program_id,
101 'title': title,
102 'formats': formats,
103 'thumbnail': program_info.get('coverUrl'),
104 'is_live': True,
105 }
106 info.update(self._extract_streamer_info(live_info))
107 return info
108
109
110class TrovoVodIE(TrovoBaseIE):
660c0c4e 111 _VALID_URL = TrovoBaseIE._VALID_URL_BASE + r'(?:clip|video|s)/(?:[^/]+/\d+[^#]*[?&]vid=)?(?P<id>(?<!/s/)[^/?&#]+)'
a820dc72 112 _TESTS = [{
6ef5ad9e 113 'url': 'https://trovo.live/clip/lc-5285890818705062210?ltab=videos',
114 'params': {'getcomments': True},
a820dc72 115 'info_dict': {
6ef5ad9e 116 'id': 'lc-5285890818705062210',
a820dc72 117 'ext': 'mp4',
6ef5ad9e 118 'title': 'fatal moaning for a super goodšŸ¤£šŸ¤£',
119 'uploader': 'OneTappedYou',
120 'timestamp': 1621628019,
121 'upload_date': '20210521',
122 'uploader_id': '100719456',
123 'duration': 31,
a820dc72
RA
124 'view_count': int,
125 'like_count': int,
126 'comment_count': int,
6ef5ad9e 127 'comments': 'mincount:1',
128 'categories': ['Call of Duty: Mobile'],
129 'uploader_url': 'https://trovo.live/OneTappedYou',
130 'thumbnail': r're:^https?://.*\.jpg',
a820dc72 131 },
660c0c4e 132 }, {
133 'url': 'https://trovo.live/s/SkenonSLive/549759191497?vid=ltv-100829718_100829718_387702301737980280',
134 'info_dict': {
135 'id': 'ltv-100829718_100829718_387702301737980280',
136 'ext': 'mp4',
137 'timestamp': 1654909624,
138 'thumbnail': 'http://vod.trovo.live/1f09baf0vodtransger1301120758/ef9ea3f0387702301737980280/coverBySnapshot/coverBySnapshot_10_0.jpg',
139 'uploader_id': '100829718',
140 'uploader': 'SkenonSLive',
141 'title': 'Trovo u secanju, uz par modova i muzike :)',
142 'uploader_url': 'https://trovo.live/SkenonSLive',
143 'duration': 10830,
144 'view_count': int,
145 'like_count': int,
146 'upload_date': '20220611',
147 'comment_count': int,
148 'categories': ['Minecraft'],
149 }
a820dc72 150 }, {
6ef5ad9e 151 'url': 'https://trovo.live/video/ltv-100095501_100095501_1609596043',
a820dc72 152 'only_matching': True,
660c0c4e 153 }, {
154 'url': 'https://trovo.live/s/SkenonSLive/549759191497?foo=bar&vid=ltv-100829718_100829718_387702301737980280',
155 'only_matching': True,
a820dc72
RA
156 }]
157
158 def _real_extract(self, url):
159 vid = self._match_id(url)
6ef5ad9e 160
161 # NOTE: It is also possible to extract this info from the Nuxt data on the website,
162 # however that seems unreliable - sometimes it randomly doesn't return the data,
163 # at least when using a non-residential IP.
164 resp = self._call_api(vid, data={
165 'operationName': 'batchGetVodDetailInfo',
166 'variables': {
167 'params': {
168 'vids': [vid],
169 },
170 },
171 'extensions': {
172 'persistedQuery': {
173 'version': 1,
174 'sha256Hash': 'ceae0355d66476e21a1dd8e8af9f68de95b4019da2cda8b177c9a2255dad31d0',
175 },
176 },
177 })
178 vod_detail_info = resp['VodDetailInfos'][vid]
a820dc72
RA
179 vod_info = vod_detail_info['vodInfo']
180 title = vod_info['title']
181
6ef5ad9e 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
a820dc72
RA
189 language = vod_info.get('languageName')
190 formats = []
191 for play_info in (vod_info.get('playInfos') or []):
192 play_url = play_info.get('playUrl')
193 if not play_url:
194 continue
195 format_id = play_info.get('desc')
196 formats.append({
197 'ext': 'mp4',
198 'filesize': int_or_none(play_info.get('fileSize')),
199 'format_id': format_id,
200 'height': int_or_none(format_id[:-1]) if format_id else None,
201 'language': language,
202 'protocol': 'm3u8_native',
203 'tbr': int_or_none(play_info.get('bitrate')),
204 'url': play_url,
36147a63 205 'http_headers': self._HEADERS,
a820dc72
RA
206 })
207 self._sort_formats(formats)
208
209 category = vod_info.get('categoryName')
210 get_count = lambda x: int_or_none(vod_info.get(x + 'Num'))
211
a820dc72
RA
212 info = {
213 'id': vid,
214 'title': title,
215 'formats': formats,
216 'thumbnail': vod_info.get('coverUrl'),
217 'timestamp': int_or_none(vod_info.get('publishTs')),
218 'duration': int_or_none(vod_info.get('duration')),
219 'view_count': get_count('watch'),
220 'like_count': get_count('like'),
221 'comment_count': get_count('comment'),
a820dc72 222 'categories': [category] if category else None,
6ef5ad9e 223 '__post_extractor': self.extract_comments(vid),
a820dc72
RA
224 }
225 info.update(self._extract_streamer_info(vod_detail_info))
226 return info
974208e1 227
6ef5ad9e 228 def _get_comments(self, vid):
229 for page in itertools.count(1):
230 comments_json = self._call_api(vid, data={
231 'operationName': 'getCommentList',
232 'variables': {
233 'params': {
234 'appInfo': {
235 'postID': vid,
236 },
237 'preview': {},
238 'pageSize': 99,
239 'page': page,
240 },
241 },
242 'extensions': {
243 'persistedQuery': {
244 'version': 1,
245 'sha256Hash': 'be8e5f9522ddac7f7c604c0d284fd22481813263580849926c4c66fb767eed25',
246 },
247 },
248 })
249 for comment in comments_json['commentList']:
250 content = comment.get('content')
251 if not content:
252 continue
253 author = comment.get('author') or {}
254 parent = comment.get('parentID')
255 yield {
256 'author': author.get('nickName'),
257 'author_id': str_or_none(author.get('uid')),
258 'id': str_or_none(comment.get('commentID')),
259 'text': content,
260 'timestamp': int_or_none(comment.get('createdAt')),
261 'parent': 'root' if parent == 0 else str_or_none(parent),
262 }
263
264 if comments_json['lastPage']:
265 break
266
974208e1 267
3262f8ab 268class TrovoChannelBaseIE(TrovoBaseIE):
974208e1
AG
269 def _get_vod_json(self, page, uid):
270 raise NotImplementedError('This method must be implemented by subclasses')
271
272 def _entries(self, uid):
273 for page in itertools.count(1):
274 vod_json = self._get_vod_json(page, uid)
275 vods = vod_json.get('vodInfos', [])
276 for vod in vods:
277 yield self.url_result(
278 'https://trovo.live/%s/%s' % (self._TYPE, vod.get('vid')),
279 ie=TrovoVodIE.ie_key())
280 has_more = vod_json['hasMore']
281 if not has_more:
282 break
283
284 def _real_extract(self, url):
285 id = self._match_id(url)
6ef5ad9e 286 live_info = self._call_api(id, data={
287 'operationName': 'live_LiveReaderService_GetLiveInfo',
288 'variables': {
289 'params': {
290 'userName': id,
291 },
292 },
293 })
294 uid = str(live_info['streamerInfo']['uid'])
974208e1
AG
295 return self.playlist_result(self._entries(uid), playlist_id=uid)
296
297
298class TrovoChannelVodIE(TrovoChannelBaseIE):
299 _VALID_URL = r'trovovod:(?P<id>[^\s]+)'
96565c7e 300 IE_DESC = 'All VODs of a trovo.live channel; "trovovod:" prefix'
974208e1
AG
301
302 _TESTS = [{
303 'url': 'trovovod:OneTappedYou',
304 'playlist_mincount': 24,
305 'info_dict': {
306 'id': '100719456',
307 },
308 }]
309
974208e1
AG
310 _TYPE = 'video'
311
312 def _get_vod_json(self, page, uid):
6ef5ad9e 313 return self._call_api(uid, data={
314 'operationName': 'getChannelLtvVideoInfos',
315 'variables': {
316 'params': {
317 'channelID': int(uid),
318 'pageSize': 99,
319 'currPage': page,
320 },
321 },
322 'extensions': {
323 'persistedQuery': {
324 'version': 1,
325 'sha256Hash': '78fe32792005eab7e922cafcdad9c56bed8bbc5f5df3c7cd24fcb84a744f5f78',
326 },
327 },
328 })
974208e1
AG
329
330
331class TrovoChannelClipIE(TrovoChannelBaseIE):
332 _VALID_URL = r'trovoclip:(?P<id>[^\s]+)'
96565c7e 333 IE_DESC = 'All Clips of a trovo.live channel; "trovoclip:" prefix'
974208e1
AG
334
335 _TESTS = [{
336 'url': 'trovoclip:OneTappedYou',
337 'playlist_mincount': 29,
338 'info_dict': {
339 'id': '100719456',
340 },
341 }]
342
974208e1
AG
343 _TYPE = 'clip'
344
345 def _get_vod_json(self, page, uid):
6ef5ad9e 346 return self._call_api(uid, data={
347 'operationName': 'getChannelClipVideoInfos',
348 'variables': {
349 'params': {
350 'channelID': int(uid),
351 'pageSize': 99,
352 'currPage': page,
353 },
354 },
355 'extensions': {
356 'persistedQuery': {
357 'version': 1,
358 'sha256Hash': 'e7924bfe20059b5c75fc8ff9e7929f43635681a7bdf3befa01072ed22c8eff31',
359 },
360 },
361 })