From 09a4c9fc1e98b8fb419fff15f21bd4414ecfeff1 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 13 Oct 2026 15:38:13 +0800 Subject: [PATCH] fix(organize): stop pending sidecars when replaying failed main files --- .../services/organize-exec.ts | 5 ++- .../organize-recovery.test.ts | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts index b81b6a82..1baada3d 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts @@ -166,7 +166,10 @@ export async function executeRecord( .orderBy("ordinal") .execute(); const planned = new Map(frozen.organize.files.map((f) => [f.src, f])); - const failedMains = new Set(); + // 主文件的失败可能已在上次执行提交;重放也要阻止随行文件脱离主文件继续搬运。 + const failedMains = new Set( + files.filter((f) => f.role === "main" && f.status === "failed").map((f) => f.source_path), + ); /** 本轮 TIMEOUT/IO 类失败、留待任务重试的文件(结果不入账本,文件结果保持 running) */ const deferred: { src: string; diff --git a/apps/server/test/acquisition-organize/organize-recovery.test.ts b/apps/server/test/acquisition-organize/organize-recovery.test.ts index f27864b4..72e76e36 100644 --- a/apps/server/test/acquisition-organize/organize-recovery.test.ts +++ b/apps/server/test/acquisition-organize/organize-recovery.test.ts @@ -50,6 +50,8 @@ describe.skipIf(!hasPg)("整理卡死回收(真实 PG + mock qB + 临时目录)" "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, + "sidecar/Sidecar.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv": SIZE, + "sidecar/Sidecar.Show.S01E01.1080p.WEB-DL.x264-GRP.srt": 100, }, { prefix: "umt-reccache-" }, ); @@ -208,6 +210,48 @@ describe.skipIf(!hasPg)("整理卡死回收(真实 PG + mock qB + 临时目录)" expect((await recoverStalledRecords(h.d)).failed).toBe(0); }); + it.each(["copy", "move"] as const)("%s 重放已失败的主文件:未完成字幕留在源目录,不再搬运", async (action) => { + const entityId = await seedEntity(h.db, { kind: "series", title: `字幕恢复 ${action}`, year: 2024 }); + const plan = await planImport(h.d, { + sourcePaths: [cache.path("sidecar")], + mediaEntityId: entityId, + action, + trigger: "manual", + actorType: "user", + }); + expect(plan.status, JSON.stringify(plan)).toBe("running"); + const recordId = plan.recordId!; + // 主文件失败已经提交,进程在处理随行字幕前退出;恢复时主文件不会再次执行。 + await h.db + .updateTable("organization_file_result") + .set({ status: "failed", error: JSON.stringify({ code: "EEXIST", message: "目标冲突" }) }) + .where("organization_record_id", "=", recordId) + .where("role", "=", "main") + .execute(); + const sidecar = await h.db + .selectFrom("organization_file_result") + .selectAll() + .where("organization_record_id", "=", recordId) + .where("role", "=", "subtitle") + .executeTakeFirstOrThrow(); + expect(sidecar.status).toBe("queued"); + expect(sidecar.target_path).not.toBeNull(); + + expect((await executeRecord(h.d, recordId)).status).toBe("failed"); + const result = await h.db + .selectFrom("organization_file_result") + .selectAll() + .where("id", "=", sidecar.id) + .executeTakeFirstOrThrow(); + expect(result).toMatchObject({ status: "skipped", skip_reason: "main_failed", asset_operation_id: null }); + expect(result.finished_at).not.toBeNull(); + expect(existsSync(sidecar.source_path)).toBe(true); + expect(existsSync(join(lib.root, sidecar.target_path!))).toBe(false); + expect( + await h.db.selectFrom("asset_operation").select("id").where("organization_record_id", "=", recordId).execute(), + ).toEqual([]); + }); + it("库根离线:不抛错耗尽重试,记录等待并开待处理;恢复后巡检重投(不计上限)并完成", async () => { const id = await manualRecord("offline", "离线的剧"); await rm(join(lib.root, ".uhd-root"));