- contracts DownloaderPolicy(存 downloader_instance.settings.policy):硬阈值 / 恢复水位 / 清理目标 / 保种天数, 不填用全局 cache / retention - volumeThresholds:卷的生效水位 = 各实例「自己的值 ?? 全局」取最小;decideVolume、阈值清理、手动恢复、 首页卷信息、定时清理检查都改用它;cleanupCandidates 保种天数按实例 - 保存时校验合并全局后 恢复 < 硬阈值、清理目标 ≤ 恢复 - 下载器设置表单新增四项(留空用全局) Co-Authored-By: Claude Opus 5.5 <[email protected]>
246 lines
9.6 KiB
TypeScript
246 lines
9.6 KiB
TypeScript
import { z } from "zod";
|
|
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp, WriteOnlySecret } from "./common.ts";
|
|
import { SiteCredentialsBody } from "./sources.ts";
|
|
|
|
// 实例域:媒体库根、下载器实例、媒体服务器实例、路径映射(REQ §7.1、§8.4、§12.5)
|
|
// 凭证只以 secretRef 暴露;创建/更新时的 credential 是只写明文,服务端加密为 secret 后丢弃。
|
|
|
|
export const InstanceHealth = z.enum(["unknown", "healthy", "degraded", "offline"]);
|
|
export type InstanceHealth = z.infer<typeof InstanceHealth>;
|
|
|
|
export const LibraryMediaKind = z.enum(["movie", "series", "variety", "mixed", "other"]);
|
|
export type LibraryMediaKind = z.infer<typeof LibraryMediaKind>;
|
|
|
|
/** register_only:只登记不自动维护;managed:允许按策略补齐/升级(REQ §6.6) */
|
|
export const ManagementMode = z.enum(["register_only", "managed"]);
|
|
export type ManagementMode = z.infer<typeof ManagementMode>;
|
|
|
|
export const LibraryRoot = EntityBase.extend({
|
|
name: z.string().min(1),
|
|
mediaKind: LibraryMediaKind,
|
|
/** 执行进程可访问的绝对路径 */
|
|
localPath: z.string().startsWith("/"),
|
|
managementMode: ManagementMode,
|
|
enabled: z.boolean(),
|
|
sentinelFile: z.string().nullable(),
|
|
healthStatus: InstanceHealth,
|
|
lastHealthAt: Timestamp.nullable(),
|
|
healthDetail: JsonObject,
|
|
notes: z.string().nullable(),
|
|
/** 库根级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /library-roots/{id}/naming 修改 */
|
|
naming: JsonObject.nullable().optional(),
|
|
});
|
|
export type LibraryRoot = z.infer<typeof LibraryRoot>;
|
|
export const LibraryRootCreate = LibraryRoot.pick({
|
|
name: true,
|
|
mediaKind: true,
|
|
localPath: true,
|
|
managementMode: true,
|
|
enabled: true,
|
|
sentinelFile: true,
|
|
notes: true,
|
|
}).partial({ mediaKind: true, managementMode: true, enabled: true, sentinelFile: true, notes: true });
|
|
export type LibraryRootCreate = z.infer<typeof LibraryRootCreate>;
|
|
export const LibraryRootUpdate = LibraryRootCreate.partial();
|
|
export type LibraryRootUpdate = z.infer<typeof LibraryRootUpdate>;
|
|
|
|
/** 只写凭证:明文仅在请求中出现,绝不回显;空串 = 不设置 / 不修改 */
|
|
const Credential = WriteOnlySecret(4096);
|
|
|
|
export const DownloaderKind = z.enum(["qbittorrent"]);
|
|
export type DownloaderKind = z.infer<typeof DownloaderKind>;
|
|
|
|
/**
|
|
* 下载器认证方式(存 settings.authKind):password 账密登录(凭证=密码);api_key qB 5.2+ WebUI 生成的 API Key
|
|
* (凭证=Key,走 Authorization: Bearer,不登录);none 无认证(qB 对内网白名单跳过认证)。
|
|
*/
|
|
export const DownloaderAuthKind = z.enum(["password", "api_key", "none"]);
|
|
export type DownloaderAuthKind = z.infer<typeof DownloaderAuthKind>;
|
|
|
|
/**
|
|
* 下载器自己的水位与保种(存 settings.policy,不填的项用全局「缓存」「保留」设置)。
|
|
* 多个实例共用一个缓存卷时取各实例生效值里最严格的(硬阈值 / 恢复水位 / 清理目标都取最小)。
|
|
* 公网 BT 专用的 qB 可以单独设,例如保种 0 天 = 入库后即可清理。
|
|
*/
|
|
export const DownloaderPolicy = z
|
|
.object({
|
|
hardThresholdPercent: z.number().min(1).max(100).optional(),
|
|
resumePercent: z.number().min(1).max(100).optional(),
|
|
cleanupTargetPercent: z.number().min(1).max(100).optional(),
|
|
seedRetentionDays: z.number().int().min(0).max(365).optional(),
|
|
})
|
|
.strict();
|
|
export type DownloaderPolicy = z.infer<typeof DownloaderPolicy>;
|
|
|
|
export const DownloaderInstance = EntityBase.extend({
|
|
name: z.string().min(1),
|
|
kind: DownloaderKind,
|
|
baseUrl: z.url(),
|
|
username: z.string().nullable(),
|
|
secretRef: Id.nullable(),
|
|
/** 管理标签名(REQ §8.4) */
|
|
managedTag: z.string().min(1),
|
|
/** 共享缓存卷标识:多 qB 共用同一卷时按此汇总水位 */
|
|
cacheVolumeKey: z.string().nullable(),
|
|
enabled: z.boolean(),
|
|
/** 默认下载器(至多一个):订阅 / 手动没指定、站点也没指定时用它 */
|
|
isDefault: z.boolean(),
|
|
healthStatus: InstanceHealth,
|
|
lastSeenAt: Timestamp.nullable(),
|
|
versionInfo: JsonObject,
|
|
settings: JsonObject,
|
|
});
|
|
export type DownloaderInstance = z.infer<typeof DownloaderInstance>;
|
|
export const DownloaderInstanceCreate = DownloaderInstance.pick({
|
|
name: true,
|
|
kind: true,
|
|
baseUrl: true,
|
|
username: true,
|
|
managedTag: true,
|
|
cacheVolumeKey: true,
|
|
enabled: true,
|
|
isDefault: true,
|
|
settings: true,
|
|
})
|
|
.partial({
|
|
kind: true,
|
|
username: true,
|
|
managedTag: true,
|
|
cacheVolumeKey: true,
|
|
enabled: true,
|
|
isDefault: true,
|
|
settings: true,
|
|
})
|
|
.extend({
|
|
credential: Credential.optional(),
|
|
/** 认证方式,写入 settings.authKind;缺省按"有用户名 → 账密,否则有凭证 → API Key,否则无认证"推断 */
|
|
authKind: DownloaderAuthKind.optional(),
|
|
});
|
|
export type DownloaderInstanceCreate = z.infer<typeof DownloaderInstanceCreate>;
|
|
/** 更新:credential 省略或空串 = 不变,null = 清除 */
|
|
export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial().extend({
|
|
credential: Credential.nullable().optional(),
|
|
});
|
|
export type DownloaderInstanceUpdate = z.infer<typeof DownloaderInstanceUpdate>;
|
|
|
|
export const MediaServerKind = z.enum(["emby", "jellyfin"]);
|
|
export type MediaServerKind = z.infer<typeof MediaServerKind>;
|
|
|
|
export const MediaServerInstance = EntityBase.extend({
|
|
name: z.string().min(1),
|
|
kind: MediaServerKind,
|
|
baseUrl: z.url(),
|
|
userId: z.string().nullable(),
|
|
secretRef: Id.nullable(),
|
|
enabled: z.boolean(),
|
|
healthStatus: InstanceHealth,
|
|
lastSeenAt: Timestamp.nullable(),
|
|
versionInfo: JsonObject,
|
|
settings: JsonObject,
|
|
});
|
|
export type MediaServerInstance = z.infer<typeof MediaServerInstance>;
|
|
export const MediaServerInstanceCreate = MediaServerInstance.pick({
|
|
name: true,
|
|
kind: true,
|
|
baseUrl: true,
|
|
userId: true,
|
|
enabled: true,
|
|
settings: true,
|
|
})
|
|
.partial({ kind: true, userId: true, enabled: true, settings: true })
|
|
.extend({ credential: Credential.optional() });
|
|
export type MediaServerInstanceCreate = z.infer<typeof MediaServerInstanceCreate>;
|
|
/** 更新:credential 省略或空串 = 不变,null = 清除 */
|
|
export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial().extend({
|
|
credential: Credential.nullable().optional(),
|
|
});
|
|
export type MediaServerInstanceUpdate = z.infer<typeof MediaServerInstanceUpdate>;
|
|
|
|
export const PathViewKind = z.enum(["downloader", "media_server"]);
|
|
export type PathViewKind = z.infer<typeof PathViewKind>;
|
|
export const PathMappingVerifyStatus = z.enum(["unverified", "ok", "failed"]);
|
|
export type PathMappingVerifyStatus = z.infer<typeof PathMappingVerifyStatus>;
|
|
|
|
/** 下载器/媒体服务器路径视图 → 执行进程可访问路径的前缀映射(REQ §12.5) */
|
|
export const PathMapping = EntityBase.extend({
|
|
viewKind: PathViewKind,
|
|
downloaderInstanceId: Id.nullable(),
|
|
mediaServerInstanceId: Id.nullable(),
|
|
libraryRootId: Id.nullable(),
|
|
remotePrefix: z.string().min(1),
|
|
localPrefix: z.string().startsWith("/"),
|
|
priority: z.number().int(),
|
|
enabled: z.boolean(),
|
|
verifyStatus: PathMappingVerifyStatus,
|
|
verifiedAt: Timestamp.nullable(),
|
|
verifyDetail: JsonObject,
|
|
});
|
|
export type PathMapping = z.infer<typeof PathMapping>;
|
|
|
|
const pathMappingOwnerOk = (v: {
|
|
viewKind?: PathViewKind;
|
|
downloaderInstanceId?: string | null;
|
|
mediaServerInstanceId?: string | null;
|
|
}) =>
|
|
v.viewKind === undefined ||
|
|
(v.viewKind === "downloader"
|
|
? !!v.downloaderInstanceId && !v.mediaServerInstanceId
|
|
: !!v.mediaServerInstanceId && !v.downloaderInstanceId);
|
|
|
|
const PathMappingInput = PathMapping.omit({
|
|
...SERVER_MANAGED,
|
|
verifyStatus: true,
|
|
verifiedAt: true,
|
|
verifyDetail: true,
|
|
});
|
|
export const PathMappingCreate = PathMappingInput.partial({
|
|
downloaderInstanceId: true,
|
|
mediaServerInstanceId: true,
|
|
libraryRootId: true,
|
|
priority: true,
|
|
enabled: true,
|
|
}).refine(pathMappingOwnerOk, { message: "viewKind 与实例 ID 不匹配" });
|
|
export type PathMappingCreate = z.infer<typeof PathMappingCreate>;
|
|
export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOwnerOk, {
|
|
message: "viewKind 与实例 ID 不匹配",
|
|
});
|
|
export type PathMappingUpdate = z.infer<typeof PathMappingUpdate>;
|
|
|
|
/** POST /{downloader,media-server}-instances/{id}/test 与 /library-roots/{id}/test 结果 */
|
|
export const InstanceTestResult = z.object({
|
|
ok: z.boolean(),
|
|
version: z.string().nullable(),
|
|
message: z.string().nullable(),
|
|
healthStatus: InstanceHealth,
|
|
});
|
|
export type InstanceTestResult = z.infer<typeof InstanceTestResult>;
|
|
|
|
/**
|
|
* @deprecated 旧格式 {authKind, credential, username?};新客户端用 SiteCredentialsBody({kind, ...})。
|
|
* 服务端两种都接受,旧格式按 kind 转换。
|
|
*/
|
|
export const SiteCredentialsLegacyPut = z.object({
|
|
label: z.string().min(1).max(80).default("default"),
|
|
authKind: z.enum(["cookie", "passkey", "api_key", "password"]),
|
|
/** 只写明文 */
|
|
credential: z.string().min(1).max(4096),
|
|
/** authKind=password 时的登录名 */
|
|
username: z.string().max(200).optional(),
|
|
enabled: z.boolean().optional(),
|
|
});
|
|
export type SiteCredentialsLegacyPut = z.infer<typeof SiteCredentialsLegacyPut>;
|
|
/** PUT /sites/{id}/credentials 请求体:新格式(按 kind 判别)或旧格式 */
|
|
export const SiteCredentialsPut = z.union([SiteCredentialsBody, SiteCredentialsLegacyPut]);
|
|
export type SiteCredentialsPut = z.infer<typeof SiteCredentialsPut>;
|
|
export const SiteCredentialsResult = z.object({
|
|
siteAccountId: Id,
|
|
siteId: Id,
|
|
label: z.string(),
|
|
authKind: z.string(),
|
|
/** 本次写入的 secret 引用(passkey 请求时为 passkey 的引用) */
|
|
secretRef: Id,
|
|
/** true:新建账号;false:替换已有凭证 */
|
|
created: z.boolean(),
|
|
});
|
|
export type SiteCredentialsResult = z.infer<typeof SiteCredentialsResult>;
|