fix(organize): 跨卷 Move 删源前全量校验、计划期 sha256 缓存、补全元数据带超时

- 跨卷移动删源前一律全量 sha256(不再抽样);
- 计划期同尺寸目标的全量 sha256 按 (路径, 大小, 修改时间, inode) 缓存,向导预览 / 复核 / 执行不重复算;
- metadata.resolve_organize 传 3 分钟超时的 signal;
- 季号推断已由解析器统一推定(seasonInferred),补回归用例。

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:37:28 +08:00
1 parent 4b49be7228
commit d129a96110
5 files changed
+130 -11

No files matched your search

@@ -0,0 +1,51 @@
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// 季号推断(审视 ④ 风险 9):解析器对有集号没季号的文件已推定第 1 季(seasonInferred),服务端与域层用同一个值;
// 带季号与不带季号的文件混在一起时,不带季号的不会因为「季目录不唯一」被静默排除(回归用例)。
const hasPg = await pgAvailable();
const SIZE = 200_000;
describe.skipIf(!hasPg)("整理:季号推断收敛到一处", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-silib-" });
src = await createTempTree(
{
"Pack/Show.S02E01.1080p.WEB-DL.mkv": SIZE,
"Pack/Show.E05.1080p.WEB-DL.mkv": SIZE,
},
{ prefix: "umt-sisrc-" },
);
h = await createHarness("season_inference");
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("没季号的集按第 1 季入库,不以 no_target_dir 排除", async () => {
const entity = await seedEntity(h.db, { kind: "series", title: "剧", year: 2020 });
const plan = await planImport(h.d, {
sourcePaths: [src.path("Pack")],
mediaEntityId: entity,
action: "copy",
trigger: "manual",
actorType: "user",
dryRun: true,
});
const o = plan.plan!.organize;
expect(o.excluded.filter((x) => x.reason === "no_target_dir")).toEqual([]);
const e05 = o.files.find((f) => f.src.endsWith("Show.E05.1080p.WEB-DL.mkv"))!;
expect(e05).toMatchObject({ season: 1, episodes: { start: 5, end: 5 } });
expect(e05.dst).toContain("Season 1");
expect(o.files.find((f) => f.src.endsWith("S02E01.1080p.WEB-DL.mkv"))!.dst).toContain("Season 2");
});
});
@@ -0,0 +1,38 @@
import { createHash } from "node:crypto";
import { mkdtemp, rm, stat, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { cachedSha256 } from "../../src/modules/acquisition-organize/services/organize-plan.ts";
// 计划期全量 sha256 按 (路径, 大小, 修改时间, inode) 缓存:同一文件没变时不重算,变了就重算。
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
describe("计划期 sha256 缓存", () => {
let dir = "";
afterAll(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
it("事实不变命中缓存;修改时间变了重算", async () => {
dir = await mkdtemp(join(tmpdir(), "umt-shacache-"));
const p = join(dir, "a.mkv");
await writeFile(p, "AAAA");
const t = new Date("2024-01-01T00:00:00Z");
await utimes(p, t, t);
const facts = async () => {
const s = await stat(p);
return { size: s.size, mtimeMs: s.mtimeMs, ino: s.ino };
};
expect(await cachedSha256(p, await facts())).toBe(sha("AAAA"));
// 同大小、同修改时间、同 inode 的改写:按缓存(这正是缓存键的语义)
await writeFile(p, "BBBB");
await utimes(p, t, t);
expect(await cachedSha256(p, await facts())).toBe(sha("AAAA"));
// 修改时间变了:重算
const t2 = new Date("2024-01-02T00:00:00Z");
await utimes(p, t2, t2);
expect(await cachedSha256(p, await facts())).toBe(sha("BBBB"));
});
});