]> jfr.im git - yt-dlp.git/blob - yt_dlp/extractor/twitch.py
Completely change project name to yt-dlp (#85)
[yt-dlp.git] / yt_dlp / extractor / twitch.py
1 # coding: utf-8
2 from __future__ import unicode_literals
3
4 import collections
5 import itertools
6 import json
7 import random
8 import re
9
10 from .common import InfoExtractor
11 from ..compat import (
12 compat_parse_qs,
13 compat_str,
14 compat_urlparse,
15 compat_urllib_parse_urlencode,
16 compat_urllib_parse_urlparse,
17 )
18 from ..utils import (
19 clean_html,
20 dict_get,
21 ExtractorError,
22 float_or_none,
23 int_or_none,
24 parse_duration,
25 parse_iso8601,
26 qualities,
27 try_get,
28 unified_timestamp,
29 update_url_query,
30 url_or_none,
31 urljoin,
32 )
33
34
35 class TwitchBaseIE(InfoExtractor):
36 _VALID_URL_BASE = r'https?://(?:(?:www|go|m)\.)?twitch\.tv'
37
38 _API_BASE = 'https://api.twitch.tv'
39 _USHER_BASE = 'https://usher.ttvnw.net'
40 _LOGIN_FORM_URL = 'https://www.twitch.tv/login'
41 _LOGIN_POST_URL = 'https://passport.twitch.tv/login'
42 _CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'
43 _NETRC_MACHINE = 'twitch'
44
45 _OPERATION_HASHES = {
46 'CollectionSideBar': '27111f1b382effad0b6def325caef1909c733fe6a4fbabf54f8d491ef2cf2f14',
47 'FilterableVideoTower_Videos': 'a937f1d22e269e39a03b509f65a7490f9fc247d7f83d6ac1421523e3b68042cb',
48 'ClipsCards__User': 'b73ad2bfaecfd30a9e6c28fada15bd97032c83ec77a0440766a56fe0bd632777',
49 'ChannelCollectionsContent': '07e3691a1bad77a36aba590c351180439a40baefc1c275356f40fc7082419a84',
50 'StreamMetadata': '1c719a40e481453e5c48d9bb585d971b8b372f8ebb105b17076722264dfa5b3e',
51 'ComscoreStreamingQuery': 'e1edae8122517d013405f237ffcc124515dc6ded82480a88daef69c83b53ac01',
52 'VideoPreviewOverlay': '3006e77e51b128d838fa4e835723ca4dc9a05c5efd4466c1085215c6e437e65c',
53 'VideoMetadata': '226edb3e692509f727fd56821f5653c05740242c82b0388883e0c0e75dcbf687',
54 }
55
56 def _real_initialize(self):
57 self._login()
58
59 def _login(self):
60 username, password = self._get_login_info()
61 if username is None:
62 return
63
64 def fail(message):
65 raise ExtractorError(
66 'Unable to login. Twitch said: %s' % message, expected=True)
67
68 def login_step(page, urlh, note, data):
69 form = self._hidden_inputs(page)
70 form.update(data)
71
72 page_url = urlh.geturl()
73 post_url = self._search_regex(
74 r'<form[^>]+action=(["\'])(?P<url>.+?)\1', page,
75 'post url', default=self._LOGIN_POST_URL, group='url')
76 post_url = urljoin(page_url, post_url)
77
78 headers = {
79 'Referer': page_url,
80 'Origin': 'https://www.twitch.tv',
81 'Content-Type': 'text/plain;charset=UTF-8',
82 }
83
84 response = self._download_json(
85 post_url, None, note, data=json.dumps(form).encode(),
86 headers=headers, expected_status=400)
87 error = dict_get(response, ('error', 'error_description', 'error_code'))
88 if error:
89 fail(error)
90
91 if 'Authenticated successfully' in response.get('message', ''):
92 return None, None
93
94 redirect_url = urljoin(
95 post_url,
96 response.get('redirect') or response['redirect_path'])
97 return self._download_webpage_handle(
98 redirect_url, None, 'Downloading login redirect page',
99 headers=headers)
100
101 login_page, handle = self._download_webpage_handle(
102 self._LOGIN_FORM_URL, None, 'Downloading login page')
103
104 # Some TOR nodes and public proxies are blocked completely
105 if 'blacklist_message' in login_page:
106 fail(clean_html(login_page))
107
108 redirect_page, handle = login_step(
109 login_page, handle, 'Logging in', {
110 'username': username,
111 'password': password,
112 'client_id': self._CLIENT_ID,
113 })
114
115 # Successful login
116 if not redirect_page:
117 return
118
119 if re.search(r'(?i)<form[^>]+id="two-factor-submit"', redirect_page) is not None:
120 # TODO: Add mechanism to request an SMS or phone call
121 tfa_token = self._get_tfa_info('two-factor authentication token')
122 login_step(redirect_page, handle, 'Submitting TFA token', {
123 'authy_token': tfa_token,
124 'remember_2fa': 'true',
125 })
126
127 def _prefer_source(self, formats):
128 try:
129 source = next(f for f in formats if f['format_id'] == 'Source')
130 source['quality'] = 10
131 except StopIteration:
132 for f in formats:
133 if '/chunked/' in f['url']:
134 f.update({
135 'quality': 10,
136 'format_note': 'Source',
137 })
138 self._sort_formats(formats)
139
140 def _download_base_gql(self, video_id, ops, note, fatal=True):
141 headers = {
142 'Content-Type': 'text/plain;charset=UTF-8',
143 'Client-ID': self._CLIENT_ID,
144 }
145 gql_auth = self._get_cookies('https://gql.twitch.tv').get('auth-token')
146 if gql_auth:
147 headers['Authorization'] = 'OAuth ' + gql_auth.value
148 return self._download_json(
149 'https://gql.twitch.tv/gql', video_id, note,
150 data=json.dumps(ops).encode(),
151 headers=headers, fatal=fatal)
152
153 def _download_gql(self, video_id, ops, note, fatal=True):
154 for op in ops:
155 op['extensions'] = {
156 'persistedQuery': {
157 'version': 1,
158 'sha256Hash': self._OPERATION_HASHES[op['operationName']],
159 }
160 }
161 return self._download_base_gql(video_id, ops, note)
162
163 def _download_access_token(self, video_id, token_kind, param_name):
164 method = '%sPlaybackAccessToken' % token_kind
165 ops = {
166 'query': '''{
167 %s(
168 %s: "%s",
169 params: {
170 platform: "web",
171 playerBackend: "mediaplayer",
172 playerType: "site"
173 }
174 )
175 {
176 value
177 signature
178 }
179 }''' % (method, param_name, video_id),
180 }
181 return self._download_base_gql(
182 video_id, ops,
183 'Downloading %s access token GraphQL' % token_kind)['data'][method]
184
185
186 class TwitchVodIE(TwitchBaseIE):
187 IE_NAME = 'twitch:vod'
188 _VALID_URL = r'''(?x)
189 https?://
190 (?:
191 (?:(?:www|go|m)\.)?twitch\.tv/(?:[^/]+/v(?:ideo)?|videos)/|
192 player\.twitch\.tv/\?.*?\bvideo=v?
193 )
194 (?P<id>\d+)
195 '''
196
197 _TESTS = [{
198 'url': 'http://www.twitch.tv/riotgames/v/6528877?t=5m10s',
199 'info_dict': {
200 'id': 'v6528877',
201 'ext': 'mp4',
202 'title': 'LCK Summer Split - Week 6 Day 1',
203 'thumbnail': r're:^https?://.*\.jpg$',
204 'duration': 17208,
205 'timestamp': 1435131734,
206 'upload_date': '20150624',
207 'uploader': 'Riot Games',
208 'uploader_id': 'riotgames',
209 'view_count': int,
210 'start_time': 310,
211 },
212 'params': {
213 # m3u8 download
214 'skip_download': True,
215 },
216 }, {
217 # Untitled broadcast (title is None)
218 'url': 'http://www.twitch.tv/belkao_o/v/11230755',
219 'info_dict': {
220 'id': 'v11230755',
221 'ext': 'mp4',
222 'title': 'Untitled Broadcast',
223 'thumbnail': r're:^https?://.*\.jpg$',
224 'duration': 1638,
225 'timestamp': 1439746708,
226 'upload_date': '20150816',
227 'uploader': 'BelkAO_o',
228 'uploader_id': 'belkao_o',
229 'view_count': int,
230 },
231 'params': {
232 # m3u8 download
233 'skip_download': True,
234 },
235 'skip': 'HTTP Error 404: Not Found',
236 }, {
237 'url': 'http://player.twitch.tv/?t=5m10s&video=v6528877',
238 'only_matching': True,
239 }, {
240 'url': 'https://www.twitch.tv/videos/6528877',
241 'only_matching': True,
242 }, {
243 'url': 'https://m.twitch.tv/beagsandjam/v/247478721',
244 'only_matching': True,
245 }, {
246 'url': 'https://www.twitch.tv/northernlion/video/291940395',
247 'only_matching': True,
248 }, {
249 'url': 'https://player.twitch.tv/?video=480452374',
250 'only_matching': True,
251 }]
252
253 def _download_info(self, item_id):
254 data = self._download_gql(
255 item_id, [{
256 'operationName': 'VideoMetadata',
257 'variables': {
258 'channelLogin': '',
259 'videoID': item_id,
260 },
261 }],
262 'Downloading stream metadata GraphQL')[0]['data']
263 video = data.get('video')
264 if video is None:
265 raise ExtractorError(
266 'Video %s does not exist' % item_id, expected=True)
267 return self._extract_info_gql(video, item_id)
268
269 @staticmethod
270 def _extract_info(info):
271 status = info.get('status')
272 if status == 'recording':
273 is_live = True
274 elif status == 'recorded':
275 is_live = False
276 else:
277 is_live = None
278 _QUALITIES = ('small', 'medium', 'large')
279 quality_key = qualities(_QUALITIES)
280 thumbnails = []
281 preview = info.get('preview')
282 if isinstance(preview, dict):
283 for thumbnail_id, thumbnail_url in preview.items():
284 thumbnail_url = url_or_none(thumbnail_url)
285 if not thumbnail_url:
286 continue
287 if thumbnail_id not in _QUALITIES:
288 continue
289 thumbnails.append({
290 'url': thumbnail_url,
291 'preference': quality_key(thumbnail_id),
292 })
293 return {
294 'id': info['_id'],
295 'title': info.get('title') or 'Untitled Broadcast',
296 'description': info.get('description'),
297 'duration': int_or_none(info.get('length')),
298 'thumbnails': thumbnails,
299 'uploader': info.get('channel', {}).get('display_name'),
300 'uploader_id': info.get('channel', {}).get('name'),
301 'timestamp': parse_iso8601(info.get('recorded_at')),
302 'view_count': int_or_none(info.get('views')),
303 'is_live': is_live,
304 }
305
306 @staticmethod
307 def _extract_info_gql(info, item_id):
308 vod_id = info.get('id') or item_id
309 # id backward compatibility for download archives
310 if vod_id[0] != 'v':
311 vod_id = 'v%s' % vod_id
312 thumbnail = url_or_none(info.get('previewThumbnailURL'))
313 if thumbnail:
314 for p in ('width', 'height'):
315 thumbnail = thumbnail.replace('{%s}' % p, '0')
316 return {
317 'id': vod_id,
318 'title': info.get('title') or 'Untitled Broadcast',
319 'description': info.get('description'),
320 'duration': int_or_none(info.get('lengthSeconds')),
321 'thumbnail': thumbnail,
322 'uploader': try_get(info, lambda x: x['owner']['displayName'], compat_str),
323 'uploader_id': try_get(info, lambda x: x['owner']['login'], compat_str),
324 'timestamp': unified_timestamp(info.get('publishedAt')),
325 'view_count': int_or_none(info.get('viewCount')),
326 }
327
328 def _real_extract(self, url):
329 vod_id = self._match_id(url)
330
331 info = self._download_info(vod_id)
332 access_token = self._download_access_token(vod_id, 'video', 'id')
333
334 formats = self._extract_m3u8_formats(
335 '%s/vod/%s.m3u8?%s' % (
336 self._USHER_BASE, vod_id,
337 compat_urllib_parse_urlencode({
338 'allow_source': 'true',
339 'allow_audio_only': 'true',
340 'allow_spectre': 'true',
341 'player': 'twitchweb',
342 'playlist_include_framerate': 'true',
343 'nauth': access_token['value'],
344 'nauthsig': access_token['signature'],
345 })),
346 vod_id, 'mp4', entry_protocol='m3u8_native')
347
348 self._prefer_source(formats)
349 info['formats'] = formats
350
351 parsed_url = compat_urllib_parse_urlparse(url)
352 query = compat_parse_qs(parsed_url.query)
353 if 't' in query:
354 info['start_time'] = parse_duration(query['t'][0])
355
356 if info.get('timestamp') is not None:
357 info['subtitles'] = {
358 'rechat': [{
359 'url': update_url_query(
360 'https://api.twitch.tv/v5/videos/%s/comments' % vod_id, {
361 'client_id': self._CLIENT_ID,
362 }),
363 'ext': 'json',
364 }],
365 }
366
367 return info
368
369
370 def _make_video_result(node):
371 assert isinstance(node, dict)
372 video_id = node.get('id')
373 if not video_id:
374 return
375 return {
376 '_type': 'url_transparent',
377 'ie_key': TwitchVodIE.ie_key(),
378 'id': 'v' + video_id,
379 'url': 'https://www.twitch.tv/videos/%s' % video_id,
380 'title': node.get('title'),
381 'thumbnail': node.get('previewThumbnailURL'),
382 'duration': float_or_none(node.get('lengthSeconds')),
383 'view_count': int_or_none(node.get('viewCount')),
384 }
385
386
387 class TwitchCollectionIE(TwitchBaseIE):
388 _VALID_URL = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/collections/(?P<id>[^/]+)'
389
390 _TESTS = [{
391 'url': 'https://www.twitch.tv/collections/wlDCoH0zEBZZbQ',
392 'info_dict': {
393 'id': 'wlDCoH0zEBZZbQ',
394 'title': 'Overthrow Nook, capitalism for children',
395 },
396 'playlist_mincount': 13,
397 }]
398
399 _OPERATION_NAME = 'CollectionSideBar'
400
401 def _real_extract(self, url):
402 collection_id = self._match_id(url)
403 collection = self._download_gql(
404 collection_id, [{
405 'operationName': self._OPERATION_NAME,
406 'variables': {'collectionID': collection_id},
407 }],
408 'Downloading collection GraphQL')[0]['data']['collection']
409 title = collection.get('title')
410 entries = []
411 for edge in collection['items']['edges']:
412 if not isinstance(edge, dict):
413 continue
414 node = edge.get('node')
415 if not isinstance(node, dict):
416 continue
417 video = _make_video_result(node)
418 if video:
419 entries.append(video)
420 return self.playlist_result(
421 entries, playlist_id=collection_id, playlist_title=title)
422
423
424 class TwitchPlaylistBaseIE(TwitchBaseIE):
425 _PAGE_LIMIT = 100
426
427 def _entries(self, channel_name, *args):
428 cursor = None
429 variables_common = self._make_variables(channel_name, *args)
430 entries_key = '%ss' % self._ENTRY_KIND
431 for page_num in itertools.count(1):
432 variables = variables_common.copy()
433 variables['limit'] = self._PAGE_LIMIT
434 if cursor:
435 variables['cursor'] = cursor
436 page = self._download_gql(
437 channel_name, [{
438 'operationName': self._OPERATION_NAME,
439 'variables': variables,
440 }],
441 'Downloading %ss GraphQL page %s' % (self._NODE_KIND, page_num),
442 fatal=False)
443 if not page:
444 break
445 edges = try_get(
446 page, lambda x: x[0]['data']['user'][entries_key]['edges'], list)
447 if not edges:
448 break
449 for edge in edges:
450 if not isinstance(edge, dict):
451 continue
452 if edge.get('__typename') != self._EDGE_KIND:
453 continue
454 node = edge.get('node')
455 if not isinstance(node, dict):
456 continue
457 if node.get('__typename') != self._NODE_KIND:
458 continue
459 entry = self._extract_entry(node)
460 if entry:
461 cursor = edge.get('cursor')
462 yield entry
463 if not cursor or not isinstance(cursor, compat_str):
464 break
465
466
467 class TwitchVideosIE(TwitchPlaylistBaseIE):
468 _VALID_URL = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/(?P<id>[^/]+)/(?:videos|profile)'
469
470 _TESTS = [{
471 # All Videos sorted by Date
472 'url': 'https://www.twitch.tv/spamfish/videos?filter=all',
473 'info_dict': {
474 'id': 'spamfish',
475 'title': 'spamfish - All Videos sorted by Date',
476 },
477 'playlist_mincount': 924,
478 }, {
479 # All Videos sorted by Popular
480 'url': 'https://www.twitch.tv/spamfish/videos?filter=all&sort=views',
481 'info_dict': {
482 'id': 'spamfish',
483 'title': 'spamfish - All Videos sorted by Popular',
484 },
485 'playlist_mincount': 931,
486 }, {
487 # Past Broadcasts sorted by Date
488 'url': 'https://www.twitch.tv/spamfish/videos?filter=archives',
489 'info_dict': {
490 'id': 'spamfish',
491 'title': 'spamfish - Past Broadcasts sorted by Date',
492 },
493 'playlist_mincount': 27,
494 }, {
495 # Highlights sorted by Date
496 'url': 'https://www.twitch.tv/spamfish/videos?filter=highlights',
497 'info_dict': {
498 'id': 'spamfish',
499 'title': 'spamfish - Highlights sorted by Date',
500 },
501 'playlist_mincount': 901,
502 }, {
503 # Uploads sorted by Date
504 'url': 'https://www.twitch.tv/esl_csgo/videos?filter=uploads&sort=time',
505 'info_dict': {
506 'id': 'esl_csgo',
507 'title': 'esl_csgo - Uploads sorted by Date',
508 },
509 'playlist_mincount': 5,
510 }, {
511 # Past Premieres sorted by Date
512 'url': 'https://www.twitch.tv/spamfish/videos?filter=past_premieres',
513 'info_dict': {
514 'id': 'spamfish',
515 'title': 'spamfish - Past Premieres sorted by Date',
516 },
517 'playlist_mincount': 1,
518 }, {
519 'url': 'https://www.twitch.tv/spamfish/videos/all',
520 'only_matching': True,
521 }, {
522 'url': 'https://m.twitch.tv/spamfish/videos/all',
523 'only_matching': True,
524 }, {
525 'url': 'https://www.twitch.tv/spamfish/videos',
526 'only_matching': True,
527 }]
528
529 Broadcast = collections.namedtuple('Broadcast', ['type', 'label'])
530
531 _DEFAULT_BROADCAST = Broadcast(None, 'All Videos')
532 _BROADCASTS = {
533 'archives': Broadcast('ARCHIVE', 'Past Broadcasts'),
534 'highlights': Broadcast('HIGHLIGHT', 'Highlights'),
535 'uploads': Broadcast('UPLOAD', 'Uploads'),
536 'past_premieres': Broadcast('PAST_PREMIERE', 'Past Premieres'),
537 'all': _DEFAULT_BROADCAST,
538 }
539
540 _DEFAULT_SORTED_BY = 'Date'
541 _SORTED_BY = {
542 'time': _DEFAULT_SORTED_BY,
543 'views': 'Popular',
544 }
545
546 _OPERATION_NAME = 'FilterableVideoTower_Videos'
547 _ENTRY_KIND = 'video'
548 _EDGE_KIND = 'VideoEdge'
549 _NODE_KIND = 'Video'
550
551 @classmethod
552 def suitable(cls, url):
553 return (False
554 if any(ie.suitable(url) for ie in (
555 TwitchVideosClipsIE,
556 TwitchVideosCollectionsIE))
557 else super(TwitchVideosIE, cls).suitable(url))
558
559 @staticmethod
560 def _make_variables(channel_name, broadcast_type, sort):
561 return {
562 'channelOwnerLogin': channel_name,
563 'broadcastType': broadcast_type,
564 'videoSort': sort.upper(),
565 }
566
567 @staticmethod
568 def _extract_entry(node):
569 return _make_video_result(node)
570
571 def _real_extract(self, url):
572 channel_name = self._match_id(url)
573 qs = compat_urlparse.parse_qs(compat_urlparse.urlparse(url).query)
574 filter = qs.get('filter', ['all'])[0]
575 sort = qs.get('sort', ['time'])[0]
576 broadcast = self._BROADCASTS.get(filter, self._DEFAULT_BROADCAST)
577 return self.playlist_result(
578 self._entries(channel_name, broadcast.type, sort),
579 playlist_id=channel_name,
580 playlist_title='%s - %s sorted by %s'
581 % (channel_name, broadcast.label,
582 self._SORTED_BY.get(sort, self._DEFAULT_SORTED_BY)))
583
584
585 class TwitchVideosClipsIE(TwitchPlaylistBaseIE):
586 _VALID_URL = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/(?P<id>[^/]+)/(?:clips|videos/*?\?.*?\bfilter=clips)'
587
588 _TESTS = [{
589 # Clips
590 'url': 'https://www.twitch.tv/vanillatv/clips?filter=clips&range=all',
591 'info_dict': {
592 'id': 'vanillatv',
593 'title': 'vanillatv - Clips Top All',
594 },
595 'playlist_mincount': 1,
596 }, {
597 'url': 'https://www.twitch.tv/dota2ruhub/videos?filter=clips&range=7d',
598 'only_matching': True,
599 }]
600
601 Clip = collections.namedtuple('Clip', ['filter', 'label'])
602
603 _DEFAULT_CLIP = Clip('LAST_WEEK', 'Top 7D')
604 _RANGE = {
605 '24hr': Clip('LAST_DAY', 'Top 24H'),
606 '7d': _DEFAULT_CLIP,
607 '30d': Clip('LAST_MONTH', 'Top 30D'),
608 'all': Clip('ALL_TIME', 'Top All'),
609 }
610
611 # NB: values other than 20 result in skipped videos
612 _PAGE_LIMIT = 20
613
614 _OPERATION_NAME = 'ClipsCards__User'
615 _ENTRY_KIND = 'clip'
616 _EDGE_KIND = 'ClipEdge'
617 _NODE_KIND = 'Clip'
618
619 @staticmethod
620 def _make_variables(channel_name, filter):
621 return {
622 'login': channel_name,
623 'criteria': {
624 'filter': filter,
625 },
626 }
627
628 @staticmethod
629 def _extract_entry(node):
630 assert isinstance(node, dict)
631 clip_url = url_or_none(node.get('url'))
632 if not clip_url:
633 return
634 return {
635 '_type': 'url_transparent',
636 'ie_key': TwitchClipsIE.ie_key(),
637 'id': node.get('id'),
638 'url': clip_url,
639 'title': node.get('title'),
640 'thumbnail': node.get('thumbnailURL'),
641 'duration': float_or_none(node.get('durationSeconds')),
642 'timestamp': unified_timestamp(node.get('createdAt')),
643 'view_count': int_or_none(node.get('viewCount')),
644 'language': node.get('language'),
645 }
646
647 def _real_extract(self, url):
648 channel_name = self._match_id(url)
649 qs = compat_urlparse.parse_qs(compat_urlparse.urlparse(url).query)
650 range = qs.get('range', ['7d'])[0]
651 clip = self._RANGE.get(range, self._DEFAULT_CLIP)
652 return self.playlist_result(
653 self._entries(channel_name, clip.filter),
654 playlist_id=channel_name,
655 playlist_title='%s - Clips %s' % (channel_name, clip.label))
656
657
658 class TwitchVideosCollectionsIE(TwitchPlaylistBaseIE):
659 _VALID_URL = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/(?P<id>[^/]+)/videos/*?\?.*?\bfilter=collections'
660
661 _TESTS = [{
662 # Collections
663 'url': 'https://www.twitch.tv/spamfish/videos?filter=collections',
664 'info_dict': {
665 'id': 'spamfish',
666 'title': 'spamfish - Collections',
667 },
668 'playlist_mincount': 3,
669 }]
670
671 _OPERATION_NAME = 'ChannelCollectionsContent'
672 _ENTRY_KIND = 'collection'
673 _EDGE_KIND = 'CollectionsItemEdge'
674 _NODE_KIND = 'Collection'
675
676 @staticmethod
677 def _make_variables(channel_name):
678 return {
679 'ownerLogin': channel_name,
680 }
681
682 @staticmethod
683 def _extract_entry(node):
684 assert isinstance(node, dict)
685 collection_id = node.get('id')
686 if not collection_id:
687 return
688 return {
689 '_type': 'url_transparent',
690 'ie_key': TwitchCollectionIE.ie_key(),
691 'id': collection_id,
692 'url': 'https://www.twitch.tv/collections/%s' % collection_id,
693 'title': node.get('title'),
694 'thumbnail': node.get('thumbnailURL'),
695 'duration': float_or_none(node.get('lengthSeconds')),
696 'timestamp': unified_timestamp(node.get('updatedAt')),
697 'view_count': int_or_none(node.get('viewCount')),
698 }
699
700 def _real_extract(self, url):
701 channel_name = self._match_id(url)
702 return self.playlist_result(
703 self._entries(channel_name), playlist_id=channel_name,
704 playlist_title='%s - Collections' % channel_name)
705
706
707 class TwitchStreamIE(TwitchBaseIE):
708 IE_NAME = 'twitch:stream'
709 _VALID_URL = r'''(?x)
710 https?://
711 (?:
712 (?:(?:www|go|m)\.)?twitch\.tv/|
713 player\.twitch\.tv/\?.*?\bchannel=
714 )
715 (?P<id>[^/#?]+)
716 '''
717
718 _TESTS = [{
719 'url': 'http://www.twitch.tv/shroomztv',
720 'info_dict': {
721 'id': '12772022048',
722 'display_id': 'shroomztv',
723 'ext': 'mp4',
724 'title': 're:^ShroomzTV [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$',
725 'description': 'H1Z1 - lonewolfing with ShroomzTV | A3 Battle Royale later - @ShroomzTV',
726 'is_live': True,
727 'timestamp': 1421928037,
728 'upload_date': '20150122',
729 'uploader': 'ShroomzTV',
730 'uploader_id': 'shroomztv',
731 'view_count': int,
732 },
733 'params': {
734 # m3u8 download
735 'skip_download': True,
736 },
737 }, {
738 'url': 'http://www.twitch.tv/miracle_doto#profile-0',
739 'only_matching': True,
740 }, {
741 'url': 'https://player.twitch.tv/?channel=lotsofs',
742 'only_matching': True,
743 }, {
744 'url': 'https://go.twitch.tv/food',
745 'only_matching': True,
746 }, {
747 'url': 'https://m.twitch.tv/food',
748 'only_matching': True,
749 }]
750
751 @classmethod
752 def suitable(cls, url):
753 return (False
754 if any(ie.suitable(url) for ie in (
755 TwitchVodIE,
756 TwitchCollectionIE,
757 TwitchVideosIE,
758 TwitchVideosClipsIE,
759 TwitchVideosCollectionsIE,
760 TwitchClipsIE))
761 else super(TwitchStreamIE, cls).suitable(url))
762
763 def _real_extract(self, url):
764 channel_name = self._match_id(url).lower()
765
766 gql = self._download_gql(
767 channel_name, [{
768 'operationName': 'StreamMetadata',
769 'variables': {'channelLogin': channel_name},
770 }, {
771 'operationName': 'ComscoreStreamingQuery',
772 'variables': {
773 'channel': channel_name,
774 'clipSlug': '',
775 'isClip': False,
776 'isLive': True,
777 'isVodOrCollection': False,
778 'vodID': '',
779 },
780 }, {
781 'operationName': 'VideoPreviewOverlay',
782 'variables': {'login': channel_name},
783 }],
784 'Downloading stream GraphQL')
785
786 user = gql[0]['data']['user']
787
788 if not user:
789 raise ExtractorError(
790 '%s does not exist' % channel_name, expected=True)
791
792 stream = user['stream']
793
794 if not stream:
795 raise ExtractorError('%s is offline' % channel_name, expected=True)
796
797 access_token = self._download_access_token(
798 channel_name, 'stream', 'channelName')
799 token = access_token['value']
800
801 stream_id = stream.get('id') or channel_name
802 query = {
803 'allow_source': 'true',
804 'allow_audio_only': 'true',
805 'allow_spectre': 'true',
806 'p': random.randint(1000000, 10000000),
807 'player': 'twitchweb',
808 'playlist_include_framerate': 'true',
809 'segment_preference': '4',
810 'sig': access_token['signature'].encode('utf-8'),
811 'token': token.encode('utf-8'),
812 }
813 formats = self._extract_m3u8_formats(
814 '%s/api/channel/hls/%s.m3u8' % (self._USHER_BASE, channel_name),
815 stream_id, 'mp4', query=query)
816 self._prefer_source(formats)
817
818 view_count = stream.get('viewers')
819 timestamp = unified_timestamp(stream.get('createdAt'))
820
821 sq_user = try_get(gql, lambda x: x[1]['data']['user'], dict) or {}
822 uploader = sq_user.get('displayName')
823 description = try_get(
824 sq_user, lambda x: x['broadcastSettings']['title'], compat_str)
825
826 thumbnail = url_or_none(try_get(
827 gql, lambda x: x[2]['data']['user']['stream']['previewImageURL'],
828 compat_str))
829
830 title = uploader or channel_name
831 stream_type = stream.get('type')
832 if stream_type in ['rerun', 'live']:
833 title += ' (%s)' % stream_type
834
835 return {
836 'id': stream_id,
837 'display_id': channel_name,
838 'title': self._live_title(title),
839 'description': description,
840 'thumbnail': thumbnail,
841 'uploader': uploader,
842 'uploader_id': channel_name,
843 'timestamp': timestamp,
844 'view_count': view_count,
845 'formats': formats,
846 'is_live': stream_type == 'live',
847 }
848
849
850 class TwitchClipsIE(TwitchBaseIE):
851 IE_NAME = 'twitch:clips'
852 _VALID_URL = r'''(?x)
853 https?://
854 (?:
855 clips\.twitch\.tv/(?:embed\?.*?\bclip=|(?:[^/]+/)*)|
856 (?:(?:www|go|m)\.)?twitch\.tv/[^/]+/clip/
857 )
858 (?P<id>[^/?#&]+)
859 '''
860
861 _TESTS = [{
862 'url': 'https://clips.twitch.tv/FaintLightGullWholeWheat',
863 'md5': '761769e1eafce0ffebfb4089cb3847cd',
864 'info_dict': {
865 'id': '42850523',
866 'ext': 'mp4',
867 'title': 'EA Play 2016 Live from the Novo Theatre',
868 'thumbnail': r're:^https?://.*\.jpg',
869 'timestamp': 1465767393,
870 'upload_date': '20160612',
871 'creator': 'EA',
872 'uploader': 'stereotype_',
873 'uploader_id': '43566419',
874 },
875 }, {
876 # multiple formats
877 'url': 'https://clips.twitch.tv/rflegendary/UninterestedBeeDAESuppy',
878 'only_matching': True,
879 }, {
880 'url': 'https://www.twitch.tv/sergeynixon/clip/StormyThankfulSproutFutureMan',
881 'only_matching': True,
882 }, {
883 'url': 'https://clips.twitch.tv/embed?clip=InquisitiveBreakableYogurtJebaited',
884 'only_matching': True,
885 }, {
886 'url': 'https://m.twitch.tv/rossbroadcast/clip/ConfidentBraveHumanChefFrank',
887 'only_matching': True,
888 }, {
889 'url': 'https://go.twitch.tv/rossbroadcast/clip/ConfidentBraveHumanChefFrank',
890 'only_matching': True,
891 }]
892
893 def _real_extract(self, url):
894 video_id = self._match_id(url)
895
896 clip = self._download_base_gql(
897 video_id, {
898 'query': '''{
899 clip(slug: "%s") {
900 broadcaster {
901 displayName
902 }
903 createdAt
904 curator {
905 displayName
906 id
907 }
908 durationSeconds
909 id
910 tiny: thumbnailURL(width: 86, height: 45)
911 small: thumbnailURL(width: 260, height: 147)
912 medium: thumbnailURL(width: 480, height: 272)
913 title
914 videoQualities {
915 frameRate
916 quality
917 sourceURL
918 }
919 viewCount
920 }
921 }''' % video_id}, 'Downloading clip GraphQL')['data']['clip']
922
923 if not clip:
924 raise ExtractorError(
925 'This clip is no longer available', expected=True)
926
927 formats = []
928 for option in clip.get('videoQualities', []):
929 if not isinstance(option, dict):
930 continue
931 source = url_or_none(option.get('sourceURL'))
932 if not source:
933 continue
934 formats.append({
935 'url': source,
936 'format_id': option.get('quality'),
937 'height': int_or_none(option.get('quality')),
938 'fps': int_or_none(option.get('frameRate')),
939 })
940 self._sort_formats(formats)
941
942 thumbnails = []
943 for thumbnail_id in ('tiny', 'small', 'medium'):
944 thumbnail_url = clip.get(thumbnail_id)
945 if not thumbnail_url:
946 continue
947 thumb = {
948 'id': thumbnail_id,
949 'url': thumbnail_url,
950 }
951 mobj = re.search(r'-(\d+)x(\d+)\.', thumbnail_url)
952 if mobj:
953 thumb.update({
954 'height': int(mobj.group(2)),
955 'width': int(mobj.group(1)),
956 })
957 thumbnails.append(thumb)
958
959 return {
960 'id': clip.get('id') or video_id,
961 'title': clip.get('title') or video_id,
962 'formats': formats,
963 'duration': int_or_none(clip.get('durationSeconds')),
964 'views': int_or_none(clip.get('viewCount')),
965 'timestamp': unified_timestamp(clip.get('createdAt')),
966 'thumbnails': thumbnails,
967 'creator': try_get(clip, lambda x: x['broadcaster']['displayName'], compat_str),
968 'uploader': try_get(clip, lambda x: x['curator']['displayName'], compat_str),
969 'uploader_id': try_get(clip, lambda x: x['curator']['id'], compat_str),
970 }