335 lines
13 KiB
TypeScript
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")),
|
|
);
|
|
});
|
|
});
|