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

@@ -20,6 +20,14 @@ import { scrapeAfterImport } from "./scrape.ts";
import { slotComplete as sharedSlotComplete } from "./subscriptions.ts";
import { downloadTasksUnder } from "./takeover.ts";
/** 被排除待确认的视频原因(待处理摘要里显示) */
const EXCLUDED_TEXT: Record<string, string> = {
episode_unresolved: "集数待确认",
no_target_dir: "没有季目录",
naming_failed: "命名失败",
title_mismatch: "标题不是本作品",
};
/** 登记资产/位置/绑定 + Slot/计划/下载任务 + 记录终态,同一事务;记录行锁作 fencing */
export async function finalizeRecord(d: AoDeps, recordId: string): Promise<ExecuteResult> {
const db = d.ctx.db;
@@ -350,13 +358,14 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.where("id", "=", recordId)
.execute();
if (unresolved.length > 0) {
const shown = unresolved
.slice(0, 5)
.map((f) => `${f.source_path.split("/").pop()}(${f.skip_reason?.replace(/^excluded:/, "")})`);
const shown = unresolved.slice(0, 5).map((f) => {
const code = f.skip_reason?.replace(/^excluded:/, "") ?? "";
return `${f.source_path.split("/").pop()}(${EXCLUDED_TEXT[code] ?? code})`;
});
await upsertDecision(d, trx, {
kind: "other",
title: `有 ${unresolved.length} 个视频没有整理`,
summary: `季集、季目录或命名没能确定,文件留在原处${
summary: `季集、季目录、命名没能确定,或文件标题不是本作品,文件留在原处${
rec.action === "move" ? ",下载任务保留" : ""
}:${shown.join("、")}${unresolved.length > shown.length ? " 等" : ""}`,
subjectType: "organization_record",
@@ -24,6 +24,7 @@ import {
resolveTargetDirectory,
type SourceFile,
sidecarStem,
titleMismatches,
} from "@umt/domain";
import { organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping";
import {
@@ -637,9 +638,22 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const episodes = mediaType === "tv" ? await planFileEpisodes(d, entity, rel, dt) : null;
const mappedOverrides: Record<string, DomainFileOverride> = {};
const unresolvedEpisodes: NonNullable<FrozenPlan["unresolvedEpisodes"]> = {};
// 文件级标题粗核:混合包 / 合集里标题明显不是本作品的视频不按本作品命名入库(待确认 title_mismatch);
// 用户逐文件指定过的不核
const mismatched = await fileTitleMismatches(
d,
entity,
mediaType,
files.filter((f) => !overrideFor(req.fileOverrides, f.path) && fileKindOf(basename(f.path)) === "video"),
);
for (const f of files) {
let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot));
if (parsed.fileKind !== "video") continue;
const wrongTitle = mismatched.get(f.path);
if (wrongTitle !== undefined) {
mappedOverrides[f.path] = { exclude: { reason: "title_mismatch", detail: wrongTitle } };
continue;
}
const ov = overrideFor(req.fileOverrides, f.path);
let resolution: EpisodeResolution | undefined;
if (mediaType === "tv" && (ov?.season !== undefined || ov?.episodes)) {
@@ -1221,3 +1235,32 @@ export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promi
mediaEntityId: r.media_entity_id,
});
}
/** 作品标题 / 原名 / 别名 → 这批视频里标题明显不是本作品的(路径 → 文件标题) */
async function fileTitleMismatches(
d: AoDeps,
entity: Pick<MediaEntityRow, "id" | "title" | "original_title" | "metadata">,
mediaType: "movie" | "tv",
videos: readonly { path: string }[],
): Promise<Map<string, string>> {
if (videos.length < 2) return new Map();
const aliases = await d.ctx.db
.selectFrom("title_alias")
.select("alias")
.where("media_entity_id", "=", entity.id)
.where("content_unit_id", "is", null)
.execute();
const meta = (entity.metadata ?? {}) as Record<string, { value?: unknown } | undefined>;
const names = [
entity.title,
entity.original_title,
meta.title?.value,
meta.originalTitle?.value,
...aliases.map((a) => a.alias),
].filter((n): n is string => typeof n === "string" && n.trim().length > 0);
return titleMismatches(
videos.map((v) => ({ path: v.path, name: basename(v.path) })),
names,
mediaType,
);
}
@@ -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);
});
});