Archived
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:
1 parent
4962542e78
commit
ce7ea66d6a
8 files changed
+520
-490
No files matched your search
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user