import { createTmdbClient } from "@umt/adapters"; import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db"; import { normalizeTitle } from "@umt/domain"; import { AUDIENCES_FIXTURE_RELEASES, createMockNexusPhpSite, createMockTmdb, type MockNexusPhpSite, type MockTmdb, } from "@umt/testkit"; import { Kysely as K, PostgresDialect } from "kysely"; import pg from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createOutboxPort } from "../../src/context.ts"; import { createApp } from "../../src/http/app.ts"; import { EventBus } from "../../src/http/sse.ts"; import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts"; import { createAgentModule } from "../../src/modules/agent/index.ts"; import { createSearchModule } from "../../src/modules/search/index.ts"; import { searchTerms } from "../../src/modules/search/works.ts"; import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts"; import type { ServerContext } from "../../src/modules/types.ts"; import { DEV_KEY, memoryLogger } from "../helpers.ts"; import { fakeEngine, fakeSecrets, PG_URL, pgAvailable, plainSettings } from "../sources-identity/harness.ts"; // SEARCH-2 两阶段搜索:第一阶段只查作品(本地 + TMDB 合并去重)与库存对比,不碰站点; // 第二阶段按作品显式搜资源(服务端生成查询词,站点按默认 / 全部 / 指定);仅 TMDB 作品的详情端点。 const up = await pgAvailable(); // biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON type Loose = any; describe("searchTerms", () => { it("电影:原名带年份 + 译名;同名只留一个;剧集不带年份", () => { expect(searchTerms({ title: "星际穿越", originalTitle: "Interstellar", year: 2014, movie: true })).toEqual([ "Interstellar 2014", "星际穿越", ]); expect(searchTerms({ title: "流浪地球2", originalTitle: "流浪地球2", year: 2023, movie: true })).toEqual([ "流浪地球2", ]); expect(searchTerms({ title: "熊家餐馆", originalTitle: "The Bear", year: 2022, movie: false })).toEqual([ "The Bear", "熊家餐馆", ]); expect(searchTerms({ title: "Oppenheimer", originalTitle: null, year: 2023, movie: true })).toEqual([ "Oppenheimer 2023", ]); }); }); describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => { let db: Kysely; let admin: K; let schema: string; let a: MockNexusPhpSite; let b: MockNexusPhpSite; let tmdb: MockTmdb; let app: ReturnType; const sites = { a: "", b: "", c: "" }; let dune = ""; const engine = fakeEngine(); const req = async (path: string, init: { method?: string; body?: unknown } = {}) => { const res = await app.request(`/api/v1${path}`, { method: init.method ?? (init.body !== undefined ? "POST" : "GET"), headers: { authorization: `Bearer ${DEV_KEY}`, ...(init.body !== undefined ? { "content-type": "application/json" } : {}), }, ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), }); return { status: res.status, body: (await res.json()) as Loose }; }; const searches = (m: MockNexusPhpSite) => m.requests.filter((r) => r.path.includes("search=")).map((r) => r.path); const counts = () => [searches(a).length, searches(b).length]; beforeAll(async () => { const release = { id: "700001", title: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.HEVC.Atmos-GRP", subtitle: "沙丘2", catId: "401", catName: "电影", sizeText: "70.00 GB", seeders: "50", leechers: "1", snatched: "10", uploadedAt: "2026-09-20 10:00:00", }; a = await createMockNexusPhpSite({ releases: [...AUDIENCES_FIXTURE_RELEASES, release] }); b = await createMockNexusPhpSite({ releases: [{ ...release, id: "800001" }] }); tmdb = await createMockTmdb(); schema = `search2_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`; admin = new K({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }), }); await sql.raw(`create schema ${schema}`).execute(admin); db = new K({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }), }), }) as unknown as Kysely; await migrateToLatest(db, { migrationTableSchema: schema }); const { logger } = memoryLogger(); const secrets = fakeSecrets(() => db); const ctx = { role: "all", db, config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never, logger, events: new EventBus(5), engine, outbox: createOutboxPort(), secrets, settings: plainSettings(() => db), capabilities: {} as never, } as ServerContext; const sources = createSourcesIdentityModule({ tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }), }); app = createApp({ config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, logger, bus: new EventBus(5), role: "all", version: "0.0.0-test", modules: [ sources, createAcquisitionOrganizeModule(), createAgentModule(), createSearchModule({ runtime: (c) => sources.runtime(c), siteTimeoutMs: 2000 }), ], ctx, }); const addSite = async (key: string, name: string, m: MockNexusPhpSite | null, searchDefault: boolean) => { const id = ( await db .insertInto("site") .values({ key, name, adapter: "nexusphp", base_url: m ? `${m.baseUrl}/` : "http://never.invalid/", rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }), search_default: searchDefault, }) .returning("id") .executeTakeFirstOrThrow() ).id; if (m) { const ref = await secrets.create({ purpose: "site_cookie", plaintext: m.cookie }); await db .insertInto("site_account") .values({ site_id: id, label: "main", auth_kind: "cookie", secret_ref: ref }) .execute(); } return id; }; sites.a = await addSite("audiences", "观众", a, true); sites.b = await addSite("hdsky", "天空", b, false); // 已关闭:任何模式都不查 sites.c = await addSite("hdhome", "家园", null, true); await db.updateTable("site").set({ enabled: false }).where("id", "=", sites.c).execute(); // 本地已建档的沙丘2(接受了 TMDB 693134),有一个未满足的收录版本 dune = ( await db .insertInto("media_entity") .values({ kind: "movie", title: "沙丘2", original_title: "Dune: Part Two", year: 2024, status: "active" }) .returning("id") .executeTakeFirstOrThrow() ).id; await db .insertInto("title_alias") .values({ media_entity_id: dune, alias: "沙丘2", normalized: normalizeTitle("沙丘2"), kind: "original", source: "tmdb", }) .execute(); await db .insertInto("external_identity") .values({ media_entity_id: dune, provider: "tmdb", entity_type: "movie", external_id: "693134", status: "accepted", }) .execute(); await db .insertInto("acquisition_slot") .values({ media_entity_id: dune, name: "2160p Remux", status: "empty" }) .execute(); }, 60_000); afterAll(async () => { await db?.destroy(); if (admin) { await sql.raw(`drop schema if exists ${schema} cascade`).execute(admin); await admin.destroy(); } await a?.close(); await b?.close(); await tmdb?.close(); }); it("第一阶段:作品本地 + TMDB 合并去重、本地在前、带库存对比;不请求任何站点", async () => { const before = counts(); for (const scope of ["all", "media"]) { const r = await req(`/search?q=${encodeURIComponent("沙丘")}&scope=${scope}`); expect(r.status).toBe(200); expect(r.body.data.releases).toBeNull(); const works = r.body.data.media.works as Loose[]; // 同一 TMDB 身份只出现一次(本地那条),本地在前 expect(works.filter((w) => w.tmdb?.externalId === "693134")).toHaveLength(1); expect(works[0]).toMatchObject({ key: `local:${dune}`, source: "local", localEntityId: dune, tmdb: { entityType: "movie", externalId: "693134" }, inventory: { state: "tracked", slotTotal: 1, slotSatisfied: 0, missing: [{ name: "2160p Remux" }] }, }); } const opp = await req(`/search?q=Oppenheimer&scope=media`); const w = (opp.body.data.media.works as Loose[]).find((x) => x.tmdb?.externalId === "872585"); expect(w).toMatchObject({ source: "tmdb", kind: "movie", localEntityId: null, year: 2023, inventory: { state: "not_collected", versionCount: 0 }, }); expect(w.overview).toBeTruthy(); expect(counts()).toEqual(before); }); it("第二阶段:不带 sites 走默认集合(search_default),sites= 只查指定站点,关闭的站点不查", async () => { let before = counts(); const def = await req(`/search/releases?q=Dune`); expect(def.status).toBe(200); expect(def.body.data.sites.map((s: Loose) => s.siteId)).toEqual([sites.a]); expect(counts()).toEqual([before[0]! + 1, before[1]]); // 惰性识别(DEC-1):搜到的未识别资源以 trigger=search 入队识别 const resolves = engine.enqueued.filter((e) => e.task === "resolveObservation"); expect(resolves.length).toBeGreaterThan(0); expect(resolves.every((e) => (e.input as { trigger?: string }).trigger === "search")).toBe(true); before = counts(); const only = await req(`/search/releases?q=Dune&sites=${sites.b},${sites.c}`); expect(only.body.data.sites.map((s: Loose) => s.siteId)).toEqual([sites.b]); // 结果只来自天空(其定义与 mock 页面结构不完全一致,条数不断言) expect(only.body.data.items.every((i: Loose) => i.siteId === sites.b)).toBe(true); expect(counts()).toEqual([before[0], before[1]! + 1]); // 两个都选:两站的(站点, 查询词)都已缓存 → 不再请求 before = counts(); const both = await req(`/search/releases?q=Dune&sites=${sites.a},${sites.b}`); expect(both.body.data.cached).toBe(true); expect(both.body.data.sites).toHaveLength(2); expect(counts()).toEqual(before); }); it("settings.search.defaultSiteMode:all = 全部启用站点;custom = 指定站点", async () => { await db.deleteFrom("settings").where("key", "=", "search").execute(); await db .insertInto("settings") .values({ key: "search", value: JSON.stringify({ defaultSiteMode: "all", siteIds: [] }) }) .execute(); const all = await req(`/search/releases?q=Oppenheimer`); expect(all.body.data.sites.map((s: Loose) => s.siteId).sort()).toEqual([sites.a, sites.b].sort()); await db .updateTable("settings") .set({ value: JSON.stringify({ defaultSiteMode: "custom", siteIds: [sites.b] }) }) .where("key", "=", "search") .execute(); const custom = await req(`/search/releases?q=Interstellar`); expect(custom.body.data.sites.map((s: Loose) => s.siteId)).toEqual([sites.b]); await db.deleteFrom("settings").where("key", "=", "search").execute(); }); it("按作品搜资源:服务端用原名 + 年份 / 译名生成查询词,每站每词一次", async () => { const before = counts(); const r = await req(`/search/releases?tmdb=movie:693134&sites=${sites.a}`); expect(r.status).toBe(200); expect(r.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]); expect(r.body.data.q).toBe("Dune: Part Two 2024"); const sent = searches(a).slice(before[0]); expect(sent).toHaveLength(2); expect(r.body.data.sites[0]).toMatchObject({ siteId: sites.a, status: "ok" }); const byEntity = await req(`/search/releases?entityId=${dune}&sites=${sites.a}`); expect(byEntity.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]); expect(byEntity.body.data.cached).toBe(true); expect((await req(`/search/releases?sites=${sites.a}`)).status).toBe(422); expect((await req(`/search/releases?tmdb=movie:999999999`)).status).toBe(404); }); it("GET /media/tmdb/{type}/{id}:TMDB 信息 + 季列表 + 库存对比 + 是否已建档", async () => { const before = counts(); const movie = await req(`/media/tmdb/movie/872585`); expect(movie.status).toBe(200); expect(movie.body.data).toMatchObject({ provider: "tmdb", entityType: "movie", externalId: "872585", kind: "movie", year: 2023, originalTitle: "Oppenheimer", localEntityId: null, inventory: { state: "not_collected" }, mediaServers: null, seasons: [], queries: ["Oppenheimer 2023", "奥本海默"], externalIds: { imdb: "tt15398776" }, }); const local = await req(`/media/tmdb/movie/693134?include=mediaServers`); expect(local.body.data).toMatchObject({ localEntityId: dune, inventory: { state: "tracked", missing: [{ name: "2160p Remux" }] }, mediaServers: { mediaEntityId: dune, instances: [] }, }); const tv = await req(`/media/tmdb/tv/136315`); expect(tv.body.data.kind).toBe("series"); expect(tv.body.data.seasons.map((s: Loose) => [s.seasonNumber, s.episodeCount, s.localHave])).toEqual([ [1, 8, null], [2, 10, null], [3, 10, null], ]); expect(tv.body.data.queries).toEqual(["The Bear", "熊家餐馆"]); expect((await req(`/media/tmdb/movie/999999999`)).status).toBe(404); expect((await req(`/media/tmdb/person/1`)).status).toBe(422); // 详情页不查站点 expect(counts()).toEqual(before); }); it("MD-1 GET /media/tmdb/tv/{id}(语言回退链)与 /season/{n}(每集信息)", async () => { const d = await req(`/media/tmdb/tv/95396`); expect(d.status).toBe(200); expect(d.body.data).toMatchObject({ title: "人生切割术", titleZh: "人生切割术", overview: "故事发生在一家名为卢蒙的超级大公司内。", genres: ["剧情", "悬疑", "Sci-Fi & Fantasy"], voteAverage: 8.4, airStatus: "Returning Series", platform: "Apple TV", backdropPath: "/severance-bg.jpg", }); expect(d.body.data.cast[0]).toMatchObject({ name: "亚当·斯科特", role: "Mark Scout", profilePath: "/adam.jpg" }); expect(d.body.data.seasons[0]).toMatchObject({ seasonNumber: 1, posterPath: "/sev-s1.jpg" }); const s1 = await req(`/media/tmdb/tv/95396/season/1`); expect(s1.status).toBe(200); expect(s1.body.data.episodes.map((e: Loose) => [e.episodeNumber, e.name, e.nameLanguage])).toEqual([ [1, "有关地狱的好消息", "zh-CN"], [2, "半循環", "zh-TW"], [3, "In Perpetuity", "en-US"], ]); expect(s1.body.data.episodes[0]).toMatchObject({ runtime: 59, stillPath: "/sev-101.jpg", voteAverage: 8.2 }); expect((await req(`/media/tmdb/tv/95396/season/9`)).status).toBe(404); }); it("GET /inventory/media-servers 可按 TMDB 条目查(未建档时 mediaEntityId = null)", async () => { const r = await req(`/inventory/media-servers?tmdbType=movie&tmdbId=872585`); expect(r.status).toBe(200); expect(r.body.data).toMatchObject({ mediaEntityId: null, instances: [], partial: false }); const linked = await req(`/inventory/media-servers?tmdbType=movie&tmdbId=693134`); expect(linked.body.data.mediaEntityId).toBe(dune); expect((await req(`/inventory/media-servers`)).status).toBe(422); }); it("站点「默认参与搜索」可在 PATCH /sites/{id} 修改,列表返回 searchDefault", async () => { const r = await req(`/sites/${sites.b}`, { method: "PATCH", body: { searchDefault: true } }); expect(r.status).toBe(200); expect(r.body.data.searchDefault).toBe(true); const list = await req(`/sites`); const byId = Object.fromEntries(list.body.data.items.map((s: Loose) => [s.id, s.searchDefault])); expect(byId[sites.a]).toBe(true); expect(byId[sites.b]).toBe(true); await req(`/sites/${sites.b}`, { method: "PATCH", body: { searchDefault: false } }); }); });