// 迁移来源: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 { let s: Awaited>; 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 { 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 { hash?: VerifyMethod; /** 上次运行的步骤日志,用于中断续跑(只作参考,实际仍以文件系统观察为准) */ previous?: CrossMoveStepLog[]; } async function exists(p: string): Promise { 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 { 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 }; }