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; export const LibraryMediaKind = z.enum(["movie", "series", "variety", "mixed", "other"]); export type LibraryMediaKind = z.infer; /** register_only:只登记不自动维护;managed:允许按策略补齐/升级(REQ §6.6) */ export const ManagementMode = z.enum(["register_only", "managed"]); export type ManagementMode = z.infer; 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; 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; export const LibraryRootUpdate = LibraryRootCreate.partial(); export type LibraryRootUpdate = z.infer; /** 只写凭证:明文仅在请求中出现,绝不回显;空串 = 不设置 / 不修改 */ const Credential = WriteOnlySecret(4096); export const DownloaderKind = z.enum(["qbittorrent"]); export type DownloaderKind = z.infer; /** * 下载器认证方式(存 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; 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; 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; /** 更新:credential 省略或空串 = 不变,null = 清除 */ export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial().extend({ credential: Credential.nullable().optional(), }); export type DownloaderInstanceUpdate = z.infer; export const MediaServerKind = z.enum(["emby", "jellyfin"]); export type MediaServerKind = z.infer; 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; 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; /** 更新:credential 省略或空串 = 不变,null = 清除 */ export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial().extend({ credential: Credential.nullable().optional(), }); export type MediaServerInstanceUpdate = z.infer; export const PathViewKind = z.enum(["downloader", "media_server"]); export type PathViewKind = z.infer; export const PathMappingVerifyStatus = z.enum(["unverified", "ok", "failed"]); export type PathMappingVerifyStatus = z.infer; /** 下载器/媒体服务器路径视图 → 执行进程可访问路径的前缀映射(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; 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; export const PathMappingUpdate = PathMappingInput.partial().refine(pathMappingOwnerOk, { message: "viewKind 与实例 ID 不匹配", }); export type PathMappingUpdate = z.infer; /** 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; /** * @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; /** PUT /sites/{id}/credentials 请求体:新格式(按 kind 判别)或旧格式 */ export const SiteCredentialsPut = z.union([SiteCredentialsBody, SiteCredentialsLegacyPut]); export type SiteCredentialsPut = z.infer; 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;