fix(organize): restore both source and target files after partial moves

This commit is contained in:
root committed 2026-10-13 15:48:54 +08:00
1 parent 09a4c9fc1e
commit 041d59405c
2 files changed
+68 -8

No files matched your search

@@ -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");