Files
UHD_Media_Tool/apps/server/test/acquisition-organize/collection-strategy.test.ts
T

335 lines
13 KiB
TypeScript

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")),
);
});
});