From 7b9939e0a072ed91720b951b16aaa27e73dd775c Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 17:18:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(domain):=20=E9=9B=86=E6=95=B0=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=BA=AF=E5=87=BD=E6=95=B0=E2=80=94=E2=80=94=E8=A7=84?= =?UTF-8?q?=E5=88=99=E3=80=81=E5=8E=9F=E5=8F=B7=E6=A0=A1=E9=AA=8C(?= =?UTF-8?q?=E6=BB=9E=E5=90=8E=E6=94=BE=E8=A1=8C)=E3=80=81=E7=BB=9D?= =?UTF-8?q?=E5=AF=B9=E9=9B=86=E5=8F=B7=E4=B8=8E=E6=92=AD=E5=87=BA=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E6=8D=A2=E7=AE=97=E3=80=81=E5=BB=BA=E8=AE=AE=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plan/episode-resolution.md | 2 +- packages/domain/README.md | 1 + .../domain/src/episode-resolution/convert.ts | 136 +++++++++ .../domain/src/episode-resolution/index.ts | 22 ++ .../domain/src/episode-resolution/resolve.ts | 131 ++++++++ .../domain/src/episode-resolution/rules.ts | 86 ++++++ .../domain/src/episode-resolution/suggest.ts | 78 +++++ .../domain/src/episode-resolution/table.ts | 44 +++ .../domain/src/episode-resolution/types.ts | 108 +++++++ .../domain/src/episode-resolution/validate.ts | 32 ++ packages/domain/src/index.ts | 1 + .../test/episode-resolution-suggest.test.ts | 69 +++++ .../domain/test/episode-resolution.test.ts | 282 ++++++++++++++++++ 13 files changed, 991 insertions(+), 1 deletion(-) create mode 100644 packages/domain/src/episode-resolution/convert.ts create mode 100644 packages/domain/src/episode-resolution/index.ts create mode 100644 packages/domain/src/episode-resolution/resolve.ts create mode 100644 packages/domain/src/episode-resolution/rules.ts create mode 100644 packages/domain/src/episode-resolution/suggest.ts create mode 100644 packages/domain/src/episode-resolution/table.ts create mode 100644 packages/domain/src/episode-resolution/types.ts create mode 100644 packages/domain/src/episode-resolution/validate.ts create mode 100644 packages/domain/test/episode-resolution-suggest.test.ts create mode 100644 packages/domain/test/episode-resolution.test.ts diff --git a/docs/plan/episode-resolution.md b/docs/plan/episode-resolution.md index 6d314553..bdb91a61 100644 --- a/docs/plan/episode-resolution.md +++ b/docs/plan/episode-resolution.md @@ -52,7 +52,7 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一 ## 状态 -- [ ] 批次 1 +- [x] 批次 1 - [ ] 批次 2 - [ ] 批次 3 - [ ] 批次 4 diff --git a/packages/domain/README.md b/packages/domain/README.md index 9c69b3ca..d31fe573 100644 --- a/packages/domain/README.md +++ b/packages/domain/README.md @@ -12,6 +12,7 @@ | --- | --- | --- | | `release-parser/` | `parseRelease(title, opts)`、`parseReleasePath(path \| segments, opts)`、`mergeParsed`、`scanCnMarkers`、`evalEpisodeOffset`、`cnToInt`、`fileKindOf`、`expandEpisodes`、`RELEASE_GROUP_SEED`、`PARSER_VERSION` | 标题/文件名/目录名 → `ParsedRelease`。行为对照 MP MetaInfo/MetaInfoPath,偏离点见下 | | `identity/` | `scoreCandidate`、`rankCandidates`、`resolveIdentity`、`normalizeTitle`、`stripSeasonSuffix`、`toSimplified` | 候选(TMDB 或本地别名召回,统一为 `IdentityCandidate`)按证据等级排序;年份容差 ±1;只给等级不给概率 | +| `episode-resolution/` | `resolveEpisodes`、`validateEpisodes`、`convertEpisodes`、`selectRule`、`applyRule`、`suggestRule` | 解析出的季集号 → 媒体库规范季集:规则 → 原号校验(元数据滞后放行)→ 绝对集号 / 播出日期换算 → 待确认。见 docs/plan/episode-resolution.md | | `slot/` | `evaluateCandidate(spec, parsed, current?, ctx?)`、`rankForSlot`、`checkHardConditions`、`softRank`、`qualityTier`、`priorityLadder`/`currentPri`、`improvableUnits`、`describeViolation`、`validateKeywordPattern`、`transition`/`canTransition`、`transitionWithInflight`、`movieCompletion`、`unitCompletion`、`checkDistinctAssets`、`resolveEpisodeTotal`、`computeUnitGaps`/`seasonProgress`/`unitsOverlap` | 硬条件(区分缺字段与不满足;含排除来源、HDR/编码集合、每集体积、关键词正则、站点/做种/免费,站点事实经 `EvaluationContext`)、软偏好排序、优先级阶梯(MP `>` 分层 → pri 0–100 + 逐层 trace)、升级阈值与洗版模式(movie/per_unit/full_pack)、状态机(含 Slot×单元多在途聚合)、完成条件、季总集数账本(只升、回退不低于已覆盖最高集)、缺集 = 单元 ∩ 范围 − 已覆盖 − 在途 | | `rules/` | `parseRuleString`/`parseExpr`、`serializeLayers`/`serializeExpr`、`normalizeExpr`、`BUILTIN_ATOMS`、`customAtomFromMp`、`evaluateRuleGroup`/`evaluateRuleChain`、`testAtom` | MP 规则串 ↔ 结构化层(`>` 分层,层内 `! & \|` 与括号,错误带位置);内置关键字对齐 MP(能用解析字段的用字段,其余沿用 MP 正则,音乐 13 条不支持);多组串联取交集、pri 取最后一个带层的组;经 `SlotSpec.rules` 接入 `evaluateCandidate`。差分见 `test/differential/rules.test.ts` 与 `docs/runbook/rules.md` | | `acquisition/` | `determineMode`、`sortByPriority`/`priorityOf`、`checkBudget`、`evaluateCacheVolume` | 新作品/追更补缺/升级判定;即时>追更>新作品>升级(可配置);每日预算;缓存卷硬阈值与恢复水位 | diff --git a/packages/domain/src/episode-resolution/convert.ts b/packages/domain/src/episode-resolution/convert.ts new file mode 100644 index 00000000..a27298f3 --- /dev/null +++ b/packages/domain/src/episode-resolution/convert.ts @@ -0,0 +1,136 @@ +import type { EpisodeRange } from "../release-parser/types.ts"; +import { cumulativeSeasons, seasonOf } from "./table.ts"; +import type { CanonicalEpisodes, SeasonTable } from "./types.ts"; +import { validateEpisodes } from "./validate.ts"; + +export type ConversionMethod = "absolute_in_season" | "absolute_cross_season" | "air_date"; + +export interface ConversionCandidate { + method: ConversionMethod; + canonical: CanonicalEpisodes; + evidence: string; +} + +const shift = (r: EpisodeRange, by: number): EpisodeRange => ({ start: r.start - by, end: r.end - by }); +const key = (c: CanonicalEpisodes) => `${c.season}:${c.episodes.start}-${c.episodes.end}`; + +/** + * 季内绝对集号:季号可信(非推定)、≥ 2,集号从作品开头或上一季接续编号。 + * 候选偏移:前面所有季的总集数之和(从第 1 季起连续编号);季 ≥ 3 时再试只减上一季(分 cour 接续)。 + * 区间整体平移;前面任一季总集数未知则该偏移不成立。 + */ +export function inSeasonCandidates(season: number, episodes: EpisodeRange, table: SeasonTable): ConversionCandidate[] { + if (season < 2) return []; + const offsets = new Set(); + const cum = cumulativeSeasons(table).find((c) => c.season === season - 1); + if (cum) offsets.add(cum.before + cum.total); + const prev = seasonOf(table, season - 1)?.total; + if (season >= 3 && typeof prev === "number") offsets.add(prev); + const out: ConversionCandidate[] = []; + for (const off of offsets) { + if (off <= 0) continue; + const r = shift(episodes, off); + if (r.start < 1) continue; + out.push({ + method: "absolute_in_season", + canonical: { season, episodes: r }, + evidence: `absolute_in_season:-${off}`, + }); + } + return out; +} + +export type CrossSeasonResult = + | { kind: "candidate"; candidate: ConversionCandidate } + | { kind: "split"; evidence: string } + | { kind: "none" }; + +/** + * 跨季绝对集号:只在季号是推定的(`seasonInferred`)时用。按季 1..n 累加总集数定位; + * 区间两端落在不同季(如 12-13 跨 S1/S2)视为失败,不拆成两条。 + */ +export function crossSeasonCandidate(episodes: EpisodeRange, table: SeasonTable): CrossSeasonResult { + const seasons = cumulativeSeasons(table); + const first = seasons[0]; + if (!first || episodes.start <= first.total) { + if (first && episodes.end > first.total) return { kind: "split", evidence: `absolute_cross_season:split` }; + return { kind: "none" }; + } + const hit = seasons.find((s) => episodes.start > s.before && episodes.start <= s.before + s.total); + if (!hit) return { kind: "none" }; + if (episodes.end > hit.before + hit.total) return { kind: "split", evidence: `absolute_cross_season:split` }; + return { + kind: "candidate", + candidate: { + method: "absolute_cross_season", + canonical: { season: hit.season, episodes: shift(episodes, hit.before) }, + evidence: `absolute_cross_season:-${hit.before}`, + }, + }; +} + +/** + * 综艺按播出日期对集:`airDate`(YYYYMMDD)与季表中每集播出日期精确比较,只看季 ≥ 1。 + * 季号可信(非推定)且该季内有命中时只取该季。 + */ +export function airDateCandidates(airDate: string, table: SeasonTable, season: number | null): ConversionCandidate[] { + const m = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(airDate); + if (!m) return []; + const iso = `${m[1]}-${m[2]}-${m[3]}`; + let hits: CanonicalEpisodes[] = []; + for (const s of table.seasons) { + if (s.season < 1) continue; + for (const e of s.episodes ?? []) { + if (e.airDate === iso) hits.push({ season: s.season, episodes: { start: e.episode, end: e.episode } }); + } + } + if (season !== null && hits.some((h) => h.season === season)) hits = hits.filter((h) => h.season === season); + return hits.map((canonical) => ({ method: "air_date", canonical, evidence: `air_date:${iso}` })); +} + +export interface ConversionOutcome { + /** 去重后、校验为 pass 的候选 */ + candidates: ConversionCandidate[]; + evidence: string[]; +} + +/** + * 确定性换算(判定流程第 3 步):收集全部方法的候选,换算结果必须校验为 pass(滞后放行不算), + * 不同方法得到同一季集只算一个解。是否唯一、是否与滞后冲突由调用方判断。 + */ +export function convertEpisodes(input: { + season: number | null; + seasonInferred: boolean; + episodes: EpisodeRange | null; + airDate: string | null; + variety: boolean; + table: SeasonTable; +}): ConversionOutcome { + const { season, seasonInferred, episodes, airDate, table } = input; + const evidence: string[] = []; + const raw: ConversionCandidate[] = []; + if (episodes && season !== null) { + if (!seasonInferred) raw.push(...inSeasonCandidates(season, episodes, table)); + if (seasonInferred && season === 1) { + const cross = crossSeasonCandidate(episodes, table); + if (cross.kind === "candidate") raw.push(cross.candidate); + if (cross.kind === "split") evidence.push(cross.evidence); + } + } + if (input.variety && airDate) { + raw.push(...airDateCandidates(airDate, table, seasonInferred ? null : season)); + } + const seen = new Set(); + const candidates: ConversionCandidate[] = []; + for (const c of raw) { + const v = validateEpisodes(c.canonical.season, c.canonical.episodes, table); + if (v !== "pass") { + evidence.push(`${c.evidence}:${v}`); + continue; + } + if (seen.has(key(c.canonical))) continue; + seen.add(key(c.canonical)); + candidates.push(c); + } + return { candidates, evidence }; +} diff --git a/packages/domain/src/episode-resolution/index.ts b/packages/domain/src/episode-resolution/index.ts new file mode 100644 index 00000000..4a1ea2e9 --- /dev/null +++ b/packages/domain/src/episode-resolution/index.ts @@ -0,0 +1,22 @@ +export { + airDateCandidates, + type ConversionCandidate, + type ConversionMethod, + type ConversionOutcome, + type CrossSeasonResult, + convertEpisodes, + crossSeasonCandidate, + inSeasonCandidates, +} from "./convert.ts"; +export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts"; +export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts"; +export { + SUGGEST_MIN_SAMPLES, + SUGGEST_PAIR_DAYS, + type SuggestedRule, + type SuggestSample, + suggestRule, +} from "./suggest.ts"; +export { airDateOf, daysBetween } from "./table.ts"; +export * from "./types.ts"; +export { LAG_TOLERANCE, validateEpisodes } from "./validate.ts"; diff --git a/packages/domain/src/episode-resolution/resolve.ts b/packages/domain/src/episode-resolution/resolve.ts new file mode 100644 index 00000000..060b2af5 --- /dev/null +++ b/packages/domain/src/episode-resolution/resolve.ts @@ -0,0 +1,131 @@ +import { convertEpisodes } from "./convert.ts"; +import { applyRule, selectRule } from "./rules.ts"; +import { airDateOf, daysBetween } from "./table.ts"; +import { + type CanonicalEpisodes, + type EpisodeResolution, + MAIN_CONTENT_TYPES, + type ResolveEpisodesInput, +} from "./types.ts"; +import { validateEpisodes } from "./validate.ts"; + +/** 换算结果与「元数据滞后」冲突时,发布时间与换算后那集播出日期相差不超过这么多天才认换算 */ +export const CONVERSION_NEAR_DAYS = 7; + +/** + * 集数解析总入口(设计文档判定流程 1→4)。纯函数,时间等事实由调用方传入。 + * 1 有适用规则 → 按规则换算并校验;fail → unresolved + ruleFailed(不回退到确定性换算) + * 2 原号校验 pass → raw + * 3 确定性换算(只取唯一解):原号 fail 时唯一解即采用;原号滞后放行(unverified)时, + * 唯一解还要发布时间贴近目标集播出日期(按日期对集的除外),否则按滞后放行 + * 4 仍不行 → unresolved + */ +export function resolveEpisodes(input: ResolveEpisodesInput): EpisodeResolution { + const { parsed, kind, table, rules } = input; + const publishedAt = input.publishedAt ?? null; + const source: EpisodeResolution["source"] = {}; + if (parsed.season !== undefined) source.season = parsed.season; + if (parsed.episodes) source.episodes = parsed.episodes; + if (parsed.airDate) source.airDate = parsed.airDate; + const result = (r: Omit): EpisodeResolution => ({ source, ...r }); + const skipped = (why: string) => result({ method: "skipped", canonical: null, evidence: [why] }); + + if (kind !== "series" && kind !== "variety") return skipped(`kind:${kind}`); + if (!MAIN_CONTENT_TYPES.includes(parsed.contentType)) return skipped(`content_type:${parsed.contentType}`); + if (parsed.season === 0) return skipped("season:0"); + const episodes = parsed.episodes ?? null; + const airDate = parsed.airDate ?? null; + if (!episodes && !airDate) return skipped("no_episode"); + + // 有集号无季号按第 1 季推定(与解析器一致) + const season = parsed.season ?? (episodes ? 1 : null); + const seasonInferred = parsed.seasonInferred === true || (parsed.season === undefined && season !== null); + const evidence: string[] = []; + + // 1 规则 + const rule = selectRule(rules, season, parsed.releaseGroup?.name ?? null); + if (rule && season !== null) { + const applied = applyRule(rule, season, episodes); + if (applied.kind === "special") { + return result({ + method: "rule", + canonical: null, + ruleId: rule.id, + special: applied.special, + evidence: [`rule:special:${applied.special}`], + }); + } + if (applied.kind === "error") { + return result({ method: "unresolved", canonical: null, ruleFailed: rule.id, evidence: [applied.evidence] }); + } + const v = validateEpisodes(applied.canonical.season, applied.canonical.episodes, table); + if (v === "fail") { + return result({ + method: "unresolved", + canonical: null, + ruleFailed: rule.id, + evidence: [applied.evidence, "rule:validate_fail"], + }); + } + return result({ + method: "rule", + canonical: applied.canonical, + ruleId: rule.id, + evidence: [applied.evidence, `validate:${v}`], + }); + } + + // 2 原号校验 + let rawStatus: "unverified" | "fail" | null = null; + let rawCanonical: CanonicalEpisodes | null = null; + if (episodes && season !== null) { + rawCanonical = { season, episodes }; + const v = validateEpisodes(season, episodes, table); + if (v === "pass") return result({ method: "raw", canonical: rawCanonical, evidence: ["validate:pass"] }); + rawStatus = v; + evidence.push(`validate:${v}`); + } + + // 3 确定性换算 + const conv = convertEpisodes({ season, seasonInferred, episodes, airDate, variety: kind === "variety", table }); + evidence.push(...conv.evidence); + const unique = conv.candidates.length === 1 ? conv.candidates[0]! : null; + if (conv.candidates.length > 1) + evidence.push(`conversion:ambiguous:${conv.candidates.map((c) => c.evidence).join(",")}`); + + if (rawStatus === "unverified" && rawCanonical) { + if (unique) { + if (unique.method === "air_date") return accepted(unique); + const target = airDateOf(table, unique.canonical.season, unique.canonical.episodes.start); + const d = target && publishedAt ? daysBetween(publishedAt, target) : null; + if (d !== null && d <= CONVERSION_NEAR_DAYS) return accepted(unique, `published_near:${d}d`); + evidence.push(`${unique.evidence}:${d === null ? "no_date" : `published_far:${d}d`}`); + } + return result({ method: "unverified", canonical: rawCanonical, evidence }); + } + if (unique) return accepted(unique); + + // 只有播出日期、对不上:元数据没覆盖到这一天(没有每集日期、或作品未完结、或日期晚于已知最后一集)按滞后放行, + // canonical 为空表示照用解析结果;只有已完结且已知日期覆盖到这一天才转待确认 + if (!episodes && airDate && conv.candidates.length === 0 && !coveredAirDate(airDate)) { + return result({ method: "unverified", canonical: null, evidence: [...evidence, "air_date:not_covered"] }); + } + + // 4 待确认 + if (conv.candidates.length === 0) evidence.push("conversion:none"); + return result({ method: "unresolved", canonical: null, evidence }); + + function coveredAirDate(d: string): boolean { + if (!table.ended) return false; + const iso = d.replace(/^(\d{4})-?(\d{2})-?(\d{2})$/, "$1-$2-$3"); + return table.seasons.some((s) => (s.episodes ?? []).some((e) => e.airDate !== null && e.airDate >= iso)); + } + + function accepted(c: (typeof conv.candidates)[number], extra?: string): EpisodeResolution { + return result({ + method: c.method, + canonical: c.canonical, + evidence: [...evidence, c.evidence, ...(extra ? [extra] : [])], + }); + } +} diff --git a/packages/domain/src/episode-resolution/rules.ts b/packages/domain/src/episode-resolution/rules.ts new file mode 100644 index 00000000..02f9bb72 --- /dev/null +++ b/packages/domain/src/episode-resolution/rules.ts @@ -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 } + | { 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" }; +} diff --git a/packages/domain/src/episode-resolution/suggest.ts b/packages/domain/src/episode-resolution/suggest.ts new file mode 100644 index 00000000..c017b874 --- /dev/null +++ b/packages/domain/src/episode-resolution/suggest.ts @@ -0,0 +1,78 @@ +import { evalEpisodeOffset } from "../release-parser/offset.ts"; +import type { EpisodeRange } from "../release-parser/types.ts"; +import { daysBetween } from "./table.ts"; +import type { SeasonTable } from "./types.ts"; + +/** 建议规则配对时,发布时间与播出日期允许相差的天数(字幕组通常当天到隔天出,留出周末与时区) */ +export const SUGGEST_PAIR_DAYS = 3; +/** 至少这么多样本都成立才给建议,单个样本任何偏移都能凑上 */ +export const SUGGEST_MIN_SAMPLES = 2; + +export interface SuggestSample { + episodes: EpisodeRange; + publishedAt: string; +} + +export interface SuggestedRule { + expression: string; + targetSeason: number; + samples: { + source: EpisodeRange; + target: { season: number; episode: number }; + publishedAt: string; + airDate: string; + }[]; +} + +const offsetExpr = (n: number) => (n === 0 ? "EP" : n > 0 ? `EP+${n}` : `EP${n}`); + +/** + * 从同作品 + 季 + 发布组的待确认资源推导建议规则:用发布时间对播出日期(±SUGGEST_PAIR_DAYS)配对, + * 试 `EP±n` / `EP*2` / `EP*2-1`。全部样本成立、样本数 ≥ SUGGEST_MIN_SAMPLES、且只有一个表达式成立才返回。 + * 只看区间起始集号(双集按起始集配对)。 + */ +export function suggestRule(samples: readonly SuggestSample[], table: SeasonTable): SuggestedRule | null { + if (samples.length < SUGGEST_MIN_SAMPLES) return null; + const aired: { season: number; episode: number; airDate: string }[] = []; + for (const s of table.seasons) { + if (s.season < 1) continue; + for (const e of s.episodes ?? []) + if (e.airDate) aired.push({ season: s.season, episode: e.episode, airDate: e.airDate }); + } + const near = (publishedAt: string) => + aired.filter((a) => { + const d = daysBetween(publishedAt, a.airDate); + return d !== null && d <= SUGGEST_PAIR_DAYS; + }); + + // 候选来自第一个样本的配对;其余样本逐一验证 + const first = samples[0]!; + const candidates = new Map(); + const add = (expression: string, targetSeason: number) => + candidates.set(`${targetSeason}|${expression}`, { expression, targetSeason }); + for (const a of near(first.publishedAt)) { + const ep = first.episodes.start; + add(offsetExpr(a.episode - ep), a.season); + if (a.episode === ep * 2) add("EP*2", a.season); + if (a.episode === ep * 2 - 1) add("EP*2-1", a.season); + } + + const valid: SuggestedRule[] = []; + for (const c of candidates.values()) { + const pairs: SuggestedRule["samples"] = []; + for (const s of samples) { + const target = evalEpisodeOffset(c.expression, s.episodes.start); + const hit = aired.find((a) => a.season === c.targetSeason && a.episode === target); + const d = hit ? daysBetween(s.publishedAt, hit.airDate) : null; + if (!hit || d === null || d > SUGGEST_PAIR_DAYS) break; + pairs.push({ + source: s.episodes, + target: { season: hit.season, episode: hit.episode }, + publishedAt: s.publishedAt, + airDate: hit.airDate, + }); + } + if (pairs.length === samples.length) valid.push({ ...c, samples: pairs }); + } + return valid.length === 1 ? valid[0]! : null; +} diff --git a/packages/domain/src/episode-resolution/table.ts b/packages/domain/src/episode-resolution/table.ts new file mode 100644 index 00000000..3e9070a7 --- /dev/null +++ b/packages/domain/src/episode-resolution/table.ts @@ -0,0 +1,44 @@ +import type { SeasonInfo, SeasonTable } from "./types.ts"; + +const DAY_MS = 86_400_000; + +/** 日期或时间字符串 → UTC 日序号;`YYYY-MM-DD` / `YYYYMMDD` 按当天 0 点 */ +export function dayNumber(value: string): number | null { + const ymd = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(value); + const ms = ymd ? Date.parse(`${ymd[1]}-${ymd[2]}-${ymd[3]}T00:00:00Z`) : Date.parse(value); + return Number.isNaN(ms) ? null : Math.floor(ms / DAY_MS); +} + +/** 两个日期相差的天数(绝对值);任一无法解析返回 null */ +export function daysBetween(a: string, b: string): number | null { + const x = dayNumber(a); + const y = dayNumber(b); + return x === null || y === null ? null : Math.abs(x - y); +} + +export function seasonOf(table: SeasonTable, season: number): SeasonInfo | undefined { + return table.seasons.find((s) => s.season === season); +} + +export function latestSeasonOf(table: SeasonTable): number | null { + if (table.latestSeason !== null) return table.latestSeason; + const nums = table.seasons.map((s) => s.season).filter((n) => n >= 1); + return nums.length ? Math.max(...nums) : null; +} + +/** 某集的播出日期(YYYY-MM-DD),未知返回 null */ +export function airDateOf(table: SeasonTable, season: number, episode: number): string | null { + return seasonOf(table, season)?.episodes?.find((e) => e.episode === episode)?.airDate ?? null; +} + +/** 季 1..n 依次累加总集数;遇到缺季或总集数未知即停止。返回 [季, 该季之前的累计, 该季总集数][] */ +export function cumulativeSeasons(table: SeasonTable): { season: number; before: number; total: number }[] { + const out: { season: number; before: number; total: number }[] = []; + let before = 0; + for (let s = 1; ; s++) { + const total = seasonOf(table, s)?.total; + if (total === undefined || total === null) return out; + out.push({ season: s, before, total }); + before += total; + } +} diff --git a/packages/domain/src/episode-resolution/types.ts b/packages/domain/src/episode-resolution/types.ts new file mode 100644 index 00000000..d3159987 --- /dev/null +++ b/packages/domain/src/episode-resolution/types.ts @@ -0,0 +1,108 @@ +import type { ContentType, EpisodeRange, ParsedRelease } from "../release-parser/types.ts"; + +/** 季表里已知的一集:播出日期 YYYY-MM-DD,未知为 null */ +export interface SeasonEpisodeInfo { + episode: number; + airDate: string | null; +} + +export interface SeasonInfo { + season: number; + /** 该季总集数;元数据还没给出时为 null */ + total: number | null; + episodes?: SeasonEpisodeInfo[]; +} + +/** 作品的季表(来自元数据,调用方组装)。季 0(特别篇)可以在表里,换算时忽略 */ +export interface SeasonTable { + seasons: SeasonInfo[]; + /** 最新季;null 时取表中最大季号 */ + latestSeason: number | null; + /** 作品已完结:完结后不再有「元数据滞后」放行 */ + ended: boolean; +} + +export type EpisodeSpecialKind = "special" | "extra" | "ignore"; + +export interface EpisodeMappingTableEntry { + /** 来源集号区间(含两端) */ + from: EpisodeRange; + /** 整个来源区间映射到的一集(如 `27-28 → S02E02`) */ + to: { season: number; episode: number }; +} + +/** + * 映射规则。作品由调用方过滤,停用的规则也由调用方剔除,这里只看季 + 发布组。 + * - expression:复用 `evalEpisodeOffset`,目标季缺省为来源季 + * - table:按来源区间逐条映射 + * - special:映射为特别篇 / 花絮 / 忽略,不再校验 + */ +export interface EpisodeMappingRule { + id: string; + /** 来源季;null = 任意季 */ + season: number | null; + /** 发布组;null = 任意发布组。比较忽略大小写 */ + releaseGroup: string | null; + kind: "expression" | "table" | "special"; + expression?: string; + targetSeason?: number; + table?: EpisodeMappingTableEntry[]; + special?: EpisodeSpecialKind; +} + +export type EpisodeValidation = "pass" | "unverified" | "fail"; + +export type EpisodeResolutionMethod = + | "raw" + | "rule" + | "absolute_in_season" + | "absolute_cross_season" + | "air_date" + | "unverified" + | "unresolved" + | "skipped"; + +export interface CanonicalEpisodes { + season: number; + episodes: EpisodeRange; +} + +export interface EpisodeResolution { + method: EpisodeResolutionMethod; + /** 发布者声称的季集(解析结果原样);airDate 为 YYYYMMDD */ + source: { season?: number; episodes?: EpisodeRange; airDate?: string }; + /** + * 媒体库应当使用的季集。 + * - raw / rule / absolute_* / air_date:换算后的值 + * - unverified:原号(元数据滞后放行) + * - unresolved / skipped / special 规则:null(skipped 时调用方照用解析结果) + */ + canonical: CanonicalEpisodes | null; + ruleId?: string; + /** 规则算出的结果校验不通过时,记失败规则的 id(调用方停用规则并开待处理) */ + ruleFailed?: string; + special?: EpisodeSpecialKind; + /** 判定依据(规则名式短串),不含概率 */ + evidence: string[]; +} + +/** 作品类型,与 contracts MediaKind 同值;只有 series / variety 参与集数解析 */ +export type EpisodeResolutionKind = "movie" | "series" | "variety" | "collection" | "other"; + +export type ResolveParsedInput = Pick< + ParsedRelease, + "season" | "seasonInferred" | "episodes" | "airDate" | "contentType" | "releaseGroup" +>; + +export interface ResolveEpisodesInput { + parsed: ResolveParsedInput; + kind: EpisodeResolutionKind; + table: SeasonTable; + /** 该作品下启用的映射规则 */ + rules: readonly EpisodeMappingRule[]; + /** 资源发布时间(ISO 8601) */ + publishedAt?: string | null; +} + +/** 视为正片的内容类型:解析器缺省是 unknown,普通剧集标题不会标 main */ +export const MAIN_CONTENT_TYPES: readonly ContentType[] = ["main", "unknown"]; diff --git a/packages/domain/src/episode-resolution/validate.ts b/packages/domain/src/episode-resolution/validate.ts new file mode 100644 index 00000000..ad770187 --- /dev/null +++ b/packages/domain/src/episode-resolution/validate.ts @@ -0,0 +1,32 @@ +import type { EpisodeRange } from "../release-parser/types.ts"; +import { latestSeasonOf, seasonOf } from "./table.ts"; +import type { EpisodeValidation, SeasonTable } from "./types.ts"; + +/** + * 「超出不多」:在播最新季的集号最多比元数据总集数多这么几集仍按滞后放行。 + * 取 3:TMDB 等通常提前列出已定档的集,周更剧落后 3 集 ≈ 3 周没人补,再多就更像是连续编号 + * (跨一整季的绝对集号至少超出前一季的集数,常见 10 集以上),不该放行。 + */ +export const LAG_TOLERANCE = 3; + +/** + * 校验季集是否与季表吻合(设计文档判定流程第 2 步)。 + * - pass:季存在且整个区间 ≤ 该季总集数 + * - unverified:该季总集数未知;或该季是最新季、作品未完结、区间末尾超出 ≤ LAG_TOLERANCE; + * 或季表完全为空(元数据缺失);或作品未完结、季号正好是最新季 + 1(新季尚未入元数据)且集号 ≤ LAG_TOLERANCE + * - fail:其余 + */ +export function validateEpisodes(season: number, episodes: EpisodeRange, table: SeasonTable): EpisodeValidation { + if (episodes.start < 1 || episodes.end < episodes.start) return "fail"; + const info = seasonOf(table, season); + const latest = latestSeasonOf(table); + if (!info) { + if (!table.seasons.some((s) => s.season >= 1)) return "unverified"; + const nextSeason = latest !== null && season === latest + 1; + return !table.ended && nextSeason && episodes.end <= LAG_TOLERANCE ? "unverified" : "fail"; + } + if (info.total === null) return "unverified"; + if (episodes.end <= info.total) return "pass"; + if (!table.ended && season === latest && episodes.end <= info.total + LAG_TOLERANCE) return "unverified"; + return "fail"; +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 6d7e68f3..ebb80681 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -4,6 +4,7 @@ export const PACKAGE_NAME = "@umt/domain"; export * from "./acquisition/index.ts"; export * from "./classification.ts"; export * from "./download-state/index.ts"; +export * from "./episode-resolution/index.ts"; export * from "./identity/index.ts"; export * from "./library/versions.ts"; export * from "./naming/index.ts"; diff --git a/packages/domain/test/episode-resolution-suggest.test.ts b/packages/domain/test/episode-resolution-suggest.test.ts new file mode 100644 index 00000000..324a262e --- /dev/null +++ b/packages/domain/test/episode-resolution-suggest.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { type SeasonTable, suggestRule } from "../src/index.ts"; + +// 建议规则:发布时间对播出日期配对,试 EP±n / EP*2 / EP*2-1 + +function weekly(first: string, count: number) { + const t0 = Date.parse(`${first}T00:00:00Z`); + return Array.from({ length: count }, (_, i) => ({ + episode: i + 1, + airDate: new Date(t0 + i * 7 * 86_400_000).toISOString().slice(0, 10), + })); +} + +// S1 2025-10 起 12 集,S2 2026-01-08 起 12 集 +const t: SeasonTable = { + seasons: [ + { season: 1, total: 12, episodes: weekly("2025-10-02", 12) }, + { season: 2, total: 12, episodes: weekly("2026-01-08", 12) }, + ], + latestSeason: 2, + ended: false, +}; +const ep = (n: number) => ({ start: n, end: n }); + +describe("suggestRule", () => { + it("连续编号 13、14、16 都对上 S2 播出 → EP-12 目标季 2", () => { + const r = suggestRule( + [ + { episodes: ep(13), publishedAt: "2026-01-08T15:00:00Z" }, + { episodes: ep(14), publishedAt: "2026-01-16T01:00:00Z" }, + { episodes: ep(16), publishedAt: "2026-01-29T15:00:00Z" }, + ], + t, + ); + expect(r).toMatchObject({ expression: "EP-12", targetSeason: 2 }); + expect(r?.samples.map((s) => s.target)).toEqual([ + { season: 2, episode: 1 }, + { season: 2, episode: 2 }, + { season: 2, episode: 4 }, + ]); + }); + + it("双集合并发布:1、2 对上 S2E1、S2E3 → EP*2-1", () => { + const r = suggestRule( + [ + { episodes: ep(1), publishedAt: "2026-01-08T15:00:00Z" }, + { episodes: ep(2), publishedAt: "2026-01-22T15:00:00Z" }, + ], + t, + ); + expect(r).toMatchObject({ expression: "EP*2-1", targetSeason: 2 }); + }); + + it("样本不足(1 个)→ null", () => { + expect(suggestRule([{ episodes: ep(13), publishedAt: "2026-01-08T15:00:00Z" }], t)).toBeNull(); + }); + + it("样本冲突(第二个发布时间对不上同一偏移)→ null", () => { + expect( + suggestRule( + [ + { episodes: ep(13), publishedAt: "2026-01-08T15:00:00Z" }, + { episodes: ep(14), publishedAt: "2026-03-01T15:00:00Z" }, + ], + t, + ), + ).toBeNull(); + }); +}); diff --git a/packages/domain/test/episode-resolution.test.ts b/packages/domain/test/episode-resolution.test.ts new file mode 100644 index 00000000..3602a541 --- /dev/null +++ b/packages/domain/test/episode-resolution.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import { + type EpisodeMappingRule, + type ResolveEpisodesInput, + type ResolveParsedInput, + resolveEpisodes, + type SeasonTable, + validateEpisodes, +} from "../src/index.ts"; + +// 集数解析与映射:判定流程(设计见 docs/plan/episode-resolution.md) + +/** 周更播出日期:从 first 起每 7 天一集 */ +function weekly(first: string, count: number) { + const t0 = Date.parse(`${first}T00:00:00Z`); + return Array.from({ length: count }, (_, i) => ({ + episode: i + 1, + airDate: new Date(t0 + i * 7 * 86_400_000).toISOString().slice(0, 10), + })); +} + +function table( + totals: (number | null)[], + o: { + ended?: boolean; + latest?: number | null; + air?: Record; + } = {}, +): SeasonTable { + return { + seasons: totals.map((total, i) => ({ + season: i + 1, + total, + ...(o.air?.[i + 1] ? { episodes: o.air[i + 1] } : {}), + })), + latestSeason: o.latest === undefined ? totals.length : o.latest, + ended: o.ended ?? true, + }; +} + +const parsed = (p: Partial): ResolveParsedInput => ({ contentType: "unknown", ...p }); +const ep = (start: number, end = start) => ({ start, end }); +const run = (p: Partial, t: SeasonTable, o: Partial = {}) => + resolveEpisodes({ parsed: parsed(p), kind: "series", table: t, rules: [], ...o }); + +describe("validateEpisodes", () => { + it("季内集号通过,超出完结作品失败", () => { + expect(validateEpisodes(1, ep(12), table([12]))).toBe("pass"); + expect(validateEpisodes(1, ep(13), table([12]))).toBe("fail"); + expect(validateEpisodes(1, ep(0), table([12]))).toBe("fail"); + }); + it("在播最新季超出不多(≤3)放行,超出更多失败;非最新季不放行", () => { + const t = table([12, 12], { ended: false }); + expect(validateEpisodes(2, ep(15), t)).toBe("unverified"); + expect(validateEpisodes(2, ep(16), t)).toBe("fail"); + expect(validateEpisodes(1, ep(13), t)).toBe("fail"); + }); + it("季总集数未知放行;新季尚未入元数据只放行开头几集", () => { + expect(validateEpisodes(2, ep(30), table([12, null]))).toBe("unverified"); + const t = table([12], { ended: false }); + expect(validateEpisodes(2, ep(2), t)).toBe("unverified"); + expect(validateEpisodes(2, ep(8), t)).toBe("fail"); + expect(validateEpisodes(3, ep(1), t)).toBe("fail"); + expect(validateEpisodes(1, ep(5), { seasons: [], latestSeason: null, ended: false })).toBe("unverified"); + }); +}); + +describe("resolveEpisodes 原号与跳过", () => { + it("原号通过 → raw", () => { + const r = run({ season: 1, episodes: ep(5) }, table([12])); + expect(r.method).toBe("raw"); + expect(r.canonical).toEqual({ season: 1, episodes: ep(5) }); + expect(r.source).toEqual({ season: 1, episodes: ep(5) }); + }); + it("电影 / 非正片 / 季 0 / 无集号也无日期 → skipped", () => { + expect( + resolveEpisodes({ parsed: parsed({ season: 1, episodes: ep(1) }), kind: "movie", table: table([12]), rules: [] }) + .method, + ).toBe("skipped"); + expect(run({ season: 1, episodes: ep(1), contentType: "extra" }, table([12])).method).toBe("skipped"); + expect(run({ season: 0, episodes: ep(1) }, table([12])).method).toBe("skipped"); + const pack = run({ season: 1 }, table([12])); + expect(pack.method).toBe("skipped"); + expect(pack.canonical).toBeNull(); + }); +}); + +describe("resolveEpisodes 确定性换算", () => { + it("ANi `S02 13`(S1=12、S2=12 已完结)→ 季内绝对集号 S02E01", () => { + const r = run({ season: 2, episodes: ep(13) }, table([12, 12])); + expect(r.method).toBe("absolute_in_season"); + expect(r.canonical).toEqual({ season: 2, episodes: ep(1) }); + expect(r.evidence).toContain("absolute_in_season:-12"); + }); + it("无季号 `- 25`(S1=12、S2=13)→ 跨季绝对集号 S02E13", () => { + const r = run({ season: 1, seasonInferred: true, episodes: ep(25) }, table([12, 13])); + expect(r.method).toBe("absolute_cross_season"); + expect(r.canonical).toEqual({ season: 2, episodes: ep(13) }); + }); + it("季号可信时不做跨季换算", () => { + expect(run({ season: 1, episodes: ep(25) }, table([12, 13])).method).toBe("unresolved"); + }); + it("双集整体换算:跨季 27-28 → S02E15-E16,季内 S02 13-14 → S02E01-E02", () => { + expect(run({ season: 1, seasonInferred: true, episodes: ep(27, 28) }, table([12, 24])).canonical).toEqual({ + season: 2, + episodes: ep(15, 16), + }); + expect(run({ season: 2, episodes: ep(13, 14) }, table([12, 12])).canonical).toEqual({ + season: 2, + episodes: ep(1, 2), + }); + }); + it("区间跨季拆开 → 拒绝(unresolved)", () => { + const r = run({ season: 1, seasonInferred: true, episodes: ep(12, 13) }, table([12, 12])); + expect(r.method).toBe("unresolved"); + expect(r.canonical).toBeNull(); + expect(r.evidence).toContain("absolute_cross_season:split"); + }); + it("多解拒绝:S03E25(S1=12、S2=12、S3=24)从开头编号与接续上一季都成立 → unresolved", () => { + const r = run({ season: 3, episodes: ep(25) }, table([12, 12, 24])); + expect(r.method).toBe("unresolved"); + expect(r.evidence.some((e) => e.startsWith("conversion:ambiguous"))).toBe(true); + }); + it("换算结果本身校验不通过 → unresolved", () => { + expect(run({ season: 2, episodes: ep(30) }, table([12, 12])).method).toBe("unresolved"); + }); +}); + +describe("resolveEpisodes 元数据滞后与发布时间", () => { + // S2 在播,元数据只列 12 集;S02E01 于 2026-01-08 播出 + const airing = table([12, 12], { ended: false, air: { 2: weekly("2026-01-08", 12) } }); + + it("E13 发布时间远离 S02E01 播出 → 按滞后放行 unverified", () => { + const r = run({ season: 2, episodes: ep(13) }, airing, { publishedAt: "2026-04-02T12:00:00Z" }); + expect(r.method).toBe("unverified"); + expect(r.canonical).toEqual({ season: 2, episodes: ep(13) }); + expect(r.evidence).toContain("absolute_in_season:-12:published_far:84d"); + }); + it("E13 发布时间贴近 S02E01 播出 → absolute_in_season", () => { + const r = run({ season: 2, episodes: ep(13) }, airing, { publishedAt: "2026-01-08T16:30:00Z" }); + expect(r.method).toBe("absolute_in_season"); + expect(r.canonical).toEqual({ season: 2, episodes: ep(1) }); + expect(r.evidence).toContain("published_near:0d"); + }); + it("没有发布时间 → unverified", () => { + expect(run({ season: 2, episodes: ep(13) }, airing).method).toBe("unverified"); + }); + it("季总集数未知 → unverified", () => { + const r = run({ season: 2, episodes: ep(5) }, table([12, null], { ended: false })); + expect(r.method).toBe("unverified"); + expect(r.canonical).toEqual({ season: 2, episodes: ep(5) }); + }); +}); + +describe("resolveEpisodes 综艺按播出日期对集", () => { + const variety = table([10], { ended: false, air: { 1: weekly("2026-01-03", 10) } }); + const runVariety = (p: Partial, t = variety) => + resolveEpisodes({ parsed: parsed(p), kind: "variety", table: t, rules: [] }); + + it("只有日期 → air_date 对到唯一一期", () => { + const r = runVariety({ airDate: "20260117" }); + expect(r.method).toBe("air_date"); + expect(r.canonical).toEqual({ season: 1, episodes: ep(3) }); + expect(r.source).toEqual({ airDate: "20260117" }); + }); + it("在播、日期对不上 → 按滞后放行(照用解析结果)", () => { + const r = runVariety({ airDate: "20260118" }); + expect(r.method).toBe("unverified"); + expect(r.canonical).toBeNull(); + }); + it("没有每集播出日期 → 按滞后放行", () => { + expect(runVariety({ airDate: "20260117" }, table([10], { ended: true })).method).toBe("unverified"); + }); + it("已完结且已知日期覆盖到这一天、对不上 → unresolved", () => { + const ended = table([10], { ended: true, air: { 1: weekly("2026-01-03", 10) } }); + const r = runVariety({ airDate: "20260118" }, ended); + expect(r.method).toBe("unresolved"); + expect(r.canonical).toBeNull(); + }); + it("同一天两期 → 多解拒绝", () => { + const t = table([3], { + air: { + 1: [ + { episode: 1, airDate: "2026-01-03" }, + { episode: 2, airDate: "2026-01-03" }, + { episode: 3, airDate: "2026-01-10" }, + ], + }, + }); + expect(runVariety({ airDate: "20260103" }, t).method).toBe("unresolved"); + }); + it("集号越界但日期能对上 → air_date", () => { + const r = runVariety( + { season: 1, episodes: ep(20), airDate: "20260124" }, + table([10], { air: { 1: weekly("2026-01-03", 10) } }), + ); + expect(r.method).toBe("air_date"); + expect(r.canonical).toEqual({ season: 1, episodes: ep(4) }); + }); +}); + +describe("resolveEpisodes 规则", () => { + const rule = (o: Partial & { id: string }): EpisodeMappingRule => ({ + season: null, + releaseGroup: null, + kind: "expression", + expression: "EP", + ...o, + }); + const ani = { name: "ANi", source: "bracket" as const }; + + it("优先级:季 + 发布组 > 仅季 > 仅发布组 > 都空;发布组忽略大小写", () => { + const t = table([12, 12]); + const rules = [ + rule({ id: "none", expression: "EP+1" }), + rule({ id: "group", releaseGroup: "ani", expression: "EP+2" }), + rule({ id: "season", season: 2, expression: "EP+3" }), + rule({ id: "both", season: 2, releaseGroup: "ANI", expression: "EP+4" }), + ]; + const pick = (rs: EpisodeMappingRule[], season = 2) => + run({ season, episodes: ep(1), releaseGroup: ani }, t, { rules: rs }).ruleId; + expect(pick(rules)).toBe("both"); + expect(pick(rules.filter((r) => r.id !== "both"))).toBe("season"); + expect(pick(rules.filter((r) => r.id !== "both" && r.id !== "season"))).toBe("group"); + expect(pick([rules[0]!])).toBe("none"); + // 季不匹配的规则不适用 + expect(pick([rule({ id: "s3", season: 3 })])).toBeUndefined(); + // 同级多条按 id 排序取第一条 + expect(pick([rule({ id: "b", season: 2 }), rule({ id: "a", season: 2 })])).toBe("a"); + }); + + it("expression `EP-12` + 目标季 2:无季号 13 → S02E01", () => { + const r = run({ season: 1, seasonInferred: true, episodes: ep(13), releaseGroup: ani }, table([12, 12]), { + rules: [rule({ id: "r1", releaseGroup: "ANi", expression: "EP-12", targetSeason: 2 })], + }); + expect(r).toMatchObject({ method: "rule", ruleId: "r1", canonical: { season: 2, episodes: ep(1) } }); + expect(r.evidence).toContain("validate:pass"); + }); + + it("table 规则 26 → S02E01、27-28 → S02E02、29 → S02E03", () => { + const tableRule = rule({ + id: "t1", + kind: "table", + table: [ + { from: ep(26), to: { season: 2, episode: 1 } }, + { from: ep(27, 28), to: { season: 2, episode: 2 } }, + { from: ep(29), to: { season: 2, episode: 3 } }, + ], + }); + const t = table([25, 12]); + const map = (e: { start: number; end: number }) => + run({ season: 1, seasonInferred: true, episodes: e }, t, { rules: [tableRule] }); + expect(map(ep(26)).canonical).toEqual({ season: 2, episodes: ep(1) }); + expect(map(ep(27, 28)).canonical).toEqual({ season: 2, episodes: ep(2) }); + expect(map(ep(29)).canonical).toEqual({ season: 2, episodes: ep(3) }); + expect(map(ep(26, 29)).canonical).toEqual({ season: 2, episodes: ep(1, 3) }); + expect(map(ep(30))).toMatchObject({ method: "unresolved", ruleFailed: "t1" }); + }); + + it("special 规则 → 返回特别篇标记,不校验", () => { + const r = run({ season: 1, episodes: ep(99) }, table([12]), { + rules: [rule({ id: "sp", kind: "special", special: "extra" })], + }); + expect(r).toMatchObject({ method: "rule", ruleId: "sp", special: "extra", canonical: null }); + }); + + it("规则结果越界 → unresolved + ruleFailed,不回退到确定性换算", () => { + // 原号 S02E13 本可按季内绝对集号换算成 S02E01,但有规则时只认规则 + const r = run({ season: 2, episodes: ep(13) }, table([12, 12]), { + rules: [rule({ id: "bad", expression: "EP+20" })], + }); + expect(r.method).toBe("unresolved"); + expect(r.ruleFailed).toBe("bad"); + expect(r.ruleId).toBeUndefined(); + expect(r.canonical).toBeNull(); + }); + + it("表达式非法 → ruleFailed", () => { + const r = run({ season: 1, episodes: ep(1) }, table([12]), { rules: [rule({ id: "x", expression: "EP-$" })] }); + expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" }); + }); +});