fix(organize): restore both source and target files after partial moves
This commit is contained in:
1 parent
09a4c9fc1e
commit
041d59405c
2 files changed
+68
-8
No files matched your search
@@ -100,7 +100,7 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
|||||||
if (!rec) throw new FilesError("not_found", "整理记录不存在");
|
if (!rec) throw new FilesError("not_found", "整理记录不存在");
|
||||||
const mains = await db
|
const mains = await db
|
||||||
.selectFrom("organization_file_result")
|
.selectFrom("organization_file_result")
|
||||||
.select(["source_path", "target_path", "target_library_root_id"])
|
.select(["source_path", "target_path", "target_library_root_id", "status"])
|
||||||
.where("organization_record_id", "=", rec.id)
|
.where("organization_record_id", "=", rec.id)
|
||||||
.where("role", "=", "main")
|
.where("role", "=", "main")
|
||||||
.orderBy("ordinal")
|
.orderBy("ordinal")
|
||||||
@@ -115,14 +115,22 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
|||||||
};
|
};
|
||||||
// Successful moves have removed their inputs: reorganize the existing library files.
|
// Successful moves have removed their inputs: reorganize the existing library files.
|
||||||
const moved = rec.action === "move" && rec.status === "succeeded";
|
const moved = rec.action === "move" && rec.status === "succeeded";
|
||||||
|
const partialMove = rec.action === "move" && rec.status === "partial";
|
||||||
const targetOf = (rootId: string | null, path: string | null) => {
|
const targetOf = (rootId: string | null, path: string | null) => {
|
||||||
const root = defs.find((d) => d.libraryRootId === rootId);
|
const root = defs.find((d) => d.libraryRootId === rootId);
|
||||||
return root && path && within(join(root.hostPath, path), root.hostPath) ? join(root.hostPath, path) : null;
|
return root && path && within(join(root.hostPath, path), root.hostPath) ? join(root.hostPath, path) : null;
|
||||||
};
|
};
|
||||||
let sources = moved
|
const targetSources = new Set<string>();
|
||||||
? mains.map((m) => targetOf(m.target_library_root_id, m.target_path)).filter((p): p is string => !!p)
|
let sources = mains
|
||||||
: mains.map((m) => m.source_path);
|
.map((m) => {
|
||||||
const dir = moved ? targetOf(rec.target_library_root_id, rec.target_path) : rec.source_path;
|
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 (
|
if (
|
||||||
dir &&
|
dir &&
|
||||||
sources.length > 1 &&
|
sources.length > 1 &&
|
||||||
@@ -134,7 +142,7 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
|||||||
const items: FilesRecordSourcesResult["items"] = [];
|
const items: FilesRecordSourcesResult["items"] = [];
|
||||||
const unresolved: string[] = [];
|
const unresolved: string[] = [];
|
||||||
for (const abs of [...new Set(sources)]) {
|
for (const abs of [...new Set(sources)]) {
|
||||||
const ref = moved || rec.source_path_view === "local" ? toRef(abs) : null;
|
const ref = moved || targetSources.has(abs) || rec.source_path_view === "local" ? toRef(abs) : null;
|
||||||
const exists = ref
|
const exists = ref
|
||||||
? await lstat(abs).then(
|
? await lstat(abs).then(
|
||||||
() => true,
|
() => true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||||
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.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 () => {
|
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 s = await source("History");
|
||||||
const sidecar = s.path.replace(/\.mkv$/, ".srt");
|
const sidecar = s.path.replace(/\.mkv$/, ".srt");
|
||||||
|
|||||||
Reference in new issue
Block a user