diff --git a/packages/contracts/src/api/episode-mapping.ts b/packages/contracts/src/api/episode-mapping.ts index d7866091..785e1310 100644 --- a/packages/contracts/src/api/episode-mapping.ts +++ b/packages/contracts/src/api/episode-mapping.ts @@ -51,4 +51,33 @@ export const episodeMappingApi = { 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; diff --git a/packages/contracts/src/content.ts b/packages/contracts/src/content.ts index 8b6663c3..473b985f 100644 --- a/packages/contracts/src/content.ts +++ b/packages/contracts/src/content.ts @@ -25,6 +25,11 @@ export const MediaEntity = EntityBase.extend({ lastMetadataRefreshAt: Timestamp.nullable(), /** 作品级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /media-entities/{id}/naming-override 修改 */ 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; export const MediaEntityCreate = MediaEntity.pick({ diff --git a/packages/contracts/src/episode-mapping.ts b/packages/contracts/src/episode-mapping.ts index fae62220..442c6ac2 100644 --- a/packages/contracts/src/episode-mapping.ts +++ b/packages/contracts/src/episode-mapping.ts @@ -51,6 +51,7 @@ export const EpisodeResolutionMethod = z.enum([ "absolute_in_season", "absolute_cross_season", "air_date", + "tmdb_season", "unverified", "unresolved", "skipped", @@ -197,6 +198,8 @@ export const EpisodeSeasonSummary = z.object({ ), latestSeason: z.number().int().nullable(), ended: z.boolean(), + /** 季表来自哪套季集结构:null = TMDB 默认季;否则为作品选的剧集组 */ + structure: z.object({ groupId: z.string(), name: z.string() }).nullable().optional(), }); export type EpisodeSeasonSummary = z.infer; @@ -210,3 +213,119 @@ export const EpisodeMappingPreview = z.object({ changed: z.number().int().min(0), }); export type EpisodeMappingPreview = z.infer; + +// ── 剧集组(季集结构):作品可选 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; + +/** 作品当前的季集结构;null = TMDB 默认季 */ +export const EpisodeStructureCurrent = z.object({ + groupId: z.string(), + name: z.string(), + setAt: z.string().nullable(), + /** `:` */ + setBy: z.string().nullable(), +}); +export type EpisodeStructureCurrent = z.infer; + +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; + +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; + +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; + +/** 某套季集结构的季卡片(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; + +export const EpisodeStructureQuery = z.object({ + /** 剧集组 id;不填 = TMDB 默认季 */ + groupId: z.string().trim().min(1).max(64).optional(), +}); +export type EpisodeStructureQuery = z.infer; + +export const EpisodeGroupSetInput = z.object({ + /** 剧集组 id;null = 改回 TMDB 默认季 */ + groupId: z.string().trim().min(1).max(64).nullable(), +}); +export type EpisodeGroupSetInput = z.infer; + +/** 切换时对不上的集单元(保留原号、标临时) */ +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; + +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; diff --git a/packages/db/migrations/0075_entity_episode_group.ts b/packages/db/migrations/0075_entity_episode_group.ts new file mode 100644 index 00000000..b9e59ad0 --- /dev/null +++ b/packages/db/migrations/0075_entity_episode_group.ts @@ -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);选择时间与操作者(`:`)供审计与界面「谁改的」。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + 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): Promise { + 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; +`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 4daaf3b8..b358b86b 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -952,6 +952,10 @@ export interface MediaEntityTable { created_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz; 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 { diff --git a/packages/domain/src/episode-resolution/convert.ts b/packages/domain/src/episode-resolution/convert.ts index a27298f3..9712801b 100644 --- a/packages/domain/src/episode-resolution/convert.ts +++ b/packages/domain/src/episode-resolution/convert.ts @@ -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)); } diff --git a/packages/domain/src/episode-resolution/index.ts b/packages/domain/src/episode-resolution/index.ts index 4a1ea2e9..3772cadf 100644 --- a/packages/domain/src/episode-resolution/index.ts +++ b/packages/domain/src/episode-resolution/index.ts @@ -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"; diff --git a/packages/domain/src/episode-resolution/types.ts b/packages/domain/src/episode-resolution/types.ts index d3159987..5c7cc183 100644 --- a/packages/domain/src/episode-resolution/types.ts +++ b/packages/domain/src/episode-resolution/types.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"; diff --git a/packages/domain/test/episode-resolution.test.ts b/packages/domain/test/episode-resolution.test.ts index 3602a541..6b60e67e 100644 --- a/packages/domain/test/episode-resolution.test.ts +++ b/packages/domain/test/episode-resolution.test.ts @@ -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"); + }); +});