feat(subscriptions): private rule groups, MP quality presets, save path, reset and calendar API

- include/exclude sync to a per-subscription private rule group (rule_group.hard, migration 0036);
  evaluation merges chain hard conditions; private groups hidden from GET /rules, soft-deleted with
  the subscription and rebuilt on resubscribe
- MP resolution / quality / effect map to presets written onto the subscription's own slots
  (default slot template on create); UHD / Atmos go to the private group include
- downloadSavePath mapped to the downloader path and passed as qB savepath; libraryRootId used
  for the first organize when the work has no path binding; keywords appended to active search
- POST /subscriptions/{id}/reset releases stale in-flight plans, clears deferrals, re-evaluates
- GET /subscriptions/calendar from episode air dates; refreshMetadata writes TMDB season
  episode air dates for tracked seasons

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:14:40 +08:00
1 parent 52402864d6
commit 70d56645fe
20 files changed
+1663 -44

No files matched your search

@@ -0,0 +1,513 @@
import { type DB, sql } from "@umt/db";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } 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 "../../src/modules/acquisition-organize/__tests__/harness.ts";
import {
evaluateAcquisition,
subscriptionSavePath,
} from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { submitDownload } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { allOfAny, chainHard, mergeHard } from "../../src/modules/acquisition-organize/services/rules.ts";
import { calendarLabel, releaseDateOf } from "../../src/modules/acquisition-organize/services/subscription-calendar.ts";
import {
parseMpEffect,
parseMpResolution,
parseMpSource,
presetConditions,
} from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
// C1-A 订阅遗留收尾:包含 / 排除 → 订阅私有规则组(评估链纳入、随订阅删除)、MP 分辨率 / 来源 / 特效 → 版本目标、
// 保存路径经路径映射传给 qB、库根用于首次整理、下载器实例、重置进度、日历。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe("C1-A 纯函数", () => {
it("MP 质量字段:标签 / 正则值 / 枚举都能识别", () => {
expect(parseMpResolution("4K|2160p|x2160")).toBe("4k");
expect(parseMpResolution("1080[pi]|x1080")).toBe("1080p");
expect(parseMpResolution("720P")).toBe("720p");
expect(parseMpResolution("480p")).toBeNull();
expect(parseMpSource("Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD")).toBe("bluray_disc");
expect(parseMpSource("Remux")).toBe("remux");
expect(parseMpSource("Blu-?Ray")).toBe("bluray");
expect(parseMpSource("UHD|UltraHD")).toBe("uhd");
expect(parseMpSource("WEB-?DL|WEB-?RIP")).toBe("web");
expect(parseMpSource("[Hx].?265|HEVC")).toBe("h265");
expect(parseMpEffect("Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+")).toBe("dv");
expect(parseMpEffect("Dolby[\\s.]*\\+?Atmos|Atmos")).toBe("atmos");
expect(parseMpEffect("[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+")).toBe("hdr");
expect(parseMpEffect("SDR")).toBe("sdr");
});
it("质量预设 → 硬条件 / 软偏好;UHD / Atmos 只能走包含", () => {
expect(presetConditions({ resolution: "4k", source: "web", effect: "dv" })).toEqual({
hard: { resolutions: ["2160p"], sources: ["WEB-DL", "WEBRip"], hdr: { anyOf: ["DV"] } },
soft: { sources: ["WEB-DL", "WEBRip"], hdr: ["DV", "HDR10+", "HDR10"] },
include: [],
});
expect(presetConditions({ source: "uhd", effect: "atmos" }).include).toEqual(["UHD|Ultra-?HD", "Atmos"]);
expect(presetConditions({ effect: "sdr" }).hard).toEqual({ hdr: { requireSdr: true } });
});
it("包含取 AND:多组合成前瞻正则;排除取并集", () => {
expect(allOfAny([["a", "b"]])).toEqual(["a", "b"]);
const [re] = allOfAny([["中字", "CHS"], ["UHD"]]);
const rx = new RegExp(re!, "i");
expect(rx.test("Show 2160p UHD 中字")).toBe(true);
expect(rx.test("Show 2160p 中字")).toBe(false);
expect(chainHard([{ hard: { include: ["x"] } }, { hard: { exclude: ["y"] } }, { hard: {} }])).toEqual({
include: ["x"],
exclude: ["y"],
});
expect(mergeHard({ exclude: ["a"] }, { exclude: ["b"] })).toEqual({ exclude: ["a", "b"] });
expect(mergeHard({ include: ["a"] }, {})).toEqual({ include: ["a"] });
});
it("保存路径:本机路径经映射换成下载器路径;下载器侧路径原样;都不匹配为 null;无映射同路径", () => {
const t = {
inst: { id: "i1" } as never,
mappings: [{ instanceId: "i1", downloaderPath: "/downloads", hostPath: "/mnt/cache" }],
};
expect(subscriptionSavePath(t, "/mnt/cache/tv/")).toBe("/downloads/tv");
expect(subscriptionSavePath(t, "/downloads/tv")).toBe("/downloads/tv");
expect(subscriptionSavePath(t, "/elsewhere")).toBeNull();
expect(subscriptionSavePath({ inst: t.inst, mappings: [] }, "/x/y")).toBe("/x/y");
});
it("日历标签与上映日期", () => {
expect(calendarLabel(1, [3])).toBe("S01E03");
expect(calendarLabel(1, [4, 3])).toBe("S01E03–E04");
expect(calendarLabel(2, [1, 3])).toBe("S02E01、E03");
expect(calendarLabel(null, [])).toBe("当期");
expect(releaseDateOf({ releaseDate: { value: "2026-10-02", source: "tmdb" } })).toBe("2026-10-02");
expect(releaseDateOf({})).toBeNull();
});
});
describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let lib2: TempTree;
let cache: TempTree;
const disk = fakeDisk(10_000_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
const db = (): Kysely<DB> => h.db;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-c1a-lib-" });
lib2 = await createTempTree({}, { sentinel: true, prefix: "umt-c1a-lib2-" });
cache = await createTempTree({ "Root.Pick.2021.1080p.BluRay.x264-GRP.mkv": SIZE }, { prefix: "umt-c1a-cache-" });
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("subsc1a", {
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 });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
for (const t of [lib, lib2, cache]) await t?.cleanup();
});
async function release(entityId: string, title: string) {
const t = 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;
}
const privateGroups = (subId: string) =>
db()
.selectFrom("rule_group")
.selectAll()
.where(sql<string>`scope ->> 'subscriptionId'`, "=", subId)
.orderBy("created_at")
.execute();
const slotsOf = (subId: string) =>
db().selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute();
it("包含 / 排除 → 私有规则组:规则页不列出;修改 revision+1;清空软删;删除订阅软删,重新订阅重建", async () => {
const e = await seedEntity(db(), { kind: "series", title: "私有组剧", year: 2025 });
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
params: { include: "中字|CHS", exclude: ["HDTV"] },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const id = c.body.data.id as string;
let gs = await privateGroups(id);
expect(gs).toHaveLength(1);
expect(gs[0]).toMatchObject({ kind: "acquisition", deleted_at: null, revision: 1, ladder_required: false });
expect(gs[0]!.hard).toEqual({ include: ["中字|CHS"], exclude: ["HDTV"] });
const list = await h.api("GET", "/rules?limit=200");
expect(list.body.data.items.some((g: { id: string }) => g.id === gs[0]!.id)).toBe(false);
const p = await h.api("PATCH", `/subscriptions/${id}`, {
params: { include: ["中字"], exclude: ["HDTV", "CAM"] },
});
expect(p.status).toBe(200);
gs = await privateGroups(id);
expect(gs[0]).toMatchObject({ revision: 2, hard: { include: ["中字"], exclude: ["HDTV", "CAM"] } });
await h.api("PATCH", `/subscriptions/${id}`, { params: {} });
expect((await privateGroups(id))[0]!.deleted_at).not.toBeNull();
await h.api("PATCH", `/subscriptions/${id}`, { params: { exclude: ["CAM"] } });
const live = (await privateGroups(id)).filter((g) => !g.deleted_at);
expect(live).toHaveLength(1);
expect(live[0]!.hard).toEqual({ exclude: ["CAM"] });
const del = await h.api("POST", "/subscriptions/batch", { ids: [id], action: "delete" });
expect(del.body.data.results[0].ok).toBe(true);
expect((await privateGroups(id)).filter((g) => !g.deleted_at)).toHaveLength(0);
const re = await h.api("POST", `/subscriptions/${id}/resubscribe`);
expect(re.status, JSON.stringify(re.body)).toBe(200);
const again = (await privateGroups(id)).filter((g) => !g.deleted_at);
expect(again).toHaveLength(1);
expect(again[0]!.hard).toEqual({ exclude: ["CAM"] });
});
it("评估链纳入私有组:包含没命中 / 排除命中都拒绝;命中包含建计划;与版本目标自己的包含取 AND", async () => {
const e = await seedEntity(db(), { kind: "movie", title: "Filter Film", year: 2024 });
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "movie",
params: { include: ["CHS"], exclude: ["HDTV"], resolution: "1080P" },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const [slot] = await slotsOf(c.body.data.id);
expect(slot).toBeTruthy();
const noInc = await release(e, "Filter.Film.2024.1080p.WEB-DL.x264-GRP");
const r1 = await evaluateAcquisition(h.d, { sourceReleaseId: noInc });
expect(r1.plans).toHaveLength(0);
const cand = await db()
.selectFrom("artifact")
.select("content")
.where("title", "=", "candidate_evaluation")
.where(sql<boolean>`content->>'sourceReleaseId' = ${noInc}`)
.executeTakeFirstOrThrow();
expect(JSON.stringify(cand.content)).toContain("include_missed");
const excl = await release(e, "Filter.Film.2024.1080p.HDTV.CHS.x264-GRP");
expect((await evaluateAcquisition(h.d, { sourceReleaseId: excl })).plans).toHaveLength(0);
const ok = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP");
const r3 = await evaluateAcquisition(h.d, { sourceReleaseId: ok });
expect(r3.plans, JSON.stringify(r3)).toHaveLength(1);
// 版本目标自己也要求 "GRP2":与私有组的 CHS 取 AND
await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("id", "=", r3.plans[0]!).execute();
await db()
.updateTable("acquisition_slot")
.set({
status: "empty",
hard_conditions: JSON.stringify({ resolutions: ["1080p", "1080i"], include: ["GRP2"] }),
})
.where("id", "=", slot!.id)
.execute();
const onlyGrp = await release(e, "Filter.Film.2024.1080p.WEB-DL.x264-GRP2");
expect((await evaluateAcquisition(h.d, { sourceReleaseId: onlyGrp })).plans).toHaveLength(0);
const both = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP2");
expect((await evaluateAcquisition(h.d, { sourceReleaseId: both })).plans).toHaveLength(1);
});
it("MP 分辨率 / 来源 / 特效:新建时建默认版本目标承载预设;改预设覆盖同一字段,手改过的字段保留;无法识别 400", async () => {
const e = await seedEntity(db(), { kind: "series", title: "预设剧", year: 2025 });
const bad = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
params: { resolution: "480p" },
});
expect(bad.status).toBe(400);
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
params: { resolution: "4K|2160p|x2160", quality: "WEB-?DL|WEB-?RIP", effect: "杜比视界" },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const id = c.body.data.id as string;
expect(c.body.data.params).toMatchObject({ resolution: "4k", source: "web", effect: "dv" });
expect(c.body.data.params.quality).toBeUndefined();
let [slot] = await slotsOf(id);
expect(slot).toMatchObject({ name: "默认版本" });
expect(slot!.hard_conditions).toEqual({
resolutions: ["2160p"],
sources: ["WEB-DL", "WEBRip"],
hdr: { anyOf: ["DV"] },
});
expect(slot!.soft_preferences).toEqual({ sources: ["WEB-DL", "WEBRip"], hdr: ["DV", "HDR10+", "HDR10"] });
// 手改来源
await db()
.updateTable("acquisition_slot")
.set({ hard_conditions: JSON.stringify({ ...(slot!.hard_conditions as object), sources: ["WEB-DL"] }) })
.where("id", "=", slot!.id)
.execute();
const p = await h.api("PATCH", `/subscriptions/${id}`, { params: { resolution: "1080p" } });
expect(p.status).toBe(200);
[slot] = await slotsOf(id);
// 分辨率换成新预设;特效预设去掉 → 删;来源手改过 → 保留
expect(slot!.hard_conditions).toEqual({ resolutions: ["1080p", "1080i"], sources: ["WEB-DL"] });
expect(slot!.soft_preferences).toEqual({});
// 预设 UHD / Atmos 进私有组包含,与表单包含取 AND
await h.api("PATCH", `/subscriptions/${id}`, { params: { source: "uhd", include: ["中字"] } });
const g = (await privateGroups(id)).find((x) => !x.deleted_at)!;
expect((g.hard as { include: string[] }).include).toHaveLength(1);
expect(new RegExp((g.hard as { include: string[] }).include[0]!, "i").test("X UHD 中字")).toBe(true);
expect(new RegExp((g.hard as { include: string[] }).include[0]!, "i").test("X 中字")).toBe(false);
});
it("下载提交:订阅指定下载器与保存路径(本机路径经映射)→ 计划评估与 qB savepath;映射不到 → 不建计划", async () => {
const inst2 = await db()
.insertInto("downloader_instance")
.values({ name: "qb-c1a-2", base_url: qb.url, username: "admin", settings: JSON.stringify({ savePath: "/dl" }) })
.returning("id")
.executeTakeFirstOrThrow();
await db()
.insertInto("path_mapping")
.values({
view_kind: "downloader",
downloader_instance_id: inst2.id,
remote_prefix: "/dl",
local_prefix: cache.root,
})
.execute();
const e = await seedEntity(db(), { kind: "movie", title: "Save Path Film", year: 2023 });
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "movie",
params: { downloaderInstanceId: inst2.id, downloadSavePath: `${cache.root}/movies`, resolution: "1080p" },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const rel = await release(e, "Save.Path.Film.2023.1080p.WEB-DL.x264-GRP");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel });
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
const plan = await db()
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", ev.plans[0]!)
.executeTakeFirstOrThrow();
expect(plan.evaluation).toMatchObject({ instanceId: inst2.id, savePath: "/dl/movies" });
const s = await submitDownload(h.d, plan.id);
expect(s.status, JSON.stringify(s)).toBe("submitted");
const dt = await db()
.selectFrom("download_task")
.selectAll()
.where("id", "=", s.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(dt.instance_id).toBe(inst2.id);
expect(dt.save_path).toBe("/dl/movies");
// 映射不到的保存路径:不建计划,理由可见
const e2 = await seedEntity(db(), { kind: "movie", title: "Unmapped Film", year: 2023 });
await h.api("POST", "/subscriptions", {
mediaEntityId: e2,
mediaKind: "movie",
params: { downloaderInstanceId: inst2.id, downloadSavePath: "/nowhere", resolution: "1080p" },
});
const rel2 = await release(e2, "Unmapped.Film.2023.1080p.WEB-DL.x264-GRP");
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel2 });
expect(ev2.plans).toHaveLength(0);
expect(ev2.skipped).toContainEqual({ sourceReleaseId: rel2, reason: "download_save_path_unmapped" });
});
it("库根:作品还没有路径绑定时,整理按订阅指定的库根;已有绑定仍按绑定", async () => {
const root2 = await db()
.insertInto("library_root")
.values({ name: "c1a-root2", media_kind: "series", local_path: lib2.root, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
const e = await seedEntity(db(), { kind: "movie", title: "Root Pick", year: 2021 });
const dry = () =>
planImport(h.d, {
sourcePaths: [cache.path("Root.Pick.2021.1080p.BluRay.x264-GRP.mkv")],
mediaEntityId: e,
action: "copy",
trigger: "manual",
actorType: "user",
dryRun: true,
});
const before = await dry();
expect(before.plan?.libraryRootId, JSON.stringify(before)).toBe(base.rootId);
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "movie",
params: { libraryRootId: root2.id },
});
expect(c.status).toBe(201);
const after = await dry();
expect(after.plan?.libraryRootId, JSON.stringify(after)).toBe(root2.id);
});
it("重置:取消未提交的计划与下载已不在的计划,保留仍在下载的;清延后;排立即重评;已完成 409", async () => {
const e = await seedEntity(db(), { kind: "series", title: "Reset Show", year: 2024 });
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
params: { resolution: "1080p" },
});
const id = c.body.data.id as string;
const [slot] = await slotsOf(id);
const r1 = await release(e, "Reset.Show.S01E01.1080p.WEB-DL.x264-GRP");
const r2 = await release(e, "Reset.Show.S01E02.1080p.WEB-DL.x264-GRP");
const r3 = await release(e, "Reset.Show.S01E03.1080p.WEB-DL.x264-GRP");
const [p1] = (await evaluateAcquisition(h.d, { sourceReleaseId: r1 })).plans;
const [p2] = (await evaluateAcquisition(h.d, { sourceReleaseId: r2 })).plans;
const [p3] = (await evaluateAcquisition(h.d, { sourceReleaseId: r3 })).plans;
expect([p1, p2, p3].every(Boolean)).toBe(true);
// p1 在下载;p2 已提交但下载任务被移除;p3 只预留
expect((await submitDownload(h.d, p1!)).status).toBe("submitted");
const s2 = await submitDownload(h.d, p2!);
await db()
.updateTable("download_task")
.set({ removed_at: new Date() })
.where("id", "=", s2.downloadTaskId!)
.execute();
const r2b = await release(e, "Reset.Show.S01E02.1080p.WEB-DL.x265-OTHER");
await evaluateAcquisition(h.d, { sourceReleaseId: r2b });
expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", slot!.id).execute()).toHaveLength(
1,
);
h.engine.enqueued.length = 0;
const res = await h.api("POST", `/subscriptions/${id}/reset`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.data).toMatchObject({
subscriptionId: id,
keptPlans: [p1],
clearedDeferrals: 1,
reevaluated: true,
});
expect([...res.body.data.releasedPlans].sort()).toEqual([p2, p3].sort());
const statuses = await db()
.selectFrom("acquisition_plan")
.select(["id", "status", "status_reason"])
.where("id", "in", [p1!, p2!, p3!])
.execute();
expect(statuses.find((x) => x.id === p1)?.status).toBe("submitted");
expect(statuses.filter((x) => x.status === "cancelled").map((x) => x.status_reason)).toEqual([
"subscription_reset",
"subscription_reset",
]);
expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", slot!.id).execute()).toHaveLength(
0,
);
expect((await slotsOf(id))[0]!.next_evaluate_at).not.toBeNull();
expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true);
const audit = await db()
.selectFrom("audit_event")
.select("action")
.where("target_id", "=", id)
.where("action", "=", "subscription.reset")
.execute();
expect(audit).toHaveLength(1);
await db().updateTable("subscription").set({ status: "completed" }).where("id", "=", id).execute();
expect((await h.api("POST", `/subscriptions/${id}/reset`)).status).toBe(409);
});
it("日历:集播出日期同日同季聚合,状态取主版本;没有播出日期列 unknown;电影取上映日期;跨度超限 400", async () => {
const today = new Date().toISOString().slice(0, 10);
const addDays = (n: number) => new Date(Date.now() + n * 86_400_000).toISOString().slice(0, 10);
const e = await seedEntity(db(), { kind: "series", title: "日历剧", year: 2026 });
const season = await db()
.insertInto("content_unit")
.values({
media_entity_id: e,
kind: "season",
season_number: 1,
status: "confirmed",
episode_total: 4,
episode_total_source: "tmdb",
})
.returning("id")
.executeTakeFirstOrThrow();
const ep = async (n: number, air: string | null) =>
(
await db()
.insertInto("content_unit")
.values({
media_entity_id: e,
parent_unit_id: season.id,
kind: "episode",
season_number: 1,
episode_number: n,
status: "confirmed",
air_date: air,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const e1 = await ep(1, addDays(-3));
await ep(2, addDays(-3));
await ep(3, addDays(5));
await ep(4, null);
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
seasonUnitId: season.id,
params: { resolution: "1080p" },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const subId = c.body.data.id as string;
// E01 已入库(单版本目标:作品下资产都算)
const a = await db()
.insertInto("media_asset")
.values({
media_entity_id: e,
kind: "video",
size_bytes: SIZE,
media_info: JSON.stringify({ _umt: { quality: { resolution: "1080p", source: "WEB-DL" } } }),
})
.returning("id")
.executeTakeFirstOrThrow();
await db().insertInto("asset_unit_coverage").values({ media_asset_id: a.id, content_unit_id: e1 }).execute();
const m = await seedEntity(db(), { kind: "movie", title: "日历片", year: 2026 });
await db()
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ releaseDate: { value: addDays(10), source: "tmdb" } }) })
.where("id", "=", m)
.execute();
await h.api("POST", "/subscriptions", { mediaEntityId: m, mediaKind: "movie" });
const m2 = await seedEntity(db(), { kind: "movie", title: "无日期片", year: 2026 });
await h.api("POST", "/subscriptions", { mediaEntityId: m2, mediaKind: "movie" });
const cal = await h.api("GET", `/subscriptions/calendar?from=${addDays(-10)}&to=${addDays(20)}`);
expect(cal.status, JSON.stringify(cal.body)).toBe(200);
const mine = cal.body.data.entries.filter((x: { subscriptionId: string }) => x.subscriptionId === subId);
expect(mine).toEqual([
expect.objectContaining({
date: addDays(-3),
seasonNumber: 1,
episodes: [1, 2],
label: "S01E01–E02",
state: "partial",
have: 1,
total: 2,
}),
expect.objectContaining({ date: addDays(5), episodes: [3], state: "upcoming", have: 0, total: 1 }),
]);
expect(cal.body.data.unknown).toContainEqual(
expect.objectContaining({ subscriptionId: subId, seasonNumber: 1, episodes: [4] }),
);
expect(cal.body.data.entries).toContainEqual(
expect.objectContaining({ title: "日历片", date: addDays(10), label: "上映", state: "upcoming" }),
);
expect(cal.body.data.unknown).toContainEqual(expect.objectContaining({ title: "无日期片", episodes: [] }));
const movies = await h.api("GET", `/subscriptions/calendar?from=${addDays(-10)}&to=${addDays(20)}&mediaKind=movie`);
expect(movies.body.data.entries.every((x: { mediaKind: string }) => x.mediaKind === "movie")).toBe(true);
expect((await h.api("GET", `/subscriptions/calendar?from=${today}&to=${addDays(90)}`)).status).toBe(400);
expect((await h.api("GET", `/subscriptions/calendar?from=${today}&to=${addDays(-1)}`)).status).toBe(400);
});
});