- 下载器说明去掉「接管」,选下载器顺序写成一句;缓存盘统一为下载盘 - 「凭据」统一为「凭证」;去掉说明里的「同 MP」「待处理」等旧称 - 整理来源的基线、存量说法改成用户能懂的话,确认按钮写动作 - 数据保留里「站点快照」「待识别条目」按术语表改名 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
491 lines
19 KiB
TypeScript
491 lines
19 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, 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 (
|
||
<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";
|
||
}
|
||
|
||
/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用「下载器」分区的缺省值 */
|
||
const POLICY_FIELDS = [
|
||
["hardPct", "hardThresholdPercent"],
|
||
["resumePct", "resumePercent"],
|
||
["targetPct", "cleanupTargetPercent"],
|
||
["seedDays", "seedRetentionDays"],
|
||
] as const;
|
||
function policyForm(r: DownloaderInstance): Record<string, string | boolean> {
|
||
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]) : ""])),
|
||
hnrUnknownCleanable: p.hnrUnknownCleanable === true,
|
||
};
|
||
}
|
||
function policyBody(v: FormValues): Record<string, number | boolean> {
|
||
const out: Record<string, number | boolean> = {};
|
||
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 (
|
||
<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: "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 (
|
||
<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: "其他" },
|
||
];
|
||
|
||
/** 库根路径 ↔ 存储 + 路径:Alist 上的库根存成 /@alist/<存储 id>/<Alist 内路径> */
|
||
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 (
|
||
<ResourceSection<LibraryRoot>
|
||
hooks={{ list, create, update, remove } as never}
|
||
config={{
|
||
noun: "媒体库目录",
|
||
description:
|
||
"本机目录保存时会放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。也可以选登记好的 Alist 存储,Alist 上的目录要先建好。",
|
||
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">
|
||
{(() => {
|
||
const sp = splitRootPath(r.localPath);
|
||
return sp.storage === "local" ? r.localPath : `${storageName(sp.storage)}:${sp.path}`;
|
||
})()}
|
||
</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: "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,
|
||
}}
|
||
/>
|
||
);
|
||
}
|