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
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:48:46 +08:00
1 parent f6d976a963
commit 9d7e20067b
9 files changed
+1560 -10

No files matched your search

+55 -4
View File
@@ -1,5 +1,11 @@
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import { fetchRichDetail, fetchRichSeason, TmdbError, type TmdbRichDetail } from "@umt/adapters";
import {
fetchRichDetail,
fetchRichSeason,
type MetadataCandidate,
TmdbError,
type TmdbRichDetail,
} from "@umt/adapters";
import {
Api,
type ApiScope,
@@ -38,6 +44,7 @@ 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,
@@ -165,6 +172,33 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
}
};
/** 按作品搜资源时判定归属用的 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();
@@ -196,7 +230,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
}
if (q.tmdb) {
const [type, id] = q.tmdb.split(":") as ["movie" | "tv", string];
const m = await tmdbDetails(type, id);
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 至少给一个");
@@ -279,18 +313,32 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
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 [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,
@@ -445,6 +493,9 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
});
}
/** 「可能不是这部作品」最多给多少条 */
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;