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(); + }); +}); diff --git a/apps/web/app/api/episode-mapping.ts b/apps/web/app/api/episode-mapping.ts index f1da0756..74557600 100644 --- a/apps/web/app/api/episode-mapping.ts +++ b/apps/web/app/api/episode-mapping.ts @@ -1,11 +1,14 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + EpisodeGroupList, + EpisodeGroupSetResult, EpisodeMappingPreview, type EpisodeMappingPreviewInput, type EpisodeMappingRuleCreate, EpisodeMappingRuleList, type EpisodeMappingRuleUpdate, EpisodeMappingRuleWriteResult, + EpisodeStructureSeasons, } from "@umt/contracts"; import { api } from "~/lib/api.ts"; import { decisionKeys } from "./decisions.ts"; @@ -18,6 +21,9 @@ export const episodeMappingKeys = { rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const, preview: (entityId: string, input: EpisodeMappingPreviewInput) => ["episode-mapping", "preview", entityId, input] as const, + groups: (entityId: string) => ["episode-mapping", "groups", entityId] as const, + structure: (entityId: string, groupId: string | null) => + ["episode-mapping", "structure", entityId, groupId ?? "default"] as const, }; export function useEpisodeMappingRules(entityId: string | undefined) { @@ -73,3 +79,51 @@ export function useEpisodeMappingWrite(entityId: string) { }, }); } + +// ── 剧集组(季集结构) ── + +/** 作品可选的 TMDB 剧集组与当前选择 */ +export function useEpisodeGroups(entityId: string | undefined, enabled = true) { + return useQuery({ + queryKey: episodeMappingKeys.groups(entityId ?? ""), + enabled: !!entityId && enabled, + staleTime: 5 * 60_000, + queryFn: ({ signal }) => + getParsed(EpisodeGroupList, `/media-entities/${entityId}/episode-groups`, undefined, signal), + }); +} + +/** 某套季集结构(null = TMDB 默认季)的季卡片与每集,带本地已入库 */ +export function useEpisodeStructure(entityId: string | undefined, groupId: string | null, enabled = true) { + return useQuery({ + queryKey: episodeMappingKeys.structure(entityId ?? "", groupId), + enabled: !!entityId && enabled, + placeholderData: keepPreviousData, + queryFn: ({ signal }) => + getParsed( + EpisodeStructureSeasons, + `/media-entities/${entityId}/episode-structure`, + groupId ? { groupId } : undefined, + signal, + ), + }); +} + +/** 切换作品的季集结构:成功后刷新剧集组、预览、规则、待处理与作品详情(季集都变了) */ +export function useSetEpisodeGroup(entityId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (groupId: string | null) => + parseOrThrow( + EpisodeGroupSetResult, + await api.put(`/media-entities/${entityId}/episode-group`, { groupId }), + "/media-entities/{id}/episode-group", + ), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: episodeMappingKeys.all }); + void qc.invalidateQueries({ queryKey: decisionKeys.all }); + void qc.invalidateQueries({ queryKey: libraryKeys.all }); + void qc.invalidateQueries({ queryKey: ["subscriptions"] }); + }, + }); +} diff --git a/apps/web/app/components/episode-mapping/mapping-panel.tsx b/apps/web/app/components/episode-mapping/mapping-panel.tsx index 99cd777d..861ea9bb 100644 --- a/apps/web/app/components/episode-mapping/mapping-panel.tsx +++ b/apps/web/app/components/episode-mapping/mapping-panel.tsx @@ -65,7 +65,10 @@ function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) { {s.season === p.latestSeason ? " · 最新" : ""} ))} - {p.ended ? "已完结" : "未完结"} + + {p.ended ? "已完结" : "未完结"} + {p.structure ? ` · 按剧集组「${p.structure.name}」分季` : ""} + ); } diff --git a/apps/web/app/components/episode-mapping/model.test.ts b/apps/web/app/components/episode-mapping/model.test.ts index c6350228..ea492b19 100644 --- a/apps/web/app/components/episode-mapping/model.test.ts +++ b/apps/web/app/components/episode-mapping/model.test.ts @@ -28,6 +28,7 @@ describe("集数映射文案", () => { absolute_in_season: "季内绝对集号", absolute_cross_season: "跨季绝对集号", air_date: "按播出日期", + tmdb_season: "按 TMDB 默认季集换算", unverified: "元数据未覆盖,先按原号", unresolved: "待确认", skipped: "不适用", diff --git a/apps/web/app/components/episode-mapping/model.ts b/apps/web/app/components/episode-mapping/model.ts index f6a556f3..d3062756 100644 --- a/apps/web/app/components/episode-mapping/model.ts +++ b/apps/web/app/components/episode-mapping/model.ts @@ -22,6 +22,7 @@ export const METHOD_LABEL: Record = { absolute_in_season: "季内绝对集号", absolute_cross_season: "跨季绝对集号", air_date: "按播出日期", + tmdb_season: "按 TMDB 默认季集换算", unverified: "元数据未覆盖,先按原号", unresolved: "待确认", skipped: "不适用", diff --git a/apps/web/app/components/library/episode-structure-model.ts b/apps/web/app/components/library/episode-structure-model.ts new file mode 100644 index 00000000..d4e59103 --- /dev/null +++ b/apps/web/app/components/library/episode-structure-model.ts @@ -0,0 +1,67 @@ +import type { EpisodeGroupList, EpisodeStructureSeason } from "@umt/contracts"; + +// 季集结构(TMDB 默认季 / 剧集组)的界面纯函数:轨道选项、季卡片文案、切换确认文案。学 MoviePilot 的订阅选季对话框: +// 横向轨道选「TMDB 默认 / 各剧集组」,下面按所选结构显示季卡片(海报、季名、首播年、集数、已入库)。 + +export interface StructureOption { + /** 剧集组 id;null = TMDB 默认季 */ + groupId: string | null; + label: string; + /** 第二行:类型 · 季数 · 集数 */ + meta: string | null; + current: boolean; +} + +export const DEFAULT_STRUCTURE_LABEL = "TMDB 默认季"; + +/** 轨道选项:TMDB 默认季在最前,其后按 TMDB 顺序列剧集组;当前选择打标 */ +export function structureOptions(list: EpisodeGroupList | undefined): StructureOption[] { + const cur = list?.current?.groupId ?? null; + const out: StructureOption[] = [{ groupId: null, label: DEFAULT_STRUCTURE_LABEL, meta: null, current: cur === null }]; + for (const g of list?.groups ?? []) { + out.push({ + groupId: g.id, + label: g.name, + meta: [g.typeLabel, `${g.groupCount} 季`, `${g.episodeCount} 集`].filter(Boolean).join(" · "), + current: cur === g.id, + }); + } + // 当前选的剧集组已从 TMDB 删除:仍列出来,免得界面上看不到当前状态 + if (cur && !out.some((o) => o.groupId === cur)) + out.push({ groupId: cur, label: list?.current?.name ?? cur, meta: "TMDB 上已不存在", current: true }); + return out; +} + +/** 有剧集组可选时才显示轨道 */ +export const hasStructureChoice = (list: EpisodeGroupList | undefined) => + !!list?.applicable && (list.groups.length > 0 || !!list.current); + +export function seasonTitle(s: Pick): string { + return s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`); +} + +/** 季卡片第二行:首播年 · 集数 */ +export function seasonLine(s: Pick): string { + return [s.airDate ? s.airDate.slice(0, 4) : null, `${s.episodeCount} 集`].filter(Boolean).join(" · "); +} + +/** 已入库 x/y;一集都没有时返回 null(只列非零项) */ +export function inLibraryText(s: Pick): string | null { + return s.inLibrary > 0 ? `已入库 ${s.inLibrary}/${s.episodeCount}` : null; +} + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** 剧集组里的集与 TMDB 默认季集号不同时给「原 S01E14」对照 */ +export function originText( + seasonNumber: number, + e: { episodeNumber: number; tmdbSeason: number; tmdbEpisode: number }, +): string | null { + if (e.tmdbSeason === seasonNumber && e.tmdbEpisode === e.episodeNumber) return null; + return `原 S${pad(e.tmdbSeason)}E${pad(e.tmdbEpisode)}`; +} + +/** 切换确认的说明:改的是作品设置,影响季表、集数映射、订阅与刮削 */ +export function switchDescription(target: string): string { + return `作品改按「${target}」分季:季与集按 TMDB 原季集迁到新位置,已入库文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`; +} diff --git a/apps/web/app/components/library/episode-structure.test.ts b/apps/web/app/components/library/episode-structure.test.ts new file mode 100644 index 00000000..268cd437 --- /dev/null +++ b/apps/web/app/components/library/episode-structure.test.ts @@ -0,0 +1,183 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { EpisodeGroupList, EpisodeStructureSeasons } from "@umt/contracts"; +import { createElement, type ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { episodeMappingKeys } from "~/api/episode-mapping.ts"; +import { + EpisodeStructureSwitcher, + SeasonCardGrid, + StructureTrack, + SubscriptionSeasonPicker, +} from "./episode-structure.tsx"; +import { + hasStructureChoice, + inLibraryText, + originText, + seasonLine, + structureOptions, +} from "./episode-structure-model.ts"; + +const ENTITY = "6b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + +const groups = (current: string | null): EpisodeGroupList => ({ + applicable: true, + current: current ? { groupId: current, name: "制作季", setAt: null, setBy: null } : null, + groups: [ + { + id: "grp-production", + name: "制作季", + type: 6, + typeLabel: "制作季", + description: null, + groupCount: 2, + episodeCount: 25, + network: null, + }, + { + id: "grp-aired", + name: "播出顺序", + type: 1, + typeLabel: "原始播出", + description: null, + groupCount: 1, + episodeCount: 25, + network: null, + }, + ], + error: null, +}); + +const structure: EpisodeStructureSeasons = { + groupId: "grp-production", + name: "制作季", + current: true, + seasons: [ + { + seasonNumber: 1, + name: "第一部", + episodeCount: 12, + airDate: "2024-01-07", + posterPath: null, + inLibrary: 0, + seasonUnitId: "1b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", + episodes: [], + }, + { + seasonNumber: 2, + name: "第二部", + episodeCount: 13, + airDate: "2024-07-07", + posterPath: null, + inLibrary: 1, + seasonUnitId: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", + episodes: [ + { + episodeNumber: 2, + name: "下篇第2话", + airDate: "2024-07-14", + tmdbSeason: 1, + tmdbEpisode: 14, + inLibrary: true, + }, + ], + }, + ], +}; + +function withCache(el: ReactElement, list: EpisodeGroupList | null, seasons?: EpisodeStructureSeasons) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } } }); + if (list) qc.setQueryData(episodeMappingKeys.groups(ENTITY), list); + if (seasons) qc.setQueryData(episodeMappingKeys.structure(ENTITY, seasons.groupId), seasons); + return renderToStaticMarkup(createElement(QueryClientProvider, { client: qc }, el)); +} + +describe("季集结构:纯函数", () => { + it("轨道选项:TMDB 默认季在前,剧集组带类型 · 季数 · 集数,当前选择打标", () => { + const opts = structureOptions(groups("grp-production")); + expect(opts.map((o) => [o.groupId, o.label, o.meta, o.current])).toEqual([ + [null, "TMDB 默认季", null, false], + ["grp-production", "制作季", "制作季 · 2 季 · 25 集", true], + ["grp-aired", "播出顺序", "原始播出 · 1 季 · 25 集", false], + ]); + // 当前剧集组已从 TMDB 删除:仍列出 + const gone = structureOptions({ ...groups("old"), groups: [] }); + expect(gone.at(-1)).toMatchObject({ groupId: "old", current: true, meta: "TMDB 上已不存在" }); + expect(hasStructureChoice({ ...groups(null), groups: [] })).toBe(false); + expect(hasStructureChoice(groups(null))).toBe(true); + }); + + it("季卡片文案与原季集对照", () => { + expect(seasonLine({ airDate: "2024-07-07", episodeCount: 13 })).toBe("2024 · 13 集"); + expect(inLibraryText({ inLibrary: 0, episodeCount: 13 })).toBeNull(); + expect(inLibraryText({ inLibrary: 3, episodeCount: 13 })).toBe("已入库 3/13"); + expect(originText(2, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 14 })).toBe("原 S01E14"); + expect(originText(1, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 2 })).toBeNull(); + }); +}); + +describe("季集结构:组件", () => { + it("剧集组轨道:每项一张小卡,选中的 aria-pressed", () => { + const html = renderToStaticMarkup( + createElement(StructureTrack, { + options: structureOptions(groups(null)), + value: "grp-aired", + onChange: () => {}, + }), + ); + expect(html).toContain("TMDB 默认季"); + expect(html).toContain("制作季 · 2 季 · 25 集"); + expect(html).toMatch(/aria-pressed="true"[^>]*>.*播出顺序/); + expect(html).toContain('aria-label="当前"'); + }); + + it("季卡片:季名、首播年 · 集数、已入库 x/y,选中的高亮", () => { + const html = renderToStaticMarkup( + createElement(SeasonCardGrid, { seasons: structure.seasons, selected: 2, onPick: () => {} }), + ); + expect(html).toContain("第一部"); + expect(html).toContain("2024 · 12 集"); + expect(html).toContain("已入库 1/13"); + expect(html).not.toContain("已入库 0/12"); + expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected/); + }); + + it("订阅选季:有剧集组时显示轨道 + 全部季 + 季卡片,当前结构下按季单元回显选中", () => { + const html = withCache( + createElement(SubscriptionSeasonPicker, { + entityId: ENTITY, + value: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", + onChange: () => {}, + }), + groups("grp-production"), + structure, + ); + expect(html).toContain("季集结构"); + expect(html).toContain("全部季"); + expect(html).toContain("第二部"); + expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected[^"]*"[^>]*>.*?第二部/); + expect(html).not.toContain("不是作品当前的季集结构"); + }); + + it("没有剧集组时:订阅选季不出现(表单用下拉),作品页只显示原来的季与集", () => { + const empty = { ...groups(null), groups: [] }; + expect( + withCache(createElement(SubscriptionSeasonPicker, { entityId: ENTITY, value: "", onChange: () => {} }), empty), + ).toBe(""); + const html = withCache( + createElement(EpisodeStructureSwitcher, { entityId: ENTITY }, createElement("p", null, "原季列表")), + empty, + ); + expect(html).toBe("

原季列表

"); + }); + + it("作品页:有剧集组时轨道在季列表上方,当前结构显示原来的季与集", () => { + const html = withCache( + createElement(EpisodeStructureSwitcher, { entityId: ENTITY }, createElement("p", null, "原季列表")), + groups("grp-production"), + ); + expect(html).toContain("季集结构"); + expect(html).toContain("原季列表"); + expect(html).not.toContain("改用这套结构"); + }); +}); diff --git a/apps/web/app/components/library/episode-structure.tsx b/apps/web/app/components/library/episode-structure.tsx new file mode 100644 index 00000000..ef0473cf --- /dev/null +++ b/apps/web/app/components/library/episode-structure.tsx @@ -0,0 +1,366 @@ +import type { EpisodeGroupSetResult, EpisodeStructureSeason } from "@umt/contracts"; +import { Check, Layers } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useEpisodeGroups, useEpisodeStructure, useSetEpisodeGroup } from "~/api/episode-mapping.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { PaginatedList } from "~/components/console/paginated-list.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { CoverPlaceholder } from "~/components/library/parts.tsx"; +import { EpisodeRow, SeasonSection } from "~/components/media/media-detail.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; +import { + DEFAULT_STRUCTURE_LABEL, + hasStructureChoice, + inLibraryText, + originText, + type StructureOption, + seasonLine, + seasonTitle, + structureOptions, + switchDescription, +} from "./episode-structure-model.ts"; + +// 季集结构选择(学 MoviePilot SubscribeSeasonDialog):横向轨道选「TMDB 默认季 / 各剧集组」, +// 下面按所选结构显示季卡片;选了非当前结构时可以「改用这套结构」(改的是作品设置,先确认)。 + +/** 横向轨道:每个选项一张小卡(名称 + 类型 · 季数 · 集数),当前结构带勾 */ +export function StructureTrack({ + options, + value, + onChange, +}: { + options: StructureOption[]; + value: string | null; + onChange: (groupId: string | null) => void; +}) { + return ( +
+
+ {options.map((o) => { + const selected = o.groupId === value; + return ( + + ); + })} +
+
+ ); +} + +/** 季卡片网格(订阅选季):海报、季名、首播年 · 集数、已入库;可选中 */ +export function SeasonCardGrid({ + seasons, + selected, + onPick, + disabled, +}: { + seasons: EpisodeStructureSeason[]; + /** 选中的季号 */ + selected: number | null; + onPick: (s: EpisodeStructureSeason) => void; + disabled?: boolean; +}) { + return ( +
    + {seasons.map((s) => { + const on = s.seasonNumber === selected; + const lib = inLibraryText(s); + return ( +
  • + +
  • + ); + })} +
+ ); +} + +/** 某套结构的季折叠列表(作品页预览):季头 + 每集(集号、标题、播出日期、入库状态、原季集对照) */ +export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[] }) { + const [open, setOpen] = useState>(new Set()); + const toggle = (n: number) => { + const next = new Set(open); + if (next.has(n)) next.delete(n); + else next.add(n); + setOpen(next); + }; + return ( + + {(s) => ( + = s.episodeCount ? "success" : "neutral"}> + 已入库 {s.inLibrary}/{s.episodeCount} + + ), + }} + open={open.has(s.seasonNumber)} + onToggle={() => toggle(s.seasonNumber)} + > + {s.episodes.length === 0 ? ( +

TMDB 上这一季还没有集信息。

+ ) : ( + + {(e) => ( + + {originText(s.seasonNumber, e)} +

+ ) : null, + }} + /> + )} +
+ )} +
+ )} +
+ ); +} + +function resultToast(r: EpisodeGroupSetResult) { + const name = r.current?.name ?? DEFAULT_STRUCTURE_LABEL; + const parts = [`改按「${name}」分季`]; + if (r.moved) parts.push(`${r.moved} 集换了季集号`); + if (r.unmatched.length) parts.push(`${r.unmatched.length} 集对不上、已标临时`); + if (r.reresolve.changed) parts.push(`${r.reresolve.changed} 条资源重新换算`); + toast.success(parts.join(",")); +} + +/** + * 作品页「季与集」顶部的季集结构切换:只有 TMDB 有剧集组(或作品已选了剧集组)时出现。 + * 选中当前结构时显示 children(现有的季与集);选中其它结构时显示该结构的季卡片预览与「改用这套结构」。 + */ +export function EpisodeStructureSwitcher({ entityId, children }: { entityId: string; children?: ReactNode }) { + const groups = useEpisodeGroups(entityId); + const options = structureOptions(groups.data); + const current = groups.data?.current?.groupId ?? null; + const [picked, setPicked] = useState(undefined); + const selected = picked === undefined ? current : picked; + const viewingOther = selected !== current; + const structure = useEpisodeStructure(entityId, selected, viewingOther); + const setGroup = useSetEpisodeGroup(entityId); + const [confirm, setConfirm] = useState(false); + if (!hasStructureChoice(groups.data)) return <>{children}; + const target = options.find((o) => o.groupId === selected); + return ( +
+
+

+ + 季集结构 +

+ +
+ {!viewingOther ? ( + children + ) : ( +
+
+

+ 预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已入库按 TMDB 原季集对照。 +

+ +
+ {structure.isPending ? ( + + ) : structure.error ? ( + structure.refetch()} /> + ) : ( + + )} +
+ )} + { + try { + resultToast(await setGroup.mutateAsync(selected)); + setPicked(undefined); + } catch (err) { + toast.error(errorMessage(err, "切换失败")); + throw err; + } + }} + /> +
+ ); +} + +/** + * 订阅表单的选季(学 MP):作品有剧集组时用轨道 + 季卡片代替下拉;选了非当前结构的季要先确认改作品设置, + * 改完按新结构的同号季单元回填。没有剧集组时返回 null,表单照用下拉。 + */ +export function SubscriptionSeasonPicker({ + entityId, + value, + onChange, + disabled, +}: { + entityId: string; + /** 选中的季单元 id;"" = 全部季 */ + value: string; + onChange: (seasonUnitId: string) => void; + disabled?: boolean; +}) { + const groups = useEpisodeGroups(entityId); + const current = groups.data?.current?.groupId ?? null; + const [picked, setPicked] = useState(undefined); + const selected = picked === undefined ? current : picked; + const structure = useEpisodeStructure(entityId, selected, hasStructureChoice(groups.data)); + const setGroup = useSetEpisodeGroup(entityId); + const [ask, setAsk] = useState(null); + const [after, setAfter] = useState(null); + const currentData = structure.data?.current ? structure.data : null; + // 改完结构后,按新结构里同号季的季单元回填 + useEffect(() => { + if (after === null || !currentData) return; + const hit = currentData.seasons.find((s) => s.seasonNumber === after); + if (hit?.seasonUnitId) onChange(hit.seasonUnitId); + setAfter(null); + }, [after, currentData, onChange]); + if (!hasStructureChoice(groups.data)) return null; + const options = structureOptions(groups.data); + const seasons = structure.data?.seasons ?? []; + const selectedSeason = structure.data?.current + ? (seasons.find((s) => s.seasonUnitId && s.seasonUnitId === value)?.seasonNumber ?? null) + : null; + const target = options.find((o) => o.groupId === selected); + const pick = (s: EpisodeStructureSeason) => { + if (structure.data?.current) { + onChange(s.seasonUnitId && s.seasonUnitId !== value ? s.seasonUnitId : ""); + return; + } + setAsk(s); + }; + return ( +
+ +
+ + {!structure.data?.current && structure.data ? ( + + 不是作品当前的季集结构,选季时会先确认改用「{target?.label}」 + + ) : null} +
+ {structure.isPending ? ( + + ) : structure.error ? ( + structure.refetch()} /> + ) : ( + s.seasonNumber > 0 || s.episodeCount > 0)} + selected={selectedSeason} + onPick={pick} + disabled={disabled || setGroup.isPending} + /> + )} + !o && setAsk(null)} + title={`改按「${target?.label ?? DEFAULT_STRUCTURE_LABEL}」分季?`} + description={`订阅「${ask ? seasonTitle(ask) : ""}」要先改作品设置。${switchDescription(target?.label ?? DEFAULT_STRUCTURE_LABEL)}`} + confirmLabel="改用并选季" + onConfirm={async () => { + const s = ask; + try { + resultToast(await setGroup.mutateAsync(selected)); + setPicked(undefined); + if (s) setAfter(s.seasonNumber); + setAsk(null); + } catch (err) { + toast.error(errorMessage(err, "切换失败")); + throw err; + } + }} + /> +
+ ); +} diff --git a/apps/web/app/components/subscriptions/subscription-form.tsx b/apps/web/app/components/subscriptions/subscription-form.tsx index ec52f86c..e9286ae4 100644 --- a/apps/web/app/components/subscriptions/subscription-form.tsx +++ b/apps/web/app/components/subscriptions/subscription-form.tsx @@ -13,6 +13,7 @@ import { ExternalLink, Search, X } from "lucide-react"; import { type ReactNode, useEffect, useId, useMemo, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; +import { useEpisodeGroups } from "~/api/episode-mapping.ts"; import { useEntityFromCandidate } from "~/api/files.ts"; import { downloaders, useSiteOptions } from "~/api/instances.ts"; import { useEntityDetail, useLibraryRoots } from "~/api/library.ts"; @@ -29,6 +30,8 @@ import { import { FormField } from "~/components/console/form-field.tsx"; import { consolePath } from "~/components/console/nav.ts"; import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx"; +import { SubscriptionSeasonPicker } from "~/components/library/episode-structure.tsx"; +import { hasStructureChoice } from "~/components/library/episode-structure-model.ts"; import { PRIORITY, SEASON_SCOPE, SUB_MODE } from "~/components/library/labels.ts"; import { ChipToggleGroup } from "~/components/library/parts.tsx"; import { Button } from "~/components/ui/button.tsx"; @@ -333,6 +336,9 @@ export function SubscriptionFormDialog({ const isMovie = kind === "movie"; const detail = useEntityDetail(entityId && !isMovie ? entityId : undefined); + // 作品有剧集组可选时,新建订阅用「季集结构轨道 + 季卡片」选季(学 MP);编辑时季不能改,仍是下拉 + const epGroups = useEpisodeGroups(entityId && !isMovie && !editing ? entityId : undefined); + const seasonCards = !editing && !!entityId && hasStructureChoice(epGroups.data); const seasons = (detail.data?.contentUnits ?? []) .filter((u) => u.kind === "season" && u.seasonNumber !== null) .sort((a, b) => (a.seasonNumber ?? 0) - (b.seasonNumber ?? 0)); @@ -537,27 +543,38 @@ export function SubscriptionFormDialog({ /> + {!isMovie && seasonCards && entityId ? ( + + patch({ seasonUnitId: v })} + /> + + ) : null} {isMovie ? null : (
- - patch({ seasonUnitId: v })} - placeholder="全部季" - options={seasons.map((s) => ({ - value: s.id, - label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`, - }))} - disabled={editing || !entityId} - /> - + {seasonCards ? null : ( + + patch({ seasonUnitId: v })} + placeholder="全部季" + options={seasons.map((s) => ({ + value: s.id, + label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`, + }))} + disabled={editing || !entityId} + /> + + )} } > - {rows.length > 1 ? ( - - {(r, i) => ( - - )} - - ) : null} - {seasonNums.length === 0 ? ( - - ) : ( - - {(n) => { - const su = seasonUnitOf(n); - const eps = episodes.filter((u) => u.seasonNumber === n); - const total = su?.episodeTotal ?? (eps.length || null); - const nameV = su ? finalOf(su, "name")?.value : null; - const posterV = su ? finalOf(su, "posterPath")?.value : null; - const ovV = su ? finalOf(su, "overview")?.value : null; - const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null; - const have = haveIn(n); - return ( - { - const vs = versionSeasons.find((x) => x.seasonNumber === n); - return vs ? seasonLine(vs, !anyVersion) : null; - })(), - summary: ( - = total ? "success" : "neutral"}> - 已入库 {have}/{total ?? "?"} - - ), - }} - open={openSet.has(n)} - onToggle={() => toggle(n)} + + {rows.length > 1 ? ( + + {(r, i) => ( + + )} + + ) : null} + {seasonNums.length === 0 ? ( + + ) : ( + + {(n) => { + const su = seasonUnitOf(n); + const eps = episodes.filter((u) => u.seasonNumber === n); + const total = su?.episodeTotal ?? (eps.length || null); + const nameV = su ? finalOf(su, "name")?.value : null; + const posterV = su ? finalOf(su, "posterPath")?.value : null; + const ovV = su ? finalOf(su, "overview")?.value : null; + const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null; + const have = haveIn(n); + return ( + { + const vs = versionSeasons.find((x) => x.seasonNumber === n); + return vs ? seasonLine(vs, !anyVersion) : null; + })(), + summary: ( + = total ? "success" : "neutral"}> + 已入库 {have}/{total ?? "?"} + + ), + }} + open={openSet.has(n)} + onToggle={() => toggle(n)} + > + {eps.length === 0 ? ( +

这一季还没有集信息。

+ ) : ( + + {(u) => { + const v = (f: string) => finalOf(u, f)?.value; + const str = (x: unknown) => (typeof x === "string" && x ? x : null); + const num = (x: unknown) => (typeof x === "number" ? x : null); + return ( + 0, + onEdit: () => setEditing(u), + extra: (() => { + const ev = versionsOf(u); + return ev ? ( + + ) : null; + })(), + }} + /> + ); + }} + + )} +
+ ); + }} +
+ )} + +
!o && setEditing(null)} /> ); diff --git a/docs/porting/moviepilot-episode-groups.md b/docs/porting/moviepilot-episode-groups.md new file mode 100644 index 00000000..4c264a27 --- /dev/null +++ b/docs/porting/moviepilot-episode-groups.md @@ -0,0 +1,17 @@ +# MoviePilot 剧集组对照(TMDB episode groups) + +上游(只读):`/tmp/umt-moviepilot-v3-review-20261001`(v3.0.10-1);前端 `/opt/moviepilot-plus/upstream/MoviePilot-Frontend`。使用说明见 `docs/runbook/identity.md`「剧集组」。 + +| MP | 我们 | 说明 | +|---|---|---| +| `tmdbv3api/objs/tv.py episode_groups`(`/tv/{id}/episode_groups`) | `adapters/tmdb/episode-groups.ts fetchEpisodeGroups` | id、名称、类型、组数、集数、平台;走同一请求缓存(`moviepilot-tmdb-cache.md`) | +| `objs/group.py details`、`tmdbapi.py get_tv_group_seasons`(组内 `episode_number` 按顺序从 1 重排)、`get_tv_group_detail`(按 `order` 取组) | `fetchEpisodeGroup` | 组 `order` = 季号;组内按 TMDB `order` 排序后编号(MP 用返回顺序,实际一致);重复 `order` 顺延;保留默认季集号 `tmdbSeason/tmdbEpisode`;占位名按无标题 | +| `MediaInfo.episode_group` / `_fill_group_season_info`(识别时带 `episode_group` 把季集换成组的) | 作品级设置 `media_entity.episode_group_id`(迁移 0075),季 / 集单元按组生成 | MP 每次识别 / 订阅 / 整理各带一个 `episode_group`;我们存在作品上,季表、映射、订阅、刷新、刮削都读它 | +| `_process_episode_groups`(没指定组时从 type 6 组取季年份) | 不做 | 我们的季年份来自季单元 | +| `api/endpoints/media.py groups/{tmdbid}`、`group/seasons/{groupId}` | `GET /media-entities/{id}/episode-groups`、`GET /media-entities/{id}/episode-structure?groupId=` | 季卡片额外带本地已入库(按 TMDB 原季集对照)与当前结构下的季单元 id | +| `schemas/subscribe.py episode_group`、`SubscribeSeasonDialog.vue`(剧集组横向轨道 + 季卡片:海报、集数、首播、已入库) | 订阅表单 `SubscriptionSeasonPicker` | 订阅不单独存组:选了非当前结构的季要确认改作品设置 | +| `scraper.py`:NFO / 剧照按 `get_tv_group_detail` 取季 | 刮削 NFO 用单元元数据(本来就按组);缺元数据时 `scrapeTmdbSeason(…, episodeGroupId)` 按组取 | 季海报 MP 仍取默认季;我们取组内首集所在默认季的海报 | +| `schemas/transfer.py episode_group`、`ReorganizeDialog.vue` | 不做 | 整理按作品当前季集结构映射 | +| `metabase.py` 识别词 `g=` 指定剧集组 | 不做 | 单条资源不指定组;规则的目标季集写组内的即可 | + +我们多出来的:切换结构时已有单元按 TMDB 原季集原地迁移(引用不动,对不上的标临时并列出);集数解析多一步「按 TMDB 默认季集换算」(`tmdb_season`),发布者按默认季编号时自动换到组内季集;刷新时 TMDB 调整了组内顺序也按原季集挪单元。 diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md index 644fb3de..0028aeb7 100644 --- a/docs/runbook/identity.md +++ b/docs/runbook/identity.md @@ -171,6 +171,32 @@ select key, value->>'title' title, value->>'year' y, value->>'source' src from k - 没有授权(或模式是只读调查)时降级只读:智能体在会话里用 `simulate`(带事项 ID 和理由)试出的通过规则,会作为「智能体建议」写进事项证据(`evidence.agentSuggestion`,事项刷新时保留),待处理卡片显示规则、理由、样本套用后的季集与模拟结果,点「确认智能体建议」(选项 `confirm_agent_suggestion`)即建规则 + 重算;建之前按当前资源再模拟一次,不通过返回 409 不写。人工确认的规则记操作者为管理员、证据等级「人工确认」。 - 怎么撤销:作品页「集数映射」里智能体写的规则带「智能体 · 多证据 / 待复核」标记和依据,点「停用」:立即按作品重算(资源回到待确认并重新开事项),且同作用域有手动停用的规则时 `apply_rule` 一律拒绝,智能体不会再自动写回(仍可留建议给人确认)。直接删除不留这个记号,新开的事项可能再被自动写一次(受同根因自动恢复次数上限 `agent.investigateMaxAttempts` 约束)。要彻底停掉自动写规则,撤销那条持续授权或把自动调查模式改回只读调查。 +## 剧集组(季集结构) + +TMDB 默认季有时和发布者、媒体服务器的分季不一样(最常见:番剧两个 cour 被合成一季 25 集)。TMDB 的「剧集组」(episode group)是编辑整理的其它分法(原始播出 / 绝对集号 / DVD / 数字发行 / 故事线 / 制作季 / 电视)。作品可以选「TMDB 默认季」或某个剧集组作为**季集结构**,选了之后季表、季 / 集单元、集数映射、订阅、元数据刷新与刮削都按这套季集走。对照 MP 见 `docs/porting/moviepilot-episode-groups.md`。 + +**在哪选**:作品页「季与集」顶部的「季集结构」轨道(TMDB 有剧集组或作品已选了剧集组时才出现)。点一个非当前的结构先预览它的季卡片与每集(已入库按 TMDB 原季集对照),点「改用这套结构」确认后切换。新建订阅选季时同样有轨道 + 季卡片;选了非当前结构的季,确认后等同改作品设置,改完按新结构的同号季回填订阅。编辑订阅时季不能改。 + +**接口**:`GET /media-entities/{id}/episode-groups`(列表 + 当前选择)、`GET /media-entities/{id}/episode-structure?groupId=`(某套结构的季卡片,纯读)、`PUT /media-entities/{id}/episode-group`(`{groupId}`,null = 改回默认季;scope `organize.execute`,写审计 `media_entity.episode_group`)。作品上存 `episode_group_id / _name / _set_at / _set_by`(迁移 0075);改 TMDB 身份时一并清掉。 + +**剧集组怎么变成季集**:组的 `order` = 季号(重复的顺延),组内按 `order` 排序后从 1 编集号(同 MP `get_tv_group_seasons`)。每集记下 TMDB 默认季集号(单元字段 `tmdbSeason` / `tmdbEpisode`,tmdb 来源),季海报取组内首集所在默认季的海报。 + +**切换时单元怎么迁移**(`service/episode-structure.ts relayoutUnits`,同一事务): + +1. 每个未退役的集单元先求「TMDB 原季集」:单元上记的 `tmdbSeason / tmdbEpisode` 优先;否则按当前结构换算(默认季 = 自身季集;剧集组 = 当前组里这个位置的原季集)。 +2. 按目标结构把原季集换成新季集,**原地**改单元的季号、集号、所属季单元。单元 id 不变,资产覆盖、资源、下载计划、订阅覆盖、字段覆盖等引用全部跟着走,文件不用重新认。 +3. 编号人工锁定的单元不动;求不出原季集、或目标结构里没有这一集(比如剧集组不含特别篇)的单元保留原号、标临时(来源记为资源),切换结果里逐条列出(含有没有被引用)。同季同集已有对上的单元时,随后的元数据刷新按重复单元合并(DH-1,引用并到对上的那条)。 +4. 季单元:目标结构的每季确保有季单元(同号沿用,临时的转正),总集数直接改成新结构的集数并解除锁定;不在目标结构里的季单元标临时、清总集数(订阅等引用不动)。 +5. 记下选择 → 按作品**全量**重算已绑定资源(`reresolveEntityEpisodes`)→ 提交后 full 刷新元数据(季 / 集标题、播出日期、原季集对照按新结构写入;写进 NFO 的字段变了按原规则排「更新」重刮)。 + +切回默认季同样按这几步,单元回到原号、原季集对照清掉、特别篇季转正。已整理的文件路径不随之改名(S01E14 的文件现在对应 S02E02 单元),要改名用「重新整理」;刮削写的 NFO 季集号、标题、简介随单元走。 + +**集数映射**:季表就是组的季集,校验、换算、规则都按组;规则的目标季集写组内的(作品选了剧集组时,`EP-12 → 第 2 季` 指组的第 2 季)。额外一步换算 **按 TMDB 默认季集**(方式 `tmdb_season`):季表带原季集对照(`SeasonTable.origins`),发布者按 TMDB 默认季编号(S01E14)而原号对不上组的季表时,换到组内季集(S02E02);整段区间要落在同一季且连续。原号对得上时原号优先。集数映射分区的季表摘要会标「按剧集组「X」分季」。 + +**刷新**:剧集组作品的元数据刷新拉 `/tv/episode_group/{id}` 而不是默认季;TMDB 编辑调整过组内顺序时,集单元按记下的原季集挪到新位置(规则同切换)。组取不到(404 / 网络)时本次不动季集结构,只刷作品字段。刮削缺季集元数据时也按组取。 + +**没做**:识别词 / 单条资源指定剧集组(MP 的 `g=`)、整理时按剧集组(MP `TransferInfo.episode_group`)——季集结构是作品级的一个选择。 + ## 池的治理 - 复查有上限:`unmatched` 每天一次本地召回,最多 3 次;查过 TMDB 的按原退避(无结果 1h 起、查询失败 15min 起,翻倍到 7 天,最多 8 次)。 diff --git a/packages/adapters/src/tmdb/episode-groups.test.ts b/packages/adapters/src/tmdb/episode-groups.test.ts new file mode 100644 index 00000000..9b501f79 --- /dev/null +++ b/packages/adapters/src/tmdb/episode-groups.test.ts @@ -0,0 +1,115 @@ +import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTmdbClient, fetchEpisodeGroup, fetchEpisodeGroups, groupSeasonAsRichSeason } from "./index.ts"; + +let mock: MockTmdb | null = null; +afterEach(async () => { + await mock?.close(); + mock = null; +}); + +async function setup() { + mock = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SPLIT_COUR_FIXTURE] }); + const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 }); + return { mock, tmdb }; +} + +describe("TMDB 剧集组", () => { + it("列表:id、名称、类型、组数、集数、播出平台", async () => { + const { tmdb } = await setup(); + const groups = await fetchEpisodeGroups(tmdb, 990001, "zh-CN"); + expect(groups).toEqual([ + { + id: "grp-production", + name: "制作季", + type: 6, + description: null, + groupCount: 2, + episodeCount: 25, + network: "TOKYO MX", + }, + { + id: "grp-aired", + name: "播出顺序", + type: 1, + description: null, + groupCount: 1, + episodeCount: 25, + network: null, + }, + ]); + }); + + it("详情:组序号 = 季号,组内顺序从 1 编集号,保留 TMDB 默认季集号、标题、播出日期", async () => { + const { tmdb } = await setup(); + const g = await fetchEpisodeGroup(tmdb, "grp-production", "zh-CN"); + expect(g.name).toBe("制作季"); + expect(g.seasons.map((s) => [s.seasonNumber, s.name, s.episodes.length, s.airDate])).toEqual([ + [1, "第一部", 12, "2024-01-07"], + [2, "第二部", 13, "2024-07-07"], + ]); + const s2e1 = g.seasons[1]!.episodes[0]!; + expect(s2e1).toMatchObject({ + seasonNumber: 2, + episodeNumber: 1, + tmdbSeason: 1, + tmdbEpisode: 13, + name: "下篇第1话", + nameLanguage: "zh-CN", + airDate: "2024-07-07", + }); + expect(g.seasons[1]!.episodes.at(-1)).toMatchObject({ episodeNumber: 13, tmdbEpisode: 25 }); + }); + + it("组内按 order 排序;重复的组序号顺延;占位名按无标题处理", async () => { + const calls: string[] = []; + const client = { + getJson: async (path: string) => { + calls.push(path); + return { + id: "x", + name: "X", + type: 3, + groups: [ + { + id: "b", + name: "B", + order: 1, + episodes: [{ order: 0, season_number: 2, episode_number: 1, name: "Opening", air_date: "2020-01-01" }], + }, + { + id: "a", + name: "A", + order: 1, + episodes: [ + { order: 1, season_number: 1, episode_number: 5, name: "第 5 集", air_date: "2019-02-01" }, + { order: 0, season_number: 1, episode_number: 9, name: "Nine", air_date: "2019-01-01" }, + ], + }, + ], + } as T; + }, + }; + const g = await fetchEpisodeGroup(client, "x", "zh-CN"); + expect(calls).toEqual(["/tv/episode_group/x"]); + expect(g.seasons.map((s) => [s.seasonNumber, s.name])).toEqual([ + [1, "B"], + [2, "A"], + ]); + expect(g.seasons[1]!.episodes.map((e) => [e.episodeNumber, e.tmdbSeason, e.tmdbEpisode, e.name])).toEqual([ + [1, 1, 9, "Nine"], + [2, 1, 5, null], + ]); + const rich = groupSeasonAsRichSeason(42, g.seasons[1]!, "zh-CN", "/p.jpg"); + expect(rich).toMatchObject({ tvId: "42", seasonNumber: 2, name: "A", posterPath: "/p.jpg", airDate: "2019-01-01" }); + }); + + it("剧集组不存在抛 notfound;请求走缓存", async () => { + const { tmdb, mock } = await setup(); + await expect(fetchEpisodeGroup(tmdb, "nope")).rejects.toMatchObject({ kind: "notfound" }); + await fetchEpisodeGroups(tmdb, 990001, "zh-CN"); + const before = mock.calls.length; + await fetchEpisodeGroups(tmdb, 990001, "zh-CN"); + expect(mock.calls.length).toBe(before); + }); +}); diff --git a/packages/adapters/src/tmdb/episode-groups.ts b/packages/adapters/src/tmdb/episode-groups.ts new file mode 100644 index 00000000..4438ad70 --- /dev/null +++ b/packages/adapters/src/tmdb/episode-groups.ts @@ -0,0 +1,178 @@ +/** + * TMDB 剧集组(episode groups):一部剧在默认季之外由编辑整理的其它季集结构(按播出、DVD、制作季、故事线…)。 + * 迁移来源:MoviePilot app/modules/themoviedb/tmdbv3api/objs/tv.py episode_groups(`/tv/{id}/episode_groups`)、 + * objs/group.py details(`/tv/episode_group/{id}`)、tmdbapi.py get_tv_group_seasons(组内集号按顺序从 1 重排)、 + * get_tv_group_detail(按 order 取组 = 季);请求缓存同 client.ts(MP tmdbv3api request 的 @cached)。 + * 调整:组内集按 TMDB 给的 order 排序后再编号(MP 直接用返回顺序,两者在 TMDB 实际数据上一致); + * 每集保留 TMDB 默认季集号(tmdbSeason / tmdbEpisode),供切换季集结构时对照迁移; + * 占位名("第 14 集"这类按默认集号起的名字)在组里位置不同,按无标题处理。 + */ +import type { TmdbClient } from "./client.ts"; +import type { TmdbRichEpisode, TmdbRichSeasonDetail } from "./details.ts"; +import { isPlaceholderEpisodeName } from "./translations.ts"; + +type Json = Record; +const s = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null); +const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); +const int = (v: unknown): number | null => { + const x = n(v); + return x === null ? null : Math.round(x); +}; +const DATE_RE = /^\d{4}-\d{2}-\d{2}/; + +/** TMDB 剧集组类型(type 1–7) */ +export const EPISODE_GROUP_TYPES: Readonly> = { + 1: "原始播出", + 2: "绝对集号", + 3: "DVD", + 4: "数字发行", + 5: "故事线", + 6: "制作季", + 7: "电视", +}; + +export interface TmdbEpisodeGroupSummary { + id: string; + name: string; + /** TMDB type 1–7(见 EPISODE_GROUP_TYPES);未知为 null */ + type: number | null; + description: string | null; + /** 组(= 季)数 */ + groupCount: number; + episodeCount: number; + network: string | null; +} + +export interface TmdbEpisodeGroupEpisode extends TmdbRichEpisode { + /** TMDB 默认季集号(切换季集结构时据此对照) */ + tmdbSeason: number; + tmdbEpisode: number; +} + +export interface TmdbEpisodeGroupSeason { + /** 组的 TMDB id */ + id: string | null; + /** 季号 = 组的 order(MP 同口径) */ + seasonNumber: number; + name: string | null; + /** 首集播出日期 */ + airDate: string | null; + episodes: TmdbEpisodeGroupEpisode[]; +} + +export interface TmdbEpisodeGroupDetail { + id: string; + name: string; + type: number | null; + description: string | null; + network: string | null; + seasons: TmdbEpisodeGroupSeason[]; + language: string; +} + +/** `/tv/{id}/episode_groups`:剧集组列表(TMDB 顺序) */ +export async function fetchEpisodeGroups( + client: Pick, + tvId: string | number, + language?: string, +): Promise { + const j = await client.getJson(`/tv/${tvId}/episode_groups`, { language }); + return ((Array.isArray(j.results) ? j.results : []) as Json[]) + .filter((g) => s(g.id)) + .map((g) => ({ + id: s(g.id)!, + name: s(g.name) ?? s(g.id)!, + type: int(g.type), + description: s(g.description), + groupCount: int(g.group_count) ?? 0, + episodeCount: int(g.episode_count) ?? 0, + network: s((g.network as Json | null | undefined)?.name), + })); +} + +function groupEpisode(e: Json, season: number, episode: number, lang: string): TmdbEpisodeGroupEpisode { + const rawName = s(e.name); + const name = rawName && !isPlaceholderEpisodeName(rawName) ? rawName : null; + const overview = s(e.overview); + const air = s(e.air_date); + return { + seasonNumber: season, + episodeNumber: episode, + name, + nameLanguage: name ? lang : null, + overview, + overviewLanguage: overview ? lang : null, + airDate: air && DATE_RE.test(air) ? air.slice(0, 10) : null, + runtime: int(e.runtime), + stillPath: s(e.still_path), + voteAverage: + n(e.vote_average) !== null && (int(e.vote_count) ?? 0) > 0 ? Math.round(n(e.vote_average)! * 10) / 10 : null, + voteCount: int(e.vote_count), + tmdbSeason: int(e.season_number) ?? 0, + tmdbEpisode: int(e.episode_number) ?? 0, + }; +} + +/** + * `/tv/episode_group/{id}`:组 = 季(季号取 order),组内按 order 排序后从 1 编集号。 + * 同一 order 出现两次时后面的组顺延到下一个空季号(TMDB 编辑数据偶有重复)。 + */ +export async function fetchEpisodeGroup( + client: Pick, + groupId: string, + language?: string, +): Promise { + const lang = language ?? "zh-CN"; + const j = await client.getJson(`/tv/episode_group/${encodeURIComponent(groupId)}`, { language: lang }); + const groups = ((Array.isArray(j.groups) ? j.groups : []) as Json[]) + .map((g, i) => ({ g, order: int(g.order) ?? i })) + .sort((a, b) => a.order - b.order); + const used = new Set(); + const seasons: TmdbEpisodeGroupSeason[] = []; + for (const { g, order } of groups) { + let season = Math.max(0, order); + while (used.has(season)) season++; + used.add(season); + const eps = ((Array.isArray(g.episodes) ? g.episodes : []) as Json[]) + .map((e, i) => ({ e, order: int(e.order) ?? i })) + .sort((a, b) => a.order - b.order) + .map(({ e }, i) => groupEpisode(e, season, i + 1, lang)); + seasons.push({ + id: s(g.id), + seasonNumber: season, + name: s(g.name), + airDate: eps.find((e) => e.airDate)?.airDate ?? null, + episodes: eps, + }); + } + return { + id: s(j.id) ?? groupId, + name: s(j.name) ?? groupId, + type: int(j.type), + description: s(j.description), + network: s((j.network as Json | null | undefined)?.name), + seasons, + language: lang, + }; +} + +/** 剧集组的一季 → 季详情结构(元数据刷新写季 / 集单元时与默认季同一条路径);海报由调用方补 */ +export function groupSeasonAsRichSeason( + tvId: string | number, + season: TmdbEpisodeGroupSeason, + language: string, + posterPath: string | null = null, +): TmdbRichSeasonDetail & { episodes: TmdbEpisodeGroupEpisode[] } { + return { + tvId: String(tvId), + seasonNumber: season.seasonNumber, + name: season.name, + nameLanguage: season.name ? language : null, + overview: null, + overviewLanguage: null, + airDate: season.airDate, + posterPath, + episodes: season.episodes, + fetchedLanguages: [language], + }; +} diff --git a/packages/adapters/src/tmdb/index.ts b/packages/adapters/src/tmdb/index.ts index 9cd5e2c2..4676f6bd 100644 --- a/packages/adapters/src/tmdb/index.ts +++ b/packages/adapters/src/tmdb/index.ts @@ -1,6 +1,7 @@ export { RequestLimiter, TtlLruCache } from "./cache.ts"; export * from "./client.ts"; export * from "./details.ts"; +export * from "./episode-groups.ts"; export * from "./match.ts"; export * from "./recall.ts"; export * from "./translations.ts"; 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 3c4af318..530f79d2 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -953,6 +953,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"); + }); +}); diff --git a/packages/testkit/src/tmdb/fixtures.ts b/packages/testkit/src/tmdb/fixtures.ts index 76f21bc0..af0454c8 100644 --- a/packages/testkit/src/tmdb/fixtures.ts +++ b/packages/testkit/src/tmdb/fixtures.ts @@ -56,6 +56,17 @@ export interface TmdbFixture { seasons?: TmdbFixtureSeason[]; /** 海报路径(拼 image.tmdb.org);缺省 null */ poster_path?: string | null; + /** 剧集组(`/tv/{id}/episode_groups`、`/tv/episode_group/{id}`);组内集按 TMDB 默认季集号引用 seasons 里的集 */ + episode_groups?: TmdbFixtureEpisodeGroup[]; +} + +export interface TmdbFixtureEpisodeGroup { + id: string; + name: string; + type: number; + description?: string; + network?: string | null; + groups: { id?: string; name: string; order: number; episodes: { season_number: number; episode_number: number }[] }[]; } function eps(n: number, start: string, zh: (i: number) => string, en: (i: number) => string): TmdbFixtureEpisode[] { @@ -374,3 +385,90 @@ export const HEART_SIGNAL_FIXTURE: TmdbFixture = { }, ], }; + +/** + * 两个 cour 被 TMDB 默认季合成一季的番剧(剧集组测试用):默认 S1 共 25 集(含季 0 特别篇 1 集); + * 剧集组「制作季」把它拆成 S1(1–12)、S2(13–25),不含特别篇;剧集组「播出顺序」把全部 25 集放在一季。 + * 不在 TMDB_FIXTURES 里,用到的测试自行拼进 fixtures。 + */ +export const SPLIT_COUR_FIXTURE: TmdbFixture = { + type: "tv", + id: 990001, + original_title: "Split Cour", + original_language: "ja", + origin_country: ["JP"], + date: "2024-01-07", + titles: { "zh-CN": "分割番", "en-US": "Split Cour" }, + overviews: { "zh-CN": "两个 cour 的番剧。" }, + popularity: 30, + alternative_titles: [], + external_ids: { imdb_id: null, tvdb_id: null }, + rich: { genres: ["动画"], status: "Ended" }, + poster_path: "/split.jpg", + seasons: [ + { + season_number: 0, + air_date: "2024-12-25", + names: { "zh-CN": "特别篇", "en-US": "Specials" }, + episodes: [{ episode_number: 1, air_date: "2024-12-25", names: { "zh-CN": "圣诞特别篇", "en-US": "Xmas" } }], + }, + { + season_number: 1, + air_date: "2024-01-07", + names: { "zh-CN": "第 1 季", "en-US": "Season 1" }, + poster_path: "/split-s1.jpg", + episodes: [ + ...eps( + 12, + "2024-01-07", + (i) => `上篇第${i}话`, + (i) => `Part1 Ep${i}`, + ), + ...eps( + 13, + "2024-07-07", + (i) => `下篇第${i}话`, + (i) => `Part2 Ep${i}`, + ).map((e, k) => ({ + ...e, + episode_number: 13 + k, + })), + ], + }, + ], + episode_groups: [ + { + id: "grp-production", + name: "制作季", + type: 6, + network: "TOKYO MX", + groups: [ + { + id: "g1", + name: "第一部", + order: 1, + episodes: Array.from({ length: 12 }, (_, k) => ({ season_number: 1, episode_number: k + 1 })), + }, + { + id: "g2", + name: "第二部", + order: 2, + episodes: Array.from({ length: 13 }, (_, k) => ({ season_number: 1, episode_number: 13 + k })), + }, + ], + }, + { + id: "grp-aired", + name: "播出顺序", + type: 1, + groups: [ + { + id: "a1", + name: "全集", + order: 1, + episodes: Array.from({ length: 25 }, (_, k) => ({ season_number: 1, episode_number: k + 1 })), + }, + ], + }, + ], +}; diff --git a/packages/testkit/src/tmdb/mock-tmdb.ts b/packages/testkit/src/tmdb/mock-tmdb.ts index 83aebc59..8ad5272c 100644 --- a/packages/testkit/src/tmdb/mock-tmdb.ts +++ b/packages/testkit/src/tmdb/mock-tmdb.ts @@ -19,7 +19,7 @@ const notFound: MockReply = { json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." }, }; -/** mock TMDB v3:/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n */ +/** mock TMDB v3:/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n、/3/tv/:id/episode_groups、/3/tv/episode_group/:id */ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { const fx = opts.fixtures ?? TMDB_FIXTURES; const apiKey = opts.apiKey ?? "test-tmdb-key"; @@ -101,6 +101,72 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { + const f = find("tv", m[1]!); + if (!f) return notFound; + return { + json: { + id: f.id, + results: (f.episode_groups ?? []).map((g) => ({ + id: g.id, + name: g.name, + type: g.type, + description: g.description ?? "", + group_count: g.groups.length, + episode_count: g.groups.reduce((a, x) => a + x.episodes.length, 0), + network: g.network ? { id: 1, name: g.network } : null, + })), + }, + }; + }, + ], + [ + /^\/3\/tv\/episode_group\/([\w-]+)$/, + (m, q) => { + const owner = fx.find((f) => f.episode_groups?.some((g) => g.id === m[1])); + const g = owner?.episode_groups?.find((x) => x.id === m[1]); + if (!owner || !g) return notFound; + const lang = q.language ?? "en-US"; + const zh = lang.startsWith("zh"); + return { + json: { + id: g.id, + name: g.name, + type: g.type, + description: g.description ?? "", + group_count: g.groups.length, + episode_count: g.groups.reduce((a, x) => a + x.episodes.length, 0), + network: g.network ? { id: 1, name: g.network } : null, + groups: g.groups.map((x, gi) => ({ + id: x.id ?? `${g.id}-${gi}`, + name: x.name, + order: x.order, + locked: true, + episodes: x.episodes.map((ref, order) => { + const s = owner.seasons?.find((y) => y.season_number === ref.season_number); + const e = s?.episodes.find((y) => y.episode_number === ref.episode_number); + return { + id: owner.id * 10000 + ref.season_number * 1000 + ref.episode_number, + show_id: owner.id, + order, + season_number: ref.season_number, + episode_number: ref.episode_number, + name: e?.names[lang] ?? (zh ? `第 ${ref.episode_number} 集` : `Episode ${ref.episode_number}`), + overview: e?.overviews?.[lang] ?? "", + air_date: e?.air_date ?? null, + runtime: e?.runtime ?? null, + still_path: e?.still_path ?? null, + vote_average: e?.vote_average ?? 0, + vote_count: e?.vote_count ?? 0, + }; + }), + })), + }, + }; + }, + ], [ /^\/3\/tv\/(\d+)\/season\/(\d+)$/, (m, q) => {