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:
rootandClaude Opus 5.5 committed 2026-10-06 23:52:20 +08:00
1 parent 5ff24110f8
commit c2bf8a071a
20 files changed
+1625 -8

No files matched your search

@@ -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)}`,
);
}
+30
View File
@@ -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>;