diff --git a/CHANGELOG.md b/CHANGELOG.md index df39bdc8..ee003078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 新增 +- 多下载器路由(照 MP):下载器的名称作为别名;可设一个默认下载器;站点可指定下载器。选择顺序:订阅 / 手动明确指定 > 站点指定 > 默认 > 第一个可用;站点指定的停用时回落默认,明确指定的不可用时不悄悄换。迁移 `0063_downloader_routing`。 + ### 修复(站点适配,2026-10-02 真实账号列表页核对) - OurBits:标题列嵌套表格让大小、发布时间取错(大小全为 0),改为只匹配行的直接子单元格;副标题原先取成了标签,改为取标签之后的正文;新增标签与列表页 IMDb / 豆瓣 ID。 - 彩虹岛:副标题前混入的标签与末尾徽标去掉,标签单独取。 diff --git a/apps/server/src/modules/acquisition-organize/services/acquisition.ts b/apps/server/src/modules/acquisition-organize/services/acquisition.ts index 404802b1..72007cfc 100644 --- a/apps/server/src/modules/acquisition-organize/services/acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/acquisition.ts @@ -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; diff --git a/apps/server/src/modules/acquisition-organize/services/common.ts b/apps/server/src/modules/acquisition-organize/services/common.ts index 756facd7..78542b6e 100644 --- a/apps/server/src/modules/acquisition-organize/services/common.ts +++ b/apps/server/src/modules/acquisition-organize/services/common.ts @@ -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 { + 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[] } = {}, diff --git a/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts b/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts index 5ed7de12..d58e0fcc 100644 --- a/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts @@ -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)) diff --git a/apps/server/src/modules/auth-settings/instances-routes.ts b/apps/server/src/modules/auth-settings/instances-routes.ts index caedbd2b..2f72ec2d 100644 --- a/apps/server/src/modules/auth-settings/instances-routes.ts +++ b/apps/server/src/modules/auth-settings/instances-routes.ts @@ -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).authKind; if (credential === null && (authKind ?? curAuth) === "api_key") throw new ApiException("validation_failed", "API Key 认证不能清除 API Key;不用认证时把认证方式改为无认证"); diff --git a/apps/server/test/acquisition-organize/downloader-routing.test.ts b/apps/server/test/acquisition-organize/downloader-routing.test.ts new file mode 100644 index 00000000..10764fa4 --- /dev/null +++ b/apps/server/test/acquisition-organize/downloader-routing.test.ts @@ -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[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(); + }); +}); diff --git a/apps/web/app/components/settings/instances.tsx b/apps/web/app/components/settings/instances.tsx index 3fd9bcad..3111b7cb 100644 --- a/apps/web/app/components/settings/instances.tsx +++ b/apps/web/app/components/settings/instances.tsx @@ -102,16 +102,21 @@ function DownloadersTable() { config={{ noun: "下载器实例", description: - "只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。", + "只接管带管理标签的任务。可添加多个下载器:订阅或手动下载指定了下载器就用指定的,否则用站点指定的,再否则用默认下载器。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。", emptyHint: "添加 qBittorrent 后才能提交下载。", drawerExtra: (r) => , columns: [ { key: "name", - header: "名称", + header: "别名", cell: (r) => (
-

{r.name}

+

+ {r.name} + {r.isDefault ? ( + 默认 + ) : null} +

{r.baseUrl}

), @@ -128,7 +133,7 @@ function DownloadersTable() { ], rowActions: (r) => 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, diff --git a/apps/web/app/components/sites/site-dialogs.tsx b/apps/web/app/components/sites/site-dialogs.tsx index 499341a8..f59a1b12 100644 --- a/apps/web/app/components/sites/site-dialogs.tsx +++ b/apps/web/app/components/sites/site-dialogs.tsx @@ -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>({}); const set = (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: /> ) : null} + + +
set("proxy", x)} disabled={busy} />