Files
UHD_Media_Tool/apps/server/test/file-manager/organize-episode-structure.test.ts
T
rootandClaude Opus 5.5 6e21bda481 fix(organize): 同时设为作品季集结构改到执行前检查全部通过后再切换
计划一致、Move 授权、做种义务检查失败时作品结构不变;切换后按作品新结构重算一遍,前后路径与预览不一致按 plan_stale。
补测试:Move 授权不通过 / 做种义务未满足时作品结构不变。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 16:42:22 +08:00

310 lines
13 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, EXEC_KEY, type FmHarness, key, pgAvailable, seedDownload, 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("勾「同时设为作品的季集结构」但 Move 授权不通过:不执行,作品结构不变", async () => {
const body = {
action: "move",
items: [
{
root: h.dlRoot,
path: "Split Cour S01E14 [2160p].mkv",
mediaEntityId: show,
targetDir: { root: h.libRoot, path: "alt" },
},
],
episodeStructures: [{ mediaEntityId: show, groupId: "grp-production", applyToEntity: true }],
};
const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
expect(dry.body.data.groups[0].error).toBeNull();
const exec = await h.api(
"POST",
"/files/organize",
{ ...body, idempotencyKey: key(), dryRun: false, planIds: [dry.body.data.groups[0].planId] },
EXEC_KEY,
);
expect(exec.status).toBe(403);
expect(exec.body.error.message).toMatch(/^approval_required:/);
expect(await entityGroup()).toBeNull();
expect(await unitAt(1, 14)).toBe(unitE14);
});
it("勾「同时设为作品的季集结构」但做种义务未满足、不能移动:该组不执行,作品结构不变", async () => {
const name = "Split Cour S01E14 [2160p].mkv";
const task = await seedDownload(h.db, h, { name, status: "completed" });
const body = {
action: "move",
items: [{ root: h.dlRoot, path: name, mediaEntityId: show, targetDir: { root: h.libRoot, path: "alt" } }],
episodeStructures: [{ mediaEntityId: show, groupId: "grp-production", applyToEntity: true }],
};
const dry = await h.api("POST", "/files/organize", { ...body, idempotencyKey: key(), dryRun: true });
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
expect(dry.body.data.groups[0].error).toBeNull();
const exec = await h.api("POST", "/files/organize", {
...body,
idempotencyKey: key(),
dryRun: false,
planIds: [dry.body.data.groups[0].planId],
});
expect(exec.status).toBe(200);
expect(exec.body.data.groups[0].error?.code).toBe("download_active");
expect(exec.body.data.recordIds).toEqual([]);
expect(await entityGroup()).toBeNull();
expect(await unitAt(1, 14)).toBe(unitE14);
await h.db.deleteFrom("download_task").where("id", "=", task).execute();
});
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 } },
});
});
});