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:
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";
|
||||
|
||||
@@ -280,3 +280,39 @@ describe("resolveEpisodes 规则", () => {
|
||||
expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEpisodes 剧集组:按 TMDB 默认季集换算", () => {
|
||||
// TMDB 默认 S1 共 25 集;剧集组拆成 S1(1–12)、S2(13–25)
|
||||
const grouped: SeasonTable = {
|
||||
...table([12, 13]),
|
||||
origins: Array.from({ length: 25 }, (_, k) => ({
|
||||
tmdbSeason: 1,
|
||||
tmdbEpisode: k + 1,
|
||||
season: k < 12 ? 1 : 2,
|
||||
episode: k < 12 ? k + 1 : k - 11,
|
||||
})),
|
||||
};
|
||||
|
||||
it("S01E14(按 TMDB 默认季编号)→ 组内 S02E02", () => {
|
||||
const r = run({ season: 1, episodes: ep(14) }, grouped);
|
||||
expect(r).toMatchObject({ method: "tmdb_season", canonical: { season: 2, episodes: ep(2) } });
|
||||
expect(r.evidence).toContain("tmdb_season:S01E14");
|
||||
});
|
||||
|
||||
it("「- 13」季号推定:跨季绝对集号与默认季对照同解,归第 2 季第 1 集", () => {
|
||||
const r = run({ season: 1, seasonInferred: true, episodes: ep(13) }, grouped);
|
||||
expect(r.canonical).toEqual({ season: 2, episodes: ep(1) });
|
||||
expect(["absolute_cross_season", "tmdb_season"]).toContain(r.method);
|
||||
});
|
||||
|
||||
it("区间跨组不换;组内号本身对得上时原号优先", () => {
|
||||
expect(run({ season: 1, episodes: ep(12, 13) }, grouped).method).toBe("unresolved");
|
||||
expect(run({ season: 2, episodes: ep(3) }, grouped)).toMatchObject({ method: "raw" });
|
||||
expect(run({ season: 1, episodes: ep(14, 15) }, grouped).canonical).toEqual({ season: 2, episodes: ep(2, 3) });
|
||||
});
|
||||
|
||||
it("没有 origins(默认季结构)时不出这种换算", () => {
|
||||
const { origins: _o, ...plain } = grouped;
|
||||
expect(run({ season: 1, episodes: ep(14) }, plain).method).toBe("unresolved");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user