import type { DB } from "@umt/db"; import { makeTorrent } from "@umt/testkit"; import type { Kysely } from "kysely"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease, } from "../acquisition-organize/harness.ts"; import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts"; // W9-E 订阅重做后端:总览分组/排序/计数、覆盖矩阵(单集/季包/多版本/在途/临时单元/电影版本)、 // 多版本自动补全(开关、规则组继承、补全预算、手动补全)、表单字段与 MP/旧字段兼容、暂停/恢复/重新订阅/批量/排序。 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>; const db = (): Kysely => h.db; beforeAll(async () => { disk.setPct(10); h = await createHarness("subsv2", { diskUsage: disk.fn }); base = await seedBase(h.db, { libraryPath: "/tmp/umt-nonexistent-lib", cachePath: "/tmp", qbUrl: "http://127.0.0.1:9", }); }, 60_000); afterAll(async () => { await h?.close(); }); // ── 种子 ── async function sub( entityId: string | null, o: { kind?: "movie" | "series"; status?: "active" | "paused" | "pending" | "completed" | "cancelled"; params?: object; mode?: "once" | "continuous"; lastProgressAt?: Date; } = {}, ) { return ( await db() .insertInto("subscription") .values({ media_entity_id: entityId, title_query: entityId ? null : "未建档", media_kind: o.kind ?? "series", mode: o.mode ?? "continuous", status: o.status ?? "active", params: JSON.stringify(o.params ?? {}), last_progress_at: o.lastProgressAt ?? null, }) .returning("id") .executeTakeFirstOrThrow() ).id; } async function slot( entityId: string, subId: string, name: string, o: { position?: number; hard?: object; upgrade?: object; status?: string; currentAssetId?: string } = {}, ) { return ( await db() .insertInto("acquisition_slot") .values({ media_entity_id: entityId, subscription_id: subId, name, position: o.position ?? 0, hard_conditions: JSON.stringify(o.hard ?? {}), upgrade_threshold: JSON.stringify(o.upgrade ?? {}), ...(o.status ? { status: o.status as never } : {}), current_asset_id: o.currentAssetId ?? null, }) .returning("id") .executeTakeFirstOrThrow() ).id; } async function season(entityId: string, n: number, total: number | null) { return ( await db() .insertInto("content_unit") .values({ media_entity_id: entityId, kind: "season", season_number: n, status: "confirmed", episode_total: total, episode_total_source: total ? "tmdb" : null, }) .returning("id") .executeTakeFirstOrThrow() ).id; } async function episode( entityId: string, s: number, e: number, o: { airDate?: string; status?: "provisional" | "confirmed" } = {}, ) { return ( await db() .insertInto("content_unit") .values({ media_entity_id: entityId, kind: "episode", season_number: s, episode_number: e, status: o.status ?? "confirmed", air_date: o.airDate ?? null, }) .returning("id") .executeTakeFirstOrThrow() ).id; } async function asset(entityId: string, o: { edition?: string; quality?: object } = {}) { return ( await db() .insertInto("media_asset") .values({ media_entity_id: entityId, kind: "video", size_bytes: SIZE, content_edition: o.edition ?? null, media_info: JSON.stringify({ _umt: { quality: o.quality ?? { resolution: "1080p", source: "WEB-DL" } } }), }) .returning("id") .executeTakeFirstOrThrow() ).id; } async function cover(assetId: string, unitIds: string[]) { for (const u of unitIds) await db().insertInto("asset_unit_coverage").values({ media_asset_id: assetId, content_unit_id: u }).execute(); } async function ledger(slotId: string, unitId: string, assetId: string, pri: number, quality: object) { await db() .insertInto("slot_unit") .values({ slot_id: slotId, content_unit_id: unitId, media_asset_id: assetId, pri, quality: JSON.stringify(quality), }) .execute(); } async function plan(slotId: string, entityId: string, status: "reserved" | "submitted", unitKeys: string[]) { const t = makeTorrent({ name: `x${Math.random()}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) }); const rel = await seedRelease(db(), { siteId: base.siteId, entityId, title: `Seed.S01.1080p-${t.hash.slice(0, 6)}`, size: SIZE, hash: t.hash, }); return ( await db() .insertInto("acquisition_plan") .values({ slot_id: slotId, source_release_id: rel, purpose: "fill", status, priority: "tracking", evaluation: "{}", payload_hash: t.hash, idempotency_key: `seed:${t.hash}`, unit_keys: unitKeys, }) .returning("id") .executeTakeFirstOrThrow() ).id; } async function rel(entityId: string, title: string) { const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) }); return seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash }); } const cov = async (id: string) => { const r = await h.api("GET", `/subscriptions/${id}/coverage?refresh=true`); expect(r.status, JSON.stringify(r.body)).toBe(200); return r.body.data; }; const cellsOf = ( c: { seasons: { columns: { unitKey: string }[] }[]; rows: { slotId: string | null; cells: { state: string }[] }[] }, slotId: string | null, ) => { const keys = c.seasons.flatMap((s) => s.columns.map((x) => x.unitKey)); const row = c.rows.find((r) => r.slotId === slotId)!; return Object.fromEntries(keys.map((k, i) => [k, row.cells[i]])) as Record>; }; it("覆盖矩阵:单集 / 季包 / 多版本 / 在途(含整季通配)/ 临时单元 / 未播出;缓存 30s", async () => { const e = await seedEntity(db(), { kind: "series", title: "矩阵剧", year: 2025 }); await season(e, 1, 6); const u = [ await episode(e, 1, 1), await episode(e, 1, 2), await episode(e, 1, 3), await episode(e, 1, 4, { status: "provisional" }), await episode(e, 1, 5, { airDate: "2099-01-01" }), ]; const s = await sub(e); const p = await slot(e, s, "4K", { position: 0, upgrade: { enabled: true, basis: "priority", targetPri: 100 } }); const q = await slot(e, s, "1080p", { position: 1 }); // 主版本:季包资产覆盖 E1–E2(分集账本记 pri);E3 在途(单集计划) const pack = await asset(e, { quality: { resolution: "2160p", source: "REMUX" } }); await cover(pack, [u[0]!, u[1]!]); await ledger(p, u[0]!, pack, 100, { resolution: "2160p", source: "REMUX" }); await ledger(p, u[1]!, pack, 97, { resolution: "2160p", source: "REMUX" }); const p3 = await plan(p, e, "reserved", ["s1e3"]); // 次版本:E1 单集已入库;整季通配在途(季包提交、集号未知) const single = await asset(e); await cover(single, [u[0]!]); await ledger(q, u[0]!, single, 0, { resolution: "1080p", source: "WEB-DL" }); const qp = await plan(q, e, "submitted", ["s1*"]); const c = await cov(s); expect(c.mediaKind).toBe("series"); expect(c.seasons).toHaveLength(1); expect(c.seasons[0]).toMatchObject({ seasonNumber: 1, total: 6, totalKnown: true, source: "tmdb" }); const cols = c.seasons[0].columns; expect(cols.map((x: { unitKey: string }) => x.unitKey)).toEqual(["s1e1", "s1e2", "s1e3", "s1e4", "s1e5", "s1e6"]); expect(cols.map((x: { temporary: boolean }) => x.temporary)).toEqual([false, false, false, true, false, true]); expect(c.rows.map((r: { slotId: string | null; primary: boolean }) => [r.slotId, r.primary])).toEqual([ [null, false], [p, true], [q, false], ]); const P = cellsOf(c, p); expect(P.s1e1).toMatchObject({ state: "have", assetId: pack, pri: 100, upgradable: false }); expect((P.s1e1!.quality as { label: string }).label).toBe("2160p REMUX"); expect(P.s1e2).toMatchObject({ state: "have", pri: 97, upgradable: true }); expect(P.s1e3).toMatchObject({ state: "inflight", planId: p3, planStage: "reserved" }); expect(P.s1e4).toMatchObject({ state: "missing" }); expect(P.s1e5).toMatchObject({ state: "skipped", reason: "not_aired" }); expect(P.s1e6).toMatchObject({ state: "missing" }); const Q = cellsOf(c, q); expect(Q.s1e1).toMatchObject({ state: "have", assetId: single }); for (const k of ["s1e2", "s1e3", "s1e4", "s1e5", "s1e6"]) expect(Q[k]).toMatchObject({ state: "inflight", planId: qp }); const any = cellsOf(c, null); expect(any.s1e2).toMatchObject({ state: "have" }); expect(c.rows[1].summary).toEqual({ have: 2, inflight: 1, missing: 2, skipped: 1 }); // 缓存:不带 refresh 30s 内返回同一份 const a = await h.api("GET", `/subscriptions/${s}/coverage`); await db().deleteFrom("acquisition_plan").where("id", "=", p3).execute(); const b = await h.api("GET", `/subscriptions/${s}/coverage`); expect(b.body.data.computedAt).toBe(a.body.data.computedAt); expect(cellsOf(await cov(s), p).s1e3).toMatchObject({ state: "missing" }); expect((await h.api("GET", "/subscriptions/00000000-0000-0000-0000-000000000000/coverage")).status).toBe(404); }); it("覆盖矩阵:集范围裁剪;总集数未知列到已见最高集并标 suspected;电影按内容版本 × 收录版本", async () => { const e = await seedEntity(db(), { kind: "series", title: "范围剧", year: 2025 }); await season(e, 1, null); const e3 = await episode(e, 1, 3); const s = await sub(e, { params: { episodeRange: { start: 2 } } }); const p = await slot(e, s, "main"); const a = await asset(e); await cover(a, [e3]); await ledger(p, e3, a, 0, {}); const c = await cov(s); expect(c.seasons[0]).toMatchObject({ total: null, totalKnown: false }); const P = cellsOf(c, p); expect(Object.keys(P)).toEqual(["s1e1", "s1e2", "s1e3"]); expect(P.s1e1).toMatchObject({ state: "skipped", reason: "out_of_range" }); expect(P.s1e2).toMatchObject({ state: "missing", reason: "suspected" }); expect(P.s1e3).toMatchObject({ state: "have" }); const m = await seedEntity(db(), { kind: "movie", title: "矩阵电影", year: 2024 }); const ms = await sub(m, { kind: "movie", mode: "once" }); const ma = await asset(m, { quality: { resolution: "2160p", source: "REMUX" } }); const mp = await slot(m, ms, "4K", { position: 0, currentAssetId: ma, status: "satisfied" }); const mq = await slot(m, ms, "1080p", { position: 1 }); await db() .updateTable("acquisition_slot") .set({ completion: JSON.stringify({ kind: "movie", cuts: ["default", "Extended"] }) }) .where("id", "=", mq) .execute(); const mc = await cov(ms); expect(mc.seasons[0].columns.map((x: { edition: string }) => x.edition).sort()).toEqual(["Extended", "default"]); expect(cellsOf(mc, mp)).toMatchObject({ "edition:default": { state: "have", assetId: ma }, "edition:Extended": { state: "skipped", reason: "not_required" }, }); expect(cellsOf(mc, mq)).toMatchObject({ "edition:default": { state: "missing" }, "edition:Extended": { state: "missing" }, }); }); it("总览:分组由服务端判定并给计数;卡片字段;按上次更新/名称/缺集排序;筛选与分页", async () => { const tag = `总览${Math.random().toString(36).slice(2, 6)}`; // 追更中:总集数已知,有入库 const e1 = await seedEntity(db(), { kind: "series", title: `${tag}甲`, year: 2025 }); await season(e1, 1, 3); const e1u = await episode(e1, 1, 1); const s1 = await sub(e1, { lastProgressAt: new Date("2026-01-03T00:00:00Z"), params: { siteIds: [base.siteId] } }); const s1p = await slot(e1, s1, "main"); const a1 = await asset(e1); await cover(a1, [e1u]); await ledger(s1p, e1u, a1, 0, {}); // 待搜索:总集数已知,没有入库/在途 const e2 = await seedEntity(db(), { kind: "series", title: `${tag}乙`, year: 2024 }); await season(e2, 1, 8); const s2 = await sub(e2, { lastProgressAt: new Date("2026-01-05T00:00:00Z"), params: { searchIntervalHours: 0 } }); await slot(e2, s2, "main"); // 待定:总集数未知 const e3 = await seedEntity(db(), { kind: "series", title: `${tag}丙`, year: 2023 }); const s3 = await sub(e3, { lastProgressAt: new Date("2026-01-01T00:00:00Z") }); // 暂停 const e4 = await seedEntity(db(), { kind: "series", title: `${tag}丁`, year: 2022 }); const s4 = await sub(e4, { status: "paused" }); // 已完成(电影:唯一收录版本已入库) const e5 = await seedEntity(db(), { kind: "movie", title: `${tag}戊`, year: 2021 }); const a5 = await asset(e5); const s5 = await sub(e5, { kind: "movie", mode: "once" }); await slot(e5, s5, "main", { currentAssetId: a5, status: "satisfied" }); // 取消:不进总览 const e6 = await seedEntity(db(), { kind: "series", title: `${tag}己`, year: 2020 }); const s6 = await sub(e6, { status: "cancelled" }); const r = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}`); expect(r.status, JSON.stringify(r.body)).toBe(200); const d = r.body.data; expect(d.counts).toEqual({ following: 1, waiting: 1, completed: 1, paused: 1, pending: 1 }); expect(d.total).toBe(5); const byId = Object.fromEntries(d.items.map((x: { id: string }) => [x.id, x])); expect(byId[s6]).toBeUndefined(); expect(byId[s1]).toMatchObject({ group: "following", mediaEntityTitle: `${tag}甲`, year: 2025, progress: { have: 1, total: 3, inflight: 0 }, missing: 2, sites: [{ id: base.siteId, name: "测试站" }], defaultRuleGroups: true, autoFill: { enabled: true, mode: "inherit", source: "default" }, searchEnabled: true, lastUpdatedAt: "2026-01-03T00:00:00.000Z", }); expect(byId[s1].ruleGroups.map((g: { name: string }) => g.name)).toEqual(["订阅默认"]); expect(byId[s1].slots).toEqual([ expect.objectContaining({ slotId: s1p, primary: true, have: 1, missing: 2, total: 3, autoFill: true }), ]); expect(byId[s2]).toMatchObject({ group: "waiting", searchEnabled: false, progress: { have: 0, total: 8 } }); expect(byId[s3]).toMatchObject({ group: "pending", progress: { total: null } }); expect(byId[s4].group).toBe("paused"); expect(byId[s5]).toMatchObject({ group: "completed", awaitingNewSeason: false, progress: { have: 1, total: 1 } }); // 默认按上次更新倒序(没写 last_progress_at 的取 created_at,晚于 2026-01) const order = d.items.map((x: { id: string }) => x.id); expect(order.indexOf(s2)).toBeLessThan(order.indexOf(s1)); expect(order.indexOf(s1)).toBeLessThan(order.indexOf(s3)); const byName = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&sort=name`); expect(byName.body.data.items.map((x: { mediaEntityTitle: string }) => x.mediaEntityTitle.slice(-1))).toEqual( ["丙", "丁", "戊", "甲", "乙"].sort((a, b) => a.localeCompare(b, "zh")), ); const miss = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&sort=missing`); expect(miss.body.data.items[0].id).toBe(s2); const g = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&group=following`); expect(g.body.data.items.map((x: { id: string }) => x.id)).toEqual([s1]); expect(g.body.data.counts.waiting).toBe(1); const p1 = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&limit=2`); expect(p1.body.data.items).toHaveLength(2); const p2 = await h.api( "GET", `/subscriptions?q=${encodeURIComponent(tag)}&limit=2&cursor=${p1.body.data.nextCursor}`, ); expect(p2.body.data.items.map((x: { id: string }) => x.id)).toEqual(order.slice(2, 4)); const cancelled = await h.api("GET", `/subscriptions?q=${encodeURIComponent(tag)}&status=cancelled`); expect(cancelled.body.data.items.map((x: { id: string; group: null }) => [x.id, x.group])).toEqual([[s6, null]]); }); it("自动补全:主版本已满足的单元补其他版本(优先级 upgrade);主版本也缺的照常追更;关闭只记延后", async () => { const e = await seedEntity(db(), { kind: "series", title: "Fill Show", year: 2025 }); await season(e, 1, 10); const u1 = await episode(e, 1, 1); const s = await sub(e); const p = await slot(e, s, "4K", { position: 0, hard: { resolutions: ["2160p"] } }); const q = await slot(e, s, "1080p", { position: 1, hard: { resolutions: ["1080p"] } }); const a = await asset(e, { quality: { resolution: "2160p", source: "REMUX" } }); await cover(a, [u1]); await ledger(p, u1, a, 0, { resolution: "2160p" }); const r1 = await rel(e, "Fill.Show.S01E01.1080p.WEB-DL.x264-GRP"); const ev1 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 }); expect(ev1.plans, JSON.stringify(ev1)).toHaveLength(1); const fillPlan = await db() .selectFrom("acquisition_plan") .selectAll() .where("id", "=", ev1.plans[0]!) .executeTakeFirstOrThrow(); expect(fillPlan).toMatchObject({ slot_id: q, priority: "upgrade", unit_keys: ["s1e1"] }); expect((fillPlan.evaluation as { autoFill?: boolean; primarySlotId?: string }).autoFill).toBe(true); const r2 = await rel(e, "Fill.Show.S01E02.1080p.WEB-DL.x264-GRP"); const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: r2 }); const chase = await db() .selectFrom("acquisition_plan") .selectAll() .where("id", "=", ev2.plans[0]!) .executeTakeFirstOrThrow(); expect(chase).toMatchObject({ slot_id: q, priority: "tracking", unit_keys: ["s1e2"] }); expect((chase.evaluation as { autoFill?: boolean }).autoFill).toBeUndefined(); // 关闭:非主版本不建计划,候选记延后(未开自动补全),不进 slot_deferral await db() .updateTable("subscription") .set({ params: JSON.stringify({ autoFill: "off" }) }) .where("id", "=", s) .execute(); const r3 = await rel(e, "Fill.Show.S01E03.1080p.WEB-DL.x264-GRP"); const ev3 = await evaluateAcquisition(h.d, { sourceReleaseId: r3 }); expect(ev3.plans).toHaveLength(0); expect(ev3.deferred).toEqual([{ sourceReleaseId: r3, slotId: q, reason: "auto_fill_off" }]); expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", q).execute()).toHaveLength(0); const ov = await h.api("GET", "/subscriptions?q=Fill%20Show"); expect(ov.body.data.items[0].autoFill).toMatchObject({ enabled: false, mode: "off", source: "subscription" }); expect(ov.body.data.items[0].slots.map((x: { autoFill: boolean }) => x.autoFill)).toEqual([true, false]); // 手动补全:不受开关限制,可限定单元 const f = await h.api("POST", `/subscriptions/${s}/slots/${q}/fill`, { unitKeys: ["s1e3"] }); expect(f.status).toBe(202); const job = h.engine.enqueued.findLast((x) => x.task === "evaluateAcquisition")!; expect(job.input).toEqual({ acquisitionSlotId: q, forceFill: { unitKeys: ["s1e3"] } }); const forced = await evaluateAcquisition(h.d, job.input as never); const fp = await db().selectFrom("acquisition_plan").selectAll().where("id", "in", forced.plans).execute(); expect(fp.map((x) => [x.slot_id, x.unit_keys])).toEqual([[q, ["s1e3"]]]); expect( (await h.api("POST", `/subscriptions/${s}/slots/00000000-0000-0000-0000-000000000000/fill`, {})).status, ).toBe(404); }); it("自动补全:继承规则组 autoFill;补全预算超出记延后(slot_deferral),电影同口径", async () => { const g = await h.api("POST", "/rules", { name: `不补全${Math.random().toString(36).slice(2, 6)}`, ruleString: "1080P | 4K", }); expect(g.status, JSON.stringify(g.body)).toBe(201); await db().updateTable("rule_group").set({ auto_fill: false }).where("id", "=", g.body.data.id).execute(); const m = await seedEntity(db(), { kind: "movie", title: "Fill Movie", year: 2024 }); const ma = await asset(m, { quality: { resolution: "2160p", source: "REMUX" } }); const ms = await sub(m, { kind: "movie", mode: "once", params: { ruleGroupIds: [g.body.data.id] } }); await slot(m, ms, "4K", { position: 0, hard: { resolutions: ["2160p"] }, currentAssetId: ma, status: "satisfied" }); const mq = await slot(m, ms, "1080p", { position: 1, hard: { resolutions: ["1080p"] } }); const r1 = await rel(m, "Fill.Movie.2024.1080p.WEB-DL.x264-GRP"); const ev1 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 }); expect(ev1.deferred).toEqual([{ sourceReleaseId: r1, slotId: mq, reason: "auto_fill_off" }]); const ov = await h.api("GET", "/subscriptions?q=Fill%20Movie"); expect(ov.body.data.items[0].autoFill).toMatchObject({ enabled: false, mode: "inherit", source: "rule_group" }); // 订阅显式打开 + 预算 0 单元/天 → 延后(记 slot_deferral,次日重评) await db() .updateTable("subscription") .set({ params: JSON.stringify({ ruleGroupIds: [g.body.data.id], autoFill: "on", fillBudget: { unitsPerDay: 0 } }), }) .where("id", "=", ms) .execute(); const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 }); expect(ev2.plans).toHaveLength(0); expect(ev2.deferred).toEqual([{ sourceReleaseId: r1, slotId: mq, reason: "fill_budget" }]); expect(await db().selectFrom("slot_deferral").select("reason").where("slot_id", "=", mq).execute()).toEqual([ { reason: "fill_budget" }, ]); // 预算放开 → 补全计划(低优先级) await db() .updateTable("subscription") .set({ params: JSON.stringify({ autoFill: "on", fillBudget: { unitsPerDay: 1, bytesPerDay: SIZE } }) }) .where("id", "=", ms) .execute(); const ev3 = await evaluateAcquisition(h.d, { sourceReleaseId: r1 }); expect(ev3.plans).toHaveLength(1); const pl = await db() .selectFrom("acquisition_plan") .selectAll() .where("id", "=", ev3.plans[0]!) .executeTakeFirstOrThrow(); expect(pl).toMatchObject({ slot_id: mq, priority: "upgrade" }); expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", mq).execute()).toHaveLength(0); }); it("表单:MP 字段与旧键兼容(规范化后只存规范键);校验 400;同作品同季 409;best_version → 升级策略", async () => { const e = await seedEntity(db(), { kind: "series", title: "表单剧", year: 2025 }); const site = await db() .selectFrom("site") .select(["id", "key"]) .where("id", "=", base.siteId) .executeTakeFirstOrThrow(); const inst = await db() .selectFrom("downloader_instance") .select(["id", "name"]) .where("id", "=", base.instanceId) .executeTakeFirstOrThrow(); const c = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "series", params: { sites: [site.key], downloader: inst.name, save_path: "/downloads/tv", start_episode: 2, total_episode: 12, manual_total_episode: 1, include: "中字|CHS", exclude: "(?i)HDTV", keyword: "Form Show", filter_groups: ["订阅默认"], autoFillVersions: { enabled: false, budget: { perDay: 3, bytes: 1_000_000 } }, libraryRootId: base.rootId, custom: "kept", }, }); expect(c.status, JSON.stringify(c.body)).toBe(201); const params = c.body.data.params; const defGroup = await db() .selectFrom("rule_group") .select("id") .where("name", "=", "订阅默认") .executeTakeFirstOrThrow(); expect(params).toEqual({ siteIds: [site.id], downloaderInstanceId: inst.id, downloadSavePath: "/downloads/tv", episodeRange: { start: 2 }, include: ["中字|CHS"], exclude: ["HDTV"], keywords: ["Form Show"], ruleGroupIds: [defGroup.id], autoFill: "off", fillBudget: { unitsPerDay: 3, bytesPerDay: 1_000_000 }, libraryRootId: base.rootId, custom: "kept", }); const s1 = await db() .selectFrom("content_unit") .select(["episode_total", "episode_total_source", "episode_total_locked"]) .where("media_entity_id", "=", e) .where("kind", "=", "season") .executeTakeFirstOrThrow(); expect(s1).toEqual({ episode_total: 12, episode_total_source: "manual", episode_total_locked: true }); expect( ( await db() .selectFrom("subscription") .select("last_progress_at") .where("id", "=", c.body.data.id) .executeTakeFirstOrThrow() ).last_progress_at, ).not.toBeNull(); // 同作品同季 → 409 const dup = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "series" }); expect(dup.status).toBe(409); expect(dup.body.error.message).toContain(c.body.data.id); // 校验 const e2 = await seedEntity(db(), { kind: "series", title: "表单剧二", year: 2025 }); for (const bad of [ { include: "(a+)+" }, { exclude: "(\\d)\\1" }, { sites: ["nosuchsite"] }, { filter_groups: ["不存在的组"] }, { episodeRange: { start: 5, end: 2 } }, { fillBudget: { unitsPerDay: -1 } }, ]) { const r = await h.api("POST", "/subscriptions", { mediaEntityId: e2, mediaKind: "series", params: bad }); expect(r.status, JSON.stringify(bad)).toBe(400); } // PATCH:autoFill 布尔 → on/off;best_version → 收录版本升级策略 const sid = c.body.data.id; const sl = await slot(e, sid, "main"); const u = await h.api("PATCH", `/subscriptions/${sid}`, { params: { autoFill: true, best_version: 1, best_version_full: 1 }, }); expect(u.status, JSON.stringify(u.body)).toBe(200); expect(u.body.data.params).toEqual({ autoFill: "on" }); const up = await db() .selectFrom("acquisition_slot") .select(["upgrade_threshold", "revision"]) .where("id", "=", sl) .executeTakeFirstOrThrow(); expect(up.upgrade_threshold).toEqual({ enabled: true, mode: "full_pack" }); expect(up.revision).toBe(2); await h.api("PATCH", `/subscriptions/${sid}`, { params: { best_version: 0 } }); expect( ( await db() .selectFrom("acquisition_slot") .select("upgrade_threshold") .where("id", "=", sl) .executeTakeFirstOrThrow() ).upgrade_threshold, ).toEqual({ enabled: false, mode: "full_pack" }); }); it("暂停 / 恢复 / 重新订阅 / 批量 / 收录版本排序", async () => { const e = await seedEntity(db(), { kind: "movie", title: "动作片", year: 2024 }); const s = await sub(e, { kind: "movie", mode: "once" }); const a = await slot(e, s, "A", { position: 0 }); const b = await slot(e, s, "B", { position: 1 }); const p = await h.api("POST", `/subscriptions/${s}/pause`); expect(p.body.data).toMatchObject({ status: "paused", statusReason: "user_paused", nextSearchAt: null }); expect((await h.api("POST", `/subscriptions/${s}/resubscribe`)).status).toBe(409); const r = await h.api("POST", `/subscriptions/${s}/resume`); expect(r.body.data).toMatchObject({ status: "active", statusReason: "resumed" }); expect(r.body.data.nextSearchAt).not.toBeNull(); expect((await h.api("POST", `/subscriptions/${s}/resume`)).body.data.status).toBe("active"); await db().updateTable("subscription").set({ status: "completed" }).where("id", "=", s).execute(); expect((await h.api("POST", `/subscriptions/${s}/pause`)).status).toBe(409); const re = await h.api("POST", `/subscriptions/${s}/resubscribe`); expect(re.body.data).toMatchObject({ status: "active", statusReason: "resubscribed" }); expect( h.engine.enqueued.some( (x) => x.task === "searchSubscription" && (x.input as { subscriptionId: string }).subscriptionId === s, ), ).toBe(true); const o = await h.api("PUT", `/subscriptions/${s}/slot-order`, { slotIds: [b, a] }); expect(o.status, JSON.stringify(o.body)).toBe(200); expect(o.body.data.map((x: { id: string; position: number }) => [x.id, x.position])).toEqual([ [b, 0], [a, 1], ]); expect((await h.api("PUT", `/subscriptions/${s}/slot-order`, { slotIds: [b] })).status).toBe(400); const c = await cov(s); expect(c.rows.find((x: { primary: boolean }) => x.primary).slotId).toBe(b); const s2 = await sub(await seedEntity(db(), { kind: "movie", title: "批量片", year: 2024 }), { kind: "movie" }); const bp = await h.api("POST", "/subscriptions/batch", { ids: [s, s2], action: "pause" }); expect(bp.body.data.results).toEqual([ { id: s, ok: true, error: null }, { id: s2, ok: true, error: null }, ]); const bd = await h.api("POST", "/subscriptions/batch", { ids: [s2, "00000000-0000-0000-0000-000000000000"], action: "delete", }); expect(bd.body.data.results.map((x: { ok: boolean }) => x.ok)).toEqual([true, false]); expect( (await db().selectFrom("subscription").select("status").where("id", "=", s2).executeTakeFirstOrThrow()).status, ).toBe("cancelled"); const au = await db().selectFrom("audit_event").select("action").where("target_id", "=", s2).execute(); expect(au.map((x) => x.action)).toContain("subscription.deleted"); }); });