refactor(async): music_api 异步化 + 全项目 lint 收敛

music_api.py:
- 22 个方法改 async(HTTPClient.post_request*/NeteaseAPI.*/QRLoginManager.*),
  33 处 requests.* → await core.aiohttp_compat.*
- 16 个模块级同步包装改走 run_sync 桥(main.py 等 99 个同步调用点零改动)
- async 内 time.sleep → asyncio.sleep(修 7 处阻塞事件循环)

musica_downloader/qr_login:
- 单曲三请求 ThreadPoolExecutor → asyncio.gather(语义等价,失败处理保留)
- interactive_login 改 async,CLI 入口用 asyncio.run

真实缺陷修复(lint 发现):
- podcast_api._to_int 未定义(2 处调用会 NameError)→ 补安全转换 helper
- main.py VALID_QUALITY_LEVELS 重复定义(覆盖 core.config 导入值)→ 移除
- 移除死代码变量(salt×2/device_id×3/file_path/zipped_files)

lint 收敛:524→0(ruff All checks passed);pyright 0 errors。
pyproject 记录「有意保留」项(B904/RUF012/S305 上游协议等)及理由。

验证:冒烟 10/10;pytest 16/16;真实上游 get_toplist 异步直调与桥接均得 63 条。
This commit is contained in:
snowy-yang committed 2026-09-26 00:32:45 +08:00
1 parent 4962542e78
commit ce7ea66d6a
8 files changed
+520 -490

No files matched your search

+169 -149
View File
@@ -9,16 +9,13 @@
- 健康检查
"""
import base64
import json
import collections
import hashlib
import hmac
import json
import logging
import os
import re
import requests
import secrets
import shutil
import sys
import threading
@@ -26,27 +23,45 @@ import time
import traceback
import uuid
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, List
from urllib.parse import quote, urlsplit, parse_qs
from flask import Flask, request, send_file, render_template, Response, jsonify, stream_with_context, make_response, g
from typing import Any
from urllib.parse import parse_qs, quote, urlsplit
import requests
from flask import Flask, Response, g, make_response, render_template, request, send_file, stream_with_context
try:
from music_api import (
NeteaseAPI, APIException, QualityLevel,
url_v1, name_v1, lyric_v1, search_music,
playlist_detail, album_detail,
toplist_list, artist_songs, song_comments,
account_info, user_playlists, detect_vip_by_probe,
playlist_subscribe, album_subscribe, post_comment,
load_cookies,
)
from music_downloader import MusicDownloader, DownloadException, DownloadCanceled, CountingSemaphore, fetch_cover_bytes
from qr_login import QRLoginClient
import podcast_api
import auth_system
import podcast_api
from auth_routes import register_auth_routes
from music_api import (
APIException,
NeteaseAPI,
account_info,
album_detail,
album_subscribe,
artist_songs,
detect_vip_by_probe,
load_cookies,
lyric_v1,
name_v1,
playlist_detail,
playlist_subscribe,
post_comment,
search_music,
song_comments,
toplist_list,
url_v1,
user_playlists,
)
from music_downloader import (
CountingSemaphore,
DownloadCanceled,
DownloadException,
MusicDownloader,
fetch_cover_bytes,
)
from qr_login import QRLoginClient
except ImportError as e:
# 坑 11:导入失败发生在 _safe_console_print 定义之前,用纯 ASCII 保证任何编码下可打印
print("ImportError: " + str(e))
@@ -58,35 +73,46 @@ except ImportError as e:
# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现**
# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。
from core.config import (
APP_VERSION, RESULT_RETENTION_SECONDS, Settings,
APP_VERSION,
BATCH_ZIP_CONCURRENCY,
CANCEL_AFTER_SECONDS,
HEARTBEAT_INTERVAL,
MAX_ACTIVE_TASKS_PER_USER,
MAX_TERMINAL_TASKS,
MAX_TOTAL_TASKS,
PAUSE_AFTER_SECONDS,
RESULT_RETENTION_SECONDS,
USER_COOKIE_FILE,
PAUSE_AFTER_SECONDS, CANCEL_AFTER_SECONDS, HEARTBEAT_INTERVAL,
MAX_ACTIVE_TASKS_PER_USER, MAX_TOTAL_TASKS, MAX_TERMINAL_TASKS,
BATCH_ZIP_CONCURRENCY, VALID_QUALITY_LEVELS,
VALID_QUALITY_LEVELS,
Settings,
)
from core.security import init_secret_key as _init_audio_secret
from core.security import protect_audio_urls as _protect_audio_urls
from core.security import verify_audio_token as _verify_audio_token
from core.state import (
RATE_BUCKETS as _RATE_BUCKETS, RATE_LOCK as _RATE_LOCK,
BATCH_DURATIONS, BATCH_DURATIONS_LOCK,
DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK,
DEVICE_LAST_SEEN, DEVICE_LAST_SEEN_LOCK,
REQUEST_STATS, STATS_LOCK as _STATS_LOCK, REQ_LOG,
GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE,
VIP_CACHE as _VIP_CACHE, VIP_USER_CACHE as _VIP_USER_CACHE,
SERVER_NICK_CACHE as _SERVER_NICK_CACHE, SERVER_NICK_LOCK as _SERVER_NICK_LOCK,
VIP_PROBE_LOCK as _VIP_PROBE_LOCK,
)
from core.utils import (
safe_int as _safe_int, safe_console_print as _safe_console_print,
srt_time as _srt_time, format_lyric as _format_lyric,
parse_meta_fields as _parse_meta_fields, expand_shortlink as _expand_shortlink,
)
from core.security import (
init_secret_key as _init_audio_secret,
is_audio_url as _is_audio_url,
make_audio_token as _make_audio_token,
verify_audio_token as _verify_audio_token,
protect_audio_urls as _protect_audio_urls,
BATCH_DURATIONS,
BATCH_DURATIONS_LOCK,
DEVICE_LAST_SEEN,
DEVICE_LAST_SEEN_LOCK,
DOWNLOAD_TASKS,
DOWNLOAD_TASKS_LOCK,
REQ_LOG,
REQUEST_STATS,
)
from core.state import GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE
from core.state import RATE_BUCKETS as _RATE_BUCKETS
from core.state import RATE_LOCK as _RATE_LOCK
from core.state import SERVER_NICK_CACHE as _SERVER_NICK_CACHE
from core.state import SERVER_NICK_LOCK as _SERVER_NICK_LOCK
from core.state import STATS_LOCK as _STATS_LOCK
from core.state import VIP_CACHE as _VIP_CACHE
from core.state import VIP_PROBE_LOCK as _VIP_PROBE_LOCK
from core.state import VIP_USER_CACHE as _VIP_USER_CACHE
from core.utils import expand_shortlink as _expand_shortlink
from core.utils import format_lyric as _format_lyric
from core.utils import parse_meta_fields as _parse_meta_fields
from core.utils import safe_console_print as _safe_console_print
from core.utils import safe_int as _safe_int
# 向后兼容别名:既有代码/文档沿用 APIConfig 名称
APIConfig = Settings
@@ -94,9 +120,9 @@ APIConfig = Settings
class APIResponse:
"""API响应工具类"""
@staticmethod
def success(data: Any = None, message: str = 'success', status_code: int = 200) -> Tuple[Dict[str, Any], int]:
def success(data: Any = None, message: str = 'success', status_code: int = 200) -> tuple[dict[str, Any], int]:
"""成功响应"""
response = {
'status': status_code,
@@ -106,9 +132,9 @@ class APIResponse:
if data is not None:
response['data'] = data
return response, status_code
@staticmethod
def error(message: str, status_code: int = 400, error_code: str = None) -> Tuple[Dict[str, Any], int]:
def error(message: str, status_code: int = 400, error_code: str | None = None) -> tuple[dict[str, Any], int]:
"""错误响应"""
response = {
'status': status_code,
@@ -122,7 +148,7 @@ class APIResponse:
class MusicAPIService:
"""音乐API服务类"""
def __init__(self, config: APIConfig):
self.config = config
self.logger = self._setup_logger()
@@ -136,12 +162,12 @@ class MusicAPIService:
self.downloads_path.mkdir(exist_ok=True)
self.logger.info(f"音乐API服务初始化完成,下载目录: {self.downloads_path.absolute()}")
def _setup_logger(self) -> logging.Logger:
"""设置日志记录器"""
logger = logging.getLogger('music_api')
logger.setLevel(getattr(logging, self.config.log_level.upper()))
if not logger.handlers:
# 控制台处理器
console_handler = logging.StreamHandler()
@@ -150,7 +176,7 @@ class MusicAPIService:
)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# 文件处理器(轮转:单文件 5MB,保留 3 份,防止长期运行无限膨胀)
try:
from logging.handlers import RotatingFileHandler
@@ -163,10 +189,10 @@ class MusicAPIService:
logger.addHandler(file_handler)
except Exception as e:
logger.warning(f"无法创建日志文件: {e}")
return logger
def _get_cookies(self) -> Dict[str, str]:
def _get_cookies(self) -> dict[str, str]:
"""直接从 cookie.txt 读取并解析为 dict"""
try:
cookies = load_cookies()
@@ -176,7 +202,7 @@ class MusicAPIService:
except Exception as e:
self.logger.error(f"读取 cookie.txt 异常: {e}")
return {}
def _extract_music_id(self, id_or_url: str) -> str:
"""提取音乐ID(F7/F74:正规 URL 解析替代子串/删除非数字启发式)
- 短链仅当整串就是一个 163cn.tv 短链 URL 时才请求展开(防 SSRF)
@@ -207,7 +233,7 @@ class MusicAPIService:
except Exception as e:
self.logger.error(f"提取音乐ID失败: {e}")
return str(id_or_url or '').strip()
def _format_file_size(self, size_bytes) -> str:
"""格式化文件大小(上游 size 可能为 null/垃圾值:容错返回"大小未知"而非 500)"""
try:
@@ -225,12 +251,12 @@ class MusicAPIService:
unit_index += 1
return f"{size:.2f}{units[unit_index]}"
def _get_quality_display_name(self, quality: str) -> str:
"""获取音质显示名称"""
quality_names = {
'standard': "标准音质",
'exhigh': "极高音质",
'exhigh': "极高音质",
'lossless': "无损音质",
'hires': "Hi-Res音质",
'sky': "沉浸环绕声",
@@ -239,15 +265,15 @@ class MusicAPIService:
'dolby': "杜比全景声"
}
return quality_names.get(quality, f"未知音质({quality})")
def _validate_request_params(self, required_params: Dict[str, Any]) -> Optional[Tuple[Dict[str, Any], int]]:
def _validate_request_params(self, required_params: dict[str, Any]) -> tuple[dict[str, Any], int] | None:
"""验证请求参数"""
for param_name, param_value in required_params.items():
if not param_value:
return APIResponse.error(f"参数 '{param_name}' 不能为空", 400)
return None
def _safe_get_request_data(self) -> Dict[str, Any]:
def _safe_get_request_data(self) -> dict[str, Any]:
"""安全获取请求数据"""
try:
if request.method == 'GET':
@@ -541,7 +567,6 @@ def api_task_cancel():
try:
data = api_service._safe_get_request_data()
task_id = str(data.get('task_id') or '').strip()
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
if not task_id:
return APIResponse.error("缺少 task_id 参数")
with DOWNLOAD_TASKS_LOCK:
@@ -685,7 +710,7 @@ BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY)
# USER_COOKIE_FILE → core.config
def _register_download_task(task: Dict[str, Any], reserved: int, task_id_hint: str = '') -> Optional[str]:
def _register_download_task(task: dict[str, Any], reserved: int, task_id_hint: str = '') -> str | None:
"""任务登记统一入口:归属校验、数量上限、账本承接。失败返回错误描述(str),成功返回 None。
- F05:任务必须绑定站点用户(提交接口都有 login_required,这里再取一次会话)
@@ -759,7 +784,7 @@ def _register_download_task(task: Dict[str, Any], reserved: int, task_id_hint: s
return None
def _start_task_thread(task_id: str, target, args=()) -> Optional[str]:
def _start_task_thread(task_id: str, target, args=()) -> str | None:
"""启动任务 worker(F90:启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。
A2:退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。
R5-14:线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死
@@ -784,7 +809,7 @@ def _start_task_thread(task_id: str, target, args=()) -> Optional[str]:
return f'任务启动失败:{e}'
def _task_owned_by_current_user(task: Dict[str, Any]) -> bool:
def _task_owned_by_current_user(task: dict[str, Any]) -> bool:
"""任务归属校验(F05:站点用户是任务的第一归属,device_id 仅作辅助)"""
user = auth_system.current_user()
if not user:
@@ -823,7 +848,7 @@ def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None:
pass
def _task_files(task: Dict[str, Any]) -> List[str]:
def _task_files(task: dict[str, Any]) -> list[str]:
"""任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)"""
result = task.get('result') or {}
files = []
@@ -875,9 +900,9 @@ def _task_monitor_loop() -> None:
# ---- 终态任务清理(F38/F106:按完成时刻;删除失败保留记录下轮重试,最多 8 次)----
# 第 91 条(P2-1):锁内只收集过期清单,文件删除移到锁外——GB 级任务目录
# 的删除曾阻塞锁数十秒,期间全站进度上报/提交/取消/领取全部停摆
finished_done: List[str] = []
finished_retry: List[str] = []
expired: List[tuple] = []
finished_done: list[str] = []
finished_retry: list[str] = []
expired: list[tuple] = []
with DOWNLOAD_TASKS_LOCK:
for tid, task in DOWNLOAD_TASKS.items():
if task.get('status') not in ('done', 'error', 'canceled'):
@@ -890,7 +915,7 @@ def _task_monitor_loop() -> None:
continue
expired.append((tid, _task_files(task),
api_service.downloads_path / 'tasks' / tid))
cleanup_results: List[tuple] = []
cleanup_results: list[tuple] = []
for tid, files, task_dir in expired:
all_removed = True
for f in files:
@@ -929,7 +954,7 @@ def _task_monitor_loop() -> None:
if finished_retry:
api_service.logger.info(f"监控线程保留清理失败任务待重试: {finished_retry}")
# ---- 长时间未结束的任务:置取消(含 cancel Event,唤醒可能的阻塞等待)----
timeout_settle: List[str] = []
timeout_settle: list[str] = []
with DOWNLOAD_TASKS_LOCK:
for tid, task in DOWNLOAD_TASKS.items():
if (task.get('status') in ('queued', 'running', 'paused')
@@ -1118,9 +1143,9 @@ def _record_log(action: str, ip: str, song: str) -> None:
del REQ_LOG[:len(REQ_LOG) - 2000]
def _parse_cookie_str(raw) -> Dict[str, str]:
def _parse_cookie_str(raw) -> dict[str, str]:
"""将 'k=v;k2=v2' 格式的 Cookie 字符串解析为字典"""
cookies: Dict[str, str] = {}
cookies: dict[str, str] = {}
if not raw:
return cookies
for part in str(raw).split(';'):
@@ -1154,7 +1179,7 @@ def _global_music_u_cache_reset() -> None:
_GLOBAL_MUSIC_U_CACHE['ts'] = 0.0
def _resolve_user_cookies(data: Dict[str, Any]) -> Dict[str, str]:
def _resolve_user_cookies(data: dict[str, Any]) -> dict[str, str]:
"""解析个人账号 Cookie:仅使用浏览器传入的 user_cookie(不落服务器,杜绝跨设备访问他人账号)。
第 92 条(owner 拍板):全局账号(cookie.txt)在发评/收藏/下载等个人身份操作上
零权限、零功能、零入口——浏览器提交的 MUSIC_U 若与全局账号一致(全局 Cookie
@@ -1177,7 +1202,7 @@ def _resolve_user_cookies(data: Dict[str, Any]) -> Dict[str, str]:
return ck
def _build_download_base(filename_format: str, music_info: Dict[str, Any]) -> str:
def _build_download_base(filename_format: str, music_info: dict[str, Any]) -> str:
"""按文件名格式生成下载基础名(不含扩展名)"""
name = music_info.get('name', '未知歌曲')
artist = music_info.get('artist_string', '') or '未知歌手'
@@ -1199,7 +1224,7 @@ def _build_download_base(filename_format: str, music_info: Dict[str, Any]) -> st
# _srt_time / _format_lyric / _parse_meta_fields 已迁出至 core.utils(此处 import 同名共享)
def _verify_admin_password(data) -> Optional[Tuple[Dict[str, Any], int]]:
def _verify_admin_password(data) -> tuple[dict[str, Any], int] | None:
"""校验管理员权限:仅站点管理员会话(旧版口令已废除,2026-09-15)"""
_user = auth_system.current_user()
if _user is not None and _user['role'] == 'admin':
@@ -1219,7 +1244,7 @@ def _vip_name(vip_type: int) -> str:
}.get(vip_type, '普通用户')
def _detect_vip(cookies: Dict[str, str]) -> Tuple[int, str, List[str]]:
def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]:
"""检测账号会员类型与可解析音质:
优先账号接口(vipType 精确),并始终用音质探测接口实测可解析列表。
第 100 条:探测单飞锁(有界等待 0.5s)——上游黑洞时并发请求不再各自串行
@@ -1488,7 +1513,7 @@ def health_check():
}
return APIResponse.success(health_info, "API服务运行正常")
except Exception as e:
api_service.logger.error(f"健康检查失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -1506,23 +1531,23 @@ def get_song_info():
url = data.get('url')
level = data.get('level', 'lossless')
info_type = data.get('type', 'url')
# 参数验证
if not song_ids and not url:
return APIResponse.error("必须提供 'ids'、'id' 或 'url' 参数")
# 提取音乐ID
music_id = api_service._extract_music_id(song_ids or url)
# P2-2:ID 白名单(与 /album、/artist_songs、/comments 同口径——
# 垃圾输入不再透传上游并把上游报错原文回显给公网)
if not str(music_id).strip().isdigit():
return APIResponse.error("无效的歌曲ID,请输入数字ID或分享链接")
# 验证音质参数
valid_levels = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby']
if level not in valid_levels:
return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_levels)}")
# 验证类型参数
valid_types = ['url', 'name', 'lyric', 'json']
if info_type not in valid_types:
@@ -1536,7 +1561,7 @@ def get_song_info():
return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401)
else:
cookies = api_service._get_cookies()
# 根据类型获取不同信息
if info_type == 'url':
result = url_v1(music_id, level, cookies)
@@ -1559,15 +1584,15 @@ def get_song_info():
return APIResponse.success(response_data, "获取歌曲URL成功")
else:
return APIResponse.error("获取音乐URL失败,可能是版权限制或音质不支持", 404)
elif info_type == 'name':
result = name_v1(music_id)
return APIResponse.success(result, "获取歌曲信息成功")
elif info_type == 'lyric':
result = lyric_v1(music_id, cookies)
return APIResponse.success(result, "获取歌词成功")
elif info_type == 'json':
# 获取完整的歌曲信息(用于前端解析)
song_info = name_v1(music_id)
@@ -1576,10 +1601,10 @@ def get_song_info():
lyric_info = lyric_v1(music_id, cookies)
except Exception:
lyric_info = None # 歌词接口抖动不阻断解析
if not song_info or 'songs' not in song_info or not song_info['songs']:
return APIResponse.error("未找到歌曲信息", 404)
song_data = song_info['songs'][0]
# S1/F94:可选字段为 null(而非缺失)时同样容错,不让有效歌曲解析失败
@@ -1598,7 +1623,7 @@ def get_song_info():
'tlyric': ((lyric_info or {}).get('tlyric') or {}).get('lyric') or '',
'yrc': ((lyric_info or {}).get('yrc') or {}).get('lyric') or ''
}
# 添加URL和大小信息
if url_info and url_info.get('data') and len(url_info['data']) > 0:
url_data = url_info['data'][0]
@@ -1612,7 +1637,7 @@ def get_song_info():
'url': '',
'size': '获取失败'
})
# 记录用户解析操作(IP + 歌曲名)
try:
_record_log('解析', auth_system._client_ip(), response_data.get('name') or str(music_id))
@@ -1620,7 +1645,7 @@ def get_song_info():
pass
return APIResponse.success(response_data, "获取歌曲信息成功")
except APIException as e:
api_service.logger.error(f"API调用失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -1641,20 +1666,20 @@ def search_music_api():
limit = max(1, _safe_int(data.get('limit'), 30))
offset = max(0, _safe_int(data.get('offset'), 0))
search_type = data.get('type', '1') # 1-歌曲, 10-专辑, 100-歌手, 1000-歌单
# 参数验证
validation_error = api_service._validate_request_params({'keyword': keyword})
if validation_error:
return validation_error
# 限制搜索数量
if limit > 100:
limit = 100
cookies = api_service._get_cookies()
# F45:offset 透传给上游,翻页不再永远从第一页开始
result = search_music(keyword, cookies, limit, int(search_type), offset=offset)
# 向后兼容:歌曲搜索结果补充 artist_string 字段;歌单结果遮蔽服务器账号昵称
if result:
for song in result:
@@ -1665,11 +1690,11 @@ def search_music_api():
song['creator'] = _mask_server_nickname(song['creator'])
if song.get('name'):
song['name'] = _mask_server_nickname(song['name'])
return APIResponse.success(result, "搜索完成")
except ValueError as e:
return APIResponse.error(f"参数格式错误: {str(e)}")
return APIResponse.error(f"参数格式错误: {e!s}")
except Exception as e:
api_service.logger.error(f"搜索音乐异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -1684,7 +1709,7 @@ def get_playlist():
# 获取请求参数
data = api_service._safe_get_request_data()
playlist_id = data.get('id')
# 参数验证
validation_error = api_service._validate_request_params({'playlist_id': playlist_id})
if validation_error:
@@ -1709,9 +1734,9 @@ def get_playlist():
'status': 'success',
'playlist': result
}
return APIResponse.success(response_data, "获取歌单详情成功")
except Exception as e:
api_service.logger.error(f"获取歌单异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -1740,15 +1765,15 @@ def get_album():
cookies = api_service._get_cookies()
result = album_detail(album_id, cookies)
# 适配前端期望的响应格式
response_data = {
'status': 200,
'album': result
}
return APIResponse.success(response_data, "获取专辑详情成功")
except Exception as e:
api_service.logger.error(f"获取专辑异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -2219,8 +2244,8 @@ def _podcast_stream_download(url: str, target: Path, total_size: int,
return done
def _podcast_resolve_audio(program: Dict[str, Any], quality: str,
cookies: Dict[str, str]) -> Dict[str, Any]:
def _podcast_resolve_audio(program: dict[str, Any], quality: str,
cookies: dict[str, str]) -> dict[str, Any]:
"""解析单集音频直链,失败抛 DownloadException"""
song_id = program.get('main_song_id')
if not song_id:
@@ -2255,7 +2280,7 @@ def get_song_comments_api():
result = song_comments(song_id, limit, offset)
return APIResponse.success(result, "获取评论成功")
except ValueError as e:
return APIResponse.error(f"参数格式错误: {str(e)}")
return APIResponse.error(f"参数格式错误: {e!s}")
except APIException as e:
api_service.logger.error(f"获取评论失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -2687,8 +2712,7 @@ def user_comment():
# ============ 全局账号歌单(管理员 cookie.txt,无需游客扫码) ============
VALID_QUALITY_LEVELS = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby']
# VALID_QUALITY_LEVELS 已迁至 core.config(顶部 import 同名共享,此处不再重复定义)
@app.route('/global/playlists', methods=['GET', 'POST'])
@@ -2942,7 +2966,7 @@ def download_music_api():
music_id = data.get('id')
quality = data.get('quality', 'lossless')
return_format = data.get('format', 'file') # file 或 json
# 参数验证
validation_error = api_service._validate_request_params({'music_id': music_id})
if validation_error:
@@ -2951,16 +2975,16 @@ def download_music_api():
music_id = str(music_id).strip()
if not music_id.isdigit():
return APIResponse.error("无效的歌曲 ID")
# 验证音质参数
valid_qualities = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby']
if quality not in valid_qualities:
return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_qualities)}")
# 验证返回格式
if return_format not in ['file', 'json']:
return APIResponse.error("返回格式只支持 'file' 或 'json'")
music_id = api_service._extract_music_id(music_id)
# 解析账号选择:user=游客个人账号,默认 admin=全局管理员账号
@@ -3007,7 +3031,6 @@ def download_music_api():
# R5-P3:对外泛化(error_message 可能含上游细节),原文留日志
api_service.logger.error(f"下载失败: {download_result.error_message}")
return APIResponse.error("下载失败,请稍后重试或更换音质", 500)
file_path = Path(download_result.file_path)
api_service.logger.info(f"下载完成: {filename}")
except DownloadException as e:
api_service.logger.error(f"下载异常: {e}")
@@ -3265,10 +3288,9 @@ def _batch_zip_worker(task_id: str) -> None:
import tempfile as _tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
zip_tmp_path: Optional[str] = None
zipped_files: List[str] = []
zip_tmp_path: str | None = None
ok_count = 0
failed: List[str] = []
failed: list[str] = []
used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖)
info_pool = None
info_futures = {}
@@ -3562,7 +3584,7 @@ def _batch_zip_worker(task_id: str) -> None:
refunded = _settle_batch_billing(task_id, delivered, 'error')
if delivered > 0 and zip_tmp_path:
_mark_task_terminal(task_id, 'error',
f"部分歌曲已打包完成,可领取" +
"部分歌曲已打包完成,可领取" +
(f",退回 {refunded} 次" if refunded else ''))
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
@@ -3603,9 +3625,9 @@ def _podcast_download_worker(task_id: str) -> None:
"""
import tempfile as _tempfile
file_path_str: Optional[str] = None
zip_tmp_path: Optional[str] = None
task_dir: Optional[Path] = None
file_path_str: str | None = None
zip_tmp_path: str | None = None
task_dir: Path | None = None
try:
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id]
@@ -3643,7 +3665,7 @@ def _podcast_download_worker(task_id: str) -> None:
raise DownloadCanceled("任务已取消")
file_path_str = str(target)
result: Dict[str, Any]
result: dict[str, Any]
if need_zip:
fd, zip_tmp_path = _tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip',
dir=str(task_dir))
@@ -3737,7 +3759,7 @@ def _podcast_download_worker(task_id: str) -> None:
shutil.rmtree(str(task_dir), ignore_errors=True)
def _podcast_cleanup_files(paths: List[Optional[str]]) -> None:
def _podcast_cleanup_files(paths: list[str | None]) -> None:
"""清理播客任务产生的临时/残留文件"""
for p in paths:
if not p:
@@ -3759,9 +3781,9 @@ def _podcast_zip_worker(task_id: str) -> None:
"""整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)"""
import tempfile as _tempfile
zip_tmp_path: Optional[str] = None
zip_tmp_path: str | None = None
ok_count = 0
failed: List[str] = []
failed: list[str] = []
# 第 97 条(P1-1):zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等
# 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程
zip_valid = False
@@ -4064,7 +4086,6 @@ def download_batch_zip_result():
try:
data = api_service._safe_get_request_data()
task_id = str(data.get('task_id') or '').strip()
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
if not task_id:
return APIResponse.error("缺少 task_id 参数")
with DOWNLOAD_TASKS_LOCK:
@@ -4127,9 +4148,9 @@ def _single_download_worker(task_id: str) -> None:
"""后台单曲下载:下载音频(可选 ZIP 打包),支持取消;完成后由 /download/result 交付,35 分钟保留期清理"""
import tempfile as _tempfile
file_path_str: Optional[str] = None
zip_tmp_path: Optional[str] = None
task_dir_path: Optional[str] = None
file_path_str: str | None = None
zip_tmp_path: str | None = None
task_dir_path: str | None = None
try:
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id]
@@ -4308,7 +4329,6 @@ def download_single_result():
try:
data = api_service._safe_get_request_data()
task_id = str(data.get('task_id') or '').strip()
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
if not task_id:
return APIResponse.error("缺少 task_id 参数")
with DOWNLOAD_TASKS_LOCK:
@@ -4384,9 +4404,9 @@ def api_info():
'max_body_bytes': '2MB(备份上传 200MB)'
}
}
return APIResponse.success(info, "API信息获取成功")
except Exception as e:
api_service.logger.error(f"获取API信息异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -4437,21 +4457,21 @@ def start_api_server():
_safe_console_print(f"📁 下载目录: {api_service.downloads_path.absolute()}")
_safe_console_print(f"📋 日志级别: {config.log_level}")
_safe_console_print("\n📚 API端点:")
_safe_console_print(f" ├─ GET /health - 健康检查")
_safe_console_print(f" ├─ POST /search - 搜索音乐(支持歌曲/歌单/专辑/歌手)")
_safe_console_print(f" ├─ POST /song - 获取歌曲信息")
_safe_console_print(f" ├─ POST /playlist - 获取歌单详情")
_safe_console_print(f" ├─ POST /album - 获取专辑详情")
_safe_console_print(f" ├─ POST /toplist - 获取官方榜单列表")
_safe_console_print(f" ├─ POST /artist_songs - 获取歌手信息与热门歌曲")
_safe_console_print(f" ├─ POST /comments - 获取歌曲评论")
_safe_console_print(f" ├─ POST /download - 下载音乐")
_safe_console_print(f" ├─ GET/POST /global/playlists - 全局账号歌单列表")
_safe_console_print(f" ├─ GET/POST /global/playlist - 全局账号歌单详情")
_safe_console_print(f" ├─ GET/POST /api/playlist/play - 公开歌单播放API")
_safe_console_print(f" └─ GET /api/info - API信息")
_safe_console_print(" ├─ GET /health - 健康检查")
_safe_console_print(" ├─ POST /search - 搜索音乐(支持歌曲/歌单/专辑/歌手)")
_safe_console_print(" ├─ POST /song - 获取歌曲信息")
_safe_console_print(" ├─ POST /playlist - 获取歌单详情")
_safe_console_print(" ├─ POST /album - 获取专辑详情")
_safe_console_print(" ├─ POST /toplist - 获取官方榜单列表")
_safe_console_print(" ├─ POST /artist_songs - 获取歌手信息与热门歌曲")
_safe_console_print(" ├─ POST /comments - 获取歌曲评论")
_safe_console_print(" ├─ POST /download - 下载音乐")
_safe_console_print(" ├─ GET/POST /global/playlists - 全局账号歌单列表")
_safe_console_print(" ├─ GET/POST /global/playlist - 全局账号歌单详情")
_safe_console_print(" ├─ GET/POST /api/playlist/play - 公开歌单播放API")
_safe_console_print(" └─ GET /api/info - API信息")
_safe_console_print("\n🎵 支持的音质:")
_safe_console_print(f" standard, exhigh, lossless, hires, sky, jyeffect, jymaster, dolby")
_safe_console_print(" standard, exhigh, lossless, hires, sky, jyeffect, jymaster, dolby")
_safe_console_print("="*60)
_safe_console_print(f"⏰ 启动时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
_safe_console_print("🌟 服务已就绪,等待请求...\n")