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(规则照写的用,不回退到确定性换算,也不停用规则) * 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 ref = { ruleId: rule.id, ...(rule.line !== undefined ? { ruleLine: rule.line } : {}), ...(rule.text !== undefined ? { ruleText: rule.text } : {}), }; const applied = applyRule(rule, season, episodes); if (applied.kind === "special") { return result({ method: "rule", canonical: null, ...ref, special: applied.special, evidence: [`rule:special:${applied.special}`], }); } if (applied.kind === "error") { return result({ method: "unresolved", canonical: null, ...ref, evidence: [applied.evidence] }); } const v = validateEpisodes(applied.canonical.season, applied.canonical.episodes, table); if (v === "fail") { return result({ method: "unresolved", canonical: null, ...ref, evidence: [applied.evidence, "rule:validate_fail"], }); } return result({ method: "rule", canonical: applied.canonical, ...ref, 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] : [])], }); } }