merge: fix/f-acquisition

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 23:58:00 +08:00
commit 1aebb519ef
10 files changed
+1033 -192

No files matched your search

@@ -84,7 +84,8 @@ describe.skipIf(!hasPg)("规则 API(真实 PG)", () => {
});
expect(ok.body.data).toMatchObject({ revision: 2, softPreferences: { hdr: ["DV"] } });
const list = await h.api("GET", `/acquisition-slots?mediaEntityId=${e}`);
expect(list.body.data.items).toHaveLength(1);
// 新建订阅时已自动建了该订阅自己的「默认版本」
expect(list.body.data.items.map((x: { name: string }) => x.name).sort()).toEqual(["1080p", "默认版本"].sort());
expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true);
});
@@ -0,0 +1,155 @@
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";
// 洗版完成口径(H2):收录版本有升级目标(规则组阶梯按 pri 洗版)时,覆盖齐还不够,要到最高档才完成订阅。
// 电影洗版、按集洗版、全集洗版各一例;下载器接收即计入(afterDownloadAccepted)与入库后(afterSlotVerified)同口径。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("洗版到最高档才完成(真实 PG + mock qB + 临时目录)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
const disk = fakeDisk(10_000_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
let groupId: string;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
cache = await createTempTree({}, { prefix: "umt-cache-" });
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("upgradedone", {
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 });
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();
});
const db = () => h.db;
async function washSub(kind: "movie" | "series", title: string, mode: "movie" | "per_unit" | "full_pack") {
const entityId = await seedEntity(db(), { kind, title, year: 2024 });
const sub = await db()
.insertInto("subscription")
.values({ media_entity_id: entityId, media_kind: kind, mode: "once", season_scope: "current_season" })
.returning("id")
.executeTakeFirstOrThrow();
await db()
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: sub.id,
name: "洗版",
upgrade_threshold: JSON.stringify({ enabled: true, mode }),
rule_group_ids: JSON.stringify([groupId]),
})
.execute();
return { entityId, subId: sub.id };
}
async function release(entityId: string, title: string, files?: { path: string; length: number }[]) {
const t = files
? makeTorrent({ name: title, files, pieceSeed: Math.floor(Math.random() * 1e6) })
: makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
const id = await seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
torrents.set(id, t.bytes);
return { id, hash: t.hash };
}
const subRow = (id: string) =>
db().selectFrom("subscription").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const planRow = (id: string) =>
db().selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
async function planFor(relId: string) {
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
return ev.plans[0]!;
}
async function submit(planId: string) {
const s = await submitDownload(h.d, planId);
expect(s.status).toBe("submitted");
return s.downloadTaskId!;
}
async function completeAndImport(hash: string, dtId: string) {
await qb.complete(hash, { writeFiles: true });
const sync = await syncDownloads(h.d);
expect(sync.completed).toContain(dtId);
const p = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(p.status, JSON.stringify(p)).toBe("running");
const ex = await executeRecord(h.d, p.recordId!);
expect(ex.status).toBe("succeeded");
}
it("电影洗版:WEB-DL(pri 99)接收、入库都不完成;REMUX(pri 100)接收即完成", async () => {
const { entityId, subId } = await washSub("movie", "Wash Film", "movie");
const web = await release(entityId, "Wash.Film.2024.1080p.WEB-DL.x264-GRP");
const webTask = await submit(await planFor(web.id));
expect(await subRow(subId)).toMatchObject({ status: "active" });
await completeAndImport(web.hash, webTask);
expect(await subRow(subId)).toMatchObject({ status: "active" });
const remux = await release(entityId, "Wash.Film.2024.1080p.BluRay.REMUX.AVC-GRP");
const up = await planFor(remux.id);
expect(await planRow(up)).toMatchObject({ purpose: "upgrade" });
await submit(up);
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
}, 60_000);
it("按集洗版:全集已覆盖但不是最高档时不完成;逐集洗到 REMUX 后完成", async () => {
const { entityId, subId } = await washSub("series", "Wash Episodes", "per_unit");
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
const web = await release(entityId, "Wash.Episodes.S01E01.1080p.WEB-DL.x264-GRP");
const webTask = await submit(await planFor(web.id));
expect(await subRow(subId)).toMatchObject({ status: "active" });
await completeAndImport(web.hash, webTask);
expect(await subRow(subId)).toMatchObject({ status: "active" });
const remux = await release(entityId, "Wash.Episodes.S01E01.1080p.BluRay.REMUX.AVC-GRP");
const up = await planFor(remux.id);
expect(await planRow(up)).toMatchObject({ purpose: "upgrade", unit_keys: ["s1e1"] });
await submit(up);
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
}, 60_000);
it("全集洗版:WEB-DL 季包覆盖全季不完成;单集 REMUX 不接;REMUX 季包后完成", async () => {
const { entityId, subId } = await washSub("series", "Wash Pack", "full_pack");
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 2, now: new Date() }));
const files = (tag: string) => [1, 2].map((e) => ({ path: `Wash.Pack.S01E0${e}.1080p.${tag}.mkv`, length: SIZE }));
const web = await release(entityId, "Wash.Pack.S01.1080p.WEB-DL.x264-GRP", files("WEB-DL.x264-GRP"));
const webPlan = await planFor(web.id);
expect((await planRow(webPlan)).unit_keys).toEqual(["s1e1", "s1e2"]);
const webTask = await submit(webPlan);
expect(await subRow(subId)).toMatchObject({ status: "active" });
await completeAndImport(web.hash, webTask);
expect(await subRow(subId)).toMatchObject({ status: "active" });
const single = await release(entityId, "Wash.Pack.S01E01.1080p.BluRay.REMUX.AVC-GRP");
expect((await evaluateAcquisition(h.d, { sourceReleaseId: single.id })).plans).toHaveLength(0);
const remux = await release(entityId, "Wash.Pack.S01.1080p.BluRay.REMUX.AVC-GRP", files("BluRay.REMUX.AVC-GRP"));
const up = await planFor(remux.id);
expect(await planRow(up)).toMatchObject({ purpose: "upgrade" });
await submit(up);
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
}, 60_000);
});
@@ -0,0 +1,222 @@
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);
});
});