From b40716d74f7b33b4a033a102ca6b9e240c1198e7 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:28:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(organize):=20=E4=B8=8D=E6=98=AF=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E7=9A=84=E6=96=87=E4=BB=B6=E7=A7=BB=E5=85=A5=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E7=AB=99=E5=B9=B6=E5=BC=80=E5=BE=85=E5=A4=84=E7=90=86?= =?UTF-8?q?,=E4=B8=8D=E7=95=99=E5=9C=A8=E5=AA=92=E4=BD=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 收尾判定 NOT_VIDEO 后,已拷 / 搬进库的新文件和它的附属文件移入回收站(登记 trash_item),开待处理; - 附属文件结果改为跳过 main_failed(不再写成功却不登记); - 已结束记录重放时补做,可重入;原地入库不动原文件,洗版替换照旧回滚。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../services/organize-exec.ts | 8 +- .../services/organize-finalize.ts | 19 +++- .../services/replacement.ts | 104 ++++++++++++++++++ .../organize-not-video.test.ts | 85 ++++++++++++++ docs/runbook/organize.md | 5 + 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 apps/server/test/acquisition-organize/organize-not-video.test.ts 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 23286904..15f385b3 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts @@ -21,7 +21,12 @@ import { type AoDeps, upsertDecision } from "./common.ts"; import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts"; import { emitRecord } from "./organize-plan.ts"; import type { FrozenPlan } from "./organize-types.ts"; -import { cleanupReplacement, settleSamePathReplacement, stagedReplacementBackup } from "./replacement.ts"; +import { + cleanupReplacement, + quarantineNotVideo, + settleSamePathReplacement, + stagedReplacementBackup, +} from "./replacement.ts"; import { scrapeAfterImport } from "./scrape.ts"; // ── 执行 ── @@ -81,6 +86,7 @@ export async function executeRecord( if (rec.status !== "running" && rec.status !== "queued") { await settleSamePathReplacement(d, rec.plan as unknown as FrozenPlan, recordId); await cleanupReplacement(d, rec.plan as unknown as FrozenPlan, recordId); + await quarantineNotVideo(d, rec.plan as unknown as FrozenPlan, recordId); const frozen = rec.plan as unknown as FrozenPlan; // 与 finalizeRecord 同一口径(按设置 / 本次选项);排队按整理记录幂等,重放不会重复刮 if (["succeeded", "partial"].includes(rec.status)) { diff --git a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts index 3c92e0d3..ffe65b7e 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts @@ -15,7 +15,7 @@ import type { ExecuteResult } from "./organize-exec.ts"; import { emitRecord } from "./organize-plan.ts"; import { finalizeTransfer } from "./organize-transfer.ts"; import type { FrozenPlan } from "./organize-types.ts"; -import { cleanupReplacement, settleSamePathReplacement } from "./replacement.ts"; +import { cleanupReplacement, quarantineNotVideo, settleSamePathReplacement } from "./replacement.ts"; import { scrapeAfterImport } from "./scrape.ts"; import { slotComplete as sharedSlotComplete } from "./subscriptions.ts"; import { downloadTasksUnder } from "./takeover.ts"; @@ -82,6 +82,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise(); for (const f of files) { const p = probes.get(f.id) as { status?: string; video?: unknown[] } | undefined; if (p?.status === "ok" && Array.isArray(p.video) && p.video.length === 0) { @@ -97,6 +98,21 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise [pf.src, pf.mainSrc])); + for (const f of files) { + if (f.status !== "succeeded" || f.role === "main" || !notVideo.has(mainOf.get(f.source_path) ?? "")) continue; + await trx + .updateTable("organization_file_result") + .set({ status: "skipped", skip_reason: "main_failed" }) + .where("id", "=", f.id) + .execute(); + f.status = "skipped"; + f.skip_reason = "main_failed"; } } @@ -498,6 +514,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise { + const db = d.ctx.db; + const rec = await db + .selectFrom("organization_record") + .select(["action", "download_task_id"]) + .where("id", "=", recordId) + .executeTakeFirst(); + if (!rec || rec.action === "in_place" || frozen.transfer) return; + const results = await db + .selectFrom("organization_file_result") + .select(["id", "source_path", "target_path", "status", "skip_reason", "error", "source_size_bytes", "role"]) + .where("organization_record_id", "=", recordId) + .orderBy("ordinal") + .execute(); + const planned = new Map(frozen.organize.files.map((f) => [f.src, f])); + const rejectedMains = new Set( + results + .filter((f) => f.status === "failed" && (f.error as { code?: string } | null)?.code === "NOT_VIDEO") + .map((f) => f.source_path), + ); + if (!rejectedMains.size) return; + const targets = results.filter( + (f) => + f.target_path && + (rejectedMains.has(f.source_path) || + (f.status === "skipped" && + f.skip_reason === "main_failed" && + rejectedMains.has(planned.get(f.source_path)?.mainSrc ?? ""))), + ); + const fsr = fsRuntime(d); + const moved: { path: string; trash: string }[] = []; + const left: { path: string; error: string }[] = []; + for (const f of targets) { + const pf = planned.get(f.source_path); + if (frozen.replacement && pf?.action === "replace") continue; + const rel = f.target_path!; + try { + if ((await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel)).kind !== "online") throw new Error("库根离线"); + const rootReal = await realpath(frozen.rootPath); + const abs = join(rootReal, rel); + const st = await fsr.proxy.statSafe(abs, { root: rootReal, sentinel: frozen.sentinel }); + if (st.kind === "missing") continue; + if (st.kind !== "ok" || !st.facts.isFile || st.facts.isSymbolicLink) throw new Error("目标不是普通文件"); + const registered = await db + .selectFrom("asset_location") + .select("id") + .where("library_root_id", "=", frozen.libraryRootId) + .where("relative_path", "=", rel) + .where("status", "<>", "removed") + .executeTakeFirst(); + if (registered) continue; + const size = Number(f.source_size_bytes ?? pf?.size ?? -1); + if (size >= 0 && st.facts.size !== size) throw new Error("目标大小与本次写入的不同,保留待核对"); + const id = randomUUID(); + const now = d.ports.now(); + const trash = join(rootReal, ".umt-trash", now.toISOString().slice(0, 10).replace(/-/g, ""), id, rel); + await mkdir(dirname(trash), { recursive: true }); + const m = await fsr.proxy.moveSafe(abs, trash); + if (m.status !== "moved") throw new Error(`移入回收站失败:${m.status}`); + const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7; + await db + .insertInto("trash_item") + .values({ + id, + root_id: `lib:${frozen.libraryRootId}`, + library_root_id: frozen.libraryRootId, + root_host_path: rootReal, + original_path: rel, + trash_path: trash, + is_dir: false, + size_bytes: st.facts.size, + actor: "system:organize", + deleted_at: now, + expires_at: new Date(now.getTime() + days * 86_400_000), + asset_snapshot: jsonb({ source: "organize_not_video", recordId, sourcePath: f.source_path }), + }) + .execute(); + moved.push({ path: rel, trash }); + } catch (error) { + left.push({ path: rel, error: error instanceof Error ? error.message : String(error) }); + } + } + if (!moved.length && !left.length) return; + const names = [...rejectedMains].map((p) => basename(p)); + await upsertDecision(d, db, { + kind: "other", + title: `${rejectedMains.size} 个文件不是视频,没有入库`, + summary: `${names.slice(0, 5).join("、")}${names.length > 5 ? " 等" : ""} 没有视频流(伪装或损坏的文件)。${ + moved.length ? "已拷进媒体库的文件移入回收站" : "" + }${left.length ? `${moved.length ? "," : ""}${left.length} 个文件留在媒体库里待核对` : ""}${ + rec.action === "move" ? ";源文件已随移动删除,回收站里是唯一一份" : "" + }`, + subjectType: "organization_record", + subjectId: recordId, + dedupeKey: `organize_not_video:${recordId}`, + evidence: { downloadTaskId: rec.download_task_id, moved, left }, + }); +} diff --git a/apps/server/test/acquisition-organize/organize-not-video.test.ts b/apps/server/test/acquisition-organize/organize-not-video.test.ts new file mode 100644 index 00000000..ccccb370 --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-not-video.test.ts @@ -0,0 +1,85 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { createTempTree, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// 收尾时 ffprobe 判定「不是视频」:已拷 / 搬进库的新文件与它的附属文件移入回收站(不留未登记文件), +// 附属文件结果改为跳过(main_failed),开待处理;重放不重复处理。 + +const hasPg = await pgAvailable(); +const FAKE = "Fake.2020.1080p.BluRay.x264-GRP.mkv"; +const SUB = "Fake.2020.1080p.BluRay.x264-GRP.zh.srt"; + +describe.skipIf(!hasPg)("整理:不是视频的文件不留在媒体库", () => { + let h: Harness; + let lib: TempTree; + let src: TempTree; + + beforeAll(async () => { + lib = await createTempTree({}, { sentinel: true, prefix: "umt-nvlib-" }); + src = await createTempTree( + { [FAKE]: 200_000, [SUB]: "1\n00:00:01,000 --> 00:00:02,000\n字幕\n" }, + { prefix: "umt-nvsrc-" }, + ); + h = await createHarness("not_video", { + probe: async (p: string) => + p.endsWith(".mkv") + ? ({ status: "ok", video: [], audio: [], subtitle: [] } as never) + : ({ status: "unknown" } as never), + }); + await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" }); + }, 60_000); + afterAll(async () => { + await h?.close(); + for (const t of [lib, src]) await t?.cleanup(); + }); + + it("Move:新文件与字幕移入回收站,不登记资产,开待处理;重放不重复", async () => { + const entity = await seedEntity(h.db, { kind: "movie", title: "伪装片", year: 2020 }); + const plan = await planImport(h.d, { + sourcePaths: [src.path(FAKE)], + mediaEntityId: entity, + action: "move", + trigger: "manual", + actorType: "user", + }); + expect(plan.status).toBe("running"); + const ex = await executeRecord(h.d, plan.recordId!); + expect(ex.status).toBe("failed"); + + const files = await h.db + .selectFrom("organization_file_result") + .select(["role", "status", "skip_reason", "target_path", "error", "media_asset_id"]) + .where("organization_record_id", "=", plan.recordId!) + .orderBy("ordinal") + .execute(); + const main = files.find((f) => f.role === "main")!; + const sub = files.find((f) => f.role === "subtitle")!; + expect(main).toMatchObject({ status: "failed", media_asset_id: null }); + expect((main.error as { code: string }).code).toBe("NOT_VIDEO"); + expect(sub).toMatchObject({ status: "skipped", skip_reason: "main_failed", media_asset_id: null }); + + // 库里不留文件,回收站里各一份(Move 时源已删,回收站里是唯一一份) + expect(existsSync(join(lib.root, main.target_path!))).toBe(false); + expect(existsSync(join(lib.root, sub.target_path!))).toBe(false); + expect(existsSync(src.path(FAKE))).toBe(false); + const trash = await h.db.selectFrom("trash_item").select(["original_path", "trash_path"]).execute(); + expect(trash.map((t) => t.original_path).sort()).toEqual([main.target_path, sub.target_path].sort()); + for (const t of trash) expect(existsSync(t.trash_path)).toBe(true); + expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", entity).execute()).toEqual( + [], + ); + const decisions = await h.db + .selectFrom("decision") + .select(["title", "status"]) + .where("dedupe_key", "=", `organize_not_video:${plan.recordId}`) + .execute(); + expect(decisions).toEqual([{ title: "1 个文件不是视频,没有入库", status: "open" }]); + + // 重放:已结束记录不重复移动 + await executeRecord(h.d, plan.recordId!); + expect(await h.db.selectFrom("trash_item").select("id").execute()).toHaveLength(2); + }); +}); diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index 7ee9e8fa..ab0a0067 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -50,6 +50,11 @@ 洗版在本次冻结计划中保存选中的旧文件。新文件核验登记成功后才清理所选旧版本,剧集只处理成功对应的季集与 Part;覆盖不完整的合并集保留。相同目标路径会先暂存旧文件,失败后恢复。旧版的私有字幕等随之清理,共享 NFO/图片、未选版本及下载缓存保留;按现有文件替换设置移入回收站或删除。 +## 收尾核验与资产登记 + +- 目标路径已有位置时:本次是「替换」(向导 / 待处理选替换,或洗版选中的旧版本),或旧位置属于别的作品,退役旧位置(没有别的位置时旧资产退役)并新建资产,大小、校验、版本信息都是新文件的;否则沿用同一资产。沿用的位置之前被对账判为缺失 / 不可用时恢复可用,资产事实换成这次入库的文件。 +- ffprobe 判定「不是视频」(没有视频流,伪装或损坏)的文件:核验失败,已拷 / 搬进库的新文件和它的字幕等附属文件移入回收站(Move 时源已删,回收站里是唯一一份),附属文件结果记跳过 `main_failed`,开待处理「N 个文件不是视频,没有入库」。原地入库不动原文件;洗版替换照旧回滚旧文件。 + 关联迁移 `0060_manual_acquisition`,已于 2026-10-01 随 v0.3.10 发布执行。业务依据与验收见 [MP 对照 §5.6](../porting/moviepilot-v3-comparison.md#56-本轮实现与验收已部署-v0310)。 ## 与 MP 的对照