Archived
## uv 环境管理 - pyproject.toml 声明 dependencies + [dependency-groups].dev,uv.lock 精确锁定 - 删除 requirements.txt / requirements-fastapi.txt / requirements-dev.txt - .python-version = 3.12;Dockerfile 改用 uv sync --frozen(可复现构建) - 新增 jinja2(首页渲染) ## 删除 Flask 技术债 - 从 main.py 用 ast 精确剥离 56 个 Flask 节点(路由/中间件/错误处理/启动), 框架无关的服务层落为 services/runtime.py(MusicAPIService、下载/打包 worker、 任务监控、缓存清扫),零 Flask 依赖 - 删除 main.py(4527 行)与 auth_routes.py(1050 行) - auth_system.py 移除会话/请求上下文层(login_session/current_user/login_required/ admin_required/_client_ip 等),保留数据与业务层 - 新增 core/net.py(IP 解析)、web/auth_state.py(登录锁定/2FA 票据/发码限流) - FastAPI 侧改直接依赖 services.runtime,不再 import main - web/app.py 去掉 Flask fallback;入口默认根路径模式(API_PREFIX 可选) ## 删除 requests 技术债 - 新增 core/sync_http.py:requests 风格的同步门面(底层 aiohttp 连接池, 流式读取经有界队列背压),含 Session/headers/(connect,read) 元组超时兼容 - core/utils、auth_system、podcast_api、music_downloader、services 全部切换 - 实测真实下载 5.2MB 音频完整成功 ## 质量门禁归零 - ruff:0 错误(B904 全部修复,不再忽略) - pyright:0 错误(类型收窄修复) - pytest:140/140(含 golden 契约回归、root 模式 66 项路径覆盖) - 真实 uvicorn 启动:/health、/、/static、受保护端鉴权全部正确 新增 tests/golden/responses.json:删除 Flask 前捕获的 32 例响应快照, FastAPI 逐字段复现——删除巨石后行为回归仍有硬证据。
1958 lines
93 KiB
Python
1958 lines
93 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""网易云音乐API服务主程序
|
||
|
||
提供网易云音乐相关API服务,包括:
|
||
- 歌曲信息获取
|
||
- 音乐搜索
|
||
- 歌单和专辑详情
|
||
- 音乐下载
|
||
- 健康检查
|
||
"""
|
||
|
||
import collections
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import sys
|
||
import threading
|
||
import time
|
||
import traceback
|
||
import zipfile
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import parse_qs, urlsplit
|
||
|
||
try:
|
||
import auth_system
|
||
import podcast_api
|
||
from music_api import (
|
||
APIException,
|
||
NeteaseAPI,
|
||
account_info,
|
||
detect_vip_by_probe,
|
||
load_cookies,
|
||
)
|
||
from music_downloader import (
|
||
CountingSemaphore,
|
||
DownloadCanceled,
|
||
DownloadException,
|
||
MusicDownloader,
|
||
fetch_cover_bytes,
|
||
)
|
||
from qr_login import QRLoginClient
|
||
except ImportError as e:
|
||
# 坑 11:导入失败发生在 _safe_console_print 定义之前,用纯 ASCII 保证任何编码下可打印
|
||
print("ImportError: " + str(e))
|
||
print("Please make sure all dependency modules exist and are importable.")
|
||
sys.exit(1)
|
||
|
||
|
||
# ============ 框架无关核心层(core/)============
|
||
# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现**
|
||
# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。
|
||
# QRLoginManager 等已异步化的上游方法,Flask 同步路由经 run_sync 桥接调用。
|
||
from core.config import (
|
||
BATCH_ZIP_CONCURRENCY,
|
||
CANCEL_AFTER_SECONDS,
|
||
HEARTBEAT_INTERVAL,
|
||
PAUSE_AFTER_SECONDS,
|
||
RESULT_RETENTION_SECONDS,
|
||
USER_COOKIE_FILE,
|
||
Settings,
|
||
)
|
||
from core.state import (
|
||
BATCH_DURATIONS,
|
||
BATCH_DURATIONS_LOCK,
|
||
DEVICE_LAST_SEEN,
|
||
DEVICE_LAST_SEEN_LOCK,
|
||
DOWNLOAD_TASKS,
|
||
DOWNLOAD_TASKS_LOCK,
|
||
REQ_LOG,
|
||
)
|
||
from core.state import GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE
|
||
from core.state import RATE_BUCKETS as _RATE_BUCKETS
|
||
from core.state import RATE_LOCK as _RATE_LOCK
|
||
from core.state import VIP_PROBE_LOCK as _VIP_PROBE_LOCK
|
||
from core.utils import expand_shortlink as _expand_shortlink
|
||
from core.utils import format_lyric as _format_lyric
|
||
|
||
# 向后兼容别名:既有代码/文档沿用 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 = 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 _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
|
||
|
||
# 数据库恢复前静默在途任务(与 Flask 版同口径)
|
||
auth_system.register_before_restore_hook(_quiesce_tasks_for_restore)
|
||
|
||
# 服务实例(框架无关)
|
||
config = APIConfig()
|
||
api_service = MusicAPIService(config)
|
||
|
||
|
||
|
||
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
|
||
|
||
# 旧版管理员口令已废除(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)
|
||
|
||
|
||
def _start_task_thread(task_id: str, target, args=()) -> str | None:
|
||
"""启动任务 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 _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 dev else None
|
||
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=lambda k: DEVICE_LAST_SEEN.get(k, 0.0))[: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 _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 时交付头的
|
||
# 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(默认)
|
||
|
||
|
||
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()
|
||
|
||
|
||
# 高频轮询/流式端点:不写逐请求日志(3s 排队轮询、1.5s 进度轮询、15s 心跳、
|
||
# Range 分段流、错误上报都会在忙碌站点把 5MB×3 的轮转日志几小时刷一圈,排障信息被冲掉)。
|
||
# 用户主动动作(任务列表/取消)保留日志。
|
||
_QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat',
|
||
'/audio_stream', '/api/fe_error'}
|
||
|
||
|
||
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
|
||
|
||
|
||
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
|
||
|
||
|
||
|
||
class _SyncStreamResponse:
|
||
"""同步流式响应包装(把 aiohttp 的异步流按需拉成同步迭代)。
|
||
|
||
供流式下载使用:worker 是同步线程,这里经 core.async_bridge 在后台事件循环
|
||
逐块取数据,避免引入 requests 依赖(Flask 已删除、requests 已移出依赖)。
|
||
"""
|
||
|
||
def __init__(self, url: str, timeout: float):
|
||
from core import async_bridge
|
||
self._bridge = async_bridge
|
||
self._url = url
|
||
self._timeout = timeout
|
||
self._agent = None
|
||
self.status = 0
|
||
self.headers: dict[str, str] = {}
|
||
self._resp = None
|
||
|
||
def __enter__(self):
|
||
self._agent = self._bridge.run_sync(self._open())
|
||
self.status = self._agent[0]
|
||
self.headers = self._agent[1]
|
||
return self
|
||
|
||
async def _open(self):
|
||
from core.http_client import client
|
||
resp = await client.open('GET', self._url, 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/',
|
||
}, timeout=self._timeout)
|
||
return resp.status, dict(resp.headers), resp
|
||
|
||
def iter_content(self, chunk_size: int = 256 * 1024):
|
||
assert self._agent is not None
|
||
resp = self._agent[2]
|
||
it = resp.content.iter_chunked(chunk_size)
|
||
while True:
|
||
try:
|
||
chunk = self._bridge.run_sync(it.__anext__())
|
||
except StopAsyncIteration:
|
||
break
|
||
yield chunk
|
||
|
||
def __exit__(self, *exc):
|
||
if self._agent is not None and self._agent[2] is not None:
|
||
try:
|
||
self._agent[2].close()
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def _stream_open(url: str, timeout: float = 60) -> _SyncStreamResponse:
|
||
"""打开同步可迭代的流式响应(见 _SyncStreamResponse)。"""
|
||
return _SyncStreamResponse(url, timeout)
|
||
|
||
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 更新一次任务进度消息。
|
||
"""
|
||
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 _stream_open(url, timeout=60) as r:
|
||
if r.status >= 400:
|
||
raise DownloadException(f"上游返回 {r.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
|
||
|
||
|
||
# ============ 管理员设置(二维码登录 / Cookie 管理) ============
|
||
|
||
def _admin_client() -> QRLoginClient:
|
||
"""创建二维码登录客户端(基于 cookie.txt)"""
|
||
return QRLoginClient()
|
||
|
||
|
||
# ============ 个人歌单(游客扫码登录自己的账号) ============
|
||
|
||
def _user_client() -> QRLoginClient:
|
||
"""创建个人用户二维码登录客户端(第 91 条后不再落盘 user_cookie.txt,
|
||
USER_COOKIE_FILE 仅作 QRLoginClient 构造参数遗留)"""
|
||
return QRLoginClient(USER_COOKIE_FILE)
|
||
|
||
|
||
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
|
||
|
||
|
||
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: str | None = None
|
||
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
|
||
assert result.file_path is not None
|
||
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",退回 {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: str | None = None
|
||
zip_tmp_path: str | None = None
|
||
task_dir: Path | None = 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[str | None]) -> 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: str | None = 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}") from 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()}")
|
||
|
||
|
||
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: str | None = None
|
||
zip_tmp_path: str | None = None
|
||
task_dir_path: str | None = None
|
||
try:
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
task = DOWNLOAD_TASKS[task_id]
|
||
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("任务已取消")
|
||
assert download_result.file_path is not None
|
||
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)
|
||
|