/** * 标题识别差分:把 MP MetaInfo 输出与我们的 ParsedRelease 映射到同一"规范形",逐字段比较。 * 规范化只抹平表示差异(大小写、分隔符、同义写法),不抹平语义差异;语义差异登记在 meta-deviations.json。 */ import { readFileSync } from "node:fs"; import { type ParsedRelease, parseRelease, parseReleasePath } from "../../src/release-parser/index.ts"; export interface MetaSample { id: string; kind: "title" | "path"; input: string; subtitle?: string; source: string; ourNote?: string; } export type MpMeta = Record & { error?: string }; export type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "representation"; export interface Deviation { kind: DeviationKind; mp: string | null; ours: string | null; note: string; } /** 参与比较的字段(media_id = find_metainfo 显式 ID 标签;edition = resource_type + resource_effect,由这两项覆盖;customization 双方均未配置) */ export const FIELDS = [ "name", "cn_name", "en_name", "year", "type", "begin_season", "end_season", "begin_episode", "end_episode", "resource_pix", "resource_type", "resource_effect", "video_encode", "audio_encode", "resource_team", "part", "web_source", "media_id", ] as const; export type Field = (typeof FIELDS)[number]; export type Canon = Record; const readJson = (name: string): T => JSON.parse(readFileSync(new URL(`./${name}`, import.meta.url), "utf8")) as T; export function loadMeta() { const samples = readJson<{ samples: MetaSample[] }>("meta-samples.json").samples; const mp = readJson<{ python: Record; rustMismatch: Record }>("meta-mp.json"); const deviations = readJson<{ deviations: Record>> }>( "meta-deviations.json", ).deviations; return { samples, mp, deviations }; } const str = (v: unknown): string | null => (v === null || v === undefined || v === "" ? null : String(v)); /** 名称:忽略大小写与标点/空白(MP 英文名做 Title Case,我们保留原大小写) */ const canonName = (v: unknown) => { const s = str(v); return s === null ? null : s .normalize("NFKC") .toLowerCase() .replace(/[\s._\-::·'’!!??,,&]+/g, "") || null; }; function canonSource(v: string | null): string | null { if (!v) return null; const u = v.toUpperCase(); const cls = /REMUX/.test(u) ? "REMUX" : /BLURAY|BDRIP|\bBD\b|BLU/.test(u) ? "BluRay" : /WEB-?DL|^WEB$|\bWEB\b(?!RIP)/.test(u) ? "WEB-DL" : /WEBRIP/.test(u) ? "WEBRip" : /HDTV|UHDTV/.test(u) ? "HDTV" : /HDRIP/.test(u) ? "HDRip" : /DVD/.test(u) ? "DVD" : /HDDVD/.test(u) ? "HDDVD" : null; const uhd = /\bUHD\b/.test(u.replace(/UHDTV/g, "")) ? "UHD " : ""; return `${uhd}${cls ?? `?${u}`}`.trim(); } const HDR_CANON: Record = { HDR: "HDR10", HDR10: "HDR10", "HDR10+": "HDR10+", HDR10PLUS: "HDR10+", HDR10P: "HDR10+", DV: "DV", DOVI: "DV", HLG: "HLG", HDRVIVID: "HDR Vivid", VIVID: "HDR Vivid", "HDR VIVID": "HDR Vivid", }; function canonHdrList(tokens: string[]): string | null { const set = new Set(); for (const t of tokens) { const k = t.toUpperCase().replace(/\s+/g, ""); set.add(HDR_CANON[k] ?? HDR_CANON[t.toUpperCase()] ?? `?${t}`); } return set.size ? [...set].sort().join(",") : null; } function canonCodec(v: string | null): string | null { if (!v) return null; const u = v.toUpperCase().replace(/[\s.]/g, ""); if (/265|HEVC/.test(u)) return "H.265"; if (/264|AVC/.test(u)) return "H.264"; if (/AV1/.test(u)) return "AV1"; if (/VC-?1/.test(u)) return "VC-1"; if (/MPEG-?2/.test(u)) return "MPEG-2"; if (/XVID/.test(u)) return "XVID"; if (/DIVX/.test(u)) return "DIVX"; return `?${u}`; } const canonAudio = (v: string | null) => v ? v .toUpperCase() .replace(/DD\+/g, "DDP") .replace(/[\s\-_.]/g, "") || null : null; const canonGroups = (v: string | null) => v ? v .split(/[@&]/) .map((g) => g.trim().toLowerCase()) .filter(Boolean) .sort() .join("@") : null; function canonPix(v: string | null): string | null { if (!v) return null; const u = v.toLowerCase(); return ({ "4k": "2160p", "8k": "4320p", "2k": "1440p" } as Record)[u] ?? u; } const canonPart = (v: string | null) => (v ? v.toUpperCase().replace(/[\s._-]/g, "") : null); const canonType = (v: string | null) => v === "电视剧" ? "tv" : v === "电影" ? "movie" : v === "未知" || v === null ? "unknown" : v; export function canonMp(m: MpMeta): Canon { const effect = str(m.resource_effect); const hdrTokens = effect ? effect.split(/\s+/).filter((t) => /^(HDR|DV|DOVI|HLG|VIVID|HDRVIVID)/i.test(t)) : []; return { name: canonName(m.name), cn_name: canonName(m.cn_name), en_name: canonName(m.en_name), year: str(m.year), type: canonType(str(m.type)), begin_season: str(m.begin_season), end_season: str(m.end_season), begin_episode: str(m.begin_episode), end_episode: str(m.end_episode), resource_pix: canonPix(str(m.resource_pix)), resource_type: canonSource(str(m.resource_type)), resource_effect: canonHdrList(hdrTokens), video_encode: canonCodec(str(m.video_encode)), audio_encode: canonAudio(str(m.audio_encode)), resource_team: canonGroups(str(m.resource_team)), part: canonPart(str(m.part)), web_source: canonName(m.web_source), media_id: m.media_id ? `${m.media_source}:${m.media_id}` : null, }; } const PROVIDER: Record = { tmdb: "themoviedb", douban: "douban", imdb: "imdb", tvdb: "tvdb", bangumi: "bangumi", anilist: "anilist", }; export function oursParse(s: MetaSample): ParsedRelease { return s.kind === "path" ? parseReleasePath(s.input) : parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {}); } export function canonOurs(r: ParsedRelease): Canon { const src = r.source ? `${r.uhd ? "UHD " : ""}${r.source === "BluRay-Disc" ? "BluRay" : r.source === "DVDRip" ? "DVD" : r.source}` : r.uhd ? "UHD ?" : null; const eps = r.episodes; return { name: canonName(r.title), cn_name: canonName(r.titleCn), en_name: canonName(r.titleEn), year: str(r.year), type: r.mediaTypeHint, begin_season: str(r.season), end_season: str(r.seasonEnd), begin_episode: str(eps?.start), end_episode: eps && eps.end !== eps.start ? String(eps.end) : null, resource_pix: canonPix(str(r.resolution)), resource_type: src, resource_effect: r.hdr.length ? canonHdrList(r.hdr) : null, video_encode: canonCodec(str(r.videoCodec)), audio_encode: r.audio.length ? canonAudio(r.audio.join(" ")) : null, resource_team: canonGroups(str(r.releaseGroup?.name)), part: canonPart(str(r.part)), web_source: canonName(r.webSource), media_id: r.explicitIds[0] ? `${PROVIDER[r.explicitIds[0].provider]}:${r.explicitIds[0].id}` : null, }; } /** 逐字段差异(规范形不等) */ export function diffSample( s: MetaSample, mp: MpMeta, ): Partial> { const a = canonMp(mp); const b = canonOurs(oursParse(s)); const out: Partial> = {}; for (const f of FIELDS) if (a[f] !== b[f]) out[f] = { mp: a[f], ours: b[f] }; return out; }