feat(ao,sources): W5-C subscription completion, Slot × unit reservation, deferral, pack file selection, active search
- completion by season episode_total ledger (applySlotEvent verify_passed hook overrides organize's
complete): unknown total keeps tracking and records suspected-missing; pending never completes;
once / continuous current-season completion writes subscription_history
- evaluation: missing = release units ∩ episodeRange − covered − in-flight; series slots may hold
several unit-disjoint in-flight plans; overlapping/busy releases are deferred (slot_deferral) and
re-evaluated when a plan ends (next_evaluate_at due) or on the periodic slot re-evaluation
- per-unit upgrade via slot_unit ledger; site scope (params.siteIds); site facts in evaluation context
- season packs: after submit read qB files and setFilePriority(0) for covered/in-flight episodes;
whole pack with recorded reason when undeterminable; removed downloads release their reservation
- searchSubscription task (title/aliases × enabled sites via liveSearch, snapshot + rate limiter):
on create, every searchIntervalHours (default 24h) and POST /subscriptions/{id}/search
- refreshMetadata writes season totals (only grows, floored by covered, lock respected) and turns
metadata_pending subscriptions active; plan loop refreshes tracked series every 6h
- endpoints: subscription progress, episode-total, subscription-history; e9 continuous-series e2e
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1dd460d359
commit
7aa6cac717
14 files changed
+2553
-190
No files matched your search
@@ -0,0 +1,274 @@
|
||||
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 单元冲突 → 延后(slot_deferral),计划结束后重评时已覆盖,不重复下载;
|
||||
// · 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,
|
||||
"另一版本识别",
|
||||
);
|
||||
await waitFor(
|
||||
async () =>
|
||||
e.db
|
||||
.selectFrom("slot_deferral")
|
||||
.selectAll()
|
||||
.where("slot_id", "=", slotId)
|
||||
.where("source_release_id", "=", loserRel.id)
|
||||
.executeTakeFirst(),
|
||||
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("slot_satisfied");
|
||||
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: "slot_satisfied", 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);
|
||||
});
|
||||
@@ -72,7 +72,13 @@ describe.skipIf(!up)("M-Team 快照与任务注册", () => {
|
||||
it("模块注册 4 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => {
|
||||
const reg = new TaskRegistry();
|
||||
h.module.registerTasks(reg, h.ctx);
|
||||
expect(reg.names()).toEqual(["ingestFeed", "refreshMetadata", "resolveObservation", "snapshotSite"]);
|
||||
expect(reg.names()).toEqual([
|
||||
"ingestFeed",
|
||||
"refreshMetadata",
|
||||
"resolveObservation",
|
||||
"searchSubscription",
|
||||
"snapshotSite",
|
||||
]);
|
||||
expect(Object.keys(h.module.outboxRoutes!(h.ctx)).sort()).toEqual([
|
||||
"feed.due",
|
||||
"feed.gap_suspected",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { applySeasonTotals } from "../../src/modules/sources-identity/service/metadata-totals.ts";
|
||||
import { searchSubscription } from "../../src/modules/sources-identity/tasks.ts";
|
||||
import { createHarness, type Harness, json, pgAvailable } from "./harness.ts";
|
||||
|
||||
// W5-C 订阅主动搜索(mock NexusPHP 站点:适配器 search + 限流 + 快照)与季总集数账本(refreshMetadata 的写入部分)。
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe.skipIf(!up)("订阅主动搜索与季总集数", () => {
|
||||
let h: Harness;
|
||||
let mock: MockNexusPhpSite;
|
||||
let siteId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
mock = await createMockNexusPhpSite();
|
||||
h = await createHarness("subsearch");
|
||||
const ok = await json(
|
||||
await h.req("/sites", {
|
||||
body: {
|
||||
key: "audiences",
|
||||
name: "观众",
|
||||
adapter: "nexusphp",
|
||||
baseUrl: `${mock.baseUrl}/`,
|
||||
rateLimit: { minIntervalMs: 0, concurrency: 4 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
siteId = ok.body.data.id;
|
||||
await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: mock.cookie } });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await mock?.close();
|
||||
});
|
||||
|
||||
const entity = async (title: string, kind: "movie" | "series" = "movie") =>
|
||||
(
|
||||
await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind, title, original_title: title, year: 2021, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const subscribe = async (entityId: string, extra: Record<string, unknown> = {}) =>
|
||||
(
|
||||
await h.db
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: entityId, media_kind: "movie", mode: "once", ...extra })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
|
||||
it("按标题在已开启站点搜索:结果 upsert 为 source_release(新结果发 release.observed),排下次搜索并入队订阅重评", async () => {
|
||||
const e = await entity("Dune");
|
||||
const subId = await subscribe(e);
|
||||
const searches = () => mock.requests.filter((r) => r.path.includes("search=")).length;
|
||||
const before = searches();
|
||||
const r = await searchSubscription(h.rt, subId);
|
||||
expect(r).toMatchObject({ status: "ok", keywords: ["Dune"], sites: [siteId], searches: 1, errors: [] });
|
||||
expect(searches() - before).toBe(1);
|
||||
const rels = await h.db
|
||||
.selectFrom("source_release")
|
||||
.select(["id", "title"])
|
||||
.where("site_id", "=", siteId)
|
||||
.execute();
|
||||
expect(rels.some((x) => /Dune/i.test(x.title))).toBe(true);
|
||||
expect((await h.outbox("release.observed")).length).toBeGreaterThanOrEqual(1);
|
||||
const snap = await h.db
|
||||
.selectFrom("site_snapshot")
|
||||
.selectAll()
|
||||
.where("query", "=", "Dune")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(snap.kind).toBe("live_search");
|
||||
const sub = await h.db.selectFrom("subscription").selectAll().where("id", "=", subId).executeTakeFirstOrThrow();
|
||||
expect(sub.last_searched_at).not.toBeNull();
|
||||
expect(sub.next_search_at!.getTime() - sub.last_searched_at!.getTime()).toBe(24 * 3600_000);
|
||||
expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "evaluateAcquisition", input: { subscriptionId: subId } });
|
||||
});
|
||||
|
||||
it("站点范围外不搜;暂停订阅只允许手动;searchIntervalHours=0 关闭周期", async () => {
|
||||
const e = await entity("Bear");
|
||||
const scoped = await subscribe(e, {
|
||||
params: JSON.stringify({ siteIds: ["00000000-0000-4000-8000-000000000000"] }),
|
||||
});
|
||||
expect(await searchSubscription(h.rt, scoped)).toMatchObject({ status: "ok", sites: [], searches: 0 });
|
||||
const paused = await subscribe(e, { status: "paused", params: JSON.stringify({ searchIntervalHours: 0 }) });
|
||||
expect(await searchSubscription(h.rt, paused)).toMatchObject({ status: "skipped", reason: "subscription_paused" });
|
||||
const manual = await searchSubscription(h.rt, paused, { manual: true });
|
||||
expect(manual).toMatchObject({ status: "ok", searches: 1, nextSearchAt: null });
|
||||
});
|
||||
|
||||
it("季总集数:TMDB 集数写季单元(S00 不计、未公布保持未知);回退接受但被已覆盖最高集兜底;锁定不改;pending 订阅转 active", async () => {
|
||||
const e = await entity("Series T", "series");
|
||||
const sub = await subscribe(e, { media_kind: "series", status: "pending", status_reason: "metadata_pending" });
|
||||
const now = new Date();
|
||||
const r1 = await h.db.transaction().execute((trx) =>
|
||||
applySeasonTotals(
|
||||
trx,
|
||||
e,
|
||||
[
|
||||
{ seasonNumber: 0, episodeCount: 3, name: "特别篇", airDate: null },
|
||||
{ seasonNumber: 1, episodeCount: 8, name: "第 1 季", airDate: null },
|
||||
{ seasonNumber: 2, episodeCount: 0, name: "第 2 季", airDate: null },
|
||||
],
|
||||
now,
|
||||
),
|
||||
);
|
||||
expect(r1.seasons).toEqual([
|
||||
{ season: 1, total: 8, known: true, changed: true },
|
||||
{ season: 2, total: null, known: false, changed: false },
|
||||
]);
|
||||
expect(r1.activated).toBe(1);
|
||||
expect(
|
||||
(await h.db.selectFrom("subscription").select("status").where("id", "=", sub).executeTakeFirstOrThrow()).status,
|
||||
).toBe("active");
|
||||
const r2 = await h.db
|
||||
.transaction()
|
||||
.execute((trx) =>
|
||||
applySeasonTotals(trx, e, [{ seasonNumber: 1, episodeCount: 12, name: null, airDate: null }], now),
|
||||
);
|
||||
expect(r2.seasons[0]).toMatchObject({ total: 12, changed: true });
|
||||
await h.db
|
||||
.updateTable("content_unit")
|
||||
.set({ episode_total_locked: true })
|
||||
.where("media_entity_id", "=", e)
|
||||
.where("season_number", "=", 1)
|
||||
.execute();
|
||||
const r3 = await h.db
|
||||
.transaction()
|
||||
.execute((trx) =>
|
||||
applySeasonTotals(trx, e, [{ seasonNumber: 1, episodeCount: 20, name: null, airDate: null }], now),
|
||||
);
|
||||
expect(r3.seasons[0]).toMatchObject({ total: 12, changed: false });
|
||||
const units = await h.db
|
||||
.selectFrom("content_unit")
|
||||
.select(["kind", "season_number", "episode_total", "episode_total_source"])
|
||||
.where("media_entity_id", "=", e)
|
||||
.orderBy("season_number")
|
||||
.execute();
|
||||
// 只建季单元,不预建集单元
|
||||
expect(units).toEqual([
|
||||
{ kind: "season", season_number: 1, episode_total: 12, episode_total_source: "tmdb" },
|
||||
{ kind: "season", season_number: 2, episode_total: null, episode_total_source: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user