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

+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);
});
});