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