Files
UHD_Media_Tool/apps/server/test/search/search2.test.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +08:00

396 lines
16 KiB
TypeScript

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<DB>;
let admin: K<unknown>;
let schema: string;
let a: MockNexusPhpSite;
let b: MockNexusPhpSite;
let tmdb: MockTmdb;
let app: ReturnType<typeof createApp>;
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<unknown>({
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }),
});
await sql.raw(`create schema ${schema}`).execute(admin);
db = new K<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
}),
}) as unknown as Kysely<DB>;
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 } });
});
});