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