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:
rootandClaude Opus 5.5 committed 2026-10-06 12:22:37 +08:00
commit 1360120e75
14 files changed
+1272 -123

No files matched your search

@@ -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();
}
});
});