From 265340751c7f0b55d424d5746208b7cdb16364ff Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 15:45:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=89=A7=E9=9B=86=E7=BB=84):=20=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E5=88=87=E6=8D=A2=E5=AD=A3=E9=9B=86=E7=BB=93=E6=9E=84?= =?UTF-8?q?,=E5=8D=95=E5=85=83=E6=8C=89=20TMDB=20=E5=8E=9F=E5=AD=A3?= =?UTF-8?q?=E9=9B=86=E5=8E=9F=E5=9C=B0=E8=BF=81=E7=A7=BB,=E5=AD=A3?= =?UTF-8?q?=E8=A1=A8=20/=20=E5=88=B7=E6=96=B0=20/=20=E5=88=AE=E5=89=8A?= =?UTF-8?q?=E6=8C=89=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../src/modules/acquisition-organize/ports.ts | 13 +- .../acquisition-organize/services/scrape.ts | 7 +- .../routes/episode-mapping.ts | 9 + .../service/episode-groups.ts | 350 +++++++++++++++++ .../service/episode-mapping-admin.ts | 10 +- .../service/episode-resolution.ts | 7 +- .../service/episode-structure.ts | 360 ++++++++++++++++++ .../sources-identity/service/metadata-rich.ts | 9 +- .../sources-identity/service/metadata.ts | 61 ++- .../sources-identity/episode-groups.test.ts | 333 ++++++++++++++++ 10 files changed, 1145 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/modules/sources-identity/service/episode-groups.ts create mode 100644 apps/server/src/modules/sources-identity/service/episode-structure.ts create mode 100644 apps/server/test/sources-identity/episode-groups.test.ts diff --git a/apps/server/src/modules/acquisition-organize/ports.ts b/apps/server/src/modules/acquisition-organize/ports.ts index d783106d..21c33885 100644 --- a/apps/server/src/modules/acquisition-organize/ports.ts +++ b/apps/server/src/modules/acquisition-organize/ports.ts @@ -6,11 +6,13 @@ import { type DownloaderClient, diskUsage, type EmbyClient, + fetchEpisodeGroup, fetchRichDetail, fetchRichSeason, ffprobe, getSiteAdapter, getSiteDefinition, + groupSeasonAsRichSeason, type ProbeResult, qbAuthConfig, type SecretResolver, @@ -98,7 +100,8 @@ export interface AoPorts { * TMDB 没配置返回 null;条目不存在或上游失败抛错。 */ scrapeTmdbDetail(type: "movie" | "tv", id: string): Promise; - scrapeTmdbSeason(tvId: string, season: number): Promise; + /** episodeGroupId:作品选了剧集组时按组取(季号 = 组序号,集号 = 组内顺序) */ + scrapeTmdbSeason(tvId: string, season: number, episodeGroupId?: string | null): Promise; options: AoOptions; } @@ -235,9 +238,15 @@ export function createPorts( if (!client) return null; return fetchRichDetail(client, type, id, await metadataLanguages(ctx), await metadataTextOptions(ctx)); }, - async scrapeTmdbSeason(tvId, season) { + async scrapeTmdbSeason(tvId, season, episodeGroupId) { const client = await rt().tmdb(); if (!client) return null; + if (episodeGroupId) { + const lang = (await metadataLanguages(ctx))[0]; + const g = await fetchEpisodeGroup(client, episodeGroupId, lang); + const s = g.seasons.find((x) => x.seasonNumber === season); + return s ? groupSeasonAsRichSeason(tvId, s, g.language) : null; + } return fetchRichSeason(client, tvId, season, await metadataLanguages(ctx), await metadataTextOptions(ctx)); }, diskUsage: (p) => diskUsage(p), diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index 2f22d634..d50de135 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -970,12 +970,15 @@ export async function runScrapeRecord( let fields: Fields | null = null; let coverage = new Map(); let mapEpisodes: EntityEpisodeMapper | null = null; + /** 作品选的剧集组:缺元数据时按组取季详情(季集号是组内的) */ + let episodeGroupId: string | null = null; if (match.mediaEntityId) { const e = await db .selectFrom("media_entity") - .select(["id", "kind", "title", "original_title", "year", "metadata", "status"]) + .select(["id", "kind", "title", "original_title", "year", "metadata", "status", "episode_group_id"]) .where("id", "=", match.mediaEntityId) .executeTakeFirst(); + episodeGroupId = e?.episode_group_id ?? null; if (e && e.status !== "merged") { if (kind === "tv" && (e.kind === "series" || e.kind === "variety")) mapEpisodes = await createEntityEpisodeMapper(db, e.id, e.kind); @@ -1109,7 +1112,7 @@ export async function runScrapeRecord( (typeof v.episode !== "number" && dated.has(v.path))), ); if (!tmdbId || (!fromTmdb && !lacking)) continue; - const sd = await d.ports.scrapeTmdbSeason(tmdbId, n).catch((err) => { + const sd = await d.ports.scrapeTmdbSeason(tmdbId, n, episodeGroupId).catch((err) => { d.ctx.logger.warn({ recordId, season: n, err: { message: errMessage(err) } }, "刮削取 TMDB 季详情失败"); return null; }); diff --git a/apps/server/src/modules/sources-identity/routes/episode-mapping.ts b/apps/server/src/modules/sources-identity/routes/episode-mapping.ts index 7b1ecaf5..6b03131f 100644 --- a/apps/server/src/modules/sources-identity/routes/episode-mapping.ts +++ b/apps/server/src/modules/sources-identity/routes/episode-mapping.ts @@ -2,6 +2,7 @@ import { Api } from "@umt/contracts"; import type { ModuleApp } from "../../types.ts"; import { route } from "../http.ts"; import type { SourcesRuntime } from "../runtime.ts"; +import { episodeStructureSeasons, listEpisodeGroups, setEpisodeGroup } from "../service/episode-groups.ts"; import { createRule, deleteRule, @@ -11,9 +12,11 @@ import { } from "../service/episode-mapping-admin.ts"; // 集数映射(docs/plan/episode-resolution.md 批次 4):规则增删改(写入后按作品重算)、模拟预览(纯读)。 +// 剧集组:作品的季集结构(TMDB 默认季 / 某个剧集组)列表、季卡片预览、切换。 const T = ["episode-mapping"]; const E = Api.episodeMapping; +const G = Api.episodeGroups; export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) { const ctx = () => rt().ctx; @@ -23,4 +26,10 @@ export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) { route(app, { ...E.updateRule, tags: T }, ({ params, body, actor }) => updateRule(ctx(), params.id, body, actor)); route(app, { ...E.deleteRule, tags: T }, ({ params, actor }) => deleteRule(ctx(), params.id, actor)); route(app, { ...E.preview, tags: T }, ({ params, body }) => previewEpisodeMapping(ctx().db, params.id, body)); + + route(app, { ...G.list, tags: T }, ({ params }) => listEpisodeGroups(rt(), params.id)); + route(app, { ...G.seasons, tags: T }, ({ params, query }) => + episodeStructureSeasons(rt(), params.id, query.groupId ?? null), + ); + route(app, { ...G.set, tags: T }, ({ params, body, actor }) => setEpisodeGroup(rt(), params.id, body.groupId, actor)); } diff --git a/apps/server/src/modules/sources-identity/service/episode-groups.ts b/apps/server/src/modules/sources-identity/service/episode-groups.ts new file mode 100644 index 00000000..012d1771 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/episode-groups.ts @@ -0,0 +1,350 @@ +import { + EPISODE_GROUP_TYPES, + fetchEpisodeGroup, + fetchEpisodeGroups, + fetchRichDetail, + type TmdbClient, + type TmdbEpisodeGroupDetail, + TmdbError, + type TmdbRichDetail, +} from "@umt/adapters"; +import type { + EpisodeGroupList, + EpisodeGroupSetResult, + EpisodeStructureCurrent, + EpisodeStructureSeason, + EpisodeStructureSeasons, +} from "@umt/contracts"; +import { sql } from "@umt/db"; +import { ApiException } from "../../../http/errors.ts"; +import type { SourcesRuntime } from "../runtime.ts"; +import { type Actor, audit, type Exec } from "./common.ts"; +import { resolveEntityId } from "./entities.ts"; +import { reresolveEntityEpisodes } from "./episode-resolution.ts"; +import { + type Coord, + defaultLayout, + entityEpisodeGroup, + groupLayout, + type Layout, + relayoutUnits, + unitOrigins, +} from "./episode-structure.ts"; +import { refreshMetadata } from "./metadata.ts"; +import { metadataLanguages } from "./metadata-rich.ts"; + +// 剧集组(季集结构)接口:列出 TMDB 剧集组、预览某套结构的季卡片(带本地已入库)、切换作品的季集结构。 +// 对照 MoviePilot:media/groups/{tmdbid}、media/group/seasons/{groupId}、订阅 / 整理带 episode_group(见 docs/porting/moviepilot-episode-groups.md)。 + +const EPISODIC = new Set(["series", "variety"]); +/** 默认季结构预览时最多拉多少季的每集信息 */ +const MAX_DEFAULT_SEASONS = 40; + +interface TvContext { + id: string; + tvId: string; + tmdb: TmdbClient; + lang: string; +} + +async function tvContext(rt: SourcesRuntime, entityId: string): Promise { + const id = await resolveEntityId(rt.ctx.db, entityId); + if (!id) throw new ApiException("not_found", "作品不存在"); + const e = await rt.ctx.db.selectFrom("media_entity").select(["kind"]).where("id", "=", id).executeTakeFirstOrThrow(); + if (!EPISODIC.has(e.kind)) return { error: "not_episodic", id }; + const ident = await rt.ctx.db + .selectFrom("external_identity") + .select(["entity_type", "external_id"]) + .where("media_entity_id", "=", id) + .where("provider", "=", "tmdb") + .where("status", "=", "accepted") + .executeTakeFirst(); + if (ident?.entity_type !== "tv") return { error: "no_tmdb_identity", id }; + const tmdb = await rt.tmdb(); + if (!tmdb || typeof tmdb.getJson !== "function") return { error: "tmdb_not_configured", id }; + const lang = (await metadataLanguages(rt.ctx))[0] ?? "zh-CN"; + return { id, tvId: ident.external_id, tmdb, lang }; +} + +const currentOf = (g: Awaited>): EpisodeStructureCurrent | null => + g ? { groupId: g.id, name: g.name, setAt: g.setAt?.toISOString() ?? null, setBy: g.setBy } : null; + +/** 作品可选的剧集组与当前选择;TMDB 不可用时 groups 为空、error 给原因码 */ +export async function listEpisodeGroups(rt: SourcesRuntime, entityId: string): Promise { + const tv = await tvContext(rt, entityId); + const current = currentOf(await entityEpisodeGroup(rt.ctx.db, tv.id)); + if ("error" in tv) + return { + applicable: tv.error !== "not_episodic" && tv.error !== "no_tmdb_identity", + current, + groups: [], + error: tv.error, + }; + try { + const groups = await fetchEpisodeGroups(tv.tmdb, tv.tvId, tv.lang); + return { + applicable: true, + current, + groups: groups.map((g) => ({ ...g, typeLabel: g.type !== null ? (EPISODE_GROUP_TYPES[g.type] ?? null) : null })), + error: null, + }; + } catch (e) { + if (!(e instanceof TmdbError)) throw e; + return { applicable: true, current, groups: [], error: `tmdb_${e.kind}` }; + } +} + +/** 默认季结构:TMDB 详情的季(含季 0 特别篇,没有集的季不列) */ +async function richDetailOf(tv: TvContext): Promise { + return fetchRichDetail(tv.tmdb, "tv", tv.tvId, [tv.lang]); +} + +async function layoutOf( + tv: TvContext, + groupId: string | null, + rich?: TmdbRichDetail, +): Promise<{ layout: Layout; detail: TmdbEpisodeGroupDetail | null; rich: TmdbRichDetail }> { + const r = rich ?? (await richDetailOf(tv)); + if (!groupId) { + return { + layout: defaultLayout( + r.seasons + .filter((s) => s.episodeCount > 0) + .map((s) => ({ + seasonNumber: s.seasonNumber, + name: s.name, + episodeCount: s.episodeCount, + airDate: s.airDate, + })), + ), + detail: null, + rich: r, + }; + } + const detail = await fetchEpisodeGroup(tv.tmdb, groupId, tv.lang); + return { layout: groupLayout(detail), detail, rich: r }; +} + +const tmdbFailure = (e: unknown): never => { + if (e instanceof TmdbError) { + if (e.kind === "notfound") throw new ApiException("not_found", "TMDB 上没有这个剧集组"); + throw new ApiException("upstream_unavailable", "TMDB 暂不可用,稍后再试"); + } + throw e; +}; + +/** 已入库的集单元(直接覆盖,或所在季单元被整季覆盖) */ +async function coveredUnitIds(exec: Exec, entityId: string): Promise> { + const r = await sql<{ id: string }>` + select distinct u.id from content_unit u + left join content_unit s on s.id = u.parent_unit_id + where u.media_entity_id = ${entityId} and u.kind = 'episode' and u.status <> 'retired' + and exists (select 1 from asset_unit_coverage c join media_asset a on a.id = c.media_asset_id + where a.status = 'active' and (c.content_unit_id = u.id or c.content_unit_id = s.id))`.execute( + exec, + ); + return new Set(r.rows.map((x) => x.id)); +} + +/** 某套季集结构的季卡片:季名、集数、首播、海报、本地已入库(按 TMDB 原季集对照,换结构前也能看) */ +export async function episodeStructureSeasons( + rt: SourcesRuntime, + entityId: string, + groupId: string | null, +): Promise { + const tv = await tvContext(rt, entityId); + if ("error" in tv) throw new ApiException("bad_request", "作品没有 TMDB 剧集身份或 TMDB 未配置"); + const exec = rt.ctx.db; + const cur = await entityEpisodeGroup(exec, tv.id); + const isCurrent = (cur?.id ?? null) === groupId; + let target: Awaited>; + let current: Layout; + try { + target = await layoutOf(tv, groupId); + current = isCurrent ? target.layout : (await layoutOf(tv, cur?.id ?? null, target.rich)).layout; + } catch (e) { + return tmdbFailure(e); + } + const units = await unitOrigins(exec, tv.id, current); + const covered = await coveredUnitIds(exec, tv.id); + const have = new Set(); + for (const u of units) { + if (!covered.has(u.id)) continue; + const pos: Coord | null = isCurrent + ? { season: u.season, episode: u.episode } + : u.origin + ? target.layout.fromOrigin(u.origin.season, u.origin.episode) + : null; + if (pos) have.add(`${pos.season}:${pos.episode}`); + } + const seasonUnits = isCurrent + ? await exec + .selectFrom("content_unit") + .select(["id", "season_number"]) + .where("media_entity_id", "=", tv.id) + .where("kind", "=", "season") + .where("status", "<>", "retired") + .orderBy("created_at") + .execute() + : []; + const unitOf = (n: number) => seasonUnits.find((s) => s.season_number === n)?.id ?? null; + const posterOf = (tmdbSeason: number | undefined) => + target.rich.seasons.find((x) => x.seasonNumber === tmdbSeason)?.posterPath ?? null; + + const seasons: EpisodeStructureSeason[] = []; + if (target.detail) { + for (const s of target.detail.seasons) { + const eps = s.episodes.map((e) => ({ + episodeNumber: e.episodeNumber, + name: e.name, + airDate: e.airDate, + tmdbSeason: e.tmdbSeason, + tmdbEpisode: e.tmdbEpisode, + inLibrary: have.has(`${s.seasonNumber}:${e.episodeNumber}`), + })); + seasons.push({ + seasonNumber: s.seasonNumber, + name: s.name, + episodeCount: eps.length, + airDate: s.airDate, + posterPath: posterOf(s.episodes[0]?.tmdbSeason), + inLibrary: eps.filter((e) => e.inLibrary).length, + seasonUnitId: unitOf(s.seasonNumber), + episodes: eps, + }); + } + } else { + for (const s of target.rich.seasons.filter((x) => x.episodeCount > 0).slice(0, MAX_DEFAULT_SEASONS)) { + let list: { episodeNumber: number; name: string | null; airDate: string | null }[] = []; + try { + list = (await tv.tmdb.season(tv.tvId, s.seasonNumber)).episodes; + } catch (e) { + if (!(e instanceof TmdbError)) throw e; + } + const eps = list + .filter((e) => e.episodeNumber >= 1) + .map((e) => ({ + episodeNumber: e.episodeNumber, + name: e.name, + airDate: e.airDate, + tmdbSeason: s.seasonNumber, + tmdbEpisode: e.episodeNumber, + inLibrary: have.has(`${s.seasonNumber}:${e.episodeNumber}`), + })); + const count = eps.length || s.episodeCount; + seasons.push({ + seasonNumber: s.seasonNumber, + name: s.name, + episodeCount: count, + airDate: s.airDate, + posterPath: s.posterPath, + inLibrary: eps.length + ? eps.filter((e) => e.inLibrary).length + : [...have].filter((k) => k.startsWith(`${s.seasonNumber}:`)).length, + seasonUnitId: unitOf(s.seasonNumber), + episodes: eps, + }); + } + } + return { groupId, name: target.layout.name, current: isCurrent, seasons }; +} + +/** + * 切换作品的季集结构(TMDB 默认季 ⇄ 剧集组 / 剧集组之间): + * 事务内按 TMDB 原季集原地迁移集单元、重建季表与总集数、记下选择,按作品全量重算已绑定资源; + * 提交后 full 刷新元数据(季 / 集标题、播出日期、原季集对照按新结构写入,写进 NFO 的字段变了会排重刮)。 + */ +export async function setEpisodeGroup( + rt: SourcesRuntime, + entityId: string, + groupId: string | null, + actor: Actor, +): Promise { + const tv = await tvContext(rt, entityId); + if ("error" in tv) { + throw new ApiException( + "bad_request", + tv.error === "not_episodic" + ? "只有剧集 / 综艺能选季集结构" + : tv.error === "no_tmdb_identity" + ? "作品还没有 TMDB 剧集身份" + : "TMDB 未配置", + ); + } + const before = await entityEpisodeGroup(rt.ctx.db, tv.id); + const fromId = before?.id ?? null; + const empty = { releases: 0, changed: 0, resolved: 0 }; + if (fromId === groupId) { + return { + changed: false, + current: currentOf(before), + moved: 0, + kept: 0, + unmatched: [], + seasons: [], + orphanSeasons: [], + reresolve: empty, + refresh: "skipped", + }; + } + let from: Layout; + let to: Awaited>; + try { + to = await layoutOf(tv, groupId); + from = fromId ? (await layoutOf(tv, fromId, to.rich)).layout : defaultLayout([]); + } catch (e) { + return tmdbFailure(e); + } + const now = rt.now(); + const setBy = `${actor.type}:${actor.id ?? "-"}`; + const moved = await rt.ctx.db.transaction().execute(async (trx) => { + await trx.selectFrom("media_entity").select("id").where("id", "=", tv.id).forUpdate().executeTakeFirstOrThrow(); + const r = await relayoutUnits(trx, tv.id, from, to.layout, now, { resetTotals: true }); + await trx + .updateTable("media_entity") + .set({ + episode_group_id: groupId, + episode_group_name: groupId ? to.layout.name : null, + episode_group_set_at: now, + episode_group_set_by: setBy, + updated_at: now, + }) + .where("id", "=", tv.id) + .execute(); + await audit( + trx, + actor, + "media_entity.episode_group", + { type: "media_entity", id: tv.id }, + { + from: fromId, + to: groupId, + name: groupId ? to.layout.name : null, + moved: r.moved, + kept: r.kept, + unmatched: r.unmatched.length, + orphanSeasons: r.orphanSeasons, + }, + ); + const reresolve = await reresolveEntityEpisodes(rt.ctx, trx, tv.id); + return { ...r, reresolve }; + }); + let refresh = "refreshed"; + try { + refresh = (await refreshMetadata(rt, tv.id, { full: true })).status; + } catch (e) { + rt.ctx.logger.warn({ entityId: tv.id, err: { message: (e as Error).message } }, "切换季集结构后刷新元数据失败"); + refresh = "failed"; + } + return { + changed: true, + current: currentOf(await entityEpisodeGroup(rt.ctx.db, tv.id)), + moved: moved.moved, + kept: moved.kept, + unmatched: moved.unmatched, + seasons: to.layout.seasons.map((s) => ({ seasonNumber: s.seasonNumber, episodeCount: s.episodeCount })), + orphanSeasons: moved.orphanSeasons, + reresolve: moved.reresolve, + refresh, + }; +} diff --git a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts index bd81215b..7f266b58 100644 --- a/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts +++ b/apps/server/src/modules/sources-identity/service/episode-mapping-admin.ts @@ -42,6 +42,7 @@ import { reresolveEntityEpisodes, sameResolution, } from "./episode-resolution.ts"; +import { entityEpisodeGroup } from "./episode-structure.ts"; // 集数映射的接口层(docs/plan/episode-resolution.md 批次 4):规则写入校验、写入后按作品重算、模拟预览、 // 待处理 episode_mapping 的「确认建议规则 / 忽略并建忽略规则」。判定语义全在 domain,这里只装配与校验。 @@ -334,8 +335,12 @@ export function validationOf(r: EpisodeResolution): EpisodeValidationState { const sourceSeason = (r: EpisodeResolution) => r.source.season ?? (r.source.episodes ? 1 : null); -function seasonSummary(table: SeasonTable): EpisodeMappingPreview["seasons"] { +function seasonSummary( + table: SeasonTable, + structure: { groupId: string; name: string } | null, +): EpisodeMappingPreview["seasons"] { return { + structure, seasons: table.seasons.map((s) => ({ season: s.season, total: s.total, @@ -374,7 +379,8 @@ export async function previewEpisodeMapping( ): Promise { const entity = await loadEpisodicEntity(exec, entityId); const table = await loadSeasonTable(exec, entityId); - const seasons = seasonSummary(table); + const group = await entityEpisodeGroup(exec, entityId); + const seasons = seasonSummary(table, group ? { groupId: group.id, name: group.name } : null); if (!EPISODE_KINDS.has(entity.kind)) return { applicable: false, seasons, groups: [], items: [], changed: 0 }; let rules = await loadRules(exec, entityId); diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index 670fcfbd..72afde45 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -24,6 +24,7 @@ import type { ServerContext } from "../../types.ts"; import { jsonb } from "../rows.ts"; import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { ensureUnits } from "./entities.ts"; +import { seasonOrigins } from "./episode-structure.ts"; // 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把 // 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库: @@ -42,11 +43,13 @@ const DECISION_LIST_LIMIT = 50; * 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元。 * 只计元数据(tmdb)/ 人工(user)来源或已确认的季单元:资源 / 文件懒建的临时季(解析错的 S05 之类)不进季表, * 否则它总集数为空会让该季资源一律按「元数据滞后」放行,还会抬高最新季。 + * 作品选了剧集组时季 / 集单元本身就是按组生成的,季表随之是组的季集;另附原季集对照(origins), + * 发布者按 TMDB 默认季编号时换算一步据此换到组内季集。 */ export async function loadSeasonTable(exec: Exec, entityId: string): Promise { const entity = await exec .selectFrom("media_entity") - .select(["metadata"]) + .select(["metadata", "episode_group_id"]) .where("id", "=", entityId) .executeTakeFirst(); const units = await exec @@ -85,10 +88,12 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise a.season - b.season); for (const s of list) s.episodes!.sort((a, b) => a.episode - b.episode); const main = list.filter((s) => s.season >= 1).map((s) => s.season); + const origins = entity?.episode_group_id ? await seasonOrigins(exec, entityId) : []; return { seasons: list, latestSeason: main.length ? Math.max(...main) : null, ended: ENDED_STATUSES.has(airStatusOf(entity?.metadata).toLowerCase()), + ...(origins.length ? { origins } : {}), }; } diff --git a/apps/server/src/modules/sources-identity/service/episode-structure.ts b/apps/server/src/modules/sources-identity/service/episode-structure.ts new file mode 100644 index 00000000..541f2c7a --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/episode-structure.ts @@ -0,0 +1,360 @@ +import type { TmdbEpisodeGroupDetail } from "@umt/adapters"; +import { sql } from "@umt/db"; +import type { SeasonOrigin } from "@umt/domain"; +import { jsonb } from "../rows.ts"; +import type { Exec } from "./common.ts"; +import { ensureSeason } from "./metadata-totals.ts"; + +// 季集结构(TMDB 默认季 / 剧集组)与切换时的单元迁移。 +// 迁移规则(docs/runbook/identity.md「剧集组」): +// 1. 每个未退役的集单元先求「TMDB 原季集」:单元元数据里记的原季集(按剧集组生成 / 刷新时写入的 tmdbSeason、tmdbEpisode)优先; +// 否则按当前结构换算(默认季 = 自身季集;剧集组 = 当前组里这个位置的原季集)。 +// 2. 按目标结构把原季集换成新季集(默认季 = 原季集;剧集组 = 组序号 / 组内顺序),原地改单元的季号、集号、所属季单元。 +// 单元 id 不变,资产覆盖、资源、下载、订阅覆盖、字段覆盖等引用全部跟着走。 +// 3. 编号人工锁定的单元不动;求不出原季集、或目标结构里没有这一集的单元保留原号、标临时(来源记为资源),结果里列出; +// 同季同集已有对上的单元时,随后的元数据刷新按重复单元合并(引用并到对上的那条,DH-1)。 +// 4. 季单元:目标结构的每季确保有季单元(同号沿用,临时的转正);不在目标结构里的季单元标临时、清总集数(引用不动)。 + +const EPISODE_ORIGIN_FIELDS = ["tmdbSeason", "tmdbEpisode"] as const; + +export interface Coord { + season: number; + episode: number; +} + +export interface StructureSeason { + seasonNumber: number; + name: string | null; + episodeCount: number; + airDate: string | null; +} + +/** 一套季集结构:默认季(groupId null,原季集即自身)或剧集组(按组内位置对照原季集) */ +export interface Layout { + groupId: string | null; + name: string; + seasons: StructureSeason[]; + toOrigin(season: number, episode: number): Coord | null; + fromOrigin(season: number, episode: number): Coord | null; +} + +const key = (s: number, e: number) => `${s}:${e}`; + +export function defaultLayout(seasons: readonly StructureSeason[]): Layout { + return { + groupId: null, + name: "TMDB 默认季", + seasons: [...seasons], + toOrigin: (season, episode) => ({ season, episode }), + fromOrigin: (season, episode) => ({ season, episode }), + }; +} + +export function groupLayout(detail: TmdbEpisodeGroupDetail): Layout { + const toO = new Map(); + const fromO = new Map(); + for (const s of detail.seasons) + for (const e of s.episodes) { + toO.set(key(e.seasonNumber, e.episodeNumber), { season: e.tmdbSeason, episode: e.tmdbEpisode }); + // 同一原集出现在多个组时取第一次出现的位置 + if (!fromO.has(key(e.tmdbSeason, e.tmdbEpisode))) + fromO.set(key(e.tmdbSeason, e.tmdbEpisode), { season: e.seasonNumber, episode: e.episodeNumber }); + } + return { + groupId: detail.id, + name: detail.name, + seasons: detail.seasons.map((s) => ({ + seasonNumber: s.seasonNumber, + name: s.name, + episodeCount: s.episodes.length, + airDate: s.airDate, + })), + toOrigin: (s, e) => toO.get(key(s, e)) ?? null, + fromOrigin: (s, e) => fromO.get(key(s, e)) ?? null, + }; +} + +/** 作品当前选的剧集组;null = TMDB 默认季 */ +export async function entityEpisodeGroup( + exec: Exec, + entityId: string, +): Promise<{ id: string; name: string; setAt: Date | null; setBy: string | null } | null> { + const e = await exec + .selectFrom("media_entity") + .select(["episode_group_id", "episode_group_name", "episode_group_set_at", "episode_group_set_by"]) + .where("id", "=", entityId) + .executeTakeFirst(); + if (!e?.episode_group_id) return null; + return { + id: e.episode_group_id, + name: e.episode_group_name ?? e.episode_group_id, + setAt: e.episode_group_set_at ?? null, + setBy: e.episode_group_set_by ?? null, + }; +} + +const finalNum = (metadata: unknown, field: string): number | null => { + const v = (metadata as Record | null)?.[field]; + const raw = v && typeof v === "object" && "value" in v ? (v as { value: unknown }).value : null; + return typeof raw === "number" && Number.isInteger(raw) ? raw : null; +}; + +/** 单元元数据里记的 TMDB 原季集(按剧集组生成 / 刷新时写入);没有为 null */ +export function metadataOrigin(metadata: unknown): Coord | null { + const season = finalNum(metadata, "tmdbSeason"); + const episode = finalNum(metadata, "tmdbEpisode"); + return season !== null && episode !== null ? { season, episode } : null; +} + +export interface UnitOriginRow { + id: string; + season: number; + episode: number; + status: string; + numberingLocked: boolean; + /** TMDB 原季集;求不出为 null */ + origin: Coord | null; +} + +/** 作品未退役集单元与其 TMDB 原季集(元数据记的优先,否则按 layout 换算) */ +export async function unitOrigins(exec: Exec, entityId: string, layout: Layout): Promise { + const rows = await exec + .selectFrom("content_unit") + .select(["id", "season_number", "episode_number", "status", "numbering_locked", "metadata"]) + .where("media_entity_id", "=", entityId) + .where("kind", "=", "episode") + .where("status", "<>", "retired") + .where("season_number", "is not", null) + .where("episode_number", "is not", null) + .orderBy("created_at") + .execute(); + return rows.map((u) => ({ + id: u.id, + season: u.season_number!, + episode: u.episode_number!, + status: u.status, + numberingLocked: u.numbering_locked, + origin: + (layout.groupId ? metadataOrigin(u.metadata) : null) ?? layout.toOrigin(u.season_number!, u.episode_number!), + })); +} + +/** 剧集组结构的季表对照(loadSeasonTable 用):集单元元数据里的原季集 → 单元季集 */ +export async function seasonOrigins(exec: Exec, entityId: string): Promise { + const r = await sql<{ season_number: number; episode_number: number; s: string | null; e: string | null }>` + select season_number, episode_number, + metadata -> 'tmdbSeason' ->> 'value' as s, metadata -> 'tmdbEpisode' ->> 'value' as e + from content_unit + where media_entity_id = ${entityId} and kind = 'episode' and status <> 'retired' + and season_number is not null and episode_number is not null + and metadata ? 'tmdbSeason' and metadata ? 'tmdbEpisode' + order by created_at`.execute(exec); + const seen = new Set(); + const out: SeasonOrigin[] = []; + for (const x of r.rows) { + const tmdbSeason = Number(x.s); + const tmdbEpisode = Number(x.e); + if (!Number.isInteger(tmdbSeason) || !Number.isInteger(tmdbEpisode)) continue; + const k = key(tmdbSeason, tmdbEpisode); + if (seen.has(k)) continue; + seen.add(k); + out.push({ tmdbSeason, tmdbEpisode, season: x.season_number, episode: x.episode_number }); + } + return out; +} + +export interface UnmatchedUnit { + unitId: string; + seasonNumber: number; + episodeNumber: number; + reason: "no_origin" | "not_in_target" | "numbering_locked"; + referenced: boolean; +} + +export interface RelayoutResult { + moved: number; + kept: number; + unmatched: UnmatchedUnit[]; + orphanSeasons: number[]; +} + +/** 单元除自身元数据外有没有被引用(资产覆盖、资源、下载、订阅覆盖…) */ +async function referencedUnits(exec: Exec, ids: readonly string[]): Promise> { + if (!ids.length) return new Set(); + const r = await sql<{ id: string }>` + select x.id from ( + select content_unit_id as id from asset_unit_coverage where content_unit_id = any(${[...ids]}::uuid[]) + union select content_unit_id from source_release where content_unit_id = any(${[...ids]}::uuid[]) + union select content_unit_id from slot_unit where content_unit_id = any(${[...ids]}::uuid[]) + union select content_unit_id from acquisition_slot where content_unit_id = any(${[...ids]}::uuid[]) + ) x where x.id is not null`.execute(exec); + return new Set(r.rows.map((x) => x.id)); +} + +/** + * 把作品的季 / 集单元从 from 结构迁到 to 结构(规则见文件头)。必须在事务里调用。 + * resetTotals:切换结构时把季总集数直接改成新结构的集数(解除锁定);刷新时不传(总集数仍按只升规则走 applySeasonTotals)。 + */ +export async function relayoutUnits( + exec: Exec, + entityId: string, + from: Layout, + to: Layout, + now: Date, + opts: { resetTotals?: boolean } = {}, +): Promise { + const out: RelayoutResult = { moved: 0, kept: 0, unmatched: [], orphanSeasons: [] }; + const units = await unitOrigins(exec, entityId, from); + const seasonIds = new Map(); + const seasonOf = async (n: number) => { + let id = seasonIds.get(n); + if (!id) { + id = await ensureSeason(exec, entityId, n, to.seasons.find((s) => s.seasonNumber === n)?.name ?? null); + seasonIds.set(n, id); + } + return id; + }; + for (const s of to.seasons) await seasonOf(s.seasonNumber); + + const unmatched: Omit[] = []; + const placed: { id: string; origin: Coord }[] = []; + for (const u of units) { + if (u.numberingLocked) { + unmatched.push({ unitId: u.id, seasonNumber: u.season, episodeNumber: u.episode, reason: "numbering_locked" }); + continue; + } + const target = u.origin ? to.fromOrigin(u.origin.season, u.origin.episode) : null; + if (!target) { + unmatched.push({ + unitId: u.id, + seasonNumber: u.season, + episodeNumber: u.episode, + reason: u.origin ? "not_in_target" : "no_origin", + }); + continue; + } + placed.push({ id: u.id, origin: u.origin! }); + const parent = await seasonOf(target.season); + if (target.season === u.season && target.episode === u.episode) { + out.kept++; + await exec + .updateTable("content_unit") + .set({ parent_unit_id: parent }) + .where("id", "=", u.id) + .where(sql`parent_unit_id is distinct from ${parent}::uuid`) + .execute(); + continue; + } + out.moved++; + await exec + .updateTable("content_unit") + .set({ + season_number: target.season, + episode_number: target.episode, + display_order: target.episode, + parent_unit_id: parent, + updated_at: now, + }) + .where("id", "=", u.id) + .execute(); + } + // 对不上的单元标临时(来源记为资源,下次对上 TMDB 时 DH-1 再转正) + const demote = unmatched.filter((x) => x.reason !== "numbering_locked").map((x) => x.unitId); + if (demote.length) + await exec + .updateTable("content_unit") + .set({ status: "provisional", source: "release", updated_at: now }) + .where("id", "in", demote) + .where("status", "=", "confirmed") + .execute(); + const refs = await referencedUnits( + exec, + unmatched.map((x) => x.unitId), + ); + out.unmatched = unmatched.map((x) => ({ ...x, referenced: refs.has(x.unitId) })); + + // 原季集对照:默认季结构不记;剧集组结构给对上的集单元写上(刷新元数据时会以 tmdb 来源字段再写一遍) + await stripOrigins(exec, entityId, to.groupId ? demote : null); + if (to.groupId) for (const p of placed) await writeOrigin(exec, p.id, p.origin, now); + + // 季单元:目标结构里的季总集数按新结构;不在目标结构里的季标临时 + const keepSeasons = new Set(to.seasons.map((s) => s.seasonNumber)); + const seasons = await exec + .selectFrom("content_unit") + .select(["id", "season_number", "status"]) + .where("media_entity_id", "=", entityId) + .where("kind", "=", "season") + .where("status", "<>", "retired") + .where("season_number", "is not", null) + .execute(); + for (const s of seasons) { + if (keepSeasons.has(s.season_number!)) continue; + if (!out.orphanSeasons.includes(s.season_number!)) out.orphanSeasons.push(s.season_number!); + await exec + .updateTable("content_unit") + .set({ + status: "provisional", + source: "release", + episode_total: null, + episode_total_source: null, + episode_total_locked: false, + episode_total_at: null, + updated_at: now, + }) + .where("id", "=", s.id) + .execute(); + } + out.orphanSeasons.sort((a, b) => a - b); + if (opts.resetTotals) { + for (const s of to.seasons) { + if (s.seasonNumber < 1) continue; + await exec + .updateTable("content_unit") + .set({ + episode_total: s.episodeCount > 0 ? s.episodeCount : null, + episode_total_source: s.episodeCount > 0 ? "tmdb" : null, + episode_total_locked: false, + episode_total_at: now, + title: s.name, + }) + .where("media_entity_id", "=", entityId) + .where("kind", "=", "season") + .where("season_number", "=", s.seasonNumber) + .where("status", "<>", "retired") + .execute(); + } + } + return out; +} + +/** 删掉单元上的原季集记录(metadata_field 的 tmdb 行与投影出的最终值);ids = null 时清整个作品 */ +async function stripOrigins(exec: Exec, entityId: string, ids: readonly string[] | null): Promise { + if (ids && !ids.length) return; + const scope = ids + ? sql`id = any(${[...ids]}::uuid[])` + : sql`media_entity_id = ${entityId} and (metadata ? 'tmdbSeason' or metadata ? 'tmdbEpisode')`; + await sql` + delete from metadata_field f using content_unit u + where f.content_unit_id = u.id and f.field = any(${[...EPISODE_ORIGIN_FIELDS]}::text[]) + and u.media_entity_id = ${entityId} ${ids ? sql`and u.id = any(${[...ids]}::uuid[])` : sql``}`.execute(exec); + await sql`update content_unit set metadata = metadata - 'tmdbSeason' - 'tmdbEpisode' + where ${scope} and media_entity_id = ${entityId}`.execute(exec); +} + +/** 集单元记下 TMDB 原季集(tmdb 来源字段 + 投影值) */ +async function writeOrigin(exec: Exec, unitId: string, origin: Coord, now: Date): Promise { + const at = now.toISOString(); + const fv = (value: number) => ({ value, source: "tmdb", language: null, at, locked: false }); + for (const [field, value] of [ + ["tmdbSeason", origin.season], + ["tmdbEpisode", origin.episode], + ] as const) { + await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at) + values (${null}, ${unitId}, ${field}, ${jsonb(value)}::jsonb, ${null}, 'tmdb', ${"episode_group"}, ${now}) + on conflict (media_entity_id, content_unit_id, field, source, language) do update set + value = excluded.value, fetched_at = excluded.fetched_at`.execute(exec); + } + await sql`update content_unit + set metadata = metadata || ${jsonb({ tmdbSeason: fv(origin.season), tmdbEpisode: fv(origin.episode) })}::jsonb + where id = ${unitId}`.execute(exec); +} diff --git a/apps/server/src/modules/sources-identity/service/metadata-rich.ts b/apps/server/src/modules/sources-identity/service/metadata-rich.ts index 34658f15..053e76e7 100644 --- a/apps/server/src/modules/sources-identity/service/metadata-rich.ts +++ b/apps/server/src/modules/sources-identity/service/metadata-rich.ts @@ -293,9 +293,16 @@ export async function projectUnit( return { changedFields: changed, lockedFields }; } -function episodeFields(e: TmdbRichEpisode): Record { +function episodeFields(e: TmdbRichEpisode & { tmdbSeason?: number; tmdbEpisode?: number }): Record { const air = e.airDate && DATE_RE.test(e.airDate.slice(0, 10)) ? e.airDate.slice(0, 10) : null; return { + // 剧集组的集:记下 TMDB 默认季集号(切换季集结构时据此对照,季表换算也用) + ...(e.tmdbSeason !== undefined && e.tmdbEpisode !== undefined + ? { + tmdbSeason: { value: e.tmdbSeason, language: null }, + tmdbEpisode: { value: e.tmdbEpisode, language: null }, + } + : {}), name: { value: e.name, language: e.nameLanguage }, overview: { value: e.overview, language: e.overviewLanguage }, airDate: { value: air, language: null }, diff --git a/apps/server/src/modules/sources-identity/service/metadata.ts b/apps/server/src/modules/sources-identity/service/metadata.ts index 9b704e82..c2add041 100644 --- a/apps/server/src/modules/sources-identity/service/metadata.ts +++ b/apps/server/src/modules/sources-identity/service/metadata.ts @@ -1,6 +1,9 @@ import { + type CandidateSeason, + fetchEpisodeGroup, fetchRichDetail, fetchRichSeason, + groupSeasonAsRichSeason, type MetadataCandidate, type TmdbClient, TmdbError, @@ -23,6 +26,7 @@ import type { SourcesRuntime } from "../runtime.ts"; import { type Actor, audit, type Exec } from "./common.ts"; import { resolveEntityId, seedAliases } from "./entities.ts"; import { reresolveEntityEpisodes } from "./episode-resolution.ts"; +import { entityEpisodeGroup, groupLayout, type Layout, relayoutUnits } from "./episode-structure.ts"; import { applySeasonMetadata, metadataIncludeSpecials, @@ -140,7 +144,7 @@ const ENTITY_NFO_FIELDS: ReadonlySet = new Set([ /** * 改作品的 TMDB 映射后:清掉该作品及其季 / 集单元的 tmdb 来源字段(metadata_field 与投影出的最终值), - * 人工覆盖与锁定字段保留;单元的 title / air_date 列随 name / airDate 一起清空。之后由 full 刷新按新身份重新填, + * 人工覆盖与锁定字段保留;单元的 title / air_date 列随 name / airDate 一起清空;选的剧集组一并清掉。之后由 full 刷新按新身份重新填, * 避免新身份的 NFO 混进旧作品的标题、简介、IMDb、海报(海报 / 背景图「TMDB 没有时保留旧值」不能保留旧作品的)。 */ export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date): Promise { @@ -177,6 +181,11 @@ export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date): new Set(entityOverrides.map((o) => o.field)), ), ), + // 剧集组属于旧 TMDB 条目,换身份后回到默认季 + episode_group_id: null, + episode_group_name: null, + episode_group_set_at: null, + episode_group_set_by: null, updated_at: now, }) .where("id", "=", entityId) @@ -301,7 +310,46 @@ export async function refreshMetadata( ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 富详情获取失败,按基础详情刷新"); } } - if (rich && ident.entity_type === "tv" && detail.seasons?.length) { + // 作品选了剧集组:季表与季 / 集单元按组(组序号 → 季号,组内顺序 → 集号);组取不到时这次不动季集结构 + const group = ident.entity_type === "tv" && rich ? await entityEpisodeGroup(ctx.db, id) : null; + let layout: Layout | null = null; + let groupSeasons: CandidateSeason[] | null = null; + if (group) { + try { + const gd = await fetchEpisodeGroup(tmdb, group.id, langs[0]); + layout = groupLayout(gd); + groupSeasons = layout.seasons.map((s) => ({ + seasonNumber: s.seasonNumber, + episodeCount: s.episodeCount, + name: s.name, + airDate: s.airDate, + })); + const known = gd.seasons.map((s) => s.seasonNumber); + const tracked = await trackedSeasonsForAirDates(ctx.db, id, known); + const want = seasonsToRefresh(known, { + full: !!opts.full, + tracked, + inLibrary: await seasonsInLibrary(ctx.db, id), + specials: await metadataIncludeSpecials(ctx), + }); + creatable = new Set(opts.full ? want : tracked); + // 组的季没有海报:取组内首集所在的 TMDB 默认季的海报 + const posterOf = (s: (typeof gd.seasons)[number]) => { + const o = s.episodes[0]?.tmdbSeason; + return richDetail?.seasons.find((x) => x.seasonNumber === o)?.posterPath ?? null; + }; + for (const s of gd.seasons) + if (want.includes(s.seasonNumber)) + richSeasons.push(groupSeasonAsRichSeason(ident.external_id, s, gd.language, posterOf(s))); + } catch (e) { + if (!(e instanceof TmdbError)) throw e; + ctx.logger.warn( + { entityId: id, episodeGroup: group.id, err: { message: e.message } }, + "剧集组获取失败,本次不刷新季集", + ); + groupSeasons = []; + } + } else if (rich && ident.entity_type === "tv" && detail.seasons?.length) { const known = detail.seasons.map((x) => x.seasonNumber); const tracked = await trackedSeasonsForAirDates(ctx.db, id, known); const want = seasonsToRefresh(known, { @@ -321,7 +369,7 @@ export async function refreshMetadata( } // 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新 const airSeasons: TmdbSeasonDetail[] = []; - if (!rich && ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") { + if (!group && !rich && ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") { const want = await trackedSeasonsForAirDates( ctx.db, id, @@ -350,15 +398,16 @@ export async function refreshMetadata( await seedAliases(trx, id, detail); if (richDetail) await tagTitleAliases(trx, id, richDetail); const r = await project(trx, id, now); + // 剧集组:TMDB 编辑调整过组内顺序时,集单元按记下的原季集挪到新位置(规则同切换季集结构) + if (layout) await relayoutUnits(trx, id, layout, layout, now); // DH-1:先合并重复单元(并发懒建留下的同季同集多条),再按 TMDB 季集结构写入 / 转正 const merged = await mergeDuplicateUnits(trx, id); if (merged.merged) ctx.logger.info({ entityId: id, merged: merged.merged, repointed: merged.repointed }, "合并重复内容单元"); // 季总集数账本:只升;回退不低于已覆盖最高集;锁定不改(订阅完成判定据此) + const seasonList = groupSeasons ?? detail.seasons ?? []; const totals = - ident.entity_type === "tv" && detail.seasons?.length - ? await applySeasonTotals(trx, id, detail.seasons, now) - : null; + ident.entity_type === "tv" && seasonList.length ? await applySeasonTotals(trx, id, seasonList, now) : null; for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd); const episodes: SeasonMetadataResult[] = []; for (const sd of richSeasons) diff --git a/apps/server/test/sources-identity/episode-groups.test.ts b/apps/server/test/sources-identity/episode-groups.test.ts new file mode 100644 index 00000000..8bd90162 --- /dev/null +++ b/apps/server/test/sources-identity/episode-groups.test.ts @@ -0,0 +1,333 @@ +import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { loadSeasonTable } from "../../src/modules/sources-identity/service/episode-resolution.ts"; +import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; +import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { createHarness, EXEC_KEY, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts"; + +// 剧集组(季集结构):TMDB 默认把两个 cour 合成一季(S1 = 25 集),剧集组「制作季」拆成 S1(1–12)、S2(13–25)。 +// 选剧集组后季表 / 单元按组生成、旧单元与资产覆盖按 TMDB 原季集原地迁移、集数映射按组校验与换算、切回默认、刷新按组。 + +const pg = await pgAvailable(); + +describe.skipIf(!pg)("剧集组", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + let entityId: string; + let abs13: string; + let s01e14: string; + let unitE14: string; + let unitE30: string; + + beforeAll(async () => { + tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SPLIT_COUR_FIXTURE] }); + h = await createHarness("episode_groups", { tmdbUrl: tmdb.url }); + siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const resolve = async (title: string) => { + const r = await h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: String(820000 + ++n), title, subtitle: "分割番" }) + .returningAll() + .executeTakeFirstOrThrow(); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); + expect(res.outcome, JSON.stringify(res)).toBe("auto"); + return { id: r.id, entityId: res.mediaEntityId! }; + }; + // biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb + const release = async (id: string): Promise => + h.db + .selectFrom("source_release") + .select(["episode_resolution", "content_unit_id"]) + .where("id", "=", id) + .executeTakeFirstOrThrow(); + const unit = (id: string) => + h.db + .selectFrom("content_unit") + .select(["season_number", "episode_number", "status", "source", "metadata", "title", "parent_unit_id"]) + .where("id", "=", id) + .executeTakeFirstOrThrow(); + const unitAt = async (season: number, episode: number) => + ( + await h.db + .selectFrom("content_unit") + .select("id") + .where("media_entity_id", "=", entityId) + .where("kind", "=", "episode") + .where("season_number", "=", season) + .where("episode_number", "=", episode) + .where("status", "<>", "retired") + .executeTakeFirstOrThrow() + ).id; + const seasonTotals = async () => + ( + await h.db + .selectFrom("content_unit") + .select(["season_number", "episode_total", "status"]) + .where("media_entity_id", "=", entityId) + .where("kind", "=", "season") + .where("status", "<>", "retired") + .orderBy("season_number") + .execute() + ).map((s) => [s.season_number, s.episode_total, s.status]); + const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) => + json(await h.req(path, init)); + const setGroup = (groupId: string | null, key?: string) => + call(`/media-entities/${entityId}/episode-group`, { + method: "PUT", + body: { groupId }, + ...(key ? { key } : {}), + }); + + it("准备:默认季 S1 = 25 集;`- 13` 与 S01E14 按原号;S01E14 有入库文件;另有一条 S01E30 临时单元", async () => { + const r1 = await resolve("[ANi] Split Cour - 13 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); + entityId = r1.entityId; + abs13 = r1.id; + expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed"); + s01e14 = (await resolve("[Nekomoe] Split Cour S01E14 [1080p]")).id; + expect((await release(abs13)).episode_resolution).toMatchObject({ method: "raw", canonical: { season: 1 } }); + expect((await release(s01e14)).episode_resolution).toMatchObject({ + method: "raw", + canonical: { season: 1, episodes: { start: 14, end: 14 } }, + }); + expect(await seasonTotals()).toEqual([ + [0, null, "confirmed"], + [1, 25, "confirmed"], + ]); + unitE14 = await unitAt(1, 14); + const asset = await h.db + .insertInto("media_asset") + .values({ media_entity_id: entityId, kind: "video", status: "active" }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("asset_unit_coverage") + .values({ media_asset_id: asset.id, content_unit_id: unitE14 }) + .execute(); + const s1 = await h.db + .selectFrom("content_unit") + .select("id") + .where("media_entity_id", "=", entityId) + .where("kind", "=", "season") + .where("season_number", "=", 1) + .executeTakeFirstOrThrow(); + unitE30 = ( + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: entityId, + parent_unit_id: s1.id, + kind: "episode", + season_number: 1, + episode_number: 30, + status: "provisional", + source: "release", + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + }); + + it("剧集组列表与季卡片预览:按组显示季名、集数、首播、本地已入库(换结构前按原季集对照)", async () => { + const list = await call(`/media-entities/${entityId}/episode-groups`, { key: READ_KEY }); + expect(list.status).toBe(200); + expect(list.body.data).toMatchObject({ applicable: true, current: null, error: null }); + expect(list.body.data.groups.map((g: { id: string; typeLabel: string }) => [g.id, g.typeLabel])).toEqual([ + ["grp-production", "制作季"], + ["grp-aired", "原始播出"], + ]); + + const preview = await call(`/media-entities/${entityId}/episode-structure?groupId=grp-production`); + expect(preview.status).toBe(200); + expect(preview.body.data).toMatchObject({ groupId: "grp-production", name: "制作季", current: false }); + expect( + preview.body.data.seasons.map((s: Record) => [ + s.seasonNumber, + s.name, + s.episodeCount, + s.airDate, + s.posterPath, + s.inLibrary, + s.seasonUnitId, + ]), + ).toEqual([ + [1, "第一部", 12, "2024-01-07", "/split-s1.jpg", 0, null], + [2, "第二部", 13, "2024-07-07", "/split-s1.jpg", 1, null], + ]); + expect(preview.body.data.seasons[1].episodes[1]).toMatchObject({ + episodeNumber: 2, + tmdbSeason: 1, + tmdbEpisode: 14, + inLibrary: true, + }); + + const def = await call(`/media-entities/${entityId}/episode-structure`); + expect(def.body.data).toMatchObject({ groupId: null, current: true }); + const s1 = def.body.data.seasons.find((s: { seasonNumber: number }) => s.seasonNumber === 1); + expect(s1).toMatchObject({ episodeCount: 25, inLibrary: 1 }); + expect(s1.seasonUnitId).toBeTruthy(); + }); + + it("切到剧集组:只读 Key 不能改;集单元按原季集原地迁移(资产覆盖跟着走),季表按组,资源全量重算", async () => { + expect((await setGroup("grp-production", READ_KEY)).status).toBe(403); + const r = await setGroup("grp-production", EXEC_KEY); + expect(r.status, JSON.stringify(r.body)).toBe(200); + const d = r.body.data; + expect(d).toMatchObject({ + changed: true, + current: { groupId: "grp-production", name: "制作季", setBy: "api_key:operator" }, + moved: 13, + kept: 12, + seasons: [ + { seasonNumber: 1, episodeCount: 12 }, + { seasonNumber: 2, episodeCount: 13 }, + ], + orphanSeasons: [0], + refresh: "refreshed", + }); + expect( + d.unmatched.map((u: Record) => [u.seasonNumber, u.episodeNumber, u.reason, u.referenced]), + ).toEqual( + expect.arrayContaining([ + [0, 1, "not_in_target", false], + [1, 30, "not_in_target", false], + ]), + ); + + // 同一个单元(id 不变)挪到 S02E02,资产覆盖仍指向它;标题 / 原季集对照随单元 + const u14 = await unit(unitE14); + expect(u14).toMatchObject({ season_number: 2, episode_number: 2, status: "confirmed", title: "下篇第2话" }); + expect(u14.metadata).toMatchObject({ tmdbSeason: { value: 1 }, tmdbEpisode: { value: 14 } }); + const cov = await h.db + .selectFrom("asset_unit_coverage") + .select("content_unit_id") + .where("content_unit_id", "=", unitE14) + .execute(); + expect(cov).toHaveLength(1); + expect(await unit(unitE30)).toMatchObject({ season_number: 1, episode_number: 30, status: "provisional" }); + + expect(await seasonTotals()).toEqual([ + [0, null, "provisional"], + [1, 12, "confirmed"], + [2, 13, "confirmed"], + ]); + const s2Unit = await h.db + .selectFrom("content_unit") + .select(["id", "title"]) + .where("media_entity_id", "=", entityId) + .where("kind", "=", "season") + .where("season_number", "=", 2) + .executeTakeFirstOrThrow(); + expect(u14.parent_unit_id).toBe(s2Unit.id); + expect(s2Unit.title).toBe("第二部"); + + // 季表按组;带 25 条原季集对照 + const table = await loadSeasonTable(h.db, entityId); + expect(table.seasons.filter((s) => s.season >= 1).map((s) => [s.season, s.total])).toEqual([ + [1, 12], + [2, 13], + ]); + expect(table.origins).toHaveLength(25); + + // `- 13`(季号推定)归第 2 季第 1 集;按默认季编号的 S01E14 换到 S02E02,资源指向迁移后的同一个单元 + expect((await release(abs13)).episode_resolution).toMatchObject({ + canonical: { season: 2, episodes: { start: 1, end: 1 } }, + }); + const r14 = await release(s01e14); + expect(r14.episode_resolution).toMatchObject({ + method: "tmdb_season", + canonical: { season: 2, episodes: { start: 2, end: 2 } }, + }); + expect(r14.content_unit_id).toBe(unitE14); + expect(d.reresolve).toMatchObject({ releases: 2, changed: 2 }); + + const ent = await call(`/media-entities/${entityId}`); + expect(ent.body.data).toMatchObject({ episodeGroupId: "grp-production", episodeGroupName: "制作季" }); + const audit = await h.db + .selectFrom("audit_event") + .select("payload") + .where("action", "=", "media_entity.episode_group") + .execute(); + expect(audit).toHaveLength(1); + }); + + it("集数映射按组:规则目标是组内季集,按组的季表校验;新资源按组解析", async () => { + const rule = await call(`/media-entities/${entityId}/episode-mapping-rules`, { + body: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-12", targetSeason: 2 }, + }); + expect(rule.status, JSON.stringify(rule.body)).toBe(200); + const lil = (await resolve("[Lilith-Raws] Split Cour - 20 [Baha][WEB-DL][1080p]")).id; + expect((await release(lil)).episode_resolution).toMatchObject({ + method: "rule", + canonical: { season: 2, episodes: { start: 8, end: 8 } }, + }); + expect((await release(lil)).content_unit_id).toBe(await unitAt(2, 8)); + // 越过组内 S2 的 13 集:规则校验不过 → 待确认、规则停用 + const bad = (await resolve("[Lilith-Raws] Split Cour - 27 [Baha][WEB-DL][1080p]")).id; + expect((await release(bad)).episode_resolution).toMatchObject({ method: "unresolved" }); + + const preview = await call(`/media-entities/${entityId}/episode-mapping/preview`, { body: {} }); + expect(preview.body.data.seasons.structure).toEqual({ groupId: "grp-production", name: "制作季" }); + }); + + it("剧集组下刷新元数据按组拉取:不改季集结构,单元保持组内季集", async () => { + const r = await refreshMetadata(h.rt, entityId, { full: true }); + expect(r.status).toBe("refreshed"); + expect(r.seasons?.map((s) => [s.season, s.total])).toEqual([ + [1, 12], + [2, 13], + ]); + expect(await unit(unitE14)).toMatchObject({ season_number: 2, episode_number: 2, title: "下篇第2话" }); + expect(tmdb.callsTo(/\/3\/tv\/episode_group\/grp-production/).length).toBeGreaterThan(0); + }); + + it("剧集组之间切换:按原季集对照到另一组(全部 25 集一季)", async () => { + const r = await setGroup("grp-aired"); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.data).toMatchObject({ moved: 13, kept: 12, orphanSeasons: [0, 2] }); + expect(await unit(unitE14)).toMatchObject({ season_number: 1, episode_number: 14 }); + expect((await release(s01e14)).episode_resolution).toMatchObject({ method: "raw" }); + }); + + it("切回 TMDB 默认季:单元回到原号,原季集对照清掉,特别篇季转正", async () => { + await setGroup("grp-production"); + expect(await unit(unitE14)).toMatchObject({ season_number: 2, episode_number: 2 }); + const r = await setGroup(null); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.data).toMatchObject({ changed: true, current: null, orphanSeasons: [2] }); + const u14 = await unit(unitE14); + expect(u14).toMatchObject({ season_number: 1, episode_number: 14, status: "confirmed" }); + expect(u14.metadata).not.toHaveProperty("tmdbSeason"); + expect(await seasonTotals()).toEqual([ + [0, null, "confirmed"], + [1, 25, "confirmed"], + [2, null, "provisional"], + ]); + expect((await loadSeasonTable(h.db, entityId)).origins).toBeUndefined(); + expect((await release(abs13)).episode_resolution).toMatchObject({ + method: "raw", + canonical: { season: 1, episodes: { start: 13, end: 13 } }, + }); + expect((await release(s01e14)).content_unit_id).toBe(unitE14); + // 同一个结构再选一次:不变 + expect((await setGroup(null)).body.data).toMatchObject({ changed: false }); + }); + + it("剧集组不存在 404,不改作品设置", async () => { + const r = await setGroup("nope"); + expect(r.status).toBe(404); + const e = await h.db + .selectFrom("media_entity") + .select("episode_group_id") + .where("id", "=", entityId) + .executeTakeFirstOrThrow(); + expect(e.episode_group_id).toBeNull(); + }); +});