真正入库取决于媒体服务器有没有扫到,本工具只负责整理(元数据、NFO 与图片、按规则归到目录)。 界面与面向用户的服务端文案里「已入库 / 未入库 / 部分入库 / 入库失败 / 入库中 / 入库后 / 原地入库…」 改为「已整理 / 未整理 / 部分整理 / 整理失败 / 整理中 / 整理后 / 原地整理…」;文件管理快速筛选「已整理」、 目录副行「n 个已整理 / 未整理」、季卡片「已整理 x/y」。代码标识符不改,「Emby 已有」这类媒体服务器状态保留。 glossary 加「整理」口径条目;check:copy 拦处理状态语境的「入库」搭配,其它线待替换的文件暂列 TERM_PENDING。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
196 lines
7.5 KiB
TypeScript
196 lines
7.5 KiB
TypeScript
import { lstat, mkdir } from "node:fs/promises";
|
|
import { basename, dirname, extname, join } from "node:path";
|
|
import { moveSafe } from "@umt/adapters";
|
|
import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts";
|
|
import { sanitizeSegment } from "@umt/domain";
|
|
import type { Principal } from "../../http/auth.ts";
|
|
import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
|
|
import { audit, filesChanged } from "./ledger.ts";
|
|
import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts";
|
|
import { type RecordFile, relocate, relocatePreview, writeRecord } from "./records.ts";
|
|
|
|
// mkdir / rename:同目录改名不覆盖(moveSafe = link+unlink / lstat+rename),执行后 inode 比对;
|
|
// 已登记资产同事务同步 asset_location,目录命中 path_binding 写修订。
|
|
|
|
const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : "");
|
|
|
|
function assertCleanName(name: string) {
|
|
if (sanitizeSegment(name) !== name)
|
|
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`);
|
|
}
|
|
|
|
export async function mkdirIn(
|
|
d: FmDeps,
|
|
principal: Principal | undefined,
|
|
body: FilesMkdirRequest,
|
|
): Promise<FilesMkdirResult> {
|
|
const r = await resolveIn(d, body.root, body.path);
|
|
if (!(await isWritableTarget(r.def, r.rootReal))) throw new FilesError("not_writable", `根 ${r.def.name} 不可写`);
|
|
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
|
|
assertCleanName(body.name);
|
|
await recheck(r.abs, r.rootReal);
|
|
const abs = join(r.abs, body.name);
|
|
try {
|
|
await mkdir(abs);
|
|
} catch (e) {
|
|
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`);
|
|
throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`);
|
|
}
|
|
const rel = relOf(r.rootReal, abs);
|
|
await audit(d, principal, "mkdir", { root: body.root, path: rel });
|
|
filesChanged(d, body.root, [r.rel]);
|
|
return { root: body.root, path: rel };
|
|
}
|
|
|
|
interface Pair {
|
|
from: string;
|
|
to: string;
|
|
fromAbs: string;
|
|
toAbs: string;
|
|
role: "main" | "sidecar";
|
|
}
|
|
|
|
export async function renameEntry(
|
|
d: FmDeps,
|
|
principal: Principal | undefined,
|
|
body: FilesRenameRequest,
|
|
): Promise<FilesRenameResult> {
|
|
const r = await resolveIn(d, body.root, body.path);
|
|
if (r.rel === "") throw new FilesError("bad_request", "根目录不能重命名");
|
|
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 只读,不能重命名`);
|
|
assertCleanName(body.newName);
|
|
const oldName = basename(r.abs);
|
|
if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同");
|
|
const isDir = !!r.lst?.isDirectory() && !r.isSymlink;
|
|
|
|
// 纳管下载任务的内容(或目录下有):改名会让下载器找不到文件
|
|
const hits = downloadsFor(await managedDownloads(d), r.abs);
|
|
if (hits.length > 0)
|
|
throw new FilesError(
|
|
"download_active",
|
|
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理再改`,
|
|
);
|
|
|
|
const dirAbs = dirname(r.abs);
|
|
const pRel = parentRel(r.rel);
|
|
const relIn = (n: string) => (pRel ? `${pRel}/${n}` : n);
|
|
const pairs: Pair[] = [
|
|
{ from: r.rel, to: relIn(body.newName), fromAbs: r.abs, toAbs: join(dirAbs, body.newName), role: "main" },
|
|
];
|
|
if (!isDir && body.withSidecars && kindOfName(oldName) === "video") {
|
|
const oldStem = oldName.slice(0, oldName.length - extname(oldName).length);
|
|
const newStem = body.newName.slice(0, body.newName.length - extname(body.newName).length);
|
|
for (const n of await sidecarsOf(r.abs)) {
|
|
const nn = `${newStem}${n.slice(oldStem.length)}`;
|
|
pairs.push({ from: relIn(n), to: relIn(nn), fromAbs: join(dirAbs, n), toAbs: join(dirAbs, nn), role: "sidecar" });
|
|
}
|
|
}
|
|
// 不覆盖:任一目标已存在 → 409,原文件不动
|
|
const taken: string[] = [];
|
|
for (const p of pairs) {
|
|
const st = await lstat(p.toAbs).catch((e) => (errnoOf(e) === "ENOENT" ? null : e));
|
|
if (st) taken.push(p.to);
|
|
}
|
|
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`);
|
|
|
|
const lib = r.def.libraryRootId;
|
|
let locCount = 0;
|
|
let bindingIds: string[] = [];
|
|
if (lib) {
|
|
for (const p of pairs) {
|
|
const pv = await relocatePreview(d.ctx.db, lib, p.from, isDir && p.role === "main");
|
|
locCount += pv.locations;
|
|
bindingIds = [...bindingIds, ...pv.bindingIds];
|
|
}
|
|
}
|
|
const renames = pairs.map((p) => ({ from: p.from, to: p.to, role: p.role }));
|
|
if (body.dryRun)
|
|
return {
|
|
dryRun: true,
|
|
renames,
|
|
assetLocations: locCount,
|
|
bindings: bindingIds.map((id) => ({ oldBindingId: id, newBindingId: null })),
|
|
recordId: null,
|
|
};
|
|
|
|
// 执行:逐个 no-clobber 改名;任一失败回滚已改的
|
|
const done: Pair[] = [];
|
|
const rollback = async () => {
|
|
for (const p of done.reverse()) await moveSafe(p.toAbs, p.fromAbs).catch(() => undefined);
|
|
};
|
|
for (const p of pairs) {
|
|
try {
|
|
await recheck(p.fromAbs, r.rootReal);
|
|
const before = await lstat(p.fromAbs);
|
|
const res = await moveSafe(p.fromAbs, p.toAbs);
|
|
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`);
|
|
if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`);
|
|
const after = await lstat(p.toAbs);
|
|
if (after.ino !== before.ino || after.dev !== before.dev)
|
|
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`);
|
|
done.push(p);
|
|
} catch (e) {
|
|
await rollback();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
let recordId: string | null = null;
|
|
const bindings: FilesRenameResult["bindings"] = [];
|
|
let synced = 0;
|
|
if (lib) {
|
|
try {
|
|
await d.ctx.db.transaction().execute(async (trx) => {
|
|
const files: RecordFile[] = [];
|
|
const entities = new Set<string>();
|
|
for (const p of pairs) {
|
|
const rr = await relocate(trx, {
|
|
fromLib: lib,
|
|
fromRel: p.from,
|
|
toLib: lib,
|
|
toRel: p.to,
|
|
isDir: isDir && p.role === "main",
|
|
actor: actorOfP(principal),
|
|
opKind: "rename",
|
|
});
|
|
synced += rr.locations.length;
|
|
bindings.push(...rr.bindings);
|
|
for (const e of rr.mediaEntityIds) entities.add(e);
|
|
if (rr.locations.length > 0 || rr.bindings.length > 0)
|
|
files.push({
|
|
src: p.from,
|
|
dst: p.to,
|
|
size: 0,
|
|
status: "succeeded",
|
|
role: p.role === "main" ? "main" : "other",
|
|
});
|
|
}
|
|
if (files.length > 0)
|
|
recordId = await writeRecord(d, trx, {
|
|
principal,
|
|
action: "rename",
|
|
mediaEntityId: entities.size === 1 ? [...entities][0]! : null,
|
|
sourcePath: r.abs,
|
|
targetLibraryRootId: lib,
|
|
targetPath: pairs[0]!.to,
|
|
plan: { op: "rename", renames, bindings },
|
|
files,
|
|
});
|
|
});
|
|
} catch (e) {
|
|
// 数据库同步失败:文件改回原名,保持"路径变更与登记同事务"
|
|
await rollback();
|
|
throw e;
|
|
}
|
|
}
|
|
await audit(d, principal, "rename", {
|
|
root: body.root,
|
|
renames,
|
|
assetLocations: synced,
|
|
bindings,
|
|
recordId,
|
|
});
|
|
filesChanged(d, body.root, [pRel]);
|
|
return { dryRun: false, renames, assetLocations: synced, bindings, recordId };
|
|
}
|