[youtube] Use a cookie for seeting the language
[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
289         # Dash webm audio
290         '171': {'ext': 'webm', 'vcodec': 'none', 'format_note': 'DASH audio', 'abr': 128, 'preference': -50},
291         '172': {'ext': 'webm', 'vcodec': 'none', 'format_note': 'DASH audio', 'abr': 256, 'preference': -50},
292
293         # Dash webm audio with opus inside
294         '249': {'ext': 'webm', 'vcodec': 'none', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 50, 'preference': -50},
295         '250': {'ext': 'webm', 'vcodec': 'none', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 70, 'preference': -50},
296         '251': {'ext': 'webm', 'vcodec': 'none', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 160, 'preference': -50},
297
298         # RTMP (unnamed)
299         '_rtmp': {'protocol': 'rtmp'},
300     }
301
302     IE_NAME = 'youtube'
303     _TESTS = [
304         {
305             'url': 'http://www.youtube.com/watch?v=BaW_jenozKc',
306             'info_dict': {
307                 'id': 'BaW_jenozKc',
308                 'ext': 'mp4',
309                 'title': 'youtube-dl test video "\'/\\ä↭𝕐',
310                 'uploader': 'Philipp Hagemeister',
311                 'uploader_id': 'phihag',
312                 'upload_date': '20121002',
313                 '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 .',
314                 'categories': ['Science & Technology'],
315                 'like_count': int,
316                 'dislike_count': int,
317             }
318         },
319         {
320             'url': 'http://www.youtube.com/watch?v=UxxajLWwzqY',
321             'note': 'Test generic use_cipher_signature video (#897)',
322             'info_dict': {
323                 'id': 'UxxajLWwzqY',
324                 'ext': 'mp4',
325                 'upload_date': '20120506',
326                 'title': 'Icona Pop - I Love It (feat. Charli XCX) [OFFICIAL VIDEO]',
327                 'description': 'md5:fea86fda2d5a5784273df5c7cc994d9f',
328                 'uploader': 'Icona Pop',
329                 'uploader_id': 'IconaPop',
330             }
331         },
332         {
333             'url': 'https://www.youtube.com/watch?v=07FYdnEawAQ',
334             'note': 'Test VEVO video with age protection (#956)',
335             'info_dict': {
336                 'id': '07FYdnEawAQ',
337                 'ext': 'mp4',
338                 'upload_date': '20130703',
339                 'title': 'Justin Timberlake - Tunnel Vision (Explicit)',
340                 'description': 'md5:64249768eec3bc4276236606ea996373',
341                 'uploader': 'justintimberlakeVEVO',
342                 'uploader_id': 'justintimberlakeVEVO',
343             }
344         },
345         {
346             'url': '//www.YouTube.com/watch?v=yZIXLfi8CZQ',
347             'note': 'Embed-only video (#1746)',
348             'info_dict': {
349                 'id': 'yZIXLfi8CZQ',
350                 'ext': 'mp4',
351                 'upload_date': '20120608',
352                 'title': 'Principal Sexually Assaults A Teacher - Episode 117 - 8th June 2012',
353                 'description': 'md5:09b78bd971f1e3e289601dfba15ca4f7',
354                 'uploader': 'SET India',
355                 'uploader_id': 'setindia'
356             }
357         },
358         {
359             'url': 'http://www.youtube.com/watch?v=a9LDPn-MO4I',
360             'note': '256k DASH audio (format 141) via DASH manifest',
361             'info_dict': {
362                 'id': 'a9LDPn-MO4I',
363                 'ext': 'm4a',
364                 'upload_date': '20121002',
365                 'uploader_id': '8KVIDEO',
366                 'description': '',
367                 'uploader': '8KVIDEO',
368                 'title': 'UHDTV TEST 8K VIDEO.mp4'
369             },
370             'params': {
371                 'youtube_include_dash_manifest': True,
372                 'format': '141',
373             },
374         },
375         # DASH manifest with encrypted signature
376         {
377             'url': 'https://www.youtube.com/watch?v=IB3lcPjvWLA',
378             'info_dict': {
379                 'id': 'IB3lcPjvWLA',
380                 'ext': 'm4a',
381                 'title': 'Afrojack - The Spark ft. Spree Wilson',
382                 'description': 'md5:9717375db5a9a3992be4668bbf3bc0a8',
383                 'uploader': 'AfrojackVEVO',
384                 'uploader_id': 'AfrojackVEVO',
385                 'upload_date': '20131011',
386             },
387             'params': {
388                 'youtube_include_dash_manifest': True,
389                 'format': '141',
390             },
391         },
392         # Controversy video
393         {
394             'url': 'https://www.youtube.com/watch?v=T4XJQO3qol8',
395             'info_dict': {
396                 'id': 'T4XJQO3qol8',
397                 'ext': 'mp4',
398                 'upload_date': '20100909',
399                 'uploader': 'The Amazing Atheist',
400                 'uploader_id': 'TheAmazingAtheist',
401                 'title': 'Burning Everyone\'s Koran',
402                 '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',
403             }
404         }
405     ]
406
407     def __init__(self, *args, **kwargs):
408         super(YoutubeIE, self).__init__(*args, **kwargs)
409         self._player_cache = {}
410
411     def report_video_info_webpage_download(self, video_id):
412         """Report attempt to download video info webpage."""
413         self.to_screen('%s: Downloading video info webpage' % video_id)
414
415     def report_information_extraction(self, video_id):
416         """Report attempt to extract video information."""
417         self.to_screen('%s: Extracting video information' % video_id)
418
419     def report_unavailable_format(self, video_id, format):
420         """Report extracted video URL."""
421         self.to_screen('%s: Format %s not available' % (video_id, format))
422
423     def report_rtmp_download(self):
424         """Indicate the download will use the RTMP protocol."""
425         self.to_screen('RTMP download detected')
426
427     def _signature_cache_id(self, example_sig):
428         """ Return a string representation of a signature """
429         return '.'.join(compat_str(len(part)) for part in example_sig.split('.'))
430
431     def _extract_signature_function(self, video_id, player_url, example_sig):
432         id_m = re.match(
433             r'.*-(?P<id>[a-zA-Z0-9_-]+)(?:/watch_as3|/html5player)?\.(?P<ext>[a-z]+)$',
434             player_url)
435         if not id_m:
436             raise ExtractorError('Cannot identify player %r' % player_url)
437         player_type = id_m.group('ext')
438         player_id = id_m.group('id')
439
440         # Read from filesystem cache
441         func_id = '%s_%s_%s' % (
442             player_type, player_id, self._signature_cache_id(example_sig))
443         assert os.path.basename(func_id) == func_id
444
445         cache_spec = self._downloader.cache.load('youtube-sigfuncs', func_id)
446         if cache_spec is not None:
447             return lambda s: ''.join(s[i] for i in cache_spec)
448
449         if player_type == 'js':
450             code = self._download_webpage(
451                 player_url, video_id,
452                 note='Downloading %s player %s' % (player_type, player_id),
453                 errnote='Download of %s failed' % player_url)
454             res = self._parse_sig_js(code)
455         elif player_type == 'swf':
456             urlh = self._request_webpage(
457                 player_url, video_id,
458                 note='Downloading %s player %s' % (player_type, player_id),
459                 errnote='Download of %s failed' % player_url)
460             code = urlh.read()
461             res = self._parse_sig_swf(code)
462         else:
463             assert False, 'Invalid player type %r' % player_type
464
465         if cache_spec is None:
466             test_string = ''.join(map(compat_chr, range(len(example_sig))))
467             cache_res = res(test_string)
468             cache_spec = [ord(c) for c in cache_res]
469
470         self._downloader.cache.store('youtube-sigfuncs', func_id, cache_spec)
471         return res
472
473     def _print_sig_code(self, func, example_sig):
474         def gen_sig_code(idxs):
475             def _genslice(start, end, step):
476                 starts = '' if start == 0 else str(start)
477                 ends = (':%d' % (end + step)) if end + step >= 0 else ':'
478                 steps = '' if step == 1 else (':%d' % step)
479                 return 's[%s%s%s]' % (starts, ends, steps)
480
481             step = None
482             start = '(Never used)'  # Quelch pyflakes warnings - start will be
483                                     # set as soon as step is set
484             for i, prev in zip(idxs[1:], idxs[:-1]):
485                 if step is not None:
486                     if i - prev == step:
487                         continue
488                     yield _genslice(start, prev, step)
489                     step = None
490                     continue
491                 if i - prev in [-1, 1]:
492                     step = i - prev
493                     start = prev
494                     continue
495                 else:
496                     yield 's[%d]' % prev
497             if step is None:
498                 yield 's[%d]' % i
499             else:
500                 yield _genslice(start, i, step)
501
502         test_string = ''.join(map(compat_chr, range(len(example_sig))))
503         cache_res = func(test_string)
504         cache_spec = [ord(c) for c in cache_res]
505         expr_code = ' + '.join(gen_sig_code(cache_spec))
506         signature_id_tuple = '(%s)' % (
507             ', '.join(compat_str(len(p)) for p in example_sig.split('.')))
508         code = ('if tuple(len(p) for p in s.split(\'.\')) == %s:\n'
509                 '    return %s\n') % (signature_id_tuple, expr_code)
510         self.to_screen('Extracted signature function:\n' + code)
511
512     def _parse_sig_js(self, jscode):
513         funcname = self._search_regex(
514             r'\.sig\|\|([a-zA-Z0-9]+)\(', jscode,
515             'Initial JS player signature function name')
516
517         jsi = JSInterpreter(jscode)
518         initial_function = jsi.extract_function(funcname)
519         return lambda s: initial_function([s])
520
521     def _parse_sig_swf(self, file_contents):
522         swfi = SWFInterpreter(file_contents)
523         TARGET_CLASSNAME = 'SignatureDecipher'
524         searched_class = swfi.extract_class(TARGET_CLASSNAME)
525         initial_function = swfi.extract_function(searched_class, 'decipher')
526         return lambda s: initial_function([s])
527
528     def _decrypt_signature(self, s, video_id, player_url, age_gate=False):
529         """Turn the encrypted s field into a working signature"""
530
531         if player_url is None:
532             raise ExtractorError('Cannot decrypt signature without player_url')
533
534         if player_url.startswith('//'):
535             player_url = 'https:' + player_url
536         try:
537             player_id = (player_url, self._signature_cache_id(s))
538             if player_id not in self._player_cache:
539                 func = self._extract_signature_function(
540                     video_id, player_url, s
541                 )
542                 self._player_cache[player_id] = func
543             func = self._player_cache[player_id]
544             if self._downloader.params.get('youtube_print_sig_code'):
545                 self._print_sig_code(func, s)
546             return func(s)
547         except Exception as e:
548             tb = traceback.format_exc()
549             raise ExtractorError(
550                 'Signature extraction failed: ' + tb, cause=e)
551
552     def _get_available_subtitles(self, video_id, webpage):
553         try:
554             sub_list = self._download_webpage(
555                 'https://video.google.com/timedtext?hl=en&type=list&v=%s' % video_id,
556                 video_id, note=False)
557         except ExtractorError as err:
558             self._downloader.report_warning('unable to download video subtitles: %s' % compat_str(err))
559             return {}
560         lang_list = re.findall(r'name="([^"]*)"[^>]+lang_code="([\w\-]+)"', sub_list)
561
562         sub_lang_list = {}
563         for l in lang_list:
564             lang = l[1]
565             if lang in sub_lang_list:
566                 continue
567             params = compat_urllib_parse.urlencode({
568                 'lang': lang,
569                 'v': video_id,
570                 'fmt': self._downloader.params.get('subtitlesformat', 'srt'),
571                 'name': unescapeHTML(l[0]).encode('utf-8'),
572             })
573             url = 'https://www.youtube.com/api/timedtext?' + params
574             sub_lang_list[lang] = url
575         if not sub_lang_list:
576             self._downloader.report_warning('video doesn\'t have subtitles')
577             return {}
578         return sub_lang_list
579
580     def _get_available_automatic_caption(self, video_id, webpage):
581         """We need the webpage for getting the captions url, pass it as an
582            argument to speed up the process."""
583         sub_format = self._downloader.params.get('subtitlesformat', 'srt')
584         self.to_screen('%s: Looking for automatic captions' % video_id)
585         mobj = re.search(r';ytplayer.config = ({.*?});', webpage)
586         err_msg = 'Couldn\'t find automatic captions for %s' % video_id
587         if mobj is None:
588             self._downloader.report_warning(err_msg)
589             return {}
590         player_config = json.loads(mobj.group(1))
591         try:
592             args = player_config['args']
593             caption_url = args['ttsurl']
594             timestamp = args['timestamp']
595             # We get the available subtitles
596             list_params = compat_urllib_parse.urlencode({
597                 'type': 'list',
598                 'tlangs': 1,
599                 'asrs': 1,
600             })
601             list_url = caption_url + '&' + list_params
602             caption_list = self._download_xml(list_url, video_id)
603             original_lang_node = caption_list.find('track')
604             if original_lang_node is None or original_lang_node.attrib.get('kind') != 'asr':
605                 self._downloader.report_warning('Video doesn\'t have automatic captions')
606                 return {}
607             original_lang = original_lang_node.attrib['lang_code']
608
609             sub_lang_list = {}
610             for lang_node in caption_list.findall('target'):
611                 sub_lang = lang_node.attrib['lang_code']
612                 params = compat_urllib_parse.urlencode({
613                     'lang': original_lang,
614                     'tlang': sub_lang,
615                     'fmt': sub_format,
616                     'ts': timestamp,
617                     'kind': 'asr',
618                 })
619                 sub_lang_list[sub_lang] = caption_url + '&' + params
620             return sub_lang_list
621         # An extractor error can be raise by the download process if there are
622         # no automatic captions but there are subtitles
623         except (KeyError, ExtractorError):
624             self._downloader.report_warning(err_msg)
625             return {}
626
627     @classmethod
628     def extract_id(cls, url):
629         mobj = re.match(cls._VALID_URL, url, re.VERBOSE)
630         if mobj is None:
631             raise ExtractorError('Invalid URL: %s' % url)
632         video_id = mobj.group(2)
633         return video_id
634
635     def _extract_from_m3u8(self, manifest_url, video_id):
636         url_map = {}
637
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&bpctr=9999999999' % video_id
667         video_webpage = self._download_webpage(url, video_id)
668
669         # Attempt to extract SWF player URL
670         mobj = re.search(r'swfConfig.*?"(https?:\\/\\/.*?watch.*?-.*?\.swf)"', video_webpage)
671         if mobj is not None:
672             player_url = re.sub(r'\\(.)', r'\1', mobj.group(1))
673         else:
674             player_url = None
675
676         # Get video info
677         self.report_video_info_webpage_download(video_id)
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             for el_type in ['&el=embedded', '&el=detailpage', '&el=vevo', '']:
697                 video_info_url = (proto + '://www.youtube.com/get_video_info?&video_id=%s%s&ps=default&eurl=&gl=US&hl=en'
698                                   % (video_id, el_type))
699                 video_info_webpage = self._download_webpage(video_info_url, video_id,
700                                                             note=False,
701                                                             errnote='unable to download video info webpage')
702                 video_info = compat_parse_qs(video_info_webpage)
703                 if 'token' in video_info:
704                     break
705         if 'token' not in video_info:
706             if 'reason' in video_info:
707                 raise ExtractorError(
708                     'YouTube said: %s' % video_info['reason'][0],
709                     expected=True, video_id=video_id)
710             else:
711                 raise ExtractorError(
712                     '"token" parameter not in video info for unknown reason',
713                     video_id=video_id)
714
715         if 'view_count' in video_info:
716             view_count = int(video_info['view_count'][0])
717         else:
718             view_count = None
719
720         # Check for "rental" videos
721         if 'ypc_video_rental_bar_text' in video_info and 'author' not in video_info:
722             raise ExtractorError('"rental" videos not supported')
723
724         # Start extracting information
725         self.report_information_extraction(video_id)
726
727         # uploader
728         if 'author' not in video_info:
729             raise ExtractorError('Unable to extract uploader name')
730         video_uploader = compat_urllib_parse.unquote_plus(video_info['author'][0])
731
732         # uploader_id
733         video_uploader_id = None
734         mobj = re.search(r'<link itemprop="url" href="http://www.youtube.com/(?:user|channel)/([^"]+)">', video_webpage)
735         if mobj is not None:
736             video_uploader_id = mobj.group(1)
737         else:
738             self._downloader.report_warning('unable to extract uploader nickname')
739
740         # title
741         if 'title' in video_info:
742             video_title = video_info['title'][0]
743         else:
744             self._downloader.report_warning('Unable to extract video title')
745             video_title = '_'
746
747         # thumbnail image
748         # We try first to get a high quality image:
749         m_thumb = re.search(r'<span itemprop="thumbnail".*?href="(.*?)">',
750                             video_webpage, re.DOTALL)
751         if m_thumb is not None:
752             video_thumbnail = m_thumb.group(1)
753         elif 'thumbnail_url' not in video_info:
754             self._downloader.report_warning('unable to extract video thumbnail')
755             video_thumbnail = None
756         else:   # don't panic if we can't find it
757             video_thumbnail = compat_urllib_parse.unquote_plus(video_info['thumbnail_url'][0])
758
759         # upload date
760         upload_date = None
761         mobj = re.search(r'(?s)id="eow-date.*?>(.*?)</span>', video_webpage)
762         if mobj is None:
763             mobj = re.search(
764                 r'(?s)id="watch-uploader-info".*?>.*?(?:Published|Uploaded|Streamed live) on (.*?)</strong>',
765                 video_webpage)
766         if mobj is not None:
767             upload_date = ' '.join(re.sub(r'[/,-]', r' ', mobj.group(1)).split())
768             upload_date = unified_strdate(upload_date)
769
770         m_cat_container = self._search_regex(
771             r'(?s)<h4[^>]*>\s*Category\s*</h4>\s*<ul[^>]*>(.*?)</ul>',
772             video_webpage, 'categories', fatal=False)
773         if m_cat_container:
774             category = self._html_search_regex(
775                 r'(?s)<a[^<]+>(.*?)</a>', m_cat_container, 'category',
776                 default=None)
777             video_categories = None if category is None else [category]
778         else:
779             video_categories = None
780
781         # description
782         video_description = get_element_by_id("eow-description", video_webpage)
783         if video_description:
784             video_description = re.sub(r'''(?x)
785                 <a\s+
786                     (?:[a-zA-Z-]+="[^"]+"\s+)*?
787                     title="([^"]+)"\s+
788                     (?:[a-zA-Z-]+="[^"]+"\s+)*?
789                     class="yt-uix-redirect-link"\s*>
790                 [^<]+
791                 </a>
792             ''', r'\1', video_description)
793             video_description = clean_html(video_description)
794         else:
795             fd_mobj = re.search(r'<meta name="description" content="([^"]+)"', video_webpage)
796             if fd_mobj:
797                 video_description = unescapeHTML(fd_mobj.group(1))
798             else:
799                 video_description = ''
800
801         def _extract_count(count_name):
802             count = self._search_regex(
803                 r'id="watch-%s"[^>]*>.*?([\d,]+)\s*</span>' % re.escape(count_name),
804                 video_webpage, count_name, default=None)
805             if count is not None:
806                 return int(count.replace(',', ''))
807             return None
808         like_count = _extract_count('like')
809         dislike_count = _extract_count('dislike')
810
811         # subtitles
812         video_subtitles = self.extract_subtitles(video_id, video_webpage)
813
814         if self._downloader.params.get('listsubtitles', False):
815             self._list_available_subtitles(video_id, video_webpage)
816             return
817
818         if 'length_seconds' not in video_info:
819             self._downloader.report_warning('unable to extract video duration')
820             video_duration = None
821         else:
822             video_duration = int(compat_urllib_parse.unquote_plus(video_info['length_seconds'][0]))
823
824         # annotations
825         video_annotations = None
826         if self._downloader.params.get('writeannotations', False):
827             video_annotations = self._extract_annotations(video_id)
828
829         # Decide which formats to download
830         try:
831             mobj = re.search(r';ytplayer\.config\s*=\s*({.*?});', video_webpage)
832             if not mobj:
833                 raise ValueError('Could not find vevo ID')
834             json_code = uppercase_escape(mobj.group(1))
835             ytplayer_config = json.loads(json_code)
836             args = ytplayer_config['args']
837             # Easy way to know if the 's' value is in url_encoded_fmt_stream_map
838             # this signatures are encrypted
839             if 'url_encoded_fmt_stream_map' not in args:
840                 raise ValueError('No stream_map present')  # caught below
841             re_signature = re.compile(r'[&,]s=')
842             m_s = re_signature.search(args['url_encoded_fmt_stream_map'])
843             if m_s is not None:
844                 self.to_screen('%s: Encrypted signatures detected.' % video_id)
845                 video_info['url_encoded_fmt_stream_map'] = [args['url_encoded_fmt_stream_map']]
846             m_s = re_signature.search(args.get('adaptive_fmts', ''))
847             if m_s is not None:
848                 if 'adaptive_fmts' in video_info:
849                     video_info['adaptive_fmts'][0] += ',' + args['adaptive_fmts']
850                 else:
851                     video_info['adaptive_fmts'] = [args['adaptive_fmts']]
852         except ValueError:
853             pass
854
855         def _map_to_format_list(urlmap):
856             formats = []
857             for itag, video_real_url in urlmap.items():
858                 dct = {
859                     'format_id': itag,
860                     'url': video_real_url,
861                     'player_url': player_url,
862                 }
863                 if itag in self._formats:
864                     dct.update(self._formats[itag])
865                 formats.append(dct)
866             return formats
867
868         if 'conn' in video_info and video_info['conn'][0].startswith('rtmp'):
869             self.report_rtmp_download()
870             formats = [{
871                 'format_id': '_rtmp',
872                 'protocol': 'rtmp',
873                 'url': video_info['conn'][0],
874                 'player_url': player_url,
875             }]
876         elif len(video_info.get('url_encoded_fmt_stream_map', [])) >= 1 or len(video_info.get('adaptive_fmts', [])) >= 1:
877             encoded_url_map = video_info.get('url_encoded_fmt_stream_map', [''])[0] + ',' + video_info.get('adaptive_fmts', [''])[0]
878             if 'rtmpe%3Dyes' in encoded_url_map:
879                 raise ExtractorError('rtmpe downloads are not supported, see https://github.com/rg3/youtube-dl/issues/343 for more information.', expected=True)
880             url_map = {}
881             for url_data_str in encoded_url_map.split(','):
882                 url_data = compat_parse_qs(url_data_str)
883                 if 'itag' not in url_data or 'url' not in url_data:
884                     continue
885                 format_id = url_data['itag'][0]
886                 url = url_data['url'][0]
887
888                 if 'sig' in url_data:
889                     url += '&signature=' + url_data['sig'][0]
890                 elif 's' in url_data:
891                     encrypted_sig = url_data['s'][0]
892
893                     if not age_gate:
894                         jsplayer_url_json = self._search_regex(
895                             r'"assets":.+?"js":\s*("[^"]+")',
896                             video_webpage, 'JS player URL')
897                         player_url = json.loads(jsplayer_url_json)
898                     if player_url is None:
899                         player_url_json = self._search_regex(
900                             r'ytplayer\.config.*?"url"\s*:\s*("[^"]+")',
901                             video_webpage, 'age gate player URL')
902                         player_url = json.loads(player_url_json)
903
904                     if self._downloader.params.get('verbose'):
905                         if player_url is None:
906                             player_version = 'unknown'
907                             player_desc = 'unknown'
908                         else:
909                             if player_url.endswith('swf'):
910                                 player_version = self._search_regex(
911                                     r'-(.+?)(?:/watch_as3)?\.swf$', player_url,
912                                     'flash player', fatal=False)
913                                 player_desc = 'flash player %s' % player_version
914                             else:
915                                 player_version = self._search_regex(
916                                     r'html5player-([^/]+?)(?:/html5player)?\.js',
917                                     player_url,
918                                     'html5 player', fatal=False)
919                                 player_desc = 'html5 player %s' % player_version
920
921                         parts_sizes = self._signature_cache_id(encrypted_sig)
922                         self.to_screen('{%s} signature length %s, %s' %
923                                        (format_id, parts_sizes, player_desc))
924
925                     signature = self._decrypt_signature(
926                         encrypted_sig, video_id, player_url, age_gate)
927                     url += '&signature=' + signature
928                 if 'ratebypass' not in url:
929                     url += '&ratebypass=yes'
930                 url_map[format_id] = url
931             formats = _map_to_format_list(url_map)
932         elif video_info.get('hlsvp'):
933             manifest_url = video_info['hlsvp'][0]
934             url_map = self._extract_from_m3u8(manifest_url, video_id)
935             formats = _map_to_format_list(url_map)
936         else:
937             raise ExtractorError('no conn, hlsvp or url_encoded_fmt_stream_map information found in video info')
938
939         # Look for the DASH manifest
940         if self._downloader.params.get('youtube_include_dash_manifest', True):
941             try:
942                 # The DASH manifest used needs to be the one from the original video_webpage.
943                 # The one found in get_video_info seems to be using different signatures.
944                 # However, in the case of an age restriction there won't be any embedded dashmpd in the video_webpage.
945                 # Luckily, it seems, this case uses some kind of default signature (len == 86), so the
946                 # combination of get_video_info and the _static_decrypt_signature() decryption fallback will work here.
947                 if age_gate:
948                     dash_manifest_url = video_info.get('dashmpd')[0]
949                 else:
950                     dash_manifest_url = ytplayer_config['args']['dashmpd']
951
952                 def decrypt_sig(mobj):
953                     s = mobj.group(1)
954                     dec_s = self._decrypt_signature(s, video_id, player_url, age_gate)
955                     return '/signature/%s' % dec_s
956                 dash_manifest_url = re.sub(r'/s/([\w\.]+)', decrypt_sig, dash_manifest_url)
957                 dash_doc = self._download_xml(
958                     dash_manifest_url, video_id,
959                     note='Downloading DASH manifest',
960                     errnote='Could not download DASH manifest')
961                 for r in dash_doc.findall('.//{urn:mpeg:DASH:schema:MPD:2011}Representation'):
962                     url_el = r.find('{urn:mpeg:DASH:schema:MPD:2011}BaseURL')
963                     if url_el is None:
964                         continue
965                     format_id = r.attrib['id']
966                     video_url = url_el.text
967                     filesize = int_or_none(url_el.attrib.get('{http://youtube.com/yt/2012/10/10}contentLength'))
968                     f = {
969                         'format_id': format_id,
970                         'url': video_url,
971                         'width': int_or_none(r.attrib.get('width')),
972                         'tbr': int_or_none(r.attrib.get('bandwidth'), 1000),
973                         'asr': int_or_none(r.attrib.get('audioSamplingRate')),
974                         'filesize': filesize,
975                     }
976                     try:
977                         existing_format = next(
978                             fo for fo in formats
979                             if fo['format_id'] == format_id)
980                     except StopIteration:
981                         f.update(self._formats.get(format_id, {}))
982                         formats.append(f)
983                     else:
984                         existing_format.update(f)
985
986             except (ExtractorError, KeyError) as e:
987                 self.report_warning('Skipping DASH manifest: %r' % e, video_id)
988
989         self._sort_formats(formats)
990
991         return {
992             'id': video_id,
993             'uploader': video_uploader,
994             'uploader_id': video_uploader_id,
995             'upload_date': upload_date,
996             'title': video_title,
997             'thumbnail': video_thumbnail,
998             'description': video_description,
999             'categories': video_categories,
1000             'subtitles': video_subtitles,
1001             'duration': video_duration,
1002             'age_limit': 18 if age_gate else 0,
1003             'annotations': video_annotations,
1004             'webpage_url': proto + '://www.youtube.com/watch?v=%s' % video_id,
1005             'view_count': view_count,
1006             'like_count': like_count,
1007             'dislike_count': dislike_count,
1008             'formats': formats,
1009         }
1010
1011
1012 class YoutubePlaylistIE(YoutubeBaseInfoExtractor):
1013     IE_DESC = 'YouTube.com playlists'
1014     _VALID_URL = r"""(?x)(?:
1015                         (?:https?://)?
1016                         (?:\w+\.)?
1017                         youtube\.com/
1018                         (?:
1019                            (?:course|view_play_list|my_playlists|artist|playlist|watch|embed/videoseries)
1020                            \? (?:.*?&)*? (?:p|a|list)=
1021                         |  p/
1022                         )
1023                         (
1024                             (?:PL|LL|EC|UU|FL|RD)?[0-9A-Za-z-_]{10,}
1025                             # Top tracks, they can also include dots
1026                             |(?:MC)[\w\.]*
1027                         )
1028                         .*
1029                      |
1030                         ((?:PL|LL|EC|UU|FL|RD)[0-9A-Za-z-_]{10,})
1031                      )"""
1032     _TEMPLATE_URL = 'https://www.youtube.com/playlist?list=%s'
1033     _MORE_PAGES_INDICATOR = r'data-link-type="next"'
1034     _VIDEO_RE = r'href="\s*/watch\?v=(?P<id>[0-9A-Za-z_-]{11})&amp;[^"]*?index=(?P<index>\d+)'
1035     IE_NAME = 'youtube:playlist'
1036     _TESTS = [{
1037         'url': 'https://www.youtube.com/playlist?list=PLwiyx1dc3P2JR9N8gQaQN_BCvlSlap7re',
1038         'info_dict': {
1039             'title': 'ytdl test PL',
1040             'id': 'PLwiyx1dc3P2JR9N8gQaQN_BCvlSlap7re',
1041         },
1042         'playlist_count': 3,
1043     }, {
1044         'url': 'https://www.youtube.com/playlist?list=PLtPgu7CB4gbZDA7i_euNxn75ISqxwZPYx',
1045         'info_dict': {
1046             'title': 'YDL_Empty_List',
1047         },
1048         'playlist_count': 0,
1049     }, {
1050         'note': 'Playlist with deleted videos (#651). As a bonus, the video #51 is also twice in this list.',
1051         'url': 'https://www.youtube.com/playlist?list=PLwP_SiAcdui0KVebT0mU9Apz359a4ubsC',
1052         'info_dict': {
1053             'title': '29C3: Not my department',
1054         },
1055         'playlist_count': 95,
1056     }, {
1057         'note': 'issue #673',
1058         'url': 'PLBB231211A4F62143',
1059         'info_dict': {
1060             'title': '[OLD]Team Fortress 2 (Class-based LP)',
1061         },
1062         'playlist_mincount': 26,
1063     }, {
1064         'note': 'Large playlist',
1065         'url': 'https://www.youtube.com/playlist?list=UUBABnxM4Ar9ten8Mdjj1j0Q',
1066         'info_dict': {
1067             'title': 'Uploads from Cauchemar',
1068         },
1069         'playlist_mincount': 799,
1070     }, {
1071         'url': 'PLtPgu7CB4gbY9oDN3drwC3cMbJggS7dKl',
1072         'info_dict': {
1073             'title': 'YDL_safe_search',
1074         },
1075         'playlist_count': 2,
1076     }, {
1077         'note': 'embedded',
1078         'url': 'http://www.youtube.com/embed/videoseries?list=PL6IaIsEjSbf96XFRuNccS_RuEXwNdsoEu',
1079         'playlist_count': 4,
1080         'info_dict': {
1081             'title': 'JODA15',
1082         }
1083     }, {
1084         'note': 'Embedded SWF player',
1085         'url': 'http://www.youtube.com/p/YN5VISEtHet5D4NEvfTd0zcgFk84NqFZ?hl=en_US&fs=1&rel=0',
1086         'playlist_count': 4,
1087         'info_dict': {
1088             'title': 'JODA7',
1089         }
1090     }]
1091
1092     def _real_initialize(self):
1093         self._login()
1094
1095     def _ids_to_results(self, ids):
1096         return [
1097             self.url_result(vid_id, 'Youtube', video_id=vid_id)
1098             for vid_id in ids]
1099
1100     def _extract_mix(self, playlist_id):
1101         # The mixes are generated from a a single video
1102         # the id of the playlist is just 'RD' + video_id
1103         url = 'https://youtube.com/watch?v=%s&list=%s' % (playlist_id[-11:], playlist_id)
1104         webpage = self._download_webpage(
1105             url, playlist_id, 'Downloading Youtube mix')
1106         search_title = lambda class_name: get_element_by_attribute('class', class_name, webpage)
1107         title_span = (
1108             search_title('playlist-title') or
1109             search_title('title long-title') or
1110             search_title('title'))
1111         title = clean_html(title_span)
1112         ids = orderedSet(re.findall(
1113             r'''(?xs)data-video-username=".*?".*?
1114                        href="/watch\?v=([0-9A-Za-z_-]{11})&amp;[^"]*?list=%s''' % re.escape(playlist_id),
1115             webpage))
1116         url_results = self._ids_to_results(ids)
1117
1118         return self.playlist_result(url_results, playlist_id, title)
1119
1120     def _real_extract(self, url):
1121         # Extract playlist id
1122         mobj = re.match(self._VALID_URL, url)
1123         if mobj is None:
1124             raise ExtractorError('Invalid URL: %s' % url)
1125         playlist_id = mobj.group(1) or mobj.group(2)
1126
1127         # Check if it's a video-specific URL
1128         query_dict = compat_urlparse.parse_qs(compat_urlparse.urlparse(url).query)
1129         if 'v' in query_dict:
1130             video_id = query_dict['v'][0]
1131             if self._downloader.params.get('noplaylist'):
1132                 self.to_screen('Downloading just video %s because of --no-playlist' % video_id)
1133                 return self.url_result(video_id, 'Youtube', video_id=video_id)
1134             else:
1135                 self.to_screen('Downloading playlist %s - add --no-playlist to just download video %s' % (playlist_id, video_id))
1136
1137         if playlist_id.startswith('RD'):
1138             # Mixes require a custom extraction process
1139             return self._extract_mix(playlist_id)
1140         if playlist_id.startswith('TL'):
1141             raise ExtractorError('For downloading YouTube.com top lists, use '
1142                                  'the "yttoplist" keyword, for example "youtube-dl \'yttoplist:music:Top Tracks\'"', expected=True)
1143
1144         url = self._TEMPLATE_URL % playlist_id
1145         page = self._download_webpage(url, playlist_id)
1146         more_widget_html = content_html = page
1147
1148         # Check if the playlist exists or is private
1149         if re.search(r'<div class="yt-alert-message">[^<]*?(The|This) playlist (does not exist|is private)[^<]*?</div>', page) is not None:
1150             raise ExtractorError(
1151                 'The playlist doesn\'t exist or is private, use --username or '
1152                 '--netrc to access it.',
1153                 expected=True)
1154
1155         # Extract the video ids from the playlist pages
1156         ids = []
1157
1158         for page_num in itertools.count(1):
1159             matches = re.finditer(self._VIDEO_RE, content_html)
1160             # We remove the duplicates and the link with index 0
1161             # (it's not the first video of the playlist)
1162             new_ids = orderedSet(m.group('id') for m in matches if m.group('index') != '0')
1163             ids.extend(new_ids)
1164
1165             mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
1166             if not mobj:
1167                 break
1168
1169             more = self._download_json(
1170                 'https://youtube.com/%s' % mobj.group('more'), playlist_id,
1171                 'Downloading page #%s' % page_num,
1172                 transform_source=uppercase_escape)
1173             content_html = more['content_html']
1174             more_widget_html = more['load_more_widget_html']
1175
1176         playlist_title = self._html_search_regex(
1177             r'(?s)<h1 class="pl-header-title[^"]*">\s*(.*?)\s*</h1>',
1178             page, 'title')
1179
1180         url_results = self._ids_to_results(ids)
1181         return self.playlist_result(url_results, playlist_id, playlist_title)
1182
1183
1184 class YoutubeTopListIE(YoutubePlaylistIE):
1185     IE_NAME = 'youtube:toplist'
1186     IE_DESC = ('YouTube.com top lists, "yttoplist:{channel}:{list title}"'
1187                ' (Example: "yttoplist:music:Top Tracks")')
1188     _VALID_URL = r'yttoplist:(?P<chann>.*?):(?P<title>.*?)$'
1189     _TESTS = [{
1190         'url': 'yttoplist:music:Trending',
1191         'playlist_mincount': 5,
1192         'skip': 'Only works for logged-in users',
1193     }]
1194
1195     def _real_extract(self, url):
1196         mobj = re.match(self._VALID_URL, url)
1197         channel = mobj.group('chann')
1198         title = mobj.group('title')
1199         query = compat_urllib_parse.urlencode({'title': title})
1200         channel_page = self._download_webpage(
1201             'https://www.youtube.com/%s' % channel, title)
1202         link = self._html_search_regex(
1203             r'''(?x)
1204                 <a\s+href="([^"]+)".*?>\s*
1205                 <span\s+class="branded-page-module-title-text">\s*
1206                 <span[^>]*>.*?%s.*?</span>''' % re.escape(query),
1207             channel_page, 'list')
1208         url = compat_urlparse.urljoin('https://www.youtube.com/', link)
1209
1210         video_re = r'data-index="\d+".*?data-video-id="([0-9A-Za-z_-]{11})"'
1211         ids = []
1212         # sometimes the webpage doesn't contain the videos
1213         # retry until we get them
1214         for i in itertools.count(0):
1215             msg = 'Downloading Youtube mix'
1216             if i > 0:
1217                 msg += ', retry #%d' % i
1218
1219             webpage = self._download_webpage(url, title, msg)
1220             ids = orderedSet(re.findall(video_re, webpage))
1221             if ids:
1222                 break
1223         url_results = self._ids_to_results(ids)
1224         return self.playlist_result(url_results, playlist_title=title)
1225
1226
1227 class YoutubeChannelIE(InfoExtractor):
1228     IE_DESC = 'YouTube.com channels'
1229     _VALID_URL = r"^(?:https?://)?(?:youtu\.be|(?:\w+\.)?youtube(?:-nocookie)?\.com)/channel/([0-9A-Za-z_-]+)"
1230     _MORE_PAGES_INDICATOR = 'yt-uix-load-more'
1231     _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'
1232     IE_NAME = 'youtube:channel'
1233     _TESTS = [{
1234         'note': 'paginated channel',
1235         'url': 'https://www.youtube.com/channel/UCKfVa3S1e4PHvxWcwyMMg8w',
1236         'playlist_mincount': 91,
1237     }]
1238
1239     def extract_videos_from_page(self, page):
1240         ids_in_page = []
1241         for mobj in re.finditer(r'href="/watch\?v=([0-9A-Za-z_-]+)&?', page):
1242             if mobj.group(1) not in ids_in_page:
1243                 ids_in_page.append(mobj.group(1))
1244         return ids_in_page
1245
1246     def _real_extract(self, url):
1247         # Extract channel id
1248         mobj = re.match(self._VALID_URL, url)
1249         if mobj is None:
1250             raise ExtractorError('Invalid URL: %s' % url)
1251
1252         # Download channel page
1253         channel_id = mobj.group(1)
1254         video_ids = []
1255         url = 'https://www.youtube.com/channel/%s/videos' % channel_id
1256         channel_page = self._download_webpage(url, channel_id)
1257         autogenerated = re.search(r'''(?x)
1258                 class="[^"]*?(?:
1259                     channel-header-autogenerated-label|
1260                     yt-channel-title-autogenerated
1261                 )[^"]*"''', channel_page) is not None
1262
1263         if autogenerated:
1264             # The videos are contained in a single page
1265             # the ajax pages can't be used, they are empty
1266             video_ids = self.extract_videos_from_page(channel_page)
1267         else:
1268             # Download all channel pages using the json-based channel_ajax query
1269             for pagenum in itertools.count(1):
1270                 url = self._MORE_PAGES_URL % (pagenum, channel_id)
1271                 page = self._download_json(
1272                     url, channel_id, note='Downloading page #%s' % pagenum,
1273                     transform_source=uppercase_escape)
1274
1275                 ids_in_page = self.extract_videos_from_page(page['content_html'])
1276                 video_ids.extend(ids_in_page)
1277
1278                 if self._MORE_PAGES_INDICATOR not in page['load_more_widget_html']:
1279                     break
1280
1281         self._downloader.to_screen('[youtube] Channel %s: Found %i videos' % (channel_id, len(video_ids)))
1282
1283         url_entries = [self.url_result(video_id, 'Youtube', video_id=video_id)
1284                        for video_id in video_ids]
1285         return self.playlist_result(url_entries, channel_id)
1286
1287
1288 class YoutubeUserIE(InfoExtractor):
1289     IE_DESC = 'YouTube.com user videos (URL or "ytuser" keyword)'
1290     _VALID_URL = r'(?:(?:(?:https?://)?(?:\w+\.)?youtube\.com/(?:user/)?(?!(?:attribution_link|watch|results)(?:$|[^a-z_A-Z0-9-])))|ytuser:)(?!feed/)([A-Za-z0-9_-]+)'
1291     _TEMPLATE_URL = 'https://gdata.youtube.com/feeds/api/users/%s'
1292     _GDATA_PAGE_SIZE = 50
1293     _GDATA_URL = 'https://gdata.youtube.com/feeds/api/users/%s/uploads?max-results=%d&start-index=%d&alt=json'
1294     IE_NAME = 'youtube:user'
1295
1296     _TESTS = [{
1297         'url': 'https://www.youtube.com/user/TheLinuxFoundation',
1298         'playlist_mincount': 320,
1299         'info_dict': {
1300             'title': 'TheLinuxFoundation',
1301         }
1302     }, {
1303         'url': 'ytuser:phihag',
1304         'only_matching': True,
1305     }]
1306
1307     @classmethod
1308     def suitable(cls, url):
1309         # Don't return True if the url can be extracted with other youtube
1310         # extractor, the regex would is too permissive and it would match.
1311         other_ies = iter(klass for (name, klass) in globals().items() if name.endswith('IE') and klass is not cls)
1312         if any(ie.suitable(url) for ie in other_ies):
1313             return False
1314         else:
1315             return super(YoutubeUserIE, cls).suitable(url)
1316
1317     def _real_extract(self, url):
1318         # Extract username
1319         mobj = re.match(self._VALID_URL, url)
1320         if mobj is None:
1321             raise ExtractorError('Invalid URL: %s' % url)
1322
1323         username = mobj.group(1)
1324
1325         # Download video ids using YouTube Data API. Result size per
1326         # query is limited (currently to 50 videos) so we need to query
1327         # page by page until there are no video ids - it means we got
1328         # all of them.
1329
1330         def download_page(pagenum):
1331             start_index = pagenum * self._GDATA_PAGE_SIZE + 1
1332
1333             gdata_url = self._GDATA_URL % (username, self._GDATA_PAGE_SIZE, start_index)
1334             page = self._download_webpage(
1335                 gdata_url, username,
1336                 'Downloading video ids from %d to %d' % (
1337                     start_index, start_index + self._GDATA_PAGE_SIZE))
1338
1339             try:
1340                 response = json.loads(page)
1341             except ValueError as err:
1342                 raise ExtractorError('Invalid JSON in API response: ' + compat_str(err))
1343             if 'entry' not in response['feed']:
1344                 return
1345
1346             # Extract video identifiers
1347             entries = response['feed']['entry']
1348             for entry in entries:
1349                 title = entry['title']['$t']
1350                 video_id = entry['id']['$t'].split('/')[-1]
1351                 yield {
1352                     '_type': 'url',
1353                     'url': video_id,
1354                     'ie_key': 'Youtube',
1355                     'id': video_id,
1356                     'title': title,
1357                 }
1358         url_results = OnDemandPagedList(download_page, self._GDATA_PAGE_SIZE)
1359
1360         return self.playlist_result(url_results, playlist_title=username)
1361
1362
1363 class YoutubeSearchIE(SearchInfoExtractor):
1364     IE_DESC = 'YouTube.com searches'
1365     _API_URL = 'https://gdata.youtube.com/feeds/api/videos?q=%s&start-index=%i&max-results=50&v=2&alt=jsonc'
1366     _MAX_RESULTS = 1000
1367     IE_NAME = 'youtube:search'
1368     _SEARCH_KEY = 'ytsearch'
1369
1370     def _get_n_results(self, query, n):
1371         """Get a specified number of results for a query"""
1372
1373         video_ids = []
1374         pagenum = 0
1375         limit = n
1376         PAGE_SIZE = 50
1377
1378         while (PAGE_SIZE * pagenum) < limit:
1379             result_url = self._API_URL % (
1380                 compat_urllib_parse.quote_plus(query.encode('utf-8')),
1381                 (PAGE_SIZE * pagenum) + 1)
1382             data_json = self._download_webpage(
1383                 result_url, video_id='query "%s"' % query,
1384                 note='Downloading page %s' % (pagenum + 1),
1385                 errnote='Unable to download API page')
1386             data = json.loads(data_json)
1387             api_response = data['data']
1388
1389             if 'items' not in api_response:
1390                 raise ExtractorError(
1391                     '[youtube] No video results', expected=True)
1392
1393             new_ids = list(video['id'] for video in api_response['items'])
1394             video_ids += new_ids
1395
1396             limit = min(n, api_response['totalItems'])
1397             pagenum += 1
1398
1399         if len(video_ids) > n:
1400             video_ids = video_ids[:n]
1401         videos = [self.url_result(video_id, 'Youtube', video_id=video_id)
1402                   for video_id in video_ids]
1403         return self.playlist_result(videos, query)
1404
1405
1406 class YoutubeSearchDateIE(YoutubeSearchIE):
1407     IE_NAME = YoutubeSearchIE.IE_NAME + ':date'
1408     _API_URL = 'https://gdata.youtube.com/feeds/api/videos?q=%s&start-index=%i&max-results=50&v=2&alt=jsonc&orderby=published'
1409     _SEARCH_KEY = 'ytsearchdate'
1410     IE_DESC = 'YouTube.com searches, newest videos first'
1411
1412
1413 class YoutubeSearchURLIE(InfoExtractor):
1414     IE_DESC = 'YouTube.com search URLs'
1415     IE_NAME = 'youtube:search_url'
1416     _VALID_URL = r'https?://(?:www\.)?youtube\.com/results\?(.*?&)?search_query=(?P<query>[^&]+)(?:[&]|$)'
1417     _TESTS = [{
1418         'url': 'https://www.youtube.com/results?baz=bar&search_query=youtube-dl+test+video&filters=video&lclk=video',
1419         'playlist_mincount': 5,
1420         'info_dict': {
1421             'title': 'youtube-dl test video',
1422         }
1423     }]
1424
1425     def _real_extract(self, url):
1426         mobj = re.match(self._VALID_URL, url)
1427         query = compat_urllib_parse.unquote_plus(mobj.group('query'))
1428
1429         webpage = self._download_webpage(url, query)
1430         result_code = self._search_regex(
1431             r'(?s)<ol class="item-section"(.*?)</ol>', webpage, 'result HTML')
1432
1433         part_codes = re.findall(
1434             r'(?s)<h3 class="yt-lockup-title">(.*?)</h3>', result_code)
1435         entries = []
1436         for part_code in part_codes:
1437             part_title = self._html_search_regex(
1438                 [r'(?s)title="([^"]+)"', r'>([^<]+)</a>'], part_code, 'item title', fatal=False)
1439             part_url_snippet = self._html_search_regex(
1440                 r'(?s)href="([^"]+)"', part_code, 'item URL')
1441             part_url = compat_urlparse.urljoin(
1442                 'https://www.youtube.com/', part_url_snippet)
1443             entries.append({
1444                 '_type': 'url',
1445                 'url': part_url,
1446                 'title': part_title,
1447             })
1448
1449         return {
1450             '_type': 'playlist',
1451             'entries': entries,
1452             'title': query,
1453         }
1454
1455
1456 class YoutubeShowIE(InfoExtractor):
1457     IE_DESC = 'YouTube.com (multi-season) shows'
1458     _VALID_URL = r'https?://www\.youtube\.com/show/(?P<id>[^?#]*)'
1459     IE_NAME = 'youtube:show'
1460     _TESTS = [{
1461         'url': 'http://www.youtube.com/show/airdisasters',
1462         'playlist_mincount': 3,
1463         'info_dict': {
1464             'id': 'airdisasters',
1465             'title': 'Air Disasters',
1466         }
1467     }]
1468
1469     def _real_extract(self, url):
1470         mobj = re.match(self._VALID_URL, url)
1471         playlist_id = mobj.group('id')
1472         webpage = self._download_webpage(
1473             url, playlist_id, 'Downloading show webpage')
1474         # There's one playlist for each season of the show
1475         m_seasons = list(re.finditer(r'href="(/playlist\?list=.*?)"', webpage))
1476         self.to_screen('%s: Found %s seasons' % (playlist_id, len(m_seasons)))
1477         entries = [
1478             self.url_result(
1479                 'https://www.youtube.com' + season.group(1), 'YoutubePlaylist')
1480             for season in m_seasons
1481         ]
1482         title = self._og_search_title(webpage, fatal=False)
1483
1484         return {
1485             '_type': 'playlist',
1486             'id': playlist_id,
1487             'title': title,
1488             'entries': entries,
1489         }
1490
1491
1492 class YoutubeFeedsInfoExtractor(YoutubeBaseInfoExtractor):
1493     """
1494     Base class for extractors that fetch info from
1495     http://www.youtube.com/feed_ajax
1496     Subclasses must define the _FEED_NAME and _PLAYLIST_TITLE properties.
1497     """
1498     _LOGIN_REQUIRED = True
1499     # use action_load_personal_feed instead of action_load_system_feed
1500     _PERSONAL_FEED = False
1501
1502     @property
1503     def _FEED_TEMPLATE(self):
1504         action = 'action_load_system_feed'
1505         if self._PERSONAL_FEED:
1506             action = 'action_load_personal_feed'
1507         return 'https://www.youtube.com/feed_ajax?%s=1&feed_name=%s&paging=%%s' % (action, self._FEED_NAME)
1508
1509     @property
1510     def IE_NAME(self):
1511         return 'youtube:%s' % self._FEED_NAME
1512
1513     def _real_initialize(self):
1514         self._login()
1515
1516     def _real_extract(self, url):
1517         feed_entries = []
1518         paging = 0
1519         for i in itertools.count(1):
1520             info = self._download_json(self._FEED_TEMPLATE % paging,
1521                                        '%s feed' % self._FEED_NAME,
1522                                        'Downloading page %s' % i)
1523             feed_html = info.get('feed_html') or info.get('content_html')
1524             load_more_widget_html = info.get('load_more_widget_html') or feed_html
1525             m_ids = re.finditer(r'"/watch\?v=(.*?)["&]', feed_html)
1526             ids = orderedSet(m.group(1) for m in m_ids)
1527             feed_entries.extend(
1528                 self.url_result(video_id, 'Youtube', video_id=video_id)
1529                 for video_id in ids)
1530             mobj = re.search(
1531                 r'data-uix-load-more-href="/?[^"]+paging=(?P<paging>\d+)',
1532                 load_more_widget_html)
1533             if mobj is None:
1534                 break
1535             paging = mobj.group('paging')
1536         return self.playlist_result(feed_entries, playlist_title=self._PLAYLIST_TITLE)
1537
1538
1539 class YoutubeRecommendedIE(YoutubeFeedsInfoExtractor):
1540     IE_DESC = 'YouTube.com recommended videos, ":ytrec" for short (requires authentication)'
1541     _VALID_URL = r'https?://www\.youtube\.com/feed/recommended|:ytrec(?:ommended)?'
1542     _FEED_NAME = 'recommended'
1543     _PLAYLIST_TITLE = 'Youtube Recommended videos'
1544
1545
1546 class YoutubeWatchLaterIE(YoutubeFeedsInfoExtractor):
1547     IE_DESC = 'Youtube watch later list, ":ytwatchlater" for short (requires authentication)'
1548     _VALID_URL = r'https?://www\.youtube\.com/feed/watch_later|:ytwatchlater'
1549     _FEED_NAME = 'watch_later'
1550     _PLAYLIST_TITLE = 'Youtube Watch Later'
1551     _PERSONAL_FEED = True
1552
1553
1554 class YoutubeHistoryIE(YoutubeFeedsInfoExtractor):
1555     IE_DESC = 'Youtube watch history, ":ythistory" for short (requires authentication)'
1556     _VALID_URL = 'https?://www\.youtube\.com/feed/history|:ythistory'
1557     _FEED_NAME = 'history'
1558     _PERSONAL_FEED = True
1559     _PLAYLIST_TITLE = 'Youtube Watch History'
1560
1561
1562 class YoutubeFavouritesIE(YoutubeBaseInfoExtractor):
1563     IE_NAME = 'youtube:favorites'
1564     IE_DESC = 'YouTube.com favourite videos, ":ytfav" for short (requires authentication)'
1565     _VALID_URL = r'https?://www\.youtube\.com/my_favorites|:ytfav(?:ou?rites)?'
1566     _LOGIN_REQUIRED = True
1567
1568     def _real_extract(self, url):
1569         webpage = self._download_webpage('https://www.youtube.com/my_favorites', 'Youtube Favourites videos')
1570         playlist_id = self._search_regex(r'list=(.+?)["&]', webpage, 'favourites playlist id')
1571         return self.url_result(playlist_id, 'YoutubePlaylist')
1572
1573
1574 class YoutubeSubscriptionsIE(YoutubePlaylistIE):
1575     IE_NAME = 'youtube:subscriptions'
1576     IE_DESC = 'YouTube.com subscriptions feed, "ytsubs" keyword (requires authentication)'
1577     _VALID_URL = r'https?://www\.youtube\.com/feed/subscriptions|:ytsubs(?:criptions)?'
1578     _TESTS = []
1579
1580     def _real_extract(self, url):
1581         title = 'Youtube Subscriptions'
1582         page = self._download_webpage('https://www.youtube.com/feed/subscriptions', title)
1583
1584         # The extraction process is the same as for playlists, but the regex
1585         # for the video ids doesn't contain an index
1586         ids = []
1587         more_widget_html = content_html = page
1588
1589         for page_num in itertools.count(1):
1590             matches = re.findall(r'href="\s*/watch\?v=([0-9A-Za-z_-]{11})', content_html)
1591             new_ids = orderedSet(matches)
1592             ids.extend(new_ids)
1593
1594             mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
1595             if not mobj:
1596                 break
1597
1598             more = self._download_json(
1599                 'https://youtube.com/%s' % mobj.group('more'), title,
1600                 'Downloading page #%s' % page_num,
1601                 transform_source=uppercase_escape)
1602             content_html = more['content_html']
1603             more_widget_html = more['load_more_widget_html']
1604
1605         return {
1606             '_type': 'playlist',
1607             'title': title,
1608             'entries': self._ids_to_results(ids),
1609         }
1610
1611
1612 class YoutubeTruncatedURLIE(InfoExtractor):
1613     IE_NAME = 'youtube:truncated_url'
1614     IE_DESC = False  # Do not list
1615     _VALID_URL = r'''(?x)
1616         (?:https?://)?[^/]+/watch\?(?:
1617             feature=[a-z_]+|
1618             annotation_id=annotation_[^&]+
1619         )?$|
1620         (?:https?://)?(?:www\.)?youtube\.com/attribution_link\?a=[^&]+$
1621     '''
1622
1623     _TESTS = [{
1624         'url': 'http://www.youtube.com/watch?annotation_id=annotation_3951667041',
1625         'only_matching': True,
1626     }, {
1627         'url': 'http://www.youtube.com/watch?',
1628         'only_matching': True,
1629     }]
1630
1631     def _real_extract(self, url):
1632         raise ExtractorError(
1633             'Did you forget to quote the URL? Remember that & is a meta '
1634             'character in most shells, so you want to put the URL in quotes, '
1635             'like  youtube-dl '
1636             '"http://www.youtube.com/watch?feature=foo&v=BaW_jenozKc" '
1637             ' or simply  youtube-dl BaW_jenozKc  .',
1638             expected=True)