From 6e21bda481fa8b63c92a9c26af25da3a77061a9c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 16:42:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(organize):=20=E5=90=8C=E6=97=B6=E8=AE=BE?= =?UTF-8?q?=E4=B8=BA=E4=BD=9C=E5=93=81=E5=AD=A3=E9=9B=86=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E6=94=B9=E5=88=B0=E6=89=A7=E8=A1=8C=E5=89=8D=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E9=80=9A=E8=BF=87=E5=90=8E=E5=86=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 计划一致、Move 授权、做种义务检查失败时作品结构不变;切换后按作品新结构重算一遍,前后路径与预览不一致按 plan_stale。 补测试:Move 授权不通过 / 做种义务未满足时作品结构不变。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../src/modules/file-manager/organize.ts | 20 ++++--- .../organize-episode-structure.test.ts | 55 ++++++++++++++++++- docs/runbook/organize.md | 2 +- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/apps/server/src/modules/file-manager/organize.ts b/apps/server/src/modules/file-manager/organize.ts index 09da6654..2d17a25c 100644 --- a/apps/server/src/modules/file-manager/organize.ts +++ b/apps/server/src/modules/file-manager/organize.ts @@ -517,14 +517,6 @@ 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)) @@ -561,6 +553,18 @@ export async function organizeFiles( `下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`, ); } + // 同时设为作品的季集结构:所有执行前检查(计划一致、Move 授权、H&R)通过后才切换作品 + // (单元迁移 + 重算 + 刷新),同一作品的多组只切一次;切换后按作品(新结构)再算一遍,前后路径须与预览一致 + 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 after = pairsOf(await planImport(d.ao, importReq(true))).map(({ src, dst }) => ({ src, dst })); + if (hashJson(after) !== hashJson(frozen.pairs)) + throw new FilesError("plan_stale", "切换作品季集结构后目标路径与预览不一致。重新预览"); + } await retirePlan(d, planId); const taskId = await ensureTaskRow(db, { kind: "verifyAndImport", diff --git a/apps/server/test/file-manager/organize-episode-structure.test.ts b/apps/server/test/file-manager/organize-episode-structure.test.ts index f6380217..214ae06f 100644 --- a/apps/server/test/file-manager/organize-episode-structure.test.ts +++ b/apps/server/test/file-manager/organize-episode-structure.test.ts @@ -6,7 +6,7 @@ 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"; +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 单元; @@ -156,6 +156,59 @@ describe.skipIf(skip)("整理时指定季集结构", () => { 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", diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index 0a37f2e2..e1759d25 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -28,7 +28,7 @@ - **缺省「跟随作品(当前:X)」**:请求不带 `episodeStructures`,冻结计划和以前一样(不记季集结构)。 - **临时选 TMDB 默认季或作品的其它剧集组**(列表同 `GET /media-entities/{id}/episode-groups`):只这次按所选结构换算季集、定季目录与命名,作品设置不变。选的就是作品当前结构时等同跟随作品。 -- **勾「同时设为作品的季集结构」**(缺省不勾):预览不改作品;执行时先走作品级切换(`setEpisodeGroup`:单元按 TMDB 原季集原地迁移、全量重算、刷新元数据,写审计),再按新结构(此时就是作品结构)整理。同一作品多组只切一次。 +- **勾「同时设为作品的季集结构」**(缺省不勾):预览不改作品;执行时等计划一致、Move 授权、做种义务这些执行前检查都通过后,才走作品级切换(`setEpisodeGroup`:单元按 TMDB 原季集原地迁移、全量重算、刷新元数据,写审计),检查失败时作品结构不变;切换后按新结构(此时就是作品结构)再算一遍,前后路径须与预览一致,再整理。同一作品多组只切一次。 请求:`POST /files/organize` 的 `episodeStructures: [{ mediaEntityId, groupId | null, applyToEntity? }]`(预览与执行同一请求体,进请求指纹)。组响应带 `episodeStructure { groupId, name, temporary, appliedToEntity? }`,预览头部显示「季集结构:制作季(仅本次)」。