This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/main.py
T
snowy-yang c2a8da3e43 refactor(core): 抽出框架无关核心层 core/,为 FastAPI 迁移铺路
第一步模块拆分(纯搬运,零行为变化):
- 新增 core/ 包:config(常量/配置) + state(运行时状态) + utils(纯函数) + security(音频签名)
- main.py 改为从 core 导入并以原下划线名再导出,250+ 既有调用点零改动

关键设计:
- 状态全部原地修改,import 即共享同一对象(21 项身份断言 + 10 项冒烟全绿)
- 音频签名密钥由启动时 _init_audio_secret(app.secret_key) 注入,
  解除 core.security 对 Flask app 的依赖,新老应用可互认 token

验证:py_compile 通过;冒烟 10/10 PASS;对象身份 22/22 PASS;
waitress 真实启动 /health 200 version=2.4.0。main.py 减 229 行。
2026-09-26 00:16:04 +08:00

4505 lines
218 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""网易云音乐API服务主程序
提供网易云音乐相关API服务,包括:
- 歌曲信息获取
- 音乐搜索
- 歌单和专辑详情
- 音乐下载
- 健康检查
"""
import base64
import json
import collections
import hashlib
import hmac
import logging
import os
import re
import requests
import secrets
import shutil
import sys
import threading
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
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
from auth_routes import register_auth_routes
except ImportError as e:
# 坑 11:导入失败发生在 _safe_console_print 定义之前,用纯 ASCII 保证任何编码下可打印
print("ImportError: " + str(e))
print("Please make sure all dependency modules exist and are importable.")
sys.exit(1)
# ============ 框架无关核心层(core/)============
# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现**
# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。
from core.config import (
APP_VERSION, RESULT_RETENTION_SECONDS, Settings,
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,
)
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,
)
# 向后兼容别名:既有代码/文档沿用 APIConfig 名称
APIConfig = Settings
class APIResponse:
"""API响应工具类"""
@staticmethod
def success(data: Any = None, message: str = 'success', status_code: int = 200) -> Tuple[Dict[str, Any], int]:
"""成功响应"""
response = {
'status': status_code,
'success': True,
'message': message
}
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]:
"""错误响应"""
response = {
'status': status_code,
'success': False,
'message': message
}
if error_code:
response['error_code'] = error_code
return response, status_code
class MusicAPIService:
"""音乐API服务类"""
def __init__(self, config: APIConfig):
self.config = config
self.logger = self._setup_logger()
self.netease_api = NeteaseAPI()
# F97:下载目录传给下载器——原实现下载器固定 downloads,
# 服务配置指向自定义目录时音乐落盘位置与配置分离
self.downloader = MusicDownloader(download_dir=config.downloads_dir)
# 创建下载目录
self.downloads_path = Path(config.downloads_dir)
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()
console_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# 文件处理器(轮转:单文件 5MB,保留 3 份,防止长期运行无限膨胀)
try:
from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler('music_api.log', maxBytes=5 * 1024 * 1024,
backupCount=3, encoding='utf-8')
file_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
except Exception as e:
logger.warning(f"无法创建日志文件: {e}")
return logger
def _get_cookies(self) -> Dict[str, str]:
"""直接从 cookie.txt 读取并解析为 dict"""
try:
cookies = load_cookies()
if not cookies:
self.logger.warning("cookie.txt 为空或不存在,部分接口(VIP 音质)将不可用")
return cookies
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)
- music.163.com 链接用 parse_qs 取 id 参数(参数顺序无关)
"""
raw = str(id_or_url or '').strip()
try:
# 短链展开:仅接受"整个输入就是 163cn.tv 链接"(F7:子串包含不算)
if re.match(r'^https?://(www\.)?163cn\.tv/\S+$', raw, re.IGNORECASE):
raw = _expand_shortlink(raw)
# 网易云链接:正规解析 query 取 id(F74:不再 find('id=') 误吃 userid= 尾部)
if 'music.163.com' in raw:
try:
q = parse_qs(urlsplit(raw).query)
for key in ('id', 'songid'):
if q.get(key):
return str(q[key][0]).strip()
except Exception:
pass
# 兜底:老格式 #/song?id=xxx 的片段部分
m = re.search(r'[?&#]id=(\d+)', raw)
if m:
return m.group(1)
# 纯数字/普通文本直接返回
return raw
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:
size = float(size_bytes or 0)
except (TypeError, ValueError):
return "大小未知"
if size <= 0:
return "0B"
units = ["B", "KB", "MB", "GB", "TB"]
unit_index = 0
while size >= 1024.0 and unit_index < len(units) - 1:
size /= 1024.0
unit_index += 1
return f"{size:.2f}{units[unit_index]}"
def _get_quality_display_name(self, quality: str) -> str:
"""获取音质显示名称"""
quality_names = {
'standard': "标准音质",
'exhigh': "极高音质",
'lossless': "无损音质",
'hires': "Hi-Res音质",
'sky': "沉浸环绕声",
'jyeffect': "高清环绕声",
'jymaster': "超清母带",
'dolby': "杜比全景声"
}
return quality_names.get(quality, f"未知音质({quality})")
def _validate_request_params(self, required_params: Dict[str, Any]) -> Optional[Tuple[Dict[str, Any], int]]:
"""验证请求参数"""
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]:
"""安全获取请求数据"""
try:
if request.method == 'GET':
return dict(request.args)
else:
# 优先使用JSON数据,然后是表单数据
json_data = request.get_json(silent=True) or {}
form_data = dict(request.form)
# 合并数据,JSON优先
return {**form_data, **json_data}
except Exception as e:
self.logger.error(f"获取请求数据失败: {e}")
return {}
# 创建Flask应用和服务实例
config = APIConfig()
app = Flask(__name__)
api_service = MusicAPIService(config)
# ============ 站点认证系统(SQLite + 签名会话) ============
app.secret_key = auth_system.get_secret_key()
# 音频签名密钥注入 core.security(框架无关;与 app.secret_key 同源,
# 保证 Flask 与迁移目标 FastAPI 生成的音频 token 互相可校验)
_init_audio_secret(app.secret_key)
def _quiesce_tasks_for_restore() -> None:
"""S5:恢复数据库前取消全部在途任务(worker 不再引用旧库用户结算)"""
with DOWNLOAD_TASKS_LOCK:
for task in DOWNLOAD_TASKS.values():
if task.get('status') in ('queued', 'running', 'paused'):
task['status'] = 'canceled'
task['msg'] = '数据库恢复中,任务已取消(费用按实际交付结算)'
task['completed_at'] = time.time()
try:
task['cancel'].set()
except Exception:
pass
auth_system.register_before_restore_hook(_quiesce_tasks_for_restore)
app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 7 # 会话 7 天
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF 加固:跨站请求不携带会话 Cookie
# F11:部署在 HTTPS 反代(如 Caddy)后设置环境变量 ENABLE_SECURE_COOKIE=1,
# 会话 Cookie 仅经 HTTPS 传输(HTTP 部署下不要开启,否则浏览器拒收 Cookie)
if os.environ.get('ENABLE_SECURE_COOKIE', '').strip() in ('1', 'true', 'yes'):
app.config['SESSION_COOKIE_SECURE'] = True
auth_system.init_db()
register_auth_routes(app)
# ============ 公网限流(按 IP 滑动窗口,防刷防爆破) ============
# _RATE_BUCKETS / _RATE_LOCK 已迁出至 core.state(此处 import 同名共享)
def _rate_client_ip() -> str:
# F08:仅当直连方是可信反代时才信任代理头(与 auth_system._client_ip 同策略)。
# 第 91 条 P2-4:可信范围从"本机/内网"收窄为"仅回环"——绑 0.0.0.0 时内网任意
# 主机直连可伪造 XFF 轮换绕过全部限流(动态实测实锤)
remote = request.remote_addr or '-'
ip = remote
if remote != '-' and auth_system._is_loopback_ip(remote):
# 第 97 条(P2-1):统一走 auth_system._client_ip_from_headers——XFF 最右段
# 优先(客户端伪造的最左段与直连伪造均无效),最右段为回环(PassNAT 隧道
# 场景)回落 X-Real-IP;取值做可解析校验,垃圾值回落直连地址
ip = auth_system._client_ip_from_headers(remote)
return ip
def _rate_ok(key: str, limit: int, window: int) -> bool:
"""滑动窗口限流:True=放行,False=超限"""
now = time.time()
with _RATE_LOCK:
dq = _RATE_BUCKETS.get(key)
if dq is None:
# 桶数量防膨胀:超限时清理不活跃桶
if len(_RATE_BUCKETS) > 8000:
for k in [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > window]:
_RATE_BUCKETS.pop(k, None)
dq = collections.deque()
_RATE_BUCKETS[key] = dq
while dq and now - dq[0] > window:
dq.popleft()
if len(dq) >= limit:
return False
dq.append(now)
return True
@app.before_request
def _body_size_limit():
"""P2-16:POST 请求体上限。默认 2MB(正常业务请求都在 KB 级);数据库备份上传
单独放宽到 200MB(P3-11)。按 Content-Length 直接短路返回 413 JSON——
原实现的 MAX_CONTENT_LENGTH 方式,413 会被 silent=True 的 JSON 解析吞成 400。
R5-1:无 Content-Length 的 chunked 流式请求同样拒绝——本站前端从不发 chunked,
放行等于给公网开无限内存放大面(Werkzeug 逐线程全量读取)。"""
limit = 200 * 1024 * 1024 if request.path == '/admin/site/backup/upload' else 2 * 1024 * 1024
cl = request.content_length or 0
if cl > limit:
return APIResponse.error("请求体过大,请减少提交内容后重试", 413)
if request.method == 'POST' and cl == 0 and \
'chunked' in (request.headers.get('Transfer-Encoding') or '').lower():
return APIResponse.error("请求体过大,请减少提交内容后重试", 413)
@app.before_request
def _rate_limit_guard():
"""公网接口限流(按 IP):免费业务 30 次/分,下载提交 20 次/分,登录 10 次/分。
试听流 /audio_stream 与进度轮询不受限(保证播放与任务状态刷新)。"""
if request.method == 'OPTIONS':
return None
path = request.path
if path == '/auth/login':
if not _rate_ok('login:' + _rate_client_ip(), 10, 60):
return APIResponse.error('尝试过于频繁,请稍后再试', 429)
return None
if path in ('/auth/admin_login/email', '/auth/admin_login/verify'):
# 第 86 条:管理员两步验证 Step2/3 共桶 10 次/分/IP(Step1 走 /auth/login 桶;
# 票据侧另有 5 次尝试上限)
if not _rate_ok('adminlogin:' + _rate_client_ip(), 10, 60):
return APIResponse.error('尝试过于频繁,请稍后再试', 429)
return None
if path == '/auth/redeem':
# R5-P3:卡密爆破在线虽不可行,但登录号无限速刷失败会灌掉审计日志——独立 5 次/分桶
if not _rate_ok('redeem:' + _rate_client_ip(), 5, 60):
return APIResponse.error('操作过于频繁,请稍后再试', 429)
return None
if path == '/auth/forgot_password':
# R5-P3:找回密码涉及验码+改密,独立 5 次/分桶(配合验证码 5 次错作废)
if not _rate_ok('forgot:' + _rate_client_ip(), 5, 60):
return APIResponse.error('操作过于频繁,请稍后再试', 429)
return None
if path == '/auth/register':
# 注册接口本身不在统计名单里,但验证码是 6 位数字且消费是原子删除——
# 不限流就存在无速率上限的暴力猜测面,这里按 IP 10 次/分钟封住
if not _rate_ok('reg:' + _rate_client_ip(), 10, 60):
return APIResponse.error('注册尝试过于频繁,请稍后再试', 429)
return None
if path == '/api/task/heartbeat':
# 第 98 条:心跳不在统计名单且原零限速——正常节奏 15s/次(4 次/分),
# 60 次/分/IP 的宽松桶只封滥用,不影响合法客户端
if not _rate_ok('hb:' + _rate_client_ip(), 60, 60):
return APIResponse.error('请求过于频繁,请稍后再试', 429)
return None
if path.lower() not in _COUNTED_LOWER:
return None
# R5-P3:/download/result 等领取端点不计入 20 次/分付费提交桶——连续领取多个
# 文件(批量逐首模式)会被自家限流误伤;领取免费且归属校验在端点内
if path.startswith('/download') and '/result' not in path:
ok = _rate_ok('dl:' + _rate_client_ip(), 20, 60)
elif path.startswith('/podcast/download'):
ok = _rate_ok('dl:' + _rate_client_ip(), 20, 60)
else:
# 30/分:实测网易云搜索接口的快速突发容忍度就在 30 次附近,
# 超过的请求会被网易云拒绝并变成 500——限流阈值对齐后用户只会看到干净的 429
ok = _rate_ok('free:' + _rate_client_ip(), 30, 60)
if not ok:
return APIResponse.error('请求过于频繁,请稍后再试', 429)
@app.before_request
def _count_request():
"""统计各接口请求数(仅统计真实业务接口)。C8:键统一小写,别名不再分开计数"""
path = request.path.lower()
if path in _COUNTED_LOWER:
with _STATS_LOCK:
info = REQUEST_STATS.setdefault(path, {'count': 0, 'last': None})
info['count'] += 1
info['last'] = time.strftime('%Y-%m-%d %H:%M:%S')
@app.route('/api/topen', methods=['GET', 'POST'])
def api_topen():
"""接口请求统计(实时)(F112:锁内快照,杜绝 total 与明细不一致或迭代中 500)"""
try:
with _STATS_LOCK:
snapshot = {p: dict(v) for p, v in REQUEST_STATS.items()}
endpoints = []
total = sum(v['count'] for v in snapshot.values())
for path, info in snapshot.items():
endpoints.append({
'path': path,
'name': ENDPOINT_NAMES.get(path, path),
'count': info['count'],
'percent': round(info['count'] / total * 100, 1) if total else 0,
'last': info['last'],
})
endpoints.sort(key=lambda x: x['count'], reverse=True)
return APIResponse.success({
'total': total,
'endpoint_count': len(endpoints),
'max_count': endpoints[0]['count'] if endpoints else 0,
'endpoints': endpoints,
}, "获取请求统计成功")
except Exception as e:
api_service.logger.error(f"获取请求统计异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/api/queue', methods=['GET', 'POST'])
def api_queue():
"""实时排队状态(前端悬浮框轮询):排队人数 + 预计等待时间"""
try:
dl_sem = api_service.downloader._download_semaphore
avg_batch = None
with BATCH_DURATIONS_LOCK:
if BATCH_DURATIONS:
avg_batch = sum(BATCH_DURATIONS) / len(BATCH_DURATIONS)
data = {
'batch_capacity': BATCH_ZIP_SEM.capacity,
'batch_active': BATCH_ZIP_SEM.active,
'batch_waiting': BATCH_ZIP_SEM.waiting,
'download_capacity': dl_sem.capacity,
'download_active': dl_sem.active,
'download_waiting': dl_sem.waiting,
'avg_batch_seconds': round(avg_batch, 1) if avg_batch else None,
}
return APIResponse.success(data, "获取排队状态成功")
except Exception as e:
api_service.logger.error(f"获取排队状态异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/api/batch_progress', methods=['GET', 'POST'])
@auth_system.login_required(free=True)
def api_batch_progress():
"""下载任务进度(前端悬浮窗轮询):已处理 X/Y + 百分比 + 预计剩余时间。
F05:仅任务本人可查询;F38:终态保留期从完成时刻起算;F95:未知总数不是 100%"""
try:
data = api_service._safe_get_request_data()
task_id = str(data.get('task_id') or '').strip()
if not task_id:
return APIResponse.error("缺少 task_id 参数")
now = time.time()
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS.get(task_id)
if not task:
return APIResponse.error("任务不存在或已过期", 404)
if not _task_owned_by_current_user(task):
return APIResponse.error("无权查询该任务", 403)
total = task.get('total', 0)
done = task.get('done', 0)
# F95:总数未知(total=0)的运行中任务进度应为 0%,不是 100%;
# 单曲任务运行中优先用下载器回报的字节级 live_percent(进度条不再卡 0%)
live = task.get('live_percent')
if task.get('status') == 'running' and isinstance(live, (int, float)):
percent = round(min(99.0, max(0.0, float(live))), 1)
elif total:
percent = round(done / total * 100, 1)
else:
percent = 100.0 if task.get('status') in ('done',) else 0.0
elapsed = now - task.get('start', now)
# 预计剩余:按当前已完成进度速率外推(仅运行中且有进度时计算)
eta = None
if task.get('status') == 'running' and done > 0 and elapsed > 0 and total:
rate = done / elapsed
if rate > 0:
eta = round((total - done) / rate, 1)
result = task.get('result') or {}
has_result = bool(result.get('zip_path') or result.get('file_path'))
return APIResponse.success({
'task_id': task_id,
'type': task.get('type', 'batch'),
'total': total,
'done': done,
'failed': task.get('failed', 0),
'percent': percent,
'status': task.get('status', 'running'),
'elapsed': round(elapsed, 1),
'eta': eta,
'msg': task.get('msg', ''),
'refunded': task.get('refunded', 0),
# F24:取消/失败但已有部分成果 → 前端凭此直接领取
'has_result': has_result,
}, "获取任务进度成功")
except Exception as e:
api_service.logger.error(f"获取任务进度异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
# R5-P3:取消是状态变更动作,收敛 POST-only(SameSite=Lax 下顶层导航 GET 可携 Cookie
# 触发取消自己的任务,属滋扰面);前端既有调用就是 POST,零兼容影响
@app.route('/api/task/cancel', methods=['POST'])
@auth_system.login_required(free=True)
def api_task_cancel():
"""取消下载任务(批量打包 / 单曲下载)—— 仅任务本人的站点账号可取消(F05)"""
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:
task = DOWNLOAD_TASKS.get(task_id)
if not task:
return APIResponse.error("任务不存在或已过期", 404)
# 归属校验:站点用户必须匹配(device_id 仅作辅助兼容)
if not _task_owned_by_current_user(task):
return APIResponse.error("无权取消该任务", 403)
if task.get('status') in ('done', 'error', 'canceled'):
return APIResponse.error(f"任务已结束({task.get('status')})", 400)
task['status'] = 'canceled'
task['msg'] = '任务已取消'
task['completed_at'] = time.time() # F38:手工取消也记录完成时刻(worker 收尾会再刷新)
task['cancel'].set()
return APIResponse.success({'task_id': task_id, 'status': 'canceled'}, "已请求取消")
except Exception as e:
api_service.logger.error(f"取消任务异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/api/task/heartbeat', methods=['GET', 'POST'])
@auth_system.login_required(free=True)
def api_task_heartbeat():
"""设备心跳:保持任务活跃;暂停中的任务自动恢复(重新进入排队)。
F05:恢复操作仅对本人任务生效"""
try:
data = api_service._safe_get_request_data()
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
if not device_id:
return APIResponse.error("缺少 device_id 参数")
_touch_device(device_id)
me = auth_system.current_user()
my_uid = int(me['id']) if me else 0
resumed = 0
with DOWNLOAD_TASKS_LOCK:
for task in DOWNLOAD_TASKS.values():
if (task.get('device_id') == device_id
and my_uid and int(task.get('user_id') or 0) == my_uid
and task.get('status') == 'paused'):
task['status'] = 'queued'
task['msg'] = '恢复中...'
task['pause'].clear() # 唤醒暂停中的 worker 继续下载
resumed += 1
# 清理过期设备记录
now = time.time()
with DEVICE_LAST_SEEN_LOCK:
stale = [d for d, ts in DEVICE_LAST_SEEN.items() if now - ts > 86400]
for d in stale:
DEVICE_LAST_SEEN.pop(d, None)
return APIResponse.success({'resumed': resumed, 'online': True}, "心跳成功")
except Exception as e:
api_service.logger.error(f"心跳异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/api/task/list', methods=['GET', 'POST'])
@auth_system.login_required(free=True)
def api_task_list():
"""获取本设备未结束的任务列表(重新打开网站时恢复任务悬浮窗)。
F05:仅返回当前登录用户本人的任务"""
try:
data = api_service._safe_get_request_data()
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
if not device_id:
return APIResponse.error("缺少 device_id 参数")
_touch_device(device_id)
me = auth_system.current_user()
my_uid = int(me['id']) if me else 0
my_tasks = []
with DOWNLOAD_TASKS_LOCK:
for tid, task in DOWNLOAD_TASKS.items():
if (task.get('device_id') == device_id
and my_uid and int(task.get('user_id') or 0) == my_uid
and task.get('status') in ('queued', 'running', 'paused')):
my_tasks.append({
'task_id': tid,
'type': task.get('type', 'batch'),
# P3-33:播客打包 type 同为 batch,用播客特有键区分 kind,
# 供前端恢复时显示正确单位(首/集)
'kind': ('podcast' if ('program_id' in (task.get('params') or {})
or 'radio_name' in (task.get('params') or {})
or (task.get('params') or {}).get('radio_id'))
else 'music'),
'total': task.get('total', 0),
'done': task.get('done', 0),
'status': task.get('status', 'queued'),
'msg': task.get('msg', ''),
})
return APIResponse.success({'tasks': my_tasks}, "获取任务列表成功")
except Exception as e:
api_service.logger.error(f"任务列表异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/api/fe_error', methods=['POST', 'GET'])
def api_fe_error():
"""前端错误日志上报(2026-09-17 v10 新增)
用途:收集各浏览器(含老内核/微信 QQ 内置/国产浏览器)的 JS 运行错误、
Promise 拒绝与资源加载失败,便于排查"某些浏览器 API 行为不一致/样式错位"类问题。
安全:每 IP 每分钟 10 次;字段全部截断;仅写服务日志(music_api.log,已轮转),不入库。
"""
try:
# 独立限流:10 次/分/IP(异常场景下用户连续报错也不会刷爆日志)
if not _rate_ok('fe:' + _rate_client_ip(), 10, 60):
return APIResponse.error('上报过于频繁', 429)
data = api_service._safe_get_request_data()
# P2-3:全部字段与 stack 同口径清洗换行/回车(否则可向 music_api.log 注入伪造多行内容)
msg = str(data.get('msg') or '')[:200].replace('\n', ' | ').replace('\r', '')
etype = str(data.get('type') or 'error')[:20].replace('\n', ' ').replace('\r', '')
stack = str(data.get('stack') or '')[:800].replace('\n', ' | ').replace('\r', '')
page = str(data.get('page') or '')[:120].replace('\n', ' | ').replace('\r', '')
env = str(data.get('env') or '')[:200].replace('\n', ' | ').replace('\r', '')
if not msg:
return APIResponse.error("缺少 msg 参数")
ip = _rate_client_ip()
api_service.logger.error(
f"[前端错误] type={etype} msg={msg} page={page} ip={ip} env={env} stack={stack}")
return APIResponse.success(None, "已记录")
except Exception as e:
api_service.logger.error(f"前端错误上报处理异常: {e}")
return APIResponse.error("记录失败", 500)
# 旧版管理员口令已废除(2026-09-15):管理员权限仅认站点管理员会话(admin 登录),
# 见 _verify_admin_password。管理后台(/admin/site/*)与旧版二维码接口(/admin/qr/*)
# 均通过该会话鉴权。
# 全局批量打包并发信号量:限制同时构建的批量 ZIP 数量。
# 大量用户同时批量下载时,超出的请求排队等待,避免磁盘/内存/CPU 被同时占满。
# (下载本身的并发已由下载器内的全局信号量控制)
# 使用带计数的信号量,供前端实时展示排队人数与预计等待时间。
# 注:信号量依赖下载器的 CountingSemaphore,属服务层对象,随下载服务搬迁,
# 暂留 main(并发上限 3 见 core.config.BATCH_ZIP_CONCURRENCY)。
BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY)
# 以下运行时状态/常量已迁出(原样实现,此处 import 共享同一对象):
# DOWNLOAD_TASKS / DEVICE_LAST_SEEN / BATCH_DURATIONS → core.state
# PAUSE_AFTER_SECONDS / CANCEL_AFTER_SECONDS / HEARTBEAT_INTERVAL
# MAX_ACTIVE_TASKS_PER_USER / MAX_TOTAL_TASKS / MAX_TERMINAL_TASKS
# USER_COOKIE_FILE → core.config
def _register_download_task(task: Dict[str, Any], reserved: int, task_id_hint: str = '') -> Optional[str]:
"""任务登记统一入口:归属校验、数量上限、账本承接。失败返回错误描述(str),成功返回 None。
- F05:任务必须绑定站点用户(提交接口都有 login_required,这里再取一次会话)
- F20:每用户在途任务与全站任务上限
- A4:task_id 的碰撞检查、换新与写入在同一把锁内完成(消除 TOCTOU 覆盖窗口)
- A2/B8:把请求级临时账本行改名挂到正式 task_id(worker 结算唯一入口走账本)
"""
user = auth_system.current_user()
if not user:
return '登录已失效,请重新登录'
task['user_id'] = int(user['id'])
task['billing_reserved'] = int(reserved)
with DOWNLOAD_TASKS_LOCK:
tid = (task_id_hint or '').strip() or uuid.uuid4().hex[:12]
# R5-P1(第 77 条):task_id 是 downloads/tasks/<tid> 的路径成分(mkdir/rmtree
# 都用它),客户端值必须强制白名单——含路径分隔符/../绝对路径/超长一律换服务端 uuid
if not re.match(r'^[A-Za-z0-9_-]{1,32}$', tid):
tid = uuid.uuid4().hex[:12]
while tid in DOWNLOAD_TASKS:
tid = uuid.uuid4().hex[:12]
active = sum(1 for t in DOWNLOAD_TASKS.values()
if t.get('user_id') == user['id'] and t.get('status') in ('queued', 'running', 'paused'))
if active >= MAX_ACTIVE_TASKS_PER_USER:
return f'您有 {active} 个任务正在进行,请等待完成或取消后再提交'
# 第 97 条(P2-2):全站容量只数在途任务——终态记录留表 35 分钟属领取窗口,
# 不再挤占在途配额;终态单独用更大的滞留上限保内存有界
active_total = sum(1 for t in DOWNLOAD_TASKS.values()
if t.get('status') in ('queued', 'running', 'paused'))
if active_total >= MAX_TOTAL_TASKS:
return '当前任务队列已满,请稍后再试'
if len(DOWNLOAD_TASKS) - active_total >= MAX_TERMINAL_TASKS:
return '当前任务队列已满,请稍后再试'
task['task_id'] = tid
DOWNLOAD_TASKS[tid] = task
# 账本承接:请求级临时行(login_required 预扣时创建)改名为正式 task_id;
# 无临时行(unlimited 用户路径)则直接开一行 reserved=0 的账。
# 第 91 条(P2-2):承接抛异常(SQLite 锁超时等)时回滚任务注册——否则僵尸
# queued 任务占用户在途名额最长 4 小时(无 worker、无账本行,装饰器只退请求级行)
_gkey = getattr(g, 'billing_ledger_key', None)
try:
if _gkey:
# R5-P3:rename 失败 = task_id 与 90 天保留期内的历史账本行撞名——
# 复用会把本次预扣挂到已结算的旧行上(结算打到旧行返 0,预扣冻结成孤儿)。
# 换服务端 uuid 重新注册任务键并重试 rename,彻底消除撞名面。
renamed = auth_system.ledger_rename(_gkey, tid)
retries = 0
while not renamed and retries < 5:
retries += 1
with DOWNLOAD_TASKS_LOCK:
DOWNLOAD_TASKS.pop(tid, None)
tid = uuid.uuid4().hex[:12]
while tid in DOWNLOAD_TASKS:
tid = uuid.uuid4().hex[:12]
task['task_id'] = tid
DOWNLOAD_TASKS[tid] = task
renamed = auth_system.ledger_rename(_gkey, tid)
if renamed:
g.billing_ledger_key = tid # 后续失败退款由装饰器按"仍 open 才退"处理
else:
# 第 91 条(P3-1):5 次撞名后不再兜底开新行——那会给同一笔预扣留下
# 两个 open 账本行(结算/对账各认一行,破坏守恒)。直接失败:任务注册
# 由下方 except 回滚,装饰器退回请求级预扣行(概率≈0 的极端路径)
raise RuntimeError('任务账本键撞名,请重新提交')
else:
auth_system.ledger_open(tid, user['id'], reserved)
except Exception:
with DOWNLOAD_TASKS_LOCK:
if DOWNLOAD_TASKS.get(tid) is task:
DOWNLOAD_TASKS.pop(tid, None)
raise
return None
def _start_task_thread(task_id: str, target, args=()) -> Optional[str]:
"""启动任务 worker(F90:启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。
A2:退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。
R5-14:线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死
(无法自行走到终态结算),可代为按账本结算退款(settle 幂等,无双退)。"""
try:
th = threading.Thread(target=target, args=args, daemon=True)
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['worker_thread'] = th
th.start()
return None
except Exception as e:
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.pop(task_id, None)
if t:
try:
auth_system.ledger_settle(task_id, 0, 'submit_failed')
except Exception:
pass
api_service.logger.error(f"任务线程启动失败: {task_id} {e}")
return f'任务启动失败:{e}'
def _task_owned_by_current_user(task: Dict[str, Any]) -> bool:
"""任务归属校验(F05:站点用户是任务的第一归属,device_id 仅作辅助)"""
user = auth_system.current_user()
if not user:
return False
return int(task.get('user_id') or 0) == int(user['id'])
def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None:
"""任务进入终态的统一入口(F38:记录 completed_at,保留期从完成时刻起算,
而不是从提交时刻起算)。
R5-14/F-E:置 error/canceled 终态时若 worker 线程已死(无人再走正常结算),
代为按账本结算退款——ledger_settle 幂等,worker 已自行结算过则此处为 0。
第 100 条(T1 不变量):终态先到先得——已终态(done/error/canceled)的任务
不再被覆盖(堵住 canceled→error 的非法迁移边:取消后解析阶段的自身失败
不得把"已取消"改写成"失败",completed_at 也不再刷新)。"""
need_settle = False
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t and t.get('status') in ('done', 'error', 'canceled'):
return
if t:
t['status'] = status
t['msg'] = msg or t.get('msg', '')
t['completed_at'] = time.time()
if status in ('canceled', 'error'):
th = t.get('worker_thread')
if th is not None and not th.is_alive():
need_settle = True
# 第 97 条(P2-3):settle 移到任务锁外——ledger_settle 内部要抢 _DB_LOCK 写库
# (busy 最长 15s),持任务锁调用会让全站进度/提交/取消/领取随库停摆;
# settle 幂等,锁外调用无双退
if need_settle:
try:
auth_system.ledger_settle(task_id, 0, status)
except Exception:
pass
def _task_files(task: Dict[str, Any]) -> List[str]:
"""任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)"""
result = task.get('result') or {}
files = []
for f in (result.get('zip_path'), result.get('file_path')):
if f:
files.append(f)
return files
def _sweep_download_cache(max_age_seconds: int = RESULT_RETENTION_SECONDS) -> None:
"""下载缓存清扫(F27 配套:批量任务不再即时删除共享缓存,改由这里按年龄回收)。
只清扫 downloads 根目录的普通文件,不碰 tasks/ 子目录(那是任务专属交付物)。"""
try:
root = api_service.downloads_path
now = time.time()
for p in root.iterdir():
try:
if p.is_file() and now - p.stat().st_mtime > max_age_seconds:
p.unlink()
except OSError:
pass
except Exception:
pass
_MONITOR_ERR_TS = {'ts': 0.0, 'n': 0} # 监控线程异常限频记录(持续异常不静默也不刷屏)
# 第 95 条(P1-1):监控线程周期兜底的三个节流字典——第 94 条补丁时定义丢失
# (只剩使用),NameError 被外层 except 吞成 WARNING,孤儿对账/账本清理/tasks
# 补扫自那以后从未执行(music_api.log 实证 2064 条 WARNING)。红转绿:重启后
# 日志零 NameError 且对账真实运行。
_LEDGER_LAST_RECOVER = {'ts': 0.0} # 上次孤儿账本对账时间(每 60s 一次)
_LEDGER_LAST_CLEANUP = {'ts': 0.0} # 上次 settled 账本 90 天清理时间(每 24h 一次)
_TASKS_SWEEP_LAST = {'ts': 0.0} # 上次 tasks/ 孤儿目录补扫时间(每小时一次)
def _task_monitor_loop() -> None:
"""后台监控:
- 心跳超时(40s)→ 暂停离线设备的批量任务,释放排队名额给下一位
- 心跳超时 > 30 分钟 → 自动取消该设备的所有未完成任务并清理文件
- 终态任务按 completed_at 保留 35 分钟(F38/P1-3:与前端「领取」入口对齐)
- 运行中/排队/暂停任务超过 4 小时仍未结束 → 置取消(F38:cancel Event 一并置位)
- 下载缓存按年龄清扫(F27 配套,同为 35 分钟口径)
- 顺带清理限流桶中的过期项(O-5:不再只依赖"新建桶时"的清理时机)
"""
while True:
time.sleep(HEARTBEAT_INTERVAL)
try:
now = time.time()
# ---- 终态任务清理(F38/F106:按完成时刻;删除失败保留记录下轮重试,最多 8 次)----
# 第 91 条(P2-1):锁内只收集过期清单,文件删除移到锁外——GB 级任务目录
# 的删除曾阻塞锁数十秒,期间全站进度上报/提交/取消/领取全部停摆
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'):
continue
done_at = task.get('completed_at') or 0
# 兼容无 completed_at 的旧记录:回退到 start
if not done_at:
done_at = task.get('start', 0)
if now - done_at <= RESULT_RETENTION_SECONDS:
continue
expired.append((tid, _task_files(task),
api_service.downloads_path / 'tasks' / tid))
cleanup_results: List[tuple] = []
for tid, files, task_dir in expired:
all_removed = True
for f in files:
try:
os.remove(f)
except FileNotFoundError:
pass
except OSError:
all_removed = False # F106:文件暂时不可删(被占用等)
if task_dir.exists():
shutil.rmtree(str(task_dir), ignore_errors=True)
if task_dir.exists():
all_removed = False
cleanup_results.append((tid, all_removed))
with DOWNLOAD_TASKS_LOCK:
for tid, all_removed in cleanup_results:
task = DOWNLOAD_TASKS.get(tid)
if task is None:
finished_done.append(tid) # 锁外期间已被其他路径移除
continue
if all_removed:
finished_done.append(tid)
else:
task['_cleanup_tries'] = int(task.get('_cleanup_tries') or 0) + 1
if task['_cleanup_tries'] >= 8:
# 重试多次仍失败:放弃记录但大声记录日志(文件留给缓存清扫兜底)
api_service.logger.warning(
f"任务 {tid} 结果文件多次清理失败,已放弃(文件可能被占用)")
finished_done.append(tid)
else:
finished_retry.append(tid)
for tid in finished_done:
DOWNLOAD_TASKS.pop(tid, None)
if finished_done:
api_service.logger.info(f"监控线程清理过期终态任务: {finished_done}")
if finished_retry:
api_service.logger.info(f"监控线程保留清理失败任务待重试: {finished_retry}")
# ---- 长时间未结束的任务:置取消(含 cancel Event,唤醒可能的阻塞等待)----
timeout_settle: List[str] = []
with DOWNLOAD_TASKS_LOCK:
for tid, task in DOWNLOAD_TASKS.items():
if (task.get('status') in ('queued', 'running', 'paused')
and now - task.get('start', 0) > 4 * 3600):
task['status'] = 'canceled'
task['msg'] = '任务超时未完成,已自动取消(费用按实际交付结算)'
task['completed_at'] = time.time()
try:
task['cancel'].set()
except Exception:
pass
# R5-14:worker 线程已死则无人再走终态结算——监控代为按账本
# 结算退款(settle 幂等:worker 若已结算过,此处返回 0)
th = task.get('worker_thread')
if th is not None and not th.is_alive():
timeout_settle.append(tid)
api_service.logger.warning(f"任务超时自动取消: {tid}")
# 第 97 条(P2-3):结算移到任务锁外(同 _mark_task_terminal 口径)——
# 库忙 15s 不再随任务锁波及全站任务端点
for tid in timeout_settle:
try:
auth_system.ledger_settle(tid, 0, 'timeout')
except Exception:
pass
# ---- 下载缓存清扫(保留期同交付口径 35 分钟)+ 限流桶过期项顺手清理(O-5)----
_sweep_download_cache(RESULT_RETENTION_SECONDS)
_sweep_rate_buckets()
# ---- 第 91→93 条(P2-1/P2-2):账本周期兜底(原仅启动执行一次)----
# 孤儿对账:长运行进程内 open 且账龄 > 5h(严格大于任务 4h 硬超时 + 监控
# tick 抖动——账本行 created_at 整秒截断使账龄恒 ≥ 任务龄,旧 4h 相等口径
# 存在抢退合法 4h+ 长任务的窗口,第 97 条 P2-4)的行每分钟周期检查退回;
# settled 行 90 天清理改为每小时低频执行,防长运行进程下表无界增长
try:
# 第 94 条(P3-1):时间戳门槛替代 tick 取模——单轮工作段超过周期时
# 取模落点会被整段跳过,时间戳门槛只延迟不漏跑
if now - _LEDGER_LAST_RECOVER['ts'] > 60:
_LEDGER_LAST_RECOVER['ts'] = now
auth_system.ledger_recover_orphans(max_age_seconds=5 * 3600)
if now - _LEDGER_LAST_CLEANUP['ts'] > 24 * 3600:
_LEDGER_LAST_CLEANUP['ts'] = now
auth_system.ledger_cleanup_settled(90 * 86400)
except Exception as e:
api_service.logger.warning(f"账本周期兜底异常(下轮重试): {e}")
# ---- 第 91→94 条(P3-3):tasks/ 孤儿目录每小时低频补扫(>24h 且无记录)----
try:
if now - _TASKS_SWEEP_LAST['ts'] > 3600:
_TASKS_SWEEP_LAST['ts'] = now
tasks_root = api_service.downloads_path / 'tasks'
if tasks_root.exists():
for d in tasks_root.iterdir():
try:
if (d.is_dir() and d.name not in DOWNLOAD_TASKS
and now - d.stat().st_mtime > 24 * 3600):
shutil.rmtree(str(d), ignore_errors=True)
api_service.logger.info(f"tasks 孤儿目录补扫移除: {d.name}")
except FileNotFoundError:
pass # 第 94 条(P3-3):单条竞态不中止整轮扫描
except OSError:
pass
except Exception as e:
api_service.logger.warning(f"tasks 孤儿目录补扫异常(下轮重试): {e}")
# ---- 设备心跳离线处理 ----
with DEVICE_LAST_SEEN_LOCK:
offline = {d: now - ts for d, ts in DEVICE_LAST_SEEN.items() if now - ts > PAUSE_AFTER_SECONDS}
if not offline:
continue
with DOWNLOAD_TASKS_LOCK:
for tid, task in DOWNLOAD_TASKS.items():
dev = task.get('device_id')
gap = offline.get(dev)
if not gap:
continue
st = task.get('status')
if gap > CANCEL_AFTER_SECONDS and st in ('queued', 'running', 'paused'):
# 离线超 30 分钟:请求取消(C3:文件删除与退款统一交给 worker 的
# 取消收尾/终态清理;worker 已不在时由启动对账按账本兜底)
task['status'] = 'canceled'
task['msg'] = '设备离线超过 30 分钟,任务已自动取消'
task['completed_at'] = time.time()
try:
task['cancel'].set()
except Exception:
pass
api_service.logger.info(f"设备离线超 30 分钟自动请求取消: {tid}")
elif task.get('type') == 'batch' and st in ('queued', 'running'):
# 心跳超时:先暂停,让出排队名额给下一位
task['status'] = 'paused'
task['msg'] = '设备离线,任务暂停(重新打开页面自动恢复)'
task['pause'].set()
except Exception:
# 持续异常不能完全静默(否则监控失能无人知晓):限频记录,每 10 分钟最多一条
_now = time.time()
_MONITOR_ERR_TS['n'] += 1
if _now - _MONITOR_ERR_TS['ts'] > 600:
api_service.logger.warning(
f"任务监控循环异常(近 10 分钟累计 {_MONITOR_ERR_TS['n']} 次,"
f"最后异常见堆栈): {traceback.format_exc()[-600:]}")
_MONITOR_ERR_TS['ts'] = _now
_MONITOR_ERR_TS['n'] = 0
def _sweep_rate_buckets() -> None:
"""限流桶周期清扫(O-5):把超过 10 分钟无新请求的桶移出,防止低流量长期运行下
缓慢累积(原实现只在"新建第 8001 个桶"时才触发清理,时机太晚)"""
now = time.time()
with _RATE_LOCK:
stale = [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > 600]
for k in stale:
_RATE_BUCKETS.pop(k, None)
def _touch_device(device_id: str) -> None:
"""记录设备心跳时间(P2-10:设备表容量上限——device_id 是客户端任意串,
登录用户可提交海量随机 ID 使字典无界增长,超限清最旧;
第 98 条:入口已统一钳 64,此处兜底再钳一次防新调用点绕过)"""
device_id = str(device_id or '')[:64]
if not device_id:
return
with DEVICE_LAST_SEEN_LOCK:
if len(DEVICE_LAST_SEEN) >= 5000:
# 保留最近心跳的设备:按最后心跳时间排序,砍掉最旧的一半
for d in sorted(DEVICE_LAST_SEEN, key=DEVICE_LAST_SEEN.get)[:2500]:
DEVICE_LAST_SEEN.pop(d, None)
DEVICE_LAST_SEEN[device_id] = time.time()
# _safe_int / _safe_console_print 已迁出至 core.utils(此处 import 同名共享)
# ============ 接口请求统计 ============
# REQUEST_STATS / _STATS_LOCK / REQ_LOG 已迁出至 core.state(此处 import 同名共享)
# 只统计真实业务接口(排除首页/健康检查/统计/账号探测等内部调用)
# F09:兼容别名(/Search、/Song_V1、/Playlist、/Album、/Download)一并纳入统计与限流,
# 防止用大写别名绕过业务限流
COUNTED_PATHS = {
'/song', '/search', '/playlist', '/album', '/toplist', '/artist_songs',
'/comments', '/download', '/download/batch_zip',
'/podcast/parse', '/podcast/programs', '/podcast/search', '/podcast/hot',
'/podcast/download', '/podcast/download/batch_zip',
'/user/playlists', '/user/playlist', '/user/status', '/user/logout',
'/user/qr/create', '/user/qr/check',
# P1-2/P2-17/P3-6:账号探测与个人操作端点此前漏在限流名单外——
# /account/vip 每请求最多 9 次上游探测、/user/comment 与 /user/collect 携
# 任意 cookie 触发上游 weapi、/auth/me 可匿名无限刷,全部纳入免费 30/分桶
'/account/vip', '/user/collect', '/user/comment', '/auth/me',
'/admin/qr/create', '/admin/qr/check', '/admin/cookie/status',
'/admin/cookie/delete', '/admin/logs',
'/admin/site/play_key/get', '/admin/site/play_key/rotate',
'/global/playlists', '/global/playlist', '/api/playlist/play',
'/song_v1',
}
_COUNTED_LOWER = {p.lower() for p in COUNTED_PATHS}
ENDPOINT_NAMES = {
'/song': '歌曲信息', '/search': '搜索', '/playlist': '歌单详情',
'/album': '专辑详情', '/toplist': '榜单列表', '/artist_songs': '歌手歌曲',
'/comments': '评论', '/download': '音乐下载', '/download/batch_zip': '批量ZIP',
'/health': '健康检查', '/api/info': '服务信息', '/api/topen': '请求统计',
'/account/vip': '账号VIP信息',
'/podcast/parse': '播客解析', '/podcast/programs': '播客单集列表',
'/podcast/search': '播客搜索', '/podcast/hot': '热门播客',
'/podcast/download': '播客单集下载', '/podcast/download/batch_zip': '播客整台打包',
'/user/playlists': '个人歌单', '/user/playlist': '个人歌单详情',
'/user/status': '个人登录状态', '/user/logout': '个人退出',
'/user/qr/create': '个人二维码', '/user/qr/check': '个人登录轮询',
'/admin/qr/create': '管理员二维码', '/admin/qr/check': '管理员登录轮询',
'/admin/cookie/status': 'Cookie状态', '/admin/cookie/delete': 'Cookie管理',
'/admin/logs': '操作记录',
'/global/playlists': '全局账号歌单', '/global/playlist': '全局歌单详情',
'/api/playlist/play': '公开歌单播放API',
}
# 用户解析/下载操作记录(IP)——REQ_LOG 已迁出至 core.state(此处 import 同名共享)
def _record_log(action: str, ip: str, song: str) -> None:
"""记录用户操作(解析/下载)。P3-4:歌名截断(来自上游/用户输入,不设上限会撑内存与后台页)"""
REQ_LOG.append({
'action': action,
'ip': ip,
'song': str(song or '')[:100],
'time': time.strftime('%Y-%m-%d %H:%M:%S'),
})
if len(REQ_LOG) > 2000:
del REQ_LOG[:len(REQ_LOG) - 2000]
def _parse_cookie_str(raw) -> Dict[str, str]:
"""将 'k=v;k2=v2' 格式的 Cookie 字符串解析为字典"""
cookies: Dict[str, str] = {}
if not raw:
return cookies
for part in str(raw).split(';'):
if '=' in part:
k, v = part.strip().split('=', 1)
if k:
cookies[k] = v
return cookies
# _GLOBAL_MUSIC_U_CACHE 已迁出至 core.state(此处 import 同名共享)
def _global_music_u() -> str:
"""全局账号 MUSIC_U(5 分钟缓存;后台重新扫码后自动跟随新值)"""
now = time.time()
if _GLOBAL_MUSIC_U_CACHE['v'] is None or now - _GLOBAL_MUSIC_U_CACHE['ts'] > 300:
try:
ck = api_service._get_cookies()
_GLOBAL_MUSIC_U_CACHE['v'] = (ck.get('MUSIC_U') or '').strip()
except Exception:
_GLOBAL_MUSIC_U_CACHE['v'] = ''
_GLOBAL_MUSIC_U_CACHE['ts'] = now
return _GLOBAL_MUSIC_U_CACHE['v']
def _global_music_u_cache_reset() -> None:
"""第 94 条(P3-5):重新扫码/删除 Cookie 后同步清空全局 MUSIC_U 缓存——
否则 5 分钟窗口内冒用闸拿着旧值比对,新 Cookie 被放行、旧 Cookie 被误拦"""
_GLOBAL_MUSIC_U_CACHE['v'] = None
_GLOBAL_MUSIC_U_CACHE['ts'] = 0.0
def _resolve_user_cookies(data: Dict[str, Any]) -> Dict[str, str]:
"""解析个人账号 Cookie:仅使用浏览器传入的 user_cookie(不落服务器,杜绝跨设备访问他人账号)。
第 92 条(owner 拍板):全局账号(cookie.txt)在发评/收藏/下载等个人身份操作上
零权限、零功能、零入口——浏览器提交的 MUSIC_U 若与全局账号一致(全局 Cookie
泄露被当个人 Cookie 冒用),一律拒绝并大声记录,防止以全局账号身份执行任何
操作(评论区发言、收藏变更、多 IP 下载触发上游风控均属"危害全局账号")。"""
ck = _parse_cookie_str(data.get('user_cookie'))
mu = (ck.get('MUSIC_U') or '').strip()
gmu = _global_music_u()
if mu and gmu and hmac.compare_digest(mu, gmu):
try:
_cu = auth_system.current_user()
auth_system.add_log('fail', _cu['username'] if _cu else '匿名',
auth_system._client_ip(), '全局账号冒用拦截',
'浏览器提交的 MUSIC_U 与全局账号一致,已按未登录拒绝')
except Exception:
pass
api_service.logger.warning(
f"[全局账号冒用拦截] {request.path} 提交的 MUSIC_U 与全局账号一致,已按未登录处理")
return {}
return ck
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 '未知歌手'
def _safe(s: str) -> str:
# P2-14:交付文件名必须剔除控制字符——歌名含 \r\n 时 send_file 的
# Content-Disposition 会抛 ValueError,任务完成已扣费但领取必 500
# (F79 当年只修了下载器磁盘名这条同型路径,交付名漏网)
s = re.sub(r'[\x00-\x1f\x7f]', '', s)
return ''.join(c for c in s if c not in r'<>:"/\|?*').strip() or 'unknown'
if filename_format == 'song':
return _safe(name)
if filename_format == 'song_artist':
return _safe(f"{name} - {artist}")
return _safe(f"{artist} - {name}") # artist_song(默认)
# _srt_time / _format_lyric / _parse_meta_fields 已迁出至 core.utils(此处 import 同名共享)
def _verify_admin_password(data) -> Optional[Tuple[Dict[str, Any], int]]:
"""校验管理员权限:仅站点管理员会话(旧版口令已废除,2026-09-15)"""
_user = auth_system.current_user()
if _user is not None and _user['role'] == 'admin':
return None
return APIResponse.error('需要管理员权限,请使用管理员账号登录', 403)
def _vip_name(vip_type: int) -> str:
"""根据 vipType 返回会员类型名称(兼容新旧取值)"""
return {
0: '普通用户',
1: '音乐包',
11: '黑胶VIP',
100: '黑胶VIP',
10: '黑胶SVIP',
110: '黑胶SVIP'
}.get(vip_type, '普通用户')
def _detect_vip(cookies: Dict[str, str]) -> Tuple[int, str, List[str]]:
"""检测账号会员类型与可解析音质:
优先账号接口(vipType 精确),并始终用音质探测接口实测可解析列表。
第 100 条:探测单飞锁(有界等待 0.5s)——上游黑洞时并发请求不再各自串行
8 级探测(最坏 16 分钟线程滞留),等锁超时者直接用账号接口给降级结果"""
if not _VIP_PROBE_LOCK.acquire(timeout=0.5):
info = account_info(cookies)
if info:
return info.get('vipType', 0), 'account', []
return 0, 'probe', []
try:
probe = detect_vip_by_probe(cookies)
levels_ok = probe.get('levels_ok', [])
info = account_info(cookies)
if info:
return info.get('vipType', 0), 'account', levels_ok
return probe.get('vip_type', 0), 'probe', levels_ok
finally:
_VIP_PROBE_LOCK.release()
# 服务器网易云账号昵称缓存与单飞锁已迁出至 core.state(此处 import 同名共享):
# _SERVER_NICK_CACHE(10 分钟) / _SERVER_NICK_LOCK / _VIP_PROBE_LOCK
def _mask_server_nickname(nick: str) -> str:
"""非站点管理员可见的响应中,文本内出现的服务器账号昵称替换为站点名
(覆盖 creator 精确字段与歌单名等含昵称的文本),管理员本人仍可见真实昵称。
P1-4:查询失败时保留上一次已知昵称继续遮蔽(原实现清空缓存导致失败开放,
网易接口抖动的 10 分钟窗口内真昵称原样放行)。
R5-11:昵称成功获取后同时持久化进站点库(settings 表,私有数据、不进公开包)——
内存缓存丢失(进程重启)或服务器 Cookie 失效导致上游拿不到昵称时,
回落读库里的最后已知昵称继续遮蔽,不再"从未成功获取就无限期放行"。"""
if not nick:
return nick
_cu = auth_system.current_user()
if _cu is not None and _cu['role'] == 'admin':
return nick
now = time.time()
if _SERVER_NICK_CACHE['nick'] is None or now - _SERVER_NICK_CACHE['ts'] > 600:
# 第 91 条(P3-9):单飞锁——缓存过期时并发请求只放一个去打上游,其余等结果
if not _SERVER_NICK_LOCK.acquire(timeout=10):
server_nick = _SERVER_NICK_CACHE['nick']
return nick.replace(server_nick, '云村小助手') if (server_nick and server_nick in nick) else nick
try:
# 双检:等锁期间别的请求可能已完成刷新
if _SERVER_NICK_CACHE['nick'] is None or time.time() - _SERVER_NICK_CACHE['ts'] > 600:
fetched = ''
try:
fetched = (account_info(api_service._get_cookies()) or {}).get('nickname', '')
except Exception:
fetched = ''
if fetched:
# 仅在成功拿到非空昵称时覆盖(失败保留旧值,遮蔽不失效),并持久化兜底
_SERVER_NICK_CACHE['nick'] = fetched
_SERVER_NICK_CACHE['ts'] = time.time()
try:
auth_system.set_setting('server_nickname', fetched)
except Exception:
pass
else:
# 上游拿不到(Cookie 失效/网络故障):回落站点库里的最后已知昵称继续遮蔽
try:
persisted = auth_system.get_setting('server_nickname', '')
except Exception:
persisted = ''
if persisted:
_SERVER_NICK_CACHE['nick'] = persisted
_SERVER_NICK_CACHE['ts'] = time.time() # 库值视为可信,走正常 10 分钟重取节奏
else:
# 库里也从未有过(全新部署且从未成功获取):缩短重试间隔,本次无遮蔽依据只能放行
_SERVER_NICK_CACHE['ts'] = time.time() - 600 + 30
finally:
_SERVER_NICK_LOCK.release()
server_nick = _SERVER_NICK_CACHE['nick']
if server_nick and server_nick in nick:
return nick.replace(server_nick, '云村小助手')
return nick
# 高频轮询/流式端点:不写逐请求日志(3s 排队轮询、1.5s 进度轮询、15s 心跳、
# Range 分段流、错误上报都会在忙碌站点把 5MB×3 的轮转日志几小时刷一圈,排障信息被冲掉)。
# 用户主动动作(任务列表/取消)保留日志。
_QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat',
'/audio_stream', '/api/fe_error'}
@app.before_request
def before_request():
"""请求前处理(高频轮询/流式端点降噪:3 秒排队轮询 + 1.5 秒进度轮询会刷爆轮转日志)"""
if request.path in _QUIET_LOG_PATHS:
return
# 记录请求信息(P3-3:UA 截断并清洗换行——4KB 垃圾 UA 或伪日志行不再整段进日志;
# 第 97 条:path 同口径清洗——%0A 解码后的请求路径可伪造日志行)
ua = (request.headers.get('User-Agent', 'Unknown') or 'Unknown')[:200].replace('\n', ' ').replace('\r', '')
req_path = request.path.replace('\n', ' ').replace('\r', '')
api_service.logger.info(
f"{request.method} {req_path} - IP: {request.remote_addr} - User-Agent: {ua}"
)
# ============ 音频直链多重防护(HMAC 签名 + 时效 + 代理流)============
# 已迁出至 core.security(框架无关,密钥经 _init_audio_secret 注入):
# _AUDIO_TOKEN_TTL 常量 → core.config.AUDIO_TOKEN_TTL(仍按 RESULT_RETENTION_SECONDS+60 计)
# _is_audio_url / _make_audio_token / _verify_audio_token / _protect_audio_urls → 此处 import 同名共享
# 注:核心层用 sign_payload 替代原 _sign_payload(仅内部调用,未在此保留旧名)
@app.route('/audio_stream')
@auth_system.login_required(free=True)
def audio_stream():
"""签名音频代理流:校验 HMAC 签名与时效后流式转发(支持 Range 拖动进度)
F40:下游中断/HEAD 也保证上游 Response 显式关闭;F92:解压后正文不再沿用压缩前的长度头"""
if request.method == 'HEAD':
# HEAD 无正文:不去请求上游(原实现会真的发起 GET 却不消费)
return Response(status=200, headers={'Accept-Ranges': 'bytes'})
token = request.args.get('token', '')
url = _verify_audio_token(token)
if not url:
return APIResponse.error('音频链接已过期或无效,请重新解析', 403)
upstream_headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://music.163.com/',
}
range_header = request.headers.get('Range')
if range_header:
upstream_headers['Range'] = range_header
try:
session = getattr(audio_stream, '_session', None)
if session is None:
session = requests.Session()
session.trust_env = False # 忽略环境代理,直连音频 CDN
audio_stream._session = session
upstream = session.get(url, headers=upstream_headers, stream=True, timeout=20)
except Exception as e:
api_service.logger.warning(f'音频代理获取失败: {type(e).__name__}: {e}')
return APIResponse.error('音频获取失败', 502)
# requests 对 gzip 等编码做了透明解压:iter_content 吐出的是解码后字节,
# 上游 Content-Length/Content-Range 是压缩前口径,直接转发会造成头体不一致(F92)
upstream_encoded = bool(upstream.headers.get('Content-Encoding'))
def _stream_and_close():
try:
yield from upstream.iter_content(chunk_size=16 * 1024)
finally:
# F40:下游断开/提前关闭时也释放上游连接
try:
upstream.close()
except Exception:
pass
resp = Response(
stream_with_context(_stream_and_close()),
status=upstream.status_code,
content_type=upstream.headers.get('Content-Type', 'audio/mpeg'),
)
if not upstream_encoded:
for h in ('Content-Length', 'Content-Range', 'Accept-Ranges'):
if h in upstream.headers:
resp.headers[h] = upstream.headers[h]
else:
# 解压转发:长度未知,交给分块传输;不携带 Range 语义
resp.headers.pop('Content-Length', None)
if 'Accept-Ranges' in upstream.headers:
resp.headers['Accept-Ranges'] = upstream.headers['Accept-Ranges']
resp.headers['Cache-Control'] = 'no-store'
return resp
@app.after_request
def after_request(response: Response) -> Response:
"""请求后处理 - 设置CORS头"""
response.headers.add('Access-Control-Allow-Origin', config.cors_origins)
response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
response.headers.add('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
response.headers.add('Access-Control-Max-Age', '3600')
# P3-16:零成本安全响应头(nosniff 防 MIME 嗅探、DENY 防点击劫持、
# Referrer-Policy 防跨站 Referer 泄漏);O-3:429 统一带 Retry-After 供客户端退避
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'DENY')
response.headers.setdefault('Referrer-Policy', 'same-origin')
# 第 91 条(P3):CSP 基线(站点全部脚本/样式内联,需 unsafe-inline;音频同源 +
# blob 预览;图片允许 data:/http:/https:——网易封面直链为 http 协议)——
# 防注入脚本外联的纵深防御
response.headers.setdefault(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: http: https:; media-src 'self' blob:; connect-src 'self'; "
"font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'")
# 第 91 条(P3):抹掉 Werkzeug/Python 精确版本号(fuzz 实测泄露)
response.headers['Server'] = 'NeteaseUrl'
if response.status_code == 429:
response.headers.setdefault('Retry-After', '30')
# 音频直链保护:JSON 响应中的网易云音频地址替换为签名代理地址
if response.is_json and response.status_code == 200:
try:
data = response.get_json(silent=True)
if isinstance(data, (dict, list)) and _protect_audio_urls(data):
response.data = json.dumps(data, ensure_ascii=False)
except Exception:
pass
# 记录响应信息(高频轮询/流式端点降噪,与 before_request 同口径)
if request.path not in _QUIET_LOG_PATHS:
api_service.logger.info(f"响应状态: {response.status_code}")
return response
@app.errorhandler(400)
def handle_bad_request(e):
"""处理400错误"""
return APIResponse.error("请求参数错误", 400)
@app.errorhandler(404)
def handle_not_found(e):
"""处理404错误"""
return APIResponse.error("请求的资源不存在", 404)
@app.errorhandler(405)
def handle_method_not_allowed(e):
"""处理405错误(P3-28:fetch 消费方拿到统一 JSON,而非 werkzeug 默认 HTML)"""
return APIResponse.error("不支持的请求方法", 405)
@app.errorhandler(413)
def handle_payload_too_large(e):
"""处理413错误(P2-16:请求体超过上限时给出可读提示而非 werkzeug HTML)"""
return APIResponse.error("请求体过大,请减少提交内容后重试", 413)
@app.errorhandler(500)
def handle_internal_error(e):
"""处理500错误"""
api_service.logger.error(f"服务器内部错误: {e}")
return APIResponse.error("服务器内部错误", 500)
@app.route('/')
def index() -> str:
"""首页路由"""
resp = make_response(render_template('index.html'))
# 禁止缓存首页,确保前端更新即时生效(避免旧 HTML 缓存引发的功能异常)
resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
return resp
@app.route('/health', methods=['GET'])
def health_check():
"""健康检查API"""
try:
# 检查 Cookie 状态:直接读 cookie.txt
cookies = load_cookies()
cookie_status = 'valid' if cookies else 'invalid'
health_info = {
'service': 'running',
'timestamp': int(time.time()) if 'time' in sys.modules else None,
'cookie_status': cookie_status,
'cookie_count': len(cookies),
'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径
'version': APP_VERSION
}
return APIResponse.success(health_info, "API服务运行正常")
except Exception as e:
api_service.logger.error(f"健康检查失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/song', methods=['GET', 'POST'])
@app.route('/Song_V1', methods=['GET', 'POST']) # 向后兼容
@auth_system.login_required('歌曲解析', free=True)
def get_song_info():
"""获取歌曲信息API"""
try:
# 获取请求参数
data = api_service._safe_get_request_data()
song_ids = data.get('ids') or data.get('id')
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:
return APIResponse.error(f"无效的类型参数,支持: {', '.join(valid_types)}")
# 解析账号选择:user=游客个人账号(个人歌单登录,Cookie 由浏览器传入),默认 admin=全局管理员账号
account = data.get('account', 'admin')
if account == 'user':
cookies = _resolve_user_cookies(data)
if not cookies:
return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401)
else:
cookies = api_service._get_cookies()
# 根据类型获取不同信息
if info_type == 'url':
result = url_v1(music_id, level, cookies)
if result and result.get('data') and len(result['data']) > 0:
# 第 99 条:无版权/VIP 歌上游返回 data:[null]——元素为 null 时走下方
# 404 分支而非 AttributeError 500(下载器同族已修,解析路由补齐)
song_data = result['data'][0] or {}
if not song_data:
return APIResponse.error("获取音乐URL失败,可能是版权限制或音质不支持", 404)
response_data = {
'id': song_data.get('id'),
'url': song_data.get('url'),
'level': song_data.get('level'),
'quality_name': api_service._get_quality_display_name(song_data.get('level', level)),
'size': song_data.get('size'),
'size_formatted': api_service._format_file_size(song_data.get('size', 0)),
'type': song_data.get('type'),
'bitrate': song_data.get('br')
}
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)
url_info = url_v1(music_id, level, cookies)
try:
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(而非缺失)时同样容错,不让有效歌曲解析失败
_al = song_data.get('al') or {}
_ar_names = ', '.join(filter(None, ((a or {}).get('name') for a in (song_data.get('ar') or []))))
# 构建前端期望的响应格式
response_data = {
'id': music_id,
'name': song_data.get('name') or '',
'ar_name': _ar_names,
'al_name': _al.get('name') or '',
'pic': _al.get('picUrl') or '',
'level': level,
'lyric': ((lyric_info or {}).get('lrc') or {}).get('lyric') or '',
'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]
response_data.update({
'url': url_data.get('url', ''),
'size': api_service._format_file_size(url_data.get('size', 0)),
'level': url_data.get('level', level)
})
else:
response_data.update({
'url': '',
'size': '获取失败'
})
# 记录用户解析操作(IP + 歌曲名)
try:
_record_log('解析', auth_system._client_ip(), response_data.get('name') or str(music_id))
except Exception:
pass
return APIResponse.success(response_data, "获取歌曲信息成功")
except APIException as e:
api_service.logger.error(f"API调用失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"获取歌曲信息异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/search', methods=['GET', 'POST'])
@app.route('/Search', methods=['GET', 'POST']) # 向后兼容
@auth_system.login_required('搜索', free=True)
def search_music_api():
"""搜索音乐API"""
try:
# 获取请求参数
data = api_service._safe_get_request_data()
keyword = data.get('keyword') or data.get('keywords') or data.get('q')
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:
if song.get('type', 'song') == 'song' and 'artists' in song:
song['artist_string'] = song['artists']
if song.get('type') == 'playlist':
if song.get('creator'):
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)}")
except Exception as e:
api_service.logger.error(f"搜索音乐异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/playlist', methods=['GET', 'POST'])
@app.route('/Playlist', methods=['GET', 'POST']) # 向后兼容
@auth_system.login_required('歌单解析', free=True)
def get_playlist():
"""获取歌单详情API"""
try:
# 获取请求参数
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:
return validation_error
# P2-2:ID 白名单(同 /album 口径——原实现垃圾 ID 透传上游并把上游报错回显)
playlist_id = api_service._extract_music_id(playlist_id)
if not str(playlist_id).strip().isdigit():
return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接")
cookies = api_service._get_cookies()
result = playlist_detail(playlist_id, cookies)
# 非管理员遮蔽服务器账号昵称(creator / 歌单名等文本字段)
if isinstance(result, dict):
if result.get('creator'):
result['creator'] = _mask_server_nickname(result['creator'])
if result.get('name'):
result['name'] = _mask_server_nickname(result['name'])
# 适配前端期望的响应格式
response_data = {
'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)
@app.route('/album', methods=['GET', 'POST'])
@app.route('/Album', methods=['GET', 'POST']) # 向后兼容
@auth_system.login_required('专辑解析', free=True)
def get_album():
"""获取专辑详情API"""
try:
# 获取请求参数
data = api_service._safe_get_request_data()
album_id = data.get('id')
# 参数验证
validation_error = api_service._validate_request_params({'album_id': album_id})
if validation_error:
return validation_error
# ID 白名单:必须能归一化为纯数字(原实现直接拼进上游 URL 路径,
# 恶意输入可构造路径穿越让服务器携 Cookie 请求任意 GET 接口)
album_id = api_service._extract_music_id(album_id)
if not str(album_id).strip().isdigit():
return APIResponse.error("无效的专辑ID,请输入数字ID或分享链接")
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)
@app.route('/toplist', methods=['GET', 'POST'])
@auth_system.login_required('榜单获取', free=True)
def get_toplist_api():
"""获取网易云官方榜单列表API"""
try:
result = toplist_list()
return APIResponse.success(result, "获取榜单列表成功")
except APIException as e:
api_service.logger.error(f"获取榜单失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"获取榜单异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/artist_songs', methods=['GET', 'POST'])
@auth_system.login_required('歌手解析', free=True)
def get_artist_songs_api():
"""获取歌手信息和热门歌曲API"""
try:
data = api_service._safe_get_request_data()
artist_id = data.get('id') or data.get('artist_id')
validation_error = api_service._validate_request_params({'artist_id': artist_id})
if validation_error:
return validation_error
artist_id = api_service._extract_music_id(artist_id)
# ID 白名单:归一化后必须为纯数字(防路径拼接注入面,同 /album)
if not str(artist_id).strip().isdigit():
return APIResponse.error("无效的歌手ID,请输入数字ID或分享链接")
result = artist_songs(artist_id)
return APIResponse.success(result, "获取歌手歌曲成功")
except APIException as e:
api_service.logger.error(f"获取歌手歌曲失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"获取歌手歌曲异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
# ============ 播客(电台 / 单集声音)============
@app.route('/podcast/parse', methods=['GET', 'POST'])
@auth_system.login_required('播客解析', free=True)
def podcast_parse_api():
"""播客解析:电台链接/ID → 电台详情+单集列表;单集链接/ID → 单集详情+音频直链"""
try:
data = api_service._safe_get_request_data()
raw = data.get('id') or data.get('url')
validation_error = api_service._validate_request_params({'id': raw})
if validation_error:
return validation_error
page_size = max(1, min(_safe_int(data.get('page_size'), 30), 100))
quality = str(data.get('quality') or 'auto')
cookies = api_service._get_cookies()
result = podcast_api.parse_podcast_ref(raw, cookies, page_size=page_size)
# 单集解析:附音频直链(音质自动降级)
if result['kind'] == 'program':
program = result['program']
song_id = program.get('main_song_id')
url_info = podcast_api.resolve_episode_url(song_id, cookies, quality) if song_id else None
if url_info:
program['audio'] = {
'url': url_info['url'],
'level': url_info['level'],
'level_name': api_service._get_quality_display_name(url_info['level']),
'type': url_info['type'],
'size': url_info['size'],
'size_formatted': api_service._format_file_size(url_info['size']),
'br': url_info['br'],
}
else:
program['audio'] = None
try:
_record_log('解析', auth_system._client_ip(), program.get('name') or str(raw))
except Exception:
pass
else:
try:
_record_log('解析', auth_system._client_ip(), result['radio'].get('name') or str(raw))
except Exception:
pass
return APIResponse.success(result, "播客解析成功")
except APIException as e:
api_service.logger.warning(f"播客解析失败: {e}")
return APIResponse.error(str(e), 404)
except Exception as e:
api_service.logger.error(f"播客解析异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/podcast/programs', methods=['GET', 'POST'])
@auth_system.login_required('播客单集列表', free=True)
def podcast_programs_api():
"""播客单集列表(分页加载)"""
try:
data = api_service._safe_get_request_data()
radio_id = data.get('radio_id') or data.get('id')
validation_error = api_service._validate_request_params({'radio_id': radio_id})
if validation_error:
return validation_error
limit = max(1, min(_safe_int(data.get('limit'), 30), 100))
offset = max(0, _safe_int(data.get('offset'), 0))
asc = str(data.get('asc', 'true')).lower() in ('1', 'true', 'yes')
cookies = api_service._get_cookies()
radio_id = api_service._extract_music_id(str(radio_id))
# R5-P3:数字白名单——垃圾 radio_id 会在 podcast_api 的 int() 处抛 ValueError 变 500
if not str(radio_id).isdigit():
return APIResponse.error("无效的电台 ID")
result = podcast_api.get_radio_programs(radio_id, cookies, limit=limit, offset=offset, asc=asc)
return APIResponse.success(result, "获取单集列表成功")
except APIException as e:
return APIResponse.error(str(e), 404)
except Exception as e:
api_service.logger.error(f"获取播客单集列表异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/podcast/search', methods=['GET', 'POST'])
@auth_system.login_required('播客搜索', free=True)
def podcast_search_api():
"""播客搜索(电台 / 单集声音)"""
try:
data = api_service._safe_get_request_data()
keyword = data.get('keyword') or data.get('q')
validation_error = api_service._validate_request_params({'keyword': keyword})
if validation_error:
return validation_error
limit = max(1, min(_safe_int(data.get('limit'), 20), 50))
offset = max(0, _safe_int(data.get('offset'), 0))
search_type = data.get('type', 'all')
if search_type not in ('all', 'radio', 'voice'):
search_type = 'all'
cookies = api_service._get_cookies()
result = podcast_api.search_podcast(keyword, cookies, limit=limit,
offset=offset, search_type=search_type)
return APIResponse.success(result, "播客搜索完成")
except APIException as e:
return APIResponse.error(str(e), 404)
except Exception as e:
api_service.logger.error(f"播客搜索异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/podcast/hot', methods=['GET', 'POST'])
@auth_system.login_required(free=True)
def podcast_hot_api():
"""热门播客 + 播客分类列表"""
try:
data = api_service._safe_get_request_data()
limit = max(1, min(_safe_int(data.get('limit'), 30), 100))
offset = max(0, _safe_int(data.get('offset'), 0))
cate_id = data.get('cate_id') or None
cookies = api_service._get_cookies()
radios = podcast_api.get_hot_radios(cookies, limit=limit, offset=offset, cate_id=cate_id)
try:
categories = podcast_api.get_categories()
except APIException:
categories = []
# S2:满页视为可能还有更多,供前端"加载更多"按钮显隐(空页/短页则没有更多)
return APIResponse.success({
'radios': radios,
'categories': categories,
'has_more': len(radios) >= limit,
}, "获取热门播客成功")
except APIException as e:
return APIResponse.error(str(e), 404)
except Exception as e:
api_service.logger.error(f"获取热门播客异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/podcast/download', methods=['POST'])
@auth_system.login_required('播客单集下载')
def podcast_download_api():
"""播客单集下载:后台任务下载音频到服务器,完成后经 /download/result 交付
参数: id(单集ID/链接), quality(auto/exhigh/standard), zip(打包音频+封面+信息), task_id, device_id
"""
try:
data = api_service._safe_get_request_data()
raw = data.get('id')
validation_error = api_service._validate_request_params({'id': raw})
if validation_error:
return validation_error
quality = str(data.get('quality') or 'auto')
if quality not in ('auto', 'standard', 'exhigh', 'lossless', 'hires'):
quality = 'auto'
need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes')
# 单集ID提取(支持 program?id= / sound?id= / 短链 / 纯数字)
ref = podcast_api.extract_podcast_ref(raw)
if ref['kind'] == 'radio':
return APIResponse.error("这是电台链接,请先解析电台后选择单集下载,或使用「打包整个播客」")
# F64:个人账号选择贯通播客入口。第 91 条(P3-2):删除 user_cookie.txt
# 服务器回退——旧版部署残留文件可被任意登录用户复用(跨设备访问他人账号),
# 与 /song、/download 的"仅浏览器传入"口径统一
account = data.get('account', 'admin')
if account == 'user':
cookies = _resolve_user_cookies(data)
if not cookies:
return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401)
else:
cookies = api_service._get_cookies()
task_id_hint = str(data.get('task_id') or '').strip()
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
_touch_device(device_id)
_cur_user = auth_system.current_user()
_reserved = 0 if (not _cur_user or _cur_user['role'] == 'admin'
or int(_cur_user['duration_seconds'] or 0) == -1) else 1
task = {
'type': 'single',
'billing_user_id': int(_cur_user['id']) if _cur_user else 0,
'billing_reserved': _reserved,
'total': 1,
'done': 0,
'failed': 0,
'status': 'queued',
'start': time.time(),
'msg': '排队中...',
'cancel': threading.Event(),
'pause': threading.Event(),
'device_id': device_id,
'result': None,
'params': {
'program_id': ref['id'],
'quality': quality,
'need_zip': need_zip,
'cookies': cookies,
'remote_ip': auth_system._client_ip(),
},
}
reg_err = _register_download_task(task, _reserved, task_id_hint=task_id_hint)
if reg_err:
return APIResponse.error(reg_err, 429)
task_id = task['task_id']
start_err = _start_task_thread(task_id, _podcast_download_worker, (task_id,))
if start_err:
return APIResponse.error(start_err, 500)
return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "播客单集下载任务已提交")
except APIException as e:
return APIResponse.error(str(e), 404)
except Exception as e:
api_service.logger.error(f"播客单集下载提交异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
def _podcast_billing_count() -> int:
"""播客打包计费:指定单集列表按数量扣;整台电台提交时先扣 1 次,
worker 解析完实际单集数后补扣差额(一首单集 = 1 次)
F22:与路由完全同口径——ids 模式先做与路由一致的 limit 截断,避免多扣"""
try:
data = api_service._safe_get_request_data()
if data.get('radio_id'):
return 1
try:
limit = int(data.get('limit', 200) or 200)
except (TypeError, ValueError):
limit = 200
limit = max(1, min(limit, 500))
raw = data.get('ids')
if isinstance(raw, str):
raw_list = raw.split(',')
else:
raw_list = list(raw or [])
# 与路由完全同口径(提取→去重):URL 形式与纯数字形式的同一单集不再重复计费
ids, seen = [], set()
for x in raw_list:
try:
mid = api_service._extract_music_id(x)
except Exception:
mid = str(x).strip()
if mid and mid not in seen:
seen.add(mid)
ids.append(mid)
return max(1, min(len(ids), limit))
except Exception:
return 1
@app.route('/podcast/download/batch_zip', methods=['POST'])
@auth_system.login_required('播客打包', cost=_podcast_billing_count)
def podcast_batch_zip_api():
"""整台播客打包:后台任务逐集下载并入 ZIP,完成后经 /download/batch_zip/result 交付
参数: radio_id(电台ID/链接) 或 ids(单集ID列表,逗号分隔), limit(最多集数), quality, task_id, device_id
"""
try:
data = api_service._safe_get_request_data()
raw_radio = data.get('radio_id')
raw_ids = data.get('ids')
if not raw_radio and not raw_ids:
return APIResponse.error("缺少 radio_id 或 ids 参数")
try:
limit = int(data.get('limit', 200) or 200)
except (TypeError, ValueError):
limit = 200
limit = max(1, min(limit, 500))
quality = str(data.get('quality') or 'auto')
if quality not in ('auto', 'standard', 'exhigh', 'lossless', 'hires'):
quality = 'auto'
cookies = api_service._get_cookies()
if raw_radio:
ref = podcast_api.extract_podcast_ref(raw_radio)
if ref['kind'] == 'program':
return APIResponse.error("这是单集链接,请使用「下载单集」或直接输入电台链接/ID")
radio_id = ref['id']
radio_name = ''
try:
radio_name = podcast_api.get_radio_detail(radio_id, cookies).get('name', '')
except APIException:
pass
else:
# 直接传单集ID列表(从解析结果勾选);F46:数组元素统一 str() 清洗
if isinstance(raw_ids, str):
id_list = [x.strip() for x in raw_ids.split(',') if x.strip()]
else:
id_list = [str(x).strip() for x in (raw_ids or []) if str(x).strip()]
# 与音乐批量同口径(S10):提取 ID 后去重,重复单集不多扣也不重复下载
_seen = set()
deduped = []
for x in id_list:
mid = api_service._extract_music_id(x)
if mid and mid not in _seen:
_seen.add(mid)
deduped.append(mid)
id_list = deduped[:limit]
if not id_list:
return APIResponse.error("缺少 ids 参数")
radio_id = None
radio_name = str(data.get('name') or '播客单集')
task_id_hint = str(data.get('task_id') or '').strip()
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
_touch_device(device_id)
_cu = auth_system.current_user()
_billed = _podcast_billing_count()
_unlimited = (_cu is None or _cu['role'] == 'admin'
or int(_cu['duration_seconds'] or 0) == -1)
_reserved = 0 if _unlimited else _billed
task = {
'type': 'batch',
'billing_user_id': int(_cu['id']) if _cu else 0,
'billing_reserved': _reserved,
'total': 0, # worker 解析完单集列表后回填
'done': 0,
'failed': 0,
'status': 'queued',
'start': time.time(),
'msg': '排队中...',
'cancel': threading.Event(),
'pause': threading.Event(),
'device_id': device_id,
'result': None,
'params': {
'radio_id': radio_id,
'ids': None if radio_id else id_list,
'limit': limit,
'quality': quality,
'radio_name': radio_name,
'cookies': cookies,
'remote_ip': auth_system._client_ip(),
# 整台电台模式:worker 解析完实际单集数后按差额补扣(一首 = 1 次)
'billing_uid': (_cu['id'] if _cu is not None else None),
'billed': _billed,
},
}
reg_err = _register_download_task(task, _reserved, task_id_hint=task_id_hint)
if reg_err:
return APIResponse.error(reg_err, 429)
task_id = task['task_id']
start_err = _start_task_thread(task_id, _podcast_zip_worker, (task_id,))
if start_err:
return APIResponse.error(start_err, 500)
return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "播客打包任务已提交")
except APIException as e:
return APIResponse.error(str(e), 404)
except Exception as e:
api_service.logger.error(f"播客打包提交异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
def _podcast_safe_name(s: str, fallback: str = 'unknown') -> str:
"""文件名安全化(去除非法字符)"""
s = ''.join(c for c in str(s or '') if c not in r'<>:"/\|?*' and ord(c) >= 32).strip()
return s or fallback
def _podcast_stream_download(url: str, target: Path, total_size: int,
cancel_ev: threading.Event,
task_id: str = '', progress_label: str = '') -> int:
"""流式下载播客音频到 target(先写 .part 再改名),返回实际字节数
自动加入全局下载信号量排队;每 0.5s 更新一次任务进度消息。
"""
import requests as _requests
done = 0
last_update = 0.0
part = target.with_name(target.name + '.part')
# B2:等待全局下载名额期间可被取消(与音乐批量 BATCH_ZIP_SEM 同语义)
sem = api_service.downloader._download_semaphore
if not sem.acquire_cancellable(cancel_ev):
raise DownloadCanceled("任务已取消")
try:
with _requests.get(url, stream=True, timeout=(15, 60), headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36',
'Referer': 'https://music.163.com/',
}) as r:
r.raise_for_status()
total = total_size or int(r.headers.get('Content-Length') or 0)
with open(part, 'wb') as f:
for chunk in r.iter_content(chunk_size=256 * 1024):
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
if chunk:
f.write(chunk)
done += len(chunk)
now = time.time()
if now - last_update > 0.5:
last_update = now
if total:
pct = min(99, int(done / total * 100))
msg = f"{progress_label} {pct}% · {api_service._format_file_size(done)}/{api_service._format_file_size(total)}"
else:
msg = f"{progress_label} 已下载 {api_service._format_file_size(done)}"
if task_id:
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t and t['status'] == 'running':
t['msg'] = msg
# 单集任务的字节级进度(批量任务保持按集数口径)
if t.get('type') == 'single' and total:
t['live_percent'] = min(99.0, float(pct))
finally:
sem.release()
if part.exists():
part.replace(target)
# 完整性校验:CDN 提前断流时 iter_content 会"正常"结束,文件静默截断
# (F29 同口径:API 报告的期望大小时精确相等——95% 容差会交付截断的坏文件还扣次数;
# 期望未知时退化为非空校验,Content-Length 兜底值可能是压缩口径不做精确比对)
if total_size and done != total_size:
try:
os.remove(str(target))
except OSError:
pass
raise DownloadException(f"下载不完整({done}/{total_size} 字节),请重试")
if not total_size and done <= 0:
try:
os.remove(str(target))
except OSError:
pass
raise DownloadException("下载内容为空,请重试")
return done
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:
raise DownloadException("该单集缺少音频信息(mainSong)")
url_info = podcast_api.resolve_episode_url(song_id, cookies, quality)
if not url_info or not url_info.get('url'):
raise DownloadException("未获取到音频直链(单集可能已下架或受版权限制)")
return url_info
@app.route('/comments', methods=['GET', 'POST'])
@auth_system.login_required('评论获取', free=True)
def get_song_comments_api():
"""获取歌曲评论API"""
try:
data = api_service._safe_get_request_data()
song_id = data.get('id') or data.get('song_id')
limit = max(1, min(_safe_int(data.get('limit'), 20), 100))
offset = max(0, _safe_int(data.get('offset'), 0))
validation_error = api_service._validate_request_params({'song_id': song_id})
if validation_error:
return validation_error
if limit > 100:
limit = 100
song_id = api_service._extract_music_id(song_id)
# ID 白名单:归一化后必须为纯数字(防 URL 路径拼接注入面,同 /album)
if not str(song_id).strip().isdigit():
return APIResponse.error("无效的歌曲ID,请输入数字ID或分享链接")
result = song_comments(song_id, limit, offset)
return APIResponse.success(result, "获取评论成功")
except ValueError as e:
return APIResponse.error(f"参数格式错误: {str(e)}")
except APIException as e:
api_service.logger.error(f"获取评论失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"获取评论异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
# ============ 管理员设置(二维码登录 / Cookie 管理) ============
def _admin_client() -> QRLoginClient:
"""创建二维码登录客户端(基于 cookie.txt)"""
return QRLoginClient()
@app.route('/admin/qr/create', methods=['POST'])
def admin_qr_create():
"""生成登录二维码(返回 SVG)"""
try:
data = api_service._safe_get_request_data()
auth_error = _verify_admin_password(data)
if auth_error:
return auth_error
client = _admin_client()
unikey = client.qr_manager.generate_qr_key()
if not unikey:
return APIResponse.error("生成二维码失败", 500)
import qrcode
from qrcode.image.svg import SvgPathImage
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M)
qr.add_data(f'https://music.163.com/login?codekey={unikey}')
qr.make(fit=True)
svg_bytes = qr.make_image(image_factory=SvgPathImage).to_string()
if isinstance(svg_bytes, bytes):
svg_bytes = svg_bytes.decode('utf-8')
return APIResponse.success({
'unikey': unikey,
'qr_svg': svg_bytes,
'expires_in': 180
}, "二维码生成成功")
except Exception as e:
api_service.logger.error(f"生成二维码异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/admin/qr/check', methods=['POST'])
def admin_qr_check():
"""轮询二维码登录状态,成功时自动保存 Cookie"""
try:
data = api_service._safe_get_request_data()
auth_error = _verify_admin_password(data)
if auth_error:
return auth_error
unikey = data.get('unikey')
if not unikey:
return APIResponse.error("缺少 unikey 参数")
client = _admin_client()
code, cookie_dict = client.qr_manager.check_qr_login(unikey)
if code == 803:
# 登录成功,自动保存 Cookie
musu_u = cookie_dict.get('MUSIC_U', '')
if not musu_u:
return APIResponse.error("登录成功但未获取到 Cookie", 500)
# 第 94 条(P1-2):__csrf 一并入库——weapi 写接口(发评论等)的
# csrf_token 取自 Cookie 的 __csrf,缺失则写接口恒被上游拒绝
cookie = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;"
if cookie_dict.get('__csrf'):
cookie += f"__csrf={cookie_dict['__csrf']};"
if not client.save_cookie(cookie):
return APIResponse.error("登录成功但 Cookie 保存失败", 500)
_vip_cache_reset() # 账号已变更,VIP 探测缓存立即失效
_global_music_u_cache_reset() # 第 92 条闸的全局 MUSIC_U 缓存同步刷新
api_service.logger.info("二维码登录成功,Cookie 已自动保存")
return APIResponse.success(
{'status': 'success', 'cookie_info': client._get_cookie_info()},
"登录成功,Cookie 已保存")
elif code == 801:
return APIResponse.success({'status': 'waiting'}, "等待扫码")
elif code == 802:
return APIResponse.success({'status': 'scanned'}, "已扫码,请在手机上确认登录")
elif code == 800:
return APIResponse.success({'status': 'expired'}, "二维码已过期")
else:
return APIResponse.success({'status': 'error', 'code': code}, f"登录状态异常({code})")
except Exception as e:
api_service.logger.error(f"检查登录状态异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/admin/cookie/status', methods=['POST'])
def admin_cookie_status():
"""获取当前 Cookie 状态"""
try:
data = api_service._safe_get_request_data()
auth_error = _verify_admin_password(data)
if auth_error:
return auth_error
client = _admin_client()
return APIResponse.success(client._get_cookie_info(), "获取 Cookie 状态成功")
except Exception as e:
api_service.logger.error(f"获取 Cookie 状态异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/admin/cookie/delete', methods=['POST'])
def admin_cookie_delete():
"""删除(清除)Cookie,备份后删除,可重新扫码登录"""
try:
data = api_service._safe_get_request_data()
auth_error = _verify_admin_password(data)
if auth_error:
return auth_error
client = _admin_client()
# F60:检查登出返回值,失败如实报告(原实现无论成败都回"已清除")
ok = client.logout()
if not ok:
info = client._get_cookie_info()
return APIResponse.error(
f"Cookie 清除失败(文件仍存在:{info.get('file_exists')},"
f"可能被占用或为挂载点,请手动删除)", 500)
_vip_cache_reset() # Cookie 已删除,VIP 探测缓存立即失效
_global_music_u_cache_reset() # 第 92 条闸的全局 MUSIC_U 缓存同步刷新
api_service.logger.info("管理员已清除 Cookie")
return APIResponse.success(client._get_cookie_info(), "Cookie 已清除")
except Exception as e:
api_service.logger.error(f"清除 Cookie 异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/admin/logs', methods=['POST'])
def admin_logs():
"""用户 IP 解析/下载记录(分页)"""
try:
data = api_service._safe_get_request_data()
auth_error = _verify_admin_password(data)
if auth_error:
return auth_error
page = max(1, _safe_int(data.get('page'), 1))
page_size = max(1, min(50, _safe_int(data.get('page_size'), 10)))
logs = list(reversed(REQ_LOG)) # 最新在前
total = len(logs)
start = (page - 1) * page_size
items = logs[start:start + page_size]
return APIResponse.success({
'total': total,
'page': page,
'page_size': page_size,
'pages': max(1, (total + page_size - 1) // page_size),
'items': items,
}, "获取操作记录成功")
except ValueError:
return APIResponse.error("参数格式错误")
except Exception as e:
api_service.logger.error(f"获取操作记录异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
# ============ 个人歌单(游客扫码登录自己的账号) ============
def _user_client() -> QRLoginClient:
"""创建个人用户二维码登录客户端(第 91 条后不再落盘 user_cookie.txt,
USER_COOKIE_FILE 仅作 QRLoginClient 构造参数遗留)"""
return QRLoginClient(USER_COOKIE_FILE)
@app.route('/user/qr/create', methods=['POST'])
def user_qr_create():
"""生成个人登录二维码(返回 SVG),游客无需密码"""
try:
client = _user_client()
unikey = client.qr_manager.generate_qr_key()
if not unikey:
return APIResponse.error("生成二维码失败", 500)
import qrcode
from qrcode.image.svg import SvgPathImage
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M)
qr.add_data(f'https://music.163.com/login?codekey={unikey}')
qr.make(fit=True)
svg_bytes = qr.make_image(image_factory=SvgPathImage).to_string()
if isinstance(svg_bytes, bytes):
svg_bytes = svg_bytes.decode('utf-8')
return APIResponse.success({
'unikey': unikey,
'qr_svg': svg_bytes,
'expires_in': 180
}, "二维码生成成功")
except Exception as e:
api_service.logger.error(f"生成个人二维码异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/user/qr/check', methods=['POST'])
def user_qr_check():
"""轮询个人登录状态(第 91 条后不写服务器文件;站点账号登录时同步一份
到站点库供管理后台查看,第 98 条注释口径与 UI 对齐)"""
try:
data = api_service._safe_get_request_data()
unikey = data.get('unikey')
if not unikey:
return APIResponse.error("缺少 unikey 参数")
client = _user_client()
code, cookie_dict = client.qr_manager.check_qr_login(unikey)
if code == 803:
musu_u = cookie_dict.get('MUSIC_U', '')
if not musu_u:
return APIResponse.error("登录成功但未获取到 Cookie", 500)
# 个人登录信息仅返回给浏览器(存 localStorage),不写入服务器文件。
# 第 94 条(P1-2):__csrf 一并返回(weapi 写接口的 csrf_token 来源)
cookie = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;"
if cookie_dict.get('__csrf'):
cookie += f"__csrf={cookie_dict['__csrf']};"
# 站点登录用户:同步记录网易云 Cookie 供后台管理查看
try:
_site_user = auth_system.current_user()
if _site_user is not None:
auth_system.set_user_netease_cookie(_site_user['username'], cookie)
except Exception:
pass
api_service.logger.info("个人账号扫码登录成功(Cookie 已返回浏览器本地保存)")
return APIResponse.success(
{'status': 'success', 'cookie': cookie},
"登录成功,Cookie 已保存在本机浏览器")
elif code == 801:
return APIResponse.success({'status': 'waiting'}, "等待扫码")
elif code == 802:
return APIResponse.success({'status': 'scanned'}, "已扫码,请在手机上确认登录")
elif code == 800:
return APIResponse.success({'status': 'expired'}, "二维码已过期")
else:
return APIResponse.success({'status': 'error', 'code': code}, f"登录状态异常({code})")
except Exception as e:
api_service.logger.error(f"检查个人登录状态异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/user/status', methods=['POST'])
def user_status():
"""获取个人用户登录状态与账号信息(Cookie 由浏览器本地保存并随请求传入)"""
try:
data = api_service._safe_get_request_data()
cookies = _resolve_user_cookies(data)
if not cookies:
return APIResponse.success({'cookie': {'is_valid': False}, 'user': None}, "个人账号未登录")
user = account_info(cookies)
return APIResponse.success({'cookie': {'is_valid': user is not None}, 'user': user}, "获取个人登录状态成功")
except Exception as e:
api_service.logger.error(f"获取个人登录状态异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/user/logout', methods=['POST'])
def user_logout():
"""退出个人账号登录(浏览器清除本地 Cookie 即可,服务器无可清除内容)"""
try:
return APIResponse.success({'is_valid': False}, "已退出登录")
except Exception as e:
api_service.logger.error(f"个人退出登录异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/user/playlists', methods=['POST'])
def user_playlists_api():
"""获取个人用户收藏/创建的歌单(含「我喜欢的音乐」)"""
try:
data = api_service._safe_get_request_data()
cookies = _resolve_user_cookies(data)
if not cookies:
return APIResponse.error("请先扫码登录你的网易云账号", 401)
account = account_info(cookies)
if not account or not account.get('userId'):
return APIResponse.error("获取账号信息失败,Cookie 可能已失效,请重新登录", 401)
playlists = user_playlists(account['userId'], cookies)
# R5-P3:列表内歌单名/创建者昵称过遮蔽(与 /playlist、/search 口径一致)
if isinstance(playlists, list):
for pl in playlists:
if isinstance(pl, dict):
if pl.get('name'):
pl['name'] = _mask_server_nickname(pl['name'])
cr = pl.get('creator')
if isinstance(cr, dict) and cr.get('nickname'):
cr['nickname'] = _mask_server_nickname(cr['nickname'])
return APIResponse.success({'user': account, 'playlists': playlists}, "获取个人歌单成功")
except APIException as e:
api_service.logger.error(f"获取个人歌单失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"获取个人歌单异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/user/playlist', methods=['POST'])
def user_playlist_api():
"""获取个人歌单详情(使用个人 Cookie,支持「我喜欢的音乐」等私有歌单)"""
try:
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:
return validation_error
cookies = _resolve_user_cookies(data)
if not cookies:
return APIResponse.error("请先扫码登录你的网易云账号", 401)
playlist_id = api_service._extract_music_id(playlist_id)
# 第 91 条(P3-3):与 /playlist 同口径的数字白名单
if not str(playlist_id).strip().isdigit():
return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接")
result = playlist_detail(playlist_id, cookies)
# R5-P3:详情内歌单名/创建者昵称同样过遮蔽(铁律:服务器昵称对非 admin 不可见)
if isinstance(result, dict):
pl = result.get('playlist')
if isinstance(pl, dict):
if pl.get('name'):
pl['name'] = _mask_server_nickname(pl['name'])
cr = pl.get('creator')
if isinstance(cr, dict) and cr.get('nickname'):
cr['nickname'] = _mask_server_nickname(cr['nickname'])
return APIResponse.success({'playlist': result}, "获取个人歌单详情成功")
except APIException as e:
api_service.logger.error(f"获取个人歌单详情失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"获取个人歌单详情异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/user/collect', methods=['POST'])
@auth_system.login_required(free=True)
def user_collect():
"""收藏/取消收藏歌单或专辑(官方接口,真实收藏到个人账号)"""
try:
data = api_service._safe_get_request_data()
cookies = _resolve_user_cookies(data)
if not cookies:
return APIResponse.error("请先扫码登录你的网易云账号", 401)
collect_id = data.get('id')
collect_type = data.get('type', 'playlist') # playlist | album
action = _safe_int(data.get('action'), 1) # 1=收藏, 2=取消收藏(垃圾值不 500)
if not collect_id:
return APIResponse.error("缺少 id 参数")
if collect_type not in ('playlist', 'album'):
return APIResponse.error("type 仅支持 playlist/album")
if action not in (1, 2):
return APIResponse.error("action 仅支持 1(收藏)/2(取消)")
collect_id = api_service._extract_music_id(collect_id)
# 第 91 条(P3-3):数字白名单
if not str(collect_id).strip().isdigit():
return APIResponse.error("无效的ID,请输入数字ID或分享链接")
if collect_type == 'album':
result = album_subscribe(collect_id, cookies, action)
else:
result = playlist_subscribe(collect_id, cookies, action)
return APIResponse.success({'result': result}, "收藏成功" if action == 1 else "取消收藏成功")
except APIException as e:
api_service.logger.error(f"收藏操作失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"收藏操作异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/user/comment', methods=['POST'])
@auth_system.login_required(free=True)
def user_comment():
"""发送/回复歌曲评论(官方 weapi 接口,使用个人账号)"""
try:
data = api_service._safe_get_request_data()
cookies = _resolve_user_cookies(data)
if not cookies:
return APIResponse.error("请先扫码登录你的网易云账号", 401)
song_id = data.get('song_id') or data.get('id')
content = (data.get('content') or '').strip()
action = _safe_int(data.get('action'), 1) # 1=发送评论, 2=回复评论(垃圾值不 500)
comment_id = data.get('comment_id') or data.get('reply_to')
if not song_id:
return APIResponse.error("缺少歌曲 ID")
if not content:
return APIResponse.error("评论内容不能为空")
if len(content) > 140:
return APIResponse.error("评论内容不能超过 140 字")
if action not in (1, 2):
return APIResponse.error("action 仅支持 1(评论)/2(回复)")
if action == 2 and not comment_id:
return APIResponse.error("回复评论缺少目标评论 ID")
song_id = api_service._extract_music_id(song_id)
# R5-P3:thread_id 由 song_id 拼接,垃圾值先拦下(与 /download 同口径)
if not str(song_id).isdigit():
return APIResponse.error("无效的歌曲 ID")
thread_id = f"R_SO_4_{song_id}"
# 回复目标 ID 归一化:垃圾值不再触发 int() 500
reply_target = _safe_int(comment_id, 0) if comment_id else 0
if action == 2 and reply_target <= 0:
return APIResponse.error("回复评论的目标评论 ID 无效")
result = post_comment(thread_id, content, action,
reply_target if reply_target > 0 else None, cookies)
return APIResponse.success({'result': result}, "评论成功" if action == 1 else "回复成功")
except APIException as e:
api_service.logger.error(f"评论操作失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"评论操作异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
# ============ 全局账号歌单(管理员 cookie.txt,无需游客扫码) ============
VALID_QUALITY_LEVELS = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby']
@app.route('/global/playlists', methods=['GET', 'POST'])
@auth_system.admin_required
def global_playlists_api():
"""获取全局账号收藏/创建的歌单(含「我喜欢的音乐」)"""
try:
cookies = api_service._get_cookies()
account = account_info(cookies)
if not account or not account.get('userId'):
return APIResponse.error("全局账号未登录或 Cookie 已失效,请先在「管理员设置」扫码登录", 401)
playlists = user_playlists(account['userId'], cookies)
return APIResponse.success({'user': account, 'playlists': playlists}, "获取全局账号歌单成功")
except APIException as e:
api_service.logger.error(f"获取全局账号歌单失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"获取全局账号歌单异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/global/playlist', methods=['GET', 'POST'])
@auth_system.admin_required
def global_playlist_api():
"""获取全局账号歌单详情(使用全局 Cookie,支持「我喜欢的音乐」等私有歌单)"""
try:
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:
return validation_error
cookies = api_service._get_cookies()
playlist_id = api_service._extract_music_id(playlist_id)
# 第 91 条(P3-3):数字白名单
if not str(playlist_id).strip().isdigit():
return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接")
result = playlist_detail(playlist_id, cookies)
return APIResponse.success({'playlist': result}, "获取全局账号歌单详情成功")
except APIException as e:
api_service.logger.error(f"获取全局账号歌单详情失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"获取全局账号歌单详情异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/api/playlist/play', methods=['GET', 'POST'])
def playlist_play_api():
"""公开的歌单播放 API(方案 C:需 API 密钥,管理后台「播放API密钥」可刷新)
返回歌单信息与每首歌的可播放直链(使用全局账号解析音质)。
GET/POST 均支持,参数:
id - 必填,歌单 ID(或网易云分享链接)
key - 必填,播放 API 密钥(管理后台生成/刷新,旧密钥刷新即失效)
level - 选填,音质等级,默认 lossless(支持 standard/exhigh/lossless/hires/sky/jyeffect/jymaster/dolby)
limit - 选填,最多返回的歌曲数量,默认 100,最大 500(0 或超过按 500 封顶,第 99 条文案与实现对齐)
"""
try:
data = api_service._safe_get_request_data()
playlist_id = data.get('id')
level = data.get('level', 'lossless')
raw_limit = data.get('limit', 100)
# 方案 C(第 78 条):密钥前置校验——放在一切上游调用之前,
# 无密钥的匿名请求 0 次上游调用(消除 1:501 的放大面)
provided = str(data.get('key') or request.args.get('key') or '')
expected = auth_system.get_setting('playlist_play_key', '')
if not expected or not provided or not hmac.compare_digest(provided, expected):
return APIResponse.error("需要有效的 API 密钥(请联系站点管理员获取)", 401)
validation_error = api_service._validate_request_params({'playlist_id': playlist_id})
if validation_error:
return validation_error
if level not in VALID_QUALITY_LEVELS:
return APIResponse.error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}")
try:
limit = int(raw_limit)
except (TypeError, ValueError):
return APIResponse.error("limit 参数必须为整数")
if limit < 0:
return APIResponse.error("limit 参数不能为负数")
if limit == 0:
# F18:limit=0 表示"全部",但必须封顶 500(原实现可对 501 首歌单逐首放大上游请求)
limit = 500
if limit > 500:
limit = 500
playlist_id = api_service._extract_music_id(playlist_id)
# 第 91 条(P3-3):数字白名单
if not str(playlist_id).strip().isdigit():
return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接")
cookies = api_service._get_cookies()
detail = playlist_detail(playlist_id, cookies)
all_tracks = detail.get('tracks', [])
tracks = all_tracks if limit == 0 else all_tracks[:limit]
# 逐首解析播放直链,跳过无版权/无法解析的歌曲
# O-2:小并发(4)解析——原实现 500 首串行逐首请求可达数分钟;
# 4 并发对齐批量下载窗口的节奏,不放大风控面
from concurrent.futures import ThreadPoolExecutor
playable = []
skipped = 0
def _resolve_track(t):
try:
url_info = url_v1(t['id'], level, cookies)
if url_info and url_info.get('data') and url_info['data'][0].get('url'):
d = url_info['data'][0]
return {
'id': t['id'],
'name': t.get('name', ''),
'artists': t.get('artists', ''),
'album': t.get('album', ''),
'picUrl': t.get('picUrl', ''),
'url': d.get('url', ''),
'level': d.get('level', level),
'level_name': api_service._get_quality_display_name(d.get('level', level)),
'size': d.get('size', 0),
'size_formatted': api_service._format_file_size(d.get('size', 0)),
'type': d.get('type'),
'bitrate': d.get('br'),
}
return None # 该曲不可播放
except Exception:
return None
with ThreadPoolExecutor(max_workers=4) as _pool:
for item in _pool.map(_resolve_track, tracks):
if item is not None:
playable.append(item)
else:
skipped += 1
response_data = {
'playlist': {
'id': detail.get('id'),
'name': _mask_server_nickname(detail.get('name', '')),
'coverImgUrl': detail.get('coverImgUrl', ''),
'creator': _mask_server_nickname(detail.get('creator', '')),
'trackCount': detail.get('trackCount', len(all_tracks)),
},
'account': 'admin',
'level': level,
'level_name': api_service._get_quality_display_name(level),
'limit': limit or len(tracks),
'total': len(all_tracks),
'playable': len(playable),
'skipped': skipped,
'tracks': playable,
}
return APIResponse.success(response_data, "获取歌单播放列表成功")
except APIException as e:
api_service.logger.error(f"获取歌单播放列表失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
except Exception as e:
api_service.logger.error(f"获取歌单播放列表异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
# 全局账号 VIP 探测缓存已迁出至 core.state(此处 import 同名共享):
# _VIP_CACHE(5 分钟;第 91 条修复的缩进缺陷原样保留正确语义)
# _VIP_USER_CACHE(user 路径按 cookie 哈希的 5 分钟缓存)
def _vip_cache_reset() -> None:
"""重新扫码登录/删除 Cookie 后调用,强制下次探测走真实接口"""
_VIP_CACHE['data'] = None
_VIP_CACHE['ts'] = 0.0
@app.route('/account/vip', methods=['GET', 'POST'])
def account_vip_info():
"""获取当前解析账号的 VIP 类型(admin=全局管理员账号 / user=个人账号)"""
try:
data = api_service._safe_get_request_data()
account = data.get('account', 'admin')
if account == 'user':
user_cookies = _resolve_user_cookies(data)
if not user_cookies:
return APIResponse.success(
{'account': 'user', 'logged_in': False, 'vip_type': None, 'vip_name': '未登录',
'levels_ok': []},
"个人账号未登录")
# 第 91 条(P3-7):user 路径加 5 分钟缓存——_detect_vip 每次调用 =
# 8 次音质探测 + 1 次账号接口,匿名 30/分/IP 打满即 270 次上游调用/分
ck = '|'.join(f'{k}={v}' for k, v in sorted(user_cookies.items()))
ck_hash = hashlib.md5(ck.encode('utf-8')).hexdigest()
cached_user = _VIP_USER_CACHE.get(ck_hash)
if cached_user is not None and time.time() - cached_user['ts'] <= cached_user.get('ttl', 300):
return APIResponse.success(dict(cached_user['data'], account='user'),
"获取个人账号信息成功(缓存)")
vip_type, method, levels_ok = _detect_vip(user_cookies)
info = account_info(user_cookies)
user_payload = {
'account': 'user', 'logged_in': True, 'vip_type': vip_type,
'vip_name': _vip_name(vip_type), 'detected_by': method,
'levels_ok': levels_ok,
'nickname': (info or {}).get('nickname', ''),
}
# 第 100 条(对抗复审):降级结果(探测列表为空 = 单飞超时/预算耗尽)只缓存
# 30s——上游恢复后 VIP 账号不再被"普通用户"钉满 5 分钟;正常结果仍 300s
_vip_ttl = 30 if not levels_ok else 300
if len(_VIP_USER_CACHE) > 500:
_VIP_USER_CACHE.clear()
_VIP_USER_CACHE[ck_hash] = {'ts': time.time(), 'ttl': _vip_ttl, 'data': user_payload}
return APIResponse.success(user_payload, "获取个人账号信息成功")
# admin(默认)—— 探测结果缓存 5 分钟
cached = _VIP_CACHE['data']
if cached is None or time.time() - _VIP_CACHE['ts'] > _VIP_CACHE.get('ttl', 300):
admin_cookies = api_service._get_cookies()
vip_type, method, levels_ok = _detect_vip(admin_cookies)
info = account_info(admin_cookies)
cached = {
'vip_type': vip_type, 'vip_name': _vip_name(vip_type),
'detected_by': method, 'levels_ok': levels_ok,
'nickname': (info or {}).get('nickname', ''),
'logged_in': info is not None,
'note': '' if info else '账号接口未返回会员信息,已通过歌曲音质探测接口检测',
}
_VIP_CACHE['data'] = cached
_VIP_CACHE['ts'] = time.time()
_VIP_CACHE['ttl'] = 30 if not levels_ok else 300 # 第 100 条:降级结果短缓存
# 服务器账号昵称仅站点管理员可见;VIP 等级与可解析音质对所有人开放
_cu = auth_system.current_user()
_is_admin = _cu is not None and _cu['role'] == 'admin'
payload = dict(cached)
payload['account'] = 'admin'
payload['nickname'] = cached['nickname'] if _is_admin else ''
return APIResponse.success(payload, "获取全局账号信息成功")
except Exception as e:
api_service.logger.error(f"获取账号VIP信息异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/download', methods=['POST'])
@app.route('/Download', methods=['POST']) # 向后兼容(F10:付费动作仅接受 POST,杜绝跨站 GET 顶层导航扣费)
@auth_system.login_required('音乐下载')
def download_music_api():
"""下载音乐API"""
try:
# 获取请求参数
data = api_service._safe_get_request_data()
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:
return validation_error
# R5-P3:ID 数字白名单(与 /song 同口径;付费入口优先收紧)
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=全局管理员账号
account = data.get('account', 'admin')
if account == 'user':
cookies = _resolve_user_cookies(data)
if not cookies:
return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401)
else:
cookies = api_service._get_cookies()
# 下载配置参数
filename_format = data.get('filename_format', 'artist_song') # song | artist_song | song_artist
write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes')
meta_fields = _parse_meta_fields(data.get('meta_fields'))
lyric_format = data.get('lyric_format', 'lrc') # lrc | srt
# F-C:白名单——垃圾 lyric_format 会拼出覆盖音频条目的 ZIP arcname
if lyric_format not in ('lrc', 'srt'):
lyric_format = 'lrc'
need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes')
# return_format=json:同步下载并返回歌曲信息(不产生后台任务)
if return_format == 'json':
# 一次预取 URL/详情/歌词(3 接口并行),同时供下载与响应使用,
# 避免下载器内部再走一遍 API
try:
info = api_service.downloader.get_music_info(music_id, quality, cookies)
except DownloadException as e:
# R5-P3:对外泛化(原始错误含上游主机/URL 细节),细节留日志
api_service.logger.error(f"获取音乐信息失败: {e}")
return APIResponse.error("获取失败:歌曲不存在或暂无版权", 404)
# 按前端选择的命名格式生成本次响应的文件名
base = _build_download_base(filename_format, {'name': info.name, 'artist_string': info.artists})
filename = f"{base}.{info.file_type}"
try:
download_result = api_service.downloader.download_music_file(
music_id, quality,
write_meta=write_meta,
meta_fields=meta_fields,
cookies=cookies,
music_info=info,
)
if not download_result.success:
# 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}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
try:
_record_log('下载', auth_system._client_ip(), info.name)
except Exception:
pass
response_data = {
'music_id': music_id,
'name': info.name,
'artist': info.artists.replace('/', ', '),
'album': info.album,
'quality': quality,
'quality_name': api_service._get_quality_display_name(quality),
'file_type': info.file_type,
'file_size': info.file_size,
'file_size_formatted': api_service._format_file_size(info.file_size),
# P3-29:不再回传服务器绝对路径(路径信息披露);交付语义只需文件名,
# 文件在 downloads 根目录按 35 分钟口径由缓存清扫回收
'filename': filename,
'duration': info.duration * 1000
}
# A2/B8:json 模式无 task_id,成功交付即按请求级账本键结算 delivered=1
_gkey = getattr(g, 'billing_ledger_key', None)
if _gkey:
try:
auth_system.ledger_settle(_gkey, 1, 'delivered')
except Exception as _e:
# 结算失败不改交付结果;账本行留 open,重启对账兜底退款(宁商家小损不坑用户)
api_service.logger.error(f"json 交付结算失败(重启对账兜底): {_gkey} {_e}")
return APIResponse.success(response_data, "下载完成")
# return_format=file:注册后台任务,立即返回 task_id(支持进度/取消/完成后自动清理)
task_id = str(data.get('task_id') or '').strip()
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
_touch_device(device_id)
_cur_user = auth_system.current_user()
_reserved = 0 if (not _cur_user or _cur_user['role'] == 'admin'
or int(_cur_user['duration_seconds'] or 0) == -1) else 1
task = {
'type': 'single',
'billing_user_id': int(_cur_user['id']) if _cur_user else 0,
'billing_reserved': _reserved,
'total': 1,
'done': 0,
'failed': 0,
'status': 'queued',
'start': time.time(),
'msg': '排队中...',
'cancel': threading.Event(),
'pause': threading.Event(),
'device_id': device_id,
'result': None,
'params': {
'music_id': music_id,
'quality': quality,
'filename_format': filename_format,
'write_meta': write_meta,
'meta_fields': meta_fields,
'lyric_format': lyric_format,
'need_zip': need_zip,
'cookies': cookies,
'remote_ip': auth_system._client_ip(),
},
}
reg_err = _register_download_task(task, _reserved, task_id_hint=task_id)
if reg_err:
return APIResponse.error(reg_err, 429)
task_id = task['task_id']
start_err = _start_task_thread(task_id, _single_download_worker, (task_id,))
if start_err:
return APIResponse.error(start_err, 500)
return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "下载任务已提交")
except Exception as e:
api_service.logger.error(f"下载音乐异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
def _billing_ids_count() -> int:
"""按次计费:解析请求中的 ids 数量(一首歌 = 1 次;100 首歌单打包 = 100 次)
F23:与路由的清洗规则完全一致(提取→去重),预扣数=实际执行数,重复 ID 不多扣"""
try:
data = api_service._safe_get_request_data()
raw = data.get('ids')
if isinstance(raw, str):
raw_list = raw.split(',')
else:
raw_list = list(raw or [])
ids, seen = [], set()
for x in raw_list:
try:
mid = api_service._extract_music_id(x)
except Exception:
mid = str(x).strip()
if mid and mid not in seen:
seen.add(mid)
ids.append(mid)
# 与路由同口径:单任务上限 500(超出部分路由会 400 拒绝并退款,
# 预扣数先封顶可让"请分批提交"提示优先于"次数不足")
return max(1, min(len(ids), 500))
except Exception:
return 1
@app.route('/download/batch_zip', methods=['POST'])
@auth_system.login_required('批量ZIP下载', cost=_billing_ids_count)
def download_batch_zip():
"""提交批量打包任务(后台线程执行,立即返回 task_id,前端轮询进度/取消)
改为后台任务的原因:
- 原方案在请求线程里一次性构建并回传大 ZIP,长连接易被代理/浏览器超时中断(~60% 时失败)
- 后台执行 + 轮询,连接断开不影响任务继续;音频入包即删,缓解磁盘占用
"""
try:
data = api_service._safe_get_request_data()
task_id_hint = str(data.get('task_id') or '').strip()
raw_ids = data.get('ids')
if isinstance(raw_ids, str):
raw_id_list = [x.strip() for x in raw_ids.split(',') if x.strip()]
else:
# F46:数组分支元素可能是数字,统一 str() 后再清洗(原实现直接 x.strip() 会 500)
raw_id_list = [str(x).strip() for x in (raw_ids or []) if str(x).strip()]
if not raw_id_list:
return APIResponse.error("缺少 ids 参数")
# R5-P3:ID 数字白名单(与 /download 同口径;垃圾 ID 不再透传上游)
non_digit = [x for x in raw_id_list if not x.isdigit()]
if non_digit:
return APIResponse.error("ids 中含无效歌曲 ID")
quality = data.get('quality', 'lossless')
# 音质白名单校验(与 /download 同口径:垃圾音质直接 400,而不是让整批全失败再退费)
valid_qualities = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby']
if quality not in valid_qualities:
return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_qualities)}")
filename_format = data.get('filename_format', 'artist_song')
write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes')
meta_fields = _parse_meta_fields(data.get('meta_fields'))
lyric_format = data.get('lyric_format', 'lrc')
# F-C:白名单——垃圾 lyric_format 会在 ZIP 内覆盖音频条目
if lyric_format not in ('lrc', 'srt'):
lyric_format = 'lrc'
try:
concurrency = int(data.get('concurrency', 8) or 8)
concurrency = max(1, min(10, concurrency))
except (TypeError, ValueError):
concurrency = 8
account = data.get('account', 'admin')
if account == 'user':
# 个人账号 Cookie 由浏览器传入(与 /song 等接口一致)。
# 第 91 条(P3-2):删除 user_cookie.txt 服务器回退(跨账号复用残留 Cookie 的洞)
cookies = _resolve_user_cookies(data)
if not cookies:
return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401)
else:
cookies = api_service._get_cookies()
# 提前解析并去重 ID,避免同一首歌被重复下载
ids = []
seen = set()
for raw_id in raw_id_list:
try:
mid = api_service._extract_music_id(raw_id)
except Exception:
mid = str(raw_id).strip()
if mid and mid not in seen:
seen.add(mid)
ids.append(mid)
if not ids:
return APIResponse.error("缺少 ids 参数")
# 数量上限:防止超大歌单长期占满批量队列(播客打包同为 500 上限)
if len(ids) > 500:
return APIResponse.error("单个批量任务最多 500 首,请分批提交")
# 注册任务,后台线程执行(避免长连接被中断)
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
_touch_device(device_id)
_cur_user = auth_system.current_user()
_unlimited = (not _cur_user or _cur_user['role'] == 'admin'
or int(_cur_user['duration_seconds'] or 0) == -1)
_reserved = 0 if _unlimited else len(ids)
task = {
'type': 'batch',
'billing_user_id': int(_cur_user['id']) if _cur_user else 0,
'billing_reserved': _reserved,
'billing_cost': len(ids), # 名义歌曲数(对账用);实际退款以账本 reserved 为准
'total': len(ids),
'done': 0,
'failed': 0,
'status': 'queued',
'start': time.time(),
'msg': '排队中...',
'cancel': threading.Event(),
'pause': threading.Event(),
'device_id': device_id,
'result': None,
'params': {
'ids': ids,
'quality': quality,
'filename_format': filename_format,
'write_meta': write_meta,
'meta_fields': meta_fields,
'lyric_format': lyric_format,
'concurrency': concurrency,
'cookies': cookies,
},
}
reg_err = _register_download_task(task, _reserved, task_id_hint=task_id_hint)
if reg_err:
return APIResponse.error(reg_err, 429)
task_id = task['task_id']
start_err = _start_task_thread(task_id, _batch_zip_worker, (task_id,))
if start_err:
return APIResponse.error(start_err, 500)
return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "批量打包任务已提交")
except Exception as e:
api_service.logger.error(f"批量打包提交异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int:
"""批量任务结算(F25/F26:走持久账本,按实际预扣-实际交付幂等退款;
重复调用/重启后调用都只会退一次)"""
try:
refund = auth_system.ledger_settle(task_id, ok_count, final_status)
except Exception:
# 第 97 条(P3):本函数多在异常处理器内被调用——结算再抛错(库忙超时)
# 会杀死 worker 收尾路径,跳过终态标记;失败留周期对账兜底(幂等)
api_service.logger.error(
f"批量结算异常(留对账兜底): {task_id}\n{traceback.format_exc()}")
return 0
if refund:
try:
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id) or {}
uid = int(t.get('billing_user_id') or 0)
u = auth_system.get_user_by_id(uid) if uid else None
uname = u['username'] if u else 'system'
remaining = u['duration_seconds'] if u else '?'
auth_system.add_log('success', uname, 'system', '批量退费',
f'失败部分 {refund} 次已退回,剩余 {remaining} 次')
except Exception:
pass
return refund
def _batch_zip_worker(task_id: str) -> None:
"""后台批量打包:窗口化下载 → 写 ZIP(音频免压缩),支持取消与断线暂停
- 断线暂停:设备心跳超时后,当前窗口下载完即释放 BATCH_ZIP_SEM 名额给下一位,
保存进度进入暂停等待;重新打开网站(心跳恢复)后自动回到队列继续下载
- 音频入包即删,缓解磁盘占用
"""
import tempfile as _tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
zip_tmp_path: Optional[str] = None
zipped_files: List[str] = []
ok_count = 0
failed: List[str] = []
used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖)
info_pool = None
info_futures = {}
try:
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id]
task['status'] = 'running'
task['msg'] = '下载打包中...'
params = task['params']
cancel_ev = task['cancel']
pause_ev = task['pause']
ids = list(params['ids'])
quality = params['quality']
filename_format = params['filename_format']
write_meta = params['write_meta']
meta_fields = params['meta_fields']
lyric_format = params['lyric_format']
concurrency = params['concurrency']
cookies = params['cookies']
batch_start = time.time()
# P2-7:预取按窗口进行——原实现一次性提交全部(最多 500 首 ×3 接口)预取任务,
# 大歌单瞬间以 10 并发连发上游约 1500 次请求打风控;改为每个下载窗口只预取本窗口
# 的歌(与下载节奏对齐),_download_one 语义不变(等预取结果再下载)
from concurrent.futures import ThreadPoolExecutor as _InfoPool
info_pool = _InfoPool(max_workers=min(4, max(1, concurrency)))
def _prefetch(window_ids):
for mid in window_ids:
if mid not in info_futures:
info_futures[mid] = info_pool.submit(
api_service.downloader.get_music_info, mid, quality, cookies
)
def _download_one(mid):
"""并行下载单元:等待预取信息 → 下载 → 复用预取歌词与缓存封面(零重复请求)"""
info = info_futures[mid].result()
result = api_service.downloader.download_music_file(
mid, quality,
write_meta=write_meta,
meta_fields=meta_fields,
cookies=cookies,
cancel_event=cancel_ev,
music_info=info,
)
extra = {'lyric': '', 'cover': b''}
if result.success:
# 歌词已随 get_music_info 预取返回,无需再调一次歌词接口
extra['lyric'] = getattr(info, 'lyric', '') or ''
# 封面走共享缓存(同一专辑只拉一次网络);取消后不再发起新请求
if not cancel_ev.is_set() and info.pic_url:
cover = fetch_cover_bytes(info.pic_url)
if cover:
extra['cover'] = cover
return result, extra
def _update_progress() -> None:
with DOWNLOAD_TASKS_LOCK:
DOWNLOAD_TASKS[task_id]['done'] = ok_count + len(failed)
DOWNLOAD_TASKS[task_id]['failed'] = len(failed)
def _wait_if_paused() -> None:
"""暂停时等待恢复或取消(不持有 BATCH_ZIP_SEM)"""
if not (pause_ev.is_set() or task['status'] == 'paused'):
return
with DOWNLOAD_TASKS_LOCK:
task['status'] = 'paused'
task['done'] = ok_count + len(failed)
task['msg'] = '已暂停(重新打开页面自动恢复)'
while not cancel_ev.is_set() and (pause_ev.is_set() or task['status'] == 'paused'):
time.sleep(1)
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
pause_ev.clear()
with DOWNLOAD_TASKS_LOCK:
task['status'] = 'running'
task['msg'] = '继续下载...'
# P3-26:ZIP 构建放 downloads 卷(原实现落系统 %TEMP%——500 首无损可达 GB 级,
# 小系统盘一次大单即塞爆;单曲/播客早已指定 dir,唯批量漏了)
# F-A(第 76 条):临时包建在任务专属目录 tasks/<task_id>/——原放根目录会被
# 缓存清扫线程按 mtime 回收(Linux 上已打开文件也能被 unlink,Docker 部署
# 排队超 35 分钟即出现"扣费后领取 404"的悬空交付),根目录从此碰不到交付物
_task_dir_for_zip = api_service.downloads_path / 'tasks' / task_id
_task_dir_for_zip.mkdir(parents=True, exist_ok=True)
zip_valid = False # F-B:ZIP 中央目录完整写出才置 True,否则一律按失败全额退费
with _tempfile.NamedTemporaryFile(prefix='batch_zip_', suffix='.zip',
dir=str(_task_dir_for_zip),
delete=False) as zip_tmp:
zip_tmp_path = zip_tmp.name
# 音频本身已压缩,用 ZIP_STORED(免压缩)大幅提速;歌词等文本仍压缩
with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
# P3-27:不用 with 管理下载池——取消时 with 退出会 wait=True 等在途
# future(其内部阻塞在预取 HTTP 上最长 30s),改为不等待 + 取消排队任务
pool = ThreadPoolExecutor(max_workers=concurrency)
try:
while ids:
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
# 断线暂停:释放名额等待恢复/取消,恢复后重新进入排队
_wait_if_paused()
# 每个窗口持有 BATCH_ZIP_SEM(同一时刻全局最多 3 个批量任务在下载)
# F91:等待名额期间可被取消,不再无限阻塞线程与退款
if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev):
raise DownloadCanceled("任务已取消")
try:
if cancel_ev.is_set() or pause_ev.is_set() or task['status'] == 'paused':
continue # 立即释放名额,回到循环顶部处理暂停
window = ids[:concurrency]
ids = ids[concurrency:]
_prefetch(window) # P2-7:只预取本窗口
future_map = {
pool.submit(_download_one, mid): mid
for mid in window
}
for future in as_completed(future_map):
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
mid = future_map[future]
try:
result, extra = future.result()
except DownloadCanceled:
raise
except Exception as e:
failed.append(f"{mid}({e})")
_update_progress()
continue
if not result.success:
failed.append(str(mid))
_update_progress()
continue
fp = Path(result.file_path)
if not fp.exists():
# 文件可能已被同名并发任务入包删除,按失败处理而非炸掉整个任务
failed.append(str(mid))
_update_progress()
continue
mi = result.music_info
base = _build_download_base(filename_format, {'name': mi.name, 'artist_string': mi.artists})
# 同名去重:歌单里同一首歌出现两次/同名不同版本时,
# 追加序号避免 ZIP 内重名互相覆盖(用户被扣 2 次只拿到 1 首)
# F-C:音频/歌词/封面三个 arcname 都要查重——同名同歌手但
# 音源扩展不同(flac vs mp3)时歌词/封面条目也会互相覆盖
final_name = f"{base}.{mi.file_type}"
lrc_name = f"{base}.{lyric_format}"
jpg_name = f"{base}.jpg"
k = 2
while (final_name in used_arcnames
or lrc_name in used_arcnames
or jpg_name in used_arcnames):
final_name = f"{base} ({k}).{mi.file_type}"
lrc_name = f"{base} ({k}).{lyric_format}"
jpg_name = f"{base} ({k}).jpg"
k += 1
used_arcnames.update((final_name, lrc_name, jpg_name))
# F-D:exists 与 zf.write 之间的缓存竞态(另一任务缓存 miss
# 删文件)按单曲失败计,不再让整批转 error
try:
zf.write(str(fp), arcname=final_name, compress_type=zipfile.ZIP_STORED)
except OSError as e:
failed.append(f"{mid}(缓存文件读取失败: {e})")
_update_progress()
continue
# F27:缓存文件是跨任务共享资源,任务不得删除;
# 磁盘回收交给监控线程的下载缓存清扫(按文件年龄)
# 歌词与封面已在并行下载线程中拉取,直接写入 ZIP(无网络等待)
lrc = extra.get('lyric', '')
if lrc:
try:
zf.writestr(lrc_name, _format_lyric(lrc, lyric_format), compress_type=zipfile.ZIP_DEFLATED)
except Exception:
pass
if extra.get('cover'):
try:
zf.writestr(jpg_name, extra['cover'], compress_type=zipfile.ZIP_STORED)
except Exception:
pass
ok_count += 1
_update_progress()
finally:
BATCH_ZIP_SEM.release()
finally:
# P3-27:取消/异常时不等待在途下载线程(其可能阻塞在预取 HTTP 上),
# 排队任务一并取消;在途线程自行收尾(.part 残片由续传/清扫兜底)
try:
pool.shutdown(wait=False, cancel_futures=True)
except TypeError:
pool.shutdown(wait=False)
# 第 93 条复审 P2-1 修正:zip_valid = True 必须在内层 with zipfile 退出之后
# (中央目录在 close 时才写出)——原缩进 16 在 with 内部,守卫不可达
zip_valid = True
if ok_count > 0:
with BATCH_DURATIONS_LOCK:
BATCH_DURATIONS.append(time.time() - batch_start)
# F-B:ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛
if not zip_valid:
if zip_tmp_path:
try:
os.remove(zip_tmp_path)
except OSError:
pass
zip_tmp_path = None
refunded = _settle_batch_billing(task_id, 0, 'error')
_mark_task_terminal(task_id, 'error',
f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded
else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回")
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['failed'] = t.get('failed', 0) + ok_count
t['refunded'] = refunded
api_service.logger.error(f"批量ZIP构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次")
return
# F41:全部失败不发布空 ZIP、不标 done —— 按失败收敛并全额退费
if ok_count == 0 and failed:
if zip_tmp_path:
try:
os.remove(zip_tmp_path)
except OSError:
pass
zip_tmp_path = None
refunded = _settle_batch_billing(task_id, 0, 'error')
_mark_task_terminal(task_id, 'error',
f"全部失败({len(failed)} 首)" + (f",已退回 {refunded} 次" if refunded else ''))
with DOWNLOAD_TASKS_LOCK:
DOWNLOAD_TASKS[task_id]['failed'] = len(failed)
DOWNLOAD_TASKS[task_id]['refunded'] = refunded
api_service.logger.info(f"批量ZIP全部失败: {task_id} 失败{len(failed)} 退回{refunded}次")
return
refunded = _settle_batch_billing(task_id, ok_count)
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
# S3:发布前锁内复查取消/失效状态——取消请求落在"检查后、拿锁前"的窗口也不得覆盖终态
if t is None or t.get('status') in ('canceled', 'error'):
raise DownloadCanceled("任务已取消")
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
t['status'] = 'done'
t['completed_at'] = time.time()
t['done'] = ok_count + len(failed)
t['failed'] = len(failed)
t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '')
t['refunded'] = refunded
t['result'] = {
'zip_path': zip_tmp_path,
'filename': f"批量下载_{ok_count}首.zip",
'audio_files': [], # F27:缓存文件不归任务所有,不进入清理清单
}
api_service.logger.info(f"批量ZIP完成: 成功{ok_count} 失败{len(failed)} 退回{refunded}次")
except DownloadCanceled:
# F24:部分成功后取消——已入包部分照常交付(成果守恒),未完成部分按账本退费
# F-B:ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛
delivered = ok_count if zip_valid else 0
refunded = _settle_batch_billing(task_id, delivered, 'canceled')
if delivered > 0 and zip_tmp_path:
_mark_task_terminal(task_id, 'canceled',
f"已取消(已打包 {ok_count} 首可领取)" +
(f",退回 {refunded} 次" if refunded else ''))
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['refunded'] = refunded
t['result'] = {
'zip_path': zip_tmp_path,
'filename': f"批量下载_{ok_count}首.zip",
'audio_files': [],
}
else:
if zip_tmp_path:
try:
os.remove(zip_tmp_path)
except OSError:
pass
_mark_task_terminal(task_id, 'canceled',
'任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else ''))
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['refunded'] = refunded
api_service.logger.info(f"批量ZIP已取消: {task_id}(成功{ok_count} 退回{refunded}次)")
except Exception as e:
# F24:通用异常与取消同一套交付/结算规则:有成果则交付,按账本退未交付部分
# F-B:ZIP 未正常完成(写坏)时按全额退费,不发布坏包
# 第 97 条(对抗复审):非预期异常原文 logger 先行,用户可见文案泛化
api_service.logger.error(f"批量打包异常: {task_id} {e}\n{traceback.format_exc()}")
delivered = ok_count if zip_valid else 0
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)
if t is not None:
t['refunded'] = refunded
t['result'] = {
'zip_path': zip_tmp_path,
'filename': f"批量下载_{ok_count}首.zip",
'audio_files': [],
}
else:
_mark_task_terminal(task_id, 'error',
'打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else ''))
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['refunded'] = refunded
if zip_tmp_path:
try:
os.remove(zip_tmp_path)
except OSError:
pass
api_service.logger.error(f"批量ZIP任务异常: {task_id} {e}(成功{ok_count} 退回{refunded}次)\n{traceback.format_exc()}")
finally:
# 回收信息预取线程池,避免线程泄漏(未启动的预取任务一并取消)
if info_pool is not None:
try:
info_pool.shutdown(wait=False, cancel_futures=True)
except TypeError:
info_pool.shutdown(wait=False)
def _podcast_download_worker(task_id: str) -> None:
"""后台播客单集下载:解析单集 → 音质降级取直链 → 流式下载(可选 ZIP 打包)
任务 type 为 'single',完成后经 /download/result 交付,与单曲下载共用交付通道。
F28:落盘文件进入任务专属目录且带节目 ID,跨任务永不重名;清理只动本任务目录。
"""
import tempfile as _tempfile
file_path_str: Optional[str] = None
zip_tmp_path: Optional[str] = None
task_dir: Optional[Path] = None
try:
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id]
task['status'] = 'running'
task['msg'] = '解析单集信息...'
params = task['params']
cancel_ev = task['cancel']
program_id = params['program_id']
quality = params['quality']
need_zip = params['need_zip']
cookies = params['cookies']
remote_ip = params.get('remote_ip') or '-'
program = podcast_api.get_program_detail(program_id, cookies)
url_info = _podcast_resolve_audio(program, quality, cookies)
serial = int(program.get('serial_num') or 0)
name = program.get('name') or program.get('main_song_name') or '播客单集'
prefix = f"{serial:03d} - " if serial else ''
base = _podcast_safe_name(f"{prefix}{name}")
ext = (url_info.get('type') or 'mp3').lower().lstrip('.')
# F28:磁盘名附节目 ID + 任务目录隔离(两个任务同下载一集互不覆盖、互不删除)
task_dir = api_service.downloads_path / 'tasks' / task_id
task_dir.mkdir(parents=True, exist_ok=True)
disk_name = f"{base} [{program_id}].{ext}"
target = task_dir / disk_name
filename = f"{base}.{ext}" # 交付名保持干净(不带 ID)
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS[task_id]
t['msg'] = '下载中...'
_podcast_stream_download(url_info['url'], target, url_info.get('size', 0),
cancel_ev, task_id, '下载中')
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
file_path_str = str(target)
result: Dict[str, Any]
if need_zip:
fd, zip_tmp_path = _tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip',
dir=str(task_dir))
os.close(fd)
with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(file_path_str, arcname=filename)
cover_url = program.get('cover_url') or ''
if cover_url:
try:
cover = fetch_cover_bytes(cover_url)
if cover:
zf.writestr(f"{base}.jpg", cover)
except Exception:
pass
# F17:info.txt 不再写直链(真实 URL 不应随交付文件外泄)
info_txt = (
f"单集: {name}\n"
f"播客: {program.get('radio_name') or ''}\n"
f"主播: {program.get('dj_name') or ''}\n"
f"时长: {program.get('duration_text') or ''}\n"
f"音质: {api_service._get_quality_display_name(url_info.get('level', quality))}\n"
)
zf.writestr("info.txt", info_txt)
# F42:打包阶段后同样以取消检查收口
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
result = {
'file_path': file_path_str,
'zip_path': zip_tmp_path,
'filename': f"{base}.zip",
'mimetype': 'application/zip',
'audio_files': [file_path_str],
}
else:
result = {
'file_path': file_path_str,
'filename': filename,
'mimetype': f"audio/{ext}",
'audio_files': [file_path_str],
}
# F42:最终发布前的取消检查(末段取消不得覆盖为 done)
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is None or t.get('status') in ('canceled', 'error'):
raise DownloadCanceled("任务已取消")
t['status'] = 'done'
t['completed_at'] = time.time()
t['done'] = 1
t['msg'] = '下载完成'
t['result'] = result
# C1:交付已落定;结算失败不再把任务改标 error(同单曲)
try:
auth_system.ledger_settle(task_id, 1, 'delivered')
except Exception as _e:
api_service.logger.error(f"播客单集交付结算失败(重启对账兜底): {task_id} {_e}")
try:
_record_log('下载', remote_ip, f"[播客] {name}")
except Exception:
pass
api_service.logger.info(f"播客单集下载完成: {filename} ({task_id})")
except DownloadCanceled:
_mark_task_terminal(task_id, 'canceled', '任务已取消')
try:
auth_system.ledger_settle(task_id, 0, 'canceled')
except Exception:
pass
if task_dir is not None:
shutil.rmtree(str(task_dir), ignore_errors=True)
api_service.logger.info(f"播客单集下载已取消: {task_id}")
except DownloadException as e:
_mark_task_terminal(task_id, 'error', str(e))
try:
auth_system.ledger_settle(task_id, 0, 'error')
except Exception:
pass
if task_dir is not None:
shutil.rmtree(str(task_dir), ignore_errors=True)
api_service.logger.error(f"播客单集下载失败: {task_id} {e}")
except Exception as e:
# 第 97 条(P3):非预期异常原文 logger 先行,用户可见文案泛化(第 84 条教训)
api_service.logger.error(f"播客单集下载异常: {task_id} {e}\n{traceback.format_exc()}")
_mark_task_terminal(task_id, 'error', '下载失败,请稍后重试')
try:
auth_system.ledger_settle(task_id, 0, 'error')
except Exception:
pass
if task_dir is not None:
shutil.rmtree(str(task_dir), ignore_errors=True)
def _podcast_cleanup_files(paths: List[Optional[str]]) -> None:
"""清理播客任务产生的临时/残留文件"""
for p in paths:
if not p:
continue
try:
os.remove(p)
except OSError:
pass
# 连带清理可能残留的 .part 文件
try:
part = Path(str(p) + '.part')
if part.exists():
part.unlink()
except OSError:
pass
def _podcast_zip_worker(task_id: str) -> None:
"""整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)"""
import tempfile as _tempfile
zip_tmp_path: Optional[str] = None
ok_count = 0
failed: List[str] = []
# 第 97 条(P1-1):zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等
# 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程
zip_valid = False
try:
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id]
task['status'] = 'running'
task['msg'] = '解析单集列表...'
params = task['params']
cancel_ev = task['cancel']
pause_ev = task['pause']
radio_id = params['radio_id']
preset_ids = params.get('ids')
limit = params['limit']
quality = params['quality']
cookies = params['cookies']
remote_ip = params.get('remote_ip') or '-'
# 组装单集清单:整台电台(正序全量)或指定单集ID列表
if radio_id:
episodes = podcast_api.list_radio_all_program_ids(radio_id, cookies, max_count=limit)
if not episodes:
raise DownloadException("该电台暂无可下载的单集")
else:
episodes = []
for pid in (preset_ids or []):
try:
prog = podcast_api.get_program_detail(pid, cookies)
episodes.append({
'id': prog['id'],
'name': prog['name'],
'main_song_id': prog['main_song_id'],
'serial_num': prog['serial_num'],
'cover_url': prog['cover_url'],
'detail': prog,
})
except APIException as ae:
failed.append(f"{pid}({ae})")
episodes = episodes[:limit]
if not episodes:
raise DownloadException("没有可下载的单集")
radio_name = _podcast_safe_name(params.get('radio_name') or '', '播客')
with DOWNLOAD_TASKS_LOCK:
DOWNLOAD_TASKS[task_id]['total'] = len(episodes)
# 按次计费补扣(F21):整台电台提交时只预扣 1 次,此处按实际单集数补足差额。
# 补扣失败(余额不足/数据库异常)任务必须失败并按账本退费——不允许无账本继续交付
try:
_buid = params.get('billing_uid')
_extra = len(episodes) - int(params.get('billed') or 1)
if _buid and _extra > 0:
if not auth_system.ledger_supplement(task_id, _extra):
raise DownloadException(
f"补扣下载次数失败(需补扣 {_extra} 次),任务已取消,预扣部分将退回")
if _buid:
with DOWNLOAD_TASKS_LOCK:
DOWNLOAD_TASKS[task_id]['billing_user_id'] = int(_buid)
DOWNLOAD_TASKS[task_id]['billing_cost'] = len(episodes)
except DownloadCanceled:
raise
except DownloadException:
raise
except Exception as e:
raise DownloadException(f"补扣下载次数异常: {e}")
def _wait_if_paused() -> None:
"""暂停时等待恢复或取消"""
if not (pause_ev.is_set() or task['status'] == 'paused'):
return
with DOWNLOAD_TASKS_LOCK:
task['status'] = 'paused'
task['done'] = ok_count + len(failed)
task['msg'] = '已暂停(重新打开页面自动恢复)'
while not cancel_ev.is_set() and (pause_ev.is_set() or task['status'] == 'paused'):
time.sleep(1)
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
pause_ev.clear()
with DOWNLOAD_TASKS_LOCK:
task['status'] = 'running'
task['msg'] = '继续下载...'
def _update_progress(msg: str = '') -> None:
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS[task_id]
t['done'] = ok_count + len(failed)
t['failed'] = len(failed)
if msg:
t['msg'] = msg
# 第 91 条(P1-1):对齐音乐批量 F-B 口径——zip_valid 仅在 ZIP with 块正常
# 退出(中央目录完整写出)后才置 True(初始 False 已上移函数头,第 97 条);
# 未 valid 一律不发布、不按 ok_count 结算
# P3-26:同音乐批量——ZIP 构建放 downloads 卷,不落系统 %TEMP%
# R5-P3/W-A 同口径:建在任务专属目录 tasks/<task_id>/,根目录缓存清扫不再触及交付物
_podcast_task_dir = api_service.downloads_path / 'tasks' / task_id
_podcast_task_dir.mkdir(parents=True, exist_ok=True)
with _tempfile.NamedTemporaryFile(prefix='podcast_batch_', suffix='.zip',
dir=str(_podcast_task_dir),
delete=False) as zip_tmp:
zip_tmp_path = zip_tmp.name
used_arcnames = set() # F73:包内条目名去重(同名单集/同号不同集不得互相覆盖)
with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
for idx, ep in enumerate(episodes, 1):
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
_wait_if_paused()
# 并发对齐音乐批量:播客打包同样占用全局批量名额(BATCH_ZIP_SEM,全局 3),
# 逐集 acquire/release——暂停等待/取消时不长期占名额;音频本身仍受
# 全局下载信号量约束。原实现不受批量并发控制,多用户同时打包会无限并发
if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev):
raise DownloadCanceled("任务已取消")
try:
label = f"[{idx}/{len(episodes)}] {ep['name'][:24]}"
_update_progress(f"{label} 解析中...")
try:
prog = ep.get('detail') or podcast_api.get_program_detail(ep['id'], cookies)
url_info = _podcast_resolve_audio(prog, quality, cookies)
_update_progress(f"{label} 下载中...")
ext = (url_info.get('type') or 'mp3').lower().lstrip('.')
serial = int(ep.get('serial_num') or 0)
prefix = f"{serial:03d} - " if serial else ''
base = _podcast_safe_name(f"{prefix}{ep['name']}")
# 第 91 条(P3-5):中间文件移入任务目录——落 downloads 根目录会被
# 缓存清扫按 mtime 打断慢速单集(Linux 上 unlink 打开中的文件)
tmp_audio = _podcast_task_dir / f"podcast_zip_tmp_{task_id}_{idx}.{ext}"
try:
_podcast_stream_download(url_info['url'], tmp_audio,
url_info.get('size', 0), cancel_ev)
# F73:重名条目自动追加序号
arcname = f"{base}.{ext}"
k = 2
while arcname in used_arcnames:
arcname = f"{base} ({k}).{ext}"
k += 1
used_arcnames.add(arcname)
zf.write(str(tmp_audio), arcname=arcname, compress_type=zipfile.ZIP_STORED)
finally:
# P3-5:finally 连带清理 .part 残片(原实现只删正文,
# 取消/失败时残片要等 1 小时缓存清扫兜底)
_podcast_cleanup_files([str(tmp_audio)])
# 首集附带封面,便于识别
if idx == 1 and ep.get('cover_url'):
try:
cover = fetch_cover_bytes(ep['cover_url'])
if cover:
zf.writestr("cover.jpg", cover, compress_type=zipfile.ZIP_STORED)
except Exception:
pass
ok_count += 1
_update_progress(f"{label} 完成")
except DownloadCanceled:
raise
except Exception as e:
failed.append(f"{ep['name']}({e})")
_update_progress()
finally:
BATCH_ZIP_SEM.release()
# 第 91 条(P1-1):走到这里说明 ZIP with 块正常退出、中央目录完整,才允许发布
zip_valid = True
if ok_count == 0:
raise DownloadException(f"全部单集下载失败: {'; '.join(failed[:3])}")
# 第 91 条(P1-1):ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛
if not zip_valid:
if zip_tmp_path:
try:
os.remove(zip_tmp_path)
except OSError:
pass
zip_tmp_path = None
refunded = _settle_batch_billing(task_id, 0, 'error')
_mark_task_terminal(task_id, 'error',
f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded
else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回")
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['failed'] = t.get('failed', 0) + ok_count
t['refunded'] = refunded
api_service.logger.error(f"播客打包构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次")
return
# F42:最终发布前的取消检查(末段取消不得覆盖为 done)
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
refunded = _settle_batch_billing(task_id, ok_count)
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
# S3:发布前锁内复查取消/失效状态(同音乐批量)
if t is None or t.get('status') in ('canceled', 'error'):
raise DownloadCanceled("任务已取消")
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
t['status'] = 'done'
t['completed_at'] = time.time()
t['done'] = ok_count + len(failed)
t['failed'] = len(failed)
t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '')
t['refunded'] = refunded
t['result'] = {
'zip_path': zip_tmp_path,
'filename': f"播客_{radio_name}_{ok_count}集.zip",
'audio_files': [],
}
try:
first_name = episodes[0].get('name') if episodes else ''
_record_log('下载', remote_ip, f"[播客打包] {radio_name} · {first_name}")
except Exception:
pass
api_service.logger.info(f"播客打包完成: {radio_name} 成功{ok_count} 失败{len(failed)} ({task_id})")
except DownloadCanceled:
# F24:部分成功后取消——已入包部分照常交付,未完成部分按账本退费
# 第 91 条(P1-1):ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛
delivered = ok_count if zip_valid else 0
refunded = _settle_batch_billing(task_id, delivered, 'canceled')
if delivered > 0 and zip_tmp_path:
_mark_task_terminal(task_id, 'canceled',
f"已取消(已打包 {ok_count} 集可领取)" +
(f",退回 {refunded} 次" if refunded else ''))
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['refunded'] = refunded
t['result'] = {
'zip_path': zip_tmp_path,
'filename': f"播客_{radio_name or '打包'}_{ok_count}集.zip",
'audio_files': [],
}
else:
_mark_task_terminal(task_id, 'canceled',
'任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else ''))
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is not None:
t['refunded'] = refunded
if zip_tmp_path:
try:
os.remove(zip_tmp_path)
except OSError:
pass
api_service.logger.info(f"播客打包已取消: {task_id}(成功{ok_count} 退回{refunded}次)")
except DownloadException as e:
delivered = ok_count if zip_valid else 0
refunded = _settle_batch_billing(task_id, delivered)
with DOWNLOAD_TASKS_LOCK:
_t = DOWNLOAD_TASKS.get(task_id)
# 第 100 条(T1 不变量):终态先到先得——已终态(如用户取消/超时兜底)
# 不再被覆盖为 error(堵 canceled→error 非法迁移边)
if _t and _t.get('status') not in ('done', 'error', 'canceled'):
_t['status'] = 'error'
_t['completed_at'] = time.time() # F38:保留期从完成时刻起算
_t['msg'] = str(e) + (f"(已退回 {refunded} 次下载次数)" if refunded else '')
_t['refunded'] = refunded
elif _t:
_t['refunded'] = refunded
if zip_tmp_path:
try:
os.remove(zip_tmp_path)
except OSError:
pass
api_service.logger.error(f"播客打包失败: {task_id} {e}(退回{refunded}次)")
except Exception as e:
# 第 97 条(对抗复审):非预期异常原文 logger 先行,用户可见文案泛化
# (DownloadException 的文案是我们自己拼的用户可读文本,保留 str(e))
api_service.logger.error(f"播客打包异常: {task_id} {e}\n{traceback.format_exc()}")
delivered = ok_count if zip_valid else 0
refunded = _settle_batch_billing(task_id, delivered)
with DOWNLOAD_TASKS_LOCK:
_t = DOWNLOAD_TASKS.get(task_id)
# 第 100 条(T1 不变量):同上——已终态不再覆盖为 error
if _t and _t.get('status') not in ('done', 'error', 'canceled'):
_t['status'] = 'error'
_t['completed_at'] = time.time() # F38:保留期从完成时刻起算
_t['msg'] = '打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')
_t['refunded'] = refunded
elif _t:
_t['refunded'] = refunded
if zip_tmp_path:
try:
os.remove(zip_tmp_path)
except OSError:
pass
api_service.logger.error(f"播客打包异常: {task_id} {e}(退回{refunded}次)\n{traceback.format_exc()}")
# P2-8:交付结果的延迟清理不再用"每请求 spawn 一个 sleep 35 分钟线程"的方案
# (高并发下线程堆积;且线程是内存态,重启即丢、无人兜底)。统一交给监控线程:
# 终态任务记录+结果文件+任务目录按 completed_at+35 分钟清理,下载根目录的
# ZIP/文件另有按 mtime 的缓存清扫双保险(同一 35 分钟口径)。
@app.route('/download/batch_zip/result', methods=['GET', 'POST'])
@auth_system.login_required(free=True)
def download_batch_zip_result():
"""获取批量打包结果:任务完成后流式返回 ZIP;ZIP 发完即删,任务记录按 35 分钟保留期清理"""
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:
task = DOWNLOAD_TASKS.get(task_id)
if not task or task.get('type') != 'batch':
return APIResponse.error("任务不存在或已过期", 404)
# F05:站点用户是任务的第一归属(device_id 仅辅助),非本人任务 403
if not _task_owned_by_current_user(task):
return APIResponse.error("无权下载该任务", 403)
status = task.get('status')
result = task.get('result') or {}
has_deliverable = bool(result.get('zip_path')) and os.path.exists(result.get('zip_path'))
if status in ('queued', 'running'):
return APIResponse.error("任务还在进行中,请稍后再试", 202)
if status == 'paused':
return APIResponse.error("任务已暂停,请重新打开页面后恢复", 200)
# F24:取消/失败但已有部分成果的任务,成果必须可领取(钱不白花)
if status in ('canceled', 'error') and not has_deliverable:
return APIResponse.error(task.get('msg') or ('任务已取消' if status == 'canceled' else '任务失败'),
200 if status == 'canceled' else 500)
zip_path = result.get('zip_path')
if not zip_path or not os.path.exists(zip_path):
return APIResponse.error("打包文件不存在或已被清理", 404)
zip_name = result.get('filename') or '批量下载.zip'
response = send_file(
zip_path, as_attachment=True,
download_name=zip_name,
mimetype='application/zip'
)
# 第 97 条(P3):私有交付物禁止共享缓存存储(no-cache 允许存储后重验)
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Download-Filename'] = quote(zip_name, safe='')
# P2-8:清理统一交监控线程(completed_at + 35 分钟),交付请求不再挂 sleep 线程
return response
except Exception as e:
api_service.logger.error(f"批量结果下载异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
def _task_delivery_copy(task_id: str, src: Path, filename: str) -> str:
"""把共享缓存文件复制/硬链接为任务专属交付文件(F27:任务成果与共享缓存分离,
任何任务的清理都不会再删掉别的任务的成果)"""
task_dir = api_service.downloads_path / 'tasks' / task_id
task_dir.mkdir(parents=True, exist_ok=True)
dst = task_dir / filename
if dst.exists():
return str(dst)
try:
os.link(str(src), str(dst)) # 同卷硬链接:零拷贝
except OSError:
shutil.copy2(str(src), str(dst))
return str(dst)
# 第 91 条(P3):_cleanup_task_dir 死代码删除——worker 直接 rmtree、监控线程直接
# rmtree,本函数全文件零调用(审计确认)。
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
try:
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id]
task['status'] = 'running'
task['msg'] = '下载中...'
params = task['params']
cancel_ev = task['cancel']
music_id = params['music_id']
quality = params['quality']
filename_format = params['filename_format']
write_meta = params['write_meta']
meta_fields = params['meta_fields']
lyric_format = params['lyric_format']
need_zip = params['need_zip']
cookies = params['cookies']
remote_ip = params.get('remote_ip') or '-'
# 一次预取歌曲完整信息(URL/详情/歌词三接口并行),
# 替代原先 name_v1 + url_v1 + 下载器内部 get_music_info 的重复串行调用
mi = api_service.downloader.get_music_info(music_id, quality, cookies)
music_info = {
'id': music_id,
'name': mi.name,
'artist_string': mi.artists,
'album': mi.album,
'pic_url': mi.pic_url,
'file_type': mi.file_type,
'file_size': mi.file_size,
'duration': mi.duration * 1000,
'download_url': mi.download_url,
}
base = _build_download_base(filename_format, music_info)
filename = f"{base}.{music_info['file_type']}"
# 字节级进度回调(限频 0.4s):写进任务 live_percent,/api/batch_progress
# 对运行中的单曲任务优先返回它——原实现 done 按首计数,单曲进度条全程 0%
_cb_ts = {'t': 0.0}
def _progress_cb(written, total_bytes):
try:
now = time.time()
if now - _cb_ts['t'] < 0.4 and written < (total_bytes or 0):
return
_cb_ts['t'] = now
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if not t or t.get('status') != 'running':
return
if total_bytes:
pct = min(99.0, written / total_bytes * 100)
t['live_percent'] = pct
t['msg'] = (f"下载中 {int(pct)}% · "
f"{api_service._format_file_size(written)}/{api_service._format_file_size(total_bytes)}")
else:
t['msg'] = f"下载中 · 已接收 {api_service._format_file_size(written)}"
except Exception:
pass # 进度上报故障绝不影响下载
download_result = api_service.downloader.download_music_file(
music_id, quality,
write_meta=write_meta,
meta_fields=meta_fields,
cookies=cookies,
cancel_event=cancel_ev,
music_info=mi,
progress_cb=_progress_cb,
)
if not download_result.success:
raise DownloadException(download_result.error_message or "下载失败")
# F42:下载完成后的每一阶段都可能已被取消,发布前统一校验
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
cache_path = Path(download_result.file_path)
# F27:交付文件用任务专属副本,共享缓存不进清理清单
file_path_str = _task_delivery_copy(task_id, cache_path, filename)
task_dir_path = str(Path(file_path_str).parent)
if need_zip:
fd, zip_tmp_path = _tempfile.mkstemp(prefix='single_zip_', suffix='.zip',
dir=str(Path(file_path_str).parent))
os.close(fd)
with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(file_path_str, arcname=filename)
# 歌词已在预取时一并拿到,无需再请求一次
if mi.lyric:
try:
zf.writestr(f"{base}.{lyric_format}", _format_lyric(mi.lyric, lyric_format))
except Exception:
pass
# 封面走共享缓存(与元数据写入共用同一次网络请求)
cover = fetch_cover_bytes(mi.pic_url)
if cover:
zf.writestr(f"{base}.jpg", cover)
info_txt = (
f"歌曲: {music_info['name']}\n"
f"歌手: {music_info['artist_string']}\n"
f"专辑: {music_info['album']}\n"
f"音质: {api_service._get_quality_display_name(quality)}\n"
)
zf.writestr("info.txt", info_txt)
# F42:打包阶段同样以取消检查收口
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
result = {
'file_path': file_path_str,
'zip_path': zip_tmp_path,
'filename': f"{base}.zip",
'mimetype': 'application/zip',
'audio_files': [file_path_str],
}
else:
result = {
'file_path': file_path_str,
'filename': filename,
'mimetype': f"audio/{music_info['file_type']}",
'audio_files': [file_path_str],
}
# F42:最终发布前的取消检查(末段取消不得覆盖为 done)
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is None or t.get('status') in ('canceled', 'error'):
raise DownloadCanceled("任务已取消")
t['status'] = 'done'
t['completed_at'] = time.time()
t['done'] = 1
t['msg'] = '下载完成'
t['result'] = result
# C1:交付已落定;结算失败不再把任务改标 error(钱由重启对账兜底,宁可商家小损不坑用户)
try:
auth_system.ledger_settle(task_id, 1, 'delivered')
except Exception as _e:
api_service.logger.error(f"单曲交付结算失败(重启对账兜底): {task_id} {_e}")
try:
_record_log('下载', remote_ip, music_info['name'])
except Exception:
pass
api_service.logger.info(f"单曲下载完成: {filename} ({task_id})")
except DownloadCanceled:
_mark_task_terminal(task_id, 'canceled', '任务已取消')
try:
auth_system.ledger_settle(task_id, 0, 'canceled')
except Exception:
pass
if task_dir_path:
shutil.rmtree(task_dir_path, ignore_errors=True)
api_service.logger.info(f"单曲下载已取消: {task_id}")
except DownloadException as e:
_mark_task_terminal(task_id, 'error', str(e))
try:
auth_system.ledger_settle(task_id, 0, 'error')
except Exception:
pass
if task_dir_path:
shutil.rmtree(task_dir_path, ignore_errors=True)
api_service.logger.error(f"单曲下载失败: {task_id} {e}")
except Exception as e:
# 第 97 条(P3):非预期异常的 repr 可能含上游主机/连接细节——用户可见文案
# 泛化为固定文案,原文 logger 先行留档(第 84 条教训:先记录再泛化)
api_service.logger.error(f"单曲下载异常: {task_id} {e}\n{traceback.format_exc()}")
_mark_task_terminal(task_id, 'error', '下载失败,请稍后重试')
try:
auth_system.ledger_settle(task_id, 0, 'error')
except Exception:
pass
if task_dir_path:
shutil.rmtree(task_dir_path, ignore_errors=True)
@app.route('/download/result', methods=['GET', 'POST'])
@auth_system.login_required(free=True)
def download_single_result():
"""获取单曲下载结果:任务完成后流式返回文件;交付后按 35 分钟保留期清理"""
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:
task = DOWNLOAD_TASKS.get(task_id)
if not task or task.get('type') != 'single':
return APIResponse.error("任务不存在或已过期", 404)
# F05:站点用户是任务的第一归属(device_id 仅辅助),非本人任务 403
if not _task_owned_by_current_user(task):
return APIResponse.error("无权下载该任务", 403)
status = task.get('status')
if status in ('queued', 'running'):
return APIResponse.error("任务还在进行中,请稍后再试", 202)
if status == 'paused':
# 与批量结果端点同口径:暂停任务给出明确提示(而非 404"文件不存在")
return APIResponse.error("任务已暂停,请重新打开页面后恢复", 200)
result = task.get('result') or {}
target = result.get('zip_path') or result.get('file_path')
if status in ('canceled', 'error') and not (target and os.path.exists(target)):
return APIResponse.error(task.get('msg') or ('任务已取消' if status == 'canceled' else '任务失败'),
200 if status == 'canceled' else 500)
if not target or not os.path.exists(target):
return APIResponse.error("文件不存在或已被清理", 404)
filename = result.get('filename') or 'download'
response = send_file(
target, as_attachment=True,
download_name=filename,
mimetype=result.get('mimetype') or 'application/octet-stream'
)
# 第 97 条(P3):私有交付物禁止共享缓存存储(no-cache 允许存储后重验)
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Download-Filename'] = quote(filename, safe='')
# P2-8:任务目录清理统一交监控线程(completed_at + 35 分钟)
return response
except Exception as e:
api_service.logger.error(f"单曲结果下载异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@app.route('/api/info', methods=['GET'])
def api_info():
"""API信息接口"""
try:
info = {
'name': '云村小助手API服务',
'version': APP_VERSION,
'description': '云村小助手 · 网易云音乐解析/下载服务',
'endpoints': {
'/health': 'GET - 健康检查',
'/song': 'GET/POST - 获取歌曲信息',
'/search': 'GET/POST - 搜索音乐',
'/playlist': 'GET/POST - 获取歌单详情',
'/album': 'GET/POST - 获取专辑详情',
'/download': 'POST - 下载音乐(付费动作仅接受 POST)',
'/podcast/parse': 'GET/POST - 播客解析(电台/单集)',
'/podcast/programs': 'GET/POST - 播客单集列表(分页)',
'/podcast/search': 'GET/POST - 播客搜索',
'/podcast/hot': 'GET/POST - 热门播客与分类',
'/podcast/download': 'POST - 播客单集下载',
'/podcast/download/batch_zip': 'POST - 整台播客打包ZIP',
'/global/playlists': 'GET/POST - 全局账号歌单列表',
'/global/playlist': 'GET/POST - 全局账号歌单详情',
'/api/playlist/play': 'GET/POST - 公开歌单播放API(id/level/limit)',
'/api/info': 'GET - API信息'
},
'supported_qualities': [
'standard', 'exhigh', 'lossless',
'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'
],
'config': {
'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径
# 第 98 条:原 max_file_size/request_timeout 为架空字段且与实际口径
# 不符——改为真实生效的请求体上限(业务 2MB/备份上传 200MB)
'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)
def start_api_server():
"""启动API服务器"""
try:
# F26:启动对账——账本中仍 open 的孤儿任务(上次进程中断遗留)按实扣全额退回。
# 宽限期 2 小时,避免误伤同库其他存活进程的进行中任务
try:
recovered = auth_system.ledger_recover_orphans(max_age_seconds=2 * 3600)
if recovered:
api_service.logger.info(f"启动对账:已结算孤儿任务 {recovered} 笔(详见管理日志)")
except Exception as e:
api_service.logger.error(f"启动对账异常: {e}")
# P3-20:清理 90 天前已结算的账本行(只增不删会让表无限膨胀)
try:
auth_system.ledger_cleanup_settled(90 * 86400)
except Exception as e:
api_service.logger.error(f"账本历史清理异常: {e}")
# F27 配套:启动时清扫上次运行遗留的下载缓存(35 分钟口径,P1-3 统一保留期)
_sweep_download_cache(RESULT_RETENTION_SECONDS)
# P2-18:清扫 tasks/ 孤儿目录——交付清理线程与任务记录都是内存态,进程在
# 保留期内重启会让任务目录无主残留(实测发生过);超过 24 小时的必是孤儿
try:
tasks_root = api_service.downloads_path / 'tasks'
if tasks_root.exists():
now = time.time()
for d in tasks_root.iterdir():
try:
if d.is_dir() and now - d.stat().st_mtime > 24 * 3600:
shutil.rmtree(str(d), ignore_errors=True)
except OSError:
pass
except Exception as e:
api_service.logger.error(f"tasks 孤儿目录清扫异常: {e}")
# 启动设备心跳监控:心跳超时自动暂停离线设备的批量任务,让出排队名额
threading.Thread(target=_task_monitor_loop, daemon=True).start()
# 启动数据库定时备份线程(间隔可在管理后台设置)
auth_system.start_auto_backup()
_safe_console_print("\n" + "="*60)
_safe_console_print("🚀 云村小助手API服务启动中...")
_safe_console_print("="*60)
_safe_console_print(f"📡 服务地址: http://{config.host}:{config.port}")
_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("\n🎵 支持的音质:")
_safe_console_print(f" 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")
# 第 102 条(owner 拍板):生产服务器 waitress——安装了即自动启用;
# 未安装(开发机零依赖口径)回退 Werkzeug 开发服务器。
# 注意 trusted_proxy='127.0.0.1':waitress 默认清除"非可信代理"的
# X-Forwarded-For——Caddy 在本机回环转发属可信,必须放行其 XFF,
# 否则 _client_ip 的 XFF 最右段口径(第 97/100 条)失效。
try:
from waitress import serve
except ImportError:
api_service.logger.warning(
"未安装 waitress,回退 Werkzeug 开发服务器(生产部署建议:pip install waitress)")
_safe_console_print("⚠️ 未安装 waitress,当前为 Werkzeug 开发服务器(生产环境建议安装)")
app.run(
host=config.host,
port=config.port,
debug=config.debug,
threaded=True
)
else:
# 第 102 条(对抗复审实锤修复):trusted_proxy 必须配 trusted_proxy_headers——
# waitress 3.x 只配前者时仅隐式信任 x-forwarded-proto,会把 Caddy 追加的
# X-Forwarded-For 当"不可信头"从 environ 清掉 → 全站 IP 塌缩 127.0.0.1。
# 显式信任 XFF 后,waitress 将 XFF 最右段重写进 REMOTE_ADDR,与应用口径一致。
_safe_console_print(f"🍳 waitress 生产服务器已就绪: {config.host}:{config.port}(16 threads)")
serve(
app,
host=config.host,
port=config.port,
threads=16,
channel_timeout=120, # 弱网下载/大 ZIP 流式响应容限(不活动超时)
trusted_proxy='127.0.0.1', # Caddy 本机反代可信
trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'},
clear_untrusted_proxy_headers=True,
ident='NeteaseUrl', # 不暴露 waitress 版本指纹
)
except KeyboardInterrupt:
_safe_console_print("\n\n👋 服务已停止")
except Exception as e:
api_service.logger.error(f"启动服务失败: {e}")
_safe_console_print(f"❌ 启动失败: {e}")
sys.exit(1)
if __name__ == '__main__':
start_api_server()