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