feat(storage): 存储实例与 Alist 虚拟路径(迁移 0090、设置接口、注册表、vfs、健康检查)
- storage_instance 表 + secret 用途 storage_credential;令牌 / 密码 / 路径密码只写不读 - /storage-instances CRUD 与测试连接(版本、用户、根路径、容量);被库根引用不能删 - 远端路径 /@alist/<id>/<路径>,vfs 统一本地 / Alist 的 stat、列目录、建目录、读写、移动、删除 - 整理矩阵中涉及 Alist 的传输(storage/transfer.ts)与远端 ffprobe(直链只读头) - 媒体库目录可登记在 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
5ff24110f8
commit
c2bf8a071a
20 files changed
+1625
-8
No files matched your search
@@ -49,6 +49,8 @@ export interface ProbeOptions {
|
||||
/** ffprobe 可执行文件,默认 "ffprobe"(PATH 查找) */
|
||||
bin?: string;
|
||||
timeoutMs?: number;
|
||||
/** 输入前的附加参数(远端直链用 -rw_timeout 等) */
|
||||
inputArgs?: string[];
|
||||
}
|
||||
|
||||
const optNum = (v: unknown): number | undefined => {
|
||||
@@ -78,7 +80,7 @@ export function ffprobe(path: string, opts: ProbeOptions = {}): Promise<ProbeRes
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
opts.bin ?? "ffprobe",
|
||||
["-v", "error", "-print_format", "json", "-show_format", "-show_streams", "--", path],
|
||||
["-v", "error", ...(opts.inputArgs ?? []), "-print_format", "json", "-show_format", "-show_streams", "--", path],
|
||||
{ timeout: opts.timeoutMs ?? 30_000, maxBuffer: 16 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// Alist / OpenList 存储实例(docs/design/storage-alist.md §3):远端路径用虚拟前缀 /@alist/<id>/<路径> 表示,
|
||||
// 库根 local_path、整理记录、资产位置里都直接存这个路径,不需要新列。凭据走 secret 表(purpose storage_credential)。
|
||||
|
||||
const PURPOSE_OLD = [
|
||||
"site_cookie",
|
||||
"site_passkey",
|
||||
"site_api_key",
|
||||
"site_password",
|
||||
"feed_url",
|
||||
"downloader_password",
|
||||
"media_server_api_key",
|
||||
"metadata_api_key",
|
||||
"llm_api_key",
|
||||
"other",
|
||||
];
|
||||
const PURPOSE_NEW = [...PURPOSE_OLD.slice(0, -1), "storage_credential", "other"];
|
||||
const HEALTH = ["unknown", "healthy", "degraded", "offline"];
|
||||
|
||||
const swapPurpose = (values: readonly string[]) => `
|
||||
alter table secret drop constraint if exists secret_purpose_check;
|
||||
alter table secret add constraint secret_purpose_check ${oneOf("purpose", values)};
|
||||
`;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, swapPurpose(PURPOSE_NEW));
|
||||
await execDdl(
|
||||
db,
|
||||
entityTable(
|
||||
"storage_instance",
|
||||
`
|
||||
name text not null unique,
|
||||
kind text not null default 'alist' ${oneOf("kind", ["alist"])},
|
||||
flavor text not null default 'openlist' ${oneOf("flavor", ["alist", "openlist"])},
|
||||
base_url text not null,
|
||||
root_path text not null default '/' check (root_path like '/%'),
|
||||
auth_kind text not null default 'token' ${oneOf("auth_kind", ["token", "password", "guest"])},
|
||||
username text,
|
||||
secret_ref uuid references secret(id) on delete set null,
|
||||
path_password_ref uuid references secret(id) on delete set null,
|
||||
enabled boolean not null default true,
|
||||
options jsonb not null default '{}'::jsonb,
|
||||
health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)},
|
||||
last_health_at timestamptz,
|
||||
health_detail jsonb not null default '{}'::jsonb,
|
||||
version_info jsonb not null default '{}'::jsonb`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`drop table if exists storage_instance cascade;
|
||||
delete from secret where purpose = 'storage_credential';
|
||||
${swapPurpose(PURPOSE_OLD)}`,
|
||||
);
|
||||
}
|
||||
@@ -246,6 +246,7 @@ export type SecretPurpose =
|
||||
| "media_server_api_key"
|
||||
| "metadata_api_key"
|
||||
| "llm_api_key"
|
||||
| "storage_credential"
|
||||
| "other";
|
||||
export type ServerMappingItemType = "movie" | "series" | "season" | "episode" | "other";
|
||||
export type ServerMappingMatchStatus = "matched" | "ambiguous" | "unmatched" | "stale";
|
||||
@@ -260,6 +261,10 @@ export type SiteSnapshotKind = "periodic" | "backfill" | "live_search" | "manual
|
||||
export type SiteSnapshotStatus = "running" | "ok" | "partial" | "failed";
|
||||
export type SourceReleaseResolutionStatus = "unresolved" | "resolved" | "ambiguous" | "unidentifiable" | "rejected";
|
||||
export type SourceReleaseAvailability = "active" | "site_disabled" | "expired" | "unknown";
|
||||
export type StorageInstanceKind = "alist";
|
||||
export type StorageInstanceFlavor = "alist" | "openlist";
|
||||
export type StorageInstanceAuthKind = "token" | "password" | "guest";
|
||||
export type StorageInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline";
|
||||
export type SubscriptionMediaKind = "movie" | "series" | "variety";
|
||||
export type SubscriptionMode = "once" | "continuous";
|
||||
export type SubscriptionSeasonScope = "current_season" | "following_seasons";
|
||||
@@ -1635,6 +1640,27 @@ export interface SseEventTable {
|
||||
payload: Generated<Json>;
|
||||
}
|
||||
|
||||
export interface StorageInstanceTable {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
kind: Generated<StorageInstanceKind>;
|
||||
flavor: Generated<StorageInstanceFlavor>;
|
||||
base_url: string;
|
||||
root_path: Generated<string>;
|
||||
auth_kind: Generated<StorageInstanceAuthKind>;
|
||||
username: string | null;
|
||||
secret_ref: string | null;
|
||||
path_password_ref: string | null;
|
||||
enabled: Generated<boolean>;
|
||||
options: Generated<Json>;
|
||||
health_status: Generated<StorageInstanceHealthStatus>;
|
||||
last_health_at: Timestamptz | null;
|
||||
health_detail: Generated<Json>;
|
||||
version_info: Generated<Json>;
|
||||
created_at: GeneratedTimestamptz;
|
||||
updated_at: GeneratedTimestamptz;
|
||||
}
|
||||
|
||||
export interface SubscriptionTable {
|
||||
id: Generated<string>;
|
||||
media_entity_id: string | null;
|
||||
@@ -1869,6 +1895,7 @@ export interface DB {
|
||||
slot_unit: SlotUnitTable;
|
||||
source_release: SourceReleaseTable;
|
||||
sse_event: SseEventTable;
|
||||
storage_instance: StorageInstanceTable;
|
||||
subscription: SubscriptionTable;
|
||||
subscription_history: SubscriptionHistoryTable;
|
||||
subtitle_write: SubtitleWriteTable;
|
||||
@@ -2115,6 +2142,9 @@ export type SourceReleaseUpdate = Updateable<SourceReleaseTable>;
|
||||
export type SseEventRow = Selectable<SseEventTable>;
|
||||
export type NewSseEvent = Insertable<SseEventTable>;
|
||||
export type SseEventUpdate = Updateable<SseEventTable>;
|
||||
export type StorageInstanceRow = Selectable<StorageInstanceTable>;
|
||||
export type NewStorageInstance = Insertable<StorageInstanceTable>;
|
||||
export type StorageInstanceUpdate = Updateable<StorageInstanceTable>;
|
||||
export type SubscriptionRow = Selectable<SubscriptionTable>;
|
||||
export type NewSubscription = Insertable<SubscriptionTable>;
|
||||
export type SubscriptionUpdate = Updateable<SubscriptionTable>;
|
||||
|
||||
Reference in new issue
Block a user