docs(plan): on-demand site detail page parsing (HHClub details.php)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
94fd48bff2
commit
ac478673bc
39 files changed
+2446
-339
No files matched your search
@@ -405,7 +405,7 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
expect(f("false")).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("页面与操作跳转;scope=all 四组都在", async () => {
|
||||
it("页面与操作跳转;scope=all 只查作品与操作(不查站点)", async () => {
|
||||
const r = await req("/search?q=设置&scope=actions");
|
||||
expect(r.body.data.actions.items[0]).toMatchObject({
|
||||
id: "page.settings",
|
||||
@@ -414,10 +414,12 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
});
|
||||
const sub = await req("/search?q=订阅&scope=actions");
|
||||
expect(sub.body.data.actions.items.map((a: Loose) => a.id)).toContain("action.new-subscription");
|
||||
const all = await req("/search?q=Dune");
|
||||
const before = [nexusSearches(), mteamSearches(), slowCalls];
|
||||
const all = await req("/search?q=Dune%20Part%20Two&refresh=true");
|
||||
expect(all.body.data.media).not.toBeNull();
|
||||
expect(all.body.data.releases).not.toBeNull();
|
||||
expect(all.body.data.releases).toBeNull();
|
||||
expect(all.body.data.actions).not.toBeNull();
|
||||
expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before);
|
||||
});
|
||||
|
||||
it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
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("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 } });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user