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