Files
UHD_Media_Tool/packages/domain/test/differential/meta-map.ts
T
rootandClaude Opus 5.5 36dc048e89 feat(parser,sites): W5-D port title/zhconv/site parsing gaps found by MP differential
Title recognition (parseRelease / parseReleasePath):
- anime prefix tags (★N月新番, [国漫], 【22年日剧】), category lead bracket, `A / B` cn aliases
- `Name [01]`, `[TV 04]`, `#13`, `名称 12.mkv`, `S01 13-14`, `E13-14` episodes
- lead bracket as title vs group, trailing [Group], YTS.MX/Hall_of_C style groups, 字幕组 suffix
- `[dot.release.name][…]` unbracketing, `Season 2017` as year, leading date not airDate,
  date-only parent dir does not merge as TV
- original disc: Blu-ray + AVC/HEVC/VC-1/MPEG-2 without x26x/H.26x -> BluRay-Disc
- `{[tmdbid=;type=;s=;e=]}` explicit block, bangumi/anilist ids
- bare [1080]/[5.1], DTS-HD MA5.1, £ multi-group, 合集, pinyin title -> cn from subtitle,
  streaming platform table, CHS/CHT/GB/BIG5 in subtitle
zh-map: add 迴滾魷瑯臥駭; document one-to-many chars kept out.
Sites: tbody branch + header-row tightening via movieclaw-import rowSelector (18 definitions),
row-level case/hr/labels include the row itself, subtitle branch (searchType: "subtitles"),
parseSizeMp banker's rounding.
Differential snapshots regenerated; MP test sample sources registered in ledger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:09:14 +08:00

241 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 标题识别差分:把 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<string, string | number | null> & { 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<Field, string | null>;
const readJson = <T>(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<string, MpMeta>; rustMismatch: Record<string, unknown> }>("meta-mp.json");
const deviations = readJson<{ deviations: Record<string, Partial<Record<Field, Deviation>>> }>(
"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<string, string> = {
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<string>();
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<string, string>)[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<string, string> = {
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<Record<Field, { mp: string | null; ours: string | null }>> {
const a = canonMp(mp);
const b = canonOurs(oursParse(s));
const out: Partial<Record<Field, { mp: string | null; ours: string | null }>> = {};
for (const f of FIELDS) if (a[f] !== b[f]) out[f] = { mp: a[f], ours: b[f] };
return out;
}