]> Raphaël G. Git Repositories - youtubedl/blob - youtube_dl/extractor/youtube.py
debian/changelog: Annotate bugs being closed.
[youtubedl] / youtube_dl / extractor / youtube.py
1 # coding: utf-8
2
3 from __future__ import unicode_literals
4
5
6 import itertools
7 import json
8 import os.path
9 import re
10 import traceback
11
12 from .common import InfoExtractor, SearchInfoExtractor
13 from .subtitles import SubtitlesInfoExtractor
14 from ..jsinterp import JSInterpreter
15 from ..swfinterp import SWFInterpreter
16 from ..utils import (
17 compat_chr,
18 compat_parse_qs,
19 compat_urllib_parse,
20 compat_urllib_request,
21 compat_urlparse,
22 compat_str,
23
24 clean_html,
25 get_element_by_id,
26 get_element_by_attribute,
27 ExtractorError,
28 int_or_none,
29 OnDemandPagedList,
30 unescapeHTML,
31 unified_strdate,
32 orderedSet,
33 uppercase_escape,
34 )
35
36 class YoutubeBaseInfoExtractor(InfoExtractor):
37 """Provide base functions for Youtube extractors"""
38 _LOGIN_URL = 'https://accounts.google.com/ServiceLogin'
39 _TWOFACTOR_URL = 'https://accounts.google.com/SecondFactor'
40 _LANG_URL = r'https://www.youtube.com/?hl=en&persist_hl=1&gl=US&persist_gl=1&opt_out_ackd=1'
41 _AGE_URL = 'https://www.youtube.com/verify_age?next_url=/&gl=US&hl=en'
42 _NETRC_MACHINE = 'youtube'
43 # If True it will raise an error if no login info is provided
44 _LOGIN_REQUIRED = False
45
46 def _set_language(self):
47 return bool(self._download_webpage(
48 self._LANG_URL, None,
49 note='Setting language', errnote='unable to set language',
50 fatal=False))
51
52 def _login(self):
53 """
54 Attempt to log in to YouTube.
55 True is returned if successful or skipped.
56 False is returned if login failed.
57
58 If _LOGIN_REQUIRED is set and no authentication was provided, an error is raised.
59 """
60 (username, password) = self._get_login_info()
61 # No authentication to be performed
62 if username is None:
63 if self._LOGIN_REQUIRED:
64 raise ExtractorError('No login info available, needed for using %s.' % self.IE_NAME, expected=True)
65 return True
66
67 login_page = self._download_webpage(
68 self._LOGIN_URL, None,
69 note='Downloading login page',
70 errnote='unable to fetch login page', fatal=False)
71 if login_page is False:
72 return
73
74 galx = self._search_regex(r'(?s)<input.+?name="GALX".+?value="(.+?)"',
75 login_page, 'Login GALX parameter')
76
77 # Log in
78 login_form_strs = {
79 'continue': 'https://www.youtube.com/signin?action_handle_signin=true&feature=sign_in_button&hl=en_US&nomobiletemp=1',
80 'Email': username,
81 'GALX': galx,
82 'Passwd': password,
83
84 'PersistentCookie': 'yes',
85 '_utf8': '霱',
86 'bgresponse': 'js_disabled',
87 'checkConnection': '',
88 'checkedDomains': 'youtube',
89 'dnConn': '',
90 'pstMsg': '0',
91 'rmShown': '1',
92 'secTok': '',
93 'signIn': 'Sign in',
94 'timeStmp': '',
95 'service': 'youtube',
96 'uilel': '3',
97 'hl': 'en_US',
98 }
99
100 # Convert to UTF-8 *before* urlencode because Python 2.x's urlencode
101 # chokes on unicode
102 login_form = dict((k.encode('utf-8'), v.encode('utf-8')) for k,v in login_form_strs.items())
103 login_data = compat_urllib_parse.urlencode(login_form).encode('ascii')
104
105 req = compat_urllib_request.Request(self._LOGIN_URL, login_data)
106 login_results = self._download_webpage(
107 req, None,
108 note='Logging in', errnote='unable to log in', fatal=False)
109 if login_results is False:
110 return False
111
112 if re.search(r'id="errormsg_0_Passwd"', login_results) is not None:
113 raise ExtractorError('Please use your account password and a two-factor code instead of an application-specific password.', expected=True)
114
115 # Two-Factor
116 # TODO add SMS and phone call support - these require making a request and then prompting the user
117
118 if re.search(r'(?i)<form[^>]* id="gaia_secondfactorform"', login_results) is not None:
119 tfa_code = self._get_tfa_info()
120
121 if tfa_code is None:
122 self._downloader.report_warning('Two-factor authentication required. Provide it with --twofactor <code>')
123 self._downloader.report_warning('(Note that only TOTP (Google Authenticator App) codes work at this time.)')
124 return False
125
126 # Unlike the first login form, secTok and timeStmp are both required for the TFA form
127
128 match = re.search(r'id="secTok"\n\s+value=\'(.+)\'/>', login_results, re.M | re.U)
129 if match is None:
130 self._downloader.report_warning('Failed to get secTok - did the page structure change?')
131 secTok = match.group(1)
132 match = re.search(r'id="timeStmp"\n\s+value=\'(.+)\'/>', login_results, re.M | re.U)
133 if match is None:
134 self._downloader.report_warning('Failed to get timeStmp - did the page structure change?')
135 timeStmp = match.group(1)
136
137 tfa_form_strs = {
138 'continue': 'https://www.youtube.com/signin?action_handle_signin=true&feature=sign_in_button&hl=en_US&nomobiletemp=1',
139 'smsToken': '',
140 'smsUserPin': tfa_code,
141 'smsVerifyPin': 'Verify',
142
143 'PersistentCookie': 'yes',
144 'checkConnection': '',
145 'checkedDomains': 'youtube',
146 'pstMsg': '1',
147 'secTok': secTok,
148 'timeStmp': timeStmp,
149 'service': 'youtube',
150 'hl': 'en_US',
151 }
152 tfa_form = dict((k.encode('utf-8'), v.encode('utf-8')) for k,v in tfa_form_strs.items())
153 tfa_data = compat_urllib_parse.urlencode(tfa_form).encode('ascii')
154
155 tfa_req = compat_urllib_request.Request(self._TWOFACTOR_URL, tfa_data)
156 tfa_results = self._download_webpage(
157 tfa_req, None,
158 note='Submitting TFA code', errnote='unable to submit tfa', fatal=False)
159
160 if tfa_results is False:
161 return False
162
163 if re.search(r'(?i)<form[^>]* id="gaia_secondfactorform"', tfa_results) is not None:
164 self._downloader.report_warning('Two-factor code expired. Please try again, or use a one-use backup code instead.')
165 return False
166 if re.search(r'(?i)<form[^>]* id="gaia_loginform"', tfa_results) is not None:
167 self._downloader.report_warning('unable to log in - did the page structure change?')
168 return False
169 if re.search(r'smsauth-interstitial-reviewsettings', tfa_results) is not None:
170 self._downloader.report_warning('Your Google account has a security notice. Please log in on your web browser, resolve the notice, and try again.')
171 return False
172
173 if re.search(r'(?i)<form[^>]* id="gaia_loginform"', login_results) is not None:
174 self._downloader.report_warning('unable to log in: bad username or password')
175 return False
176 return True
177
178 def _confirm_age(self):
179 age_form = {
180 'next_url': '/',
181 'action_confirm': 'Confirm',
182 }
183 req = compat_urllib_request.Request(self._AGE_URL,
184 compat_urllib_parse.urlencode(age_form).encode('ascii'))
185
186 self._download_webpage(
187 req, None,
188 note='Confirming age', errnote='Unable to confirm age',
189 fatal=False)
190
191 def _real_initialize(self):
192 if self._downloader is None:
193 return
194 if self._get_login_info()[0] is not None:
195 if not self._set_language():
196 return
197 if not self._login():
198 return
199 self._confirm_age()
200
201
202 class YoutubeIE(YoutubeBaseInfoExtractor, SubtitlesInfoExtractor):
203 IE_DESC = 'YouTube.com'
204 _VALID_URL = r"""(?x)^
205 (
206 (?:https?://|//) # http(s):// or protocol-independent URL
207 (?:(?:(?:(?:\w+\.)?[yY][oO][uU][tT][uU][bB][eE](?:-nocookie)?\.com/|
208 (?:www\.)?deturl\.com/www\.youtube\.com/|
209 (?:www\.)?pwnyoutube\.com/|
210 (?:www\.)?yourepeat\.com/|
211 tube\.majestyc\.net/|
212 youtube\.googleapis\.com/) # the various hostnames, with wildcard subdomains
213 (?:.*?\#/)? # handle anchor (#/) redirect urls
214 (?: # the various things that can precede the ID:
215 (?:(?:v|embed|e)/(?!videoseries)) # v/ or embed/ or e/
216 |(?: # or the v= param in all its forms
217 (?:(?:watch|movie)(?:_popup)?(?:\.php)?/?)? # preceding watch(_popup|.php) or nothing (like /?v=xxxx)
218 (?:\?|\#!?) # the params delimiter ? or # or #!
219 (?:.*?&)? # any other preceding param (like /?s=tuff&v=xxxx)
220 v=
221 )
222 ))
223 |youtu\.be/ # just youtu.be/xxxx
224 |(?:www\.)?cleanvideosearch\.com/media/action/yt/watch\?videoId=
225 )
226 )? # all until now is optional -> you can pass the naked ID
227 ([0-9A-Za-z_-]{11}) # here is it! the YouTube video ID
228 (?!.*?&list=) # combined list/video URLs are handled by the playlist IE
229 (?(1).+)? # if we found the ID, everything can follow
230 $"""
231 _NEXT_URL_RE = r'[\?&]next_url=([^&]+)'
232 _formats = {
233 '5': {'ext': 'flv', 'width': 400, 'height': 240},
234 '6': {'ext': 'flv', 'width': 450, 'height': 270},
235 '13': {'ext': '3gp'},
236 '17': {'ext': '3gp', 'width': 176, 'height': 144},
237 '18': {'ext': 'mp4', 'width': 640, 'height': 360},
238 '22': {'ext': 'mp4', 'width': 1280, 'height': 720},
239 '34': {'ext': 'flv', 'width': 640, 'height': 360},
240 '35': {'ext': 'flv', 'width': 854, 'height': 480},
241 '36': {'ext': '3gp', 'width': 320, 'height': 240},
242 '37': {'ext': 'mp4', 'width': 1920, 'height': 1080},
243 '38': {'ext': 'mp4', 'width': 4096, 'height': 3072},
244 '43': {'ext': 'webm', 'width': 640, 'height': 360},
245 '44': {'ext': 'webm', 'width': 854, 'height': 480},
246 '45': {'ext': 'webm', 'width': 1280, 'height': 720},
247 '46': {'ext': 'webm', 'width': 1920, 'height': 1080},
248
249
250 # 3d videos
251 '82': {'ext': 'mp4', 'height': 360, 'format_note': '3D', 'preference': -20},
252 '83': {'ext': 'mp4', 'height': 480, 'format_note': '3D', 'preference': -20},
253 '84': {'ext': 'mp4', 'height': 720, 'format_note': '3D', 'preference': -20},
254 '85': {'ext': 'mp4', 'height': 1080, 'format_note': '3D', 'preference': -20},
255 '100': {'ext': 'webm', 'height': 360, 'format_note': '3D', 'preference': -20},
256 '101': {'ext': 'webm', 'height': 480, 'format_note': '3D', 'preference': -20},
257 '102': {'ext': 'webm', 'height': 720, 'format_note': '3D', 'preference': -20},
258
259 # Apple HTTP Live Streaming
260 '92': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'preference': -10},
261 '93': {'ext': 'mp4', 'height': 360, 'format_note': 'HLS', 'preference': -10},
262 '94': {'ext': 'mp4', 'height': 480, 'format_note': 'HLS', 'preference': -10},
263 '95': {'ext': 'mp4', 'height': 720, 'format_note': 'HLS', 'preference': -10},
264 '96': {'ext': 'mp4', 'height': 1080, 'format_note': 'HLS', 'preference': -10},
265 '132': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'preference': -10},
266 '151': {'ext': 'mp4', 'height': 72, 'format_note': 'HLS', 'preference': -10},
267
268 # DASH mp4 video
269 '133': {'ext': 'mp4', 'height': 240, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
270 '134': {'ext': 'mp4', 'height': 360, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
271 '135': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
272 '136': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
273 '137': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
274 '138': {'ext': 'mp4', 'height': 2160, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
275 '160': {'ext': 'mp4', 'height': 144, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
276 '264': {'ext': 'mp4', 'height': 1440, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
277
278 # Dash mp4 audio
279 '139': {'ext': 'm4a', 'format_note': 'DASH audio', 'vcodec': 'none', 'abr': 48, 'preference': -50},
280 '140': {'ext': 'm4a', 'format_note': 'DASH audio', 'vcodec': 'none', 'abr': 128, 'preference': -50},
281 '141': {'ext': 'm4a', 'format_note': 'DASH audio', 'vcodec': 'none', 'abr': 256, 'preference': -50},
282
283 # Dash webm
284 '167': {'ext': 'webm', 'height': 360, 'width': 640, 'format_note': 'DASH video', 'acodec': 'none', 'container': 'webm', 'vcodec': 'VP8', 'preference': -40},
285 '168': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'acodec': 'none', 'container': 'webm', 'vcodec': 'VP8', 'preference': -40},
286 '169': {'ext': 'webm', 'height': 720, 'width': 1280, 'format_note': 'DASH video', 'acodec': 'none', 'container': 'webm', 'vcodec': 'VP8', 'preference': -40},
287 '170': {'ext': 'webm', 'height': 1080, 'width': 1920, 'format_note': 'DASH video', 'acodec': 'none', 'container': 'webm', 'vcodec': 'VP8', 'preference': -40},
288 '218': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'acodec': 'none', 'container': 'webm', 'vcodec': 'VP8', 'preference': -40},
289 '219': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'acodec': 'none', 'container': 'webm', 'vcodec': 'VP8', 'preference': -40},
290 '278': {'ext': 'webm', 'height': 144, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40, 'container': 'webm', 'vcodec': 'VP9'},
291 '242': {'ext': 'webm', 'height': 240, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
292 '243': {'ext': 'webm', 'height': 360, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
293 '244': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
294 '245': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
295 '246': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
296 '247': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
297 '248': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
298 '271': {'ext': 'webm', 'height': 1440, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
299 '272': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40},
300 '302': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40, 'fps': 60, 'vcodec': 'VP9'},
301 '303': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40, 'fps': 60, 'vcodec': 'VP9'},
302
303 # Dash webm audio
304 '171': {'ext': 'webm', 'vcodec': 'none', 'format_note': 'DASH audio', 'abr': 128, 'preference': -50},
305 '172': {'ext': 'webm', 'vcodec': 'none', 'format_note': 'DASH audio', 'abr': 256, 'preference': -50},
306
307 # Dash mov
308 '298': {'ext': 'mov', 'height': 720, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40, 'fps': 60, 'vcodec': 'h264'},
309 '299': {'ext': 'mov', 'height': 1080, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40, 'fps': 60, 'vcodec': 'h264'},
310 '266': {'ext': 'mov', 'height': 2160, 'format_note': 'DASH video', 'acodec': 'none', 'preference': -40, 'vcodec': 'h264'},
311
312 # RTMP (unnamed)
313 '_rtmp': {'protocol': 'rtmp'},
314 }
315
316 IE_NAME = 'youtube'
317 _TESTS = [
318 {
319 'url': 'http://www.youtube.com/watch?v=BaW_jenozKc',
320 'info_dict': {
321 'id': 'BaW_jenozKc',
322 'ext': 'mp4',
323 'title': 'youtube-dl test video "\'/\\ä↭𝕐',
324 'uploader': 'Philipp Hagemeister',
325 'uploader_id': 'phihag',
326 'upload_date': '20121002',
327 'description': 'test chars: "\'/\\ä↭𝕐\ntest URL: https://github.com/rg3/youtube-dl/issues/1892\n\nThis is a test video for youtube-dl.\n\nFor more information, contact phihag@phihag.de .',
328 'categories': ['Science & Technology'],
329 'like_count': int,
330 'dislike_count': int,
331 }
332 },
333 {
334 'url': 'http://www.youtube.com/watch?v=UxxajLWwzqY',
335 'note': 'Test generic use_cipher_signature video (#897)',
336 'info_dict': {
337 'id': 'UxxajLWwzqY',
338 'ext': 'mp4',
339 'upload_date': '20120506',
340 'title': 'Icona Pop - I Love It (feat. Charli XCX) [OFFICIAL VIDEO]',
341 'description': 'md5:fea86fda2d5a5784273df5c7cc994d9f',
342 'uploader': 'Icona Pop',
343 'uploader_id': 'IconaPop',
344 }
345 },
346 {
347 'url': 'https://www.youtube.com/watch?v=07FYdnEawAQ',
348 'note': 'Test VEVO video with age protection (#956)',
349 'info_dict': {
350 'id': '07FYdnEawAQ',
351 'ext': 'mp4',
352 'upload_date': '20130703',
353 'title': 'Justin Timberlake - Tunnel Vision (Explicit)',
354 'description': 'md5:64249768eec3bc4276236606ea996373',
355 'uploader': 'justintimberlakeVEVO',
356 'uploader_id': 'justintimberlakeVEVO',
357 }
358 },
359 {
360 'url': '//www.YouTube.com/watch?v=yZIXLfi8CZQ',
361 'note': 'Embed-only video (#1746)',
362 'info_dict': {
363 'id': 'yZIXLfi8CZQ',
364 'ext': 'mp4',
365 'upload_date': '20120608',
366 'title': 'Principal Sexually Assaults A Teacher - Episode 117 - 8th June 2012',
367 'description': 'md5:09b78bd971f1e3e289601dfba15ca4f7',
368 'uploader': 'SET India',
369 'uploader_id': 'setindia'
370 }
371 },
372 {
373 'url': 'http://www.youtube.com/watch?v=a9LDPn-MO4I',
374 'note': '256k DASH audio (format 141) via DASH manifest',
375 'info_dict': {
376 'id': 'a9LDPn-MO4I',
377 'ext': 'm4a',
378 'upload_date': '20121002',
379 'uploader_id': '8KVIDEO',
380 'description': '',
381 'uploader': '8KVIDEO',
382 'title': 'UHDTV TEST 8K VIDEO.mp4'
383 },
384 'params': {
385 'youtube_include_dash_manifest': True,
386 'format': '141',
387 },
388 },
389 # DASH manifest with encrypted signature
390 {
391 'url': 'https://www.youtube.com/watch?v=IB3lcPjvWLA',
392 'info_dict': {
393 'id': 'IB3lcPjvWLA',
394 'ext': 'm4a',
395 'title': 'Afrojack - The Spark ft. Spree Wilson',
396 'description': 'md5:9717375db5a9a3992be4668bbf3bc0a8',
397 'uploader': 'AfrojackVEVO',
398 'uploader_id': 'AfrojackVEVO',
399 'upload_date': '20131011',
400 },
401 'params': {
402 'youtube_include_dash_manifest': True,
403 'format': '141',
404 },
405 },
406 ]
407
408 def __init__(self, *args, **kwargs):
409 super(YoutubeIE, self).__init__(*args, **kwargs)
410 self._player_cache = {}
411
412 def report_video_info_webpage_download(self, video_id):
413 """Report attempt to download video info webpage."""
414 self.to_screen('%s: Downloading video info webpage' % video_id)
415
416 def report_information_extraction(self, video_id):
417 """Report attempt to extract video information."""
418 self.to_screen('%s: Extracting video information' % video_id)
419
420 def report_unavailable_format(self, video_id, format):
421 """Report extracted video URL."""
422 self.to_screen('%s: Format %s not available' % (video_id, format))
423
424 def report_rtmp_download(self):
425 """Indicate the download will use the RTMP protocol."""
426 self.to_screen('RTMP download detected')
427
428 def _signature_cache_id(self, example_sig):
429 """ Return a string representation of a signature """
430 return '.'.join(compat_str(len(part)) for part in example_sig.split('.'))
431
432 def _extract_signature_function(self, video_id, player_url, example_sig):
433 id_m = re.match(
434 r'.*-(?P<id>[a-zA-Z0-9_-]+)(?:/watch_as3|/html5player)?\.(?P<ext>[a-z]+)$',
435 player_url)
436 if not id_m:
437 raise ExtractorError('Cannot identify player %r' % player_url)
438 player_type = id_m.group('ext')
439 player_id = id_m.group('id')
440
441 # Read from filesystem cache
442 func_id = '%s_%s_%s' % (
443 player_type, player_id, self._signature_cache_id(example_sig))
444 assert os.path.basename(func_id) == func_id
445
446 cache_spec = self._downloader.cache.load('youtube-sigfuncs', func_id)
447 if cache_spec is not None:
448 return lambda s: ''.join(s[i] for i in cache_spec)
449
450 if player_type == 'js':
451 code = self._download_webpage(
452 player_url, video_id,
453 note='Downloading %s player %s' % (player_type, player_id),
454 errnote='Download of %s failed' % player_url)
455 res = self._parse_sig_js(code)
456 elif player_type == 'swf':
457 urlh = self._request_webpage(
458 player_url, video_id,
459 note='Downloading %s player %s' % (player_type, player_id),
460 errnote='Download of %s failed' % player_url)
461 code = urlh.read()
462 res = self._parse_sig_swf(code)
463 else:
464 assert False, 'Invalid player type %r' % player_type
465
466 if cache_spec is None:
467 test_string = ''.join(map(compat_chr, range(len(example_sig))))
468 cache_res = res(test_string)
469 cache_spec = [ord(c) for c in cache_res]
470
471 self._downloader.cache.store('youtube-sigfuncs', func_id, cache_spec)
472 return res
473
474 def _print_sig_code(self, func, example_sig):
475 def gen_sig_code(idxs):
476 def _genslice(start, end, step):
477 starts = '' if start == 0 else str(start)
478 ends = (':%d' % (end+step)) if end + step >= 0 else ':'
479 steps = '' if step == 1 else (':%d' % step)
480 return 's[%s%s%s]' % (starts, ends, steps)
481
482 step = None
483 start = '(Never used)' # Quelch pyflakes warnings - start will be
484 # set as soon as step is set
485 for i, prev in zip(idxs[1:], idxs[:-1]):
486 if step is not None:
487 if i - prev == step:
488 continue
489 yield _genslice(start, prev, step)
490 step = None
491 continue
492 if i - prev in [-1, 1]:
493 step = i - prev
494 start = prev
495 continue
496 else:
497 yield 's[%d]' % prev
498 if step is None:
499 yield 's[%d]' % i
500 else:
501 yield _genslice(start, i, step)
502
503 test_string = ''.join(map(compat_chr, range(len(example_sig))))
504 cache_res = func(test_string)
505 cache_spec = [ord(c) for c in cache_res]
506 expr_code = ' + '.join(gen_sig_code(cache_spec))
507 signature_id_tuple = '(%s)' % (
508 ', '.join(compat_str(len(p)) for p in example_sig.split('.')))
509 code = ('if tuple(len(p) for p in s.split(\'.\')) == %s:\n'
510 ' return %s\n') % (signature_id_tuple, expr_code)
511 self.to_screen('Extracted signature function:\n' + code)
512
513 def _parse_sig_js(self, jscode):
514 funcname = self._search_regex(
515 r'signature=([$a-zA-Z]+)', jscode,
516 'Initial JS player signature function name')
517
518 jsi = JSInterpreter(jscode)
519 initial_function = jsi.extract_function(funcname)
520 return lambda s: initial_function([s])
521
522 def _parse_sig_swf(self, file_contents):
523 swfi = SWFInterpreter(file_contents)
524 TARGET_CLASSNAME = 'SignatureDecipher'
525 searched_class = swfi.extract_class(TARGET_CLASSNAME)
526 initial_function = swfi.extract_function(searched_class, 'decipher')
527 return lambda s: initial_function([s])
528
529 def _decrypt_signature(self, s, video_id, player_url, age_gate=False):
530 """Turn the encrypted s field into a working signature"""
531
532 if player_url is None:
533 raise ExtractorError('Cannot decrypt signature without player_url')
534
535 if player_url.startswith('//'):
536 player_url = 'https:' + player_url
537 try:
538 player_id = (player_url, self._signature_cache_id(s))
539 if player_id not in self._player_cache:
540 func = self._extract_signature_function(
541 video_id, player_url, s
542 )
543 self._player_cache[player_id] = func
544 func = self._player_cache[player_id]
545 if self._downloader.params.get('youtube_print_sig_code'):
546 self._print_sig_code(func, s)
547 return func(s)
548 except Exception as e:
549 tb = traceback.format_exc()
550 raise ExtractorError(
551 'Signature extraction failed: ' + tb, cause=e)
552
553 def _get_available_subtitles(self, video_id, webpage):
554 try:
555 sub_list = self._download_webpage(
556 'https://video.google.com/timedtext?hl=en&type=list&v=%s' % video_id,
557 video_id, note=False)
558 except ExtractorError as err:
559 self._downloader.report_warning('unable to download video subtitles: %s' % compat_str(err))
560 return {}
561 lang_list = re.findall(r'name="([^"]*)"[^>]+lang_code="([\w\-]+)"', sub_list)
562
563 sub_lang_list = {}
564 for l in lang_list:
565 lang = l[1]
566 if lang in sub_lang_list:
567 continue
568 params = compat_urllib_parse.urlencode({
569 'lang': lang,
570 'v': video_id,
571 'fmt': self._downloader.params.get('subtitlesformat', 'srt'),
572 'name': unescapeHTML(l[0]).encode('utf-8'),
573 })
574 url = 'https://www.youtube.com/api/timedtext?' + params
575 sub_lang_list[lang] = url
576 if not sub_lang_list:
577 self._downloader.report_warning('video doesn\'t have subtitles')
578 return {}
579 return sub_lang_list
580
581 def _get_available_automatic_caption(self, video_id, webpage):
582 """We need the webpage for getting the captions url, pass it as an
583 argument to speed up the process."""
584 sub_format = self._downloader.params.get('subtitlesformat', 'srt')
585 self.to_screen('%s: Looking for automatic captions' % video_id)
586 mobj = re.search(r';ytplayer.config = ({.*?});', webpage)
587 err_msg = 'Couldn\'t find automatic captions for %s' % video_id
588 if mobj is None:
589 self._downloader.report_warning(err_msg)
590 return {}
591 player_config = json.loads(mobj.group(1))
592 try:
593 args = player_config[u'args']
594 caption_url = args[u'ttsurl']
595 timestamp = args[u'timestamp']
596 # We get the available subtitles
597 list_params = compat_urllib_parse.urlencode({
598 'type': 'list',
599 'tlangs': 1,
600 'asrs': 1,
601 })
602 list_url = caption_url + '&' + list_params
603 caption_list = self._download_xml(list_url, video_id)
604 original_lang_node = caption_list.find('track')
605 if original_lang_node is None or original_lang_node.attrib.get('kind') != 'asr' :
606 self._downloader.report_warning('Video doesn\'t have automatic captions')
607 return {}
608 original_lang = original_lang_node.attrib['lang_code']
609
610 sub_lang_list = {}
611 for lang_node in caption_list.findall('target'):
612 sub_lang = lang_node.attrib['lang_code']
613 params = compat_urllib_parse.urlencode({
614 'lang': original_lang,
615 'tlang': sub_lang,
616 'fmt': sub_format,
617 'ts': timestamp,
618 'kind': 'asr',
619 })
620 sub_lang_list[sub_lang] = caption_url + '&' + params
621 return sub_lang_list
622 # An extractor error can be raise by the download process if there are
623 # no automatic captions but there are subtitles
624 except (KeyError, ExtractorError):
625 self._downloader.report_warning(err_msg)
626 return {}
627
628 @classmethod
629 def extract_id(cls, url):
630 mobj = re.match(cls._VALID_URL, url, re.VERBOSE)
631 if mobj is None:
632 raise ExtractorError('Invalid URL: %s' % url)
633 video_id = mobj.group(2)
634 return video_id
635
636 def _extract_from_m3u8(self, manifest_url, video_id):
637 url_map = {}
638 def _get_urls(_manifest):
639 lines = _manifest.split('\n')
640 urls = filter(lambda l: l and not l.startswith('#'),
641 lines)
642 return urls
643 manifest = self._download_webpage(manifest_url, video_id, 'Downloading formats manifest')
644 formats_urls = _get_urls(manifest)
645 for format_url in formats_urls:
646 itag = self._search_regex(r'itag/(\d+?)/', format_url, 'itag')
647 url_map[itag] = format_url
648 return url_map
649
650 def _extract_annotations(self, video_id):
651 url = 'https://www.youtube.com/annotations_invideo?features=1&legacy=1&video_id=%s' % video_id
652 return self._download_webpage(url, video_id, note='Searching for annotations.', errnote='Unable to download video annotations.')
653
654 def _real_extract(self, url):
655 proto = (
656 'http' if self._downloader.params.get('prefer_insecure', False)
657 else 'https')
658
659 # Extract original video URL from URL with redirection, like age verification, using next_url parameter
660 mobj = re.search(self._NEXT_URL_RE, url)
661 if mobj:
662 url = proto + '://www.youtube.com/' + compat_urllib_parse.unquote(mobj.group(1)).lstrip('/')
663 video_id = self.extract_id(url)
664
665 # Get video webpage
666 url = proto + '://www.youtube.com/watch?v=%s&gl=US&hl=en&has_verified=1' % video_id
667 pref_cookies = [
668 c for c in self._downloader.cookiejar
669 if c.domain == '.youtube.com' and c.name == 'PREF']
670 for pc in pref_cookies:
671 if 'hl=' in pc.value:
672 pc.value = re.sub(r'hl=[^&]+', 'hl=en', pc.value)
673 else:
674 if pc.value:
675 pc.value += '&'
676 pc.value += 'hl=en'
677 video_webpage = self._download_webpage(url, video_id)
678
679 # Attempt to extract SWF player URL
680 mobj = re.search(r'swfConfig.*?"(https?:\\/\\/.*?watch.*?-.*?\.swf)"', video_webpage)
681 if mobj is not None:
682 player_url = re.sub(r'\\(.)', r'\1', mobj.group(1))
683 else:
684 player_url = None
685
686 # Get video info
687 self.report_video_info_webpage_download(video_id)
688 if re.search(r'player-age-gate-content">', video_webpage) is not None:
689 self.report_age_confirmation()
690 age_gate = True
691 # We simulate the access to the video from www.youtube.com/v/{video_id}
692 # this can be viewed without login into Youtube
693 data = compat_urllib_parse.urlencode({
694 'video_id': video_id,
695 'eurl': 'https://youtube.googleapis.com/v/' + video_id,
696 'sts': self._search_regex(
697 r'"sts"\s*:\s*(\d+)', video_webpage, 'sts'),
698 })
699 video_info_url = proto + '://www.youtube.com/get_video_info?' + data
700 video_info_webpage = self._download_webpage(video_info_url, video_id,
701 note=False,
702 errnote='unable to download video info webpage')
703 video_info = compat_parse_qs(video_info_webpage)
704 else:
705 age_gate = False
706 for el_type in ['&el=embedded', '&el=detailpage', '&el=vevo', '']:
707 video_info_url = (proto + '://www.youtube.com/get_video_info?&video_id=%s%s&ps=default&eurl=&gl=US&hl=en'
708 % (video_id, el_type))
709 video_info_webpage = self._download_webpage(video_info_url, video_id,
710 note=False,
711 errnote='unable to download video info webpage')
712 video_info = compat_parse_qs(video_info_webpage)
713 if 'token' in video_info:
714 break
715 if 'token' not in video_info:
716 if 'reason' in video_info:
717 raise ExtractorError(
718 'YouTube said: %s' % video_info['reason'][0],
719 expected=True, video_id=video_id)
720 else:
721 raise ExtractorError(
722 '"token" parameter not in video info for unknown reason',
723 video_id=video_id)
724
725 if 'view_count' in video_info:
726 view_count = int(video_info['view_count'][0])
727 else:
728 view_count = None
729
730 # Check for "rental" videos
731 if 'ypc_video_rental_bar_text' in video_info and 'author' not in video_info:
732 raise ExtractorError('"rental" videos not supported')
733
734 # Start extracting information
735 self.report_information_extraction(video_id)
736
737 # uploader
738 if 'author' not in video_info:
739 raise ExtractorError('Unable to extract uploader name')
740 video_uploader = compat_urllib_parse.unquote_plus(video_info['author'][0])
741
742 # uploader_id
743 video_uploader_id = None
744 mobj = re.search(r'<link itemprop="url" href="http://www.youtube.com/(?:user|channel)/([^"]+)">', video_webpage)
745 if mobj is not None:
746 video_uploader_id = mobj.group(1)
747 else:
748 self._downloader.report_warning('unable to extract uploader nickname')
749
750 # title
751 if 'title' in video_info:
752 video_title = video_info['title'][0]
753 else:
754 self._downloader.report_warning('Unable to extract video title')
755 video_title = '_'
756
757 # thumbnail image
758 # We try first to get a high quality image:
759 m_thumb = re.search(r'<span itemprop="thumbnail".*?href="(.*?)">',
760 video_webpage, re.DOTALL)
761 if m_thumb is not None:
762 video_thumbnail = m_thumb.group(1)
763 elif 'thumbnail_url' not in video_info:
764 self._downloader.report_warning('unable to extract video thumbnail')
765 video_thumbnail = None
766 else: # don't panic if we can't find it
767 video_thumbnail = compat_urllib_parse.unquote_plus(video_info['thumbnail_url'][0])
768
769 # upload date
770 upload_date = None
771 mobj = re.search(r'(?s)id="eow-date.*?>(.*?)</span>', video_webpage)
772 if mobj is None:
773 mobj = re.search(
774 r'(?s)id="watch-uploader-info".*?>.*?(?:Published|Uploaded|Streamed live) on (.*?)</strong>',
775 video_webpage)
776 if mobj is not None:
777 upload_date = ' '.join(re.sub(r'[/,-]', r' ', mobj.group(1)).split())
778 upload_date = unified_strdate(upload_date)
779
780 m_cat_container = self._search_regex(
781 r'(?s)<h4[^>]*>\s*Category\s*</h4>\s*<ul[^>]*>(.*?)</ul>',
782 video_webpage, 'categories', fatal=False)
783 if m_cat_container:
784 category = self._html_search_regex(
785 r'(?s)<a[^<]+>(.*?)</a>', m_cat_container, 'category',
786 default=None)
787 video_categories = None if category is None else [category]
788 else:
789 video_categories = None
790
791 # description
792 video_description = get_element_by_id("eow-description", video_webpage)
793 if video_description:
794 video_description = re.sub(r'''(?x)
795 <a\s+
796 (?:[a-zA-Z-]+="[^"]+"\s+)*?
797 title="([^"]+)"\s+
798 (?:[a-zA-Z-]+="[^"]+"\s+)*?
799 class="yt-uix-redirect-link"\s*>
800 [^<]+
801 </a>
802 ''', r'\1', video_description)
803 video_description = clean_html(video_description)
804 else:
805 fd_mobj = re.search(r'<meta name="description" content="([^"]+)"', video_webpage)
806 if fd_mobj:
807 video_description = unescapeHTML(fd_mobj.group(1))
808 else:
809 video_description = ''
810
811 def _extract_count(count_name):
812 count = self._search_regex(
813 r'id="watch-%s"[^>]*>.*?([\d,]+)\s*</span>' % re.escape(count_name),
814 video_webpage, count_name, default=None)
815 if count is not None:
816 return int(count.replace(',', ''))
817 return None
818 like_count = _extract_count('like')
819 dislike_count = _extract_count('dislike')
820
821 # subtitles
822 video_subtitles = self.extract_subtitles(video_id, video_webpage)
823
824 if self._downloader.params.get('listsubtitles', False):
825 self._list_available_subtitles(video_id, video_webpage)
826 return
827
828 if 'length_seconds' not in video_info:
829 self._downloader.report_warning('unable to extract video duration')
830 video_duration = None
831 else:
832 video_duration = int(compat_urllib_parse.unquote_plus(video_info['length_seconds'][0]))
833
834 # annotations
835 video_annotations = None
836 if self._downloader.params.get('writeannotations', False):
837 video_annotations = self._extract_annotations(video_id)
838
839 # Decide which formats to download
840 try:
841 mobj = re.search(r';ytplayer\.config\s*=\s*({.*?});', video_webpage)
842 if not mobj:
843 raise ValueError('Could not find vevo ID')
844 json_code = uppercase_escape(mobj.group(1))
845 ytplayer_config = json.loads(json_code)
846 args = ytplayer_config['args']
847 # Easy way to know if the 's' value is in url_encoded_fmt_stream_map
848 # this signatures are encrypted
849 if 'url_encoded_fmt_stream_map' not in args:
850 raise ValueError('No stream_map present') # caught below
851 re_signature = re.compile(r'[&,]s=')
852 m_s = re_signature.search(args['url_encoded_fmt_stream_map'])
853 if m_s is not None:
854 self.to_screen('%s: Encrypted signatures detected.' % video_id)
855 video_info['url_encoded_fmt_stream_map'] = [args['url_encoded_fmt_stream_map']]
856 m_s = re_signature.search(args.get('adaptive_fmts', ''))
857 if m_s is not None:
858 if 'adaptive_fmts' in video_info:
859 video_info['adaptive_fmts'][0] += ',' + args['adaptive_fmts']
860 else:
861 video_info['adaptive_fmts'] = [args['adaptive_fmts']]
862 except ValueError:
863 pass
864
865 def _map_to_format_list(urlmap):
866 formats = []
867 for itag, video_real_url in urlmap.items():
868 dct = {
869 'format_id': itag,
870 'url': video_real_url,
871 'player_url': player_url,
872 }
873 if itag in self._formats:
874 dct.update(self._formats[itag])
875 formats.append(dct)
876 return formats
877
878 if 'conn' in video_info and video_info['conn'][0].startswith('rtmp'):
879 self.report_rtmp_download()
880 formats = [{
881 'format_id': '_rtmp',
882 'protocol': 'rtmp',
883 'url': video_info['conn'][0],
884 'player_url': player_url,
885 }]
886 elif len(video_info.get('url_encoded_fmt_stream_map', [])) >= 1 or len(video_info.get('adaptive_fmts', [])) >= 1:
887 encoded_url_map = video_info.get('url_encoded_fmt_stream_map', [''])[0] + ',' + video_info.get('adaptive_fmts',[''])[0]
888 if 'rtmpe%3Dyes' in encoded_url_map:
889 raise ExtractorError('rtmpe downloads are not supported, see https://github.com/rg3/youtube-dl/issues/343 for more information.', expected=True)
890 url_map = {}
891 for url_data_str in encoded_url_map.split(','):
892 url_data = compat_parse_qs(url_data_str)
893 if 'itag' not in url_data or 'url' not in url_data:
894 continue
895 format_id = url_data['itag'][0]
896 url = url_data['url'][0]
897
898 if 'sig' in url_data:
899 url += '&signature=' + url_data['sig'][0]
900 elif 's' in url_data:
901 encrypted_sig = url_data['s'][0]
902
903 if not age_gate:
904 jsplayer_url_json = self._search_regex(
905 r'"assets":.+?"js":\s*("[^"]+")',
906 video_webpage, 'JS player URL')
907 player_url = json.loads(jsplayer_url_json)
908 if player_url is None:
909 player_url_json = self._search_regex(
910 r'ytplayer\.config.*?"url"\s*:\s*("[^"]+")',
911 video_webpage, 'age gate player URL')
912 player_url = json.loads(player_url_json)
913
914 if self._downloader.params.get('verbose'):
915 if player_url is None:
916 player_version = 'unknown'
917 player_desc = 'unknown'
918 else:
919 if player_url.endswith('swf'):
920 player_version = self._search_regex(
921 r'-(.+?)(?:/watch_as3)?\.swf$', player_url,
922 'flash player', fatal=False)
923 player_desc = 'flash player %s' % player_version
924 else:
925 player_version = self._search_regex(
926 r'html5player-([^/]+?)(?:/html5player)?\.js',
927 player_url,
928 'html5 player', fatal=False)
929 player_desc = 'html5 player %s' % player_version
930
931 parts_sizes = self._signature_cache_id(encrypted_sig)
932 self.to_screen('{%s} signature length %s, %s' %
933 (format_id, parts_sizes, player_desc))
934
935 signature = self._decrypt_signature(
936 encrypted_sig, video_id, player_url, age_gate)
937 url += '&signature=' + signature
938 if 'ratebypass' not in url:
939 url += '&ratebypass=yes'
940 url_map[format_id] = url
941 formats = _map_to_format_list(url_map)
942 elif video_info.get('hlsvp'):
943 manifest_url = video_info['hlsvp'][0]
944 url_map = self._extract_from_m3u8(manifest_url, video_id)
945 formats = _map_to_format_list(url_map)
946 else:
947 raise ExtractorError('no conn, hlsvp or url_encoded_fmt_stream_map information found in video info')
948
949 # Look for the DASH manifest
950 if self._downloader.params.get('youtube_include_dash_manifest', True):
951 try:
952 # The DASH manifest used needs to be the one from the original video_webpage.
953 # The one found in get_video_info seems to be using different signatures.
954 # However, in the case of an age restriction there won't be any embedded dashmpd in the video_webpage.
955 # Luckily, it seems, this case uses some kind of default signature (len == 86), so the
956 # combination of get_video_info and the _static_decrypt_signature() decryption fallback will work here.
957 if age_gate:
958 dash_manifest_url = video_info.get('dashmpd')[0]
959 else:
960 dash_manifest_url = ytplayer_config['args']['dashmpd']
961 def decrypt_sig(mobj):
962 s = mobj.group(1)
963 dec_s = self._decrypt_signature(s, video_id, player_url, age_gate)
964 return '/signature/%s' % dec_s
965 dash_manifest_url = re.sub(r'/s/([\w\.]+)', decrypt_sig, dash_manifest_url)
966 dash_doc = self._download_xml(
967 dash_manifest_url, video_id,
968 note='Downloading DASH manifest',
969 errnote='Could not download DASH manifest')
970 for r in dash_doc.findall('.//{urn:mpeg:DASH:schema:MPD:2011}Representation'):
971 url_el = r.find('{urn:mpeg:DASH:schema:MPD:2011}BaseURL')
972 if url_el is None:
973 continue
974 format_id = r.attrib['id']
975 video_url = url_el.text
976 filesize = int_or_none(url_el.attrib.get('{http://youtube.com/yt/2012/10/10}contentLength'))
977 f = {
978 'format_id': format_id,
979 'url': video_url,
980 'width': int_or_none(r.attrib.get('width')),
981 'tbr': int_or_none(r.attrib.get('bandwidth'), 1000),
982 'asr': int_or_none(r.attrib.get('audioSamplingRate')),
983 'filesize': filesize,
984 }
985 try:
986 existing_format = next(
987 fo for fo in formats
988 if fo['format_id'] == format_id)
989 except StopIteration:
990 f.update(self._formats.get(format_id, {}))
991 formats.append(f)
992 else:
993 existing_format.update(f)
994
995 except (ExtractorError, KeyError) as e:
996 self.report_warning('Skipping DASH manifest: %s' % e, video_id)
997
998 self._sort_formats(formats)
999
1000 return {
1001 'id': video_id,
1002 'uploader': video_uploader,
1003 'uploader_id': video_uploader_id,
1004 'upload_date': upload_date,
1005 'title': video_title,
1006 'thumbnail': video_thumbnail,
1007 'description': video_description,
1008 'categories': video_categories,
1009 'subtitles': video_subtitles,
1010 'duration': video_duration,
1011 'age_limit': 18 if age_gate else 0,
1012 'annotations': video_annotations,
1013 'webpage_url': proto + '://www.youtube.com/watch?v=%s' % video_id,
1014 'view_count': view_count,
1015 'like_count': like_count,
1016 'dislike_count': dislike_count,
1017 'formats': formats,
1018 }
1019
1020 class YoutubePlaylistIE(YoutubeBaseInfoExtractor):
1021 IE_DESC = 'YouTube.com playlists'
1022 _VALID_URL = r"""(?x)(?:
1023 (?:https?://)?
1024 (?:\w+\.)?
1025 youtube\.com/
1026 (?:
1027 (?:course|view_play_list|my_playlists|artist|playlist|watch|embed/videoseries)
1028 \? (?:.*?&)*? (?:p|a|list)=
1029 | p/
1030 )
1031 (
1032 (?:PL|LL|EC|UU|FL|RD)?[0-9A-Za-z-_]{10,}
1033 # Top tracks, they can also include dots
1034 |(?:MC)[\w\.]*
1035 )
1036 .*
1037 |
1038 ((?:PL|LL|EC|UU|FL|RD)[0-9A-Za-z-_]{10,})
1039 )"""
1040 _TEMPLATE_URL = 'https://www.youtube.com/playlist?list=%s'
1041 _MORE_PAGES_INDICATOR = r'data-link-type="next"'
1042 _VIDEO_RE = r'href="\s*/watch\?v=(?P<id>[0-9A-Za-z_-]{11})&amp;[^"]*?index=(?P<index>\d+)'
1043 IE_NAME = 'youtube:playlist'
1044 _TESTS = [{
1045 'url': 'https://www.youtube.com/playlist?list=PLwiyx1dc3P2JR9N8gQaQN_BCvlSlap7re',
1046 'info_dict': {
1047 'title': 'ytdl test PL',
1048 },
1049 'playlist_count': 3,
1050 }, {
1051 'url': 'https://www.youtube.com/playlist?list=PLtPgu7CB4gbZDA7i_euNxn75ISqxwZPYx',
1052 'info_dict': {
1053 'title': 'YDL_Empty_List',
1054 },
1055 'playlist_count': 0,
1056 }, {
1057 'note': 'Playlist with deleted videos (#651). As a bonus, the video #51 is also twice in this list.',
1058 'url': 'https://www.youtube.com/playlist?list=PLwP_SiAcdui0KVebT0mU9Apz359a4ubsC',
1059 'info_dict': {
1060 'title': '29C3: Not my department',
1061 },
1062 'playlist_count': 95,
1063 }, {
1064 'note': 'issue #673',
1065 'url': 'PLBB231211A4F62143',
1066 'info_dict': {
1067 'title': '[OLD]Team Fortress 2 (Class-based LP)',
1068 },
1069 'playlist_mincount': 26,
1070 }, {
1071 'note': 'Large playlist',
1072 'url': 'https://www.youtube.com/playlist?list=UUBABnxM4Ar9ten8Mdjj1j0Q',
1073 'info_dict': {
1074 'title': 'Uploads from Cauchemar',
1075 },
1076 'playlist_mincount': 799,
1077 }, {
1078 'url': 'PLtPgu7CB4gbY9oDN3drwC3cMbJggS7dKl',
1079 'info_dict': {
1080 'title': 'YDL_safe_search',
1081 },
1082 'playlist_count': 2,
1083 }, {
1084 'note': 'embedded',
1085 'url': 'http://www.youtube.com/embed/videoseries?list=PL6IaIsEjSbf96XFRuNccS_RuEXwNdsoEu',
1086 'playlist_count': 4,
1087 'info_dict': {
1088 'title': 'JODA15',
1089 }
1090 }, {
1091 'note': 'Embedded SWF player',
1092 'url': 'http://www.youtube.com/p/YN5VISEtHet5D4NEvfTd0zcgFk84NqFZ?hl=en_US&fs=1&rel=0',
1093 'playlist_count': 4,
1094 'info_dict': {
1095 'title': 'JODA7',
1096 }
1097 }]
1098
1099 def _real_initialize(self):
1100 self._login()
1101
1102 def _ids_to_results(self, ids):
1103 return [
1104 self.url_result(vid_id, 'Youtube', video_id=vid_id)
1105 for vid_id in ids]
1106
1107 def _extract_mix(self, playlist_id):
1108 # The mixes are generated from a a single video
1109 # the id of the playlist is just 'RD' + video_id
1110 url = 'https://youtube.com/watch?v=%s&list=%s' % (playlist_id[-11:], playlist_id)
1111 webpage = self._download_webpage(
1112 url, playlist_id, 'Downloading Youtube mix')
1113 search_title = lambda class_name: get_element_by_attribute('class', class_name, webpage)
1114 title_span = (
1115 search_title('playlist-title') or
1116 search_title('title long-title') or
1117 search_title('title'))
1118 title = clean_html(title_span)
1119 ids = orderedSet(re.findall(
1120 r'''(?xs)data-video-username=".*?".*?
1121 href="/watch\?v=([0-9A-Za-z_-]{11})&amp;[^"]*?list=%s''' % re.escape(playlist_id),
1122 webpage))
1123 url_results = self._ids_to_results(ids)
1124
1125 return self.playlist_result(url_results, playlist_id, title)
1126
1127 def _real_extract(self, url):
1128 # Extract playlist id
1129 mobj = re.match(self._VALID_URL, url)
1130 if mobj is None:
1131 raise ExtractorError('Invalid URL: %s' % url)
1132 playlist_id = mobj.group(1) or mobj.group(2)
1133
1134 # Check if it's a video-specific URL
1135 query_dict = compat_urlparse.parse_qs(compat_urlparse.urlparse(url).query)
1136 if 'v' in query_dict:
1137 video_id = query_dict['v'][0]
1138 if self._downloader.params.get('noplaylist'):
1139 self.to_screen('Downloading just video %s because of --no-playlist' % video_id)
1140 return self.url_result(video_id, 'Youtube', video_id=video_id)
1141 else:
1142 self.to_screen('Downloading playlist %s - add --no-playlist to just download video %s' % (playlist_id, video_id))
1143
1144 if playlist_id.startswith('RD'):
1145 # Mixes require a custom extraction process
1146 return self._extract_mix(playlist_id)
1147 if playlist_id.startswith('TL'):
1148 raise ExtractorError('For downloading YouTube.com top lists, use '
1149 'the "yttoplist" keyword, for example "youtube-dl \'yttoplist:music:Top Tracks\'"', expected=True)
1150
1151 url = self._TEMPLATE_URL % playlist_id
1152 page = self._download_webpage(url, playlist_id)
1153 more_widget_html = content_html = page
1154
1155 # Check if the playlist exists or is private
1156 if re.search(r'<div class="yt-alert-message">[^<]*?(The|This) playlist (does not exist|is private)[^<]*?</div>', page) is not None:
1157 raise ExtractorError(
1158 'The playlist doesn\'t exist or is private, use --username or '
1159 '--netrc to access it.',
1160 expected=True)
1161
1162 # Extract the video ids from the playlist pages
1163 ids = []
1164
1165 for page_num in itertools.count(1):
1166 matches = re.finditer(self._VIDEO_RE, content_html)
1167 # We remove the duplicates and the link with index 0
1168 # (it's not the first video of the playlist)
1169 new_ids = orderedSet(m.group('id') for m in matches if m.group('index') != '0')
1170 ids.extend(new_ids)
1171
1172 mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
1173 if not mobj:
1174 break
1175
1176 more = self._download_json(
1177 'https://youtube.com/%s' % mobj.group('more'), playlist_id,
1178 'Downloading page #%s' % page_num,
1179 transform_source=uppercase_escape)
1180 content_html = more['content_html']
1181 more_widget_html = more['load_more_widget_html']
1182
1183 playlist_title = self._html_search_regex(
1184 r'(?s)<h1 class="pl-header-title[^"]*">\s*(.*?)\s*</h1>',
1185 page, 'title')
1186
1187 url_results = self._ids_to_results(ids)
1188 return self.playlist_result(url_results, playlist_id, playlist_title)
1189
1190
1191 class YoutubeTopListIE(YoutubePlaylistIE):
1192 IE_NAME = 'youtube:toplist'
1193 IE_DESC = ('YouTube.com top lists, "yttoplist:{channel}:{list title}"'
1194 ' (Example: "yttoplist:music:Top Tracks")')
1195 _VALID_URL = r'yttoplist:(?P<chann>.*?):(?P<title>.*?)$'
1196 _TESTS = [{
1197 'url': 'yttoplist:music:Trending',
1198 'playlist_mincount': 5,
1199 'skip': 'Only works for logged-in users',
1200 }]
1201
1202 def _real_extract(self, url):
1203 mobj = re.match(self._VALID_URL, url)
1204 channel = mobj.group('chann')
1205 title = mobj.group('title')
1206 query = compat_urllib_parse.urlencode({'title': title})
1207 channel_page = self._download_webpage(
1208 'https://www.youtube.com/%s' % channel, title)
1209 link = self._html_search_regex(
1210 r'''(?x)
1211 <a\s+href="([^"]+)".*?>\s*
1212 <span\s+class="branded-page-module-title-text">\s*
1213 <span[^>]*>.*?%s.*?</span>''' % re.escape(query),
1214 channel_page, 'list')
1215 url = compat_urlparse.urljoin('https://www.youtube.com/', link)
1216
1217 video_re = r'data-index="\d+".*?data-video-id="([0-9A-Za-z_-]{11})"'
1218 ids = []
1219 # sometimes the webpage doesn't contain the videos
1220 # retry until we get them
1221 for i in itertools.count(0):
1222 msg = 'Downloading Youtube mix'
1223 if i > 0:
1224 msg += ', retry #%d' % i
1225
1226 webpage = self._download_webpage(url, title, msg)
1227 ids = orderedSet(re.findall(video_re, webpage))
1228 if ids:
1229 break
1230 url_results = self._ids_to_results(ids)
1231 return self.playlist_result(url_results, playlist_title=title)
1232
1233
1234 class YoutubeChannelIE(InfoExtractor):
1235 IE_DESC = 'YouTube.com channels'
1236 _VALID_URL = r"^(?:https?://)?(?:youtu\.be|(?:\w+\.)?youtube(?:-nocookie)?\.com)/channel/([0-9A-Za-z_-]+)"
1237 _MORE_PAGES_INDICATOR = 'yt-uix-load-more'
1238 _MORE_PAGES_URL = 'https://www.youtube.com/c4_browse_ajax?action_load_more_videos=1&flow=list&paging=%s&view=0&sort=da&channel_id=%s'
1239 IE_NAME = 'youtube:channel'
1240 _TESTS = [{
1241 'note': 'paginated channel',
1242 'url': 'https://www.youtube.com/channel/UCKfVa3S1e4PHvxWcwyMMg8w',
1243 'playlist_mincount': 91,
1244 }]
1245
1246 def extract_videos_from_page(self, page):
1247 ids_in_page = []
1248 for mobj in re.finditer(r'href="/watch\?v=([0-9A-Za-z_-]+)&?', page):
1249 if mobj.group(1) not in ids_in_page:
1250 ids_in_page.append(mobj.group(1))
1251 return ids_in_page
1252
1253 def _real_extract(self, url):
1254 # Extract channel id
1255 mobj = re.match(self._VALID_URL, url)
1256 if mobj is None:
1257 raise ExtractorError('Invalid URL: %s' % url)
1258
1259 # Download channel page
1260 channel_id = mobj.group(1)
1261 video_ids = []
1262 url = 'https://www.youtube.com/channel/%s/videos' % channel_id
1263 channel_page = self._download_webpage(url, channel_id)
1264 autogenerated = re.search(r'''(?x)
1265 class="[^"]*?(?:
1266 channel-header-autogenerated-label|
1267 yt-channel-title-autogenerated
1268 )[^"]*"''', channel_page) is not None
1269
1270 if autogenerated:
1271 # The videos are contained in a single page
1272 # the ajax pages can't be used, they are empty
1273 video_ids = self.extract_videos_from_page(channel_page)
1274 else:
1275 # Download all channel pages using the json-based channel_ajax query
1276 for pagenum in itertools.count(1):
1277 url = self._MORE_PAGES_URL % (pagenum, channel_id)
1278 page = self._download_json(
1279 url, channel_id, note='Downloading page #%s' % pagenum,
1280 transform_source=uppercase_escape)
1281
1282 ids_in_page = self.extract_videos_from_page(page['content_html'])
1283 video_ids.extend(ids_in_page)
1284
1285 if self._MORE_PAGES_INDICATOR not in page['load_more_widget_html']:
1286 break
1287
1288 self._downloader.to_screen('[youtube] Channel %s: Found %i videos' % (channel_id, len(video_ids)))
1289
1290 url_entries = [self.url_result(video_id, 'Youtube', video_id=video_id)
1291 for video_id in video_ids]
1292 return self.playlist_result(url_entries, channel_id)
1293
1294
1295 class YoutubeUserIE(InfoExtractor):
1296 IE_DESC = 'YouTube.com user videos (URL or "ytuser" keyword)'
1297 _VALID_URL = r'(?:(?:(?:https?://)?(?:\w+\.)?youtube\.com/(?:user/)?(?!(?:attribution_link|watch|results)(?:$|[^a-z_A-Z0-9-])))|ytuser:)(?!feed/)([A-Za-z0-9_-]+)'
1298 _TEMPLATE_URL = 'https://gdata.youtube.com/feeds/api/users/%s'
1299 _GDATA_PAGE_SIZE = 50
1300 _GDATA_URL = 'https://gdata.youtube.com/feeds/api/users/%s/uploads?max-results=%d&start-index=%d&alt=json'
1301 IE_NAME = 'youtube:user'
1302
1303 _TESTS = [{
1304 'url': 'https://www.youtube.com/user/TheLinuxFoundation',
1305 'playlist_mincount': 320,
1306 'info_dict': {
1307 'title': 'TheLinuxFoundation',
1308 }
1309 }, {
1310 'url': 'ytuser:phihag',
1311 'only_matching': True,
1312 }]
1313
1314 @classmethod
1315 def suitable(cls, url):
1316 # Don't return True if the url can be extracted with other youtube
1317 # extractor, the regex would is too permissive and it would match.
1318 other_ies = iter(klass for (name, klass) in globals().items() if name.endswith('IE') and klass is not cls)
1319 if any(ie.suitable(url) for ie in other_ies): return False
1320 else: return super(YoutubeUserIE, cls).suitable(url)
1321
1322 def _real_extract(self, url):
1323 # Extract username
1324 mobj = re.match(self._VALID_URL, url)
1325 if mobj is None:
1326 raise ExtractorError('Invalid URL: %s' % url)
1327
1328 username = mobj.group(1)
1329
1330 # Download video ids using YouTube Data API. Result size per
1331 # query is limited (currently to 50 videos) so we need to query
1332 # page by page until there are no video ids - it means we got
1333 # all of them.
1334
1335 def download_page(pagenum):
1336 start_index = pagenum * self._GDATA_PAGE_SIZE + 1
1337
1338 gdata_url = self._GDATA_URL % (username, self._GDATA_PAGE_SIZE, start_index)
1339 page = self._download_webpage(
1340 gdata_url, username,
1341 'Downloading video ids from %d to %d' % (
1342 start_index, start_index + self._GDATA_PAGE_SIZE))
1343
1344 try:
1345 response = json.loads(page)
1346 except ValueError as err:
1347 raise ExtractorError('Invalid JSON in API response: ' + compat_str(err))
1348 if 'entry' not in response['feed']:
1349 return
1350
1351 # Extract video identifiers
1352 entries = response['feed']['entry']
1353 for entry in entries:
1354 title = entry['title']['$t']
1355 video_id = entry['id']['$t'].split('/')[-1]
1356 yield {
1357 '_type': 'url',
1358 'url': video_id,
1359 'ie_key': 'Youtube',
1360 'id': video_id,
1361 'title': title,
1362 }
1363 url_results = OnDemandPagedList(download_page, self._GDATA_PAGE_SIZE)
1364
1365 return self.playlist_result(url_results, playlist_title=username)
1366
1367
1368 class YoutubeSearchIE(SearchInfoExtractor):
1369 IE_DESC = 'YouTube.com searches'
1370 _API_URL = 'https://gdata.youtube.com/feeds/api/videos?q=%s&start-index=%i&max-results=50&v=2&alt=jsonc'
1371 _MAX_RESULTS = 1000
1372 IE_NAME = 'youtube:search'
1373 _SEARCH_KEY = 'ytsearch'
1374
1375 def _get_n_results(self, query, n):
1376 """Get a specified number of results for a query"""
1377
1378 video_ids = []
1379 pagenum = 0
1380 limit = n
1381 PAGE_SIZE = 50
1382
1383 while (PAGE_SIZE * pagenum) < limit:
1384 result_url = self._API_URL % (
1385 compat_urllib_parse.quote_plus(query.encode('utf-8')),
1386 (PAGE_SIZE * pagenum) + 1)
1387 data_json = self._download_webpage(
1388 result_url, video_id='query "%s"' % query,
1389 note='Downloading page %s' % (pagenum + 1),
1390 errnote='Unable to download API page')
1391 data = json.loads(data_json)
1392 api_response = data['data']
1393
1394 if 'items' not in api_response:
1395 raise ExtractorError(
1396 '[youtube] No video results', expected=True)
1397
1398 new_ids = list(video['id'] for video in api_response['items'])
1399 video_ids += new_ids
1400
1401 limit = min(n, api_response['totalItems'])
1402 pagenum += 1
1403
1404 if len(video_ids) > n:
1405 video_ids = video_ids[:n]
1406 videos = [self.url_result(video_id, 'Youtube', video_id=video_id)
1407 for video_id in video_ids]
1408 return self.playlist_result(videos, query)
1409
1410
1411 class YoutubeSearchDateIE(YoutubeSearchIE):
1412 IE_NAME = YoutubeSearchIE.IE_NAME + ':date'
1413 _API_URL = 'https://gdata.youtube.com/feeds/api/videos?q=%s&start-index=%i&max-results=50&v=2&alt=jsonc&orderby=published'
1414 _SEARCH_KEY = 'ytsearchdate'
1415 IE_DESC = 'YouTube.com searches, newest videos first'
1416
1417
1418 class YoutubeSearchURLIE(InfoExtractor):
1419 IE_DESC = 'YouTube.com search URLs'
1420 IE_NAME = 'youtube:search_url'
1421 _VALID_URL = r'https?://(?:www\.)?youtube\.com/results\?(.*?&)?search_query=(?P<query>[^&]+)(?:[&]|$)'
1422 _TESTS = [{
1423 'url': 'https://www.youtube.com/results?baz=bar&search_query=youtube-dl+test+video&filters=video&lclk=video',
1424 'playlist_mincount': 5,
1425 'info_dict': {
1426 'title': 'youtube-dl test video',
1427 }
1428 }]
1429
1430 def _real_extract(self, url):
1431 mobj = re.match(self._VALID_URL, url)
1432 query = compat_urllib_parse.unquote_plus(mobj.group('query'))
1433
1434 webpage = self._download_webpage(url, query)
1435 result_code = self._search_regex(
1436 r'(?s)<ol class="item-section"(.*?)</ol>', webpage, 'result HTML')
1437
1438 part_codes = re.findall(
1439 r'(?s)<h3 class="yt-lockup-title">(.*?)</h3>', result_code)
1440 entries = []
1441 for part_code in part_codes:
1442 part_title = self._html_search_regex(
1443 [r'(?s)title="([^"]+)"', r'>([^<]+)</a>'], part_code, 'item title', fatal=False)
1444 part_url_snippet = self._html_search_regex(
1445 r'(?s)href="([^"]+)"', part_code, 'item URL')
1446 part_url = compat_urlparse.urljoin(
1447 'https://www.youtube.com/', part_url_snippet)
1448 entries.append({
1449 '_type': 'url',
1450 'url': part_url,
1451 'title': part_title,
1452 })
1453
1454 return {
1455 '_type': 'playlist',
1456 'entries': entries,
1457 'title': query,
1458 }
1459
1460
1461 class YoutubeShowIE(InfoExtractor):
1462 IE_DESC = 'YouTube.com (multi-season) shows'
1463 _VALID_URL = r'https?://www\.youtube\.com/show/(?P<id>[^?#]*)'
1464 IE_NAME = 'youtube:show'
1465 _TESTS = [{
1466 'url': 'http://www.youtube.com/show/airdisasters',
1467 'playlist_mincount': 3,
1468 'info_dict': {
1469 'id': 'airdisasters',
1470 'title': 'Air Disasters',
1471 }
1472 }]
1473
1474 def _real_extract(self, url):
1475 mobj = re.match(self._VALID_URL, url)
1476 playlist_id = mobj.group('id')
1477 webpage = self._download_webpage(
1478 url, playlist_id, 'Downloading show webpage')
1479 # There's one playlist for each season of the show
1480 m_seasons = list(re.finditer(r'href="(/playlist\?list=.*?)"', webpage))
1481 self.to_screen('%s: Found %s seasons' % (playlist_id, len(m_seasons)))
1482 entries = [
1483 self.url_result(
1484 'https://www.youtube.com' + season.group(1), 'YoutubePlaylist')
1485 for season in m_seasons
1486 ]
1487 title = self._og_search_title(webpage, fatal=False)
1488
1489 return {
1490 '_type': 'playlist',
1491 'id': playlist_id,
1492 'title': title,
1493 'entries': entries,
1494 }
1495
1496
1497 class YoutubeFeedsInfoExtractor(YoutubeBaseInfoExtractor):
1498 """
1499 Base class for extractors that fetch info from
1500 http://www.youtube.com/feed_ajax
1501 Subclasses must define the _FEED_NAME and _PLAYLIST_TITLE properties.
1502 """
1503 _LOGIN_REQUIRED = True
1504 # use action_load_personal_feed instead of action_load_system_feed
1505 _PERSONAL_FEED = False
1506
1507 @property
1508 def _FEED_TEMPLATE(self):
1509 action = 'action_load_system_feed'
1510 if self._PERSONAL_FEED:
1511 action = 'action_load_personal_feed'
1512 return 'https://www.youtube.com/feed_ajax?%s=1&feed_name=%s&paging=%%s' % (action, self._FEED_NAME)
1513
1514 @property
1515 def IE_NAME(self):
1516 return 'youtube:%s' % self._FEED_NAME
1517
1518 def _real_initialize(self):
1519 self._login()
1520
1521 def _real_extract(self, url):
1522 feed_entries = []
1523 paging = 0
1524 for i in itertools.count(1):
1525 info = self._download_json(self._FEED_TEMPLATE % paging,
1526 '%s feed' % self._FEED_NAME,
1527 'Downloading page %s' % i)
1528 feed_html = info.get('feed_html') or info.get('content_html')
1529 load_more_widget_html = info.get('load_more_widget_html') or feed_html
1530 m_ids = re.finditer(r'"/watch\?v=(.*?)["&]', feed_html)
1531 ids = orderedSet(m.group(1) for m in m_ids)
1532 feed_entries.extend(
1533 self.url_result(video_id, 'Youtube', video_id=video_id)
1534 for video_id in ids)
1535 mobj = re.search(
1536 r'data-uix-load-more-href="/?[^"]+paging=(?P<paging>\d+)',
1537 load_more_widget_html)
1538 if mobj is None:
1539 break
1540 paging = mobj.group('paging')
1541 return self.playlist_result(feed_entries, playlist_title=self._PLAYLIST_TITLE)
1542
1543 class YoutubeRecommendedIE(YoutubeFeedsInfoExtractor):
1544 IE_DESC = 'YouTube.com recommended videos, "ytrec" keyword (requires authentication)'
1545 _VALID_URL = r'https?://www\.youtube\.com/feed/recommended|:ytrec(?:ommended)?'
1546 _FEED_NAME = 'recommended'
1547 _PLAYLIST_TITLE = 'Youtube Recommended videos'
1548
1549 class YoutubeWatchLaterIE(YoutubeFeedsInfoExtractor):
1550 IE_DESC = 'Youtube watch later list, "ytwatchlater" keyword (requires authentication)'
1551 _VALID_URL = r'https?://www\.youtube\.com/feed/watch_later|:ytwatchlater'
1552 _FEED_NAME = 'watch_later'
1553 _PLAYLIST_TITLE = 'Youtube Watch Later'
1554 _PERSONAL_FEED = True
1555
1556 class YoutubeHistoryIE(YoutubeFeedsInfoExtractor):
1557 IE_DESC = 'Youtube watch history, "ythistory" keyword (requires authentication)'
1558 _VALID_URL = 'https?://www\.youtube\.com/feed/history|:ythistory'
1559 _FEED_NAME = 'history'
1560 _PERSONAL_FEED = True
1561 _PLAYLIST_TITLE = 'Youtube Watch History'
1562
1563 class YoutubeFavouritesIE(YoutubeBaseInfoExtractor):
1564 IE_NAME = 'youtube:favorites'
1565 IE_DESC = 'YouTube.com favourite videos, "ytfav" keyword (requires authentication)'
1566 _VALID_URL = r'https?://www\.youtube\.com/my_favorites|:ytfav(?:ou?rites)?'
1567 _LOGIN_REQUIRED = True
1568
1569 def _real_extract(self, url):
1570 webpage = self._download_webpage('https://www.youtube.com/my_favorites', 'Youtube Favourites videos')
1571 playlist_id = self._search_regex(r'list=(.+?)["&]', webpage, 'favourites playlist id')
1572 return self.url_result(playlist_id, 'YoutubePlaylist')
1573
1574
1575 class YoutubeSubscriptionsIE(YoutubePlaylistIE):
1576 IE_NAME = 'youtube:subscriptions'
1577 IE_DESC = 'YouTube.com subscriptions feed, "ytsubs" keyword (requires authentication)'
1578 _VALID_URL = r'https?://www\.youtube\.com/feed/subscriptions|:ytsubs(?:criptions)?'
1579 _TESTS = []
1580
1581 def _real_extract(self, url):
1582 title = 'Youtube Subscriptions'
1583 page = self._download_webpage('https://www.youtube.com/feed/subscriptions', title)
1584
1585 # The extraction process is the same as for playlists, but the regex
1586 # for the video ids doesn't contain an index
1587 ids = []
1588 more_widget_html = content_html = page
1589
1590 for page_num in itertools.count(1):
1591 matches = re.findall(r'href="\s*/watch\?v=([0-9A-Za-z_-]{11})', content_html)
1592 new_ids = orderedSet(matches)
1593 ids.extend(new_ids)
1594
1595 mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
1596 if not mobj:
1597 break
1598
1599 more = self._download_json(
1600 'https://youtube.com/%s' % mobj.group('more'), title,
1601 'Downloading page #%s' % page_num,
1602 transform_source=uppercase_escape)
1603 content_html = more['content_html']
1604 more_widget_html = more['load_more_widget_html']
1605
1606 return {
1607 '_type': 'playlist',
1608 'title': title,
1609 'entries': self._ids_to_results(ids),
1610 }
1611
1612
1613 class YoutubeTruncatedURLIE(InfoExtractor):
1614 IE_NAME = 'youtube:truncated_url'
1615 IE_DESC = False # Do not list
1616 _VALID_URL = r'''(?x)
1617 (?:https?://)?[^/]+/watch\?(?:
1618 feature=[a-z_]+|
1619 annotation_id=annotation_[^&]+
1620 )?$|
1621 (?:https?://)?(?:www\.)?youtube\.com/attribution_link\?a=[^&]+$
1622 '''
1623
1624 _TESTS = [{
1625 'url': 'http://www.youtube.com/watch?annotation_id=annotation_3951667041',
1626 'only_matching': True,
1627 }, {
1628 'url': 'http://www.youtube.com/watch?',
1629 'only_matching': True,
1630 }]
1631
1632 def _real_extract(self, url):
1633 raise ExtractorError(
1634 'Did you forget to quote the URL? Remember that & is a meta '
1635 'character in most shells, so you want to put the URL in quotes, '
1636 'like youtube-dl '
1637 '"http://www.youtube.com/watch?feature=foo&v=BaW_jenozKc" '
1638 ' or simply youtube-dl BaW_jenozKc .',
1639 expected=True)