merge: fix/p-episode-groups

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 16:02:17 +08:00
commit 02b9773047
35 files changed
+2751 -154

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();
});
});
+54
View File
@@ -1,11 +1,14 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
EpisodeGroupList,
EpisodeGroupSetResult,
EpisodeMappingPreview, EpisodeMappingPreview,
type EpisodeMappingPreviewInput, type EpisodeMappingPreviewInput,
type EpisodeMappingRuleCreate, type EpisodeMappingRuleCreate,
EpisodeMappingRuleList, EpisodeMappingRuleList,
type EpisodeMappingRuleUpdate, type EpisodeMappingRuleUpdate,
EpisodeMappingRuleWriteResult, EpisodeMappingRuleWriteResult,
EpisodeStructureSeasons,
} from "@umt/contracts"; } from "@umt/contracts";
import { api } from "~/lib/api.ts"; import { api } from "~/lib/api.ts";
import { decisionKeys } from "./decisions.ts"; import { decisionKeys } from "./decisions.ts";
@@ -18,6 +21,9 @@ export const episodeMappingKeys = {
rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const, rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const,
preview: (entityId: string, input: EpisodeMappingPreviewInput) => preview: (entityId: string, input: EpisodeMappingPreviewInput) =>
["episode-mapping", "preview", entityId, input] as const, ["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) { 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<unknown>(`/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"] });
},
});
}
@@ -65,7 +65,10 @@ function SeasonSummary({ p }: { p: EpisodeMappingPreview["seasons"] }) {
{s.season === p.latestSeason ? " · 最新" : ""} {s.season === p.latestSeason ? " · 最新" : ""}
</Chip> </Chip>
))} ))}
<span className="text-meta text-muted-foreground">{p.ended ? "已完结" : "未完结"}</span> <span className="text-meta text-muted-foreground">
{p.ended ? "已完结" : "未完结"}
{p.structure ? ` · 按剧集组「${p.structure.name}」分季` : ""}
</span>
</div> </div>
); );
} }
@@ -28,6 +28,7 @@ describe("集数映射文案", () => {
absolute_in_season: "季内绝对集号", absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号", absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期", air_date: "按播出日期",
tmdb_season: "按 TMDB 默认季集换算",
unverified: "元数据未覆盖,先按原号", unverified: "元数据未覆盖,先按原号",
unresolved: "待确认", unresolved: "待确认",
skipped: "不适用", skipped: "不适用",
@@ -22,6 +22,7 @@ export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
absolute_in_season: "季内绝对集号", absolute_in_season: "季内绝对集号",
absolute_cross_season: "跨季绝对集号", absolute_cross_season: "跨季绝对集号",
air_date: "按播出日期", air_date: "按播出日期",
tmdb_season: "按 TMDB 默认季集换算",
unverified: "元数据未覆盖,先按原号", unverified: "元数据未覆盖,先按原号",
unresolved: "待确认", unresolved: "待确认",
skipped: "不适用", skipped: "不适用",
@@ -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<EpisodeStructureSeason, "seasonNumber" | "name">): string {
return s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`);
}
/** 季卡片第二行:首播年 · 集数 */
export function seasonLine(s: Pick<EpisodeStructureSeason, "airDate" | "episodeCount">): string {
return [s.airDate ? s.airDate.slice(0, 4) : null, `${s.episodeCount} 集`].filter(Boolean).join(" · ");
}
/** 已入库 x/y;一集都没有时返回 null(只列非零项) */
export function inLibraryText(s: Pick<EpisodeStructureSeason, "inLibrary" | "episodeCount">): 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 原季集迁到新位置,已入库文件跟着走;集数映射、订阅和刮削都按新季集,已绑定的资源会重新换算。之后可以再切回来。`;
}
@@ -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("<p>原季列表</p>");
});
it("作品页:有剧集组时轨道在季列表上方,当前结构显示原来的季与集", () => {
const html = withCache(
createElement(EpisodeStructureSwitcher, { entityId: ENTITY }, createElement("p", null, "原季列表")),
groups("grp-production"),
);
expect(html).toContain("季集结构");
expect(html).toContain("原季列表");
expect(html).not.toContain("改用这套结构");
});
});
@@ -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 (
<fieldset aria-label="季集结构" className="m-0 min-w-0 border-0 p-0">
<div className="scroll-bleed -mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{options.map((o) => {
const selected = o.groupId === value;
return (
<button
key={o.groupId ?? "default"}
type="button"
aria-pressed={selected}
onClick={() => onChange(o.groupId)}
className={cn(
"flex min-w-[9.5rem] shrink-0 cursor-pointer flex-col items-start rounded-xl border px-3 py-2 text-left transition-colors duration-base",
selected
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-wash/[0.06] hover:text-foreground",
)}
>
<span className="flex items-center gap-1 text-sm font-medium text-foreground">
{o.current ? <Check className="h-3.5 w-3.5" aria-label="当前" /> : null}
{o.label}
</span>
<span className="text-meta text-muted-foreground">{o.meta ?? (o.current ? "当前" : "按 TMDB 季")}</span>
</button>
);
})}
</div>
</fieldset>
);
}
/** 季卡片网格(订阅选季):海报、季名、首播年 · 集数、已入库;可选中 */
export function SeasonCardGrid({
seasons,
selected,
onPick,
disabled,
}: {
seasons: EpisodeStructureSeason[];
/** 选中的季号 */
selected: number | null;
onPick: (s: EpisodeStructureSeason) => void;
disabled?: boolean;
}) {
return (
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{seasons.map((s) => {
const on = s.seasonNumber === selected;
const lib = inLibraryText(s);
return (
<li key={s.seasonNumber}>
<button
type="button"
aria-pressed={on}
disabled={disabled}
onClick={() => onPick(s)}
className={cn(
"flex w-full cursor-pointer flex-col gap-1.5 rounded-xl border p-1.5 text-left transition-colors duration-base disabled:cursor-not-allowed disabled:opacity-50",
on ? "pill-selected" : "border-border hover:bg-wash/[0.06]",
)}
>
<CoverPlaceholder
title={seasonTitle(s)}
kind="series"
small
posterPath={s.posterPath}
className="aspect-[2/3] h-auto w-full"
/>
<span className="min-w-0 px-0.5">
<span className="block truncate text-caption font-medium text-foreground">{seasonTitle(s)}</span>
<span className="block text-meta text-muted-foreground tabular-nums">{seasonLine(s)}</span>
{lib ? <span className="block text-meta text-muted-foreground tabular-nums">{lib}</span> : null}
</span>
</button>
</li>
);
})}
</ul>
);
}
/** 某套结构的季折叠列表(作品页预览):季头 + 每集(集号、标题、播出日期、入库状态、原季集对照) */
export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[] }) {
const [open, setOpen] = useState<Set<number>>(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 (
<PaginatedList rows={seasons} label="季列表" className="space-y-2">
{(s) => (
<SeasonSection
key={s.seasonNumber}
s={{
seasonNumber: s.seasonNumber,
name: s.name,
airDate: s.airDate,
episodeCount: s.episodeCount,
posterPath: s.posterPath,
overview: null,
summary: (
<StatusBadge tone={s.episodeCount && s.inLibrary >= s.episodeCount ? "success" : "neutral"}>
已入库 {s.inLibrary}/{s.episodeCount}
</StatusBadge>
),
}}
open={open.has(s.seasonNumber)}
onToggle={() => toggle(s.seasonNumber)}
>
{s.episodes.length === 0 ? (
<p className="text-meta text-muted-foreground">TMDB 上这一季还没有集信息。</p>
) : (
<PaginatedList
rows={s.episodes}
as="ul"
compact
label={`第 ${s.seasonNumber} 季集列表`}
className="divide-y divide-border/60"
>
{(e) => (
<EpisodeRow
key={e.episodeNumber}
e={{
key: String(e.episodeNumber),
seasonNumber: s.seasonNumber,
episodeNumber: e.episodeNumber,
name: e.name,
overview: null,
airDate: e.airDate,
runtime: null,
stillPath: null,
voteAverage: null,
status: e.inLibrary ? { label: "已入库", tone: "success" } : { label: "未入库", tone: "neutral" },
extra: originText(s.seasonNumber, e) ? (
<p className="mt-0.5 font-mono text-meta text-muted-foreground">
{originText(s.seasonNumber, e)}
</p>
) : null,
}}
/>
)}
</PaginatedList>
)}
</SeasonSection>
)}
</PaginatedList>
);
}
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<string | null | undefined>(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 (
<div className="space-y-3">
<div className="space-y-1.5">
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<Layers className="h-3.5 w-3.5" aria-hidden />
季集结构
</p>
<StructureTrack options={options} value={selected} onChange={setPicked} />
</div>
{!viewingOther ? (
children
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-dashed border-border px-3 py-2">
<p className="text-caption text-muted-foreground">
预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已入库按 TMDB 原季集对照。
</p>
<Button size="sm" disabled={setGroup.isPending} onClick={() => setConfirm(true)}>
改用这套结构
</Button>
</div>
{structure.isPending ? (
<LoadingState rows={2} />
) : structure.error ? (
<ErrorState error={structure.error} onRetry={() => structure.refetch()} />
) : (
<StructurePreview seasons={structure.data?.seasons ?? []} />
)}
</div>
)}
<ConfirmDialog
open={confirm}
onOpenChange={setConfirm}
title={`改按「${target?.label ?? DEFAULT_STRUCTURE_LABEL}」分季?`}
description={switchDescription(target?.label ?? DEFAULT_STRUCTURE_LABEL)}
confirmLabel="改用"
onConfirm={async () => {
try {
resultToast(await setGroup.mutateAsync(selected));
setPicked(undefined);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
}}
/>
</div>
);
}
/**
* 订阅表单的选季(学 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<string | null | undefined>(undefined);
const selected = picked === undefined ? current : picked;
const structure = useEpisodeStructure(entityId, selected, hasStructureChoice(groups.data));
const setGroup = useSetEpisodeGroup(entityId);
const [ask, setAsk] = useState<EpisodeStructureSeason | null>(null);
const [after, setAfter] = useState<number | null>(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 (
<div className="space-y-2">
<StructureTrack options={options} value={selected} onChange={setPicked} />
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
type="button"
size="sm"
variant="outline"
aria-pressed={value === ""}
disabled={disabled}
className={value === "" ? "pill-selected" : undefined}
onClick={() => onChange("")}
>
全部季
</Button>
{!structure.data?.current && structure.data ? (
<span className="text-meta text-muted-foreground">
不是作品当前的季集结构,选季时会先确认改用「{target?.label}」
</span>
) : null}
</div>
{structure.isPending ? (
<LoadingState rows={2} />
) : structure.error ? (
<ErrorState error={structure.error} onRetry={() => structure.refetch()} />
) : (
<SeasonCardGrid
seasons={seasons.filter((s) => s.seasonNumber > 0 || s.episodeCount > 0)}
selected={selectedSeason}
onPick={pick}
disabled={disabled || setGroup.isPending}
/>
)}
<ConfirmDialog
open={!!ask}
onOpenChange={(o) => !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;
}
}}
/>
</div>
);
}
@@ -13,6 +13,7 @@ import { ExternalLink, Search, X } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useState } from "react"; import { type ReactNode, useEffect, useId, useMemo, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useEpisodeGroups } from "~/api/episode-mapping.ts";
import { useEntityFromCandidate } from "~/api/files.ts"; import { useEntityFromCandidate } from "~/api/files.ts";
import { downloaders, useSiteOptions } from "~/api/instances.ts"; import { downloaders, useSiteOptions } from "~/api/instances.ts";
import { useEntityDetail, useLibraryRoots } from "~/api/library.ts"; import { useEntityDetail, useLibraryRoots } from "~/api/library.ts";
@@ -29,6 +30,8 @@ import {
import { FormField } from "~/components/console/form-field.tsx"; import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts"; import { consolePath } from "~/components/console/nav.ts";
import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx"; 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 { PRIORITY, SEASON_SCOPE, SUB_MODE } from "~/components/library/labels.ts";
import { ChipToggleGroup } from "~/components/library/parts.tsx"; import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
@@ -333,6 +336,9 @@ export function SubscriptionFormDialog({
const isMovie = kind === "movie"; const isMovie = kind === "movie";
const detail = useEntityDetail(entityId && !isMovie ? entityId : undefined); 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 ?? []) const seasons = (detail.data?.contentUnits ?? [])
.filter((u) => u.kind === "season" && u.seasonNumber !== null) .filter((u) => u.kind === "season" && u.seasonNumber !== null)
.sort((a, b) => (a.seasonNumber ?? 0) - (b.seasonNumber ?? 0)); .sort((a, b) => (a.seasonNumber ?? 0) - (b.seasonNumber ?? 0));
@@ -537,27 +543,38 @@ export function SubscriptionFormDialog({
/> />
</FormField> </FormField>
</div> </div>
{!isMovie && seasonCards && entityId ? (
<FormField label="季" hint="按作品当前的季集结构订阅;换剧集组等于改作品设置,会先确认">
<SubscriptionSeasonPicker
entityId={entityId}
value={d.seasonUnitId}
onChange={(v) => patch({ seasonUnitId: v })}
/>
</FormField>
) : null}
{isMovie ? null : ( {isMovie ? null : (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<FormField {seasonCards ? null : (
label="季" <FormField
htmlFor={f("season")} label="季"
hint={ htmlFor={f("season")}
editing ? "季建好后不能改;要换季就新建订阅" : entityId ? undefined : "作品加入媒体库后可选季" hint={
} editing ? "季建好后不能改;要换季就新建订阅" : entityId ? undefined : "作品加入媒体库后可选季"
> }
<Pick >
id={f("season")} <Pick
value={d.seasonUnitId} id={f("season")}
onChange={(v) => patch({ seasonUnitId: v })} value={d.seasonUnitId}
placeholder="全部季" onChange={(v) => patch({ seasonUnitId: v })}
options={seasons.map((s) => ({ placeholder="全部季"
value: s.id, options={seasons.map((s) => ({
label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`, value: s.id,
}))} label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
disabled={editing || !entityId} }))}
/> disabled={editing || !entityId}
</FormField> />
</FormField>
)}
<FormField label="季范围" htmlFor={f("scope")}> <FormField label="季范围" htmlFor={f("scope")}>
<Pick <Pick
id={f("scope")} id={f("scope")}
+121 -118
View File
@@ -50,6 +50,7 @@ import { TechInfo } from "~/components/console/tech-info.tsx";
import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts"; import { DROPPED_STATUS, droppedReason, PURPOSE_LABEL } from "~/components/downloads/plan-format.ts";
import { EpisodeMappingPanel } from "~/components/episode-mapping/mapping-panel.tsx"; import { EpisodeMappingPanel } from "~/components/episode-mapping/mapping-panel.tsx";
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx"; import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
import { EpisodeStructureSwitcher } from "~/components/library/episode-structure.tsx";
import { import {
CONTENT_TYPE, CONTENT_TYPE,
DOWNLOAD_STATUS, DOWNLOAD_STATUS,
@@ -755,125 +756,127 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
</Button> </Button>
} }
> >
{rows.length > 1 ? ( <EpisodeStructureSwitcher entityId={e.id}>
<PaginatedList {rows.length > 1 ? (
rows={rows} <PaginatedList
as="fieldset" rows={rows}
label="按收录版本看状态" as="fieldset"
resetKey={e.id} label="按收录版本看状态"
className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0" resetKey={e.id}
> className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
{(r, i) => ( >
<button {(r, i) => (
key={r.slotId ?? "any"} <button
type="button" key={r.slotId ?? "any"}
aria-pressed={i === rowIdx} type="button"
onClick={() => setRowIdx(i)} aria-pressed={i === rowIdx}
className={ onClick={() => setRowIdx(i)}
i === rowIdx className={
? "pill-selected inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium text-foreground" i === rowIdx
: "inline-flex h-7 cursor-pointer items-center rounded-full border border-border px-2.5 text-meta font-medium text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground" ? "pill-selected inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium text-foreground"
} : "inline-flex h-7 cursor-pointer items-center rounded-full border border-border px-2.5 text-meta font-medium text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
> }
{r.name}
</button>
)}
</PaginatedList>
) : null}
{seasonNums.length === 0 ? (
<EmptyState
plain
icon={Tv}
title="还没有每集信息"
description="点「更新每集信息」从 TMDB 拉取全部季的集标题、播出日期和简介。"
className="py-8"
/>
) : (
<PaginatedList rows={seasonNums} label="季列表" resetKey={`${e.id}:${rowIdx}`} className="space-y-2">
{(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 (
<SeasonSection
key={n}
s={{
seasonNumber: n,
name: typeof nameV === "string" ? nameV : (su?.title ?? null),
airDate: typeof airV === "string" ? airV : null,
episodeCount: total,
posterPath: typeof posterV === "string" ? posterV : null,
overview: typeof ovV === "string" ? ovV : null,
detail: (() => {
const vs = versionSeasons.find((x) => x.seasonNumber === n);
return vs ? seasonLine(vs, !anyVersion) : null;
})(),
summary: (
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
已入库 {have}/{total ?? "?"}
</StatusBadge>
),
}}
open={openSet.has(n)}
onToggle={() => toggle(n)}
> >
{eps.length === 0 ? ( {r.name}
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p> </button>
) : ( )}
<PaginatedList </PaginatedList>
rows={eps} ) : null}
as="ul" {seasonNums.length === 0 ? (
compact <EmptyState
label={`第 ${n} 季集列表`} plain
resetKey={`${e.id}:${n}:${rowIdx}`} icon={Tv}
className="divide-y divide-border/60" title="还没有每集信息"
> description="点「更新每集信息」从 TMDB 拉取全部季的集标题、播出日期和简介。"
{(u) => { className="py-8"
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); <PaginatedList rows={seasonNums} label="季列表" resetKey={`${e.id}:${rowIdx}`} className="space-y-2">
return ( {(n) => {
<EpisodeRow const su = seasonUnitOf(n);
key={u.id} const eps = episodes.filter((u) => u.seasonNumber === n);
e={{ const total = su?.episodeTotal ?? (eps.length || null);
key: u.id, const nameV = su ? finalOf(su, "name")?.value : null;
seasonNumber: n, const posterV = su ? finalOf(su, "posterPath")?.value : null;
episodeNumber: u.episodeNumber!, const ovV = su ? finalOf(su, "overview")?.value : null;
name: str(v("name")) ?? u.title, const airV = su ? (finalOf(su, "airDate")?.value ?? su.airDate) : null;
overview: str(v("overview")), const have = haveIn(n);
airDate: str(v("airDate")) ?? u.airDate, return (
runtime: num(v("runtime")), <SeasonSection
stillPath: str(v("stillPath")), key={n}
voteAverage: num(v("voteAverage")), s={{
status: statusOf(u), seasonNumber: n,
locked: u.fieldLocks.length > 0, name: typeof nameV === "string" ? nameV : (su?.title ?? null),
onEdit: () => setEditing(u), airDate: typeof airV === "string" ? airV : null,
extra: (() => { episodeCount: total,
const ev = versionsOf(u); posterPath: typeof posterV === "string" ? posterV : null,
return ev ? ( overview: typeof ovV === "string" ? ovV : null,
<EpisodeVersionList detail: (() => {
versions={ev.versions} const vs = versionSeasons.find((x) => x.seasonNumber === n);
match={anyVersion ? undefined : ev.matches?.[0]} return vs ? seasonLine(vs, !anyVersion) : null;
/> })(),
) : null; summary: (
})(), <StatusBadge tone={total && have >= total ? "success" : "neutral"}>
}} 已入库 {have}/{total ?? "?"}
/> </StatusBadge>
); ),
}} }}
</PaginatedList> open={openSet.has(n)}
)} onToggle={() => toggle(n)}
</SeasonSection> >
); {eps.length === 0 ? (
}} <p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
</PaginatedList> ) : (
)} <PaginatedList
<UnplacedVersions versions={versions.data?.unplaced ?? []} /> rows={eps}
as="ul"
compact
label={`第 ${n} 季集列表`}
resetKey={`${e.id}:${n}:${rowIdx}`}
className="divide-y divide-border/60"
>
{(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 (
<EpisodeRow
key={u.id}
e={{
key: u.id,
seasonNumber: n,
episodeNumber: u.episodeNumber!,
name: str(v("name")) ?? u.title,
overview: str(v("overview")),
airDate: str(v("airDate")) ?? u.airDate,
runtime: num(v("runtime")),
stillPath: str(v("stillPath")),
voteAverage: num(v("voteAverage")),
status: statusOf(u),
locked: u.fieldLocks.length > 0,
onEdit: () => setEditing(u),
extra: (() => {
const ev = versionsOf(u);
return ev ? (
<EpisodeVersionList
versions={ev.versions}
match={anyVersion ? undefined : ev.matches?.[0]}
/>
) : null;
})(),
}}
/>
);
}}
</PaginatedList>
)}
</SeasonSection>
);
}}
</PaginatedList>
)}
<UnplacedVersions versions={versions.data?.unplaced ?? []} />
</EpisodeStructureSwitcher>
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} /> <UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
</Panel> </Panel>
); );
+17
View File
@@ -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 调整了组内顺序也按原季集挪单元。
+26
View File
@@ -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 不写。人工确认的规则记操作者为管理员、证据等级「人工确认」。 - 没有授权(或模式是只读调查)时降级只读:智能体在会话里用 `simulate`(带事项 ID 和理由)试出的通过规则,会作为「智能体建议」写进事项证据(`evidence.agentSuggestion`,事项刷新时保留),待处理卡片显示规则、理由、样本套用后的季集与模拟结果,点「确认智能体建议」(选项 `confirm_agent_suggestion`)即建规则 + 重算;建之前按当前资源再模拟一次,不通过返回 409 不写。人工确认的规则记操作者为管理员、证据等级「人工确认」。
- 怎么撤销:作品页「集数映射」里智能体写的规则带「智能体 · 多证据 / 待复核」标记和依据,点「停用」:立即按作品重算(资源回到待确认并重新开事项),且同作用域有手动停用的规则时 `apply_rule` 一律拒绝,智能体不会再自动写回(仍可留建议给人确认)。直接删除不留这个记号,新开的事项可能再被自动写一次(受同根因自动恢复次数上限 `agent.investigateMaxAttempts` 约束)。要彻底停掉自动写规则,撤销那条持续授权或把自动调查模式改回只读调查。 - 怎么撤销:作品页「集数映射」里智能体写的规则带「智能体 · 多证据 / 待复核」标记和依据,点「停用」:立即按作品重算(资源回到待确认并重新开事项),且同作用域有手动停用的规则时 `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 次)。 - 复查有上限:`unmatched` 每天一次本地召回,最多 3 次;查过 TMDB 的按原退避(无结果 1h 起、查询失败 15min 起,翻倍到 7 天,最多 8 次)。
@@ -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 <T>(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);
});
});
@@ -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<string, unknown>;
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<Record<number, string>> = {
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<TmdbClient, "getJson">,
tvId: string | number,
language?: string,
): Promise<TmdbEpisodeGroupSummary[]> {
const j = await client.getJson<Json>(`/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<TmdbClient, "getJson">,
groupId: string,
language?: string,
): Promise<TmdbEpisodeGroupDetail> {
const lang = language ?? "zh-CN";
const j = await client.getJson<Json>(`/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<number>();
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],
};
}
+1
View File
@@ -1,6 +1,7 @@
export { RequestLimiter, TtlLruCache } from "./cache.ts"; export { RequestLimiter, TtlLruCache } from "./cache.ts";
export * from "./client.ts"; export * from "./client.ts";
export * from "./details.ts"; export * from "./details.ts";
export * from "./episode-groups.ts";
export * from "./match.ts"; export * from "./match.ts";
export * from "./recall.ts"; export * from "./recall.ts";
export * from "./translations.ts"; export * from "./translations.ts";
@@ -51,4 +51,33 @@ export const episodeMappingApi = {
response: em.EpisodeMappingPreview, response: em.EpisodeMappingPreview,
}), }),
}, },
episodeGroups: {
list: ep({
method: "get",
path: "/media-entities/{id}/episode-groups",
scope: "read",
summary: "作品可选的季集结构:TMDB 剧集组列表与当前选择",
params: IdParam,
response: em.EpisodeGroupList,
}),
seasons: ep({
method: "get",
path: "/media-entities/{id}/episode-structure",
scope: "read",
summary: "某套季集结构(TMDB 默认季或剧集组)的季卡片与每集,带本地已入库情况(纯读)",
params: IdParam,
query: em.EpisodeStructureQuery,
response: em.EpisodeStructureSeasons,
}),
set: ep({
method: "put",
path: "/media-entities/{id}/episode-group",
scope: "organize.execute",
summary:
"切换作品的季集结构:按 TMDB 原季集原地迁移集单元(引用不动),重建季表,按作品重算已绑定资源并刷新元数据",
params: IdParam,
body: em.EpisodeGroupSetInput,
response: em.EpisodeGroupSetResult,
}),
},
} as const; } as const;
+5
View File
@@ -25,6 +25,11 @@ export const MediaEntity = EntityBase.extend({
lastMetadataRefreshAt: Timestamp.nullable(), lastMetadataRefreshAt: Timestamp.nullable(),
/** 作品级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /media-entities/{id}/naming-override 修改 */ /** 作品级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /media-entities/{id}/naming-override 修改 */
namingOverride: JsonObject.nullable().optional(), namingOverride: JsonObject.nullable().optional(),
/** 季集结构:TMDB 剧集组 id / 名称;null = TMDB 默认季。经 PUT /media-entities/{id}/episode-group 修改 */
episodeGroupId: z.string().nullable().optional(),
episodeGroupName: z.string().nullable().optional(),
episodeGroupSetAt: Timestamp.nullable().optional(),
episodeGroupSetBy: z.string().nullable().optional(),
}); });
export type MediaEntity = z.infer<typeof MediaEntity>; export type MediaEntity = z.infer<typeof MediaEntity>;
export const MediaEntityCreate = MediaEntity.pick({ export const MediaEntityCreate = MediaEntity.pick({
+119
View File
@@ -51,6 +51,7 @@ export const EpisodeResolutionMethod = z.enum([
"absolute_in_season", "absolute_in_season",
"absolute_cross_season", "absolute_cross_season",
"air_date", "air_date",
"tmdb_season",
"unverified", "unverified",
"unresolved", "unresolved",
"skipped", "skipped",
@@ -197,6 +198,8 @@ export const EpisodeSeasonSummary = z.object({
), ),
latestSeason: z.number().int().nullable(), latestSeason: z.number().int().nullable(),
ended: z.boolean(), ended: z.boolean(),
/** 季表来自哪套季集结构:null = TMDB 默认季;否则为作品选的剧集组 */
structure: z.object({ groupId: z.string(), name: z.string() }).nullable().optional(),
}); });
export type EpisodeSeasonSummary = z.infer<typeof EpisodeSeasonSummary>; export type EpisodeSeasonSummary = z.infer<typeof EpisodeSeasonSummary>;
@@ -210,3 +213,119 @@ export const EpisodeMappingPreview = z.object({
changed: z.number().int().min(0), changed: z.number().int().min(0),
}); });
export type EpisodeMappingPreview = z.infer<typeof EpisodeMappingPreview>; export type EpisodeMappingPreview = z.infer<typeof EpisodeMappingPreview>;
// ── 剧集组(季集结构):作品可选 TMDB 默认季或某个剧集组,季表与季 / 集单元随之生成 ──
export const EpisodeGroupSummary = z.object({
id: z.string(),
name: z.string(),
/** TMDB type 1–7;未知为 null */
type: z.number().int().nullable(),
/** 类型的中文名(原始播出 / 绝对集号 / DVD / 数字发行 / 故事线 / 制作季 / 电视) */
typeLabel: z.string().nullable(),
description: z.string().nullable(),
/** 组(= 季)数 */
groupCount: z.number().int().min(0),
episodeCount: z.number().int().min(0),
network: z.string().nullable(),
});
export type EpisodeGroupSummary = z.infer<typeof EpisodeGroupSummary>;
/** 作品当前的季集结构;null = TMDB 默认季 */
export const EpisodeStructureCurrent = z.object({
groupId: z.string(),
name: z.string(),
setAt: z.string().nullable(),
/** `<actor_type>:<actor_id>` */
setBy: z.string().nullable(),
});
export type EpisodeStructureCurrent = z.infer<typeof EpisodeStructureCurrent>;
export const EpisodeGroupList = z.object({
/** 作品是否能选剧集组(剧集 / 综艺且有 TMDB 剧集身份) */
applicable: z.boolean(),
current: EpisodeStructureCurrent.nullable(),
groups: z.array(EpisodeGroupSummary),
/** 取列表失败的原因码(TMDB 未配置 / 不可用);成功为 null */
error: z.string().nullable(),
});
export type EpisodeGroupList = z.infer<typeof EpisodeGroupList>;
export const EpisodeStructureEpisode = z.object({
episodeNumber: z.number().int(),
name: z.string().nullable(),
airDate: z.string().nullable(),
/** TMDB 默认季集号(剧集组里才有意义;默认季时与自身相同) */
tmdbSeason: z.number().int(),
tmdbEpisode: z.number().int(),
/** 本地已入库(按 TMDB 原季集对照,换结构前也能看) */
inLibrary: z.boolean(),
});
export type EpisodeStructureEpisode = z.infer<typeof EpisodeStructureEpisode>;
export const EpisodeStructureSeason = z.object({
seasonNumber: z.number().int(),
name: z.string().nullable(),
episodeCount: z.number().int().min(0),
/** 首播日期 */
airDate: z.string().nullable(),
posterPath: z.string().nullable(),
/** 本地已入库集数 */
inLibrary: z.number().int().min(0),
/** 当前结构下对应的季单元(选季订阅用);不是当前结构或还没建为 null */
seasonUnitId: Id.nullable(),
episodes: z.array(EpisodeStructureEpisode),
});
export type EpisodeStructureSeason = z.infer<typeof EpisodeStructureSeason>;
/** 某套季集结构的季卡片(TMDB 默认季或某个剧集组),带本地已入库情况 */
export const EpisodeStructureSeasons = z.object({
groupId: z.string().nullable(),
name: z.string(),
/** 是不是作品当前的季集结构 */
current: z.boolean(),
seasons: z.array(EpisodeStructureSeason),
});
export type EpisodeStructureSeasons = z.infer<typeof EpisodeStructureSeasons>;
export const EpisodeStructureQuery = z.object({
/** 剧集组 id;不填 = TMDB 默认季 */
groupId: z.string().trim().min(1).max(64).optional(),
});
export type EpisodeStructureQuery = z.infer<typeof EpisodeStructureQuery>;
export const EpisodeGroupSetInput = z.object({
/** 剧集组 id;null = 改回 TMDB 默认季 */
groupId: z.string().trim().min(1).max(64).nullable(),
});
export type EpisodeGroupSetInput = z.infer<typeof EpisodeGroupSetInput>;
/** 切换时对不上的集单元(保留原号、标临时) */
export const EpisodeGroupUnmatchedUnit = z.object({
unitId: Id,
seasonNumber: z.number().int().nullable(),
episodeNumber: z.number().int().nullable(),
/** no_origin:求不出 TMDB 原季集;not_in_target:目标结构里没有这一集;numbering_locked:编号已人工锁定 */
reason: z.enum(["no_origin", "not_in_target", "numbering_locked"]),
/** 有资产覆盖 / 资源 / 下载等引用 */
referenced: z.boolean(),
});
export type EpisodeGroupUnmatchedUnit = z.infer<typeof EpisodeGroupUnmatchedUnit>;
export const EpisodeGroupSetResult = z.object({
changed: z.boolean(),
current: EpisodeStructureCurrent.nullable(),
/** 改了季集号的集单元数 */
moved: z.number().int().min(0),
/** 季集号不变的集单元数 */
kept: z.number().int().min(0),
unmatched: z.array(EpisodeGroupUnmatchedUnit),
/** 新结构的季与集数 */
seasons: z.array(z.object({ seasonNumber: z.number().int(), episodeCount: z.number().int().min(0) })),
/** 不在新结构里、标为临时的季单元的季号 */
orphanSeasons: z.array(z.number().int()),
reresolve: EpisodeReresolveResult,
/** 切换后元数据刷新结果(refreshed / skipped / failed) */
refresh: z.string(),
});
export type EpisodeGroupSetResult = z.infer<typeof EpisodeGroupSetResult>;
@@ -0,0 +1,31 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// 作品的季集结构(剧集组):null = TMDB 默认季;否则为 TMDB 剧集组 id(/tv/episode_group/{id}),季表与季 / 集单元按组生成。
// 名称冗余存一份(界面显示不用再查 TMDB);选择时间与操作者(`<actor_type>:<actor_id>`)供审计与界面「谁改的」。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table media_entity add column episode_group_id text;
alter table media_entity add column episode_group_name text;
alter table media_entity add column episode_group_set_at timestamptz;
alter table media_entity add column episode_group_set_by text;
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table media_entity drop column if exists episode_group_set_by;
alter table media_entity drop column if exists episode_group_set_at;
alter table media_entity drop column if exists episode_group_name;
alter table media_entity drop column if exists episode_group_id;
`,
);
}
+4
View File
@@ -953,6 +953,10 @@ export interface MediaEntityTable {
created_at: GeneratedTimestamptz; created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz;
naming_override: Json | null; naming_override: Json | null;
episode_group_id: string | null;
episode_group_name: string | null;
episode_group_set_at: Timestamptz | null;
episode_group_set_by: string | null;
} }
export interface MediaServerInstanceTable { export interface MediaServerInstanceTable {
@@ -3,7 +3,7 @@ import { cumulativeSeasons, seasonOf } from "./table.ts";
import type { CanonicalEpisodes, SeasonTable } from "./types.ts"; import type { CanonicalEpisodes, SeasonTable } from "./types.ts";
import { validateEpisodes } from "./validate.ts"; import { validateEpisodes } from "./validate.ts";
export type ConversionMethod = "absolute_in_season" | "absolute_cross_season" | "air_date"; export type ConversionMethod = "absolute_in_season" | "absolute_cross_season" | "air_date" | "tmdb_season";
export interface ConversionCandidate { export interface ConversionCandidate {
method: ConversionMethod; method: ConversionMethod;
@@ -88,6 +88,33 @@ export function airDateCandidates(airDate: string, table: SeasonTable, season: n
return hits.map((canonical) => ({ method: "air_date", canonical, evidence: `air_date:${iso}` })); return hits.map((canonical) => ({ method: "air_date", canonical, evidence: `air_date:${iso}` }));
} }
/**
* 剧集组结构下按 TMDB 默认季集换算:把发布者的季集当作 TMDB 默认季集,经季表的 origins 对照到组内季集。
* 区间内每一集都要能对上,且落在同一季、组内集号连续,否则不成立。季 0 不换。
*/
export function tmdbSeasonCandidate(
season: number,
episodes: EpisodeRange,
table: SeasonTable,
): ConversionCandidate | null {
const origins = table.origins;
if (!origins?.length || season < 1 || episodes.end - episodes.start > 200) return null;
const hits: { season: number; episode: number }[] = [];
for (let e = episodes.start; e <= episodes.end; e++) {
const o = origins.find((x) => x.tmdbSeason === season && x.tmdbEpisode === e);
if (!o) return null;
hits.push(o);
}
const first = hits[0]!;
if (hits.some((h, i) => h.season !== first.season || h.episode !== first.episode + i)) return null;
const pad = (n: number) => String(n).padStart(2, "0");
return {
method: "tmdb_season",
canonical: { season: first.season, episodes: { start: first.episode, end: hits.at(-1)!.episode } },
evidence: `tmdb_season:S${pad(season)}E${pad(episodes.start)}`,
};
}
export interface ConversionOutcome { export interface ConversionOutcome {
/** 去重后、校验为 pass 的候选 */ /** 去重后、校验为 pass 的候选 */
candidates: ConversionCandidate[]; candidates: ConversionCandidate[];
@@ -117,6 +144,10 @@ export function convertEpisodes(input: {
if (cross.kind === "split") evidence.push(cross.evidence); if (cross.kind === "split") evidence.push(cross.evidence);
} }
} }
if (episodes && season !== null) {
const origin = tmdbSeasonCandidate(season, episodes, table);
if (origin) raw.push(origin);
}
if (input.variety && airDate) { if (input.variety && airDate) {
raw.push(...airDateCandidates(airDate, table, seasonInferred ? null : season)); raw.push(...airDateCandidates(airDate, table, seasonInferred ? null : season));
} }
@@ -7,6 +7,7 @@ export {
convertEpisodes, convertEpisodes,
crossSeasonCandidate, crossSeasonCandidate,
inSeasonCandidates, inSeasonCandidates,
tmdbSeasonCandidate,
} from "./convert.ts"; } from "./convert.ts";
export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts"; export { CONVERSION_NEAR_DAYS, resolveEpisodes } from "./resolve.ts";
export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts"; export { applyRule, type RuleApplication, ruleSpecificity, selectRule } from "./rules.ts";
@@ -13,9 +13,22 @@ export interface SeasonInfo {
episodes?: SeasonEpisodeInfo[]; episodes?: SeasonEpisodeInfo[];
} }
/** 剧集组结构下一集的 TMDB 默认季集 → 组内季集 */
export interface SeasonOrigin {
tmdbSeason: number;
tmdbEpisode: number;
season: number;
episode: number;
}
/** 作品的季表(来自元数据,调用方组装)。季 0(特别篇)可以在表里,换算时忽略 */ /** 作品的季表(来自元数据,调用方组装)。季 0(特别篇)可以在表里,换算时忽略 */
export interface SeasonTable { export interface SeasonTable {
seasons: SeasonInfo[]; seasons: SeasonInfo[];
/**
* 作品选了 TMDB 剧集组时:每集的 TMDB 默认季集与组内季集对照。发布者按 TMDB 默认季编号(S01E14)而作品按组分季时,
* 换算一步按它把原号换成组内季集(方式 tmdb_season)。默认季结构不给。
*/
origins?: SeasonOrigin[];
/** 最新季;null 时取表中最大季号 */ /** 最新季;null 时取表中最大季号 */
latestSeason: number | null; latestSeason: number | null;
/** 作品已完结:完结后不再有「元数据滞后」放行 */ /** 作品已完结:完结后不再有「元数据滞后」放行 */
@@ -58,6 +71,7 @@ export type EpisodeResolutionMethod =
| "absolute_in_season" | "absolute_in_season"
| "absolute_cross_season" | "absolute_cross_season"
| "air_date" | "air_date"
| "tmdb_season"
| "unverified" | "unverified"
| "unresolved" | "unresolved"
| "skipped"; | "skipped";
@@ -280,3 +280,39 @@ describe("resolveEpisodes 规则", () => {
expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" }); expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" });
}); });
}); });
describe("resolveEpisodes 剧集组:按 TMDB 默认季集换算", () => {
// TMDB 默认 S1 共 25 集;剧集组拆成 S1(1–12)、S2(13–25)
const grouped: SeasonTable = {
...table([12, 13]),
origins: Array.from({ length: 25 }, (_, k) => ({
tmdbSeason: 1,
tmdbEpisode: k + 1,
season: k < 12 ? 1 : 2,
episode: k < 12 ? k + 1 : k - 11,
})),
};
it("S01E14(按 TMDB 默认季编号)→ 组内 S02E02", () => {
const r = run({ season: 1, episodes: ep(14) }, grouped);
expect(r).toMatchObject({ method: "tmdb_season", canonical: { season: 2, episodes: ep(2) } });
expect(r.evidence).toContain("tmdb_season:S01E14");
});
it("「- 13」季号推定:跨季绝对集号与默认季对照同解,归第 2 季第 1 集", () => {
const r = run({ season: 1, seasonInferred: true, episodes: ep(13) }, grouped);
expect(r.canonical).toEqual({ season: 2, episodes: ep(1) });
expect(["absolute_cross_season", "tmdb_season"]).toContain(r.method);
});
it("区间跨组不换;组内号本身对得上时原号优先", () => {
expect(run({ season: 1, episodes: ep(12, 13) }, grouped).method).toBe("unresolved");
expect(run({ season: 2, episodes: ep(3) }, grouped)).toMatchObject({ method: "raw" });
expect(run({ season: 1, episodes: ep(14, 15) }, grouped).canonical).toEqual({ season: 2, episodes: ep(2, 3) });
});
it("没有 origins(默认季结构)时不出这种换算", () => {
const { origins: _o, ...plain } = grouped;
expect(run({ season: 1, episodes: ep(14) }, plain).method).toBe("unresolved");
});
});
+98
View File
@@ -56,6 +56,17 @@ export interface TmdbFixture {
seasons?: TmdbFixtureSeason[]; seasons?: TmdbFixtureSeason[];
/** 海报路径(拼 image.tmdb.org);缺省 null */ /** 海报路径(拼 image.tmdb.org);缺省 null */
poster_path?: string | 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[] { 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 })),
},
],
},
],
};
+67 -1
View File
@@ -19,7 +19,7 @@ const notFound: MockReply = {
json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." }, 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<MockTmdb> { export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTmdb> {
const fx = opts.fixtures ?? TMDB_FIXTURES; const fx = opts.fixtures ?? TMDB_FIXTURES;
const apiKey = opts.apiKey ?? "test-tmdb-key"; const apiKey = opts.apiKey ?? "test-tmdb-key";
@@ -101,6 +101,72 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
}; };
}, },
], ],
[
/^\/3\/tv\/(\d+)\/episode_groups$/,
(m) => {
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+)$/, /^\/3\/tv\/(\d+)\/season\/(\d+)$/,
(m, q) => { (m, q) => {