merge: ui/j-download-dialog
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
1360120e75
14 files changed
+1272
-123
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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user