diff --git a/apps/server/src/modules/acquisition-organize/services/organize-guard.ts b/apps/server/src/modules/acquisition-organize/services/organize-guard.ts index ad3d0f3b..253c7ca2 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-guard.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-guard.ts @@ -1,6 +1,8 @@ import { realpath } from "node:fs/promises"; import { isAbsolute, resolve, sep } from "node:path"; 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 { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts"; import { hnrSatisfied } from "./retention.ts"; @@ -41,8 +43,17 @@ export async function allowedSourceRoots(exec: Exec): Promise { for (const m of t.mappings) roots.add(m.hostPath); 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[] = []; - 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 !== "/"); } @@ -62,7 +73,7 @@ export async function sourceGuard(exec: Exec): Promise { if (p.split("/").some((seg) => seg === "..")) throw new PathGuardError(`源路径不允许包含 "..": ${p}`, p); let r: string; try { - r = await realpath(p); + r = isRemotePath(p) ? await vrealpath(p) : await realpath(p); } catch { throw new PathGuardError(`源路径不存在或不可访问:${p}`, p); } diff --git a/apps/server/src/modules/acquisition-organize/services/organize-transfer.ts b/apps/server/src/modules/acquisition-organize/services/organize-transfer.ts index cd5c9318..63219a33 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-transfer.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-transfer.ts @@ -1,9 +1,17 @@ import { randomUUID } from "node:crypto"; import { readdir, rmdir } from "node:fs/promises"; 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 { 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 type { AoDeps, Exec } from "./common.ts"; import { type ExecuteResult, emitRecord, type FrozenPlan, type ImportRequest, type PlanResult } from "./organize.ts"; @@ -27,7 +35,8 @@ export interface TransferItem { export interface TransferSpec { items: TransferItem[]; - targetLibraryRootId: string; + /** 目标库根;目标是 Alist 存储根(文件管理 st:)时为 null */ + targetLibraryRootId: string | null; /** 目标库根 realpath(相对路径以它为基准) */ targetRootPath: string; /** 遍历到的每个文件都经它复核(realpath 在允许的根下) */ @@ -36,7 +45,7 @@ export interface TransferSpec { export interface FrozenTransfer { items: (TransferItem & { isDir: boolean; files: number })[]; - targetLibraryRootId: string; + targetLibraryRootId: string | null; } const KIND_OF = (p: string): OrganizeFileKind => { @@ -50,6 +59,18 @@ async function walkTree( out: { files: string[]; dirs: string[]; links: string[] }, limit: number, ): Promise { + 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(); for (const n of names) { 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 action = req.action ?? "copy"; const failed = (reason: string): PlanResult => ({ recordId: null, status: "failed", reason }); - const root = await db - .selectFrom("library_root") - .selectAll() - .where("id", "=", t.targetLibraryRootId) - .executeTakeFirst(); - if (root?.management_mode !== "managed") return failed("target_root_not_managed"); - const sentinel = root.sentinel_file ?? DEFAULT_ROOT_SENTINEL; - const health = await checkRoot(t.targetRootPath, sentinel); + const lib = t.targetLibraryRootId + ? await db.selectFrom("library_root").selectAll().where("id", "=", t.targetLibraryRootId).executeTakeFirst() + : null; + if (t.targetLibraryRootId && lib?.management_mode !== "managed") return failed("target_root_not_managed"); + if (!t.targetLibraryRootId && !isRemotePath(t.targetRootPath)) return failed("target_root_not_managed"); + const root = { id: lib?.id ?? null }; + const sentinel = isRemotePath(t.targetRootPath) ? "" : (lib?.sentinel_file ?? DEFAULT_ROOT_SENTINEL); + const health = await vcheckRoot(t.targetRootPath, sentinel); if (health.kind === "offline") return failed(`library_root_offline:${health.reason}`); const max = d.ports.options.walkMaxFiles; @@ -148,7 +169,7 @@ export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpe organize: plan, entityId: req.mediaEntityId ?? "", mediaType: "movie", - libraryRootId: root.id, + libraryRootId: root.id ?? "", rootPath: t.targetRootPath, sentinel, 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 } : {}) }; - const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id }); + const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id ?? "" }); const now = d.ports.now(); 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)); @@ -321,6 +342,15 @@ export async function relocate(trx: Exec, input: RelocateInput): Promise { 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 ents = await readdir(p, { withFileTypes: true }).catch(() => []); for (const e of ents) if (e.isDirectory()) await walk(join(p, e.name)); @@ -330,6 +360,16 @@ async function dirsDeepFirst(dir: string): Promise { return out; } +/** 只删空目录(Alist 的 remove 会递归删,先确认为空) */ +async function removeEmptyDir(dir: string): Promise { + 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,重复调用幂等 */ export async function finalizeTransfer(d: AoDeps, recordId: string): Promise { const db = d.ctx.db; @@ -351,7 +391,7 @@ export async function finalizeTransfer(d: AoDeps, recordId: string): Promise 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) => { const rec = await trx @@ -373,9 +413,9 @@ export async function finalizeTransfer(d: AoDeps, recordId: string): Promise 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")) { // 整项搬完:按前缀跟随(目录含路径绑定修订) const r = await relocate(trx, { ...base, fromRel: it.srcRel, toRel: it.dstRel, isDir: it.isDir }); diff --git a/apps/server/src/modules/file-manager/common.ts b/apps/server/src/modules/file-manager/common.ts index 5cdf6157..177f963a 100644 --- a/apps/server/src/modules/file-manager/common.ts +++ b/apps/server/src/modules/file-manager/common.ts @@ -1,10 +1,13 @@ import { createHash } from "node:crypto"; import { lstat, realpath, stat } from "node:fs/promises"; 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 { LibraryRootRow } from "@umt/db"; 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 { loadInstances } from "../acquisition-organize/services/common.ts"; import type { SourcesRuntime } from "../sources-identity/runtime.ts"; @@ -122,13 +125,15 @@ export const isOfflineErr = (e: unknown) => OFFLINE_CODES.has(errnoOf(e) ?? ""); * 处理器里漏出的文件系统错误(检查与使用之间目录被删、挂载掉线等竞态)→ 业务错误,而不是 500: * 离线类 → 503 root_unavailable(失败 ≠ 空目录),ENOENT/ENOTDIR → 404,EACCES/EPERM → 403,其余 errno → 400。 */ -export function fsErrorToFiles(e: unknown): FilesError | null { - if (e instanceof FilesError) return e; +export function fsErrorToFiles(e0: unknown): FilesError | null { + if (e0 instanceof FilesError) return e0; + const e = e0 instanceof AlistError ? remoteError(e0) : e0; const code = errnoOf(e); if (typeof code !== "string" || !/^E[A-Z]+$/.test(code)) return null; const msg = e instanceof Error ? e.message : String(e); 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 === "EEXIST") return new FilesError("exists", `目标已存在:${msg}`); if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`); return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`); } @@ -137,7 +142,7 @@ export function fsErrorToFiles(e: unknown): FilesError | null { export interface RootDef { id: FileRootId; - kind: "library" | "download"; + kind: "library" | "download" | "storage"; name: string; hostPath: string; mediaKind: LibraryRootRow["media_kind"] | null; @@ -146,6 +151,9 @@ export interface RootDef { libraryRootId: string | null; instanceId: string | null; sentinel: string | null; + /** 所在 Alist 存储实例(本机为 null);hostPath 是虚拟路径 /@alist//… */ + storageId: string | null; + storageName: string | null; } const norm = (p: string) => p.replace(/\/+$/, "") || "/"; @@ -153,10 +161,14 @@ const norm = (p: string) => p.replace(/\/+$/, "") || "/"; /** 已登记的根:启用的库根 + 下载器路径映射本机前缀(无映射 = 同机,取保存路径);"/" 永不出现 */ export async function loadRootDefs(exec: Exec): Promise { 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(); for (const r of libs) { const hostPath = norm(r.local_path); if (hostPath === "/" || !hostPath.startsWith("/")) continue; + const v = parseVPath(hostPath); + if (isRemotePath(hostPath) && !v) continue; out.push({ id: `lib:${r.id}`, kind: "library", @@ -166,7 +178,9 @@ export async function loadRootDefs(exec: Exec): Promise { modifiable: r.management_mode === "managed", libraryRootId: r.id, 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 })) { @@ -184,9 +198,28 @@ export async function loadRootDefs(exec: Exec): Promise { libraryRootId: null, instanceId: t.inst.id, 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; } @@ -211,6 +244,11 @@ const withTimeout = (p: Promise, ms: number, fallback: T): Promise => export async function probeRoot(def: RootDef, timeoutMs: number): Promise { const checkedAt = new Date(); const run = async (): Promise => { + 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) { const h = await checkRoot(def.hostPath, def.sentinel); 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 rootReal = opts.rootReal ?? (await onlineRoot(d, def)); const rel = checkRel(relIn); + if (def.storageId) return resolveRemote(def, rootReal, rel, opts.mustExist !== false); if (rel === "") { const lst = await lstat(rootReal).catch(() => null); 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 }; } +/** 远端根:没有符号链接,路径即 realpath;lst 是 vfs 的 stat(不存在为 null,连不上抛 root_unavailable) */ +async function resolveRemote(def: RootDef, rootReal: string, rel: string, mustExist: boolean): Promise { + 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,必须仍在根内 */ export async function recheck(abs: string, rootReal: string): Promise { + 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; try { r = await realpath(abs); diff --git a/apps/server/src/modules/file-manager/download.ts b/apps/server/src/modules/file-manager/download.ts index 8ba3dbb3..b9b30bf3 100644 --- a/apps/server/src/modules/file-manager/download.ts +++ b/apps/server/src/modules/file-manager/download.ts @@ -1,6 +1,7 @@ import { basename, extname } from "node:path"; import type { FilesDownloadQuery } from "@umt/contracts"; 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 { audit } from "./ledger.ts"; import { kindOfName } from "./list.ts"; @@ -55,6 +56,7 @@ export async function downloadFile( if (!ALLOWED.has(kind) && !pk) throw new FilesError("forbidden", `只能下载 NFO、字幕、图片、文本和 .torrent,此文件类型为 ${kind}`); 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 }); let size: number; 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, +): Promise { + 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", + }, + }); +} diff --git a/apps/server/src/modules/file-manager/identify.ts b/apps/server/src/modules/file-manager/identify.ts index c80e25db..186d4baf 100644 --- a/apps/server/src/modules/file-manager/identify.ts +++ b/apps/server/src/modules/file-manager/identify.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { readdir, stat } from "node:fs/promises"; import { join } from "node:path"; import { EventType, @@ -21,6 +20,7 @@ import { } from "@umt/domain"; import { localIdentityCandidate } from "@umt/mapping"; 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 { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.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]; while (stack.length > 0) { 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), ); for (const n of names) { diff --git a/apps/server/src/modules/file-manager/index.ts b/apps/server/src/modules/file-manager/index.ts index b9b12a0c..45493fd8 100644 --- a/apps/server/src/modules/file-manager/index.ts +++ b/apps/server/src/modules/file-manager/index.ts @@ -3,6 +3,7 @@ import type { TmdbClient } from "@umt/adapters"; import { FilesIdentifyRequest } from "@umt/contracts"; import { defineTask } from "@umt/tasks"; import { z } from "zod"; +import { configureStorage } from "../../storage/registry.ts"; import type { AoOverrides } from "../acquisition-organize/index.ts"; import { createPorts } from "../acquisition-organize/ports.ts"; import { registerScheduledJob } from "../schedules/registry.ts"; @@ -57,10 +58,12 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu return deps; }, registerRoutes(app, ctx) { + configureStorage(ctx); registerFileRoutes(app, lazy(ctx)); registerOrganizeSourceRoutes(app, lazy(ctx)); }, registerTasks(registry, ctx) { + configureStorage(ctx); registry.register( defineTask({ name: "scanOrganizeSource", diff --git a/apps/server/src/modules/file-manager/list.ts b/apps/server/src/modules/file-manager/list.ts index 3452b90a..ad668c98 100644 --- a/apps/server/src/modules/file-manager/list.ts +++ b/apps/server/src/modules/file-manager/list.ts @@ -17,6 +17,8 @@ import type { } from "@umt/contracts"; import { sql } from "@umt/db"; 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 { hnrSatisfied } from "../acquisition-organize/services/retention.ts"; import { @@ -29,6 +31,7 @@ import { probeRoot, type RootDef, relOf, + remoteFilesError, resolveIn, within, } from "./common.ts"; @@ -43,7 +46,11 @@ export async function listRoots(d: FmDeps): Promise<{ items: FileRoot[] }> { const h = await probeRoot(def, d.options.healthTimeoutMs); let writable = false; 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); const du = await d.ao.ports.diskUsage(h.real).catch(() => null); 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(), healthReason: h.reason, 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" ? await pendingOf(d, def.id, h.real) : { pendingCount: null, pendingPartial: false }), @@ -68,6 +82,23 @@ export async function listRoots(d: FmDeps): Promise<{ items: FileRoot[] }> { return { items }; } +/** Alist 容量(拿不到为 null,不报错;缓存 5 分钟,避免每次列根都打 admin 接口) */ +const usageCache = new Map(); +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 分钟;文件变更事件使对应根失效) ── const PENDING_TTL_MS = 5 * 60_000; @@ -121,6 +152,7 @@ async function canWrite(p: string): Promise { } export async function isWritableTarget(def: RootDef, rootReal: string): Promise { + if (def.storageId) return (def.kind === "library" || def.kind === "storage") && def.modifiable; 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 { let st: Awaited>; try { - st = await lstat(abs); + st = (isRemotePath(abs) ? await vlstat(abs) : await lstat(abs)) as Awaited>; } catch (e) { if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`); return null; // 读取期间被删除 @@ -436,6 +468,7 @@ const matchFilter = (e: FileEntry, f: FileFilter) => { export async function listDir(d: FmDeps, q: FilesListQuery): Promise { const r = await resolveIn(d, q.root, q.path); + if (r.def.storageId) return listRemoteDir(d, q, r); if (r.isSymlink || !r.lst?.isDirectory()) { const st = await stat(r.abs).catch(() => null); if (!st?.isDirectory()) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`); @@ -463,13 +496,7 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise (re ? re.test(n) : n.toLowerCase().includes(lower))); - } + const pool = filterNames(names, q.q); const raws: RawEntry[] = []; for (let i = 0; i < pool.length; i += 64) { const batch = await Promise.all( @@ -477,6 +504,66 @@ export async function listDir(d: FmDeps, q: FilesListQuery): Promise (re ? re.test(n) : n.toLowerCase().includes(lower))); +} + +/** Alist 目录:一次 list 拿全(自动翻页),refresh 只在用户点刷新时带(§9-8);不做递归目录摘要(避免逐目录打网盘接口) */ +async function listRemoteDir( + d: FmDeps, + q: FilesListQuery, + r: Awaited>, +): Promise { + 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>; + 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>, + raws: RawEntry[], + dirMtime: number, + cursor: { o: number; m: number } | null, + truncated: boolean, +): Promise { const downloads = await managedDownloads(d); let entries = await annotate(d, r.def, raws, downloads); 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 e.type === "dir"); - if (dirs.length === 0) return; + if (dirs.length === 0 || def.storageId) return; const reg = new Map(); if (def.kind === "library") { const likes = dirs.map((e) => `${e.path.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`); @@ -558,7 +645,7 @@ export async function sidecarsOf(abs: string): Promise { const name = basename(abs); const stem = name.slice(0, name.length - extname(name).length); 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) => { if (n === name) 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]); if (entry!.type === "dir") await fillDirSummaries(d, r.def, r.rootReal, [entry!]); 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); return { entry: entry!, diff --git a/apps/server/src/modules/file-manager/mutate.ts b/apps/server/src/modules/file-manager/mutate.ts index c764ef05..931d50cd 100644 --- a/apps/server/src/modules/file-manager/mutate.ts +++ b/apps/server/src/modules/file-manager/mutate.ts @@ -1,9 +1,9 @@ import { lstat, mkdir } from "node:fs/promises"; import { basename, dirname, extname, join } from "node:path"; -import { moveSafe } from "@umt/adapters"; import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts"; import { sanitizeSegment } from "@umt/domain"; import type { Principal } from "../../http/auth.ts"; +import { isRemotePath, vexists, vlstat, vmkdirp, vrenameNoClobber } from "../../storage/vfs.ts"; import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts"; import { audit, filesChanged } from "./ledger.ts"; import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts"; @@ -31,7 +31,10 @@ export async function mkdirIn( await recheck(r.abs, r.rootReal); const abs = join(r.abs, body.name); 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) { if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`); throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`); @@ -88,7 +91,9 @@ export async function renameEntry( // 不覆盖:任一目标已存在 → 409,原文件不动 const taken: string[] = []; 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 (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`); @@ -116,17 +121,19 @@ export async function renameEntry( // 执行:逐个 no-clobber 改名;任一失败回滚已改的 const done: Pair[] = []; const rollback = async () => { - for (const p of done.reverse()) await moveSafe(p.toAbs, p.fromAbs).catch(() => undefined); + for (const p of done.reverse()) await vrenameNoClobber(p.toAbs, p.fromAbs).catch(() => undefined); }; for (const p of pairs) { try { await recheck(p.fromAbs, r.rootReal); - const before = await lstat(p.fromAbs); - const res = await moveSafe(p.fromAbs, p.toAbs); + const remote = isRemotePath(p.fromAbs); + 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 !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`); - const after = await lstat(p.toAbs); - if (after.ino !== before.ino || after.dev !== before.dev) + // 远端没有 inode:Alist 改名接口成功即算成功(同 MP) + 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}`); done.push(p); } catch (e) { diff --git a/apps/server/src/modules/file-manager/organize.ts b/apps/server/src/modules/file-manager/organize.ts index c12896cb..6a63248e 100644 --- a/apps/server/src/modules/file-manager/organize.ts +++ b/apps/server/src/modules/file-manager/organize.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; -import { stat } from "node:fs/promises"; import { join, relative } from "node:path"; import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts"; import type { Principal } from "../../http/auth.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 { type FileOverrideInput, type FrozenPlan, planImport } from "../acquisition-organize/services/organize.ts"; import { diff --git a/apps/server/src/modules/file-manager/preview.ts b/apps/server/src/modules/file-manager/preview.ts index 459640d2..2e084d1c 100644 --- a/apps/server/src/modules/file-manager/preview.ts +++ b/apps/server/src/modules/file-manager/preview.ts @@ -4,6 +4,7 @@ import type { FilePreview, FilesPreviewQuery } from "@umt/contracts"; import type { Principal } from "../../http/auth.ts"; import { runSandbox, SandboxError } from "../../sandbox/client.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 { BAD_TORRENT } from "./preview-build.ts"; import { extOf, previewKindOf } from "./preview-parse.ts"; @@ -16,6 +17,20 @@ const IMAGE_HEAD = 512 * 1024; const TORRENT_MAX = 4 * 1024 * 1024; 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"); try { const st = await fh.stat(); diff --git a/apps/server/src/modules/file-manager/probe.ts b/apps/server/src/modules/file-manager/probe.ts index b9e04bce..35997881 100644 --- a/apps/server/src/modules/file-manager/probe.ts +++ b/apps/server/src/modules/file-manager/probe.ts @@ -1,5 +1,5 @@ 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 type { ProbeResult } from "@umt/adapters"; import type { @@ -11,6 +11,7 @@ import type { } from "@umt/contracts"; import { sql } from "@umt/db"; import type { Principal } from "../../http/auth.ts"; +import { vlstat } from "../../storage/vfs.ts"; import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts"; import { audit } from "./ledger.ts"; import { kindOfName, mediaSummary } from "./list.ts"; @@ -54,7 +55,7 @@ export async function probeFiles(d: FmDeps, body: FilesProbeRequest): Promise: ` 开头)。 @@ -120,6 +121,13 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) { mount(app, F.probe, async (_c, { body }) => probeFiles(d, body)); mount(app, F.records, async (_c, { query }) => findRecords(d, query)); 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; + 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)); // W9-H 文件管理 v2 mount(app, F.entityFromCandidate, async (c, { body }) => entityFromCandidate(d, c.get("principal"), body)); diff --git a/apps/server/src/modules/file-manager/transfer.ts b/apps/server/src/modules/file-manager/transfer.ts index 72816f78..04a8826a 100644 --- a/apps/server/src/modules/file-manager/transfer.ts +++ b/apps/server/src/modules/file-manager/transfer.ts @@ -1,7 +1,7 @@ -import { lstat, readdir, stat } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import type { FilePlanPair, FilesMoveRequest, FilesTransferResult, OrganizationStatus } from "@umt/contracts"; 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 { planImport } from "../acquisition-organize/services/organize.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 t = await resolveIn(d, body.target.root, body.target.path); if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录"); - if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal))) - throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 可写的媒体库目录`); + // 目标:可写的媒体库目录(本地或 Alist),或 Alist 存储根(文件管理的 st: 根,对照 MP 存储内移动 / 复制) + 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[] = []; for (const it of body.items) { @@ -118,7 +120,7 @@ export async function transfer( for (const h of downloadsFor(downloads, s.abs)) 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 = { items: sources.map((s, 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 : false; const crossDevice = Number((await lstat(s.abs)).dev) !== targetDev; + if (storageTarget && mode === "move" && registered) + throw new FilesError( + "needs_retire", + `${s.rel} 含已整理的文件,只能移动到媒体库目录;要放到 Alist 存储请用复制或先从媒体库移除`, + ); pairs.push({ source: { root: body.items[i]!.root, path: s.rel }, 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; } - 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 spaceOk = freeBytes === null ? true : needBytes <= freeBytes; const truncated = snaps.some((s) => s.truncated) || planned.truncated === true; @@ -184,10 +191,14 @@ export async function transfer( const request = { sourcePaths: sources.map((s) => s.abs), action: mode, - targetLibraryRootId: t.def.libraryRootId, + ...(t.def.libraryRootId ? { targetLibraryRootId: t.def.libraryRootId } : {}), }; 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[] = []; if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启"); if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`); diff --git a/apps/server/src/modules/file-manager/trash.ts b/apps/server/src/modules/file-manager/trash.ts index d71ea7f7..e3d30633 100644 --- a/apps/server/src/modules/file-manager/trash.ts +++ b/apps/server/src/modules/file-manager/trash.ts @@ -16,6 +16,8 @@ import type { } from "@umt/contracts"; import { sql, type TrashItemRow } from "@umt/db"; 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 { fsRuntime } from "../acquisition-organize/services/organize.ts"; 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, ""); 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); if (!st.isDirectory()) return { size: Number(st.size), files: [""] }; let size = 0; @@ -68,6 +71,39 @@ async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; 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 { abs: string | null; rootReal: string | null; @@ -234,7 +270,8 @@ export async function deleteFiles( await recheck(abs, ctx.rootReal!); let where: string | null = null; 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); } else { const row = await moveToTrash(d, principal, check, ctx, expiresAt); @@ -379,6 +416,16 @@ async function moveToTrash( const abs = ctx.abs!; const id = randomUUID(); 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 moved = false; try { @@ -404,13 +451,24 @@ async function moveToTrash( await copyTree(d, abs, trashPath, files); 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 { + const { id, trashPath, expiresAt } = x; return d.ctx.db .insertInto("trash_item") .values({ id, root_id: check.root, library_root_id: ctx.libraryRootId, - root_host_path: ctx.rootReal!, + root_host_path: x.rootHost, original_path: check.path, trash_path: trashPath, is_dir: check.isDir, @@ -477,6 +535,11 @@ function trashPathSafe(d: FmDeps, row: TrashItemRow): boolean { */ export async function mkdirWithin(rootReal: string, rel: string): Promise { 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; for (const seg of rel.split("/")) { if (seg === "" || seg === ".") continue; @@ -494,6 +557,13 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise } } 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//:回收站路径里 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( d: FmDeps, @@ -537,8 +607,14 @@ export async function restoreTrash( ); dst = parent.abs; } - if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择「还原到…」"); - const r = await moveSafe(row.trash_path, dst); + if (await (isRemotePath(dst) ? vlstat(dst) : lstat(dst)).catch(() => null)) + 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 === "exdev") { // 跨卷还原:与跨卷进回收站同一上限(FILES_TRASH_CROSS_MAX_BYTES),复制经 fsproxy,核验后删回收站副本 @@ -601,10 +677,17 @@ export async function purgeTrash( for (const row of rows) { try { if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除"); - await rm(row.trash_path, { recursive: true, force: true }); // 顺带清掉空的 / 目录 - const uuidDir = row.trash_path.slice(0, row.trash_path.length - row.original_path.length - 1); - await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined); + const uuidDir = trashUuidDir(row); + 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(); if (gone) purged.push(row.id); } catch (e) { diff --git a/apps/server/src/modules/file-manager/tree.ts b/apps/server/src/modules/file-manager/tree.ts index a521639d..1044ae9e 100644 --- a/apps/server/src/modules/file-manager/tree.ts +++ b/apps/server/src/modules/file-manager/tree.ts @@ -1,4 +1,3 @@ -import { lstat, readdir } from "node:fs/promises"; import { join } from "node:path"; import type { FileRootId, @@ -8,6 +7,7 @@ import type { OrganizationStatus, } from "@umt/contracts"; 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 { 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 { const r = await resolveIn(d, q.root, q.path); 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 .filter((n) => n.isDirectory() && !hiddenName(n.name)) .map((n) => n.name) @@ -30,7 +31,9 @@ export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise { 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)) }; }), ); @@ -39,10 +42,15 @@ export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise n.isFile() && kindOfName(n.name) === "video").length; + partial = names.some((n) => n.isDirectory()); + } + const stack = remote ? [] : [r.abs]; while (stack.length > 0 && !partial) { 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) { partial = true; break; diff --git a/apps/server/src/modules/file-manager/upload.ts b/apps/server/src/modules/file-manager/upload.ts new file mode 100644 index 00000000..43f6eefb --- /dev/null +++ b/apps/server/src/modules/file-manager/upload.ts @@ -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, file: string, signal?: AbortSignal): Promise { + 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 | null, + signal?: AbortSignal, +): Promise { + 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 }; +} diff --git a/apps/server/test/file-manager/harness.ts b/apps/server/test/file-manager/harness.ts index 54d1cfb8..df0b2d19 100644 --- a/apps/server/test/file-manager/harness.ts +++ b/apps/server/test/file-manager/harness.ts @@ -74,6 +74,8 @@ export interface FmHarness { api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>; /** 原始响应(非 JSON 接口,如 /files/download) */ raw(method: string, path: string, key?: string): Promise; + /** 带原始请求体(如 PUT /files/upload) */ + rawBody(method: string, path: string, body: BodyInit, key?: string): Promise; close(): Promise; } @@ -264,6 +266,15 @@ export async function createFmHarness( raw(method, path, key = DEV_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() { await ts.drop(); await rm(base, { recursive: true, force: true }); diff --git a/apps/server/test/storage/alist-files.test.ts b/apps/server/test/storage/alist-files.test.ts new file mode 100644 index 00000000..1bafe65e --- /dev/null +++ b/apps/server/test/storage/alist-files.test.ts @@ -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", ""); + }); + 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(""); + // 本地根同样可以上传 + 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(""); + 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(""); + 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:/); + }); +}); diff --git a/apps/server/test/storage/alist.ts b/apps/server/test/storage/alist.ts new file mode 100644 index 00000000..e336d9ff --- /dev/null +++ b/apps/server/test/storage/alist.ts @@ -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:) */ + root: string; + /** Alist 内路径 → 虚拟路径 */ + v(p: string): string; +} + +export async function addAlist( + db: Kysely, + o: { + name?: string; + rootPath?: string; + mock?: MockAlist; + mockOptions?: MockAlistOptions; + client?: Partial; + } = {}, +): Promise { + 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, 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); diff --git a/apps/web/app/components/files/mock.ts b/apps/web/app/components/files/mock.ts index a9284f3c..635ea41d 100644 --- a/apps/web/app/components/files/mock.ts +++ b/apps/web/app/components/files/mock.ts @@ -101,6 +101,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record } { usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB }, pendingCount: null, pendingPartial: false, + storage: null, }, { id: "lib:tv", @@ -115,6 +116,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record } { usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB }, pendingCount: null, pendingPartial: false, + storage: null, }, { id: "lib:nas-old", @@ -129,6 +131,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record } { usage: null, pendingCount: null, pendingPartial: false, + storage: null, }, { id: "dl:qb-main:0", @@ -143,6 +146,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record } { usage: { totalBytes: 8_000 * GB, freeBytes: 1_210 * GB }, pendingCount: 12, pendingPartial: false, + storage: null, }, ]; const tlouEps = [1, 2, 3, 4, 5, 6, 7].map((ep) => diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts index ce7902f6..6c47df57 100644 --- a/apps/web/app/components/files/model.ts +++ b/apps/web/app/components/files/model.ts @@ -480,7 +480,11 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint return null; } -export const ROOT_KIND_LABEL: Record = { download: "下载目录", library: "媒体库" }; +export const ROOT_KIND_LABEL: Record = { + download: "下载目录", + library: "媒体库", + storage: "Alist 存储", +}; export type Crumb = { label: string; path: string }; diff --git a/packages/contracts/src/api/organize.ts b/packages/contracts/src/api/organize.ts index b5c0c465..5d04d680 100644 --- a/packages/contracts/src/api/organize.ts +++ b/packages/contracts/src/api/organize.ts @@ -266,6 +266,14 @@ export const organizeApi = { body: files.FilesProbeRequest, response: files.FilesProbeResult, }), + upload: ep({ + method: "put", + path: "/files/upload", + scope: "organize.execute", + summary: "上传文件到目录(请求体为文件字节;同名不覆盖;Alist 根经服务端转传)", + query: files.FilesUploadQuery, + response: files.FilesUploadResult, + }), download: ep({ method: "get", path: "/files/download", diff --git a/packages/contracts/src/files.ts b/packages/contracts/src/files.ts index 301d51d4..fddf5167 100644 --- a/packages/contracts/src/files.ts +++ b/packages/contracts/src/files.ts @@ -13,8 +13,10 @@ import { ScrapeMode, ScrapeRecord } from "./scrape.ts"; // 移动/复制/整理/删除都是两段式:dryRun=true 取得 planId 与前后路径清单,执行时回传 planId; // 源文件 size/mtime 变化或 planId 过期/与请求不符 → 409 plan_stale。 -/** `lib:` 或 `dl::<路径映射序号>` */ -export const FileRootId = z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3})$/i, { message: "非法根 id" }); +/** `lib:`、`dl::<路径映射序号>` 或 `st:`(Alist 存储) */ +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; /** 根内相对路径:不以 / 开头,不含 `..` 段与 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; export const FileRoot = z.object({ @@ -59,6 +61,8 @@ export const FileRoot = z.object({ /** 健康探测失败原因(离线时) */ healthReason: z.string().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 */ pendingCount: NonNegInt.nullable(), /** G6:递归超过扫描上限时为 true,pendingCount 是下限 */ @@ -182,6 +186,8 @@ export const FilesListQuery = z.object({ .optional(), /** 默认 false:隐藏点文件、.uhd-partial、.umt-trash、哨兵 */ hidden: boolQuery.default(false), + /** Alist 根:让 Alist 刷新目录缓存(穿透到网盘接口;只在用户点「刷新」时带) */ + refresh: boolQuery.default(false), }); export type FilesListQuery = z.infer; @@ -797,6 +803,20 @@ export type FilesProbeResult = z.infer; // 下载(W5-A2):GET /files/download 直接返回文件流(非 JSON 信封);仅管理员会话; // 仅 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; +export const FilesUploadResult = z.object({ path: FileRelPath, size: NonNegInt }); +export type FilesUploadResult = z.infer; + export const FilesDownloadQuery = z.object({ root: FileRootId, path: FileRelPath, inline: boolQuery.default(false) }); export type FilesDownloadQuery = z.infer; /** OpenAPI 描述用:实际响应为二进制流 */