Files
UHD_Media_Tool/packages/domain/src/release-parser/path.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

178 lines
6.6 KiB
TypeScript

import { confidenceOf, parseRelease } from "./parse.ts";
import type { Confidence, PackHint, ParsedRelease, ParseOptions } from "./types.ts";
export interface PathSegments {
file: string;
parent?: string;
grandParent?: string;
}
const EXTRAS_DIR =
/^(?:featurettes?|extras?|bonus(?:es)?|behind[ ._-]the[ ._-]scenes|deleted[ ._-]scenes|interviews?|trailers?|shorts|scenes|花絮|特典|特辑|特輯|幕后|幕後)$/iu;
const SAMPLE_DIR = /^samples?$/i;
/** 把路径拆成 文件 / 父目录 / 祖父目录;附加内容目录会被跳过并记为 extra */
export function splitReleasePath(path: string): PathSegments & { extrasDir?: string; sampleDir?: boolean } {
const parts = path.split(/[\\/]+/).filter((p) => p.length > 0);
const file = parts.pop() ?? "";
let parent = parts.pop();
let extrasDir: string | undefined;
let sampleDir = false;
if (parent && (EXTRAS_DIR.test(parent) || SAMPLE_DIR.test(parent))) {
if (SAMPLE_DIR.test(parent)) sampleDir = true;
else extrasDir = parent;
parent = parts.pop();
}
const grandParent = parts.pop();
return {
file,
...(parent !== undefined ? { parent } : {}),
...(grandParent !== undefined ? { grandParent } : {}),
...(extrasDir ? { extrasDir } : {}),
...(sampleDir ? { sampleDir } : {}),
};
}
/** 合并后的置信度:由证据推导,多次合并不会把降级"洗掉" */
function mergedConfidence(r: ParsedRelease): Confidence {
let conf = confidenceOf(r);
if (r.evidence.some((e) => /_dir:/.test(e))) conf = minConfidence(conf, "multi_evidence");
if (r.evidence.some((e) => e.endsWith(":type_inferred"))) conf = "needs_confirm";
if (r.mediaTypeHint !== "tv" && r.evidence.some((e) => e.endsWith(":conflict_year"))) conf = "needs_confirm";
return conf;
}
const RANK: Record<Confidence, number> = { rule_confirmed: 2, multi_evidence: 1, needs_confirm: 0 };
const minConfidence = (a: Confidence, b: Confidence): Confidence => (RANK[a] <= RANK[b] ? a : b);
function filePackHint(r: ParsedRelease): PackHint {
if (r.airDate !== undefined || r.issue !== undefined) return "single";
if (r.episodes) return r.episodes.end > r.episodes.start ? "multi_episode" : "single";
return r.mediaTypeHint === "tv" ? "unknown" : "single";
}
/**
* 文件优先、上级目录只补空字段(对照 MP MetaInfoPath.merge)。
* 偏离:文件本身无剧集信号而目录是剧集时,MP 不合并;本项目合并名称与季号,
* 打 `<level>:type_inferred` 证据并把置信度降为 needs_confirm。
*/
export function mergeParsed(
primary: ParsedRelease,
fallback: ParsedRelease,
level: "parent_dir" | "grandparent_dir",
): ParsedRelease {
const r: ParsedRelease = {
...primary,
audio: [...primary.audio],
hdr: [...primary.hdr],
variantTags: [...primary.variantTags],
evidence: [...primary.evidence],
};
const used: string[] = [];
// 目录只有日期(`…Collection.20240501.FRDS` 这类收藏/打包日期)不算剧集信号,不参与类型合并
const dateOnly =
fallback.airDate !== undefined &&
fallback.season === undefined &&
fallback.episodes === undefined &&
fallback.issue === undefined &&
fallback.totalEpisodes === undefined;
const fbTv = fallback.mediaTypeHint === "tv" && !dateOnly;
const deviation = primary.mediaTypeHint !== "tv" && fbTv;
if (!primary.title) {
if (fallback.title) {
r.title = fallback.title;
r.titleCn = fallback.titleCn;
r.titleEn = fallback.titleEn;
used.push("title");
}
}
if (r.year === undefined && fallback.year !== undefined) {
r.year = fallback.year;
used.push("year");
}
if ((r.season === undefined || r.seasonInferred) && fallback.season !== undefined && !fallback.seasonInferred) {
r.season = fallback.season;
r.seasonInferred = undefined;
if (!r.isFile && fallback.seasonEnd !== undefined) r.seasonEnd = fallback.seasonEnd;
used.push("season");
}
const fill = <K extends keyof ParsedRelease>(k: K) => {
if (r[k] === undefined && fallback[k] !== undefined) {
r[k] = fallback[k];
used.push(String(k));
}
};
for (const k of [
"resolution",
"source",
"videoCodec",
"encoder",
"bitDepth",
"releaseGroup",
"cut",
"webSource",
"uhd",
"fps",
"subtitles",
] as const)
fill(k);
if (used.includes("source")) r.isEncode = fallback.isEncode;
if (r.audio.length === 0 && fallback.audio.length > 0) {
r.audio = [...fallback.audio];
used.push("audio");
}
if (r.hdr.length === 0 && fallback.hdr.length > 0) {
r.hdr = [...fallback.hdr];
used.push("hdr");
}
if (r.explicitIds.length === 0 && fallback.explicitIds.length > 0) {
r.explicitIds = [...fallback.explicitIds];
used.push("explicitIds");
}
if (r.contentType === "unknown" && fallback.contentType !== "unknown") {
r.contentType = fallback.contentType;
used.push("contentType");
}
if (fbTv) r.mediaTypeHint = "tv";
for (const u of used) r.evidence.push(`${level}:${u}`);
if (r.isFile) {
r.packHint = filePackHint(r);
r.isSeasonPack = false;
}
if (deviation && used.length > 0) r.evidence.push(`${level}:type_inferred`);
if (primary.year !== undefined && fallback.year !== undefined && primary.year !== fallback.year) {
// 电影合集年份冲突:以文件为准,但需要确认
r.evidence.push(`${level}:conflict_year`);
}
r.confidence = mergedConfidence(r);
return r;
}
/**
* 目录名 + 文件名合并识别。纯函数,不访问文件系统;最多看两级上级目录。
* 附加内容目录(Featurettes/Extras/花絮…)不参与命名,结果标记 contentType=extra。
*/
export function parseReleasePath(path: string | PathSegments, opts: ParseOptions = {}): ParsedRelease {
const seg = typeof path === "string" ? splitReleasePath(path) : path;
const { subtitle: _subtitle, ...dirOpts } = opts;
let r = parseRelease(seg.file, { ...opts, isFile: true });
if (seg.parent) r = mergeParsed(r, parseRelease(seg.parent, { ...dirOpts, isFile: false }), "parent_dir");
if (seg.grandParent)
r = mergeParsed(r, parseRelease(seg.grandParent, { ...dirOpts, isFile: false }), "grandparent_dir");
const extras = typeof path === "string" ? splitReleasePath(path) : undefined;
if (extras?.extrasDir && seg.parent) {
// 附加内容归属父作品:名称取父目录,文件自身名称不参与身份识别
const owner = parseRelease(seg.parent, { ...dirOpts, isFile: false });
r.title = owner.title;
r.titleCn = owner.titleCn;
r.titleEn = owner.titleEn;
r.contentType = "extra";
r.evidence.push("dir:extras");
r.confidence = "needs_confirm";
}
if (extras?.sampleDir) r.evidence.push("dir:sample");
return r;
}