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:
1 parent
731336ccb1
commit
d55fa4b240
10 files changed
+637
-11
No files matched your search
@@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user