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);
});
});
@@ -410,6 +410,7 @@ export const EXCLUDE_REASON: Record<string, string> = {
extra_content: "额外内容",
no_target_dir: "没有目标目录",
naming_failed: "命名失败",
title_mismatch: "标题不是本作品",
};
export const SKIP_REASON: Record<string, string> = {
+4
View File
@@ -53,6 +53,10 @@
## 收尾核验与资产登记
- 目标路径已有位置时:本次是「替换」(向导 / 待处理选替换,或洗版选中的旧版本),或旧位置属于别的作品,退役旧位置(没有别的位置时旧资产退役)并新建资产,大小、校验、版本信息都是新文件的;否则沿用同一资产。沿用的位置之前被对账判为缺失 / 不可用时恢复可用,资产事实换成这次入库的文件。
- 文件级标题粗核:同一批视频(合集、混合包)里,文件名解析出的标题和作品标题 / 原名 / 别名明显不是同一部的(没有足够共同词;
剧集只看带 SxxEyy 的文件),排除为待确认 `title_mismatch`,不按本作品命名入库,记录部分完成并开待处理「有 N 个视频没有整理」,
Move 时下载任务保留。只在同一批里至少有一个文件对得上作品时才判断别的文件(全都对不上多半是作品名的另一种写法,交给资源级识别);
用户在向导里逐文件指定过的不核。
- ffprobe 判定「不是视频」(没有视频流,伪装或损坏)的文件:核验失败,已拷 / 搬进库的新文件和它的字幕等附属文件移入回收站(Move 时源已删,回收站里是唯一一份),附属文件结果记跳过 `main_failed`,开待处理「N 个文件不是视频,没有入库」。原地入库不动原文件;洗版替换照旧回滚旧文件。
关联迁移 `0060_manual_acquisition`,已于 2026-10-01 随 v0.3.10 发布执行。业务依据与验收见 [MP 对照 §5.6](../porting/moviepilot-v3-comparison.md#56-本轮实现与验收已部署-v0310)。
+1
View File
@@ -17,4 +17,5 @@ export {
} from "./plan.ts";
export * from "./replacement.ts";
export { type DefaultSubtitleLang, sidecarLanguageSuffix, sidecarStem } from "./sidecars.ts";
export { type TitleCheckFile, titleMatchesNames, titleMismatches } from "./title-check.ts";
export { findOtherVersions } from "./versions.ts";
+6 -4
View File
@@ -69,9 +69,9 @@ export interface FileOverride {
episodes?: { start: number; end: number };
/**
* 不入库(调用方按作品集数映射判定:集数待确认 episode_unresolved、映射规则标为特别篇 / 花絮 / 忽略 special_extra);
* 只按精确路径生效,目录前缀覆写不带它
* 只按精确路径生效,目录前缀覆写不带它。title_mismatch = 文件标题明显不是本作品(混合包里别的作品),电影也生效
*/
exclude?: { reason: "episode_unresolved" | "special_extra"; detail?: string };
exclude?: { reason: "episode_unresolved" | "special_extra" | "title_mismatch"; detail?: string };
}
/** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */
@@ -121,7 +121,8 @@ export interface ExcludedFile {
| "special_extra"
| "extra_content"
| "no_target_dir"
| "naming_failed";
| "naming_failed"
| "title_mismatch";
detail?: string;
}
@@ -232,7 +233,8 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
}
const parsed = parseReleasePath(segmentsFor(f.path, input.sourceRoot), input.parse ?? {});
const ov = input.mediaType === "tv" ? overrideFor(input.overrides, f.path) : undefined;
const ex = input.mediaType === "tv" ? input.overrides?.[f.path]?.exclude : undefined;
const exact = input.overrides?.[f.path]?.exclude;
const ex = input.mediaType === "tv" || exact?.reason === "title_mismatch" ? exact : undefined;
if (ex) {
excluded.push({ src: f.path, kind, reason: ex.reason, ...(ex.detail ? { detail: ex.detail } : {}) });
continue;
@@ -0,0 +1,77 @@
// 整理的文件级标题粗核(审视 ④ 风险 7):资源级认定了作品,但混合包 / 合集里混着别的作品时,
// 按文件名解析出的标题和作品标题 / 别名对一下,明显不是同一部的不按本作品命名入库(降为待确认 title_mismatch)。
// 只拦明显不同的:
// - 只比文件名本身(不看目录名);文件名里解析不出标题的不判断;
// - 剧集只看带 SxxEyy 的文件(只有集标题的文件名常被解析成「标题」);
// - 同一批里至少有一个文件的标题对得上作品才判断别的文件(全都对不上时多半是作品名的另一种写法,
// 例如罗马音、英文译名,交给资源级识别,不拦);
// - 「对得上」= 有足够多的共同词:拉丁词(≥3 个字母,去掉常见虚词)或中日韩相邻两字,
// 文件标题的词至少一半出现在作品某个名字里。
import { parseRelease } from "../release-parser/index.ts";
const STOP = new Set(["the", "and", "of", "a", "an", "no", "to", "in", "on", "season", "part", "complete", "series"]);
const CJK = /[぀-ヿ㐀-䶿一-鿿가-힯]/;
function tokensOf(s: string): Set<string> {
const out = new Set<string>();
const t = s.normalize("NFKC").toLowerCase();
for (const w of t.match(/[a-z0-9]+(?:'[a-z]+)?/g) ?? []) {
const word = w.replace(/'[a-z]+$/, "");
if (word.length >= 3 && !STOP.has(word) && !/^\d+$/.test(word)) out.add(word);
}
// 中日韩:按连续段取相邻两字(单字段整段算一个)
for (const run of t.match(new RegExp(`${CJK.source}+`, "gu")) ?? []) {
const chars = [...run];
if (chars.length === 1) out.add(run);
for (let i = 0; i + 1 < chars.length; i++) out.add(chars[i]! + chars[i + 1]!);
}
return out;
}
/** 一个文件标题和作品名字对得上(共同词占文件标题的一半以上);文件标题没有可比的词时返回 null */
export function titleMatchesNames(fileTitle: string, names: readonly string[]): boolean | null {
const ft = tokensOf(fileTitle);
if (!ft.size) return null;
for (const n of names) {
const nt = tokensOf(n);
if (!nt.size) continue;
let shared = 0;
for (const w of ft) if (nt.has(w)) shared++;
if (shared > 0 && shared * 2 >= ft.size) return true;
}
return false;
}
export interface TitleCheckFile {
path: string;
/** 文件名(不含目录) */
name: string;
}
/**
* 一批视频文件里标题明显不是本作品的(混合包里别的作品)。返回 路径 → 解析出的文件标题。
* names:作品标题、原名、别名。
*/
export function titleMismatches(
files: readonly TitleCheckFile[],
names: readonly string[],
mediaType: "movie" | "tv",
): Map<string, string> {
const usable = names.filter((n) => n?.trim());
const judged: { path: string; title: string; ok: boolean }[] = [];
for (const f of files) {
const p = parseRelease(f.name, { isFile: true });
if (mediaType === "tv" && (p.season === undefined || p.seasonInferred || !p.episodes)) continue;
const titles = [
...new Set([p.titleCn, p.titleEn, p.title, ...(p.altTitles ?? [])].filter((t): t is string => !!t)),
];
if (!titles.length) continue;
const results = titles.map((t) => titleMatchesNames(t, usable));
if (results.every((r) => r === null)) continue;
judged.push({ path: f.path, title: titles[0]!, ok: results.some((r) => r === true) });
}
const out = new Map<string, string>();
if (!judged.some((j) => j.ok)) return out;
for (const j of judged) if (!j.ok) out.set(j.path, j.title);
return out;
}
+47
View File
@@ -5,6 +5,8 @@ import {
planOrganize,
sidecarLanguageSuffix,
sidecarStem,
titleMatchesNames,
titleMismatches,
} from "../src/index.ts";
const MiB = 1024 ** 2;
@@ -407,3 +409,48 @@ describe("原地入库计划", () => {
expect(p.requiresApproval).toBe(false);
});
});
describe("文件级标题粗核", () => {
const names = ["盗梦空间", "Inception"];
it("合集里别的电影判为不符;对得上的、解析不出标题的不拦", () => {
const r = titleMismatches(
[
{ path: "a", name: "Inception.2010.1080p.BluRay.x264-GRP.mkv" },
{ path: "b", name: "Interstellar.2014.1080p.BluRay.x264-GRP.mkv" },
{ path: "c", name: "盗梦空间.2010.2160p.mkv" },
],
names,
"movie",
);
expect([...r]).toEqual([["b", "Interstellar"]]);
});
it("全都对不上时不拦(多半是作品名的另一种写法)", () => {
expect(titleMismatches([{ path: "a", name: "Sousou.no.Frieren.S01E01.mkv" }], ["葬送的芙莉莲"], "tv").size).toBe(0);
expect(
titleMismatches(
[
{ path: "a", name: "Frieren.S01E01.1080p.WEB-DL.mkv" },
{ path: "b", name: "Sousou.no.Frieren.S01E02.1080p.WEB-DL.mkv" },
],
["葬送的芙莉莲", "Frieren: Beyond Journey's End"],
"tv",
).size,
).toBe(0);
});
it("剧集只看带 SxxEyy 的文件:只有集标题的文件名不拦", () => {
const r = titleMismatches(
[
{ path: "a", name: "Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv" },
{ path: "b", name: "Other.Show.S01E01.1080p.WEB-DL.mkv" },
{ path: "c", name: "01 - The Beginning.mkv" },
],
["人生切割术", "Severance"],
"tv",
);
expect([...r.keys()]).toEqual(["b"]);
});
it("中文标题按相邻两字比较:同系列相近的标题不拦", () => {
expect(titleMatchesNames("哈利波特与密室", ["哈利波特与魔法石"])).toBe(true);
expect(titleMatchesNames("星际穿越", ["盗梦空间"])).toBe(false);
});
});
+7 -2
View File
@@ -143,10 +143,15 @@ export function organizePlanToFileResults(
}
/**
* 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段。文件留在原处没人管,
* 被排除但"本该入库"的视频:季集没定下来、没有对应季目录、命名缺字段、文件标题明显不是本作品(待确认)。文件留在原处没人管,
* 不能算整理成功(Move 成功后会移除下载任务)。样例、过小、已在库等预期跳过不在此列。
*/
export const UNRESOLVED_EXCLUSIONS = ["episode_unresolved", "no_target_dir", "naming_failed"] as const;
export const UNRESOLVED_EXCLUSIONS = [
"episode_unresolved",
"no_target_dir",
"naming_failed",
"title_mismatch",
] as const;
const UNRESOLVED_SKIP = new Set<string>(UNRESOLVED_EXCLUSIONS.map((r) => `excluded:${r}`));
/** 文件结果的 skip_reason 是否属于"本该入库却被排除" */