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); }); });