87 lines
3.9 KiB
TypeScript
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" };
|
|
}
|