[youtube] Add format 313 (fixes #4339)
[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 - The Spark ft. Spree Wilson',
383                 'description': 'md5:9717375db5a9a3992be4668bbf3bc0a8',
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         self.report_video_info_webpage_download(video_id)
679         if re.search(r'player-age-gate-content">', video_webpage) is not None:
680             age_gate = True
681             # We simulate the access to the video from www.youtube.com/v/{video_id}
682             # this can be viewed without login into Youtube
683             data = compat_urllib_parse.urlencode({
684                 'video_id': video_id,
685                 'eurl': 'https://youtube.googleapis.com/v/' + video_id,
686                 'sts': self._search_regex(
687                     r'"sts"\s*:\s*(\d+)', video_webpage, 'sts', default=''),
688             })
689             video_info_url = proto + '://www.youtube.com/get_video_info?' + data
690             video_info_webpage = self._download_webpage(
691                 video_info_url, video_id,
692                 note='Refetching age-gated info webpage',
693                 errnote='unable to download video info webpage')
694             video_info = compat_parse_qs(video_info_webpage)
695         else:
696             age_gate = False
697             for el_type in ['&el=embedded', '&el=detailpage', '&el=vevo', '']:
698                 video_info_url = (proto + '://www.youtube.com/get_video_info?&video_id=%s%s&ps=default&eurl=&gl=US&hl=en'
699                                   % (video_id, el_type))
700                 video_info_webpage = self._download_webpage(video_info_url, video_id,
701                                                             note=False,
702                                                             errnote='unable to download video info webpage')
703                 video_info = compat_parse_qs(video_info_webpage)
704                 if 'token' in video_info:
705                     break
706         if 'token' not in video_info:
707             if 'reason' in video_info:
708                 raise ExtractorError(
709                     'YouTube said: %s' % video_info['reason'][0],
710                     expected=True, video_id=video_id)
711             else:
712                 raise ExtractorError(
713                     '"token" parameter not in video info for unknown reason',
714                     video_id=video_id)
715
716         if 'view_count' in video_info:
717             view_count = int(video_info['view_count'][0])
718         else:
719             view_count = None
720
721         # Check for "rental" videos
722         if 'ypc_video_rental_bar_text' in video_info and 'author' not in video_info:
723             raise ExtractorError('"rental" videos not supported')
724
725         # Start extracting information
726         self.report_information_extraction(video_id)
727
728         # uploader
729         if 'author' not in video_info:
730             raise ExtractorError('Unable to extract uploader name')
731         video_uploader = compat_urllib_parse.unquote_plus(video_info['author'][0])
732
733         # uploader_id
734         video_uploader_id = None
735         mobj = re.search(r'<link itemprop="url" href="http://www.youtube.com/(?:user|channel)/([^"]+)">', video_webpage)
736         if mobj is not None:
737             video_uploader_id = mobj.group(1)
738         else:
739             self._downloader.report_warning('unable to extract uploader nickname')
740
741         # title
742         if 'title' in video_info:
743             video_title = video_info['title'][0]
744         else:
745             self._downloader.report_warning('Unable to extract video title')
746             video_title = '_'
747
748         # thumbnail image
749         # We try first to get a high quality image:
750         m_thumb = re.search(r'<span itemprop="thumbnail".*?href="(.*?)">',
751                             video_webpage, re.DOTALL)
752         if m_thumb is not None:
753             video_thumbnail = m_thumb.group(1)
754         elif 'thumbnail_url' not in video_info:
755             self._downloader.report_warning('unable to extract video thumbnail')
756             video_thumbnail = None
757         else:   # don't panic if we can't find it
758             video_thumbnail = compat_urllib_parse.unquote_plus(video_info['thumbnail_url'][0])
759
760         # upload date
761         upload_date = None
762         mobj = re.search(r'(?s)id="eow-date.*?>(.*?)</span>', video_webpage)
763         if mobj is None:
764             mobj = re.search(
765                 r'(?s)id="watch-uploader-info".*?>.*?(?:Published|Uploaded|Streamed live) on (.*?)</strong>',
766                 video_webpage)
767         if mobj is not None:
768             upload_date = ' '.join(re.sub(r'[/,-]', r' ', mobj.group(1)).split())
769             upload_date = unified_strdate(upload_date)
770
771         m_cat_container = self._search_regex(
772             r'(?s)<h4[^>]*>\s*Category\s*</h4>\s*<ul[^>]*>(.*?)</ul>',
773             video_webpage, 'categories', fatal=False)
774         if m_cat_container:
775             category = self._html_search_regex(
776                 r'(?s)<a[^<]+>(.*?)</a>', m_cat_container, 'category',
777                 default=None)
778             video_categories = None if category is None else [category]
779         else:
780             video_categories = None
781
782         # description
783         video_description = get_element_by_id("eow-description", video_webpage)
784         if video_description:
785             video_description = re.sub(r'''(?x)
786                 <a\s+
787                     (?:[a-zA-Z-]+="[^"]+"\s+)*?
788                     title="([^"]+)"\s+
789                     (?:[a-zA-Z-]+="[^"]+"\s+)*?
790                     class="yt-uix-redirect-link"\s*>
791                 [^<]+
792                 </a>
793             ''', r'\1', video_description)
794             video_description = clean_html(video_description)
795         else:
796             fd_mobj = re.search(r'<meta name="description" content="([^"]+)"', video_webpage)
797             if fd_mobj:
798                 video_description = unescapeHTML(fd_mobj.group(1))
799             else:
800                 video_description = ''
801
802         def _extract_count(count_name):
803             count = self._search_regex(
804                 r'id="watch-%s"[^>]*>.*?([\d,]+)\s*</span>' % re.escape(count_name),
805                 video_webpage, count_name, default=None)
806             if count is not None:
807                 return int(count.replace(',', ''))
808             return None
809         like_count = _extract_count('like')
810         dislike_count = _extract_count('dislike')
811
812         # subtitles
813         video_subtitles = self.extract_subtitles(video_id, video_webpage)
814
815         if self._downloader.params.get('listsubtitles', False):
816             self._list_available_subtitles(video_id, video_webpage)
817             return
818
819         if 'length_seconds' not in video_info:
820             self._downloader.report_warning('unable to extract video duration')
821             video_duration = None
822         else:
823             video_duration = int(compat_urllib_parse.unquote_plus(video_info['length_seconds'][0]))
824
825         # annotations
826         video_annotations = None
827         if self._downloader.params.get('writeannotations', False):
828             video_annotations = self._extract_annotations(video_id)
829
830         # Decide which formats to download
831         try:
832             mobj = re.search(r';ytplayer\.config\s*=\s*({.*?});', video_webpage)
833             if not mobj:
834                 raise ValueError('Could not find vevo ID')
835             json_code = uppercase_escape(mobj.group(1))
836             ytplayer_config = json.loads(json_code)
837             args = ytplayer_config['args']
838             # Easy way to know if the 's' value is in url_encoded_fmt_stream_map
839             # this signatures are encrypted
840             if 'url_encoded_fmt_stream_map' not in args:
841                 raise ValueError('No stream_map present')  # caught below
842             re_signature = re.compile(r'[&,]s=')
843             m_s = re_signature.search(args['url_encoded_fmt_stream_map'])
844             if m_s is not None:
845                 self.to_screen('%s: Encrypted signatures detected.' % video_id)
846                 video_info['url_encoded_fmt_stream_map'] = [args['url_encoded_fmt_stream_map']]
847             m_s = re_signature.search(args.get('adaptive_fmts', ''))
848             if m_s is not None:
849                 if 'adaptive_fmts' in video_info:
850                     video_info['adaptive_fmts'][0] += ',' + args['adaptive_fmts']
851                 else:
852                     video_info['adaptive_fmts'] = [args['adaptive_fmts']]
853         except ValueError:
854             pass
855
856         def _map_to_format_list(urlmap):
857             formats = []
858             for itag, video_real_url in urlmap.items():
859                 dct = {
860                     'format_id': itag,
861                     'url': video_real_url,
862                     'player_url': player_url,
863                 }
864                 if itag in self._formats:
865                     dct.update(self._formats[itag])
866                 formats.append(dct)
867             return formats
868
869         if 'conn' in video_info and video_info['conn'][0].startswith('rtmp'):
870             self.report_rtmp_download()
871             formats = [{
872                 'format_id': '_rtmp',
873                 'protocol': 'rtmp',
874                 'url': video_info['conn'][0],
875                 'player_url': player_url,
876             }]
877         elif len(video_info.get('url_encoded_fmt_stream_map', [])) >= 1 or len(video_info.get('adaptive_fmts', [])) >= 1:
878             encoded_url_map = video_info.get('url_encoded_fmt_stream_map', [''])[0] + ',' + video_info.get('adaptive_fmts', [''])[0]
879             if 'rtmpe%3Dyes' in encoded_url_map:
880                 raise ExtractorError('rtmpe downloads are not supported, see https://github.com/rg3/youtube-dl/issues/343 for more information.', expected=True)
881             url_map = {}
882             for url_data_str in encoded_url_map.split(','):
883                 url_data = compat_parse_qs(url_data_str)
884                 if 'itag' not in url_data or 'url' not in url_data:
885                     continue
886                 format_id = url_data['itag'][0]
887                 url = url_data['url'][0]
888
889                 if 'sig' in url_data:
890                     url += '&signature=' + url_data['sig'][0]
891                 elif 's' in url_data:
892                     encrypted_sig = url_data['s'][0]
893
894                     if not age_gate:
895                         jsplayer_url_json = self._search_regex(
896                             r'"assets":.+?"js":\s*("[^"]+")',
897                             video_webpage, 'JS player URL')
898                         player_url = json.loads(jsplayer_url_json)
899                     if player_url is None:
900                         player_url_json = self._search_regex(
901                             r'ytplayer\.config.*?"url"\s*:\s*("[^"]+")',
902                             video_webpage, 'age gate player URL')
903                         player_url = json.loads(player_url_json)
904
905                     if self._downloader.params.get('verbose'):
906                         if player_url is None:
907                             player_version = 'unknown'
908                             player_desc = 'unknown'
909                         else:
910                             if player_url.endswith('swf'):
911                                 player_version = self._search_regex(
912                                     r'-(.+?)(?:/watch_as3)?\.swf$', player_url,
913                                     'flash player', fatal=False)
914                                 player_desc = 'flash player %s' % player_version
915                             else:
916                                 player_version = self._search_regex(
917                                     r'html5player-([^/]+?)(?:/html5player)?\.js',
918                                     player_url,
919                                     'html5 player', fatal=False)
920                                 player_desc = 'html5 player %s' % player_version
921
922                         parts_sizes = self._signature_cache_id(encrypted_sig)
923                         self.to_screen('{%s} signature length %s, %s' %
924                                        (format_id, parts_sizes, player_desc))
925
926                     signature = self._decrypt_signature(
927                         encrypted_sig, video_id, player_url, age_gate)
928                     url += '&signature=' + signature
929                 if 'ratebypass' not in url:
930                     url += '&ratebypass=yes'
931                 url_map[format_id] = url
932             formats = _map_to_format_list(url_map)
933         elif video_info.get('hlsvp'):
934             manifest_url = video_info['hlsvp'][0]
935             url_map = self._extract_from_m3u8(manifest_url, video_id)
936             formats = _map_to_format_list(url_map)
937         else:
938             raise ExtractorError('no conn, hlsvp or url_encoded_fmt_stream_map information found in video info')
939
940         # Look for the DASH manifest
941         if self._downloader.params.get('youtube_include_dash_manifest', True):
942             try:
943                 # The DASH manifest used needs to be the one from the original video_webpage.
944                 # The one found in get_video_info seems to be using different signatures.
945                 # However, in the case of an age restriction there won't be any embedded dashmpd in the video_webpage.
946                 # Luckily, it seems, this case uses some kind of default signature (len == 86), so the
947                 # combination of get_video_info and the _static_decrypt_signature() decryption fallback will work here.
948                 if age_gate:
949                     dash_manifest_url = video_info.get('dashmpd')[0]
950                 else:
951                     dash_manifest_url = ytplayer_config['args']['dashmpd']
952
953                 def decrypt_sig(mobj):
954                     s = mobj.group(1)
955                     dec_s = self._decrypt_signature(s, video_id, player_url, age_gate)
956                     return '/signature/%s' % dec_s
957                 dash_manifest_url = re.sub(r'/s/([\w\.]+)', decrypt_sig, dash_manifest_url)
958                 dash_doc = self._download_xml(
959                     dash_manifest_url, video_id,
960                     note='Downloading DASH manifest',
961                     errnote='Could not download DASH manifest')
962                 for r in dash_doc.findall('.//{urn:mpeg:DASH:schema:MPD:2011}Representation'):
963                     url_el = r.find('{urn:mpeg:DASH:schema:MPD:2011}BaseURL')
964                     if url_el is None:
965                         continue
966                     format_id = r.attrib['id']
967                     video_url = url_el.text
968                     filesize = int_or_none(url_el.attrib.get('{http://youtube.com/yt/2012/10/10}contentLength'))
969                     f = {
970                         'format_id': format_id,
971                         'url': video_url,
972                         'width': int_or_none(r.attrib.get('width')),
973                         'tbr': int_or_none(r.attrib.get('bandwidth'), 1000),
974                         'asr': int_or_none(r.attrib.get('audioSamplingRate')),
975                         'filesize': filesize,
976                     }
977                     try:
978                         existing_format = next(
979                             fo for fo in formats
980                             if fo['format_id'] == format_id)
981                     except StopIteration:
982                         f.update(self._formats.get(format_id, {}))
983                         formats.append(f)
984                     else:
985                         existing_format.update(f)
986
987             except (ExtractorError, KeyError) as e:
988                 self.report_warning('Skipping DASH manifest: %r' % e, video_id)
989
990         self._sort_formats(formats)
991
992         return {
993             'id': video_id,
994             'uploader': video_uploader,
995             'uploader_id': video_uploader_id,
996             'upload_date': upload_date,
997             'title': video_title,
998             'thumbnail': video_thumbnail,
999             'description': video_description,
1000             'categories': video_categories,
1001             'subtitles': video_subtitles,
1002             'duration': video_duration,
1003             'age_limit': 18 if age_gate else 0,
1004             'annotations': video_annotations,
1005             'webpage_url': proto + '://www.youtube.com/watch?v=%s' % video_id,
1006             'view_count': view_count,
1007             'like_count': like_count,
1008             'dislike_count': dislike_count,
1009             'formats': formats,
1010         }
1011
1012
1013 class YoutubePlaylistIE(YoutubeBaseInfoExtractor):
1014     IE_DESC = 'YouTube.com playlists'
1015     _VALID_URL = r"""(?x)(?:
1016                         (?:https?://)?
1017                         (?:\w+\.)?
1018                         youtube\.com/
1019                         (?:
1020                            (?:course|view_play_list|my_playlists|artist|playlist|watch|embed/videoseries)
1021                            \? (?:.*?&)*? (?:p|a|list)=
1022                         |  p/
1023                         )
1024                         (
1025                             (?:PL|LL|EC|UU|FL|RD)?[0-9A-Za-z-_]{10,}
1026                             # Top tracks, they can also include dots
1027                             |(?:MC)[\w\.]*
1028                         )
1029                         .*
1030                      |
1031                         ((?:PL|LL|EC|UU|FL|RD)[0-9A-Za-z-_]{10,})
1032                      )"""
1033     _TEMPLATE_URL = 'https://www.youtube.com/playlist?list=%s'
1034     _MORE_PAGES_INDICATOR = r'data-link-type="next"'
1035     _VIDEO_RE = r'href="\s*/watch\?v=(?P<id>[0-9A-Za-z_-]{11})&amp;[^"]*?index=(?P<index>\d+)'
1036     IE_NAME = 'youtube:playlist'
1037     _TESTS = [{
1038         'url': 'https://www.youtube.com/playlist?list=PLwiyx1dc3P2JR9N8gQaQN_BCvlSlap7re',
1039         'info_dict': {
1040             'title': 'ytdl test PL',
1041             'id': 'PLwiyx1dc3P2JR9N8gQaQN_BCvlSlap7re',
1042         },
1043         'playlist_count': 3,
1044     }, {
1045         'url': 'https://www.youtube.com/playlist?list=PLtPgu7CB4gbZDA7i_euNxn75ISqxwZPYx',
1046         'info_dict': {
1047             'title': 'YDL_Empty_List',
1048         },
1049         'playlist_count': 0,
1050     }, {
1051         'note': 'Playlist with deleted videos (#651). As a bonus, the video #51 is also twice in this list.',
1052         'url': 'https://www.youtube.com/playlist?list=PLwP_SiAcdui0KVebT0mU9Apz359a4ubsC',
1053         'info_dict': {
1054             'title': '29C3: Not my department',
1055         },
1056         'playlist_count': 95,
1057     }, {
1058         'note': 'issue #673',
1059         'url': 'PLBB231211A4F62143',
1060         'info_dict': {
1061             'title': '[OLD]Team Fortress 2 (Class-based LP)',
1062         },
1063         'playlist_mincount': 26,
1064     }, {
1065         'note': 'Large playlist',
1066         'url': 'https://www.youtube.com/playlist?list=UUBABnxM4Ar9ten8Mdjj1j0Q',
1067         'info_dict': {
1068             'title': 'Uploads from Cauchemar',
1069         },
1070         'playlist_mincount': 799,
1071     }, {
1072         'url': 'PLtPgu7CB4gbY9oDN3drwC3cMbJggS7dKl',
1073         'info_dict': {
1074             'title': 'YDL_safe_search',
1075         },
1076         'playlist_count': 2,
1077     }, {
1078         'note': 'embedded',
1079         'url': 'http://www.youtube.com/embed/videoseries?list=PL6IaIsEjSbf96XFRuNccS_RuEXwNdsoEu',
1080         'playlist_count': 4,
1081         'info_dict': {
1082             'title': 'JODA15',
1083         }
1084     }, {
1085         'note': 'Embedded SWF player',
1086         'url': 'http://www.youtube.com/p/YN5VISEtHet5D4NEvfTd0zcgFk84NqFZ?hl=en_US&fs=1&rel=0',
1087         'playlist_count': 4,
1088         'info_dict': {
1089             'title': 'JODA7',
1090         }
1091     }]
1092
1093     def _real_initialize(self):
1094         self._login()
1095
1096     def _ids_to_results(self, ids):
1097         return [
1098             self.url_result(vid_id, 'Youtube', video_id=vid_id)
1099             for vid_id in ids]
1100
1101     def _extract_mix(self, playlist_id):
1102         # The mixes are generated from a a single video
1103         # the id of the playlist is just 'RD' + video_id
1104         url = 'https://youtube.com/watch?v=%s&list=%s' % (playlist_id[-11:], playlist_id)
1105         webpage = self._download_webpage(
1106             url, playlist_id, 'Downloading Youtube mix')
1107         search_title = lambda class_name: get_element_by_attribute('class', class_name, webpage)
1108         title_span = (
1109             search_title('playlist-title') or
1110             search_title('title long-title') or
1111             search_title('title'))
1112         title = clean_html(title_span)
1113         ids = orderedSet(re.findall(
1114             r'''(?xs)data-video-username=".*?".*?
1115                        href="/watch\?v=([0-9A-Za-z_-]{11})&amp;[^"]*?list=%s''' % re.escape(playlist_id),
1116             webpage))
1117         url_results = self._ids_to_results(ids)
1118
1119         return self.playlist_result(url_results, playlist_id, title)
1120
1121     def _real_extract(self, url):
1122         # Extract playlist id
1123         mobj = re.match(self._VALID_URL, url)
1124         if mobj is None:
1125             raise ExtractorError('Invalid URL: %s' % url)
1126         playlist_id = mobj.group(1) or mobj.group(2)
1127
1128         # Check if it's a video-specific URL
1129         query_dict = compat_urlparse.parse_qs(compat_urlparse.urlparse(url).query)
1130         if 'v' in query_dict:
1131             video_id = query_dict['v'][0]
1132             if self._downloader.params.get('noplaylist'):
1133                 self.to_screen('Downloading just video %s because of --no-playlist' % video_id)
1134                 return self.url_result(video_id, 'Youtube', video_id=video_id)
1135             else:
1136                 self.to_screen('Downloading playlist %s - add --no-playlist to just download video %s' % (playlist_id, video_id))
1137
1138         if playlist_id.startswith('RD'):
1139             # Mixes require a custom extraction process
1140             return self._extract_mix(playlist_id)
1141         if playlist_id.startswith('TL'):
1142             raise ExtractorError('For downloading YouTube.com top lists, use '
1143                                  'the "yttoplist" keyword, for example "youtube-dl \'yttoplist:music:Top Tracks\'"', expected=True)
1144
1145         url = self._TEMPLATE_URL % playlist_id
1146         page = self._download_webpage(url, playlist_id)
1147         more_widget_html = content_html = page
1148
1149         # Check if the playlist exists or is private
1150         if re.search(r'<div class="yt-alert-message">[^<]*?(The|This) playlist (does not exist|is private)[^<]*?</div>', page) is not None:
1151             raise ExtractorError(
1152                 'The playlist doesn\'t exist or is private, use --username or '
1153                 '--netrc to access it.',
1154                 expected=True)
1155
1156         # Extract the video ids from the playlist pages
1157         ids = []
1158
1159         for page_num in itertools.count(1):
1160             matches = re.finditer(self._VIDEO_RE, content_html)
1161             # We remove the duplicates and the link with index 0
1162             # (it's not the first video of the playlist)
1163             new_ids = orderedSet(m.group('id') for m in matches if m.group('index') != '0')
1164             ids.extend(new_ids)
1165
1166             mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
1167             if not mobj:
1168                 break
1169
1170             more = self._download_json(
1171                 'https://youtube.com/%s' % mobj.group('more'), playlist_id,
1172                 'Downloading page #%s' % page_num,
1173                 transform_source=uppercase_escape)
1174             content_html = more['content_html']
1175             more_widget_html = more['load_more_widget_html']
1176
1177         playlist_title = self._html_search_regex(
1178             r'(?s)<h1 class="pl-header-title[^"]*">\s*(.*?)\s*</h1>',
1179             page, 'title')
1180
1181         url_results = self._ids_to_results(ids)
1182         return self.playlist_result(url_results, playlist_id, playlist_title)
1183
1184
1185 class YoutubeTopListIE(YoutubePlaylistIE):
1186     IE_NAME = 'youtube:toplist'
1187     IE_DESC = ('YouTube.com top lists, "yttoplist:{channel}:{list title}"'
1188                ' (Example: "yttoplist:music:Top Tracks")')
1189     _VALID_URL = r'yttoplist:(?P<chann>.*?):(?P<title>.*?)$'
1190     _TESTS = [{
1191         'url': 'yttoplist:music:Trending',
1192         'playlist_mincount': 5,
1193         'skip': 'Only works for logged-in users',
1194     }]
1195
1196     def _real_extract(self, url):
1197         mobj = re.match(self._VALID_URL, url)
1198         channel = mobj.group('chann')
1199         title = mobj.group('title')
1200         query = compat_urllib_parse.urlencode({'title': title})
1201         channel_page = self._download_webpage(
1202             'https://www.youtube.com/%s' % channel, title)
1203         link = self._html_search_regex(
1204             r'''(?x)
1205                 <a\s+href="([^"]+)".*?>\s*
1206                 <span\s+class="branded-page-module-title-text">\s*
1207                 <span[^>]*>.*?%s.*?</span>''' % re.escape(query),
1208             channel_page, 'list')
1209         url = compat_urlparse.urljoin('https://www.youtube.com/', link)
1210
1211         video_re = r'data-index="\d+".*?data-video-id="([0-9A-Za-z_-]{11})"'
1212         ids = []
1213         # sometimes the webpage doesn't contain the videos
1214         # retry until we get them
1215         for i in itertools.count(0):
1216             msg = 'Downloading Youtube mix'
1217             if i > 0:
1218                 msg += ', retry #%d' % i
1219
1220             webpage = self._download_webpage(url, title, msg)
1221             ids = orderedSet(re.findall(video_re, webpage))
1222             if ids:
1223                 break
1224         url_results = self._ids_to_results(ids)
1225         return self.playlist_result(url_results, playlist_title=title)
1226
1227
1228 class YoutubeChannelIE(InfoExtractor):
1229     IE_DESC = 'YouTube.com channels'
1230     _VALID_URL = r"^(?:https?://)?(?:youtu\.be|(?:\w+\.)?youtube(?:-nocookie)?\.com)/channel/([0-9A-Za-z_-]+)"
1231     _MORE_PAGES_INDICATOR = 'yt-uix-load-more'
1232     _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'
1233     IE_NAME = 'youtube:channel'
1234     _TESTS = [{
1235         'note': 'paginated channel',
1236         'url': 'https://www.youtube.com/channel/UCKfVa3S1e4PHvxWcwyMMg8w',
1237         'playlist_mincount': 91,
1238     }]
1239
1240     def extract_videos_from_page(self, page):
1241         ids_in_page = []
1242         for mobj in re.finditer(r'href="/watch\?v=([0-9A-Za-z_-]+)&?', page):
1243             if mobj.group(1) not in ids_in_page:
1244                 ids_in_page.append(mobj.group(1))
1245         return ids_in_page
1246
1247     def _real_extract(self, url):
1248         # Extract channel id
1249         mobj = re.match(self._VALID_URL, url)
1250         if mobj is None:
1251             raise ExtractorError('Invalid URL: %s' % url)
1252
1253         # Download channel page
1254         channel_id = mobj.group(1)
1255         video_ids = []
1256         url = 'https://www.youtube.com/channel/%s/videos' % channel_id
1257         channel_page = self._download_webpage(url, channel_id)
1258         autogenerated = re.search(r'''(?x)
1259                 class="[^"]*?(?:
1260                     channel-header-autogenerated-label|
1261                     yt-channel-title-autogenerated
1262                 )[^"]*"''', channel_page) is not None
1263
1264         if autogenerated:
1265             # The videos are contained in a single page
1266             # the ajax pages can't be used, they are empty
1267             video_ids = self.extract_videos_from_page(channel_page)
1268         else:
1269             # Download all channel pages using the json-based channel_ajax query
1270             for pagenum in itertools.count(1):
1271                 url = self._MORE_PAGES_URL % (pagenum, channel_id)
1272                 page = self._download_json(
1273                     url, channel_id, note='Downloading page #%s' % pagenum,
1274                     transform_source=uppercase_escape)
1275
1276                 ids_in_page = self.extract_videos_from_page(page['content_html'])
1277                 video_ids.extend(ids_in_page)
1278
1279                 if self._MORE_PAGES_INDICATOR not in page['load_more_widget_html']:
1280                     break
1281
1282         self._downloader.to_screen('[youtube] Channel %s: Found %i videos' % (channel_id, len(video_ids)))
1283
1284         url_entries = [self.url_result(video_id, 'Youtube', video_id=video_id)
1285                        for video_id in video_ids]
1286         return self.playlist_result(url_entries, channel_id)
1287
1288
1289 class YoutubeUserIE(InfoExtractor):
1290     IE_DESC = 'YouTube.com user videos (URL or "ytuser" keyword)'
1291     _VALID_URL = r'(?:(?:(?:https?://)?(?:\w+\.)?youtube\.com/(?:user/)?(?!(?:attribution_link|watch|results)(?:$|[^a-z_A-Z0-9-])))|ytuser:)(?!feed/)([A-Za-z0-9_-]+)'
1292     _TEMPLATE_URL = 'https://gdata.youtube.com/feeds/api/users/%s'
1293     _GDATA_PAGE_SIZE = 50
1294     _GDATA_URL = 'https://gdata.youtube.com/feeds/api/users/%s/uploads?max-results=%d&start-index=%d&alt=json'
1295     IE_NAME = 'youtube:user'
1296
1297     _TESTS = [{
1298         'url': 'https://www.youtube.com/user/TheLinuxFoundation',
1299         'playlist_mincount': 320,
1300         'info_dict': {
1301             'title': 'TheLinuxFoundation',
1302         }
1303     }, {
1304         'url': 'ytuser:phihag',
1305         'only_matching': True,
1306     }]
1307
1308     @classmethod
1309     def suitable(cls, url):
1310         # Don't return True if the url can be extracted with other youtube
1311         # extractor, the regex would is too permissive and it would match.
1312         other_ies = iter(klass for (name, klass) in globals().items() if name.endswith('IE') and klass is not cls)
1313         if any(ie.suitable(url) for ie in other_ies):
1314             return False
1315         else:
1316             return super(YoutubeUserIE, cls).suitable(url)
1317
1318     def _real_extract(self, url):
1319         # Extract username
1320         mobj = re.match(self._VALID_URL, url)
1321         if mobj is None:
1322             raise ExtractorError('Invalid URL: %s' % url)
1323
1324         username = mobj.group(1)
1325
1326         # Download video ids using YouTube Data API. Result size per
1327         # query is limited (currently to 50 videos) so we need to query
1328         # page by page until there are no video ids - it means we got
1329         # all of them.
1330
1331         def download_page(pagenum):
1332             start_index = pagenum * self._GDATA_PAGE_SIZE + 1
1333
1334             gdata_url = self._GDATA_URL % (username, self._GDATA_PAGE_SIZE, start_index)
1335             page = self._download_webpage(
1336                 gdata_url, username,
1337                 'Downloading video ids from %d to %d' % (
1338                     start_index, start_index + self._GDATA_PAGE_SIZE))
1339
1340             try:
1341                 response = json.loads(page)
1342             except ValueError as err:
1343                 raise ExtractorError('Invalid JSON in API response: ' + compat_str(err))
1344             if 'entry' not in response['feed']:
1345                 return
1346
1347             # Extract video identifiers
1348             entries = response['feed']['entry']
1349             for entry in entries:
1350                 title = entry['title']['$t']
1351                 video_id = entry['id']['$t'].split('/')[-1]
1352                 yield {
1353                     '_type': 'url',
1354                     'url': video_id,
1355                     'ie_key': 'Youtube',
1356                     'id': video_id,
1357                     'title': title,
1358                 }
1359         url_results = OnDemandPagedList(download_page, self._GDATA_PAGE_SIZE)
1360
1361         return self.playlist_result(url_results, playlist_title=username)
1362
1363
1364 class YoutubeSearchIE(SearchInfoExtractor):
1365     IE_DESC = 'YouTube.com searches'
1366     _API_URL = 'https://gdata.youtube.com/feeds/api/videos?q=%s&start-index=%i&max-results=50&v=2&alt=jsonc'
1367     _MAX_RESULTS = 1000
1368     IE_NAME = 'youtube:search'
1369     _SEARCH_KEY = 'ytsearch'
1370
1371     def _get_n_results(self, query, n):
1372         """Get a specified number of results for a query"""
1373
1374         video_ids = []
1375         pagenum = 0
1376         limit = n
1377         PAGE_SIZE = 50
1378
1379         while (PAGE_SIZE * pagenum) < limit:
1380             result_url = self._API_URL % (
1381                 compat_urllib_parse.quote_plus(query.encode('utf-8')),
1382                 (PAGE_SIZE * pagenum) + 1)
1383             data_json = self._download_webpage(
1384                 result_url, video_id='query "%s"' % query,
1385                 note='Downloading page %s' % (pagenum + 1),
1386                 errnote='Unable to download API page')
1387             data = json.loads(data_json)
1388             api_response = data['data']
1389
1390             if 'items' not in api_response:
1391                 raise ExtractorError(
1392                     '[youtube] No video results', expected=True)
1393
1394             new_ids = list(video['id'] for video in api_response['items'])
1395             video_ids += new_ids
1396
1397             limit = min(n, api_response['totalItems'])
1398             pagenum += 1
1399
1400         if len(video_ids) > n:
1401             video_ids = video_ids[:n]
1402         videos = [self.url_result(video_id, 'Youtube', video_id=video_id)
1403                   for video_id in video_ids]
1404         return self.playlist_result(videos, query)
1405
1406
1407 class YoutubeSearchDateIE(YoutubeSearchIE):
1408     IE_NAME = YoutubeSearchIE.IE_NAME + ':date'
1409     _API_URL = 'https://gdata.youtube.com/feeds/api/videos?q=%s&start-index=%i&max-results=50&v=2&alt=jsonc&orderby=published'
1410     _SEARCH_KEY = 'ytsearchdate'
1411     IE_DESC = 'YouTube.com searches, newest videos first'
1412
1413
1414 class YoutubeSearchURLIE(InfoExtractor):
1415     IE_DESC = 'YouTube.com search URLs'
1416     IE_NAME = 'youtube:search_url'
1417     _VALID_URL = r'https?://(?:www\.)?youtube\.com/results\?(.*?&)?search_query=(?P<query>[^&]+)(?:[&]|$)'
1418     _TESTS = [{
1419         'url': 'https://www.youtube.com/results?baz=bar&search_query=youtube-dl+test+video&filters=video&lclk=video',
1420         'playlist_mincount': 5,
1421         'info_dict': {
1422             'title': 'youtube-dl test video',
1423         }
1424     }]
1425
1426     def _real_extract(self, url):
1427         mobj = re.match(self._VALID_URL, url)
1428         query = compat_urllib_parse.unquote_plus(mobj.group('query'))
1429
1430         webpage = self._download_webpage(url, query)
1431         result_code = self._search_regex(
1432             r'(?s)<ol class="item-section"(.*?)</ol>', webpage, 'result HTML')
1433
1434         part_codes = re.findall(
1435             r'(?s)<h3 class="yt-lockup-title">(.*?)</h3>', result_code)
1436         entries = []
1437         for part_code in part_codes:
1438             part_title = self._html_search_regex(
1439                 [r'(?s)title="([^"]+)"', r'>([^<]+)</a>'], part_code, 'item title', fatal=False)
1440             part_url_snippet = self._html_search_regex(
1441                 r'(?s)href="([^"]+)"', part_code, 'item URL')
1442             part_url = compat_urlparse.urljoin(
1443                 'https://www.youtube.com/', part_url_snippet)
1444             entries.append({
1445                 '_type': 'url',
1446                 'url': part_url,
1447                 'title': part_title,
1448             })
1449
1450         return {
1451             '_type': 'playlist',
1452             'entries': entries,
1453             'title': query,
1454         }
1455
1456
1457 class YoutubeShowIE(InfoExtractor):
1458     IE_DESC = 'YouTube.com (multi-season) shows'
1459     _VALID_URL = r'https?://www\.youtube\.com/show/(?P<id>[^?#]*)'
1460     IE_NAME = 'youtube:show'
1461     _TESTS = [{
1462         'url': 'http://www.youtube.com/show/airdisasters',
1463         'playlist_mincount': 3,
1464         'info_dict': {
1465             'id': 'airdisasters',
1466             'title': 'Air Disasters',
1467         }
1468     }]
1469
1470     def _real_extract(self, url):
1471         mobj = re.match(self._VALID_URL, url)
1472         playlist_id = mobj.group('id')
1473         webpage = self._download_webpage(
1474             url, playlist_id, 'Downloading show webpage')
1475         # There's one playlist for each season of the show
1476         m_seasons = list(re.finditer(r'href="(/playlist\?list=.*?)"', webpage))
1477         self.to_screen('%s: Found %s seasons' % (playlist_id, len(m_seasons)))
1478         entries = [
1479             self.url_result(
1480                 'https://www.youtube.com' + season.group(1), 'YoutubePlaylist')
1481             for season in m_seasons
1482         ]
1483         title = self._og_search_title(webpage, fatal=False)
1484
1485         return {
1486             '_type': 'playlist',
1487             'id': playlist_id,
1488             'title': title,
1489             'entries': entries,
1490         }
1491
1492
1493 class YoutubeFeedsInfoExtractor(YoutubeBaseInfoExtractor):
1494     """
1495     Base class for extractors that fetch info from
1496     http://www.youtube.com/feed_ajax
1497     Subclasses must define the _FEED_NAME and _PLAYLIST_TITLE properties.
1498     """
1499     _LOGIN_REQUIRED = True
1500     # use action_load_personal_feed instead of action_load_system_feed
1501     _PERSONAL_FEED = False
1502
1503     @property
1504     def _FEED_TEMPLATE(self):
1505         action = 'action_load_system_feed'
1506         if self._PERSONAL_FEED:
1507             action = 'action_load_personal_feed'
1508         return 'https://www.youtube.com/feed_ajax?%s=1&feed_name=%s&paging=%%s' % (action, self._FEED_NAME)
1509
1510     @property
1511     def IE_NAME(self):
1512         return 'youtube:%s' % self._FEED_NAME
1513
1514     def _real_initialize(self):
1515         self._login()
1516
1517     def _real_extract(self, url):
1518         feed_entries = []
1519         paging = 0
1520         for i in itertools.count(1):
1521             info = self._download_json(self._FEED_TEMPLATE % paging,
1522                                        '%s feed' % self._FEED_NAME,
1523                                        'Downloading page %s' % i)
1524             feed_html = info.get('feed_html') or info.get('content_html')
1525             load_more_widget_html = info.get('load_more_widget_html') or feed_html
1526             m_ids = re.finditer(r'"/watch\?v=(.*?)["&]', feed_html)
1527             ids = orderedSet(m.group(1) for m in m_ids)
1528             feed_entries.extend(
1529                 self.url_result(video_id, 'Youtube', video_id=video_id)
1530                 for video_id in ids)
1531             mobj = re.search(
1532                 r'data-uix-load-more-href="/?[^"]+paging=(?P<paging>\d+)',
1533                 load_more_widget_html)
1534             if mobj is None:
1535                 break
1536             paging = mobj.group('paging')
1537         return self.playlist_result(feed_entries, playlist_title=self._PLAYLIST_TITLE)
1538
1539
1540 class YoutubeRecommendedIE(YoutubeFeedsInfoExtractor):
1541     IE_DESC = 'YouTube.com recommended videos, ":ytrec" for short (requires authentication)'
1542     _VALID_URL = r'https?://www\.youtube\.com/feed/recommended|:ytrec(?:ommended)?'
1543     _FEED_NAME = 'recommended'
1544     _PLAYLIST_TITLE = 'Youtube Recommended videos'
1545
1546
1547 class YoutubeWatchLaterIE(YoutubeFeedsInfoExtractor):
1548     IE_DESC = 'Youtube watch later list, ":ytwatchlater" for short (requires authentication)'
1549     _VALID_URL = r'https?://www\.youtube\.com/feed/watch_later|:ytwatchlater'
1550     _FEED_NAME = 'watch_later'
1551     _PLAYLIST_TITLE = 'Youtube Watch Later'
1552     _PERSONAL_FEED = True
1553
1554
1555 class YoutubeHistoryIE(YoutubeFeedsInfoExtractor):
1556     IE_DESC = 'Youtube watch history, ":ythistory" for short (requires authentication)'
1557     _VALID_URL = 'https?://www\.youtube\.com/feed/history|:ythistory'
1558     _FEED_NAME = 'history'
1559     _PERSONAL_FEED = True
1560     _PLAYLIST_TITLE = 'Youtube Watch History'
1561
1562
1563 class YoutubeFavouritesIE(YoutubeBaseInfoExtractor):
1564     IE_NAME = 'youtube:favorites'
1565     IE_DESC = 'YouTube.com favourite videos, ":ytfav" for short (requires authentication)'
1566     _VALID_URL = r'https?://www\.youtube\.com/my_favorites|:ytfav(?:ou?rites)?'
1567     _LOGIN_REQUIRED = True
1568
1569     def _real_extract(self, url):
1570         webpage = self._download_webpage('https://www.youtube.com/my_favorites', 'Youtube Favourites videos')
1571         playlist_id = self._search_regex(r'list=(.+?)["&]', webpage, 'favourites playlist id')
1572         return self.url_result(playlist_id, 'YoutubePlaylist')
1573
1574
1575 class YoutubeSubscriptionsIE(YoutubePlaylistIE):
1576     IE_NAME = 'youtube:subscriptions'
1577     IE_DESC = 'YouTube.com subscriptions feed, "ytsubs" keyword (requires authentication)'
1578     _VALID_URL = r'https?://www\.youtube\.com/feed/subscriptions|:ytsubs(?:criptions)?'
1579     _TESTS = []
1580
1581     def _real_extract(self, url):
1582         title = 'Youtube Subscriptions'
1583         page = self._download_webpage('https://www.youtube.com/feed/subscriptions', title)
1584
1585         # The extraction process is the same as for playlists, but the regex
1586         # for the video ids doesn't contain an index
1587         ids = []
1588         more_widget_html = content_html = page
1589
1590         for page_num in itertools.count(1):
1591             matches = re.findall(r'href="\s*/watch\?v=([0-9A-Za-z_-]{11})', content_html)
1592             new_ids = orderedSet(matches)
1593             ids.extend(new_ids)
1594
1595             mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
1596             if not mobj:
1597                 break
1598
1599             more = self._download_json(
1600                 'https://youtube.com/%s' % mobj.group('more'), title,
1601                 'Downloading page #%s' % page_num,
1602                 transform_source=uppercase_escape)
1603             content_html = more['content_html']
1604             more_widget_html = more['load_more_widget_html']
1605
1606         return {
1607             '_type': 'playlist',
1608             'title': title,
1609             'entries': self._ids_to_results(ids),
1610         }
1611
1612
1613 class YoutubeTruncatedURLIE(InfoExtractor):
1614     IE_NAME = 'youtube:truncated_url'
1615     IE_DESC = False  # Do not list
1616     _VALID_URL = r'''(?x)
1617         (?:https?://)?[^/]+/watch\?(?:
1618             feature=[a-z_]+|
1619             annotation_id=annotation_[^&]+
1620         )?$|
1621         (?:https?://)?(?:www\.)?youtube\.com/attribution_link\?a=[^&]+$
1622     '''
1623
1624     _TESTS = [{
1625         'url': 'http://www.youtube.com/watch?annotation_id=annotation_3951667041',
1626         'only_matching': True,
1627     }, {
1628         'url': 'http://www.youtube.com/watch?',
1629         'only_matching': True,
1630     }]
1631
1632     def _real_extract(self, url):
1633         raise ExtractorError(
1634             'Did you forget to quote the URL? Remember that & is a meta '
1635             'character in most shells, so you want to put the URL in quotes, '
1636             'like  youtube-dl '
1637             '"http://www.youtube.com/watch?feature=foo&v=BaW_jenozKc" '
1638             ' or simply  youtube-dl BaW_jenozKc  .',
1639             expected=True)