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), "")
|