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:
rootandClaude Opus 5.5 committed 2026-10-06 23:52:39 +08:00
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";