feat(集数映射): 作品季集结构字段(迁移 0075)、剧集组接口契约与按 TMDB 默认季集换算

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 15:45:12 +08:00
1 parent 5434ae8ee8
commit 3f6d8e410f
9 files changed
+271 -1

No files matched your search

@@ -51,4 +51,33 @@ export const episodeMappingApi = {
response: em.EpisodeMappingPreview, response: em.EpisodeMappingPreview,
}), }),
}, },
episodeGroups: {
list: ep({
method: "get",
path: "/media-entities/{id}/episode-groups",
scope: "read",
summary: "作品可选的季集结构:TMDB 剧集组列表与当前选择",
params: IdParam,
response: em.EpisodeGroupList,
}),
seasons: ep({
method: "get",
path: "/media-entities/{id}/episode-structure",
scope: "read",
summary: "某套季集结构(TMDB 默认季或剧集组)的季卡片与每集,带本地已入库情况(纯读)",
params: IdParam,
query: em.EpisodeStructureQuery,
response: em.EpisodeStructureSeasons,
}),
set: ep({
method: "put",
path: "/media-entities/{id}/episode-group",
scope: "organize.execute",
summary:
"切换作品的季集结构:按 TMDB 原季集原地迁移集单元(引用不动),重建季表,按作品重算已绑定资源并刷新元数据",
params: IdParam,
body: em.EpisodeGroupSetInput,
response: em.EpisodeGroupSetResult,
}),
},
} as const; } as const;
+5
View File
@@ -25,6 +25,11 @@ export const MediaEntity = EntityBase.extend({
lastMetadataRefreshAt: Timestamp.nullable(), lastMetadataRefreshAt: Timestamp.nullable(),
/** 作品级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /media-entities/{id}/naming-override 修改 */ /** 作品级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /media-entities/{id}/naming-override 修改 */
namingOverride: JsonObject.nullable().optional(), namingOverride: JsonObject.nullable().optional(),
/** 季集结构:TMDB 剧集组 id / 名称;null = TMDB 默认季。经 PUT /media-entities/{id}/episode-group 修改 */
episodeGroupId: z.string().nullable().optional(),
episodeGroupName: z.string().nullable().optional(),
episodeGroupSetAt: Timestamp.nullable().optional(),
episodeGroupSetBy: z.string().nullable().optional(),
}); });
export type MediaEntity = z.infer<typeof MediaEntity>; export type MediaEntity = z.infer<typeof MediaEntity>;
export const MediaEntityCreate = MediaEntity.pick({ export const MediaEntityCreate = MediaEntity.pick({
+119
View File
@@ -51,6 +51,7 @@ export const EpisodeResolutionMethod = z.enum([
"absolute_in_season", "absolute_in_season",
"absolute_cross_season", "absolute_cross_season",
"air_date", "air_date",
"tmdb_season",
"unverified", "unverified",
"unresolved", "unresolved",
"skipped", "skipped",
@@ -197,6 +198,8 @@ export const EpisodeSeasonSummary = z.object({
), ),
latestSeason: z.number().int().nullable(), latestSeason: z.number().int().nullable(),
ended: z.boolean(), ended: z.boolean(),
/** 季表来自哪套季集结构:null = TMDB 默认季;否则为作品选的剧集组 */
structure: z.object({ groupId: z.string(), name: z.string() }).nullable().optional(),
}); });
export type EpisodeSeasonSummary = z.infer<typeof EpisodeSeasonSummary>; export type EpisodeSeasonSummary = z.infer<typeof EpisodeSeasonSummary>;
@@ -210,3 +213,119 @@ export const EpisodeMappingPreview = z.object({
changed: z.number().int().min(0), changed: z.number().int().min(0),
}); });
export type EpisodeMappingPreview = z.infer<typeof EpisodeMappingPreview>; export type EpisodeMappingPreview = z.infer<typeof EpisodeMappingPreview>;
// ── 剧集组(季集结构):作品可选 TMDB 默认季或某个剧集组,季表与季 / 集单元随之生成 ──
export const EpisodeGroupSummary = z.object({
id: z.string(),
name: z.string(),
/** TMDB type 1–7;未知为 null */
type: z.number().int().nullable(),
/** 类型的中文名(原始播出 / 绝对集号 / DVD / 数字发行 / 故事线 / 制作季 / 电视) */
typeLabel: z.string().nullable(),
description: z.string().nullable(),
/** 组(= 季)数 */
groupCount: z.number().int().min(0),
episodeCount: z.number().int().min(0),
network: z.string().nullable(),
});
export type EpisodeGroupSummary = z.infer<typeof EpisodeGroupSummary>;
/** 作品当前的季集结构;null = TMDB 默认季 */
export const EpisodeStructureCurrent = z.object({
groupId: z.string(),
name: z.string(),
setAt: z.string().nullable(),
/** `<actor_type>:<actor_id>` */
setBy: z.string().nullable(),
});
export type EpisodeStructureCurrent = z.infer<typeof EpisodeStructureCurrent>;
export const EpisodeGroupList = z.object({
/** 作品是否能选剧集组(剧集 / 综艺且有 TMDB 剧集身份) */
applicable: z.boolean(),
current: EpisodeStructureCurrent.nullable(),
groups: z.array(EpisodeGroupSummary),
/** 取列表失败的原因码(TMDB 未配置 / 不可用);成功为 null */
error: z.string().nullable(),
});
export type EpisodeGroupList = z.infer<typeof EpisodeGroupList>;
export const EpisodeStructureEpisode = z.object({
episodeNumber: z.number().int(),
name: z.string().nullable(),
airDate: z.string().nullable(),
/** TMDB 默认季集号(剧集组里才有意义;默认季时与自身相同) */
tmdbSeason: z.number().int(),
tmdbEpisode: z.number().int(),
/** 本地已入库(按 TMDB 原季集对照,换结构前也能看) */
inLibrary: z.boolean(),
});
export type EpisodeStructureEpisode = z.infer<typeof EpisodeStructureEpisode>;
export const EpisodeStructureSeason = z.object({
seasonNumber: z.number().int(),
name: z.string().nullable(),
episodeCount: z.number().int().min(0),
/** 首播日期 */
airDate: z.string().nullable(),
posterPath: z.string().nullable(),
/** 本地已入库集数 */
inLibrary: z.number().int().min(0),
/** 当前结构下对应的季单元(选季订阅用);不是当前结构或还没建为 null */
seasonUnitId: Id.nullable(),
episodes: z.array(EpisodeStructureEpisode),
});
export type EpisodeStructureSeason = z.infer<typeof EpisodeStructureSeason>;
/** 某套季集结构的季卡片(TMDB 默认季或某个剧集组),带本地已入库情况 */
export const EpisodeStructureSeasons = z.object({
groupId: z.string().nullable(),
name: z.string(),
/** 是不是作品当前的季集结构 */
current: z.boolean(),
seasons: z.array(EpisodeStructureSeason),
});
export type EpisodeStructureSeasons = z.infer<typeof EpisodeStructureSeasons>;
export const EpisodeStructureQuery = z.object({
/** 剧集组 id;不填 = TMDB 默认季 */
groupId: z.string().trim().min(1).max(64).optional(),
});
export type EpisodeStructureQuery = z.infer<typeof EpisodeStructureQuery>;
export const EpisodeGroupSetInput = z.object({
/** 剧集组 id;null = 改回 TMDB 默认季 */
groupId: z.string().trim().min(1).max(64).nullable(),
});
export type EpisodeGroupSetInput = z.infer<typeof EpisodeGroupSetInput>;
/** 切换时对不上的集单元(保留原号、标临时) */
export const EpisodeGroupUnmatchedUnit = z.object({
unitId: Id,
seasonNumber: z.number().int().nullable(),
episodeNumber: z.number().int().nullable(),
/** no_origin:求不出 TMDB 原季集;not_in_target:目标结构里没有这一集;numbering_locked:编号已人工锁定 */
reason: z.enum(["no_origin", "not_in_target", "numbering_locked"]),
/** 有资产覆盖 / 资源 / 下载等引用 */
referenced: z.boolean(),
});
export type EpisodeGroupUnmatchedUnit = z.infer<typeof EpisodeGroupUnmatchedUnit>;
export const EpisodeGroupSetResult = z.object({
changed: z.boolean(),
current: EpisodeStructureCurrent.nullable(),
/** 改了季集号的集单元数 */
moved: z.number().int().min(0),
/** 季集号不变的集单元数 */
kept: z.number().int().min(0),
unmatched: z.array(EpisodeGroupUnmatchedUnit),
/** 新结构的季与集数 */
seasons: z.array(z.object({ seasonNumber: z.number().int(), episodeCount: z.number().int().min(0) })),
/** 不在新结构里、标为临时的季单元的季号 */
orphanSeasons: z.array(z.number().int()),
reresolve: EpisodeReresolveResult,
/** 切换后元数据刷新结果(refreshed / skipped / failed) */
refresh: z.string(),
});
export type EpisodeGroupSetResult = z.infer<typeof EpisodeGroupSetResult>;
@@ -0,0 +1,31 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// 作品的季集结构(剧集组):null = TMDB 默认季;否则为 TMDB 剧集组 id(/tv/episode_group/{id}),季表与季 / 集单元按组生成。
// 名称冗余存一份(界面显示不用再查 TMDB);选择时间与操作者(`<actor_type>:<actor_id>`)供审计与界面「谁改的」。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table media_entity add column episode_group_id text;
alter table media_entity add column episode_group_name text;
alter table media_entity add column episode_group_set_at timestamptz;
alter table media_entity add column episode_group_set_by text;
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table media_entity drop column if exists episode_group_set_by;
alter table media_entity drop column if exists episode_group_set_at;
alter table media_entity drop column if exists episode_group_name;
alter table media_entity drop column if exists episode_group_id;
`,
);
}
+4
View File
@@ -952,6 +952,10 @@ export interface MediaEntityTable {
created_at: GeneratedTimestamptz; created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz;
naming_override: Json | null; naming_override: Json | null;
episode_group_id: string | null;
episode_group_name: string | null;
episode_group_set_at: Timestamptz | null;
episode_group_set_by: string | null;
} }
export interface MediaServerInstanceTable { export interface MediaServerInstanceTable {
@@ -3,7 +3,7 @@ import { cumulativeSeasons, seasonOf } from "./table.ts";
import type { CanonicalEpisodes, SeasonTable } from "./types.ts"; import type { CanonicalEpisodes, SeasonTable } from "./types.ts";
import { validateEpisodes } from "./validate.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 { export interface ConversionCandidate {
method: ConversionMethod; 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}` })); 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 { export interface ConversionOutcome {
/** 去重后、校验为 pass 的候选 */ /** 去重后、校验为 pass 的候选 */
candidates: ConversionCandidate[]; candidates: ConversionCandidate[];
@@ -117,6 +144,10 @@ export function convertEpisodes(input: {
if (cross.kind === "split") evidence.push(cross.evidence); 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) { if (input.variety && airDate) {
raw.push(...airDateCandidates(airDate, table, seasonInferred ? null : season)); raw.push(...airDateCandidates(airDate, table, seasonInferred ? null : season));
} }
@@ -7,6 +7,7 @@ export {
convertEpisodes, convertEpisodes,
crossSeasonCandidate, crossSeasonCandidate,
inSeasonCandidates, inSeasonCandidates,
tmdbSeasonCandidate,
} from "./convert.ts"; } from "./convert.ts";
export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts"; export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts";
export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts"; export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
@@ -13,9 +13,22 @@ export interface SeasonInfo {
episodes?: SeasonEpisodeInfo[]; episodes?: SeasonEpisodeInfo[];
} }
/** 剧集组结构下一集的 TMDB 默认季集 → 组内季集 */
export interface SeasonOrigin {
tmdbSeason: number;
tmdbEpisode: number;
season: number;
episode: number;
}
/** 作品的季表(来自元数据,调用方组装)。季 0(特别篇)可以在表里,换算时忽略 */ /** 作品的季表(来自元数据,调用方组装)。季 0(特别篇)可以在表里,换算时忽略 */
export interface SeasonTable { export interface SeasonTable {
seasons: SeasonInfo[]; seasons: SeasonInfo[];
/**
* 作品选了 TMDB 剧集组时:每集的 TMDB 默认季集与组内季集对照。发布者按 TMDB 默认季编号(S01E14)而作品按组分季时,
* 换算一步按它把原号换成组内季集(方式 tmdb_season)。默认季结构不给。
*/
origins?: SeasonOrigin[];
/** 最新季;null 时取表中最大季号 */ /** 最新季;null 时取表中最大季号 */
latestSeason: number | null; latestSeason: number | null;
/** 作品已完结:完结后不再有「元数据滞后」放行 */ /** 作品已完结:完结后不再有「元数据滞后」放行 */
@@ -58,6 +71,7 @@ export type EpisodeResolutionMethod =
| "absolute_in_season" | "absolute_in_season"
| "absolute_cross_season" | "absolute_cross_season"
| "air_date" | "air_date"
| "tmdb_season"
| "unverified" | "unverified"
| "unresolved" | "unresolved"
| "skipped"; | "skipped";
@@ -280,3 +280,39 @@ describe("resolveEpisodes 规则", () => {
expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" }); 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");
});
});