Initial commit

This commit is contained in:
2025-11-05 14:22:18 +08:00
commit d7a0850171
20 changed files with 3199 additions and 0 deletions

343
main.py Normal file
View File

@@ -0,0 +1,343 @@
# --- main.py (v8.0 - The Ground-Up Rewrite) ---
# [!! 已修改 - 增加了智能错误提示 !!]
import logging, sys, time, traceback, io, zipfile, requests, re, os, tempfile, shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any
from urllib.parse import quote
from flask import Flask, request, send_file, render_template, Response, jsonify
try:
from mutagen.flac import FLAC, Picture
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, APIC
MUTAGEN_AVAILABLE = True
except ImportError:
MUTAGEN_AVAILABLE = False
try:
from music_api import NeteaseAPI, APIException, search_music, playlist_detail, album_detail
from cookie_manager import CookieManager, CookieException, CookieManager
import qr_login
from music_downloader import MusicDownloader, DownloadException
except ImportError as e:
print(f"导入模块失败: {e}\n请确保所有依赖模块存在且可用")
sys.exit(1)
@dataclass
class APIConfig:
host: str = '0.0.0.0'; port: int = 5000; debug: bool = False
log_level: str = 'INFO'; cors_origins: str = '*'
class APIResponse:
@staticmethod
def success(data: Any = None, message: str = 'success'): return jsonify({'success': True, 'message': message, 'data': data}), 200
@staticmethod
def error(message: str, status_code: int = 400): return jsonify({'success': False, 'message': message}), status_code
class MusicAPIService:
def __init__(self, config: APIConfig):
self.config = config
self.logger = self._setup_logger()
self.cookie_manager = CookieManager()
self.netease_api = NeteaseAPI()
self.downloader = MusicDownloader()
if not MUTAGEN_AVAILABLE: self.logger.warning("`mutagen` 库未安装, 打包文件将不含独立封面。请运行 `pip install mutagen`")
def _setup_logger(self):
logger = logging.getLogger('music_api'); logger.setLevel(logging.INFO)
if not logger.handlers:
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
ch = logging.StreamHandler(); ch.setFormatter(formatter); logger.addHandler(ch)
return logger
def _get_cookies_from_request(self) -> Dict[str, str]:
data = self._safe_get_request_data()
user_cookie_str = data.get('cookie')
if user_cookie_str:
self.logger.debug("正在使用前端提供的用户Cookie")
return self.cookie_manager.parse_cookie_string(user_cookie_str)
try:
self.logger.debug("未使用用户Cookie降级到服务器 cookie.txt")
return self.cookie_manager.parse_cookies()
except CookieException:
self.logger.error("读取服务器 cookie.txt 失败")
return {}
def _extract_id(self, id_or_url, id_type):
patterns = [rf'{id_type}\?id=(\d+)', rf'\/{id_type}\/(\d+)']
for p in patterns:
match = re.search(p, str(id_or_url))
if match: return match.group(1)
return str(id_or_url).strip()
def _get_quality_display_name(self, quality):
names = {'standard':"标准",'exhigh':"极高",'lossless':"无损",'hires':"Hi-Res",'sky':"环绕声",'jyeffect':"高清环绕",'jymaster':"母带"}
return names.get(quality, quality)
def _safe_get_request_data(self):
return request.get_json(silent=True) or dict(request.form) or dict(request.args)
def _create_zip_response(self, memory_file, filename):
response = Response(memory_file.getvalue(), mimetype='application/zip')
safe_filename = re.sub(r'[\\/*?:"<>|]', "", filename)
encoded_filename = quote(safe_filename)
response.headers['Content-Disposition'] = f"attachment; filename*=UTF-8''{encoded_filename}"
return response
def _embed_cover_on_disk(self, audio_path, cover_path, file_type):
if not MUTAGEN_AVAILABLE or not cover_path or not os.path.exists(cover_path): return
try:
with open(cover_path, 'rb') as f:
cover_data = f.read()
if file_type == 'flac':
audio = FLAC(audio_path)
pic = Picture(); pic.type = 3; pic.mime = "image/jpeg"; pic.data = cover_data
audio.add_picture(pic)
elif file_type == 'mp3':
audio = MP3(audio_path, ID3=ID3)
if audio.tags is None: audio.add_tags()
audio.tags.add(APIC(encoding=3, mime='image/jpeg', type=3, desc='Cover', data=cover_data))
else: return
audio.save()
except Exception as e:
self.logger.error(f"在磁盘嵌入封面失败 ({file_type}): {e}")
def _package_tracks_as_zip(self, tracks, quality, collection_name, collection_cover_url, cookies: Dict[str, str]):
temp_dir = tempfile.mkdtemp()
self.logger.info(f"创建临时目录: {temp_dir}")
zip_buffer = io.BytesIO()
try:
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
if collection_cover_url:
try: zf.writestr('cover.jpg', requests.get(collection_cover_url, timeout=10).content)
except Exception: pass
for i, track in enumerate(tracks):
song_id = track.get('id')
if not song_id: continue
self.logger.info(f"[{i+1}/{len(tracks)}] 打包中: {track.get('name')}")
try:
music_info = self.downloader.get_music_info(int(song_id), quality, cookies)
if not music_info.download_url: raise DownloadException("获取下载链接失败")
base_fn = self.downloader._sanitize_filename(f"{music_info.artists} - {music_info.name}")
audio_filename = f"{base_fn}.{music_info.file_type}"
audio_path = os.path.join(temp_dir, audio_filename)
with requests.get(music_info.download_url, stream=True, timeout=120) as r:
r.raise_for_status()
with open(audio_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
if music_info.pic_url:
cover_path = os.path.join(temp_dir, f"cover_{song_id}.jpg")
try:
with requests.get(music_info.pic_url, stream=True, timeout=20) as r:
r.raise_for_status()
with open(cover_path, 'wb') as f:
shutil.copyfileobj(r.raw, f)
self._embed_cover_on_disk(audio_path, cover_path, music_info.file_type)
except Exception as e:
self.logger.warning(f"下载或嵌入封面失败 for song {song_id}: {e}")
zf.write(audio_path, arcname=audio_filename)
if music_info.lyric:
zf.writestr(f"{base_fn}.lrc", music_info.lyric.encode('utf-8'))
except Exception as e:
# [!! 已修改 - 歌单打包时的错误提示 !!]
error_msg = f"歌曲 '{track.get('name')}' (ID: {song_id}) 处理失败: {e}"
if ("无可用的下载链接" in str(e) or "播放链接" in str(e)):
# 检查传入的 cookies 字典是否为空或没有 MUSIC_U来*猜测*是否为登录问题
# 这是一个不完美的猜测,但优于没有
if not cookies.get('MUSIC_U'):
error_msg = f"歌曲 '{track.get('name')}' (ID: {song_id}) 处理失败: 需要会员权限"
else:
error_msg = f"歌曲 '{track.get('name')}' (ID: {song_id}) 处理失败: 权限不足或Cookie过期"
self.logger.error(error_msg)
zf.writestr(f"_error_log_ID_{song_id}.txt", error_msg.encode('utf-8'))
zip_buffer.seek(0)
download_name = f"{collection_name} [{self._get_quality_display_name(quality)}].zip"
return self._create_zip_response(zip_buffer, download_name)
finally:
shutil.rmtree(temp_dir)
self.logger.info(f"删除临时目录: {temp_dir}")
config = APIConfig(); app = Flask(__name__)
api_service = MusicAPIService(config)
@app.after_request
def after_request(response: Response) -> Response:
response.headers.add('Access-Control-Allow-Origin', config.cors_origins)
response.headers.add('Access-Control-Expose-Headers', 'Content-Disposition')
response.headers.add('Access-Control-Allow-Headers', 'Content-Type')
response.headers.add('Access-Control-Allow-Methods', 'GET, POST')
return response
@app.route('/')
def index(): return render_template('index.html')
@app.route('/login/qr/generate', methods=['POST'])
def qr_generate():
try:
result = qr_login.api_generate_qr_key()
if result['success']:
return APIResponse.success(result)
else:
return APIResponse.error(result.get('message', '生成二维码失败'), 500)
except Exception as e:
return APIResponse.error(f"生成二维码异常: {e}", 500)
@app.route('/login/qr/check', methods=['POST'])
def qr_check():
data = api_service._safe_get_request_data()
qr_key = data.get('qr_key')
if not qr_key:
return APIResponse.error("参数 'qr_key' 不能为空")
try:
result = qr_login.api_check_qr_status(qr_key)
return APIResponse.success(result)
except Exception as e:
return APIResponse.error(f"检查状态异常: {e}", 500)
@app.route('/song', methods=['POST'])
def get_song_info():
data = api_service._safe_get_request_data(); url = data.get('url'); level = data.get('level', 'lossless')
# [!! 已修改 !!] 检查用户是否*发送*了cookie
user_was_logged_in = bool(data.get('cookie'))
if not url: return APIResponse.error("参数 'url' 不能为空")
music_id = api_service._extract_id(url, 'song')
try:
cookies = api_service._get_cookies_from_request()
music_info = api_service.downloader.get_music_info(int(music_id), level, cookies)
response_data = {
'id': music_id, 'name': music_info.name, 'ar_name': music_info.artists,
'al_name': music_info.album, 'pic': music_info.pic_url, 'lyric': music_info.lyric,
'url': music_info.download_url,
'size': f"{music_info.file_size/1024/1024:.2f} MB" if music_info.file_size > 0 else "N/A",
'level': api_service._get_quality_display_name(level),
'file_type': music_info.file_type
}
return APIResponse.success(response_data)
except Exception as e:
# [!! 已修改 - 智能错误处理 !!]
error_msg = str(e)
if "无可用的下载链接" in error_msg or "播放链接" in error_msg:
if not user_was_logged_in:
error_msg = "解析失败:此音质或歌曲需要会员权限。请点击右上角登录黑胶会员账号后再试。"
else:
error_msg = "解析失败您的账号可能不是黑胶会员Cookie已过期或歌曲已下架。"
return APIResponse.error(error_msg, 502)
@app.route('/search', methods=['POST'])
def search_music_api():
data = api_service._safe_get_request_data(); keyword = data.get('keyword')
if not keyword: return APIResponse.error("参数 'keyword' 不能为空")
try:
cookies = api_service._get_cookies_from_request()
return APIResponse.success(search_music(keyword, cookies, limit=30))
except APIException as e: return APIResponse.error(str(e), 502)
@app.route('/playlist', methods=['POST'])
def get_playlist():
data = api_service._safe_get_request_data(); pid = data.get('id')
if not pid: return APIResponse.error("参数 'id' 不能为空")
playlist_id = api_service._extract_id(pid, 'playlist')
try:
cookies = api_service._get_cookies_from_request()
return APIResponse.success({'playlist': playlist_detail(playlist_id, cookies)})
except APIException as e: return APIResponse.error(str(e), 502)
@app.route('/album', methods=['POST'])
def get_album():
data = api_service._safe_get_request_data(); aid = data.get('id')
if not aid: return APIResponse.error("参数 'id' 不能为空")
album_id = api_service._extract_id(aid, 'album')
try:
cookies = api_service._get_cookies_from_request()
return APIResponse.success({'album': album_detail(album_id, cookies)})
except APIException as e: return APIResponse.error(str(e), 502)
@app.route('/download_song_zip', methods=['POST'])
def download_song_zip():
data = api_service._safe_get_request_data()
song_id = data.get('id'); quality = data.get('quality', 'lossless')
# [!! 已修改 !!] 检查用户是否*发送*了cookie
user_was_logged_in = bool(data.get('cookie'))
if not song_id: return APIResponse.error("参数 'id' 不能为空", 400)
try:
cookies = api_service._get_cookies_from_request()
music_info = api_service.downloader.get_music_info(int(song_id), quality, cookies)
song_as_track = [{'id': song_id, 'name': music_info.name}]
return api_service._package_tracks_as_zip(song_as_track, quality, music_info.name, None, cookies)
except Exception as e:
api_service.logger.error(f"打包单曲异常: {e}\n{traceback.format_exc}")
# [!! 已修改 - 智能错误处理 !!]
error_msg = str(e)
if "无可用的下载链接" in error_msg or "播放链接" in error_msg:
if not user_was_logged_in:
error_msg = "打包失败:此音质或歌曲需要会员权限。请点击右上角登录黑胶会员账号后再试。"
else:
error_msg = "打包失败您的账号可能不是黑胶会员Cookie已过期或歌曲已下架。"
else:
error_msg = f"打包失败: {str(e)}"
return APIResponse.error(error_msg, 500)
@app.route('/download_playlist_zip', methods=['POST'])
def download_playlist_zip():
data = api_service._safe_get_request_data(); pid = data.get('id'); quality = data.get('quality', 'lossless')
if not pid: return APIResponse.error("参数 'id' 不能为空", 400)
playlist_id = api_service._extract_id(pid, 'playlist')
try:
cookies = api_service._get_cookies_from_request()
playlist_info = playlist_detail(playlist_id, cookies)
if not playlist_info or not playlist_info.get('tracks'): return APIResponse.error("歌单信息获取失败或歌单为空", 404)
return api_service._package_tracks_as_zip(
playlist_info['tracks'], quality, playlist_info.get('name'),
playlist_info.get('coverImgUrl'), cookies
)
except Exception as e:
api_service.logger.error(f"打包歌单异常: {e}\n{traceback.format_exc()}"); return APIResponse.error(f"打包失败: {str(e)}", 500)
@app.route('/download_album_zip', methods=['POST'])
def download_album_zip():
data = api_service._safe_get_request_data(); aid = data.get('id'); quality = data.get('quality', 'lossless')
if not aid: return APIResponse.error("参数 'id' 不能为空", 400)
album_id = api_service._extract_id(aid, 'album')
try:
cookies = api_service._get_cookies_from_request()
album_info = album_detail(album_id, cookies)
if not album_info or not album_info.get('songs'): return APIResponse.error("专辑信息获取失败或专辑为空", 404)
return api_service._package_tracks_as_zip(
album_info['songs'], quality, album_info.get('name'),
album_info.get('coverImgUrl'), cookies
)
except Exception as e:
api_service.logger.error(f"打包专辑异常: {e}\n{traceback.format_exc()}"); return APIResponse.error(f"打包失败: {str(e)}", 500)
if __name__ == '__main__':
print("\n" + "="*60); print("🚀 网易云音乐工具箱 (v8.1 - 重构稳定版) 启动中...");
print(" [!!] 已集成用户Cookie和QR登录API");
print(f"📡 服务地址: http://{config.host}:{config.port}"); print("="*60)
app.run(host=config.host, port=config.port, debug=config.debug)