Archived
第一步模块拆分(纯搬运,零行为变化): - 新增 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 行。
4505 lines
218 KiB
Python
4505 lines
218 KiB
Python
# -*- 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()
|
||
|