- 新增 apps/server/test/support/{pg,engine}.ts:测试库地址、可用性探测、schema 咨询锁、
建 schema + 加锁迁移 + 删除、可选去重的假引擎;6 份 harness 改用它(acquisition-organize 与
file-manager 原先迁移不加锁,并行下有冲突隐患,一并修正)
- sse-pg 起子进程,并行下子进程启动超时,归入 serial
- 修正 b61335e 挪动 / 改名测试后遗留的 import 排序与格式(当时 lint 检查经管道被吞掉退出码)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
288 lines
15 KiB
TypeScript
288 lines
15 KiB
TypeScript
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
|
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
|
import {
|
|
organizeDecisionHook,
|
|
pickMainVideo,
|
|
takeoverParsed,
|
|
} from "../../src/modules/acquisition-organize/services/takeover.ts";
|
|
import { addAliasIfAbsent } from "../../src/modules/sources-identity/service/entities.ts";
|
|
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
|
|
|
// QB-1:接管任务(用户手动加进 qB、贴管理标签,没有站点资源)完成后按 种子名 + 主视频文件名 识别 → 直接整理;
|
|
// 识别失败建带选项的事项(候选 / 选择作品 / 临时作品 / 忽略),处理后执行并闭环;目标已存在 → 替换 / 保留两者 / 跳过;
|
|
// 手动按路径整理也关闭同一下载任务的整理事项;作品详情下载历史列出接管任务与整理记录。
|
|
|
|
const hasPg = await pgAvailable();
|
|
const SIZE = 300_000;
|
|
|
|
function walkFiles(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const n of readdirSync(dir)) {
|
|
const p = join(dir, n);
|
|
if (statSync(p).isDirectory()) out.push(...walkFiles(p));
|
|
else out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("接管识别:文件名解析", () => {
|
|
it("主视频取要下载的最大视频,跳过 sample;文件名 + 目录 + 种子名合并", () => {
|
|
expect(
|
|
pickMainVideo([
|
|
{ name: "Movie.2021/Sample/sample.mkv", size: 9e9 },
|
|
{ name: "Movie.2021/Movie.2021.1080p.mkv", size: 5e9 },
|
|
{ name: "Movie.2021/Extra.mkv", size: 6e9, priority: 0 },
|
|
{ name: "Movie.2021/Movie.2021.nfo", size: 1e3 },
|
|
]),
|
|
).toBe("Movie.2021/Movie.2021.1080p.mkv");
|
|
expect(pickMainVideo([{ name: "a.nfo", size: 1 }])).toBeNull();
|
|
// 文件名只有集号:标题 / 季从目录名(=种子根目录)补
|
|
const p = takeoverParsed("The.Bear.S02.1080p.WEB-DL", "The.Bear.S02.1080p.WEB-DL/E03.mkv");
|
|
expect(p.title?.toLowerCase()).toContain("bear");
|
|
expect(p.season).toBe(2);
|
|
expect(p.episodes).toEqual({ start: 3, end: 3 });
|
|
// 单文件种子:种子名即文件名
|
|
const m = takeoverParsed("Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer.2023.2160p.UHD.BluRay.mkv");
|
|
expect(m.year).toBe(2023);
|
|
// 文件名解析不出标题:用种子名
|
|
expect(takeoverParsed("Heat.1995.1080p.BluRay", "Heat.1995.1080p.BluRay/0001.mkv").year).toBe(1995);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!hasPg)("QB-1 接管任务识别与事项闭环(真实 PG + mock qB + 临时目录)", () => {
|
|
let h: Harness;
|
|
let qb: MockQb;
|
|
let lib: TempTree;
|
|
let cache: TempTree;
|
|
let n = 0;
|
|
const actor = { type: "user", id: "tester" };
|
|
|
|
beforeAll(async () => {
|
|
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
|
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
|
|
cache = await createTempTree({}, { prefix: "umt-cache-" });
|
|
h = await createHarness("takeover", {}, { withSources: true });
|
|
await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
|
await h.setSettings("retention", { seedRetentionDays: 0 });
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await qb?.close();
|
|
await lib?.cleanup();
|
|
await cache?.cleanup();
|
|
});
|
|
|
|
/** 用户在 qB 里手动加的任务(贴管理标签)→ 同步接管 → 完成落盘 */
|
|
async function adoptCompleted(name: string, file: string, size = SIZE) {
|
|
n++;
|
|
const hash = n.toString(16).padStart(40, "a");
|
|
qb.seed({ hash, name, tags: ["umt"], save_path: cache.root });
|
|
await qb.complete(hash, { files: [{ path: `${name}/${file}`, size }] });
|
|
const sync = await syncDownloads(h.d);
|
|
expect(sync.adopted).toBeGreaterThanOrEqual(1);
|
|
const dt = await h.db
|
|
.selectFrom("download_task")
|
|
.selectAll()
|
|
.where("torrent_hash", "=", hash)
|
|
.executeTakeFirstOrThrow();
|
|
expect(dt).toMatchObject({ origin: "adopted", source_release_id: null, status: "completed" });
|
|
return dt;
|
|
}
|
|
|
|
const decisionOf = (key: string) =>
|
|
h.db
|
|
.selectFrom("decision")
|
|
.selectAll()
|
|
.where("dedupe_key", "=", key)
|
|
.orderBy("created_at", "desc")
|
|
.executeTakeFirst();
|
|
|
|
let movieId: string;
|
|
let firstDtId: string;
|
|
|
|
it("识别成功(本地别名召回):不建事项,直接整理入库并回填下载任务的作品", async () => {
|
|
movieId = await seedEntity(h.db, { kind: "movie", title: "Quiet Harbor", year: 2021 });
|
|
await addAliasIfAbsent(h.db, movieId, "Quiet Harbor", "original", "user");
|
|
const dt = await adoptCompleted(
|
|
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
|
|
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
|
|
);
|
|
firstDtId = dt.id;
|
|
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
|
expect(plan.status).toBe("running");
|
|
const done = await executeRecord(h.d, plan.recordId!);
|
|
expect(done.status).toBe("succeeded");
|
|
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
|
|
expect(row).toMatchObject({ media_entity_id: movieId, status: "imported" });
|
|
expect(await decisionOf(`organize_identity:${dt.id}`)).toBeUndefined();
|
|
});
|
|
|
|
let unknownDtId: string;
|
|
|
|
it("识别失败:事项带 选择作品(本地 / TMDB)/ 临时作品 / 忽略;选临时作品 → 建临时作品并整理,事项闭环", async () => {
|
|
const dt = await adoptCompleted("Zzqx.Vorlanth.2019.1080p.WEB-DL", "Zzqx.Vorlanth.2019.1080p.WEB-DL.mkv");
|
|
unknownDtId = dt.id;
|
|
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
|
expect(plan).toMatchObject({ status: "failed", reason: "identity_unknown" });
|
|
const dec = (await decisionOf(`organize_identity:${dt.id}`))!;
|
|
expect(dec).toBeTruthy();
|
|
const keys = (dec.options as { key: string }[]).map((o) => o.key);
|
|
expect(keys).toEqual(expect.arrayContaining(["select_entity", "select_tmdb", "provisional", "dismiss"]));
|
|
expect(dec.evidence).toMatchObject({ downloadTaskId: dt.id, outcome: "no_match" });
|
|
expect((dec.evidence as { parsed: { year: number } }).parsed.year).toBe(2019);
|
|
|
|
const hook = organizeDecisionHook(h.d);
|
|
expect(hook.match(dec)).toBe(true);
|
|
// 选择作品(本地)但没给作品 → 错误,事项保持打开
|
|
await expect(hook.execute(dec, { optionKey: "select_entity" }, actor)).rejects.toThrow(/mediaEntityId/);
|
|
const out = await hook.execute(dec, { optionKey: "provisional" }, actor);
|
|
expect(out).toMatchObject({ kind: "organize", status: "running" });
|
|
const ent = await h.db
|
|
.selectFrom("media_entity")
|
|
.selectAll()
|
|
.where("id", "=", out.mediaEntityId as string)
|
|
.executeTakeFirstOrThrow();
|
|
expect(ent).toMatchObject({ status: "provisional", year: 2019 });
|
|
expect(h.engine.enqueued.some((e) => (e.input as { recordId?: string }).recordId === out.recordId)).toBe(true);
|
|
const done = await executeRecord(h.d, out.recordId as string);
|
|
expect(done.status).toBe("succeeded");
|
|
const closed = (await decisionOf(`organize_identity:${dt.id}`))!;
|
|
expect(closed.status).toBe("resolved");
|
|
expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: out.recordId });
|
|
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
|
|
expect(row.media_entity_id).toBe(ent.id);
|
|
});
|
|
|
|
it("待处理中心 API:选候选 / 本地作品 → 执行并关闭;缺作品 → 400 且事项保持打开", async () => {
|
|
const target = await seedEntity(h.db, { kind: "movie", title: "Amber Coast", year: 2017 });
|
|
const dt = await adoptCompleted("Qwzz.Nomatch.2017.1080p.WEB", "Qwzz.Nomatch.2017.1080p.WEB.mkv");
|
|
await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
|
const dec = (await decisionOf(`organize_identity:${dt.id}`))!;
|
|
const bad = await h.api("POST", `/decisions/${dec.id}/resolve`, { optionKey: "select_entity" });
|
|
expect(bad.status).toBe(400);
|
|
expect((await decisionOf(`organize_identity:${dt.id}`))?.status).toBe("open");
|
|
const ok = await h.api("POST", `/decisions/${dec.id}/resolve`, {
|
|
optionKey: "select_entity",
|
|
identity: { mediaEntityId: target },
|
|
});
|
|
expect(ok.status).toBe(200);
|
|
expect(ok.body.data.status).toBe("resolved");
|
|
expect(ok.body.data.resolution.execution).toMatchObject({ kind: "organize", mediaEntityId: target });
|
|
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
|
|
expect(row.media_entity_id).toBe(target);
|
|
const recId = ok.body.data.resolution.execution.recordId as string;
|
|
expect((await executeRecord(h.d, recId)).status).toBe("succeeded");
|
|
});
|
|
|
|
it("识别失败后在文件管理按路径手动整理:同一下载任务的整理事项关闭,回填作品", async () => {
|
|
const other = await seedEntity(h.db, { kind: "movie", title: "Glass Orchard", year: 2018 });
|
|
const dt = await adoptCompleted("Xqqz.Unmatched.2018.720p.HDTV", "Xqqz.Unmatched.2018.720p.HDTV.mkv");
|
|
await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
|
expect((await decisionOf(`organize_identity:${dt.id}`))?.status).toBe("open");
|
|
const src = join(cache.root, "Xqqz.Unmatched.2018.720p.HDTV");
|
|
const plan = await planImport(h.d, {
|
|
sourcePaths: [src],
|
|
mediaEntityId: other,
|
|
trigger: "manual",
|
|
actorType: "user",
|
|
actorId: "tester",
|
|
});
|
|
expect(plan.status).toBe("running");
|
|
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
|
|
const closed = (await decisionOf(`organize_identity:${dt.id}`))!;
|
|
expect(closed).toMatchObject({ status: "resolved" });
|
|
expect(closed.resolution).toMatchObject({ key: "manual_organize" });
|
|
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
|
|
expect(row.media_entity_id).toBe(other);
|
|
});
|
|
|
|
it("目标已存在:事项给 替换 / 保留两者 / 跳过;保留两者 → 新文件加序号入库,旧文件不动,事项闭环", async () => {
|
|
// 同一作品的另一份(不同内容、同名)→ 目标文件已存在
|
|
const dt = await adoptCompleted(
|
|
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
|
|
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
|
|
SIZE + 1000,
|
|
);
|
|
// 同名种子在 qB 里是新 hash;接管识别仍命中同一作品
|
|
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
|
expect(plan.status).toBe("failed");
|
|
expect(plan.reason).toContain("target_exists");
|
|
const dec = (await decisionOf(`organize_blocked:${dt.id}`))!;
|
|
expect((dec.options as { key: string }[]).map((o) => o.key)).toEqual(["replace", "keep_both", "skip"]);
|
|
const before = walkFiles(lib.root).filter((p) => p.endsWith(".mkv"));
|
|
expect(before.filter((p) => p.includes("Quiet"))).toHaveLength(1);
|
|
const hook = organizeDecisionHook(h.d);
|
|
const out = await hook.execute(dec, { optionKey: "keep_both" }, actor);
|
|
expect(out).toMatchObject({ kind: "organize", choice: "keep_both", status: "running" });
|
|
expect((await executeRecord(h.d, out.recordId as string)).status).toBe("succeeded");
|
|
const after = walkFiles(lib.root).filter((p) => p.endsWith(".mkv"));
|
|
expect(after).toHaveLength(before.length + 1);
|
|
const renamed = after.find((p) => p.includes(" (2)."));
|
|
expect(renamed && statSync(renamed).size).toBe(SIZE + 1000);
|
|
for (const p of before) expect(existsSync(p)).toBe(true);
|
|
expect((await decisionOf(`organize_blocked:${dt.id}`))?.status).toBe("resolved");
|
|
});
|
|
|
|
it("目标已存在 → 跳过:其余照常,已存在的不整理;替换:核验后替换", async () => {
|
|
const dt = await adoptCompleted(
|
|
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
|
|
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
|
|
SIZE + 2000,
|
|
);
|
|
await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
|
|
const dec = (await decisionOf(`organize_blocked:${dt.id}`))!;
|
|
const hook = organizeDecisionHook(h.d);
|
|
const skip = await hook.execute(dec, { optionKey: "skip" }, actor);
|
|
const rec = await h.db
|
|
.selectFrom("organization_record")
|
|
.selectAll()
|
|
.where("id", "=", skip.recordId as string)
|
|
.executeTakeFirstOrThrow();
|
|
expect(rec.file_skipped).toBeGreaterThanOrEqual(1);
|
|
|
|
const dt2 = await adoptCompleted(
|
|
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP",
|
|
"Quiet.Harbor.2021.1080p.BluRay.x264-GRP.mkv",
|
|
SIZE + 3000,
|
|
);
|
|
await planImport(h.d, { downloadTaskId: dt2.id, trigger: "auto", actorType: "system" });
|
|
const dec2 = (await decisionOf(`organize_blocked:${dt2.id}`))!;
|
|
const rep = await hook.execute(dec2, { optionKey: "replace" }, actor);
|
|
expect(rep.status).toBe("running");
|
|
expect((await executeRecord(h.d, rep.recordId as string)).status).toBe("succeeded");
|
|
// 被替换的旧文件进库根回收站(.umt-trash),不在库内
|
|
const inLib = walkFiles(lib.root).filter(
|
|
(p) => p.endsWith(".mkv") && p.includes("Quiet") && !p.includes("/.umt-trash/"),
|
|
);
|
|
const sizes = inLib.map((p) => statSync(p).size);
|
|
expect(sizes).toContain(SIZE + 3000);
|
|
expect(sizes).not.toContain(SIZE);
|
|
expect(walkFiles(lib.root).some((p) => p.includes("/.umt-trash/") && statSync(p).size === SIZE)).toBe(true);
|
|
});
|
|
|
|
it("作品详情下载历史:列出接管的下载任务与整理记录", async () => {
|
|
const r = await h.api("GET", `/library/${movieId}`);
|
|
expect(r.status).toBe(200);
|
|
const ids = r.body.data.downloads.map((d: { downloadTask: { id: string } }) => d.downloadTask.id);
|
|
expect(ids).toContain(firstDtId);
|
|
const first = r.body.data.downloads.find((d: { downloadTask: { id: string } }) => d.downloadTask.id === firstDtId);
|
|
expect(first.downloadTask).toMatchObject({ origin: "adopted", mediaEntityId: movieId });
|
|
expect(first.lastOrganizationStatus).toBe("succeeded");
|
|
expect(r.body.data.organizationRecords.length).toBeGreaterThanOrEqual(3);
|
|
const prov = await h.db
|
|
.selectFrom("download_task")
|
|
.select("media_entity_id")
|
|
.where("id", "=", unknownDtId)
|
|
.executeTakeFirstOrThrow();
|
|
const r2 = await h.api("GET", `/library/${prov.media_entity_id}`);
|
|
expect(r2.body.data.downloads.map((d: { downloadTask: { id: string } }) => d.downloadTask.id)).toContain(
|
|
unknownDtId,
|
|
);
|
|
});
|
|
});
|