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 } | { 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(); 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" }; }