Add audioCodec, subtitle, dub, feature, year, category, size and seeders facets with disjunctive counts; parser records dub languages (audioLangs). Co-Authored-By: Claude Opus 5.5 <[email protected]>
596 lines
25 KiB
TypeScript
596 lines
25 KiB
TypeScript
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
|
|
import { normalizeTitle } from "@umt/domain";
|
|
import {
|
|
AUDIENCES_FIXTURE_RELEASES,
|
|
createMockMteamApi,
|
|
createMockNexusPhpSite,
|
|
type MockMteamApi,
|
|
type MockNexusPhpSite,
|
|
MTEAM_FIXTURE_TORRENTS,
|
|
} 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 { type BaseRelease, filterReleases, quickFilter } from "../../src/modules/search/releases.ts";
|
|
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
|
|
import type { ServerContext } from "../../src/modules/types.ts";
|
|
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
|
|
import { fakeEngine, fakeSecrets, PG_URL, pgAvailable, plainSettings } from "../sources-identity/harness.ts";
|
|
|
|
// W9-G 全局搜索:两个 mock 站点(NexusPHP 观众 + M-Team API)+ 一个挂起的站点(超时 → partial)、
|
|
// 并发相同查询合并、30s 缓存命中、库存叠加字段、筛选排序游标、从搜索结果下载转调 /acquisition-plans。
|
|
|
|
const up = await pgAvailable();
|
|
const READ_KEY = "w9g-read-key";
|
|
const SLOW_HOST = "slow-site.invalid";
|
|
|
|
const sample: BaseRelease = {
|
|
key: "k",
|
|
sourceReleaseId: null,
|
|
siteId: "00000000-0000-4000-8000-000000000000",
|
|
siteName: "s",
|
|
siteReleaseId: "1",
|
|
title: "t",
|
|
subtitle: null,
|
|
category: { id: null, name: null },
|
|
sizeBytes: null,
|
|
seeders: null,
|
|
leechers: null,
|
|
snatched: null,
|
|
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
|
|
free: false,
|
|
hr: "unknown",
|
|
uploadedAt: null,
|
|
detailUrl: "http://x/",
|
|
observedAt: "2026-09-28T00:00:00.000Z",
|
|
parsed: {
|
|
titleCn: null,
|
|
titleEn: null,
|
|
year: null,
|
|
season: null,
|
|
episodes: null,
|
|
resolution: null,
|
|
source: null,
|
|
videoCodec: null,
|
|
hdr: [],
|
|
releaseGroup: null,
|
|
},
|
|
};
|
|
|
|
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
|
|
type Loose = any;
|
|
|
|
describe.skipIf(!up)("全局搜索", () => {
|
|
let db: Kysely<DB>;
|
|
let admin: K<unknown>;
|
|
let schema: string;
|
|
let nexus: MockNexusPhpSite;
|
|
let mteam: MockMteamApi;
|
|
let app: ReturnType<typeof createApp>;
|
|
let slowCalls = 0;
|
|
const sites: Record<"nexus" | "mteam" | "slow", string> = { nexus: "", mteam: "", slow: "" };
|
|
|
|
const req = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) => {
|
|
const res = await app.request(`/api/v1${path}`, {
|
|
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
|
|
headers: {
|
|
authorization: `Bearer ${init.key ?? 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 nexusSearches = () => nexus.requests.filter((r) => r.path.includes("search=")).length;
|
|
const mteamSearches = () => mteam.requests.filter((r) => r.path.includes("/api/torrent/search")).length;
|
|
|
|
beforeAll(async () => {
|
|
nexus = await createMockNexusPhpSite({
|
|
releases: [
|
|
...AUDIENCES_FIXTURE_RELEASES,
|
|
{
|
|
id: "900001",
|
|
title: "Dune.Part.Two.2024.1080p.WEB-DL.H264.AAC-GRP",
|
|
subtitle: "沙丘2",
|
|
catId: "401",
|
|
catName: "电影",
|
|
sizeText: "8.00 GB",
|
|
seeders: "300",
|
|
leechers: "5",
|
|
snatched: "900",
|
|
uploadedAt: "2026-09-20 10:00:00",
|
|
hr: true,
|
|
},
|
|
],
|
|
});
|
|
mteam = await createMockMteamApi({
|
|
torrents: [
|
|
...MTEAM_FIXTURE_TORRENTS,
|
|
{
|
|
id: "990001",
|
|
name: "Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.H.265-FLUX",
|
|
smallDescr: "沙丘2",
|
|
category: "419",
|
|
size: "25000000000",
|
|
createdDate: "2026-09-22 08:00:00",
|
|
status: { seeders: "40", leechers: "1", timesCompleted: "100", discount: "NORMAL" },
|
|
},
|
|
],
|
|
});
|
|
|
|
schema = `w9g_search_${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);
|
|
await import("@umt/db");
|
|
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: fakeEngine(),
|
|
outbox: createOutboxPort(),
|
|
secrets,
|
|
settings: plainSettings(() => db),
|
|
capabilities: {} as never,
|
|
} as ServerContext;
|
|
// 挂起的站点:请求一直不返回,直到被中止(单站超时)
|
|
const fetchImpl: typeof fetch = (input, init) => {
|
|
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
if (url.hostname !== SLOW_HOST) return fetch(input, init);
|
|
slowCalls++;
|
|
return new Promise<Response>((_, reject) => {
|
|
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
});
|
|
};
|
|
const sources = createSourcesIdentityModule({ fetch: fetchImpl, tmdb: () => null });
|
|
const search = createSearchModule({ runtime: (c) => sources.runtime(c), siteTimeoutMs: 600 });
|
|
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(), search],
|
|
ctx,
|
|
apiKeyStore: memoryStore({ [READ_KEY]: { id: "reader", scopes: ["read"] } }),
|
|
});
|
|
|
|
const addSite = async (
|
|
key: string,
|
|
name: string,
|
|
adapter: "nexusphp" | "mteam_api",
|
|
baseUrl: string,
|
|
config = {},
|
|
) =>
|
|
(
|
|
await db
|
|
.insertInto("site")
|
|
.values({
|
|
key,
|
|
name,
|
|
adapter,
|
|
base_url: baseUrl,
|
|
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
|
|
config: JSON.stringify(config),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
const addAccount = async (siteId: string, kind: "cookie" | "api_key", plaintext: string) => {
|
|
const ref = await secrets.create({ purpose: kind === "cookie" ? "site_cookie" : "site_api_key", plaintext });
|
|
await db
|
|
.insertInto("site_account")
|
|
.values({ site_id: siteId, label: "main", auth_kind: kind, secret_ref: ref })
|
|
.execute();
|
|
};
|
|
sites.nexus = await addSite("audiences", "观众", "nexusphp", `${nexus.baseUrl}/`);
|
|
await addAccount(sites.nexus, "cookie", nexus.cookie);
|
|
sites.mteam = await addSite("mteam", "馒头", "mteam_api", "https://kp.m-team.cc/", { apiBaseUrl: mteam.baseUrl });
|
|
await addAccount(sites.mteam, "api_key", mteam.apiKey);
|
|
sites.slow = await addSite("hdsky", "天空", "nexusphp", `http://${SLOW_HOST}/`);
|
|
await addAccount(sites.slow, "cookie", "uid=1; pass=x");
|
|
// 已关闭的站点不参与搜索
|
|
await db
|
|
.insertInto("site")
|
|
.values({ key: "hdhome", name: "家园", adapter: "nexusphp", base_url: "http://off.invalid/", enabled: false })
|
|
.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 nexus?.close();
|
|
await mteam?.close();
|
|
});
|
|
|
|
it("两站成功 + 一站超时 → partial;结果写入资源表;每组带 tookMs", async () => {
|
|
const r = await req("/search?q=Dune&scope=releases&limit=50");
|
|
expect(r.status).toBe(200);
|
|
const d = r.body.data;
|
|
expect(d.media).toBeNull();
|
|
expect(d.actions).toBeNull();
|
|
const rel = d.releases;
|
|
expect(rel.partial).toBe(true);
|
|
expect(rel.failedSites).toEqual([
|
|
expect.objectContaining({
|
|
siteId: sites.slow,
|
|
status: "timeout",
|
|
count: 0,
|
|
error: expect.stringContaining("秒"),
|
|
}),
|
|
]);
|
|
const byId = Object.fromEntries(rel.sites.map((s: Loose) => [s.siteId, s]));
|
|
expect(Object.keys(byId).sort()).toEqual([sites.nexus, sites.mteam, sites.slow].sort());
|
|
expect(byId[sites.nexus]).toMatchObject({ status: "ok", count: 2 });
|
|
expect(byId[sites.mteam]).toMatchObject({ status: "ok", count: 1 });
|
|
expect(slowCalls).toBeGreaterThan(0);
|
|
expect(rel.total).toBe(3);
|
|
expect(rel.cached).toBe(false);
|
|
expect(typeof rel.tookMs).toBe("number");
|
|
expect(typeof d.tookMs).toBe("number");
|
|
// 默认排序:做种数降序
|
|
expect(rel.items.map((i: Loose) => i.seeders)).toEqual([1204, 300, 40]);
|
|
const remux = rel.items[0];
|
|
expect(remux).toMatchObject({
|
|
siteId: sites.nexus,
|
|
siteName: "观众",
|
|
siteReleaseId: "812345",
|
|
free: true,
|
|
parsed: { resolution: "2160p", source: "REMUX" },
|
|
});
|
|
expect(remux.sourceReleaseId).toMatch(/^[0-9a-f-]{36}$/);
|
|
expect(rel.items[1]).toMatchObject({ free: false, parsed: { resolution: "1080p", source: "WEB-DL" } });
|
|
const rows = await db.selectFrom("source_release").select(["id", "site_id"]).execute();
|
|
expect(rows.length).toBe(3);
|
|
});
|
|
|
|
it("30s 内相同查询命中缓存,不再请求站点", async () => {
|
|
const before = [nexusSearches(), mteamSearches(), slowCalls];
|
|
const r = await req("/search?q=%20dune%20&scope=releases");
|
|
expect(r.body.data.releases).toMatchObject({ cached: true, total: 3, partial: true });
|
|
const page = await req("/search/releases?q=Dune");
|
|
expect(page.body.data).toMatchObject({ cached: true, total: 3 });
|
|
expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before);
|
|
// refresh=true 跳过缓存
|
|
const fresh = await req("/search/releases?q=Dune&refresh=true");
|
|
expect(fresh.body.data.cached).toBe(false);
|
|
expect(nexusSearches()).toBe(before[0]! + 1);
|
|
});
|
|
|
|
it("并发的相同查询合并为一次站点请求", async () => {
|
|
const before = mteamSearches();
|
|
const [a, b] = await Promise.all([
|
|
req("/search/releases?q=Oppenheimer"),
|
|
req("/search?q=oppenheimer&scope=releases"),
|
|
]);
|
|
expect(a.status).toBe(200);
|
|
expect(b.status).toBe(200);
|
|
expect(mteamSearches() - before).toBe(1);
|
|
const flags = [a.body.data.coalesced, b.body.data.releases.coalesced];
|
|
expect(flags.filter(Boolean).length).toBe(1);
|
|
expect(a.body.data.items[0]).toMatchObject({ siteId: sites.mteam, siteReleaseId: "912234" });
|
|
});
|
|
|
|
it("库存叠加:已识别资源 → resolved + 订阅/收录版本缺口;未识别但标题命中唯一作品别名 → alias", async () => {
|
|
const entity = (
|
|
await db
|
|
.insertInto("media_entity")
|
|
.values({ kind: "movie", title: "沙丘2", original_title: "Dune: Part Two", year: 2024, status: "active" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
for (const alias of ["沙丘2", "Dune Part Two"]) {
|
|
await db
|
|
.insertInto("title_alias")
|
|
.values({ media_entity_id: entity, alias, normalized: normalizeTitle(alias), kind: "original", source: "tmdb" })
|
|
.execute();
|
|
}
|
|
await db
|
|
.insertInto("subscription")
|
|
.values({ media_entity_id: entity, media_kind: "movie", mode: "once", status: "active" })
|
|
.execute();
|
|
const slot = (
|
|
await db
|
|
.insertInto("acquisition_slot")
|
|
.values({ media_entity_id: entity, name: "2160p Remux", status: "empty" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
// 观众 REMUX 已识别到作品
|
|
await db
|
|
.updateTable("source_release")
|
|
.set({ media_entity_id: entity, resolution_status: "resolved" })
|
|
.where("site_id", "=", sites.nexus)
|
|
.where("site_release_id", "=", "812345")
|
|
.execute();
|
|
|
|
const r = await req("/search/releases?q=Dune");
|
|
const items = r.body.data.items as Loose[];
|
|
const remux = items.find((i) => i.siteReleaseId === "812345");
|
|
expect(remux).toMatchObject({
|
|
mediaEntityId: entity,
|
|
entityMatch: "resolved",
|
|
entity: { id: entity, title: "沙丘2", year: 2024, kind: "movie" },
|
|
inventory: {
|
|
entityId: entity,
|
|
state: "gap",
|
|
subscribed: true,
|
|
gaps: [{ slotId: slot, name: "2160p Remux", status: "empty" }],
|
|
versions: [],
|
|
downloading: [],
|
|
candidateReasons: [],
|
|
},
|
|
});
|
|
const mt = items.find((i) => i.siteReleaseId === "990001");
|
|
expect(mt).toMatchObject({ mediaEntityId: entity, entityMatch: "alias", inventory: { state: "gap" } });
|
|
|
|
// 本地作品组:别名精确命中 + 收录版本状态摘要
|
|
const all = await req("/search?q=沙丘2&scope=media");
|
|
const local = all.body.data.media.local.items;
|
|
expect(local[0]).toMatchObject({
|
|
entity: { id: entity, title: "沙丘2" },
|
|
matchedBy: "exact",
|
|
score: 1,
|
|
subscribed: true,
|
|
versionCount: 0,
|
|
slotCounts: { empty: 1, satisfied: 0 },
|
|
});
|
|
expect(all.body.data.media.tmdb).toMatchObject({ configured: false, items: [] });
|
|
expect(typeof all.body.data.media.local.tookMs).toBe("number");
|
|
const prefix = await req("/search?q=Dune%20Pa&scope=media");
|
|
expect(prefix.body.data.media.local.items[0]).toMatchObject({ entity: { id: entity }, matchedBy: "prefix" });
|
|
});
|
|
|
|
it("筛选 / 排序 / 游标 / 分面", async () => {
|
|
const q = (s: string) => req(`/search/releases?q=Dune&${s}`).then((r) => r.body.data);
|
|
let d = await q("resolution=2160p");
|
|
expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["812345", "990001"]);
|
|
expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 });
|
|
expect(d.facets.resolutions).toEqual([
|
|
{ value: "2160p", label: "2160p", count: 2 },
|
|
{ value: "1080p", label: "1080p", count: 1 },
|
|
]);
|
|
expect(d.facets.sites.find((f: Loose) => f.value === sites.nexus)).toMatchObject({ label: "观众", count: 2 });
|
|
|
|
d = await q("source=web-dl,REMUX&free=false");
|
|
expect(d.items.map((i: Loose) => i.siteReleaseId).sort()).toEqual(["900001", "990001"]);
|
|
d = await q(`sites=${sites.mteam}`);
|
|
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]);
|
|
d = await q(`minSize=${10 * 1024 ** 3}&maxSize=${50 * 1024 ** 3}`);
|
|
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["990001"]);
|
|
|
|
d = await q("sort=size&order=asc");
|
|
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001", "990001", "812345"]);
|
|
d = await q("sort=uploadedAt");
|
|
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["812345", "990001", "900001"]);
|
|
d = await q("sort=seeders&order=asc");
|
|
expect(d.items.map((i: Loose) => i.seeders)).toEqual([40, 300, 1204]);
|
|
|
|
const p1 = await q("limit=2");
|
|
expect(p1.items.length).toBe(2);
|
|
expect(p1.nextCursor).toBeTruthy();
|
|
const p2 = await q(`limit=2&cursor=${p1.nextCursor}`);
|
|
expect(p2.items.map((i: Loose) => i.seeders)).toEqual([40]);
|
|
expect(p2.nextCursor).toBeNull();
|
|
});
|
|
|
|
it("快速筛选 qf(SEARCH-3):分面计数、同维度 OR / 跨维度 AND、分页一致", async () => {
|
|
const q = (qf: string, extra = "") =>
|
|
req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data);
|
|
const counts = (opts: Loose[]) => Object.fromEntries(opts.map((o) => [o.value, o.count]));
|
|
const ids = (d: Loose) => d.items.map((i: Loose) => i.siteReleaseId).sort();
|
|
|
|
let d = await q("");
|
|
expect(d.total).toBe(3);
|
|
expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1, "720p": 0 });
|
|
expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0 });
|
|
expect(counts(d.facets.releaseType)).toEqual({ disc: 0, remux: 1, encode: 0, webdl: 2, hdtv: 0 });
|
|
expect(counts(d.facets.site)).toEqual({ [sites.nexus]: 2, [sites.mteam]: 1 });
|
|
expect(d.facets.site.find((f: Loose) => f.value === sites.nexus).label).toBe("观众");
|
|
expect(counts(d.facets.releaseGroup)).toEqual({ FraMeSToR: 1, GRP: 1, FLUX: 1 });
|
|
expect(d.facets.hdr.find((f: Loose) => f.value === "SDR").count).toBe(2);
|
|
expect(d.facets.season).toEqual([]);
|
|
expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(3);
|
|
// 旧分面仍在
|
|
expect(d.facets.resolutions.length).toBe(2);
|
|
|
|
d = await q("resolution:2160p");
|
|
expect(ids(d)).toEqual(["812345", "990001"]);
|
|
expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 });
|
|
// 本维度其它选项仍按其它条件计数
|
|
expect(counts(d.facets.resolution)).toMatchObject({ "2160p": 2, "1080p": 1 });
|
|
expect(counts(d.facets.releaseType)).toMatchObject({ remux: 1, webdl: 1 });
|
|
|
|
d = await q("resolution:2160p;releaseType:webdl");
|
|
expect(ids(d)).toEqual(["990001"]);
|
|
d = await q(`resolution:2160p,1080p;site:${sites.nexus}`);
|
|
expect(ids(d)).toEqual(["812345", "900001"]);
|
|
d = await q("releaseGroup:flux,grp");
|
|
expect(ids(d)).toEqual(["900001", "990001"]);
|
|
d = await q("hdr:DV");
|
|
expect(ids(d)).toEqual(["812345"]);
|
|
d = await q("videoCodec:AV1");
|
|
expect(d.items).toEqual([]);
|
|
expect(d.total).toBe(0);
|
|
// 与旧参数叠加(AND)
|
|
d = await q("resolution:2160p", "&source=REMUX");
|
|
expect(ids(d)).toEqual(["812345"]);
|
|
|
|
// 分页:同一筛选下逐页取完 = 一次取完,且排序一致
|
|
const whole = await q("videoCodec:H.265,H.264", "&sort=size&limit=50");
|
|
const seen: string[] = [];
|
|
let cursor: string | null = null;
|
|
do {
|
|
const p: Loose = await q("videoCodec:H.265,H.264", `&sort=size&limit=1${cursor ? `&cursor=${cursor}` : ""}`);
|
|
expect(p.total).toBe(3);
|
|
expect(p.facets.videoCodec).toEqual(whole.facets.videoCodec);
|
|
seen.push(...p.items.map((i: Loose) => i.siteReleaseId));
|
|
cursor = p.nextCursor;
|
|
} while (cursor);
|
|
expect(seen).toEqual(whole.items.map((i: Loose) => i.siteReleaseId));
|
|
});
|
|
|
|
it("站点资源列表的分面基于本地数据,不请求站点", async () => {
|
|
const before = [nexusSearches(), mteamSearches()];
|
|
const list = (qf: string) =>
|
|
req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
|
|
let d = await list("");
|
|
expect(d.total).toBe(2);
|
|
expect(d.unfilteredTotal).toBe(2);
|
|
expect(d.facets.site).toEqual([{ value: sites.nexus, label: "观众", count: 2 }]);
|
|
expect(d.facets.resolution.find((f: Loose) => f.value === "2160p").count).toBe(1);
|
|
expect(d.facets.releaseType.find((f: Loose) => f.value === "remux").count).toBe(1);
|
|
// H&R 分面来自资源表 raw._site.hr,每条资源恰好落在一档
|
|
expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(2);
|
|
d = await list("resolution:1080p");
|
|
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
|
|
expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 });
|
|
expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([
|
|
["FraMeSToR", 0],
|
|
["GRP", 1],
|
|
]);
|
|
// 分页
|
|
const p1 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1`).then((r) => r.body.data);
|
|
expect(p1.items.length).toBe(1);
|
|
const p2 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1&cursor=${p1.nextCursor}`).then(
|
|
(r) => r.body.data,
|
|
);
|
|
expect(p2.items.length).toBe(1);
|
|
expect(p2.items[0].id).not.toBe(p1.items[0].id);
|
|
expect(p2.nextCursor).toBeNull();
|
|
expect([nexusSearches(), mteamSearches()]).toEqual(before);
|
|
});
|
|
|
|
it("SEARCH-4 新维度:音频 / 年份 / 分类 / 做种 / 大小,搜索结果与站点资源列表同一口径", async () => {
|
|
const q = (qf: string) => req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
|
|
const list = (qf: string) =>
|
|
req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
|
|
const pick = (f: Loose) =>
|
|
Object.fromEntries(
|
|
["audioCodec", "subtitle", "dub", "feature", "year", "category", "size", "seeders"].map((k) => [
|
|
k,
|
|
Object.fromEntries(f[k].map((o: Loose) => [o.value, o.count])),
|
|
]),
|
|
);
|
|
const d = await q("");
|
|
expect(pick(d.facets)).toMatchObject({
|
|
audioCodec: { Atmos: 1, TrueHD: 1, DDP: 1, AAC: 1 },
|
|
subtitle: { zh: 1, unknown: 2 },
|
|
dub: { cmn: 1, en: 1, unknown: 2 },
|
|
feature: {},
|
|
year: { "2024": 3 },
|
|
category: { 电影: 3 },
|
|
size: { "5-10": 1, "20-50": 1, "50+": 1 },
|
|
seeders: { "10-49": 1, "50+": 2 },
|
|
});
|
|
// 组合:年份 × 做种 × 音频(同维度 OR)
|
|
const hit = await q("year:2024;seeders:50+;audioCodec:AAC,DDP");
|
|
expect(hit.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
|
|
expect(hit.facets.audioCodec.find((o: Loose) => o.value === "DDP").count).toBe(0);
|
|
expect(hit.facets.seeders.find((o: Loose) => o.value === "10-49").count).toBe(1);
|
|
// 站点资源列表:从资源表的解析结果 / 分类 / 大小 / 做种算,不请求站点
|
|
const before = nexusSearches();
|
|
const l = await list("");
|
|
expect(pick(l.facets)).toMatchObject({
|
|
audioCodec: { Atmos: 1, TrueHD: 1, AAC: 1 },
|
|
dub: { cmn: 1, unknown: 1 },
|
|
category: { 电影: 2 },
|
|
size: { "5-10": 1, "50+": 1 },
|
|
seeders: { "50+": 2 },
|
|
});
|
|
expect((await list("dub:cmn;category:电影")).total).toBe(1);
|
|
expect((await list("size:5-10;audioCodec:AAC")).items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
|
|
expect(nexusSearches()).toBe(before);
|
|
});
|
|
|
|
it("H&R 筛选:true 只要明确 H&R;false 只排除明确 H&R(unknown 保留)", () => {
|
|
const base = (id: string, hr: boolean | "unknown") => ({ ...sample, key: id, siteReleaseId: id, hr });
|
|
const items = [base("a", true), base("b", false), base("c", "unknown")];
|
|
const f = (hr: "true" | "false") =>
|
|
filterReleases(items, { q: "x", hr, sort: "default", order: "desc", limit: 50 }).map((i) => i.siteReleaseId);
|
|
expect(f("true")).toEqual(["a"]);
|
|
expect(f("false")).toEqual(["b", "c"]);
|
|
// 快速筛选里的 H&R 分面(POLISH-1):有 / 无 / 未知,多选为 OR
|
|
const qfq = (qf: string) => quickFilter(items, { q: "x", qf, sort: "default", order: "desc", limit: 50 }, 0);
|
|
const all = qfq("");
|
|
expect(all.facets.hr).toEqual([
|
|
{ value: "yes", label: "有", count: 1 },
|
|
{ value: "no", label: "无", count: 1 },
|
|
{ value: "unknown", label: "未知", count: 1 },
|
|
]);
|
|
expect(qfq("hr:no,unknown").items.map((i) => i.siteReleaseId)).toEqual(["b", "c"]);
|
|
});
|
|
|
|
it("页面与操作跳转;scope=all 只查作品与操作(不查站点)", async () => {
|
|
const r = await req("/search?q=设置&scope=actions");
|
|
expect(r.body.data.actions.items[0]).toMatchObject({
|
|
id: "page.settings",
|
|
kind: "page",
|
|
href: "/console/settings",
|
|
});
|
|
const sub = await req("/search?q=订阅&scope=actions");
|
|
expect(sub.body.data.actions.items.map((a: Loose) => a.id)).toContain("action.new-subscription");
|
|
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).toBeNull();
|
|
expect(all.body.data.actions).not.toBeNull();
|
|
expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before);
|
|
});
|
|
|
|
it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => {
|
|
// 未识别的资源 → 与 POST /acquisition-plans 相同的 412
|
|
const unresolved = await req(`/search/releases/${sites.nexus}/900001/plan`, { body: {} });
|
|
expect(unresolved.status).toBe(412);
|
|
expect(unresolved.body.error.code).toBe("precondition_failed");
|
|
|
|
// 已识别但作品没有收录版本 → submitted=false + no_slot
|
|
const other = (
|
|
await db
|
|
.insertInto("media_entity")
|
|
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
await db
|
|
.updateTable("source_release")
|
|
.set({ media_entity_id: other })
|
|
.where("site_id", "=", sites.mteam)
|
|
.where("site_release_id", "=", "912234")
|
|
.execute();
|
|
const ok = await req(`/search/releases/${sites.mteam}/912234/plan`, { body: { note: "from search" } });
|
|
expect(ok.status).toBe(200);
|
|
expect(ok.body.data).toMatchObject({ submitted: false, plan: null, rejected: [{ code: "no_slot" }] });
|
|
|
|
// 不在搜索结果里
|
|
const missing = await req(`/search/releases/${sites.mteam}/nope/plan`, { body: {} });
|
|
expect(missing.status).toBe(404);
|
|
// read scope 不能下载;但可以搜索
|
|
const ro = await req(`/search/releases/${sites.mteam}/912234/plan`, { body: {}, key: READ_KEY });
|
|
expect(ro.status).toBe(403);
|
|
expect((await req("/search?q=Dune&scope=releases", { key: READ_KEY })).status).toBe(200);
|
|
});
|
|
});
|