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