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:
1 parent
50aa02cfc5
commit
12a8caa463
13 files changed
+57
-53
No files matched your search
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user