Files
UHD_Media_Tool/apps/server/test/acquisition-organize/subscription.test.ts
T
rootandClaude Opus 5.5 c9e8120fc6 test: server 测试共用建库与假引擎,sse-pg 归入串行组
- 新增 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]>
2026-10-02 17:40:43 +08:00

351 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createQbClient } from "@umt/adapters";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import {
applyEpisodeTotal,
subscriptionProgress,
sweepDueSlots,
} from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
// W5-C 订阅增强集成:Slot×单元多在途 + 忙时延后重评 + 移除释放;季包按缺集选文件(mock qB files/filePrio);
// 总集数未知不结束(疑似缺集);once 完成写历史;pending 不完成;分集洗版只替换能提升的单元。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
const disk = fakeDisk(10_000_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
cache = await createTempTree({}, { prefix: "umt-cache-" });
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("subscription", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
});
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
const db = () => h.db;
async function seriesSlot(
title: string,
opts: { mode?: "once" | "continuous"; status?: "active" | "pending"; hard?: object; upgrade?: object } = {},
) {
const entityId = await seedEntity(h.db, { kind: "series", title, year: 2024 });
const sub = await db()
.insertInto("subscription")
.values({
media_entity_id: entityId,
media_kind: "series",
mode: opts.mode ?? "continuous",
status: opts.status ?? "active",
})
.returning("id")
.executeTakeFirstOrThrow();
const slot = await db()
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: sub.id,
name: "1080p",
hard_conditions: JSON.stringify(opts.hard ?? { resolutions: ["1080p"] }),
upgrade_threshold: JSON.stringify(opts.upgrade ?? {}),
})
.returning("id")
.executeTakeFirstOrThrow();
return { entityId, subId: sub.id, slotId: slot.id };
}
async function release(entityId: string, title: string, files?: { path: string; length: number }[]) {
const t = files
? makeTorrent({ name: title, files, pieceSeed: Math.floor(Math.random() * 1e6) })
: makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
const id = await seedRelease(h.db, { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
torrents.set(id, t.bytes);
return { id, hash: t.hash, name: t.name };
}
const slotRow = (id: string) =>
db().selectFrom("acquisition_slot").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const planRow = (id: string) =>
db().selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const subRow = (id: string) =>
db().selectFrom("subscription").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
async function planFor(relId: string) {
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
return ev.plans[0]!;
}
async function submit(planId: string) {
const s = await submitDownload(h.d, planId);
expect(s.status).toBe("submitted");
return s.downloadTaskId!;
}
async function completeAndImport(hash: string, dtId: string) {
await qb.complete(hash, { writeFiles: true });
const sync = await syncDownloads(h.d);
expect(sync.completed).toContain(dtId);
const p = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(p.status, JSON.stringify(p)).toBe("running");
const ex = await executeRecord(h.d, p.recordId!);
expect(ex.status).toBe("succeeded");
}
it("Slot×单元多在途:两集同时在途;重叠单元的发布延后;下载被移除即释放预留,重评后补上", async () => {
const { entityId, slotId } = await seriesSlot("熊家餐馆");
const e1 = await release(entityId, "The.Bear.S01E01.1080p.WEB-DL.x264-GRP");
const e2 = await release(entityId, "The.Bear.S01E02.1080p.WEB-DL.x264-GRP");
const p1 = await planFor(e1.id);
const p2 = await planFor(e2.id); // Slot 已 reserved,单元不相交 → 第二个在途计划
expect((await planRow(p1)).unit_keys).toEqual(["s1e1"]);
expect((await planRow(p2)).unit_keys).toEqual(["s1e2"]);
expect((await slotRow(slotId)).status).toBe("reserved");
// 同一集的另一个版本:单元在途 → 延后(deferred),不丢
const e2b = await release(entityId, "The.Bear.S01E02.1080p.WEB-DL.x265-OTHER");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: e2b.id });
expect(ev.plans).toHaveLength(0);
expect(ev.deferred).toEqual([{ sourceReleaseId: e2b.id, slotId, reason: "units_inflight" }]);
expect(
await db().selectFrom("slot_deferral").select(["unit_keys"]).where("slot_id", "=", slotId).execute(),
).toEqual([{ unit_keys: ["s1e2"] }]);
const dt1 = await submit(p1);
await submit(p2);
expect((await slotRow(slotId)).status).toBe("downloading");
await completeAndImport(e1.hash, dt1);
// E01 入库,但 E02 仍在途:Slot 显示在途阶段而不是静止态;分集账本登记 E01
const s1 = await slotRow(slotId);
expect(s1.status).toBe("downloading");
expect(s1.current_asset_id).not.toBeNull();
expect(s1.next_evaluate_at).not.toBeNull();
const ledger = await db()
.selectFrom("slot_unit as su")
.innerJoin("content_unit as u", "u.id", "su.content_unit_id")
.select(["u.episode_number", "su.plan_id"])
.where("su.slot_id", "=", slotId)
.execute();
expect(ledger).toEqual([{ episode_number: 1, plan_id: p1 }]);
// 用户在 qB 里删掉 E02:确认移除 → 计划失败释放单元 → Slot 到期重评 → 延后的 E02 另一版本得到计划
const qc = createQbClient({ instanceId: "t", baseUrl: qb.url, username: "admin", password: "adminadmin" });
await qc.delete([e2.hash], { deleteFiles: false });
await syncDownloads(h.d);
expect(await planRow(p2)).toMatchObject({ status: "failed", status_reason: "download_removed" });
expect((await slotRow(slotId)).status).toBe("attention");
h.engine.enqueued.length = 0;
expect(await sweepDueSlots(h.d)).toBeGreaterThanOrEqual(1);
expect(h.engine.enqueued).toContainEqual(
expect.objectContaining({ task: "evaluateAcquisition", input: { acquisitionSlotId: slotId } }),
);
const re = await evaluateAcquisition(h.d, { acquisitionSlotId: slotId });
expect(re.plans).toHaveLength(1);
expect(await planRow(re.plans[0]!)).toMatchObject({ source_release_id: e2b.id, unit_keys: ["s1e2"] });
// e2b 的延后已消化;被移除的原 E02 此时单元在途(e2b)→ 转为延后,等 e2b 结束后再判
const left = await db()
.selectFrom("slot_deferral")
.select("source_release_id")
.where("slot_id", "=", slotId)
.execute();
expect(left.map((x) => x.source_release_id)).toEqual([e2.id]);
// 候选评估记录带 trace / unitKeys
const art = await db()
.selectFrom("artifact")
.select("content")
.where("subject_id", "=", slotId)
.where("title", "=", "candidate_evaluation")
.execute();
const byRel = new Map(art.map((a) => [(a.content as { sourceReleaseId: string }).sourceReleaseId, a.content]));
expect(byRel.get(e2b.id)).toMatchObject({ decision: "accept", unitKeys: ["s1e2"], pri: 100 });
}, 60_000);
it("季包:总集数已知 → 只预留缺集;提交后读文件清单,已覆盖/在途的集文件优先级置 0", async () => {
const { entityId, slotId } = await seriesSlot("选集剧");
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 4, now: new Date() }));
const e1 = await release(entityId, "Pick.Show.S01E01.1080p.WEB-DL.x264-GRP");
await completeAndImport(e1.hash, await submit(await planFor(e1.id)));
const e2 = await release(entityId, "Pick.Show.S01E02.1080p.WEB-DL.x264-GRP");
const p2 = await planFor(e2.id); // E02 在途(未提交)
const packTitle = "Pick.Show.S01.1080p.WEB-DL.x264-GRP";
const files = [1, 2, 3, 4].map((e) => ({ path: `Pick.Show.S01E0${e}.1080p.WEB-DL.x264-GRP.mkv`, length: SIZE }));
const pack = await release(entityId, packTitle, [
...files,
{ path: "Pick.Show.S01E01.chs.srt", length: 100 },
{ path: "Pick.Show.S01E03.chs.srt", length: 100 },
]);
const pp = await planFor(pack.id);
expect((await planRow(pp)).unit_keys).toEqual(["s1e3", "s1e4"]);
const before = qb.callsTo("torrents/filePrio").length;
await submit(pp);
const prio = qb.callsTo("torrents/filePrio").slice(before);
expect(prio).toHaveLength(1);
const t = qb.get(pack.hash)!;
const off = t.files.filter((f) => f.priority === 0).map((f) => f.name.split("/").pop());
expect(off.sort()).toEqual(
[
"Pick.Show.S01E01.1080p.WEB-DL.x264-GRP.mkv",
"Pick.Show.S01E01.chs.srt",
"Pick.Show.S01E02.1080p.WEB-DL.x264-GRP.mkv",
].sort(),
);
expect((await planRow(pp)).file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s1e3", "s1e4"] });
// 进度:E01 已覆盖,E02–E04 在途
const prog = await subscriptionProgress(db(), (await slotRow(slotId)).subscription_id!);
expect(prog!.seasons[0]).toMatchObject({ seasonNumber: 1, episodeTotal: 4, totalKnown: true, missing: [] });
expect(prog!.seasons[0]!.units.map((u) => u.state)).toEqual(["covered", "inflight", "inflight", "inflight"]);
expect(prog!.slots[0]!.inflightPlans.map((x) => x.planId).sort()).toEqual([p2, pp].sort());
// 认不出集号的整包:整包下载并记理由
const { entityId: e2Id } = await seriesSlot("整包剧");
const odd = await release(e2Id, "Odd.Show.S01.1080p.WEB-DL.x264-GRP", [
{ path: "disc1.mkv", length: SIZE },
{ path: "disc2.mkv", length: SIZE },
]);
const po = await planFor(odd.id);
expect((await planRow(po)).unit_keys).toEqual(["s1*"]);
const n0 = qb.callsTo("torrents/filePrio").length;
await submit(po);
expect(qb.callsTo("torrents/filePrio").length).toBe(n0);
expect((await planRow(po)).file_selection).toMatchObject({ mode: "whole", reason: "unmapped_video_file" });
}, 60_000);
it("总集数未知:入库后不结束、记疑似缺集;总集数就绪后补齐 → once 订阅完成并写历史", async () => {
const { entityId, slotId, subId } = await seriesSlot("未知集数剧", { mode: "once" });
for (const ep of [1, 3]) {
const r = await release(entityId, `Unknown.Total.S01E0${ep}.1080p.WEB-DL.x264-GRP`);
await completeAndImport(r.hash, await submit(await planFor(r.id)));
}
expect((await slotRow(slotId)).status).toBe("empty");
expect(await subRow(subId)).toMatchObject({ status: "active" });
const dec = await db()
.selectFrom("decision")
.selectAll()
.where("dedupe_key", "=", `suspected_missing:${slotId}`)
.executeTakeFirstOrThrow();
expect(dec.evidence).toMatchObject({ suspectedMissing: [{ season: 1, episodes: [2] }] });
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 3, now: new Date() }));
const r2 = await release(entityId, "Unknown.Total.S01E02.1080p.WEB-DL.x264-GRP");
const acceptedTask = await submit(await planFor(r2.id));
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
expect((await slotRow(slotId)).status).toBe("downloading");
await completeAndImport(r2.hash, acceptedTask);
expect((await slotRow(slotId)).status).toBe("satisfied");
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
const hist = await db()
.selectFrom("subscription_history")
.selectAll()
.where("subscription_id", "=", subId)
.execute();
expect(hist).toHaveLength(1);
expect(hist[0]).toMatchObject({ reason: "download_accepted", media_entity_id: entityId, mode: "once" });
expect((hist[0]!.progress as { seasons: { complete: boolean }[] }).seasons[0]!.complete).toBe(true);
// 历史 API + 重新订阅(改回 active)
const api = await h.api("GET", `/subscription-history?subscriptionId=${subId}`);
expect(api.body.data.items).toHaveLength(1);
const re = await h.api("PATCH", `/subscriptions/${subId}`, { status: "active" });
expect(re.body.data.status).toBe("active");
}, 90_000);
it("多个目标版本按各自已接收单元完成,单版本入库不提前完成订阅", async () => {
const { entityId, subId } = await seriesSlot("双版本剧", { mode: "once" });
await db()
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: subId,
name: "720p",
hard_conditions: JSON.stringify({ resolutions: ["720p"] }),
})
.execute();
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
const first = await release(entityId, "Two.Versions.S01E01.1080p.WEB-DL.x264-GRP");
const firstTask = await submit(await planFor(first.id));
expect((await subRow(subId)).status).toBe("active");
await completeAndImport(first.hash, firstTask);
expect((await subRow(subId)).status).toBe("active");
const second = await release(entityId, "Two.Versions.S01E01.720p.WEB-DL.x264-GRP");
await submit(await planFor(second.id));
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
await qb.complete(second.hash, { writeFiles: true });
await syncDownloads(h.d);
}, 60_000);
it("pending(TMDB 未就绪)照常下载但不完成;暂停的订阅不评估", async () => {
const { entityId, slotId, subId } = await seriesSlot("待定剧", { mode: "once", status: "pending" });
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
const r = await release(entityId, "Pending.Show.S01E01.1080p.WEB-DL.x264-GRP");
await completeAndImport(r.hash, await submit(await planFor(r.id)));
expect((await slotRow(slotId)).status).toBe("empty");
expect((await subRow(subId)).status).toBe("pending");
const paused = await seriesSlot("暂停剧", { status: "active" });
await db().updateTable("subscription").set({ status: "paused" }).where("id", "=", paused.subId).execute();
const pr = await release(paused.entityId, "Paused.Show.S01E01.1080p.WEB-DL.x264-GRP");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: pr.id });
expect(ev).toMatchObject({ evaluated: 0, plans: [] });
}, 60_000);
it("分集洗版:只替换能提升的单元;同档不换(trace 可读)", async () => {
const { entityId, slotId } = await seriesSlot("洗版剧", {
hard: { resolutions: ["720p", "1080p"] },
upgrade: { enabled: true, allowResolutionUpgrade: true },
});
const r = await release(entityId, "Wash.Show.S01E01.720p.WEB-DL.x264-GRP");
await completeAndImport(r.hash, await submit(await planFor(r.id)));
const same = await release(entityId, "Wash.Show.S01E01.720p.WEB-DL.x264-OTHER");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: same.id });
expect(ev.plans).toHaveLength(0);
const up = await release(entityId, "Wash.Show.S01E01-E02.1080p.WEB-DL.x264-GRP");
// E02 还缺 → 追更(fill);只有 E01 覆盖时的纯升级场景见下一条
const fill = await planFor(up.id);
expect(await planRow(fill)).toMatchObject({ purpose: "fill", unit_keys: ["s1e2"] });
await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("id", "=", fill).execute();
await db().updateTable("acquisition_slot").set({ status: "empty" }).where("id", "=", slotId).execute();
const up1 = await release(entityId, "Wash.Show.S01E01.1080p.WEB-DL.x264-GRP");
const pu = await planFor(up1.id);
expect(await planRow(pu)).toMatchObject({ purpose: "upgrade", unit_keys: ["s1e1"] });
const art = await db()
.selectFrom("artifact")
.select("content")
.where("subject_id", "=", slotId)
.where("title", "=", "candidate_evaluation")
.execute();
const sameEval = art
.map((a) => a.content as { sourceReleaseId: string; trace?: string[] })
.find((a) => a.sourceReleaseId === same.id);
expect(sameEval?.trace?.[0]).toBe("与当前版本同档,不反复更换");
}, 60_000);
});