copy(server): 文件管理错误文案按 human-copy 重写

「根」统一叫登记目录;去掉「请…」;H&R 拦截不再露待处理事项 ID,改给下一步动作。

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-07 01:52:23 +08:00
1 parent 50aa02cfc5
commit 12a8caa463
13 files changed
+57 -53

No files matched your search

+10 -9
View File
@@ -131,7 +131,8 @@ export function fsErrorToFiles(e0: unknown): FilesError | null {
const code = errnoOf(e); const code = errnoOf(e);
if (typeof code !== "string" || !/^E[A-Z]+$/.test(code)) return null; if (typeof code !== "string" || !/^E[A-Z]+$/.test(code)) return null;
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线:${msg}`); if (OFFLINE_CODES.has(code))
return new FilesError("root_unavailable", `读取失败(${code}),登记目录可能已离线:${msg}`);
if (code === "ENOENT" || code === "ENOTDIR") return new FilesError("not_found", `路径已不存在(${code})`); if (code === "ENOENT" || code === "ENOTDIR") return new FilesError("not_found", `路径已不存在(${code})`);
if (code === "EEXIST") return new FilesError("exists", `目标已存在:${msg}`); if (code === "EEXIST") return new FilesError("exists", `目标已存在:${msg}`);
if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`); if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`);
@@ -225,7 +226,7 @@ export async function loadRootDefs(exec: Exec): Promise<RootDef[]> {
export async function rootDef(exec: Exec, id: string): Promise<RootDef> { export async function rootDef(exec: Exec, id: string): Promise<RootDef> {
const r = (await loadRootDefs(exec)).find((x) => x.id === id); const r = (await loadRootDefs(exec)).find((x) => x.id === id);
if (!r) throw new FilesError("not_found", `根不存在或未启用:${id}`); if (!r) throw new FilesError("not_found", `登记目录不存在或未启用:${id}`);
return r; return r;
} }
@@ -278,7 +279,7 @@ export async function probeRoot(def: RootDef, timeoutMs: number): Promise<RootHe
export async function onlineRoot(d: FmDeps, def: RootDef): Promise<string> { export async function onlineRoot(d: FmDeps, def: RootDef): Promise<string> {
const h = await probeRoot(def, d.options.healthTimeoutMs); const h = await probeRoot(def, d.options.healthTimeoutMs);
if (h.health !== "healthy" || !h.real) if (h.health !== "healthy" || !h.real)
throw new FilesError("root_unavailable", `根 ${def.name} 不可用(${h.reason ?? h.health})`); throw new FilesError("root_unavailable", `登记目录「${def.name}」不可用(${h.reason ?? h.health})`);
return h.real; return h.real;
} }
@@ -331,16 +332,16 @@ export async function resolveIn(
try { try {
parentReal = await realpath(dirname(joined)); parentReal = await realpath(dirname(joined));
} catch (e) { } catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`); if (isOfflineErr(e)) throw new FilesError("root_unavailable", `登记目录「${def.name}」读取失败(${errnoOf(e)})`);
throw new FilesError("not_found", `父目录不存在:${dirname(rel) || "/"}`); throw new FilesError("not_found", `父目录不存在:${dirname(rel) || "/"}`);
} }
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向根外:${rel}`); if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向登记目录之外:${rel}`);
const abs = join(parentReal, basename(joined)); const abs = join(parentReal, basename(joined));
let lst: Awaited<ReturnType<typeof lstat>> | null = null; let lst: Awaited<ReturnType<typeof lstat>> | null = null;
try { try {
lst = await lstat(abs); lst = await lstat(abs);
} catch (e) { } catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`); if (isOfflineErr(e)) throw new FilesError("root_unavailable", `登记目录「${def.name}」读取失败(${errnoOf(e)})`);
if (errnoOf(e) !== "ENOENT") throw new FilesError("bad_request", `无法访问 ${rel}: ${errnoOf(e)}`); if (errnoOf(e) !== "ENOENT") throw new FilesError("bad_request", `无法访问 ${rel}: ${errnoOf(e)}`);
} }
if (!lst && opts.mustExist !== false) throw new FilesError("not_found", `不存在:${rel}`); if (!lst && opts.mustExist !== false) throw new FilesError("not_found", `不存在:${rel}`);
@@ -348,7 +349,7 @@ export async function resolveIn(
if (isSymlink) { if (isSymlink) {
const target = await realpath(abs).catch(() => null); const target = await realpath(abs).catch(() => null);
if (!target || !within(target, rootReal)) if (!target || !within(target, rootReal))
throw new FilesError("path_escape", `符号链接指向根外或无法解析,不能操作:${rel}`); throw new FilesError("path_escape", `符号链接指向登记目录之外或无法解析,不能操作:${rel}`);
} }
return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink }; return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink };
} }
@@ -382,7 +383,7 @@ export async function recheck(abs: string, rootReal: string): Promise<string> {
if (isRemotePath(abs)) { if (isRemotePath(abs)) {
try { try {
const r = await vrealpath(abs); const r = await vrealpath(abs);
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外:${abs}`); if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向登记目录之外:${abs}`);
return r; return r;
} catch (e) { } catch (e) {
if (errnoOf(e) === "ENOENT") throw new FilesError("plan_stale", `源已不存在:${abs}`); if (errnoOf(e) === "ENOENT") throw new FilesError("plan_stale", `源已不存在:${abs}`);
@@ -396,7 +397,7 @@ export async function recheck(abs: string, rootReal: string): Promise<string> {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`); if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`);
throw new FilesError("plan_stale", `源已不存在:${abs}`); throw new FilesError("plan_stale", `源已不存在:${abs}`);
} }
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外:${abs}`); if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向登记目录之外:${abs}`);
return r; return r;
} }
@@ -46,7 +46,7 @@ export async function downloadFile(
): Promise<Response> { ): Promise<Response> {
// 路由中间件已按 auth:"session" 拦截;这里同口径复核(纵深防御) // 路由中间件已按 auth:"session" 拦截;这里同口径复核(纵深防御)
if (!isAdminSession(principal)) if (!isAdminSession(principal))
throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV key 不可用"); throw new FilesError("forbidden", "只有控制台管理员会话能下载,API Key / DEV key 不行");
const r = await resolveIn(d, q.root, q.path); const r = await resolveIn(d, q.root, q.path);
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录"); if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
const name = basename(r.abs); const name = basename(r.abs);
@@ -54,7 +54,7 @@ export async function downloadFile(
// FM-2:可预览的文本类(txt/log/md/json…)、.torrent、avif/gif 也允许下载 // FM-2:可预览的文本类(txt/log/md/json…)、.torrent、avif/gif 也允许下载
const pk = previewKindOf(name); const pk = previewKindOf(name);
if (!ALLOWED.has(kind) && !pk) if (!ALLOWED.has(kind) && !pk)
throw new FilesError("forbidden", `只能下载 NFO、字幕、图片、文本和 .torrent,此文件类型为 ${kind}`); throw new FilesError("forbidden", `只能下载 NFO、字幕、图片、文本和 .torrent,这个文件是 ${kind}`);
const real = await recheck(r.abs, r.rootReal); const real = await recheck(r.abs, r.rootReal);
if (r.def.storageId) return downloadRemote(d, principal, q, r.rel, real, name, kind, pk); if (r.def.storageId) return downloadRemote(d, principal, q, r.rel, real, name, kind, pk);
const file = await Deno.open(real, { read: true }); const file = await Deno.open(real, { read: true });
@@ -305,7 +305,7 @@ export async function identifyCore(
if (remoteQueries >= REMOTE_QUERY_MAX) { if (remoteQueries >= REMOTE_QUERY_MAX) {
res = { res = {
list: [], list: [],
warning: `远端查询已达上限 ${REMOTE_QUERY_MAX} 次,此项只查了本地。缩小范围后重新识别`, warning: `远端查询已达上限 ${REMOTE_QUERY_MAX} 次,这一项只查了本地。缩小范围后重新识别`,
}; };
remoteCache.set(key, res); remoteCache.set(key, res);
return res; return res;
@@ -318,7 +318,7 @@ export async function identifyCore(
return res; return res;
} }
if (opts.signal?.aborted) { if (opts.signal?.aborted) {
res = { list: [], warning: `远端查询超时(${IDENTIFY_WAIT_MAX_MS / 1000}s),此项只查了本地` }; res = { list: [], warning: `远端查询超时(${IDENTIFY_WAIT_MAX_MS / 1000}s),这一项只查了本地` };
remoteCache.set(key, res); remoteCache.set(key, res);
return res; return res;
} }
@@ -463,7 +463,7 @@ export async function identifyCore(
const m = map(parsed); const m = map(parsed);
if (m.resolution.method === "unresolved") { if (m.resolution.method === "unresolved") {
unresolved = true; unresolved = true;
warnings.push("集数对不上作品的季集,需要人工指定"); warnings.push("集数对不上作品的季集,需要手动指定");
} else shown = m.parsed; } else shown = m.parsed;
} }
} }
+1 -1
View File
@@ -492,7 +492,7 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise<FilesListRe
names.push(ent.name); names.push(ent.name);
} }
} catch (e) { } catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取目录失败(${errnoOf(e)}),根可能已离线`); if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取目录失败(${errnoOf(e)}),登记目录可能已离线`);
throw new FilesError("bad_request", `读取目录失败:${errnoOf(e) ?? String(e)}`); throw new FilesError("bad_request", `读取目录失败:${errnoOf(e) ?? String(e)}`);
} }
@@ -25,7 +25,8 @@ export async function mkdirIn(
body: FilesMkdirRequest, body: FilesMkdirRequest,
): Promise<FilesMkdirResult> { ): Promise<FilesMkdirResult> {
const r = await resolveIn(d, body.root, body.path); 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 (!(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 || "/"}`); if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
assertCleanName(body.name); assertCleanName(body.name);
await recheck(r.abs, r.rootReal); await recheck(r.abs, r.rootReal);
@@ -59,8 +60,8 @@ export async function renameEntry(
body: FilesRenameRequest, body: FilesRenameRequest,
): Promise<FilesRenameResult> { ): Promise<FilesRenameResult> {
const r = await resolveIn(d, body.root, body.path); const r = await resolveIn(d, body.root, body.path);
if (r.rel === "") throw new FilesError("bad_request", "根目录不能重命名"); if (r.rel === "") throw new FilesError("bad_request", "登记目录本身不能重命名");
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 只读,不能重命名`); if (!r.def.modifiable) throw new FilesError("not_writable", `登记目录「${r.def.name}」只读,不能重命名`);
assertCleanName(body.newName); assertCleanName(body.newName);
const oldName = basename(r.abs); const oldName = basename(r.abs);
if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同"); if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同");
@@ -71,7 +72,7 @@ export async function renameEntry(
if (hits.length > 0) if (hits.length > 0)
throw new FilesError( throw new FilesError(
"download_active", "download_active",
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理再改`, `该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。整理后再改名`,
); );
const dirAbs = dirname(r.abs); const dirAbs = dirname(r.abs);
@@ -70,7 +70,7 @@ async function editSources(
export async function saveOrganizeSource(d: FmDeps, id: string, input: Omit<OrganizeSourceConfig, "id">) { export async function saveOrganizeSource(d: FmDeps, id: string, input: Omit<OrganizeSourceConfig, "id">) {
const source = await resolveIn(d, input.source.root, input.source.path); const source = await resolveIn(d, input.source.root, input.source.path);
if (source.isSymlink || !source.lst?.isDirectory()) throw new FilesError("bad_request", "来源必须为可访问的目录"); if (source.isSymlink || !source.lst?.isDirectory()) throw new FilesError("bad_request", "来源必须是可访问的目录");
const target = await resolveIn(d, input.target.root, input.target.path); const target = await resolveIn(d, input.target.root, input.target.path);
if ( if (
target.isSymlink || target.isSymlink ||
@@ -83,7 +83,7 @@ export async function saveOrganizeSource(d: FmDeps, id: string, input: Omit<Orga
() => false, () => false,
))) )))
) )
throw new FilesError("not_writable", "目标必须为可写的纳管媒体库目录"); throw new FilesError("not_writable", "目标必须是可写的媒体库目录");
// Alist 来源(对照 MP 远程目录监控):只能手动或目录快照触发,qB 标签对网盘没有意义 // Alist 来源(对照 MP 远程目录监控):只能手动或目录快照触发,qB 标签对网盘没有意义
if (source.def.storageId && input.trigger === "qb_tag") if (source.def.storageId && input.trigger === "qb_tag")
throw new FilesError("bad_request", "Alist 来源只能用手动或目录快照触发"); throw new FilesError("bad_request", "Alist 来源只能用手动或目录快照触发");
@@ -212,7 +212,7 @@ export async function organizeFiles(
if (!episodeRange(it.episodes)) if (!episodeRange(it.episodes))
throw new FilesError("bad_request", `${it.path}:集号 ${it.episodes.join(",")} 不连续,只支持连续区间`); throw new FilesError("bad_request", `${it.path}:集号 ${it.episodes.join(",")} 不连续,只支持连续区间`);
const r = await resolveIn(d, it.root, it.path); const r = await resolveIn(d, it.root, it.path);
if (r.lst?.isDirectory()) throw new FilesError("bad_request", `${r.rel}:目录项不能指定集号,请展开后逐个视频指定`); if (r.lst?.isDirectory()) throw new FilesError("bad_request", `${r.rel}:目录不能指定集号,展开后逐个视频指定`);
} }
// G1:外部候选 → 本地作品(就地建,幂等);每项解析出 mediaEntityId // G1:外部候选 → 本地作品(就地建,幂等);每项解析出 mediaEntityId
@@ -287,11 +287,11 @@ export async function organizeFiles(
const roots = new Set<string>(); const roots = new Set<string>();
for (const it of body.items) { for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path); const r = await resolveIn(d, it.root, it.path);
if (r.rel === "") throw new FilesError("bad_request", "不能整理整个根"); if (r.rel === "") throw new FilesError("bad_request", "不能整理整个登记目录");
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接:${r.rel}`); if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接:${r.rel}`);
const action = it.operation ?? body.action; const action = it.operation ?? body.action;
if (action === "move" && !r.def.modifiable) if (action === "move" && !r.def.modifiable)
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`); throw new FilesError("not_writable", `登记目录「${r.def.name}」只读,不能移动`);
roots.add(it.root); roots.add(it.root);
const isDir = !!r.lst?.isDirectory(); const isDir = !!r.lst?.isDirectory();
const entityId = entityOf(it); const entityId = entityOf(it);
@@ -306,7 +306,7 @@ export async function organizeFiles(
if (action === "in_place") { if (action === "in_place") {
// 本次保留实际库根与路径,不使用来源配置的搬运目标。 // 本次保留实际库根与路径,不使用来源配置的搬运目标。
if (!r.def.libraryRootId) if (!r.def.libraryRootId)
throw new FilesError("bad_request", "原地整理需要文件位于已登记的媒体库目录;请先在设置中登记该目录"); throw new FilesError("bad_request", "原地整理只能用于媒体库目录里的文件。先在设置里登记这个目录");
targetLib = r.def.libraryRootId; targetLib = r.def.libraryRootId;
} }
if (action !== "in_place" && it.targetDir) { if (action !== "in_place" && it.targetDir) {
@@ -314,19 +314,19 @@ export async function organizeFiles(
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId) if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
throw new FilesError("bad_request", `目标目录必须是媒体库目录下已存在的目录:${it.targetDir.path || "/"}`); throw new FilesError("bad_request", `目标目录必须是媒体库目录下已存在的目录:${it.targetDir.path || "/"}`);
if (!(await isWritableTarget(t.def, t.rootReal))) if (!(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`); throw new FilesError("not_writable", `登记目录「${t.def.name}」不可写`);
targetDir = join(t.def.hostPath, t.rel); targetDir = join(t.def.hostPath, t.rel);
targetLib = t.def.libraryRootId; targetLib = t.def.libraryRootId;
} }
// 季/集 // 季/集
if (it.season !== undefined || it.episodes !== undefined) { if (it.season !== undefined || it.episodes !== undefined) {
if (!isTv) warnings.push(`${r.rel}:电影不适用季/集覆写,已忽略`); if (!isTv) warnings.push(`${r.rel}:电影不用指定季集,已忽略`);
else { else {
if (it.season !== undefined) ov.season = it.season; if (it.season !== undefined) ov.season = it.season;
if (it.episodes?.length) { if (it.episodes?.length) {
const range = episodeRange(it.episodes); const range = episodeRange(it.episodes);
if (!range) throw new FilesError("bad_request", `${r.rel}:集号不连续,只支持连续区间`); if (!range) throw new FilesError("bad_request", `${r.rel}:集号不连续,只支持连续区间`);
if (isDir) throw new FilesError("bad_request", `${r.rel}:目录项不能指定集号,请展开后逐个视频指定`); if (isDir) throw new FilesError("bad_request", `${r.rel}:目录不能指定集号,展开后逐个视频指定`);
ov.episodes = range; ov.episodes = range;
} }
} }
@@ -348,7 +348,7 @@ export async function organizeFiles(
warnings.push(`${r.rel}:收录版本 ${slotId} 不属于该作品,未应用`); warnings.push(`${r.rel}:收录版本 ${slotId} 不属于该作品,未应用`);
slotId = null; slotId = null;
} else if (BUSY_SLOT.has(s.status)) { } else if (BUSY_SLOT.has(s.status)) {
warnings.push(`${r.rel}:收录版本正在获取中(${s.status}),不改其状态`); warnings.push(`${r.rel}:收录版本正在下载(${s.status}),不改其状态`);
slotId = null; slotId = null;
} }
} }
@@ -550,7 +550,7 @@ export async function organizeFiles(
if (block) if (block)
throw new FilesError( throw new FilesError(
"download_active", "download_active",
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`, `下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。改用复制,或等做种完成后再移动`,
); );
} }
// 同时设为作品的季集结构:所有执行前检查(计划一致、Move 授权、H&R)通过后才切换作品 // 同时设为作品的季集结构:所有执行前检查(计划一致、Move 授权、H&R)通过后才切换作品
@@ -692,6 +692,6 @@ async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise<
.where("title", "=", "files_plan") .where("title", "=", "files_plan")
.execute(); .execute();
const hit = rows.find((r) => (r.content as { op?: string; requestHash?: string }).requestHash === reqHash); const hit = rows.find((r) => (r.content as { op?: string; requestHash?: string }).requestHash === reqHash);
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,重新预览"); if (!hit) throw new FilesError("plan_stale", "没有与这组请求一致的 planId,重新预览");
return hit.id; return hit.id;
} }
@@ -54,7 +54,7 @@ export async function previewFile(
principal: Principal | undefined, principal: Principal | undefined,
q: FilesPreviewQuery, q: FilesPreviewQuery,
): Promise<FilePreview> { ): Promise<FilePreview> {
if (!isAdminSession(principal)) throw new FilesError("forbidden", "预览仅限控制台管理员会话"); if (!isAdminSession(principal)) throw new FilesError("forbidden", "只有控制台管理员会话能预览");
const r = await resolveIn(d, q.root, q.path); const r = await resolveIn(d, q.root, q.path);
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能预览文件,不能预览目录"); if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能预览文件,不能预览目录");
const name = basename(r.abs); const name = basename(r.abs);
@@ -58,7 +58,7 @@ export async function walkSource(
if (!within(path, config.source.path) && config.source.path !== "") if (!within(path, config.source.path) && config.source.path !== "")
throw new FilesError("path_escape", "扫描路径必须在来源目录内"); throw new FilesError("path_escape", "扫描路径必须在来源目录内");
const start = await resolveIn(d, config.source.root, path); const start = await resolveIn(d, config.source.root, path);
if (start.isSymlink || !start.lst?.isDirectory()) throw new FilesError("bad_request", "来源必须为目录"); if (start.isSymlink || !start.lst?.isDirectory()) throw new FilesError("bad_request", "来源必须是目录");
if (start.def.storageId) return walkRemoteSource(start, prev); if (start.def.storageId) return walkRemoteSource(start, prev);
const files: Record<string, Omit<SnapshotFile, "accepted">> = {}; const files: Record<string, Omit<SnapshotFile, "accepted">> = {};
const stack = [start.abs]; const stack = [start.abs];
@@ -286,7 +286,7 @@ export async function scanOrganizeSource(d: FmDeps, input: { sourceId: string })
files: row?.baseline_at ? ((row.snapshot as unknown as Snapshot).files ?? {}) : {}, files: row?.baseline_at ? ((row.snapshot as unknown as Snapshot).files ?? {}) : {},
since: row?.baseline_at && row.last_scan_at ? new Date(row.last_scan_at).getTime() : null, since: row?.baseline_at && row.last_scan_at ? new Date(row.last_scan_at).getTime() : null,
}); });
if (scan.truncated) throw new FilesError("too_large", "来源超过单次扫描上限,请选择更小的目录"); if (scan.truncated) throw new FilesError("too_large", "来源超过单次扫描上限,选一个更小的目录");
const baseline = !row?.baseline_at; const baseline = !row?.baseline_at;
const advance = advanceSnapshot(baseline ? null : (row!.snapshot as unknown as Snapshot), scan.files); const advance = advanceSnapshot(baseline ? null : (row!.snapshot as unknown as Snapshot), scan.files);
let queued = 0; let queued = 0;
@@ -334,7 +334,7 @@ export async function scanOrganizeSource(d: FmDeps, input: { sourceId: string })
if (!existing) if (!existing)
await upsertDecision(d.ao, trx, { await upsertDecision(d.ao, trx, {
kind: "identity_ambiguous", kind: "identity_ambiguous",
title: `整理来源待识别:${basename(path)}`, title: `整理来源有未识别的文件:${basename(path)}`,
subjectType: "organize_source", subjectType: "organize_source",
subjectId: config.id, subjectId: config.id,
dedupeKey, dedupeKey,
@@ -29,7 +29,7 @@ function segmentsOf(rel: string) {
export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Promise<FilesSuggestNameResult> { export async function suggestName(d: FmDeps, body: FilesSuggestNameRequest): Promise<FilesSuggestNameResult> {
const db = d.ctx.db; const db = d.ctx.db;
const r = await resolveIn(d, body.root, body.path); const r = await resolveIn(d, body.root, body.path);
if (r.rel === "") throw new FilesError("bad_request", "不能给登记目录本身生成推荐名"); if (r.rel === "") throw new FilesError("bad_request", "登记目录本身不能生成推荐名");
const isDir = !!r.lst?.isDirectory(); const isDir = !!r.lst?.isDirectory();
const parsed = parseReleasePath(segmentsOf(r.rel)); const parsed = parseReleasePath(segmentsOf(r.rel));
@@ -97,13 +97,14 @@ export async function transfer(
// 目标:可写的媒体库目录(本地或 Alist),或 Alist 存储根(文件管理的 st: 根,对照 MP 存储内移动 / 复制) // 目标:可写的媒体库目录(本地或 Alist),或 Alist 存储根(文件管理的 st: 根,对照 MP 存储内移动 / 复制)
const storageTarget = t.def.kind === "storage"; const storageTarget = t.def.kind === "storage";
if ((!t.def.libraryRootId && !storageTarget) || !(await isWritableTarget(t.def, t.rootReal))) if ((!t.def.libraryRootId && !storageTarget) || !(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 可写的媒体库目录或 Alist 存储`); throw new FilesError("not_writable", `登记目录「${t.def.name}」不可写,只能选可写的媒体库目录或 Alist 存储`);
const sources: Resolved[] = []; const sources: Resolved[] = [];
for (const it of body.items) { for (const it of body.items) {
const s = await resolveIn(d, it.root, it.path); const s = await resolveIn(d, it.root, it.path);
if (s.rel === "") throw new FilesError("bad_request", "根目录不能移动或复制"); if (s.rel === "") throw new FilesError("bad_request", "登记目录本身不能移动或复制");
if (mode === "move" && !s.def.modifiable) throw new FilesError("not_writable", `源根 ${s.def.name} 只读,只能复制`); if (mode === "move" && !s.def.modifiable)
throw new FilesError("not_writable", `登记目录「${s.def.name}」只读,只能复制`);
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内:${s.rel}`); if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内:${s.rel}`);
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中:${s.rel}`); if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中:${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接:${s.rel}`); if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接:${s.rel}`);
@@ -169,7 +170,7 @@ export async function transfer(
if (storageTarget && mode === "move" && registered) if (storageTarget && mode === "move" && registered)
throw new FilesError( throw new FilesError(
"needs_retire", "needs_retire",
`${s.rel} 含已整理的文件,只能移动到媒体库目录;要放到 Alist 存储请用复制或先从媒体库移除`, `${s.rel} 含已整理的文件,只能移动到媒体库目录;要放到 Alist 存储,改用复制或先从媒体库移除`,
); );
pairs.push({ pairs.push({
source: { root: body.items[i]!.root, path: s.rel }, source: { root: body.items[i]!.root, path: s.rel },
@@ -200,7 +201,7 @@ export async function transfer(
!allowCross && !allowCross &&
pairs.some((p, i) => p.crossDevice && !isRemotePath(sources[i]!.abs) && !isRemotePath(t.abs)); pairs.some((p, i) => p.crossDevice && !isRemotePath(sources[i]!.abs) && !isRemotePath(t.abs));
const warnings: string[] = []; const warnings: string[] = [];
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启"); if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在「设置 → 整理」里开启");
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`); if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
if (truncated) warnings.push("源文件数超过上限,分批操作"); if (truncated) warnings.push("源文件数超过上限,分批操作");
const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings }; const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings };
+11 -11
View File
@@ -192,12 +192,12 @@ const BLOCK_ERR: Record<
[ConstructorParameters<typeof FilesError>[0], string] [ConstructorParameters<typeof FilesError>[0], string]
> = { > = {
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"], download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
is_root: ["bad_request", "不能删除根目录或只读根下的内容"], is_root: ["bad_request", "不能删除登记目录本身或只读目录里的内容"],
needs_retire: ["needs_retire", "含已整理文件,需勾选「同时从媒体库移除」"], needs_retire: ["needs_retire", "含已整理文件,需勾选「同时从媒体库移除」"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"], needs_unbind: ["needs_unbind", "是作品或季的绑定目录,需勾选「解除绑定」"],
root_unavailable: ["root_unavailable", "根不可用"], root_unavailable: ["root_unavailable", "登记目录不可用"],
not_found: ["not_found", "不存在"], not_found: ["not_found", "不存在"],
escape: ["path_escape", "路径指向根外"], escape: ["path_escape", "路径指向登记目录之外"],
}; };
export async function deleteFiles( export async function deleteFiles(
@@ -245,13 +245,13 @@ export async function deleteFiles(
if (body.planId) planId = (await loadPlan(d, body.planId, "delete", requestHash)).id; if (body.planId) planId = (await loadPlan(d, body.planId, "delete", requestHash)).id;
if (!isAdmin(principal)) { if (!isAdmin(principal)) {
// API Key:每次删除都要一次性审批,审批绑定 dryRun 的计划 id + 内容哈希 // API Key:每次删除都要一次性审批,审批绑定 dryRun 的计划 id + 内容哈希
if (!planId) throw new FilesError("approval_required", "API Key 删除需先 dryRun 取得 planId 并获得一次性审批"); if (!planId) throw new FilesError("approval_required", "用 API Key 删除要先 dryRun 拿到 planId,再申请一次性审批");
const plan = await loadPlan(d, planId, "delete", requestHash); const plan = await loadPlan(d, planId, "delete", requestHash);
const ok = await consumeApproval(d, plan.id, plan.contentHash); const ok = await consumeApproval(d, plan.id, plan.contentHash);
if (!ok) throw new FilesError("approval_required", `删除需要一次性审批(planType=files_plan, planId=${plan.id})`); if (!ok) throw new FilesError("approval_required", `删除需要一次性审批(planType=files_plan, planId=${plan.id})`);
} }
if (body.mode === "permanent" && body.items.length === 1 && body.confirmName !== basename(checks[0]!.path)) if (body.mode === "permanent" && body.items.length === 1 && body.confirmName !== basename(checks[0]!.path))
throw new FilesError("bad_request", "永久删除需在 confirmName 中输入文件名确认"); throw new FilesError("bad_request", "永久删除要在 confirmName 里填文件名确认");
if (planId) await retirePlan(d, planId); if (planId) await retirePlan(d, planId);
const now = d.ao.ports.now(); const now = d.ao.ports.now();
@@ -314,7 +314,7 @@ export async function deleteFiles(
await upsertDecision(d.ao, trx, { await upsertDecision(d.ao, trx, {
kind: "other", kind: "other",
title: `收录版本的文件已删除:${s.name}`, title: `收录版本的文件已删除:${s.name}`,
summary: "满足该收录版本的文件已在文件管理中删除,收录版本改为需关注。确认是否重新获取", summary: "这个收录版本的文件已在文件管理里删除,收录版本改为需关注。确认是否重新下载",
subjectType: "acquisition_slot", subjectType: "acquisition_slot",
subjectId: s.id, subjectId: s.id,
dedupeKey: `files_delete_slot:${s.id}`, dedupeKey: `files_delete_slot:${s.id}`,
@@ -553,7 +553,7 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise<void>
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`); throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`);
if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`); if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`);
if (!st) await mkdir(cur); if (!st) await mkdir(cur);
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外:${rel}`); if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向登记目录之外:${rel}`);
} }
} }
const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1)); const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1));
@@ -579,12 +579,12 @@ export async function restoreTrash(
throw new FilesError("bad_request", "还原目标必须是已存在的目录"); throw new FilesError("bad_request", "还原目标必须是已存在的目录");
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5) // "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
if (target && !(await isWritableTarget(target.def, target.rootReal))) if (target && !(await isWritableTarget(target.def, target.rootReal)))
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 可写的媒体库目录`); throw new FilesError("not_writable", `登记目录「${target.def.name}」不可写,只能还原到可写的媒体库目录`);
for (const id of body.ids) { for (const id of body.ids) {
try { try {
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst(); const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new FilesError("not_found", "回收站条目不存在"); if (!row) throw new FilesError("not_found", "回收站条目不存在");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作"); if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,已拒绝");
let rootId = row.root_id; let rootId = row.root_id;
let dst: string; let dst: string;
let rootReal: string; let rootReal: string;
@@ -608,7 +608,7 @@ export async function restoreTrash(
dst = parent.abs; dst = parent.abs;
} }
if (await (isRemotePath(dst) ? vlstat(dst) : lstat(dst)).catch(() => null)) if (await (isRemotePath(dst) ? vlstat(dst) : lstat(dst)).catch(() => null))
throw new FilesError("exists", "原位置已被占用,可选择「还原到…」"); throw new FilesError("exists", "原位置已被占用,用「还原到…」换个位置");
if ( if (
isRemotePath(dst) !== isRemotePath(row.trash_path) || isRemotePath(dst) !== isRemotePath(row.trash_path) ||
(isRemotePath(dst) && !sameStorageOf(dst, row.trash_path)) (isRemotePath(dst) && !sameStorageOf(dst, row.trash_path))
@@ -41,7 +41,8 @@ export async function uploadFile(
if (!body) throw new FilesError("bad_request", "请求体为空"); if (!body) throw new FilesError("bad_request", "请求体为空");
const r = await resolveIn(d, q.root, q.path); const r = await resolveIn(d, q.root, q.path);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`); if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
if (!(await isWritableTarget(r.def, r.rootReal))) throw new FilesError("not_writable", `根 ${r.def.name} 不可写`); if (!(await isWritableTarget(r.def, r.rootReal)))
throw new FilesError("not_writable", `登记目录「${r.def.name}」不可写`);
if (sanitizeSegment(q.name) !== q.name) if (sanitizeSegment(q.name) !== q.name)
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(q.name)}`); throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(q.name)}`);
await recheck(r.abs, r.rootReal); await recheck(r.abs, r.rootReal);