[youku:show] Fix playlist extraction (closes #13248)
[youtube-dl] / youtube_dl / extractor / youku.py
1 # coding: utf-8
2 from __future__ import unicode_literals
3
4 import random
5 import re
6 import string
7 import time
8
9 from .common import InfoExtractor
10 from ..utils import (
11     ExtractorError,
12     get_element_by_class,
13     js_to_json,
14     str_or_none,
15     strip_jsonp,
16     urljoin,
17 )
18
19
20 class YoukuIE(InfoExtractor):
21     IE_NAME = 'youku'
22     IE_DESC = '优酷'
23     _VALID_URL = r'''(?x)
24         (?:
25             https?://(
26                 (?:v|player)\.youku\.com/(?:v_show/id_|player\.php/sid/)|
27                 video\.tudou\.com/v/)|
28             youku:)
29         (?P<id>[A-Za-z0-9]+)(?:\.html|/v\.swf|)
30     '''
31
32     _TESTS = [{
33         # MD5 is unstable
34         'url': 'http://v.youku.com/v_show/id_XMTc1ODE5Njcy.html',
35         'info_dict': {
36             'id': 'XMTc1ODE5Njcy',
37             'title': '★Smile﹗♡ Git Fresh -Booty Music舞蹈.',
38             'ext': 'mp4',
39             'duration': 74.73,
40             'thumbnail': r're:^https?://.*',
41             'uploader': '。躲猫猫、',
42             'uploader_id': '36017967',
43             'uploader_url': 'http://i.youku.com/u/UMTQ0MDcxODY4',
44             'tags': list,
45         }
46     }, {
47         'url': 'http://player.youku.com/player.php/sid/XNDgyMDQ2NTQw/v.swf',
48         'only_matching': True,
49     }, {
50         'url': 'http://v.youku.com/v_show/id_XODgxNjg1Mzk2_ev_1.html',
51         'info_dict': {
52             'id': 'XODgxNjg1Mzk2',
53             'ext': 'mp4',
54             'title': '武媚娘传奇 85',
55             'duration': 1999.61,
56             'thumbnail': r're:^https?://.*',
57             'uploader': '疯狂豆花',
58             'uploader_id': '62583473',
59             'uploader_url': 'http://i.youku.com/u/UMjUwMzMzODky',
60             'tags': list,
61         },
62     }, {
63         'url': 'http://v.youku.com/v_show/id_XMTI1OTczNDM5Mg==.html',
64         'info_dict': {
65             'id': 'XMTI1OTczNDM5Mg',
66             'ext': 'mp4',
67             'title': '花千骨 04',
68             'duration': 2363,
69             'thumbnail': r're:^https?://.*',
70             'uploader': '放剧场-花千骨',
71             'uploader_id': '772849359',
72             'uploader_url': 'http://i.youku.com/u/UMzA5MTM5NzQzNg==',
73             'tags': list,
74         },
75     }, {
76         'url': 'http://v.youku.com/v_show/id_XNjA1NzA2Njgw.html',
77         'note': 'Video protected with password',
78         'info_dict': {
79             'id': 'XNjA1NzA2Njgw',
80             'ext': 'mp4',
81             'title': '邢義田复旦讲座之想象中的胡人—从“左衽孔子”说起',
82             'duration': 7264.5,
83             'thumbnail': r're:^https?://.*',
84             'uploader': 'FoxJin1006',
85             'uploader_id': '322014285',
86             'uploader_url': 'http://i.youku.com/u/UMTI4ODA1NzE0MA==',
87             'tags': list,
88         },
89         'params': {
90             'videopassword': '100600',
91         },
92     }, {
93         # /play/get.json contains streams with "channel_type":"tail"
94         'url': 'http://v.youku.com/v_show/id_XOTUxMzg4NDMy.html',
95         'info_dict': {
96             'id': 'XOTUxMzg4NDMy',
97             'ext': 'mp4',
98             'title': '我的世界☆明月庄主☆车震猎杀☆杀人艺术Minecraft',
99             'duration': 702.08,
100             'thumbnail': r're:^https?://.*',
101             'uploader': '明月庄主moon',
102             'uploader_id': '38465621',
103             'uploader_url': 'http://i.youku.com/u/UMTUzODYyNDg0',
104             'tags': list,
105         },
106     }, {
107         'url': 'http://video.tudou.com/v/XMjIyNzAzMTQ4NA==.html?f=46177805',
108         'info_dict': {
109             'id': 'XMjIyNzAzMTQ4NA',
110             'ext': 'mp4',
111             'title': '卡马乔国足开大脚长传冲吊集锦',
112             'duration': 289,
113             'thumbnail': r're:^https?://.*',
114             'uploader': '阿卜杜拉之星',
115             'uploader_id': '2382249',
116             'uploader_url': 'http://i.youku.com/u/UOTUyODk5Ng==',
117             'tags': list,
118         },
119     }, {
120         'url': 'http://video.tudou.com/v/XMjE4ODI3OTg2MA==.html',
121         'only_matching': True,
122     }]
123
124     @staticmethod
125     def get_ysuid():
126         return '%d%s' % (int(time.time()), ''.join([
127             random.choice(string.ascii_letters) for i in range(3)]))
128
129     def get_format_name(self, fm):
130         _dict = {
131             '3gp': 'h6',
132             '3gphd': 'h5',
133             'flv': 'h4',
134             'flvhd': 'h4',
135             'mp4': 'h3',
136             'mp4hd': 'h3',
137             'mp4hd2': 'h4',
138             'mp4hd3': 'h4',
139             'hd2': 'h2',
140             'hd3': 'h1',
141         }
142         return _dict.get(fm)
143
144     def _real_extract(self, url):
145         video_id = self._match_id(url)
146
147         self._set_cookie('youku.com', '__ysuid', self.get_ysuid())
148         self._set_cookie('youku.com', 'xreferrer', 'http://www.youku.com')
149
150         _, urlh = self._download_webpage_handle(
151             'https://log.mmstat.com/eg.js', video_id, 'Retrieving cna info')
152         # The etag header is '"foobar"'; let's remove the double quotes
153         cna = urlh.headers['etag'][1:-1]
154
155         # request basic data
156         basic_data_params = {
157             'vid': video_id,
158             'ccode': '0402' if 'tudou.com' in url else '0401',
159             'client_ip': '192.168.1.1',
160             'utid': cna,
161             'client_ts': time.time() / 1000,
162         }
163
164         video_password = self._downloader.params.get('videopassword')
165         if video_password:
166             basic_data_params['password'] = video_password
167
168         headers = {
169             'Referer': url,
170         }
171         headers.update(self.geo_verification_headers())
172         data = self._download_json(
173             'https://ups.youku.com/ups/get.json', video_id,
174             'Downloading JSON metadata',
175             query=basic_data_params, headers=headers)['data']
176
177         error = data.get('error')
178         if error:
179             error_note = error.get('note')
180             if error_note is not None and '因版权原因无法观看此视频' in error_note:
181                 raise ExtractorError(
182                     'Youku said: Sorry, this video is available in China only', expected=True)
183             elif error_note and '该视频被设为私密' in error_note:
184                 raise ExtractorError(
185                     'Youku said: Sorry, this video is private', expected=True)
186             else:
187                 msg = 'Youku server reported error %i' % error.get('code')
188                 if error_note is not None:
189                     msg += ': ' + error_note
190                 raise ExtractorError(msg)
191
192         # get video title
193         video_data = data['video']
194         title = video_data['title']
195
196         formats = [{
197             'url': stream['m3u8_url'],
198             'format_id': self.get_format_name(stream.get('stream_type')),
199             'ext': 'mp4',
200             'protocol': 'm3u8_native',
201             'filesize': int(stream.get('size')),
202             'width': stream.get('width'),
203             'height': stream.get('height'),
204         } for stream in data['stream'] if stream.get('channel_type') != 'tail']
205         self._sort_formats(formats)
206
207         return {
208             'id': video_id,
209             'title': title,
210             'formats': formats,
211             'duration': video_data.get('seconds'),
212             'thumbnail': video_data.get('logo'),
213             'uploader': video_data.get('username'),
214             'uploader_id': str_or_none(video_data.get('userid')),
215             'uploader_url': data.get('uploader', {}).get('homepage'),
216             'tags': video_data.get('tags'),
217         }
218
219
220 class YoukuShowIE(InfoExtractor):
221     _VALID_URL = r'https?://list\.youku\.com/show/id_(?P<id>[0-9a-z]+)\.html'
222     IE_NAME = 'youku:show'
223
224     _TESTS = [{
225         'url': 'http://list.youku.com/show/id_zc7c670be07ff11e48b3f.html',
226         'info_dict': {
227             'id': 'zc7c670be07ff11e48b3f',
228             'title': '花千骨 DVD版',
229             'description': 'md5:a1ae6f5618571bbeb5c9821f9c81b558',
230         },
231         'playlist_count': 50,
232     }, {
233         # Episode number not starting from 1
234         'url': 'http://list.youku.com/show/id_zefbfbd70efbfbd780bef.html',
235         'info_dict': {
236             'id': 'zefbfbd70efbfbd780bef',
237             'title': '超级飞侠3',
238             'description': 'md5:275715156abebe5ccc2a1992e9d56b98',
239         },
240         'playlist_count': 24,
241     }]
242
243     def _extract_entries(self, playlist_data_url, show_id, idx, query, url):
244         query['callback'] = 'cb'
245         playlist_data = self._download_json(
246             playlist_data_url, show_id, query=query,
247             note='Downloading playlist data page %d' % (idx + 1),
248             transform_source=lambda s: js_to_json(strip_jsonp(s)))['html']
249         drama_list = (get_element_by_class('p-drama-grid', playlist_data) or
250                       get_element_by_class('p-drama-half-row', playlist_data))
251         if drama_list is None:
252             raise ExtractorError('No episodes found')
253         video_urls = re.findall(r'<a[^>]+href="([^"]+)"', drama_list)
254         return playlist_data, [
255             self.url_result(urljoin(url, video_url), YoukuIE.ie_key())
256             for video_url in video_urls]
257
258     def _real_extract(self, url):
259         show_id = self._match_id(url)
260         webpage = self._download_webpage(url, show_id)
261
262         page_config = self._parse_json(self._search_regex(
263             r'var\s+PageConfig\s*=\s*({.+});', webpage, 'page config'),
264             show_id, transform_source=js_to_json)
265         first_page, entries = self._extract_entries(
266             'http://list.youku.com/show/module', show_id, 0, {
267                 'id': page_config['showid'],
268                 'tab': 'showInfo',
269             }, url)
270         # The first reload_id has the same items as first_page
271         reload_ids = re.findall('<li[^>]+data-id="([^"]+)">', first_page)[1:]
272         for idx, reload_id in enumerate(reload_ids):
273             _, new_entries = self._extract_entries(
274                 'http://list.youku.com/show/episode', show_id, idx + 1, {
275                     'id': page_config['showid'],
276                     'stage': reload_id,
277                 }, url)
278             entries.extend(new_entries)
279
280         desc = self._html_search_meta('description', webpage, fatal=False)
281         playlist_title = desc.split(',')[0] if desc else None
282         detail_li = get_element_by_class('p-intro', webpage)
283         playlist_description = get_element_by_class(
284             'intro-more', detail_li) if detail_li else None
285
286         return self.playlist_result(
287             entries, show_id, playlist_title, playlist_description)