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:
1 parent
64e1dfb1d2
commit
135c50048f
12 files changed
+196
-11
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user