feat(organize): 整理时指定季集结构(临时剧集组 / 同时设为作品结构)

- 契约:FilesOrganizeRequest.episodeStructures(按作品 groupId / applyToEntity),组响应带 episodeStructure
- 集数映射:createStructureEpisodeMapper 按指定结构的季表(剧集组带原季集对照)换算;作品规则先按作品结构命中,再经 TMDB 原季集换到指定结构
- 资产覆盖:指定结构季集 → TMDB 原季集 → 作品当前结构的单元,换不回不登记集覆盖;冻结计划记 episodeStructure 与 facts.structureEpisodes
- 向导执行勾选「同时设为作品的季集结构」时先走 setEpisodeGroup 切换作品再整理
- 测试:organize-episode-structure(SPLIT_COUR_FIXTURE)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 16:28:34 +08:00
1 parent 731336ccb1
commit d55fa4b240
10 files changed
+637 -11

No files matched your search

@@ -29,11 +29,14 @@ import {
import { organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping";
import {
createEntityEpisodeMapper,
createStructureEpisodeMapper,
type EntityEpisodeMapper,
type EpisodeSpan,
type FileEpisodeOptions,
publishedAtOf,
settleEpisodeDecision,
} from "../../sources-identity/service/episode-resolution.ts";
import type { Layout } from "../../sources-identity/service/episode-structure.ts";
import { errMessage, hashJson, jsonb, toApi } from "../util.ts";
import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
import { classifyEntity } from "./classification.ts";
@@ -46,7 +49,14 @@ import { successfulOrganizeCounts } from "./organize-history.ts";
import { planTransfer } from "./organize-transfer.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
import type { EpisodeFacts, FrozenPlan, ImportRequest, PlanResult } from "./organize-types.ts";
import type {
EpisodeFacts,
FrozenEpisodeStructure,
FrozenPlan,
ImportRequest,
OrganizeEpisodeStructure,
PlanResult,
} from "./organize-types.ts";
import { type FrozenReplacement, freezeReplacement } from "./replacement.ts";
import { hnrSatisfied } from "./retention.ts";
import { subscriptionLibraryRoot } from "./subscriptions.ts";
@@ -636,7 +646,12 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const seasons = new Set<number>();
// 集数映射(批次 3):文件名解析出的是发布者声称的季集,按作品季表 + 规则换成规范季集再定季目录、命名与单元;
// 用户逐文件指定的季 / 集优先,不再映射。结果以精确路径覆写交给 planOrganize(待确认 / special 规则的文件不入库)
const episodes = mediaType === "tv" ? await planFileEpisodes(d, entity, rel, dt) : null;
// 整理时指定了季集结构(向导):与作品不同的结构只这次用,季集按它换算与命名,单元覆盖换回作品结构(见 planFileEpisodes)
const structure = mediaType === "tv" ? frozenStructure(entity, req.episodeStructure) : undefined;
const episodes =
mediaType === "tv"
? await planFileEpisodes(d, entity, rel, dt, structure?.temporary ? req.episodeStructure!.layout : null)
: null;
const mappedOverrides: Record<string, DomainFileOverride> = {};
const unresolvedEpisodes: NonNullable<FrozenPlan["unresolvedEpisodes"]> = {};
// 文件级标题粗核:混合包 / 合集里标题明显不是本作品的视频不按本作品命名入库(待确认 title_mismatch);
@@ -683,16 +698,26 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
}
const season = parsed.season ?? (parsed.episodes || parsed.airDate ? 1 : undefined);
if (mediaType === "tv" && season !== undefined) seasons.add(season);
// 临时结构:命名 / 季目录按指定结构的季集;资产覆盖的单元换回作品当前结构(换不回就不登记集覆盖)
const named: EpisodeSpan | null =
episodes?.toEntity && season !== undefined && parsed.episodes ? { season, episodes: parsed.episodes } : null;
const unit = named ? episodes!.toEntity!(named) : undefined;
const unitParsed =
unit === undefined ? parsed : unit ? { ...parsed, season: unit.season, episodes: unit.episodes } : null;
const unitSeason = unit === undefined ? season : unit?.season;
facts[f.path] = {
quality: qualityOf(parsed),
unitKeys: unitKeysOf({
...parsed,
...(season !== undefined ? { season } : {}),
}),
...(season !== undefined ? { season } : {}),
...(parsed.episodes ? { episodes: parsed.episodes } : {}),
unitKeys: unitParsed
? unitKeysOf({
...unitParsed,
...(unitSeason !== undefined ? { season: unitSeason } : {}),
})
: [],
...(unitSeason !== undefined ? { season: unitSeason } : {}),
...(unitParsed?.episodes ? { episodes: unitParsed.episodes } : {}),
...(ov?.contentUnitIds?.length ? { unitIds: ov.contentUnitIds } : {}),
...(resolution && resolution.method !== "skipped" ? { episodeResolution: episodeFacts(resolution) } : {}),
...(named ? { structureEpisodes: named } : {}),
};
}
const overrides =
@@ -924,6 +949,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
sourceRoot,
facts,
...(Object.keys(unresolvedEpisodes).length ? { unresolvedEpisodes } : {}),
...(structure ? { episodeStructure: structure } : {}),
...(downgradedFromMove ? { downgradedFromMove } : {}),
...(req.slotId ? { slotId: req.slotId } : {}),
naming: {
@@ -1073,8 +1099,9 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
return { recordId: rec.id, status: rec.status, reason };
});
// 文件集数待确认:并入关联资源的 episode_mapping 待处理(待处理按资源归集;没有关联资源只记整理失败原因)
// 临时结构下的待确认是相对指定结构的,不并进作品的集数待确认
const pending = episodes?.unresolved[0];
if (pending && episodes?.release) {
if (pending && episodes?.release && !episodes.toEntity) {
await settleEpisodeDecision(d.ctx, db, {
sourceReleaseId: episodes.release.id,
entityId: entity.id,
@@ -1088,6 +1115,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
interface FileEpisodes {
map: EntityEpisodeMapper;
opts: FileEpisodeOptions;
/** 临时季集结构:指定结构的季集 → 作品当前结构的单元季集;跟随作品时没有 */
toEntity?: (span: EpisodeSpan) => EpisodeSpan | null;
/** 下载关联的资源(已绑定本作品) */
release: SourceReleaseRow | null;
unresolved: { path: string; resolution: EpisodeResolution; parsed: ParsedRelease }[];
@@ -1102,6 +1131,8 @@ async function planFileEpisodes(
entity: MediaEntityRow,
rel: SourceReleaseRow | undefined,
dt: DownloadTaskRow | undefined,
/** 临时季集结构(与作品当前结构不同) */
layout: Layout | null,
): Promise<FileEpisodes> {
const db = d.ctx.db;
let linked = rel;
@@ -1114,8 +1145,19 @@ async function planFileEpisodes(
.where("p.id", "=", planId)
.executeTakeFirst();
}
const map = await createEntityEpisodeMapper(db, entity.id, entity.kind);
const own = linked && linked.media_entity_id === entity.id ? linked : null;
if (layout) {
// 资源的集数解析是按作品结构做的,临时结构下不沿用,只取发布时间
const s = await createStructureEpisodeMapper(db, entity.id, entity.kind, layout);
return {
map: s.map,
toEntity: s.toEntity,
opts: linked ? { publishedAt: publishedAtOf(linked) } : {},
release: own,
unresolved: [],
};
}
const map = await createEntityEpisodeMapper(db, entity.id, entity.kind);
const res = own?.episode_resolution as EpisodeResolution | null | undefined;
return {
map,
@@ -1128,6 +1170,23 @@ async function planFileEpisodes(
};
}
/** 冻结进计划的季集结构;请求没指定时为 undefined(跟随作品,计划与以前一致) */
function frozenStructure(
entity: MediaEntityRow,
s: OrganizeEpisodeStructure | undefined,
): FrozenEpisodeStructure | undefined {
if (!s) return undefined;
const entityGroupId = entity.episode_group_id ?? null;
const temporary = !!s.layout && s.groupId !== entityGroupId;
return {
groupId: s.groupId,
name: temporary ? s.layout!.name : s.groupId ? (entity.episode_group_name ?? s.groupId) : "TMDB 默认季",
entityGroupId,
temporary,
...(s.appliedToEntity ? { appliedToEntity: true } : {}),
};
}
const sameRange = (a: { start: number; end: number }, b: { start: number; end: number } | undefined) =>
!!b && a.start === b.start && a.end === b.end;
@@ -1,5 +1,6 @@
import type { ActorType, OrganizationRecord, OrganizationTrigger } from "@umt/contracts";
import type { EpisodeResolution, ExistingPolicy, NewBinding, OrganizePlan, QualityFacts } from "@umt/domain";
import type { Layout } from "../../sources-identity/service/episode-structure.ts";
import type { FrozenTransfer, TransferSpec } from "./organize-transfer.ts";
import type { FrozenReplacement } from "./replacement.ts";
@@ -52,6 +53,28 @@ export interface ImportRequest {
namingMode?: NamingMode | undefined;
/** W9-H 调用方声明的命名模板来源(本次覆写等);缺省按模板层级推断,冻结进 plan.naming */
namingSource?: NamingSourceTag | undefined;
/** 整理时指定的季集结构(向导「季集结构」);缺省跟随作品 */
episodeStructure?: OrganizeEpisodeStructure | undefined;
}
/** 整理时指定的季集结构:groupId null = TMDB 默认季 */
export interface OrganizeEpisodeStructure {
groupId: string | null;
/** 与作品当前结构不同时的目标结构(调用方按 TMDB 取);null = 就是作品当前结构,按作品整理 */
layout: Layout | null;
/** 整理前已把它设为作品的季集结构(向导勾「同时设为作品的季集结构」) */
appliedToEntity?: boolean | undefined;
}
/** 冻结进计划的季集结构(排查用):这次按哪套结构换算季集与命名 */
export interface FrozenEpisodeStructure {
groupId: string | null;
name: string;
/** 计划时作品的季集结构 */
entityGroupId: string | null;
/** 与作品结构不同、只这次使用(资产覆盖换回作品结构的单元) */
temporary: boolean;
appliedToEntity?: boolean;
}
export type NamingMode = "template" | "keep";
@@ -112,10 +135,17 @@ export interface FrozenPlan {
unitIds?: string[];
/** 按作品做的集数映射(方式 + 文件名原始季集);人工指定季集或非剧集时没有 */
episodeResolution?: EpisodeFacts;
/**
* 临时季集结构下命名用的季集(指定结构的);此时 season / episodes 是换回作品当前结构的单元季集。
* 换不回(作品结构里没有这几集)时 season / episodes 不给,不登记集覆盖
*/
structureEpisodes?: { season: number; episodes: { start: number; end: number } };
}
>;
/** 集数待确认 / 映射规则标为特别篇 · 花絮 · 忽略、因而不入库的视频:源路径 → 集数映射摘要 */
unresolvedEpisodes?: Record<string, EpisodeFacts>;
/** 整理时指定了季集结构(向导);缺省跟随作品时没有 */
episodeStructure?: FrozenEpisodeStructure;
downgradedFromMove?: string;
/** W5-A2:手动整理指定的 Slot */
slotId?: string;
@@ -7,6 +7,7 @@ import type { LibraryRootRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import type { AoDeps, Exec } from "../acquisition-organize/services/common.ts";
import { loadInstances } from "../acquisition-organize/services/common.ts";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import type { ServerContext } from "../types.ts";
// 文件管理模块共用:依赖、错误、根定义、路径守卫、计划/幂等/审计小工具。
@@ -45,6 +46,8 @@ export interface FmDeps {
options: FmOptions;
/** W9-H(G1):TMDB 客户端(按 TMDB 候选建作品);未配置返回 null */
tmdb: () => Promise<TmdbClient | null>;
/** sources-identity 运行时(与 tmdb 同一实例):整理时指定季集结构 / 切换作品季集结构 */
sources: () => SourcesRuntime;
}
const envNum = (k: string, dflt: number) => {
@@ -42,6 +42,10 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu
sources ??= new SourcesRuntime(ctx, overrides.tmdb ? { tmdb: overrides.tmdb } : {});
return sources.tmdb();
},
sources: () => {
sources ??= new SourcesRuntime(ctx, overrides.tmdb ? { tmdb: overrides.tmdb } : {});
return sources;
},
};
}
return deps;
@@ -3,6 +3,7 @@ import { stat } from "node:fs/promises";
import { join, relative } from "node:path";
import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
import { type FileOverrideInput, type FrozenPlan, planImport } from "../acquisition-organize/services/organize.ts";
import {
@@ -13,6 +14,8 @@ import {
requestKey,
sourceGuard,
} from "../acquisition-organize/services/organize-guard.ts";
import { organizeStructure, setEpisodeGroup } from "../sources-identity/service/episode-groups.ts";
import type { Layout } from "../sources-identity/service/episode-structure.ts";
import { entityFromCandidate, externalKey } from "./candidate.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resolveIn } from "./common.ts";
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
@@ -164,6 +167,24 @@ function episodeRange(eps: number[]): { start: number; end: number } | null {
const BUSY_SLOT = new Set(["reserved", "downloading", "verifying"]);
/** 整理时指定的季集结构(按作品):layout = 与作品当前结构不同的目标结构;switched = 已按勾选切换了作品结构 */
interface StructureChoice {
groupId: string | null;
apply: boolean;
layout: Layout | null;
switched: boolean;
}
/** 取 TMDB / 校验季集结构时的错误 → 文件管理错误 */
function structureError(e: unknown): never {
if (e instanceof ApiException)
throw new FilesError(
e.code === "upstream_unavailable" ? "upstream" : e.code === "not_found" ? "not_found" : "bad_request",
e.message,
);
throw e;
}
export async function organizeFiles(
d: FmDeps,
principal: Principal | undefined,
@@ -234,6 +255,20 @@ export async function organizeFiles(
).map((u) => [u.id, u])
: [],
);
// 季集结构(向导「季集结构」):只对本次涉及的本地剧集作品;与作品当前结构相同等同跟随作品
const structures = new Map<string, StructureChoice>();
for (const x of body.episodeStructures ?? []) {
if (structures.has(x.mediaEntityId) || !entityIds.includes(x.mediaEntityId)) continue;
const kind = entities.get(x.mediaEntityId);
if (kind !== "series" && kind !== "variety") continue;
const r = await organizeStructure(d.sources(), x.mediaEntityId, x.groupId).catch(structureError);
structures.set(x.mediaEntityId, {
groupId: x.groupId,
apply: x.applyToEntity === true && !!r.layout,
layout: r.layout,
switched: false,
});
}
const slotIds = [...new Set(body.items.map((i) => i.slotId).filter((x): x is string => !!x))];
const slots = new Map(
slotIds.length
@@ -383,6 +418,7 @@ export async function organizeFiles(
for (const g of groups.values()) {
const req = reqOf(g);
const hasOverrides = Object.keys(g.overrides).length > 0;
const st = structures.get(g.entity);
const reqHash = hashJson({
key: requestKey(req),
...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}),
@@ -395,6 +431,7 @@ export async function organizeFiles(
...(namingMode !== "template" ? { naming: namingMode } : {}),
...(body.skipSuccessfulRecords ? { skipSuccessfulRecords: true } : {}),
...(body.scrapeAfterImport !== undefined ? { scrapeAfterImport: body.scrapeAfterImport } : {}),
...(st ? { episodeStructure: { groupId: st.groupId, apply: st.apply } } : {}),
});
const ext = fromExternal.get(g.entity);
const base = {
@@ -423,6 +460,16 @@ export async function organizeFiles(
namingMode,
skipSuccessfulRecords: body.skipSuccessfulRecords,
scrapeAfterImport: body.scrapeAfterImport,
// 勾了「同时设为作品的季集结构」:执行前已切换作品,之后按作品(新结构)整理;预览按临时结构算,命名相同
...(st
? {
episodeStructure: {
groupId: st.groupId,
layout: st.switched ? null : st.layout,
...(st.switched ? { appliedToEntity: true } : {}),
},
}
: {}),
trigger: "manual" as const,
actorType: actorType as "api_key" | "user",
actorId: actor,
@@ -455,6 +502,7 @@ export async function organizeFiles(
...planExtras(plan.plan, !!g.targetDir),
planId: saved.id,
files: filesOf(plan.plan),
...structureOf(plan.plan, st),
truncated: plan.truncated === true,
recordId: null,
taskId: null,
@@ -469,6 +517,14 @@ export async function organizeFiles(
const now = await factsOf(frozen.facts.map((f) => f.path));
const changed = now.find((f, i) => f.size !== frozen.facts[i]!.size || f.mtimeMs !== frozen.facts[i]!.mtimeMs);
if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化:${changed.path}`);
// 同时设为作品的季集结构:先走作品级切换(单元迁移 + 重算 + 刷新),同一作品的多组只切一次
if (st?.apply && !st.switched) {
await setEpisodeGroup(d.sources(), g.entity, st.groupId, {
type: principal?.kind === "api_key" ? "api_key" : "user",
id: principal?.id ?? null,
}).catch(structureError);
st.switched = true;
}
const again = await planImport(d.ao, importReq(true));
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
@@ -534,6 +590,7 @@ export async function organizeFiles(
planId,
// 执行时 planImport 不回传计划体;冻结计划与刚重算的 dryRun 一致(上面已校验),直接回显
files: filesOf(again.plan),
...structureOf(again.plan, st),
truncated: false,
recordId: plan.recordId,
taskId,
@@ -607,6 +664,20 @@ function planExtras(plan: FrozenPlan | undefined, overrideDir: boolean) {
};
}
/** 组按哪套季集结构整理(请求指定了才有) */
function structureOf(plan: FrozenPlan | undefined, st: StructureChoice | undefined) {
const s = plan?.episodeStructure;
if (!s) return {};
return {
episodeStructure: {
groupId: s.groupId,
name: s.name,
temporary: s.temporary,
...(s.appliedToEntity || st?.apply ? { appliedToEntity: true } : {}),
},
};
}
/** 执行请求携带全部组的 planId:找与本组请求一致的那个 */
async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise<string> {
if (planIds.length === 0) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planIds");
@@ -249,6 +249,36 @@ export async function episodeStructureSeasons(
return { groupId, name: target.layout.name, current: isCurrent, seasons };
}
/**
* 整理时临时指定的季集结构(docs/runbook/organize.md「整理时指定剧集组」):返回作品当前选择与目标结构;
* 目标就是作品当前结构时 layout 为 null(按作品整理,不必取 TMDB)。
*/
export async function organizeStructure(
rt: SourcesRuntime,
entityId: string,
groupId: string | null,
): Promise<{ current: { id: string; name: string } | null; layout: Layout | null }> {
const cur = await entityEpisodeGroup(rt.ctx.db, entityId);
const current = cur ? { id: cur.id, name: cur.name } : null;
if ((cur?.id ?? null) === groupId) return { current, layout: null };
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 未配置,不能指定季集结构",
);
}
try {
return { current, layout: (await layoutOf(tv, groupId)).layout };
} catch (e) {
return tmdbFailure(e);
}
}
/**
* 切换作品的季集结构(TMDB 默认季 ⇄ 剧集组 / 剧集组之间):
* 事务内按 TMDB 原季集原地迁移集单元、重建季表与总集数、记下选择,按作品全量重算已绑定资源;
@@ -24,7 +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";
import { type Layout, seasonOrigins } from "./episode-structure.ts";
// 集数解析接入(docs/plan/episode-resolution.md 批次 2):识别绑定作品后、建集单元前,按作品季表 + 映射规则把
// 「发布者声称的季集」换成「媒体库应当使用的季集」。判定本身在 domain resolveEpisodes,这里只装配数据与落库:
@@ -190,6 +190,137 @@ export async function createEntityEpisodeMapper(
};
}
/** 季集区间(含两端) */
export interface EpisodeSpan {
season: number;
episodes: { start: number; end: number };
}
/** 按指定季集结构整理时的文件映射器(整理时临时选了与作品不同的结构) */
export interface StructureEpisodeMapper {
/** 映射结果的季集是指定结构的(命名、季目录按它) */
map: EntityEpisodeMapper;
/**
* 指定结构的季集 → 作品当前结构的单元季集(资产覆盖挂在作品单元上):逐集求 TMDB 原季集,再按作品当前结构换算;
* 区间里有一集对不上、或换算后不在同一季 / 不连续时为 null(不登记覆盖)
*/
toEntity(span: EpisodeSpan): EpisodeSpan | null;
}
/**
* 整理时临时指定季集结构(docs/runbook/organize.md「整理时指定剧集组」):不改作品设置,只这次按 target 换算季集与命名。
* - 季表:target 的季与集数(剧集组另带每集播出日期与原季集对照 origins,发布者按 TMDB 默认季编号时换到组内季集)
* - 规则:作品的映射规则是按作品当前结构写的。先按作品季表 + 规则跑一遍,命中规则(含特别篇 / 忽略)时用规则的结果,
* 季集再经 TMDB 原季集换到 target;没命中规则时按 target 季表单独判定(不带规则)
* - 单元:toEntity 把 target 季集换回作品当前结构(作品是剧集组时按单元上记的原季集对照,默认季时原季集即单元季集)
*/
export async function createStructureEpisodeMapper(
exec: Exec,
entityId: string,
kind: MediaKind,
target: Layout,
): Promise<StructureEpisodeMapper> {
const current = await createEntityEpisodeMapper(exec, entityId, kind);
const entity = await exec
.selectFrom("media_entity")
.select(["metadata", "episode_group_id"])
.where("id", "=", entityId)
.executeTakeFirst();
const airs = new Map<number, { episode: number; airDate: string | null }[]>();
for (const e of target.episodes ?? []) airs.set(e.season, [...(airs.get(e.season) ?? []), e]);
const seasons: SeasonInfo[] = target.seasons
.filter((s) => s.seasonNumber >= 0)
.map((s) => ({
season: s.seasonNumber,
total: s.episodeCount > 0 ? s.episodeCount : null,
episodes: (airs.get(s.seasonNumber) ?? [])
.filter((e) => e.airDate)
.map((e) => ({ episode: e.episode, airDate: e.airDate }))
.sort((a, b) => a.episode - b.episode),
}))
.sort((a, b) => a.season - b.season);
const main = seasons.filter((s) => s.season >= 1).map((s) => s.season);
const origins = target.groupId
? (target.episodes ?? []).map((e) => ({
tmdbSeason: e.tmdbSeason,
tmdbEpisode: e.tmdbEpisode,
season: e.season,
episode: e.episode,
}))
: [];
const table: SeasonTable = {
seasons,
latestSeason: main.length ? Math.max(...main) : null,
ended: ENDED_STATUSES.has(airStatusOf(entity?.metadata).toLowerCase()),
...(origins.length ? { origins } : {}),
};
// 作品当前结构:默认季 = 原季集即单元季集;剧集组 = 单元上记的原季集对照
const grouped = !!entity?.episode_group_id;
const unitByOrigin = new Map<string, { season: number; episode: number }>();
const originByUnit = new Map<string, { season: number; episode: number }>();
if (grouped)
for (const o of await seasonOrigins(exec, entityId)) {
unitByOrigin.set(`${o.tmdbSeason}:${o.tmdbEpisode}`, { season: o.season, episode: o.episode });
originByUnit.set(`${o.season}:${o.episode}`, { season: o.tmdbSeason, episode: o.tmdbEpisode });
}
const currentToOrigin = (s: number, e: number) =>
grouped ? (originByUnit.get(`${s}:${e}`) ?? null) : { season: s, episode: e };
const originToCurrent = (s: number, e: number) =>
grouped ? (unitByOrigin.get(`${s}:${e}`) ?? null) : { season: s, episode: e };
const tag = `structure:${target.groupId ?? "default"}`;
/** 逐集换算并要求落在同一季、连续 */
const convert = (
span: EpisodeSpan,
step: (s: number, e: number) => { season: number; episode: number } | null,
): EpisodeSpan | null => {
const { start, end } = span.episodes;
if (end < start || end - start > 200) return null;
const hits: { season: number; episode: number }[] = [];
for (let e = start; e <= end; e++) {
const h = step(span.season, e);
if (!h) return null;
hits.push(h);
}
const first = hits[0]!;
if (hits.some((h, i) => h.season !== first.season || h.episode !== first.episode + i)) return null;
return { season: first.season, episodes: { start: first.episode, end: hits.at(-1)!.episode } };
};
const toTarget = (span: EpisodeSpan) =>
convert(span, (s, e) => {
const o = currentToOrigin(s, e);
return o ? target.fromOrigin(o.season, o.episode) : null;
});
const toEntity = (span: EpisodeSpan) =>
convert(span, (s, e) => {
const o = target.toOrigin(s, e);
return o ? originToCurrent(o.season, o.episode) : null;
});
const map: EntityEpisodeMapper = (parsed, opts = {}) => {
const p = opts.publishedAt;
const publishedAt = p instanceof Date ? p.toISOString() : (p ?? null);
const byRule = current(parsed, { publishedAt }).resolution;
if (byRule.method === "rule" || byRule.ruleFailed) {
if (!byRule.canonical) return { resolution: { ...byRule, evidence: [...byRule.evidence, tag] }, parsed };
const moved = toTarget(byRule.canonical);
const resolution: EpisodeResolution = moved
? { ...byRule, canonical: moved, evidence: [...byRule.evidence, tag] }
: {
source: byRule.source,
method: "unresolved",
canonical: null,
evidence: [...byRule.evidence, `${tag}:not_in_target`],
};
return { resolution, parsed: canonicalParsed(parsed, resolution) };
}
const r = resolveEpisodes({ parsed, kind, table, rules: [], publishedAt });
const resolution = { ...r, evidence: [...r.evidence, tag] };
return { resolution, parsed: canonicalParsed(parsed, resolution) };
};
return { map, toEntity };
}
/** 资源的原始季集与文件解析结果相同(季号缺省都按未给出比较,集区间与播出日期逐项相等) */
function sameSource(src: EpisodeResolution["source"], p: ParsedRelease): boolean {
if (!src.episodes && !src.airDate) return false;
@@ -34,10 +34,20 @@ export interface Layout {
groupId: string | null;
name: string;
seasons: StructureSeason[];
/** 剧集组的每集(组内季集 + 播出日期 + TMDB 原季集);默认季不给 */
episodes?: LayoutEpisode[];
toOrigin(season: number, episode: number): Coord | null;
fromOrigin(season: number, episode: number): Coord | null;
}
export interface LayoutEpisode {
season: number;
episode: number;
airDate: string | null;
tmdbSeason: number;
tmdbEpisode: number;
}
const key = (s: number, e: number) => `${s}:${e}`;
export function defaultLayout(seasons: readonly StructureSeason[]): Layout {
@@ -69,6 +79,15 @@ export function groupLayout(detail: TmdbEpisodeGroupDetail): Layout {
episodeCount: s.episodes.length,
airDate: s.airDate,
})),
episodes: detail.seasons.flatMap((s) =>
s.episodes.map((e) => ({
season: e.seasonNumber,
episode: e.episodeNumber,
airDate: e.airDate,
tmdbSeason: e.tmdbSeason,
tmdbEpisode: e.tmdbEpisode,
})),
),
toOrigin: (s, e) => toO.get(key(s, e)) ?? null,
fromOrigin: (s, e) => fromO.get(key(s, e)) ?? null,
};
@@ -0,0 +1,256 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTmdbClient } from "@umt/adapters";
import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts";
// 整理时指定季集结构(docs/runbook/organize.md「整理时指定剧集组」):作品是 TMDB 默认季(S1 = 25 集),
// 剧集组「制作季」拆成 S1(1–12)、S2(13–25)。临时按组整理 S01E14 → 命名 S02E02,资产覆盖仍挂作品的 S01E14 单元;
// 勾「同时设为作品的季集结构」→ 先切换作品(单元原地迁移),再按新结构整理;缺省跟随作品时行为不变。
const skip = !(await pgAvailable());
describe.skipIf(skip)("整理时指定季集结构", () => {
let h: FmHarness;
let tmdb: MockTmdb;
let show: string;
let unitE14: string;
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SPLIT_COUR_FIXTURE] });
h = await createFmHarness("organize_episode_structure", {
tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }),
});
show = await seedEntity(h.db, { kind: "series", title: "Split Cour", year: 2024 });
await h.db
.insertInto("external_identity")
.values({
media_entity_id: show,
provider: "tmdb",
entity_type: "tv",
external_id: String(SPLIT_COUR_FIXTURE.id),
status: "accepted",
})
.execute();
expect((await refreshMetadata(h.module.deps().sources(), show, { full: true })).status).toBe("refreshed");
unitE14 = await unitAt(1, 14);
for (const name of [
"Split Cour S01E13 [1080p].mkv",
"Split Cour S01E14 [1080p].mkv",
"Split Cour S01E14 [2160p].mkv",
])
await writeFile(join(h.dl, name), name.padEnd(64, "x"));
await mkdir(join(h.lib, "alt"), { recursive: true });
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
async function unitAt(season: number, episode: number): Promise<string> {
return (
await h.db
.selectFrom("content_unit")
.select("id")
.where("media_entity_id", "=", show)
.where("kind", "=", "episode")
.where("season_number", "=", season)
.where("episode_number", "=", episode)
.where("status", "<>", "retired")
.executeTakeFirstOrThrow()
).id;
}
const entityGroup = async () =>
(await h.db.selectFrom("media_entity").select("episode_group_id").where("id", "=", show).executeTakeFirstOrThrow())
.episode_group_id;
const coverage = async (recordId: string) =>
h.db
.selectFrom("organization_file_result as f")
.innerJoin("asset_unit_coverage as c", "c.media_asset_id", "f.media_asset_id")
.innerJoin("content_unit as u", "u.id", "c.content_unit_id")
.select(["u.id", "u.season_number", "u.episode_number"])
.where("f.organization_record_id", "=", recordId)
.execute();
const frozen = async (recordId: string) =>
(await h.db.selectFrom("organization_record").select("plan").where("id", "=", recordId).executeTakeFirstOrThrow())
.plan as unknown as FrozenPlan;
async function organize(path: string, extra: Record<string, unknown> = {}, targetDir?: string) {
const body = {
action: "copy",
items: [
{
root: h.dlRoot,
path,
mediaEntityId: show,
...(targetDir ? { targetDir: { root: h.libRoot, path: targetDir } } : {}),
},
],
...extra,
};
const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true });
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
const preview = dry.body.data.groups[0];
expect(preview.error, JSON.stringify(preview)).toBeNull();
const groupAfterPreview = await entityGroup();
const exec = await h.api("POST", "/files/organize", {
...body,
idempotencyKey: key(),
dryRun: false,
planIds: [preview.planId],
});
expect(exec.status, JSON.stringify(exec.body)).toBe(200);
expect(exec.body.data.groups[0].error, JSON.stringify(exec.body.data.groups[0])).toBeNull();
const recordId = exec.body.data.recordIds[0] as string;
expect((await executeRecord(h.module.deps().ao, recordId)).status).toBe("succeeded");
return { preview, groupAfterPreview, exec: exec.body.data.groups[0], recordId };
}
it("缺省跟随作品:按作品默认季命名与登记,计划里不记季集结构(回归)", async () => {
const r = await organize("Split Cour S01E13 [1080p].mkv");
expect(r.preview.files[0].target).toMatch(/Season 1\/.*S01E13/);
expect(r.preview.episodeStructure).toBeUndefined();
expect(await coverage(r.recordId)).toEqual([expect.objectContaining({ season_number: 1, episode_number: 13 })]);
expect((await frozen(r.recordId)).episodeStructure).toBeUndefined();
});
it("指定与作品相同的结构等同跟随作品", async () => {
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [{ root: h.dlRoot, path: "Split Cour S01E14 [1080p].mkv", mediaEntityId: show }],
episodeStructures: [{ mediaEntityId: show, groupId: null }],
});
expect(dry.status).toBe(200);
const g = dry.body.data.groups[0];
expect(g.files[0].target).toMatch(/Season 1\/.*S01E14/);
expect(g.episodeStructure).toEqual({ groupId: null, name: "TMDB 默认季", temporary: false });
});
it("临时按剧集组整理:S01E14 命名为 S02E02,作品设置不变,资产覆盖挂作品的 S01E14 单元", async () => {
const r = await organize("Split Cour S01E14 [1080p].mkv", {
episodeStructures: [{ mediaEntityId: show, groupId: "grp-production" }],
});
expect(r.preview.files[0].target).toMatch(/Season 2\/.*S02E02/);
expect(r.preview.episodeStructure).toEqual({ groupId: "grp-production", name: "制作季", temporary: true });
expect(await entityGroup()).toBeNull();
expect(await coverage(r.recordId)).toEqual([{ id: unitE14, season_number: 1, episode_number: 14 }]);
const plan = await frozen(r.recordId);
expect(plan.episodeStructure).toEqual({
groupId: "grp-production",
name: "制作季",
entityGroupId: null,
temporary: true,
});
const fx = Object.values(plan.facts)[0]!;
expect(fx).toMatchObject({
season: 1,
episodes: { start: 14, end: 14 },
structureEpisodes: { season: 2, episodes: { start: 2, end: 2 } },
});
expect(fx.episodeResolution?.evidence).toContain("structure:grp-production");
});
it("勾「同时设为作品的季集结构」:预览不切换,执行时先切换作品、单元原地迁移,命名 S02E02,覆盖挂迁移后的单元", async () => {
const r = await organize(
"Split Cour S01E14 [2160p].mkv",
{ episodeStructures: [{ mediaEntityId: show, groupId: "grp-production", applyToEntity: true }] },
"alt",
);
expect(r.groupAfterPreview).toBeNull();
expect(r.preview.episodeStructure).toEqual({
groupId: "grp-production",
name: "制作季",
temporary: true,
appliedToEntity: true,
});
expect(r.preview.files[0].target).toMatch(/alt\/.*S02E02/);
expect(r.exec.files[0].target).toMatch(/alt\/.*S02E02/);
expect(await entityGroup()).toBe("grp-production");
// 单元 id 不变,原地迁到组的 S2E2
expect(await unitAt(2, 2)).toBe(unitE14);
expect(await coverage(r.recordId)).toEqual([{ id: unitE14, season_number: 2, episode_number: 2 }]);
expect((await frozen(r.recordId)).episodeStructure).toEqual({
groupId: "grp-production",
name: "制作季",
entityGroupId: "grp-production",
temporary: false,
appliedToEntity: true,
});
});
it("作品是剧集组时临时按默认季:S01E13 命名 S01E13,覆盖换回组的 S02E01 单元", async () => {
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [
{
root: h.dlRoot,
path: "Split Cour S01E13 [1080p].mkv",
mediaEntityId: show,
targetDir: { root: h.libRoot, path: "alt" },
},
],
episodeStructures: [{ mediaEntityId: show, groupId: null }],
});
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
const g = dry.body.data.groups[0];
expect(g.files[0].target).toMatch(/alt\/.*S01E13/);
expect(g.episodeStructure).toEqual({ groupId: null, name: "TMDB 默认季", temporary: true });
const art = await h.db
.selectFrom("artifact")
.select("content")
.where("id", "=", g.planId)
.executeTakeFirstOrThrow();
const plan = (art.content as { plan: FrozenPlan }).plan;
expect(Object.values(plan.facts)[0]).toMatchObject({
season: 2,
episodes: { start: 1, end: 1 },
structureEpisodes: { season: 1, episodes: { start: 13, end: 13 } },
});
});
it("作品的映射规则按作品结构写:临时结构下规则结果经 TMDB 原季集换到指定结构", async () => {
// 作品现在是「制作季」:ABS 组按 EP-24 换到组的第 2 季(- 26 → 组 S02E02 = 默认 S01E14)
await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: show,
season: 1,
release_group: "ABS",
kind: "expression",
expression: "EP-24",
target_season: 2,
created_by_type: "user",
})
.execute();
await writeFile(join(h.dl, "[ABS] Split Cour - 26 [1080p].mkv"), "abs".padEnd(64, "x"));
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [{ root: h.dlRoot, path: "[ABS] Split Cour - 26 [1080p].mkv", mediaEntityId: show }],
episodeStructures: [{ mediaEntityId: show, groupId: null }],
});
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
const g = dry.body.data.groups[0];
expect(g.files[0].target).toMatch(/Season 1\/.*S01E14/);
const art = await h.db
.selectFrom("artifact")
.select("content")
.where("id", "=", g.planId)
.executeTakeFirstOrThrow();
const fx = Object.values((art.content as { plan: FrozenPlan }).plan.facts)[0]!;
expect(fx).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } });
expect(fx.episodeResolution).toMatchObject({
method: "rule",
canonical: { season: 1, episodes: { start: 14, end: 14 } },
});
});
});