merge: feat/alist-storage

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei

# Conflicts:
#	packages/adapters/deno.json
This commit is contained in:
root committed 2026-10-07 00:48:53 +08:00
commit 339105e151
108 files changed
+7255 -214

No files matched your search

+22
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { IdParam } from "../common.ts";
import * as instances from "../instances.ts";
import * as storage from "../storage.ts";
import { crud, del, ep, instanceTest, R } from "./shared.ts";
export const instancesApi = {
@@ -54,6 +55,27 @@ export const instancesApi = {
test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"),
remove: del("/media-server-instances/{id}", "删除媒体服务器实例"),
},
storageInstances: {
...crud(
"/storage-instances",
"存储实例",
{
row: storage.StorageInstance,
create: storage.StorageInstanceCreate,
update: storage.StorageInstanceUpdate,
},
R,
),
test: ep({
method: "post",
path: "/storage-instances/{id}/test",
scope: "admin",
summary: "测试 Alist / OpenList 连接(版本、登录用户、根路径、容量)",
params: IdParam,
response: storage.StorageTestResult,
}),
remove: del("/storage-instances/{id}", "删除存储实例"),
},
pathMappings: {
...crud(
"/path-mappings",
+8
View File
@@ -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",
+4
View File
@@ -225,6 +225,10 @@ export const AuditAction = z.enum([
"media_server_instance.update",
"media_server_instance.delete",
"media_server_instance.test",
"storage_instance.create",
"storage_instance.update",
"storage_instance.delete",
"storage_instance.test",
"path_mapping.create",
"path_mapping.update",
"path_mapping.delete",
+23 -3
View File
@@ -13,8 +13,10 @@ import { ScrapeMode, ScrapeRecord } from "./scrape.ts";
// 移动/复制/整理/删除都是两段式:dryRun=true 取得 planId 与前后路径清单,执行时回传 planId;
// 源文件 size/mtime 变化或 planId 过期/与请求不符 → 409 plan_stale。
/** `lib:<library_root.id>` 或 `dl:<downloader_instance.id>:<路径映射序号>` */
export const FileRootId = z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3})$/i, { message: "非法根 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}|st:[0-9a-f-]{36})$/i, { message: "非法根 id" });
export type FileRootId = z.infer<typeof FileRootId>;
/** 根内相对路径:不以 / 开头,不含 `..` 段与 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 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<typeof FilesListQuery>;
@@ -797,6 +803,20 @@ export type FilesProbeResult = z.infer<typeof FilesProbeResult>;
// 下载(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<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 type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>;
/** OpenAPI 描述用:实际响应为二进制流 */
+1
View File
@@ -31,5 +31,6 @@ export * from "./redact.ts";
export * from "./scrape.ts";
export * from "./settings.ts";
export * from "./sources.ts";
export * from "./storage.ts";
export * from "./subtitles.ts";
export * from "./system-resources.ts";
+3 -1
View File
@@ -168,6 +168,7 @@ export const SecretPurpose = z.enum([
"media_server_api_key",
"metadata_api_key",
"llm_api_key",
"storage_credential",
"other",
]);
export type SecretPurpose = z.infer<typeof SecretPurpose>;
@@ -448,7 +449,8 @@ export const SystemDisk = z.object({
export type SystemDisk = z.infer<typeof SystemDisk>;
export const SystemMount = z.object({
kind: z.enum(["library_root", "path_mapping"]),
/** storage_instance = Alist / OpenList 实例(连通 + 根路径可访问;令牌失效为 offline/auth) */
kind: z.enum(["library_root", "path_mapping", "storage_instance"]),
id: z.string(),
name: z.string(),
path: z.string(),
+1 -1
View File
@@ -3,7 +3,7 @@ import { Id, JsonObject, Timestamp } from "./common.ts";
// Independent reference shape avoids settings → instances → settings import cycles.
const SourceRef = z.object({
root: z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3})$/i),
root: z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3}|st:[0-9a-f-]{36})$/i),
path: z
.string()
.max(4096)
+3 -2
View File
@@ -1085,9 +1085,10 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
id: "library",
group: "flow",
label: "媒体库与目录",
description: "媒体库目录、整理来源、分类规则、命名模板",
description: "存储、媒体库目录、整理来源、分类规则、命名模板",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "storages", label: "存储", keywords: ["Alist", "OpenList", "网盘", "令牌"] },
{ id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] },
{ id: "sources", label: "整理来源" },
{ id: "classification", label: "分类规则", keywords: ["二级分类"] },
@@ -1102,7 +1103,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
"naming.variety",
"naming.lockFileNameForBound",
],
resources: ["library-roots"],
resources: ["storage-instances", "library-roots"],
},
{
id: "organize",
+121
View File
@@ -0,0 +1,121 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, Timestamp, WriteOnlySecret } from "./common.ts";
import { InstanceHealth } from "./instances.ts";
// Alist / OpenList 存储实例(docs/design/storage-alist.md §3)。凭据(令牌 / 密码)与路径密码只写不读。
export const StorageKind = z.enum(["alist"]);
export type StorageKind = z.infer<typeof StorageKind>;
/** 只用于展示与版本差异;接口相同(MP 的 AlistGo 是同实现的子类) */
export const StorageFlavor = z.enum(["alist", "openlist"]);
export type StorageFlavor = z.infer<typeof StorageFlavor>;
/** token 永久令牌(Alist 后台「设置 → 其他 → 令牌」);password 账号密码换临时令牌;guest 访客 */
export const StorageAuthKind = z.enum(["token", "password", "guest"]);
export type StorageAuthKind = z.infer<typeof StorageAuthKind>;
/** 实例高级选项;不填用缺省(括号内) */
export const StorageOptions = z
.object({
/** 每秒请求数(5);0 不限 */
qps: z.number().int().min(0).max(100),
/** 并发(4);0 不限 */
concurrency: z.number().int().min(0).max(32),
/** 普通请求超时秒(30) */
timeoutSec: z.number().int().min(5).max(600),
/** 上传 / 下载无进度空闲超时秒(120) */
idleTimeoutSec: z.number().int().min(10).max(3600),
/** 等待 Alist 复制 / 移动 / 上传任务的上限分钟(360) */
taskTimeoutMin: z.number().int().min(1).max(10080),
/** 可重试错误的重试次数(3) */
retries: z.number().int().min(0).max(10),
/** 上传用 As-Task(false,同 MP) */
uploadAsTask: z.boolean(),
/** 目录监控最大深度(5,同 MP) */
snapshotMaxDepth: z.number().int().min(1).max(20),
/** 目录监控按目录修改时间跳过未变子树(true) */
snapshotCheckFolderModtime: z.boolean(),
})
.partial();
export type StorageOptions = z.infer<typeof StorageOptions>;
export const STORAGE_OPTION_DEFAULTS: Required<StorageOptions> = {
qps: 5,
concurrency: 4,
timeoutSec: 30,
idleTimeoutSec: 120,
taskTimeoutMin: 360,
retries: 3,
uploadAsTask: false,
snapshotMaxDepth: 5,
snapshotCheckFolderModtime: true,
};
/** Alist 内路径:以 / 开头,不含 .. 段 */
export const AlistPath = z
.string()
.max(4096)
.refine((p) => p.startsWith("/") && !p.includes("\0") && !p.split("/").some((s) => s === ".." || s === "."), {
message: "Alist 路径必须以 / 开头,不能含 . 或 ..",
});
export const StorageInstance = EntityBase.extend({
name: z.string().min(1).max(100),
kind: StorageKind,
flavor: StorageFlavor,
baseUrl: z.url(),
/** 本系统可浏览 / 使用的 Alist 根路径(缺省 /) */
rootPath: AlistPath,
authKind: StorageAuthKind,
username: z.string().nullable(),
secretRef: Id.nullable(),
pathPasswordRef: Id.nullable(),
enabled: z.boolean(),
options: StorageOptions,
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
healthDetail: JsonObject,
versionInfo: JsonObject,
});
export type StorageInstance = z.infer<typeof StorageInstance>;
export const StorageInstanceCreate = StorageInstance.pick({
name: true,
flavor: true,
baseUrl: true,
rootPath: true,
authKind: true,
username: true,
enabled: true,
options: true,
})
.partial({ flavor: true, rootPath: true, authKind: true, username: true, enabled: true, options: true })
.extend({
/** 令牌(authKind=token)或密码(authKind=password);只写 */
credential: WriteOnlySecret(4096).optional(),
/** 路径密码;只写 */
pathPassword: WriteOnlySecret(1024).optional(),
});
export type StorageInstanceCreate = z.infer<typeof StorageInstanceCreate>;
/** 更新:credential / pathPassword 省略或空串 = 不变,null = 清除 */
export const StorageInstanceUpdate = StorageInstanceCreate.partial().extend({
credential: WriteOnlySecret(4096).nullable().optional(),
pathPassword: WriteOnlySecret(1024).nullable().optional(),
});
export type StorageInstanceUpdate = z.infer<typeof StorageInstanceUpdate>;
export const StorageUsage = z.object({ total: z.number(), free: z.number(), used: z.number() });
export type StorageUsage = z.infer<typeof StorageUsage>;
export const StorageTestResult = z.object({
ok: z.boolean(),
healthStatus: InstanceHealth,
version: z.string().nullable(),
/** 登录用户(访客为 guest) */
user: z.string().nullable(),
/** 根路径存在且是目录 */
rootOk: z.boolean(),
/** 容量;驱动不给或令牌不是管理员时为 null(显示「未知」) */
usage: StorageUsage.nullable(),
message: z.string().nullable(),
});
export type StorageTestResult = z.infer<typeof StorageTestResult>;
+5
View File
@@ -65,6 +65,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
MediaAsset: { row: C.MediaAsset },
MediaEntity: { row: C.MediaEntity },
MediaServerInstance: { row: C.MediaServerInstance },
StorageInstance: { row: C.StorageInstance },
Observation: { row: C.Observation },
OrganizationFileResult: { row: C.OrganizationFileResult },
OrganizeSourceSnapshot: { row: C.OrganizeSourceSnapshotRow },
@@ -224,6 +225,10 @@ const ENUMS: Record<string, z.ZodEnum> = {
MediaEntityStatus: C.MediaEntityStatus,
MediaServerInstanceKind: C.MediaServerKind,
MediaServerInstanceHealthStatus: C.InstanceHealth,
StorageInstanceKind: C.StorageKind,
StorageInstanceFlavor: C.StorageFlavor,
StorageInstanceAuthKind: C.StorageAuthKind,
StorageInstanceHealthStatus: C.InstanceHealth,
ObservationStatus: C.ObservationStatus,
OrganizationFileResultRole: C.FileRole,
OrganizationFileResultMode: C.OrganizationAction,