Files
UHD_Media_Tool/apps/server/src/modules/search/routes.ts
T
rootandClaude Opus 5.5 9d7e20067b feat(search): 按作品搜资源时判定每条结果是否属于这部作品
对照 MP TorrentHelper.match_torrent:已识别到同一本地作品(或同一 TMDB 身份)的属于、识别到别的作品的不属于;
未识别的先排除音乐 / 游戏 / 软件 / 书分类,再核对类型、年份(剧集按各季首播年份 ±1)、季集范围(TMDB 季列表,
单季集号可按全部季总集数的绝对集号),最后按解析出的中 / 英文名、标题非英文词、副标题非英文词比对作品名、原名、别名。
不属于的不参与筛选与分面,响应里单独给出(unmatched / unmatchedTotal,只在第一页,最多 200 条),
页面在列表最后折叠为「另有 N 条可能不是这部作品」,展开后仍可下载。关键词搜索不做这一步。
用「药屋少女的呢喃」109 条真实结果做样本:只有《迷宫饭》片尾曲专辑和标题写成 S01-S24 的一条被判为不属于。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 15:48:46 +08:00

505 lines
20 KiB
TypeScript

import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import {
fetchRichDetail,
fetchRichSeason,
type MetadataCandidate,
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 { localWorkTarget, splitByWork, tmdbWorkTarget, type WorkTarget } from "./work-match.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}`,
);
}
};
/** 按作品搜资源时判定归属用的 TMDB 详情(季列表、别名):按 TMDB 缓存时长复用,失败返回 null */
const detailCache = new Map<string, { expires: number; value: Promise<MetadataCandidate | null> }>();
const cachedDetails = (type: "movie" | "tv", id: string): Promise<MetadataCandidate | null> => {
const key = `${type}:${id}`;
const hit = detailCache.get(key);
if (hit && hit.expires > d.now()) return hit.value;
// 失败不缓存:下次重试
const value = tmdbDetails(type, id).catch(() => {
detailCache.delete(key);
return null;
});
for (const [k, v] of detailCache) if (v.expires <= d.now()) detailCache.delete(k);
detailCache.set(key, { expires: d.now() + d.tmdbCacheTtlMs, value });
return value;
};
/** 按作品搜(entityId / tmdb)时的目标作品;关键词搜索为 null(不判定归属) */
const workTarget = async (q: ReleaseSearchQuery): Promise<WorkTarget | null> => {
if (q.entityId) return localWorkTarget(ctx, q.entityId, cachedDetails);
if (!q.tmdb) return null;
const [type, id] = q.tmdb.split(":") as ["movie" | "tv", string];
const local = (await localEntities(ctx, [{ entityType: type, externalId: id }])).get(q.tmdb);
if (local) return localWorkTarget(ctx, local, cachedDetails);
const m = await cachedDetails(type, id);
return m ? tmdbWorkTarget(m) : null;
};
/** 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 cachedDetails(type, id)) ?? (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, target] = await Promise.all([
fanout.search(terms, await resolveSites(q.sites), { refresh: q.refresh === "true" }),
workTarget(q),
]);
// 按作品搜:不属于这部作品的不参与筛选与分面,第一页另外给出(列表最后折叠展示)
const split = target ? await splitByWork(ctx, target, hit.fanout.items) : null;
const all = split ? split.matched : 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 others =
split && offset === 0 && split.unmatched.length
? await decorate(
d.runtime(),
sortReleases(split.unmatched, q.sort, q.order).slice(0, UNMATCHED_MAX),
inventory(c),
)
: [];
const out: ReleaseSearchPage = {
q: q.q ?? terms[0]!,
queries: terms,
items: await decorate(d.runtime(), pageItems, inventory(c)),
...(split ? { unmatched: others, unmatchedTotal: split.unmatched.length } : {}),
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 UNMATCHED_MAX = 200;
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;
}