feat(files): 文件管理接入 Alist 存储(浏览、增删改、移动复制、上传下载、回收站)

- 文件管理根多出 st:<存储 id>(对照 MP 存储切换),Alist 媒体库目录照常出现在媒体库组
- 列目录一次 list,refresh 只在用户刷新时带;连不上返回 503 不当成空目录
- 新建目录 / 改名(同名不覆盖)/ 删除进 Alist 上的 .umt-trash 并可还原
- 存储内移动复制走 Alist API,本地 ↔ Alist 走整理同一套执行与账本
- 新增 PUT /files/upload(同名不覆盖,Alist 经服务端转传);下载 / 预览流式转发直链

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 c2bf8a071a
commit 9d95076a0e
24 files changed
+901 -72

No files matched your search

@@ -1,6 +1,8 @@
import { realpath } from "node:fs/promises"; import { realpath } from "node:fs/promises";
import { isAbsolute, resolve, sep } from "node:path"; import { isAbsolute, resolve, sep } from "node:path";
import type { Principal } from "../../../http/auth.ts"; import type { Principal } from "../../../http/auth.ts";
import { isRemotePath, vrealpath } from "../../../storage/vfs.ts";
import { toVPath } from "../../../storage/vpath.ts";
import { canonicalJson } from "../util.ts"; import { canonicalJson } from "../util.ts";
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts"; import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
import { hnrSatisfied } from "./retention.ts"; import { hnrSatisfied } from "./retention.ts";
@@ -41,8 +43,17 @@ export async function allowedSourceRoots(exec: Exec): Promise<string[]> {
for (const m of t.mappings) roots.add(m.hostPath); for (const m of t.mappings) roots.add(m.hostPath);
if (t.mappings.length === 0 && t.localSavePath) roots.add(t.localSavePath); if (t.mappings.length === 0 && t.localSavePath) roots.add(t.localSavePath);
} }
// Alist 存储:实例根路径(文件管理 st: 根、整理来源可选)
for (const s of await exec
.selectFrom("storage_instance")
.select(["id", "root_path"])
.where("enabled", "=", true)
.execute())
roots.add(toVPath(s.id, s.root_path));
const out: string[] = []; const out: string[] = [];
for (const r of roots) if (r && isAbsolute(r)) out.push(await real(r.replace(/\/+$/, "") || "/")); for (const r of roots)
if (r && isAbsolute(r))
out.push(isRemotePath(r) ? r.replace(/\/+$/, "") : await real(r.replace(/\/+$/, "") || "/"));
// 根目录本身("/")不能作为白名单,否则等于不设防 // 根目录本身("/")不能作为白名单,否则等于不设防
return [...new Set(out)].filter((r) => r !== "/"); return [...new Set(out)].filter((r) => r !== "/");
} }
@@ -62,7 +73,7 @@ export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
if (p.split("/").some((seg) => seg === "..")) throw new PathGuardError(`源路径不允许包含 "..": ${p}`, p); if (p.split("/").some((seg) => seg === "..")) throw new PathGuardError(`源路径不允许包含 "..": ${p}`, p);
let r: string; let r: string;
try { try {
r = await realpath(p); r = isRemotePath(p) ? await vrealpath(p) : await realpath(p);
} catch { } catch {
throw new PathGuardError(`源路径不存在或不可访问:${p}`, p); throw new PathGuardError(`源路径不存在或不可访问:${p}`, p);
} }
@@ -1,9 +1,17 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { readdir, rmdir } from "node:fs/promises"; import { readdir, rmdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path"; import { dirname, join, relative } from "node:path";
import { checkRoot, DEFAULT_ROOT_SENTINEL, statSafe } from "@umt/adapters"; import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
import { fileKindOf, type OrganizeFileKind, type OrganizePlan, type PlannedFile } from "@umt/domain"; import { fileKindOf, type OrganizeFileKind, type OrganizePlan, type PlannedFile } from "@umt/domain";
import { organizationStatusFromFiles, organizePlanToFileResults } from "@umt/mapping"; import { organizationStatusFromFiles, organizePlanToFileResults } from "@umt/mapping";
import {
isRemotePath,
vstatSafe as statSafe,
vcheckRoot,
vreaddir,
vreaddirStats,
vremove,
} from "../../../storage/vfs.ts";
import { hashJson, jsonb } from "../util.ts"; import { hashJson, jsonb } from "../util.ts";
import type { AoDeps, Exec } from "./common.ts"; import type { AoDeps, Exec } from "./common.ts";
import { type ExecuteResult, emitRecord, type FrozenPlan, type ImportRequest, type PlanResult } from "./organize.ts"; import { type ExecuteResult, emitRecord, type FrozenPlan, type ImportRequest, type PlanResult } from "./organize.ts";
@@ -27,7 +35,8 @@ export interface TransferItem {
export interface TransferSpec { export interface TransferSpec {
items: TransferItem[]; items: TransferItem[];
targetLibraryRootId: string; /** 目标库根;目标是 Alist 存储根(文件管理 st:)时为 null */
targetLibraryRootId: string | null;
/** 目标库根 realpath(相对路径以它为基准) */ /** 目标库根 realpath(相对路径以它为基准) */
targetRootPath: string; targetRootPath: string;
/** 遍历到的每个文件都经它复核(realpath 在允许的根下) */ /** 遍历到的每个文件都经它复核(realpath 在允许的根下) */
@@ -36,7 +45,7 @@ export interface TransferSpec {
export interface FrozenTransfer { export interface FrozenTransfer {
items: (TransferItem & { isDir: boolean; files: number })[]; items: (TransferItem & { isDir: boolean; files: number })[];
targetLibraryRootId: string; targetLibraryRootId: string | null;
} }
const KIND_OF = (p: string): OrganizeFileKind => { const KIND_OF = (p: string): OrganizeFileKind => {
@@ -50,6 +59,18 @@ async function walkTree(
out: { files: string[]; dirs: string[]; links: string[] }, out: { files: string[]; dirs: string[]; links: string[] },
limit: number, limit: number,
): Promise<boolean> { ): Promise<boolean> {
if (isRemotePath(dir)) {
// Alist:一次 list 拿到子项与类型,没有符号链接
for (const it of (await vreaddirStats(dir)).sort((a, b) => (a.name < b.name ? -1 : 1))) {
if (out.files.length >= limit) return true;
const p = join(dir, it.name);
if (it.stats.isDirectory()) {
out.dirs.push(p);
if (await walkTree(p, out, limit)) return true;
} else out.files.push(p);
}
return false;
}
const names = (await readdir(dir)).sort(); const names = (await readdir(dir)).sort();
for (const n of names) { for (const n of names) {
if (out.files.length >= limit) return true; if (out.files.length >= limit) return true;
@@ -71,14 +92,14 @@ export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpe
const dry = req.dryRun === true; const dry = req.dryRun === true;
const action = req.action ?? "copy"; const action = req.action ?? "copy";
const failed = (reason: string): PlanResult => ({ recordId: null, status: "failed", reason }); const failed = (reason: string): PlanResult => ({ recordId: null, status: "failed", reason });
const root = await db const lib = t.targetLibraryRootId
.selectFrom("library_root") ? await db.selectFrom("library_root").selectAll().where("id", "=", t.targetLibraryRootId).executeTakeFirst()
.selectAll() : null;
.where("id", "=", t.targetLibraryRootId) if (t.targetLibraryRootId && lib?.management_mode !== "managed") return failed("target_root_not_managed");
.executeTakeFirst(); if (!t.targetLibraryRootId && !isRemotePath(t.targetRootPath)) return failed("target_root_not_managed");
if (root?.management_mode !== "managed") return failed("target_root_not_managed"); const root = { id: lib?.id ?? null };
const sentinel = root.sentinel_file ?? DEFAULT_ROOT_SENTINEL; const sentinel = isRemotePath(t.targetRootPath) ? "" : (lib?.sentinel_file ?? DEFAULT_ROOT_SENTINEL);
const health = await checkRoot(t.targetRootPath, sentinel); const health = await vcheckRoot(t.targetRootPath, sentinel);
if (health.kind === "offline") return failed(`library_root_offline:${health.reason}`); if (health.kind === "offline") return failed(`library_root_offline:${health.reason}`);
const max = d.ports.options.walkMaxFiles; const max = d.ports.options.walkMaxFiles;
@@ -148,7 +169,7 @@ export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpe
organize: plan, organize: plan,
entityId: req.mediaEntityId ?? "", entityId: req.mediaEntityId ?? "",
mediaType: "movie", mediaType: "movie",
libraryRootId: root.id, libraryRootId: root.id ?? "",
rootPath: t.targetRootPath, rootPath: t.targetRootPath,
sentinel, sentinel,
targetDirs: [...dirs].sort(), targetDirs: [...dirs].sort(),
@@ -162,7 +183,7 @@ export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpe
}; };
if (dry) return { recordId: null, status: "queued", reason: null, plan: frozen, ...(truncated ? { truncated } : {}) }; if (dry) return { recordId: null, status: "queued", reason: null, plan: frozen, ...(truncated ? { truncated } : {}) };
const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id }); const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id ?? "" });
const now = d.ports.now(); const now = d.ports.now();
const rel = (abs: string | null | undefined) => (abs ? relative(t.targetRootPath, abs) : null); const rel = (abs: string | null | undefined) => (abs ? relative(t.targetRootPath, abs) : null);
const heldSrc = new Set(files.filter((f) => f.action === "hold").map((f) => f.src)); const heldSrc = new Set(files.filter((f) => f.action === "hold").map((f) => f.src));
@@ -321,6 +342,15 @@ export async function relocate(trx: Exec, input: RelocateInput): Promise<Relocat
/** 目录下的子目录,深的在前(删空目录用) */ /** 目录下的子目录,深的在前(删空目录用) */
async function dirsDeepFirst(dir: string): Promise<string[]> { async function dirsDeepFirst(dir: string): Promise<string[]> {
const out: string[] = []; const out: string[] = [];
if (isRemotePath(dir)) {
const walk = async (p: string) => {
const ents = await vreaddirStats(p).catch(() => []);
for (const e of ents) if (e.stats.isDirectory()) await walk(join(p, e.name));
out.push(p);
};
await walk(dir);
return out;
}
const walk = async (p: string) => { const walk = async (p: string) => {
const ents = await readdir(p, { withFileTypes: true }).catch(() => []); const ents = await readdir(p, { withFileTypes: true }).catch(() => []);
for (const e of ents) if (e.isDirectory()) await walk(join(p, e.name)); for (const e of ents) if (e.isDirectory()) await walk(join(p, e.name));
@@ -330,6 +360,16 @@ async function dirsDeepFirst(dir: string): Promise<string[]> {
return out; return out;
} }
/** 只删空目录(Alist 的 remove 会递归删,先确认为空) */
async function removeEmptyDir(dir: string): Promise<void> {
if (!isRemotePath(dir)) {
await rmdir(dir).catch(() => undefined);
return;
}
const left = await vreaddir(dir).catch(() => null);
if (left && left.length === 0) await vremove(dir).catch(() => undefined);
}
/** 原样搬运的终态:记录状态 + move 的位置跟随/绑定修订 + 删源侧空目录;行锁 fencing,重复调用幂等 */ /** 原样搬运的终态:记录状态 + move 的位置跟随/绑定修订 + 删源侧空目录;行锁 fencing,重复调用幂等 */
export async function finalizeTransfer(d: AoDeps, recordId: string): Promise<ExecuteResult> { export async function finalizeTransfer(d: AoDeps, recordId: string): Promise<ExecuteResult> {
const db = d.ctx.db; const db = d.ctx.db;
@@ -351,7 +391,7 @@ export async function finalizeTransfer(d: AoDeps, recordId: string): Promise<Exe
// move:整个目录都搬完才删源侧空目录(先于终态提交;失败/中断重跑幂等) // move:整个目录都搬完才删源侧空目录(先于终态提交;失败/中断重跑幂等)
if (pre.action === "move" && (pre.status === "running" || pre.status === "queued")) if (pre.action === "move" && (pre.status === "running" || pre.status === "queued"))
for (const it of tr.items.filter((x) => x.isDir && allOk(x))) for (const it of tr.items.filter((x) => x.isDir && allOk(x)))
for (const dir of await dirsDeepFirst(it.src)) await rmdir(dir).catch(() => undefined); for (const dir of await dirsDeepFirst(it.src)) await removeEmptyDir(dir);
return db.transaction().execute(async (trx) => { return db.transaction().execute(async (trx) => {
const rec = await trx const rec = await trx
@@ -373,9 +413,9 @@ export async function finalizeTransfer(d: AoDeps, recordId: string): Promise<Exe
if (rec.action === "move") { if (rec.action === "move") {
const actor = `organize:${recordId}`; const actor = `organize:${recordId}`;
for (const it of t.items) { for (const it of t.items) {
if (it.srcRel === null || it.srcLibraryRootId === null) continue; if (it.srcRel === null || it.srcLibraryRootId === null || t.targetLibraryRootId === null) continue;
const mine = files.filter((f) => under(it, f.source_path)); const mine = files.filter((f) => under(it, f.source_path));
const base = { fromLib: it.srcLibraryRootId, toLib: t.targetLibraryRootId, actor, opKind: "move" as const }; const base = { fromLib: it.srcLibraryRootId, toLib: t.targetLibraryRootId!, actor, opKind: "move" as const };
if (mine.length > 0 && mine.every((f) => f.status === "succeeded")) { if (mine.length > 0 && mine.every((f) => f.status === "succeeded")) {
// 整项搬完:按前缀跟随(目录含路径绑定修订) // 整项搬完:按前缀跟随(目录含路径绑定修订)
const r = await relocate(trx, { ...base, fromRel: it.srcRel, toRel: it.dstRel, isDir: it.isDir }); const r = await relocate(trx, { ...base, fromRel: it.srcRel, toRel: it.dstRel, isDir: it.isDir });
+78 -5
View File
@@ -1,10 +1,13 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { lstat, realpath, stat } from "node:fs/promises"; import { lstat, realpath, stat } from "node:fs/promises";
import { basename, dirname, join, sep } from "node:path"; import { basename, dirname, join, sep } from "node:path";
import { checkRoot, DEFAULT_ROOT_SENTINEL, type TmdbClient } from "@umt/adapters"; import { AlistError, checkRoot, DEFAULT_ROOT_SENTINEL, type TmdbClient } from "@umt/adapters";
import type { ApiErrorCode, FileRootId, FilesErrorReason } from "@umt/contracts"; import type { ApiErrorCode, FileRootId, FilesErrorReason } from "@umt/contracts";
import type { LibraryRootRow } from "@umt/db"; import type { LibraryRootRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { listStorages } from "../../storage/registry.ts";
import { isRemotePath, remoteError, vcheckRoot, vlstat, vrealpath } from "../../storage/vfs.ts";
import { parseVPath, toVPath } from "../../storage/vpath.ts";
import type { AoDeps, Exec } from "../acquisition-organize/services/common.ts"; import type { AoDeps, Exec } from "../acquisition-organize/services/common.ts";
import { loadInstances } from "../acquisition-organize/services/common.ts"; import { loadInstances } from "../acquisition-organize/services/common.ts";
import type { SourcesRuntime } from "../sources-identity/runtime.ts"; import type { SourcesRuntime } from "../sources-identity/runtime.ts";
@@ -122,13 +125,15 @@ export const isOfflineErr = (e: unknown) => OFFLINE_CODES.has(errnoOf(e) ?? "");
* 处理器里漏出的文件系统错误(检查与使用之间目录被删、挂载掉线等竞态)→ 业务错误,而不是 500: * 处理器里漏出的文件系统错误(检查与使用之间目录被删、挂载掉线等竞态)→ 业务错误,而不是 500:
* 离线类 → 503 root_unavailable(失败 ≠ 空目录),ENOENT/ENOTDIR → 404,EACCES/EPERM → 403,其余 errno → 400。 * 离线类 → 503 root_unavailable(失败 ≠ 空目录),ENOENT/ENOTDIR → 404,EACCES/EPERM → 403,其余 errno → 400。
*/ */
export function fsErrorToFiles(e: unknown): FilesError | null { export function fsErrorToFiles(e0: unknown): FilesError | null {
if (e instanceof FilesError) return e; if (e0 instanceof FilesError) return e0;
const e = e0 instanceof AlistError ? remoteError(e0) : e0;
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 === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`); if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`);
return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`); return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`);
} }
@@ -137,7 +142,7 @@ export function fsErrorToFiles(e: unknown): FilesError | null {
export interface RootDef { export interface RootDef {
id: FileRootId; id: FileRootId;
kind: "library" | "download"; kind: "library" | "download" | "storage";
name: string; name: string;
hostPath: string; hostPath: string;
mediaKind: LibraryRootRow["media_kind"] | null; mediaKind: LibraryRootRow["media_kind"] | null;
@@ -146,6 +151,9 @@ export interface RootDef {
libraryRootId: string | null; libraryRootId: string | null;
instanceId: string | null; instanceId: string | null;
sentinel: string | null; sentinel: string | null;
/** 所在 Alist 存储实例(本机为 null);hostPath 是虚拟路径 /@alist/<id>/… */
storageId: string | null;
storageName: string | null;
} }
const norm = (p: string) => p.replace(/\/+$/, "") || "/"; const norm = (p: string) => p.replace(/\/+$/, "") || "/";
@@ -153,10 +161,14 @@ const norm = (p: string) => p.replace(/\/+$/, "") || "/";
/** 已登记的根:启用的库根 + 下载器路径映射本机前缀(无映射 = 同机,取保存路径);"/" 永不出现 */ /** 已登记的根:启用的库根 + 下载器路径映射本机前缀(无映射 = 同机,取保存路径);"/" 永不出现 */
export async function loadRootDefs(exec: Exec): Promise<RootDef[]> { export async function loadRootDefs(exec: Exec): Promise<RootDef[]> {
const out: RootDef[] = []; const out: RootDef[] = [];
const storages = await listStorages(exec as never).catch(() => []);
const storageName = new Map(storages.map((s) => [s.id, s.name]));
const libs = await exec.selectFrom("library_root").selectAll().where("enabled", "=", true).orderBy("name").execute(); const libs = await exec.selectFrom("library_root").selectAll().where("enabled", "=", true).orderBy("name").execute();
for (const r of libs) { for (const r of libs) {
const hostPath = norm(r.local_path); const hostPath = norm(r.local_path);
if (hostPath === "/" || !hostPath.startsWith("/")) continue; if (hostPath === "/" || !hostPath.startsWith("/")) continue;
const v = parseVPath(hostPath);
if (isRemotePath(hostPath) && !v) continue;
out.push({ out.push({
id: `lib:${r.id}`, id: `lib:${r.id}`,
kind: "library", kind: "library",
@@ -166,7 +178,9 @@ export async function loadRootDefs(exec: Exec): Promise<RootDef[]> {
modifiable: r.management_mode === "managed", modifiable: r.management_mode === "managed",
libraryRootId: r.id, libraryRootId: r.id,
instanceId: null, instanceId: null,
sentinel: r.sentinel_file ?? DEFAULT_ROOT_SENTINEL, sentinel: v ? null : (r.sentinel_file ?? DEFAULT_ROOT_SENTINEL),
storageId: v?.storageId ?? null,
storageName: v ? (storageName.get(v.storageId) ?? null) : null,
}); });
} }
for (const t of await loadInstances(exec, { enabledOnly: false })) { for (const t of await loadInstances(exec, { enabledOnly: false })) {
@@ -184,9 +198,28 @@ export async function loadRootDefs(exec: Exec): Promise<RootDef[]> {
libraryRootId: null, libraryRootId: null,
instanceId: t.inst.id, instanceId: t.inst.id,
sentinel: null, sentinel: null,
storageId: null,
storageName: null,
}); });
}); });
} }
// Alist 存储(对照 MP 存储切换):整个实例从根路径开始可浏览
for (const s of storages) {
if (!s.enabled) continue;
out.push({
id: `st:${s.id}`,
kind: "storage",
name: s.name,
hostPath: toVPath(s.id, s.root_path),
mediaKind: null,
modifiable: true,
libraryRootId: null,
instanceId: null,
sentinel: null,
storageId: s.id,
storageName: s.name,
});
}
return out; return out;
} }
@@ -211,6 +244,11 @@ const withTimeout = <T>(p: Promise<T>, ms: number, fallback: T): Promise<T> =>
export async function probeRoot(def: RootDef, timeoutMs: number): Promise<RootHealthView> { export async function probeRoot(def: RootDef, timeoutMs: number): Promise<RootHealthView> {
const checkedAt = new Date(); const checkedAt = new Date();
const run = async (): Promise<RootHealthView> => { const run = async (): Promise<RootHealthView> => {
if (def.storageId) {
const h = await vcheckRoot(def.hostPath);
if (h.kind === "offline") return { health: "offline", reason: h.reason, checkedAt, real: null };
return { health: "healthy", reason: null, checkedAt, real: def.hostPath };
}
if (def.sentinel) { if (def.sentinel) {
const h = await checkRoot(def.hostPath, def.sentinel); const h = await checkRoot(def.hostPath, def.sentinel);
if (h.kind === "offline") return { health: "offline", reason: h.reason, checkedAt, real: null }; if (h.kind === "offline") return { health: "offline", reason: h.reason, checkedAt, real: null };
@@ -283,6 +321,7 @@ export async function resolveIn(
const def = opts.def ?? (await rootDef(d.ctx.db, rootId)); const def = opts.def ?? (await rootDef(d.ctx.db, rootId));
const rootReal = opts.rootReal ?? (await onlineRoot(d, def)); const rootReal = opts.rootReal ?? (await onlineRoot(d, def));
const rel = checkRel(relIn); const rel = checkRel(relIn);
if (def.storageId) return resolveRemote(def, rootReal, rel, opts.mustExist !== false);
if (rel === "") { if (rel === "") {
const lst = await lstat(rootReal).catch(() => null); const lst = await lstat(rootReal).catch(() => null);
return { def, rootReal, rel, abs: rootReal, lst, isSymlink: false }; return { def, rootReal, rel, abs: rootReal, lst, isSymlink: false };
@@ -314,8 +353,42 @@ export async function resolveIn(
return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink }; return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink };
} }
/** 远端根:没有符号链接,路径即 realpath;lst 是 vfs 的 stat(不存在为 null,连不上抛 root_unavailable) */
async function resolveRemote(def: RootDef, rootReal: string, rel: string, mustExist: boolean): Promise<Resolved> {
const abs = rel === "" ? rootReal : join(rootReal, rel);
let lst: Resolved["lst"] = null;
try {
lst = (await vlstat(abs)) as unknown as Resolved["lst"];
} catch (e) {
if (errnoOf(e) !== "ENOENT") throw remoteFilesError(e, def);
}
if (!lst && mustExist) throw new FilesError("not_found", `不存在:${rel}`);
return { def, rootReal, rel, abs, lst, isSymlink: false };
}
/** 远端错误 → 业务错误(连不上 / 超时 → 503 根不可用;鉴权 → 403;冲突 → 409) */
export function remoteFilesError(e: unknown, def?: RootDef): FilesError {
if (e instanceof FilesError) return e;
const code = errnoOf(e);
const msg = e instanceof Error ? e.message : String(e);
if (code === "ENOENT") return new FilesError("not_found", msg);
if (code === "EEXIST") return new FilesError("exists", msg);
if (code === "EACCES") return new FilesError("forbidden", msg);
return new FilesError("root_unavailable", `${def ? `存储 ${def.storageName ?? def.name} ` : ""}不可用:${msg}`);
}
/** 执行阶段复核(TOCTOU):再次 realpath,必须仍在根内 */ /** 执行阶段复核(TOCTOU):再次 realpath,必须仍在根内 */
export async function recheck(abs: string, rootReal: string): Promise<string> { export async function recheck(abs: string, rootReal: string): Promise<string> {
if (isRemotePath(abs)) {
try {
const r = await vrealpath(abs);
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外:${abs}`);
return r;
} catch (e) {
if (errnoOf(e) === "ENOENT") throw new FilesError("plan_stale", `源已不存在:${abs}`);
throw remoteFilesError(e);
}
}
let r: string; let r: string;
try { try {
r = await realpath(abs); r = await realpath(abs);
@@ -1,6 +1,7 @@
import { basename, extname } from "node:path"; import { basename, extname } from "node:path";
import type { FilesDownloadQuery } from "@umt/contracts"; import type { FilesDownloadQuery } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { remote, remoteError } from "../../storage/vfs.ts";
import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts"; import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts";
import { audit } from "./ledger.ts"; import { audit } from "./ledger.ts";
import { kindOfName } from "./list.ts"; import { kindOfName } from "./list.ts";
@@ -55,6 +56,7 @@ export async function downloadFile(
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);
const file = await Deno.open(real, { read: true }); const file = await Deno.open(real, { read: true });
let size: number; let size: number;
try { try {
@@ -86,3 +88,47 @@ export async function downloadFile(
}, },
}); });
} }
/** Alist 文件:经服务端流式转发(不落本地盘,同一下载上限与类型限制;MP 是先下到临时目录再回传) */
async function downloadRemote(
d: FmDeps,
principal: Principal | undefined,
q: FilesDownloadQuery,
rel: string,
abs: string,
name: string,
kind: string,
pk: ReturnType<typeof previewKindOf>,
): Promise<Response> {
const { v, client } = await remote(abs);
let size: number;
let res: Response;
try {
const o = await client.get(v.remote);
if (o.isDir) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
size = o.size;
if (size > d.options.downloadMaxBytes)
throw new FilesError(
"too_large",
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
);
res = await client.openRead(v.remote);
} catch (e) {
if (e instanceof FilesError) throw e;
throw remoteError(e, abs);
}
const ext = extname(name).replace(/^\./, "").toLowerCase();
const inline = q.inline && (kind === "image" || pk === "image") && ext !== "svg";
await audit(d, principal, "download", { root: q.root, path: rel, size, inline });
return new Response(res.body, {
status: 200,
headers: {
"content-type": (ext !== "svg" && MIME[ext]) || "application/octet-stream",
...(size ? { "content-length": String(size) } : {}),
"content-disposition": disposition(inline ? "inline" : "attachment", name),
"x-content-type-options": "nosniff",
"content-security-policy": "default-src 'none'; sandbox",
"cache-control": "private, no-store",
},
});
}
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { readdir, stat } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { import {
EventType, EventType,
@@ -21,6 +20,7 @@ import {
} from "@umt/domain"; } from "@umt/domain";
import { localIdentityCandidate } from "@umt/mapping"; import { localIdentityCandidate } from "@umt/mapping";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { vlstat as stat, vreaddirents } from "../../storage/vfs.ts";
import { successfulOrganizeCounts } from "../acquisition-organize/services/organize-history.ts"; import { successfulOrganizeCounts } from "../acquisition-organize/services/organize-history.ts";
import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts"; import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts";
import { createEntityEpisodeMapper, type EntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts"; import { createEntityEpisodeMapper, type EntityEpisodeMapper } from "../sources-identity/service/episode-resolution.ts";
@@ -90,7 +90,7 @@ async function collectVideos(d: FmDeps, body: FilesIdentifyRequest): Promise<{ v
const stack = [r.rel]; const stack = [r.rel];
while (stack.length > 0) { while (stack.length > 0) {
const rel = stack.shift()!; const rel = stack.shift()!;
const names = (await readdir(join(r.rootReal, rel), { withFileTypes: true }).catch(() => [])).sort((a, b) => const names = (await vreaddirents(join(r.rootReal, rel)).catch(() => [])).sort((a, b) =>
a.name.localeCompare(b.name), a.name.localeCompare(b.name),
); );
for (const n of names) { for (const n of names) {
@@ -3,6 +3,7 @@ import type { TmdbClient } from "@umt/adapters";
import { FilesIdentifyRequest } from "@umt/contracts"; import { FilesIdentifyRequest } from "@umt/contracts";
import { defineTask } from "@umt/tasks"; import { defineTask } from "@umt/tasks";
import { z } from "zod"; import { z } from "zod";
import { configureStorage } from "../../storage/registry.ts";
import type { AoOverrides } from "../acquisition-organize/index.ts"; import type { AoOverrides } from "../acquisition-organize/index.ts";
import { createPorts } from "../acquisition-organize/ports.ts"; import { createPorts } from "../acquisition-organize/ports.ts";
import { registerScheduledJob } from "../schedules/registry.ts"; import { registerScheduledJob } from "../schedules/registry.ts";
@@ -57,10 +58,12 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu
return deps; return deps;
}, },
registerRoutes(app, ctx) { registerRoutes(app, ctx) {
configureStorage(ctx);
registerFileRoutes(app, lazy(ctx)); registerFileRoutes(app, lazy(ctx));
registerOrganizeSourceRoutes(app, lazy(ctx)); registerOrganizeSourceRoutes(app, lazy(ctx));
}, },
registerTasks(registry, ctx) { registerTasks(registry, ctx) {
configureStorage(ctx);
registry.register( registry.register(
defineTask({ defineTask({
name: "scanOrganizeSource", name: "scanOrganizeSource",
+99 -12
View File
@@ -17,6 +17,8 @@ import type {
} from "@umt/contracts"; } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import { fileKindOf } from "@umt/domain"; import { fileKindOf } from "@umt/domain";
import { storageEntry } from "../../storage/registry.ts";
import { isRemotePath, vlstat, vreaddir, vreaddirStats, vrealpath } from "../../storage/vfs.ts";
import { hostPathOf, loadInstances } from "../acquisition-organize/services/common.ts"; import { hostPathOf, loadInstances } from "../acquisition-organize/services/common.ts";
import { hnrSatisfied } from "../acquisition-organize/services/retention.ts"; import { hnrSatisfied } from "../acquisition-organize/services/retention.ts";
import { import {
@@ -29,6 +31,7 @@ import {
probeRoot, probeRoot,
type RootDef, type RootDef,
relOf, relOf,
remoteFilesError,
resolveIn, resolveIn,
within, within,
} from "./common.ts"; } from "./common.ts";
@@ -43,7 +46,11 @@ export async function listRoots(d: FmDeps): Promise<{ items: FileRoot[] }> {
const h = await probeRoot(def, d.options.healthTimeoutMs); const h = await probeRoot(def, d.options.healthTimeoutMs);
let writable = false; let writable = false;
let usage: FileRoot["usage"] = null; let usage: FileRoot["usage"] = null;
if (h.real) { if (h.real && def.storageId) {
writable = await isWritableTarget(def, h.real);
const u = await storageUsage(def.storageId, def.hostPath);
if (u) usage = { totalBytes: u.total, freeBytes: u.free };
} else if (h.real) {
writable = await isWritableTarget(def, h.real); writable = await isWritableTarget(def, h.real);
const du = await d.ao.ports.diskUsage(h.real).catch(() => null); const du = await d.ao.ports.diskUsage(h.real).catch(() => null);
if (du?.status === "ok") usage = { totalBytes: du.totalBytes, freeBytes: du.availableBytes }; if (du?.status === "ok") usage = { totalBytes: du.totalBytes, freeBytes: du.availableBytes };
@@ -59,6 +66,13 @@ export async function listRoots(d: FmDeps): Promise<{ items: FileRoot[] }> {
checkedAt: h.checkedAt.toISOString(), checkedAt: h.checkedAt.toISOString(),
healthReason: h.reason, healthReason: h.reason,
usage, usage,
storage: def.storageId
? {
id: def.storageId,
name: def.storageName ?? def.name,
path: def.hostPath.replace(/^\/@alist\/[0-9a-f-]{36}/i, "") || "/",
}
: null,
...(def.kind === "download" && h.real && h.health !== "offline" ...(def.kind === "download" && h.real && h.health !== "offline"
? await pendingOf(d, def.id, h.real) ? await pendingOf(d, def.id, h.real)
: { pendingCount: null, pendingPartial: false }), : { pendingCount: null, pendingPartial: false }),
@@ -68,6 +82,23 @@ export async function listRoots(d: FmDeps): Promise<{ items: FileRoot[] }> {
return { items }; return { items };
} }
/** Alist 容量(拿不到为 null,不报错;缓存 5 分钟,避免每次列根都打 admin 接口) */
const usageCache = new Map<string, { at: number; v: { total: number; free: number } | null }>();
async function storageUsage(storageId: string, hostPath: string) {
const hit = usageCache.get(hostPath);
if (hit && Date.now() - hit.at < 5 * 60_000) return hit.v;
let v: { total: number; free: number } | null = null;
try {
const e = await storageEntry(storageId);
const remote = hostPath.replace(/^\/@alist\/[0-9a-f-]{36}/i, "") || "/";
v = await e.client.usage(remote);
} catch {
v = null;
}
usageCache.set(hostPath, { at: Date.now(), v });
return v;
}
// ── G6:下载目录待识别计数(缓存 5 分钟;文件变更事件使对应根失效) ── // ── G6:下载目录待识别计数(缓存 5 分钟;文件变更事件使对应根失效) ──
const PENDING_TTL_MS = 5 * 60_000; const PENDING_TTL_MS = 5 * 60_000;
@@ -121,6 +152,7 @@ async function canWrite(p: string): Promise<boolean> {
} }
export async function isWritableTarget(def: RootDef, rootReal: string): Promise<boolean> { export async function isWritableTarget(def: RootDef, rootReal: string): Promise<boolean> {
if (def.storageId) return (def.kind === "library" || def.kind === "storage") && def.modifiable;
return def.kind === "library" && def.modifiable && (await canWrite(rootReal)); return def.kind === "library" && def.modifiable && (await canWrite(rootReal));
} }
@@ -144,7 +176,7 @@ export function kindOfName(name: string): FileKindTag {
async function rawOf(abs: string, rel: string, rootReal: string): Promise<RawEntry | null> { async function rawOf(abs: string, rel: string, rootReal: string): Promise<RawEntry | null> {
let st: Awaited<ReturnType<typeof lstat>>; let st: Awaited<ReturnType<typeof lstat>>;
try { try {
st = await lstat(abs); st = (isRemotePath(abs) ? await vlstat(abs) : await lstat(abs)) as Awaited<ReturnType<typeof lstat>>;
} catch (e) { } catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`); if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`);
return null; // 读取期间被删除 return null; // 读取期间被删除
@@ -436,6 +468,7 @@ const matchFilter = (e: FileEntry, f: FileFilter) => {
export async function listDir(d: FmDeps, q: FilesListQuery): Promise<FilesListResponse> { export async function listDir(d: FmDeps, q: FilesListQuery): Promise<FilesListResponse> {
const r = await resolveIn(d, q.root, q.path); const r = await resolveIn(d, q.root, q.path);
if (r.def.storageId) return listRemoteDir(d, q, r);
if (r.isSymlink || !r.lst?.isDirectory()) { if (r.isSymlink || !r.lst?.isDirectory()) {
const st = await stat(r.abs).catch(() => null); const st = await stat(r.abs).catch(() => null);
if (!st?.isDirectory()) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`); if (!st?.isDirectory()) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
@@ -463,13 +496,7 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise<FilesListRe
throw new FilesError("bad_request", `读取目录失败:${errnoOf(e) ?? String(e)}`); throw new FilesError("bad_request", `读取目录失败:${errnoOf(e) ?? String(e)}`);
} }
let pool = names; const pool = filterNames(names, q.q);
if (q.q) {
const needle = q.q;
const re = /[*?]/.test(needle) ? globToRegex(needle) : null;
const lower = needle.toLowerCase();
pool = names.filter((n) => (re ? re.test(n) : n.toLowerCase().includes(lower)));
}
const raws: RawEntry[] = []; const raws: RawEntry[] = [];
for (let i = 0; i < pool.length; i += 64) { for (let i = 0; i < pool.length; i += 64) {
const batch = await Promise.all( const batch = await Promise.all(
@@ -477,6 +504,66 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise<FilesListRe
); );
for (const b of batch) if (b) raws.push(b); for (const b of batch) if (b) raws.push(b);
} }
return finishList(d, q, r, raws, dirMtime, cursor, truncated);
}
function filterNames(names: string[], needle: string | undefined): string[] {
if (!needle) return names;
const re = /[*?]/.test(needle) ? globToRegex(needle) : null;
const lower = needle.toLowerCase();
return names.filter((n) => (re ? re.test(n) : n.toLowerCase().includes(lower)));
}
/** Alist 目录:一次 list 拿全(自动翻页),refresh 只在用户点刷新时带(§9-8);不做递归目录摘要(避免逐目录打网盘接口) */
async function listRemoteDir(
d: FmDeps,
q: FilesListQuery,
r: Awaited<ReturnType<typeof resolveIn>>,
): Promise<FilesListResponse> {
if (!r.lst?.isDirectory()) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
const cursor = decodeCursor(q.cursor);
const dirMtime = Number(r.lst.mtimeMs) || 0;
let items: Awaited<ReturnType<typeof vreaddirStats>>;
try {
items = await vreaddirStats(r.abs, { refresh: q.refresh === true });
} catch (e) {
throw remoteFilesError(e, r.def);
}
const visible = items.filter((it) => q.hidden || !isHidden(it.name, r.def));
const truncated = visible.length > d.options.listMaxEntries;
const keep = new Set(
filterNames(
visible.slice(0, d.options.listMaxEntries).map((it) => it.name),
q.q,
),
);
const raws: RawEntry[] = visible
.filter((it) => keep.has(it.name))
.map((it) => {
const abs = join(r.abs, it.name);
const dir = it.stats.isDirectory();
return {
name: it.name,
rel: relOf(r.rootReal, abs),
abs,
type: dir ? "dir" : "file",
size: dir ? 0 : Number(it.stats.size),
mtimeMs: Number(it.stats.mtimeMs) || null,
escape: false,
} satisfies RawEntry;
});
return finishList(d, q, r, raws, dirMtime, cursor, truncated);
}
async function finishList(
d: FmDeps,
q: FilesListQuery,
r: Awaited<ReturnType<typeof resolveIn>>,
raws: RawEntry[],
dirMtime: number,
cursor: { o: number; m: number } | null,
truncated: boolean,
): Promise<FilesListResponse> {
const downloads = await managedDownloads(d); const downloads = await managedDownloads(d);
let entries = await annotate(d, r.def, raws, downloads); let entries = await annotate(d, r.def, raws, downloads);
if (q.filter?.length) entries = entries.filter((e) => q.filter!.some((f) => matchFilter(e, f))); if (q.filter?.length) entries = entries.filter((e) => q.filter!.some((f) => matchFilter(e, f)));
@@ -512,7 +599,7 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise<FilesListRe
/** 当前页目录的懒摘要:递归数视频(上限 dirSummaryMax 项)+ 一次查询数已登记 */ /** 当前页目录的懒摘要:递归数视频(上限 dirSummaryMax 项)+ 一次查询数已登记 */
async function fillDirSummaries(d: FmDeps, def: RootDef, rootReal: string, page: FileEntry[]) { async function fillDirSummaries(d: FmDeps, def: RootDef, rootReal: string, page: FileEntry[]) {
const dirs = page.filter((e) => e.type === "dir"); const dirs = page.filter((e) => e.type === "dir");
if (dirs.length === 0) return; if (dirs.length === 0 || def.storageId) return;
const reg = new Map<string, number>(); const reg = new Map<string, number>();
if (def.kind === "library") { if (def.kind === "library") {
const likes = dirs.map((e) => `${e.path.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`); const likes = dirs.map((e) => `${e.path.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`);
@@ -558,7 +645,7 @@ export async function sidecarsOf(abs: string): Promise<string[]> {
const name = basename(abs); const name = basename(abs);
const stem = name.slice(0, name.length - extname(name).length); const stem = name.slice(0, name.length - extname(name).length);
if (!stem) return []; if (!stem) return [];
const names = await readdir(dirname(abs)).catch(() => [] as string[]); const names = await vreaddir(dirname(abs)).catch(() => [] as string[]);
return names.filter((n) => { return names.filter((n) => {
if (n === name) return false; if (n === name) return false;
if (!(n.startsWith(`${stem}.`) || n.startsWith(`${stem}-`))) return false; if (!(n.startsWith(`${stem}.`) || n.startsWith(`${stem}-`))) return false;
@@ -602,7 +689,7 @@ export async function statEntry(d: FmDeps, root: string, path: string): Promise<
const [entry] = await annotate(d, r.def, [raw]); const [entry] = await annotate(d, r.def, [raw]);
if (entry!.type === "dir") await fillDirSummaries(d, r.def, r.rootReal, [entry!]); if (entry!.type === "dir") await fillDirSummaries(d, r.def, r.rootReal, [entry!]);
const st = r.lst!; const st = r.lst!;
const real = await realpath(r.abs).catch(() => r.abs); const real = await vrealpath(r.abs).catch(() => r.abs);
const nlink = Number(st.nlink); const nlink = Number(st.nlink);
return { return {
entry: entry!, entry: entry!,
+15 -8
View File
@@ -1,9 +1,9 @@
import { lstat, mkdir } from "node:fs/promises"; import { lstat, mkdir } from "node:fs/promises";
import { basename, dirname, extname, join } from "node:path"; import { basename, dirname, extname, join } from "node:path";
import { moveSafe } from "@umt/adapters";
import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts"; import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts";
import { sanitizeSegment } from "@umt/domain"; import { sanitizeSegment } from "@umt/domain";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { isRemotePath, vexists, vlstat, vmkdirp, vrenameNoClobber } from "../../storage/vfs.ts";
import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts"; import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
import { audit, filesChanged } from "./ledger.ts"; import { audit, filesChanged } from "./ledger.ts";
import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts"; import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts";
@@ -31,7 +31,10 @@ export async function mkdirIn(
await recheck(r.abs, r.rootReal); await recheck(r.abs, r.rootReal);
const abs = join(r.abs, body.name); const abs = join(r.abs, body.name);
try { try {
await mkdir(abs); if (r.def.storageId) {
if (await vexists(abs)) throw Object.assign(new Error("exists"), { code: "EEXIST" });
await vmkdirp(abs);
} else await mkdir(abs);
} catch (e) { } catch (e) {
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`); if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`);
throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`); throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`);
@@ -88,7 +91,9 @@ export async function renameEntry(
// 不覆盖:任一目标已存在 → 409,原文件不动 // 不覆盖:任一目标已存在 → 409,原文件不动
const taken: string[] = []; const taken: string[] = [];
for (const p of pairs) { for (const p of pairs) {
const st = await lstat(p.toAbs).catch((e) => (errnoOf(e) === "ENOENT" ? null : e)); const st = await (isRemotePath(p.toAbs) ? vlstat(p.toAbs) : lstat(p.toAbs)).catch((e) =>
errnoOf(e) === "ENOENT" ? null : e,
);
if (st) taken.push(p.to); if (st) taken.push(p.to);
} }
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`); if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`);
@@ -116,17 +121,19 @@ export async function renameEntry(
// 执行:逐个 no-clobber 改名;任一失败回滚已改的 // 执行:逐个 no-clobber 改名;任一失败回滚已改的
const done: Pair[] = []; const done: Pair[] = [];
const rollback = async () => { const rollback = async () => {
for (const p of done.reverse()) await moveSafe(p.toAbs, p.fromAbs).catch(() => undefined); for (const p of done.reverse()) await vrenameNoClobber(p.toAbs, p.fromAbs).catch(() => undefined);
}; };
for (const p of pairs) { for (const p of pairs) {
try { try {
await recheck(p.fromAbs, r.rootReal); await recheck(p.fromAbs, r.rootReal);
const before = await lstat(p.fromAbs); const remote = isRemotePath(p.fromAbs);
const res = await moveSafe(p.fromAbs, p.toAbs); const before = remote ? null : await lstat(p.fromAbs);
const res = await vrenameNoClobber(p.fromAbs, p.toAbs);
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`); if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`);
if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`); if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`);
const after = await lstat(p.toAbs); // 远端没有 inode:Alist 改名接口成功即算成功(同 MP)
if (after.ino !== before.ino || after.dev !== before.dev) const after = remote ? null : await lstat(p.toAbs);
if (before && after && (after.ino !== before.ino || after.dev !== before.dev))
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`); throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`);
done.push(p); done.push(p);
} catch (e) { } catch (e) {
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { stat } from "node:fs/promises";
import { join, relative } from "node:path"; import { join, relative } from "node:path";
import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts"; import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts"; import { ApiException } from "../../http/errors.ts";
import { vlstat as stat } from "../../storage/vfs.ts";
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts"; import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
import { type FileOverrideInput, type FrozenPlan, planImport } from "../acquisition-organize/services/organize.ts"; import { type FileOverrideInput, type FrozenPlan, planImport } from "../acquisition-organize/services/organize.ts";
import { import {
@@ -4,6 +4,7 @@ import type { FilePreview, FilesPreviewQuery } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { runSandbox, SandboxError } from "../../sandbox/client.ts"; import { runSandbox, SandboxError } from "../../sandbox/client.ts";
import { b64encode } from "../../sandbox/jobs.ts"; import { b64encode } from "../../sandbox/jobs.ts";
import { isRemotePath, remote, remoteError } from "../../storage/vfs.ts";
import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts"; import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts";
import { BAD_TORRENT } from "./preview-build.ts"; import { BAD_TORRENT } from "./preview-build.ts";
import { extOf, previewKindOf } from "./preview-parse.ts"; import { extOf, previewKindOf } from "./preview-parse.ts";
@@ -16,6 +17,20 @@ const IMAGE_HEAD = 512 * 1024;
const TORRENT_MAX = 4 * 1024 * 1024; const TORRENT_MAX = 4 * 1024 * 1024;
async function readHead(abs: string, max: number): Promise<{ buf: Uint8Array; size: number }> { async function readHead(abs: string, max: number): Promise<{ buf: Uint8Array; size: number }> {
if (isRemotePath(abs)) {
// Alist:按 Range 只取头部
const { v, client } = await remote(abs);
try {
const o = await client.get(v.remote);
if (o.isDir) throw new FilesError("bad_request", "不是普通文件");
const res = await client.openRead(v.remote, { range: `bytes=0-${Math.max(0, max - 1)}` });
const buf = new Uint8Array(await res.arrayBuffer()).subarray(0, max);
return { buf, size: o.size };
} catch (e) {
if (e instanceof FilesError) throw e;
throw remoteError(e, abs);
}
}
const fh = await open(abs, "r"); const fh = await open(abs, "r");
try { try {
const st = await fh.stat(); const st = await fh.stat();
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import type { ProbeResult } from "@umt/adapters"; import type { ProbeResult } from "@umt/adapters";
import type { import type {
@@ -11,6 +11,7 @@ import type {
} from "@umt/contracts"; } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { vlstat } from "../../storage/vfs.ts";
import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts"; import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts";
import { audit } from "./ledger.ts"; import { audit } from "./ledger.ts";
import { kindOfName, mediaSummary } from "./list.ts"; import { kindOfName, mediaSummary } from "./list.ts";
@@ -54,7 +55,7 @@ export async function probeFiles(d: FmDeps, body: FilesProbeRequest): Promise<Fi
}); });
continue; continue;
} }
const st = await stat(r.abs); const st = await vlstat(r.abs);
const key = createHash("sha1").update(`${r.abs}|${st.size}|${st.mtimeMs}`).digest("hex"); const key = createHash("sha1").update(`${r.abs}|${st.size}|${st.mtimeMs}`).digest("hex");
const cacheFile = join(cacheDir, `${key}.json`); const cacheFile = join(cacheDir, `${key}.json`);
let result: ProbeResult | null = null; let result: ProbeResult | null = null;
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { lstat } from "node:fs/promises";
import { basename, isAbsolute, join } from "node:path"; import { basename, isAbsolute, join } from "node:path";
import type { import type {
FileRef, FileRef,
@@ -9,6 +8,7 @@ import type {
OrganizationDeleteResult, OrganizationDeleteResult,
} from "@umt/contracts"; } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { vlstat as lstat } from "../../storage/vfs.ts";
import { FilesError, type FmDeps, isAdmin, loadRootDefs, type RootDef, within } from "./common.ts"; import { FilesError, type FmDeps, isAdmin, loadRootDefs, type RootDef, within } from "./common.ts";
import { audit } from "./ledger.ts"; import { audit } from "./ledger.ts";
import { deleteFiles } from "./trash.ts"; import { deleteFiles } from "./trash.ts";
@@ -23,6 +23,7 @@ import { suggestName } from "./suggest-name.ts";
import { transfer } from "./transfer.ts"; import { transfer } from "./transfer.ts";
import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts"; import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
import { recordSources, treeOf } from "./tree.ts"; import { recordSources, treeOf } from "./tree.ts";
import { uploadFile } from "./upload.ts";
// /files 路由:scope 由 contracts 端点定义(读 organize.read / 写 organize.execute),处理器内再按授权矩阵细分。 // /files 路由:scope 由 contracts 端点定义(读 organize.read / 写 organize.execute),处理器内再按授权矩阵细分。
// FilesError → 信封 + 对应状态(根离线 503,message 以 `<reason>: ` 开头)。 // FilesError → 信封 + 对应状态(根离线 503,message 以 `<reason>: ` 开头)。
@@ -120,6 +121,13 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body)); mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
mount(app, F.records, async (_c, { query }) => findRecords(d, query)); mount(app, F.records, async (_c, { query }) => findRecords(d, query));
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true); mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
// 上传:请求体是文件原始字节(不走 JSON 校验),query 照常校验;幂等键防重放
mount(app, F.upload, async (c, { query }) => {
const { idempotencyKey, ...rest } = query as { idempotencyKey: string } & Record<string, unknown>;
return idempotent(d, c.get("principal"), "upload", idempotencyKey, rest, () =>
uploadFile(d, c.get("principal"), query, c.req.raw.body, c.req.raw.signal),
);
});
mount(app, F.preview, async (c, { query }) => previewFile(d, c.get("principal"), query)); mount(app, F.preview, async (c, { query }) => previewFile(d, c.get("principal"), query));
// W9-H 文件管理 v2 // W9-H 文件管理 v2
mount(app, F.entityFromCandidate, async (c, { body }) => entityFromCandidate(d, c.get("principal"), body)); mount(app, F.entityFromCandidate, async (c, { body }) => entityFromCandidate(d, c.get("principal"), body));
@@ -1,7 +1,7 @@
import { lstat, readdir, stat } from "node:fs/promises";
import { basename, dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import type { FilePlanPair, FilesMoveRequest, FilesTransferResult, OrganizationStatus } from "@umt/contracts"; import type { FilePlanPair, FilesMoveRequest, FilesTransferResult, OrganizationStatus } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { isRemotePath, vlstat as lstat, vreaddir as readdir } from "../../storage/vfs.ts";
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts"; import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
import { planImport } from "../acquisition-organize/services/organize.ts"; import { planImport } from "../acquisition-organize/services/organize.ts";
import { authorizeMoveEntities, PathGuardError, sourceGuard } from "../acquisition-organize/services/organize-guard.ts"; import { authorizeMoveEntities, PathGuardError, sourceGuard } from "../acquisition-organize/services/organize-guard.ts";
@@ -94,8 +94,10 @@ export async function transfer(
const mode = body.mode; const mode = body.mode;
const t = await resolveIn(d, body.target.root, body.target.path); const t = await resolveIn(d, body.target.root, body.target.path);
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录"); if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal))) // 目标:可写的媒体库目录(本地或 Alist),或 Alist 存储根(文件管理的 st: 根,对照 MP 存储内移动 / 复制)
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 可写的媒体库目录`); const storageTarget = t.def.kind === "storage";
if ((!t.def.libraryRootId && !storageTarget) || !(await isWritableTarget(t.def, t.rootReal)))
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) {
@@ -118,7 +120,7 @@ export async function transfer(
for (const h of downloadsFor(downloads, s.abs)) for (const h of downloadsFor(downloads, s.abs))
hnrBlocks.push({ taskId: h.taskId, path: s.rel, hnr: h.hnr, status: h.status }); hnrBlocks.push({ taskId: h.taskId, path: s.rel, hnr: h.hnr, status: h.status });
const targetDev = Number((await stat(t.abs)).dev); const targetDev = Number((await lstat(t.abs)).dev);
const spec: TransferSpec = { const spec: TransferSpec = {
items: sources.map((s, i) => { items: sources.map((s, i) => {
const dst = join(t.abs, names[i]!); const dst = join(t.abs, names[i]!);
@@ -164,6 +166,11 @@ export async function transfer(
? (await relocatePreview(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir)).locations > 0 ? (await relocatePreview(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir)).locations > 0
: false; : false;
const crossDevice = Number((await lstat(s.abs)).dev) !== targetDev; const crossDevice = Number((await lstat(s.abs)).dev) !== targetDev;
if (storageTarget && mode === "move" && registered)
throw new FilesError(
"needs_retire",
`${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 },
target: { root: body.target.root, path: relOf(t.rootReal, dst) }, target: { root: body.target.root, path: relOf(t.rootReal, dst) },
@@ -175,7 +182,7 @@ export async function transfer(
}); });
if (!(mode === "move" && !crossDevice)) needBytes += snaps[i]!.size; if (!(mode === "move" && !crossDevice)) needBytes += snaps[i]!.size;
} }
const du = await d.ao.ports.diskUsage(t.abs).catch(() => null); const du = isRemotePath(t.abs) ? null : await d.ao.ports.diskUsage(t.abs).catch(() => null);
const freeBytes = du?.status === "ok" ? du.availableBytes : null; const freeBytes = du?.status === "ok" ? du.availableBytes : null;
const spaceOk = freeBytes === null ? true : needBytes <= freeBytes; const spaceOk = freeBytes === null ? true : needBytes <= freeBytes;
const truncated = snaps.some((s) => s.truncated) || planned.truncated === true; const truncated = snaps.some((s) => s.truncated) || planned.truncated === true;
@@ -184,10 +191,14 @@ export async function transfer(
const request = { const request = {
sourcePaths: sources.map((s) => s.abs), sourcePaths: sources.map((s) => s.abs),
action: mode, action: mode,
targetLibraryRootId: t.def.libraryRootId, ...(t.def.libraryRootId ? { targetLibraryRootId: t.def.libraryRootId } : {}),
}; };
const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove; const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove;
const crossBlocked = mode === "move" && !allowCross && pairs.some((p) => p.crossDevice); // 跨卷开关只管本地 ↔ 本地;涉及 Alist 的移动一律「传输成功 → 删源」(同 MP)
const crossBlocked =
mode === "move" &&
!allowCross &&
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} 个文件在目标已存在,不覆盖,执行时记为失败`);
+90 -7
View File
@@ -16,6 +16,8 @@ import type {
} from "@umt/contracts"; } from "@umt/contracts";
import { sql, type TrashItemRow } from "@umt/db"; import { sql, type TrashItemRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { isRemotePath, vlstat, vmkdirp, vreaddirStats, vremove, vrenameNoClobber } from "../../storage/vfs.ts";
import { parseVPath, toVPath } from "../../storage/vpath.ts";
import { upsertDecision } from "../acquisition-organize/services/common.ts"; import { upsertDecision } from "../acquisition-organize/services/common.ts";
import { fsRuntime } from "../acquisition-organize/services/organize.ts"; import { fsRuntime } from "../acquisition-organize/services/organize.ts";
import { import {
@@ -47,6 +49,7 @@ const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/
const day = (t: Date) => t.toISOString().slice(0, 10).replace(/-/g, ""); const day = (t: Date) => t.toISOString().slice(0, 10).replace(/-/g, "");
async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; files: string[] }> { async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; files: string[] }> {
if (isRemotePath(abs)) return remoteSizeOf(abs, cap);
const st = await lstat(abs); const st = await lstat(abs);
if (!st.isDirectory()) return { size: Number(st.size), files: [""] }; if (!st.isDirectory()) return { size: Number(st.size), files: [""] };
let size = 0; let size = 0;
@@ -68,6 +71,39 @@ async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; files
return { size, files }; return { size, files };
} }
/** 远端:按目录 list(每个子目录一次请求) */
async function remoteSizeOf(abs: string, cap: number): Promise<{ size: number; files: string[] }> {
const st = await vlstat(abs);
if (!st.isDirectory()) return { size: Number(st.size), files: [""] };
let size = 0;
const files: string[] = [];
const walk = async (dir: string, prefix: string) => {
for (const it of await vreaddirStats(dir)) {
if (files.length >= cap) return;
const rel = prefix ? `${prefix}/${it.name}` : it.name;
if (it.stats.isDirectory()) await walk(join(dir, it.name), rel);
else {
size += Number(it.stats.size);
files.push(rel);
}
}
};
await walk(abs, "");
return { size, files };
}
/**
* 回收站所在目录:本地 = <根>/.umt-trash;远端同布局,用 Alist 同驱动 move(立即完成)。
* 远端根是 Alist 的 "/"(各挂载的上一级,不能建目录)时放进该项所在挂载的 .umt-trash。
*/
export function trashBase(rootReal: string, abs: string): string {
const v = parseVPath(rootReal);
if (v?.remote !== "/") return rootReal;
const a = parseVPath(abs);
const mount = a?.remote.split("/").filter(Boolean)[0];
return mount ? toVPath(v.storageId, `/${mount}`) : rootReal;
}
interface CheckCtx { interface CheckCtx {
abs: string | null; abs: string | null;
rootReal: string | null; rootReal: string | null;
@@ -234,7 +270,8 @@ export async function deleteFiles(
await recheck(abs, ctx.rootReal!); await recheck(abs, ctx.rootReal!);
let where: string | null = null; let where: string | null = null;
if (body.mode === "permanent") { if (body.mode === "permanent") {
await rm(abs, { recursive: true, force: false }); if (isRemotePath(abs)) await vremove(abs);
else await rm(abs, { recursive: true, force: false });
deleted.push(check.path); deleted.push(check.path);
} else { } else {
const row = await moveToTrash(d, principal, check, ctx, expiresAt); const row = await moveToTrash(d, principal, check, ctx, expiresAt);
@@ -379,6 +416,16 @@ async function moveToTrash(
const abs = ctx.abs!; const abs = ctx.abs!;
const id = randomUUID(); const id = randomUUID();
const stamp = day(d.ao.ports.now()); const stamp = day(d.ao.ports.now());
if (isRemotePath(abs)) {
// Alist:同存储 move 进 <根>/.umt-trash(MP 无回收站;Alist 驱动自己的回收站不依赖)
const base = trashBase(ctx.rootReal!, abs);
const rel = base === ctx.rootReal ? check.path : relOfRoot(base, abs);
const trashPath = join(base, TRASH_DIR, stamp, id, rel);
await vmkdirp(dirname(trashPath));
const r = await vrenameNoClobber(abs, trashPath);
if (r.status !== "moved") throw new FilesError("exists", `移入回收站失败(${r.status}):${trashPath}`);
return insertTrashRow(d, principal, check, ctx, { id, rootHost: base, trashPath, expiresAt });
}
let trashPath = join(ctx.rootReal!, TRASH_DIR, stamp, id, check.path); let trashPath = join(ctx.rootReal!, TRASH_DIR, stamp, id, check.path);
let moved = false; let moved = false;
try { try {
@@ -404,13 +451,24 @@ async function moveToTrash(
await copyTree(d, abs, trashPath, files); await copyTree(d, abs, trashPath, files);
await rm(abs, { recursive: true }); await rm(abs, { recursive: true });
} }
return insertTrashRow(d, principal, check, ctx, { id, rootHost: ctx.rootReal!, trashPath, expiresAt });
}
function insertTrashRow(
d: FmDeps,
principal: Principal | undefined,
check: FileDeleteCheck,
ctx: CheckCtx,
x: { id: string; rootHost: string; trashPath: string; expiresAt: Date },
): Promise<TrashItemRow> {
const { id, trashPath, expiresAt } = x;
return d.ctx.db return d.ctx.db
.insertInto("trash_item") .insertInto("trash_item")
.values({ .values({
id, id,
root_id: check.root, root_id: check.root,
library_root_id: ctx.libraryRootId, library_root_id: ctx.libraryRootId,
root_host_path: ctx.rootReal!, root_host_path: x.rootHost,
original_path: check.path, original_path: check.path,
trash_path: trashPath, trash_path: trashPath,
is_dir: check.isDir, is_dir: check.isDir,
@@ -477,6 +535,11 @@ function trashPathSafe(d: FmDeps, row: TrashItemRow): boolean {
*/ */
export async function mkdirWithin(rootReal: string, rel: string): Promise<void> { export async function mkdirWithin(rootReal: string, rel: string): Promise<void> {
if (rel === "" || rel === ".") return; if (rel === "" || rel === ".") return;
if (isRemotePath(rootReal)) {
if (rel.split("/").includes("..")) throw new FilesError("path_escape", `路径含 ..: ${rel}`);
await vmkdirp(join(rootReal, rel));
return;
}
let cur = rootReal; let cur = rootReal;
for (const seg of rel.split("/")) { for (const seg of rel.split("/")) {
if (seg === "" || seg === ".") continue; if (seg === "" || seg === ".") continue;
@@ -494,6 +557,13 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise<void>
} }
} }
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));
const sameStorageOf = (a: string, b: string) => parseVPath(a)?.storageId === parseVPath(b)?.storageId;
/** <…>/.umt-trash/<yyyymmdd>/<uuid>:回收站路径里 uuid 那一级 */
function trashUuidDir(row: TrashItemRow): string | null {
const segs = row.trash_path.split("/");
const i = segs.indexOf(row.id);
return i > 0 ? segs.slice(0, i + 1).join("/") : null;
}
export async function restoreTrash( export async function restoreTrash(
d: FmDeps, d: FmDeps,
@@ -537,8 +607,14 @@ export async function restoreTrash(
); );
dst = parent.abs; dst = parent.abs;
} }
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择「还原到…」"); if (await (isRemotePath(dst) ? vlstat(dst) : lstat(dst)).catch(() => null))
const r = await moveSafe(row.trash_path, dst); throw new FilesError("exists", "原位置已被占用,可选择「还原到…」");
if (
isRemotePath(dst) !== isRemotePath(row.trash_path) ||
(isRemotePath(dst) && !sameStorageOf(dst, row.trash_path))
)
throw new FilesError("bad_request", "回收站里的项只能还原到它原来所在的存储");
const r = await vrenameNoClobber(row.trash_path, dst);
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用"); if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
if (r.status === "exdev") { if (r.status === "exdev") {
// 跨卷还原:与跨卷进回收站同一上限(FILES_TRASH_CROSS_MAX_BYTES),复制经 fsproxy,核验后删回收站副本 // 跨卷还原:与跨卷进回收站同一上限(FILES_TRASH_CROSS_MAX_BYTES),复制经 fsproxy,核验后删回收站副本
@@ -601,10 +677,17 @@ export async function purgeTrash(
for (const row of rows) { for (const row of rows) {
try { try {
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除"); if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
await rm(row.trash_path, { recursive: true, force: true });
// 顺带清掉空的 <uuid>/<yyyymmdd> 目录 // 顺带清掉空的 <uuid>/<yyyymmdd> 目录
const uuidDir = row.trash_path.slice(0, row.trash_path.length - row.original_path.length - 1); const uuidDir = trashUuidDir(row);
await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined); if (isRemotePath(row.trash_path)) {
await vremove(row.trash_path).catch((e) => {
if (errnoOf(e) !== "ENOENT") throw e;
});
if (uuidDir) await vremove(uuidDir).catch(() => undefined);
} else {
await rm(row.trash_path, { recursive: true, force: true });
if (uuidDir) await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined);
}
const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst(); const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst();
if (gone) purged.push(row.id); if (gone) purged.push(row.id);
} catch (e) { } catch (e) {
+13 -5
View File
@@ -1,4 +1,3 @@
import { lstat, readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { import type {
FileRootId, FileRootId,
@@ -8,6 +7,7 @@ import type {
OrganizationStatus, OrganizationStatus,
} from "@umt/contracts"; } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import { isRemotePath, vlstat as lstat, vreaddirents as readdirents } from "../../storage/vfs.ts";
import { FilesError, type FmDeps, isTempName, loadRootDefs, resolveIn, within } from "./common.ts"; import { FilesError, type FmDeps, isTempName, loadRootDefs, resolveIn, within } from "./common.ts";
import { kindOfName } from "./list.ts"; import { kindOfName } from "./list.ts";
@@ -20,7 +20,8 @@ const hiddenName = (n: string) => n.startsWith(".") || isTempName(n);
export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeResult> { export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeResult> {
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 || "/"}`);
const names = await readdir(r.abs, { withFileTypes: true }); const remote = isRemotePath(r.abs);
const names = await readdirents(r.abs);
const subdirs = names const subdirs = names
.filter((n) => n.isDirectory() && !hiddenName(n.name)) .filter((n) => n.isDirectory() && !hiddenName(n.name))
.map((n) => n.name) .map((n) => n.name)
@@ -30,7 +31,9 @@ export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeRes
const dirs = await Promise.all( const dirs = await Promise.all(
subdirs.slice(0, max).map(async (name) => { subdirs.slice(0, max).map(async (name) => {
const path = r.rel ? `${r.rel}/${name}` : name; const path = r.rel ? `${r.rel}/${name}` : name;
const kids = await readdir(join(r.abs, name), { withFileTypes: true }).catch(() => []); // Alist:不逐个子目录再列一遍(避免 N 次网盘请求),按「可能有下级」展示,展开时再列
if (remote) return { name, path, hasChildren: true };
const kids = await readdirents(join(r.abs, name)).catch(() => []);
return { name, path, hasChildren: kids.some((k) => k.isDirectory() && !hiddenName(k.name)) }; return { name, path, hasChildren: kids.some((k) => k.isDirectory() && !hiddenName(k.name)) };
}), }),
); );
@@ -39,10 +42,15 @@ export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeRes
let seen = 0; let seen = 0;
let videoCount = 0; let videoCount = 0;
let partial = false; let partial = false;
const stack = [r.abs]; // Alist:只数当前目录这一层(不递归打网盘接口),结果标为不完整
if (remote) {
videoCount = names.filter((n) => n.isFile() && kindOfName(n.name) === "video").length;
partial = names.some((n) => n.isDirectory());
}
const stack = remote ? [] : [r.abs];
while (stack.length > 0 && !partial) { while (stack.length > 0 && !partial) {
const dir = stack.pop()!; const dir = stack.pop()!;
for (const n of await readdir(dir, { withFileTypes: true }).catch(() => [])) { for (const n of await readdirents(dir).catch(() => [])) {
if (++seen > d.options.dirSummaryMax) { if (++seen > d.options.dirSummaryMax) {
partial = true; partial = true;
break; break;
@@ -0,0 +1,83 @@
import { randomUUID } from "node:crypto";
import { mkdir, open, rm } from "node:fs/promises";
import { join } from "node:path";
import { commitNoClobber, partialPathFor } from "@umt/adapters";
import type { FilesUploadQuery, FilesUploadResult } from "@umt/contracts";
import { sanitizeSegment } from "@umt/domain";
import type { Principal } from "../../http/auth.ts";
import { relayDir, remote, remoteError, vexists } from "../../storage/vfs.ts";
import { errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
import { audit, filesChanged } from "./ledger.ts";
import { isWritableTarget } from "./list.ts";
// PUT /files/upload(docs/design/storage-alist.md §4):请求体是文件字节,放进 {root, path} 目录,同名不覆盖。
// Alist 根:先流式写本机临时文件,再按 §2.6 上传(临时名 → 改名,成功判定同 MP);本地根:同目录 partial → 不覆盖改名。
async function spool(body: ReadableStream<Uint8Array>, file: string, signal?: AbortSignal): Promise<number> {
const fh = await open(file, "w");
let n = 0;
try {
const reader = body.getReader();
for (;;) {
signal?.throwIfAborted();
const { value, done } = await reader.read();
if (done) break;
await fh.write(value);
n += value.length;
}
} finally {
await fh.close();
}
return n;
}
export async function uploadFile(
d: FmDeps,
principal: Principal | undefined,
q: FilesUploadQuery,
body: ReadableStream<Uint8Array> | null,
signal?: AbortSignal,
): Promise<FilesUploadResult> {
if (!body) throw new FilesError("bad_request", "请求体为空");
const r = await resolveIn(d, q.root, q.path);
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 (sanitizeSegment(q.name) !== q.name)
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(q.name)}`);
await recheck(r.abs, r.rootReal);
const dst = join(r.abs, q.name);
const rel = relOf(r.rootReal, dst);
let size: number;
if (r.def.storageId) {
if (await vexists(dst).catch((e) => Promise.reject(remoteError(e, dst))))
throw new FilesError("exists", `已存在,不覆盖:${q.name}`);
const dir = relayDir();
await mkdir(dir, { recursive: true });
const tmp = join(dir, `${randomUUID()}-upload`);
try {
size = await spool(body, tmp, signal);
const { v, client } = await remote(dst);
await client.upload(tmp, v.remote, signal ? { signal } : {});
} catch (e) {
if (e instanceof FilesError) throw e;
const err = remoteError(e, dst);
if (errnoOf(err) === "EEXIST") throw new FilesError("exists", `已存在,不覆盖:${q.name}`);
throw new FilesError("upstream", `上传到 Alist 失败:${err.message}`);
} finally {
await rm(tmp, { force: true }).catch(() => undefined);
}
} else {
const partial = partialPathFor(dst);
try {
size = await spool(body, partial, signal);
await commitNoClobber(partial, dst);
} catch (e) {
await rm(partial, { force: true }).catch(() => undefined);
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在,不覆盖:${q.name}`);
throw e;
}
}
await audit(d, principal, "upload", { root: q.root, path: rel, size });
filesChanged(d, q.root, [r.rel]);
return { path: rel, size };
}
+11
View File
@@ -74,6 +74,8 @@ export interface FmHarness {
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>; api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
/** 原始响应(非 JSON 接口,如 /files/download) */ /** 原始响应(非 JSON 接口,如 /files/download) */
raw(method: string, path: string, key?: string): Promise<Response>; raw(method: string, path: string, key?: string): Promise<Response>;
/** 带原始请求体(如 PUT /files/upload) */
rawBody(method: string, path: string, body: BodyInit, key?: string): Promise<Response>;
close(): Promise<void>; close(): Promise<void>;
} }
@@ -264,6 +266,15 @@ export async function createFmHarness(
raw(method, path, key = DEV_KEY) { raw(method, path, key = DEV_KEY) {
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: authHeaders(key) })); return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: authHeaders(key) }));
}, },
rawBody(method, path, body, key = DEV_KEY) {
return Promise.resolve(
app.request(`/api/v1${path}`, {
method,
headers: { ...authHeaders(key), "content-type": "application/octet-stream" },
body,
}),
);
},
async close() { async close() {
await ts.drop(); await ts.drop();
await rm(base, { recursive: true, force: true }); await rm(base, { recursive: true, force: true });
@@ -0,0 +1,240 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createFmHarness, type FmHarness, key, pgAvailable, SESSION } from "../file-manager/harness.ts";
import { addAlist, addAlistLibrary, type TestAlist, text } from "./alist.ts";
// 文件管理接入 Alist(对照 MP 存储切换 + Alist 浏览 / 新建目录 / 改名 / 删除 / 移动 / 复制 / 上传 / 下载):
// 根列表多出 st: 根、列目录一次 list(refresh 只在用户刷新时带)、删除进 Alist 上的 .umt-trash 并可还原、
// 存储内移动 / 复制走 Alist API、上传同名不覆盖、连不上返回 503 而不是「空目录」。
const skip = !(await pgAvailable());
describe.skipIf(skip)("文件管理:Alist 存储", () => {
let h: FmHarness;
let a: TestAlist;
beforeAll(async () => {
h = await createFmHarness("alist_files");
a = await addAlist(h.db, {
name: "OpenList",
mockOptions: { token: "t", mountDetails: { "/s1": { total_space: 100, free_space: 40, used_space: 60 } } },
rootPath: "/s1",
});
a.mock.put("/s1/movies/A.2020.1080p.mkv", "video-a");
a.mock.put("/s1/movies/A.2020.1080p.chs.srt", "sub-a");
a.mock.put("/s1/notes/readme.nfo", "<movie/>");
});
afterAll(async () => h?.close());
it("根列表:每个启用的 Alist 实例一个 st: 根,带存储名与容量", async () => {
const r = await h.api("GET", "/files/roots");
expect(r.status).toBe(200);
const st = r.body.data.items.find((x: { id: string }) => x.id === a.root);
expect(st).toMatchObject({
kind: "storage",
name: "OpenList",
health: "healthy",
writable: true,
storage: { id: a.id, name: "OpenList", path: "/s1" },
usage: { totalBytes: 100, freeBytes: 40 },
});
});
it("列目录:一次 list,不带 refresh;用户刷新才带", async () => {
a.mock.requests.length = 0;
const r = await h.api("GET", `/files?root=${a.root}&path=movies`);
expect(r.status).toBe(200);
expect(r.body.data.entries.map((e: { name: string }) => e.name)).toEqual([
"A.2020.1080p.chs.srt",
"A.2020.1080p.mkv",
]);
expect(r.body.data.entries.find((e: { name: string }) => e.name.endsWith(".mkv"))).toMatchObject({
type: "file",
size: 7,
fileKind: "video",
status: "unidentified",
});
const lists = a.mock.requests.filter((x) => x.path === "/api/fs/list");
expect(lists).toHaveLength(1);
expect(lists[0]!.body!.refresh).toBe(false);
await h.api("GET", `/files?root=${a.root}&path=movies&refresh=true`);
expect(a.mock.requests.filter((x) => x.path === "/api/fs/list").at(-1)!.body!.refresh).toBe(true);
});
it("新建目录 / 改名(带字幕)/ 同名不覆盖", async () => {
const mk = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: a.root, path: "", name: "新目录" });
expect(mk.status).toBe(200);
expect(a.mock.exists("/s1/新目录")).toBe(true);
const dup = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: a.root, path: "", name: "新目录" });
expect(dup.status).toBe(409);
const rn = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: a.root,
path: "movies/A.2020.1080p.mkv",
newName: "A (2020).mkv",
withSidecars: true,
});
expect(rn.status).toBe(200);
expect(a.mock.listNames("/s1/movies")).toEqual(["A (2020).chs.srt", "A (2020).mkv"]);
a.mock.put("/s1/movies/B.mkv", "b");
const clash = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: a.root,
path: "movies/B.mkv",
newName: "A (2020).mkv",
});
expect(clash.status).toBe(409);
expect(text(a.mock.read("/s1/movies/A (2020).mkv"))).toBe("video-a");
});
it("上传:流式转传到 Alist,同名不覆盖", async () => {
const r = await h.rawBody(
"PUT",
`/files/upload?root=${a.root}&path=notes&name=${encodeURIComponent("新 文件.txt")}&idempotencyKey=${key()}`,
"hello alist",
);
expect(r.status).toBe(200);
expect((await r.json()).data).toEqual({ path: "notes/新 文件.txt", size: 11 });
expect(text(a.mock.read("/s1/notes/新 文件.txt"))).toBe("hello alist");
const again = await h.rawBody(
"PUT",
`/files/upload?root=${a.root}&path=notes&name=readme.nfo&idempotencyKey=${key()}`,
"overwrite?",
);
expect(again.status).toBe(409);
expect(text(a.mock.read("/s1/notes/readme.nfo"))).toBe("<movie/>");
// 本地根同样可以上传
const local = await h.rawBody(
"PUT",
`/files/upload?root=${h.libRoot}&path=&name=up.txt&idempotencyKey=${key()}`,
"local",
);
expect(local.status).toBe(200);
expect(await readFile(join(h.lib, "up.txt"), "utf8")).toBe("local");
});
it("下载与预览(管理员会话):流式转发 Alist 直链", async () => {
const r = await h.raw("GET", `/files/download?root=${a.root}&path=notes/readme.nfo`, SESSION);
expect(r.status).toBe(200);
expect(await r.text()).toBe("<movie/>");
const p = await h.api("GET", `/files/preview?root=${a.root}&path=notes/readme.nfo`, undefined, SESSION);
expect(p.status).toBe(200);
expect(JSON.stringify(p.body.data)).toContain("movie");
});
it("删除进 Alist 上的回收站,可还原", async () => {
const del = await h.api("POST", "/files/delete", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/B.mkv" }],
dryRun: false,
});
expect(del.status).toBe(200);
expect(del.body.data.trashed).toHaveLength(1);
expect(a.mock.exists("/s1/movies/B.mkv")).toBe(false);
const trashPath = del.body.data.trashed[0].trashPath as string;
expect(trashPath.startsWith(a.v("/s1/.umt-trash/"))).toBe(true);
const restore = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [del.body.data.trashed[0].id],
});
expect(restore.status).toBe(200);
expect(restore.body.data.restored).toHaveLength(1);
expect(text(a.mock.read("/s1/movies/B.mkv"))).toBe("b");
});
it("存储内复制 / 移动走 Alist API(复制源保留,移动源消失)", async () => {
const dry = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "notes/readme.nfo" }],
target: { root: a.root, path: "新目录" },
dryRun: true,
});
expect(dry.status).toBe(200);
const exec = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "notes/readme.nfo" }],
target: { root: a.root, path: "新目录" },
dryRun: false,
planId: dry.body.data.planId,
});
expect(exec.status).toBe(200);
expect(exec.body.data.executed).toMatchObject({ succeeded: ["notes/readme.nfo"], failed: [] });
expect(text(a.mock.read("/s1/新目录/readme.nfo"))).toBe("<movie/>");
expect(a.mock.exists("/s1/notes/readme.nfo")).toBe(true);
expect(a.mock.requests.some((x) => x.path === "/api/fs/copy")).toBe(true);
const mdry = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/B.mkv" }],
target: { root: a.root, path: "新目录" },
mode: "move",
dryRun: true,
});
const mexec = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/B.mkv" }],
target: { root: a.root, path: "新目录" },
mode: "move",
dryRun: false,
planId: mdry.body.data.planId,
});
expect(mexec.status).toBe(200);
expect(mexec.body.data.executed.failed).toEqual([]);
expect(a.mock.exists("/s1/新目录/B.mkv")).toBe(true);
expect(a.mock.exists("/s1/movies/B.mkv")).toBe(false);
});
it("复制到本地媒体库(下载到本地)与从本地复制到 Alist 媒体库(上传)", async () => {
const dry = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/A (2020).mkv" }],
target: { root: h.libRoot, path: "" },
dryRun: true,
});
const exec = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/A (2020).mkv" }],
target: { root: h.libRoot, path: "" },
dryRun: false,
planId: dry.body.data.planId,
});
expect(exec.body.data.executed.failed).toEqual([]);
expect(await readFile(join(h.lib, "A (2020).mkv"), "utf8")).toBe("video-a");
const alib = await addAlistLibrary(h.db, a, "/s1/library");
await writeFile(join(h.lib, "local.mkv"), "local-video");
const d2 = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: "local.mkv" }],
target: { root: alib.root, path: "" },
dryRun: true,
});
expect(d2.status).toBe(200);
const e2 = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: "local.mkv" }],
target: { root: alib.root, path: "" },
dryRun: false,
planId: d2.body.data.planId,
});
expect(e2.body.data.executed.failed).toEqual([]);
expect(text(a.mock.read("/s1/library/local.mkv"))).toBe("local-video");
});
it("目录树:Alist 只列当前层,不逐个子目录再请求", async () => {
a.mock.requests.length = 0;
const r = await h.api("GET", `/files/tree?root=${a.root}&path=`);
expect(r.status).toBe(200);
expect(r.body.data.dirs.map((x: { name: string }) => x.name)).toEqual(
expect.arrayContaining(["movies", "notes", "新目录"]),
);
expect(a.mock.requests.filter((x) => x.path === "/api/fs/list")).toHaveLength(1);
});
it("Alist 连不上:列目录 503,不当成空目录", async () => {
a.mock.failNext(5);
const r = await h.api("GET", `/files?root=${a.root}&path=movies`);
expect(r.status).toBe(503);
expect(r.body.error.message).toMatch(/^root_unavailable:/);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { AlistClient, type AlistClientOptions } from "@umt/adapters";
import { createMockAlist, type MockAlist, type MockAlistOptions } from "@umt/adapters/alist-mock";
import type { DB, Kysely } from "@umt/db";
import { overrideStorage } from "../../src/storage/registry.ts";
import { toVPath } from "../../src/storage/vpath.ts";
// Alist 测试装配:登记一个存储实例(storage_instance 行)并把注册表里的客户端换成进程内模拟 Alist。
export interface TestAlist {
id: string;
mock: MockAlist;
client: AlistClient;
/** 文件管理根 id(st:<id>) */
root: string;
/** Alist 内路径 → 虚拟路径 */
v(p: string): string;
}
export async function addAlist(
db: Kysely<DB>,
o: {
name?: string;
rootPath?: string;
mock?: MockAlist;
mockOptions?: MockAlistOptions;
client?: Partial<AlistClientOptions>;
} = {},
): Promise<TestAlist> {
const mock = o.mock ?? createMockAlist(o.mockOptions ?? { token: "t" });
const rootPath = o.rootPath ?? "/";
const row = await db
.insertInto("storage_instance")
.values({ name: o.name ?? `alist-${crypto.randomUUID().slice(0, 6)}`, base_url: mock.base, root_path: rootPath })
.returning("id")
.executeTakeFirstOrThrow();
// 假时钟:等待 / 退避不真睡,超时按累计的「睡眠」计
let clock = 0;
const client = new AlistClient({
baseUrl: mock.base,
auth: { kind: "token", token: "t" },
fetch: mock.fetch,
qps: 0,
concurrency: 0,
retries: 0,
now: () => clock,
sleep: async (ms) => {
clock += ms;
},
...o.client,
});
overrideStorage(row.id, { client, name: o.name ?? "Alist", rootPath });
return { id: row.id, mock, client, root: `st:${row.id}`, v: (p) => toVPath(row.id, p) };
}
/** 在 Alist 上登记一个纳管媒体库目录 */
export async function addAlistLibrary(db: Kysely<DB>, a: TestAlist, path: string, name = "Alist 媒体库") {
a.mock.mkdirp(path);
const r = await db
.insertInto("library_root")
.values({ name, media_kind: "mixed", local_path: a.v(path), management_mode: "managed", sentinel_file: null })
.returning("id")
.executeTakeFirstOrThrow();
return { libraryRootId: r.id, root: `lib:${r.id}` };
}
export const text = (b: Uint8Array | null) => (b ? new TextDecoder().decode(b) : null);
+4
View File
@@ -101,6 +101,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB }, usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
pendingCount: null, pendingCount: null,
pendingPartial: false, pendingPartial: false,
storage: null,
}, },
{ {
id: "lib:tv", id: "lib:tv",
@@ -115,6 +116,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB }, usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
pendingCount: null, pendingCount: null,
pendingPartial: false, pendingPartial: false,
storage: null,
}, },
{ {
id: "lib:nas-old", id: "lib:nas-old",
@@ -129,6 +131,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
usage: null, usage: null,
pendingCount: null, pendingCount: null,
pendingPartial: false, pendingPartial: false,
storage: null,
}, },
{ {
id: "dl:qb-main:0", id: "dl:qb-main:0",
@@ -143,6 +146,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
usage: { totalBytes: 8_000 * GB, freeBytes: 1_210 * GB }, usage: { totalBytes: 8_000 * GB, freeBytes: 1_210 * GB },
pendingCount: 12, pendingCount: 12,
pendingPartial: false, pendingPartial: false,
storage: null,
}, },
]; ];
const tlouEps = [1, 2, 3, 4, 5, 6, 7].map((ep) => const tlouEps = [1, 2, 3, 4, 5, 6, 7].map((ep) =>
+5 -1
View File
@@ -480,7 +480,11 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint
return null; return null;
} }
export const ROOT_KIND_LABEL: Record<FileRoot["kind"], string> = { download: "下载目录", library: "媒体库" }; export const ROOT_KIND_LABEL: Record<FileRoot["kind"], string> = {
download: "下载目录",
library: "媒体库",
storage: "Alist 存储",
};
export type Crumb = { label: string; path: string }; export type Crumb = { label: string; path: string };
+8
View File
@@ -266,6 +266,14 @@ export const organizeApi = {
body: files.FilesProbeRequest, body: files.FilesProbeRequest,
response: files.FilesProbeResult, response: files.FilesProbeResult,
}), }),
upload: ep({
method: "put",
path: "/files/upload",
scope: "organize.execute",
summary: "上传文件到目录(请求体为文件字节;同名不覆盖;Alist 根经服务端转传)",
query: files.FilesUploadQuery,
response: files.FilesUploadResult,
}),
download: ep({ download: ep({
method: "get", method: "get",
path: "/files/download", path: "/files/download",
+23 -3
View File
@@ -13,8 +13,10 @@ import { ScrapeMode, ScrapeRecord } from "./scrape.ts";
// 移动/复制/整理/删除都是两段式:dryRun=true 取得 planId 与前后路径清单,执行时回传 planId; // 移动/复制/整理/删除都是两段式:dryRun=true 取得 planId 与前后路径清单,执行时回传 planId;
// 源文件 size/mtime 变化或 planId 过期/与请求不符 → 409 plan_stale。 // 源文件 size/mtime 变化或 planId 过期/与请求不符 → 409 plan_stale。
/** `lib:<library_root.id>` 或 `dl:<downloader_instance.id>:<路径映射序号>` */ /** `lib:<library_root.id>`、`dl:<downloader_instance.id>:<路径映射序号>` 或 `st:<storage_instance.id>`(Alist 存储) */
export const FileRootId = z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3})$/i, { message: "非法根 id" }); export const FileRootId = z
.string()
.regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3}|st:[0-9a-f-]{36})$/i, { message: "非法根 id" });
export type FileRootId = z.infer<typeof FileRootId>; export type FileRootId = z.infer<typeof FileRootId>;
/** 根内相对路径:不以 / 开头,不含 `..` 段与 NUL;服务端仍会 realpath 复核 */ /** 根内相对路径:不以 / 开头,不含 `..` 段与 NUL;服务端仍会 realpath 复核 */
@@ -41,7 +43,7 @@ export const IdempotencyKey = z.string().min(8).max(200);
// ── 根 ── // ── 根 ──
export const FileRootKind = z.enum(["library", "download"]); export const FileRootKind = z.enum(["library", "download", "storage"]);
export type FileRootKind = z.infer<typeof FileRootKind>; export type FileRootKind = z.infer<typeof FileRootKind>;
export const FileRoot = z.object({ export const FileRoot = z.object({
@@ -59,6 +61,8 @@ export const FileRoot = z.object({
/** 健康探测失败原因(离线时) */ /** 健康探测失败原因(离线时) */
healthReason: z.string().nullable(), healthReason: z.string().nullable(),
usage: z.object({ totalBytes: NonNegInt, freeBytes: NonNegInt }).nullable(), usage: z.object({ totalBytes: NonNegInt, freeBytes: NonNegInt }).nullable(),
/** 根所在存储:本机为 null;Alist 根给实例 id 与名称(界面显示「存储名:/路径」) */
storage: z.object({ id: z.string(), name: z.string(), path: z.string() }).nullable().default(null),
/** G6:下载目录下待识别视频数(与列表"待识别"筛选同口径,递归,缓存 5 分钟);库根与离线根为 null */ /** G6:下载目录下待识别视频数(与列表"待识别"筛选同口径,递归,缓存 5 分钟);库根与离线根为 null */
pendingCount: NonNegInt.nullable(), pendingCount: NonNegInt.nullable(),
/** G6:递归超过扫描上限时为 true,pendingCount 是下限 */ /** G6:递归超过扫描上限时为 true,pendingCount 是下限 */
@@ -182,6 +186,8 @@ export const FilesListQuery = z.object({
.optional(), .optional(),
/** 默认 false:隐藏点文件、.uhd-partial、.umt-trash、哨兵 */ /** 默认 false:隐藏点文件、.uhd-partial、.umt-trash、哨兵 */
hidden: boolQuery.default(false), hidden: boolQuery.default(false),
/** Alist 根:让 Alist 刷新目录缓存(穿透到网盘接口;只在用户点「刷新」时带) */
refresh: boolQuery.default(false),
}); });
export type FilesListQuery = z.infer<typeof FilesListQuery>; export type FilesListQuery = z.infer<typeof FilesListQuery>;
@@ -797,6 +803,20 @@ export type FilesProbeResult = z.infer<typeof FilesProbeResult>;
// 下载(W5-A2):GET /files/download 直接返回文件流(非 JSON 信封);仅管理员会话; // 下载(W5-A2):GET /files/download 直接返回文件流(非 JSON 信封);仅管理员会话;
// 仅 nfo/字幕/图片且 ≤ FILES_DOWNLOAD_MAX_BYTES(默认 20 MiB);inline=true 仅对图片生效(预览)。 // 仅 nfo/字幕/图片且 ≤ FILES_DOWNLOAD_MAX_BYTES(默认 20 MiB);inline=true 仅对图片生效(预览)。
/**
* 上传(PUT /files/upload,请求体是文件原始字节):放进 {root, path} 目录,文件名 name;同名已存在 409(不覆盖)。
* Alist 根经服务端转传(先写本机临时文件再上传到 Alist,同 MP);本地根直接写临时文件后不覆盖改名。
*/
export const FilesUploadQuery = z.object({
root: FileRootId,
path: FileRelPath,
name: FileName,
idempotencyKey: IdempotencyKey,
});
export type FilesUploadQuery = z.infer<typeof FilesUploadQuery>;
export const FilesUploadResult = z.object({ path: FileRelPath, size: NonNegInt });
export type FilesUploadResult = z.infer<typeof FilesUploadResult>;
export const FilesDownloadQuery = z.object({ root: FileRootId, path: FileRelPath, inline: boolQuery.default(false) }); export const FilesDownloadQuery = z.object({ root: FileRootId, path: FileRelPath, inline: boolQuery.default(false) });
export type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>; export type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>;
/** OpenAPI 描述用:实际响应为二进制流 */ /** OpenAPI 描述用:实际响应为二进制流 */