import { type DownloaderInstance, DownloaderInstanceCreate, DownloaderInstanceUpdate, type LibraryRoot, LibraryRootCreate, LibraryRootUpdate, type MediaServerInstance, MediaServerInstanceCreate, MediaServerInstanceUpdate, } from "@umt/contracts"; import { PlugZap } from "lucide-react"; import { toast } from "sonner"; import { downloaders, libraryRoots, mediaServers } from "~/api/instances.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formatRelative, healthLabel } from "~/lib/labels.ts"; import { secretPatch } from "~/lib/secret.ts"; import { PathMappingsPanel } from "./path-mappings.tsx"; import { type FormValues, nullStr, optStr, ResourceSection, str } from "./resource-section.tsx"; import { SettingsBlock } from "./settings-block.tsx"; export function Health({ status, at }: { status: string; at?: string | null }) { const h = healthLabel(status); return (
{h.label} {at ? {formatRelative(at)} : null}
); } function Enabled({ on }: { on: boolean }) { return {on ? "启用" : "停用"}; } /** 行内"测试连接"按钮:结果 toast,health 写回后列表自动刷新 */ export function TestButton({ run, pending, label = "测试连接", }: { run: () => Promise<{ ok: boolean; message: string | null; version?: string | null }>; pending: boolean; label?: string; }) { return ( ); } /** 下载器:已存凭证只对保存时的认证方式有效(改了认证方式就要重新填) */ const dlSecretSet = (v: FormValues) => v["secret:set"] === true && v["secret:authKind"] === v.authKind; const AUTH_KINDS = [ { value: "password", label: "用户名和密码" }, { value: "api_key", label: "API Key" }, { value: "none", label: "无认证(内网白名单)" }, ]; /** 已存认证方式;旧数据没有 settings.authKind 时按用户名 / 凭证推断(与服务端一致) */ function authKindOf(r: DownloaderInstance): string { const k = (r.settings as { authKind?: unknown }).authKind; if (k === "password" || k === "api_key" || k === "none") return k; return r.username ? "password" : r.secretRef ? "api_key" : "none"; } export function DownloadersSection() { return ( ); } function DownloadersTable() { const list = downloaders.useList(); const create = downloaders.useCreate(); const update = downloaders.useUpdate(); const remove = downloaders.useRemove(); const test = downloaders.useTest(); return ( hooks={{ list, create, update, remove } as never} config={{ noun: "下载器实例", description: "只接管带管理标签的任务。可添加多个下载器:订阅或手动下载指定了下载器就用指定的,否则用站点指定的,再否则用默认下载器。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。", emptyHint: "添加 qBittorrent 后才能提交下载。", drawerExtra: (r) => , columns: [ { key: "name", header: "别名", cell: (r) => (

{r.name} {r.isDefault ? ( 默认 ) : null}

{r.baseUrl}

), }, { key: "tag", header: "管理标签", hideBelow: "md", cell: (r) => {r.managedTag}, }, { key: "vol", header: "缓存卷", hideBelow: "lg", cell: (r) => r.cacheVolumeKey ?? "—" }, { key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => }, { key: "health", header: "健康", cell: (r) => }, ], rowActions: (r) => test.mutateAsync(r.id)} />, fields: [ { name: "name", label: "别名", kind: "text", required: true, placeholder: "例如:家里的 qB、番剧专用" }, { name: "baseUrl", label: "WebUI 地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8080", }, { name: "authKind", label: "认证方式", kind: "select", options: AUTH_KINDS, hint: "API Key 需要 qBittorrent 5.2 及以上,在 WebUI 设置里生成", }, { name: "username", label: "用户名", kind: "text", visible: (v) => v.authKind === "password" }, { name: "password", label: "密码", kind: "secret", secret: { set: dlSecretSet, clearable: true }, visible: (v) => v.authKind === "password", }, { name: "apiKey", label: "API Key", kind: "secret", secret: { set: dlSecretSet }, placeholder: "qbt_…", visible: (v) => v.authKind === "api_key", }, { name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" }, { name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" }, { name: "isDefault", label: "设为默认下载器", kind: "switch", hint: "订阅、手动下载和站点都没指定下载器时用它;只能有一个默认", }, { name: "enabled", label: "启用", kind: "switch" }, ], initial: { name: "", baseUrl: "", authKind: "password", username: "", password: "", apiKey: "", managedTag: "umt", cacheVolumeKey: "", isDefault: false, enabled: true, }, fromRow: (r) => ({ name: r.name, baseUrl: r.baseUrl, authKind: authKindOf(r), username: r.username ?? "", password: "", apiKey: "", "secret:set": !!r.secretRef, "secret:authKind": authKindOf(r), managedTag: r.managedTag, cacheVolumeKey: r.cacheVolumeKey ?? "", isDefault: r.isDefault, enabled: r.enabled, }), toBody: (v: FormValues, mode) => ({ name: str(v.name), kind: mode === "create" ? "qbittorrent" : undefined, baseUrl: str(v.baseUrl), authKind: str(v.authKind), username: v.authKind === "password" ? nullStr(v.username) : null, credential: v.authKind === "password" ? secretPatch(v.password) : v.authKind === "api_key" ? secretPatch(v.apiKey) : undefined, managedTag: optStr(v.managedTag), cacheVolumeKey: nullStr(v.cacheVolumeKey), isDefault: v.isDefault === true, enabled: v.enabled === true, }), createSchema: DownloaderInstanceCreate, updateSchema: DownloaderInstanceUpdate, }} /> ); } export function MediaServersSection() { return ( ); } function MediaServersTable() { const list = mediaServers.useList(); const create = mediaServers.useCreate(); const update = mediaServers.useUpdate(); const remove = mediaServers.useRemove(); const test = mediaServers.useTest(); return ( hooks={{ list, create, update, remove } as never} config={{ noun: "媒体服务器", description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和路径映射。", drawerExtra: (r) => , columns: [ { key: "name", header: "名称", cell: (r) => (

{r.name}

{r.baseUrl}

), }, { key: "kind", header: "类型", hideBelow: "md", cell: (r) => (r.kind === "emby" ? "Emby" : "Jellyfin") }, { key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => }, { key: "health", header: "健康", cell: (r) => }, ], rowActions: (r) => test.mutateAsync(r.id)} />, fields: [ { name: "name", label: "名称", kind: "text", required: true }, { name: "kind", label: "类型", kind: "select", options: [ { value: "emby", label: "Emby" }, { value: "jellyfin", label: "Jellyfin" }, ], }, { name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" }, { name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" }, { name: "credential", label: "API Key", kind: "secret", secret: { set: (v) => v["secret:set"] === true, clearable: true }, }, { name: "enabled", label: "启用", kind: "switch" }, ], initial: { name: "", kind: "emby", baseUrl: "", userId: "", credential: "", enabled: true }, fromRow: (r) => ({ name: r.name, kind: r.kind, baseUrl: r.baseUrl, userId: r.userId ?? "", credential: "", "secret:set": !!r.secretRef, enabled: r.enabled, }), toBody: (v) => ({ name: str(v.name), kind: str(v.kind), baseUrl: str(v.baseUrl), userId: nullStr(v.userId), credential: secretPatch(v.credential), enabled: v.enabled === true, }), createSchema: MediaServerInstanceCreate, updateSchema: MediaServerInstanceUpdate, }} /> ); } const MEDIA_KIND = [ { value: "movie", label: "电影" }, { value: "series", label: "剧集" }, { value: "variety", label: "综艺" }, { value: "mixed", label: "混合" }, { value: "other", label: "其它" }, ]; export function LibraryRootsTable() { const list = libraryRoots.useList(); const create = libraryRoots.useCreate(); const update = libraryRoots.useUpdate(); const remove = libraryRoots.useRemove(); const test = libraryRoots.useTest(); return ( hooks={{ list, create, update, remove } as never} config={{ noun: "媒体库目录", description: "保存时会在目录里放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。", deleteHint: "只删除登记,磁盘上的文件不动。", columns: [ { key: "name", header: "名称", cell: (r) => (

{r.name}

{r.localPath}

), }, { key: "kind", header: "类型", hideBelow: "md", cell: (r) => MEDIA_KIND.find((k) => k.value === r.mediaKind)?.label ?? r.mediaKind, }, { key: "mode", header: "管理方式", hideBelow: "lg", cell: (r) => (r.managementMode === "managed" ? "自动维护" : "只登记"), }, { key: "health", header: "在线", cell: (r) => }, ], rowActions: (r) => test.mutateAsync(r.id)} />, fields: [ { name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" }, { name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND }, { name: "localPath", label: "本地路径", kind: "text", required: true, placeholder: "/media/movies", hint: "服务进程能访问的绝对路径", wide: true, }, { name: "managementMode", label: "管理方式", kind: "select", options: [ { value: "register_only", label: "只登记" }, { value: "managed", label: "自动维护(补齐、升级)" }, ], hint: "自动维护:自动整理可把新下载放进来,文件管理可在其中改名、移动、删除;只登记:只扫描登记已有文件,不自动写入也不可改动。", wide: true, }, { name: "enabled", label: "启用", kind: "switch" }, { name: "notes", label: "备注", kind: "textarea" }, ], initial: { name: "", mediaKind: "movie", localPath: "", managementMode: "register_only", enabled: true, notes: "", }, fromRow: (r) => ({ name: r.name, mediaKind: r.mediaKind, localPath: r.localPath, managementMode: r.managementMode, enabled: r.enabled, notes: r.notes ?? "", }), toBody: (v) => ({ name: str(v.name), mediaKind: str(v.mediaKind), localPath: str(v.localPath), managementMode: str(v.managementMode), enabled: v.enabled === true, notes: nullStr(v.notes), }), createSchema: LibraryRootCreate, updateSchema: LibraryRootUpdate, }} /> ); }