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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 20:32:29 +08:00
1 parent 64e1dfb1d2
commit 135c50048f
12 files changed
+196 -11

No files matched your search

+3
View File
@@ -4,6 +4,9 @@
## [Unreleased]
### 新增
- 多下载器路由(照 MP):下载器的名称作为别名;可设一个默认下载器;站点可指定下载器。选择顺序:订阅 / 手动明确指定 > 站点指定 > 默认 > 第一个可用;站点指定的停用时回落默认,明确指定的不可用时不悄悄换。迁移 `0063_downloader_routing`。
### 修复(站点适配,2026-10-02 真实账号列表页核对)
- OurBits:标题列嵌套表格让大小、发布时间取错(大小全为 0),改为只匹配行的直接子单元格;副标题原先取成了标签,改为取标签之后的正文;新增标签与列表页 IMDb / 豆瓣 ID。
- 彩虹岛:副标题前混入的标签与末尾徽标去掉,标签单独取。
@@ -40,6 +40,7 @@ import {
hostPathOf,
type InstanceTopology,
loadInstances,
pickInstance,
recordCandidate,
} from "./common.ts";
import { autoFillCheck, type ForceFill } from "./evaluate-autofill.ts";
@@ -894,9 +895,10 @@ async function gates(
const blocked = await automationBlock(d);
if (blocked) return { ok: false, reasons: [blocked], rejected: [] };
const sp = (sub?.params ?? {}) as { downloaderInstanceId?: string; downloadSavePath?: string };
const wanted = sp.downloaderInstanceId;
const insts = (await loadInstances(d.ctx.db)).filter((t) => t.savePath && (!wanted || t.inst.id === wanted));
const inst = insts[0];
const inst = await pickInstance(d.ctx.db, await loadInstances(d.ctx.db), {
explicitId: sp.downloaderInstanceId,
siteId: rel.site_id,
});
if (!inst?.savePath) return { ok: false, reasons: ["no_downloader_instance"], rejected: [] };
// 订阅指定的保存路径:本机路径经下载器路径映射换成下载器侧路径;本来就是下载器侧路径的原样用
let savePath = inst.savePath;
@@ -216,6 +216,31 @@ export interface InstanceTopology {
category: string | null;
}
/**
* 选下载器(照 MP):明确指定(订阅 / 手动)> 站点指定 > 默认下载器 > 第一个有保存目录的。
* 明确指定但不可用时返回 undefined(不悄悄换别的);站点指定的不可用时回落默认。
*/
export async function pickInstance(
exec: Exec,
insts: readonly InstanceTopology[],
opts: { explicitId?: string | undefined; siteId?: string | null | undefined },
): Promise<InstanceTopology | undefined> {
const usable = insts.filter((t) => t.savePath);
if (opts.explicitId) return usable.find((t) => t.inst.id === opts.explicitId);
if (opts.siteId) {
const site = await exec
.selectFrom("site")
.select("downloader_instance_id")
.where("id", "=", opts.siteId)
.executeTakeFirst();
const hit = site?.downloader_instance_id
? usable.find((t) => t.inst.id === site.downloader_instance_id)
: undefined;
if (hit) return hit;
}
return usable.find((t) => t.inst.is_default) ?? usable[0];
}
export async function loadInstances(
exec: Exec,
opts: { enabledOnly?: boolean; ids?: string[] } = {},
@@ -4,7 +4,7 @@ import { ApiException } from "../../../http/errors.ts";
import type { ServerContext } from "../../types.ts";
import { hashJson, jsonb, toApi } from "../util.ts";
import { subscriptionSavePath } from "./acquisition.ts";
import { enqueueTracked, loadInstances } from "./common.ts";
import { enqueueTracked, loadInstances, pickInstance } from "./common.ts";
/** Manual choice is a one-off plan, independent of subscription filters and slots. */
export async function planManualAcquisition(
@@ -32,8 +32,10 @@ export async function planManualAcquisition(
};
if (entityId && !(await db.selectFrom("media_entity").select("id").where("id", "=", entityId).executeTakeFirst()))
throw new ApiException("not_found", "作品不存在");
const instances = await loadInstances(db, body.downloaderInstanceId ? { ids: [body.downloaderInstanceId] } : {});
const topo = instances.find((i) => i.savePath);
const topo = await pickInstance(db, await loadInstances(db), {
explicitId: body.downloaderInstanceId,
siteId: rel.site_id,
});
if (!topo?.savePath) throw new ApiException("precondition_failed", "没有可用的下载器或保存目录");
const replacementIds = body.importMode === "replace" ? [...new Set(body.replacementAssetIds)] : [];
if (body.importMode === "replace" && (!entityId || replacementIds.length === 0))
@@ -262,8 +262,15 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
throw new ApiException("validation_failed", "API Key 认证需要填写 API Key");
return { ...curSettings, ...(next.settings ?? {}), authKind };
};
/** 默认下载器至多一个:设新的之前先取消旧的 */
const clearDefault = (exceptId?: string) => {
let q = ctx.db.updateTable("downloader_instance").set({ is_default: false }).where("is_default", "=", true);
if (exceptId) q = q.where("id", "!=", exceptId);
return q.execute();
};
endpoint(app, DL.create, async ({ body, actor }) => {
const { credential: rawCredential, authKind, ...rest } = body;
if (rest.isDefault) await clearDefault();
const credential = secretInput(rawCredential) ?? undefined;
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) });
const keep = authKind !== "none" && credential;
@@ -291,6 +298,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
const { credential: rawCredential, authKind, ...rest } = body;
const credential = secretInput(rawCredential);
const cur = await getRow("downloader_instance", params.id);
if (rest.isDefault) await clearDefault(params.id);
const curAuth = ((cur.settings ?? {}) as Record<string, unknown>).authKind;
if (credential === null && (authKind ?? curAuth) === "api_key")
throw new ApiException("validation_failed", "API Key 认证不能清除 API Key;不用认证时把认证方式改为无认证");
@@ -0,0 +1,63 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { loadInstances, pickInstance } from "../../src/modules/acquisition-organize/services/common.ts";
import { createHarness, type Harness, pgAvailable, seedBase } from "./harness.ts";
const hasPg = await pgAvailable();
// 多下载器路由(照 MP):明确指定 > 站点指定 > 默认 > 第一个可用
describe.skipIf(!hasPg)("下载器选择", () => {
let h: Harness;
let first: string;
let anime: string;
let siteId: string;
beforeAll(async () => {
h = await createHarness("downloader_routing");
const base = await seedBase(h.db, {
libraryPath: "/tmp/lib",
cachePath: "/tmp/cache",
qbUrl: "http://127.0.0.1:9",
});
first = base.instanceId;
siteId = base.siteId;
const add = async (name: string) =>
(
await h.db
.insertInto("downloader_instance")
.values({ name, base_url: "http://127.0.0.1:9", settings: JSON.stringify({ savePath: "/tmp/cache" }) })
.returning("id")
.executeTakeFirstOrThrow()
).id;
anime = await add("zz-番剧专用");
});
afterAll(async () => {
await h?.close();
});
const pick = async (opts: Parameters<typeof pickInstance>[2]) =>
(await pickInstance(h.db, await loadInstances(h.db), opts))?.inst.id;
it("都没指定:第一个可用;设了默认后用默认", async () => {
expect(await pick({})).toBe(first);
await h.db.updateTable("downloader_instance").set({ is_default: true }).where("id", "=", anime).execute();
expect(await pick({ siteId })).toBe(anime);
});
it("站点指定优先于默认;明确指定优先于站点", async () => {
await h.db.updateTable("site").set({ downloader_instance_id: first }).where("id", "=", siteId).execute();
expect(await pick({ siteId })).toBe(first);
expect(await pick({ siteId, explicitId: anime })).toBe(anime);
});
it("站点指定的下载器停用时回落默认;明确指定的不可用时不悄悄换", async () => {
await h.db.updateTable("downloader_instance").set({ enabled: false }).where("id", "=", first).execute();
expect(await pick({ siteId })).toBe(anime);
expect(await pick({ explicitId: first })).toBeUndefined();
});
it("默认下载器至多一个", async () => {
await expect(
h.db.updateTable("downloader_instance").set({ is_default: true }).where("id", "=", first).execute(),
).rejects.toThrow();
});
});
+18 -4
View File
@@ -102,16 +102,21 @@ function DownloadersTable() {
config={{
noun: "下载器实例",
description:
"只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
"只接管带管理标签的任务。可添加多个下载器:订阅或手动下载指定了下载器就用指定的,否则用站点指定的,再否则用默认下载器。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
emptyHint: "添加 qBittorrent 后才能提交下载。",
drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />,
columns: [
{
key: "name",
header: "名称",
header: "别名",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{r.name}</p>
<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>
),
@@ -128,7 +133,7 @@ function DownloadersTable() {
],
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:家里的 qB" },
{ name: "name", label: "别名", kind: "text", required: true, placeholder: "例如:家里的 qB、番剧专用" },
{
name: "baseUrl",
label: "WebUI 地址",
@@ -161,6 +166,12 @@ function DownloadersTable() {
},
{ 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: {
@@ -172,6 +183,7 @@ function DownloadersTable() {
apiKey: "",
managedTag: "umt",
cacheVolumeKey: "",
isDefault: false,
enabled: true,
},
fromRow: (r) => ({
@@ -185,6 +197,7 @@ function DownloadersTable() {
"secret:authKind": authKindOf(r),
managedTag: r.managedTag,
cacheVolumeKey: r.cacheVolumeKey ?? "",
isDefault: r.isDefault,
enabled: r.enabled,
}),
toBody: (v: FormValues, mode) => ({
@@ -201,6 +214,7 @@ function DownloadersTable() {
: undefined,
managedTag: optStr(v.managedTag),
cacheVolumeKey: nullStr(v.cacheVolumeKey),
isDefault: v.isDefault === true,
enabled: v.enabled === true,
}),
createSchema: DownloaderInstanceCreate,
@@ -3,6 +3,7 @@ import { CircleCheck, CircleX, KeyRound, LoaderCircle, TriangleAlert } from "luc
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { downloaders } from "~/api/instances.ts";
import {
useDeleteSite,
useHealthCheck,
@@ -25,6 +26,7 @@ import {
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
@@ -323,8 +325,11 @@ export function CredentialDialog({
}
/** 编辑抽屉:名称、域名、限流(空 = 站点默认)、定时快照、代理 */
const DEFAULT_DOWNLOADER = "__default__";
export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose: () => void }) {
const update = useUpdateSite();
const dl = downloaders.useList();
const [v, setV] = useState({
name: site.name,
baseUrl: site.baseUrl,
@@ -334,6 +339,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
intervalMin: site.snapshotIntervalSec ? String(Math.round(site.snapshotIntervalSec / 60)) : "",
proxy: site.proxy,
searchDefault: site.searchDefault !== false,
downloader: site.downloaderInstanceId ?? DEFAULT_DOWNLOADER,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const set = <K extends keyof typeof v>(k: K, val: (typeof v)[K]) => setV((s) => ({ ...s, [k]: val }));
@@ -376,6 +382,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
snapshotIntervalSec: interval ? interval * 60 : null,
proxy: v.proxy,
searchDefault: v.searchDefault,
downloaderInstanceId: v.downloader === DEFAULT_DOWNLOADER ? null : v.downloader,
},
});
toast.success(`${v.name.trim()} 已保存`);
@@ -458,6 +465,27 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
/>
</FormField>
) : null}
<FormField
label="下载器"
htmlFor="se-downloader"
hint="这个站的资源用哪个下载器;订阅或手动下载时明确指定的下载器优先"
>
<Select value={v.downloader} onValueChange={(x) => set("downloader", x)} disabled={busy}>
<SelectTrigger id="se-downloader" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={DEFAULT_DOWNLOADER}>默认下载器</SelectItem>
{(dl.data?.items ?? []).map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
{d.isDefault ? "(默认)" : ""}
{d.enabled ? "" : "(已停用)"}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<div className="mb-2 flex items-start gap-3">
<Switch id="se-proxy" checked={v.proxy} onCheckedChange={(x) => set("proxy", x)} disabled={busy} />
<label htmlFor="se-proxy" className="text-caption">
+12 -1
View File
@@ -68,6 +68,8 @@ export const DownloaderInstance = EntityBase.extend({
/** 共享缓存卷标识:多 qB 共用同一卷时按此汇总水位 */
cacheVolumeKey: z.string().nullable(),
enabled: z.boolean(),
/** 默认下载器(至多一个):订阅 / 手动没指定、站点也没指定时用它 */
isDefault: z.boolean(),
healthStatus: InstanceHealth,
lastSeenAt: Timestamp.nullable(),
versionInfo: JsonObject,
@@ -82,9 +84,18 @@ export const DownloaderInstanceCreate = DownloaderInstance.pick({
managedTag: true,
cacheVolumeKey: true,
enabled: true,
isDefault: true,
settings: true,
})
.partial({ kind: true, username: true, managedTag: true, cacheVolumeKey: true, enabled: true, settings: true })
.partial({
kind: true,
username: true,
managedTag: true,
cacheVolumeKey: true,
enabled: true,
isDefault: true,
settings: true,
})
.extend({
credential: Credential.optional(),
/** 认证方式,写入 settings.authKind;缺省按"有用户名 → 账密,否则有凭证 → API Key,否则无认证"推断 */
+4
View File
@@ -185,6 +185,8 @@ export const Site = EntityBase.extend({
proxy: z.boolean(),
/** 默认参与资源搜索(站点选择弹层的"默认"集合) */
searchDefault: z.boolean(),
/** 该站资源用的下载器;null = 默认下载器(订阅 / 手动明确指定时以指定为准) */
downloaderInstanceId: Id.nullable(),
});
export type Site = z.infer<typeof Site>;
export const SiteCreate = Site.pick({
@@ -200,6 +202,7 @@ export const SiteCreate = Site.pick({
snapshotIntervalSec: true,
proxy: true,
searchDefault: true,
downloaderInstanceId: true,
})
.partial({
key: true,
@@ -211,6 +214,7 @@ export const SiteCreate = Site.pick({
snapshotIntervalSec: true,
proxy: true,
searchDefault: true,
downloaderInstanceId: true,
})
.extend({
/**
@@ -0,0 +1,23 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// 多下载器路由(照 MP):下载器选择 = 明确指定(订阅 / 手动)> 站点指定 > 默认下载器 > 第一个可用。
// 默认下载器至多一个;站点指定的下载器被删时回落默认。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`alter table downloader_instance add column if not exists is_default boolean not null default false;
create unique index if not exists downloader_instance_one_default on downloader_instance ((true)) where is_default;
alter table site add column if not exists downloader_instance_id uuid references downloader_instance (id) on delete set null;`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`alter table site drop column if exists downloader_instance_id;
drop index if exists downloader_instance_one_default;
alter table downloader_instance drop column if exists is_default;`,
);
}
+2
View File
@@ -730,6 +730,7 @@ export interface DownloaderInstanceTable {
settings: Generated<Json>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
is_default: Generated<boolean>;
}
export interface ExternalIdentityTable {
@@ -1429,6 +1430,7 @@ export interface SiteTable {
deleted_at: Timestamptz | null;
search_default: Generated<boolean>;
kind: Generated<SiteKind>;
downloader_instance_id: string | null;
}
export interface SiteAccountTable {