- 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
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import { readdirSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { DiskUsage } from "@umt/adapters";
|
|
import { createQbClient } from "@umt/adapters";
|
|
import { DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
|
|
import type { DB } from "@umt/db";
|
|
import { type Kysely, sql } from "kysely";
|
|
import { ConfigSchema } from "../../src/config.ts";
|
|
import { createOutboxPort } from "../../src/context.ts";
|
|
import { createApp } from "../../src/http/app.ts";
|
|
import { hashApiKey } from "../../src/http/auth.ts";
|
|
import { EventBus } from "../../src/http/sse.ts";
|
|
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";
|
|
import { createTestSchema, PG_URL as DB_URL, uniqueSchema } from "../support/pg.ts";
|
|
|
|
// 真实 PG(独立 schema)+ mock qB + 临时目录 的测试装配;引擎用记录入队的假实现,测试直接驱动服务函数。
|
|
|
|
export type { Enqueued } from "../support/engine.ts";
|
|
export { fakeEngine } from "../support/engine.ts";
|
|
export { pgAvailable } from "../support/pg.ts";
|
|
export { DB_URL };
|
|
export const DEV_KEY = "dev-admin-ao-test-key";
|
|
/** 只读整理记录 / 可发起整理 的 API Key */
|
|
export const READ_KEY = "ao-organize-read-key";
|
|
export const EXEC_KEY = "ao-organize-exec-key";
|
|
const KEYS = [
|
|
{ key: READ_KEY, scopes: ["organize.read"] },
|
|
{ key: EXEC_KEY, scopes: ["organize.execute"] },
|
|
];
|
|
|
|
/** 可调的磁盘用量:used = base + 目录下文件总大小(清理删文件后自然下降) */
|
|
export function fakeDisk(total: number) {
|
|
const state = { total, base: 0, watch: [] as string[], fail: false };
|
|
const du = (dir: string): number => {
|
|
let s = 0;
|
|
try {
|
|
for (const n of readdirSync(dir)) {
|
|
const p = join(dir, n);
|
|
const st = statSync(p);
|
|
s += st.isDirectory() ? du(p) : st.size;
|
|
}
|
|
} catch {
|
|
// 不存在
|
|
}
|
|
return s;
|
|
};
|
|
const fn = async (path: string): Promise<DiskUsage> => {
|
|
if (state.fail) return { status: "unknown", path, code: "EIO", message: "fake" };
|
|
const used = state.base + state.watch.reduce((a, d) => a + du(d), 0);
|
|
return {
|
|
status: "ok",
|
|
path,
|
|
totalBytes: state.total,
|
|
freeBytes: state.total - used,
|
|
availableBytes: state.total - used,
|
|
usedBytes: used,
|
|
usedRatio: used / state.total,
|
|
};
|
|
};
|
|
/** 把使用率设为 pct(按当前目录内容反推 base) */
|
|
const setPct = (pct: number) => {
|
|
const files = state.watch.reduce((a, d) => a + du(d), 0);
|
|
state.base = Math.round((pct / 100) * state.total) - files;
|
|
};
|
|
return { state, fn, setPct };
|
|
}
|
|
|
|
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应/事件 JSON
|
|
type Loose = any;
|
|
|
|
export interface Harness {
|
|
module: ReturnType<typeof createAcquisitionOrganizeModule>;
|
|
db: Kysely<DB>;
|
|
ctx: ServerContext;
|
|
d: AoDeps;
|
|
engine: ReturnType<typeof fakeEngine>;
|
|
app: ReturnType<typeof createApp>;
|
|
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
|
|
setSettings(key: string, value: Record<string, unknown>): Promise<void>;
|
|
outbox(type?: string): Promise<{ type: string; payload: Loose; idempotency_key: string | null }[]>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
export async function createHarness(
|
|
name: string,
|
|
overrides: AoOverrides = {},
|
|
/** 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;
|
|
const logger = createLogger({ level: "silent" });
|
|
const engine = fakeEngine();
|
|
const config = ConfigSchema.parse({
|
|
DATABASE_URL: DB_URL,
|
|
SECRET_MASTER_KEY: "test-master-key-0123456789",
|
|
DEV_ADMIN_KEY: DEV_KEY,
|
|
});
|
|
const outbox = createOutboxPort();
|
|
const bus = new EventBus(50);
|
|
const ctx: ServerContext = {
|
|
role: "all",
|
|
db,
|
|
config,
|
|
logger,
|
|
events: bus,
|
|
engine,
|
|
outbox,
|
|
secrets: {
|
|
resolve: async () => "adminadmin",
|
|
create: async () => "x",
|
|
replace: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
// 不缓存的设置读取(测试里改 settings 行立即生效)
|
|
settings: {
|
|
async all() {
|
|
const rows = await db.selectFrom("settings").select(["key", "value"]).execute();
|
|
const stored = new Map(rows.map((r) => [r.key, r.value]));
|
|
const out: Record<string, unknown> = {};
|
|
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
|
|
const v = stored.get(k);
|
|
// 整理最小体积缺省跟随端口注入的 minVideoBytes(测试用小文件)
|
|
const base =
|
|
k === "organize"
|
|
? { ...DEFAULT_SETTINGS.organize, minSizeBytes: overrides.options?.minVideoBytes ?? 100_000 }
|
|
: DEFAULT_SETTINGS[k];
|
|
out[k] = GlobalSettings.shape[k].parse({ ...base, ...((v as object) ?? {}) });
|
|
}
|
|
return out as GlobalSettings;
|
|
},
|
|
async get(k) {
|
|
return (await this.all())[k];
|
|
},
|
|
},
|
|
capabilities: createCapabilityRegistry({ logger }),
|
|
};
|
|
const module = createAcquisitionOrganizeModule({
|
|
downloader: async (inst) =>
|
|
createQbClient({
|
|
instanceId: inst.id,
|
|
baseUrl: inst.base_url,
|
|
username: "admin",
|
|
password: "adminadmin",
|
|
lookup: { attempts: 3, intervalMs: 50 },
|
|
}),
|
|
probe: async () => ({ status: "unknown", reason: "ffprobe_unavailable" }),
|
|
...overrides,
|
|
options: { backgroundLoops: false, minVideoBytes: 100_000, verifyHash: "sha256", ...(overrides.options ?? {}) },
|
|
});
|
|
const app = createApp({
|
|
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
|
logger,
|
|
bus,
|
|
role: "all",
|
|
version: "test",
|
|
db,
|
|
modules: [
|
|
module,
|
|
...(opts.withSources || opts.withAgent ? [createSourcesIdentityModule()] : []),
|
|
...(opts.withAgent ? [createAgentModule()] : []),
|
|
],
|
|
ctx,
|
|
apiKeyStore: {
|
|
async findByHash(hash) {
|
|
const k = KEYS.find((x) => hashApiKey(x.key) === hash);
|
|
return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null;
|
|
},
|
|
async touch() {},
|
|
},
|
|
});
|
|
const d = module.deps();
|
|
return {
|
|
module,
|
|
db,
|
|
ctx,
|
|
d,
|
|
engine,
|
|
app,
|
|
async api(method, path, body, key = DEV_KEY) {
|
|
const res = await app.request(`/api/v1${path}`, {
|
|
method,
|
|
headers: {
|
|
authorization: `Bearer ${key}`,
|
|
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
},
|
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
return { status: res.status, body: await res.json() };
|
|
},
|
|
async setSettings(key, value) {
|
|
await sql`insert into settings (key, value) values (${key}, ${JSON.stringify(value)}::jsonb)
|
|
on conflict (key) do update set value = excluded.value`.execute(db);
|
|
},
|
|
async outbox(type) {
|
|
const r = await sql<{ type: string; payload: Loose; idempotency_key: string | null }>`
|
|
select type, payload, idempotency_key from outbox_event ${type ? sql`where type = ${type}` : sql``} order by at, id`.execute(
|
|
db,
|
|
);
|
|
return r.rows;
|
|
},
|
|
async close() {
|
|
await ts.drop();
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── 种子数据 ──
|
|
|
|
export async function seedBase(
|
|
db: Kysely<DB>,
|
|
opts: { libraryPath: string; cachePath: string; qbUrl: string; mediaKind?: "movie" | "series" },
|
|
) {
|
|
const root = await db
|
|
.insertInto("library_root")
|
|
.values({
|
|
name: `lib-${Math.random().toString(36).slice(2, 6)}`,
|
|
media_kind: "mixed",
|
|
local_path: opts.libraryPath,
|
|
management_mode: "managed",
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
const inst = await db
|
|
.insertInto("downloader_instance")
|
|
.values({
|
|
name: `qb-${Math.random().toString(36).slice(2, 6)}`,
|
|
base_url: opts.qbUrl,
|
|
username: "admin",
|
|
settings: JSON.stringify({ savePath: opts.cachePath }),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
const site = await db
|
|
.insertInto("site")
|
|
.values({
|
|
key: `site${Math.random().toString(36).slice(2, 6)}`,
|
|
name: "测试站",
|
|
adapter: "nexusphp",
|
|
base_url: "https://pt.example.test",
|
|
config: JSON.stringify({ hitAndRun: { minSeedingSec: 86_400 } }),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
return { rootId: root.id, instanceId: inst.id, siteId: site.id };
|
|
}
|
|
|
|
export async function seedEntity(db: Kysely<DB>, e: { kind: "movie" | "series"; title: string; year: number }) {
|
|
return (
|
|
await db
|
|
.insertInto("media_entity")
|
|
.values({ kind: e.kind, title: e.title, year: e.year, status: "active" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
}
|
|
|
|
export async function seedRelease(
|
|
db: Kysely<DB>,
|
|
r: { siteId: string; entityId: string; title: string; size: number; hash: string; hr?: boolean | "unknown" },
|
|
) {
|
|
return (
|
|
await db
|
|
.insertInto("source_release")
|
|
.values({
|
|
site_id: r.siteId,
|
|
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
|
|
title: r.title,
|
|
size_bytes: r.size,
|
|
infohash_v1: r.hash,
|
|
media_entity_id: r.entityId,
|
|
resolution_status: "resolved",
|
|
raw: JSON.stringify({ _site: { hr: r.hr ?? false } }),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
}
|
|
|
|
export async function seedSlot(
|
|
db: Kysely<DB>,
|
|
s: { entityId: string; name?: string; hard?: Record<string, unknown>; upgrade?: Record<string, unknown> },
|
|
) {
|
|
const sub = await db
|
|
.insertInto("subscription")
|
|
.values({ media_entity_id: s.entityId, media_kind: "movie", mode: "continuous" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
return (
|
|
await db
|
|
.insertInto("acquisition_slot")
|
|
.values({
|
|
media_entity_id: s.entityId,
|
|
subscription_id: sub.id,
|
|
name: s.name ?? "main",
|
|
hard_conditions: JSON.stringify(s.hard ?? {}),
|
|
upgrade_threshold: JSON.stringify(s.upgrade ?? {}),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
}
|