Files
UHD_Media_Tool/apps/web/app/components/settings/instances.tsx
T
rootandClaude Opus 5.5 ee128e0b3f feat(downloads): 下载器单独的水位与保种天数
- contracts DownloaderPolicy(存 downloader_instance.settings.policy):硬阈值 / 恢复水位 / 清理目标 / 保种天数,
  不填用全局 cache / retention
- volumeThresholds:卷的生效水位 = 各实例「自己的值 ?? 全局」取最小;decideVolume、阈值清理、手动恢复、
  首页卷信息、定时清理检查都改用它;cleanupCandidates 保种天数按实例
- 保存时校验合并全局后 恢复 < 硬阈值、清理目标 ≤ 恢复
- 下载器设置表单新增四项(留空用全局)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 20:51:04 +08:00

455 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
{at ? <span className="text-meta text-muted-foreground">{formatRelative(at)}</span> : null}
</div>
);
}
function Enabled({ on }: { on: boolean }) {
return <StatusBadge tone={on ? "success" : "neutral"}>{on ? "启用" : "停用"}</StatusBadge>;
}
/** 行内"测试连接"按钮:结果 toast,health 写回后列表自动刷新 */
export function TestButton({
run,
pending,
label = "测试连接",
}: {
run: () => Promise<{ ok: boolean; message: string | null; version?: string | null }>;
pending: boolean;
label?: string;
}) {
return (
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={async () => {
try {
const r = await run();
if (r.ok) toast.success(`${label}成功${r.version ? ` · ${r.version}` : ""}`);
else toast.error(r.message || `${label}失败`);
} catch (err) {
toast.error(errorMessage(err, `${label}失败`));
}
}}
>
<PlugZap />
<span className="hidden md:inline">{label}</span>
</Button>
);
}
/** 下载器:已存凭证只对保存时的认证方式有效(改了认证方式就要重新填) */
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 (
<SettingsBlock section="downloaders" anchor="instances" title="qBittorrent 实例" plain>
<DownloadersTable />
</SettingsBlock>
);
}
/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用全局 */
const POLICY_FIELDS = [
["hardPct", "hardThresholdPercent"],
["resumePct", "resumePercent"],
["targetPct", "cleanupTargetPercent"],
["seedDays", "seedRetentionDays"],
] as const;
function policyForm(r: DownloaderInstance): Record<string, string> {
const p = ((r.settings as { policy?: Record<string, unknown> }).policy ?? {}) as Record<string, unknown>;
return Object.fromEntries(POLICY_FIELDS.map(([f, k]) => [f, typeof p[k] === "number" ? String(p[k]) : ""]));
}
function policyBody(v: FormValues): Record<string, number> {
const out: Record<string, number> = {};
for (const [f, k] of POLICY_FIELDS) {
const raw = String(v[f] ?? "").trim();
if (raw !== "" && Number.isFinite(Number(raw))) out[k] = Number(raw);
}
return out;
}
function DownloadersTable() {
const list = downloaders.useList();
const create = downloaders.useCreate();
const update = downloaders.useUpdate();
const remove = downloaders.useRemove();
const test = downloaders.useTest();
return (
<ResourceSection<DownloaderInstance>
hooks={{ list, create, update, remove } as never}
config={{
noun: "下载器实例",
description:
"只接管带管理标签的任务。可添加多个下载器:订阅或手动下载指定了下载器就用指定的,否则用站点指定的,再否则用默认下载器。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
emptyHint: "添加 qBittorrent 后才能提交下载。",
drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />,
columns: [
{
key: "name",
header: "别名",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">
{r.name}
{r.isDefault ? (
<span className="ml-1.5 font-normal text-meta text-muted-foreground">默认</span>
) : null}
</p>
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
</div>
),
},
{
key: "tag",
header: "管理标签",
hideBelow: "md",
cell: (r) => <code className="text-meta">{r.managedTag}</code>,
},
{ key: "vol", header: "缓存卷", hideBelow: "lg", cell: (r) => r.cacheVolumeKey ?? "—" },
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
],
rowActions: (r) => <TestButton pending={test.isPending} run={() => 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: "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: "",
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 (
<SettingsBlock section="media-servers" anchor="instances" title="Emby / Jellyfin 实例" plain>
<MediaServersTable />
</SettingsBlock>
);
}
function MediaServersTable() {
const list = mediaServers.useList();
const create = mediaServers.useCreate();
const update = mediaServers.useUpdate();
const remove = mediaServers.useRemove();
const test = mediaServers.useTest();
return (
<ResourceSection<MediaServerInstance>
hooks={{ list, create, update, remove } as never}
config={{
noun: "媒体服务器",
description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和路径映射。",
drawerExtra: (r) => <PathMappingsPanel viewKind="media_server" instanceId={r.id} />,
columns: [
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{r.name}</p>
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
</div>
),
},
{ key: "kind", header: "类型", hideBelow: "md", cell: (r) => (r.kind === "emby" ? "Emby" : "Jellyfin") },
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
],
rowActions: (r) => <TestButton pending={test.isPending} run={() => 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 (
<ResourceSection<LibraryRoot>
hooks={{ list, create, update, remove } as never}
config={{
noun: "媒体库目录",
description: "保存时会在目录里放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。",
deleteHint: "只删除登记,磁盘上的文件不动。",
columns: [
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{r.name}</p>
<p className="truncate font-mono text-meta text-muted-foreground">{r.localPath}</p>
</div>
),
},
{
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) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
],
rowActions: (r) => <TestButton label="检查目录" pending={test.isPending} run={() => 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,
}}
/>
);
}