feat(手动下载): 保存目录选项接口与下载前指定作品

- GET /download-save-paths:按下载器列出默认保存目录与路径映射的下载器侧目录,并给出「自动」会用的下载器(站点指定 > 默认 > 第一个)
- POST /acquisition-plans 新增 identity(本地作品或 TMDB 外部身份):转调 /source-releases/{id}/identity 建 / 绑作品并加人工锁,再按该作品下载
- 测试 harness 支持装配 agent 模块;pg 测试覆盖保存目录选项与指定身份下载

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 12:10:03 +08:00
1 parent fb6c76875c
commit 4bb19433ad
7 files changed
+260 -6

No files matched your search

@@ -5,6 +5,7 @@ import type { ModuleApp } from "../../types.ts";
import { type AoDeps, enqueueTracked, timeSlot } from "../services/common.ts";
import { downloadBoard } from "../services/download-board.ts";
import { applySlotEvent, userPause, userResume } from "../services/downloads.ts";
import { manualSavePaths } from "../services/manual-acquisition.ts";
import { planWhys } from "../services/plan-why.ts";
import { actorOf, conflict, notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
@@ -38,6 +39,13 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG },
);
mount(
app,
Api.acquisitionPlans.savePaths,
async (_c, { query }) => manualSavePaths(db(), (query as { siteId?: string }).siteId),
{ tag: TAG },
);
mount(
app,
Api.acquisitionPlans.why,
@@ -1,10 +1,34 @@
import type { ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts";
import type { DownloadSavePaths, ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts";
import { sql } from "@umt/db";
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, pickInstance } from "./common.ts";
import { type Exec, enqueueTracked, hostPathOf, loadInstances, pickInstance } from "./common.ts";
/**
* 手动下载对话框的保存目录选项:每个可用下载器的默认保存目录(settings.savePath,没有时取第一条映射)
* 加上各条路径映射的下载器侧目录;「自动」按 pickInstance 同一顺序解析(站点指定 > 默认下载器 > 第一个)。
*/
export async function manualSavePaths(exec: Exec, siteId?: string): Promise<DownloadSavePaths> {
const insts = (await loadInstances(exec)).filter((t) => t.savePath);
const auto = await pickInstance(exec, insts, { siteId });
return {
autoInstanceId: auto?.inst.id ?? null,
instances: insts.map((t) => {
const paths = [t.savePath!, ...t.mappings.map((m) => m.downloaderPath)];
const seen = new Set<string>();
const dirs = [];
for (const p of paths) {
const key = p.replace(/\/+$/, "") || "/";
if (seen.has(key)) continue;
seen.add(key);
dirs.push({ path: p, hostPath: hostPathOf(t, p), isDefault: dirs.length === 0 });
}
return { instanceId: t.inst.id, name: t.inst.name, isDefault: t.inst.is_default, dirs };
}),
};
}
/** Manual choice is a one-off plan, independent of subscription filters and slots. */
export async function planManualAcquisition(
+15 -1
View File
@@ -10,6 +10,7 @@ import { type AgentRunRow, sql } from "@umt/db";
import { hasScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts";
import { InternalDispatcher } from "../search/dispatch.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { planBinding } from "./authorize.ts";
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
@@ -115,6 +116,7 @@ export function registerAgentRoutes(
limiter: InvokeLimiter = sharedInvokeLimiter(),
) {
const db = () => ctx.db;
const dispatch = new InternalDispatcher(app);
// ── Agent 会话 ──
route(app, Api.agentRuns.list, async ({ query }) => {
@@ -433,7 +435,19 @@ export function registerAgentRoutes(
});
// 手动资源选择不依赖订阅,提交后仍走共同下载及整理任务。
route(app, Api.acquisitionPlans.create, async ({ body, actorId, c }) => {
route(app, Api.acquisitionPlans.create, async ({ body: input, actorId, c }) => {
let body = input;
// 下载前指定了作品:走资源指定身份的同一端点(建 / 绑作品、人工锁、审计),再按该作品下载
if (body.identity) {
const { identity, ...rest } = body;
const assigned = await dispatch.call<{ mediaEntityId: string }>(
c,
"POST",
`/source-releases/${body.sourceReleaseId}/identity`,
{ ...identity, reason: "手动下载时指定" },
);
body = { ...rest, entityId: assigned.mediaEntityId };
}
const rel = await db()
.selectFrom("source_release")
.select("media_entity_id")