feat(domain): 集数解析纯函数——规则、原号校验(滞后放行)、绝对集号与播出日期换算、建议规则
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
111cbddb36
commit
7b9939e0a0
13 files changed
+991
-1
No files matched your search
@@ -0,0 +1,86 @@
|
||||
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" };
|
||||
}
|
||||
Reference in new issue
Block a user