From 4bb19433ade076609eeca9c291d0a9491c8bb3e6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 12:10:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=89=8B=E5=8A=A8=E4=B8=8B=E8=BD=BD):=20?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=9B=AE=E5=BD=95=E9=80=89=E9=A1=B9=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=E4=B8=8B=E8=BD=BD=E5=89=8D=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E4=BD=9C=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /download-save-paths:按下载器列出默认保存目录与路径映射的下载器侧目录,并给出「自动」会用的下载器(站点指定 > 默认 > 第一个) - POST /acquisition-plans 新增 identity(本地作品或 TMDB 外部身份):转调 /source-releases/{id}/identity 建 / 绑作品并加人工锁,再按该作品下载 - 测试 harness 支持装配 agent 模块;pg 测试覆盖保存目录选项与指定身份下载 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../acquisition-organize/routes/downloads.ts | 8 + .../services/manual-acquisition.ts | 28 +++- apps/server/src/modules/agent/routes.ts | 16 +- .../test/acquisition-organize/harness.ts | 11 +- .../manual-download-options.test.ts | 157 ++++++++++++++++++ packages/contracts/src/api/execution.ts | 8 + packages/contracts/src/execution.ts | 38 +++++ 7 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 apps/server/test/acquisition-organize/manual-download-options.test.ts diff --git a/apps/server/src/modules/acquisition-organize/routes/downloads.ts b/apps/server/src/modules/acquisition-organize/routes/downloads.ts index ab3269f3..5fb72e43 100644 --- a/apps/server/src/modules/acquisition-organize/routes/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/routes/downloads.ts @@ -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, 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 d58e0fcc..80252015 100644 --- a/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts @@ -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 { + 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(); + 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( diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index 75acf024..e81ecdce 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -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") diff --git a/apps/server/test/acquisition-organize/harness.ts b/apps/server/test/acquisition-organize/harness.ts index 2fd9a74d..f0e14947 100644 --- a/apps/server/test/acquisition-organize/harness.ts +++ b/apps/server/test/acquisition-organize/harness.ts @@ -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 { 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) { diff --git a/apps/server/test/acquisition-organize/manual-download-options.test.ts b/apps/server/test/acquisition-organize/manual-download-options.test.ts new file mode 100644 index 00000000..2bf443fb --- /dev/null +++ b/apps/server/test/acquisition-organize/manual-download-options.test.ts @@ -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(); + } + }); +}); diff --git a/packages/contracts/src/api/execution.ts b/packages/contracts/src/api/execution.ts index 43cf3cbc..9ef097b6 100644 --- a/packages/contracts/src/api/execution.ts +++ b/packages/contracts/src/api/execution.ts @@ -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", diff --git a/packages/contracts/src/execution.ts b/packages/contracts/src/execution.ts index f3d67091..95baaaa4 100644 --- a/packages/contracts/src/execution.ts +++ b/packages/contracts/src/execution.ts @@ -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; @@ -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; +/** 手动下载可选的保存目录:按下载器列出默认保存目录与路径映射的下载器侧目录 */ +export const DownloadSavePathQuery = z.object({ + /** 资源所在站点;站点指定了下载器时「自动」按它解析 */ + siteId: Id.optional(), +}); +export type DownloadSavePathQuery = z.infer; +export const DownloadSaveDir = z.object({ + /** 下载器视角的路径(提交给下载器的保存路径) */ + path: z.string(), + /** 本机视角的路径;配置了映射但对不上时为 null */ + hostPath: z.string().nullable(), + /** 下载器设置里的默认保存目录(「自动」用它) */ + isDefault: z.boolean(), +}); +export type DownloadSaveDir = z.infer; +export const DownloadSaveTarget = z.object({ + instanceId: Id, + name: z.string(), + isDefault: z.boolean(), + /** 第一个是默认保存目录 */ + dirs: z.array(DownloadSaveDir), +}); +export type DownloadSaveTarget = z.infer; +export const DownloadSavePaths = z.object({ + /** 不指定下载器时会用的那个(站点指定 > 默认下载器 > 第一个有保存目录的);没有可用下载器为 null */ + autoInstanceId: Id.nullable(), + instances: z.array(DownloadSaveTarget), +}); +export type DownloadSavePaths = z.infer; + export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial(); export type AcquisitionPlanUpdate = z.infer;