2 from __future__
import unicode_literals
8 from .common
import InfoExtractor
13 compat_urllib_parse_urlparse
,
14 compat_urllib_request
,
23 class TwitchBaseIE(InfoExtractor
):
24 _VALID_URL_BASE
= r
'https?://(?:www\.)?twitch\.tv'
26 _API_BASE
= 'https://api.twitch.tv'
27 _USHER_BASE
= 'http://usher.twitch.tv'
28 _LOGIN_URL
= 'https://secure.twitch.tv/login'
29 _LOGIN_POST_URL
= 'https://passport.twitch.tv/authorize'
30 _NETRC_MACHINE
= 'twitch'
32 def _handle_error(self
, response
):
33 if not isinstance(response
, dict):
35 error
= response
.get('error')
38 '%s returned error: %s - %s' % (self
.IE_NAME
, error
, response
.get('message')),
41 def _download_json(self
, url
, video_id
, note
='Downloading JSON metadata'):
43 'Referer': 'http://api.twitch.tv/crossdomain/receiver.html?v=2',
44 'X-Requested-With': 'XMLHttpRequest',
46 for cookie
in self
._downloader
.cookiejar
:
47 if cookie
.name
== 'api_token':
48 headers
['Twitch-Api-Token'] = cookie
.value
49 request
= compat_urllib_request
.Request(url
, headers
=headers
)
50 response
= super(TwitchBaseIE
, self
)._download
_json
(request
, video_id
, note
)
51 self
._handle
_error
(response
)
54 def _real_initialize(self
):
58 (username
, password
) = self
._get
_login
_info
()
62 login_page
= self
._download
_webpage
(
63 self
._LOGIN
_URL
, None, 'Downloading login page')
65 login_form
= self
._hidden
_inputs
(login_page
)
68 'login': username
.encode('utf-8'),
69 'password': password
.encode('utf-8'),
72 request
= compat_urllib_request
.Request(
73 self
._LOGIN
_POST
_URL
, compat_urllib_parse
.urlencode(login_form
).encode('utf-8'))
74 request
.add_header('Referer', self
._LOGIN
_URL
)
75 response
= self
._download
_webpage
(
76 request
, None, 'Logging in as %s' % username
)
78 error_message
= self
._search
_regex
(
79 r
'<div[^>]+class="subwindow_notice"[^>]*>([^<]+)</div>',
80 response
, 'error message', default
=None)
83 'Unable to login. Twitch said: %s' % error_message
, expected
=True)
85 if '>Reset your password<' in response
:
86 self
.report_warning('Twitch asks you to reset your password, go to https://secure.twitch.tv/reset/submit')
88 def _prefer_source(self
, formats
):
90 source
= next(f
for f
in formats
if f
['format_id'] == 'Source')
91 source
['preference'] = 10
93 pass # No Source stream present
94 self
._sort
_formats
(formats
)
97 class TwitchItemBaseIE(TwitchBaseIE
):
98 def _download_info(self
, item
, item_id
):
99 return self
._extract
_info
(self
._download
_json
(
100 '%s/kraken/videos/%s%s' % (self
._API
_BASE
, item
, item_id
), item_id
,
101 'Downloading %s info JSON' % self
._ITEM
_TYPE
))
103 def _extract_media(self
, item_id
):
104 info
= self
._download
_info
(self
._ITEM
_SHORTCUT
, item_id
)
105 response
= self
._download
_json
(
106 '%s/api/videos/%s%s' % (self
._API
_BASE
, self
._ITEM
_SHORTCUT
, item_id
), item_id
,
107 'Downloading %s playlist JSON' % self
._ITEM
_TYPE
)
109 chunks
= response
['chunks']
110 qualities
= list(chunks
.keys())
111 for num
, fragment
in enumerate(zip(*chunks
.values()), start
=1):
113 for fmt_num
, fragment_fmt
in enumerate(fragment
):
114 format_id
= qualities
[fmt_num
]
116 'url': fragment_fmt
['url'],
117 'format_id': format_id
,
118 'quality': 1 if format_id
== 'live' else 0,
120 m
= re
.search(r
'^(?P<height>\d+)[Pp]', format_id
)
122 fmt
['height'] = int(m
.group('height'))
124 self
._sort
_formats
(formats
)
126 entry
['id'] = '%s_%d' % (entry
['id'], num
)
127 entry
['title'] = '%s part %d' % (entry
['title'], num
)
128 entry
['formats'] = formats
129 entries
.append(entry
)
130 return self
.playlist_result(entries
, info
['id'], info
['title'])
132 def _extract_info(self
, info
):
135 'title': info
['title'],
136 'description': info
['description'],
137 'duration': info
['length'],
138 'thumbnail': info
['preview'],
139 'uploader': info
['channel']['display_name'],
140 'uploader_id': info
['channel']['name'],
141 'timestamp': parse_iso8601(info
['recorded_at']),
142 'view_count': info
['views'],
145 def _real_extract(self
, url
):
146 return self
._extract
_media
(self
._match
_id
(url
))
149 class TwitchVideoIE(TwitchItemBaseIE
):
150 IE_NAME
= 'twitch:video'
151 _VALID_URL
= r
'%s/[^/]+/b/(?P<id>\d+)' % TwitchBaseIE
._VALID
_URL
_BASE
156 'url': 'http://www.twitch.tv/riotgames/b/577357806',
159 'title': 'Worlds Semifinals - Star Horn Royal Club vs. OMG',
161 'playlist_mincount': 12,
165 class TwitchChapterIE(TwitchItemBaseIE
):
166 IE_NAME
= 'twitch:chapter'
167 _VALID_URL
= r
'%s/[^/]+/c/(?P<id>\d+)' % TwitchBaseIE
._VALID
_URL
_BASE
168 _ITEM_TYPE
= 'chapter'
172 'url': 'http://www.twitch.tv/acracingleague/c/5285812',
175 'title': 'ACRL Off Season - Sports Cars @ Nordschleife',
177 'playlist_mincount': 3,
179 'url': 'http://www.twitch.tv/tsm_theoddone/c/2349361',
180 'only_matching': True,
184 class TwitchVodIE(TwitchItemBaseIE
):
185 IE_NAME
= 'twitch:vod'
186 _VALID_URL
= r
'%s/[^/]+/v/(?P<id>\d+)' % TwitchBaseIE
._VALID
_URL
_BASE
191 'url': 'http://www.twitch.tv/riotgames/v/6528877?t=5m10s',
195 'title': 'LCK Summer Split - Week 6 Day 1',
196 'thumbnail': 're:^https?://.*\.jpg$',
198 'timestamp': 1435131709,
199 'upload_date': '20150624',
200 'uploader': 'Riot Games',
201 'uploader_id': 'riotgames',
207 'skip_download': True,
211 def _real_extract(self
, url
):
212 item_id
= self
._match
_id
(url
)
213 info
= self
._download
_info
(self
._ITEM
_SHORTCUT
, item_id
)
214 access_token
= self
._download
_json
(
215 '%s/api/vods/%s/access_token' % (self
._API
_BASE
, item_id
), item_id
,
216 'Downloading %s access token' % self
._ITEM
_TYPE
)
217 formats
= self
._extract
_m
3u8_formats
(
218 '%s/vod/%s?nauth=%s&nauthsig=%s&allow_source=true'
219 % (self
._USHER
_BASE
, item_id
, access_token
['token'], access_token
['sig']),
221 self
._prefer
_source
(formats
)
222 info
['formats'] = formats
224 parsed_url
= compat_urllib_parse_urlparse(url
)
225 query
= compat_parse_qs(parsed_url
.query
)
227 info
['start_time'] = parse_duration(query
['t'][0])
232 class TwitchPlaylistBaseIE(TwitchBaseIE
):
233 _PLAYLIST_URL
= '%s/kraken/channels/%%s/videos/?offset=%%d&limit=%%d' % TwitchBaseIE
._API
_BASE
236 def _extract_playlist(self
, channel_id
):
237 info
= self
._download
_json
(
238 '%s/kraken/channels/%s' % (self
._API
_BASE
, channel_id
),
239 channel_id
, 'Downloading channel info JSON')
240 channel_name
= info
.get('display_name') or info
.get('name')
243 limit
= self
._PAGE
_LIMIT
244 for counter
in itertools
.count(1):
245 response
= self
._download
_json
(
246 self
._PLAYLIST
_URL
% (channel_id
, offset
, limit
),
247 channel_id
, 'Downloading %s videos JSON page %d' % (self
._PLAYLIST
_TYPE
, counter
))
248 page_entries
= self
._extract
_playlist
_page
(response
)
251 entries
.extend(page_entries
)
253 return self
.playlist_result(
254 [self
.url_result(entry
) for entry
in set(entries
)],
255 channel_id
, channel_name
)
257 def _extract_playlist_page(self
, response
):
258 videos
= response
.get('videos')
259 return [video
['url'] for video
in videos
] if videos
else []
261 def _real_extract(self
, url
):
262 return self
._extract
_playlist
(self
._match
_id
(url
))
265 class TwitchProfileIE(TwitchPlaylistBaseIE
):
266 IE_NAME
= 'twitch:profile'
267 _VALID_URL
= r
'%s/(?P<id>[^/]+)/profile/?(?:\#.*)?$' % TwitchBaseIE
._VALID
_URL
_BASE
268 _PLAYLIST_TYPE
= 'profile'
271 'url': 'http://www.twitch.tv/vanillatv/profile',
274 'title': 'VanillaTV',
276 'playlist_mincount': 412,
280 class TwitchPastBroadcastsIE(TwitchPlaylistBaseIE
):
281 IE_NAME
= 'twitch:past_broadcasts'
282 _VALID_URL
= r
'%s/(?P<id>[^/]+)/profile/past_broadcasts/?(?:\#.*)?$' % TwitchBaseIE
._VALID
_URL
_BASE
283 _PLAYLIST_URL
= TwitchPlaylistBaseIE
._PLAYLIST
_URL
+ '&broadcasts=true'
284 _PLAYLIST_TYPE
= 'past broadcasts'
287 'url': 'http://www.twitch.tv/spamfish/profile/past_broadcasts',
292 'playlist_mincount': 54,
296 class TwitchBookmarksIE(TwitchPlaylistBaseIE
):
297 IE_NAME
= 'twitch:bookmarks'
298 _VALID_URL
= r
'%s/(?P<id>[^/]+)/profile/bookmarks/?(?:\#.*)?$' % TwitchBaseIE
._VALID
_URL
_BASE
299 _PLAYLIST_URL
= '%s/api/bookmark/?user=%%s&offset=%%d&limit=%%d' % TwitchBaseIE
._API
_BASE
300 _PLAYLIST_TYPE
= 'bookmarks'
303 'url': 'http://www.twitch.tv/ognos/profile/bookmarks',
308 'playlist_mincount': 3,
311 def _extract_playlist_page(self
, response
):
313 for bookmark
in response
.get('bookmarks', []):
314 video
= bookmark
.get('video')
317 entries
.append(video
['url'])
321 class TwitchStreamIE(TwitchBaseIE
):
322 IE_NAME
= 'twitch:stream'
323 _VALID_URL
= r
'%s/(?P<id>[^/#?]+)/?(?:\#.*)?$' % TwitchBaseIE
._VALID
_URL
_BASE
326 'url': 'http://www.twitch.tv/shroomztv',
329 'display_id': 'shroomztv',
331 'title': 're:^ShroomzTV [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$',
332 'description': 'H1Z1 - lonewolfing with ShroomzTV | A3 Battle Royale later - @ShroomzTV',
334 'timestamp': 1421928037,
335 'upload_date': '20150122',
336 'uploader': 'ShroomzTV',
337 'uploader_id': 'shroomztv',
342 'skip_download': True,
345 'url': 'http://www.twitch.tv/miracle_doto#profile-0',
346 'only_matching': True,
349 def _real_extract(self
, url
):
350 channel_id
= self
._match
_id
(url
)
352 stream
= self
._download
_json
(
353 '%s/kraken/streams/%s' % (self
._API
_BASE
, channel_id
), channel_id
,
354 'Downloading stream JSON').get('stream')
356 # Fallback on profile extraction if stream is offline
358 return self
.url_result(
359 'http://www.twitch.tv/%s/profile' % channel_id
,
360 'TwitchProfile', channel_id
)
362 # Channel name may be typed if different case than the original channel name
363 # (e.g. http://www.twitch.tv/TWITCHPLAYSPOKEMON) that will lead to constructing
364 # an invalid m3u8 URL. Working around by use of original channel name from stream
365 # JSON and fallback to lowercase if it's not available.
366 channel_id
= stream
.get('channel', {}).get('name') or channel_id
.lower()
368 access_token
= self
._download
_json
(
369 '%s/api/channels/%s/access_token' % (self
._API
_BASE
, channel_id
), channel_id
,
370 'Downloading channel access token')
373 'allow_source': 'true',
374 'p': random
.randint(1000000, 10000000),
375 'player': 'twitchweb',
376 'segment_preference': '4',
377 'sig': access_token
['sig'].encode('utf-8'),
378 'token': access_token
['token'].encode('utf-8'),
380 formats
= self
._extract
_m
3u8_formats
(
381 '%s/api/channel/hls/%s.m3u8?%s'
382 % (self
._USHER
_BASE
, channel_id
, compat_urllib_parse
.urlencode(query
)),
384 self
._prefer
_source
(formats
)
386 view_count
= stream
.get('viewers')
387 timestamp
= parse_iso8601(stream
.get('created_at'))
389 channel
= stream
['channel']
390 title
= self
._live
_title
(channel
.get('display_name') or channel
.get('name'))
391 description
= channel
.get('status')
394 for thumbnail_key
, thumbnail_url
in stream
['preview'].items():
395 m
= re
.search(r
'(?P<width>\d+)x(?P<height>\d+)\.jpg$', thumbnail_key
)
399 'url': thumbnail_url
,
400 'width': int(m
.group('width')),
401 'height': int(m
.group('height')),
405 'id': compat_str(stream
['_id']),
406 'display_id': channel_id
,
408 'description': description
,
409 'thumbnails': thumbnails
,
410 'uploader': channel
.get('display_name'),
411 'uploader_id': channel
.get('name'),
412 'timestamp': timestamp
,
413 'view_count': view_count
,