Merge branch 'patch-8239' of https://github.com/ping/youtube-dl into ping-patch-8239
[youtube-dl] / youtube_dl / extractor / neteasemusic.py
1 # coding: utf-8
2 from __future__ import unicode_literals
3
4 from hashlib import md5
5 from base64 import b64encode
6 from datetime import datetime
7 import re
8
9 from .common import InfoExtractor
10 from ..compat import (
11     compat_urllib_parse,
12     compat_str,
13     compat_itertools_count,
14 )
15 from ..utils import sanitized_Request
16
17
18 class NetEaseMusicBaseIE(InfoExtractor):
19     _FORMATS = ['bMusic', 'mMusic', 'hMusic']
20     _NETEASE_SALT = '3go8&$8*3*3h0k(2)2'
21     _API_BASE = 'http://music.163.com/api/'
22
23     @classmethod
24     def _encrypt(cls, dfsid):
25         salt_bytes = bytearray(cls._NETEASE_SALT.encode('utf-8'))
26         string_bytes = bytearray(compat_str(dfsid).encode('ascii'))
27         salt_len = len(salt_bytes)
28         for i in range(len(string_bytes)):
29             string_bytes[i] = string_bytes[i] ^ salt_bytes[i % salt_len]
30         m = md5()
31         m.update(bytes(string_bytes))
32         result = b64encode(m.digest()).decode('ascii')
33         return result.replace('/', '_').replace('+', '-')
34
35     def extract_formats(self, info):
36         formats = []
37         for song_format in self._FORMATS:
38             details = info.get(song_format)
39             if not details:
40                 continue
41             song_file_path = '/%s/%s.%s' % (
42                 self._encrypt(details['dfsId']), details['dfsId'], details['extension'])
43
44             # 203.130.59.9, 124.40.233.182, 115.231.74.139, etc is a reverse proxy-like feature
45             # from NetEase's CDN provider that can be used if m5.music.126.net does not
46             # work, especially for users outside of Mainland China
47             # via: https://github.com/JixunMoe/unblock-163/issues/3#issuecomment-163115880
48             for host in ('http://m5.music.126.net', 'http://115.231.74.139/m1.music.126.net',
49                          'http://124.40.233.182/m1.music.126.net', 'http://203.130.59.9/m1.music.126.net'):
50                 song_url = host + song_file_path
51                 if self._is_valid_url(song_url, info['id'], 'song'):
52                     formats.append({
53                         'url': song_url,
54                         'ext': details.get('extension'),
55                         'abr': details.get('bitrate', 0) / 1000,
56                         'format_id': song_format,
57                         'filesize': details.get('size'),
58                         'asr': details.get('sr')
59                     })
60                     break
61         return formats
62
63     @classmethod
64     def convert_milliseconds(cls, ms):
65         return int(round(ms / 1000.0))
66
67     def query_api(self, endpoint, video_id, note):
68         req = sanitized_Request('%s%s' % (self._API_BASE, endpoint))
69         req.add_header('Referer', self._API_BASE)
70         return self._download_json(req, video_id, note)
71
72
73 class NetEaseMusicIE(NetEaseMusicBaseIE):
74     IE_NAME = 'netease:song'
75     IE_DESC = '网易云音乐'
76     _VALID_URL = r'https?://music\.163\.com/(#/)?song\?id=(?P<id>[0-9]+)'
77     _TESTS = [{
78         'url': 'http://music.163.com/#/song?id=32102397',
79         'md5': 'f2e97280e6345c74ba9d5677dd5dcb45',
80         'info_dict': {
81             'id': '32102397',
82             'ext': 'mp3',
83             'title': 'Bad Blood (feat. Kendrick Lamar)',
84             'creator': 'Taylor Swift / Kendrick Lamar',
85             'upload_date': '20150517',
86             'timestamp': 1431878400,
87             'description': 'md5:a10a54589c2860300d02e1de821eb2ef',
88         },
89     }, {
90         'note': 'No lyrics translation.',
91         'url': 'http://music.163.com/#/song?id=29822014',
92         'info_dict': {
93             'id': '29822014',
94             'ext': 'mp3',
95             'title': '听见下雨的声音',
96             'creator': '周杰伦',
97             'upload_date': '20141225',
98             'timestamp': 1419523200,
99             'description': 'md5:a4d8d89f44656af206b7b2555c0bce6c',
100         },
101     }, {
102         'note': 'No lyrics.',
103         'url': 'http://music.163.com/song?id=17241424',
104         'info_dict': {
105             'id': '17241424',
106             'ext': 'mp3',
107             'title': 'Opus 28',
108             'creator': 'Dustin O\'Halloran',
109             'upload_date': '20080211',
110             'timestamp': 1202745600,
111         },
112     }, {
113         'note': 'Has translated name.',
114         'url': 'http://music.163.com/#/song?id=22735043',
115         'info_dict': {
116             'id': '22735043',
117             'ext': 'mp3',
118             'title': '소원을 말해봐 (Genie)',
119             'creator': '少女时代',
120             'description': 'md5:79d99cc560e4ca97e0c4d86800ee4184',
121             'upload_date': '20100127',
122             'timestamp': 1264608000,
123             'alt_title': '说出愿望吧(Genie)',
124         }
125     }]
126
127     def _process_lyrics(self, lyrics_info):
128         original = lyrics_info.get('lrc', {}).get('lyric')
129         translated = lyrics_info.get('tlyric', {}).get('lyric')
130
131         if not translated:
132             return original
133
134         lyrics_expr = r'(\[[0-9]{2}:[0-9]{2}\.[0-9]{2,}\])([^\n]+)'
135         original_ts_texts = re.findall(lyrics_expr, original)
136         translation_ts_dict = dict(
137             (time_stamp, text) for time_stamp, text in re.findall(lyrics_expr, translated)
138         )
139         lyrics = '\n'.join([
140             '%s%s / %s' % (time_stamp, text, translation_ts_dict.get(time_stamp, ''))
141             for time_stamp, text in original_ts_texts
142         ])
143         return lyrics
144
145     def _real_extract(self, url):
146         song_id = self._match_id(url)
147
148         params = {
149             'id': song_id,
150             'ids': '[%s]' % song_id
151         }
152         info = self.query_api(
153             'song/detail?' + compat_urllib_parse.urlencode(params),
154             song_id, 'Downloading song info')['songs'][0]
155
156         formats = self.extract_formats(info)
157         self._sort_formats(formats)
158
159         lyrics_info = self.query_api(
160             'song/lyric?id=%s&lv=-1&tv=-1' % song_id,
161             song_id, 'Downloading lyrics data')
162         lyrics = self._process_lyrics(lyrics_info)
163
164         alt_title = None
165         if info.get('transNames'):
166             alt_title = '/'.join(info.get('transNames'))
167
168         return {
169             'id': song_id,
170             'title': info['name'],
171             'alt_title': alt_title,
172             'creator': ' / '.join([artist['name'] for artist in info.get('artists', [])]),
173             'timestamp': self.convert_milliseconds(info.get('album', {}).get('publishTime')),
174             'thumbnail': info.get('album', {}).get('picUrl'),
175             'duration': self.convert_milliseconds(info.get('duration', 0)),
176             'description': lyrics,
177             'formats': formats,
178         }
179
180
181 class NetEaseMusicAlbumIE(NetEaseMusicBaseIE):
182     IE_NAME = 'netease:album'
183     IE_DESC = '网易云音乐 - 专辑'
184     _VALID_URL = r'https?://music\.163\.com/(#/)?album\?id=(?P<id>[0-9]+)'
185     _TEST = {
186         'url': 'http://music.163.com/#/album?id=220780',
187         'info_dict': {
188             'id': '220780',
189             'title': 'B\'day',
190         },
191         'playlist_count': 23,
192     }
193
194     def _real_extract(self, url):
195         album_id = self._match_id(url)
196
197         info = self.query_api(
198             'album/%s?id=%s' % (album_id, album_id),
199             album_id, 'Downloading album data')['album']
200
201         name = info['name']
202         desc = info.get('description')
203         entries = [
204             self.url_result('http://music.163.com/#/song?id=%s' % song['id'],
205                             'NetEaseMusic', song['id'])
206             for song in info['songs']
207         ]
208         return self.playlist_result(entries, album_id, name, desc)
209
210
211 class NetEaseMusicSingerIE(NetEaseMusicBaseIE):
212     IE_NAME = 'netease:singer'
213     IE_DESC = '网易云音乐 - 歌手'
214     _VALID_URL = r'https?://music\.163\.com/(#/)?artist\?id=(?P<id>[0-9]+)'
215     _TESTS = [{
216         'note': 'Singer has aliases.',
217         'url': 'http://music.163.com/#/artist?id=10559',
218         'info_dict': {
219             'id': '10559',
220             'title': '张惠妹 - aMEI;阿密特',
221         },
222         'playlist_count': 50,
223     }, {
224         'note': 'Singer has translated name.',
225         'url': 'http://music.163.com/#/artist?id=124098',
226         'info_dict': {
227             'id': '124098',
228             'title': '李昇基 - 이승기',
229         },
230         'playlist_count': 50,
231     }]
232
233     def _real_extract(self, url):
234         singer_id = self._match_id(url)
235
236         info = self.query_api(
237             'artist/%s?id=%s' % (singer_id, singer_id),
238             singer_id, 'Downloading singer data')
239
240         name = info['artist']['name']
241         if info['artist']['trans']:
242             name = '%s - %s' % (name, info['artist']['trans'])
243         if info['artist']['alias']:
244             name = '%s - %s' % (name, ';'.join(info['artist']['alias']))
245
246         entries = [
247             self.url_result('http://music.163.com/#/song?id=%s' % song['id'],
248                             'NetEaseMusic', song['id'])
249             for song in info['hotSongs']
250         ]
251         return self.playlist_result(entries, singer_id, name)
252
253
254 class NetEaseMusicListIE(NetEaseMusicBaseIE):
255     IE_NAME = 'netease:playlist'
256     IE_DESC = '网易云音乐 - 歌单'
257     _VALID_URL = r'https?://music\.163\.com/(#/)?(playlist|discover/toplist)\?id=(?P<id>[0-9]+)'
258     _TESTS = [{
259         'url': 'http://music.163.com/#/playlist?id=79177352',
260         'info_dict': {
261             'id': '79177352',
262             'title': 'Billboard 2007 Top 100',
263             'description': 'md5:12fd0819cab2965b9583ace0f8b7b022'
264         },
265         'playlist_count': 99,
266     }, {
267         'note': 'Toplist/Charts sample',
268         'url': 'http://music.163.com/#/discover/toplist?id=3733003',
269         'info_dict': {
270             'id': '3733003',
271             'title': 're:韩国Melon排行榜周榜 [0-9]{4}-[0-9]{2}-[0-9]{2}',
272             'description': 'md5:73ec782a612711cadc7872d9c1e134fc',
273         },
274         'playlist_count': 50,
275     }]
276
277     def _real_extract(self, url):
278         list_id = self._match_id(url)
279
280         info = self.query_api(
281             'playlist/detail?id=%s&lv=-1&tv=-1' % list_id,
282             list_id, 'Downloading playlist data')['result']
283
284         name = info['name']
285         desc = info.get('description')
286
287         if info.get('specialType') == 10:  # is a chart/toplist
288             datestamp = datetime.fromtimestamp(
289                 self.convert_milliseconds(info['updateTime'])).strftime('%Y-%m-%d')
290             name = '%s %s' % (name, datestamp)
291
292         entries = [
293             self.url_result('http://music.163.com/#/song?id=%s' % song['id'],
294                             'NetEaseMusic', song['id'])
295             for song in info['tracks']
296         ]
297         return self.playlist_result(entries, list_id, name, desc)
298
299
300 class NetEaseMusicMvIE(NetEaseMusicBaseIE):
301     IE_NAME = 'netease:mv'
302     IE_DESC = '网易云音乐 - MV'
303     _VALID_URL = r'https?://music\.163\.com/(#/)?mv\?id=(?P<id>[0-9]+)'
304     _TEST = {
305         'url': 'http://music.163.com/#/mv?id=415350',
306         'info_dict': {
307             'id': '415350',
308             'ext': 'mp4',
309             'title': '이럴거면 그러지말지',
310             'description': '白雅言自作曲唱甜蜜爱情',
311             'creator': '白雅言',
312             'upload_date': '20150520',
313         },
314     }
315
316     def _real_extract(self, url):
317         mv_id = self._match_id(url)
318
319         info = self.query_api(
320             'mv/detail?id=%s&type=mp4' % mv_id,
321             mv_id, 'Downloading mv info')['data']
322
323         formats = [
324             {'url': mv_url, 'ext': 'mp4', 'format_id': '%sp' % brs, 'height': int(brs)}
325             for brs, mv_url in info['brs'].items()
326         ]
327         self._sort_formats(formats)
328
329         return {
330             'id': mv_id,
331             'title': info['name'],
332             'description': info.get('desc') or info.get('briefDesc'),
333             'creator': info['artistName'],
334             'upload_date': info['publishTime'].replace('-', ''),
335             'formats': formats,
336             'thumbnail': info.get('cover'),
337             'duration': self.convert_milliseconds(info.get('duration', 0)),
338         }
339
340
341 class NetEaseMusicProgramIE(NetEaseMusicBaseIE):
342     IE_NAME = 'netease:program'
343     IE_DESC = '网易云音乐 - 电台节目'
344     _VALID_URL = r'https?://music\.163\.com/(#/?)program\?id=(?P<id>[0-9]+)'
345     _TESTS = [{
346         'url': 'http://music.163.com/#/program?id=10109055',
347         'info_dict': {
348             'id': '10109055',
349             'ext': 'mp3',
350             'title': '不丹足球背后的故事',
351             'description': '喜马拉雅人的足球梦 ...',
352             'creator': '大话西藏',
353             'timestamp': 1434179342,
354             'upload_date': '20150613',
355             'duration': 900,
356         },
357     }, {
358         'note': 'This program has accompanying songs.',
359         'url': 'http://music.163.com/#/program?id=10141022',
360         'info_dict': {
361             'id': '10141022',
362             'title': '25岁,你是自在如风的少年<27°C>',
363             'description': 'md5:8d594db46cc3e6509107ede70a4aaa3b',
364         },
365         'playlist_count': 4,
366     }, {
367         'note': 'This program has accompanying songs.',
368         'url': 'http://music.163.com/#/program?id=10141022',
369         'info_dict': {
370             'id': '10141022',
371             'ext': 'mp3',
372             'title': '25岁,你是自在如风的少年<27°C>',
373             'description': 'md5:8d594db46cc3e6509107ede70a4aaa3b',
374             'timestamp': 1434450841,
375             'upload_date': '20150616',
376         },
377         'params': {
378             'noplaylist': True
379         }
380     }]
381
382     def _real_extract(self, url):
383         program_id = self._match_id(url)
384
385         info = self.query_api(
386             'dj/program/detail?id=%s' % program_id,
387             program_id, 'Downloading program info')['program']
388
389         name = info['name']
390         description = info['description']
391
392         if not info['songs'] or self._downloader.params.get('noplaylist'):
393             if info['songs']:
394                 self.to_screen(
395                     'Downloading just the main audio %s because of --no-playlist'
396                     % info['mainSong']['id'])
397
398             formats = self.extract_formats(info['mainSong'])
399             self._sort_formats(formats)
400
401             return {
402                 'id': program_id,
403                 'title': name,
404                 'description': description,
405                 'creator': info['dj']['brand'],
406                 'timestamp': self.convert_milliseconds(info['createTime']),
407                 'thumbnail': info['coverUrl'],
408                 'duration': self.convert_milliseconds(info.get('duration', 0)),
409                 'formats': formats,
410             }
411
412         self.to_screen(
413             'Downloading playlist %s - add --no-playlist to just download the main audio %s'
414             % (program_id, info['mainSong']['id']))
415
416         song_ids = [info['mainSong']['id']]
417         song_ids.extend([song['id'] for song in info['songs']])
418         entries = [
419             self.url_result('http://music.163.com/#/song?id=%s' % song_id,
420                             'NetEaseMusic', song_id)
421             for song_id in song_ids
422         ]
423         return self.playlist_result(entries, program_id, name, description)
424
425
426 class NetEaseMusicDjRadioIE(NetEaseMusicBaseIE):
427     IE_NAME = 'netease:djradio'
428     IE_DESC = '网易云音乐 - 电台'
429     _VALID_URL = r'https?://music\.163\.com/(#/)?djradio\?id=(?P<id>[0-9]+)'
430     _TEST = {
431         'url': 'http://music.163.com/#/djradio?id=42',
432         'info_dict': {
433             'id': '42',
434             'title': '声音蔓延',
435             'description': 'md5:766220985cbd16fdd552f64c578a6b15'
436         },
437         'playlist_mincount': 40,
438     }
439     _PAGE_SIZE = 1000
440
441     def _real_extract(self, url):
442         dj_id = self._match_id(url)
443
444         name = None
445         desc = None
446         entries = []
447         for offset in compat_itertools_count(start=0, step=self._PAGE_SIZE):
448             info = self.query_api(
449                 'dj/program/byradio?asc=false&limit=%d&radioId=%s&offset=%d'
450                 % (self._PAGE_SIZE, dj_id, offset),
451                 dj_id, 'Downloading dj programs - %d' % offset)
452
453             entries.extend([
454                 self.url_result(
455                     'http://music.163.com/#/program?id=%s' % program['id'],
456                     'NetEaseMusicProgram', program['id'])
457                 for program in info['programs']
458             ])
459
460             if name is None:
461                 radio = info['programs'][0]['radio']
462                 name = radio['name']
463                 desc = radio['desc']
464
465             if not info['more']:
466                 break
467
468         return self.playlist_result(entries, dj_id, name, desc)