Files
UHD_Media_Tool/apps/server/test/file-manager/organize-episode-structure.test.ts
T
rootandClaude Opus 5.5 d55fa4b240 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
2026-10-06 16:28:34 +08:00

257 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } },
});
});
});