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;
@@ -0,0 +1,239 @@
import type { MetadataCandidate } from "@umt/adapters";
import { normalizeTitle } from "@umt/domain";
import type { ServerContext } from "../types.ts";
import type { BaseRelease } from "./releases.ts";
// 按作品搜资源时,判定每条结果是否属于这部作品(对照 MP TorrentHelper.match_torrent,
// app/application/torrent/download.py):站点按关键词搜,简介里提到作品名的专辑、别的作品也会被搜到。
// 不属于的不参与筛选与分面,列表最后折叠展示(仍可下载)。关键词搜索不做这一步。
//
// 判定顺序:
// 1. 已识别到本地作品:同一作品(或同一 TMDB 身份)→ 属于;别的作品 → 不属于。
// 2. 未识别:分类是音乐 / 游戏 / 软件 / 书 → 不属于;类型(剧集资源 ≠ 电影作品)、年份、季集范围对不上 → 不属于;
// 再按名字:解析出的中 / 英文名命中作品名、原名或别名;标题里的非英文词命中作品名 / 原名;
// 副标题 / 简介里的非英文词命中作品名、原名或别名 → 属于。都没命中 → 不属于。
// 与 MP 的差别(为了不误伤,记在这里):
// - 剧集年份允许与任一季首播年份相差 1 年(MP 要求等于某季年份;蓝光常在播出后一年发行);
// - 单季集号超出该季集数、但不超过全部季总集数时算属于(动画常按绝对集号写 `S01E25-E48`);
// - 只有拿到 TMDB 详情(季列表)时才核对季集范围与各季年份;本地季单元可能过期(新季还没刷新),不用来否决;
// - 标题 / 副标题拆词时同时按 `[` `]` 拆;解析出的英文名去掉末尾的 `Vol.N` 再比。
export interface WorkTarget {
/** 本地作品 ID;只在 TMDB 上的作品为 null */
entityId: string | null;
/** 已接受的 TMDB 身份:`tv:123` / `movie:456` */
tmdb: string | null;
movie: boolean;
/** 作品名、原名(归一化) */
titles: Set<string>;
/** 别名、译名(归一化) */
names: Set<string>;
year: number | null;
/** 季号 > 0 的各季:首播年份与集数;null = 没拿到季信息(不核对季集范围) */
seasons: { season: number; year: number | null; episodes: number | null }[] | null;
}
/** 资源已识别到的本地作品(跟随合并)及其 TMDB 身份 */
export interface ReleaseLink {
entityId: string;
tmdb: string | null;
}
const norm = (s: string | null | undefined) => (s ? normalizeTitle(s) : "");
/** 非音视频分类:音乐 / 游戏 / 软件 / 书 / 学习 */
const NON_VIDEO_CATEGORY = /音乐|音樂|music|游戏|遊戲|game|软件|軟體|software|电子书|電子書|e-?book|有声书|学习|學習/i;
/** 站点分类明确是剧集 */
const TV_CATEGORY = /剧集|劇集|电视剧|電視劇|连续剧|連續劇|tv\s*series|综艺|綜藝/i;
/** MP is_english_word:不含空格的纯 ASCII 字母词(容易误匹配,拆词比对时跳过) */
const englishWord = (t: string) => /^[A-Za-z]+$/.test(t);
function splitNames(text: string, sep: RegExp): string[] {
return text
.split(sep)
.filter((t) => t && !englishWord(t))
.map(norm)
.filter(Boolean);
}
/** 资源的季范围(没有季号为空) */
function releaseSeasons(r: BaseRelease): number[] {
const s = r.parsed.season;
if (s === null || s === undefined) return [];
const end = r.parsed.seasonEnd && r.parsed.seasonEnd > s ? r.parsed.seasonEnd : s;
const out: number[] = [];
for (let i = s; i <= end && out.length < 100; i++) out.push(i);
return out;
}
function yearOk(t: WorkTarget, y: number | null): boolean {
if (t.movie) {
// 电影:上下浮动 1 年,资源没写年份不通过(MP 同)
return !!y && !!t.year && Math.abs(y - t.year) <= 1;
}
if (!y || !t.year) return true;
const years = [t.year, ...(t.seasons ?? []).map((s) => s.year).filter((x): x is number => x !== null)];
if (t.seasons?.length) return years.some((x) => Math.abs(x - y) <= 1);
// 没有各季年份:只排除早于首播一年以上的(后面的季年份未知)
return y >= t.year - 1;
}
function topologyOk(t: WorkTarget, r: BaseRelease): boolean {
if (t.movie || !t.seasons?.length) return true;
const seasons = releaseSeasons(r);
const known = new Map(t.seasons.map((s) => [s.season, s.episodes]));
if (seasons.length && !seasons.every((s) => known.has(s))) return false;
if (seasons.length !== 1 || !r.parsed.episodes) return true;
const count = known.get(seasons[0]!);
const maxEp = Math.max(r.parsed.episodes.start, r.parsed.episodes.end);
if (!count || maxEp <= count) return true;
// 绝对集号:不超过全部季的总集数
const total = t.seasons.reduce((n, s) => n + (s.episodes ?? 0), 0);
return maxEp <= total;
}
/** 一条资源是否属于目标作品 */
export function belongsToWork(t: WorkTarget, r: BaseRelease, link: ReleaseLink | null): boolean {
if (link) {
if (t.entityId && link.entityId === t.entityId) return true;
return !!t.tmdb && link.tmdb === t.tmdb;
}
const category = r.category.name ?? "";
if (NON_VIDEO_CATEGORY.test(category)) return false;
const tvRelease = r.parsed.season !== null || r.parsed.episodes !== null;
if (t.movie && (tvRelease || TV_CATEGORY.test(category))) return false;
if (!yearOk(t, r.parsed.year)) return false;
if (!topologyOk(t, r)) return false;
const all = new Set([...t.titles, ...t.names]);
const en = r.parsed.titleEn;
const metaNames = [r.parsed.titleCn, en, en?.replace(/\s+Vol\.?\s*\d+$/i, "")].map(norm).filter(Boolean);
if (metaNames.some((n) => all.has(n))) return true;
// 标题拆词:只比非英文词,只比作品名 / 原名
if (splitNames(r.title, /[\s/【】.[\]-]+/).some((n) => t.titles.has(n))) return true;
// 副标题 / 简介拆词:非英文词比作品名、原名、别名
if (r.subtitle && splitNames(r.subtitle, /[\s/【】|[\]]+/).some((n) => all.has(n))) return true;
return false;
}
const yearOf = (d: string | null | undefined) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null);
/** TMDB 详情 → 目标作品的名字与季信息 */
function fromTmdb(m: MetadataCandidate) {
const seasons = (m.seasons ?? [])
.filter((s) => s.seasonNumber > 0)
.map((s) => ({
season: s.seasonNumber,
year: yearOf(s.airDate),
episodes: s.episodeCount > 0 ? s.episodeCount : null,
}));
return {
titles: [m.title, m.originalTitle],
names: [...m.aliases, ...m.titles.map((x) => x.text)],
seasons: m.entityType === "tv" && seasons.length ? seasons : null,
};
}
export type TmdbDetails = (type: "movie" | "tv", id: string) => Promise<MetadataCandidate | null>;
/** 本地作品 → 目标(名字取作品名 / 原名 / 别名表;季信息取 TMDB 详情,拿不到就不核对季集范围) */
export async function localWorkTarget(
ctx: ServerContext,
entityId: string,
details: TmdbDetails,
): Promise<WorkTarget | null> {
const e = await ctx.db
.selectFrom("media_entity")
.select(["id", "kind", "title", "original_title", "year", "status", "merged_into_id"])
.where("id", "=", entityId)
.executeTakeFirst();
if (!e) return null;
const id = e.status === "merged" && e.merged_into_id ? e.merged_into_id : e.id;
const [aliases, ident] = await Promise.all([
ctx.db.selectFrom("title_alias").select(["alias"]).where("media_entity_id", "=", id).execute(),
ctx.db
.selectFrom("external_identity")
.select(["entity_type", "external_id"])
.where("media_entity_id", "=", id)
.where("provider", "=", "tmdb")
.where("status", "=", "accepted")
.executeTakeFirst(),
]);
const tmdb =
ident && (ident.entity_type === "movie" || ident.entity_type === "tv")
? { type: ident.entity_type as "movie" | "tv", id: ident.external_id }
: null;
const remote = tmdb ? await details(tmdb.type, tmdb.id).catch(() => null) : null;
const r = remote ? fromTmdb(remote) : null;
const titles = [e.title, e.original_title, ...(r?.titles ?? [])].map(norm).filter(Boolean);
const names = [...aliases.map((a) => a.alias), ...(r?.names ?? [])].map(norm).filter(Boolean);
return {
entityId: id,
tmdb: tmdb ? `${tmdb.type}:${tmdb.id}` : null,
movie: e.kind === "movie",
titles: new Set(titles),
names: new Set(names),
year: e.year ?? remote?.year ?? null,
seasons: e.kind === "movie" ? null : (r?.seasons ?? null),
};
}
/** 只在 TMDB 上的作品 → 目标 */
export function tmdbWorkTarget(m: MetadataCandidate): WorkTarget {
const r = fromTmdb(m);
return {
entityId: null,
tmdb: `${m.entityType}:${m.externalId}`,
movie: m.entityType === "movie",
titles: new Set(r.titles.map(norm).filter(Boolean)),
names: new Set(r.names.map(norm).filter(Boolean)),
year: m.year,
seasons: r.seasons,
};
}
/** 资源已识别到的本地作品(跟随合并)与 TMDB 身份 */
export async function releaseLinks(ctx: ServerContext, items: BaseRelease[]): Promise<Map<string, ReleaseLink>> {
const out = new Map<string, ReleaseLink>();
const ids = [...new Set(items.map((i) => i.sourceReleaseId).filter((x): x is string => !!x))];
if (ids.length === 0) return out;
const rows = await ctx.db
.selectFrom("source_release as r")
.innerJoin("media_entity as e", "e.id", "r.media_entity_id")
.select(["r.id", "e.id as entity_id", "e.status", "e.merged_into_id"])
.where("r.id", "in", ids)
.execute();
const entity = new Map(
rows.map((r) => [r.id, r.status === "merged" && r.merged_into_id ? r.merged_into_id : r.entity_id]),
);
const entityIds = [...new Set(entity.values())];
const tmdb = new Map<string, string>();
if (entityIds.length) {
for (const i of await ctx.db
.selectFrom("external_identity")
.select(["media_entity_id", "entity_type", "external_id"])
.where("media_entity_id", "in", entityIds)
.where("provider", "=", "tmdb")
.where("status", "=", "accepted")
.execute())
if (i.media_entity_id) tmdb.set(i.media_entity_id, `${i.entity_type}:${i.external_id}`);
}
for (const it of items) {
const eid = it.sourceReleaseId ? entity.get(it.sourceReleaseId) : undefined;
if (eid) out.set(it.key, { entityId: eid, tmdb: tmdb.get(eid) ?? null });
}
return out;
}
/** 把结果分成属于 / 可能不属于这部作品两组(保持输入顺序) */
export async function splitByWork(
ctx: ServerContext,
target: WorkTarget,
items: BaseRelease[],
): Promise<{ matched: BaseRelease[]; unmatched: BaseRelease[] }> {
const links = await releaseLinks(ctx, items);
const matched: BaseRelease[] = [];
const unmatched: BaseRelease[] = [];
for (const it of items) (belongsToWork(target, it, links.get(it.key) ?? null) ? matched : unmatched).push(it);
return { matched, unmatched };
}