Files
UHD_Media_Tool/packages/domain/test/differential/meta-map.ts
T
rootandClaude Opus 5.5 591cc78c7d test(porting): differential tests against MoviePilot Rust-accelerated parsers
Snapshot differential tests (known deviations registered, current state passes):
- title/path recognition: 216 samples vs MP MetaInfo/MetaInfoPath
- filter rules: 42 rules x 28 torrents vs MP FilterModule
- RSS/indexer parsing: 15 feeds, 72 indexer samples
- zhconv: 69 samples
Generator: scripts/mp-sync/gen-differential.py; report: docs/porting/mp-rust-logic-parity.md

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

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