Files
UHD_Media_Tool/packages/adapters/src/fs/move.ts
T
rootandClaude Opus 5.5 2e2d56184c feat(fs): W4-E4 fsproxy hang isolation, partial sweep, replace-with-verify, TIMEOUT retry
- 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]>
2026-09-27 23:15:05 +08:00

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 };
}