fix(organize): 不是视频的文件移入回收站并开待处理,不留在媒体库

- 收尾判定 NOT_VIDEO 后,已拷 / 搬进库的新文件和它的附属文件移入回收站(登记 trash_item),开待处理;
- 附属文件结果改为跳过 main_failed(不再写成功却不登记);
- 已结束记录重放时补做,可重入;原地入库不动原文件,洗版替换照旧回滚。

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-03 23:28:35 +08:00
1 parent 1c8c5d2fb7
commit b40716d74f
5 files changed
+219 -2

No files matched your search

@@ -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);
});
});