feat(server): 发现页接口——TMDB / 豆瓣 / Bangumi 列表、本地状态、确定匹配记忆

- GET /discover/{tmdb,douban,bangumi},bgm 趋势页在沙箱任务 bangumi.trends 里解析
- 豆瓣 / bgm 每个列表 / 搜索请求走 douban.request / bangumi.request 缓存(TTL 取 storage.cacheTtl),每源限流
- 条目按 TMDB 身份标已订阅 / 媒体库已有 / 下载中;豆瓣 / bgm 条目确定匹配后记在 discover.match

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:04:43 +08:00
1 parent e46eb0e3c3
commit 9a154bc249
5 files changed
+576 -27

No files matched your search

+234 -20
View File
@@ -1,17 +1,52 @@
import type { MetadataCandidate } from "@umt/adapters";
import { type DiscoverMatchRequest, DiscoverMatchResult } from "@umt/contracts";
import {
type MetadataCandidate,
type MetaSubject,
type parseBangumiBrowser,
TmdbError,
type TmdbListItem,
type TmdbListPage,
tmdbChart,
tmdbDiscover,
tmdbSearchPage,
tmdbTrending,
} from "@umt/adapters";
import {
DISCOVER_PAGE_SIZE,
type DiscoverBangumiQuery,
type DiscoverCalendar,
type DiscoverDoubanQuery,
type DiscoverItem,
type DiscoverLocal,
type DiscoverMatchCandidate,
type DiscoverMatchRequest,
DiscoverMatchResult,
type DiscoverPage,
type DiscoverTmdbQuery,
type InventoryEntry,
} from "@umt/contracts";
import { pgCacheFor } from "@umt/db";
import { type IdentityCandidate, parseRelease, resolveIdentity } from "@umt/domain";
import { ApiException } from "../../http/errors.ts";
import { runSandbox } from "../../sandbox/client.ts";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { subjectRecall } from "../sources-identity/service/id-escalation.ts";
import { metaClients, toCached } from "../sources-identity/service/meta-subjects.ts";
import { tmdbToCandidate } from "../sources-identity/service/tmdb-candidate.ts";
import { localEntities } from "./media.ts";
// 发现(对照 MP 推荐页的 Bangumi 每日放送、豆瓣热门):列表来自 bgm.tv / 豆瓣,点开时按名称 + 年份匹配 TMDB 作品。
// 发现(对照 MP 推荐 / 探索,docs/porting/moviepilot-discover.md):
// - TMDB:流行趋势、榜单、按条件筛选、搜索,走 TMDB 请求缓存(列表类 6 小时,失败不缓存);
// - 豆瓣 / bgm:榜单、标签、筛选、搜索、每日放送、热门趋势,走 douban.request / bangumi.request 请求缓存(meta-subjects.ts);
// - bgm 热门趋势没有 API,网页在沙箱里解析(ADR-005);
// - 每条结果按 TMDB 身份标本地状态(已订阅 / 媒体库已有 / 下载中);豆瓣 / bgm 条目匹配过 TMDB 的才知道(discover.match)。
// 本地状态每次现查,不进缓存。
const REGION = "discover.list";
const TTL_SEC = 3600;
/** 豆瓣 / bgm 条目 → TMDB 作品的确定匹配(点开时写入,列表据此标本地状态) */
const MATCH_REGION = "discover.match";
const MATCH_TTL_SEC = 30 * 86_400;
const MATCH_MAX = 20_000;
export type InventoryLookup = (ids: string[]) => Promise<Map<string, InventoryEntry>>;
function clients(rt: SourcesRuntime) {
const c = metaClients(rt);
@@ -19,29 +54,201 @@ function clients(rt: SourcesRuntime) {
return c;
}
async function cached<T>(rt: SourcesRuntime, key: string, load: () => Promise<T>): Promise<T> {
const cache = pgCacheFor(rt.ctx.db);
const hit = await cache.get<T>(REGION, key).catch(() => null);
if (hit) return hit;
let v: T;
async function upstream<T>(what: string, fn: () => Promise<T>): Promise<T> {
try {
v = await load();
return await fn();
} catch (e) {
throw new ApiException("upstream_unavailable", (e as Error).message);
if (e instanceof ApiException) throw e;
if (e instanceof TmdbError && e.kind === "auth")
throw new ApiException("precondition_failed", "TMDB 拒绝了 API Key。到设置的元数据分区检查");
throw new ApiException(
"upstream_unavailable",
`${what}查询失败:${e instanceof TmdbError ? e.kind : (e as Error).message}`,
);
}
await cache.set(REGION, key, v, { ttl: TTL_SEC, maxsize: 50 }).catch(() => {});
return v;
}
export function bangumiCalendar(rt: SourcesRuntime) {
return cached(rt, "bangumi:calendar", async () => ({ days: await clients(rt).bangumi.calendar() }));
const fromMeta = (s: MetaSubject): DiscoverItem => ({
source: s.source,
id: s.id,
title: s.title,
aliases: s.aliases,
year: s.year,
kind: s.kind,
cover: s.cover,
rating: s.rating,
date: s.date,
episodes: s.episodes,
url: s.url,
genres: s.genres ?? [],
posterPath: null,
tmdb: null,
local: null,
});
const fromTmdb = (t: TmdbListItem): DiscoverItem => ({
source: "tmdb",
id: `${t.type}:${t.id}`,
title: t.title,
aliases: t.originalTitle && t.originalTitle !== t.title ? [t.originalTitle] : [],
year: t.year,
kind: t.type,
cover: null,
rating: t.voteAverage,
date: t.date,
episodes: null,
url: `https://www.themoviedb.org/${t.type}/${t.id}`,
genres: t.genres,
posterPath: t.posterPath,
tmdb: { type: t.type, id: t.id },
local: null,
});
// ── 本地状态 ──
/** 豆瓣 / bgm 条目补上记住的 TMDB 匹配,再按 TMDB 身份查本地作品与库存 */
export async function annotate(rt: SourcesRuntime, items: DiscoverItem[], inventory: InventoryLookup) {
const cache = pgCacheFor(rt.ctx.db);
await Promise.all(
items
.filter((it) => !it.tmdb && it.source !== "tmdb")
.map(async (it) => {
const hit = await cache.get<DiscoverMatchCandidate>(MATCH_REGION, `${it.source}:${it.id}`).catch(() => null);
if (hit) it.tmdb = { type: hit.type, id: hit.id };
}),
);
const refs = items.flatMap((it) => (it.tmdb ? [{ entityType: it.tmdb.type, externalId: it.tmdb.id }] : []));
if (!refs.length) return items;
const local = await localEntities(rt.ctx, refs);
const ids = [...new Set(local.values())];
const inv = ids.length ? await inventory(ids) : new Map<string, InventoryEntry>();
for (const it of items) {
const entityId = it.tmdb ? local.get(`${it.tmdb.type}:${it.tmdb.id}`) : undefined;
if (!entityId) continue;
const e = inv.get(entityId);
it.local = {
entityId,
subscribed: e?.subscribed ?? false,
versionCount: e?.versions.length ?? 0,
downloadingCount: e?.downloading.length ?? 0,
} satisfies DiscoverLocal;
}
return items;
}
export function doubanHot(rt: SourcesRuntime, kind: "movie" | "tv") {
return cached(rt, `douban:hot:${kind}`, async () => ({ items: await clients(rt).douban.hot(kind, 30) }));
// ── TMDB ──
const tmdbPage = (p: TmdbListPage, page: number): DiscoverPage => ({
items: p.items.map(fromTmdb),
page,
hasMore: page < Math.min(p.totalPages, 500),
});
export async function tmdbList(rt: SourcesRuntime, q: DiscoverTmdbQuery): Promise<DiscoverPage> {
const client = await rt.tmdb();
if (!client) throw new ApiException("precondition_failed", "未配置 TMDB。到设置的元数据分区填写 API Key");
const type = q.type === "all" ? "movie" : q.type;
const page = q.page;
const p = await upstream("TMDB ", () => {
switch (q.list) {
case "trending":
return tmdbTrending(client, { mediaType: q.type, window: q.window, page });
case "search":
return tmdbSearchPage(client, { type: q.type, query: q.q ?? "", page });
case "discover":
return tmdbDiscover(client, {
type,
...(q.sort ? { sort: q.sort } : {}),
...(q.genre ? { genre: q.genre } : {}),
...(q.language ? { language: q.language } : {}),
...(q.region ? { region: q.region } : {}),
...(q.year ? { year: q.year } : {}),
...(q.minRating ? { minRating: q.minRating } : {}),
page,
today: rt.now().toISOString().slice(0, 10),
});
default:
return tmdbChart(client, { type, chart: q.list, page });
}
});
return tmdbPage(p, page);
}
const view = (m: MetadataCandidate) => ({
// ── 豆瓣 ──
export async function doubanList(rt: SourcesRuntime, q: DiscoverDoubanQuery): Promise<DiscoverPage> {
const { douban } = clients(rt);
const size = DISCOVER_PAGE_SIZE;
const start = (q.page - 1) * size;
return upstream("豆瓣", async () => {
if (q.list === "search") {
// 联想接口不分页
const items = q.page === 1 ? await douban.suggest(q.q ?? "") : [];
return { items: items.map(fromMeta), page: q.page, hasMore: false };
}
if (q.list === "tag") {
const items = await douban.tagSubjects(q.type, q.tag ?? "热门", start, size);
return { items: items.map(fromMeta), page: q.page, hasMore: items.length >= size };
}
const r =
q.list === "explore"
? await douban.explore(
q.type,
{ sort: q.sort ?? "U", tags: [q.genre, q.region, q.year].filter((x): x is string => !!x) },
start,
size,
)
: await douban.collection(q.list, start, size);
const hasMore = r.total !== null ? start + size < r.total : r.items.length >= size;
return { items: r.items.map(fromMeta), page: q.page, hasMore };
});
}
// ── bgm ──
/** 趋势页 HTML 解析(外部字节走沙箱,ADR-005);测试可替换 */
export let parseTrendsPage = (html: string) =>
runSandbox("bangumi.trends", { html }, { timeoutMs: 15_000 }) as Promise<ReturnType<typeof parseBangumiBrowser>>;
export function setTrendsParser(fn: typeof parseTrendsPage) {
parseTrendsPage = fn;
}
export async function bangumiList(rt: SourcesRuntime, q: DiscoverBangumiQuery): Promise<DiscoverPage> {
const { bangumi } = clients(rt);
const size = DISCOVER_PAGE_SIZE;
const offset = (q.page - 1) * size;
return upstream("Bangumi ", async () => {
if (q.list === "trends") {
const r = await parseTrendsPage(await bangumi.trendsHtml(q.page));
return { items: r.items.map(fromMeta), page: q.page, hasMore: r.hasMore };
}
const r =
q.list === "search"
? await bangumi.searchPage(q.q ?? "", size, offset)
: await bangumi.browse({
sort: q.list === "rank" ? "rank" : "date",
...(q.year ? { year: q.year } : {}),
limit: size,
offset,
});
return { items: r.items.map(fromMeta), page: q.page, hasMore: offset + size < r.total };
});
}
export async function bangumiCalendar(rt: SourcesRuntime, inventory: InventoryLookup): Promise<DiscoverCalendar> {
const days = await upstream("Bangumi ", () => clients(rt).bangumi.calendar());
const out = days.map((d) => ({ ...d, items: d.items.map(fromMeta) }));
await annotate(
rt,
out.flatMap((d) => d.items),
inventory,
);
return { days: out };
}
// ── 匹配 TMDB ──
const view = (m: MetadataCandidate): DiscoverMatchCandidate => ({
type: m.entityType as "movie" | "tv",
id: m.externalId,
title: m.title,
@@ -50,6 +257,10 @@ const view = (m: MetadataCandidate) => ({
});
export async function matchTmdb(rt: SourcesRuntime, req: DiscoverMatchRequest): Promise<DiscoverMatchResult> {
const cache = pgCacheFor(rt.ctx.db);
const memoKey = `${req.source}:${req.id}`;
const memo = await cache.get<DiscoverMatchCandidate>(MATCH_REGION, memoKey).catch(() => null);
if (memo) return { match: memo, candidates: [memo] };
const subject = toCached({ ...req, cover: null, rating: null, date: null, episodes: null, url: "" }, rt.now());
const base = parseRelease(req.title);
if (req.kind === "tv") base.mediaTypeHint = "tv";
@@ -65,5 +276,8 @@ export async function matchTmdb(rt: SourcesRuntime, req: DiscoverMatchRequest):
.flatMap((r) => byCand.get(r.candidate) ?? [])
.slice(0, 6);
const best = res.status === "auto" && res.best ? byCand.get(res.best.candidate) : undefined;
return DiscoverMatchResult.parse({ match: best ? view(best) : null, candidates: ranked.map(view) });
const match = best ? view(best) : null;
// 只记确定的匹配;不确定 / 失败不记,下次重算
if (match) await cache.set(MATCH_REGION, memoKey, match, { ttl: MATCH_TTL_SEC, maxsize: MATCH_MAX }).catch(() => {});
return DiscoverMatchResult.parse({ match, candidates: ranked.map(view) });
}
+20 -4
View File
@@ -11,8 +11,10 @@ import {
type ApiScope,
apiResponse,
DEFAULT_SETTINGS,
type DiscoverHotQuery,
type DiscoverBangumiQuery,
type DiscoverDoubanQuery,
type DiscoverMatchRequest,
type DiscoverTmdbQuery,
type EndpointDef,
type InventoryEntry,
type InventoryResponse,
@@ -40,7 +42,7 @@ import { metadataLanguages, metadataTextOptions } from "../sources-identity/serv
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { matchActions } from "./actions.ts";
import { bangumiCalendar, doubanHot, matchTmdb } from "./discover.ts";
import { annotate, bangumiCalendar, bangumiList, doubanList, matchTmdb, tmdbList } from "./discover.ts";
import { InternalDispatcher } from "./dispatch.ts";
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts";
@@ -383,8 +385,22 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
});
});
route(app, Api.search.discoverCalendar, () => bangumiCalendar(d.runtime()));
route(app, Api.search.discoverHot, ({ query }) => doubanHot(d.runtime(), (query as DiscoverHotQuery).kind));
route(app, Api.search.discoverCalendar, ({ c }) => bangumiCalendar(d.runtime(), inventory(c)));
const withLocal =
(c: Context<AuthEnv>) =>
async <P extends { items: Parameters<typeof annotate>[1] }>(p: P): Promise<P> => {
await annotate(d.runtime(), p.items, inventory(c));
return p;
};
route(app, Api.search.discoverTmdb, async ({ query, c }) =>
withLocal(c)(await tmdbList(d.runtime(), query as DiscoverTmdbQuery)),
);
route(app, Api.search.discoverDouban, async ({ query, c }) =>
withLocal(c)(await doubanList(d.runtime(), query as DiscoverDoubanQuery)),
);
route(app, Api.search.discoverBangumi, async ({ query, c }) =>
withLocal(c)(await bangumiList(d.runtime(), query as DiscoverBangumiQuery)),
);
route(app, Api.search.discoverMatch, ({ body }) => matchTmdb(d.runtime(), body as DiscoverMatchRequest));
route(app, Api.search.tmdbDetail, async ({ params, query, c }) => {
@@ -3,10 +3,13 @@ import {
createBangumiClient,
createDoubanClient,
type DoubanClient,
type MetaRequestCache,
type MetaSubject,
RequestLimiter,
} from "@umt/adapters";
import { pgCacheFor } from "@umt/db";
import { cachedCall, pgCacheFor } from "@umt/db";
import { normalizeTitle, toSimplified } from "@umt/domain";
import { storageSettings } from "../../../shared-cache.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { DOUBAN_SUBJECT_REGION, type DoubanSubject, loadDoubanSubject, saveDoubanSubject } from "./douban-subject.ts";
@@ -20,10 +23,46 @@ const MISS_TTL_SEC = 6 * 3600;
export type MetaSource = "douban" | "bangumi";
const REGION: Record<MetaSource, string> = { douban: DOUBAN_SUBJECT_REGION, bangumi: BANGUMI_SUBJECT_REGION };
/** 豆瓣 / bgm 请求缓存 region(对照 MP DoubanApi.__invoke / BangumiApi 的 @cached);key 见 metaRequestKey */
export const META_REQUEST_REGIONS = { douban: "douban.request", bangumi: "bangumi.request" } as const;
const META_REQUEST_MAXSIZE = 5000;
/** 每源限流(进程内):豆瓣风控严,间隔大一些;缓存命中不占额度 */
const META_LIMITERS = { douban: new RequestLimiter(2, 300), bangumi: new RequestLimiter(3, 100) };
/**
* 请求缓存:搜索按 storage.cacheTtl.metaSearchDays,列表(榜单 / 热门 / 每日放送 / 趋势页)按 metaListHours,
* 空结果按 metaEmptyHours(不超过正常 TTL);失败(网络、403、风控页)在适配器里抛出,cachedCall 不写。
* 条目详情不走这里:douban.subject / bangumi.subject 条目级缓存(30 天)已经存了,避免同一份数据存两遍。
*/
export function metaRequestCache(ctx: SourcesRuntime["ctx"]): MetaRequestCache {
return {
async cached(req, load) {
const t = (await storageSettings(ctx)).cacheTtl;
const ttl = req.kind === "search" ? t.metaSearchDays * 86_400 : t.metaListHours * 3600;
return cachedCall(
pgCacheFor(ctx.db),
req.key,
{
region: META_REQUEST_REGIONS[req.source],
ttl,
emptyTtl: Math.min(ttl, t.metaEmptyHours * 3600),
emptyIf: req.empty,
maxsize: META_REQUEST_MAXSIZE,
},
load,
);
},
};
}
export function metaClients(rt: SourcesRuntime): { douban: DoubanClient; bangumi: BangumiClient } | null {
const fetch = rt.metaFetch();
if (!fetch) return null;
return { douban: createDoubanClient({ fetch }), bangumi: createBangumiClient({ fetch }) };
const cache = metaRequestCache(rt.ctx);
return {
douban: createDoubanClient({ fetch, cache, limiter: META_LIMITERS.douban }),
bangumi: createBangumiClient({ fetch, cache, limiter: META_LIMITERS.bangumi }),
};
}
export function toCached(s: MetaSubject, now: Date): DoubanSubject {
+7 -1
View File
@@ -1,7 +1,7 @@
// 沙箱任务表(docs/adr/ADR-005-sandbox.md):只放「外部不可信字节 → 结构化数据」的纯函数。
// 子进程加载完本表后撤销全部权限(文件 / 网络 / 环境变量 / 子进程),所以任务里不能有任何 I/O;
// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。
import { parseTorrent } from "@umt/adapters";
import { parseBangumiBrowser, parseTorrent } from "@umt/adapters";
import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain";
import { buildPreview } from "../modules/file-manager/preview-build.ts";
import { PAGE_PARSERS, type PageKind } from "../modules/subtitles/online/parse.ts";
@@ -121,12 +121,18 @@ function subtitlePage(input: { kind: PageKind; html: string }) {
return fn(input.html);
}
/** bgm.tv 动画浏览页(热门趋势,发现页)→ 条目列表 */
function bangumiTrends(input: { html: string }) {
return parseBangumiBrowser(input.html);
}
export const SANDBOX_JOBS = {
"subtitle.parse": subtitleParse,
"torrent.parse": torrentParse,
"file.preview": buildPreview,
"captcha.solve": captchaSolve,
"subtitle.page": subtitlePage,
"bangumi.trends": bangumiTrends,
"self.test": selfTest,
} as const;
+274
View File
@@ -0,0 +1,274 @@
import { createTmdbClient, parseBangumiBrowser } from "@umt/adapters";
import { type DB, type Kysely, pgCacheFor } from "@umt/db";
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
import { afterAll, beforeAll, beforeEach, 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 { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { setTrendsParser } from "../../src/modules/search/discover.ts";
import { createSearchModule } from "../../src/modules/search/index.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import { META_REQUEST_REGIONS } from "../../src/modules/sources-identity/service/meta-subjects.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { SANDBOX_JOBS } from "../../src/sandbox/jobs.ts";
import { DEV_KEY, memoryLogger } from "../helpers.ts";
import { fakeEngine, fakeSecrets, plainSettings } from "../sources-identity/harness.ts";
import { createTestSchema, pgAvailable, type TestSchema, uniqueSchema } from "../support/pg.ts";
// 发现页接口:TMDB 列表分页、筛选参数校验、豆瓣 / bgm 请求缓存(命中、空结果短 TTL、失败不缓存)、本地状态标注、
// 豆瓣 / bgm 条目确定匹配后记住并用于标注。外站全部是 fixture(mock TMDB + 假 fetch)。
const up = await pgAvailable();
// biome-ignore lint/suspicious/noExplicitAny: 测试直接读 JSON
type Loose = any;
const DOUBAN_ITEM = (id: string, title: string, year = "2023") => ({
id,
title,
type: "movie",
year,
rating: { value: 8.1 },
pic: { large: `https://img.doubanio.com/${id}.jpg` },
card_subtitle: `${year} / 美国 / 剧情 传记 / 导演 / 主演`,
});
const BGM_HTML = `<ul id="browserItemList"><li id="item_400602" class="item"><div class="inner">
<h3><a href="/subject/400602" class="l">葬送的芙莉莲</a></h3><p class="info tip">28话 / 2023年9月29日</p></div></li></ul>`;
describe.skipIf(!up)("发现页接口", () => {
let ts: TestSchema;
let db: Kysely<DB>;
let tmdb: MockTmdb;
let app: ReturnType<typeof createApp>;
const meta: string[] = [];
/** 下一次豆瓣榜单请求的应答 */
let doubanReply: () => Response = () => new Response("{}");
const req = async (path: string, body?: unknown) => {
const res = await app.request(`/api/v1${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
authorization: `Bearer ${DEV_KEY}`,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: res.status, body: (await res.json()) as Loose };
};
const json = (b: unknown, status = 200) =>
new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json" } });
beforeAll(async () => {
tmdb = await createMockTmdb();
ts = await createTestSchema(uniqueSchema("discover"), { max: 8 });
db = ts.db;
const { logger } = memoryLogger();
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: fakeSecrets(() => db),
settings: plainSettings(() => db),
capabilities: createCapabilityRegistry({ logger }),
} as ServerContext;
const metaFetch: typeof fetch = async (input, init) => {
const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
meta.push(`${init?.method ?? "GET"} ${u.host}${u.pathname}${u.search}`);
if (u.pathname.startsWith("/rexxar/api/v2/subject_collection/")) return doubanReply();
if (u.pathname === "/j/subject_suggest")
return json([{ id: "26100958", title: "奥本海默", year: "2023", episode: "", type: "movie" }]);
if (u.pathname === "/j/search_subjects") return json({ subjects: [] });
if (u.host === "bgm.tv" && u.pathname === "/anime/browser") return new Response(BGM_HTML);
if (u.host === "api.bgm.tv" && u.pathname === "/v0/subjects")
return json({ data: [{ id: 1, name: "a", name_cn: "甲" }], total: 45 });
throw new Error(`测试不允许的请求 ${u.href}`);
};
const sources = createSourcesIdentityModule({
tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }),
metaFetch,
});
const search = createSearchModule({ runtime: (c) => sources.runtime(c) });
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(), search],
ctx,
});
// 趋势页解析缺省走沙箱子进程;这里在进程内跑同一个解析函数(沙箱任务登记见下面的用例)
setTrendsParser(async (html) => parseBangumiBrowser(html));
// 本地作品:奥本海默(TMDB movie 872585)已订阅
const e = await db
.insertInto("media_entity")
.values({ kind: "movie", title: "奥本海默", year: 2023 })
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("external_identity")
.values({
media_entity_id: e.id,
provider: "tmdb",
entity_type: "movie",
external_id: "872585",
status: "accepted",
})
.execute();
await db
.insertInto("subscription")
.values({ media_entity_id: e.id, media_kind: "movie", status: "active" })
.execute();
}, 120_000);
afterAll(async () => {
await ts?.drop();
await tmdb?.close();
});
beforeEach(() => {
meta.length = 0;
});
it("TMDB 流行趋势:分页、TMDB 引用、本地状态", async () => {
const r = await req("/discover/tmdb?list=trending&type=all&window=week");
expect(r.status).toBe(200);
const d = r.body.data;
expect(d.page).toBe(1);
expect(d.hasMore).toBe(false);
expect(d.items.length).toBeGreaterThan(3);
const opp = d.items.find((i: Loose) => i.tmdb?.id === "872585");
expect(opp).toMatchObject({
source: "tmdb",
id: "movie:872585",
kind: "movie",
posterPath: "/oppenheimer.jpg",
genres: ["剧情", "历史"],
local: { subscribed: true, versionCount: 0, downloadingCount: 0 },
});
expect(d.items.filter((i: Loose) => i.tmdb?.id !== "872585").every((i: Loose) => i.local === null)).toBe(true);
expect(tmdb.callsTo("/3/trending/all/week")).toHaveLength(1);
});
it("TMDB 榜单与筛选:参数传到 TMDB", async () => {
const r = await req("/discover/tmdb?list=discover&type=movie&genre=878&sort=rating&year=2020s&minRating=6");
expect(r.status).toBe(200);
const call = tmdb.callsTo("/3/discover/movie").at(-1)!;
expect(call.query).toMatchObject({
with_genres: "878",
sort_by: "vote_average.desc",
"primary_release_date.gte": "2020-01-01",
"primary_release_date.lte": "2029-12-31",
"vote_average.gte": "6",
});
const now = await req("/discover/tmdb?list=now_playing&type=movie&page=1");
expect(now.body.data.items.every((i: Loose) => i.kind === "movie")).toBe(true);
});
it("筛选参数校验:不配的类型 / 列表 / 风格 / 关键词 / 页码 → 422", async () => {
for (const q of [
"list=now_playing&type=tv",
"list=popular&type=all",
"list=popular&type=movie&genre=878",
"list=discover&type=tv&genre=28",
"list=discover&type=movie&year=1888",
"list=discover&type=movie&minRating=11",
"list=search&type=all",
"list=trending&q=x",
"list=trending&page=0",
"list=trending&page=501",
]) {
const r = await req(`/discover/tmdb?${q}`);
expect(r.status, q).toBe(422);
}
for (const q of ["list=nope", "list=tag&tag=不存在", "list=explore&genre=不存在", "list=movie_top250&sort=S"]) {
expect((await req(`/discover/douban?${q}`)).status, q).toBe(422);
}
expect((await req("/discover/bangumi?list=trends&year=2024")).status).toBe(422);
expect((await req("/discover/bangumi?list=search")).status).toBe(422);
});
it("豆瓣榜单:请求缓存命中;空结果短 TTL;失败不缓存", async () => {
const cache = pgCacheFor(db);
// 失败(403)不缓存
doubanReply = () => new Response("forbidden", { status: 403 });
expect((await req("/discover/douban?list=movie_showing")).status).toBe(502);
expect(await cache.count(META_REQUEST_REGIONS.douban)).toBe(0);
// 风控页(不是 JSON)不缓存
doubanReply = () => new Response("<html>验证</html>", { headers: { "content-type": "text/html" } });
expect((await req("/discover/douban?list=movie_showing")).status).toBe(502);
expect(await cache.count(META_REQUEST_REGIONS.douban)).toBe(0);
doubanReply = () =>
json({ subject_collection_items: [DOUBAN_ITEM("26100958", "奥本海默"), DOUBAN_ITEM("2", "乙")], total: 45 });
const a = await req("/discover/douban?list=movie_showing&page=2");
expect(a.status).toBe(200);
expect(a.body.data).toMatchObject({ page: 2, hasMore: true });
expect(a.body.data.items[0]).toMatchObject({ source: "douban", id: "26100958", genres: ["剧情", "传记"] });
const b = await req("/discover/douban?list=movie_showing&page=2");
expect(b.body.data.items).toHaveLength(2);
expect(meta.filter((m) => m.includes("movie_showing"))).toHaveLength(3);
expect(meta.at(-1)).toContain("start=20&count=20");
// 空结果:写入但用空结果 TTL(缺省 1 小时),正常 6 小时
doubanReply = () => json({ subject_collection_items: [], total: 0 });
await req("/discover/douban?list=tv_korean");
const rows = await cache.items(META_REQUEST_REGIONS.douban);
const ttlOf = (part: string) => {
const row = rows.find((x) => x.key.includes(part))!;
return Math.round((new Date(row.expiresAt!).getTime() - Date.now()) / 60_000);
};
expect(ttlOf("tv_korean")).toBeLessThanOrEqual(60);
expect(ttlOf("tv_korean")).toBeGreaterThan(55);
expect(ttlOf("movie_showing")).toBeGreaterThan(5 * 60);
});
it("搜索与 bgm 浏览 / 趋势", async () => {
const s = await req("/discover/douban?list=search&q=奥本海默");
expect(s.body.data).toMatchObject({ hasMore: false, items: [{ id: "26100958", kind: "movie", year: 2023 }] });
const t = await req("/discover/bangumi?list=trends");
expect(t.body.data.items[0]).toMatchObject({ source: "bangumi", id: "400602", episodes: 28 });
const rank = await req("/discover/bangumi?list=rank&year=2024&page=2");
expect(rank.body.data).toMatchObject({ page: 2, hasMore: true });
expect(meta.find((m) => m.includes("/v0/subjects"))).toContain("offset=20");
expect(SANDBOX_JOBS["bangumi.trends"]({ html: BGM_HTML }).items[0]!.id).toBe("400602");
});
it("豆瓣条目确定匹配后记住,列表据此标本地状态", async () => {
const m = await req("/discover/tmdb-match", {
source: "douban",
id: "26100958",
title: "奥本海默",
aliases: ["Oppenheimer"],
year: 2023,
kind: "movie",
});
expect(m.status).toBe(200);
expect(m.body.data.match).toMatchObject({ type: "movie", id: "872585" });
const searches = tmdb.callsTo("/3/search").length;
const again = await req("/discover/tmdb-match", {
source: "douban",
id: "26100958",
title: "奥本海默",
aliases: [],
year: 2023,
kind: "movie",
});
expect(again.body.data.match.id).toBe("872585");
expect(tmdb.callsTo("/3/search").length).toBe(searches);
const s = await req("/discover/douban?list=search&q=奥本海默");
expect(s.body.data.items[0]).toMatchObject({
tmdb: { type: "movie", id: "872585" },
local: { subscribed: true },
});
});
});