copy(server): 下载、整理、站点、浏览器、字幕等模块错误与提示文案重写

缓存卷 → 下载盘;站点快照 → 站点列表抓取;Feed → RSS;去掉猜测原因与「请…」;同步断言。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-07 01:52:24 +08:00
1 parent b752ce26f7
commit 0019dc1afe
51 files changed
+140 -142

No files matched your search

+1 -1
View File
@@ -229,7 +229,7 @@ export function requireAdminSession(): MiddlewareHandler<AuthEnv> {
return async (c, next) => { return async (c, next) => {
const p = c.get("principal"); const p = c.get("principal");
if (!p) throw new ApiException("unauthorized", "未登录"); if (!p) throw new ApiException("unauthorized", "未登录");
if (p.kind !== "admin_session") throw new ApiException("forbidden", "这个操作需要在控制台登录后进行"); if (p.kind !== "admin_session") throw new ApiException("forbidden", "这个操作只能在控制台登录后使用");
return next(); return next();
}; };
} }
@@ -116,7 +116,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "downloads.sync", key: "downloads.sync",
defaults: { defaults: {
name: "qB 同步", name: "qB 同步",
description: "同步下载器任务状态;下载盘到暂停线时暂停,降到恢复线后恢复", description: "同步下载器任务状态;下载盘到暂停线时暂停任务,降到恢复线后恢复",
group: "download", group: "download",
schedule: every(syncMs), schedule: every(syncMs),
}, },
@@ -151,7 +151,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
const pct = usedPercent(v); const pct = usedPercent(v);
return pct !== null && pct >= volumeThresholds(v, cache).hardThresholdPercent; return pct !== null && pct >= volumeThresholds(v, cache).hardThresholdPercent;
}); });
if (!over) return `未超阈值${note}`; if (!over) return `未到暂停线${note}`;
return ((await enqueueThresholdCleanup(d)) ? "已入队清理" : "清理进行中") + note; return ((await enqueueThresholdCleanup(d)) ? "已入队清理" : "清理进行中") + note;
}), }),
}); });
@@ -69,7 +69,7 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
method: "get", method: "get",
path: "/dashboard", path: "/dashboard",
scope: "read", scope: "read",
summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出", summary: "首页聚合:自动化概览 / 当前工作 / 需要处理 / 缓存与保留 / 站点健康 / 最近产出",
response: Dashboard, response: Dashboard,
}, },
async () => { async () => {
@@ -142,7 +142,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
const exists = await db().selectFrom("download_task").select("id").where("id", "=", params.id).executeTakeFirst(); const exists = await db().selectFrom("download_task").select("id").where("id", "=", params.id).executeTakeFirst();
if (!exists) notFound("下载任务"); if (!exists) notFound("下载任务");
const r = await userResume(d, params.id); const r = await userResume(d, params.id);
if (r === "threshold_active") throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,不能手动恢复"); if (r === "threshold_active") throw new ApiException("precondition_failed", "下载盘仍高于恢复线,不能手动恢复");
return toApi<DownloadTask>(r); return toApi<DownloadTask>(r);
}, },
{ tag: TAG }, { tag: TAG },
@@ -158,7 +158,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
method: "post", method: "post",
path: "/download-cache/cleanup", path: "/download-cache/cleanup",
scope: "admin", scope: "admin",
summary: "入队一轮缓存清理(只清已核验整理且满足保留/H&R 的纳管任务);返回任务 id,进度见任务页", summary: "入队一轮下载盘清理(只清已整理、满足保种与 H&R 要求的本工具管理任务);返回任务 id,进度见任务页",
body: z.object({ downloaderInstanceId: z.uuid().optional() }), body: z.object({ downloaderInstanceId: z.uuid().optional() }),
response: accepted, response: accepted,
}, },
@@ -177,7 +177,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
method: "post", method: "post",
path: "/library/reconcile", path: "/library/reconcile",
scope: "admin", scope: "admin",
summary: "入队一轮资产对账(媒体库目录哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页", summary: "入队一轮文件核对(媒体库目录标记文件 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
body: z.object({ libraryRootId: z.uuid().optional() }), body: z.object({ libraryRootId: z.uuid().optional() }),
response: accepted, response: accepted,
}, },
@@ -5,7 +5,7 @@ import { loadLibraryVersions } from "../services/library-versions.ts";
import { notFound } from "../util.ts"; import { notFound } from "../util.ts";
import { mount } from "./helpers.ts"; import { mount } from "./helpers.ts";
// LIBV-1:作品的入库版本(每季每集的本地文件版本、季 / 作品汇总、与版本目标的比对)。 // LIBV-1:作品的入库版本(每季每集的本地文件版本、季 / 作品汇总、与收录版本的比对)。
export function registerLibraryVersionRoutes(app: ModuleApp, d: AoDeps) { export function registerLibraryVersionRoutes(app: ModuleApp, d: AoDeps) {
mount( mount(
@@ -14,7 +14,7 @@ export function registerLibraryVersionRoutes(app: ModuleApp, d: AoDeps) {
method: "get", method: "get",
path: "/library/{id}/versions", path: "/library/{id}/versions",
scope: "read", scope: "read",
summary: "作品的已整理版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对", summary: "作品的已整理版本:每季每集的本地文件版本与规格、季 / 作品汇总、与收录版本的比对",
params: IdParam, params: IdParam,
query: LibraryVersionsQuery, query: LibraryVersionsQuery,
response: LibraryVersions, response: LibraryVersions,
@@ -136,7 +136,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
method: "get", method: "get",
path: "/library", path: "/library",
scope: "read", scope: "read",
summary: "媒体库列表(已登记资产或正在追踪的作品)", summary: "媒体库列表(有已整理文件或正在追踪的作品)",
query: LibraryListQuery, query: LibraryListQuery,
response: cursorPage(LibraryItemSchema), response: cursorPage(LibraryItemSchema),
}, },
@@ -239,7 +239,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
method: "get", method: "get",
path: "/library/{id}", path: "/library/{id}",
scope: "read", scope: "read",
summary: "作品详情:实际路径、身份映射、元数据来源、各 Slot、候选拒绝理由、下载历史与校验结果", summary: "作品详情:实际路径、身份映射、元数据来源、各收录版本、资源被拒原因、下载历史与校验结果",
params: IdParam, params: IdParam,
response: LibraryDetail, response: LibraryDetail,
}, },
@@ -450,7 +450,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
method: "get", method: "get",
path: "/inventory", path: "/inventory",
scope: "read", scope: "read",
summary: "库存叠加(站点工作台):按作品返回已有版本 / Slot 缺口 / 下载中", summary: "库存叠加(站点工作台):按作品返回已有版本 / 收录版本缺口 / 下载中",
query: InventoryQuery, query: InventoryQuery,
response: InventoryResponse, response: InventoryResponse,
}, },
@@ -255,8 +255,7 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
if (block) { if (block) {
throw new ApiException( throw new ApiException(
"conflict", "conflict",
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},已拒绝移动(未改为复制)。` + `下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},已拒绝移动,没有改成复制。改用复制,或等做种完成后再移动`,
`待处理事项 ${block.decisionId}`,
); );
} }
await audit(d, actorOf(c), "organize.move_authorized", { await audit(d, actorOf(c), "organize.move_authorized", {
@@ -506,7 +506,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
}; };
const resume = async (sub: SubscriptionRow) => { const resume = async (sub: SubscriptionRow) => {
if ((TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) return sub; if ((TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) return sub;
if (sub.status !== "paused") conflict(`订阅状态为 ${sub.status},已完成/已取消的订阅用"重新订阅"`); if (sub.status !== "paused") conflict(`订阅状态为 ${sub.status},已完成或已取消的订阅用「重新订阅」`);
const ready = await tmdbReady(db(), sub.media_entity_id, sub.media_kind); const ready = await tmdbReady(db(), sub.media_entity_id, sub.media_kind);
const r = await setStatus(sub, ready ? "active" : "pending", ready ? "resumed" : "metadata_pending", { const r = await setStatus(sub, ready ? "active" : "pending", ready ? "resumed" : "metadata_pending", {
nextSearchNow: true, nextSearchNow: true,
@@ -543,7 +543,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
async (c, { params }) => { async (c, { params }) => {
const sub = await load(params.id); const sub = await load(params.id);
if (sub.status !== "completed" && sub.status !== "cancelled") if (sub.status !== "completed" && sub.status !== "cancelled")
conflict(sub.status === "paused" ? "暂停的订阅用恢复" : "订阅仍在追踪"); conflict(sub.status === "paused" ? "暂停的订阅用「恢复」" : "订阅仍在追踪");
await assertNoDuplicate(db(), { await assertNoDuplicate(db(), {
entityId: sub.media_entity_id, entityId: sub.media_entity_id,
seasonUnitId: sub.season_unit_id, seasonUnitId: sub.season_unit_id,
@@ -627,7 +627,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
async (_c, { params, body }) => { async (_c, { params, body }) => {
const sub = await load(params.id); const sub = await load(params.id);
if (!(TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) if (!(TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status))
conflict("订阅不在追踪中(暂停/已完成/已取消),先恢复或重新订阅"); conflict("订阅不在追踪中(已暂停、已完成或已取消),先恢复或重新订阅");
const slots = sub.media_entity_id const slots = sub.media_entity_id
? await db() ? await db()
.selectFrom("acquisition_slot") .selectFrom("acquisition_slot")
@@ -692,7 +692,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
async (_c, { params, body }) => { async (_c, { params, body }) => {
const sub = await load(params.id); const sub = await load(params.id);
if (!sub.media_entity_id || sub.media_kind === "movie") if (!sub.media_entity_id || sub.media_kind === "movie")
throw new ApiException("bad_request", "只有已关联作品的剧集/综艺订阅有季总集数"); throw new ApiException("bad_request", "只有已关联作品的剧集和综艺订阅有季总集数");
await db() await db()
.transaction() .transaction()
.execute(async (trx) => { .execute(async (trx) => {
@@ -1237,7 +1237,7 @@ async function gates(
rejected: [ rejected: [
{ {
code: `cache.${dv.decision}`, code: `cache.${dv.decision}`,
message: `缓存卷 ${vol.volumeKey} 使用率 ${dv.usedPct?.toFixed(1) ?? "未知"}%,预测 ${dv.projectedPct?.toFixed(1) ?? "未知"}%`, message: `下载盘 ${vol.volumeKey} 已用 ${dv.usedPct?.toFixed(1) ?? "未知"}%,下完预计 ${dv.projectedPct?.toFixed(1) ?? "未知"}%`,
}, },
], ],
}; };
@@ -1703,11 +1703,11 @@ export async function cleanupDownloadCache(
await upsertDecision(d, db, { await upsertDecision(d, db, {
kind: "space_insufficient", kind: "space_insufficient",
severity: "critical", severity: "critical",
title: `下载缓存卷 ${v.volumeKey} 空间不足`, title: `下载盘 ${v.volumeKey} 空间不足`,
summary: summary:
`使用率 ${pct.toFixed(1)}%,可清理任务已用尽;新下载已暂停` + `已用 ${pct.toFixed(1)}%,没有可清理的任务了;新下载已暂停` +
(hnrUnknown (hnrUnknown
? `。另有 ${hnrUnknown} 个任务因为 H&R 要求未知没有清理,确认没有 H&R 要求的话,可在下载器设置里打开「H&R 未知也可清理」` ? `。另有 ${hnrUnknown} 个任务因为 H&R 要求未知没有清理,确认没有 H&R 要求时,可在下载器设置里打开「H&R 未知也可清理」`
: ""), : ""),
subjectType: "cache_volume", subjectType: "cache_volume",
dedupeKey: `space_insufficient:${v.volumeKey}`, dedupeKey: `space_insufficient:${v.volumeKey}`,
@@ -51,7 +51,7 @@ export async function planManualAcquisition(
slotId: null, slotId: null,
taskId: null, taskId: null,
rejected: [ rejected: [
{ slotId: null, code: "unrecognized", message: "资源尚未识别作品,确认后可以先下载,整理时补全元数据" }, { slotId: null, code: "unrecognized", message: "资源还没识别出作品。确认后可先下载,整理时再补全元数据" },
], ],
}; };
if (entityId && !(await db.selectFrom("media_entity").select("id").where("id", "=", entityId).executeTakeFirst())) if (entityId && !(await db.selectFrom("media_entity").select("id").where("id", "=", entityId).executeTakeFirst()))
@@ -63,7 +63,7 @@ export async function planManualAcquisition(
if (!topo?.savePath) throw new ApiException("precondition_failed", "没有可用的下载器或保存目录"); if (!topo?.savePath) throw new ApiException("precondition_failed", "没有可用的下载器或保存目录");
const replacementIds = body.importMode === "replace" ? [...new Set(body.replacementAssetIds)] : []; const replacementIds = body.importMode === "replace" ? [...new Set(body.replacementAssetIds)] : [];
if (body.importMode === "replace" && (!entityId || replacementIds.length === 0)) if (body.importMode === "replace" && (!entityId || replacementIds.length === 0))
throw new ApiException("bad_request", "洗版需要明确作品及要替换的已有版本"); throw new ApiException("bad_request", "洗版要指定作品和要替换的版本");
if (replacementIds.length) { if (replacementIds.length) {
const assets = await db const assets = await db
.selectFrom("media_asset") .selectFrom("media_asset")
@@ -71,7 +71,7 @@ export async function planManualAcquisition(
.where("id", "in", replacementIds) .where("id", "in", replacementIds)
.execute(); .execute();
if (assets.length !== replacementIds.length || assets.some((a) => a.media_entity_id !== entityId)) if (assets.length !== replacementIds.length || assets.some((a) => a.media_entity_id !== entityId))
throw new ApiException("bad_request", "要替换的版本必须属于本次下载的作品"); throw new ApiException("bad_request", "要替换的版本必须属于这次下载的作品");
} }
const knownHash = rel.infohash_v1 ?? rel.infohash_v2; const knownHash = rel.infohash_v1 ?? rel.infohash_v2;
if (knownHash && body.importMode !== "replace") { if (knownHash && body.importMode !== "replace") {
@@ -99,7 +99,7 @@ export async function planManualAcquisition(
} }
} }
const savePath = body.savePath ? subscriptionSavePath(topo, body.savePath) : topo.savePath; const savePath = body.savePath ? subscriptionSavePath(topo, body.savePath) : topo.savePath;
if (!savePath) throw new ApiException("bad_request", "保存目录不在该下载器的路径映射范围内"); if (!savePath) throw new ApiException("bad_request", "保存目录不在这个下载器的路径映射里");
const evaluation = { const evaluation = {
manual: true, manual: true,
mediaEntityId: entityId, mediaEntityId: entityId,
@@ -33,7 +33,7 @@ export async function resolveOrganizeSource(
); );
if (input.id) { if (input.id) {
const selected = compatible.find(({ config }) => config.id === input.id); const selected = compatible.find(({ config }) => config.id === input.id);
if (!selected) throw new Error("整理来源不存在,或文件/媒体类型不属于该来源"); if (!selected) throw new Error("整理来源不存在,或文件、媒体类型不属于这个来源");
return selected.config; return selected.config;
} }
// qB automation selects only its configured tag; manual rows require explicit selection. // qB automation selects only its configured tag; manual rows require explicit selection.
@@ -142,8 +142,8 @@ export async function executeRecord(
const code = (err as { code?: string } | null)?.code ?? "EIO"; const code = (err as { code?: string } | null)?.code ?? "EIO";
return holdRecord(d, rec, `${TARGET_DIR_UNAVAILABLE}:${code}`, { return holdRecord(d, rec, `${TARGET_DIR_UNAVAILABLE}:${code}`, {
kind: "other", kind: "other",
title: "整理目标目录建不出来", title: "无法创建整理目标目录",
summary: `${errMessage(err)};检查目录权限或挂载后会自动重试`, summary: `${errMessage(err)}。检查目录权限或挂载,恢复后自动重试`,
subjectType: "organization_record", subjectType: "organization_record",
subjectId: recordId, subjectId: recordId,
dedupeKey: `organize_target_dir:${recordId}`, dedupeKey: `organize_target_dir:${recordId}`,
@@ -270,7 +270,7 @@ export async function executeRecord(
status: "failed", status: "failed",
error: { error: {
code: "STALE_REPLACEMENT", code: "STALE_REPLACEMENT",
message: "选定旧文件在计划后发生变化,保留待核对", message: "要替换的旧文件在生成计划后有变化,已保留待核对",
}, },
durationMs: 0, durationMs: 0,
}; };
@@ -367,7 +367,7 @@ export async function executeRecord(
: r.status === "attention" : r.status === "attention"
? jsonb({ ? jsonb({
code: "source_not_removed", code: "source_not_removed",
message: "目标已就绪,源文件未能删除", message: "目标已就绪,源文件没删掉",
}) })
: null, : null,
finished_at: now, finished_at: now,
@@ -425,7 +425,7 @@ export async function executeRecord(
if (outcome.replaced?.disposition === "left") { if (outcome.replaced?.disposition === "left") {
await upsertDecision(d, db, { await upsertDecision(d, db, {
kind: "other", kind: "other",
title: "新版本已整理,旧文件未能处置", title: "新版本已整理,旧文件没处理掉",
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`, summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
subjectType: "organization_record", subjectType: "organization_record",
subjectId: recordId, subjectId: recordId,
@@ -620,11 +620,11 @@ export async function verifyInPlace(
? await vstatSafe(path, { root: frozen.rootPath }) ? await vstatSafe(path, { root: frozen.rootPath })
: await fsr.proxy.statSafe(path, { root: frozen.rootPath, sentinel: frozen.sentinel }); : await fsr.proxy.statSafe(path, { root: frozen.rootPath, sentinel: frozen.sentinel });
if (observed.kind !== "ok" || !observed.facts.isFile || observed.facts.isSymbolicLink) { if (observed.kind !== "ok" || !observed.facts.isFile || observed.facts.isSymbolicLink) {
return { status: "failed", error: { code: "SOURCE_MISSING", message: "原地整理的文件不存在或不可访问" } }; return { status: "failed", error: { code: "SOURCE_MISSING", message: "要原地整理的文件不存在或不可访问" } };
} }
const planned = frozen.sourceFacts?.[path]; const planned = frozen.sourceFacts?.[path];
if (observed.facts.size !== size || (planned && observed.facts.mtimeMs !== planned.mtimeMs)) { if (observed.facts.size !== size || (planned && observed.facts.mtimeMs !== planned.mtimeMs)) {
return { status: "failed", error: { code: "STALE_SOURCE", message: "文件在计划后发生变化,请重新预览" } }; return { status: "failed", error: { code: "STALE_SOURCE", message: "文件在生成计划后有变化。重新预览" } };
} }
const location = await d.ctx.db const location = await d.ctx.db
.selectFrom("asset_location as l") .selectFrom("asset_location as l")
@@ -637,7 +637,7 @@ export async function verifyInPlace(
if (location && location.media_entity_id !== frozen.entityId) { if (location && location.media_entity_id !== frozen.entityId) {
return { return {
status: "failed", status: "failed",
error: { code: "ASSET_ENTITY_CONFLICT", message: "该文件已登记为其它作品,请先核对作品归属" }, error: { code: "ASSET_ENTITY_CONFLICT", message: "这个文件已登记到其它作品。先核对作品归属" },
}; };
} }
return { status: "succeeded", bytes: 0 }; return { status: "succeeded", bytes: 0 };
@@ -441,7 +441,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
kind: "other", kind: "other",
severity: "info", severity: "info",
title: "旧版本待处置", title: "旧版本待处置",
summary: "新版本已核验整理,旧文件保留,按保留策略处理", summary: "新版本已整理,旧文件按保留策略处理",
subjectType: "media_asset", subjectType: "media_asset",
subjectId: old, subjectId: old,
dedupeKey: `old_version:${old}`, dedupeKey: `old_version:${old}`,
@@ -291,7 +291,7 @@ export async function authorizeMoveEntities(
unauthorized.push({ unauthorized.push({
label: s.label, label: s.label,
entityId: null, entityId: null,
reason: "无法确定所属作品,只能用一次性审批", reason: "确定不了所属作品,只能用一次性审批",
}); });
continue; continue;
} }
@@ -368,7 +368,7 @@ export async function hnrBlockForMove(
}, },
options: [ options: [
{ key: "wait", label: "做种满足后再移动" }, { key: "wait", label: "做种满足后再移动" },
{ key: "copy", label: "改用复制(需重新发起)" }, { key: "copy", label: "改用复制(要重新发起)" },
], ],
}); });
return { downloadTaskId: dt.id, hnr, decisionId }; return { downloadTaskId: dt.id, hnr, decisionId };
@@ -514,7 +514,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
await decide_({ await decide_({
kind: "other", kind: "other",
title: `暂时无法分类:${entity.title}`, title: `暂时无法分类:${entity.title}`,
summary: "检查 TMDB 连接或选择固定分类;目录扫描会在后续轮次重试,也可手动整理存量", summary: "检查 TMDB 连接或选择固定分类。目录扫描会在下一轮重试,也可以手动整理存量",
subjectType: "organize_source", subjectType: "organize_source",
subjectId: sourceConfig.id, subjectId: sourceConfig.id,
dedupeKey: `source_classification:${sourceConfig.id}:${entity.id}`, dedupeKey: `source_classification:${sourceConfig.id}:${entity.id}`,
@@ -750,7 +750,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
await decide_({ await decide_({
kind: "path_conflict", kind: "path_conflict",
title: `作品存在多个历史目录:${entity.title}`, title: `作品存在多个历史目录:${entity.title}`,
summary: "不会按最新标题自动合并。指定要保留的目录", summary: "不会按最新标题自动合并,指定要保留的目录",
subjectType: "media_entity", subjectType: "media_entity",
subjectId: entity.id, subjectId: entity.id,
dedupeKey: `path_binding_conflict:${entity.id}:${season ?? "entity"}`, dedupeKey: `path_binding_conflict:${entity.id}:${season ?? "entity"}`,
@@ -928,7 +928,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
blocking: true, blocking: true,
src: file.src, src: file.src,
dst: file.dst, dst: file.dst,
detail: "该文件已登记为其它作品", detail: "这个文件已登记到其它作品",
}); });
plan.requiresApproval = true; plan.requiresApproval = true;
} }
@@ -448,7 +448,7 @@ async function failStalledRecord(d: AoDeps, recordId: string, requeues: number):
title: "整理一直没完成,已停止", title: "整理一直没完成,已停止",
summary: `重新排队 ${requeues} 次仍没有完成${ summary: `重新排队 ${requeues} 次仍没有完成${
isHoldReason(rec.reason) ? `(${rec.reason})` : "" isHoldReason(rec.reason) ? `(${rec.reason})` : ""
};已完成的文件照常登记,其余请检查后重新整理`, };已完成的文件照常登记,其余检查后重新整理`,
subjectType: "organization_record", subjectType: "organization_record",
subjectId: recordId, subjectId: recordId,
dedupeKey: `organize_stalled:${recordId}`, dedupeKey: `organize_stalled:${recordId}`,
@@ -83,7 +83,7 @@ export async function freezeReplacement(
(a) => a.media_entity_id !== entityId || a.status !== "active" || !["video", "bluray_dir"].includes(a.kind), (a) => a.media_entity_id !== entityId || a.status !== "active" || !["video", "bluray_dir"].includes(a.kind),
) )
) { ) {
throw new Error("旧版本必须是当前作品的有效库内视频资产"); throw new Error("旧版本必须是这个作品在媒体库里的视频文件");
} }
const out: ReplacementAsset[] = []; const out: ReplacementAsset[] = [];
for (const asset of assets) { for (const asset of assets) {
@@ -104,7 +104,7 @@ export async function freezeReplacement(
.where("l.status", "=", "available") .where("l.status", "=", "available")
.execute(); .execute();
if (!locations.length || locations.some((l) => l.management_mode !== "managed")) { if (!locations.length || locations.some((l) => l.management_mode !== "managed")) {
throw new Error("旧版本必须有可写的库内位置"); throw new Error("旧版本必须在可写的媒体库目录里");
} }
const units = await d.ctx.db const units = await d.ctx.db
.selectFrom("asset_unit_coverage as c") .selectFrom("asset_unit_coverage as c")
@@ -243,7 +243,7 @@ export async function cleanupReplacement(d: AoDeps, frozen: FrozenPlan, recordId
kind: "other", kind: "other",
severity: "info", severity: "info",
title: "旧版本未完整覆盖,保留文件", title: "旧版本未完整覆盖,保留文件",
summary: "本次成功文件未明确覆盖旧文件的全部季集或 Part;旧文件保留,可核对范围后处理", summary: "这次整理成功的文件没有完整覆盖旧文件的全部季集或 Part;旧文件已保留,核对范围后再处理",
subjectType: "media_asset", subjectType: "media_asset",
subjectId: old.assetId, subjectId: old.assetId,
dedupeKey: `replacement_scope:${recordId}:${old.assetId}`, dedupeKey: `replacement_scope:${recordId}:${old.assetId}`,
@@ -272,7 +272,7 @@ export async function cleanupReplacement(d: AoDeps, frozen: FrozenPlan, recordId
try { try {
const fs = fsx(d); const fs = fsx(d);
if ((await fs.checkRoot(file.rootPath, file.sentinel)).kind !== "online") { if ((await fs.checkRoot(file.rootPath, file.sentinel)).kind !== "online") {
throw new Error("旧版本库根离线"); throw new Error("旧版本所在的媒体库目录离线");
} }
const rootReal = await fs.realpath(file.rootPath); const rootReal = await fs.realpath(file.rootPath);
const abs = join(rootReal, file.path); const abs = join(rootReal, file.path);
@@ -436,7 +436,7 @@ export async function settleSamePathReplacement(d: AoDeps, frozen: FrozenPlan, r
} }
if (main?.status !== "failed") continue; if (main?.status !== "failed") continue;
if ((await fs.checkRoot(old.rootPath, old.sentinel)).kind !== "online") { if ((await fs.checkRoot(old.rootPath, old.sentinel)).kind !== "online") {
throw new Error("旧版本库根离线,保留回收副本"); throw new Error("旧版本所在的媒体库目录离线,回收副本已保留");
} }
const remote = isRemotePath(pf.dst); const remote = isRemotePath(pf.dst);
const rejected = join(old.rootPath, ".umt-trash", old.trashDay, old.trashId, old.path); const rejected = join(old.rootPath, ".umt-trash", old.trashDay, old.trashId, old.path);
@@ -574,7 +574,7 @@ export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId
if (frozen.replacement && pf?.action === "replace") continue; if (frozen.replacement && pf?.action === "replace") continue;
const rel = f.target_path!; const rel = f.target_path!;
try { try {
if ((await fsr.checkRoot(frozen.rootPath, frozen.sentinel)).kind !== "online") throw new Error("库根离线"); if ((await fsr.checkRoot(frozen.rootPath, frozen.sentinel)).kind !== "online") throw new Error("媒体库目录离线");
const rootReal = await fsr.realpath(frozen.rootPath); const rootReal = await fsr.realpath(frozen.rootPath);
const abs = join(rootReal, rel); const abs = join(rootReal, rel);
const st = await fsr.statSafe(abs, { root: rootReal, sentinel: frozen.sentinel }); const st = await fsr.statSafe(abs, { root: rootReal, sentinel: frozen.sentinel });
@@ -806,7 +806,7 @@ export async function afterSlotVerified(
kind: "other", kind: "other",
severity: "info", severity: "info",
title: "疑似缺集", title: "疑似缺集",
summary: `已整理最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500), summary: `已整理的最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500),
subjectType: "acquisition_slot", subjectType: "acquisition_slot",
subjectId: slot.id, subjectId: slot.id,
dedupeKey: `suspected_missing:${slot.id}`, dedupeKey: `suspected_missing:${slot.id}`,
@@ -90,7 +90,7 @@ export interface DecisionOptionInput {
const OUTCOME_TEXT: Record<NameIdentifyResult["outcome"], string> = { const OUTCOME_TEXT: Record<NameIdentifyResult["outcome"], string> = {
auto: "已识别", auto: "已识别",
proposed: "识别到候选,但自动化未开启,需要确认", proposed: "找到了可能的作品,但自动化未开启,需要确认",
ambiguous: "有多个相近的作品,无法自动确定", ambiguous: "有多个相近的作品,无法自动确定",
no_match: "本地与 TMDB 都没有匹配的作品", no_match: "本地与 TMDB 都没有匹配的作品",
lookup_failed: "查询 TMDB 失败", lookup_failed: "查询 TMDB 失败",
@@ -116,7 +116,7 @@ export function identityOptions(t: TakeoverIdentify | null, fallbackTitle: strin
out.push({ out.push({
key: `candidate_${i}`, key: `candidate_${i}`,
label: `选择:${c.title}${c.year ? `(${c.year})` : ""}`, label: `选择:${c.title}${c.year ? `(${c.year})` : ""}`,
description: c.localEntityId ? "本地已有作品" : `TMDB ${c.entityType}/${c.externalId},本地没有时自动建档`, description: c.localEntityId ? "本地已有作品" : `TMDB ${c.entityType}/${c.externalId},本地没有时自动加入媒体库`,
input: { candidate: target }, input: { candidate: target },
}); });
} }
@@ -130,7 +130,7 @@ export function identityOptions(t: TakeoverIdentify | null, fallbackTitle: strin
{ {
key: "select_tmdb", key: "select_tmdb",
label: "选择作品(TMDB)", label: "选择作品(TMDB)",
description: "按 TMDB ID 指定,本地没有时自动建档", description: "按 TMDB ID 指定,本地没有时自动加入媒体库",
input: { requires: "tmdb" }, input: { requires: "tmdb" },
}, },
); );
@@ -141,7 +141,7 @@ export function identityOptions(t: TakeoverIdentify | null, fallbackTitle: strin
out.push({ out.push({
key: "provisional", key: "provisional",
label: "作为临时作品整理", label: "作为临时作品整理",
description: "按文件名建本地临时作品后整理,之后可合并或补外部身份", description: "按文件名建一个本地临时作品再整理,之后可以合并或补外部身份",
input: { provisional: { kind: tvLike ? "series" : "movie", title, year: t?.parsed.year ?? null } }, input: { provisional: { kind: tvLike ? "series" : "movie", title, year: t?.parsed.year ?? null } },
}); });
} }
@@ -55,7 +55,7 @@ export function registerDownloadRecoveryCapabilities(
if (status === "paused" && (dt.pause_reason === "threshold" || dt.pause_reason === "user")) if (status === "paused" && (dt.pause_reason === "threshold" || dt.pause_reason === "user"))
throw new CapabilityError( throw new CapabilityError(
"precondition", "precondition",
`${dt.pause_reason === "threshold" ? "缓存阈值" : "人工"}暂停不做自动恢复`, `${dt.pause_reason === "threshold" ? "因下载盘到暂停线" : "手动"}暂停的任务不自动恢复`,
); );
if (c.authorization?.grantId) await checkGrantScope(ctx, c.authorization.grantId, dt.acquisition_plan_id); if (c.authorization?.grantId) await checkGrantScope(ctx, c.authorization.grantId, dt.acquisition_plan_id);
const inst = await ctx.db const inst = await ctx.db
@@ -167,7 +167,7 @@ export function registerMetadataCapabilities(
const agent = await ctx.settings.get("agent").catch(() => null); const agent = await ctx.settings.get("agent").catch(() => null);
const ws = { ...DEFAULT_WEB_SEARCH, ...(agent?.webSearch ?? {}) }; const ws = { ...DEFAULT_WEB_SEARCH, ...(agent?.webSearch ?? {}) };
if (ws.provider === "none" || !ws.apiKeySecretRef || !registry.has("web.search")) if (ws.provider === "none" || !ws.apiKeySecretRef || !registry.has("web.search"))
throw new CapabilityError("precondition", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启后再补全"); throw new CapabilityError("precondition", "网页搜索未开启。在设置 → 智能体 → 网页搜索里开启后再补全");
// 内部调用 web.* 用本次补全自己的调用方身份:白名单与 Agent 会话的互不覆盖 // 内部调用 web.* 用本次补全自己的调用方身份:白名单与 Agent 会话的互不覆盖
const sub = (scope: string) => ({ const sub = (scope: string) => ({
actorId: scope, actorId: scope,
+1 -1
View File
@@ -436,7 +436,7 @@ export async function drive(
let settings: AgentSettings; let settings: AgentSettings;
try { try {
const resolved = await resolveRunUpstream(ctx, run.upstream_id); const resolved = await resolveRunUpstream(ctx, run.upstream_id);
if (!resolved) throw new Error("未配置模型服务(设置 → 智能体 → 模型服务)"); if (!resolved) throw new Error("未配置模型服务。在设置 → 智能体 → 模型服务里添加");
upstream = resolved; upstream = resolved;
fake = upstream.row.provider === "fake"; fake = upstream.row.provider === "fake";
settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream); settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream);
+1 -1
View File
@@ -774,7 +774,7 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
const parent = await parentOf(ctx, c); const parent = await parentOf(ctx, c);
const s = await ctx.settings.all(); const s = await ctx.settings.all();
const cfg = subagentSettings(s); const cfg = subagentSettings(s);
if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → 智能体 → 允许子会话)"); if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启。在设置 → 智能体里打开「允许子会话」");
if (parent.cancel_requested_at || TERMINAL.includes(parent.status)) if (parent.cancel_requested_at || TERMINAL.includes(parent.status))
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话"); throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`; const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`;
@@ -239,7 +239,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete"); remove("library_root", "library-roots", "/library-roots/{id}", "library_root.delete");
endpoint( endpoint(
app, app,
test("/library-roots/{id}/test", "检查媒体库目录在线(目录 + 哨兵文件)", "library-roots"), test("/library-roots/{id}/test", "检查媒体库目录在线(目录 + 标记文件)", "library-roots"),
async ({ params, actor }) => { async ({ params, actor }) => {
const row = await getRow("library_root", params.id); const row = await getRow("library_root", params.id);
const h = isRemotePath(String(row.local_path)) const h = isRemotePath(String(row.local_path))
@@ -282,13 +282,13 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
const checkPolicy = async (settings: Record<string, unknown> | undefined) => { const checkPolicy = async (settings: Record<string, unknown> | undefined) => {
if (settings?.policy === undefined) return; if (settings?.policy === undefined) return;
const r = DownloaderPolicy.safeParse(settings.policy); const r = DownloaderPolicy.safeParse(settings.policy);
if (!r.success) throw new ApiException("validation_failed", "下载器水位 / 保种设置格式不对"); if (!r.success) throw new ApiException("validation_failed", "下载盘与保种设置格式不对");
const g = await ctx.settings.get("cache"); const g = await ctx.settings.get("cache");
const hard = r.data.hardThresholdPercent ?? g.hardThresholdPercent; const hard = r.data.hardThresholdPercent ?? g.hardThresholdPercent;
const resume = r.data.resumePercent ?? g.resumePercent; const resume = r.data.resumePercent ?? g.resumePercent;
const target = r.data.cleanupTargetPercent ?? g.cleanupTargetPercent; const target = r.data.cleanupTargetPercent ?? g.cleanupTargetPercent;
if (resume >= hard) throw new ApiException("validation_failed", "恢复水位必须低于硬阈值"); if (resume >= hard) throw new ApiException("validation_failed", "恢复线必须低于暂停线");
if (target > resume) throw new ApiException("validation_failed", "清理目标不得高于恢复水位"); if (target > resume) throw new ApiException("validation_failed", "清理目标不能高于恢复线");
}; };
/** 默认下载器至多一个:设新的之前先取消旧的 */ /** 默认下载器至多一个:设新的之前先取消旧的 */
const clearDefault = (exceptId?: string) => { const clearDefault = (exceptId?: string) => {
@@ -330,7 +330,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
if (rest.isDefault) await clearDefault(params.id); if (rest.isDefault) await clearDefault(params.id);
const curAuth = ((cur.settings ?? {}) as Record<string, unknown>).authKind; const curAuth = ((cur.settings ?? {}) as Record<string, unknown>).authKind;
if (credential === null && (authKind ?? curAuth) === "api_key") if (credential === null && (authKind ?? curAuth) === "api_key")
throw new ApiException("validation_failed", "API Key 认证不能清除 API Key;不用认证时把认证方式改为无认证"); throw new ApiException("validation_failed", "API Key 认证不能清空 API Key。不用认证时把认证方式改成无认证");
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur); const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur);
await checkPolicy(settings); await checkPolicy(settings);
const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS); const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS);
@@ -174,7 +174,7 @@ export function registerKeyRoutes(app: ModuleApp, ctx: ServerContext) {
.where("id", "=", params.id) .where("id", "=", params.id)
.returning("id") .returning("id")
.executeTakeFirst() .executeTakeFirst()
.catch((e) => pgConflict(e, "凭证仍被实例、站点账号、Feed 或设置引用,先解除引用")); .catch((e) => pgConflict(e, "凭证仍被实例、站点账号、RSS 或设置引用,先解除引用"));
if (!r) throw new ApiException("not_found", "凭证不存在"); if (!r) throw new ApiException("not_found", "凭证不存在");
await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id }); await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id });
return { ok: true as const }; return { ok: true as const };
+4 -4
View File
@@ -40,17 +40,17 @@ export async function currentResource(
actor, actor,
async (provider, ref, context) => { async (provider, ref, context) => {
const page = provider.pageContent ? await provider.pageContent(ref, { currentVisible: true }) : null; const page = provider.pageContent ? await provider.pageContent(ref, { currentVisible: true }) : null;
if (!page?.url) throw new BrowserServiceError("precondition", "无法读取当前页面,请重新连接浏览器"); if (!page?.url) throw new BrowserServiceError("precondition", "读不到当前页面。重新连接浏览器");
const def = getSiteDefinition(context.site.key); const def = getSiteDefinition(context.site.key);
if (!def) throw new BrowserServiceError("precondition", "这个站点还没有详情页解析配置"); if (!def) throw new BrowserServiceError("precondition", "这个站点还没有详情页解析配置");
const resourceId = resourceIdFromUrl(page.url, context.site.base_url, def); const resourceId = resourceIdFromUrl(page.url, context.site.base_url, def);
if (!resourceId) throw new BrowserServiceError("bad_request", "请先进入本站资源详情页,再点击下载"); if (!resourceId) throw new BrowserServiceError("bad_request", "先打开本站的资源详情页再下载");
let detail: SiteReleaseDetail; let detail: SiteReleaseDetail;
if (def.umt.kind === "nexusphp") { if (def.umt.kind === "nexusphp") {
const parsed = parseDetailPage(page.html, def as IndexerDefinition, resourceId, service.now().toISOString()); const parsed = parseDetailPage(page.html, def as IndexerDefinition, resourceId, service.now().toISOString());
if (parsed.kind === "login") throw new BrowserServiceError("precondition", "请先在原站登录"); if (parsed.kind === "login") throw new BrowserServiceError("precondition", "先在原站登录");
if (!parsed.detail) if (!parsed.detail)
throw new BrowserServiceError("precondition", "当前详情页未能解析出资源,请检查站点详情配置"); throw new BrowserServiceError("precondition", "这个详情页没解析出资源。检查站点的详情页解析配置");
detail = parsed.detail; detail = parsed.detail;
} else { } else {
detail = await runtime.siteAdapter(context.site).detail(resourceId); detail = await runtime.siteAdapter(context.site).detail(resourceId);
+1 -1
View File
@@ -258,7 +258,7 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
let note: string | null = null; let note: string | null = null;
if (v.duplicate) note = "同一会话已捕获过这个文件,未重复提交"; if (v.duplicate) note = "同一会话已捕获过这个文件,未重复提交";
else if (!v.verification.valid) note = "不是有效的种子文件,未提交"; else if (!v.verification.valid) note = "不是有效的种子文件,未提交";
else if (!v.sourceReleaseId) note = "资源不在本地索引(先快照或搜索该站点),未提交"; else if (!v.sourceReleaseId) note = "资源不在本地索引(先刷新或搜索这个站点),未提交";
else if (body.submit) { else if (body.submit) {
try { try {
submission = await dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", { submission = await dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
+4 -3
View File
@@ -953,7 +953,7 @@ export class BrowserService {
const needHuman = credential.status === "injected" && loginPage && prep.authKind === "cookie"; const needHuman = credential.status === "injected" && loginPage && prep.authKind === "cookie";
if (credential.status === "injected" && loginPage) { if (credential.status === "injected" && loginPage) {
credential.status = "expired"; credential.status = "expired";
credential.message = "账号登录态已失效:接管后在画面里登录一次,交还后 HTTP 抓取、RSS、快照都用新的登录态"; credential.message = "账号登录态已失效:接管后在画面里登录一次,交还后抓取、RSS 和搜索都用新的登录态";
} }
await this.ctx.db.transaction().execute(async (trx) => { await this.ctx.db.transaction().execute(async (trx) => {
await trx await trx
@@ -2121,6 +2121,7 @@ export class BrowserService {
for (let i = 0; i < CF_VISION_ATTEMPTS && w.kind === "interactive"; i++) { for (let i = 0; i < CF_VISION_ATTEMPTS && w.kind === "interactive"; i++) {
const r = await vision({ const r = await vision({
sessionId: id, sessionId: id,
// copy-allow: 引用 Cloudflare 页面原文
goal: "这是 Cloudflare 人机验证页。只点一次「确认您是真人 / Verify you are human」复选框(Turnstile),然后等待页面跳转;不要做其它操作。如果页面要求选图片、输入验证码或别的操作,立即停止并说明需要人工。", goal: "这是 Cloudflare 人机验证页。只点一次「确认您是真人 / Verify you are human」复选框(Turnstile),然后等待页面跳转;不要做其它操作。如果页面要求选图片、输入验证码或别的操作,立即停止并说明需要人工。",
textAbsent: marker, textAbsent: marker,
maxSteps: CF_VISION_STEPS, maxSteps: CF_VISION_STEPS,
@@ -2280,7 +2281,7 @@ export class BrowserService {
write: false, write: false,
}, },
); );
if (!page?.url) throw new BrowserServiceError("precondition", "无法读取当前页面,请重新连接浏览器"); if (!page?.url) throw new BrowserServiceError("precondition", "读不到当前页面。重新连接浏览器");
const site = await this.liveSite(row.site_id); const site = await this.liveSite(row.site_id);
return { siteId: site.id, url: page.url, cookies: await this.siteCookies(row.profile_key, site.base_url) }; return { siteId: site.id, url: page.url, cookies: await this.siteCookies(row.profile_key, site.base_url) };
} }
@@ -2288,7 +2289,7 @@ export class BrowserService {
private async captchaNeedsHuman(id: string, site: SiteRow, reason: string): Promise<PageCaptchaOutcome> { private async captchaNeedsHuman(id: string, site: SiteRow, reason: string): Promise<PageCaptchaOutcome> {
const text = { const text = {
title: `${site.name}需要你在原站视图里过一次验证码`, title: `${site.name}需要你在原站视图里过一次验证码`,
summary: `站点要求输入验证码,代码识别和智能体都没能通过(${reason.slice(0, 200) || "未说明原因"})。在站点详情的原站视图接管会话,输完验证码后交还,再搜索一次即可。`, summary: `站点要求输入验证码,代码识别和智能体都没能通过(${reason.slice(0, 200) || "未说明原因"})。在站点详情的原站视图接管会话,输完验证码后交还,再搜索一次。`,
}; };
let row = await this.sessionRow(id); let row = await this.sessionRow(id);
const rs = obj(row.recovery_state) as RecoveryState; const rs = obj(row.recovery_state) as RecoveryState;
@@ -63,7 +63,7 @@ export const BUILTIN_AGENT_CHECKS: {
config: { config: {
goalTemplate: goalTemplate:
"生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅及可能原因、" + "生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅及可能原因、" +
"健康异常的站点与 Feed,给出本周建议。只读。最后用不超过 300 字的中文汇总。", "健康异常的站点与 RSS,给出本周建议。只读。最后用不超过 300 字的中文汇总。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES], capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 30, maxDurationSec: 600 }, budget: { maxToolCalls: 30, maxDurationSec: 600 },
triggerCondition: "subscriptions_active", triggerCondition: "subscriptions_active",
@@ -50,6 +50,7 @@ export function registerScheduleCapabilities(registry: CapabilityRegistry, ctx:
capabilities: caps, capabilities: caps,
dropped, dropped,
enabled: false as const, enabled: false as const,
// copy-allow: 给模型看的下一步提示
next: "已建成未启用的巡检。请用户到「计划」页检查目标、间隔和能力,确认后打开开关启用。", next: "已建成未启用的巡检。请用户到「计划」页检查目标、间隔和能力,确认后打开开关启用。",
}; };
}); });
+1 -1
View File
@@ -213,7 +213,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
"TMDB 查询超时", "TMDB 查询超时",
); );
} catch (e) { } catch (e) {
ctx.logger.debug({ type, id, err: { message: (e as Error).message } }, "TMDB 富详情获取失败"); ctx.logger.debug({ type, id, err: { message: (e as Error).message } }, "TMDB 完整详情获取失败");
return null; return null;
} }
}; };
@@ -119,7 +119,7 @@ export const D = {
body: SiteCreate, body: SiteCreate,
response: Site, response: Site,
}, },
update: write("patch", "/sites/{id}", "admin", "修改站点(启停、快照周期、限流、代理)", SiteUpdate, Site, TS), update: write("patch", "/sites/{id}", "admin", "修改站点(启停、抓取周期、限流、代理)", SiteUpdate, Site, TS),
}, },
siteAccounts: { siteAccounts: {
list: list("/site-accounts", "站点账号列表", SiteAccount, { siteId: Id.optional() }, TS), list: list("/site-accounts", "站点账号列表", SiteAccount, { siteId: Id.optional() }, TS),
@@ -144,18 +144,18 @@ export const D = {
), ),
}, },
feedSources: { feedSources: {
list: list("/feed-sources", "Feed 列表", FeedSource, { siteId: Id.optional(), health: FeedHealth.optional() }, TF), list: list("/feed-sources", "RSS 列表", FeedSource, { siteId: Id.optional(), health: FeedHealth.optional() }, TF),
get: get("/feed-sources/{id}", "Feed 详情", FeedSource, TF), get: get("/feed-sources/{id}", "RSS 详情", FeedSource, TF),
create: { create: {
method: "post" as const, method: "post" as const,
path: "/feed-sources", path: "/feed-sources",
scope: "admin" as const, scope: "admin" as const,
summary: "新建 Feed(url 只写,存为 secret;对外只给脱敏 URL)", summary: "新建 RSS(url 只写,存为 secret;对外只给脱敏 URL)",
tags: TF, tags: TF,
body: FeedSourceCreate, body: FeedSourceCreate,
response: FeedSource, response: FeedSource,
}, },
update: write("patch", "/feed-sources/{id}", "admin", "修改 Feed", FeedSourceUpdate, FeedSource, TF), update: write("patch", "/feed-sources/{id}", "admin", "修改 RSS", FeedSourceUpdate, FeedSource, TF),
fetchNow: write( fetchNow: write(
"post", "post",
"/feed-sources/{id}/fetch", "/feed-sources/{id}/fetch",
@@ -204,7 +204,7 @@ export const D = {
method: "post" as const, method: "post" as const,
path: "/media-entities", path: "/media-entities",
scope: "organize.execute" as const, scope: "organize.execute" as const,
summary: "手动建本地作品(TMDB 无记录时可先建档)", summary: "手动建本地作品(TMDB 没有这部作品时用)",
tags: TE, tags: TE,
body: MediaEntityCreate, body: MediaEntityCreate,
response: MediaEntity, response: MediaEntity,
@@ -297,7 +297,7 @@ export const D = {
decisions: { decisions: {
list: list( list: list(
"/decisions", "/decisions",
"待处理事项(缺省只列未关闭)", "待处理事项(缺省只列未关闭的)",
Decision, Decision,
{ {
status: DecisionStatus.optional(), status: DecisionStatus.optional(),
@@ -64,8 +64,8 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
}, },
}); });
job("feeds.poll", "RSS 轮询", "按各 RSS 自己的间隔抓取新资源", tickFeeds); job("feeds.poll", "RSS 轮询", "按各 RSS 自己的间隔抓取新资源", tickFeeds);
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots); job("sites.snapshot", "站点列表抓取", "到期的站点入队抓取资源列表", tickSnapshots);
job("identity.review", "未识别复查", "到期的未识别条目重新识别", tickReviews); job("identity.review", "未识别复查", "到期的未识别资源重新识别", tickReviews);
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期) // W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
registerScheduledJob(ctx, { registerScheduledJob(ctx, {
key: "sites.user_data", key: "sites.user_data",
@@ -98,7 +98,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
lane: "maintenance", lane: "maintenance",
defaults: { defaults: {
name: "元数据语言回刷", name: "元数据语言回刷",
description: "按语言设置重新拉一遍作品的标题、简介和每集信息;跑完自动停用,可手动再执行", description: "按语言设置重新拉一遍作品的标题、简介和每集信息;跑完自动停用,可以手动再运行",
group: "maintain", group: "maintain",
schedule: { type: "interval", everyMs: 24 * 3_600_000 }, schedule: { type: "interval", everyMs: 24 * 3_600_000 },
firstRun: "now", firstRun: "now",
@@ -47,7 +47,7 @@ export function enrichRoutes(app: ModuleApp, rt: () => SourcesRuntime): void {
if (resolved) if (resolved)
throw new ApiException( throw new ApiException(
"conflict", "conflict",
`站点详情页给出了作品 ID,已识别为《${resolved.title}》,不需要补全。刷新待识别列表查看`, `站点详情页给出了作品 ID,已识别为《${resolved.title}》,不需要补全。刷新未识别列表查看`,
); );
try { try {
return await ctx.capabilities.invoke( return await ctx.capabilities.invoke(
@@ -472,7 +472,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
return toApi(row); return toApi(row);
}); });
route(app, D.externalIdentities.create, async ({ body, actor }) => { route(app, D.externalIdentities.create, async ({ body, actor }) => {
if (!body.mediaEntityId) throw new ApiException("bad_request", "v0.1 只支持作品级外部身份"); if (!body.mediaEntityId) throw new ApiException("bad_request", "目前只支持作品级外部身份");
const entityId = await resolveEntityId(db(), body.mediaEntityId); const entityId = await resolveEntityId(db(), body.mediaEntityId);
if (!entityId) throw new ApiException("not_found", "作品不存在"); if (!entityId) throw new ApiException("not_found", "作品不存在");
if ((body.status ?? "proposed") === "accepted") { if ((body.status ?? "proposed") === "accepted") {
@@ -487,7 +487,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
}, },
actor, actor,
); );
if (!r.identityId) throw new ApiException("conflict", "该外部身份已是此作品的 accepted 映射"); if (!r.identityId) throw new ApiException("conflict", "这个外部身份已是该作品的 accepted 映射");
return toApi( return toApi(
await db().selectFrom("external_identity").selectAll().where("id", "=", r.identityId).executeTakeFirstOrThrow(), await db().selectFrom("external_identity").selectAll().where("id", "=", r.identityId).executeTakeFirstOrThrow(),
); );
@@ -635,7 +635,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.select(["source_release_id"]) .select(["source_release_id"])
.where("id", "=", params.id) .where("id", "=", params.id)
.executeTakeFirst(); .executeTakeFirst();
if (!pool) throw new ApiException("not_found", "未识别条目不存在"); if (!pool) throw new ApiException("not_found", "未识别资源不存在");
return assignReleaseIdentity(rt(), pool.source_release_id, body, actor); return assignReleaseIdentity(rt(), pool.source_release_id, body, actor);
}, },
); );
@@ -670,7 +670,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.select(["source_release_id", "attempts"]) .select(["source_release_id", "attempts"])
.where("id", "=", params.id) .where("id", "=", params.id)
.executeTakeFirst(); .executeTakeFirst();
if (!pool) throw new ApiException("not_found", "未识别条目不存在"); if (!pool) throw new ApiException("not_found", "未识别资源不存在");
const r = await rt().ctx.engine.enqueue( const r = await rt().ctx.engine.enqueue(
"resolveObservation", "resolveObservation",
// 用户点"重新识别" = 手动触发:即使不相关也查 TMDB(惰性识别的 c 路径) // 用户点"重新识别" = 手动触发:即使不相关也查 TMDB(惰性识别的 c 路径)
@@ -176,7 +176,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(def)}`); throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(def)}`);
} }
if (!def && body.adapter !== "generic_rss") { if (!def && body.adapter !== "generic_rss") {
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`); throw new ApiException("bad_request", `注册表中没有站点 ${body.key};没有适配的站点只能按 generic_rss 登记`);
} }
if (def && definitionKind(def) !== "pt") throw new ApiException("bad_request", `${body.key} 不是 PT 站`); if (def && definitionKind(def) !== "pt") throw new ApiException("bad_request", `${body.key} 不是 PT 站`);
} else if (kind === "subtitle") { } else if (kind === "subtitle") {
@@ -186,7 +186,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
if (body.adapter !== adapterOf(publicDef)) if (body.adapter !== adapterOf(publicDef))
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(publicDef)}`); throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(publicDef)}`);
} else if (body.adapter !== "generic_rss") { } else if (body.adapter !== "generic_rss") {
throw new ApiException("bad_request", `${SITE_KINDS[kind].label}只能以 generic_rss 登记`); throw new ApiException("bad_request", `${SITE_KINDS[kind].label}只能按 generic_rss 登记`);
} }
const key = body.key ?? (await uniqueSiteKey(db(), body.rssUrl ?? body.baseUrl)); const key = body.key ?? (await uniqueSiteKey(db(), body.rssUrl ?? body.baseUrl));
try { try {
@@ -277,7 +277,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post", method: "post",
path: `/sites/{id}/${enabled ? "enable" : "disable"}`, path: `/sites/{id}/${enabled ? "enable" : "disable"}`,
scope: "admin", scope: "admin",
summary: enabled ? "开启站点" : "关闭站点(停止网络访问与新候选,历史资源仍可查)", summary: enabled ? "开启站点" : "关闭站点(停止访问站点、不再收新资源,历史资源仍可查)",
tags: T_SITES, tags: T_SITES,
params: IdParam, params: IdParam,
response: Site, response: Site,
@@ -291,7 +291,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post", method: "post",
path: "/sites/{id}/snapshot", path: "/sites/{id}/snapshot",
scope: "admin", scope: "admin",
summary: "立即同步站点快照(受站点限流)", summary: "立即抓取站点列表(受站点限流)",
tags: T_SITES, tags: T_SITES,
params: IdParam, params: IdParam,
body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }), body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }),
@@ -315,7 +315,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get", method: "get",
path: "/sites/{id}/snapshots", path: "/sites/{id}/snapshots",
scope: "read", scope: "read",
summary: "站点快照/补查记录(覆盖范围与新鲜度)", summary: "站点抓取记录(覆盖范围与更新时间)",
tags: T_SITES, tags: T_SITES,
params: IdParam, params: IdParam,
query: CursorQuery, query: CursorQuery,
@@ -755,7 +755,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post", method: "post",
path: `/feed-sources/{id}/${enabled ? "enable" : "disable"}`, path: `/feed-sources/{id}/${enabled ? "enable" : "disable"}`,
scope: "admin", scope: "admin",
summary: enabled ? "启用 Feed" : "停用 Feed", summary: enabled ? "启用 RSS" : "停用 RSS",
tags: T_FEEDS, tags: T_FEEDS,
params: IdParam, params: IdParam,
response: FeedSource, response: FeedSource,
@@ -834,7 +834,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
); );
route(app, D.feedSources.fetchNow, async ({ params }) => { route(app, D.feedSources.fetchNow, async ({ params }) => {
const feed = await getFeed(params.id); const feed = await getFeed(params.id);
if (!feed.enabled) throw new ApiException("precondition_failed", "Feed 已停用"); if (!feed.enabled) throw new ApiException("precondition_failed", "RSS 已停用");
const r = await rt().ctx.engine.enqueue( const r = await rt().ctx.engine.enqueue(
"ingestFeed", "ingestFeed",
{ feedSourceId: feed.id, manual: true }, { feedSourceId: feed.id, manual: true },
@@ -986,7 +986,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
} }
async function getFeed(id: string) { async function getFeed(id: string) {
const row = await db().selectFrom("feed_source").selectAll().where("id", "=", id).executeTakeFirst(); const row = await db().selectFrom("feed_source").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new ApiException("not_found", "Feed 不存在"); if (!row) throw new ApiException("not_found", "RSS 不存在");
return row; return row;
} }
async function checkAccount(accountId: string, siteId: string | null) { async function checkAccount(accountId: string, siteId: string | null) {
@@ -23,7 +23,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get", method: "get",
path: "/sites/{id}/releases", path: "/sites/{id}/releases",
scope: "read", scope: "read",
summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)", summary: "资源列表 / 搜索(先用上次抓取的结果,必要时去站点搜;标注来源与更新时间)",
tags: T, tags: T,
params: z.object({ id: Id }), params: z.object({ id: Id }),
query: WorkbenchReleaseQuery, query: WorkbenchReleaseQuery,
@@ -443,7 +443,7 @@ export async function refreshSiteAccount(
if (!info) return { kind: "no_parser" }; if (!info) return { kind: "no_parser" };
if (info.userId && !info.errMsg) return { kind: "ok", info }; if (info.userId && !info.errMsg) return { kind: "ok", info };
const errs = ((info.raw as { http_errors?: string[] }).http_errors ?? []).filter(Boolean); const errs = ((info.raw as { http_errors?: string[] }).http_errors ?? []).filter(Boolean);
if (errs.some((e) => /Cloudflare/i.test(e))) return { kind: "challenge", message: "站点返回 Cloudflare 挑战页" }; if (errs.some((e) => /Cloudflare/i.test(e))) return { kind: "challenge", message: "站点返回 Cloudflare 验证页" };
// 连接失败 / 超时 / 5xx 视为网络问题;4xx 与解析失败按登录态失效处理 // 连接失败 / 超时 / 5xx 视为网络问题;4xx 与解析失败按登录态失效处理
const net = errs.find((e) => !/HTTP 4\d\d|JSON 解析失败/.test(e)); const net = errs.find((e) => !/HTTP 4\d\d|JSON 解析失败/.test(e));
if (net) return { kind: "network", message: net.slice(0, 200) }; if (net) return { kind: "network", message: net.slice(0, 200) };
@@ -461,11 +461,11 @@ export async function refreshSiteAccount(
const succeed = (info: SiteUserInfo | null, note?: string) => markSuccess(rt, acc, site, info, actor, note); const succeed = (info: SiteUserInfo | null, note?: string) => markSuccess(rt, acc, site, info, actor, note);
try { try {
if (acc.auth_kind === "passkey") return unchanged("unsupported", "Passkey 只用于 RSS 和下载,不参与登录"); if (acc.auth_kind === "passkey") return unchanged("unsupported", "Passkey 只用于 RSS 和下载,不用于登录");
if (!acc.secret_ref) return unchanged("unsupported", "账号没有凭证"); if (!acc.secret_ref) return unchanged("unsupported", "账号没有凭证");
if (acc.auth_kind === "password") { if (acc.auth_kind === "password") {
if (umt.kind !== "nexusphp" || !umt.login) { if (umt.kind !== "nexusphp" || !umt.login) {
return unchanged("unsupported", "该站点不支持账号密码登录。改用 Cookie 或 API Key"); return unchanged("unsupported", "这个站点不支持账号密码登录。改用 Cookie 或 API Key");
} }
if (opts.mode === "userData" && acc.session_secret_ref) { if (opts.mode === "userData" && acc.session_secret_ref) {
const r = await fetchUserData({ cookie: await secrets.resolve(acc.session_secret_ref) }); const r = await fetchUserData({ cookie: await secrets.resolve(acc.session_secret_ref) });
@@ -667,7 +667,7 @@ async function upsertUserDataDay(trx: Exec, accountId: string, now: Date, info:
const DECISION_TEXT: Record<string, { title: (s: string) => string; summary: string }> = { const DECISION_TEXT: Record<string, { title: (s: string) => string; summary: string }> = {
challenge: { challenge: {
title: (s) => `站点「${s}」登录需要人工验证`, title: (s) => `站点「${s}」登录需要人工验证`,
summary: "登录页要求验证码 / 二次验证,无法自动登录。改用 Cookie 登录,或填写 TOTP 密钥后重试", summary: "登录页要求验证码或二次验证,无法自动登录。改用 Cookie 登录,或填写 TOTP 密钥后重试",
}, },
invalid: { invalid: {
title: (s) => `站点「${s}」登录失败`, title: (s) => `站点「${s}」登录失败`,
@@ -707,7 +707,7 @@ async function markFailure(
const text = DECISION_TEXT[status]!; const text = DECISION_TEXT[status]!;
const summary = const summary =
reason === "challenge" reason === "challenge"
? "站点返回 Cloudflare 挑战页,无法自动登录。改用 Cookie 登录" ? "站点返回 Cloudflare 验证页,无法自动登录。改用 Cookie 登录"
: status === "expired" || : status === "expired" ||
reason === "account_blocked" || reason === "account_blocked" ||
reason === "bad_credentials" || reason === "bad_credentials" ||
@@ -363,7 +363,7 @@ async function resolveQuery(ctx: Pick<ServerContext, "db">, input: MetadataEnric
.select(["p.id", "p.query", "r.title as release_title"]) .select(["p.id", "p.query", "r.title as release_title"])
.where("p.id", "=", input.poolEntryId) .where("p.id", "=", input.poolEntryId)
.executeTakeFirst(); .executeTakeFirst();
if (!p) throw new EnrichError("not_found", "未识别条目不存在"); if (!p) throw new EnrichError("not_found", "未识别资源不存在");
const q = (p.query ?? {}) as { title?: string | null; titleCn?: string | null; year?: number | null }; const q = (p.query ?? {}) as { title?: string | null; titleCn?: string | null; year?: number | null };
const title = (q.titleCn || q.title || "").trim(); const title = (q.titleCn || q.title || "").trim();
if (!title) throw new EnrichError("precondition", "这条资源没有解析出标题,无法补全"); if (!title) throw new EnrichError("precondition", "这条资源没有解析出标题,无法补全");
@@ -457,7 +457,7 @@ export async function suggestEnrichment(
} }
if (settings.dailyBudget <= 0) if (settings.dailyBudget <= 0)
throw new EnrichError("budget", "元数据补全已停用(每日上限为 0),在设置 → 元数据里调整"); throw new EnrichError("budget", "元数据补全已停用(每日上限为 0)。在设置 → 元数据里调整");
// 预算检查与占位在同一把咨询锁里,并发补全不会超额 // 预算检查与占位在同一把咨询锁里,并发补全不会超额
const row = await ctx.db.transaction().execute(async (trx) => { const row = await ctx.db.transaction().execute(async (trx) => {
await sql`select pg_advisory_xact_lock(hashtext('metadata_enrichment:budget'))`.execute(trx); await sql`select pg_advisory_xact_lock(hashtext('metadata_enrichment:budget'))`.execute(trx);
@@ -465,7 +465,7 @@ export async function suggestEnrichment(
if (used >= settings.dailyBudget) if (used >= settings.dailyBudget)
throw new EnrichError( throw new EnrichError(
"budget", "budget",
`今天的元数据补全已用完(${used}/${settings.dailyBudget}),明天再试或在设置里调高上限`, `今天的元数据补全已用完(${used}/${settings.dailyBudget})。明天再试,或在设置里调高上限`,
); );
return trx return trx
.insertInto("metadata_enrichment") .insertInto("metadata_enrichment")
@@ -852,7 +852,7 @@ export async function applyEnrichment(
.select(["id", "source_release_id", "status"]) .select(["id", "source_release_id", "status"])
.where("id", "=", poolEntryId) .where("id", "=", poolEntryId)
.executeTakeFirst(); .executeTakeFirst();
if (!pool) throw new ApiException("not_found", "未识别条目不存在"); if (!pool) throw new ApiException("not_found", "未识别资源不存在");
const row = await ctx.db const row = await ctx.db
.selectFrom("metadata_enrichment") .selectFrom("metadata_enrichment")
.selectAll() .selectAll()
@@ -58,7 +58,7 @@ export async function enqueueNewEntityRefresh(ctx: ServerContext, entityId: stri
(err: unknown) => (err: unknown) =>
ctx.logger.debug( ctx.logger.debug(
{ entityId, err: { message: err instanceof Error ? err.message : String(err) } }, { entityId, err: { message: err instanceof Error ? err.message : String(err) } },
"新建档元数据刷新入队失败", "新作品元数据刷新入队失败",
), ),
); );
} }
@@ -94,7 +94,7 @@ export async function createEntityFromTmdbCandidate(
detail = await client.details(ref.kind, ref.externalId); detail = await client.details(ref.kind, ref.externalId);
} catch (e) { } catch (e) {
if (e instanceof TmdbError && e.kind === "notfound") if (e instanceof TmdbError && e.kind === "notfound")
throw new CandidateError("not_found", `TMDB 无此条目 ${ref.kind}/${ref.externalId}`); throw new CandidateError("not_found", `TMDB 上没有 ${ref.kind}/${ref.externalId}`);
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`); if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
throw e; throw e;
} }
@@ -103,7 +103,7 @@ export async function feedDefaultsForSite(
const feedHost = hostKey(url); const feedHost = hostKey(url);
const siteHosts = new Set([hostKey(site.base_url), def ? hostKey(def.domain) : null].filter(Boolean)); const siteHosts = new Set([hostKey(site.base_url), def ? hostKey(def.domain) : null].filter(Boolean));
if (feedHost && !siteHosts.has(feedHost)) { if (feedHost && !siteHosts.has(feedHost)) {
warnings.push(`地址域名 ${new URL(url).hostname} 与站点「${site.name}」不一致,已按该站保存;请确认没有填错站点`); warnings.push(`地址域名 ${new URL(url).hostname} 与站点「${site.name}」不一致,已按该站保存。确认没有填错站点`);
} }
return { return {
siteAccountId, siteAccountId,
@@ -214,7 +214,7 @@ export async function ingestFeed(
} }
const lastError = auth const lastError = auth
? { ...(state.lastError ?? {}), kind: "auth", message: "Feed 凭证失效或过期(401/403/过期提示),已停止自动重试" } ? { ...(state.lastError ?? {}), kind: "auth", message: "RSS 凭证失效或过期(401/403/过期提示),已停止自动重试" }
: (state.lastError ?? null); : (state.lastError ?? null);
// 疑似缺口:本次 ok 且零重叠 → 记下;本次 ok 且衔接上 → 清空;抓取失败 → 保留上次状态 // 疑似缺口:本次 ok 且零重叠 → 记下;本次 ok 且衔接上 → 清空;抓取失败 → 保留上次状态
const gap: StoredCursor["gap"] = const gap: StoredCursor["gap"] =
@@ -279,15 +279,15 @@ export async function ingestFeed(
await openDecision(rt.ctx, trx, { await openDecision(rt.ctx, trx, {
kind: "source_unreachable", kind: "source_unreachable",
severity: "warning", severity: "warning",
title: `Feed「${feed.name}」凭证失效`, title: `RSS「${feed.name}」凭证失效`,
summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 Feed 链接或站点账号凭证后恢复抓取。", summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 RSS 地址或站点账号凭证后恢复抓取。",
subjectType: "feed_source", subjectType: "feed_source",
subjectId: feed.id, subjectId: feed.id,
dedupeKey: `feed-auth:${feed.id}`, dedupeKey: `feed-auth:${feed.id}`,
evidence: { feedId: feed.id, siteId: feed.site_id, siteAccountId: feed.site_account_id, error: lastError }, evidence: { feedId: feed.id, siteId: feed.site_id, siteAccountId: feed.site_account_id, error: lastError },
options: [ options: [
{ key: "reset", label: "已更新凭证,恢复抓取" }, { key: "reset", label: "已更新凭证,恢复抓取" },
{ key: "disable", label: "停用 Feed" }, { key: "disable", label: "停用 RSS" },
], ],
}); });
out = { ...out, status: "auth_failed", reason: "auth" }; out = { ...out, status: "auth_failed", reason: "auth" };
@@ -53,7 +53,7 @@ async function entityForExternal(
if (ref.provider !== "tmdb") if (ref.provider !== "tmdb")
throw new ApiException( throw new ApiException(
"bad_request", "bad_request",
`未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 加入媒体库`, `不认识的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 加入媒体库`,
); );
const tmdb = await rt.tmdb(); const tmdb = await rt.tmdb();
if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部编号加入媒体库"); if (!tmdb) throw new ApiException("precondition_failed", "未配置 TMDB,无法按外部编号加入媒体库");
@@ -62,7 +62,7 @@ async function entityForExternal(
detail = await tmdb.details(ref.entityType, ref.externalId); detail = await tmdb.details(ref.entityType, ref.externalId);
} catch (e) { } catch (e) {
if (e instanceof TmdbError && e.kind === "notfound") if (e instanceof TmdbError && e.kind === "notfound")
throw new ApiException("not_found", `TMDB 无此条目 ${ref.entityType}/${ref.externalId}`); throw new ApiException("not_found", `TMDB 上没有 ${ref.entityType}/${ref.externalId}`);
if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`); if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`);
throw e; throw e;
} }
@@ -99,7 +99,7 @@ export async function assignReleaseIdentity(
.where("source_release_id", "=", srId) .where("source_release_id", "=", srId)
.executeTakeFirst(); .executeTakeFirst();
const c = ((pool?.candidates ?? []) as PoolCandidate[])[input.candidateIndex]; const c = ((pool?.candidates ?? []) as PoolCandidate[])[input.candidateIndex];
if (!c) throw new ApiException("bad_request", "候选序号无效"); if (!c) throw new ApiException("bad_request", "候选序号不对");
if (c.localEntityId) localId = c.localEntityId; if (c.localEntityId) localId = c.localEntityId;
else ref = { provider: c.provider, entityType: c.entityType, externalId: c.externalId }; else ref = { provider: c.provider, entityType: c.entityType, externalId: c.externalId };
} }
@@ -283,7 +283,7 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
if (!id) throw new ApiException("not_found", "作品不存在"); if (!id) throw new ApiException("not_found", "作品不存在");
const other = await entityByExternal(trx, input); const other = await entityByExternal(trx, input);
if (other && other !== id) { if (other && other !== id) {
throw new ApiException("conflict", `该外部身份已属于作品 ${other}。如果是重复作品,改用合并`); throw new ApiException("conflict", `该外部身份已属于作品 ${other}。重复作品改用合并`);
} }
if (other === id) return { mediaEntityId: id, changed: false }; if (other === id) return { mediaEntityId: id, changed: false };
const olds = await trx const olds = await trx
@@ -445,7 +445,7 @@ export async function mergeEntities(
if (!from) throw new ApiException("not_found", "源作品不存在"); if (!from) throw new ApiException("not_found", "源作品不存在");
if (!intoResolved) throw new ApiException("not_found", "目标作品不存在"); if (!intoResolved) throw new ApiException("not_found", "目标作品不存在");
if (from.status === "merged") throw new ApiException("conflict", "源作品已被合并"); if (from.status === "merged") throw new ApiException("conflict", "源作品已被合并");
if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,会形成环"); if (intoResolved === fromId) throw new ApiException("conflict", "目标作品已合并到源作品,不能再反向合并");
// 外部身份:目标已有同 provider+类型 accepted 的,源的退役;否则迁到目标 // 外部身份:目标已有同 provider+类型 accepted 的,源的退役;否则迁到目标
const idents = await trx const idents = await trx
@@ -572,7 +572,7 @@ export async function dismissPoolEntry(rt: SourcesRuntime, poolId: string, actor
.where("id", "=", poolId) .where("id", "=", poolId)
.returningAll() .returningAll()
.executeTakeFirst(); .executeTakeFirst();
if (!row) throw new ApiException("not_found", "未识别条目不存在"); if (!row) throw new ApiException("not_found", "未识别资源不存在");
await trx await trx
.updateTable("source_release") .updateTable("source_release")
.set({ resolution_status: "rejected" }) .set({ resolution_status: "rejected" })
@@ -309,7 +309,7 @@ export async function refreshMetadata(
richDetail = await fetchRichDetail(tmdb, ident.entity_type, ident.external_id, langs, textOpts); richDetail = await fetchRichDetail(tmdb, ident.entity_type, ident.external_id, langs, textOpts);
} catch (e) { } catch (e) {
if (!(e instanceof TmdbError)) throw e; if (!(e instanceof TmdbError)) throw e;
ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 富详情获取失败,按基础详情刷新"); ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 完整详情获取失败,按基础详情刷新");
} }
} }
// 作品选了剧集组:季表与季 / 集单元按组(组序号 → 季号,组内顺序 → 集号);组取不到时这次不动季集结构 // 作品选了剧集组:季表与季 / 集单元按组(组序号 → 季号,组内顺序 → 集号);组取不到时这次不动季集结构
@@ -157,7 +157,7 @@ async function syncSnapshotFailureDecision(
kind: "source_unreachable", kind: "source_unreachable",
severity: "warning", severity: "warning",
title: `站点「${site.name}」列表抓取连续失败`, title: `站点「${site.name}」列表抓取连续失败`,
summary: `连续 ${failStreak} 次没抓到列表(${err.kind}:${err.message})。可能是站点改版、Cloudflare 验证或限流;检查站点设置或稍后重试,恢复后自动关闭。`, summary: `连续 ${failStreak} 次没抓到列表(${err.kind}:${err.message})。检查站点设置或稍后重试,恢复后这条提醒自动关闭。`,
subjectType: "site", subjectType: "site",
subjectId: site.id, subjectId: site.id,
dedupeKey, dedupeKey,
@@ -240,7 +240,7 @@ export async function listSiteReleases(
let coalesced = false; let coalesced = false;
const wantLive = q.live === "true" || (q.live !== "false" && offset === 0 && !!q.q && (rows.length === 0 || stale)); const wantLive = q.live === "true" || (q.live !== "false" && offset === 0 && !!q.q && (rows.length === 0 || stale));
if (wantLive && q.q) { if (wantLive && q.q) {
if (!site.enabled) liveError = "站点已关闭,只展示历史数据"; if (!site.enabled) liveError = "站点已关闭,只显示历史数据";
else { else {
const r = await liveSearch(rt, site, q.q); const r = await liveSearch(rt, site, q.q);
live = r.error === null; live = r.error === null;
@@ -50,8 +50,7 @@ async function request(
}); });
} catch (e) { } catch (e) {
const n = (e as Error).name; const n = (e as Error).name;
if (n === "TimeoutError" || n === "AbortError") if (n === "TimeoutError" || n === "AbortError") throw new SubtitleSiteError(`连接${env.site.name}超时,稍后再试`);
throw new SubtitleSiteError(`连接${env.site.name}超时,稍后再试(站点可能在限流或网络不通)`);
throw new SubtitleSiteError(`连接${env.site.name}失败:${(e as Error).message.slice(0, 200)}`); throw new SubtitleSiteError(`连接${env.site.name}失败:${(e as Error).message.slice(0, 200)}`);
} }
for (const c of res.headers.getSetCookie()) { for (const c of res.headers.getSetCookie()) {
@@ -203,7 +202,7 @@ async function subhdDownload(env: Env, ref: string): Promise<{ name: string; byt
r = await api(code ?? ""); r = await api(code ?? "");
} }
if (!r.success || !r.url) if (!r.success || !r.url)
throw new SubtitleSiteError("SubHD 下载验证没通过:可在站点的原站视图里打开这条字幕手动下载后上传"); throw new SubtitleSiteError("SubHD 下载验证没通过。在站点的原站视图里打开这条字幕,手动下载后上传");
return await getFile(env, abs(env, r.url), downUrl); return await getFile(env, abs(env, r.url), downUrl);
} }
@@ -249,13 +248,11 @@ async function zimukuPage(env: Env, url: string, referer?: string): Promise<stri
for (const c of r.cookies) jar.set(c.name, c.value); for (const c of r.cookies) jar.set(c.name, c.value);
const o = await open(); const o = await open();
if ("html" in o) return o.html; if ("html" in o) return o.html;
throw new SubtitleSiteError("字幕库的验证码在浏览器里过了,但验证结果带不回站点请求:稍后再试"); throw new SubtitleSiteError("字幕库的验证码在浏览器里过了,但验证结果带不回站点请求。稍后再试");
} }
if (r.status === "needs_human") if (r.status === "needs_human")
throw new SubtitleSiteError( throw new SubtitleSiteError("字幕库的验证码代码识别和智能体都没过。去站点的原站视图输一次验证码后再搜");
"字幕库的验证码代码识别和智能体都没过:已开待处理事项,去站点的原站视图输一次验证码后再搜", throw new SubtitleSiteError(`字幕库的验证码没能自动通过(${r.reason})。在站点的原站视图里打开一次再试`);
);
throw new SubtitleSiteError(`字幕库的验证码没能自动通过(${r.reason}):可在站点的原站视图里打开一次再试`);
} }
async function zimukuSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> { async function zimukuSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
+7 -7
View File
@@ -243,7 +243,7 @@ function taskRunRules(c: RetentionRuleContext): RetentionRule[] {
where: sql`x.created_at < ${engineCutoff} and ${runGoneOr("workflow_run_id")}`, where: sql`x.created_at < ${engineCutoff} and ${runGoneOr("workflow_run_id")}`,
}, },
{ {
label: "操作账本", label: "任务操作记录",
table: q("tasks_op_ledger"), table: q("tasks_op_ledger"),
where: sql`x.status = 'succeeded' and x.started_at < ${cutoffOf(c.now, s.taskRun)}`, where: sql`x.status = 'succeeded' and x.started_at < ${cutoffOf(c.now, s.taskRun)}`,
}, },
@@ -280,7 +280,7 @@ function pipelineRules(c: RetentionRuleContext): RetentionRule[] {
}, },
{ {
// 只删已识别且资源仍绑定作品的;忽略(dismissed)是「不再自动识别」的标记,归档(archived)仍可手动指定,都不删 // 只删已识别且资源仍绑定作品的;忽略(dismissed)是「不再自动识别」的标记,归档(archived)仍可手动指定,都不删
label: "已识别的待识别条目", label: "已识别的未识别资源",
table: q("identity_candidate"), table: q("identity_candidate"),
where: sql`x.status = 'resolved' and x.updated_at < ${cutoffOf(c.now, s.identityResolved)} where: sql`x.status = 'resolved' and x.updated_at < ${cutoffOf(c.now, s.identityResolved)}
and exists (select 1 from source_release r where r.id = x.source_release_id and r.media_entity_id is not null) and exists (select 1 from source_release r where r.id = x.source_release_id and r.media_entity_id is not null)
@@ -290,7 +290,7 @@ function pipelineRules(c: RetentionRuleContext): RetentionRule[] {
}, },
{ {
// 每个站点最近一次成功的列表快照留着(站点刷新间隔按它判断) // 每个站点最近一次成功的列表快照留着(站点刷新间隔按它判断)
label: "站点快照", label: "站点抓取记录",
table: q("site_snapshot"), table: q("site_snapshot"),
where: sql`x.started_at < ${cutoffOf(c.now, s.siteSnapshot)} and x.status <> 'running' where: sql`x.started_at < ${cutoffOf(c.now, s.siteSnapshot)} and x.status <> 'running'
and x.id is distinct from (select s2.id from site_snapshot s2 and x.id is distinct from (select s2.id from site_snapshot s2
@@ -429,11 +429,11 @@ export const RETENTION_JOBS: RetentionJob[] = [
target: "pipeline", target: "pipeline",
jobId: "storage.retention.pipeline", jobId: "storage.retention.pipeline",
name: "流水数据清理", name: "流水数据清理",
description: "每天低峰删除过期的已投递事件、识别尝试、已识别的待识别条目与站点快照", description: "每天低峰删除过期的已投递事件、识别尝试、已识别的未识别资源和站点抓取记录",
everyMs: 24 * HOUR, everyMs: 24 * HOUR,
schedule: { type: "cron", cron: "40 4 * * *" }, schedule: { type: "cron", cron: "40 4 * * *" },
retention: (s) => retention: (s) =>
`事件 ${s.retentionDays.outboxEvent} 天,识别尝试 ${s.retentionDays.resolutionAttempt} 天,已识别条目 ${s.retentionDays.identityResolved} 天,站点快照 ${s.retentionDays.siteSnapshot} 天`, `事件 ${s.retentionDays.outboxEvent} 天,识别尝试 ${s.retentionDays.resolutionAttempt} 天,已识别的未识别资源 ${s.retentionDays.identityResolved} 天,站点抓取记录 ${s.retentionDays.siteSnapshot} 天`,
rules: pipelineRules, rules: pipelineRules,
async run(c) { async run(c) {
const rules = pipelineRules(c); const rules = pipelineRules(c);
@@ -454,7 +454,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
where status = 'pending' and reason = 'unmatched' and coalesce(last_attempt_at, created_at) < ${cutoff}`.execute( where status = 'pending' and reason = 'unmatched' and coalesce(last_attempt_at, created_at) < ${cutoff}`.execute(
c.db, c.db,
); );
return [{ label: "未识别条目(归档,不删除)", table: q("identity_candidate"), rows: Number(r.rows[0]?.n ?? 0) }]; return [{ label: "未识别资源(归档,不删除)", table: q("identity_candidate"), rows: Number(r.rows[0]?.n ?? 0) }];
}, },
async run(c) { async run(c) {
const cutoff = cutoffOf(c.now, c.settings.retentionDays.identityPoolArchive); const cutoff = cutoffOf(c.now, c.settings.retentionDays.identityPoolArchive);
@@ -468,7 +468,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
archived += n; archived += n;
if (n < size) break; if (n < size) break;
} }
return `归档 ${archived} 条未识别条目`; return `归档 ${archived} 条未识别资源`;
}, },
}, },
{ {
+2 -2
View File
@@ -89,7 +89,7 @@ export class WebSearchService {
private async client(s: WebSearchSettings) { private async client(s: WebSearchSettings) {
if (s.provider === "none") if (s.provider === "none")
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启"); throw new WebSearchServiceError("disabled", "网页搜索未开启。在设置 → 智能体 → 网页搜索里开启");
if (!s.apiKeySecretRef) throw new WebSearchServiceError("auth", "未保存搜索服务 API Key"); if (!s.apiKeySecretRef) throw new WebSearchServiceError("auth", "未保存搜索服务 API Key");
const apiKey = await this.ctx.secrets.resolve(s.apiKeySecretRef); const apiKey = await this.ctx.secrets.resolve(s.apiKeySecretRef);
return createBraveClient({ return createBraveClient({
@@ -177,7 +177,7 @@ export class WebSearchService {
async fetch(url: string, o: { scopeKey: string; signal?: AbortSignal }): Promise<WebFetchOutput> { async fetch(url: string, o: { scopeKey: string; signal?: AbortSignal }): Promise<WebFetchOutput> {
const s = await this.settings(); const s = await this.settings();
if (s.provider === "none") if (s.provider === "none")
throw new WebSearchServiceError("disabled", "网页搜索未开启,在设置 → 智能体 → 网页搜索里开启"); throw new WebSearchServiceError("disabled", "网页搜索未开启。在设置 → 智能体 → 网页搜索里开启");
const want = canonicalUrl(url.trim()); const want = canonicalUrl(url.trim());
const allow = await pgCacheFor(this.ctx.db).get<{ urls: string[] }>(WEBSEARCH_ALLOW_REGION, o.scopeKey); const allow = await pgCacheFor(this.ctx.db).get<{ urls: string[] }>(WEBSEARCH_ALLOW_REGION, o.scopeKey);
if (!want || !allow?.urls.includes(want)) if (!want || !allow?.urls.includes(want))
+1 -1
View File
@@ -185,7 +185,7 @@ export async function startWorker({ logger, heartbeatMs, bus, runtime, lagProbe
if (laned.length) { if (laned.length) {
const { byLane, missing } = groupTasksByLane(tasks); const { byLane, missing } = groupTasksByLane(tasks);
if (missing.length) if (missing.length)
log.warn({ tasks: missing }, "这些任务没有声明 lane,按缺省归入网络道(请在 defineTask 里补 lane)"); log.warn({ tasks: missing }, "这些任务没有声明 lane,按缺省归入网络道(在 defineTask 里补 lane)");
lanes = new LaneController({ lanes = new LaneController({
engine: ctx.engine, engine: ctx.engine,
logger, logger,
@@ -92,10 +92,10 @@ describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => {
expect((await json(await admin("GET", "/settings"))).data.cache.hardThresholdPercent).toBe(90); expect((await json(await admin("GET", "/settings"))).data.cache.hardThresholdPercent).toBe(90);
}); });
it("非法合并结果 → 422(恢复水位必须低于硬阈值)", async () => { it("非法合并结果 → 422(恢复线必须低于暂停线)", async () => {
const r = await admin("PATCH", "/settings", { cache: { resumePercent: 95 } }); const r = await admin("PATCH", "/settings", { cache: { resumePercent: 95 } });
expect(r.status).toBe(422); expect(r.status).toBe(422);
expect((await json(r)).error.message).toMatch(/恢复水位/); expect((await json(r)).error.message).toMatch(/恢复线/);
}); });
it("旧 pi.apiKey / provider / model 不再接受:被丢弃,不建 secret,不改设置", async () => { it("旧 pi.apiKey / provider / model 不再接受:被丢弃,不建 secret,不改设置", async () => {