- H1:新建 / 编辑订阅时没有自己的收录版本就按策略模板建,没有模板建隐式「默认版本」(质量预设随后写入); 旧订阅留下的同名版本改名加序号;已有的 0 收录版本订阅在首次评估 / 订阅重评时补建 - H2:收录版本有升级目标(规则组 / 阶梯按 pri 洗版)时,订阅完成要求覆盖齐且每个已覆盖单元到目标 pri; slotComplete 多返回 atTarget,接收即计入与入库后两处完成判定统一用 completesOnCoverage + slotDone - H4:「接续后续季」评估只看不早于订阅季的资源,与覆盖矩阵同口径(seasonInScope) - M4:按收录版本 / 订阅重评时先用 rankForSlot 按主收录版本择优排序再逐条预留;订阅重评只评该订阅的收录版本 - M5:重评取最近 500 条(原 50 条),先按订阅季范围、已覆盖单元在内存里过滤再逐条评估 - M6:同作品手动下载中的计划(slot_id 为空)并入在途单元,冲突延后时排退避重评 - 资源解析 / 站点上下文挪到 release-facts.ts,评估与完成判定共用,避免循环引用 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
|
import { ensureSeasonUnit } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
|
|
import {
|
|
createHarness,
|
|
fakeDisk,
|
|
type Harness,
|
|
pgAvailable,
|
|
seedBase,
|
|
seedEntity,
|
|
seedRelease,
|
|
} from "../acquisition-organize/harness.ts";
|
|
|
|
// 获取正确性(T3):订阅总有自己的收录版本(H1)、接续后续季的季下界(H4)、重评择优(M4)、
|
|
// 重评不止最近 50 条(M5)、手动下载中的集计入在途(M6)。只到建计划为止(假引擎,不提交下载器)。
|
|
|
|
const hasPg = await pgAvailable();
|
|
const SIZE = 300_000;
|
|
|
|
describe.skipIf(!hasPg)("订阅范围与重评(真实 PG)", () => {
|
|
let h: Harness;
|
|
const disk = fakeDisk(10_000_000_000);
|
|
let base: Awaited<ReturnType<typeof seedBase>>;
|
|
|
|
beforeAll(async () => {
|
|
disk.setPct(10);
|
|
h = await createHarness("subscope", { diskUsage: disk.fn });
|
|
base = await seedBase(h.db, {
|
|
libraryPath: "/tmp/nonexistent-lib",
|
|
cachePath: "/tmp",
|
|
qbUrl: "http://127.0.0.1:9",
|
|
});
|
|
// 计划不提交,预留会一直占并发名额:放宽并发上限
|
|
await h.setSettings("budget", { maxConcurrentDownloads: 100 });
|
|
}, 60_000);
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
});
|
|
|
|
const db = () => h.db;
|
|
let seq = 0;
|
|
const hash = () => (++seq).toString(16).padStart(40, "a");
|
|
async function release(entityId: string, title: string, firstSeen?: Date) {
|
|
const id = await seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: hash() });
|
|
if (firstSeen)
|
|
await db().updateTable("source_release").set({ first_seen_at: firstSeen }).where("id", "=", id).execute();
|
|
return id;
|
|
}
|
|
const slotsOf = (subId: string) =>
|
|
db().selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute();
|
|
const planRow = (id: string) =>
|
|
db().selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
|
async function seasons(entityId: string, n: number) {
|
|
const ids: string[] = [];
|
|
for (let s = 1; s <= n; s++)
|
|
ids.push(
|
|
await db()
|
|
.transaction()
|
|
.execute((trx) => ensureSeasonUnit(trx, entityId, s)),
|
|
);
|
|
return ids;
|
|
}
|
|
async function createSub(body: Record<string, unknown>) {
|
|
const r = await h.api("POST", "/subscriptions", body);
|
|
expect(r.status, JSON.stringify(r.body)).toBe(201);
|
|
return r.body.data.id as string;
|
|
}
|
|
|
|
it("H1:无策略模板、没填质量预设的订阅也有自己的默认收录版本,能下载", async () => {
|
|
const e = await seedEntity(db(), { kind: "series", title: "无预设剧", year: 2025 });
|
|
const subId = await createSub({ mediaEntityId: e, mediaKind: "series" });
|
|
const slots = await slotsOf(subId);
|
|
expect(slots).toHaveLength(1);
|
|
expect(slots[0]).toMatchObject({ name: "默认版本", hard_conditions: {} });
|
|
const r = await release(e, "No.Preset.S01E01.1080p.WEB-DL.x264-GRP");
|
|
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: r });
|
|
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
|
expect((await planRow(ev.plans[0]!)).slot_id).toBe(slots[0]!.id);
|
|
});
|
|
|
|
it("H1:S1 旧订阅已有收录版本时再订 S2,S2 订阅建自己的收录版本(重名加序号)并能下载", async () => {
|
|
const e = await seedEntity(db(), { kind: "series", title: "两季剧", year: 2024 });
|
|
const [s1, s2] = await seasons(e, 2);
|
|
const sub1 = await createSub({
|
|
mediaEntityId: e,
|
|
mediaKind: "series",
|
|
seasonUnitId: s1,
|
|
seasonScope: "current_season",
|
|
params: { resolution: "1080p" },
|
|
});
|
|
expect(await slotsOf(sub1)).toHaveLength(1);
|
|
await db().updateTable("subscription").set({ status: "completed" }).where("id", "=", sub1).execute();
|
|
const sub2 = await createSub({
|
|
mediaEntityId: e,
|
|
mediaKind: "series",
|
|
seasonUnitId: s2,
|
|
seasonScope: "current_season",
|
|
params: { resolution: "1080p" },
|
|
});
|
|
const own = await slotsOf(sub2);
|
|
expect(own).toHaveLength(1);
|
|
expect(own[0]!.name).toBe("默认版本 2");
|
|
// 质量预设写进了 S2 订阅自己的收录版本
|
|
expect(own[0]!.hard_conditions).toMatchObject({ resolutions: ["1080p", "1080i"] });
|
|
const r = await release(e, "Two.Seasons.S02E01.1080p.WEB-DL.x264-GRP");
|
|
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: r });
|
|
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
|
expect((await planRow(ev.plans[0]!)).slot_id).toBe(own[0]!.id);
|
|
});
|
|
|
|
it("H1:已有的 0 收录版本订阅首次评估时补建;编辑时也补建", async () => {
|
|
const e = await seedEntity(db(), { kind: "movie", title: "Legacy Film", year: 2023 });
|
|
const sub = await db()
|
|
.insertInto("subscription")
|
|
.values({ media_entity_id: e, media_kind: "movie", mode: "once", season_scope: "current_season" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
expect(await slotsOf(sub.id)).toHaveLength(0);
|
|
const r = await release(e, "Legacy.Film.2023.1080p.WEB-DL.x264-GRP");
|
|
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: r });
|
|
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
|
expect(await slotsOf(sub.id)).toHaveLength(1);
|
|
|
|
const e2 = await seedEntity(db(), { kind: "movie", title: "Legacy Film 2", year: 2023 });
|
|
const sub2 = await db()
|
|
.insertInto("subscription")
|
|
.values({ media_entity_id: e2, media_kind: "movie", mode: "once", season_scope: "current_season" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
const p = await h.api("PATCH", `/subscriptions/${sub2.id}`, { params: { resolution: "2160p" } });
|
|
expect(p.status, JSON.stringify(p.body)).toBe(200);
|
|
const [slot] = await slotsOf(sub2.id);
|
|
expect(slot?.hard_conditions).toMatchObject({ resolutions: ["2160p"] });
|
|
});
|
|
|
|
it("H4:从 S3 订「接续后续季」不下 S1 / S2,S3 及以后照常", async () => {
|
|
const e = await seedEntity(db(), { kind: "series", title: "三季剧", year: 2020 });
|
|
const [, , s3] = await seasons(e, 4);
|
|
const subId = await createSub({
|
|
mediaEntityId: e,
|
|
mediaKind: "series",
|
|
seasonUnitId: s3,
|
|
seasonScope: "following_seasons",
|
|
params: { resolution: "1080p" },
|
|
});
|
|
const planned = async (title: string) =>
|
|
(await evaluateAcquisition(h.d, { sourceReleaseId: await release(e, title) })).plans.length;
|
|
expect(await planned("Three.Seasons.S01E01.1080p.WEB-DL.x264-GRP")).toBe(0);
|
|
expect(await planned("Three.Seasons.S02E05.1080p.WEB-DL.x264-GRP")).toBe(0);
|
|
expect(await planned("Three.Seasons.S02.1080p.WEB-DL.x264-GRP")).toBe(0);
|
|
expect(await planned("Three.Seasons.S03E01.1080p.WEB-DL.x264-GRP")).toBe(1);
|
|
expect(await planned("Three.Seasons.S04E01.1080p.WEB-DL.x264-GRP")).toBe(1);
|
|
// 订阅重评同口径
|
|
const re = await evaluateAcquisition(h.d, { subscriptionId: subId });
|
|
expect(re.plans).toHaveLength(0);
|
|
});
|
|
|
|
it("M4:订阅重评时同一集多个合格资源选 pri 最高的,不是最新的", async () => {
|
|
const e = await seedEntity(db(), { kind: "series", title: "择优剧", year: 2024 });
|
|
const g = await h.api("POST", "/rules", { name: "择优阶梯", ruleString: "REMUX > WEBDL" });
|
|
expect(g.status, JSON.stringify(g.body)).toBe(201);
|
|
const t0 = Date.now();
|
|
const remux = await release(e, "Pick.Best.S01E01.1080p.BluRay.REMUX.AVC-GRP", new Date(t0 - 3_600_000));
|
|
await release(e, "Pick.Best.S01E01.1080p.WEB-DL.x264-NEW", new Date(t0 - 60_000));
|
|
// 订阅建好后 kickEvaluate 只入队(假引擎),这里直接跑订阅重评
|
|
const subId = await createSub({
|
|
mediaEntityId: e,
|
|
mediaKind: "series",
|
|
params: { filter_groups: [g.body.data.id], resolution: "1080p" },
|
|
});
|
|
const ev = await evaluateAcquisition(h.d, { subscriptionId: subId });
|
|
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
|
expect(await planRow(ev.plans[0]!)).toMatchObject({ source_release_id: remux, unit_keys: ["s1e1"] });
|
|
expect(ev.deferred.map((x) => x.reason)).toEqual(["units_inflight"]);
|
|
});
|
|
|
|
it("M5:超过 50 条较新的资源之后,更早的集仍能被重评选中", async () => {
|
|
const e = await seedEntity(db(), { kind: "series", title: "长剧", year: 2018 });
|
|
const subId = await createSub({ mediaEntityId: e, mediaKind: "series", params: { resolution: "1080p" } });
|
|
const t0 = Date.now();
|
|
const old = await release(e, "Long.Show.S01E01.1080p.WEB-DL.x264-GRP", new Date(t0 - 86_400_000));
|
|
// 55 条更新的 720p 资源(不合格)把最近 50 条占满
|
|
for (let i = 2; i <= 56; i++)
|
|
await release(e, `Long.Show.S01E${String(i).padStart(2, "0")}.720p.WEB-DL.x264-GRP`, new Date(t0 - i * 1000));
|
|
const ev = await evaluateAcquisition(h.d, { subscriptionId: subId });
|
|
expect(ev.plans, JSON.stringify(ev.skipped.slice(0, 3))).toHaveLength(1);
|
|
expect(await planRow(ev.plans[0]!)).toMatchObject({ source_release_id: old, unit_keys: ["s1e1"] });
|
|
}, 60_000);
|
|
|
|
it("M6:手动下载中的集,订阅不再用别的资源重复下;没在手动下载的集照常", async () => {
|
|
const e = await seedEntity(db(), { kind: "series", title: "手动剧", year: 2024 });
|
|
const subId = await createSub({ mediaEntityId: e, mediaKind: "series", params: { resolution: "1080p" } });
|
|
const [slot] = await slotsOf(subId);
|
|
const manualRel = await release(e, "Manual.Show.S01E01.1080p.WEB-DL.x264-USER");
|
|
await db()
|
|
.insertInto("acquisition_plan")
|
|
.values({
|
|
slot_id: null,
|
|
source_release_id: manualRel,
|
|
status: "submitted",
|
|
priority: "immediate",
|
|
evaluation: JSON.stringify({ manual: true, mediaEntityId: e }),
|
|
payload_hash: "manual-test",
|
|
idempotency_key: `manual:test:${manualRel}`,
|
|
status_reason: "manual_selection",
|
|
})
|
|
.execute();
|
|
const other = await release(e, "Manual.Show.S01E01.1080p.WEB-DL.x265-OTHER");
|
|
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: other });
|
|
expect(ev.plans).toHaveLength(0);
|
|
expect(ev.deferred).toEqual([{ sourceReleaseId: other, slotId: slot!.id, reason: "units_inflight" }]);
|
|
// 手动计划结束不会触发重评:延后时排了退避重评
|
|
const s = await db()
|
|
.selectFrom("acquisition_slot")
|
|
.select("next_evaluate_at")
|
|
.where("id", "=", slot!.id)
|
|
.executeTakeFirstOrThrow();
|
|
expect(s.next_evaluate_at).not.toBeNull();
|
|
const e2 = await release(e, "Manual.Show.S01E02.1080p.WEB-DL.x264-OTHER");
|
|
expect((await evaluateAcquisition(h.d, { sourceReleaseId: e2 })).plans).toHaveLength(1);
|
|
});
|
|
});
|