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:
commit
02b9773047
35 files changed
+2751
-154
No files matched your search
@@ -6,11 +6,13 @@ import {
|
||||
type DownloaderClient,
|
||||
diskUsage,
|
||||
type EmbyClient,
|
||||
fetchEpisodeGroup,
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
ffprobe,
|
||||
getSiteAdapter,
|
||||
getSiteDefinition,
|
||||
groupSeasonAsRichSeason,
|
||||
type ProbeResult,
|
||||
qbAuthConfig,
|
||||
type SecretResolver,
|
||||
@@ -98,7 +100,8 @@ export interface AoPorts {
|
||||
* TMDB 没配置返回 null;条目不存在或上游失败抛错。
|
||||
*/
|
||||
scrapeTmdbDetail(type: "movie" | "tv", id: string): Promise<TmdbRichDetail | null>;
|
||||
scrapeTmdbSeason(tvId: string, season: number): Promise<TmdbRichSeasonDetail | null>;
|
||||
/** episodeGroupId:作品选了剧集组时按组取(季号 = 组序号,集号 = 组内顺序) */
|
||||
scrapeTmdbSeason(tvId: string, season: number, episodeGroupId?: string | null): Promise<TmdbRichSeasonDetail | null>;
|
||||
options: AoOptions;
|
||||
}
|
||||
|
||||
@@ -235,9 +238,15 @@ export function createPorts(
|
||||
if (!client) return null;
|
||||
return fetchRichDetail(client, type, id, await metadataLanguages(ctx), await metadataTextOptions(ctx));
|
||||
},
|
||||
async scrapeTmdbSeason(tvId, season) {
|
||||
async scrapeTmdbSeason(tvId, season, episodeGroupId) {
|
||||
const client = await rt().tmdb();
|
||||
if (!client) return null;
|
||||
if (episodeGroupId) {
|
||||
const lang = (await metadataLanguages(ctx))[0];
|
||||
const g = await fetchEpisodeGroup(client, episodeGroupId, lang);
|
||||
const s = g.seasons.find((x) => x.seasonNumber === season);
|
||||
return s ? groupSeasonAsRichSeason(tvId, s, g.language) : null;
|
||||
}
|
||||
return fetchRichSeason(client, tvId, season, await metadataLanguages(ctx), await metadataTextOptions(ctx));
|
||||
},
|
||||
diskUsage: (p) => diskUsage(p),
|
||||
|
||||
@@ -970,12 +970,15 @@ export async function runScrapeRecord(
|
||||
let fields: Fields | null = null;
|
||||
let coverage = new Map<string, Covered>();
|
||||
let mapEpisodes: EntityEpisodeMapper | null = null;
|
||||
/** 作品选的剧集组:缺元数据时按组取季详情(季集号是组内的) */
|
||||
let episodeGroupId: string | null = null;
|
||||
if (match.mediaEntityId) {
|
||||
const e = await db
|
||||
.selectFrom("media_entity")
|
||||
.select(["id", "kind", "title", "original_title", "year", "metadata", "status"])
|
||||
.select(["id", "kind", "title", "original_title", "year", "metadata", "status", "episode_group_id"])
|
||||
.where("id", "=", match.mediaEntityId)
|
||||
.executeTakeFirst();
|
||||
episodeGroupId = e?.episode_group_id ?? null;
|
||||
if (e && e.status !== "merged") {
|
||||
if (kind === "tv" && (e.kind === "series" || e.kind === "variety"))
|
||||
mapEpisodes = await createEntityEpisodeMapper(db, e.id, e.kind);
|
||||
@@ -1109,7 +1112,7 @@ export async function runScrapeRecord(
|
||||
(typeof v.episode !== "number" && dated.has(v.path))),
|
||||
);
|
||||
if (!tmdbId || (!fromTmdb && !lacking)) continue;
|
||||
const sd = await d.ports.scrapeTmdbSeason(tmdbId, n).catch((err) => {
|
||||
const sd = await d.ports.scrapeTmdbSeason(tmdbId, n, episodeGroupId).catch((err) => {
|
||||
d.ctx.logger.warn({ recordId, season: n, err: { message: errMessage(err) } }, "刮削取 TMDB 季详情失败");
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Api } from "@umt/contracts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import { route } from "../http.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { episodeStructureSeasons, listEpisodeGroups, setEpisodeGroup } from "../service/episode-groups.ts";
|
||||
import {
|
||||
createRule,
|
||||
deleteRule,
|
||||
@@ -11,9 +12,11 @@ import {
|
||||
} from "../service/episode-mapping-admin.ts";
|
||||
|
||||
// 集数映射(docs/plan/episode-resolution.md 批次 4):规则增删改(写入后按作品重算)、模拟预览(纯读)。
|
||||
// 剧集组:作品的季集结构(TMDB 默认季 / 某个剧集组)列表、季卡片预览、切换。
|
||||
|
||||
const T = ["episode-mapping"];
|
||||
const E = Api.episodeMapping;
|
||||
const G = Api.episodeGroups;
|
||||
|
||||
export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
const ctx = () => rt().ctx;
|
||||
@@ -23,4 +26,10 @@ export function episodeMappingRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
route(app, { ...E.updateRule, tags: T }, ({ params, body, actor }) => updateRule(ctx(), params.id, body, actor));
|
||||
route(app, { ...E.deleteRule, tags: T }, ({ params, actor }) => deleteRule(ctx(), params.id, actor));
|
||||
route(app, { ...E.preview, tags: T }, ({ params, body }) => previewEpisodeMapping(ctx().db, params.id, body));
|
||||
|
||||
route(app, { ...G.list, tags: T }, ({ params }) => listEpisodeGroups(rt(), params.id));
|
||||
route(app, { ...G.seasons, tags: T }, ({ params, query }) =>
|
||||
episodeStructureSeasons(rt(), params.id, query.groupId ?? null),
|
||||
);
|
||||
route(app, { ...G.set, tags: T }, ({ params, body, actor }) => setEpisodeGroup(rt(), params.id, body.groupId, actor));
|
||||
}
|
||||
@@ -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,
|
||||
sameResolution,
|
||||
} from "./episode-resolution.ts";
|
||||
import { entityEpisodeGroup } from "./episode-structure.ts";
|
||||
|
||||
// 集数映射的接口层(docs/plan/episode-resolution.md 批次 4):规则写入校验、写入后按作品重算、模拟预览、
|
||||
// 待处理 episode_mapping 的「确认建议规则 / 忽略并建忽略规则」。判定语义全在 domain,这里只装配与校验。
|
||||
@@ -334,8 +335,12 @@ export function validationOf(r: EpisodeResolution): EpisodeValidationState {
|
||||
|
||||
const sourceSeason = (r: EpisodeResolution) => r.source.season ?? (r.source.episodes ? 1 : null);
|
||||
|
||||
function seasonSummary(table: SeasonTable): EpisodeMappingPreview["seasons"] {
|
||||
function seasonSummary(
|
||||
table: SeasonTable,
|
||||
structure: { groupId: string; name: string } | null,
|
||||
): EpisodeMappingPreview["seasons"] {
|
||||
return {
|
||||
structure,
|
||||
seasons: table.seasons.map((s) => ({
|
||||
season: s.season,
|
||||
total: s.total,
|
||||
@@ -374,7 +379,8 @@ export async function previewEpisodeMapping(
|
||||
): Promise<EpisodeMappingPreview> {
|
||||
const entity = await loadEpisodicEntity(exec, entityId);
|
||||
const table = await loadSeasonTable(exec, entityId);
|
||||
const seasons = seasonSummary(table);
|
||||
const group = await entityEpisodeGroup(exec, entityId);
|
||||
const seasons = seasonSummary(table, group ? { groupId: group.id, name: group.name } : null);
|
||||
if (!EPISODE_KINDS.has(entity.kind)) return { applicable: false, seasons, groups: [], items: [], changed: 0 };
|
||||
|
||||
let rules = await loadRules(exec, entityId);
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { ServerContext } from "../../types.ts";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import { closeDecisions, type Exec, openDecision } from "./common.ts";
|
||||
import { ensureUnits } from "./entities.ts";
|
||||
import { seasonOrigins } from "./episode-structure.ts";
|
||||
|
||||
// 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把
|
||||
// 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库:
|
||||
@@ -42,11 +43,13 @@ const DECISION_LIST_LIMIT = 50;
|
||||
* 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元。
|
||||
* 只计元数据(tmdb)/ 人工(user)来源或已确认的季单元:资源 / 文件懒建的临时季(解析错的 S05 之类)不进季表,
|
||||
* 否则它总集数为空会让该季资源一律按「元数据滞后」放行,还会抬高最新季。
|
||||
* 作品选了剧集组时季 / 集单元本身就是按组生成的,季表随之是组的季集;另附原季集对照(origins),
|
||||
* 发布者按 TMDB 默认季编号时换算一步据此换到组内季集。
|
||||
*/
|
||||
export async function loadSeasonTable(exec: Exec, entityId: string): Promise<SeasonTable> {
|
||||
const entity = await exec
|
||||
.selectFrom("media_entity")
|
||||
.select(["metadata"])
|
||||
.select(["metadata", "episode_group_id"])
|
||||
.where("id", "=", entityId)
|
||||
.executeTakeFirst();
|
||||
const units = await exec
|
||||
@@ -85,10 +88,12 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise<Sea
|
||||
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);
|
||||
const main = list.filter((s) => s.season >= 1).map((s) => s.season);
|
||||
const origins = entity?.episode_group_id ? await seasonOrigins(exec, entityId) : [];
|
||||
return {
|
||||
seasons: list,
|
||||
latestSeason: main.length ? Math.max(...main) : null,
|
||||
ended: ENDED_STATUSES.has(airStatusOf(entity?.metadata).toLowerCase()),
|
||||
...(origins.length ? { origins } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
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;
|
||||
return {
|
||||
// 剧集组的集:记下 TMDB 默认季集号(切换季集结构时据此对照,季表换算也用)
|
||||
...(e.tmdbSeason !== undefined && e.tmdbEpisode !== undefined
|
||||
? {
|
||||
tmdbSeason: { value: e.tmdbSeason, language: null },
|
||||
tmdbEpisode: { value: e.tmdbEpisode, language: null },
|
||||
}
|
||||
: {}),
|
||||
name: { value: e.name, language: e.nameLanguage },
|
||||
overview: { value: e.overview, language: e.overviewLanguage },
|
||||
airDate: { value: air, language: null },
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
type CandidateSeason,
|
||||
fetchEpisodeGroup,
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
groupSeasonAsRichSeason,
|
||||
type MetadataCandidate,
|
||||
type TmdbClient,
|
||||
TmdbError,
|
||||
@@ -23,6 +26,7 @@ import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, audit, type Exec } from "./common.ts";
|
||||
import { resolveEntityId, seedAliases } from "./entities.ts";
|
||||
import { reresolveEntityEpisodes } from "./episode-resolution.ts";
|
||||
import { entityEpisodeGroup, groupLayout, type Layout, relayoutUnits } from "./episode-structure.ts";
|
||||
import {
|
||||
applySeasonMetadata,
|
||||
metadataIncludeSpecials,
|
||||
@@ -140,7 +144,7 @@ const ENTITY_NFO_FIELDS: ReadonlySet<string> = new Set([
|
||||
|
||||
/**
|
||||
* 改作品的 TMDB 映射后:清掉该作品及其季 / 集单元的 tmdb 来源字段(metadata_field 与投影出的最终值),
|
||||
* 人工覆盖与锁定字段保留;单元的 title / air_date 列随 name / airDate 一起清空。之后由 full 刷新按新身份重新填,
|
||||
* 人工覆盖与锁定字段保留;单元的 title / air_date 列随 name / airDate 一起清空;选的剧集组一并清掉。之后由 full 刷新按新身份重新填,
|
||||
* 避免新身份的 NFO 混进旧作品的标题、简介、IMDb、海报(海报 / 背景图「TMDB 没有时保留旧值」不能保留旧作品的)。
|
||||
*/
|
||||
export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date): Promise<void> {
|
||||
@@ -177,6 +181,11 @@ export async function clearTmdbMetadata(trx: Exec, entityId: string, now: Date):
|
||||
new Set(entityOverrides.map((o) => o.field)),
|
||||
),
|
||||
),
|
||||
// 剧集组属于旧 TMDB 条目,换身份后回到默认季
|
||||
episode_group_id: null,
|
||||
episode_group_name: null,
|
||||
episode_group_set_at: null,
|
||||
episode_group_set_by: null,
|
||||
updated_at: now,
|
||||
})
|
||||
.where("id", "=", entityId)
|
||||
@@ -301,7 +310,46 @@ export async function refreshMetadata(
|
||||
ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 富详情获取失败,按基础详情刷新");
|
||||
}
|
||||
}
|
||||
if (rich && ident.entity_type === "tv" && detail.seasons?.length) {
|
||||
// 作品选了剧集组:季表与季 / 集单元按组(组序号 → 季号,组内顺序 → 集号);组取不到时这次不动季集结构
|
||||
const group = ident.entity_type === "tv" && rich ? await entityEpisodeGroup(ctx.db, id) : null;
|
||||
let layout: Layout | null = null;
|
||||
let groupSeasons: CandidateSeason[] | null = null;
|
||||
if (group) {
|
||||
try {
|
||||
const gd = await fetchEpisodeGroup(tmdb, group.id, langs[0]);
|
||||
layout = groupLayout(gd);
|
||||
groupSeasons = layout.seasons.map((s) => ({
|
||||
seasonNumber: s.seasonNumber,
|
||||
episodeCount: s.episodeCount,
|
||||
name: s.name,
|
||||
airDate: s.airDate,
|
||||
}));
|
||||
const known = gd.seasons.map((s) => s.seasonNumber);
|
||||
const tracked = await trackedSeasonsForAirDates(ctx.db, id, known);
|
||||
const want = seasonsToRefresh(known, {
|
||||
full: !!opts.full,
|
||||
tracked,
|
||||
inLibrary: await seasonsInLibrary(ctx.db, id),
|
||||
specials: await metadataIncludeSpecials(ctx),
|
||||
});
|
||||
creatable = new Set(opts.full ? want : tracked);
|
||||
// 组的季没有海报:取组内首集所在的 TMDB 默认季的海报
|
||||
const posterOf = (s: (typeof gd.seasons)[number]) => {
|
||||
const o = s.episodes[0]?.tmdbSeason;
|
||||
return richDetail?.seasons.find((x) => x.seasonNumber === o)?.posterPath ?? null;
|
||||
};
|
||||
for (const s of gd.seasons)
|
||||
if (want.includes(s.seasonNumber))
|
||||
richSeasons.push(groupSeasonAsRichSeason(ident.external_id, s, gd.language, posterOf(s)));
|
||||
} catch (e) {
|
||||
if (!(e instanceof TmdbError)) throw e;
|
||||
ctx.logger.warn(
|
||||
{ entityId: id, episodeGroup: group.id, err: { message: e.message } },
|
||||
"剧集组获取失败,本次不刷新季集",
|
||||
);
|
||||
groupSeasons = [];
|
||||
}
|
||||
} else if (rich && ident.entity_type === "tv" && detail.seasons?.length) {
|
||||
const known = detail.seasons.map((x) => x.seasonNumber);
|
||||
const tracked = await trackedSeasonsForAirDates(ctx.db, id, known);
|
||||
const want = seasonsToRefresh(known, {
|
||||
@@ -321,7 +369,7 @@ export async function refreshMetadata(
|
||||
}
|
||||
// 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新
|
||||
const airSeasons: TmdbSeasonDetail[] = [];
|
||||
if (!rich && ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
|
||||
if (!group && !rich && ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
|
||||
const want = await trackedSeasonsForAirDates(
|
||||
ctx.db,
|
||||
id,
|
||||
@@ -350,15 +398,16 @@ export async function refreshMetadata(
|
||||
await seedAliases(trx, id, detail);
|
||||
if (richDetail) await tagTitleAliases(trx, id, richDetail);
|
||||
const r = await project(trx, id, now);
|
||||
// 剧集组:TMDB 编辑调整过组内顺序时,集单元按记下的原季集挪到新位置(规则同切换季集结构)
|
||||
if (layout) await relayoutUnits(trx, id, layout, layout, now);
|
||||
// DH-1:先合并重复单元(并发懒建留下的同季同集多条),再按 TMDB 季集结构写入 / 转正
|
||||
const merged = await mergeDuplicateUnits(trx, id);
|
||||
if (merged.merged)
|
||||
ctx.logger.info({ entityId: id, merged: merged.merged, repointed: merged.repointed }, "合并重复内容单元");
|
||||
// 季总集数账本:只升;回退不低于已覆盖最高集;锁定不改(订阅完成判定据此)
|
||||
const seasonList = groupSeasons ?? detail.seasons ?? [];
|
||||
const totals =
|
||||
ident.entity_type === "tv" && detail.seasons?.length
|
||||
? await applySeasonTotals(trx, id, detail.seasons, now)
|
||||
: null;
|
||||
ident.entity_type === "tv" && seasonList.length ? await applySeasonTotals(trx, id, seasonList, now) : null;
|
||||
for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd);
|
||||
const episodes: SeasonMetadataResult[] = [];
|
||||
for (const sd of richSeasons)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
EpisodeGroupList,
|
||||
EpisodeGroupSetResult,
|
||||
EpisodeMappingPreview,
|
||||
type EpisodeMappingPreviewInput,
|
||||
type EpisodeMappingRuleCreate,
|
||||
EpisodeMappingRuleList,
|
||||
type EpisodeMappingRuleUpdate,
|
||||
EpisodeMappingRuleWriteResult,
|
||||
EpisodeStructureSeasons,
|
||||
} from "@umt/contracts";
|
||||
import { api } from "~/lib/api.ts";
|
||||
import { decisionKeys } from "./decisions.ts";
|
||||
@@ -18,6 +21,9 @@ export const episodeMappingKeys = {
|
||||
rules: (entityId: string) => ["episode-mapping", "rules", entityId] as const,
|
||||
preview: (entityId: string, input: EpisodeMappingPreviewInput) =>
|
||||
["episode-mapping", "preview", entityId, input] as const,
|
||||
groups: (entityId: string) => ["episode-mapping", "groups", entityId] as const,
|
||||
structure: (entityId: string, groupId: string | null) =>
|
||||
["episode-mapping", "structure", entityId, groupId ?? "default"] as const,
|
||||
};
|
||||
|
||||
export function useEpisodeMappingRules(entityId: string | undefined) {
|
||||
@@ -73,3 +79,51 @@ export function useEpisodeMappingWrite(entityId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── 剧集组(季集结构) ──
|
||||
|
||||
/** 作品可选的 TMDB 剧集组与当前选择 */
|
||||
export function useEpisodeGroups(entityId: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: episodeMappingKeys.groups(entityId ?? ""),
|
||||
enabled: !!entityId && enabled,
|
||||
staleTime: 5 * 60_000,
|
||||
queryFn: ({ signal }) =>
|
||||
getParsed(EpisodeGroupList, `/media-entities/${entityId}/episode-groups`, undefined, signal),
|
||||
});
|
||||
}
|
||||
|
||||
/** 某套季集结构(null = TMDB 默认季)的季卡片与每集,带本地已入库 */
|
||||
export function useEpisodeStructure(entityId: string | undefined, groupId: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: episodeMappingKeys.structure(entityId ?? "", groupId),
|
||||
enabled: !!entityId && enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: ({ signal }) =>
|
||||
getParsed(
|
||||
EpisodeStructureSeasons,
|
||||
`/media-entities/${entityId}/episode-structure`,
|
||||
groupId ? { groupId } : undefined,
|
||||
signal,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** 切换作品的季集结构:成功后刷新剧集组、预览、规则、待处理与作品详情(季集都变了) */
|
||||
export function useSetEpisodeGroup(entityId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (groupId: string | null) =>
|
||||
parseOrThrow(
|
||||
EpisodeGroupSetResult,
|
||||
await api.put<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 ? " · 最新" : ""}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("集数映射文案", () => {
|
||||
absolute_in_season: "季内绝对集号",
|
||||
absolute_cross_season: "跨季绝对集号",
|
||||
air_date: "按播出日期",
|
||||
tmdb_season: "按 TMDB 默认季集换算",
|
||||
unverified: "元数据未覆盖,先按原号",
|
||||
unresolved: "待确认",
|
||||
skipped: "不适用",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const METHOD_LABEL: Record<EpisodeResolutionMethod, string> = {
|
||||
absolute_in_season: "季内绝对集号",
|
||||
absolute_cross_season: "跨季绝对集号",
|
||||
air_date: "按播出日期",
|
||||
tmdb_season: "按 TMDB 默认季集换算",
|
||||
unverified: "元数据未覆盖,先按原号",
|
||||
unresolved: "待确认",
|
||||
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 { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEpisodeGroups } from "~/api/episode-mapping.ts";
|
||||
import { useEntityFromCandidate } from "~/api/files.ts";
|
||||
import { downloaders, useSiteOptions } from "~/api/instances.ts";
|
||||
import { useEntityDetail, useLibraryRoots } from "~/api/library.ts";
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { AbsoluteNumberingHint } from "~/components/episode-mapping/subscription-summary.tsx";
|
||||
import { SubscriptionSeasonPicker } from "~/components/library/episode-structure.tsx";
|
||||
import { hasStructureChoice } from "~/components/library/episode-structure-model.ts";
|
||||
import { PRIORITY, SEASON_SCOPE, SUB_MODE } from "~/components/library/labels.ts";
|
||||
import { ChipToggleGroup } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -333,6 +336,9 @@ export function SubscriptionFormDialog({
|
||||
const isMovie = kind === "movie";
|
||||
|
||||
const detail = useEntityDetail(entityId && !isMovie ? entityId : undefined);
|
||||
// 作品有剧集组可选时,新建订阅用「季集结构轨道 + 季卡片」选季(学 MP);编辑时季不能改,仍是下拉
|
||||
const epGroups = useEpisodeGroups(entityId && !isMovie && !editing ? entityId : undefined);
|
||||
const seasonCards = !editing && !!entityId && hasStructureChoice(epGroups.data);
|
||||
const seasons = (detail.data?.contentUnits ?? [])
|
||||
.filter((u) => u.kind === "season" && u.seasonNumber !== null)
|
||||
.sort((a, b) => (a.seasonNumber ?? 0) - (b.seasonNumber ?? 0));
|
||||
@@ -537,27 +543,38 @@ export function SubscriptionFormDialog({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{!isMovie && seasonCards && entityId ? (
|
||||
<FormField label="季" hint="按作品当前的季集结构订阅;换剧集组等于改作品设置,会先确认">
|
||||
<SubscriptionSeasonPicker
|
||||
entityId={entityId}
|
||||
value={d.seasonUnitId}
|
||||
onChange={(v) => patch({ seasonUnitId: v })}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
{isMovie ? null : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="季"
|
||||
htmlFor={f("season")}
|
||||
hint={
|
||||
editing ? "季建好后不能改;要换季就新建订阅" : entityId ? undefined : "作品加入媒体库后可选季"
|
||||
}
|
||||
>
|
||||
<Pick
|
||||
id={f("season")}
|
||||
value={d.seasonUnitId}
|
||||
onChange={(v) => patch({ seasonUnitId: v })}
|
||||
placeholder="全部季"
|
||||
options={seasons.map((s) => ({
|
||||
value: s.id,
|
||||
label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
|
||||
}))}
|
||||
disabled={editing || !entityId}
|
||||
/>
|
||||
</FormField>
|
||||
{seasonCards ? null : (
|
||||
<FormField
|
||||
label="季"
|
||||
htmlFor={f("season")}
|
||||
hint={
|
||||
editing ? "季建好后不能改;要换季就新建订阅" : entityId ? undefined : "作品加入媒体库后可选季"
|
||||
}
|
||||
>
|
||||
<Pick
|
||||
id={f("season")}
|
||||
value={d.seasonUnitId}
|
||||
onChange={(v) => patch({ seasonUnitId: v })}
|
||||
placeholder="全部季"
|
||||
options={seasons.map((s) => ({
|
||||
value: s.id,
|
||||
label: s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`,
|
||||
}))}
|
||||
disabled={editing || !entityId}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="季范围" htmlFor={f("scope")}>
|
||||
<Pick
|
||||
id={f("scope")}
|
||||
|
||||
@@ -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 { EpisodeMappingPanel } from "~/components/episode-mapping/mapping-panel.tsx";
|
||||
import { evidenceLabel, FieldLockDialog, IdentityDialog, MergeDialog } from "~/components/library/entity-dialogs.tsx";
|
||||
import { EpisodeStructureSwitcher } from "~/components/library/episode-structure.tsx";
|
||||
import {
|
||||
CONTENT_TYPE,
|
||||
DOWNLOAD_STATUS,
|
||||
@@ -755,125 +756,127 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{rows.length > 1 ? (
|
||||
<PaginatedList
|
||||
rows={rows}
|
||||
as="fieldset"
|
||||
label="按收录版本看状态"
|
||||
resetKey={e.id}
|
||||
className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
|
||||
>
|
||||
{(r, i) => (
|
||||
<button
|
||||
key={r.slotId ?? "any"}
|
||||
type="button"
|
||||
aria-pressed={i === rowIdx}
|
||||
onClick={() => setRowIdx(i)}
|
||||
className={
|
||||
i === rowIdx
|
||||
? "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)}
|
||||
<EpisodeStructureSwitcher entityId={e.id}>
|
||||
{rows.length > 1 ? (
|
||||
<PaginatedList
|
||||
rows={rows}
|
||||
as="fieldset"
|
||||
label="按收录版本看状态"
|
||||
resetKey={e.id}
|
||||
className="m-0 mb-3 flex min-w-0 flex-wrap gap-1.5 border-0 p-0"
|
||||
>
|
||||
{(r, i) => (
|
||||
<button
|
||||
key={r.slotId ?? "any"}
|
||||
type="button"
|
||||
aria-pressed={i === rowIdx}
|
||||
onClick={() => setRowIdx(i)}
|
||||
className={
|
||||
i === rowIdx
|
||||
? "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"
|
||||
}
|
||||
>
|
||||
{eps.length === 0 ? (
|
||||
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
|
||||
) : (
|
||||
<PaginatedList
|
||||
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 ?? []} />
|
||||
{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 ? (
|
||||
<p className="text-meta text-muted-foreground">这一季还没有集信息。</p>
|
||||
) : (
|
||||
<PaginatedList
|
||||
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)} />
|
||||
</Panel>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user