- policy.py:per-group 正交策略(自动解析 / 自动策略 / 禁用策略 / 存储 A·B·C /
公网 / 链接 / 群文件 + 平台限定),list.json v1/v2 → v3 自动迁移,
写入统一走 PolicyStore(加锁 + .tmp 原子替换 + 字段归一)
- 群文件并行通道 group_file.py:打包 zip(可选 pyzipper AES-256)后优先走 S3 预签名、
本地直传兜底;设了密码但 pyzipper 不可用就放弃上传,不退化成明文
- list_proc.py 收敛到「视频策略」统一入口,权限判定改走 policy
- Web 管理页 /hub/video_analysis(群策略 + 链接解析面板)与 services/web_jobs.py
(只复用纯函数层,Web 上下文不发消息;内存任务表 + 并发闸门 + 超时)
- 媒体命名统一到 utils.py({作者}_{作者id}/{作品名}[_短码]),cleanup 回收空目录
- 测试:policy / 命名 / 群文件 / web_jobs 四组
顺带 pyproject 的 pytest 加 testpaths=tests(避免收进 debug/ 下的调试脚本)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
177 lines
7.3 KiB
Python
177 lines
7.3 KiB
Python
"""视频解析 Web API 子应用(挂载到 /api/video_analysis)。
|
||
|
||
群策略(data/list.json v3,见 policy.py)的唯一 Web 读写入口,
|
||
鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
|
||
前端页面:hexi/web/src/plugins/video_analysis/index.tsx。
|
||
|
||
写入全部落在 `policy.PolicyStore` 上(加锁 + 原子替换 + 归一化),
|
||
所以这里不需要再做字段校验,只要校验群号形态。
|
||
|
||
另有一组「链接解析 + 预览」接口(/parse、/jobs、/jobs/{id}/refresh):
|
||
粘链接 → 起任务 → 出下载链接 + 页面内预览,实现在 services/web_jobs.py。
|
||
**每条路由都要自带 `dependencies=[auth]`** —— mount 层没有兜底,
|
||
漏一条就是匿名可访问(包括这条"让服务器去抓任意 URL"的接口)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.responses import JSONResponse
|
||
|
||
from nonebot import get_adapter
|
||
from nonebot.adapters.onebot.v11 import Adapter
|
||
|
||
from hexi.web_hub.web_auth import require_admin
|
||
|
||
from .policy import PLANS, PLATFORMS, STORE, Policy
|
||
from .services.web_jobs import JOBS, extract_urls
|
||
|
||
API = require_admin
|
||
|
||
|
||
def _ok(data=None, msg: str = "ok") -> JSONResponse:
|
||
return JSONResponse({"status": 0, "msg": msg, "data": data})
|
||
|
||
|
||
def _fail(msg: str, status: int = 400) -> JSONResponse:
|
||
return JSONResponse({"status": status, "msg": msg})
|
||
|
||
|
||
async def _group_names() -> dict[str, str]:
|
||
"""群号 → 群名;拿不到 bot(未连接)时返回空表,前端只显示群号。"""
|
||
try:
|
||
bots = get_adapter(Adapter).bots
|
||
bot = next(iter(bots.values()), None)
|
||
if bot is None:
|
||
return {}
|
||
return {
|
||
str(g["group_id"]): g.get("group_name") or ""
|
||
for g in await bot.get_group_list()
|
||
}
|
||
except Exception: # noqa: BLE001 — 未连接/适配器未加载都按拿不到处理
|
||
return {}
|
||
|
||
|
||
def _payload(policy: Policy) -> dict:
|
||
"""策略的 JSON 视图;sends_link 是"实际会不会发链接"(供前端置灰)。"""
|
||
data = policy.to_dict()
|
||
data["sends_link"] = policy.sends_link
|
||
return data
|
||
|
||
|
||
def build_admin_app() -> FastAPI | None:
|
||
"""构建群策略管理 API 子应用(挂载到 /api/video_analysis)。"""
|
||
app = FastAPI(title="Video Analysis API")
|
||
auth = require_admin
|
||
|
||
@app.get("/overview", response_class=JSONResponse, dependencies=[auth])
|
||
async def overview():
|
||
"""一次拿全:群策略列表 + 默认节 + 黑名单 + 平台/方案选项。"""
|
||
names = await _group_names()
|
||
groups = [
|
||
{
|
||
"group_id": gid,
|
||
"group_name": names.get(gid, ""),
|
||
"online": gid in names,
|
||
"policy": _payload(policy),
|
||
}
|
||
for gid, policy in sorted(STORE.all_groups().items(), key=_sort_key)
|
||
]
|
||
return _ok(
|
||
{
|
||
"groups": groups,
|
||
"default": _payload(STORE.default_policy()),
|
||
"blacklist": STORE.blacklist(),
|
||
"platforms": list(PLATFORMS),
|
||
"plans": list(PLANS),
|
||
"online": bool(names),
|
||
}
|
||
)
|
||
|
||
@app.post("/group", response_class=JSONResponse, dependencies=[auth])
|
||
async def save_group(data: dict):
|
||
"""新增/覆盖单个群的策略(群不存在则加入白名单)。"""
|
||
gid = str(data.get("group_id", "")).strip()
|
||
if not gid.isdigit():
|
||
return _fail("群号必须是纯数字")
|
||
policy = await STORE.set_group(gid, Policy.from_dict(data.get("policy")))
|
||
return _ok(_payload(policy), f"群 {gid} 策略已保存")
|
||
|
||
@app.delete("/group/{group_id}", response_class=JSONResponse, dependencies=[auth])
|
||
async def remove_group(group_id: str):
|
||
"""移出白名单(该群不再解析)。"""
|
||
if not await STORE.remove_group(group_id):
|
||
return _fail(f"群 {group_id} 不在白名单中")
|
||
return _ok({"group_id": group_id}, f"群 {group_id} 已移出白名单")
|
||
|
||
@app.post("/default", response_class=JSONResponse, dependencies=[auth])
|
||
async def save_default(data: dict):
|
||
"""保存默认策略(私聊与未配置群使用)。"""
|
||
policy = await STORE.set_default(Policy.from_dict(data.get("policy", data)))
|
||
return _ok(_payload(policy), "默认策略已保存")
|
||
|
||
@app.post("/blacklist", response_class=JSONResponse, dependencies=[auth])
|
||
async def save_blacklist(data: dict):
|
||
"""整体替换全局黑名单(QQ 列表)。"""
|
||
raw = data.get("blacklist", [])
|
||
if not isinstance(raw, list):
|
||
return _fail("blacklist 必须是列表")
|
||
values = [str(x).strip() for x in raw if str(x).strip()]
|
||
invalid = [v for v in values if not v.isdigit()]
|
||
if invalid:
|
||
return _fail(f"黑名单只能是 QQ 号:{'、'.join(invalid)}")
|
||
|
||
stored = await STORE.set_blacklist(values)
|
||
return _ok({"blacklist": stored}, "黑名单已保存")
|
||
|
||
# ── 链接解析 + 预览(services/web_jobs.py) ──────────────
|
||
# 与群策略无关:解析结果按默认策略(default 节)的 plan / upload_public 存储。
|
||
|
||
@app.post("/parse", response_class=JSONResponse, dependencies=[auth])
|
||
async def parse(data: dict):
|
||
"""整段文本里挑链接 → 逐个起任务;同 URL 已有在跑的任务时直接复用。"""
|
||
urls = extract_urls(str(data.get("text") or ""))
|
||
if not urls:
|
||
return _fail("没找到链接")
|
||
jobs = await JOBS.submit(urls, force=bool(data.get("force")))
|
||
return _ok(
|
||
{"jobs": [j.to_dict() for j in jobs], "urls": urls},
|
||
f"已提交 {len(jobs)} 个任务",
|
||
)
|
||
|
||
@app.get("/jobs", response_class=JSONResponse, dependencies=[auth])
|
||
async def list_jobs():
|
||
"""全部任务,新的在前(前端按 1.5s 轮询这一个接口)。"""
|
||
return _ok({"jobs": [j.to_dict() for j in JOBS.list_jobs()]})
|
||
|
||
@app.post(
|
||
"/jobs/{job_id}/refresh", response_class=JSONResponse, dependencies=[auth]
|
||
)
|
||
async def refresh_job(job_id: str):
|
||
"""重传已落盘文件换一批新链接(预签名链接 1 小时过期)。"""
|
||
job = await JOBS.refresh(job_id)
|
||
if job is None:
|
||
return _fail("任务不存在,或还没有可刷新的文件")
|
||
return _ok({"job": job.to_dict()}, "链接已刷新")
|
||
|
||
@app.delete("/jobs/{job_id}", response_class=JSONResponse, dependencies=[auth])
|
||
async def remove_job(job_id: str):
|
||
"""删掉一条任务(还在跑的一并取消);只清任务表,temp 里的文件交给清理任务。"""
|
||
if not JOBS.remove(job_id):
|
||
return _fail("任务不存在")
|
||
return _ok({"job_id": job_id}, "任务已清理")
|
||
|
||
@app.post("/jobs/clear", response_class=JSONResponse, dependencies=[auth])
|
||
async def clear_jobs():
|
||
"""清掉所有已完成/失败的任务(排队、运行中的不动)。"""
|
||
removed = JOBS.clear_finished()
|
||
return _ok({"removed": removed}, f"已清理 {removed} 条任务")
|
||
|
||
return app
|
||
|
||
|
||
def _sort_key(item: tuple[str, Policy]) -> tuple[int, int, str]:
|
||
"""群号按数值排序(非数字的排最后,不参与数值比较)。"""
|
||
gid = item[0]
|
||
return (1, 0, gid) if not gid.isdigit() else (0, int(gid), "")
|