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 { setWordsText } from "../support/identify-words.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 { 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 = {}, 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 setWordsText(h.db, show, "S01 @ABS >> S02 EP-24\n"); 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 } }, }); }); });