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