feat(剧集组): 作品切换季集结构,单元按 TMDB 原季集原地迁移,季表 / 刷新 / 刮削按组

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:45:12 +08:00
1 parent 3f6d8e410f
commit 265340751c
10 files changed
+1145 -14

No files matched your search

@@ -6,11 +6,13 @@ import {
type DownloaderClient, type DownloaderClient,
diskUsage, diskUsage,
type EmbyClient, type EmbyClient,
fetchEpisodeGroup,
fetchRichDetail, fetchRichDetail,
fetchRichSeason, fetchRichSeason,
ffprobe, ffprobe,
getSiteAdapter, getSiteAdapter,
getSiteDefinition, getSiteDefinition,
groupSeasonAsRichSeason,
type ProbeResult, type ProbeResult,
qbAuthConfig, qbAuthConfig,
type SecretResolver, type SecretResolver,
@@ -98,7 +100,8 @@ export interface AoPorts {
* TMDB 没配置返回 null;条目不存在或上游失败抛错。 * TMDB 没配置返回 null;条目不存在或上游失败抛错。
*/ */
scrapeTmdbDetail(type: "movie" | "tv", id: string): Promise<TmdbRichDetail | null>; scrapeTmdbDetail(type: "movie" | "tv", id: string): Promise<TmdbRichDetail | null>;
scrapeTmdbSeason(tvId: string, season: number): Promise<TmdbRichSeasonDetail | null>; /** episodeGroupId:作品选了剧集组时按组取(季号 = 组序号,集号 = 组内顺序) */
scrapeTmdbSeason(tvId: string, season: number, episodeGroupId?: string | null): Promise<TmdbRichSeasonDetail | null>;
options: AoOptions; options: AoOptions;
} }
@@ -235,9 +238,15 @@ export function createPorts(
if (!client) return null; if (!client) return null;
return fetchRichDetail(client, type, id, await metadataLanguages(ctx), await metadataTextOptions(ctx)); 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(); const client = await rt().tmdb();
if (!client) return null; 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)); return fetchRichSeason(client, tvId, season, await metadataLanguages(ctx), await metadataTextOptions(ctx));
}, },
diskUsage: (p) => diskUsage(p), diskUsage: (p) => diskUsage(p),
@@ -970,12 +970,15 @@ export async function runScrapeRecord(
let fields: Fields | null = null; let fields: Fields | null = null;
let coverage = new Map<string, Covered>(); let coverage = new Map<string, Covered>();
let mapEpisodes: EntityEpisodeMapper | null = null; let mapEpisodes: EntityEpisodeMapper | null = null;
/** 作品选的剧集组:缺元数据时按组取季详情(季集号是组内的) */
let episodeGroupId: string | null = null;
if (match.mediaEntityId) { if (match.mediaEntityId) {
const e = await db const e = await db
.selectFrom("media_entity") .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) .where("id", "=", match.mediaEntityId)
.executeTakeFirst(); .executeTakeFirst();
episodeGroupId = e?.episode_group_id ?? null;
if (e && e.status !== "merged") { if (e && e.status !== "merged") {
if (kind === "tv" && (e.kind === "series" || e.kind === "variety")) if (kind === "tv" && (e.kind === "series" || e.kind === "variety"))
mapEpisodes = await createEntityEpisodeMapper(db, e.id, e.kind); mapEpisodes = await createEntityEpisodeMapper(db, e.id, e.kind);
@@ -1109,7 +1112,7 @@ export async function runScrapeRecord(
(typeof v.episode !== "number" && dated.has(v.path))), (typeof v.episode !== "number" && dated.has(v.path))),
); );
if (!tmdbId || (!fromTmdb && !lacking)) continue; 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 季详情失败"); d.ctx.logger.warn({ recordId, season: n, err: { message: errMessage(err) } }, "刮削取 TMDB 季详情失败");
return null; return null;
}); });
@@ -2,6 +2,7 @@ import { Api } from "@umt/contracts";
import type { ModuleApp } from "../../types.ts"; import type { ModuleApp } from "../../types.ts";
import { route } from "../http.ts"; import { route } from "../http.ts";
import type { SourcesRuntime } from "../runtime.ts"; import type { SourcesRuntime } from "../runtime.ts";
import { episodeStructureSeasons, listEpisodeGroups, setEpisodeGroup } from "../service/episode-groups.ts";
import { import {
createRule, createRule,
deleteRule, deleteRule,
@@ -11,9 +12,11 @@ import {
} from "../service/episode-mapping-admin.ts"; } from "../service/episode-mapping-admin.ts";
// 集数映射(docs/plan/episode-resolution.md 批次 4):规则增删改(写入后按作品重算)、模拟预览(纯读)。 // 集数映射(docs/plan/episode-resolution.md 批次 4):规则增删改(写入后按作品重算)、模拟预览(纯读)。
// 剧集组:作品的季集结构(TMDB 默认季 / 某个剧集组)列表、季卡片预览、切换。
const T = ["episode-mapping"]; const T = ["episode-mapping"];
const E = Api.episodeMapping; const E = Api.episodeMapping;
const G = Api.episodeGroups;
export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) { export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const ctx = () => rt().ctx; 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.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.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, { ...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));
} }
@@ -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<TvContext | { error: string; id: string }> {
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<ReturnType<typeof entityEpisodeGroup>>): 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<EpisodeGroupList> {
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<TmdbRichDetail> {
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<Set<string>> {
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<EpisodeStructureSeasons> {
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<ReturnType<typeof layoutOf>>;
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<string>();
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<EpisodeGroupSetResult> {
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<ReturnType<typeof layoutOf>>;
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,
};
}
@@ -42,6 +42,7 @@ import {
reresolveEntityEpisodes, reresolveEntityEpisodes,
sameResolution, sameResolution,
} from "./episode-resolution.ts"; } from "./episode-resolution.ts";
import { entityEpisodeGroup } from "./episode-structure.ts";
// 集数映射的接口层(docs/plan/episode-resolution.md 批次 4):规则写入校验、写入后按作品重算、模拟预览、 // 集数映射的接口层(docs/plan/episode-resolution.md 批次 4):规则写入校验、写入后按作品重算、模拟预览、
// 待处理 episode_mapping 的「确认建议规则 / 忽略并建忽略规则」。判定语义全在 domain,这里只装配与校验。 // 待处理 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); 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 { return {
structure,
seasons: table.seasons.map((s) => ({ seasons: table.seasons.map((s) => ({
season: s.season, season: s.season,
total: s.total, total: s.total,
@@ -374,7 +379,8 @@ export async function previewEpisodeMapping(
): Promise<EpisodeMappingPreview> { ): Promise<EpisodeMappingPreview> {
const entity = await loadEpisodicEntity(exec, entityId); const entity = await loadEpisodicEntity(exec, entityId);
const table = await loadSeasonTable(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 }; if (!EPISODE_KINDS.has(entity.kind)) return { applicable: false, seasons, groups: [], items: [], changed: 0 };
let rules = await loadRules(exec, entityId); let rules = await loadRules(exec, entityId);
@@ -24,6 +24,7 @@ import type { ServerContext } from "../../types.ts";
import { jsonb } from "../rows.ts"; import { jsonb } from "../rows.ts";
import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { closeDecisions, type Exec, openDecision } from "./common.ts";
import { ensureUnits } from "./entities.ts"; import { ensureUnits } from "./entities.ts";
import { seasonOrigins } from "./episode-structure.ts";
// 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把 // 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把
// 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库: // 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库:
@@ -42,11 +43,13 @@ const DECISION_LIST_LIMIT = 50;
* 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元。 * 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元。
* 只计元数据(tmdb)/ 人工(user)来源或已确认的季单元:资源 / 文件懒建的临时季(解析错的 S05 之类)不进季表, * 只计元数据(tmdb)/ 人工(user)来源或已确认的季单元:资源 / 文件懒建的临时季(解析错的 S05 之类)不进季表,
* 否则它总集数为空会让该季资源一律按「元数据滞后」放行,还会抬高最新季。 * 否则它总集数为空会让该季资源一律按「元数据滞后」放行,还会抬高最新季。
* 作品选了剧集组时季 / 集单元本身就是按组生成的,季表随之是组的季集;另附原季集对照(origins),
* 发布者按 TMDB 默认季编号时换算一步据此换到组内季集。
*/ */
export async function loadSeasonTable(exec: Exec, entityId: string): Promise<SeasonTable> { export async function loadSeasonTable(exec: Exec, entityId: string): Promise<SeasonTable> {
const entity = await exec const entity = await exec
.selectFrom("media_entity") .selectFrom("media_entity")
.select(["metadata"]) .select(["metadata", "episode_group_id"])
.where("id", "=", entityId) .where("id", "=", entityId)
.executeTakeFirst(); .executeTakeFirst();
const units = await exec const units = await exec
@@ -85,10 +88,12 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise<Sea
const list = [...seasons.values()].sort((a, b) => a.season - b.season); const list = [...seasons.values()].sort((a, b) => a.season - b.season);
for (const s of list) s.episodes!.sort((a, b) => a.episode - b.episode); 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 main = list.filter((s) => s.season >= 1).map((s) => s.season);
const origins = entity?.episode_group_id ? await seasonOrigins(exec, entityId) : [];
return { return {
seasons: list, seasons: list,
latestSeason: main.length ? Math.max(...main) : null, latestSeason: main.length ? Math.max(...main) : null,
ended: ENDED_STATUSES.has(airStatusOf(entity?.metadata).toLowerCase()), ended: ENDED_STATUSES.has(airStatusOf(entity?.metadata).toLowerCase()),
...(origins.length ? { origins } : {}),
}; };
} }
@@ -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<string, Coord>();
const fromO = new Map<string, Coord>();
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<string, unknown> | 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<UnitOriginRow[]> {
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<SeasonOrigin[]> {
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<string>();
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<Set<string>> {
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<RelayoutResult> {
const out: RelayoutResult = { moved: 0, kept: 0, unmatched: [], orphanSeasons: [] };
const units = await unitOrigins(exec, entityId, from);
const seasonIds = new Map<number, string>();
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<UnmatchedUnit, "referenced">[] = [];
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<boolean>`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<void> {
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<void> {
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);
}
@@ -293,9 +293,16 @@ export async function projectUnit(
return { changedFields: changed, lockedFields }; return { changedFields: changed, lockedFields };
} }
function episodeFields(e: TmdbRichEpisode): Record<string, FieldValue> { function episodeFields(e: TmdbRichEpisode & { tmdbSeason?: number; tmdbEpisode?: number }): Record<string, FieldValue> {
const air = e.airDate && DATE_RE.test(e.airDate.slice(0, 10)) ? e.airDate.slice(0, 10) : null; const air = e.airDate && DATE_RE.test(e.airDate.slice(0, 10)) ? e.airDate.slice(0, 10) : null;
return { 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 }, name: { value: e.name, language: e.nameLanguage },
overview: { value: e.overview, language: e.overviewLanguage }, overview: { value: e.overview, language: e.overviewLanguage },
airDate: { value: air, language: null }, airDate: { value: air, language: null },
@@ -1,6 +1,9 @@
import { import {
type CandidateSeason,
fetchEpisodeGroup,
fetchRichDetail, fetchRichDetail,
fetchRichSeason, fetchRichSeason,
groupSeasonAsRichSeason,
type MetadataCandidate, type MetadataCandidate,
type TmdbClient, type TmdbClient,
TmdbError, TmdbError,
@@ -23,6 +26,7 @@ import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit, type Exec } from "./common.ts"; import { type Actor, audit, type Exec } from "./common.ts";
import { resolveEntityId, seedAliases } from "./entities.ts"; import { resolveEntityId, seedAliases } from "./entities.ts";
import { reresolveEntityEpisodes } from "./episode-resolution.ts"; import { reresolveEntityEpisodes } from "./episode-resolution.ts";
import { entityEpisodeGroup, groupLayout, type Layout, relayoutUnits } from "./episode-structure.ts";
import { import {
applySeasonMetadata, applySeasonMetadata,
metadataIncludeSpecials, metadataIncludeSpecials,
@@ -140,7 +144,7 @@ const ENTITY_NFO_FIELDS: ReadonlySet<string> = new Set([
/** /**
* 改作品的 TMDB 映射后:清掉该作品及其季 / 集单元的 tmdb 来源字段(metadata_field 与投影出的最终值), * 改作品的 TMDB 映射后:清掉该作品及其季 / 集单元的 tmdb 来源字段(metadata_field 与投影出的最终值),
* 人工覆盖与锁定字段保留;单元的 title / air_date 列随 name / airDate 一起清空。之后由 full 刷新按新身份重新填, * 人工覆盖与锁定字段保留;单元的 title / air_date 列随 name / airDate 一起清空;选的剧集组一并清掉。之后由 full 刷新按新身份重新填,
* 避免新身份的 NFO 混进旧作品的标题、简介、IMDb、海报(海报 / 背景图「TMDB 没有时保留旧值」不能保留旧作品的)。 * 避免新身份的 NFO 混进旧作品的标题、简介、IMDb、海报(海报 / 背景图「TMDB 没有时保留旧值」不能保留旧作品的)。
*/ */
export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date): Promise<void> { export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date): Promise<void> {
@@ -177,6 +181,11 @@ export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date):
new Set(entityOverrides.map((o) => o.field)), 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, updated_at: now,
}) })
.where("id", "=", entityId) .where("id", "=", entityId)
@@ -301,7 +310,46 @@ export async function refreshMetadata(
ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 富详情获取失败,按基础详情刷新"); 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 known = detail.seasons.map((x) => x.seasonNumber);
const tracked = await trackedSeasonsForAirDates(ctx.db, id, known); const tracked = await trackedSeasonsForAirDates(ctx.db, id, known);
const want = seasonsToRefresh(known, { const want = seasonsToRefresh(known, {
@@ -321,7 +369,7 @@ export async function refreshMetadata(
} }
// 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新 // 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新
const airSeasons: TmdbSeasonDetail[] = []; 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( const want = await trackedSeasonsForAirDates(
ctx.db, ctx.db,
id, id,
@@ -350,15 +398,16 @@ export async function refreshMetadata(
await seedAliases(trx, id, detail); await seedAliases(trx, id, detail);
if (richDetail) await tagTitleAliases(trx, id, richDetail); if (richDetail) await tagTitleAliases(trx, id, richDetail);
const r = await project(trx, id, now); const r = await project(trx, id, now);
// 剧集组:TMDB 编辑调整过组内顺序时,集单元按记下的原季集挪到新位置(规则同切换季集结构)
if (layout) await relayoutUnits(trx, id, layout, layout, now);
// DH-1:先合并重复单元(并发懒建留下的同季同集多条),再按 TMDB 季集结构写入 / 转正 // DH-1:先合并重复单元(并发懒建留下的同季同集多条),再按 TMDB 季集结构写入 / 转正
const merged = await mergeDuplicateUnits(trx, id); const merged = await mergeDuplicateUnits(trx, id);
if (merged.merged) if (merged.merged)
ctx.logger.info({ entityId: id, merged: merged.merged, repointed: merged.repointed }, "合并重复内容单元"); ctx.logger.info({ entityId: id, merged: merged.merged, repointed: merged.repointed }, "合并重复内容单元");
// 季总集数账本:只升;回退不低于已覆盖最高集;锁定不改(订阅完成判定据此) // 季总集数账本:只升;回退不低于已覆盖最高集;锁定不改(订阅完成判定据此)
const seasonList = groupSeasons ?? detail.seasons ?? [];
const totals = const totals =
ident.entity_type === "tv" && detail.seasons?.length ident.entity_type === "tv" && seasonList.length ? await applySeasonTotals(trx, id, seasonList, now) : null;
? await applySeasonTotals(trx, id, detail.seasons, now)
: null;
for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd); for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd);
const episodes: SeasonMetadataResult[] = []; const episodes: SeasonMetadataResult[] = [];
for (const sd of richSeasons) for (const sd of richSeasons)
@@ -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<any> =>
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<string, unknown>) => [
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<string, unknown>) => [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();
});
});