feat(organize): 整理矩阵支持 Alist(上传、下载、Alist 内复制移动、跨实例中转)
- 本地→Alist 上传、Alist→本地 下载、同实例 API copy/move(等任务完成)、跨实例下载再上传 - 成功判定同 MP:接口成功 + 目标存在;移动在判定通过后删源,删源失败记 attention - 连不上 / 超时 / 限流按可重试 IO 走整理账本,不当成源不存在;上传 / 下载中断不删源 - 计划期的目标冲突、库根在线、洗版替换、非视频隔离、对账均按存储分派;远端文件 ffprobe 读直链头 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
9d95076a0e
commit
ccd2cc2c37
9 files changed
+426
-68
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { type EventPayload, EventType } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { configureStorage } from "../../storage/registry.ts";
|
||||
import { registerDecisionHook } from "../decision-hooks.ts";
|
||||
import { registerScheduledJob } from "../schedules/registry.ts";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
@@ -69,6 +70,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
return deps;
|
||||
},
|
||||
registerRoutes(app, ctx) {
|
||||
configureStorage(ctx);
|
||||
const d = lazy(ctx);
|
||||
// 待处理"处理":接管识别失败 / 目标已存在 两类整理事项在关闭前由本模块执行所选处理
|
||||
registerDecisionHook(ctx, organizeDecisionHook(d));
|
||||
@@ -82,6 +84,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
registerClassificationRoutes(app, d);
|
||||
},
|
||||
registerTasks(registry, ctx) {
|
||||
configureStorage(ctx);
|
||||
registerAoTasks(registry, lazy(ctx));
|
||||
},
|
||||
registerCapabilities(registry, ctx) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
fetchEpisodeGroup,
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
ffprobe,
|
||||
getSiteAdapter,
|
||||
getSiteDefinition,
|
||||
groupSeasonAsRichSeason,
|
||||
@@ -27,6 +26,7 @@ import type { DownloaderInstanceRow, MediaServerInstanceRow, SiteRow, SourceRele
|
||||
import type { ParsedRelease } from "@umt/domain";
|
||||
import { dataPaths } from "../../config.ts";
|
||||
import { externalFetch } from "../../egress.ts";
|
||||
import { probeAny } from "../../storage/probe.ts";
|
||||
import { ImageCache } from "../cache/images.ts";
|
||||
import { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import {
|
||||
@@ -250,7 +250,7 @@ export function createPorts(
|
||||
return fetchRichSeason(client, tvId, season, await metadataLanguages(ctx), await metadataTextOptions(ctx));
|
||||
},
|
||||
diskUsage: (p) => diskUsage(p),
|
||||
probe: (p) => ffprobe(p),
|
||||
probe: (p) => probeAny(p),
|
||||
async mediaServer(inst) {
|
||||
const settings = (inst.settings ?? {}) as Record<string, unknown>;
|
||||
const apiKey = (await resolveSecret(inst.secret_ref)) ?? "";
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import type { OrganizationRecord } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { withAttemptToken } from "@umt/tasks";
|
||||
import { involvesRemote, remoteTransfer } from "../../../storage/transfer.ts";
|
||||
import { isRemotePath, vcheckRoot, vmkdirp, vrenameNoClobber, vstatSafe } from "../../../storage/vfs.ts";
|
||||
import { closeDecisions } from "../../sources-identity/service/common.ts";
|
||||
import { errMessage, jsonb } from "../util.ts";
|
||||
import { type AoDeps, upsertDecision } from "./common.ts";
|
||||
@@ -118,7 +120,9 @@ export async function executeRecord(
|
||||
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
|
||||
// 离线 / 建目录失败不抛错(抛错会耗尽任务重试、记录卡在 running):记录保持 running 并写明原因、开待处理,
|
||||
// 由整理巡检(recoverStalledRecords)在库根恢复后重新入队
|
||||
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
|
||||
const health = isRemotePath(frozen.rootPath)
|
||||
? await vcheckRoot(frozen.rootPath)
|
||||
: await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
|
||||
if (health.kind === "offline") {
|
||||
return holdRecord(d, rec, `${ROOT_OFFLINE}:${health.reason}`, {
|
||||
kind: "other",
|
||||
@@ -133,7 +137,7 @@ export async function executeRecord(
|
||||
}
|
||||
if (rec.action !== "in_place") {
|
||||
try {
|
||||
for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
|
||||
for (const dir of frozen.targetDirs) await vmkdirp(dir);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string } | null)?.code ?? "EIO";
|
||||
return holdRecord(d, rec, `${TARGET_DIR_UNAVAILABLE}:${code}`, {
|
||||
@@ -164,6 +168,7 @@ export async function executeRecord(
|
||||
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
|
||||
if (rec.action !== "in_place" && fsr.partialMaxAgeMs > 0) {
|
||||
for (const dir of frozen.targetDirs) {
|
||||
if (isRemotePath(dir)) continue;
|
||||
await fsr.proxy.sweepStalePartials(dir, fsr.partialMaxAgeMs).catch(() => [] as string[]);
|
||||
}
|
||||
}
|
||||
@@ -250,7 +255,7 @@ export async function executeRecord(
|
||||
: undefined;
|
||||
const stagedOld = selectedOld ? await stagedReplacementBackup(d, selectedOld) : null;
|
||||
if (selectedOld && !stagedOld) {
|
||||
const current = await fsr.proxy.statSafe(pf.dst, {
|
||||
const current = await (isRemotePath(pf.dst) ? vstatSafe : fsr.proxy.statSafe.bind(fsr.proxy))(pf.dst, {
|
||||
root: selectedOld.rootPath,
|
||||
sentinel: selectedOld.sentinel,
|
||||
});
|
||||
@@ -489,18 +494,27 @@ export async function trashReplacedOld(
|
||||
ref: { recordId: string; opId: string },
|
||||
old: { path?: string; size: number },
|
||||
): Promise<void> {
|
||||
const rootReal = await realpath(frozen.rootPath);
|
||||
const rel = relative(rootReal, await realpath(dirname(dst)).then((p) => join(p, basename(dst))));
|
||||
const remote = isRemotePath(frozen.rootPath);
|
||||
const rootReal = remote ? frozen.rootPath : await realpath(frozen.rootPath);
|
||||
const rel = remote
|
||||
? relative(rootReal, dst)
|
||||
: relative(rootReal, await realpath(dirname(dst)).then((p) => join(p, basename(dst))));
|
||||
if (!rel || rel.startsWith("..") || !old.path) return;
|
||||
const id = randomUUID();
|
||||
const now = d.ports.now();
|
||||
const to = join(rootReal, ".umt-trash", now.toISOString().slice(0, 10).replace(/-/g, ""), id, rel);
|
||||
await mkdir(dirname(to), { recursive: true });
|
||||
const m = await fsr.proxy.moveSafe(old.path, to);
|
||||
if (m.status !== "moved") {
|
||||
throw new Error(`旧文件移入回收站失败(${m.status}): ${old.path}`);
|
||||
if (remote) {
|
||||
await vmkdirp(dirname(to));
|
||||
const m = await vrenameNoClobber(old.path, to);
|
||||
if (m.status !== "moved") throw new Error(`旧文件移入回收站失败(${m.status}): ${old.path}`);
|
||||
} else {
|
||||
await mkdir(dirname(to), { recursive: true });
|
||||
const m = await fsr.proxy.moveSafe(old.path, to);
|
||||
if (m.status !== "moved") {
|
||||
throw new Error(`旧文件移入回收站失败(${m.status}): ${old.path}`);
|
||||
}
|
||||
await rmdir(dirname(old.path)).catch(() => undefined);
|
||||
}
|
||||
await rmdir(dirname(old.path)).catch(() => undefined);
|
||||
const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7;
|
||||
await d.ctx.db
|
||||
.insertInto("trash_item")
|
||||
@@ -602,7 +616,9 @@ export async function verifyInPlace(
|
||||
path: string,
|
||||
size: number,
|
||||
): Promise<FileOutcome> {
|
||||
const observed = await fsr.proxy.statSafe(path, { root: frozen.rootPath, sentinel: frozen.sentinel });
|
||||
const observed = isRemotePath(path)
|
||||
? await vstatSafe(path, { root: frozen.rootPath })
|
||||
: await fsr.proxy.statSafe(path, { root: frozen.rootPath, sentinel: frozen.sentinel });
|
||||
if (observed.kind !== "ok" || !observed.facts.isFile || observed.facts.isSymbolicLink) {
|
||||
return { status: "failed", error: { code: "SOURCE_MISSING", message: "原地整理的文件不存在或不可访问" } };
|
||||
}
|
||||
@@ -645,6 +661,28 @@ export async function transferFile(
|
||||
},
|
||||
): Promise<FileOutcome> {
|
||||
const { kind, src, dst, size, crossDevice, resumed } = x;
|
||||
// 涉及 Alist 的方向(上传 / 下载 / API copy·move / 跨实例中转):成功判定同 MP(接口成功 + 目标存在),不走本地核验
|
||||
if (involvesRemote(src, dst)) {
|
||||
const r = await remoteTransfer({
|
||||
kind,
|
||||
src,
|
||||
dst,
|
||||
onExisting: x.onExisting,
|
||||
...(x.replaceOld.trashDir ? { replaceTrashDir: x.replaceOld.trashDir } : {}),
|
||||
resumed,
|
||||
...(x.signal ? { signal: x.signal } : {}),
|
||||
});
|
||||
return {
|
||||
status: r.status,
|
||||
bytes: r.bytes ?? size,
|
||||
...(r.checksum ? { checksum: r.checksum } : {}),
|
||||
...(r.resumed ? { resumed: true } : {}),
|
||||
...(r.error ? { error: r.error } : {}),
|
||||
...(r.steps ? { steps: r.steps } : {}),
|
||||
...(r.replaced ? { replaced: r.replaced } : {}),
|
||||
hashVia: `alist:${r.via}`,
|
||||
};
|
||||
}
|
||||
const px = fsr.proxy;
|
||||
const call = x.signal ? { signal: x.signal } : {};
|
||||
const fsErr = (e: unknown) => ({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { statSafe } from "@umt/adapters";
|
||||
import type { DownloadTaskRow, OrganizationRecordRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import { findOtherVersions } from "@umt/domain";
|
||||
import { isUnresolvedExclusion, organizationStatusFromFiles } from "@umt/mapping";
|
||||
import { isRemotePath, vreaddir as readdir, vstatSafe as statSafe } from "../../../storage/vfs.ts";
|
||||
import { errMessage, jsonb } from "../util.ts";
|
||||
import { type AoDeps, type Exec, loadInstances, upsertDecision } from "./common.ts";
|
||||
import { applySlotEvent } from "./downloads.ts";
|
||||
@@ -51,7 +50,12 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
for (const f of filesPre) {
|
||||
if (f.status === "succeeded" && f.role === "main" && f.target_path) {
|
||||
const abs = join(frozenPre.rootPath, f.target_path);
|
||||
const r = await d.ports.probe(abs);
|
||||
let r = await d.ports.probe(abs);
|
||||
// Alist 目标:直链探测拿不到(驱动不给直链等)时退回探测本地源(复制时还在)
|
||||
if (r.status === "unknown" && isRemotePath(abs) && !isRemotePath(f.source_path)) {
|
||||
const local = await d.ports.probe(f.source_path);
|
||||
if (local.status === "ok") r = local;
|
||||
}
|
||||
probes.set(f.id, r);
|
||||
const names = await readdir(dirname(abs)).catch(() => [] as string[]);
|
||||
for (const o of findOtherVersions(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import { checkRoot, DEFAULT_ROOT_SENTINEL, FsError, sha256Full, statSafe } from "@umt/adapters";
|
||||
import { DEFAULT_ROOT_SENTINEL, FsError, sha256Full } from "@umt/adapters";
|
||||
import type { ActorType, OrganizationTrigger, PathBinding } from "@umt/contracts";
|
||||
import { reasonText } from "@umt/contracts";
|
||||
import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, SourceReleaseRow } from "@umt/db";
|
||||
@@ -27,6 +26,12 @@ import {
|
||||
titleMismatches,
|
||||
} from "@umt/domain";
|
||||
import { organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping";
|
||||
import {
|
||||
vcheckRoot as checkRoot,
|
||||
isRemotePath,
|
||||
vreaddir as readdir,
|
||||
vstatSafe as statSafe,
|
||||
} from "../../../storage/vfs.ts";
|
||||
import {
|
||||
createEntityEpisodeMapper,
|
||||
createStructureEpisodeMapper,
|
||||
@@ -588,7 +593,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
return fail("slot_entity_mismatch", { slotId: req.slotId }, entity.id);
|
||||
}
|
||||
}
|
||||
const sentinel = root.sentinel_file ?? DEFAULT_ROOT_SENTINEL;
|
||||
const sentinel = isRemotePath(root.local_path) ? "" : (root.sentinel_file ?? DEFAULT_ROOT_SENTINEL);
|
||||
const health = await checkRoot(root.local_path, sentinel);
|
||||
if (health.kind === "offline") {
|
||||
const r = await fail(
|
||||
@@ -881,7 +886,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
continue;
|
||||
}
|
||||
const e: ExistingTarget = { path: f.dst, size: st.facts.size };
|
||||
if (st.facts.size === f.size) {
|
||||
// 涉及 Alist 时不算哈希(网盘方向不核验,用户决定):同尺寸也按目标已存在交给策略判定
|
||||
if (st.facts.size === f.size && !isRemotePath(probe) && !isRemotePath(f.src)) {
|
||||
// 同尺寸已存在的目标:全量 sha256 判定是否同一文件(子进程,见 adapters sha256Full)
|
||||
e.sha256 = await cachedSha256(probe, st.facts);
|
||||
const src = files.find((x) => x.path === f.src);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { join } from "node:path";
|
||||
import { checkRoot, DEFAULT_ROOT_SENTINEL, statSafe } from "@umt/adapters";
|
||||
import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
|
||||
import { vcheckRoot as checkRoot, vstatSafe as statSafe } from "../../../storage/vfs.ts";
|
||||
import { errMessage, jsonb } from "../util.ts";
|
||||
import type { AoDeps } from "./common.ts";
|
||||
import { applySlotEvent } from "./downloads.ts";
|
||||
|
||||
@@ -4,10 +4,49 @@ import { basename, dirname, join, relative } from "node:path";
|
||||
import { DEFAULT_ROOT_SENTINEL, type FileFacts } from "@umt/adapters";
|
||||
import { sql } from "@umt/db";
|
||||
import { type ReplacementScope, replacementCovers, replacementScope } from "@umt/domain";
|
||||
import {
|
||||
isRemotePath,
|
||||
vcheckRoot,
|
||||
vmkdirp,
|
||||
vreaddir,
|
||||
vremove,
|
||||
vrenameNoClobber,
|
||||
vstatSafe,
|
||||
} from "../../../storage/vfs.ts";
|
||||
import { jsonb } from "../util.ts";
|
||||
import { type AoDeps, upsertDecision } from "./common.ts";
|
||||
import { type FrozenPlan, fsRuntime } from "./organize.ts";
|
||||
|
||||
// 本地 / Alist 分派(docs/design/storage-alist.md §6.3):Alist 上的旧文件用同存储 move 暂放 / 进回收站 / 移回。
|
||||
// 远端没有 inode 与可靠的修改时间,旧文件「没变」只按大小判断;失败目标不做哈希核对(网盘方向不核验,用户决定)。
|
||||
const fsx = (d: AoDeps) => {
|
||||
const fs = fsRuntime(d);
|
||||
return {
|
||||
replaceOld: fs.replaceOld,
|
||||
checkRoot: (root: string, sentinel: string) =>
|
||||
isRemotePath(root) ? vcheckRoot(root) : fs.proxy.checkRoot(root, sentinel),
|
||||
statSafe: (p: string, opts: { root?: string; sentinel?: string } = {}) =>
|
||||
isRemotePath(p) ? vstatSafe(p, opts.root ? { root: opts.root } : {}) : fs.proxy.statSafe(p, opts),
|
||||
realpath: (p: string) => (isRemotePath(p) ? Promise.resolve(p) : realpath(p)),
|
||||
async move(a: string, b: string): Promise<{ status: string }> {
|
||||
if (isRemotePath(a) || isRemotePath(b)) {
|
||||
await vmkdirp(dirname(b));
|
||||
return vrenameNoClobber(a, b);
|
||||
}
|
||||
await mkdir(dirname(b), { recursive: true });
|
||||
return fs.proxy.moveSafe(a, b);
|
||||
},
|
||||
rm: (p: string) => (isRemotePath(p) ? vremove(p) : rm(p, { force: true })),
|
||||
readdir: (p: string) => (isRemotePath(p) ? vreaddir(p) : readdir(p)),
|
||||
sha256: (p: string) => fs.proxy.sha256(p),
|
||||
};
|
||||
};
|
||||
/** 旧文件事实是否没变:本地比 size/mtime/ino/dev,远端只比 size */
|
||||
const sameFacts = (path: string, a: Partial<FileFacts>, b: Partial<FileFacts>) =>
|
||||
isRemotePath(path)
|
||||
? a.size === b.size
|
||||
: (["size", "mtimeMs", "ino", "dev"] as const).every((k) => a[k] === undefined || a[k] === b[k]);
|
||||
|
||||
export interface ReplacementFile {
|
||||
assetId: string;
|
||||
locationId: string;
|
||||
@@ -106,7 +145,7 @@ export async function freezeReplacement(
|
||||
);
|
||||
for (const file of [loc, ...privateChildren]) {
|
||||
const path = join(loc.local_path, file.relative_path);
|
||||
const st = await fsRuntime(d).proxy.statSafe(path, {
|
||||
const st = await fsx(d).statSafe(path, {
|
||||
root: loc.local_path,
|
||||
sentinel: loc.sentinel_file ?? DEFAULT_ROOT_SENTINEL,
|
||||
});
|
||||
@@ -118,7 +157,7 @@ export async function freezeReplacement(
|
||||
locationId: file.id,
|
||||
rootId: loc.root_id,
|
||||
rootPath: loc.local_path,
|
||||
sentinel: loc.sentinel_file ?? DEFAULT_ROOT_SENTINEL,
|
||||
sentinel: isRemotePath(loc.local_path) ? "" : (loc.sentinel_file ?? DEFAULT_ROOT_SENTINEL),
|
||||
path: file.relative_path,
|
||||
facts: {
|
||||
size: st.facts.size,
|
||||
@@ -231,31 +270,26 @@ export async function cleanupReplacement(d: AoDeps, frozen: FrozenPlan, recordId
|
||||
if (location.status === "removed") continue;
|
||||
if (successful.some((f) => frozen.libraryRootId === file.rootId && f.target_path === file.path)) continue;
|
||||
try {
|
||||
const fs = fsRuntime(d);
|
||||
if ((await fs.proxy.checkRoot(file.rootPath, file.sentinel)).kind !== "online") {
|
||||
const fs = fsx(d);
|
||||
if ((await fs.checkRoot(file.rootPath, file.sentinel)).kind !== "online") {
|
||||
throw new Error("旧版本库根离线");
|
||||
}
|
||||
const rootReal = await realpath(file.rootPath);
|
||||
const rootReal = await fs.realpath(file.rootPath);
|
||||
const abs = join(rootReal, file.path);
|
||||
const parent = await realpath(dirname(abs));
|
||||
const parent = await fs.realpath(dirname(abs));
|
||||
const rel = relative(rootReal, parent);
|
||||
if (rel.startsWith("..") || file.path.split("/").some((s) => s === "..")) throw new Error("旧文件位置越界");
|
||||
const trash = join(rootReal, ".umt-trash", file.trashDay, file.trashId, file.path);
|
||||
const st = await fs.proxy.statSafe(abs, {
|
||||
const st = await fs.statSafe(abs, {
|
||||
root: rootReal,
|
||||
sentinel: file.sentinel,
|
||||
});
|
||||
const moved = await fs.proxy.statSafe(trash);
|
||||
const moved = await fs.statSafe(trash);
|
||||
const resumed =
|
||||
existing?.status === "started" &&
|
||||
st.kind === "missing" &&
|
||||
(fs.replaceOld === "delete" || moved.kind === "ok");
|
||||
if (
|
||||
!resumed &&
|
||||
(st.kind !== "ok" ||
|
||||
st.facts.isSymbolicLink ||
|
||||
Object.entries(file.facts).some(([k, v]) => st.facts[k as keyof typeof file.facts] !== v))
|
||||
) {
|
||||
if (!resumed && (st.kind !== "ok" || st.facts.isSymbolicLink || !sameFacts(abs, file.facts, st.facts))) {
|
||||
throw new Error("旧文件已变化,保留待核对");
|
||||
}
|
||||
await db
|
||||
@@ -273,10 +307,9 @@ export async function cleanupReplacement(d: AoDeps, frozen: FrozenPlan, recordId
|
||||
.onConflict((oc) => oc.column("operation_id").doUpdateSet({ status: "started" }))
|
||||
.execute();
|
||||
if (!resumed) {
|
||||
if (fs.replaceOld === "delete") await rm(abs);
|
||||
if (fs.replaceOld === "delete") await fs.rm(abs);
|
||||
else {
|
||||
await mkdir(dirname(trash), { recursive: true });
|
||||
const result = await fs.proxy.moveSafe(abs, trash);
|
||||
const result = await fs.move(abs, trash);
|
||||
if (result.status !== "moved") {
|
||||
throw new Error(`旧文件回收失败:${result.status}`);
|
||||
}
|
||||
@@ -381,10 +414,10 @@ export async function settleSamePathReplacement(d: AoDeps, frozen: FrozenPlan, r
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const fs = fsRuntime(d);
|
||||
const fs = fsx(d);
|
||||
if (main?.status === "succeeded" && main.media_asset_id) {
|
||||
if (fs.replaceOld === "delete") {
|
||||
await rm(backup.path, { force: true });
|
||||
await fs.rm(backup.path);
|
||||
if (backup.trashItemId) {
|
||||
await d.ctx.db.deleteFrom("trash_item").where("id", "=", backup.trashItemId).execute();
|
||||
}
|
||||
@@ -402,38 +435,40 @@ export async function settleSamePathReplacement(d: AoDeps, frozen: FrozenPlan, r
|
||||
continue;
|
||||
}
|
||||
if (main?.status !== "failed") continue;
|
||||
if ((await fs.proxy.checkRoot(old.rootPath, old.sentinel)).kind !== "online") {
|
||||
if ((await fs.checkRoot(old.rootPath, old.sentinel)).kind !== "online") {
|
||||
throw new Error("旧版本库根离线,保留回收副本");
|
||||
}
|
||||
const remote = isRemotePath(pf.dst);
|
||||
const rejected = join(old.rootPath, ".umt-trash", old.trashDay, old.trashId, old.path);
|
||||
const oldBackup = await fs.proxy.statSafe(backup.path);
|
||||
const target = await fs.proxy.statSafe(pf.dst);
|
||||
const oldBackup = await fs.statSafe(backup.path);
|
||||
const target = await fs.statSafe(pf.dst);
|
||||
const alreadyRestored =
|
||||
oldBackup.kind === "missing" &&
|
||||
target.kind === "ok" &&
|
||||
Object.entries(old.facts).every(([key, value]) => target.facts[key as keyof typeof old.facts] === value);
|
||||
oldBackup.kind === "missing" && target.kind === "ok" && sameFacts(pf.dst, old.facts, target.facts);
|
||||
if (!alreadyRestored) {
|
||||
if (
|
||||
oldBackup.kind !== "ok" ||
|
||||
oldBackup.facts.size !== old.facts.size ||
|
||||
oldBackup.facts.ino !== old.facts.ino
|
||||
(!remote && oldBackup.facts.ino !== old.facts.ino)
|
||||
) {
|
||||
throw new Error("旧文件回收副本已变化");
|
||||
}
|
||||
if (!file.checksum?.startsWith("sha256:")) {
|
||||
// Alist 目标不做哈希核对(网盘方向不核验,用户决定)
|
||||
if (!remote && !file.checksum?.startsWith("sha256:")) {
|
||||
throw new Error("失败目标缺少全量校验,旧文件留在回收站");
|
||||
}
|
||||
if (target.kind === "ok") {
|
||||
if ((await fs.proxy.sha256(pf.dst)).digest !== file.checksum.slice(7)) {
|
||||
if (!remote && (await fs.sha256(pf.dst)).digest !== file.checksum!.slice(7)) {
|
||||
throw new Error("失败目标已变化,旧文件留在回收站");
|
||||
}
|
||||
await mkdir(dirname(rejected), { recursive: true });
|
||||
const parked = await fs.proxy.moveSafe(pf.dst, rejected);
|
||||
const parked = await fs.move(pf.dst, rejected);
|
||||
if (parked.status !== "moved") throw new Error("失败新文件无法暂存");
|
||||
} else if (target.kind !== "missing" || (await fs.proxy.sha256(rejected)).digest !== file.checksum.slice(7)) {
|
||||
} else if (
|
||||
target.kind !== "missing" ||
|
||||
(!remote && (await fs.sha256(rejected)).digest !== file.checksum!.slice(7))
|
||||
) {
|
||||
throw new Error("失败新文件暂存无法核验");
|
||||
}
|
||||
const restored = await fs.proxy.moveSafe(backup.path, pf.dst);
|
||||
const restored = await fs.move(backup.path, pf.dst);
|
||||
if (restored.status !== "moved") {
|
||||
throw new Error("旧文件恢复未完成,副本保留");
|
||||
}
|
||||
@@ -488,15 +523,11 @@ export async function settleSamePathReplacement(d: AoDeps, frozen: FrozenPlan, r
|
||||
|
||||
export async function stagedReplacementBackup(d: AoDeps, file: ReplacementFile): Promise<string | null> {
|
||||
const stage = join(file.rootPath, ".umt-trash", "replacement-staging", file.trashId);
|
||||
for (const dir of await readdir(stage).catch(() => [] as string[])) {
|
||||
const fs = fsx(d);
|
||||
for (const dir of await fs.readdir(stage).catch(() => [] as string[])) {
|
||||
const candidate = join(stage, dir, basename(file.path));
|
||||
const st = await fsRuntime(d).proxy.statSafe(candidate);
|
||||
if (
|
||||
st.kind === "ok" &&
|
||||
!st.facts.isSymbolicLink &&
|
||||
Object.entries(file.facts).every(([key, value]) => st.facts[key as keyof typeof file.facts] === value)
|
||||
)
|
||||
return candidate;
|
||||
const st = await fs.statSafe(candidate);
|
||||
if (st.kind === "ok" && !st.facts.isSymbolicLink && sameFacts(candidate, file.facts, st.facts)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -535,7 +566,7 @@ export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId
|
||||
f.skip_reason === "main_failed" &&
|
||||
rejectedMains.has(planned.get(f.source_path)?.mainSrc ?? ""))),
|
||||
);
|
||||
const fsr = fsRuntime(d);
|
||||
const fsr = fsx(d);
|
||||
const moved: { path: string; trash: string }[] = [];
|
||||
const left: { path: string; error: string }[] = [];
|
||||
for (const f of targets) {
|
||||
@@ -543,10 +574,10 @@ export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId
|
||||
if (frozen.replacement && pf?.action === "replace") continue;
|
||||
const rel = f.target_path!;
|
||||
try {
|
||||
if ((await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel)).kind !== "online") throw new Error("库根离线");
|
||||
const rootReal = await realpath(frozen.rootPath);
|
||||
if ((await fsr.checkRoot(frozen.rootPath, frozen.sentinel)).kind !== "online") throw new Error("库根离线");
|
||||
const rootReal = await fsr.realpath(frozen.rootPath);
|
||||
const abs = join(rootReal, rel);
|
||||
const st = await fsr.proxy.statSafe(abs, { root: rootReal, sentinel: frozen.sentinel });
|
||||
const st = await fsr.statSafe(abs, { root: rootReal, sentinel: frozen.sentinel });
|
||||
if (st.kind === "missing") continue;
|
||||
if (st.kind !== "ok" || !st.facts.isFile || st.facts.isSymbolicLink) throw new Error("目标不是普通文件");
|
||||
const registered = await db
|
||||
@@ -562,8 +593,7 @@ export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId
|
||||
const id = randomUUID();
|
||||
const now = d.ports.now();
|
||||
const trash = join(rootReal, ".umt-trash", now.toISOString().slice(0, 10).replace(/-/g, ""), id, rel);
|
||||
await mkdir(dirname(trash), { recursive: true });
|
||||
const m = await fsr.proxy.moveSafe(abs, trash);
|
||||
const m = await fsr.move(abs, trash);
|
||||
if (m.status !== "moved") throw new Error(`移入回收站失败:${m.status}`);
|
||||
const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7;
|
||||
await db
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { statSafe } from "@umt/adapters";
|
||||
import type { DownloadTaskRow } from "@umt/db";
|
||||
import {
|
||||
fileKindOf,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
parseRelease,
|
||||
parseReleasePath,
|
||||
} from "@umt/domain";
|
||||
import { vstatSafe as statSafe } from "../../../storage/vfs.ts";
|
||||
import type { DecisionHook, DecisionHookActor, DecisionResolveBody } from "../../decision-hooks.ts";
|
||||
import { addAliasIfAbsent } from "../../sources-identity/service/entities.ts";
|
||||
import type { NameIdentifyResult } from "../ports.ts";
|
||||
|
||||
Reference in new issue
Block a user