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]>
178 lines
6.6 KiB
TypeScript
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;
|
|
}
|