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]>
241 lines
7.5 KiB
TypeScript
241 lines
7.5 KiB
TypeScript
/**
|
||
* 标题识别差分:把 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;
|
||
}
|