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:
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 });
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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!,
|
||||||
|
|||||||
@@ -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} 个文件在目标已存在,不覆盖,执行时记为失败`);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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:/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 描述用:实际响应为二进制流 */
|
||||||
|
|||||||
Reference in new issue
Block a user