- 同一批视频里文件名标题与作品标题 / 原名 / 别名明显不符(没有足够共同词)的排除为 title_mismatch,不按本作品命名入库; - 只在同批至少一个文件对得上作品时才判断,剧集只看带 SxxEyy 的文件,用户逐文件指定过的不核; - title_mismatch 算「本该入库却被排除」:记录部分完成、开待处理、Move 不移除下载任务。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
78 lines
3.5 KiB
TypeScript
78 lines
3.5 KiB
TypeScript
// 整理的文件级标题粗核(审视 ④ 风险 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;
|
|
}
|