]> jfr.im git - yt-dlp.git/blame - youtube_dl/extractor/iqiyi.py
[youtube] Relax URL expansion in description
[yt-dlp.git] / youtube_dl / extractor / iqiyi.py
CommitLineData
605ec701 1# coding: utf-8
605ec701
P
2from __future__ import unicode_literals
3
958d0b65 4import hashlib
73f9c286 5import itertools
958d0b65 6import math
8e0548e1 7import os
958d0b65 8import random
99709cc3 9import re
605ec701 10import time
605ec701 11import uuid
958d0b65
YCH
12
13from .common import InfoExtractor
8e0548e1
YCH
14from ..compat import (
15 compat_parse_qs,
99709cc3 16 compat_str,
15707c7e 17 compat_urllib_parse_urlencode,
8e0548e1
YCH
18 compat_urllib_parse_urlparse,
19)
20from ..utils import (
f52354a8 21 decode_packed_codes,
8e0548e1 22 ExtractorError,
99709cc3 23 ohdave_rsa_encrypt,
73f9c286 24 remove_start,
8e0548e1
YCH
25 sanitized_Request,
26 urlencode_postdata,
27 url_basename,
28)
605ec701 29
f1da8610 30
99709cc3
YCH
31def md5_text(text):
32 return hashlib.md5(text.encode('utf-8')).hexdigest()
33
34
35class IqiyiSDK(object):
36 def __init__(self, target, ip, timestamp):
37 self.target = target
38 self.ip = ip
39 self.timestamp = timestamp
40
41 @staticmethod
42 def split_sum(data):
43 return compat_str(sum(map(lambda p: int(p, 16), list(data))))
44
45 @staticmethod
46 def digit_sum(num):
47 if isinstance(num, int):
48 num = compat_str(num)
49 return compat_str(sum(map(int, num)))
50
51 def even_odd(self):
52 even = self.digit_sum(compat_str(self.timestamp)[::2])
53 odd = self.digit_sum(compat_str(self.timestamp)[1::2])
54 return even, odd
55
56 def preprocess(self, chunksize):
57 self.target = md5_text(self.target)
58 chunks = []
59 for i in range(32 // chunksize):
60 chunks.append(self.target[chunksize * i:chunksize * (i + 1)])
61 if 32 % chunksize:
62 chunks.append(self.target[32 - 32 % chunksize:])
63 return chunks, list(map(int, self.ip.split('.')))
64
65 def mod(self, modulus):
66 chunks, ip = self.preprocess(32)
67 self.target = chunks[0] + ''.join(map(lambda p: compat_str(p % modulus), ip))
68
69 def split(self, chunksize):
70 modulus_map = {
71 4: 256,
72 5: 10,
73 8: 100,
74 }
75
76 chunks, ip = self.preprocess(chunksize)
77 ret = ''
78 for i in range(len(chunks)):
79 ip_part = compat_str(ip[i] % modulus_map[chunksize]) if i < 4 else ''
80 if chunksize == 8:
81 ret += ip_part + chunks[i]
82 else:
83 ret += chunks[i] + ip_part
84 self.target = ret
85
86 def handle_input16(self):
87 self.target = md5_text(self.target)
88 self.target = self.split_sum(self.target[:16]) + self.target + self.split_sum(self.target[16:])
89
90 def handle_input8(self):
91 self.target = md5_text(self.target)
92 ret = ''
93 for i in range(4):
94 part = self.target[8 * i:8 * (i + 1)]
95 ret += self.split_sum(part) + part
96 self.target = ret
97
98 def handleSum(self):
99 self.target = md5_text(self.target)
100 self.target = self.split_sum(self.target) + self.target
101
102 def date(self, scheme):
103 self.target = md5_text(self.target)
104 d = time.localtime(self.timestamp)
105 strings = {
106 'y': compat_str(d.tm_year),
107 'm': '%02d' % d.tm_mon,
108 'd': '%02d' % d.tm_mday,
109 }
110 self.target += ''.join(map(lambda c: strings[c], list(scheme)))
111
112 def split_time_even_odd(self):
113 even, odd = self.even_odd()
114 self.target = odd + md5_text(self.target) + even
115
116 def split_time_odd_even(self):
117 even, odd = self.even_odd()
118 self.target = even + md5_text(self.target) + odd
119
120 def split_ip_time_sum(self):
121 chunks, ip = self.preprocess(32)
122 self.target = compat_str(sum(ip)) + chunks[0] + self.digit_sum(self.timestamp)
123
124 def split_time_ip_sum(self):
125 chunks, ip = self.preprocess(32)
126 self.target = self.digit_sum(self.timestamp) + chunks[0] + compat_str(sum(ip))
127
128
129class IqiyiSDKInterpreter(object):
99709cc3
YCH
130 def __init__(self, sdk_code):
131 self.sdk_code = sdk_code
132
99709cc3 133 def run(self, target, ip, timestamp):
f52354a8 134 self.sdk_code = decode_packed_codes(self.sdk_code)
99709cc3
YCH
135
136 functions = re.findall(r'input=([a-zA-Z0-9]+)\(input', self.sdk_code)
137
138 sdk = IqiyiSDK(target, ip, timestamp)
139
140 other_functions = {
141 'handleSum': sdk.handleSum,
142 'handleInput8': sdk.handle_input8,
143 'handleInput16': sdk.handle_input16,
144 'splitTimeEvenOdd': sdk.split_time_even_odd,
145 'splitTimeOddEven': sdk.split_time_odd_even,
146 'splitIpTimeSum': sdk.split_ip_time_sum,
147 'splitTimeIpSum': sdk.split_time_ip_sum,
148 }
149 for function in functions:
150 if re.match(r'mod\d+', function):
151 sdk.mod(int(function[3:]))
152 elif re.match(r'date[ymd]{3}', function):
153 sdk.date(function[4:])
154 elif re.match(r'split\d+', function):
155 sdk.split(int(function[5:]))
156 elif function in other_functions:
157 other_functions[function]()
158 else:
159 raise ExtractorError('Unknown funcion %s' % function)
160
161 return sdk.target
162
163
605ec701
P
164class IqiyiIE(InfoExtractor):
165 IE_NAME = 'iqiyi'
44c514eb 166 IE_DESC = '爱奇艺'
605ec701 167
7e176eff 168 _VALID_URL = r'https?://(?:(?:[^.]+\.)?iqiyi\.com|www\.pps\.tv)/.+\.html'
605ec701 169
99709cc3
YCH
170 _NETRC_MACHINE = 'iqiyi'
171
99481135 172 _TESTS = [{
f1da8610
YCH
173 'url': 'http://www.iqiyi.com/v_19rrojlavg.html',
174 'md5': '2cb594dc2781e6c941a110d8f358118b',
175 'info_dict': {
176 'id': '9c1fb1b99d192b21c559e5a1a2cb3c73',
177 'title': '美国德州空中惊现奇异云团 酷似UFO',
178 'ext': 'f4v',
179 }
99481135
YCH
180 }, {
181 'url': 'http://www.iqiyi.com/v_19rrhnnclk.html',
182 'info_dict': {
183 'id': 'e3f585b550a280af23c98b6cb2be19fb',
184 'title': '名侦探柯南第752集',
185 },
186 'playlist': [{
99481135
YCH
187 'info_dict': {
188 'id': 'e3f585b550a280af23c98b6cb2be19fb_part1',
189 'ext': 'f4v',
190 'title': '名侦探柯南第752集',
191 },
192 }, {
99481135
YCH
193 'info_dict': {
194 'id': 'e3f585b550a280af23c98b6cb2be19fb_part2',
195 'ext': 'f4v',
196 'title': '名侦探柯南第752集',
197 },
198 }, {
99481135
YCH
199 'info_dict': {
200 'id': 'e3f585b550a280af23c98b6cb2be19fb_part3',
201 'ext': 'f4v',
202 'title': '名侦探柯南第752集',
203 },
204 }, {
99481135
YCH
205 'info_dict': {
206 'id': 'e3f585b550a280af23c98b6cb2be19fb_part4',
207 'ext': 'f4v',
208 'title': '名侦探柯南第752集',
209 },
210 }, {
99481135
YCH
211 'info_dict': {
212 'id': 'e3f585b550a280af23c98b6cb2be19fb_part5',
213 'ext': 'f4v',
214 'title': '名侦探柯南第752集',
215 },
216 }, {
99481135
YCH
217 'info_dict': {
218 'id': 'e3f585b550a280af23c98b6cb2be19fb_part6',
219 'ext': 'f4v',
220 'title': '名侦探柯南第752集',
221 },
222 }, {
99481135
YCH
223 'info_dict': {
224 'id': 'e3f585b550a280af23c98b6cb2be19fb_part7',
225 'ext': 'f4v',
226 'title': '名侦探柯南第752集',
227 },
228 }, {
99481135
YCH
229 'info_dict': {
230 'id': 'e3f585b550a280af23c98b6cb2be19fb_part8',
231 'ext': 'f4v',
232 'title': '名侦探柯南第752集',
233 },
234 }],
c2d1be89
YCH
235 'params': {
236 'skip_download': True,
237 },
59185202
YCH
238 }, {
239 'url': 'http://www.iqiyi.com/w_19rt6o8t9p.html',
240 'only_matching': True,
241 }, {
242 'url': 'http://www.iqiyi.com/a_19rrhbc6kt.html',
243 'only_matching': True,
244 }, {
245 'url': 'http://yule.iqiyi.com/pcb.html',
246 'only_matching': True,
8e0548e1
YCH
247 }, {
248 # VIP-only video. The first 2 parts (6 minutes) are available without login
1932476c 249 # MD5 sums omitted as values are different on Travis CI and my machine
8e0548e1
YCH
250 'url': 'http://www.iqiyi.com/v_19rrny4w8w.html',
251 'info_dict': {
252 'id': 'f3cf468b39dddb30d676f89a91200dc1',
253 'title': '泰坦尼克号',
254 },
255 'playlist': [{
8e0548e1
YCH
256 'info_dict': {
257 'id': 'f3cf468b39dddb30d676f89a91200dc1_part1',
258 'ext': 'f4v',
259 'title': '泰坦尼克号',
260 },
261 }, {
8e0548e1
YCH
262 'info_dict': {
263 'id': 'f3cf468b39dddb30d676f89a91200dc1_part2',
264 'ext': 'f4v',
265 'title': '泰坦尼克号',
266 },
267 }],
268 'expected_warnings': ['Needs a VIP account for full video'],
73f9c286
YCH
269 }, {
270 'url': 'http://www.iqiyi.com/a_19rrhb8ce1.html',
271 'info_dict': {
272 'id': '202918101',
273 'title': '灌篮高手 国语版',
274 },
275 'playlist_count': 101,
7e176eff
YCH
276 }, {
277 'url': 'http://www.pps.tv/w_19rrbav0ph.html',
278 'only_matching': True,
99481135 279 }]
605ec701 280
08bb8ef2
YCH
281 _FORMATS_MAP = [
282 ('1', 'h6'),
283 ('2', 'h5'),
284 ('3', 'h4'),
285 ('4', 'h3'),
286 ('5', 'h2'),
287 ('10', 'h1'),
288 ]
289
c8003791
YCH
290 AUTH_API_ERRORS = {
291 # No preview available (不允许试看鉴权失败)
292 'Q00505': 'This video requires a VIP account',
293 # End of preview time (试看结束鉴权失败)
294 'Q00506': 'Needs a VIP account for full video',
295 }
296
99709cc3
YCH
297 def _real_initialize(self):
298 self._login()
299
57565375 300 @staticmethod
99709cc3
YCH
301 def _rsa_fun(data):
302 # public key extracted from http://static.iqiyi.com/js/qiyiV2/20160129180840/jobs/i18n/i18nIndex.js
303 N = 0xab86b6371b5318aaa1d3c9e612a9f1264f372323c8c0f19875b5fc3b3fd3afcc1e5bec527aa94bfa85bffc157e4245aebda05389a5357b75115ac94f074aefcd
304 e = 65537
305
306 return ohdave_rsa_encrypt(data, e, N)
307
308 def _login(self):
309 (username, password) = self._get_login_info()
310
311 # No authentication to be performed
312 if not username:
313 return True
314
315 data = self._download_json(
316 'http://kylin.iqiyi.com/get_token', None,
317 note='Get token for logging', errnote='Unable to get token for logging')
318 sdk = data['sdk']
319 timestamp = int(time.time())
320 target = '/apis/reglogin/login.action?lang=zh_TW&area_code=null&email=%s&passwd=%s&agenttype=1&from=undefined&keeplogin=0&piccode=&fromurl=&_pos=1' % (
321 username, self._rsa_fun(password.encode('utf-8')))
322
323 interp = IqiyiSDKInterpreter(sdk)
324 sign = interp.run(target, data['ip'], timestamp)
325
326 validation_params = {
327 'target': target,
328 'server': 'BEA3AA1908656AABCCFF76582C4C6660',
329 'token': data['token'],
330 'bird_src': 'f8d91d57af224da7893dd397d52d811a',
331 'sign': sign,
332 'bird_t': timestamp,
333 }
334 validation_result = self._download_json(
15707c7e 335 'http://kylin.iqiyi.com/validate?' + compat_urllib_parse_urlencode(validation_params), None,
99709cc3
YCH
336 note='Validate credentials', errnote='Unable to validate credentials')
337
338 MSG_MAP = {
339 'P00107': 'please login via the web interface and enter the CAPTCHA code',
340 'P00117': 'bad username or password',
341 }
342
343 code = validation_result['code']
344 if code != 'A00000':
345 msg = MSG_MAP.get(code)
346 if not msg:
347 msg = 'error %s' % code
348 if validation_result.get('msg'):
349 msg += ': ' + validation_result['msg']
350 self._downloader.report_warning('unable to log in: ' + msg)
351 return False
352
353 return True
57565375 354
8e0548e1
YCH
355 def _authenticate_vip_video(self, api_video_url, video_id, tvid, _uuid, do_report_warning):
356 auth_params = {
357 # version and platform hard-coded in com/qiyi/player/core/model/remote/AuthenticationRemote.as
358 'version': '2.0',
359 'platform': 'b6c13e26323c537d',
360 'aid': tvid,
361 'tvid': tvid,
362 'uid': '',
363 'deviceId': _uuid,
364 'playType': 'main', # XXX: always main?
365 'filename': os.path.splitext(url_basename(api_video_url))[0],
366 }
367
368 qd_items = compat_parse_qs(compat_urllib_parse_urlparse(api_video_url).query)
369 for key, val in qd_items.items():
370 auth_params[key] = val[0]
371
372 auth_req = sanitized_Request(
373 'http://api.vip.iqiyi.com/services/ckn.action',
374 urlencode_postdata(auth_params))
375 # iQiyi server throws HTTP 405 error without the following header
376 auth_req.add_header('Content-Type', 'application/x-www-form-urlencoded')
377 auth_result = self._download_json(
378 auth_req, video_id,
379 note='Downloading video authentication JSON',
380 errnote='Unable to download video authentication JSON')
8790249c 381
c8003791
YCH
382 code = auth_result.get('code')
383 msg = self.AUTH_API_ERRORS.get(code) or auth_result.get('msg') or code
384 if code == 'Q00506':
8e0548e1 385 if do_report_warning:
c8003791 386 self.report_warning(msg)
8e0548e1 387 return False
c8003791
YCH
388 if 'data' not in auth_result:
389 if msg is not None:
390 raise ExtractorError('%s said: %s' % (self.IE_NAME, msg), expected=True)
391 raise ExtractorError('Unexpected error from Iqiyi auth API')
8e0548e1 392
c8003791 393 return auth_result['data']
8e0548e1
YCH
394
395 def construct_video_urls(self, data, video_id, _uuid, tvid):
605ec701
P
396 def do_xor(x, y):
397 a = y % 3
398 if a == 1:
399 return x ^ 121
400 if a == 2:
401 return x ^ 72
402 return x ^ 103
403
404 def get_encode_code(l):
405 a = 0
406 b = l.split('-')
407 c = len(b)
408 s = ''
409 for i in range(c - 1, -1, -1):
f1da8610 410 a = do_xor(int(b[c - i - 1], 16), i)
605ec701
P
411 s += chr(a)
412 return s[::-1]
413
ffba4edb 414 def get_path_key(x, format_id, segment_index):
605ec701
P
415 mg = ')(*&^flash@#$%a'
416 tm = self._download_json(
ffba4edb
YCH
417 'http://data.video.qiyi.com/t?tn=' + str(random.random()), video_id,
418 note='Download path key of segment %d for format %s' % (segment_index + 1, format_id)
419 )['t']
f1da8610 420 t = str(int(math.floor(int(tm) / (600.0))))
99709cc3 421 return md5_text(t + mg + x)
605ec701
P
422
423 video_urls_dict = {}
8e0548e1 424 need_vip_warning_report = True
ffba4edb
YCH
425 for format_item in data['vp']['tkl'][0]['vs']:
426 if 0 < int(format_item['bid']) <= 10:
427 format_id = self.get_format(format_item['bid'])
670861bd
P
428 else:
429 continue
430
431 video_urls = []
605ec701 432
ffba4edb
YCH
433 video_urls_info = format_item['fs']
434 if not format_item['fs'][0]['l'].startswith('/'):
435 t = get_encode_code(format_item['fs'][0]['l'])
605ec701 436 if t.endswith('mp4'):
ffba4edb 437 video_urls_info = format_item['flvs']
605ec701 438
ffba4edb
YCH
439 for segment_index, segment in enumerate(video_urls_info):
440 vl = segment['l']
605ec701
P
441 if not vl.startswith('/'):
442 vl = get_encode_code(vl)
8e0548e1 443 is_vip_video = '/vip/' in vl
ffba4edb 444 filesize = segment['b']
605ec701 445 base_url = data['vp']['du'].split('/')
8e0548e1
YCH
446 if not is_vip_video:
447 key = get_path_key(
448 vl.split('/')[-1].split('.')[0], format_id, segment_index)
449 base_url.insert(-1, key)
605ec701
P
450 base_url = '/'.join(base_url)
451 param = {
452 'su': _uuid,
453 'qyid': uuid.uuid4().hex,
454 'client': '',
455 'z': '',
456 'bt': '',
457 'ct': '',
458 'tn': str(int(time.time()))
459 }
8e0548e1
YCH
460 api_video_url = base_url + vl
461 if is_vip_video:
462 api_video_url = api_video_url.replace('.f4v', '.hml')
463 auth_result = self._authenticate_vip_video(
464 api_video_url, video_id, tvid, _uuid, need_vip_warning_report)
465 if auth_result is False:
466 need_vip_warning_report = False
467 break
468 param.update({
c8003791 469 't': auth_result['t'],
8e0548e1
YCH
470 # cid is hard-coded in com/qiyi/player/core/player/RuntimeData.as
471 'cid': 'afbe8fd3d73448c9',
472 'vid': video_id,
c8003791 473 'QY00001': auth_result['u'],
8e0548e1
YCH
474 })
475 api_video_url += '?' if '?' not in api_video_url else '&'
15707c7e 476 api_video_url += compat_urllib_parse_urlencode(param)
ffba4edb
YCH
477 js = self._download_json(
478 api_video_url, video_id,
479 note='Download video info of segment %d for format %s' % (segment_index + 1, format_id))
605ec701
P
480 video_url = js['l']
481 video_urls.append(
482 (video_url, filesize))
483
484 video_urls_dict[format_id] = video_urls
485 return video_urls_dict
486
487 def get_format(self, bid):
08bb8ef2
YCH
488 matched_format_ids = [_format_id for _bid, _format_id in self._FORMATS_MAP if _bid == str(bid)]
489 return matched_format_ids[0] if len(matched_format_ids) else None
670861bd
P
490
491 def get_bid(self, format_id):
08bb8ef2
YCH
492 matched_bids = [_bid for _bid, _format_id in self._FORMATS_MAP if _format_id == format_id]
493 return matched_bids[0] if len(matched_bids) else None
605ec701
P
494
495 def get_raw_data(self, tvid, video_id, enc_key, _uuid):
496 tm = str(int(time.time()))
57565375 497 tail = tm + tvid
605ec701
P
498 param = {
499 'key': 'fvip',
99709cc3 500 'src': md5_text('youtube-dl'),
605ec701
P
501 'tvId': tvid,
502 'vid': video_id,
503 'vinfo': 1,
504 'tm': tm,
99709cc3 505 'enc': md5_text(enc_key + tail),
605ec701
P
506 'qyid': _uuid,
507 'tn': random.random(),
4540515c
YCH
508 # In iQiyi's flash player, um is set to 1 if there's a logged user
509 # Some 1080P formats are only available with a logged user.
510 # Here force um=1 to trick the iQiyi server
511 'um': 1,
99709cc3 512 'authkey': md5_text(md5_text('') + tail),
8e0548e1 513 'k_tag': 1,
605ec701
P
514 }
515
516 api_url = 'http://cache.video.qiyi.com/vms' + '?' + \
15707c7e 517 compat_urllib_parse_urlencode(param)
605ec701
P
518 raw_data = self._download_json(api_url, video_id)
519 return raw_data
520
9fb556ee 521 def get_enc_key(self, video_id):
57565375 522 # TODO: automatic key extraction
6b45f9ab 523 # last update at 2016-01-22 for Zombie::bite
d7f62b04 524 enc_key = '4a1caba4b4465345366f28da7c117d20'
605ec701
P
525 return enc_key
526
73f9c286
YCH
527 def _extract_playlist(self, webpage):
528 PAGE_SIZE = 50
529
530 links = re.findall(
531 r'<a[^>]+class="site-piclist_pic_link"[^>]+href="(http://www\.iqiyi\.com/.+\.html)"',
532 webpage)
533 if not links:
534 return
535
536 album_id = self._search_regex(
537 r'albumId\s*:\s*(\d+),', webpage, 'album ID')
538 album_title = self._search_regex(
539 r'data-share-title="([^"]+)"', webpage, 'album title', fatal=False)
540
541 entries = list(map(self.url_result, links))
542
543 # Start from 2 because links in the first page are already on webpage
544 for page_num in itertools.count(2):
545 pagelist_page = self._download_webpage(
546 'http://cache.video.qiyi.com/jp/avlist/%s/%d/%d/' % (album_id, page_num, PAGE_SIZE),
547 album_id,
548 note='Download playlist page %d' % page_num,
549 errnote='Failed to download playlist page %d' % page_num)
550 pagelist = self._parse_json(
551 remove_start(pagelist_page, 'var tvInfoJs='), album_id)
552 vlist = pagelist['data']['vlist']
553 for item in vlist:
554 entries.append(self.url_result(item['vurl']))
555 if len(vlist) < PAGE_SIZE:
556 break
557
558 return self.playlist_result(entries, album_id, album_title)
559
605ec701
P
560 def _real_extract(self, url):
561 webpage = self._download_webpage(
562 url, 'temp_id', note='download video page')
73f9c286
YCH
563
564 # There's no simple way to determine whether an URL is a playlist or not
565 # So detect it
566 playlist_result = self._extract_playlist(webpage)
567 if playlist_result:
568 return playlist_result
569
605ec701 570 tvid = self._search_regex(
29e7e078 571 r'data-player-tvid\s*=\s*[\'"](\d+)', webpage, 'tvid')
605ec701 572 video_id = self._search_regex(
29e7e078 573 r'data-player-videoid\s*=\s*[\'"]([a-f\d]+)', webpage, 'video_id')
605ec701
P
574 _uuid = uuid.uuid4().hex
575
9fb556ee 576 enc_key = self.get_enc_key(video_id)
605ec701
P
577
578 raw_data = self.get_raw_data(tvid, video_id, enc_key, _uuid)
aacda28b
YCH
579
580 if raw_data['code'] != 'A000000':
581 raise ExtractorError('Unable to load data. Error code: ' + raw_data['code'])
582
605ec701
P
583 data = raw_data['data']
584
585 title = data['vi']['vn']
586
587 # generate video_urls_dict
670861bd 588 video_urls_dict = self.construct_video_urls(
8e0548e1 589 data, video_id, _uuid, tvid)
605ec701
P
590
591 # construct info
592 entries = []
593 for format_id in video_urls_dict:
594 video_urls = video_urls_dict[format_id]
595 for i, video_url_info in enumerate(video_urls):
f1da8610 596 if len(entries) < i + 1:
605ec701
P
597 entries.append({'formats': []})
598 entries[i]['formats'].append(
599 {
600 'url': video_url_info[0],
601 'filesize': video_url_info[-1],
602 'format_id': format_id,
670861bd 603 'preference': int(self.get_bid(format_id))
605ec701
P
604 }
605 )
606
607 for i in range(len(entries)):
670861bd 608 self._sort_formats(entries[i]['formats'])
605ec701
P
609 entries[i].update(
610 {
c4ee8702 611 'id': '%s_part%d' % (video_id, i + 1),
605ec701
P
612 'title': title,
613 }
614 )
615
616 if len(entries) > 1:
617 info = {
618 '_type': 'multi_video',
619 'id': video_id,
620 'title': title,
621 'entries': entries,
622 }
623 else:
624 info = entries[0]
625 info['id'] = video_id
626 info['title'] = title
627
628 return info