fix(organize): recover legacy requests without dropping one-off intent

This commit is contained in:
root committed 2026-10-13 20:38:30 +08:00
1 parent ee0d62ae6f
commit 407cb7eb36
6 files changed
+616 -32

No files matched your search

@@ -5,6 +5,7 @@ import { type AoDeps, type Exec, enqueueSweep, timeSlot, upsertDecision } from "
import { SWEEP_SLOT_MS } from "./downloads.ts";
import { isHoldReason, ROOT_OFFLINE } from "./organize-exec.ts";
import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts";
import { resolveOrganizeRecovery } from "./organize-retry.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
@@ -203,7 +204,10 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
message: (err as { message?: string }).message ?? JSON.stringify(err).slice(0, 300),
}
: null;
return rows.map((r) => {
const recoveries = includeFiles
? await Promise.all(rows.map(async (r) => (await resolveOrganizeRecovery(exec, r)).recovery))
: [];
return rows.map((r, index) => {
const e = entities.find((x) => x.id === r.media_entity_id);
const u = units.find((x) => x.id === r.content_unit_id);
const targetRef = {
@@ -213,6 +217,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
libraryRootId: r.target_library_root_id,
};
return {
...(includeFiles ? { recovery: recoveries[index] } : {}),
id: r.id,
taskId: r.task_id,
downloadTaskId: r.download_task_id,
@@ -1,10 +1,12 @@
import { randomUUID } from "node:crypto";
import { dirname } from "node:path";
import { OrganizationRecovery, type OrganizationRecovery as Recovery } from "@umt/contracts";
import { type OrganizationRecordRow, sql } from "@umt/db";
import type { DecisionHookActor } from "../../decision-hooks.ts";
import { type AoDeps, ensureTaskRow, markTask } from "./common.ts";
import { type AoDeps, type Exec, ensureTaskRow, hostPathOf, loadInstances, markTask } from "./common.ts";
import { sourceGuard } from "./organize-guard.ts";
import { planImport } from "./organize-plan.ts";
import type { ImportRequest, StoredImportRequest } from "./organize-types.ts";
import type { FrozenPlan, ImportRequest, StoredImportRequest } from "./organize-types.ts";
/** 只保留业务参数;路径守卫与任务身份在恢复时重新装配。 */
export function storedImportRequest(req: ImportRequest): StoredImportRequest {
@@ -30,6 +32,137 @@ export function organizeRequestFromRecord(
return null;
}
/** 先找完整请求;冻结结果只提供已知事实,未知的一次性设置由用户确认。 */
export async function resolveOrganizeRecovery(db: Exec, record: OrganizationRecordRow) {
const snapshot = organizeRequestFromRecord(record);
if (snapshot) return withDownloadSource(db, recoveryResult(snapshot, "request", []));
const task = record.task_id
? await db.selectFrom("task").select(["kind", "input"]).where("id", "=", record.task_id).executeTakeFirst()
: undefined;
const input = task?.input as (Partial<ImportRequest> & { planId?: string; recordId?: string }) | undefined;
// 文件向导的老 task.input 只有分组基本参数;planId 表示缺少完整一次性设置,不能当完整请求。
if (
task?.kind === "verifyAndImport" &&
input &&
!input.planId &&
!input.recordId &&
(input.downloadTaskId || input.sourcePaths?.length)
) {
const request = storedImportRequest({ ...input, trigger: "manual", actorType: "system" });
return withDownloadSource(db, recoveryResult(request, "task", []));
}
const plan = record.plan as unknown as Partial<FrozenPlan>;
const request: StoredImportRequest = {};
const missing: Recovery["missing"] = ["options"];
if (record.download_task_id) request.downloadTaskId = record.download_task_id;
if (record.media_entity_id ?? plan.entityId) request.mediaEntityId = record.media_entity_id ?? plan.entityId;
if (record.content_unit_id) request.contentUnitId = record.content_unit_id;
if (["copy", "move", "in_place"].includes(record.action)) request.action = record.action as ImportRequest["action"];
if (record.target_library_root_id ?? plan.libraryRootId)
request.targetLibraryRootId = record.target_library_root_id ?? plan.libraryRootId;
if (plan.sourceConfigId) request.sourceConfigId = plan.sourceConfigId;
if (plan.slotId) request.slotId = plan.slotId;
if (plan.naming?.mode) request.namingMode = plan.naming.mode;
if (plan.scrapeAfterImport !== undefined) request.scrapeAfterImport = plan.scrapeAfterImport;
if (plan.replacement) {
request.importMode = "replace";
request.replacementAssetIds = plan.replacement.assets.map((a) => a.assetId);
}
const paths = [
...(plan.organize?.files.filter((f) => f.role === "main").map((f) => f.src) ?? []),
...(plan.organize?.excluded.filter((f) => f.kind === "video").map((f) => f.src) ?? []),
];
if (!paths.length) {
const files = await db
.selectFrom("organization_file_result")
.select("source_path")
.where("organization_record_id", "=", record.id)
.where("role", "=", "main")
.execute();
paths.push(...files.map((f) => f.source_path));
}
if (!paths.length && input?.sourcePaths?.length) paths.push(...input.sourcePaths);
if (!paths.length && record.source_path && record.source_path_view === "local") paths.push(record.source_path);
if (!paths.length && record.download_task_id) {
const download = await db
.selectFrom("download_task")
.selectAll()
.where("id", "=", record.download_task_id)
.executeTakeFirst();
if (download?.content_path) {
const [topology] = await loadInstances(db, { enabledOnly: false, ids: [download.instance_id] });
const path = topology ? hostPathOf(topology, download.content_path) : null;
if (path) paths.push(path);
}
}
if (!paths.length) {
const decisions = await db
.selectFrom("decision")
.select("evidence")
.where("subject_type", "=", "organization_record")
.where("subject_id", "=", record.id)
.execute();
for (const decision of decisions) {
const evidence = decision.evidence as { sourcePaths?: string[]; mainFile?: string } | null;
if (evidence?.sourcePaths?.length) paths.push(...evidence.sourcePaths);
else if (evidence?.mainFile?.startsWith("/")) paths.push(evidence.mainFile);
}
}
if (paths.length) request.sourcePaths = [...new Set(paths)];
if (!request.downloadTaskId && !request.sourcePaths?.length) missing.push("sourcePaths");
// 实际目标目录可以预填,但不把它冒充原来的 targetDir 选项;确认后才按这次目录执行。
const dirs = new Set(plan.organize?.files.filter((f) => f.role === "main").map((f) => dirname(f.dst)) ?? []);
if (dirs.size === 1) request.targetDir = [...dirs][0];
if (plan.episodeStructure) {
request.episodeStructure = {
groupId: plan.episodeStructure.groupId,
layout: null,
appliedToEntity: plan.episodeStructure.appliedToEntity,
};
if (plan.episodeStructure.temporary) missing.push("episodeStructure");
}
for (const [path, facts] of Object.entries(plan.facts ?? {})) {
if (!facts.episodeResolution && (facts.season !== undefined || facts.episodes || facts.unitIds?.length)) {
request.fileOverrides ??= {};
request.fileOverrides[path] = { season: facts.season, episodes: facts.episodes, contentUnitIds: facts.unitIds };
}
}
return recoveryResult(request, plan.organize ? "plan" : "record", missing);
}
/** 下载任务原请求不增加 sourcePaths,避免改变下载器的选中文件语义;向导只预填当前已知宿主来源。 */
async function withDownloadSource(db: Exec, resolved: ReturnType<typeof recoveryResult>) {
const request = resolved.recovery.request;
if (!request.sourcePaths?.length && request.downloadTaskId) {
const download = await db
.selectFrom("download_task")
.selectAll()
.where("id", "=", request.downloadTaskId)
.executeTakeFirst();
if (download?.content_path) {
const [topology] = await loadInstances(db, { enabledOnly: false, ids: [download.instance_id] });
const path = topology ? hostPathOf(topology, download.content_path) : null;
if (path) request.sourcePaths = [path];
}
}
return resolved;
}
function recoveryResult(request: StoredImportRequest, source: Recovery["source"], missing: Recovery["missing"]) {
const recovery = OrganizationRecovery.parse({ ready: !missing.length, source, missing, request });
return { recovery, request };
}
export class OrganizeRecoveryError extends Error {
readonly code = "conflict";
constructor(readonly recovery: Recovery) {
const labels = { sourcePaths: "来源文件", options: "本次整理设置", episodeStructure: "季集结构" };
super(
`历史整理缺少${recovery.missing.map((key) => labels[key]).join("、")},已保留可用信息,请在整理向导补齐后继续`,
);
}
}
interface RetryOptions {
mediaEntityId?: string;
idempotencyKey?: string;
@@ -80,8 +213,17 @@ async function retryLocked(
if (existing?.task_id)
return { recordId: existing.id, taskId: existing.task_id, status: existing.status, reason: existing.reason };
}
const request = organizeRequestFromRecord(record);
if (!request) throw new Error("整理记录没有可恢复的来源,重新选择来源后整理");
const resolved = await resolveOrganizeRecovery(d.ctx.db, record);
if (!resolved.recovery.ready) throw new OrganizeRecoveryError(resolved.recovery);
const request = resolved.request;
if (overrides.mediaEntityId && overrides.mediaEntityId !== request.mediaEntityId) {
delete request.contentUnitId;
delete request.episodeStructure;
delete request.slotId;
delete request.fileOverrides;
delete request.importMode;
delete request.replacementAssetIds;
}
if (overrides.sourcePaths) {
const files = await d.ctx.db
.selectFrom("organization_file_result")
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { join, relative } from "node:path";
import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts";
import { overrideFor } from "@umt/domain";
import type { Principal } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import { vlstat as stat } from "../../storage/vfs.ts";
@@ -14,6 +15,8 @@ import {
requestKey,
sourceGuard,
} from "../acquisition-organize/services/organize-guard.ts";
import { walk } from "../acquisition-organize/services/organize-plan.ts";
import { resolveOrganizeRecovery } from "../acquisition-organize/services/organize-retry.ts";
import { organizeStructure, setEpisodeGroup } from "../sources-identity/service/episode-groups.ts";
import type { Layout } from "../sources-identity/service/episode-structure.ts";
import { entityFromCandidate, externalKey } from "./candidate.ts";
@@ -21,6 +24,7 @@ import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resol
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { isWritableTarget } from "./list.ts";
import { entitiesUnder } from "./records.ts";
import { recordSources } from "./tree.ts";
// 整理向导落地:按 (mediaEntityId, contentUnitId, 操作, 目标目录, Slot) 分组,每组走 planImport(与 POST /organization-records
// 同一实现,同格式整理记录)。逐项季/集/单元覆写以 fileOverrides 传入 planImport(计划前后路径即反映覆写),
@@ -193,6 +197,42 @@ export async function organizeFiles(
const db = d.ctx.db;
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
const actor = actorOfP(principal);
const recoveryRecord = body.recoveryRecordId
? await db.selectFrom("organization_record").selectAll().where("id", "=", body.recoveryRecordId).executeTakeFirst()
: undefined;
if (body.recoveryRecordId && !recoveryRecord) throw new FilesError("not_found", "原整理记录不存在");
const recovered = recoveryRecord ? await resolveOrganizeRecovery(db, recoveryRecord) : undefined;
const recoveryPaths = new Set<string>();
const recoveryDirectories: string[] = [];
const movedOverrides: Record<string, FileOverrideInput> = {};
const recoverySources = recoveryRecord ? await recordSources(d, recoveryRecord.id) : undefined;
if (recoveryRecord) {
const results = await db
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", recoveryRecord.id)
.execute();
const roots = await db.selectFrom("library_root").select(["id", "local_path"]).execute();
for (const file of results) {
recoveryPaths.add(file.source_path);
const root = roots.find((r) => r.id === file.target_library_root_id);
if (root && file.target_path && file.status === "succeeded") {
const target = join(root.local_path, file.target_path);
recoveryPaths.add(target);
const override = overrideFor(recovered?.request.fileOverrides, file.source_path);
if (override) movedOverrides[target] = override;
}
}
const frozen = recoveryRecord.plan as unknown as Partial<FrozenPlan>;
for (const file of frozen.organize?.files ?? []) recoveryPaths.add(file.src);
for (const file of frozen.organize?.excluded ?? []) recoveryPaths.add(file.src);
if (!recoveryPaths.size) {
for (const path of recovered?.recovery.request.sourcePaths ?? []) {
recoveryPaths.add(path);
if ((await stat(path).catch(() => null))?.isDirectory()) recoveryDirectories.push(path);
}
}
}
// 先核验整批人工集号,避免无效输入创建外部作品或冻结计划。数字范围由请求 schema 校验。
const localIds = [...new Set(body.items.flatMap((it) => (it.mediaEntityId ? [it.mediaEntityId] : [])))];
@@ -232,9 +272,9 @@ export async function organizeFiles(
const entityOf = (it: (typeof body.items)[number]) =>
it.mediaEntityId ?? created.get(externalKey(it.external!))!.entityId;
const fromExternal = new Map([...created.values()].map((c) => [c.entityId, c]));
const minSizeBytes = body.minSizeBytes ?? (await settingsMinSize(d));
const onExisting = body.onExisting ?? "conflict";
const namingMode = body.naming ?? "template";
const minSizeBytes = body.minSizeBytes ?? recovered?.request.minSizeBytes ?? (await settingsMinSize(d));
const onExisting = body.onExisting ?? recovered?.request.onExisting ?? "conflict";
const namingMode = body.naming ?? recovered?.request.namingMode ?? "template";
const entityIds = [...new Set(body.items.map(entityOf))];
const entities = new Map(
@@ -287,6 +327,22 @@ export async function organizeFiles(
const roots = new Set<string>();
for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path);
if (recovered && !recoverySources?.recovery?.missing.includes("sourcePaths")) {
const selected: string[] = [];
await walk(r.abs, selected, d.ao.ports.options.walkMaxFiles + 1);
if (
!selected.length ||
selected.some(
(path) =>
!recoveryPaths.has(path) &&
!recoveryDirectories.some((dir) => {
const rel = relative(dir, path);
return rel !== ".." && !rel.startsWith("../") && !rel.startsWith("/");
}),
)
)
throw new FilesError("bad_request", "重新整理只能选择原记录中的文件;请单独整理其它来源");
}
if (r.rel === "") throw new FilesError("bad_request", "不能整理整个登记目录");
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接:${r.rel}`);
const action = it.operation ?? body.action;
@@ -420,6 +476,7 @@ export async function organizeFiles(
const hasOverrides = Object.keys(g.overrides).length > 0;
const st = structures.get(g.entity);
const reqHash = hashJson({
...(body.recoveryRecordId ? { recoveryRecordId: body.recoveryRecordId, recovered: recovered?.request } : {}),
key: requestKey(req),
...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}),
...(hasOverrides ? { overrides: g.overrides } : {}),
@@ -447,10 +504,30 @@ export async function organizeFiles(
onExisting,
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
};
const inherited = { ...recovered?.request };
if (recovered && g.entity !== recovered.request.mediaEntityId) {
delete inherited.contentUnitId;
delete inherited.episodeStructure;
delete inherited.slotId;
delete inherited.fileOverrides;
delete inherited.importMode;
delete inherited.replacementAssetIds;
} else if (inherited.fileOverrides) {
inherited.fileOverrides = { ...inherited.fileOverrides, ...movedOverrides };
}
// 恢复向导提交完整的目标/来源/季集选择;省略代表选择自动或跟随作品,不能沿用旧覆写。
delete inherited.targetDir;
delete inherited.targetLibraryRootId;
delete inherited.sourceConfigId;
delete inherited.episodeStructure;
const importReq = (dryRun: boolean, taskId?: string) => ({
...inherited,
...req,
...(body.recoveryRecordId ? { retryOfRecordId: body.recoveryRecordId } : {}),
...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}),
...(hasOverrides ? { fileOverrides: g.overrides } : {}),
...(hasOverrides || inherited.fileOverrides
? { fileOverrides: { ...inherited.fileOverrides, ...g.overrides } }
: {}),
...(g.targetDir ? { targetDir: g.targetDir } : {}),
...(g.slotId ? { slotId: g.slotId } : {}),
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
@@ -458,8 +535,8 @@ export async function organizeFiles(
// 向导里显式选择"替换"即为人工授权;替换走 replace_with_verify(新文件核验通过后旧文件进回收站)
...(onExisting === "replace_with_verify" ? { grantReplace: true } : {}),
namingMode,
skipSuccessfulRecords: body.skipSuccessfulRecords,
scrapeAfterImport: body.scrapeAfterImport,
skipSuccessfulRecords: body.skipSuccessfulRecords ?? recovered?.request.skipSuccessfulRecords,
scrapeAfterImport: body.scrapeAfterImport ?? recovered?.request.scrapeAfterImport,
// 勾了「同时设为作品的季集结构」:执行前已切换作品,之后按作品(新结构)整理;预览按临时结构算,命名相同
...(st
? {
+26 -19
View File
@@ -7,9 +7,10 @@ import type {
OrganizationStatus,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { isRemotePath, vlstat as lstat, vreaddirents as readdirents } from "../../storage/vfs.ts";
import { isRemotePath, vreaddirents as readdirents } from "../../storage/vfs.ts";
import { resolveOrganizeRecovery } from "../acquisition-organize/services/organize-retry.ts";
import { FilesError, type FmDeps, isTempName, loadRootDefs, resolveIn, within } from "./common.ts";
import { kindOfName } from "./list.ts";
import { isWritableTarget, kindOfName } from "./list.ts";
// G8 目录摘要(树折叠 / 上一级):只列子目录(是否还有下级)+ 本目录递归视频数与已登记数;
// G5 整理记录 → 源 FileRef(整理记录"重新整理"按登记目录 hostPath 换算)。
@@ -82,22 +83,9 @@ export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeRes
export async function recordSources(d: FmDeps, recordId: string): Promise<FilesRecordSourcesResult> {
const db = d.ctx.db;
const rec = await db
.selectFrom("organization_record")
.select([
"id",
"media_entity_id",
"action",
"status",
"source_path",
"source_path_view",
"target_path",
"target_library_root_id",
"plan",
])
.where("id", "=", recordId)
.executeTakeFirst();
const rec = await db.selectFrom("organization_record").selectAll().where("id", "=", recordId).executeTakeFirst();
if (!rec) throw new FilesError("not_found", "整理记录不存在");
const { recovery } = await resolveOrganizeRecovery(db, rec);
const mains = await db
.selectFrom("organization_file_result")
.select(["source_path", "target_path", "target_library_root_id", "status"])
@@ -129,6 +117,7 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
return target;
})
.filter((p): p is string => !!p);
if (!mains.length && recovery.request.sourcePaths?.length) sources = recovery.request.sourcePaths;
// 部分移动的文件分布在媒体库与下载目录,不能折叠成一个目录丢掉已移动文件。
const dir = partialMove ? null : moved ? targetOf(rec.target_library_root_id, rec.target_path) : rec.source_path;
if (
@@ -142,9 +131,9 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
const items: FilesRecordSourcesResult["items"] = [];
const unresolved: string[] = [];
for (const abs of [...new Set(sources)]) {
const ref = moved || targetSources.has(abs) || rec.source_path_view === "local" ? toRef(abs) : null;
const ref = toRef(abs);
const exists = ref
? await lstat(abs).then(
? await resolveIn(d, ref.root, ref.path).then(
() => true,
() => false,
)
@@ -152,7 +141,25 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
if (ref && exists) items.push(ref);
else unresolved.push(abs);
}
if (!items.length) {
recovery.ready = false;
if (!recovery.missing.includes("sourcePaths")) recovery.missing.push("sourcePaths");
}
let targetDir: FilesRecordSourcesResult["targetDir"];
if (recovery.request.targetDir) {
const ref = toRef(recovery.request.targetDir);
if (ref?.root.startsWith("lib:")) {
const target = await resolveIn(d, ref.root, ref.path).catch(() => null);
if (target?.lst?.isDirectory() && (await isWritableTarget(target.def, target.rootReal))) targetDir = ref;
}
}
if (recovery.request.targetDir && !targetDir) {
recovery.ready = false;
if (!recovery.missing.includes("options")) recovery.missing.push("options");
}
return {
recovery,
...(targetDir ? { targetDir } : {}),
recordId: rec.id,
mediaEntityId: rec.media_entity_id,
action: rec.action,