Files
rootandClaude Opus 5.5 fa834a10bd test(e2e): 常规剧集纵切按新建作品 full 刷新后的季表断言 S1 集单元
自动识别新建作品后会排一次 full 元数据刷新,S1 集单元全部来自 TMDB 季详情并转正式,
不再停留在「E02 临时」。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-03 20:59:52 +08:00

310 lines
12 KiB
TypeScript

import { existsSync } from "node:fs";
import { copyFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createMockRssServer, type MockFeedItem, type MockRssServer } 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";
// E7 常规剧集纵切(验收 S5,REQ §7.1.1 / §8.4):全部经真实任务引擎 + outbox 中继。
// mock RSS 依次出 S01E01、S01E02 单集,最后出一个整季包;已发布收录策略(剧集 1080p Slot),订阅改为持续追踪。
// 断言:
// · 两集分别入库到同一个已绑定的季目录(季绑定只建一次,之后固定);
// · content_unit 覆盖按实际文件计算(E01→E01、E02→E02);
// · 整季包"声称"整季,但实际文件只有 E01/E02(与库内相同)→ 不新增覆盖,Slot 不因包名判满足;
// · 第三集从未出现:Slot 仍未 satisfied,订阅保持 active/continuous(总集数未知不结束)。
const hasPg = await pgAvailable();
const BASE = "https://audiences.example";
const E01 = "The.Bear.S01E01.1080p.WEB-DL.x264-GRP";
const E02 = "The.Bear.S01E02.1080p.WEB-DL.x264-GRP";
const PACK = "The.Bear.S01.1080p.WEB-DL.x264-GRP";
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=E7PASSKEY`,
size: TORRENT_SIZE,
pubDate: new Date(),
categories: ["剧集"],
});
describe.skipIf(!hasPg)("E7 常规剧集纵切", () => {
let e: E2E;
let rss: MockRssServer;
let base: Awaited<ReturnType<typeof seedBase>>;
let feedId: string;
let entityId: string;
let slotId: string;
let subId: string;
// 手动抓取按 10 秒时间槽去重(ingestFeed:manual:<feed>:<槽>):同一槽内的第二次请求会被合并,等到下一个槽再发
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 importedFor = async (title: string) => {
const hash = torrentFor(title).hash;
const dt = await waitFor(
async () => e.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", hash).executeTakeFirst(),
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} 整理记录结束`,
);
};
const slot = () => e.db.selectFrom("acquisition_slot").selectAll().where("id", "=", slotId).executeTakeFirstOrThrow();
const sub = () => e.db.selectFrom("subscription").selectAll().where("id", "=", subId).executeTakeFirstOrThrow();
const coverage = async () => {
const r = 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(["a.id as asset", "u.season_number", "u.episode_number"])
.where("a.media_entity_id", "=", entityId)
.where("a.status", "=", "active")
.orderBy("u.episode_number")
.execute();
return r.map((x) => ({ asset: x.asset, unit: `S${x.season_number}E${x.episode_number}` }));
};
beforeAll(async () => {
e = await createE2E("e7");
base = await seedBase(e, { baseUrl: `${BASE}/` });
rss = await createMockRssServer({ passkey: "E7PASSKEY", items: [item(910001, E01)] });
const urlRef = await e.booted.ctx.secrets.create({ purpose: "feed_url", plaintext: rss.feedUrl });
const feed = 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();
feedId = feed.id;
}, 120_000);
afterAll(async () => {
await rss?.close();
await e?.close();
}, 60_000);
it("发布剧集 1080p 收录策略 → 持续授权", async () => {
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);
});
it("S01E01:识别为剧集 → 订阅改为持续追踪 → 下载 → 入库到季目录;总集数未知,Slot 不判满足", async () => {
await fetchFeed();
const rel = await waitFor(
async () =>
e.db
.selectFrom("source_release")
.selectAll()
.where("title", "=", E01)
.where("resolution_status", "=", "resolved")
.executeTakeFirst(),
60_000,
"S01E01 识别完成",
);
entityId = rel.media_entity_id!;
const entity = await e.db
.selectFrom("media_entity")
.selectAll()
.where("id", "=", entityId)
.executeTakeFirstOrThrow();
expect(entity.kind).toBe("series");
const s = await waitFor(
async () =>
e.db.selectFrom("subscription").selectAll().where("media_entity_id", "=", entityId).executeTakeFirst(),
30_000,
"新作品订阅",
);
subId = s.id;
const patched = await e.api("PATCH", `/subscriptions/${subId}`, { mode: "continuous" });
expect(patched.status).toBe(200);
slotId = (
await e.db
.selectFrom("acquisition_slot")
.select("id")
.where("media_entity_id", "=", entityId)
.executeTakeFirstOrThrow()
).id;
await completeDownload(e, E01);
const rec = await importedFor(E01);
expect(rec).toMatchObject({ status: "succeeded", action: "copy", trigger: "auto" });
expect((await coverage()).map((c) => c.unit)).toEqual(["S1E1"]);
expect((await slot()).status).toBe("empty");
expect(await sub()).toMatchObject({ status: "active", mode: "continuous" });
}, 180_000);
let seasonDir: string;
it("S01E02:第二集进同一个已绑定的季目录,覆盖逐集计算,Slot 仍未满足", async () => {
const bindings0 = await e.db
.selectFrom("path_binding")
.select(["binding_kind", "season_number", "relative_path"])
.where("media_entity_id", "=", entityId)
.orderBy("binding_kind")
.execute();
expect(bindings0.map((b) => `${b.binding_kind}:${b.season_number ?? "-"}`)).toEqual(["entity:-", "season:1"]);
seasonDir = bindings0.find((b) => b.binding_kind === "season")!.relative_path;
rss.addItem(item(910002, E02));
await fetchFeed();
await completeDownload(e, E02);
const rec = await importedFor(E02);
expect(rec.status).toBe("succeeded");
const files = await e.db
.selectFrom("organization_file_result as f")
.innerJoin("organization_record as r", "r.id", "f.organization_record_id")
.select(["f.target_path", "f.status"])
.where("r.media_entity_id", "=", entityId)
.where("f.role", "=", "main")
.orderBy("f.target_path")
.execute();
expect(files).toHaveLength(2);
for (const f of files) {
expect(f.status).toBe("succeeded");
expect(dirname(f.target_path!)).toBe(seasonDir);
expect(existsSync(join(e.lib.root, f.target_path!))).toBe(true);
}
expect(files.map((f) => f.target_path!)).toEqual([
expect.stringMatching(/S01E01 - 第 1 集\.mkv$/),
expect.stringMatching(/S01E02 - 第 2 集\.mkv$/),
]);
// 路径绑定固定:没有因为第二集新建任何绑定
const bindings1 = await e.db
.selectFrom("path_binding")
.select(["binding_kind", "season_number", "relative_path"])
.where("media_entity_id", "=", entityId)
.orderBy("binding_kind")
.execute();
expect(bindings1).toEqual(bindings0);
const cov = await coverage();
expect(cov.map((c) => c.unit)).toEqual(["S1E1", "S1E2"]);
expect(new Set(cov.map((c) => c.asset)).size).toBe(2);
expect((await slot()).status).toBe("empty");
expect(await sub()).toMatchObject({ status: "active", mode: "continuous" });
}, 180_000);
it("整季包:实际文件只有 E01/E02(与库内相同)→ 不新增覆盖,不因包名判满足;第三集未出现,订阅继续", async () => {
const libFiles = await e.db
.selectFrom("organization_file_result as f")
.innerJoin("organization_record as r", "r.id", "f.organization_record_id")
.select(["f.target_path"])
.where("r.media_entity_id", "=", entityId)
.where("f.role", "=", "main")
.where("f.status", "=", "succeeded")
.orderBy("f.target_path")
.execute();
const assetsBefore = await e.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", entityId)
.execute();
rss.addItem(item(910003, PACK));
await fetchFeed();
const packRel = await waitFor(
async () =>
e.db
.selectFrom("source_release")
.selectAll()
.where("title", "=", PACK)
.where("resolution_status", "=", "resolved")
.executeTakeFirst(),
60_000,
"整季包识别完成",
);
expect(packRel.media_entity_id).toBe(entityId);
const hash = torrentFor(PACK).hash;
await waitFor(async () => e.qb.get(hash), 60_000, "qB 收到整季包");
// 包内真实文件清单:只有 E01、E02,内容与已入库的相同(常见的"同组合集")
const pack = [E01, E02].map((t, i) => ({
path: `${PACK}/${t}.mkv`,
src: join(e.lib.root, libFiles[i]!.target_path!),
}));
for (const p of pack) {
const dst = join(e.cache.root, p.path);
await mkdir(dirname(dst), { recursive: true });
await copyFile(p.src, dst);
}
await e.qb.complete(hash, { files: pack.map((p) => ({ path: p.path, size: TORRENT_SIZE })), writeFiles: false });
const rec = await importedFor(PACK);
expect(rec.status).toBe("succeeded");
const packFiles = await e.db
.selectFrom("organization_file_result")
.select(["status", "skip_reason", "media_asset_id"])
.where("organization_record_id", "=", rec.id)
.where("role", "=", "main")
.execute();
expect(packFiles).toHaveLength(2);
for (const f of packFiles) {
expect(f).toMatchObject({ status: "skipped", skip_reason: "already_in_library" });
expect(assetsBefore.map((a) => a.id)).toContain(f.media_asset_id);
}
// 没有新资产、没有新覆盖;季单元的"整季"声明不算覆盖,也没有凭空出现第三集
expect(
await e.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", entityId).execute(),
).toHaveLength(assetsBefore.length);
expect((await coverage()).map((c) => c.unit)).toEqual(["S1E1", "S1E2"]);
const eps = await e.db
.selectFrom("content_unit")
.select(["episode_number", "status"])
.where("media_entity_id", "=", entityId)
.where("kind", "=", "episode")
// 追踪订阅的最新季(S2/S3)由元数据刷新按 TMDB 季详情建集单元(日历用);这里只看包所在的 S1
.where("season_number", "=", 1)
.orderBy("episode_number")
.execute();
// 自动识别新建作品后排了一次 full 元数据刷新:S1 集单元全部来自 TMDB 季详情(mock 为 8 集)并转正式;
// 整季包本身没有建出季表以外的集
expect(eps).toEqual(Array.from({ length: 8 }, (_, i) => ({ episode_number: i + 1, status: "confirmed" })));
// 第三集没出现、总集数未知:Slot 不满足,订阅继续追踪
const s = await slot();
expect(s.status).not.toBe("satisfied");
expect(await sub()).toMatchObject({ status: "active", mode: "continuous" });
// 三个种子各提交一次
expect(e.qb.callsTo("torrents/add")).toHaveLength(3);
}, 180_000);
});