[ie/neteasemusic] Fix extractors (#8181)

Closes #4388
Authored by: c-basalt
This commit is contained in:
c-basalt 2023-10-06 18:31:33 -04:00 committed by GitHub
parent 91a670a4f7
commit f980df734c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -2,105 +2,74 @@
import json import json
import re import re
import time import time
from base64 import b64encode
from binascii import hexlify
from datetime import datetime
from hashlib import md5 from hashlib import md5
from random import randint from random import randint
from .common import InfoExtractor from .common import InfoExtractor
from ..aes import aes_ecb_encrypt, pkcs7_padding from ..aes import aes_ecb_encrypt, pkcs7_padding
from ..compat import compat_urllib_parse_urlencode
from ..networking import Request
from ..utils import ( from ..utils import (
ExtractorError, ExtractorError,
bytes_to_intlist,
error_to_compat_str,
float_or_none,
int_or_none, int_or_none,
intlist_to_bytes, join_nonempty,
try_get, str_or_none,
strftime_or_none,
traverse_obj,
unified_strdate,
url_or_none,
urljoin,
variadic,
) )
class NetEaseMusicBaseIE(InfoExtractor): class NetEaseMusicBaseIE(InfoExtractor):
_FORMATS = ['bMusic', 'mMusic', 'hMusic'] _FORMATS = ['bMusic', 'mMusic', 'hMusic']
_NETEASE_SALT = '3go8&$8*3*3h0k(2)2'
_API_BASE = 'http://music.163.com/api/' _API_BASE = 'http://music.163.com/api/'
_GEO_BYPASS = False
@classmethod @staticmethod
def _encrypt(cls, dfsid): def kilo_or_none(value):
salt_bytes = bytearray(cls._NETEASE_SALT.encode('utf-8')) return int_or_none(value, scale=1000)
string_bytes = bytearray(str(dfsid).encode('ascii'))
salt_len = len(salt_bytes)
for i in range(len(string_bytes)):
string_bytes[i] = string_bytes[i] ^ salt_bytes[i % salt_len]
m = md5()
m.update(bytes(string_bytes))
result = b64encode(m.digest()).decode('ascii')
return result.replace('/', '_').replace('+', '-')
def make_player_api_request_data_and_headers(self, song_id, bitrate): def _create_eapi_cipher(self, api_path, query_body, cookies):
KEY = b'e82ckenh8dichen8' request_text = json.dumps({**query_body, 'header': cookies}, separators=(',', ':'))
URL = '/api/song/enhance/player/url'
now = int(time.time() * 1000) message = f'nobody{api_path}use{request_text}md5forencrypt'.encode('latin1')
rand = randint(0, 1000) msg_digest = md5(message).hexdigest()
cookie = {
'osver': None, data = pkcs7_padding(list(str.encode(
'deviceId': None, f'{api_path}-36cd479b6b5-{request_text}-36cd479b6b5-{msg_digest}')))
encrypted = bytes(aes_ecb_encrypt(data, list(b'e82ckenh8dichen8')))
return f'params={encrypted.hex().upper()}'.encode()
def _download_eapi_json(self, path, video_id, query_body, headers={}, **kwargs):
cookies = {
'osver': 'undefined',
'deviceId': 'undefined',
'appver': '8.0.0', 'appver': '8.0.0',
'versioncode': '140', 'versioncode': '140',
'mobilename': None, 'mobilename': 'undefined',
'buildver': '1623435496', 'buildver': '1623435496',
'resolution': '1920x1080', 'resolution': '1920x1080',
'__csrf': '', '__csrf': '',
'os': 'pc', 'os': 'pc',
'channel': None, 'channel': 'undefined',
'requestId': '{0}_{1:04}'.format(now, rand), 'requestId': f'{int(time.time() * 1000)}_{randint(0, 1000):04}',
**traverse_obj(self._get_cookies(self._API_BASE), {
'MUSIC_U': ('MUSIC_U', {lambda i: i.value}),
})
} }
request_text = json.dumps( return self._download_json(
{'ids': '[{0}]'.format(song_id), 'br': bitrate, 'header': cookie}, urljoin('https://interface3.music.163.com/', f'/eapi{path}'), video_id,
separators=(',', ':')) data=self._create_eapi_cipher(f'/api{path}', query_body, cookies), headers={
message = 'nobody{0}use{1}md5forencrypt'.format(
URL, request_text).encode('latin1')
msg_digest = md5(message).hexdigest()
data = '{0}-36cd479b6b5-{1}-36cd479b6b5-{2}'.format(
URL, request_text, msg_digest)
data = pkcs7_padding(bytes_to_intlist(data))
encrypted = intlist_to_bytes(aes_ecb_encrypt(data, bytes_to_intlist(KEY)))
encrypted_params = hexlify(encrypted).decode('ascii').upper()
cookie = '; '.join(
['{0}={1}'.format(k, v if v is not None else 'undefined')
for [k, v] in cookie.items()])
headers = {
'User-Agent': self.extractor.get_param('http_headers')['User-Agent'],
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': 'https://music.163.com', 'Referer': 'https://music.163.com',
'Cookie': cookie, 'Cookie': '; '.join([f'{k}={v}' for k, v in cookies.items()]),
} **headers,
return ('params={0}'.format(encrypted_params), headers) }, **kwargs)
def _call_player_api(self, song_id, bitrate): def _call_player_api(self, song_id, bitrate):
url = 'https://interface3.music.163.com/eapi/song/enhance/player/url' return self._download_eapi_json(
data, headers = self.make_player_api_request_data_and_headers(song_id, bitrate) '/song/enhance/player/url', song_id, {'ids': f'[{song_id}]', 'br': bitrate},
try: note=f'Downloading song URL info: bitrate {bitrate}')
msg = 'empty result'
result = self._download_json(
url, song_id, data=data.encode('ascii'), headers=headers)
if result:
return result
except ExtractorError as e:
if type(e.cause) in (ValueError, TypeError):
# JSON load failure
raise
except Exception as e:
msg = error_to_compat_str(e)
self.report_warning('%s API call (%s) failed: %s' % (
song_id, bitrate, msg))
return {}
def extract_formats(self, info): def extract_formats(self, info):
err = 0 err = 0
@ -110,45 +79,50 @@ def extract_formats(self, info):
details = info.get(song_format) details = info.get(song_format)
if not details: if not details:
continue continue
bitrate = int_or_none(details.get('bitrate')) or 999000 bitrate = int_or_none(details.get('bitrate')) or 999000
data = self._call_player_api(song_id, bitrate) for song in traverse_obj(self._call_player_api(song_id, bitrate), ('data', lambda _, v: url_or_none(v['url']))):
for song in try_get(data, lambda x: x['data'], list) or []: song_url = song['url']
song_url = try_get(song, lambda x: x['url'])
if not song_url:
continue
if self._is_valid_url(song_url, info['id'], 'song'): if self._is_valid_url(song_url, info['id'], 'song'):
formats.append({ formats.append({
'url': song_url, 'url': song_url,
'ext': details.get('extension'),
'abr': float_or_none(song.get('br'), scale=1000),
'format_id': song_format, 'format_id': song_format,
'filesize': int_or_none(song.get('size')), 'asr': traverse_obj(details, ('sr', {int_or_none})),
'asr': int_or_none(details.get('sr')), **traverse_obj(song, {
'ext': ('type', {str}),
'abr': ('br', {self.kilo_or_none}),
'filesize': ('size', {int_or_none}),
}),
}) })
elif err == 0: elif err == 0:
err = try_get(song, lambda x: x['code'], int) err = traverse_obj(song, ('code', {int})) or 0
if not formats: if not formats:
msg = 'No media links found'
if err != 0 and (err < 200 or err >= 400): if err != 0 and (err < 200 or err >= 400):
raise ExtractorError( raise ExtractorError(f'No media links found (site code {err})', expected=True)
'%s (site code %d)' % (msg, err, ), expected=True)
else: else:
self.raise_geo_restricted( self.raise_geo_restricted(
msg + ': probably this video is not available from your location due to geo restriction.', 'No media links found: probably due to geo restriction.', countries=['CN'])
countries=['CN'])
return formats return formats
@classmethod
def convert_milliseconds(cls, ms):
return int(round(ms / 1000.0))
def query_api(self, endpoint, video_id, note): def query_api(self, endpoint, video_id, note):
req = Request('%s%s' % (self._API_BASE, endpoint)) result = self._download_json(
req.headers['Referer'] = self._API_BASE f'{self._API_BASE}{endpoint}', video_id, note, headers={'Referer': self._API_BASE})
return self._download_json(req, video_id, note) code = traverse_obj(result, ('code', {int}))
message = traverse_obj(result, ('message', {str})) or ''
if code == -462:
self.raise_login_required(f'Login required to download: {message}')
elif code != 200:
raise ExtractorError(f'Failed to get meta info: {code} {message}')
return result
def _get_entries(self, songs_data, entry_keys=None, id_key='id', name_key='name'):
for song in traverse_obj(songs_data, (
*variadic(entry_keys, (str, bytes, dict, set)),
lambda _, v: int_or_none(v[id_key]) is not None)):
song_id = str(song[id_key])
yield self.url_result(
f'http://music.163.com/#/song?id={song_id}', NetEaseMusicIE,
song_id, traverse_obj(song, (name_key, {str})))
class NetEaseMusicIE(NetEaseMusicBaseIE): class NetEaseMusicIE(NetEaseMusicBaseIE):
@ -156,16 +130,18 @@ class NetEaseMusicIE(NetEaseMusicBaseIE):
IE_DESC = '网易云音乐' IE_DESC = '网易云音乐'
_VALID_URL = r'https?://(y\.)?music\.163\.com/(?:[#m]/)?song\?.*?\bid=(?P<id>[0-9]+)' _VALID_URL = r'https?://(y\.)?music\.163\.com/(?:[#m]/)?song\?.*?\bid=(?P<id>[0-9]+)'
_TESTS = [{ _TESTS = [{
'url': 'http://music.163.com/#/song?id=32102397', 'url': 'https://music.163.com/#/song?id=548648087',
'md5': '3e909614ce09b1ccef4a3eb205441190',
'info_dict': { 'info_dict': {
'id': '32102397', 'id': '548648087',
'ext': 'mp3', 'ext': 'mp3',
'title': 'Bad Blood', 'title': '戒烟 (Live)',
'creator': 'Taylor Swift / Kendrick Lamar', 'creator': '李荣浩 / 朱正廷 / 陈立农 / 尤长靖 / ONER灵超 / ONER木子洋 / 杨非同 / 陆定昊',
'upload_date': '20150516', 'timestamp': 1522944000,
'timestamp': 1431792000, 'upload_date': '20180405',
'description': 'md5:25fc5f27e47aad975aa6d36382c7833c', 'description': 'md5:3650af9ee22c87e8637cb2dde22a765c',
'subtitles': {'lyrics': [{'ext': 'lrc'}]},
"duration": 256,
'thumbnail': r're:^http.*\.jpg',
}, },
}, { }, {
'note': 'No lyrics.', 'note': 'No lyrics.',
@ -176,21 +152,9 @@ class NetEaseMusicIE(NetEaseMusicBaseIE):
'title': 'Opus 28', 'title': 'Opus 28',
'creator': 'Dustin O\'Halloran', 'creator': 'Dustin O\'Halloran',
'upload_date': '20080211', 'upload_date': '20080211',
'description': 'md5:f12945b0f6e0365e3b73c5032e1b0ff4',
'timestamp': 1202745600, 'timestamp': 1202745600,
}, 'duration': 263,
}, { 'thumbnail': r're:^http.*\.jpg',
'note': 'Has translated name.',
'url': 'http://music.163.com/#/song?id=22735043',
'info_dict': {
'id': '22735043',
'ext': 'mp3',
'title': '소원을 말해봐 (Genie)',
'creator': '少女时代',
'description': 'md5:79d99cc560e4ca97e0c4d86800ee4184',
'upload_date': '20100127',
'timestamp': 1264608000,
'alt_title': '说出愿望吧(Genie)',
}, },
}, { }, {
'url': 'https://y.music.163.com/m/song?app_version=8.8.45&id=95670&uct2=sKnvS4+0YStsWkqsPhFijw%3D%3D&dlt=0846', 'url': 'https://y.music.163.com/m/song?app_version=8.8.45&id=95670&uct2=sKnvS4+0YStsWkqsPhFijw%3D%3D&dlt=0846',
@ -203,59 +167,99 @@ class NetEaseMusicIE(NetEaseMusicBaseIE):
'upload_date': '19911130', 'upload_date': '19911130',
'timestamp': 691516800, 'timestamp': 691516800,
'description': 'md5:1ba2f911a2b0aa398479f595224f2141', 'description': 'md5:1ba2f911a2b0aa398479f595224f2141',
'subtitles': {'lyrics': [{'ext': 'lrc'}]},
'duration': 268,
'alt_title': '伴唱:现代人乐队 合唱:总政歌舞团',
'thumbnail': r're:^http.*\.jpg',
}, },
}, {
'url': 'http://music.163.com/#/song?id=32102397',
'md5': '3e909614ce09b1ccef4a3eb205441190',
'info_dict': {
'id': '32102397',
'ext': 'mp3',
'title': 'Bad Blood',
'creator': 'Taylor Swift / Kendrick Lamar',
'upload_date': '20150516',
'timestamp': 1431792000,
'description': 'md5:21535156efb73d6d1c355f95616e285a',
'subtitles': {'lyrics': [{'ext': 'lrc'}]},
'duration': 199,
'thumbnail': r're:^http.*\.jpg',
},
'skip': 'Blocked outside Mainland China',
}, {
'note': 'Has translated name.',
'url': 'http://music.163.com/#/song?id=22735043',
'info_dict': {
'id': '22735043',
'ext': 'mp3',
'title': '소원을 말해봐 (Genie)',
'creator': '少女时代',
'upload_date': '20100127',
'timestamp': 1264608000,
'description': 'md5:03d1ffebec3139aa4bafe302369269c5',
'subtitles': {'lyrics': [{'ext': 'lrc'}]},
'duration': 229,
'alt_title': '说出愿望吧(Genie)',
'thumbnail': r're:^http.*\.jpg',
},
'skip': 'Blocked outside Mainland China',
}] }]
def _process_lyrics(self, lyrics_info): def _process_lyrics(self, lyrics_info):
original = lyrics_info.get('lrc', {}).get('lyric') original = traverse_obj(lyrics_info, ('lrc', 'lyric', {str}))
translated = lyrics_info.get('tlyric', {}).get('lyric') translated = traverse_obj(lyrics_info, ('tlyric', 'lyric', {str}))
if not original or original == '[99:00.00]纯音乐,请欣赏\n':
return None
if not translated: if not translated:
return original return {
'lyrics': [{'data': original, 'ext': 'lrc'}],
}
lyrics_expr = r'(\[[0-9]{2}:[0-9]{2}\.[0-9]{2,}\])([^\n]+)' lyrics_expr = r'(\[[0-9]{2}:[0-9]{2}\.[0-9]{2,}\])([^\n]+)'
original_ts_texts = re.findall(lyrics_expr, original) original_ts_texts = re.findall(lyrics_expr, original)
translation_ts_dict = dict( translation_ts_dict = dict(re.findall(lyrics_expr, translated))
(time_stamp, text) for time_stamp, text in re.findall(lyrics_expr, translated)
) merged = '\n'.join(
lyrics = '\n'.join([ join_nonempty(f'{timestamp}{text}', translation_ts_dict.get(timestamp, ''), delim=' / ')
'%s%s / %s' % (time_stamp, text, translation_ts_dict.get(time_stamp, '')) for timestamp, text in original_ts_texts)
for time_stamp, text in original_ts_texts
]) return {
return lyrics 'lyrics_merged': [{'data': merged, 'ext': 'lrc'}],
'lyrics': [{'data': original, 'ext': 'lrc'}],
'lyrics_translated': [{'data': translated, 'ext': 'lrc'}],
}
def _real_extract(self, url): def _real_extract(self, url):
song_id = self._match_id(url) song_id = self._match_id(url)
params = {
'id': song_id,
'ids': '[%s]' % song_id
}
info = self.query_api( info = self.query_api(
'song/detail?' + compat_urllib_parse_urlencode(params), f'song/detail?id={song_id}&ids=%5B{song_id}%5D', song_id, 'Downloading song info')['songs'][0]
song_id, 'Downloading song info')['songs'][0]
formats = self.extract_formats(info) formats = self.extract_formats(info)
lyrics_info = self.query_api( lyrics = self._process_lyrics(self.query_api(
'song/lyric?id=%s&lv=-1&tv=-1' % song_id, f'song/lyric?id={song_id}&lv=-1&tv=-1', song_id, 'Downloading lyrics data'))
song_id, 'Downloading lyrics data') lyric_data = {
lyrics = self._process_lyrics(lyrics_info) 'description': traverse_obj(lyrics, (('lyrics_merged', 'lyrics'), 0, 'data'), get_all=False),
'subtitles': lyrics,
alt_title = None } if lyrics else {}
if info.get('transNames'):
alt_title = '/'.join(info.get('transNames'))
return { return {
'id': song_id, 'id': song_id,
'title': info['name'],
'alt_title': alt_title,
'creator': ' / '.join([artist['name'] for artist in info.get('artists', [])]),
'timestamp': self.convert_milliseconds(info.get('album', {}).get('publishTime')),
'thumbnail': info.get('album', {}).get('picUrl'),
'duration': self.convert_milliseconds(info.get('duration', 0)),
'description': lyrics,
'formats': formats, 'formats': formats,
'alt_title': '/'.join(traverse_obj(info, (('transNames', 'alias'), ...))) or None,
'creator': ' / '.join(traverse_obj(info, ('artists', ..., 'name'))) or None,
**lyric_data,
**traverse_obj(info, {
'title': ('name', {str}),
'timestamp': ('album', 'publishTime', {self.kilo_or_none}),
'thumbnail': ('album', 'picUrl', {url_or_none}),
'duration': ('duration', {self.kilo_or_none}),
}),
} }
@ -263,31 +267,44 @@ class NetEaseMusicAlbumIE(NetEaseMusicBaseIE):
IE_NAME = 'netease:album' IE_NAME = 'netease:album'
IE_DESC = '网易云音乐 - 专辑' IE_DESC = '网易云音乐 - 专辑'
_VALID_URL = r'https?://music\.163\.com/(#/)?album\?id=(?P<id>[0-9]+)' _VALID_URL = r'https?://music\.163\.com/(#/)?album\?id=(?P<id>[0-9]+)'
_TEST = { _TESTS = [{
'url': 'https://music.163.com/#/album?id=133153666',
'info_dict': {
'id': '133153666',
'title': '桃几的翻唱',
'upload_date': '20210913',
'description': '桃几2021年翻唱合集',
'thumbnail': r're:^http.*\.jpg',
},
'playlist_mincount': 13,
}, {
'url': 'http://music.163.com/#/album?id=220780', 'url': 'http://music.163.com/#/album?id=220780',
'info_dict': { 'info_dict': {
'id': '220780', 'id': '220780',
'title': 'B\'day', 'title': 'B\'Day',
'upload_date': '20060904',
'description': 'md5:71a74e1d8f392d88cf1bbe48879ad0b0',
'thumbnail': r're:^http.*\.jpg',
}, },
'playlist_count': 23, 'playlist_count': 23,
'skip': 'Blocked outside Mainland China', }]
}
def _real_extract(self, url): def _real_extract(self, url):
album_id = self._match_id(url) album_id = self._match_id(url)
webpage = self._download_webpage(f'https://music.163.com/album?id={album_id}', album_id)
info = self.query_api( songs = self._search_json(
'album/%s?id=%s' % (album_id, album_id), r'<textarea[^>]+\bid="song-list-pre-data"[^>]*>', webpage, 'metainfo', album_id,
album_id, 'Downloading album data')['album'] end_pattern=r'</textarea>', contains_pattern=r'\[(?s:.+)\]')
metainfo = {
name = info['name'] 'title': self._og_search_property('title', webpage, 'title', fatal=False),
desc = info.get('description') 'description': self._html_search_regex(
entries = [ (rf'<div[^>]+\bid="album-desc-{suffix}"[^>]*>(.*?)</div>' for suffix in ('more', 'dot')),
self.url_result('http://music.163.com/#/song?id=%s' % song['id'], webpage, 'description', flags=re.S, fatal=False),
'NetEaseMusic', song['id']) 'thumbnail': self._og_search_property('image', webpage, 'thumbnail', fatal=False),
for song in info['songs'] 'upload_date': unified_strdate(self._html_search_meta('music:release_date', webpage, 'date', fatal=False)),
] }
return self.playlist_result(entries, album_id, name, desc) return self.playlist_result(self._get_entries(songs), album_id, **metainfo)
class NetEaseMusicSingerIE(NetEaseMusicBaseIE): class NetEaseMusicSingerIE(NetEaseMusicBaseIE):
@ -299,10 +316,9 @@ class NetEaseMusicSingerIE(NetEaseMusicBaseIE):
'url': 'http://music.163.com/#/artist?id=10559', 'url': 'http://music.163.com/#/artist?id=10559',
'info_dict': { 'info_dict': {
'id': '10559', 'id': '10559',
'title': '张惠妹 - aMEI;阿密特', 'title': '张惠妹 - aMEI;阿妹;阿密特',
}, },
'playlist_count': 50, 'playlist_count': 50,
'skip': 'Blocked outside Mainland China',
}, { }, {
'note': 'Singer has translated name.', 'note': 'Singer has translated name.',
'url': 'http://music.163.com/#/artist?id=124098', 'url': 'http://music.163.com/#/artist?id=124098',
@ -311,28 +327,28 @@ class NetEaseMusicSingerIE(NetEaseMusicBaseIE):
'title': '李昇基 - 이승기', 'title': '李昇基 - 이승기',
}, },
'playlist_count': 50, 'playlist_count': 50,
'skip': 'Blocked outside Mainland China', }, {
'note': 'Singer with both translated and alias',
'url': 'https://music.163.com/#/artist?id=159692',
'info_dict': {
'id': '159692',
'title': '初音ミク - 初音未来;Hatsune Miku',
},
'playlist_count': 50,
}] }]
def _real_extract(self, url): def _real_extract(self, url):
singer_id = self._match_id(url) singer_id = self._match_id(url)
info = self.query_api( info = self.query_api(
'artist/%s?id=%s' % (singer_id, singer_id), f'artist/{singer_id}?id={singer_id}', singer_id, note='Downloading singer data')
singer_id, 'Downloading singer data')
name = info['artist']['name'] name = join_nonempty(
if info['artist']['trans']: traverse_obj(info, ('artist', 'name', {str})),
name = '%s - %s' % (name, info['artist']['trans']) join_nonempty(*traverse_obj(info, ('artist', ('trans', ('alias', ...)), {str})), delim=';'),
if info['artist']['alias']: delim=' - ')
name = '%s - %s' % (name, ';'.join(info['artist']['alias']))
entries = [ return self.playlist_result(self._get_entries(info, 'hotSongs'), singer_id, name)
self.url_result('http://music.163.com/#/song?id=%s' % song['id'],
'NetEaseMusic', song['id'])
for song in info['hotSongs']
]
return self.playlist_result(entries, singer_id, name)
class NetEaseMusicListIE(NetEaseMusicBaseIE): class NetEaseMusicListIE(NetEaseMusicBaseIE):
@ -344,10 +360,28 @@ class NetEaseMusicListIE(NetEaseMusicBaseIE):
'info_dict': { 'info_dict': {
'id': '79177352', 'id': '79177352',
'title': 'Billboard 2007 Top 100', 'title': 'Billboard 2007 Top 100',
'description': 'md5:12fd0819cab2965b9583ace0f8b7b022' 'description': 'md5:12fd0819cab2965b9583ace0f8b7b022',
'tags': ['欧美'],
'uploader': '浑然破灭',
'uploader_id': '67549805',
'timestamp': int,
'upload_date': r're:\d{8}',
}, },
'playlist_count': 99, 'playlist_mincount': 95,
'skip': 'Blocked outside Mainland China', }, {
'note': 'Toplist/Charts sample',
'url': 'https://music.163.com/#/discover/toplist?id=60198',
'info_dict': {
'id': '60198',
'title': 're:美国Billboard榜 [0-9]{4}-[0-9]{2}-[0-9]{2}',
'description': '美国Billboard排行榜',
'tags': ['流行', '欧美', '榜单'],
'uploader': 'Billboard公告牌',
'uploader_id': '48171',
'timestamp': int,
'upload_date': r're:\d{8}',
},
'playlist_count': 100,
}, { }, {
'note': 'Toplist/Charts sample', 'note': 'Toplist/Charts sample',
'url': 'http://music.163.com/#/discover/toplist?id=3733003', 'url': 'http://music.163.com/#/discover/toplist?id=3733003',
@ -363,64 +397,86 @@ class NetEaseMusicListIE(NetEaseMusicBaseIE):
def _real_extract(self, url): def _real_extract(self, url):
list_id = self._match_id(url) list_id = self._match_id(url)
info = self.query_api( info = self._download_eapi_json(
'playlist/detail?id=%s&lv=-1&tv=-1' % list_id, '/v3/playlist/detail', list_id,
list_id, 'Downloading playlist data')['result'] {'id': list_id, 't': '-1', 'n': '500', 's': '0'},
note="Downloading playlist info")
name = info['name'] metainfo = traverse_obj(info, ('playlist', {
desc = info.get('description') 'title': ('name', {str}),
'description': ('description', {str}),
'tags': ('tags', ..., {str}),
'uploader': ('creator', 'nickname', {str}),
'uploader_id': ('creator', 'userId', {str_or_none}),
'timestamp': ('updateTime', {self.kilo_or_none}),
}))
if traverse_obj(info, ('playlist', 'specialType')) == 10:
metainfo['title'] = f'{metainfo.get("title")} {strftime_or_none(metainfo.get("timestamp"), "%Y-%m-%d")}'
if info.get('specialType') == 10: # is a chart/toplist return self.playlist_result(self._get_entries(info, ('playlist', 'tracks')), list_id, **metainfo)
datestamp = datetime.fromtimestamp(
self.convert_milliseconds(info['updateTime'])).strftime('%Y-%m-%d')
name = '%s %s' % (name, datestamp)
entries = [
self.url_result('http://music.163.com/#/song?id=%s' % song['id'],
'NetEaseMusic', song['id'])
for song in info['tracks']
]
return self.playlist_result(entries, list_id, name, desc)
class NetEaseMusicMvIE(NetEaseMusicBaseIE): class NetEaseMusicMvIE(NetEaseMusicBaseIE):
IE_NAME = 'netease:mv' IE_NAME = 'netease:mv'
IE_DESC = '网易云音乐 - MV' IE_DESC = '网易云音乐 - MV'
_VALID_URL = r'https?://music\.163\.com/(#/)?mv\?id=(?P<id>[0-9]+)' _VALID_URL = r'https?://music\.163\.com/(#/)?mv\?id=(?P<id>[0-9]+)'
_TEST = { _TESTS = [{
'url': 'https://music.163.com/#/mv?id=10958064',
'info_dict': {
'id': '10958064',
'ext': 'mp4',
'title': '交换余生',
'description': 'md5:e845872cff28820642a2b02eda428fea',
'creator': '林俊杰',
'upload_date': '20200916',
'thumbnail': r're:http.*\.jpg',
'duration': 364,
'view_count': int,
'like_count': int,
'comment_count': int,
},
}, {
'url': 'http://music.163.com/#/mv?id=415350', 'url': 'http://music.163.com/#/mv?id=415350',
'info_dict': { 'info_dict': {
'id': '415350', 'id': '415350',
'ext': 'mp4', 'ext': 'mp4',
'title': '이럴거면 그러지말지', 'title': '이럴거면 그러지말지',
'description': '白雅言自作曲唱甜蜜爱情', 'description': '白雅言自作曲唱甜蜜爱情',
'creator': '白雅言', 'creator': '娥娟',
'upload_date': '20150520', 'upload_date': '20150520',
'thumbnail': r're:http.*\.jpg',
'duration': 216,
'view_count': int,
'like_count': int,
'comment_count': int,
}, },
'skip': 'Blocked outside Mainland China', }]
}
def _real_extract(self, url): def _real_extract(self, url):
mv_id = self._match_id(url) mv_id = self._match_id(url)
info = self.query_api( info = self.query_api(
'mv/detail?id=%s&type=mp4' % mv_id, f'mv/detail?id={mv_id}&type=mp4', mv_id, 'Downloading mv info')['data']
mv_id, 'Downloading mv info')['data']
formats = [ formats = [
{'url': mv_url, 'ext': 'mp4', 'format_id': '%sp' % brs, 'height': int(brs)} {'url': mv_url, 'ext': 'mp4', 'format_id': f'{brs}p', 'height': int_or_none(brs)}
for brs, mv_url in info['brs'].items() for brs, mv_url in info['brs'].items()
] ]
return { return {
'id': mv_id, 'id': mv_id,
'title': info['name'],
'description': info.get('desc') or info.get('briefDesc'),
'creator': info['artistName'],
'upload_date': info['publishTime'].replace('-', ''),
'formats': formats, 'formats': formats,
'thumbnail': info.get('cover'), **traverse_obj(info, {
'duration': self.convert_milliseconds(info.get('duration', 0)), 'title': ('name', {str}),
'description': (('desc', 'briefDesc'), {str}, {lambda x: x or None}),
'creator': ('artistName', {str}),
'upload_date': ('publishTime', {unified_strdate}),
'thumbnail': ('cover', {url_or_none}),
'duration': ('duration', {self.kilo_or_none}),
'view_count': ('playCount', {int_or_none}),
'like_count': ('likeCount', {int_or_none}),
'comment_count': ('commentCount', {int_or_none}),
}, get_all=False),
} }
@ -431,75 +487,74 @@ class NetEaseMusicProgramIE(NetEaseMusicBaseIE):
_TESTS = [{ _TESTS = [{
'url': 'http://music.163.com/#/program?id=10109055', 'url': 'http://music.163.com/#/program?id=10109055',
'info_dict': { 'info_dict': {
'id': '10109055', 'id': '32593346',
'ext': 'mp3', 'ext': 'mp3',
'title': '不丹足球背后的故事', 'title': '不丹足球背后的故事',
'description': '喜马拉雅人的足球梦 ...', 'description': '喜马拉雅人的足球梦 ...',
'creator': '大话西藏', 'creator': '大话西藏',
'timestamp': 1434179342, 'timestamp': 1434179287,
'upload_date': '20150613', 'upload_date': '20150613',
'thumbnail': r're:http.*\.jpg',
'duration': 900, 'duration': 900,
}, },
'skip': 'Blocked outside Mainland China',
}, { }, {
'note': 'This program has accompanying songs.', 'note': 'This program has accompanying songs.',
'url': 'http://music.163.com/#/program?id=10141022', 'url': 'http://music.163.com/#/program?id=10141022',
'info_dict': { 'info_dict': {
'id': '10141022', 'id': '10141022',
'title': '25岁你是自在如风的少年<27°C>', 'title': '滚滚电台的有声节目',
'description': 'md5:8d594db46cc3e6509107ede70a4aaa3b', 'description': 'md5:8d594db46cc3e6509107ede70a4aaa3b',
'creator': '滚滚电台ORZ',
'timestamp': 1434450733,
'upload_date': '20150616',
'thumbnail': r're:http.*\.jpg',
}, },
'playlist_count': 4, 'playlist_count': 4,
'skip': 'Blocked outside Mainland China',
}, { }, {
'note': 'This program has accompanying songs.', 'note': 'This program has accompanying songs.',
'url': 'http://music.163.com/#/program?id=10141022', 'url': 'http://music.163.com/#/program?id=10141022',
'info_dict': { 'info_dict': {
'id': '10141022', 'id': '32647209',
'ext': 'mp3', 'ext': 'mp3',
'title': '25岁你是自在如风的少年<27°C>', 'title': '滚滚电台的有声节目',
'description': 'md5:8d594db46cc3e6509107ede70a4aaa3b', 'description': 'md5:8d594db46cc3e6509107ede70a4aaa3b',
'timestamp': 1434450841, 'creator': '滚滚电台ORZ',
'timestamp': 1434450733,
'upload_date': '20150616', 'upload_date': '20150616',
'thumbnail': r're:http.*\.jpg',
'duration': 1104,
}, },
'params': { 'params': {
'noplaylist': True 'noplaylist': True
}, },
'skip': 'Blocked outside Mainland China',
}] }]
def _real_extract(self, url): def _real_extract(self, url):
program_id = self._match_id(url) program_id = self._match_id(url)
info = self.query_api( info = self.query_api(
'dj/program/detail?id=%s' % program_id, f'dj/program/detail?id={program_id}', program_id, note='Downloading program info')['program']
program_id, 'Downloading program info')['program']
name = info['name'] metainfo = traverse_obj(info, {
description = info['description'] 'title': ('name', {str}),
'description': ('description', {str}),
'creator': ('dj', 'brand', {str}),
'thumbnail': ('coverUrl', {url_or_none}),
'timestamp': ('createTime', {self.kilo_or_none}),
})
if not self._yes_playlist(info['songs'] and program_id, info['mainSong']['id']): if not self._yes_playlist(info['songs'] and program_id, info['mainSong']['id']):
formats = self.extract_formats(info['mainSong']) formats = self.extract_formats(info['mainSong'])
return { return {
'id': info['mainSong']['id'], 'id': str(info['mainSong']['id']),
'title': name,
'description': description,
'creator': info['dj']['brand'],
'timestamp': self.convert_milliseconds(info['createTime']),
'thumbnail': info['coverUrl'],
'duration': self.convert_milliseconds(info.get('duration', 0)),
'formats': formats, 'formats': formats,
'duration': traverse_obj(info, ('mainSong', 'duration', {self.kilo_or_none})),
**metainfo,
} }
song_ids = [info['mainSong']['id']] songs = traverse_obj(info, (('mainSong', ('songs', ...)),))
song_ids.extend([song['id'] for song in info['songs']]) return self.playlist_result(self._get_entries(songs), program_id, **metainfo)
entries = [
self.url_result('http://music.163.com/#/song?id=%s' % song_id,
'NetEaseMusic', song_id)
for song_id in song_ids
]
return self.playlist_result(entries, program_id, name, description)
class NetEaseMusicDjRadioIE(NetEaseMusicBaseIE): class NetEaseMusicDjRadioIE(NetEaseMusicBaseIE):
@ -511,38 +566,32 @@ class NetEaseMusicDjRadioIE(NetEaseMusicBaseIE):
'info_dict': { 'info_dict': {
'id': '42', 'id': '42',
'title': '声音蔓延', 'title': '声音蔓延',
'description': 'md5:766220985cbd16fdd552f64c578a6b15' 'description': 'md5:c7381ebd7989f9f367668a5aee7d5f08'
}, },
'playlist_mincount': 40, 'playlist_mincount': 40,
'skip': 'Blocked outside Mainland China',
} }
_PAGE_SIZE = 1000 _PAGE_SIZE = 1000
def _real_extract(self, url): def _real_extract(self, url):
dj_id = self._match_id(url) dj_id = self._match_id(url)
name = None metainfo = {}
desc = None
entries = [] entries = []
for offset in itertools.count(start=0, step=self._PAGE_SIZE): for offset in itertools.count(start=0, step=self._PAGE_SIZE):
info = self.query_api( info = self.query_api(
'dj/program/byradio?asc=false&limit=%d&radioId=%s&offset=%d' f'dj/program/byradio?asc=false&limit={self._PAGE_SIZE}&radioId={dj_id}&offset={offset}',
% (self._PAGE_SIZE, dj_id, offset), dj_id, note=f'Downloading dj programs - {offset}')
dj_id, 'Downloading dj programs - %d' % offset)
entries.extend([ entries.extend(self.url_result(
self.url_result( f'http://music.163.com/#/program?id={program["id"]}', NetEaseMusicProgramIE,
'http://music.163.com/#/program?id=%s' % program['id'], program['id'], program.get('name')) for program in info['programs'])
'NetEaseMusicProgram', program['id']) if not metainfo:
for program in info['programs'] metainfo = traverse_obj(info, ('programs', 0, 'radio', {
]) 'title': ('name', {str}),
'description': ('desc', {str}),
if name is None: }))
radio = info['programs'][0]['radio']
name = radio['name']
desc = radio['desc']
if not info['more']: if not info['more']:
break break
return self.playlist_result(entries, dj_id, name, desc) return self.playlist_result(entries, dj_id, **metainfo)