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