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

@@ -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";