Files
UHD_Media_Tool/apps/server/test/sources-identity/episode-watch-scope.test.ts
T
rootandClaude Opus 5.5 7b7fadbfb6 feat(识别): 集数识别分两层,只对关注中的作品拉元数据、开集数待确认
- 新增 isEntityWatched(service/watched.ts):有进行中订阅、用户搜索 / 手动识别 / 手动下载 / 人工确认身份、
  正在下载或整理、已在媒体库的作品算关注中
- 自动新建作品后的 full 刷新只在关注范围内排(用户搜索 / 手动识别 / 接管照常);集数待确认事项只给关注中的作品开
- 进入关注范围(建订阅、用户搜索命中已有作品、开始下载)时对从没刷新过的作品补排刷新,刷新重算后再开事项
- 确定性的集数校验 / 换算对所有已绑定作品照做

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

151 lines
6.8 KiB
TypeScript

import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
import { sql } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { enqueueWatchedRefresh, isEntityWatched } from "../../src/modules/sources-identity/service/watched.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
// 集数识别分两层(2026-10-06):
// 确定性校验 / 换算对所有已绑定作品的资源照做;拉 TMDB(自动建档后的元数据刷新)、开集数待确认只对关注中的作品做。
// RSS 扫进来自动建档、没人关心的作品:不排刷新、不开事项;进入关注范围(建订阅等)补排刷新,刷新重算后才开内部事项。
const pg = await pgAvailable();
const season = (n: number, eps: number, start: string) => ({
season_number: n,
air_date: start,
names: { "zh-CN": `第 ${n} 季`, "en-US": `Season ${n}` },
episodes: Array.from({ length: eps }, (_, k) => ({
episode_number: k + 1,
air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10),
names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` },
})),
});
// 已完结:S1 = 12、S2 = 13;绝对集号 40 超出全部季,刷新后换算不了 → 集数待确认
const SHOW: TmdbFixture = {
type: "tv",
id: 990301,
original_title: "Yoru no Kurage wa Oyogenai",
original_language: "ja",
origin_country: ["JP"],
date: "2024-04-06",
titles: { "zh-CN": "夜晚的水母不会游泳", "en-US": "Yoru no Kurage wa Oyogenai" },
popularity: 30,
alternative_titles: [],
external_ids: {},
rich: { status: "Ended" },
seasons: [season(1, 12, "2024-04-06"), season(2, 13, "2025-04-05")],
};
describe.skipIf(!pg)("集数识别两层:只对关注中的作品拉元数据、开集数待确认", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SHOW] });
h = await createHarness("episode_watch_scope", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
// 不靠订阅也查 TMDB(旧行为):RSS 扫进来的资源会自动建档,但作品不在关注范围
await sql`insert into settings (key, value) values ('identity', ${JSON.stringify({ eagerTmdbLookup: true })}::jsonb)
on conflict (key) do update set value = excluded.value`.execute(h.db);
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const observe = async (title: string) => {
const r = await h.db
.insertInto("source_release")
.values({ site_id: siteId, site_release_id: String(830000 + ++n), title, subtitle: "夜晚的水母不会游泳" })
.returningAll()
.executeTakeFirstOrThrow();
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "observed" });
expect(res.outcome, JSON.stringify(res)).toBe("auto");
return { id: r.id, entityId: res.mediaEntityId as string };
};
const method = async (id: string) =>
(
(
await h.db
.selectFrom("source_release")
.select("episode_resolution")
.where("id", "=", id)
.executeTakeFirstOrThrow()
).episode_resolution as { method: string }
).method;
const refreshJobs = () => h.engine.enqueued.filter((e) => e.task === "refreshMetadata");
const episodeDecisions = () =>
h.db
.selectFrom("decision")
.selectAll()
.where("kind", "=", "episode_mapping")
.where("status", "in", ["open", "investigating", "awaiting_user"])
.execute();
let entityId: string;
let r40: string;
it("RSS 扫进来自动建档:确定性换算照做,但不排元数据刷新、不开集数待确认", async () => {
const a = await observe("[ANi] Yoru no Kurage wa Oyogenai - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]");
entityId = a.entityId;
expect(await method(a.id)).toBe("unverified");
expect(await isEntityWatched(h.db, entityId)).toBe(false);
const b = await observe("[Lilith-Raws] Yoru no Kurage wa Oyogenai - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]");
r40 = b.id;
expect(b.entityId).toBe(entityId);
expect(refreshJobs()).toEqual([]);
expect(await episodeDecisions()).toEqual([]);
// 季详情一次都没拉(识别只用搜索与详情)
expect(tmdb.callsTo(/\/season\//)).toHaveLength(0);
});
it("即使季表已知、资源换算不了,非关注作品也不开事项", async () => {
// 直接刷新一次(模拟别处拉过季表),非关注作品的待确认资源照样只记在资源上
expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed");
expect(await method(r40)).toBe("unresolved");
expect(await episodeDecisions()).toEqual([]);
await h.db.updateTable("media_entity").set({ last_metadata_refresh_at: null }).where("id", "=", entityId).execute();
});
it("进入关注范围(建订阅):补排一次 full 刷新;刷新重算后对换算不了的资源开内部事项", async () => {
await h.db
.insertInto("subscription")
.values({ media_entity_id: entityId, media_kind: "series", status: "active" })
.execute();
expect(await isEntityWatched(h.db, entityId)).toBe(true);
expect(await enqueueWatchedRefresh(h.ctx, entityId, "subscription")).toBe(true);
expect(refreshJobs()).toEqual([
{
task: "refreshMetadata",
input: { mediaEntityId: entityId, full: true },
key: `refreshMetadata:watched:${entityId}`,
},
]);
// 执行入队的刷新(同 refreshMetadata 任务处理器)
expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed");
const ds = await episodeDecisions();
expect(ds).toHaveLength(1);
expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([r40]);
// 已刷新过的不再补排
expect(await enqueueWatchedRefresh(h.ctx, entityId, "download")).toBe(false);
});
it("用户搜索 / 手动识别出的新作品照常排刷新", async () => {
const r = await h.db
.insertInto("source_release")
.values({ site_id: siteId, site_release_id: String(830000 + ++n), title: "Oppenheimer.2023.2160p.UHD.BluRay" })
.returningAll()
.executeTakeFirstOrThrow();
const before = refreshJobs().length;
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" });
expect(res.outcome, JSON.stringify(res)).toBe("auto");
expect(await isEntityWatched(h.db, res.mediaEntityId as string)).toBe(true);
expect(refreshJobs().length).toBe(before + 1);
});
});