feat(domain): 集数解析纯函数——规则、原号校验(滞后放行)、绝对集号与播出日期换算、建议规则

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 17:18:43 +08:00
1 parent 111cbddb36
commit 7b9939e0a0
13 files changed
+991 -1

No files matched your search

+1 -1
View File
@@ -52,7 +52,7 @@ ANI-RSS(GPL-2.0,只借思路不抄代码)摸排结论:一条订阅 = 一
## 状态
- [ ] 批次 1
- [x] 批次 1
- [ ] 批次 2
- [ ] 批次 3
- [ ] 批次 4
+1
View File
@@ -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` | 新作品/追更补缺/升级判定;即时>追更>新作品>升级(可配置);每日预算;缓存卷硬阈值与恢复水位 |
@@ -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<number>();
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<string>();
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 };
}
@@ -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";
@@ -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">): 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] : [])],
});
}
}
@@ -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" };
}
@@ -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<string, { expression: string; targetSeason: number }>();
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;
}
@@ -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;
}
}
@@ -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"];
@@ -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";
}
+1
View File
@@ -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";
@@ -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();
});
});
@@ -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<number, { episode: number; airDate: string | null }[]>;
} = {},
): 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>): ResolveParsedInput => ({ contentType: "unknown", ...p });
const ep = (start: number, end = start) => ({ start, end });
const run = (p: Partial<ResolveParsedInput>, t: SeasonTable, o: Partial<ResolveEpisodesInput> = {}) =>
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<ResolveParsedInput>, 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<EpisodeMappingRule> & { 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" });
});
});