feat(organize): 文件级标题粗核,混合包里明显不是本作品的视频降为待确认

- 同一批视频里文件名标题与作品标题 / 原名 / 别名明显不符(没有足够共同词)的排除为 title_mismatch,不按本作品命名入库;
- 只在同批至少一个文件对得上作品时才判断,剧集只看带 SxxEyy 的文件,用户逐文件指定过的不核;
- title_mismatch 算「本该入库却被排除」:记录部分完成、开待处理、Move 不移除下载任务。

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:33:41 +08:00
1 parent b40716d74f
commit c532060c83
10 files changed
+283 -10

No files matched your search

@@ -0,0 +1,84 @@
import { existsSync } from "node:fs";
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";
// 文件级标题粗核:合集 / 混合包里标题明显不是本作品的视频不按本作品命名入库,
// 降为待确认(排除原因 title_mismatch),记录部分完成并开待处理;用户逐文件指定过的不核。
const hasPg = await pgAvailable();
const SIZE = 200_000;
const PACK = "Nolan.Pack";
describe.skipIf(!hasPg)("整理:文件标题明显不是本作品时降为待确认", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
let entity = "";
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-tclib-" });
src = await createTempTree(
{
[`${PACK}/Inception.2010.1080p.BluRay.x264-GRP.mkv`]: SIZE,
[`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`]: SIZE,
},
{ prefix: "umt-tcsrc-" },
);
h = await createHarness("title_check");
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: 2010 });
await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute();
}, 60_000);
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
it("合集里的别的电影:排除 title_mismatch,不入库,记录部分完成并开待处理", async () => {
const plan = await planImport(h.d, {
sourcePaths: [src.path(PACK)],
mediaEntityId: entity,
action: "copy",
trigger: "auto",
actorType: "system",
});
expect(plan.status).toBe("running");
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("partial");
const files = await h.db
.selectFrom("organization_file_result")
.select(["source_path", "status", "skip_reason", "target_path"])
.where("organization_record_id", "=", plan.recordId!)
.execute();
const other = files.find((f) => f.source_path.includes("Interstellar"))!;
expect(other).toMatchObject({ status: "skipped", skip_reason: "excluded:title_mismatch", target_path: null });
const mine = files.find((f) => f.source_path.includes("Inception"))!;
expect(mine.status).toBe("succeeded");
expect(existsSync(src.path(`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`))).toBe(true);
const decision = await h.db
.selectFrom("decision")
.select(["summary", "status"])
.where("dedupe_key", "=", `organize_excluded:${plan.recordId}`)
.executeTakeFirstOrThrow();
expect(decision.status).toBe("open");
expect(decision.summary).toContain("标题不是本作品");
});
it("用户逐文件指定过的不核", async () => {
const plan = await planImport(h.d, {
sourcePaths: [src.path(PACK)],
mediaEntityId: entity,
action: "copy",
trigger: "manual",
actorType: "user",
dryRun: true,
fileOverrides: {
[src.path(`${PACK}/Interstellar.2014.1080p.BluRay.x264-GRP.mkv`)]: { contentUnitIds: [] },
},
});
expect(plan.plan!.organize.excluded.some((x) => x.reason === "title_mismatch")).toBe(false);
});
});