merge: fix/d-organize
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
fe32520643
12 files changed
+713
-44
No files matched your search
@@ -165,7 +165,13 @@ export function registerAoTasks(registry: TaskRegistry, d: AoDeps) {
|
|||||||
...(await executeRecord(d, recordId, { runId: t.runId, abortSignal: t.abortSignal })),
|
...(await executeRecord(d, recordId, { runId: t.runId, abortSignal: t.abortSignal })),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
(r) => (r.status === "failed" ? "failed" : r.status === "deferred" ? "waiting" : "succeeded"),
|
// 执行返回 running = 库根离线 / 建目录失败而暂停(记录写明原因并开了待处理,由整理巡检重新入队)
|
||||||
|
(r) =>
|
||||||
|
r.status === "failed"
|
||||||
|
? "failed"
|
||||||
|
: r.status === "deferred" || r.status === "running"
|
||||||
|
? "waiting"
|
||||||
|
: "succeeded",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { registerOrganizationRoutes } from "./routes/organization.ts";
|
|||||||
import { registerRuleRoutes } from "./routes/rules.ts";
|
import { registerRuleRoutes } from "./routes/rules.ts";
|
||||||
import { type AoDeps, automationBlock, enqueueSweep, enqueueTracked, timeSlot } from "./services/common.ts";
|
import { type AoDeps, automationBlock, enqueueSweep, enqueueTracked, timeSlot } from "./services/common.ts";
|
||||||
import { SWEEP_SLOT_MS, sweepReservedPlans, syncDownloads } from "./services/downloads.ts";
|
import { SWEEP_SLOT_MS, sweepReservedPlans, syncDownloads } from "./services/downloads.ts";
|
||||||
|
import { recoverStalledRecords } from "./services/organize-records.ts";
|
||||||
import { sweepDueSlots, sweepMetadataRefresh, sweepSubscriptionSearch } from "./services/subscriptions.ts";
|
import { sweepDueSlots, sweepMetadataRefresh, sweepSubscriptionSearch } from "./services/subscriptions.ts";
|
||||||
import { organizeDecisionHook } from "./services/takeover.ts";
|
import { organizeDecisionHook } from "./services/takeover.ts";
|
||||||
import { loadVolumes, usedPercent, volumeThresholds } from "./services/volumes.ts";
|
import { loadVolumes, usedPercent, volumeThresholds } from "./services/volumes.ts";
|
||||||
@@ -39,7 +40,7 @@ const CLEANUP_SLOT_MS = 5 * 60_000;
|
|||||||
* 后台只做编排,start() 把各项周期登记到定时任务注册表(schedules 模块的调度器驱动,不同任务并行、同一任务不重入):
|
* 后台只做编排,start() 把各项周期登记到定时任务注册表(schedules 模块的调度器驱动,不同任务并行、同一任务不重入):
|
||||||
* downloads.sync —— qB 同步 + 缓存阈值暂停/恢复(syncDownloads),只有轻量 HTTP 与 DB 写;
|
* downloads.sync —— qB 同步 + 缓存阈值暂停/恢复(syncDownloads),只有轻量 HTTP 与 DB 写;
|
||||||
* cache.threshold —— 超硬阈值入队 cleanupDownloadCache;
|
* cache.threshold —— 超硬阈值入队 cleanupDownloadCache;
|
||||||
* downloads.orchestrate —— 巡检补投 submitDownload / verifyAndImport;
|
* downloads.orchestrate —— 巡检补投 submitDownload / verifyAndImport;整理记录卡在 running 的重投、Move 后补移除 qB 任务;
|
||||||
* slots.reevaluate / subscriptions.search / subscriptions.metadata —— Slot 到期重评、订阅周期搜索、总集数刷新;
|
* slots.reevaluate / subscriptions.search / subscriptions.metadata —— Slot 到期重评、订阅周期搜索、总集数刷新;
|
||||||
* library.reconcile —— 按周期入队 reconcileLibrary。
|
* library.reconcile —— 按周期入队 reconcileLibrary。
|
||||||
* 复制/删种/全量 stat 这些重活都进任务引擎(租约 + attemptToken),不在调度里 await。
|
* 复制/删种/全量 stat 这些重活都进任务引擎(租约 + attemptToken),不在调度里 await。
|
||||||
@@ -149,14 +150,20 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
key: "downloads.orchestrate",
|
key: "downloads.orchestrate",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: "补提交与补入库",
|
name: "补提交与补入库",
|
||||||
description: "补交没提交成功的下载,补整理下载完但没入库的任务",
|
description: "补交没提交成功的下载,补整理下载完但没入库的任务,重新排队卡住的整理",
|
||||||
group: "download",
|
group: "download",
|
||||||
schedule: every(planMs),
|
schedule: every(planMs),
|
||||||
},
|
},
|
||||||
run: guarded(d, async (d) => {
|
run: guarded(d, async (d) => {
|
||||||
const submitted = await sweepReservedPlans(d);
|
const submitted = await sweepReservedPlans(d);
|
||||||
const imports = await sweepPendingImports(d);
|
const imports = await sweepPendingImports(d);
|
||||||
return `补提交 ${submitted},补入库 ${imports}`;
|
const rec = await recoverStalledRecords(d);
|
||||||
|
return (
|
||||||
|
`补提交 ${submitted},补入库 ${imports}` +
|
||||||
|
(rec.requeued ? `,重排整理 ${rec.requeued}` : "") +
|
||||||
|
(rec.failed ? `,整理停止 ${rec.failed}` : "") +
|
||||||
|
(rec.removed ? `,移除下载任务 ${rec.removed}` : "")
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
registerScheduledJob(ctx, {
|
registerScheduledJob(ctx, {
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
import type { OrganizationRecord } from "@umt/contracts";
|
import type { OrganizationRecord } from "@umt/contracts";
|
||||||
import { sql } from "@umt/db";
|
import { sql } from "@umt/db";
|
||||||
import { withAttemptToken } from "@umt/tasks";
|
import { withAttemptToken } from "@umt/tasks";
|
||||||
|
import { closeDecisions } from "../../sources-identity/service/common.ts";
|
||||||
import { errMessage, jsonb } from "../util.ts";
|
import { errMessage, jsonb } from "../util.ts";
|
||||||
import { type AoDeps, upsertDecision } from "./common.ts";
|
import { type AoDeps, upsertDecision } from "./common.ts";
|
||||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||||
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
||||||
import { finalizeRecord } from "./organize-finalize.ts";
|
import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts";
|
||||||
|
import { emitRecord } from "./organize-plan.ts";
|
||||||
import type { FrozenPlan } from "./organize-types.ts";
|
import type { FrozenPlan } from "./organize-types.ts";
|
||||||
import { cleanupReplacement, settleSamePathReplacement, stagedReplacementBackup } from "./replacement.ts";
|
import { cleanupReplacement, settleSamePathReplacement, stagedReplacementBackup } from "./replacement.ts";
|
||||||
import { scrapeAfterImport } from "./scrape.ts";
|
import { scrapeAfterImport } from "./scrape.ts";
|
||||||
@@ -89,6 +91,17 @@ export async function executeRecord(
|
|||||||
enabled: frozen.scrapeAfterImport,
|
enabled: frozen.scrapeAfterImport,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Move 成功但移除 qB 任务没成功(qB 暂时连不上 / 收尾后进程退出):重放时补做
|
||||||
|
if (rec.action === "move" && rec.status === "succeeded" && rec.download_task_id) {
|
||||||
|
const dt = await db
|
||||||
|
.selectFrom("download_task")
|
||||||
|
.select(["id", "instance_id", "torrent_hash"])
|
||||||
|
.where("id", "=", rec.download_task_id)
|
||||||
|
.where("removed_at", "is", null)
|
||||||
|
.where("managed", "=", true)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (dt) await removeMovedDownload(d, dt);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
recordId,
|
recordId,
|
||||||
status: rec.status,
|
status: rec.status,
|
||||||
@@ -99,11 +112,51 @@ export async function executeRecord(
|
|||||||
const frozen = rec.plan as unknown as FrozenPlan;
|
const frozen = rec.plan as unknown as FrozenPlan;
|
||||||
const fsr = fsRuntime(d);
|
const fsr = fsRuntime(d);
|
||||||
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
|
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
|
||||||
|
// 离线 / 建目录失败不抛错(抛错会耗尽任务重试、记录卡在 running):记录保持 running 并写明原因、开待处理,
|
||||||
|
// 由整理巡检(recoverStalledRecords)在库根恢复后重新入队
|
||||||
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
|
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
|
||||||
if (health.kind === "offline") {
|
if (health.kind === "offline") {
|
||||||
throw new Error(`媒体库目录离线(${health.reason}),稍后重试`);
|
return holdRecord(d, rec, `${ROOT_OFFLINE}:${health.reason}`, {
|
||||||
|
kind: "other",
|
||||||
|
severity: "critical",
|
||||||
|
title: "媒体库目录离线,整理已暂停",
|
||||||
|
summary: `${frozen.rootPath} 不可访问或未挂载(${health.reason}),恢复后自动继续整理。文件不会因此被判为缺失`,
|
||||||
|
subjectType: "library_root",
|
||||||
|
subjectId: frozen.libraryRootId,
|
||||||
|
dedupeKey: `library_root_offline:${frozen.libraryRootId}`,
|
||||||
|
evidence: { reason: health.reason, recordId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rec.action !== "in_place") {
|
||||||
|
try {
|
||||||
|
for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string } | null)?.code ?? "EIO";
|
||||||
|
return holdRecord(d, rec, `${TARGET_DIR_UNAVAILABLE}:${code}`, {
|
||||||
|
kind: "other",
|
||||||
|
title: "整理目标目录建不出来",
|
||||||
|
summary: `${errMessage(err)};检查目录权限或挂载后会自动重试`,
|
||||||
|
subjectType: "organization_record",
|
||||||
|
subjectId: recordId,
|
||||||
|
dedupeKey: `organize_target_dir:${recordId}`,
|
||||||
|
evidence: { code, targetDirs: frozen.targetDirs },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 之前因库根离线 / 建目录失败暂停过:清掉等待原因,库根离线事项随之关闭
|
||||||
|
if (isHoldReason(rec.reason)) {
|
||||||
|
await db.updateTable("organization_record").set({ reason: null }).where("id", "=", recordId).execute();
|
||||||
|
}
|
||||||
|
if (rec.reason?.startsWith(ROOT_OFFLINE)) {
|
||||||
|
await closeDecisions(
|
||||||
|
d.ctx,
|
||||||
|
db,
|
||||||
|
`library_root_offline:${frozen.libraryRootId}`,
|
||||||
|
"resolved",
|
||||||
|
{ key: "root_online", note: "媒体库目录已恢复", organizationRecordId: recordId },
|
||||||
|
"system",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (rec.action !== "in_place") for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
|
|
||||||
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
|
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
|
||||||
if (rec.action !== "in_place" && fsr.partialMaxAgeMs > 0) {
|
if (rec.action !== "in_place" && fsr.partialMaxAgeMs > 0) {
|
||||||
for (const dir of frozen.targetDirs) {
|
for (const dir of frozen.targetDirs) {
|
||||||
@@ -392,6 +445,33 @@ export async function executeRecord(
|
|||||||
return finalizeRecord(d, recordId);
|
return finalizeRecord(d, recordId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 记录暂停的原因前缀:库根离线 / 建目标目录失败(整理巡检据此判断要不要计入重投上限) */
|
||||||
|
export const ROOT_OFFLINE = "library_root_offline";
|
||||||
|
export const TARGET_DIR_UNAVAILABLE = "target_dir_unavailable";
|
||||||
|
export const isHoldReason = (reason: string | null | undefined): boolean =>
|
||||||
|
!!reason && (reason.startsWith(`${ROOT_OFFLINE}:`) || reason.startsWith(`${TARGET_DIR_UNAVAILABLE}:`));
|
||||||
|
|
||||||
|
/** 暂停执行:记录保持 running、写原因并开待处理;返回 running 让任务按"等待"收尾,不耗尽重试 */
|
||||||
|
async function holdRecord(
|
||||||
|
d: AoDeps,
|
||||||
|
rec: { id: string; file_succeeded: number; file_failed: number },
|
||||||
|
reason: string,
|
||||||
|
decision: Parameters<typeof upsertDecision>[2],
|
||||||
|
): Promise<ExecuteResult> {
|
||||||
|
await d.ctx.db.transaction().execute(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ reason })
|
||||||
|
.where("id", "=", rec.id)
|
||||||
|
.where("status", "in", ["running", "queued"])
|
||||||
|
.execute();
|
||||||
|
await upsertDecision(d, trx, decision);
|
||||||
|
await emitRecord(d, trx, rec.id);
|
||||||
|
});
|
||||||
|
d.ctx.logger.warn({ recordId: rec.id, reason }, "整理暂停,等待巡检重试");
|
||||||
|
return { recordId: rec.id, status: "running", succeeded: rec.file_succeeded, failed: rec.file_failed };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* replace 的旧文件(copy 已同卷移到 <根>/.umt-trash/replaced-*)改放成文件管理回收站的布局
|
* replace 的旧文件(copy 已同卷移到 <根>/.umt-trash/replaced-*)改放成文件管理回收站的布局
|
||||||
* <根real>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径> 并写 trash_item(与 file-manager/trash.ts moveToTrash 同构,
|
* <根real>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径> 并写 trash_item(与 file-manager/trash.ts moveToTrash 同构,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { statSafe } from "@umt/adapters";
|
|||||||
import type { DownloadTaskRow, OrganizationRecordRow } from "@umt/db";
|
import type { DownloadTaskRow, OrganizationRecordRow } from "@umt/db";
|
||||||
import { sql } from "@umt/db";
|
import { sql } from "@umt/db";
|
||||||
import { findOtherVersions } from "@umt/domain";
|
import { findOtherVersions } from "@umt/domain";
|
||||||
import { organizationStatusFromFiles } from "@umt/mapping";
|
import { isUnresolvedExclusion, organizationStatusFromFiles } from "@umt/mapping";
|
||||||
import { errMessage, jsonb } from "../util.ts";
|
import { errMessage, jsonb } from "../util.ts";
|
||||||
import { type AoDeps, type Exec, loadInstances, upsertDecision } from "./common.ts";
|
import { type AoDeps, type Exec, loadInstances, upsertDecision } from "./common.ts";
|
||||||
import { applySlotEvent } from "./downloads.ts";
|
import { applySlotEvent } from "./downloads.ts";
|
||||||
@@ -274,7 +274,9 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statuses = files.map((f) => f.status);
|
const statuses = files.map((f) => f.status);
|
||||||
const status = organizationStatusFromFiles(statuses);
|
// 季集待确认 / 没有季目录 / 命名失败而被排除的视频留在原处:记录按 partial,Move 不移除下载任务
|
||||||
|
const unresolved = files.filter((f) => f.status === "skipped" && isUnresolvedExclusion(f.skip_reason));
|
||||||
|
const status = organizationStatusFromFiles(statuses, { unresolved: unresolved.length });
|
||||||
const counts = {
|
const counts = {
|
||||||
file_succeeded: statuses.filter((s) => s === "succeeded").length,
|
file_succeeded: statuses.filter((s) => s === "succeeded").length,
|
||||||
file_failed: statuses.filter((s) => s === "failed").length,
|
file_failed: statuses.filter((s) => s === "failed").length,
|
||||||
@@ -290,10 +292,31 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
|||||||
finished_at: now,
|
finished_at: now,
|
||||||
...(status === "failed" && !rec.reason
|
...(status === "failed" && !rec.reason
|
||||||
? { reason: rec.action === "in_place" ? "in_place_failed" : "transfer_failed" }
|
? { reason: rec.action === "in_place" ? "in_place_failed" : "transfer_failed" }
|
||||||
: {}),
|
: status === "partial" && unresolved.length > 0 && counts.file_failed === 0 && !rec.reason
|
||||||
|
? { reason: "excluded_unresolved" }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
.where("id", "=", recordId)
|
.where("id", "=", recordId)
|
||||||
.execute();
|
.execute();
|
||||||
|
if (unresolved.length > 0) {
|
||||||
|
const shown = unresolved
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((f) => `${f.source_path.split("/").pop()}(${f.skip_reason?.replace(/^excluded:/, "")})`);
|
||||||
|
await upsertDecision(d, trx, {
|
||||||
|
kind: "other",
|
||||||
|
title: `有 ${unresolved.length} 个视频没有整理`,
|
||||||
|
summary: `季集、季目录或命名没能确定,文件留在原处${
|
||||||
|
rec.action === "move" ? ",下载任务保留" : ""
|
||||||
|
}:${shown.join("、")}${unresolved.length > shown.length ? " 等" : ""}`,
|
||||||
|
subjectType: "organization_record",
|
||||||
|
subjectId: recordId,
|
||||||
|
dedupeKey: `organize_excluded:${recordId}`,
|
||||||
|
evidence: {
|
||||||
|
downloadTaskId: rec.download_task_id,
|
||||||
|
files: unresolved.map((f) => ({ path: f.source_path, reason: f.skip_reason })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 下载任务 / 计划 / Slot
|
// 下载任务 / 计划 / Slot
|
||||||
const move = rec.action === "move" && status === "succeeded";
|
const move = rec.action === "move" && status === "succeeded";
|
||||||
@@ -449,30 +472,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Move 完成:移除 qB 任务记录,结束下载侧管理(不删数据)
|
// Move 完成:移除 qB 任务记录,结束下载侧管理(不删数据)
|
||||||
if (result.move && result.dt) {
|
if (result.move && result.dt) await removeMovedDownload(d, result.dt);
|
||||||
const topo = (
|
|
||||||
await loadInstances(db, {
|
|
||||||
enabledOnly: false,
|
|
||||||
ids: [result.dt.instance_id],
|
|
||||||
})
|
|
||||||
)[0];
|
|
||||||
if (topo) {
|
|
||||||
try {
|
|
||||||
const client = await d.ports.downloader(topo.inst);
|
|
||||||
await client.delete([result.dt.torrent_hash], { deleteFiles: false });
|
|
||||||
await db
|
|
||||||
.updateTable("download_task")
|
|
||||||
.set({ removed_at: d.ports.now(), managed: false })
|
|
||||||
.where("id", "=", result.dt.id)
|
|
||||||
.execute();
|
|
||||||
} catch (err) {
|
|
||||||
d.ctx.logger.warn(
|
|
||||||
{ downloadTaskId: result.dt.id, err: { message: errMessage(err) } },
|
|
||||||
"Move 后移除 qB 任务失败,下次同步重试",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
recordId: result.recordId,
|
recordId: result.recordId,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
@@ -481,6 +481,34 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move 成功后移除 qB 任务(不删数据)并结束纳管。失败只告警:executeRecord 重放已结束记录、
|
||||||
|
* 整理巡检(recoverStalledRecords)会对"move 成功但下载任务未移除"的记录再试。
|
||||||
|
*/
|
||||||
|
export async function removeMovedDownload(
|
||||||
|
d: AoDeps,
|
||||||
|
dt: Pick<DownloadTaskRow, "id" | "instance_id" | "torrent_hash">,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const topo = (await loadInstances(d.ctx.db, { enabledOnly: false, ids: [dt.instance_id] }))[0];
|
||||||
|
if (!topo) return false;
|
||||||
|
try {
|
||||||
|
const client = await d.ports.downloader(topo.inst);
|
||||||
|
await client.delete([dt.torrent_hash], { deleteFiles: false });
|
||||||
|
await d.ctx.db
|
||||||
|
.updateTable("download_task")
|
||||||
|
.set({ removed_at: d.ports.now(), managed: false })
|
||||||
|
.where("id", "=", dt.id)
|
||||||
|
.execute();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
d.ctx.logger.warn(
|
||||||
|
{ downloadTaskId: dt.id, err: { message: errMessage(err) } },
|
||||||
|
"Move 后移除 qB 任务失败,整理巡检会重试",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动 / 没识别出作品"事项(resolution.key=manual_organize,记整理记录 id) */
|
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动 / 没识别出作品"事项(resolution.key=manual_organize,记整理记录 id) */
|
||||||
export async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
|
export async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
|
||||||
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
|
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { reasonSentence } from "@umt/contracts";
|
import { reasonSentence } from "@umt/contracts";
|
||||||
import type { OrganizationRecordRow } from "@umt/db";
|
import { type OrganizationRecordRow, sql } from "@umt/db";
|
||||||
import type { Exec } from "./common.ts";
|
import { errMessage, jsonb } from "../util.ts";
|
||||||
|
import { type AoDeps, type Exec, enqueueSweep, timeSlot, upsertDecision } from "./common.ts";
|
||||||
|
import { SWEEP_SLOT_MS } from "./downloads.ts";
|
||||||
|
import { isHoldReason, ROOT_OFFLINE } from "./organize-exec.ts";
|
||||||
|
import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts";
|
||||||
|
|
||||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||||
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
||||||
@@ -190,3 +194,160 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 整理巡检:卡在 running 的记录重新入队;Move 成功但下载任务没移除的补移除 ──
|
||||||
|
|
||||||
|
/** 记录 running 且没有活跃任务运行超过这么久,视为卡住 */
|
||||||
|
export const STALLED_RECORD_MS = 30 * 60_000;
|
||||||
|
/** 库根离线暂停的记录:每隔这么久重投一次查库根(不计入重投上限) */
|
||||||
|
export const ROOT_WAIT_RECHECK_MS = 5 * 60_000;
|
||||||
|
/** 巡检重投上限;用完仍卡住 → 未完成的文件记失败、记录收尾、开待处理 */
|
||||||
|
export const MAX_RECORD_REQUEUES = 3;
|
||||||
|
/** Move 后移除下载任务的补做窗口:收尾后稍等(避开收尾自己的移除),太久的不再管 */
|
||||||
|
const MOVE_REMOVE_GRACE_MS = 2 * 60_000;
|
||||||
|
const MOVE_REMOVE_WINDOW_MS = 7 * 24 * 3600_000;
|
||||||
|
|
||||||
|
/** 引擎里排队 / 执行中的 verifyAndImport 涉及的记录 id 与下载任务 id */
|
||||||
|
async function activeImportSubjects(d: AoDeps): Promise<Set<string>> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const status of ["pending", "running"] as const) {
|
||||||
|
for (const run of await d.ctx.engine.list({ taskName: "verifyAndImport", status, limit: 500 })) {
|
||||||
|
const input = (run.input ?? {}) as { recordId?: unknown; downloadTaskId?: unknown };
|
||||||
|
if (typeof input.recordId === "string") ids.add(input.recordId);
|
||||||
|
if (typeof input.downloadTaskId === "string") ids.add(input.downloadTaskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检整理记录(挂在"补提交与补入库"定时任务上):
|
||||||
|
* - running / queued、没有活跃的 verifyAndImport 运行、超过 STALLED_RECORD_MS 没动静(任务重试耗尽、
|
||||||
|
* planImport 提交后入队前进程退出)→ 以 record:<id> 重新入队(executeRecord 可重入:已完成文件跳过,
|
||||||
|
* 账本未出结果的先对账);重投超过上限 → 未完成文件记失败、记录收尾并开待处理;
|
||||||
|
* - 库根离线暂停的记录每 ROOT_WAIT_RECHECK_MS 重投一次,不计上限(库根恢复即继续);
|
||||||
|
* - Move 成功但下载任务未移除(移除 qB 任务失败)→ 再试移除。
|
||||||
|
*/
|
||||||
|
export async function recoverStalledRecords(d: AoDeps): Promise<{ requeued: number; failed: number; removed: number }> {
|
||||||
|
const db = d.ctx.db;
|
||||||
|
const now = d.ports.now();
|
||||||
|
const out = { requeued: 0, failed: 0, removed: 0 };
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select(["id", "download_task_id", "reason", "requeue_count", "requeued_at", "started_at", "created_at"])
|
||||||
|
.where("status", "in", ["running", "queued"])
|
||||||
|
.where(
|
||||||
|
sql<Date>`coalesce(requeued_at, started_at, created_at)`,
|
||||||
|
"<",
|
||||||
|
new Date(now.getTime() - Math.min(STALLED_RECORD_MS, ROOT_WAIT_RECHECK_MS)),
|
||||||
|
)
|
||||||
|
.orderBy("created_at")
|
||||||
|
.limit(20)
|
||||||
|
.execute();
|
||||||
|
const due = rows.filter((r) => {
|
||||||
|
const since = (r.requeued_at ?? r.started_at ?? r.created_at).getTime();
|
||||||
|
const wait = r.reason?.startsWith(`${ROOT_OFFLINE}:`) ? ROOT_WAIT_RECHECK_MS : STALLED_RECORD_MS;
|
||||||
|
return now.getTime() - since >= wait;
|
||||||
|
});
|
||||||
|
const active = due.length ? await activeImportSubjects(d) : new Set<string>();
|
||||||
|
const slot = timeSlot(now, SWEEP_SLOT_MS);
|
||||||
|
for (const r of due) {
|
||||||
|
if (active.has(r.id) || (r.download_task_id && active.has(r.download_task_id))) continue;
|
||||||
|
const rootWait = r.reason?.startsWith(`${ROOT_OFFLINE}:`) ?? false;
|
||||||
|
try {
|
||||||
|
if (!rootWait && r.requeue_count >= MAX_RECORD_REQUEUES) {
|
||||||
|
await failStalledRecord(d, r.id, r.requeue_count);
|
||||||
|
out.failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const q = await enqueueSweep(
|
||||||
|
d,
|
||||||
|
{
|
||||||
|
kind: "verifyAndImport",
|
||||||
|
idempotencyKey: `record:${r.id}`,
|
||||||
|
input: { recordId: r.id },
|
||||||
|
subjectType: "organization_record",
|
||||||
|
subjectId: r.id,
|
||||||
|
},
|
||||||
|
slot,
|
||||||
|
);
|
||||||
|
if (!q) continue;
|
||||||
|
await db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ requeued_at: now, ...(rootWait ? {} : { requeue_count: r.requeue_count + 1 }) })
|
||||||
|
.where("id", "=", r.id)
|
||||||
|
.execute();
|
||||||
|
out.requeued++;
|
||||||
|
} catch (err) {
|
||||||
|
d.ctx.logger.warn({ recordId: r.id, err: { message: errMessage(err) } }, "整理记录巡检处理失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move 成功、下载任务仍纳管未移除:补移除 qB 任务(同一下载任务只看最新一条记录)
|
||||||
|
const moved = await db
|
||||||
|
.selectFrom("organization_record as r")
|
||||||
|
.innerJoin("download_task as dt", "dt.id", "r.download_task_id")
|
||||||
|
.select(["dt.id", "dt.instance_id", "dt.torrent_hash"])
|
||||||
|
.where("r.action", "=", "move")
|
||||||
|
.where("r.status", "=", "succeeded")
|
||||||
|
.where("r.finished_at", "<", new Date(now.getTime() - MOVE_REMOVE_GRACE_MS))
|
||||||
|
.where("r.finished_at", ">", new Date(now.getTime() - MOVE_REMOVE_WINDOW_MS))
|
||||||
|
.where("dt.removed_at", "is", null)
|
||||||
|
.where("dt.managed", "=", true)
|
||||||
|
.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom("organization_record as r2")
|
||||||
|
.select("r2.id")
|
||||||
|
.whereRef("r2.download_task_id", "=", "r.download_task_id")
|
||||||
|
.whereRef("r2.created_at", ">", "r.created_at"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(20)
|
||||||
|
.execute();
|
||||||
|
for (const dt of moved) if (await removeMovedDownload(d, dt)) out.removed++;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重投用完仍卡住:未完成文件记失败,按已完成的文件收尾(登记资产、推进下载任务 / Slot),开待处理 */
|
||||||
|
async function failStalledRecord(d: AoDeps, recordId: string, requeues: number): Promise<void> {
|
||||||
|
const db = d.ctx.db;
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
const rec = await trx
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select(["id", "status", "reason", "download_task_id"])
|
||||||
|
.where("id", "=", recordId)
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!rec || (rec.status !== "running" && rec.status !== "queued")) return;
|
||||||
|
await trx
|
||||||
|
.updateTable("organization_file_result")
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
error: jsonb({ code: "STALLED", message: "整理多次重试仍未完成" }),
|
||||||
|
finished_at: d.ports.now(),
|
||||||
|
})
|
||||||
|
.where("organization_record_id", "=", recordId)
|
||||||
|
.where("status", "in", ["queued", "running"])
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ reason: "organize_stalled" })
|
||||||
|
.where("id", "=", recordId)
|
||||||
|
.execute();
|
||||||
|
await upsertDecision(d, trx, {
|
||||||
|
kind: "other",
|
||||||
|
title: "整理一直没完成,已停止",
|
||||||
|
summary: `重新排队 ${requeues} 次仍没有完成${
|
||||||
|
isHoldReason(rec.reason) ? `(${rec.reason})` : ""
|
||||||
|
};已完成的文件照常登记,其余请检查后重新整理`,
|
||||||
|
subjectType: "organization_record",
|
||||||
|
subjectId: recordId,
|
||||||
|
dedupeKey: `organize_stalled:${recordId}`,
|
||||||
|
evidence: { requeues, previousReason: rec.reason, downloadTaskId: rec.download_task_id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await finalizeRecord(d, recordId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { rm, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain";
|
||||||
|
import { toParsedColumns } from "@umt/mapping";
|
||||||
|
import type { TaskRunView } from "@umt/tasks";
|
||||||
|
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||||
|
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||||
|
import {
|
||||||
|
MAX_RECORD_REQUEUES,
|
||||||
|
recoverStalledRecords,
|
||||||
|
} from "../../src/modules/acquisition-organize/services/organize-records.ts";
|
||||||
|
import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
|
// 整理卡死的回收(docs/plan/system-review.md T1-2):running 记录巡检重投 / 重投上限转失败;
|
||||||
|
// 库根离线不耗尽重试、恢复后推进;被排除的待确认视频让记录 partial(Move 不删 qB 任务);
|
||||||
|
// Move 后移除 qB 任务失败可重试。
|
||||||
|
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
const SIZE = 300_000;
|
||||||
|
const MIN = 60_000;
|
||||||
|
|
||||||
|
// 已完结:S1 = 12、S2 = 13(`- 25` → S02E13,`- 40` 超出总集数 → 待确认)
|
||||||
|
const TABLE = {
|
||||||
|
seasons: [
|
||||||
|
{ season: 1, total: 12 },
|
||||||
|
{ season: 2, total: 13 },
|
||||||
|
],
|
||||||
|
latestSeason: 2,
|
||||||
|
ended: true,
|
||||||
|
};
|
||||||
|
const ani = (ep: number) => `[ANi] Recovery Show - ${ep} [1080P].mkv`;
|
||||||
|
|
||||||
|
describe.skipIf(!hasPg)("整理卡死回收(真实 PG + mock qB + 临时目录)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let qb: MockQb;
|
||||||
|
let lib: TempTree;
|
||||||
|
let cache: TempTree;
|
||||||
|
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||||
|
let n = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||||
|
lib = await createTempTree({}, { sentinel: true, prefix: "umt-reclib-" });
|
||||||
|
cache = await createTempTree(
|
||||||
|
{
|
||||||
|
"stall/Stall.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
|
||||||
|
"cap/Cap.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
|
||||||
|
"offline/Offline.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
|
||||||
|
},
|
||||||
|
{ prefix: "umt-reccache-" },
|
||||||
|
);
|
||||||
|
h = await createHarness("recovery");
|
||||||
|
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
await qb?.close();
|
||||||
|
for (const t of [lib, cache]) await t?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = (id: string) =>
|
||||||
|
h.db.selectFrom("organization_record").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||||
|
const ago = (ms: number) => new Date(Date.now() - ms);
|
||||||
|
const decision = (dedupeKey: string) =>
|
||||||
|
h.db.selectFrom("decision").selectAll().where("dedupe_key", "=", dedupeKey).executeTakeFirst();
|
||||||
|
|
||||||
|
/** 手动按路径整理(Copy),只提交记录、不入队:模拟 planImport 提交后入队前进程退出 */
|
||||||
|
async function manualRecord(dir: string, title: string) {
|
||||||
|
const entityId = await seedEntity(h.db, { kind: "series", title, year: 2024 });
|
||||||
|
const r = await planImport(h.d, {
|
||||||
|
sourcePaths: [cache.path(dir)],
|
||||||
|
mediaEntityId: entityId,
|
||||||
|
action: "copy",
|
||||||
|
trigger: "manual",
|
||||||
|
actorType: "user",
|
||||||
|
});
|
||||||
|
expect(r.status, JSON.stringify(r)).toBe("running");
|
||||||
|
return r.recordId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** qB 里已完成的下载任务(接管进来,文件落盘),关联到资源 */
|
||||||
|
async function completedTask(releaseId: string, name: string, files: string[]) {
|
||||||
|
n++;
|
||||||
|
const hash = n.toString(16).padStart(40, "c");
|
||||||
|
qb.seed({ hash, name, tags: ["umt"], save_path: cache.root });
|
||||||
|
await qb.complete(hash, { files: files.map((f) => ({ path: `${name}/${f}`, size: SIZE })), writeFiles: true });
|
||||||
|
await syncDownloads(h.d);
|
||||||
|
const dt = await h.db
|
||||||
|
.selectFrom("download_task")
|
||||||
|
.select("id")
|
||||||
|
.where("torrent_hash", "=", hash)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db.updateTable("download_task").set({ source_release_id: releaseId }).where("id", "=", dt.id).execute();
|
||||||
|
return { dtId: dt.id, hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已识别资源(规范季集按季表判定) */
|
||||||
|
async function release(entityId: string, title: string) {
|
||||||
|
const raw = parseRelease(title);
|
||||||
|
const resolution: EpisodeResolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] });
|
||||||
|
const cols = toParsedColumns(canonicalParsed(raw, resolution));
|
||||||
|
const t = makeTorrent({ name: title, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
|
||||||
|
return (
|
||||||
|
await h.db
|
||||||
|
.insertInto("source_release")
|
||||||
|
.values({
|
||||||
|
site_id: base.siteId,
|
||||||
|
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
|
||||||
|
title,
|
||||||
|
size_bytes: SIZE,
|
||||||
|
infohash_v1: t.hash,
|
||||||
|
media_entity_id: entityId,
|
||||||
|
resolution_status: "resolved",
|
||||||
|
parsed: JSON.stringify(cols.parsed),
|
||||||
|
parser_version: cols.parserVersion,
|
||||||
|
episode_resolution: JSON.stringify(resolution),
|
||||||
|
raw: JSON.stringify({ _site: { hr: false } }),
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function series(title: string) {
|
||||||
|
const id = await seedEntity(h.db, { kind: "series", title, year: 2023 });
|
||||||
|
await h.db
|
||||||
|
.updateTable("media_entity")
|
||||||
|
.set({ metadata: JSON.stringify({ airStatus: { value: "Ended", source: "tmdb" } }) })
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
|
for (const s of TABLE.seasons) {
|
||||||
|
await h.db
|
||||||
|
.insertInto("content_unit")
|
||||||
|
.values({
|
||||||
|
media_entity_id: id,
|
||||||
|
kind: "season",
|
||||||
|
season_number: s.season,
|
||||||
|
episode_total: s.total,
|
||||||
|
status: "confirmed",
|
||||||
|
source: "tmdb",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("记录卡在 running 且没有活跃运行:巡检以 record:<id> 重投,执行后完成", async () => {
|
||||||
|
const id = await manualRecord("stall", "卡住的剧");
|
||||||
|
// 刚提交:还没到判定时间
|
||||||
|
expect((await recoverStalledRecords(h.d)).requeued).toBe(0);
|
||||||
|
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ started_at: ago(31 * MIN) })
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
|
// 引擎里还有这条记录的运行(排队 / 退避中):不重投
|
||||||
|
const list = h.engine.list;
|
||||||
|
h.engine.list = async (f) =>
|
||||||
|
f?.status === "pending" ? [{ runId: "r1", input: { recordId: id } } as unknown as TaskRunView] : [];
|
||||||
|
expect((await recoverStalledRecords(h.d)).requeued).toBe(0);
|
||||||
|
h.engine.list = list;
|
||||||
|
|
||||||
|
const before = h.engine.enqueued.length;
|
||||||
|
expect((await recoverStalledRecords(h.d)).requeued).toBe(1);
|
||||||
|
const q = h.engine.enqueued.slice(before);
|
||||||
|
expect(q).toHaveLength(1);
|
||||||
|
expect(q[0]).toMatchObject({ task: "verifyAndImport", input: { recordId: id } });
|
||||||
|
expect(q[0]!.key).toMatch(new RegExp(`^verifyAndImport:record:${id}:`));
|
||||||
|
const rec = await record(id);
|
||||||
|
expect(rec.requeue_count).toBe(1);
|
||||||
|
expect(rec.requeued_at).not.toBeNull();
|
||||||
|
// 刚重投:等下一个判定窗口
|
||||||
|
expect((await recoverStalledRecords(h.d)).requeued).toBe(0);
|
||||||
|
|
||||||
|
// 模拟 worker 执行重投的任务:可重入,正常完成
|
||||||
|
const done = await executeRecord(h.d, id);
|
||||||
|
expect(done.status).toBe("succeeded");
|
||||||
|
expect((await record(id)).status).toBe("succeeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("重投超过上限仍卡住:未完成文件记失败、记录失败并开待处理", async () => {
|
||||||
|
const id = await manualRecord("cap", "重投耗尽的剧");
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ started_at: ago(3 * 3600_000), requeued_at: ago(31 * MIN), requeue_count: MAX_RECORD_REQUEUES })
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
|
const before = h.engine.enqueued.length;
|
||||||
|
const r = await recoverStalledRecords(h.d);
|
||||||
|
expect(r.failed).toBe(1);
|
||||||
|
expect(h.engine.enqueued.length).toBe(before);
|
||||||
|
const rec = await record(id);
|
||||||
|
expect(rec).toMatchObject({ status: "failed", reason: "organize_stalled" });
|
||||||
|
const files = await h.db
|
||||||
|
.selectFrom("organization_file_result")
|
||||||
|
.select(["status", "error"])
|
||||||
|
.where("organization_record_id", "=", id)
|
||||||
|
.where("role", "=", "main")
|
||||||
|
.execute();
|
||||||
|
expect(files[0]).toMatchObject({ status: "failed", error: { code: "STALLED" } });
|
||||||
|
expect(await decision(`organize_stalled:${id}`)).toMatchObject({ status: "open", subject_id: id });
|
||||||
|
expect((await recoverStalledRecords(h.d)).failed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("库根离线:不抛错耗尽重试,记录等待并开待处理;恢复后巡检重投(不计上限)并完成", async () => {
|
||||||
|
const id = await manualRecord("offline", "离线的剧");
|
||||||
|
await rm(join(lib.root, ".uhd-root"));
|
||||||
|
const r = await executeRecord(h.d, id);
|
||||||
|
expect(r.status).toBe("running");
|
||||||
|
let rec = await record(id);
|
||||||
|
expect(rec.status).toBe("running");
|
||||||
|
expect(rec.reason).toMatch(/^library_root_offline:/);
|
||||||
|
expect(await decision(`library_root_offline:${base.rootId}`)).toMatchObject({ status: "open", kind: "other" });
|
||||||
|
// 再执行一次(任务重放)也只是等待
|
||||||
|
expect((await executeRecord(h.d, id)).status).toBe("running");
|
||||||
|
|
||||||
|
// 离线等待的记录按较短间隔重投,不计入上限
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ started_at: ago(6 * MIN) })
|
||||||
|
.where("id", "=", id)
|
||||||
|
.execute();
|
||||||
|
expect((await recoverStalledRecords(h.d)).requeued).toBe(1);
|
||||||
|
rec = await record(id);
|
||||||
|
expect(rec.requeue_count).toBe(0);
|
||||||
|
|
||||||
|
await writeFile(join(lib.root, ".uhd-root"), "sentinel\n");
|
||||||
|
const done = await executeRecord(h.d, id);
|
||||||
|
expect(done.status).toBe("succeeded");
|
||||||
|
rec = await record(id);
|
||||||
|
expect(rec).toMatchObject({ status: "succeeded", reason: null });
|
||||||
|
expect((await decision(`library_root_offline:${base.rootId}`))?.status).toBe("resolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("季包里一个视频集数待确认被排除:记录 partial,Move 不移除 qB 任务,开待处理", async () => {
|
||||||
|
await h.setSettings("organize", { defaultMode: "move" });
|
||||||
|
try {
|
||||||
|
const entityId = await series("排除视频的剧");
|
||||||
|
const relId = await release(entityId, "[ANi] Recovery Show - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]");
|
||||||
|
const name = "[ANi] Recovery Show S2 Pack";
|
||||||
|
const { dtId, hash } = await completedTask(relId, name, [ani(25), ani(40)]);
|
||||||
|
const plan = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
|
||||||
|
expect(plan.status, JSON.stringify(plan)).toBe("running");
|
||||||
|
expect((await record(plan.recordId!)).action).toBe("move");
|
||||||
|
const ex = await executeRecord(h.d, plan.recordId!);
|
||||||
|
expect(ex.status).toBe("partial");
|
||||||
|
const rec = await record(plan.recordId!);
|
||||||
|
expect(rec).toMatchObject({ status: "partial", reason: "excluded_unresolved", file_failed: 0 });
|
||||||
|
const dt = await h.db.selectFrom("download_task").selectAll().where("id", "=", dtId).executeTakeFirstOrThrow();
|
||||||
|
expect(dt).toMatchObject({ removed_at: null, managed: true, status: "imported" });
|
||||||
|
expect(qb.get(hash)).toBeDefined();
|
||||||
|
expect(qb.callsTo("torrents/delete")).toHaveLength(0);
|
||||||
|
// 被排除的视频留在下载目录
|
||||||
|
expect(existsSync(join(cache.root, name, ani(40)))).toBe(true);
|
||||||
|
expect(existsSync(join(cache.root, name, ani(25)))).toBe(false);
|
||||||
|
const dec = await decision(`organize_excluded:${plan.recordId}`);
|
||||||
|
expect(dec).toMatchObject({ status: "open", subject_id: plan.recordId });
|
||||||
|
expect(JSON.stringify(dec!.evidence)).toContain("excluded:episode_unresolved");
|
||||||
|
// partial 不在补移除范围内
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ finished_at: ago(10 * MIN) })
|
||||||
|
.where("id", "=", plan.recordId!)
|
||||||
|
.execute();
|
||||||
|
expect((await recoverStalledRecords(h.d)).removed).toBe(0);
|
||||||
|
expect(qb.get(hash)).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
await h.setSettings("organize", { defaultMode: "copy" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Move 后移除 qB 任务失败:重放已结束记录与巡检都会再试,成功后结束纳管", async () => {
|
||||||
|
await h.setSettings("organize", { defaultMode: "move" });
|
||||||
|
try {
|
||||||
|
const entityId = await series("移除失败的剧");
|
||||||
|
const mk = async (ep: number) => {
|
||||||
|
const relId = await release(entityId, `[ANi] Recovery Show - ${ep} [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]`);
|
||||||
|
return completedTask(relId, `[ANi] Recovery Show - ${ep}`, [ani(ep)]);
|
||||||
|
};
|
||||||
|
const removed = async (dtId: string) =>
|
||||||
|
(await h.db.selectFrom("download_task").selectAll().where("id", "=", dtId).executeTakeFirstOrThrow())
|
||||||
|
.removed_at !== null;
|
||||||
|
|
||||||
|
// ① 重放已结束记录时补移除
|
||||||
|
const a = await mk(14);
|
||||||
|
const pa = await planImport(h.d, { downloadTaskId: a.dtId, trigger: "auto", actorType: "system" });
|
||||||
|
expect(pa.status, JSON.stringify(pa)).toBe("running");
|
||||||
|
qb.fault("torrents/delete", { status: 500, times: 20 });
|
||||||
|
expect((await executeRecord(h.d, pa.recordId!)).status).toBe("succeeded");
|
||||||
|
expect(await removed(a.dtId)).toBe(false);
|
||||||
|
expect(qb.get(a.hash)).toBeDefined();
|
||||||
|
qb.clearFaults();
|
||||||
|
await executeRecord(h.d, pa.recordId!);
|
||||||
|
expect(await removed(a.dtId)).toBe(true);
|
||||||
|
expect(qb.get(a.hash)).toBeUndefined();
|
||||||
|
|
||||||
|
// ② 巡检补移除(收尾后留出宽限期)
|
||||||
|
const b = await mk(15);
|
||||||
|
const pb = await planImport(h.d, { downloadTaskId: b.dtId, trigger: "auto", actorType: "system" });
|
||||||
|
qb.fault("torrents/delete", { status: 500, times: 20 });
|
||||||
|
expect((await executeRecord(h.d, pb.recordId!)).status).toBe("succeeded");
|
||||||
|
expect(await removed(b.dtId)).toBe(false);
|
||||||
|
qb.clearFaults();
|
||||||
|
expect((await recoverStalledRecords(h.d)).removed).toBe(0);
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ finished_at: ago(3 * MIN) })
|
||||||
|
.where("id", "=", pb.recordId!)
|
||||||
|
.execute();
|
||||||
|
expect((await recoverStalledRecords(h.d)).removed).toBe(1);
|
||||||
|
expect(await removed(b.dtId)).toBe(true);
|
||||||
|
expect(qb.get(b.hash)).toBeUndefined();
|
||||||
|
const dt = await h.db.selectFrom("download_task").selectAll().where("id", "=", b.dtId).executeTakeFirstOrThrow();
|
||||||
|
expect(dt.managed).toBe(false);
|
||||||
|
} finally {
|
||||||
|
qb.clearFaults();
|
||||||
|
await h.setSettings("organize", { defaultMode: "copy" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,6 +38,9 @@ const REASONS: Record<string, ReasonText> = {
|
|||||||
entity_not_found: { text: "作品已不存在" },
|
entity_not_found: { text: "作品已不存在" },
|
||||||
no_library_root: { text: "没有可用的媒体库目录", action: "在设置 → 媒体库与目录里添加" },
|
no_library_root: { text: "没有可用的媒体库目录", action: "在设置 → 媒体库与目录里添加" },
|
||||||
library_root_offline: { text: "媒体库目录不可访问或未挂载", action: "检查挂载后重试" },
|
library_root_offline: { text: "媒体库目录不可访问或未挂载", action: "检查挂载后重试" },
|
||||||
|
target_dir_unavailable: { text: "整理目标目录建不出来", action: "检查目录权限或挂载,恢复后自动重试" },
|
||||||
|
organize_stalled: { text: "整理多次重试仍未完成", action: "检查后重新整理" },
|
||||||
|
excluded_unresolved: { text: "有视频没能确定季集或目标目录,留在原处", action: "确认季集后重新整理" },
|
||||||
target_dir_outside_root: { text: "目标目录不在媒体库目录里" },
|
target_dir_outside_root: { text: "目标目录不在媒体库目录里" },
|
||||||
slot_entity_mismatch: { text: "收录版本和作品对不上" },
|
slot_entity_mismatch: { text: "收录版本和作品对不上" },
|
||||||
binding_on_other_root: { text: "作品已放在另一个媒体库目录", action: "在作品详情里调整目录" },
|
binding_on_other_root: { text: "作品已放在另一个媒体库目录", action: "在作品详情里调整目录" },
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
|
|||||||
Observation: { row: C.Observation },
|
Observation: { row: C.Observation },
|
||||||
OrganizationFileResult: { row: C.OrganizationFileResult },
|
OrganizationFileResult: { row: C.OrganizationFileResult },
|
||||||
OrganizeSourceSnapshot: { row: C.OrganizeSourceSnapshotRow },
|
OrganizeSourceSnapshot: { row: C.OrganizeSourceSnapshotRow },
|
||||||
OrganizationRecord: { row: C.OrganizationRecord, dbOnly: ["changeXid"] },
|
// requeue_count / requeued_at:整理巡检的内部计数,不外露
|
||||||
|
OrganizationRecord: { row: C.OrganizationRecord, dbOnly: ["changeXid", "requeueCount", "requeuedAt"] },
|
||||||
OutboxEvent: { row: C.OutboxEvent },
|
OutboxEvent: { row: C.OutboxEvent },
|
||||||
PathBinding: { row: C.PathBinding },
|
PathBinding: { row: C.PathBinding },
|
||||||
PathMapping: { row: C.PathMapping },
|
PathMapping: { row: C.PathMapping },
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import { execDdl } from "../src/migration-helpers.ts";
|
||||||
|
|
||||||
|
// 整理记录卡在 running 的巡检重投(docs/plan/system-review.md T1-2):
|
||||||
|
// - requeue_count:巡检重新入队的次数,超过上限转失败并开待处理
|
||||||
|
// - requeued_at:最近一次巡检重投时间,下一次判定"卡住"从这里起算
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await execDdl(
|
||||||
|
db,
|
||||||
|
`alter table organization_record add column if not exists requeue_count integer not null default 0;
|
||||||
|
alter table organization_record add column if not exists requeued_at timestamptz;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await execDdl(
|
||||||
|
db,
|
||||||
|
`alter table organization_record drop column if exists requeued_at;
|
||||||
|
alter table organization_record drop column if exists requeue_count;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1081,6 +1081,8 @@ export interface OrganizationRecordTable {
|
|||||||
change_xid: Generated<string>;
|
change_xid: Generated<string>;
|
||||||
created_at: GeneratedTimestamptz;
|
created_at: GeneratedTimestamptz;
|
||||||
updated_at: GeneratedTimestamptz;
|
updated_at: GeneratedTimestamptz;
|
||||||
|
requeue_count: Generated<number>;
|
||||||
|
requeued_at: Timestamptz | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizeSourceSnapshotTable {
|
export interface OrganizeSourceSnapshotTable {
|
||||||
|
|||||||
@@ -142,16 +142,38 @@ export function organizePlanToFileResults(
|
|||||||
return [...planned, ...excluded];
|
return [...planned, ...excluded];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 逐文件状态 → 整理记录状态(全部完成后调用;仍有 queued/running 时为 running) */
|
/**
|
||||||
export function organizationStatusFromFiles(statuses: readonly FileResultStatus[]): OrganizationStatus {
|
* 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段。文件留在原处没人管,
|
||||||
|
* 不能算整理成功(Move 成功后会移除下载任务)。样例、过小、已在库等预期跳过不在此列。
|
||||||
|
*/
|
||||||
|
export const UNRESOLVED_EXCLUSIONS = ["episode_unresolved", "no_target_dir", "naming_failed"] as const;
|
||||||
|
const UNRESOLVED_SKIP = new Set<string>(UNRESOLVED_EXCLUSIONS.map((r) => `excluded:${r}`));
|
||||||
|
|
||||||
|
/** 文件结果的 skip_reason 是否属于"本该入库却被排除" */
|
||||||
|
export const isUnresolvedExclusion = (skipReason: string | null | undefined): boolean =>
|
||||||
|
!!skipReason && UNRESOLVED_SKIP.has(skipReason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐文件状态 → 整理记录状态(全部完成后调用;仍有 queued/running 时为 running)。
|
||||||
|
* unresolved:被排除的待确认视频数(isUnresolvedExclusion),有则成功降为 partial。
|
||||||
|
*/
|
||||||
|
export function organizationStatusFromFiles(
|
||||||
|
statuses: readonly FileResultStatus[],
|
||||||
|
opts: { unresolved?: number } = {},
|
||||||
|
): OrganizationStatus {
|
||||||
if (statuses.some((s) => s === "queued" || s === "running")) return "running";
|
if (statuses.some((s) => s === "queued" || s === "running")) return "running";
|
||||||
const done = statuses.filter((s) => s !== "skipped");
|
const done = statuses.filter((s) => s !== "skipped");
|
||||||
// 全部跳过(或没有文件)视为成功:没有需要做的事
|
|
||||||
if (done.length === 0) return "succeeded";
|
|
||||||
const ok = done.filter((s) => s === "succeeded").length;
|
const ok = done.filter((s) => s === "succeeded").length;
|
||||||
if (ok === done.length) return "succeeded";
|
// 全部跳过(或没有文件)视为成功:没有需要做的事
|
||||||
if (done.every((s) => s === "cancelled")) return "cancelled";
|
const base: OrganizationStatus =
|
||||||
return ok > 0 ? "partial" : "failed";
|
done.length === 0 || ok === done.length
|
||||||
|
? "succeeded"
|
||||||
|
: done.every((s) => s === "cancelled")
|
||||||
|
? "cancelled"
|
||||||
|
: ok > 0
|
||||||
|
? "partial"
|
||||||
|
: "failed";
|
||||||
|
return base === "succeeded" && (opts.unresolved ?? 0) > 0 ? "partial" : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 整理计划 → organize.plan 能力输出(planId / payloadHash 由保存计划的一方给出) */
|
/** 整理计划 → organize.plan 能力输出(planId / payloadHash 由保存计划的一方给出) */
|
||||||
|
|||||||
@@ -177,6 +177,16 @@ describe("整理计划 → 整理记录", () => {
|
|||||||
expect(M.organizationStatusFromFiles(["succeeded", "running"])).toBe("running");
|
expect(M.organizationStatusFromFiles(["succeeded", "running"])).toBe("running");
|
||||||
expect(M.organizationStatusFromFiles(["cancelled"])).toBe("cancelled");
|
expect(M.organizationStatusFromFiles(["cancelled"])).toBe("cancelled");
|
||||||
expect(M.organizationStatusFromFiles([])).toBe("succeeded");
|
expect(M.organizationStatusFromFiles([])).toBe("succeeded");
|
||||||
|
// 被排除的待确认视频(季集 / 季目录 / 命名)让成功降为 partial;预期跳过不影响
|
||||||
|
expect(M.organizationStatusFromFiles(["succeeded", "skipped"], { unresolved: 1 })).toBe("partial");
|
||||||
|
expect(M.organizationStatusFromFiles(["succeeded", "failed"], { unresolved: 1 })).toBe("partial");
|
||||||
|
expect(M.organizationStatusFromFiles(["failed", "skipped"], { unresolved: 1 })).toBe("failed");
|
||||||
|
expect(M.isUnresolvedExclusion("excluded:episode_unresolved")).toBe(true);
|
||||||
|
expect(M.isUnresolvedExclusion("excluded:no_target_dir")).toBe(true);
|
||||||
|
expect(M.isUnresolvedExclusion("excluded:naming_failed")).toBe(true);
|
||||||
|
expect(M.isUnresolvedExclusion("excluded:too_small")).toBe(false);
|
||||||
|
expect(M.isUnresolvedExclusion("already_in_library")).toBe(false);
|
||||||
|
expect(M.isUnresolvedExclusion(null)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user