Files
UHD_Media_Tool/apps/server/src/modules/search/routes.ts
T
rootandClaude Opus 5.5 709414778e feat(identity): 豆瓣 / bgm.tv 辅助信息源与发现页
- adapters/meta:bgm.tv 官方 API(条目 / 搜索 / 每日放送)与豆瓣移动版接口(条目 / 实时热门)
- 识别升级链:豆瓣条目缓存没有时在线查;AniBT 的 bgm ID、公开 BT 站标题搜 bgm → 名称 + 年份匹配 TMDB(季条目去季名与年份)
- 条目缓存 30 天,查不到 6 小时负缓存;测试进程缺省不出外网
- 发现页:bgm 每日放送、豆瓣热门电影 / 剧集,点条目匹配 TMDB 作品

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 21:30:16 +08:00

454 lines
18 KiB
TypeScript

import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import { fetchRichDetail, fetchRichSeason, TmdbError, type TmdbRichDetail } from "@umt/adapters";
import {
Api,
type ApiScope,
apiResponse,
DEFAULT_SETTINGS,
type DiscoverHotQuery,
type DiscoverMatchRequest,
type EndpointDef,
type InventoryEntry,
type InventoryResponse,
type ManualAcquisitionResult,
type MediaServerInventory,
type ReleaseSearchPage,
type ReleaseSearchQuery,
type SearchQuery,
type SearchReleaseParams,
type SearchReleasePlanRequest,
type SearchResponse,
type TmdbMediaDetail,
type TmdbMediaParams,
type TmdbMediaQuery,
type TmdbSeasonDetailView,
type TmdbSeasonParams,
} from "@umt/contracts";
import type { SiteRow } from "@umt/db";
import type { Context } from "hono";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { metadataLanguages, metadataTextOptions } from "../sources-identity/service/metadata-rich.ts";
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 { InternalDispatcher } from "./dispatch.ts";
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts";
import {
buildWorks,
detailSeasons,
localInfo,
localSeasonHave,
searchTerms,
slotCountsFor,
workInventory,
} from "./works.ts";
export interface SearchDeps {
runtime: () => SourcesRuntime;
siteTimeoutMs: number;
siteConcurrency: number;
cacheTtlMs: number;
tmdbCacheTtlMs: number;
now: () => number;
}
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
interface Input<D extends EndpointDef> {
params: Infer<D["params"]>;
query: Infer<D["query"]>;
body: Infer<D["body"]>;
c: Context<AuthEnv>;
}
function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (input: Input<D>) => Promise<unknown>) {
const request: RouteConfig["request"] = {};
if (def.params) request.params = def.params as never;
if (def.query) request.query = def.query as never;
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
const r = createRoute({
method: def.method,
path: def.path,
summary: def.summary,
tags: ["search"],
middleware: [requireScope(def.scope as ApiScope)] as never,
request,
responses: {
200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
app.openapi(r, (async (c: any) => {
const input = {
params: def.params ? c.req.valid("param") : {},
query: def.query ? c.req.valid("query") : {},
body: def.body ? c.req.valid("json") : {},
c,
} as Input<D>;
const data = def.response.parse(await handler(input));
return c.json({ ok: true, data }, 200);
// biome-ignore lint/suspicious/noExplicitAny: 同上
}) as any);
}
const since = (t0: number) => Math.round(performance.now() - t0);
export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: SearchDeps) {
const fanout = new ReleaseFanout(d.runtime, {
timeoutMs: d.siteTimeoutMs,
concurrency: d.siteConcurrency,
ttlMs: d.cacheTtlMs,
now: d.now,
});
const tmdb = new TmdbSearch(d.runtime, { ttlMs: d.tmdbCacheTtlMs, timeoutMs: d.siteTimeoutMs, now: d.now });
const dispatch = new InternalDispatcher(app);
/** 库存叠加:转调 GET /inventory(每次最多 200 个作品) */
const inventory =
(c: Context<AuthEnv>) =>
async (ids: string[]): Promise<Map<string, InventoryEntry>> => {
const out = new Map<string, InventoryEntry>();
for (let i = 0; i < ids.length; i += 200) {
const chunk = ids.slice(i, i + 200);
const r = await dispatch.call<InventoryResponse>(
c,
"GET",
`/inventory?entityIds=${encodeURIComponent(chunk.join(","))}`,
);
for (const e of r.items) out.set(e.entityId, e);
}
return out;
};
const csvIds = (s: string | undefined) =>
(s ?? "")
.split(",")
.map((x) => x.trim())
.filter(Boolean);
/** 参与搜索的站点:给了 sites 只查这些(仍须启用);否则按 settings.search.defaultSiteMode */
const resolveSites = async (sites: string | undefined): Promise<SiteRow[]> => {
const all = await fanout.enabledSites();
const ids = csvIds(sites);
if (ids.length) {
const set = new Set(ids);
return all.filter((s) => set.has(s.id));
}
const cfg = {
...DEFAULT_SETTINGS.search,
...(await ctx.settings.get("search").catch(() => DEFAULT_SETTINGS.search)),
};
if (cfg.defaultSiteMode === "all") return all;
if (cfg.defaultSiteMode === "custom") {
const set = new Set(cfg.siteIds);
return all.filter((s) => set.has(s.id));
}
return all.filter((s) => s.search_default);
};
const tmdbDetails = async (type: "movie" | "tv", id: string) => {
const client = await d.runtime().tmdb();
if (!client) throw new ApiException("precondition_failed", "未配置 TMDB。到设置的元数据分区填写 API Key");
try {
return await raceAbort(client.details(type, id), AbortSignal.timeout(d.siteTimeoutMs), "TMDB 查询超时");
} catch (e) {
if (e instanceof TmdbError && e.kind === "notfound")
throw new ApiException("not_found", `TMDB 没有 ${type}/${id}`);
throw new ApiException(
"upstream_unavailable",
`TMDB 查询失败:${e instanceof TmdbError ? e.kind : (e as Error).message}`,
);
}
};
/** MD-1 富详情(语言回退链、类型、评分、主创);失败返回 null,详情页退回基础字段 */
const tmdbRich = async (type: "movie" | "tv", id: string): Promise<TmdbRichDetail | null> => {
const client = await d.runtime().tmdb();
if (!client || typeof client.getJson !== "function") return null;
try {
const langs = await metadataLanguages(ctx);
return await raceAbort(
fetchRichDetail(client, type, id, langs, await metadataTextOptions(ctx)),
AbortSignal.timeout(d.siteTimeoutMs),
"TMDB 查询超时",
);
} catch (e) {
ctx.logger.debug({ type, id, err: { message: (e as Error).message } }, "TMDB 富详情获取失败");
return null;
}
};
/** 查询词:给了 q 就只用 q;按作品搜时由作品名 / 原名 / 年份生成 */
const releaseTerms = async (q: ReleaseSearchQuery): Promise<string[]> => {
if (q.q) return [q.q];
if (q.entityId) {
const e = await ctx.db
.selectFrom("media_entity")
.select(["title", "original_title", "year", "kind"])
.where("id", "=", q.entityId)
.executeTakeFirst();
if (!e) throw new ApiException("not_found", "作品不存在");
return searchTerms({ title: e.title, originalTitle: e.original_title, year: e.year, movie: e.kind === "movie" });
}
if (q.tmdb) {
const [type, id] = q.tmdb.split(":") as ["movie" | "tv", string];
const m = await tmdbDetails(type, id);
return searchTerms({ title: m.title, originalTitle: m.originalTitle, year: m.year, movie: type === "movie" });
}
throw new ApiException("validation_failed", "q、entityId、tmdb 至少给一个");
};
route(app, Api.search.all, async ({ query, c }) => {
const t0 = performance.now();
const q = query as SearchQuery;
const want = (s: SearchQuery["scope"]) => q.scope === s || (q.scope === "all" && s !== "releases");
const loadInv = inventory(c);
const media = want("media")
? Promise.all([
(async () => {
const t = performance.now();
const hits = await searchLocalMedia(ctx, q.q, q.limit);
return { hits, tookMs: since(t) };
})(),
(async () => {
const t = performance.now();
const r = await tmdb.search(q.q, q.limit);
return { ...r, tookMs: since(t) };
})(),
]).then(async ([local, tm]) => {
// 库存对比:本地命中 + TMDB 候选里已建档的作品一起查一次
const ids = [
...new Set([
...local.hits.map((h) => h.entity.id),
...tm.items.map((c) => c.localEntityId).filter((x): x is string => !!x),
]),
];
const [inv, info, counts] = ids.length
? await Promise.all([loadInv(ids), localInfo(ctx, ids), slotCountsFor(ctx, ids)])
: [new Map(), new Map(), new Map()];
return {
local: { items: withInventory(local.hits, inv), tookMs: local.tookMs },
tmdb: tm,
works: buildWorks(local.hits, tm.items, info, inv, counts),
};
})
: Promise.resolve(null);
// SEARCH-2:资源只在 scope=releases 时查(all 不再自动查站点),站点按 settings.search 的默认集合
const releases =
q.scope === "releases"
? (async () => {
const t = performance.now();
const hit = await fanout.search([q.q], await resolveSites(undefined));
const sorted = sortReleases(hit.fanout.items, "default", "desc");
const items = await decorate(d.runtime(), sorted.slice(0, q.limit), loadInv);
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
return {
items,
total: sorted.length,
partial: failed.length > 0,
sites: hit.fanout.sites,
failedSites: failed,
cached: hit.cached,
coalesced: hit.coalesced,
fetchedAt: hit.fanout.fetchedAt,
tookMs: since(t),
};
})()
: Promise.resolve(null);
const actions = want("actions")
? (() => {
const t = performance.now();
const items = matchActions(q.q, q.limit);
return { items, tookMs: since(t) };
})()
: null;
const [m, r] = await Promise.all([media, releases]);
const out: SearchResponse = { q: q.q, scope: q.scope, media: m, releases: r, actions, tookMs: since(t0) };
return out;
});
route(app, Api.search.releases, async ({ query, c }) => {
const t0 = performance.now();
const q = query as ReleaseSearchQuery;
const terms = await releaseTerms(q);
const hit = await fanout.search(terms, await resolveSites(q.sites), { refresh: q.refresh === "true" });
const all = hit.fanout.items;
const quick = quickFilter(all, q, d.now());
const filtered = sortReleases(quick.items, q.sort, q.order);
const offset = decodeCursor(q.cursor);
const pageItems = filtered.slice(offset, offset + q.limit);
const next = offset + q.limit < filtered.length ? encodeCursor(offset + q.limit) : null;
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
const out: ReleaseSearchPage = {
q: q.q ?? terms[0]!,
queries: terms,
items: await decorate(d.runtime(), pageItems, inventory(c)),
nextCursor: next,
total: filtered.length,
unfilteredTotal: all.length,
facets: { ...facets(all), ...quick.facets },
partial: failed.length > 0,
sites: hit.fanout.sites,
failedSites: failed,
cached: hit.cached,
coalesced: hit.coalesced,
fetchedAt: hit.fanout.fetchedAt,
tookMs: since(t0),
};
return out;
});
route(app, Api.search.plan, async ({ params, body, c }) => {
const p = params as SearchReleaseParams;
const rt = d.runtime();
const find = () =>
rt.ctx.db
.selectFrom("source_release")
.select(["id"])
.where("site_id", "=", p.siteId)
.where("site_release_id", "=", p.siteReleaseId)
.executeTakeFirst();
let row = await find();
if (!row) {
// 搜索时落库失败的兜底:按缓存里的站点结果补写
const cached = fanout.findCached(p.siteId, p.siteReleaseId);
const site = cached
? await rt.ctx.db.selectFrom("site").select(["id"]).where("id", "=", p.siteId).executeTakeFirst()
: undefined;
if (cached && site) {
await rt.ctx.db.transaction().execute((trx) => upsertSiteRelease(rt, trx, site, cached.raw));
row = await find();
}
}
if (!row) throw new ApiException("not_found", "资源不在搜索结果里,重新搜索后再下载");
return dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
...(body as SearchReleasePlanRequest),
sourceReleaseId: row.id,
});
});
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.discoverMatch, ({ body }) => matchTmdb(d.runtime(), body as DiscoverMatchRequest));
route(app, Api.search.tmdbDetail, async ({ params, query, c }) => {
const p = params as TmdbMediaParams;
const inc = (query as TmdbMediaQuery).include;
const [m, rich] = await Promise.all([tmdbDetails(p.type, p.id), tmdbRich(p.type, p.id)]);
const localId =
(await localEntities(ctx, [{ entityType: p.type, externalId: p.id }])).get(`${p.type}:${p.id}`) ?? null;
const [inv, counts] = localId
? await Promise.all([inventory(c)([localId]), slotCountsFor(ctx, [localId])])
: [new Map<string, InventoryEntry>(), new Map()];
const have =
p.type === "tv" && localId
? await localSeasonHave(
ctx,
localId,
(m.seasons ?? []).map((s) => ({ seasonNumber: s.seasonNumber, episodeCount: s.episodeCount })),
)
: null;
const mediaServers =
inc === "mediaServers"
? await dispatch
.call<MediaServerInventory>(c, "GET", `/inventory/media-servers?tmdbType=${p.type}&tmdbId=${p.id}`)
.catch(() => null)
: null;
const out: TmdbMediaDetail = {
provider: "tmdb",
entityType: p.type,
externalId: p.id,
kind: p.type === "movie" ? "movie" : "series",
title: m.title,
originalTitle: m.originalTitle,
originalLanguage: m.originalLanguage,
year: m.year,
releaseDate: m.releaseDate,
overview: m.overview,
posterPath: m.posterPath,
backdropPath: m.backdropPath ?? null,
originCountry: m.originCountry,
aliases: [...new Set(m.aliases)].filter((x) => x !== m.title).slice(0, 20),
externalIds: { imdb: m.externalIds?.imdb ?? null, tvdb: m.externalIds?.tvdb ?? null },
seasons: detailSeasons(m, have),
localEntityId: localId,
inventory: workInventory(localId ? (inv.get(localId) ?? null) : null, localId ? counts.get(localId) : undefined),
mediaServers,
queries: searchTerms({ title: m.title, originalTitle: m.originalTitle, year: m.year, movie: p.type === "movie" }),
};
if (rich) {
// 标题 / 简介按语言回退链;季名 / 简介 / 海报取富详情
out.title = rich.title || out.title;
out.overview = rich.overview ?? out.overview;
out.titleZh = rich.titleZh;
out.tagline = rich.tagline;
out.genres = rich.genres;
out.voteAverage = rich.voteAverage;
out.voteCount = rich.voteCount;
out.airStatus = rich.status;
out.runtime = rich.runtime;
out.episodeCount = rich.episodeCount;
out.lastAirDate = rich.lastAirDate;
out.platform = rich.networks.join(" / ") || null;
out.cast = rich.cast;
out.crew = rich.crew;
out.backdropPath = out.backdropPath ?? rich.backdropPath;
out.aliases = [
...new Set([rich.titleZh, ...out.aliases].filter((x): x is string => !!x && x !== rich.title)),
].slice(0, 20);
out.seasons = out.seasons.map((s) => {
const r = rich.seasons.find((x) => x.seasonNumber === s.seasonNumber);
return r ? { ...s, overview: r.overview, posterPath: r.posterPath } : s;
});
}
return out;
});
route(app, Api.search.tmdbSeason, async ({ params }) => {
const p = params as TmdbSeasonParams;
const client = await d.runtime().tmdb();
if (!client || typeof client.getJson !== "function")
throw new ApiException("precondition_failed", "未配置 TMDB。到设置的元数据分区填写 API Key");
try {
const langs = await metadataLanguages(ctx);
const sd = await raceAbort(
fetchRichSeason(client, p.id, p.season, langs, await metadataTextOptions(ctx)),
AbortSignal.timeout(d.siteTimeoutMs * 2),
"TMDB 查询超时",
);
const out: TmdbSeasonDetailView = {
tvId: sd.tvId,
seasonNumber: sd.seasonNumber,
name: sd.name,
overview: sd.overview,
airDate: sd.airDate,
posterPath: sd.posterPath,
episodes: sd.episodes,
};
return out;
} catch (e) {
if (e instanceof TmdbError && e.kind === "notfound")
throw new ApiException("not_found", `TMDB 没有 tv/${p.id} 第 ${p.season} 季`);
throw new ApiException(
"upstream_unavailable",
`TMDB 查询失败:${e instanceof TmdbError ? e.kind : (e as Error).message}`,
);
}
});
}
const encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url");
function decodeCursor(cursor: string | undefined): number {
if (!cursor) return 0;
const n = Number(Buffer.from(cursor, "base64url").toString("utf8"));
return Number.isSafeInteger(n) && n >= 0 ? n : 0;
}