Files
UHD_Media_Tool/packages/domain/src/episode-resolution/rules.ts
T

87 lines
3.9 KiB
TypeScript

import { evalEpisodeOffset } from "../release-parser/offset.ts";
import type { EpisodeRange } from "../release-parser/types.ts";
import type { CanonicalEpisodes, EpisodeMappingRule } from "./types.ts";
const sameGroup = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
/** 规则对(来源季, 发布组)的具体度:季 + 发布组 3 > 仅季 2 > 仅发布组 1 > 都空 0;不适用返回 -1 */
export function ruleSpecificity(rule: EpisodeMappingRule, season: number | null, releaseGroup: string | null): number {
if (rule.season !== null && rule.season !== season) return -1;
if (rule.releaseGroup !== null && (releaseGroup === null || !sameGroup(rule.releaseGroup, releaseGroup))) return -1;
return (rule.season !== null ? 2 : 0) + (rule.releaseGroup !== null ? 1 : 0);
}
/**
* 选最具体的适用规则(作品已由调用方过滤)。
* 同一具体度有多条时按 id 字典序取第一条:同作用域重复规则本应在写入时避免,这里只保证结果确定,不做合并。
*/
export function selectRule(
rules: readonly EpisodeMappingRule[],
season: number | null,
releaseGroup: string | null,
): EpisodeMappingRule | null {
let best: EpisodeMappingRule | null = null;
let bestScore = -1;
for (const r of rules) {
const score = ruleSpecificity(r, season, releaseGroup);
if (score < 0) continue;
if (score > bestScore || (score === bestScore && best !== null && r.id < best.id)) {
best = r;
bestScore = score;
}
}
return best;
}
export type RuleApplication =
| { kind: "special"; special: NonNullable<EpisodeMappingRule["special"]> }
| { kind: "mapped"; canonical: CanonicalEpisodes; evidence: string }
| { kind: "error"; evidence: string };
/** 按规则换算(不校验)。special 规则不需要集号;expression / table 规则没有集号时报错 */
export function applyRule(rule: EpisodeMappingRule, season: number, episodes: EpisodeRange | null): RuleApplication {
if (rule.kind === "special") {
return rule.special
? { kind: "special", special: rule.special }
: { kind: "error", evidence: "rule:special_missing" };
}
if (!episodes) return { kind: "error", evidence: "rule:no_episode" };
if (rule.kind === "expression") {
if (!rule.expression) return { kind: "error", evidence: "rule:expression_missing" };
try {
const start = evalEpisodeOffset(rule.expression, episodes.start);
const end = evalEpisodeOffset(rule.expression, episodes.end);
return {
kind: "mapped",
canonical: { season: rule.targetSeason ?? season, episodes: { start, end } },
evidence: `rule:expression:${rule.expression}`,
};
} catch (e) {
return { kind: "error", evidence: `rule:expression_error:${(e as Error).message}` };
}
}
return applyTable(rule, episodes);
}
/**
* 表格规则:来源区间里的每一集找到所在条目,条目把整个来源区间映射到目标的一集
* (`27-28 → S02E02`);结果合并成一个区间。有集不在任何条目、落到不同季或目标不连续 → 失败。
*/
function applyTable(rule: EpisodeMappingRule, episodes: EpisodeRange): RuleApplication {
const entries = rule.table ?? [];
let season: number | null = null;
const targets = new Set<number>();
for (let n = episodes.start; n <= episodes.end; n++) {
const entry = entries.find((e) => n >= e.from.start && n <= e.from.end);
if (!entry) return { kind: "error", evidence: `rule:table_no_entry:${n}` };
if (season !== null && entry.to.season !== season) return { kind: "error", evidence: "rule:table_cross_season" };
season = entry.to.season;
targets.add(entry.to.episode);
}
const sorted = [...targets].sort((a, b) => a - b);
const start = sorted[0]!;
const end = sorted[sorted.length - 1]!;
if (end - start + 1 !== sorted.length) return { kind: "error", evidence: "rule:table_gap" };
return { kind: "mapped", canonical: { season: season!, episodes: { start, end } }, evidence: "rule:table" };
}