// 整理的文件级标题粗核(审视 ④ 风险 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 { const out = new Set(); 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 { 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(); if (!judged.some((j) => j.ok)) return out; for (const j of judged) if (!j.ok) out.set(j.path, j.title); return out; }