From c2a8da3e4394de5373ba58ffaf64216aee031d7e Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:16:04 +0800 Subject: [PATCH] =?UTF-8?q?refactor(core):=20=E6=8A=BD=E5=87=BA=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E6=97=A0=E5=85=B3=E6=A0=B8=E5=BF=83=E5=B1=82=20core/?= =?UTF-8?q?=EF=BC=8C=E4=B8=BA=20FastAPI=20=E8=BF=81=E7=A7=BB=E9=93=BA?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第一步模块拆分(纯搬运,零行为变化): - 新增 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 行。 --- core/__init__.py | 12 ++ core/config.py | 67 +++++++++++ core/security.py | 74 ++++++++++++ core/state.py | 71 ++++++++++++ core/utils.py | 120 ++++++++++++++++++++ main.py | 288 ++++++++++------------------------------------- 6 files changed, 405 insertions(+), 227 deletions(-) create mode 100644 core/__init__.py create mode 100644 core/config.py create mode 100644 core/security.py create mode 100644 core/state.py create mode 100644 core/utils.py diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..95058f1 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +"""框架无关的核心层(Flask → FastAPI 迁移期两边共用)。 + +分层约定: +- core.config —— 常量、配置类、路径(无框架依赖,可被任意层导入) +- core.state —— 进程内运行时状态容器与锁(纯 threading/collections) +- core.utils —— 纯函数工具(字符串/时间/歌词/SRT、短链展开) +- core.security —— 音频直链签名与校验(密钥由启动时注入,不依赖 Flask) + +本包**不得**导入 flask / fastapi,也不得持有路由或请求上下文相关知识, +以便同一套状态与工具在迁移期被新老两个应用同时复用。 +""" diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..f3cb186 --- /dev/null +++ b/core/config.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""集中配置与常量(框架无关)。 + +自 main.py 原样迁出,语义不变:仅常量、配置类与路径定义。 +不导入 flask/fastapi,不持有任何运行时可变状态(运行时状态见 core.state)。 +""" +import os +from dataclasses import dataclass + + +# 站点统一版本号(P3-12/P3-22:/health、/api/info、前端 CHANGELOG 全部以此为准,别处不得硬编码) +APP_VERSION = '2.4.0' + +# 交付结果保留期(P1-3/D-1:服务端与前端「领取」入口统一 35 分钟——前端入口保留 35 分钟, +# 原 30 分钟会在最后 5 分钟让领取必 404)。终态任务记录、任务目录、下载缓存均按此回收。 +RESULT_RETENTION_SECONDS = 35 * 60 + + +@dataclass +class Settings: + """服务配置类(原 main.APIConfig;迁移期保留 APIConfig 别名以兼容既有引用)""" + host: str = '0.0.0.0' + port: int = 5000 + debug: bool = False + downloads_dir: str = 'downloads' + # 第 98 条:max_file_size/request_timeout 两个架空字段已删除——请求体上限真实 + # 生效点是 _body_size_limit(业务 2MB/备份 200MB),上游超时在 music_api 各调用点 + log_level: str = 'INFO' + cors_origins: str = (os.environ.get('CORS_ORIGINS') or '*').strip() or '*' # 第 100 条:可经环境变量收敛同源(空值回落默认) + + +# 向后兼容别名:既有代码/文档沿用 APIConfig 名称 +APIConfig = Settings + + +# 游客个人登录使用的独立 Cookie 文件(与管理员全局 Cookie 分离)。 +# 第 100 条:这是有意的隔离——QRLoginClient 的默认值恰是全局 cookie.txt, +# 显式传参防误写全局文件;第 91 条起本站任何路径都不再写它(仅构造参数) +USER_COOKIE_FILE = 'user_cookie.txt' + + +# 设备心跳时序:超时未上线则暂停其批量任务让出排队名额;超过取消窗口则取消并删文件 +PAUSE_AFTER_SECONDS = 40 +CANCEL_AFTER_SECONDS = 30 * 60 +HEARTBEAT_INTERVAL = 15 + +# 每用户同时在途(排队/运行/暂停)任务上限与全站任务表上限(F20:资源上限) +MAX_ACTIVE_TASKS_PER_USER = 5 +MAX_TOTAL_TASKS = 200 +# 第 97 条(P2-2):终态任务要留表 35 分钟供领取,容量闸只数在途任务——旧口径把 +# 终态记录计入 200 上限会把全站吞吐封顶在约 5.7 任务/分钟;终态另设更大的滞留 +# 上限保内存有界(1000 ≈ 28 任务/分钟的领取窗口,远高于在途处理能力) +MAX_TERMINAL_TASKS = 1000 + +# 全局批量打包并发上限(同时构建的 ZIP 数量) +BATCH_ZIP_CONCURRENCY = 3 + + +# 音频直链多重防护(HMAC 签名 + 时效 + 代理流) +AUDIO_TOKEN_TTL = RESULT_RETENTION_SECONDS + 60 # 第 93 条(P2-4):= 36 分钟——原 30 分钟 +# 大于交付/解析结果 35 分钟保留期 60 秒:交付窗口末段试听不再 403(旧 30 分钟口径矛盾已修) +AUDIO_URL_KEYS = {'url', 'audioUrl'} +AUDIO_HOST_MARKS = ('126.net', 'music.126.net', '.netease') + + +# 可解析音质档位(顺序即前端展示顺序) +VALID_QUALITY_LEVELS = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'] diff --git a/core/security.py b/core/security.py new file mode 100644 index 0000000..2143a5a --- /dev/null +++ b/core/security.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""音频直链多重防护(HMAC 签名 + 时效 + 代理流)——框架无关。 + +自 main.py 原样迁出,唯一改动是把密钥来源从 Flask 的 `app.secret_key` +改为**启动时注入**(`init_secret_key`),从而解除对本模块对 Flask app 的依赖; +签名/校验算法与口径保持逐字一致,迁移期新旧应用共用同一密钥即可互认 token。 +""" +import base64 +import hashlib +import hmac +import secrets +import time +from typing import Any, Optional + +from core.config import AUDIO_HOST_MARKS, AUDIO_TOKEN_TTL, AUDIO_URL_KEYS + + +# 由应用启动时注入(来源:auth_system.get_secret_key() → app.secret_key) +_SECRET_KEY: bytes = b'' + + +def init_secret_key(key) -> None: + """注入签名密钥(str 或 bytes)。须在生成/校验音频 token 前调用一次。""" + global _SECRET_KEY + _SECRET_KEY = key.encode('utf-8') if isinstance(key, str) else key + + +def is_audio_url(v) -> bool: + return isinstance(v, str) and v.startswith('http') and any(m in v for m in AUDIO_HOST_MARKS) + + +def sign_payload(payload: str) -> str: + return hmac.new(_SECRET_KEY, payload.encode('utf-8'), hashlib.sha256).hexdigest()[:32] + + +def make_audio_token(url: str) -> str: + exp = int(time.time()) + AUDIO_TOKEN_TTL + nonce = secrets.token_hex(4) + payload = base64.urlsafe_b64encode(f'{exp}|{nonce}|{url}'.encode('utf-8')).decode().rstrip('=') + return payload + '.' + sign_payload(payload) + + +def verify_audio_token(token: str) -> Optional[str]: + try: + payload, sig = token.rsplit('.', 1) + if not hmac.compare_digest(sig, sign_payload(payload)): + return None + pad = '=' * (-len(payload) % 4) + exp, _nonce, url = base64.urlsafe_b64decode(payload + pad).decode('utf-8').split('|', 2) + if int(exp) < time.time(): + return None + return url + except Exception: + return None + + +def protect_audio_urls(obj: Any) -> bool: + """递归就地替换 dict/list 中的音频直链为签名代理地址,返回是否发生替换""" + hit = {'v': False} + + def walk(o): + if isinstance(o, dict): + for k, v in list(o.items()): + if k in AUDIO_URL_KEYS and is_audio_url(v): + o[k] = '/audio_stream?token=' + make_audio_token(v) + hit['v'] = True + else: + walk(v) + elif isinstance(o, list): + for it in o: + walk(it) + + walk(obj) + return hit['v'] diff --git a/core/state.py b/core/state.py new file mode 100644 index 0000000..abc623e --- /dev/null +++ b/core/state.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""进程内运行时状态(框架无关)。 + +自 main.py 原样迁出:这些容器全部**原地修改**(永不重新绑定),因此 +`from core.state import DOWNLOAD_TASKS` 与 main 内的旧引用共享同一对象, +搬移不改变任何并发行为。 + +注意:这些状态**仅存于进程内存**,重启即丢(任务记录/限流桶/统计/缓存); +持久数据(账本/用户/设置)在 SQLite(auth_system)。集中在此以便 +Flask/FastAPI 迁移期共用,并为后续封装成带锁的单例(State 对象)留出唯一入口。 + +依赖仅限标准库 threading/collections——任何下载器/HTTP 相关对象 +(如批量打包信号量)不放在这里,随对应服务模块搬迁。 +""" +import collections +import threading +from typing import Any, Dict, List + + +# ============ 短链展开结果缓存 ============ +# 用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀) +SHORTLINK_CACHE: Dict[str, str] = {} +SHORTLINK_CACHE_LOCK = threading.Lock() + + +# ============ 公网限流(按 IP 滑动窗口,防刷防爆破) ============ +RATE_BUCKETS = {} +RATE_LOCK = threading.Lock() + + +# ============ 批量打包排队估算 ============ +# 最近完成批次(成功打包过)的耗时记录,用于估算排队用户的预计等待时间 +BATCH_DURATIONS = collections.deque(maxlen=10) +BATCH_DURATIONS_LOCK = threading.Lock() + + +# ============ 下载任务表 ============ +# (批量打包 + 单曲下载统一):task_id -> {type, total, done, failed, status, start, msg, cancel, pause, device_id, result, params} +# 前端通过 /api/batch_progress 轮询进度,支持取消;完成后文件按 35 分钟保留期清理 +DOWNLOAD_TASKS: Dict[str, Dict[str, Any]] = {} +DOWNLOAD_TASKS_LOCK = threading.Lock() + + +# ============ 设备在线心跳 ============ +# device_id -> 最近心跳时间。心跳超时(PAUSE_AFTER_SECONDS)未上线的设备,其批量任务 +# 自动暂停让出排队名额;超时超过 CANCEL_AFTER_SECONDS(30 分钟)则自动取消任务并删文件 +DEVICE_LAST_SEEN: Dict[str, float] = {} +DEVICE_LAST_SEEN_LOCK = threading.Lock() + + +# ============ 接口请求统计 ============ +REQUEST_STATS: Dict[str, Dict[str, Any]] = {} +STATS_LOCK = threading.Lock() + +# 用户解析/下载操作记录(IP);仅保留最近 2000 条 +REQ_LOG: List[Dict[str, Any]] = [] + + +# ============ 上游探测缓存 ============ +# 全局账号 MUSIC_U(5 分钟缓存;后台重新扫码后自动跟随新值) +GLOBAL_MUSIC_U_CACHE = {'v': None, 'ts': 0.0} + +# 服务器网易云账号昵称缓存(10 分钟):非管理员可见响应中遮蔽真实昵称 +SERVER_NICK_CACHE = {'nick': None, 'ts': 0.0} +SERVER_NICK_LOCK = threading.Lock() # 第 91 条(P3-9):刷新单飞锁,防并发重复打上游 +VIP_PROBE_LOCK = threading.Lock() # 第 100 条:音质探测单飞锁(有界等待) + +# 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口, +# 公网下账号栏高频刷新会连累服务器账号触发风控。 +VIP_CACHE = {'data': None, 'ts': 0.0} +VIP_USER_CACHE = {} # user 路径按 cookie 哈希的 5 分钟缓存 diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..edfe353 --- /dev/null +++ b/core/utils.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""纯函数工具(框架无关,无 Flask/FastAPI 依赖)。 + +自 main.py 原样迁出:安全整数解析、控制台输出兜底、SRT 时间轴、 +LRC→SRT 转换、字段参数解析、163cn.tv 短链展开(含私有缓存)。 +""" +import re +import sys +import threading +from typing import Any, Dict, List, Optional + + +def safe_int(value, default: int) -> int: + """用户输入的安全整数解析:垃圾值回退默认值(而非 500)""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +def safe_console_print(text: str) -> None: + """控制台输出兜底:stdout 被重定向到文件且系统编码非 UTF-8 时 + (典型:NSSM 服务日志 + 中文 Windows GBK 控制台),emoji 等字符会触发 + UnicodeEncodeError 中断启动流程——实测崩溃循环。此处降级为可编码字符。""" + try: + print(text) + except UnicodeEncodeError: + enc = getattr(sys.stdout, 'encoding', None) or 'ascii' + print(text.encode(enc, errors='replace').decode(enc, errors='replace')) + + +def srt_time(t: float) -> str: + """秒 → SRT 时间格式 HH:MM:SS,mmm""" + h = int(t // 3600) + m = int((t % 3600) // 60) + s = int(t % 60) + ms = int(round((t - int(t)) * 1000)) + if ms >= 1000: + ms = 999 + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def format_lyric(lrc: str, lyric_format: str) -> str: + """将 LRC 歌词转换为指定格式(lrc / srt) + F37:时间轴先规范化再转 SRT——支持一行多时间标签(同句多时段各自成条)、 + 分钟超两位([100:00])、乱序行(排序后区间不再倒挂)、纯文本行(无时间戳则原样保留)。""" + if lyric_format != 'srt': + return lrc + tag_re = re.compile(r'\[(\d{1,3}):(\d{1,2}(?:[.:]\d+)?)\]') + parsed = [] + for line in lrc.split('\n'): + tags = tag_re.findall(line) + text = tag_re.sub('', line).strip() + if not tags: + # 无时间戳的纯文本:保留为 0 时长行不合适,直接附在输出尾部 + if text: + parsed.append((None, text)) + continue + if not text: + continue + for m, s in tags: + try: + secs = int(m) * 60 + float(s.replace(':', '.')) + except ValueError: + continue + parsed.append((secs, text)) + timed = [(t, x) for t, x in parsed if t is not None] + untimed = [x for t, x in parsed if t is None] + if not timed: + return lrc + timed.sort(key=lambda p: p[0]) + out = [] + for i, (t, text) in enumerate(timed): + end = timed[i + 1][0] if i + 1 < len(timed) else t + 5 + if end <= t: + end = t + 1 # 同时间多行:给 1 秒最小区间,不再生成倒挂区间 + out.append(f"{i + 1}\n{srt_time(t)} --> {srt_time(end)}\n{text}\n") + if untimed: + out.append('\n'.join(untimed)) + return '\n'.join(out) + + +def parse_meta_fields(raw) -> Optional[List[str]]: + """解析写入字段参数,空返回 None(表示全部写入)""" + if raw is None or raw == '': + return None + if isinstance(raw, str): + return [f.strip() for f in raw.split(',') if f.strip()] or None + try: + return list(raw) or None + except Exception: + return None + + +# 短链展开结果缓存:用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀) +_SHORTLINK_CACHE: Dict[str, str] = {} +_SHORTLINK_CACHE_LOCK = threading.Lock() + + +def expand_shortlink(url: str) -> str: + """展开 163cn.tv 短链(带进程内缓存;失败原样返回)""" + with _SHORTLINK_CACHE_LOCK: + hit = _SHORTLINK_CACHE.get(url) + if hit is not None: + return hit + expanded = url + try: + import requests + response = requests.get(url, allow_redirects=False, timeout=10) + expanded = response.headers.get('Location', url) + except Exception: + expanded = url + # 第 97 条(P3):展开失败(Location 缺失/网络异常,expanded 原样等于 url)不写 + # 缓存——旧口径一次网络抖动会让该分享链接在整个缓存存续期内必然解析失败 + if expanded != url: + with _SHORTLINK_CACHE_LOCK: + if len(_SHORTLINK_CACHE) > 500: + _SHORTLINK_CACHE.clear() + _SHORTLINK_CACHE[url] = expanded + return expanded diff --git a/main.py b/main.py index 85d2a0f..3fd8e91 100644 --- a/main.py +++ b/main.py @@ -54,25 +54,42 @@ except ImportError as e: sys.exit(1) -# 站点统一版本号(P3-12/P3-22:/health、/api/info、前端 CHANGELOG 全部以此为准,别处不得硬编码) -APP_VERSION = '2.4.0' +# ============ 框架无关核心层(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, +) -# 交付结果保留期(P1-3/D-1:服务端与前端「领取」入口统一 35 分钟——前端入口保留 35 分钟, -# 原 30 分钟会在最后 5 分钟让领取必 404)。终态任务记录、任务目录、下载缓存均按此回收。 -RESULT_RETENTION_SECONDS = 35 * 60 - - -@dataclass -class APIConfig: - """API配置类""" - host: str = '0.0.0.0' - port: int = 5000 - debug: bool = False - downloads_dir: str = 'downloads' - # 第 98 条:max_file_size/request_timeout 两个架空字段已删除——请求体上限真实 - # 生效点是 _body_size_limit(业务 2MB/备份 200MB),上游超时在 music_api 各调用点 - log_level: str = 'INFO' - cors_origins: str = (os.environ.get('CORS_ORIGINS') or '*').strip() or '*' # 第 100 条:可经环境变量收敛同源(空值回落默认) +# 向后兼容别名:既有代码/文档沿用 APIConfig 名称 +APIConfig = Settings class APIResponse: @@ -103,34 +120,6 @@ class APIResponse: return response, status_code -# 短链展开结果缓存:用户反复解析同一分享链接不再重复请求 163cn.tv(有界防膨胀) -_SHORTLINK_CACHE: Dict[str, str] = {} -_SHORTLINK_CACHE_LOCK = threading.Lock() - - -def _expand_shortlink(url: str) -> str: - """展开 163cn.tv 短链(带进程内缓存;失败原样返回)""" - with _SHORTLINK_CACHE_LOCK: - hit = _SHORTLINK_CACHE.get(url) - if hit is not None: - return hit - expanded = url - try: - import requests - response = requests.get(url, allow_redirects=False, timeout=10) - expanded = response.headers.get('Location', url) - except Exception: - expanded = url - # 第 97 条(P3):展开失败(Location 缺失/网络异常,expanded 原样等于 url)不写 - # 缓存——旧口径一次网络抖动会让该分享链接在整个缓存存续期内必然解析失败 - if expanded != url: - with _SHORTLINK_CACHE_LOCK: - if len(_SHORTLINK_CACHE) > 500: - _SHORTLINK_CACHE.clear() - _SHORTLINK_CACHE[url] = expanded - return expanded - - class MusicAPIService: """音乐API服务类""" @@ -281,6 +270,9 @@ 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: @@ -309,8 +301,7 @@ register_auth_routes(app) # ============ 公网限流(按 IP 滑动窗口,防刷防爆破) ============ -_RATE_BUCKETS = {} -_RATE_LOCK = threading.Lock() +# _RATE_BUCKETS / _RATE_LOCK 已迁出至 core.state(此处 import 同名共享) def _rate_client_ip() -> str: @@ -679,43 +670,19 @@ def api_fe_error(): # 见 _verify_admin_password。管理后台(/admin/site/*)与旧版二维码接口(/admin/qr/*) # 均通过该会话鉴权。 -# 游客个人登录使用的独立 Cookie 文件(与管理员全局 Cookie 分离)。 -# 第 100 条:这是有意的隔离——QRLoginClient 的默认值恰是全局 cookie.txt, -# 显式传参防误写全局文件;第 91 条起本站任何路径都不再写它(仅构造参数) -USER_COOKIE_FILE = 'user_cookie.txt' - # 全局批量打包并发信号量:限制同时构建的批量 ZIP 数量。 # 大量用户同时批量下载时,超出的请求排队等待,避免磁盘/内存/CPU 被同时占满。 # (下载本身的并发已由下载器内的全局信号量控制) # 使用带计数的信号量,供前端实时展示排队人数与预计等待时间。 -BATCH_ZIP_SEM = CountingSemaphore(3) +# 注:信号量依赖下载器的 CountingSemaphore,属服务层对象,随下载服务搬迁, +# 暂留 main(并发上限 3 见 core.config.BATCH_ZIP_CONCURRENCY)。 +BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY) -# 最近完成批次(成功打包过)的耗时记录,用于估算排队用户的预计等待时间 -BATCH_DURATIONS = collections.deque(maxlen=10) -BATCH_DURATIONS_LOCK = threading.Lock() - -# 下载任务表(批量打包 + 单曲下载统一):task_id -> {type, total, done, failed, status, start, msg, cancel, pause, device_id, result, params} -# 前端通过 /api/batch_progress 轮询进度,支持取消;完成后文件按 35 分钟保留期清理 -DOWNLOAD_TASKS: Dict[str, Dict[str, Any]] = {} -DOWNLOAD_TASKS_LOCK = threading.Lock() - -# 设备在线心跳:device_id -> 最近心跳时间 -# 心跳超时(PAUSE_AFTER_SECONDS)未上线的设备,其批量任务自动暂停,让出排队名额给下一位; -# 重新打开网站会恢复心跳并自动回到队列继续下载 -# 心跳超时超过 CANCEL_AFTER_SECONDS(30 分钟)则自动取消任务并删除音乐文件 -DEVICE_LAST_SEEN: Dict[str, float] = {} -DEVICE_LAST_SEEN_LOCK = threading.Lock() -PAUSE_AFTER_SECONDS = 40 -CANCEL_AFTER_SECONDS = 30 * 60 -HEARTBEAT_INTERVAL = 15 - -# 每用户同时在途(排队/运行/暂停)任务上限与全站任务表上限(F20:资源上限) -MAX_ACTIVE_TASKS_PER_USER = 5 -MAX_TOTAL_TASKS = 200 -# 第 97 条(P2-2):终态任务要留表 35 分钟供领取,容量闸只数在途任务——旧口径把 -# 终态记录计入 200 上限会把全站吞吐封顶在约 5.7 任务/分钟;终态另设更大的滞留 -# 上限保内存有界(1000 ≈ 28 任务/分钟的领取窗口,远高于在途处理能力) -MAX_TERMINAL_TASKS = 1000 +# 以下运行时状态/常量已迁出(原样实现,此处 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]: @@ -1089,28 +1056,11 @@ def _touch_device(device_id: str) -> None: DEVICE_LAST_SEEN[device_id] = time.time() -def _safe_int(value, default: int) -> int: - """用户输入的安全整数解析:垃圾值回退默认值(而非 500)""" - try: - return int(value) - except (TypeError, ValueError): - return default - - -def _safe_console_print(text: str) -> None: - """控制台输出兜底:stdout 被重定向到文件且系统编码非 UTF-8 时 - (典型:NSSM 服务日志 + 中文 Windows GBK 控制台),emoji 等字符会触发 - UnicodeEncodeError 中断启动流程——实测崩溃循环。此处降级为可编码字符。""" - try: - print(text) - except UnicodeEncodeError: - enc = getattr(sys.stdout, 'encoding', None) or 'ascii' - print(text.encode(enc, errors='replace').decode(enc, errors='replace')) +# _safe_int / _safe_console_print 已迁出至 core.utils(此处 import 同名共享) # ============ 接口请求统计 ============ -REQUEST_STATS: Dict[str, Dict[str, Any]] = {} -_STATS_LOCK = threading.Lock() +# REQUEST_STATS / _STATS_LOCK / REQ_LOG 已迁出至 core.state(此处 import 同名共享) # 只统计真实业务接口(排除首页/健康检查/统计/账号探测等内部调用) # F09:兼容别名(/Search、/Song_V1、/Playlist、/Album、/Download)一并纳入统计与限流, @@ -1153,8 +1103,7 @@ ENDPOINT_NAMES = { '/api/playlist/play': '公开歌单播放API', } -# 用户解析/下载操作记录(IP) -REQ_LOG: List[Dict[str, Any]] = [] +# 用户解析/下载操作记录(IP)——REQ_LOG 已迁出至 core.state(此处 import 同名共享) def _record_log(action: str, ip: str, song: str) -> None: @@ -1182,7 +1131,7 @@ def _parse_cookie_str(raw) -> Dict[str, str]: return cookies -_GLOBAL_MUSIC_U_CACHE = {'v': None, 'ts': 0.0} +# _GLOBAL_MUSIC_U_CACHE 已迁出至 core.state(此处 import 同名共享) def _global_music_u() -> str: @@ -1247,67 +1196,7 @@ def _build_download_base(filename_format: str, music_info: Dict[str, Any]) -> st return _safe(f"{artist} - {name}") # artist_song(默认) -def _srt_time(t: float) -> str: - """秒 → SRT 时间格式 HH:MM:SS,mmm""" - h = int(t // 3600) - m = int((t % 3600) // 60) - s = int(t % 60) - ms = int(round((t - int(t)) * 1000)) - if ms >= 1000: - ms = 999 - return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" - - -def _format_lyric(lrc: str, lyric_format: str) -> str: - """将 LRC 歌词转换为指定格式(lrc / srt) - F37:时间轴先规范化再转 SRT——支持一行多时间标签(同句多时段各自成条)、 - 分钟超两位([100:00])、乱序行(排序后区间不再倒挂)、纯文本行(无时间戳则原样保留)。""" - if lyric_format != 'srt': - return lrc - tag_re = re.compile(r'\[(\d{1,3}):(\d{1,2}(?:[.:]\d+)?)\]') - parsed = [] - for line in lrc.split('\n'): - tags = tag_re.findall(line) - text = tag_re.sub('', line).strip() - if not tags: - # 无时间戳的纯文本:保留为 0 时长行不合适,直接附在输出尾部 - if text: - parsed.append((None, text)) - continue - if not text: - continue - for m, s in tags: - try: - secs = int(m) * 60 + float(s.replace(':', '.')) - except ValueError: - continue - parsed.append((secs, text)) - timed = [(t, x) for t, x in parsed if t is not None] - untimed = [x for t, x in parsed if t is None] - if not timed: - return lrc - timed.sort(key=lambda p: p[0]) - out = [] - for i, (t, text) in enumerate(timed): - end = timed[i + 1][0] if i + 1 < len(timed) else t + 5 - if end <= t: - end = t + 1 # 同时间多行:给 1 秒最小区间,不再生成倒挂区间 - out.append(f"{i + 1}\n{_srt_time(t)} --> {_srt_time(end)}\n{text}\n") - if untimed: - out.append('\n'.join(untimed)) - return '\n'.join(out) - - -def _parse_meta_fields(raw) -> Optional[List[str]]: - """解析写入字段参数,空返回 None(表示全部写入)""" - if raw is None or raw == '': - return None - if isinstance(raw, str): - return [f.strip() for f in raw.split(',') if f.strip()] or None - try: - return list(raw) or None - except Exception: - return None +# _srt_time / _format_lyric / _parse_meta_fields 已迁出至 core.utils(此处 import 同名共享) def _verify_admin_password(data) -> Optional[Tuple[Dict[str, Any], int]]: @@ -1351,10 +1240,8 @@ def _detect_vip(cookies: Dict[str, str]) -> Tuple[int, str, List[str]]: _VIP_PROBE_LOCK.release() -# 服务器网易云账号昵称缓存(10 分钟):用于在非管理员可见的响应中遮蔽真实昵称 -_SERVER_NICK_CACHE = {'nick': None, 'ts': 0.0} -_SERVER_NICK_LOCK = threading.Lock() # 第 91 条(P3-9):刷新单飞锁,防并发重复打上游 -_VIP_PROBE_LOCK = threading.Lock() # 第 100 条:音质探测单飞锁(有界等待) +# 服务器网易云账号昵称缓存与单飞锁已迁出至 core.state(此处 import 同名共享): +# _SERVER_NICK_CACHE(10 分钟) / _SERVER_NICK_LOCK / _VIP_PROBE_LOCK def _mask_server_nickname(nick: str) -> str: @@ -1434,60 +1321,10 @@ def before_request(): # ============ 音频直链多重防护(HMAC 签名 + 时效 + 代理流)============ -_AUDIO_TOKEN_TTL = RESULT_RETENTION_SECONDS + 60 # 第 93 条(P2-4):= 36 分钟——原 30 分钟 -# 大于交付/解析结果 35 分钟保留期 60 秒:交付窗口末段试听不再 403(旧 30 分钟口径矛盾已修) -_AUDIO_URL_KEYS = {'url', 'audioUrl'} -_AUDIO_HOST_MARKS = ('126.net', 'music.126.net', '.netease') - - -def _is_audio_url(v) -> bool: - return isinstance(v, str) and v.startswith('http') and any(m in v for m in _AUDIO_HOST_MARKS) - - -def _sign_payload(payload: str) -> str: - key = app.secret_key.encode('utf-8') if isinstance(app.secret_key, str) else app.secret_key - return hmac.new(key, payload.encode('utf-8'), hashlib.sha256).hexdigest()[:32] - - -def _make_audio_token(url: str) -> str: - exp = int(time.time()) + _AUDIO_TOKEN_TTL - nonce = secrets.token_hex(4) - payload = base64.urlsafe_b64encode(f'{exp}|{nonce}|{url}'.encode('utf-8')).decode().rstrip('=') - return payload + '.' + _sign_payload(payload) - - -def _verify_audio_token(token: str) -> Optional[str]: - try: - payload, sig = token.rsplit('.', 1) - if not hmac.compare_digest(sig, _sign_payload(payload)): - return None - pad = '=' * (-len(payload) % 4) - exp, _nonce, url = base64.urlsafe_b64decode(payload + pad).decode('utf-8').split('|', 2) - if int(exp) < time.time(): - return None - return url - except Exception: - return None - - -def _protect_audio_urls(obj) -> bool: - """递归就地替换 dict/list 中的音频直链为签名代理地址,返回是否发生替换""" - hit = {'v': False} - - def walk(o): - if isinstance(o, dict): - for k, v in list(o.items()): - if k in _AUDIO_URL_KEYS and _is_audio_url(v): - o[k] = '/audio_stream?token=' + _make_audio_token(v) - hit['v'] = True - else: - walk(v) - elif isinstance(o, list): - for it in o: - walk(it) - - walk(obj) - return hit['v'] +# 已迁出至 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') @@ -3017,12 +2854,9 @@ def playlist_play_api(): return APIResponse.error("服务暂时不可用,请稍后再试", 500) -# 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口, -# 公网下账号栏高频刷新会连累服务器账号触发风控。 -# 第 91 条:修复潜伏缩进缺陷——原定义误缩进在 /account/vip 函数体内,每次调用 -# 都被重置(5 分钟缓存从未生效),且模块级 _vip_cache_reset 引用它必然 NameError -_VIP_CACHE = {'data': None, 'ts': 0.0} -_VIP_USER_CACHE = {} # user 路径按 cookie 哈希的 5 分钟缓存 +# 全局账号 VIP 探测缓存已迁出至 core.state(此处 import 同名共享): +# _VIP_CACHE(5 分钟;第 91 条修复的缩进缺陷原样保留正确语义) +# _VIP_USER_CACHE(user 路径按 cookie 哈希的 5 分钟缓存) def _vip_cache_reset() -> None: