- adapters/fs: FsProxy + fsworker run each file op in a killable subprocess;
progress-stall watchdog (FS_HANG_TIMEOUT_MS) for copy/hash, total timeout for
quick ops; SIGTERM -> SIGKILL -> FsError('TIMEOUT'); stat/checkRoot timeouts
never read as missing
- copyWithVerify: onExisting conflict/skip/replace (hardlink backup + atomic
rename, old file to .umt-trash or deleted), sweep stale partials before write,
document why permissions/owner are not copied
- filesMatch/filesMatchSha256 throw FsError on read failure instead of
match=false; crossDeviceMove skips redundant re-hash after sha256 copy
- FsErrorCode TIMEOUT + fsErrorClass; organize executeRecord routes fs ops
through fsproxy, retries TIMEOUT/IO up to FS_TRANSFER_MAX_ATTEMPTS then opens
a decision without deleting the record; reconcile errors no longer reported
as EEXIST
- domain: subtitle .default marker (ORGANIZE_DEFAULT_SUBTITLE_LANG) and
findOtherVersions (same season/episode/part, video only) as upgrade
disposal candidates
- scripts/bench/fs-bench.ts and benchmark record in porting doc
Co-Authored-By: Claude Opus 5.5 <[email protected]>
191 lines
6.7 KiB
TypeScript
191 lines
6.7 KiB
TypeScript
// 迁移来源:MoviePilot storages/local.py move 与 transhandler.py materialize_target/delete_move_source(e6820a8);
|
|
// 调整:EXDEV 显式返回不降级;跨设备拆为 copy→verify→delete_source 可重入三步,删源失败 = attention。
|
|
import { link, lstat, rename, stat, unlink } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import { type CopyOptions, copyWithVerify, filesMatch, type VerifyMethod } from "./copy.ts";
|
|
import { errnoOf, FsError, toFsError } from "./errors.ts";
|
|
|
|
export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "exists" };
|
|
|
|
/**
|
|
* 同设备移动(no-clobber)。跨设备返回 { status: "exdev" },**不会**自动降级为复制——
|
|
* 调用方需显式切换到 crossDeviceMove 并在操作账本中记录(REQ §8.3)。
|
|
* 其他错误抛 FsError。
|
|
*/
|
|
export async function moveSafe(src: string, dst: string): Promise<MoveResult> {
|
|
let s: Awaited<ReturnType<typeof lstat>>;
|
|
try {
|
|
s = await lstat(src);
|
|
} catch (e) {
|
|
throw toFsError(e, src);
|
|
}
|
|
if (s.isFile()) {
|
|
try {
|
|
await link(src, dst);
|
|
} catch (e) {
|
|
const code = errnoOf(e);
|
|
if (code === "EXDEV") return { status: "exdev" };
|
|
if (code === "EEXIST") {
|
|
// 上次 link 成功但 unlink 前中断:目标与源是同一 inode → 补完删除源
|
|
const d = await lstat(dst).catch(() => null);
|
|
if (d && d.ino === s.ino && d.dev === s.dev) {
|
|
await unlink(src);
|
|
return { status: "moved" };
|
|
}
|
|
return { status: "exists" };
|
|
}
|
|
if (!["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EMLINK"].includes(code ?? "")) throw toFsError(e, dst);
|
|
return renameChecked(src, dst);
|
|
}
|
|
try {
|
|
await unlink(src);
|
|
} catch (e) {
|
|
throw toFsError(e, src);
|
|
}
|
|
return { status: "moved" };
|
|
}
|
|
return renameChecked(src, dst);
|
|
}
|
|
|
|
async function renameChecked(src: string, dst: string): Promise<MoveResult> {
|
|
try {
|
|
await lstat(dst);
|
|
return { status: "exists" };
|
|
} catch (e) {
|
|
if (errnoOf(e) !== "ENOENT") throw toFsError(e, dst);
|
|
}
|
|
try {
|
|
await rename(src, dst);
|
|
return { status: "moved" };
|
|
} catch (e) {
|
|
if (errnoOf(e) === "EXDEV") return { status: "exdev" };
|
|
throw toFsError(e, dst);
|
|
}
|
|
}
|
|
|
|
export type CrossMoveStep = "copy" | "verify" | "delete_source";
|
|
|
|
export interface CrossMoveStepLog {
|
|
step: CrossMoveStep;
|
|
status: "done" | "skipped" | "failed";
|
|
at: string;
|
|
detail?: string;
|
|
error?: { code: string; message: string };
|
|
}
|
|
|
|
export interface CrossMoveResult {
|
|
/** completed:三步完成;attention:目标已就绪但删源失败/无法确认;failed:目标未提交,源保持不动 */
|
|
status: "completed" | "attention" | "failed";
|
|
steps: CrossMoveStepLog[];
|
|
}
|
|
|
|
export interface CrossMoveOptions extends Omit<CopyOptions, "hash" | "onExisting" | "replaceOld"> {
|
|
hash?: VerifyMethod;
|
|
/** 上次运行的步骤日志,用于中断续跑(只作参考,实际仍以文件系统观察为准) */
|
|
previous?: CrossMoveStepLog[];
|
|
}
|
|
|
|
async function exists(p: string): Promise<boolean> {
|
|
try {
|
|
await stat(p);
|
|
return true;
|
|
} catch (e) {
|
|
if (errnoOf(e) === "ENOENT") return false;
|
|
throw toFsError(e, p);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 跨设备搬运:copy → verify → delete_source,每步返回日志,可重入:
|
|
* - 目标已存在且与源一致(强制 sha256)→ copy 记为 skipped(上次已完成);不一致 → failed,不覆盖
|
|
* - 源已不存在但目标在,且上次日志 verify=done → 视为删源已完成
|
|
* - 删源失败 → attention(不能报成功)
|
|
*/
|
|
export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveOptions = {}): Promise<CrossMoveResult> {
|
|
const method = opts.hash ?? "size";
|
|
const io = {
|
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
...(opts.onVerifyProgress ? { onProgress: opts.onVerifyProgress } : {}),
|
|
};
|
|
const steps: CrossMoveStepLog[] = [];
|
|
const log = (step: CrossMoveStep, status: CrossMoveStepLog["status"], detail?: string, err?: unknown) => {
|
|
const e = err === undefined ? undefined : toFsError(err);
|
|
steps.push({
|
|
step,
|
|
status,
|
|
at: new Date().toISOString(),
|
|
...(detail ? { detail } : {}),
|
|
...(e ? { error: { code: e.code, message: e.message } } : {}),
|
|
});
|
|
};
|
|
|
|
let srcExists: boolean;
|
|
let dstExists: boolean;
|
|
try {
|
|
[srcExists, dstExists] = await Promise.all([exists(src), exists(dst)]);
|
|
} catch (e) {
|
|
log("copy", "failed", "observe failed", e);
|
|
return { status: "failed", steps };
|
|
}
|
|
|
|
if (!srcExists) {
|
|
const verified = opts.previous?.some((s) => s.step === "verify" && s.status === "done");
|
|
if (dstExists && verified) {
|
|
log("copy", "skipped", "already applied");
|
|
log("verify", "skipped", "verified in previous run");
|
|
log("delete_source", "skipped", "source already removed");
|
|
return { status: "completed", steps };
|
|
}
|
|
log("copy", "failed", dstExists ? "source missing, target cannot be verified" : "source missing", undefined);
|
|
return { status: dstExists ? "attention" : "failed", steps };
|
|
}
|
|
|
|
let fullyVerified = false;
|
|
if (dstExists) {
|
|
try {
|
|
// 目标不是本次写出的:只比大小/抽样会把同尺寸的不同文件当成一致并删源,这里一律用 sha256
|
|
if (await filesMatch(src, dst, "sha256", io)) {
|
|
log("copy", "skipped", "target already matches source");
|
|
fullyVerified = true;
|
|
} else {
|
|
log("copy", "failed", "target exists and differs", new FsError("EEXIST", `target exists: ${dst}`));
|
|
return { status: "failed", steps };
|
|
}
|
|
} catch (e) {
|
|
log("copy", "failed", "compare failed", e);
|
|
return { status: "failed", steps };
|
|
}
|
|
} else {
|
|
try {
|
|
const { previous: _p, ...copyOpts } = opts;
|
|
const r = await copyWithVerify(src, dst, { ...copyOpts, hash: method });
|
|
log("copy", "done", `${r.bytes} bytes`);
|
|
// copy 已用流式哈希 + 回读临时文件全量比对,并确认源 size/mtime 未变:verify 不再重复全量哈希两边
|
|
if (r.sha256) fullyVerified = true;
|
|
} catch (e) {
|
|
log("copy", "failed", undefined, e);
|
|
return { status: "failed", steps };
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (!(await filesMatch(src, dst, fullyVerified ? "size" : method, io))) {
|
|
log("verify", "failed", undefined, new FsError("VERIFY_FAILED", "target differs from source"));
|
|
return { status: "failed", steps };
|
|
}
|
|
log("verify", "done", fullyVerified ? `${method} (sha256 verified in copy step)` : method);
|
|
} catch (e) {
|
|
log("verify", "failed", undefined, e);
|
|
return { status: "failed", steps };
|
|
}
|
|
|
|
try {
|
|
await unlink(src);
|
|
log("delete_source", "done", dirname(src));
|
|
} catch (e) {
|
|
log("delete_source", "failed", undefined, e);
|
|
return { status: "attention", steps };
|
|
}
|
|
return { status: "completed", steps };
|
|
}
|