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")
@@ -14,6 +14,7 @@ import { createLogger } from "../../src/logger.ts";
import { type AoOverrides, createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import type { AoDeps } from "../../src/modules/acquisition-organize/services/common.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { fakeEngine } from "../support/engine.ts";
@@ -90,8 +91,8 @@ export interface Harness {
export async function createHarness(
name: string,
overrides: AoOverrides = {},
/** withSources:同时装配 sources-identity 模块(待处理中心路由等) */
opts: { withSources?: boolean } = {},
/** withSources:同时装配 sources-identity 模块(待处理中心路由等);withAgent:再装配 agent 模块(POST /acquisition-plans 等) */
opts: { withSources?: boolean; withAgent?: boolean } = {},
): Promise<Harness> {
const ts = await createTestSchema(uniqueSchema(`ao_test_${name}`));
const { db } = ts;
@@ -161,7 +162,11 @@ export async function createHarness(
role: "all",
version: "test",
db,
modules: opts.withSources ? [module, createSourcesIdentityModule()] : [module],
modules: [
module,
...(opts.withSources || opts.withAgent ? [createSourcesIdentityModule()] : []),
...(opts.withAgent ? [createAgentModule()] : []),
],
ctx,
apiKeyStore: {
async findByHash(hash) {
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import { createHarness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
// 手动下载对话框:保存目录选项(默认目录 + 路径映射、「自动」按站点 / 默认下载器解析)与下载前指定作品。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("手动下载选项", () => {
it("保存目录选项:默认目录在前、映射目录去重、自动按站点指定的下载器", async () => {
const h = await createHarness("manual_save_paths");
try {
const base = await seedBase(h.db, { libraryPath: "/lib", cachePath: "/downloads", qbUrl: "http://qb.invalid" });
const other = await h.db
.insertInto("downloader_instance")
.values({
name: "zz-mapped",
base_url: "http://qb2.invalid",
username: "admin",
is_default: true,
settings: JSON.stringify({ savePath: "/data/downloads" }),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("path_mapping")
.values([
{
downloader_instance_id: other.id,
view_kind: "downloader",
remote_prefix: "/data/downloads",
local_prefix: "/mnt/dl",
},
{
downloader_instance_id: other.id,
view_kind: "downloader",
remote_prefix: "/data/tv",
local_prefix: "/mnt/tv",
},
])
.execute();
// 没有保存目录的下载器不列出
await h.db
.insertInto("downloader_instance")
.values({ name: "no-path", base_url: "http://qb3.invalid", username: "admin" })
.execute();
const all = await h.api("GET", "/download-save-paths");
expect(all.status, JSON.stringify(all.body)).toBe(200);
expect(all.body.data.autoInstanceId).toBe(other.id);
expect(all.body.data.instances).toEqual([
{
instanceId: base.instanceId,
name: expect.any(String),
isDefault: false,
dirs: [{ path: "/downloads", hostPath: "/downloads", isDefault: true }],
},
{
instanceId: other.id,
name: "zz-mapped",
isDefault: true,
dirs: [
{ path: "/data/downloads", hostPath: "/mnt/dl", isDefault: true },
{ path: "/data/tv", hostPath: "/mnt/tv", isDefault: false },
],
},
]);
await h.db
.updateTable("site")
.set({ downloader_instance_id: base.instanceId })
.where("id", "=", base.siteId)
.execute();
const bySite = await h.api("GET", `/download-save-paths?siteId=${base.siteId}`);
expect(bySite.body.data.autoInstanceId).toBe(base.instanceId);
} finally {
await h.close();
}
});
it("下载前指定作品:按 TMDB 身份绑到已登记作品并加人工锁;本地作品同样绑定", async () => {
const h = await createHarness("manual_identity", {}, { withAgent: true });
try {
const base = await seedBase(h.db, { libraryPath: "/lib", cachePath: "/downloads", qbUrl: "http://qb.invalid" });
const entityId = await seedEntity(h.db, { kind: "movie", title: "Spider-Man", year: 2026 });
await h.db
.insertInto("external_identity")
.values({
media_entity_id: entityId,
provider: "tmdb",
entity_type: "movie",
external_id: "969681",
status: "accepted",
})
.execute();
const relId = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: "Spider-Man.Brand.New.Day.2026.1080p.WEB-DL.mkv",
size: 300_000,
hash: "a".repeat(40),
});
await h.db
.updateTable("source_release")
.set({ media_entity_id: null, resolution_status: "unresolved" })
.where("id", "=", relId)
.execute();
const r = await h.api("POST", "/acquisition-plans", {
sourceReleaseId: relId,
identity: { external: { provider: "tmdb", entityType: "movie", externalId: "969681" } },
});
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data).toMatchObject({ submitted: true, rejected: [] });
expect(r.body.data.plan.evaluation.mediaEntityId).toBe(entityId);
const rel = await h.db
.selectFrom("source_release")
.select(["media_entity_id", "resolution_locked"])
.where("id", "=", relId)
.executeTakeFirstOrThrow();
expect(rel).toEqual({ media_entity_id: entityId, resolution_locked: true });
// 本地作品:同一端点绑定,旧的 entityId 被指定的作品取代
const other = await seedEntity(h.db, { kind: "movie", title: "Other Movie", year: 2025 });
const rel2 = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: "Other.Movie.2025.2160p.mkv",
size: 300_000,
hash: "b".repeat(40),
});
const r2 = await h.api("POST", "/acquisition-plans", {
sourceReleaseId: rel2,
entityId,
identity: { mediaEntityId: other },
});
expect(r2.status, JSON.stringify(r2.body)).toBe(200);
expect(r2.body.data.plan.evaluation.mediaEntityId).toBe(other);
expect(
(
await h.db
.selectFrom("source_release")
.select("media_entity_id")
.where("id", "=", rel2)
.executeTakeFirstOrThrow()
).media_entity_id,
).toBe(other);
// 本地作品与外部身份只能给一个,入参校验直接拒绝
const bad = await h.api("POST", "/acquisition-plans", {
sourceReleaseId: rel2,
identity: { mediaEntityId: other, external: { provider: "tmdb", entityType: "movie", externalId: "1" } },
});
expect(bad.status).toBe(422);
} finally {
await h.close();
}
});
});
+8
View File
@@ -64,6 +64,14 @@ export const executionApi = {
body: execution.ManualAcquisitionRequest,
response: execution.ManualAcquisitionResult,
}),
savePaths: ep({
method: "get",
path: "/download-save-paths",
scope: "read",
summary: "手动下载可选的下载器与保存目录(默认保存目录 + 路径映射),以及「自动」会用哪个下载器",
query: execution.DownloadSavePathQuery,
response: execution.DownloadSavePaths,
}),
why: ep({
method: "get",
path: "/acquisition-plans/{id}/why",
+38
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts";
import { ExternalRef } from "./identity.ts";
import { AcquisitionPriority } from "./policy.ts";
// 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002)
@@ -146,6 +147,13 @@ export const ManualAcquisitionRequest = z.object({
allowUnrecognized: z.boolean().optional(),
importMode: z.enum(["normal", "replace"]).default("normal"),
replacementAssetIds: z.array(Id).default([]),
/**
* 下载前指定作品:本地作品或外部身份(未登记的按 TMDB 详情加入媒体库),
* 与 POST /source-releases/{id}/identity 同一实现,绑定到资源并加人工锁;给了就不再用 entityId。
*/
identity: z
.union([z.object({ mediaEntityId: Id }).strict(), z.object({ external: ExternalRef }).strict()])
.optional(),
});
export type ManualAcquisitionRequest = z.infer<typeof ManualAcquisitionRequest>;
@@ -161,6 +169,36 @@ export const ManualAcquisitionResult = z.object({
rejected: z.array(z.object({ slotId: Id.nullable(), code: z.string(), message: z.string() })),
});
export type ManualAcquisitionResult = z.infer<typeof ManualAcquisitionResult>;
/** 手动下载可选的保存目录:按下载器列出默认保存目录与路径映射的下载器侧目录 */
export const DownloadSavePathQuery = z.object({
/** 资源所在站点;站点指定了下载器时「自动」按它解析 */
siteId: Id.optional(),
});
export type DownloadSavePathQuery = z.infer<typeof DownloadSavePathQuery>;
export const DownloadSaveDir = z.object({
/** 下载器视角的路径(提交给下载器的保存路径) */
path: z.string(),
/** 本机视角的路径;配置了映射但对不上时为 null */
hostPath: z.string().nullable(),
/** 下载器设置里的默认保存目录(「自动」用它) */
isDefault: z.boolean(),
});
export type DownloadSaveDir = z.infer<typeof DownloadSaveDir>;
export const DownloadSaveTarget = z.object({
instanceId: Id,
name: z.string(),
isDefault: z.boolean(),
/** 第一个是默认保存目录 */
dirs: z.array(DownloadSaveDir),
});
export type DownloadSaveTarget = z.infer<typeof DownloadSaveTarget>;
export const DownloadSavePaths = z.object({
/** 不指定下载器时会用的那个(站点指定 > 默认下载器 > 第一个有保存目录的);没有可用下载器为 null */
autoInstanceId: Id.nullable(),
instances: z.array(DownloadSaveTarget),
});
export type DownloadSavePaths = z.infer<typeof DownloadSavePaths>;
export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial();
export type AcquisitionPlanUpdate = z.infer<typeof AcquisitionPlanUpdate>;