merge: fix/p-episode-groups

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 16:02:17 +08:00
commit 02b9773047
35 files changed
+2751 -154

No files matched your search

@@ -3,7 +3,7 @@ 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 type ConversionMethod = "absolute_in_season" | "absolute_cross_season" | "air_date" | "tmdb_season";
export interface ConversionCandidate {
method: ConversionMethod;
@@ -88,6 +88,33 @@ export function airDateCandidates(airDate: string, table: SeasonTable, season: n
return hits.map((canonical) => ({ method: "air_date", canonical, evidence: `air_date:${iso}` }));
}
/**
* 剧集组结构下按 TMDB 默认季集换算:把发布者的季集当作 TMDB 默认季集,经季表的 origins 对照到组内季集。
* 区间内每一集都要能对上,且落在同一季、组内集号连续,否则不成立。季 0 不换。
*/
export function tmdbSeasonCandidate(
season: number,
episodes: EpisodeRange,
table: SeasonTable,
): ConversionCandidate | null {
const origins = table.origins;
if (!origins?.length || season < 1 || episodes.end - episodes.start > 200) return null;
const hits: { season: number; episode: number }[] = [];
for (let e = episodes.start; e <= episodes.end; e++) {
const o = origins.find((x) => x.tmdbSeason === season && x.tmdbEpisode === e);
if (!o) return null;
hits.push(o);
}
const first = hits[0]!;
if (hits.some((h, i) => h.season !== first.season || h.episode !== first.episode + i)) return null;
const pad = (n: number) => String(n).padStart(2, "0");
return {
method: "tmdb_season",
canonical: { season: first.season, episodes: { start: first.episode, end: hits.at(-1)!.episode } },
evidence: `tmdb_season:S${pad(season)}E${pad(episodes.start)}`,
};
}
export interface ConversionOutcome {
/** 去重后、校验为 pass 的候选 */
candidates: ConversionCandidate[];
@@ -117,6 +144,10 @@ export function convertEpisodes(input: {
if (cross.kind === "split") evidence.push(cross.evidence);
}
}
if (episodes && season !== null) {
const origin = tmdbSeasonCandidate(season, episodes, table);
if (origin) raw.push(origin);
}
if (input.variety && airDate) {
raw.push(...airDateCandidates(airDate, table, seasonInferred ? null : season));
}
@@ -7,6 +7,7 @@ export {
convertEpisodes,
crossSeasonCandidate,
inSeasonCandidates,
tmdbSeasonCandidate,
} from "./convert.ts";
export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts";
export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
@@ -13,9 +13,22 @@ export interface SeasonInfo {
episodes?: SeasonEpisodeInfo[];
}
/** 剧集组结构下一集的 TMDB 默认季集 → 组内季集 */
export interface SeasonOrigin {
tmdbSeason: number;
tmdbEpisode: number;
season: number;
episode: number;
}
/** 作品的季表(来自元数据,调用方组装)。季 0(特别篇)可以在表里,换算时忽略 */
export interface SeasonTable {
seasons: SeasonInfo[];
/**
* 作品选了 TMDB 剧集组时:每集的 TMDB 默认季集与组内季集对照。发布者按 TMDB 默认季编号(S01E14)而作品按组分季时,
* 换算一步按它把原号换成组内季集(方式 tmdb_season)。默认季结构不给。
*/
origins?: SeasonOrigin[];
/** 最新季;null 时取表中最大季号 */
latestSeason: number | null;
/** 作品已完结:完结后不再有「元数据滞后」放行 */
@@ -58,6 +71,7 @@ export type EpisodeResolutionMethod =
| "absolute_in_season"
| "absolute_cross_season"
| "air_date"
| "tmdb_season"
| "unverified"
| "unresolved"
| "skipped";