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, storages } 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"; } /** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用「下载器」分区的缺省值 */ const POLICY_FIELDS = [ ["hardPct", "hardThresholdPercent"], ["resumePct", "resumePercent"], ["targetPct", "cleanupTargetPercent"], ["seedDays", "seedRetentionDays"], ] as const; function policyForm(r: DownloaderInstance): Record { const p = ((r.settings as { policy?: Record }).policy ?? {}) as Record; return { ...Object.fromEntries(POLICY_FIELDS.map(([f, k]) => [f, typeof p[k] === "number" ? String(p[k]) : ""])), hnrUnknownCleanable: p.hnrUnknownCleanable === true, }; } function policyBody(v: FormValues): Record { const out: Record = {}; for (const [f, k] of POLICY_FIELDS) { const raw = String(v[f] ?? "").trim(); if (raw !== "" && Number.isFinite(Number(raw))) out[k] = Number(raw); } if (v.hnrUnknownCleanable === true) out.hnrUnknownCleanable = true; return out; } export 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: "hardPct", label: "暂停线(%)", kind: "number", placeholder: "留空用缺省值", hint: "这个下载器的下载盘用到多少就停止新增下载;多个下载器共用一块盘时取最严格的", }, { name: "resumePct", label: "恢复线(%)", kind: "number", placeholder: "留空用缺省值" }, { name: "targetPct", label: "清理目标(%)", kind: "number", placeholder: "留空用缺省值" }, { name: "seedDays", label: "保种天数", kind: "number", placeholder: "留空用缺省值", hint: "整理后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)", }, { name: "hnrUnknownCleanable", label: "H&R 未知也可清理", kind: "switch", hint: "保种天数到期后,没给出 H&R 要求的任务(如 M-Team、接管的任务、站点没配 H&R)也允许清理;默认不清理", }, { name: "isDefault", label: "设为默认下载器", kind: "switch", hint: "订阅、手动下载和站点都没指定下载器时用它;只能有一个默认", }, { name: "enabled", label: "启用", kind: "switch" }, ], initial: { name: "", baseUrl: "", authKind: "password", username: "", password: "", apiKey: "", managedTag: "umt", cacheVolumeKey: "", hardPct: "", resumePct: "", targetPct: "", seedDays: "", hnrUnknownCleanable: false, 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 ?? "", ...policyForm(r), 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), settings: { policy: policyBody(v) }, 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: "其他" }, ]; /** 库根路径 ↔ 存储 + 路径:Alist 上的库根存成 /@alist/<存储 id>/ */ const ALIST_PATH = /^\/@alist\/([0-9a-f-]{36})(\/.*)?$/i; export function splitRootPath(p: string): { storage: string; path: string } { const m = ALIST_PATH.exec(p); return m ? { storage: m[1]!.toLowerCase(), path: m[2] || "/" } : { storage: "local", path: p }; } export function joinRootPath(storage: string, path: string): string { if (storage === "local") return path; const p = `/${path.trim()}`.replace(/\/+/g, "/").replace(/\/$/, ""); return `/@alist/${storage}${p}`; } export function LibraryRootsTable() { const storageList = storages.useList(); const storageOptions = [ { value: "local", label: "本机" }, ...(storageList.data?.items ?? []).map((s) => ({ value: s.id, label: `${s.name}(Alist)` })), ]; const storageName = (id: string) => storageList.data?.items.find((s) => s.id === id)?.name ?? "Alist"; 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: "本机目录保存时会放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。也可以选登记好的 Alist 存储,Alist 上的目录要先建好。", deleteHint: "只删除登记,磁盘上的文件不动。", columns: [ { key: "name", header: "名称", cell: (r) => (

{r.name}

{(() => { const sp = splitRootPath(r.localPath); return sp.storage === "local" ? r.localPath : `${storageName(sp.storage)}:${sp.path}`; })()}

), }, { 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: "storage", label: "存储", kind: "select", options: storageOptions, hint: "本机目录,或在「存储」里登记的 Alist / OpenList", }, { name: "localPath", label: "路径", kind: "text", required: true, placeholder: "/media/movies", hint: "本机:服务进程能访问的绝对路径;Alist:Alist 里的路径,如 /115/电影", 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", storage: "local", localPath: "", managementMode: "register_only", enabled: true, notes: "", }, fromRow: (r) => ({ name: r.name, mediaKind: r.mediaKind, storage: splitRootPath(r.localPath).storage, localPath: splitRootPath(r.localPath).path, managementMode: r.managementMode, enabled: r.enabled, notes: r.notes ?? "", }), toBody: (v) => ({ name: str(v.name), mediaKind: str(v.mediaKind), localPath: joinRootPath(str(v.storage) || "local", str(v.localPath)), managementMode: str(v.managementMode), enabled: v.enabled === true, notes: nullStr(v.notes), }), createSchema: LibraryRootCreate, updateSchema: LibraryRootUpdate, }} /> ); }