refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries

This commit is contained in:
root committed 2026-10-13 14:37:03 +08:00
1 parent 3f1c6d02f6
commit 27c0f27434
268 files changed
+7468 -7852

No files matched your search

@@ -0,0 +1,258 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
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 } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("自动洗版物理替换(真实 PG、qB协议与临时文件)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let base: Awaited<ReturnType<typeof seedBase>>;
let groupId: string;
const disk = fakeDisk(100_000_000_000);
const torrents = new Map<string, Uint8Array>();
let pieceSeed = 0;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-auto-up-lib-" });
cache = await createTempTree({}, { prefix: "umt-auto-up-cache-" });
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("auto_upgrade_replace", {
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 });
await h.setSettings("naming", {
movie: "{{title}} ({{year}})/{{title}} ({{year}}) - {{videoFormat}}{{fileExt}}",
tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}",
});
const g = await h.api("POST", "/rules", { name: "自动洗版优先REMUX", ruleString: "REMUX > WEBDL" });
expect(g.status, JSON.stringify(g.body)).toBe(201);
groupId = g.body.data.id;
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
async function tracked(kind: "movie" | "series", title: string, versions: { name: string; resolutions: string[] }[]) {
const entityId = await seedEntity(h.db, { kind, title, year: 2024 });
const sub = await h.db
.insertInto("subscription")
.values({
media_entity_id: entityId,
media_kind: kind,
mode: "once",
season_scope: "current_season",
params: JSON.stringify({ autoFill: "on" }),
})
.returning("id")
.executeTakeFirstOrThrow();
const slotIds: string[] = [];
for (const [position, v] of versions.entries()) {
const slot = await h.db
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: sub.id,
name: v.name,
position,
hard_conditions: JSON.stringify({ resolutions: v.resolutions }),
soft_preferences: JSON.stringify({ groups: ["GOOD", "OTHER", "LOW"] }),
upgrade_threshold: JSON.stringify({
enabled: true,
basis: "priority",
mode: kind === "movie" ? "movie" : "per_unit",
...(kind === "series" ? { consistency: "season_release" } : {}),
}),
rule_group_ids: JSON.stringify([groupId]),
})
.returning("id")
.executeTakeFirstOrThrow();
slotIds.push(slot.id);
}
if (kind === "series")
await h.db
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 2, now: new Date() }));
return { entityId, slotIds };
}
async function release(entityId: string, title: string) {
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: ++pieceSeed });
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 };
}
async function acquire(entityId: string, title: string, expectedSlot: string, purpose: "fill" | "upgrade") {
const rel = await release(entityId, title);
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
const planId = ev.plans[0]!;
const plan = await h.db
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", planId)
.executeTakeFirstOrThrow();
expect(plan).toMatchObject({ slot_id: expectedSlot, purpose });
if (purpose === "upgrade") expect(plan.evaluation).toMatchObject({ automaticReplacement: true });
const submitted = await submitDownload(h.d, planId);
expect(submitted.status, JSON.stringify(submitted)).toBe("submitted");
await qb.complete(rel.hash, { writeFiles: true });
expect((await syncDownloads(h.d)).completed).toContain(submitted.downloadTaskId);
const imported = await planImport(h.d, {
downloadTaskId: submitted.downloadTaskId!,
trigger: "auto",
actorType: "system",
});
expect(imported.status, JSON.stringify(imported)).toBe("running");
expect((await executeRecord(h.d, imported.recordId!)).status).toBe("succeeded");
const result = await h.db
.selectFrom("organization_file_result")
.select("media_asset_id")
.where("organization_record_id", "=", imported.recordId!)
.where("role", "=", "main")
.where("status", "=", "succeeded")
.executeTakeFirstOrThrow();
const location = await h.db
.selectFrom("asset_location")
.select("relative_path")
.where("media_asset_id", "=", result.media_asset_id!)
.where("location_kind", "=", "library")
.where("status", "=", "available")
.executeTakeFirstOrThrow();
const bytes = await readFile(lib.path(location.relative_path));
expect(bytes).toHaveLength(SIZE);
return { assetId: result.media_asset_id!, path: location.relative_path, bytes, recordId: imported.recordId! };
}
async function expectKept(file: Awaited<ReturnType<typeof acquire>>) {
const a = await h.db
.selectFrom("media_asset")
.select("status")
.where("id", "=", file.assetId)
.executeTakeFirstOrThrow();
expect(a.status).toBe("active");
expect((await readFile(lib.path(file.path))).equals(file.bytes)).toBe(true);
expect(
await h.db.selectFrom("trash_item").select("id").where("original_path", "=", file.path).execute(),
).toHaveLength(0);
}
async function expectTrashed(file: Awaited<ReturnType<typeof acquire>>, samePath = false) {
const a = await h.db
.selectFrom("media_asset")
.select("status")
.where("id", "=", file.assetId)
.executeTakeFirstOrThrow();
expect(a.status).toBe("retired");
const oldLocation = await h.db
.selectFrom("asset_location")
.select("status")
.where("media_asset_id", "=", file.assetId)
.where("location_kind", "=", "library")
.executeTakeFirstOrThrow();
expect(oldLocation.status).toBe("removed");
if (!samePath) expect(existsSync(lib.path(file.path))).toBe(false);
const items = await h.db
.selectFrom("trash_item")
.select("trash_path")
.where("original_path", "=", file.path)
.execute();
expect(items).toHaveLength(1);
expect((await readFile(items[0]!.trash_path)).equals(file.bytes)).toBe(true);
}
it("电影低档自动入库后升级替换同路径旧文件并回收,另一保留分辨率保持原字节", async () => {
const f = await tracked("movie", "Auto Movie", [
{ name: "HD", resolutions: ["1080p"] },
{ name: "UHD", resolutions: ["2160p"] },
]);
const uhd = await acquire(f.entityId, "Auto.Movie.2024.2160p.BluRay.REMUX.HEVC-GOOD", f.slotIds[1]!, "fill");
const lower = await acquire(f.entityId, "Auto.Movie.2024.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
const higher = await acquire(f.entityId, "Auto.Movie.2024.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade");
expect(higher.path).toBe(lower.path);
expect(higher.assetId).not.toBe(lower.assetId);
expect(higher.bytes.equals(lower.bytes)).toBe(false);
await expectTrashed(lower, true);
await expectKept(uhd);
expect((await readFile(lib.path(higher.path))).equals(higher.bytes)).toBe(true);
await executeRecord(h.d, higher.recordId);
await expectTrashed(lower, true);
await expectKept(uhd);
}, 60_000);
it("旧单文件E1-E2在同套新E1入库时保留,新E2补齐后自动回收旧合集", async () => {
const f = await tracked("series", "Auto Merged", [{ name: "单保留", resolutions: ["720p", "1080p"] }]);
const old = await acquire(f.entityId, "Auto.Merged.S01E01-E02.720p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
const first = await acquire(f.entityId, "Auto.Merged.S01E01.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade");
await expectKept(old);
const second = await acquire(
f.entityId,
"Auto.Merged.S01E02.1080p.BluRay.REMUX.AVC-GOOD",
f.slotIds[0]!,
"upgrade",
);
await expectTrashed(old);
await expectKept(first);
await expectKept(second);
await executeRecord(h.d, second.recordId);
await expectTrashed(old);
}, 60_000);
it("不同组或另一保留分辨率的已入库E2不能凑齐新E1的合集覆盖", async () => {
const f = await tracked("series", "Auto Isolated", [
{ name: "HD", resolutions: ["1080p"] },
{ name: "UHD", resolutions: ["2160p"] },
]);
const old = await acquire(f.entityId, "Auto.Isolated.S01E01-E02.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
const otherResolution = await acquire(
f.entityId,
"Auto.Isolated.S01E02.2160p.BluRay.REMUX.AVC-GOOD",
f.slotIds[1]!,
"fill",
);
const otherGroup = await acquire(
f.entityId,
"Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-OTHER",
f.slotIds[0]!,
"upgrade",
);
await expectKept(old);
const first = await acquire(
f.entityId,
"Auto.Isolated.S01E01.1080p.BluRay.REMUX.AVC-GOOD",
f.slotIds[0]!,
"upgrade",
);
await expectKept(old);
await expectKept(otherGroup);
await expectKept(otherResolution);
const second = await acquire(
f.entityId,
"Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-GOOD",
f.slotIds[0]!,
"upgrade",
);
await expectTrashed(old);
await expectTrashed(otherGroup, true);
await expectKept(first);
await expectKept(otherResolution);
expect((await readFile(lib.path(second.path))).equals(second.bytes)).toBe(true);
}, 60_000);
});
@@ -0,0 +1,334 @@
import { randomUUID } from "node:crypto";
import type { AcquisitionPolicyScope, SlotTemplate, SubscriptionParams } from "@umt/contracts";
import type { AcquisitionSlotRow } from "@umt/db";
import { type QualityFacts, type SlotSoftPreferences, type SlotSpec, seasonReleaseKey } from "@umt/domain";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ensureSubscriptionSlot } from "../../src/modules/acquisition-organize/services/collection-strategy.ts";
import { seasonReleaseTarget } from "../../src/modules/acquisition-organize/services/season-release.ts";
import { slotComplete } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { applySlotPreset } from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("收录策略与成套洗版边界(真实 PG)", () => {
let h: Harness;
let siteId: string;
beforeAll(async () => {
h = await createHarness("collection_strategy");
siteId = (
await h.db
.insertInto("site")
.values({
key: "collection-test",
name: "收录策略测试站",
adapter: "nexusphp",
base_url: "https://pt.example.test",
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
});
afterAll(async () => {
await h?.close();
});
async function policy(scope: AcquisitionPolicyScope, slots: SlotTemplate[], publishedAt = "2026-01-01T00:00:00Z") {
return (
await h.db
.insertInto("policy_revision")
.values({
policy_key: randomUUID(),
revision: 1,
kind: "acquisition",
status: "published",
title: "收录策略",
scope: JSON.stringify(scope),
rules: JSON.stringify({ defaultSlots: slots }),
published_at: new Date(publishedAt),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function subscription(
kind: "movie" | "series",
policyId: string | null = null,
params: SubscriptionParams = {},
) {
const entityId = await seedEntity(h.db, { kind, title: `Collection ${randomUUID()}`, year: 2026 });
const sub = await h.db
.insertInto("subscription")
.values({
media_entity_id: entityId,
media_kind: kind,
mode: "once",
season_scope: "current_season",
policy_revision_id: policyId,
params: JSON.stringify(params),
})
.returningAll()
.executeTakeFirstOrThrow();
return { entityId, sub };
}
const slotsOf = (subId: string) =>
h.db.selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute();
async function series(opts: { resolutions?: string[]; wash?: boolean; soft?: SlotSoftPreferences } = {}) {
const { entityId, sub } = await subscription("series");
await h.db
.insertInto("content_unit")
.values({ media_entity_id: entityId, kind: "season", season_number: 1, status: "confirmed", episode_total: 2 })
.execute();
const eps = new Map<number, string>();
for (const ep of [1, 2]) {
const unit = await h.db
.insertInto("content_unit")
.values({
media_entity_id: entityId,
kind: "episode",
season_number: 1,
episode_number: ep,
status: "confirmed",
})
.returning("id")
.executeTakeFirstOrThrow();
eps.set(ep, unit.id);
}
const slot = await h.db
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: sub.id,
name: "保留版本",
hard_conditions: JSON.stringify({ resolutions: opts.resolutions ?? ["1080p"] }),
soft_preferences: JSON.stringify(opts.soft ?? {}),
upgrade_threshold: JSON.stringify(
opts.wash === false
? {}
: { enabled: true, basis: "priority", consistency: "season_release", targetPri: 100 },
),
})
.returningAll()
.executeTakeFirstOrThrow();
return { entityId, sub, slot, eps };
}
const quality = (group: string, resolution: QualityFacts["resolution"] = "1080p"): QualityFacts => ({
resolution,
source: "WEB-DL",
videoCodec: "H.264",
releaseGroup: { name: group, source: "suffix" },
hdr: [],
pri: 100,
});
async function asset(f: Awaited<ReturnType<typeof series>>, episodes: number[], q: QualityFacts, ledger = true) {
const a = await h.db
.insertInto("media_asset")
.values({
media_entity_id: f.entityId,
kind: "video",
status: "active",
media_info: JSON.stringify({ _umt: { quality: q } }),
})
.returning("id")
.executeTakeFirstOrThrow();
for (const ep of episodes) {
await h.db
.insertInto("asset_unit_coverage")
.values({ media_asset_id: a.id, content_unit_id: f.eps.get(ep)!, coverage: "full" })
.execute();
if (ledger)
await h.db
.insertInto("slot_unit")
.values({
slot_id: f.slot.id,
content_unit_id: f.eps.get(ep)!,
media_asset_id: a.id,
quality: JSON.stringify(q),
pri: q.pri,
})
.onConflict((oc) =>
oc
.columns(["slot_id", "content_unit_id"])
.doUpdateSet({ media_asset_id: a.id, quality: JSON.stringify(q), pri: q.pri }),
)
.execute();
}
return a.id;
}
async function plan(
slot: AcquisitionSlotRow,
q: QualityFacts,
units: string[],
status: "submitted" | "reserved" = "submitted",
) {
const relId = await seedRelease(h.db, {
siteId,
entityId: slot.media_entity_id,
title: `Collection.S01.${q.resolution}.WEB-DL.x264-${q.releaseGroup!.name}`,
size: 1_000_000,
hash: randomUUID().replaceAll("-", "").padEnd(40, "0"),
});
const p = await h.db
.insertInto("acquisition_plan")
.values({
slot_id: slot.id,
source_release_id: relId,
status,
status_reason: status === "submitted" ? "download_accepted" : null,
purpose: "upgrade",
unit_keys: units,
payload_hash: randomUUID(),
idempotency_key: randomUUID(),
evaluation: JSON.stringify({ pri: q.pri, quality: q, season: 1, seasonRelease: seasonReleaseKey(q) }),
})
.returning("id")
.executeTakeFirstOrThrow();
return { planId: p.id, relId };
}
it("显式选择优先;分类按 movie/tv 匹配,未命中分类时使用媒体类型默认模板", async () => {
const allId = await policy({}, [{ name: "通用" }], "2026-06-01T00:00:00Z");
const movieId = await policy({ mediaKinds: ["movie"] }, [{ name: "电影默认" }]);
const tvId = await policy({ mediaKinds: ["series"] }, [{ name: "剧集默认" }]);
const movieCategoryId = await policy({ categories: { movie: ["精选"] } }, [{ name: "电影精选" }]);
const tvCategoryId = await policy({ categories: { tv: ["精选"] } }, [{ name: "剧集精选" }]);
for (const [kind, category, explicitId, expectedId, name] of [
["series", "精选", allId, allId, "通用"],
["movie", "精选", null, movieCategoryId, "电影精选"],
["series", "精选", null, tvCategoryId, "剧集精选"],
["movie", "其他", null, movieId, "电影默认"],
["series", "其他", null, tvId, "剧集默认"],
] as const) {
const { sub } = await subscription(kind, explicitId);
expect(await ensureSubscriptionSlot(h.db, sub, { mediaKind: kind, category })).toBe(1);
expect(await slotsOf(sub.id)).toMatchObject([{ name, policy_revision_id: expectedId, position: 0 }]);
expect(await ensureSubscriptionSlot(h.db, sub, { mediaKind: kind, category })).toBe(0);
expect(await slotsOf(sub.id)).toHaveLength(1);
}
});
it("显式选择没有模板的策略时报告错误,不静默回退到分类或通用策略", async () => {
const policyId = await policy({}, []);
const { sub } = await subscription("series", policyId);
await expect(ensureSubscriptionSlot(h.db, sub, { mediaKind: "series", category: "精选" })).rejects.toThrow(
"所选收录策略",
);
expect(await slotsOf(sub.id)).toHaveLength(0);
});
it("双分辨率模板保留各自范围,统一预设即使与某模板相同也不能覆盖它", async () => {
const policyId = await policy({}, [
{ name: "HD", hardConditions: { resolutions: ["1080p"] } },
{ name: "UHD", hardConditions: { resolutions: ["2160p"] } },
]);
const { sub } = await subscription("series", policyId, { resolution: "1080p" });
expect(await ensureSubscriptionSlot(h.db, sub)).toBe(2);
const expected = [
{ name: "HD", hard_conditions: { resolutions: ["1080p"] } },
{ name: "UHD", hard_conditions: { resolutions: ["2160p"] } },
];
expect(await slotsOf(sub.id)).toMatchObject(expected);
await applySlotPreset(h.db, {
subscriptionId: sub.id,
prev: { resolution: "1080p" },
next: { resolution: "720p" },
});
expect(await slotsOf(sub.id)).toMatchObject(expected);
});
it("单保留4K版本时,作品已有完整720p不能填补4K覆盖", async () => {
const f = await series({ resolutions: ["2160p"], wash: false });
await asset(f, [1, 2], quality("LOW", "720p"), false);
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: false, atTarget: false });
await asset(f, [1, 2], quality("UHD", "2160p"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
});
it("已接收的旧720p计划同样不能填补改为4K后的范围", async () => {
const f = await series({ resolutions: ["2160p"], wash: false });
await plan(f.slot, quality("LOW", "720p"), ["s1e1", "s1e2"]);
expect(await slotComplete(h.db, f.slot, "tv", [], { includeSubmitted: true })).toMatchObject({
complete: false,
atTarget: false,
});
await plan(f.slot, quality("UHD", "2160p"), ["s1e1", "s1e2"]);
expect(await slotComplete(h.db, f.slot, "tv", [], { includeSubmitted: true })).toMatchObject({
complete: true,
atTarget: true,
});
});
it("HD/UHD分别完成,另一个保留分辨率的资产不能借来填缺", async () => {
const f = await series({ resolutions: ["1080p"], wash: false });
const uhd = await h.db
.insertInto("acquisition_slot")
.values({
media_entity_id: f.entityId,
subscription_id: f.sub.id,
name: "UHD",
hard_conditions: JSON.stringify({ resolutions: ["2160p"] }),
})
.returningAll()
.executeTakeFirstOrThrow();
await asset(f, [1, 2], quality("HD"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true });
expect(await slotComplete(h.db, uhd, "tv", [])).toMatchObject({ complete: false });
await asset({ ...f, slot: uhd }, [1, 2], quality("UHD", "2160p"));
expect(await slotComplete(h.db, uhd, "tv", [])).toMatchObject({ complete: true });
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true });
});
it("各集pri100但组不同仍未到成套目标;同组的分集资源合齐后到目标", async () => {
const f = await series({ soft: { groups: ["B", "A"] } });
await asset(f, [1], quality("B"));
await asset(f, [2], quality("A"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: false });
await asset(f, [2], quality("B"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
});
it("完整A不能盖过更优但只有部分集的B,成套洗版继续以B为目标", async () => {
const f = await series({ soft: { groups: ["B", "A"] } });
await asset(f, [1, 2], quality("A"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
await asset(f, [1], quality("B"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: false });
await asset(f, [2], quality("B"));
expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
});
it("在途季包冻结版本事实,发布属性修改不漂移锚点,失败后才释放", async () => {
const f = await series({ soft: { groups: ["C", "A", "B"] } });
const frozen = quality("A");
const p = await plan(f.slot, frozen, ["s1*"], "reserved");
const spec: SlotSpec = {
name: "成套目标",
hard: { resolutions: ["1080p"] },
soft: { groups: ["C", "A", "B"] },
upgrade: { enabled: true, basis: "priority", consistency: "season_release" },
};
expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
seasonReleaseKey(frozen),
);
await h.db
.updateTable("source_release")
.set({ title: "Collection.S01.1080p.WEB-DL.x264-C", parsed: JSON.stringify({}), parser_version: null })
.where("id", "=", p.relId)
.execute();
expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
seasonReleaseKey(frozen),
);
await h.db.updateTable("acquisition_plan").set({ status: "failed" }).where("id", "=", p.planId).execute();
expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
seasonReleaseKey(quality("B")),
);
});
});
@@ -0,0 +1,186 @@
import { createQbClient } from "@umt/adapters";
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { MISSING_CONFIRM_ROUNDS, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase } from "./harness.ts";
const hasPg = await pgAvailable();
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe.skipIf(!hasPg)("下载观测顺序(真实 PG + 受控 qB 响应)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let base: Awaited<ReturnType<typeof seedBase>>;
let tick = 0;
let hashIndex = 0;
const epoch = Date.parse("2026-10-13T00:00:00Z");
let pending: {
method: "list" | "findByHash";
hash?: string;
captured: ReturnType<typeof deferred>;
resume: ReturnType<typeof deferred>;
} | null = null;
beforeAll(async () => {
qb = await createMockQb();
lib = await createTempTree({}, { sentinel: true });
cache = await createTempTree({});
h = await createHarness("download_order", { diskUsage: fakeDisk(10_000_000_000).fn });
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
h.d.ports.now = () => new Date(epoch + tick);
const original = h.d.ports.downloader;
h.d.ports.downloader = async (inst) => {
const client = await original(inst);
const hold = async <T>(method: "list" | "findByHash", read: () => Promise<T>, hash?: string): Promise<T> => {
const gate = pending?.method === method && (!pending.hash || pending.hash === hash) ? pending : null;
if (gate) pending = null;
const snapshot = await read();
if (gate) {
gate.captured.resolve();
await gate.resume.promise;
}
return snapshot;
};
return {
...client,
list: (input) => hold("list", () => client.list(input)),
findByHash: (hash) => hold("findByHash", () => client.findByHash(hash), hash),
};
};
});
afterAll(async () => {
pending?.resume.resolve();
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
const nextTime = () => new Date(epoch + ++tick);
const row = (id: string) =>
h.db.selectFrom("download_task").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const pauseNext = (method: "list" | "findByHash", hash?: string) => {
const gate = { method, hash, captured: deferred(), resume: deferred() };
pending = gate;
return gate;
};
const seedTorrent = (hash: string) => {
qb.seed({ hash, name: `Observation.${hash}.mkv`, size: 300_000, tags: ["umt"], save_path: cache.root });
qb.setProgress(hash, 0.25);
};
async function task() {
const hash = (++hashIndex).toString(16).padStart(40, "0");
seedTorrent(hash);
nextTime();
await syncDownloads(h.d);
const r = await h.db
.selectFrom("download_task")
.selectAll()
.where("torrent_hash", "=", hash)
.executeTakeFirstOrThrow();
expect(r).toMatchObject({ managed: true, status: "downloading", missing_count: 0 });
return { hash, id: r.id };
}
it("移除标签的新观测先提交时,旧有标签列表晚到不能复活managed", async () => {
const t = await task();
const gate = pauseNext("list");
nextTime();
const older = syncDownloads(h.d);
try {
await gate.captured.promise;
qb.get(t.hash)!.tags = [];
const newerAt = nextTime();
await syncDownloads(h.d);
expect(await row(t.id)).toMatchObject({ managed: false, tags: [], last_synced_at: newerAt });
gate.resume.resolve();
await older;
expect(await row(t.id)).toMatchObject({ managed: false, tags: [], removed_at: null, last_synced_at: newerAt });
} finally {
gate.resume.resolve();
await older;
}
});
it("旧缺失响应晚到不能把已经重新观测到的任务判移除或恢复旧missing_count", async () => {
const t = await task();
await h.db
.updateTable("download_task")
.set({ missing_count: MISSING_CONFIRM_ROUNDS - 1, missing_since: new Date(epoch) })
.where("id", "=", t.id)
.execute();
const external = createQbClient({
instanceId: base.instanceId,
baseUrl: qb.url,
username: "admin",
password: "adminadmin",
});
await external.delete([t.hash], { deleteFiles: false });
const gate = pauseNext("findByHash", t.hash);
nextTime();
const older = syncDownloads(h.d);
try {
await gate.captured.promise;
seedTorrent(t.hash);
const newerAt = nextTime();
await syncDownloads(h.d);
expect(await row(t.id)).toMatchObject({
managed: true,
status: "downloading",
missing_count: 0,
last_synced_at: newerAt,
});
gate.resume.resolve();
await older;
expect(await row(t.id)).toMatchObject({
managed: true,
status: "downloading",
removed_at: null,
missing_count: 0,
missing_since: null,
last_synced_at: newerAt,
});
} finally {
gate.resume.resolve();
await older;
}
});
it("重新贴标签的新观测可恢复管理,旧无标签响应晚到不能撤销恢复", async () => {
const t = await task();
qb.get(t.hash)!.tags = [];
const gate = pauseNext("findByHash", t.hash);
nextTime();
const older = syncDownloads(h.d);
try {
await gate.captured.promise;
qb.get(t.hash)!.tags = ["umt"];
const newerAt = nextTime();
await syncDownloads(h.d);
gate.resume.resolve();
await older;
expect(await row(t.id)).toMatchObject({ managed: true, tags: ["umt"], last_synced_at: newerAt });
qb.get(t.hash)!.tags = [];
nextTime();
await syncDownloads(h.d);
expect(await row(t.id)).toMatchObject({ managed: false });
qb.get(t.hash)!.tags = ["umt"];
const restoredAt = nextTime();
await syncDownloads(h.d);
expect(await row(t.id)).toMatchObject({ managed: true, tags: ["umt"], last_synced_at: restoredAt });
} finally {
gate.resume.resolve();
await older;
}
});
});
@@ -262,6 +262,23 @@ describe.skipIf(!hasPg)("整理卡死回收(真实 PG + mock qB + 临时目录)"
const dec = await decision(`organize_excluded:${plan.recordId}`);
expect(dec).toMatchObject({ status: "open", subject_id: plan.recordId });
expect(JSON.stringify(dec!.evidence)).toContain("excluded:episode_unresolved");
// 终态重放也不能把部分成功当成整包成功,且刮削记录不重复。
const scrapes = () =>
h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", plan.recordId!).execute();
const beforeReplay = await scrapes();
expect(beforeReplay).toHaveLength(1);
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("partial");
expect(existsSync(join(cache.root, name, ani(40)))).toBe(true);
expect(qb.get(hash)).toBeDefined();
expect(qb.callsTo("torrents/delete")).toHaveLength(0);
expect(
await h.db
.selectFrom("download_task")
.select(["managed", "removed_at"])
.where("id", "=", dtId)
.executeTakeFirst(),
).toEqual({ managed: true, removed_at: null });
expect(await scrapes()).toEqual(beforeReplay);
// partial 不在补移除范围内
await h.db
.updateTable("organization_record")
@@ -0,0 +1,318 @@
import { randomUUID } from "node:crypto";
import type { AcquisitionPolicyRules, PolicyRevision, RuleGroup } from "@umt/contracts";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { loadRuleCatalog } from "../../src/modules/acquisition-organize/services/rules.ts";
import { slotSpecWithRules } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
// 实际 PG + 路由/评估入口:模板引用、补齐与洗版组合,以及发布/删除时的引用保护。
const pg = await pgAvailable();
describe.skipIf(!pg)("收录策略与规则组合", () => {
let h: Harness;
let base: Awaited<ReturnType<typeof seedBase>>;
let n = 0;
const disk = fakeDisk(100_000_000_000);
beforeAll(async () => {
disk.setPct(10);
h = await createHarness("policy_combinations", { diskUsage: disk.fn });
base = await seedBase(h.db, {
libraryPath: "/tmp/umt-policy-combinations-library",
cachePath: "/tmp/umt-policy-combinations-cache",
qbUrl: "http://127.0.0.1:9",
});
});
beforeEach(async () => {
const defaults = await h.api("PUT", "/rules/defaults", { acquisition: [], upgrade: [], search: [] });
expect(defaults.status).toBe(200);
await h.db
.updateTable("policy_revision")
.set({ status: "archived" })
.where("status", "in", ["draft", "published"])
.execute();
await h.db.updateTable("acquisition_plan").set({ status: "cancelled" }).where("status", "=", "reserved").execute();
});
afterAll(async () => {
await h?.close();
});
async function group(ruleString = "H265", kind: "acquisition" | "upgrade" = "acquisition") {
const response = await h.api("POST", "/rules", { name: `组合规则-${++n}`, kind, ruleString });
expect(response.status, JSON.stringify(response.body)).toBe(201);
return response.body.data as RuleGroup;
}
async function draft(rules: AcquisitionPolicyRules, policyKey = `combinations-${++n}`) {
const response = await h.api("POST", "/policy-revisions", {
policyKey,
kind: "acquisition",
title: "组合策略",
rules,
});
expect(response.status, JSON.stringify(response.body)).toBe(201);
return response.body.data as PolicyRevision;
}
async function publish(policy: PolicyRevision) {
const response = await h.api("POST", `/policy-revisions/${policy.id}/publish`, {
expectedRevision: policy.revision,
});
expect(response.status, JSON.stringify(response.body)).toBe(200);
return response.body.data as PolicyRevision;
}
const release = (entityId: string, title: string) =>
seedRelease(h.db, {
siteId: base.siteId,
entityId,
title,
size: 300_000_000,
hash: (++n).toString(16).padStart(40, "0"),
});
it("策略预览执行模板的 H265 规则组,硬条件相同的 H264 资源被排除", async () => {
const filter = await group();
const entityId = await seedEntity(h.db, { kind: "movie", title: "Preview Film", year: 2024 });
const h265 = await release(entityId, "Preview.Film.2024.1080p.WEB-DL.H265-GRP");
const h264 = await release(entityId, "Preview.Film.2024.1080p.WEB-DL.H264-GRP");
const policy = await draft({
defaultSlots: [{ name: "只要 H265", hardConditions: { resolutions: ["1080p"] }, ruleGroupIds: [filter.id] }],
});
const response = await h.api("POST", `/policy-revisions/${policy.id}/preview`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
const preview = response.body.data.preview as {
matched: number;
excluded: number;
samples: { sourceReleaseId: string; decision: string; reasons: string[] }[];
};
expect(preview).toMatchObject({ matched: 1, excluded: 1 });
expect(preview.samples.find((sample) => sample.sourceReleaseId === h265)).toMatchObject({ decision: "accept" });
expect(preview.samples.find((sample) => sample.sourceReleaseId === h264)).toMatchObject({ decision: "reject" });
expect(await h.db.selectFrom("acquisition_plan").select("id").execute()).toHaveLength(0);
});
it("新订阅允许先补 WEB:即使模板开启升级,也不把 REMUX 洗版默认链叠到补齐规则", async () => {
const fill = await group("H265");
const wash = await group("REMUX", "upgrade");
expect(
(await h.api("PUT", "/rules/defaults", { acquisition: [fill.id], upgrade: [wash.id], search: [] })).status,
).toBe(200);
await publish(
await draft({
defaultSlots: [
{
name: "先补齐再洗版",
hardConditions: { resolutions: ["1080p"] },
upgradeThreshold: { enabled: true, mode: "movie", basis: "priority" },
},
],
}),
);
const entityId = await seedEntity(h.db, { kind: "movie", title: "Fill Film", year: 2024 });
const subscription = await h.api("POST", "/subscriptions", { mediaEntityId: entityId, mediaKind: "movie" });
expect(subscription.status, JSON.stringify(subscription.body)).toBe(201);
const slot = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("subscription_id", "=", subscription.body.data.id)
.executeTakeFirstOrThrow();
expect(slot.upgrade_threshold).toMatchObject({ enabled: true });
const resource = await release(entityId, "Fill.Film.2024.1080p.WEB-DL.H265-GRP");
const result = await evaluateAcquisition(h.d, { sourceReleaseId: resource });
expect(result.plans, JSON.stringify(result)).toHaveLength(1);
const plan = await h.db
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", result.plans[0]!)
.executeTakeFirstOrThrow();
expect(plan).toMatchObject({ slot_id: slot.id, purpose: "fill", status: "reserved" });
const spec = slotSpecWithRules(
await loadRuleCatalog(h.db),
slot,
null,
{ id: entityId, kind: "movie" },
base.siteId,
);
expect(spec.rules?.groups.map((g) => g.id)).toEqual([fill.id]);
expect(spec.upgradeRules?.groups.map((g) => g.id)).toEqual([fill.id, wash.id]);
});
it("规则组被收录版本的洗版引用时不能删;通过版本更新移除引用后可删", async () => {
const wash = await group("REMUX", "upgrade");
const entityId = await seedEntity(h.db, { kind: "movie", title: "Slot Reference", year: 2024 });
const created = await h.api("POST", "/acquisition-slots", {
mediaEntityId: entityId,
name: "洗版引用",
upgradeThreshold: { enabled: true, ruleGroupIds: [wash.id] },
});
expect(created.status, JSON.stringify(created.body)).toBe(201);
expect((await h.api("DELETE", `/rules/${wash.id}`)).status).toBe(409);
expect(
(await h.db.selectFrom("rule_group").select("deleted_at").where("id", "=", wash.id).executeTakeFirstOrThrow())
.deleted_at,
).toBeNull();
const updated = await h.api("PATCH", `/acquisition-slots/${created.body.data.id}`, {
expectedRevision: created.body.data.revision,
upgradeThreshold: { enabled: true, ruleGroupIds: [] },
});
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
expect((await h.api("DELETE", `/rules/${wash.id}`)).status).toBe(200);
expect(
(await h.db.selectFrom("rule_group").select("deleted_at").where("id", "=", wash.id).executeTakeFirstOrThrow())
.deleted_at,
).not.toBeNull();
});
it.each([
{ label: "草案 defaultSlots 补齐", published: false, newTitle: false, wash: false },
{ label: "已发布 defaultSlots 洗版", published: true, newTitle: false, wash: true },
{ label: "草案 newTitle 洗版", published: false, newTitle: true, wash: true },
{ label: "已发布 newTitle 补齐", published: true, newTitle: true, wash: false },
])("$label 引用阻止删除,解除有效策略引用后可删", async ({ published, newTitle, wash }) => {
const referenced = await group(wash ? "REMUX" : "H265", wash ? "upgrade" : "acquisition");
const template = {
name: "引用模板",
...(wash
? { upgradeThreshold: { enabled: true, ruleGroupIds: [referenced.id] } }
: { ruleGroupIds: [referenced.id] }),
};
const rules: AcquisitionPolicyRules = newTitle
? { newTitle: { enabled: true, slots: [template] } }
: { defaultSlots: [template] };
const policy = await draft(rules);
if (published) await publish(policy);
expect((await h.api("DELETE", `/rules/${referenced.id}`)).status).toBe(409);
if (published) {
await publish(await draft({}, policy.policyKey));
expect(
(
await h.db
.selectFrom("policy_revision")
.select("status")
.where("id", "=", policy.id)
.executeTakeFirstOrThrow()
).status,
).toBe("superseded");
} else {
const updated = await h.api("PATCH", `/policy-revisions/${policy.id}`, { rules: {} });
expect(updated.status, JSON.stringify(updated.body)).toBe(200);
}
expect((await h.api("DELETE", `/rules/${referenced.id}`)).status).toBe(200);
});
it.each(["fill", "wash"] as const)("发布拒绝不存在的模板 %s 规则组,旧发布和授权保留", async (phase) => {
const existing = await publish(await draft({}));
const missing = randomUUID();
const rules: AcquisitionPolicyRules =
phase === "fill"
? { defaultSlots: [{ name: "坏补齐引用", ruleGroupIds: [missing] }] }
: {
newTitle: {
enabled: true,
slots: [{ name: "坏洗版引用", upgradeThreshold: { enabled: true, ruleGroupIds: [missing] } }],
},
};
const invalid = await draft(rules, existing.policyKey);
const response = await h.api("POST", `/policy-revisions/${invalid.id}/publish`, {
expectedRevision: invalid.revision,
});
expect(response.status, JSON.stringify(response.body)).toBe(400);
expect(response.body.error.message).toContain("规则组不存在");
expect(response.body.error.message).toContain(missing);
const policies = await h.db
.selectFrom("policy_revision")
.select(["id", "status"])
.where("id", "in", [existing.id, invalid.id])
.execute();
expect(policies).toEqual(
expect.arrayContaining([
{ id: existing.id, status: "published" },
{ id: invalid.id, status: "draft" },
]),
);
expect(
await h.db
.selectFrom("automation_grant")
.select(["status"])
.where("policy_revision_id", "=", existing.id)
.execute(),
).toEqual([{ status: "active" }]);
expect(
await h.db.selectFrom("automation_grant").select("id").where("policy_revision_id", "=", invalid.id).execute(),
).toHaveLength(0);
});
it("电影忽略季级一致性,仍能从已有 WEB 资产生成 REMUX 升级计划", async () => {
const entityId = await seedEntity(h.db, { kind: "movie", title: "Movie Upgrade", year: 2024 });
const subscription = await h.api("POST", "/subscriptions", { mediaEntityId: entityId, mediaKind: "movie" });
expect(subscription.status, JSON.stringify(subscription.body)).toBe(201);
const slot = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("subscription_id", "=", subscription.body.data.id)
.executeTakeFirstOrThrow();
const update = await h.api("PATCH", `/acquisition-slots/${slot.id}`, {
expectedRevision: slot.revision,
hardConditions: { resolutions: ["1080p"] },
upgradeThreshold: { enabled: true, mode: "movie", basis: "tier", consistency: "season_release" },
});
expect(update.status, JSON.stringify(update.body)).toBe(200);
const asset = await h.db
.insertInto("media_asset")
.values({
media_entity_id: entityId,
kind: "video",
size_bytes: 300_000_000,
media_info: JSON.stringify({
_umt: {
quality: {
resolution: "1080p",
source: "WEB-DL",
videoCodec: "H.264",
releaseGroup: { name: "OLD", source: "suffix" },
hdr: [],
},
},
}),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.updateTable("acquisition_slot")
.set({ status: "satisfied", current_asset_id: asset.id })
.where("id", "=", slot.id)
.execute();
const persisted = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", slot.id)
.executeTakeFirstOrThrow();
expect(persisted.upgrade_threshold).toMatchObject({ enabled: true, consistency: "season_release" });
const spec = slotSpecWithRules(
await loadRuleCatalog(h.db),
persisted,
null,
{ id: entityId, kind: "movie" },
base.siteId,
);
expect(spec.upgrade).toMatchObject({ enabled: true, mode: "movie" });
expect(spec.upgrade?.consistency).toBeUndefined();
const resource = await release(entityId, "Movie.Upgrade.2024.1080p.BluRay.REMUX.H264-NEW");
const result = await evaluateAcquisition(h.d, { sourceReleaseId: resource });
expect(result.plans, JSON.stringify(result)).toHaveLength(1);
const plan = await h.db
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", result.plans[0]!)
.executeTakeFirstOrThrow();
expect(plan).toMatchObject({
slot_id: slot.id,
purpose: "upgrade",
evaluation: { automaticReplacement: true, replacesAssetId: asset.id },
});
});
});