From d93db93ae4764fbe43fcc0b5348c65d841c78638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=99=93=E6=99=B4?= <37541680+Suxiaoqinx@users.noreply.github.com> Date: Fri, 16 May 2025 21:18:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=83=A8=E5=88=86=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=20=E4=BF=AE=E6=94=B9=E4=B8=BA=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=8C=96=20=E5=8D=95=E7=8B=AC=E5=88=86=E7=A6=BBAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 178 ++++++++++++++++++++---------- main.py | 305 ++++++++++++++++++++++++--------------------------- music_api.py | 213 +++++++++++++++++++++++++++++++++++ 3 files changed, 475 insertions(+), 221 deletions(-) create mode 100644 music_api.py diff --git a/README.md b/README.md index 9350afc..82bc4bf 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,138 @@ -# !声明 ! -本项目为开源软件,遵循MIT许可证。任何个人或组织均可自由使用、修改和分发本项目的源代码。然而,我们明确声明,本项目及其任何衍生作品不得用于任何商业或付费项目。任何违反此声明的行为都将被视为对本项目许可证的侵犯。我们鼓励大家在遵守开源精神和许可证的前提下,积极贡献和分享代码。 +# 网易云无损音乐解析 -# 网易云无损解析使用方法 -先安装 文件所需要的依赖模块 -pip install -r requirements.txt -再运行main.py文件即可 +> **声明** +> 本项目为开源软件,遵循 MIT 许可证。任何个人或组织均可自由使用、修改和分发本项目的源代码。但本项目及其任何衍生作品**禁止用于任何商业或付费项目**。如有违反,将视为对本项目许可证的侵犯。欢迎大家在遵守开源精神和许可证的前提下积极贡献和分享代码。 -# 环境要求 -Python >= 3 +--- -## GUI模式参数 -python main.py -| 参数列表 | 参数说明 | -| ---- | ---- | -| --mode | api 或 gui| -| --level | 音质参数(请看下方音质说明) | -| --url | 解析获取到的网易云音乐地址 | +## 功能简介 -完整请求 python main.py --mode gui --url 音乐地址 --level 音质 +本项目可解析网易云音乐无损音质下载链接,支持多种音质选择,支持 API 与命令行(GUI)两种模式。 -## API模式参数列表 +--- -请求链接选择 http://ip:port/Song_V1 +## 快速开始 -请求方式 GET & POST - -| 参数列表 | 参数说明 | -| ---- | ---- | -| url & ids | 解析获取到的网易云音乐地址 *任选其一| -| level | 音质参数(请看下方音质说明) | -| type | 解析类型 json down text *任选其一 | - -# docker-compose一键部署 - -## 修改参数 - -部署前,可以根据需要修改`.env`文件中的环境变量 - -默认端口为`5000`,如果需要修改,请在`docker-compose.yml`文件中修改`ports`变量 - -例如,如果需要将端口修改为`8080`,请将以下代码: - -```yaml -ports: - - "8080:5000" -``` - -## docker-compose一键启动 +### 1. 安装依赖 ```bash -docker-compose up -d +pip install -r requirements.txt ``` -# 音质说明 -standard(标准音质), exhigh(极高音质), lossless(无损音质), hires(Hi-Res音质), jyeffect(高清环绕声), sky(沉浸环绕声), jymaster(超清母带) +### 2. 配置 Cookie -黑胶VIP音质选择 standard, exhigh, lossless, hires, jyeffect

-黑胶SVIP音质选择 sky, jymaster +请在 `cookie.txt` 文件中填入黑胶会员账号的 Cookie,格式如下: + +``` +MUSIC_U=你的MUSIC_U值;os=pc;appver=8.9.70; +``` + +> 具体值请参考 `cookie.txt` 示例,替换为你自己的即可。 + +### 3. 运行 + +#### GUI 模式 + +```bash +python main.py --mode gui --url <网易云音乐地址> --level <音质参数> +``` + +#### API 模式 + +```bash +python main.py --mode api +``` + +- 访问接口:http://ip:port/类型解析 +- 支持 GET 和 POST 请求 + +--- + +## 参数说明 + +### GUI 模式参数 + +| 参数 | 说明 | +| ------------ | ---------------------------- | +| --mode | 启动模式:api 或 gui | +| --url | 需要解析的网易云音乐地址 | +| --level | 音质参数(见下方音质说明) | + +### API 模式参数 + +| 参数 | 说明 | +| ------------ | -------------------------------------------- | +| url / ids | 网易云音乐地址或歌曲ID(二选一) | +| level | 音质参数(见下方音质说明) | +| type | 解析类型:json / down / text(三选一) | + +| 类型参数 | 说明 | +| ------------ | -------------------------------------------- | +| Song_v1 | 单曲解析 | +| search | 搜索解析 | +| playlist | 歌单解析 | +| album | 专辑解析 | + +--- + +## 音质参数说明(仅限单曲解析) + +- `standard`:标准音质 +- `exhigh`:极高音质 +- `lossless`:无损音质 +- `hires`:Hi-Res音质 +- `jyeffect`:高清环绕声 +- `sky`:沉浸环绕声 +- `jymaster`:超清母带 + +> 黑胶VIP音质:standard, exhigh, lossless, hires, jyeffect +> 黑胶SVIP音质:sky, jymaster + +--- + +## Docker 一键部署 + +1. **修改参数** + + - 如需修改端口,请编辑 `.env` 或 `docker-compose.yml` 文件中的 `ports` 配置,例如: + + ```yaml + ports: + - "8080:5000" + ``` + +2. **启动服务** + + ```bash + docker-compose up -d + ``` + +--- + +## 在线演示 -# 演示站点 [在线解析](https://api.toubiec.cn/wyapi.html) -# 注意事项 -请先在cookie.txt文件内填入黑胶会员账号的cookie 才可以解析! -Cookie格式为↓ -MUSIC_U=你获取到的MUSIC_U值;os=pc;appver=8.9.70; 完整填入cookie.txt即可! -具体值在cookie.txt里面就有 替换一下就行了 +--- -# 感谢 -[Ravizhan](https://github.com/ravizhan) +## 注意事项 -# 反馈方法 -请在Github的lssues反馈 或者到我[博客](https://www.toubiec.cn)反馈 +- 必须使用黑胶会员账号的 Cookie 才能解析高音质资源。 +- Cookie 格式请严格按照 `cookie.txt` 示例填写。 + +--- + +## 致谢 + +- [Ravizhan](https://github.com/ravizhan) + +--- + +## 反馈与交流 + +- 在 Github [Issues](https://github.com/Suxiaoqinx/Netease_url/issues) 提交反馈 +- 或访问 [我的博客](https://www.toubiec.cn) + +--- + +欢迎 Star、Fork 和 PR! \ No newline at end of file diff --git a/main.py b/main.py index e1a7ccb..d40b3aa 100644 --- a/main.py +++ b/main.py @@ -1,53 +1,14 @@ import argparse from flask import Flask, request, render_template, redirect, jsonify -import json -import os -import urllib.parse -from hashlib import md5 -from random import randrange -import requests -from cryptography.hazmat.primitives import padding -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from music_api import url_v1, name_v1, lyric_v1, search_music, playlist_detail, album_detail +from cookie_manager import CookieManager -def HexDigest(data): - return "".join([hex(d)[2:].zfill(2) for d in data]) +# ================= 工具函数 ================= +cookie_manager = CookieManager() -def HashDigest(text): - HASH = md5(text.encode("utf-8")) - return HASH.digest() - -def HashHexDigest(text): - return HexDigest(HashDigest(text)) - -def parse_cookie(text: str): - cookie_ = [item.strip().split('=', 1) for item in text.strip().split(';') if item] - cookie_ = {k.strip(): v.strip() for k, v in cookie_} - return cookie_ - -def read_cookie(): - script_dir = os.path.dirname(os.path.abspath(__file__)) - cookie_file = os.path.join(script_dir, 'cookie.txt') - with open(cookie_file, 'r') as f: - cookie_contents = f.read() - return cookie_contents - -def post(url, params, cookie): - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/2.10.2.200154', - 'Referer': '', - } - cookies = { - "os": "pc", - "appver": "", - "osver": "", - "deviceId": "pyncm!" - } - cookies.update(cookie) - response = requests.post(url, headers=headers, cookies=cookies, data={"params": params}) - return response.text - -def ids(ids): +def ids(ids: str) -> str: if '163cn.tv' in ids: + import requests response = requests.get(ids, allow_redirects=False) ids = response.headers.get('Location') if 'music.163.com' in ids: @@ -55,16 +16,16 @@ def ids(ids): ids = ids[index:].split('&')[0] return ids -def size(value): +def size(value: float) -> str: units = ["B", "KB", "MB", "GB", "TB", "PB"] size = 1024.0 for i in range(len(units)): if (value / size) < 1: return "%.2f%s" % (value, units[i]) value = value / size - return value + return str(value) -def music_level1(value): +def music_level1(value: str) -> str: levels = { 'standard': "标准音质", 'exhigh': "极高音质", @@ -76,53 +37,9 @@ def music_level1(value): } return levels.get(value, "未知音质") -def url_v1(id, level, cookies): - url = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1" - AES_KEY = b"e82ckenh8dichen8" - config = { - "os": "pc", - "appver": "", - "osver": "", - "deviceId": "pyncm!", - "requestId": str(randrange(20000000, 30000000)) - } - - payload = { - 'ids': [id], - 'level': level, - 'encodeType': 'flac', - 'header': json.dumps(config), - } - - if level == 'sky': - payload['immerseType'] = 'c51' - - url2 = urllib.parse.urlparse(url).path.replace("/eapi/", "/api/") - digest = HashHexDigest(f"nobody{url2}use{json.dumps(payload)}md5forencrypt") - params = f"{url2}-36cd479b6b5-{json.dumps(payload)}-36cd479b6b5-{digest}" - padder = padding.PKCS7(algorithms.AES(AES_KEY).block_size).padder() - padded_data = padder.update(params.encode()) + padder.finalize() - cipher = Cipher(algorithms.AES(AES_KEY), modes.ECB()) - encryptor = cipher.encryptor() - enc = encryptor.update(padded_data) + encryptor.finalize() - params = HexDigest(enc) - response = post(url, params, cookies) - return json.loads(response) - -def name_v1(id): - urls = "https://interface3.music.163.com/api/v3/song/detail" - data = {'c': json.dumps([{"id":id,"v":0}])} - response = requests.post(url=urls, data=data) - return response.json() - -def lyric_v1(id, cookies): - url = "https://interface3.music.163.com/api/song/lyric" - data = {'id': id, 'cp': 'false', 'tv': '0', 'lv': '0', 'rv': '0', 'kv': '0', 'yv': '0', 'ytv': '0', 'yrv': '0'} - response = requests.post(url=url, data=data, cookies=cookies) - return response.json() - -# Flask 应用部分 +# ================= Flask 应用 ================= app = Flask(__name__) + @app.after_request def after_request(response): response.headers.add('Access-Control-Allow-Origin', '*') @@ -136,6 +53,7 @@ def index(): @app.route('/Song_V1', methods=['GET', 'POST']) def Song_v1(): + # 参数获取 if request.method == 'GET': song_ids = request.args.get('ids') url = request.args.get('url') @@ -147,93 +65,152 @@ def Song_v1(): level = request.form.get('level') type_ = request.form.get('type') + # 参数校验 if not song_ids and not url: return jsonify({'error': '必须提供 ids 或 url 参数'}), 400 - if level is None: + if not level: return jsonify({'error': 'level参数为空'}), 400 - if type_ is None: + if not type_: return jsonify({'error': 'type参数为空'}), 400 jsondata = song_ids if song_ids else url - cookies = parse_cookie(read_cookie()) - urlv1 = url_v1(ids(jsondata),level,cookies) - namev1 = name_v1(urlv1['data'][0]['id']) - lyricv1 = lyric_v1(urlv1['data'][0]['id'],cookies) - if urlv1['data'][0]['url'] is not None: - if namev1['songs']: - song_url = urlv1['data'][0]['url'] - song_name = namev1['songs'][0]['name'] - song_picUrl = namev1['songs'][0]['al']['picUrl'] - song_alname = namev1['songs'][0]['al']['name'] - artist_names = [] - for song in namev1['songs']: - ar_list = song['ar'] - if len(ar_list) > 0: - artist_names.append('/'.join(ar['name'] for ar in ar_list)) - song_arname = ', '.join(artist_names) - else: - data = jsonify({"status": 400,'msg': '信息获取不完整!'}), 400 - if type_ == 'text': - data = '歌曲名称:' + song_name + '
歌曲图片:' + song_picUrl + '
歌手:' + song_arname + '
歌曲专辑:' + song_alname + '
歌曲音质:' + music_level1(urlv1['data'][0]['level']) + '
歌曲大小:' + size(urlv1['data'][0]['size']) + '
音乐地址:' + song_url - elif type_ == 'down': - data = redirect(song_url) - elif type_ == 'json': - data = { - "status": 200, - "name": song_name, - "pic": song_picUrl, - "ar_name": song_arname, - "al_name": song_alname, - "level":music_level1(urlv1['data'][0]['level']), - "size": size(urlv1['data'][0]['size']), - "url": song_url.replace("http://", "https://", 1), - "lyric": lyricv1['lrc']['lyric'], - "tlyric": lyricv1.get('tlyric', {}).get('lyric', None) - } - data = jsonify(data) - else: - data = jsonify({"status": 400,'msg': '解析失败!请检查参数是否完整!'}), 400 - return data - -def start_gui(url=None, level='lossless'): - if url: - print(f"正在处理 URL: {url},音质:{level}") - song_ids = ids(url) - cookies = parse_cookie(read_cookie()) - urlv1 = url_v1(song_ids, level, cookies) + cookies = cookie_manager.parse_cookie(cookie_manager.read_cookie()) + try: + song_id = ids(jsondata) + urlv1 = url_v1(song_id, level, cookies) + if not urlv1['data'] or urlv1['data'][0]['url'] is None: + return jsonify({"status": 400, 'msg': '信息获取不完整!'}), 400 namev1 = name_v1(urlv1['data'][0]['id']) lyricv1 = lyric_v1(urlv1['data'][0]['id'], cookies) + song_data = urlv1['data'][0] + song_info = namev1['songs'][0] if namev1['songs'] else {} + song_url = song_data['url'] + song_name = song_info.get('name', '') + song_picUrl = song_info.get('al', {}).get('picUrl', '') + song_alname = song_info.get('al', {}).get('name', '') + # 歌手名拼接 + artist_names = [] + for song in namev1['songs']: + ar_list = song.get('ar', []) + if ar_list: + artist_names.append('/'.join(ar['name'] for ar in ar_list)) + song_arname = ', '.join(artist_names) + # 歌词 + lyric = lyricv1.get('lrc', {}).get('lyric', '') + tlyric = lyricv1.get('tlyric', {}).get('lyric', None) + except Exception as e: + return jsonify({'status': 500, 'msg': f'服务异常: {str(e)}'}), 500 - song_name = namev1['songs'][0]['name'] - song_pic = namev1['songs'][0]['al']['picUrl'] - artist_names = ', '.join(artist['name'] for artist in namev1['songs'][0]['ar']) - album_name = namev1['songs'][0]['al']['name'] - music_quality = music_level1(urlv1['data'][0]['level']) - file_size = size(urlv1['data'][0]['size']) - music_url = urlv1['data'][0]['url'] - lyrics = lyricv1['lrc']['lyric'] - translated_lyrics = lyricv1.get('tlyric', {}).get('lyric', None) + # 响应类型 + if type_ == 'text': + data = f'歌曲名称:{song_name}
歌曲图片:{song_picUrl}
歌手:{song_arname}
歌曲专辑:{song_alname}
歌曲音质:{music_level1(song_data["level"])}
歌曲大小:{size(song_data["size"])}
音乐地址:{song_url}' + elif type_ == 'down': + data = redirect(song_url) + elif type_ == 'json': + data = { + "status": 200, + "name": song_name, + "pic": song_picUrl, + "ar_name": song_arname, + "al_name": song_alname, + "level": music_level1(song_data["level"]), + "size": size(song_data["size"]), + "url": song_url.replace("http://", "https://", 1), + "lyric": lyric, + "tlyric": tlyric + } + data = jsonify(data) + else: + data = jsonify({"status": 400, 'msg': '解析失败!请检查参数是否完整!'}), 400 + return data - output_text = f""" - 歌曲名称: {song_name} - 歌曲图片: {song_pic} - 歌手: {artist_names} - 专辑名称: {album_name} - 音质: {music_quality} - 大小: {file_size} - 音乐链接: {music_url} - 歌词: {lyrics} - 翻译歌词: {translated_lyrics if translated_lyrics else '没有翻译歌词'} - """ +@app.route('/Search', methods=['GET', 'POST']) +def search(): + if request.method == 'GET': + keywords = request.args.get('keywords') + limit = request.args.get('limit', default=10, type=int) + else: + keywords = request.form.get('keywords') + limit = int(request.form.get('limit', 10)) + if not keywords: + return jsonify({'error': '必须提供 keywords 参数'}), 400 + cookies = cookie_manager.parse_cookie(cookie_manager.read_cookie()) + try: + songs = search_music(keywords, cookies, limit=limit) + return jsonify({'status': 200, 'result': songs}) + except Exception as e: + return jsonify({'status': 500, 'msg': f'搜索异常: {str(e)}'}), 500 - print(output_text) +@app.route('/Playlist', methods=['GET', 'POST']) +def playlist(): + if request.method == 'GET': + playlist_id = request.args.get('id') + else: + playlist_id = request.form.get('id') + if not playlist_id: + return jsonify({'error': '必须提供歌单id参数'}), 400 + cookies = cookie_manager.parse_cookie(cookie_manager.read_cookie()) + try: + info = playlist_detail(playlist_id, cookies) + return jsonify({'status': 200, 'playlist': info}) + except Exception as e: + return jsonify({'status': 500, 'msg': f'歌单解析异常: {str(e)}'}), 500 + +@app.route('/Album', methods=['GET', 'POST']) +def album(): + if request.method == 'GET': + album_id = request.args.get('id') + else: + album_id = request.form.get('id') + if not album_id: + return jsonify({'error': '必须提供专辑id参数'}), 400 + cookies = cookie_manager.parse_cookie(cookie_manager.read_cookie()) + try: + info = album_detail(album_id, cookies) + return jsonify({'status': 200, 'album': info}) + except Exception as e: + return jsonify({'status': 500, 'msg': f'专辑解析异常: {str(e)}'}), 500 + +# ================= 命令行启动 ================= +def start_gui(url: str = None, level: str = 'lossless'): + if url: + print(f"正在处理 URL: {url},音质:{level}") + cookies = cookie_manager.parse_cookie(cookie_manager.read_cookie()) + try: + song_ids = ids(url) + urlv1 = url_v1(song_ids, level, cookies) + namev1 = name_v1(urlv1['data'][0]['id']) + lyricv1 = lyric_v1(urlv1['data'][0]['id'], cookies) + song_info = namev1['songs'][0] + song_name = song_info['name'] + song_pic = song_info['al']['picUrl'] + artist_names = ', '.join(artist['name'] for artist in song_info['ar']) + album_name = song_info['al']['name'] + music_quality = music_level1(urlv1['data'][0]['level']) + file_size = size(urlv1['data'][0]['size']) + music_url = urlv1['data'][0]['url'] + lyrics = lyricv1.get('lrc', {}).get('lyric', '') + translated_lyrics = lyricv1.get('tlyric', {}).get('lyric', None) + output_text = f""" + 歌曲名称: {song_name} + 歌曲图片: {song_pic} + 歌手: {artist_names} + 专辑名称: {album_name} + 音质: {music_quality} + 大小: {file_size} + 音乐链接: {music_url} + 歌词: {lyrics} + 翻译歌词: {translated_lyrics if translated_lyrics else '没有翻译歌词'} + """ + print(output_text) + except Exception as e: + print(f"发生错误: {e}") else: print("没有提供 URL 参数") def start_api(): app.run(host='0.0.0.0', port=5000, debug=False) -# 启动模式解析 if __name__ == '__main__': parser = argparse.ArgumentParser(description="启动 API 或 GUI") parser.add_argument('--mode', choices=['api', 'gui'], help="选择启动模式:api 或 gui") diff --git a/music_api.py b/music_api.py new file mode 100644 index 0000000..8406b67 --- /dev/null +++ b/music_api.py @@ -0,0 +1,213 @@ +import json +import urllib.parse +from random import randrange +import requests +from hashlib import md5 +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +def HexDigest(data): + return "".join([hex(d)[2:].zfill(2) for d in data]) + +def HashDigest(text): + HASH = md5(text.encode("utf-8")) + return HASH.digest() + +def HashHexDigest(text): + return HexDigest(HashDigest(text)) + +def post(url, params, cookie): + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/2.10.2.200154', + 'Referer': '', + } + cookies = { + "os": "pc", + "appver": "", + "osver": "", + "deviceId": "pyncm!" + } + cookies.update(cookie) + response = requests.post(url, headers=headers, cookies=cookies, data={"params": params}) + return response.text + +def url_v1(id, level, cookies): + url = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1" + AES_KEY = b"e82ckenh8dichen8" + config = { + "os": "pc", + "appver": "", + "osver": "", + "deviceId": "pyncm!", + "requestId": str(randrange(20000000, 30000000)) + } + + payload = { + 'ids': [id], + 'level': level, + 'encodeType': 'flac', + 'header': json.dumps(config), + } + + if level == 'sky': + payload['immerseType'] = 'c51' + + url2 = urllib.parse.urlparse(url).path.replace("/eapi/", "/api/") + digest = HashHexDigest(f"nobody{url2}use{json.dumps(payload)}md5forencrypt") + params = f"{url2}-36cd479b6b5-{json.dumps(payload)}-36cd479b6b5-{digest}" + padder = padding.PKCS7(algorithms.AES(AES_KEY).block_size).padder() + padded_data = padder.update(params.encode()) + padder.finalize() + cipher = Cipher(algorithms.AES(AES_KEY), modes.ECB()) + encryptor = cipher.encryptor() + enc = encryptor.update(padded_data) + encryptor.finalize() + params = HexDigest(enc) + response = post(url, params, cookies) + return json.loads(response) + +def name_v1(id): + urls = "https://interface3.music.163.com/api/v3/song/detail" + data = {'c': json.dumps([{"id":id,"v":0}])} + response = requests.post(url=urls, data=data) + return response.json() + +def lyric_v1(id, cookies): + url = "https://interface3.music.163.com/api/song/lyric" + data = {'id': id, 'cp': 'false', 'tv': '0', 'lv': '0', 'rv': '0', 'kv': '0', 'yv': '0', 'ytv': '0', 'yrv': '0'} + response = requests.post(url=url, data=data, cookies=cookies) + return response.json() + +def search_music(keywords, cookies, limit=10): + """ + 网易云音乐搜索接口,返回歌曲信息列表 + :param keywords: 搜索关键词 + :param cookies: 登录 cookies + :param limit: 返回数量 + :return: 歌曲信息列表 + """ + url = 'https://music.163.com/api/cloudsearch/pc' + data = {'s': keywords, 'type': 1, 'limit': limit} + headers = { + 'User-Agent': 'Mozilla/5.0', + 'Referer': 'https://music.163.com/' + } + response = requests.post(url, data=data, headers=headers, cookies=cookies) + result = response.json() + songs = [] + for item in result.get('result', {}).get('songs', []): + song_info = { + 'id': item['id'], + 'name': item['name'], + 'artists': '/'.join(artist['name'] for artist in item['ar']), + 'album': item['al']['name'], + 'picUrl': item['al']['picUrl'] + } + songs.append(song_info) + return songs + +def playlist_detail(playlist_id, cookies): + """ + 获取网易云歌单详情及全部歌曲列表 + :param playlist_id: 歌单ID + :param cookies: 登录 cookies + :return: 歌单基本信息和全部歌曲列表 + """ + url = f'https://music.163.com/api/v6/playlist/detail' + data = {'id': playlist_id} + headers = { + 'User-Agent': 'Mozilla/5.0', + 'Referer': 'https://music.163.com/' + } + response = requests.post(url, data=data, headers=headers, cookies=cookies) + result = response.json() + playlist = result.get('playlist', {}) + info = { + 'id': playlist.get('id'), + 'name': playlist.get('name'), + 'coverImgUrl': playlist.get('coverImgUrl'), + 'creator': playlist.get('creator', {}).get('nickname', ''), + 'trackCount': playlist.get('trackCount'), + 'description': playlist.get('description', ''), + 'tracks': [] + } + # 获取所有trackIds + track_ids = [str(t['id']) for t in playlist.get('trackIds', [])] + # 分批获取详细信息(每批最多100首) + for i in range(0, len(track_ids), 100): + batch_ids = track_ids[i:i+100] + song_detail_url = 'https://interface3.music.163.com/api/v3/song/detail' + song_data = {'c': json.dumps([{ 'id': int(sid), 'v': 0 } for sid in batch_ids])} + song_resp = requests.post(url=song_detail_url, data=song_data, headers=headers, cookies=cookies) + song_result = song_resp.json() + for song in song_result.get('songs', []): + info['tracks'].append({ + 'id': song['id'], + 'name': song['name'], + 'artists': '/'.join(artist['name'] for artist in song['ar']), + 'album': song['al']['name'], + 'picUrl': song['al']['picUrl'] + }) + return info + +def album_detail(album_id, cookies): + """ + 获取网易云专辑详情及全部歌曲列表 + :param album_id: 专辑ID + :param cookies: 登录 cookies + :return: 专辑基本信息和全部歌曲列表 + """ + url = f'https://music.163.com/api/v1/album/{album_id}' + headers = { + 'User-Agent': 'Mozilla/5.0', + 'Referer': 'https://music.163.com/' + } + response = requests.get(url, headers=headers, cookies=cookies) + result = response.json() + album = result.get('album', {}) + info = { + 'id': album.get('id'), + 'name': album.get('name'), + 'coverImgUrl': get_pic_url(album.get('pic')), + #'coverImgEncryptId': netease_encryptId(str(album.get('pic'))), + 'artist': album.get('artist', {}).get('name', ''), + 'publishTime': album.get('publishTime'), + 'description': album.get('description', ''), + 'songs': [] + } + for song in result.get('songs', []): + info['songs'].append({ + 'id': song['id'], + 'name': song['name'], + 'artists': '/'.join(artist['name'] for artist in song['ar']), + 'album': song['al']['name'], + 'picUrl': get_pic_url(song['al'].get('pic')) + }) + return info + +def netease_encryptId(id_str): + """ + 网易云加密图片ID算法(PHP移植版) + :param id_str: 歌曲/专辑/图片ID(字符串) + :return: 加密后的字符串 + """ + import base64 + magic = list('3go8&$8*3*3h0k(2)2') + song_id = list(id_str) + for i in range(len(song_id)): + song_id[i] = chr(ord(song_id[i]) ^ ord(magic[i % len(magic)])) + m = ''.join(song_id) + import hashlib + md5_bytes = hashlib.md5(m.encode('utf-8')).digest() + result = base64.b64encode(md5_bytes).decode('utf-8') + result = result.replace('/', '_').replace('+', '-') + return result + +def get_pic_url(pic_id, size=300): + """ + 获取网易云加密歌曲/专辑封面直链 + :param pic_id: 封面ID(数字或字符串) + :param size: 图片尺寸,默认300 + :return: {'url': url} + """ + enc_id = netease_encryptId(str(pic_id)) + url = f'https://p3.music.126.net/{enc_id}/{pic_id}.jpg?param={size}y{size}' + return url