diff --git a/apps/server/src/modules/file-manager/tree.ts b/apps/server/src/modules/file-manager/tree.ts index 1044ae9e..8d8fc975 100644 --- a/apps/server/src/modules/file-manager/tree.ts +++ b/apps/server/src/modules/file-manager/tree.ts @@ -100,7 +100,7 @@ export async function recordSources(d: FmDeps, recordId: string): Promise { const root = defs.find((d) => d.libraryRootId === rootId); return root && path && within(join(root.hostPath, path), root.hostPath) ? join(root.hostPath, path) : null; }; - let sources = moved - ? mains.map((m) => targetOf(m.target_library_root_id, m.target_path)).filter((p): p is string => !!p) - : mains.map((m) => m.source_path); - const dir = moved ? targetOf(rec.target_library_root_id, rec.target_path) : rec.source_path; + const targetSources = new Set(); + let sources = mains + .map((m) => { + if (!moved && !(partialMove && m.status === "succeeded")) return m.source_path; + const target = targetOf(m.target_library_root_id, m.target_path); + if (target) targetSources.add(target); + return target; + }) + .filter((p): p is string => !!p); + // 部分移动的文件分布在媒体库与下载目录,不能折叠成一个目录丢掉已移动文件。 + const dir = partialMove ? null : moved ? targetOf(rec.target_library_root_id, rec.target_path) : rec.source_path; if ( dir && sources.length > 1 && @@ -134,7 +142,7 @@ export async function recordSources(d: FmDeps, recordId: string): Promise true, diff --git a/apps/server/test/file-manager/organize-manual-options.test.ts b/apps/server/test/file-manager/organize-manual-options.test.ts index 9e05ebeb..db43486b 100644 --- a/apps/server/test/file-manager/organize-manual-options.test.ts +++ b/apps/server/test/file-manager/organize-manual-options.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { access, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts"; import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts"; @@ -90,6 +90,58 @@ describe.skipIf(skip)("manual organize history and per-import scraping", () => { ]); }); + it("partial move reorganizes successful files at their target and failed files at their source", async () => { + const title = `Partial ${randomUUID().slice(0, 8)}`; + const entity = await seedEntity(h.db, { kind: "series", title, year: 2024 }); + const folder = title.replaceAll(" ", "."); + const paths = [1, 2].map((episode) => `${folder}/${folder}.S01E0${episode}.1080p.mkv`); + await mkdir(join(h.dl, folder)); + for (const path of paths) await writeFile(join(h.dl, path), `video-${path}`); + const r = await organize([{ root: h.dlRoot, path: folder, mediaEntityId: entity }], { + action: "move", + scrapeAfterImport: false, + }); + const rid = r.exec.recordIds[0]; + const files = await h.db + .selectFrom("organization_file_result") + .selectAll() + .where("organization_record_id", "=", rid) + .where("role", "=", "main") + .orderBy("ordinal") + .execute(); + expect(files).toHaveLength(2); + const [moved, failed] = files; + const target = join(h.lib, moved!.target_path!); + const conflict = join(h.lib, failed!.target_path!); + // 预览后目标出现不同内容:第一集实际移动成功,第二集冲突留在下载目录。 + await mkdir(dirname(conflict), { recursive: true }); + await writeFile(conflict, "existing-different-video"); + expect((await executeRecord(h.module.deps().ao, rid)).status).toBe("partial"); + await expect(access(moved!.source_path)).rejects.toThrow(); + await access(target); + await access(failed!.source_path); + + const refs = await h.api("GET", `/files/record-sources?recordId=${rid}`); + expect(refs.status).toBe(200); + expect(refs.body.data.unresolved).toEqual([]); + expect(refs.body.data.items).toEqual([ + { root: h.libRoot, path: moved!.target_path }, + { root: h.dlRoot, path: failed!.source_path.slice(h.dl.length + 1) }, + ]); + await unlink(conflict); + const retry = await h.api("POST", "/files/organize", { + idempotencyKey: key(), + action: "copy", + minSizeBytes: 0, + dryRun: true, + items: refs.body.data.items.map((item: { root: string; path: string }) => ({ ...item, mediaEntityId: entity })), + }); + expect(retry.status).toBe(200); + expect(retry.body.data.groups).toHaveLength(1); + expect(retry.body.data.groups[0].error).toBeNull(); + expect(retry.body.data.groups[0].files).toHaveLength(2); + }); + it("successful-history skip includes matched sidecars and does not enqueue work; latest failure does not hide success", async () => { const s = await source("History"); const sidecar = s.path.replace(/\.mkv$/, ".srt");