fix(organize): 非洗版替换新建资产;复用缺失位置时更新状态与事实

- 目标路径已有位置且本次是「替换」(向导 / 待处理选替换),或旧位置属于别的作品时,退役旧位置(没有别的位置时资产退役)
  并新建资产,size / hash / delivery / 版本信息都是新文件的(复用洗版分支的处理);
- 复用之前判为缺失 / 不可用的位置时,位置恢复可用、资产恢复 active,大小 / 校验 / 版本信息换成这次入库的文件。

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:25:41 +08:00
1 parent b7f9eb82ad
commit 1c8c5d2fb7
2 files changed
+173 -43

No files matched your search

@@ -0,0 +1,95 @@
import { rm } from "node:fs/promises";
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";
// 整理登记资产时同一路径已有位置:
// - 非洗版「替换」(向导 / 待处理选替换 = replace_with_verify)退役旧位置并新建资产,size / hash / 版本信息是新文件的;
// - 复用之前判为缺失的位置(同名文件重新入库)时,位置恢复可用,资产事实换成这次的文件。
const hasPg = await pgAvailable();
const OLD = "Film.2020.1080p.BluRay.x264-AAA.mkv";
const NEW = "Film.2020.1080p.WEB-DL.x264-BBB.mkv";
describe.skipIf(!hasPg)("整理:替换与缺失位置复用时资产事实跟着新文件", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
let entity = "";
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-aflib-" });
src = await createTempTree({ [OLD]: 200_000, [NEW]: 300_000 }, { prefix: "umt-afsrc-" });
h = await createHarness("asset_facts");
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
await h.setSettings("organize", { scrapeAfterImport: false });
entity = await seedEntity(h.db, { kind: "movie", title: "替换片", year: 2020 });
}, 60_000);
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
async function organize(file: string, extra: Record<string, unknown> = {}) {
const plan = await planImport(h.d, {
sourcePaths: [src.path(file)],
mediaEntityId: entity,
action: "copy",
trigger: "manual",
actorType: "user",
...extra,
});
expect(plan.status).toBe("running");
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
return h.db
.selectFrom("organization_file_result")
.select(["media_asset_id", "target_path"])
.where("organization_record_id", "=", plan.recordId!)
.where("role", "=", "main")
.executeTakeFirstOrThrow();
}
const asset = (id: string) =>
h.db.selectFrom("media_asset").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const locations = (id: string) =>
h.db.selectFrom("asset_location").select(["status", "size_bytes"]).where("media_asset_id", "=", id).execute();
let newAsset = "";
let target = "";
it("非洗版替换:退役旧位置与旧资产,新建资产记新文件的大小与版本", async () => {
const first = await organize(OLD);
const second = await organize(NEW, { onExisting: "replace_with_verify", grantReplace: true });
expect(second.target_path).toBe(first.target_path);
expect(second.media_asset_id).not.toBe(first.media_asset_id);
newAsset = second.media_asset_id!;
target = second.target_path!;
expect((await asset(first.media_asset_id!)).status).toBe("retired");
expect(await locations(first.media_asset_id!)).toEqual([{ status: "removed", size_bytes: expect.anything() }]);
const a = await asset(newAsset);
expect(a).toMatchObject({ status: "active", media_entity_id: entity });
expect(Number(a.size_bytes)).toBe(300_000);
expect(JSON.stringify(a.delivery)).toContain("BBB");
expect((await locations(newAsset)).map((l) => l.status)).toEqual(["available"]);
});
it("复用判为缺失的位置:位置恢复可用,资产恢复 active 且事实换成新文件", async () => {
await h.db
.updateTable("asset_location")
.set({ status: "confirmed_missing" })
.where("media_asset_id", "=", newAsset)
.execute();
await h.db.updateTable("media_asset").set({ status: "missing" }).where("id", "=", newAsset).execute();
await rm(lib.path(target));
const again = await organize(OLD);
expect(again.media_asset_id).toBe(newAsset);
const a = await asset(newAsset);
expect(a.status).toBe("active");
expect(Number(a.size_bytes)).toBe(200_000);
expect(JSON.stringify(a.delivery)).toContain("AAA");
const [loc] = await locations(newAsset);
expect(loc).toMatchObject({ status: "available" });
expect(Number(loc!.size_bytes)).toBe(200_000);
});
});