Files
UHD_Media_Tool/apps/web/app/components/settings/instances.tsx
T
rootandClaude Opus 5.5 135c50048f feat(downloads): 多下载器路由:默认下载器、站点指定下载器、别名
照 MP(chain/download/submission.py:明确指定 or 站点下载器 or 默认):
- 迁移 0063:downloader_instance.is_default(至多一个,唯一索引)、site.downloader_instance_id
- pickInstance:明确指定 > 站点指定 > 默认 > 第一个可用;自动下载(gates)与手动下载共用
- 设置:下载器名称改称别名,新增「设为默认下载器」;站点编辑新增「下载器」选择
- 测试 downloader-routing.test.ts

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

413 lines
15 KiB
TypeScript

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>
);
}
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: "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 (
<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,
}}
/>
);
}