refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries
This commit is contained in:
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user