- 自动化开关 / 下载器 / 预算 / 缓存卷挡住时,除候选记录(改为 deferred)外写 slot_deferral, 原因区分 gate:automation / gate:downloader / gate:budget / gate:cache,并排该收录版本重评; 电影收录版本与已满足的剧集收录版本也会被重评(scheduleSlotRetry,不推迟已排得更早的)。 - 重评时间按延后次数退避:30 分钟起翻倍、封顶 6 小时;预算释放 / 缓存卷恢复没有统一事件,不做事件触发。 - 并发预算:stalled 任务不占名额;已预留计划只算还没建下载任务的,不与任务重复计。 - 延后行清理:资源过期 / 不可评估 / 集数待确认时清该资源的延后;订阅完成(含 stopAfter)清其收录版本的延后; 订阅暂停 / 取消后重评跳过时清对应延后。 - continuous-series e2e:三集被接收后 once 订阅即完成、延后行随之清掉,改以候选评估记录断言"另一版本延后"。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import {
|
|
createMockRssServer,
|
|
type MockFeedItem,
|
|
type MockRssServer,
|
|
TMDB_FIXTURES,
|
|
type TmdbFixture,
|
|
} from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { TORRENT_SIZE, torrentFor } from "./fixtures/modules.ts";
|
|
import { completeDownload, createE2E, type E2E, pgAvailable, seedBase, waitFor } from "./harness.ts";
|
|
|
|
// E9 连续更新剧集(W5-C,对照 MP 缺集 − note 与批量下载):全部经真实任务引擎 + outbox 中继。
|
|
// 季共 3 集(mock TMDB),新作品策略按 once 订阅。E01 还在下载(Slot 忙)时 E02、E03 与 E02 的另一个版本先后到达:
|
|
// · E02/E03 与在途的 E01 单元不相交 → 各自得到计划并同时下载(Slot×单元预留),不再因 slot_busy 丢集;
|
|
// · E02 另一版本与在途 E02 单元冲突 → 延后(候选评估 deferred;订阅接收即完成后延后行清掉),不重复下载;
|
|
// · refreshMetadata(编排循环按周期入队)把 TMDB 季集数写进季单元 → 三集全部入库后 once 订阅完成并写历史。
|
|
|
|
const hasPg = await pgAvailable();
|
|
const BASE = "https://audiences.example";
|
|
const EP = (n: number, grp = "x264-GRP") => `Three.Eps.S01E0${n}.1080p.WEB-DL.${grp}`;
|
|
const ALT = EP(2, "x265-ALT");
|
|
|
|
const SHOW: TmdbFixture = {
|
|
type: "tv",
|
|
id: 990001,
|
|
original_title: "Three Eps",
|
|
original_language: "en",
|
|
origin_country: ["US"],
|
|
date: "2026-09-01",
|
|
titles: { "zh-CN": "三集剧", "en-US": "Three Eps" },
|
|
popularity: 50,
|
|
alternative_titles: [],
|
|
external_ids: {},
|
|
seasons: [
|
|
{
|
|
season_number: 1,
|
|
air_date: "2026-09-01",
|
|
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
|
|
episodes: [1, 2, 3].map((i) => ({
|
|
episode_number: i,
|
|
air_date: `2026-09-0${i}`,
|
|
names: { "zh-CN": `第 ${i} 集`, "en-US": `Episode ${i}` },
|
|
})),
|
|
},
|
|
],
|
|
};
|
|
|
|
const item = (id: number, title: string): MockFeedItem => ({
|
|
guid: `aud-${id}`,
|
|
title,
|
|
link: `${BASE}/details.php?id=${id}&hit=1`,
|
|
enclosureUrl: `${BASE}/download.php?id=${id}&passkey=E9PASSKEY`,
|
|
size: TORRENT_SIZE,
|
|
pubDate: new Date(),
|
|
categories: ["剧集"],
|
|
});
|
|
|
|
describe.skipIf(!hasPg)("E9 连续更新剧集:Slot 忙时到达的集全部入库", () => {
|
|
let e: E2E;
|
|
let rss: MockRssServer;
|
|
let feedId: string;
|
|
let entityId: string;
|
|
let slotId: string;
|
|
let subId: string;
|
|
|
|
let lastSlot = -1;
|
|
const fetchFeed = async () => {
|
|
const slotOf = () => Math.floor(Date.now() / 10_000);
|
|
if (slotOf() === lastSlot) await new Promise((r) => setTimeout(r, (lastSlot + 1) * 10_000 - Date.now() + 50));
|
|
lastSlot = slotOf();
|
|
const r = await e.api("POST", `/feed-sources/${feedId}/fetch`);
|
|
expect(r.status).toBe(200);
|
|
};
|
|
const dtOf = (title: string) =>
|
|
e.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", torrentFor(title).hash).executeTakeFirst();
|
|
const importedFor = async (title: string) => {
|
|
const dt = await waitFor(async () => dtOf(title), 60_000, `${title} download_task`);
|
|
return waitFor(
|
|
async () =>
|
|
e.db
|
|
.selectFrom("organization_record")
|
|
.selectAll()
|
|
.where("download_task_id", "=", dt.id)
|
|
.where("status", "in", ["succeeded", "failed", "partial"])
|
|
.executeTakeFirst(),
|
|
90_000,
|
|
`${title} 整理记录结束`,
|
|
);
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
e = await createE2E("e9", { tmdbFixtures: [...TMDB_FIXTURES, SHOW] });
|
|
const base = await seedBase(e, { baseUrl: `${BASE}/` });
|
|
rss = await createMockRssServer({ passkey: "E9PASSKEY", items: [item(930001, EP(1))] });
|
|
const urlRef = await e.booted.ctx.secrets.create({ purpose: "feed_url", plaintext: rss.feedUrl });
|
|
feedId = (
|
|
await e.db
|
|
.insertInto("feed_source")
|
|
.values({
|
|
site_id: base.siteId,
|
|
name: "观众-剧集",
|
|
purpose: "series",
|
|
url_secret_ref: urlRef,
|
|
url_display: rss.feedUrl.replace(/passkey=[^&]+/, "passkey=[REDACTED]"),
|
|
poll_interval_sec: 3600,
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
const created = await e.api("POST", "/policy-revisions", {
|
|
policyKey: "series",
|
|
kind: "acquisition",
|
|
title: "剧集 1080p",
|
|
scope: { mediaKinds: ["series"] },
|
|
rules: {
|
|
newTitle: {
|
|
enabled: true,
|
|
windowDays: 3650,
|
|
mediaKinds: ["series"],
|
|
slots: [{ name: "1080p", hardConditions: { resolutions: ["1080p"] } }],
|
|
},
|
|
},
|
|
});
|
|
expect(created.status).toBe(201);
|
|
const pub = await e.api("POST", `/policy-revisions/${created.body.data.id}/publish`, {
|
|
expectedRevision: created.body.data.revision,
|
|
});
|
|
expect(pub.status).toBe(200);
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await rss?.close();
|
|
await e?.close();
|
|
}, 60_000);
|
|
|
|
it("E01 下载中;TMDB 季集数写入季单元(总集数 3)", async () => {
|
|
await fetchFeed();
|
|
const rel = await waitFor(
|
|
async () =>
|
|
e.db
|
|
.selectFrom("source_release")
|
|
.selectAll()
|
|
.where("title", "=", EP(1))
|
|
.where("resolution_status", "=", "resolved")
|
|
.executeTakeFirst(),
|
|
60_000,
|
|
"E01 识别完成",
|
|
);
|
|
entityId = rel.media_entity_id!;
|
|
const sub = await waitFor(
|
|
async () =>
|
|
e.db.selectFrom("subscription").selectAll().where("media_entity_id", "=", entityId).executeTakeFirst(),
|
|
30_000,
|
|
"新作品订阅",
|
|
);
|
|
subId = sub.id;
|
|
expect(sub).toMatchObject({ mode: "once", status: "active" });
|
|
slotId = (
|
|
await e.db
|
|
.selectFrom("acquisition_slot")
|
|
.select("id")
|
|
.where("media_entity_id", "=", entityId)
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
await waitFor(async () => e.qb.get(torrentFor(EP(1)).hash), 60_000, "qB 收到 E01");
|
|
const season = await waitFor(
|
|
async () =>
|
|
e.db
|
|
.selectFrom("content_unit")
|
|
.select(["episode_total", "episode_total_source"])
|
|
.where("media_entity_id", "=", entityId)
|
|
.where("kind", "=", "season")
|
|
.where("episode_total", "is not", null)
|
|
.executeTakeFirst(),
|
|
60_000,
|
|
"季总集数刷新",
|
|
);
|
|
expect(season).toEqual({ episode_total: 3, episode_total_source: "tmdb" });
|
|
}, 180_000);
|
|
|
|
it("Slot 忙时 E02、E03 与 E02 另一版本到达:E02/E03 同时在途,另一版本延后", async () => {
|
|
rss.addItem(item(930002, EP(2)));
|
|
rss.addItem(item(930003, EP(3)));
|
|
rss.addItem(item(930004, ALT));
|
|
await fetchFeed();
|
|
// E02(任一版本)与 E03 都进了 qB;E01 仍未完成 → 三个在途计划并存
|
|
await waitFor(async () => e.qb.get(torrentFor(EP(3)).hash), 90_000, "qB 收到 E03");
|
|
const e02 = await waitFor(
|
|
async () => e.qb.get(torrentFor(EP(2)).hash) ?? e.qb.get(torrentFor(ALT).hash),
|
|
60_000,
|
|
"qB 收到 E02",
|
|
);
|
|
const inflight = await e.db
|
|
.selectFrom("acquisition_plan")
|
|
.select(["unit_keys", "status"])
|
|
.where("slot_id", "=", slotId)
|
|
.where("status", "in", ["reserved", "submitted"])
|
|
.execute();
|
|
expect(inflight.map((p) => p.unit_keys[0]).sort()).toEqual(["s1e1", "s1e2", "s1e3"]);
|
|
const slot = await e.db
|
|
.selectFrom("acquisition_slot")
|
|
.selectAll()
|
|
.where("id", "=", slotId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(slot.status).toBe("downloading");
|
|
// 另一版本 E02:延后而不是丢弃
|
|
const loser = e02.name.includes("x265-ALT") ? EP(2) : ALT;
|
|
const loserRel = await waitFor(
|
|
async () =>
|
|
e.db
|
|
.selectFrom("source_release")
|
|
.select("id")
|
|
.where("title", "=", loser)
|
|
.where("resolution_status", "=", "resolved")
|
|
.executeTakeFirst(),
|
|
60_000,
|
|
"另一版本识别",
|
|
);
|
|
// 三集都被下载器接收后 once 订阅即完成,完成时延后行随之清掉;以候选评估记录为准
|
|
await waitFor(
|
|
async () =>
|
|
(
|
|
await e.db
|
|
.selectFrom("artifact")
|
|
.select("content")
|
|
.where("title", "=", "candidate_evaluation")
|
|
.where("subject_id", "=", slotId)
|
|
.execute()
|
|
).find((r) => {
|
|
const c = r.content as { sourceReleaseId: string; decision: string; reasons: string[] };
|
|
return (
|
|
c.sourceReleaseId === loserRel.id &&
|
|
c.decision === "deferred" &&
|
|
c.reasons.includes("deferred:units_inflight")
|
|
);
|
|
}),
|
|
60_000,
|
|
"另一版本延后",
|
|
);
|
|
}, 180_000);
|
|
|
|
it("三集依次完成全部入库;once 订阅完成并写历史;延后的版本重评后不重复下载", async () => {
|
|
const e02Title = e.qb.get(torrentFor(EP(2)).hash) ? EP(2) : ALT;
|
|
for (const t of [EP(3), e02Title, EP(1)]) {
|
|
await completeDownload(e, t);
|
|
const rec = await importedFor(t);
|
|
expect(rec.status).toBe("succeeded");
|
|
}
|
|
const cov = await e.db
|
|
.selectFrom("asset_unit_coverage as c")
|
|
.innerJoin("content_unit as u", "u.id", "c.content_unit_id")
|
|
.innerJoin("media_asset as a", "a.id", "c.media_asset_id")
|
|
.select(["u.episode_number"])
|
|
.where("a.media_entity_id", "=", entityId)
|
|
.where("a.status", "=", "active")
|
|
.orderBy("u.episode_number")
|
|
.execute();
|
|
expect(cov.map((c) => c.episode_number)).toEqual([1, 2, 3]);
|
|
const sub = await waitFor(
|
|
async () => {
|
|
const s = await e.db.selectFrom("subscription").selectAll().where("id", "=", subId).executeTakeFirstOrThrow();
|
|
return s.status === "completed" ? s : null;
|
|
},
|
|
30_000,
|
|
"订阅完成",
|
|
);
|
|
expect(sub.status_reason).toBe("download_accepted");
|
|
const slot = await e.db
|
|
.selectFrom("acquisition_slot")
|
|
.selectAll()
|
|
.where("id", "=", slotId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(slot.status).toBe("satisfied");
|
|
const hist = await e.api("GET", `/subscription-history?subscriptionId=${subId}`);
|
|
expect(hist.body.data.items).toHaveLength(1);
|
|
expect(hist.body.data.items[0]).toMatchObject({ reason: "download_accepted", mediaEntityId: entityId });
|
|
// 进度 API:三集已覆盖,总集数已知
|
|
const prog = await e.api("GET", `/subscriptions/${subId}/progress`);
|
|
expect(prog.body.data.seasons[0]).toMatchObject({ episodeTotal: 3, totalKnown: true, complete: true });
|
|
// 延后的另一版本:计划结束后重评,E02 已覆盖 → 不下载;三个种子各提交一次
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
expect(e.qb.callsTo("torrents/add")).toHaveLength(3);
|
|
}, 300_000);
|
|
});
|