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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 21:30:16 +08:00
1 parent b8876542b8
commit 709414778e
23 files changed
+958 -31

No files matched your search

@@ -0,0 +1,69 @@
import type { MetadataCandidate } from "@umt/adapters";
import { type DiscoverMatchRequest, DiscoverMatchResult } from "@umt/contracts";
import { pgCacheFor } from "@umt/db";
import { type IdentityCandidate, parseRelease, resolveIdentity } from "@umt/domain";
import { ApiException } from "../../http/errors.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";
// 发现(对照 MP 推荐页的 Bangumi 每日放送、豆瓣热门):列表来自 bgm.tv / 豆瓣,点开时按名称 + 年份匹配 TMDB 作品。
const REGION = "discover.list";
const TTL_SEC = 3600;
function clients(rt: SourcesRuntime) {
const c = metaClients(rt);
if (!c) throw new ApiException("precondition_failed", "辅助信息源未启用");
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;
try {
v = await load();
} catch (e) {
throw new ApiException("upstream_unavailable", (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() }));
}
export function doubanHot(rt: SourcesRuntime, kind: "movie" | "tv") {
return cached(rt, `douban:hot:${kind}`, async () => ({ items: await clients(rt).douban.hot(kind, 30) }));
}
const view = (m: MetadataCandidate) => ({
type: m.entityType as "movie" | "tv",
id: m.externalId,
title: m.title,
year: m.year,
posterPath: m.posterPath,
});
export async function matchTmdb(rt: SourcesRuntime, req: DiscoverMatchRequest): Promise<DiscoverMatchResult> {
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";
const d = await subjectRecall(rt, base, subject);
if (!d) throw new ApiException("precondition_failed", "TMDB 未配置");
// 条目自带类型时只要同类型
const remote = req.kind ? d.remote.filter((m) => m.entityType === req.kind) : d.remote;
const byCand = new Map<IdentityCandidate, MetadataCandidate>();
for (const m of remote) byCand.set(tmdbToCandidate(m), m);
const res = resolveIdentity(d.parsed, [...byCand.keys()]);
const ranked = res.ranked
.filter((r) => r.level !== "rejected")
.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) });
}
+7
View File
@@ -5,6 +5,8 @@ import {
type ApiScope,
apiResponse,
DEFAULT_SETTINGS,
type DiscoverHotQuery,
type DiscoverMatchRequest,
type EndpointDef,
type InventoryEntry,
type InventoryResponse,
@@ -32,6 +34,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 { InternalDispatcher } from "./dispatch.ts";
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts";
@@ -332,6 +335,10 @@ 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.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;
@@ -43,6 +43,8 @@ export interface RuntimeOverrides {
schedulerIntervalMs?: number;
/** false = 站点限流只用进程内令牌桶(缺省走 PG 跨进程令牌桶 rate_limit_bucket) */
sharedRateLimit?: boolean;
/** 豆瓣 / bgm.tv 请求用的 fetch;null = 不在线查(测试缺省不出外网) */
metaFetch?: typeof globalThis.fetch | null;
}
interface CachedAdapter {
@@ -80,6 +82,12 @@ export class SourcesRuntime {
* TMDB 客户端:优先注入(测试);否则读设置 metadata.tmdbApiKeySecretRef(secret 现场解密)与 tmdbBaseUrl,
* 未配置时回退环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。设置变化(引用/地址)时重建。
*/
/** 豆瓣 / bgm.tv 请求:注入优先;测试进程(VITEST)缺省不出外网;否则按环境变量代理出外网 */
metaFetch(): typeof globalThis.fetch | null {
if (this.overrides.metaFetch !== undefined) return this.overrides.metaFetch;
return Deno.env.get("VITEST") ? null : externalFetch;
}
async tmdb(): Promise<TmdbClient | null> {
if (this.overrides.tmdb) {
if (this.tmdbClient === undefined) this.tmdbClient = this.overrides.tmdb();
@@ -138,10 +138,13 @@ export async function loadDetailPage(rt: SourcesRuntime, srId: string): Promise<
/** source_release.raw 里站点给出的外部 ID(列表 _site + 详情 _detail) */
export function siteExplicitIds(
raw: unknown,
): { provider: "imdb" | "douban" | "tmdb"; id: string; type?: "movie" | "tv" }[] {
const r = (raw ?? {}) as { _site?: { imdbId?: unknown; doubanId?: unknown }; _detail?: DetailIds };
const out: { provider: "imdb" | "douban" | "tmdb"; id: string; type?: "movie" | "tv" }[] = [];
const add = (provider: "imdb" | "douban" | "tmdb", id: unknown, type?: "movie" | "tv") => {
): { provider: "imdb" | "douban" | "tmdb" | "bangumi"; id: string; type?: "movie" | "tv" }[] {
const r = (raw ?? {}) as {
_site?: { imdbId?: unknown; doubanId?: unknown; bangumiId?: unknown };
_detail?: DetailIds;
};
const out: { provider: "imdb" | "douban" | "tmdb" | "bangumi"; id: string; type?: "movie" | "tv" }[] = [];
const add = (provider: "imdb" | "douban" | "tmdb" | "bangumi", id: unknown, type?: "movie" | "tv") => {
if (typeof id !== "string" || !id || out.some((x) => x.provider === provider && x.id === id)) return;
out.push({ provider, id, ...(type ? { type } : {}) });
};
@@ -149,6 +152,7 @@ export function siteExplicitIds(
add("imdb", r._site?.imdbId);
add("douban", r._detail?.doubanId);
add("douban", r._site?.doubanId);
add("bangumi", r._site?.bangumiId);
if (r._detail?.tmdb) add("tmdb", r._detail.tmdb.id, r._detail.tmdb.type);
return out;
}
@@ -4,9 +4,10 @@ import type { ServerContext } from "../../types.ts";
// ID-3 豆瓣条目缓存(kv_cache region douban.subject,key = 豆瓣 subject id):只存标题 / 别名 / 年份,供「详情页只有豆瓣 ID」时
// 按标题 + 年份匹配 TMDB(对照 MP chain/media/projection.py 豆瓣 → TMDB:取豆瓣条目的原名 / 中文名 / 英文名 + 年份做 TMDB 名称匹配)。
// 本工具不请求豆瓣页面或接口,条目只从已经拿到的数据里抽:
// 条目来源(缓存没有时才在线查,见 meta-subjects.ts):
// - site_detail:站点详情页的豆瓣信息块(◎片名 / ◎译名 / ◎年代,多数由 PT-Gen 从豆瓣生成),详情页同时给了豆瓣 ID 时写入;
// - web_snippet:元数据补全(META-2)网页搜索结果里 douban.com/subject/<id> 的搜索标题与摘要。
// - web_snippet:元数据补全(META-2)网页搜索结果里 douban.com/subject/<id> 的搜索标题与摘要;
// - douban_api:豆瓣移动版接口(识别升级时缓存没有才查)。bgm.tv 条目同形,存 region bangumi.subject。
export const DOUBAN_SUBJECT_REGION = "douban.subject";
const TTL_SEC = 30 * 86_400;
@@ -19,7 +20,8 @@ export interface DoubanSubject {
/** 译名 / 又名 / 原名 */
aliases: string[];
year: number | null;
source: "site_detail" | "web_snippet";
/** site_detail 站点详情页;web_snippet 网页搜索摘要;douban_api 豆瓣接口;bangumi_api bgm.tv 接口(bgm 条目缓存同形) */
source: "site_detail" | "web_snippet" | "douban_api" | "bangumi_api";
/** 出处:站点资源 id 或搜索结果地址 */
ref: string;
at: string;
@@ -93,18 +95,22 @@ export function doubanSubjectFromSnippet(
type Db = Pick<ServerContext, "db">;
/** 写入缓存;站点详情来源优先(已有 site_detail 条目时不用搜索摘要覆盖) */
export async function saveDoubanSubject(ctx: Db, s: DoubanSubject): Promise<void> {
export async function saveDoubanSubject(ctx: Db, s: DoubanSubject, region = DOUBAN_SUBJECT_REGION): Promise<void> {
const cache = pgCacheFor(ctx.db);
if (s.source === "web_snippet") {
const prev = await cache.get<DoubanSubject>(DOUBAN_SUBJECT_REGION, s.id);
const prev = await cache.get<DoubanSubject>(region, s.id);
if (prev?.source === "site_detail") return;
}
await cache.set(DOUBAN_SUBJECT_REGION, s.id, s, { ttl: TTL_SEC, maxsize: MAX_ENTRIES });
await cache.set(region, s.id, s, { ttl: TTL_SEC, maxsize: MAX_ENTRIES });
}
export async function loadDoubanSubject(ctx: Db, id: string): Promise<DoubanSubject | null> {
export async function loadDoubanSubject(
ctx: Db,
id: string,
region = DOUBAN_SUBJECT_REGION,
): Promise<DoubanSubject | null> {
try {
const v = await pgCacheFor(ctx.db).get<DoubanSubject>(DOUBAN_SUBJECT_REGION, id);
const v = await pgCacheFor(ctx.db).get<DoubanSubject>(region, id);
return v && typeof v.title === "string" && v.title ? v : null;
} catch {
return null;
@@ -3,7 +3,8 @@ import { type SourceReleaseRow, sql } from "@umt/db";
import { type ParsedRelease, parseRelease } from "@umt/domain";
import type { SourcesRuntime } from "../runtime.ts";
import type { siteExplicitIds } from "./detail-page.ts";
import { type DoubanSubject, loadDoubanSubject } from "./douban-subject.ts";
import type { DoubanSubject } from "./douban-subject.ts";
import { loadSubject } from "./meta-subjects.ts";
import { refreshReleaseDetail } from "./snapshot.ts";
import { tmdbRecallInput } from "./tmdb-candidate.ts";
@@ -117,16 +118,24 @@ export function newIds(before: SiteIds, after: SiteIds): SiteIds {
}
/**
* 豆瓣 ID → 缓存的豆瓣条目 → 按条目标题 / 别名 + 年份召回 TMDB 候选(对照 MP 豆瓣 → TMDB 投影:原名 / 中文名 / 英文名 + 年份)。
* 打分用的解析 = 豆瓣条目的名称与年份,季集与类型线索沿用资源标题的解析。缓存里没有这个条目 → null(不请求豆瓣)。
* 豆瓣 ID → 豆瓣条目(缓存,没有再在线查)→ 按条目标题 / 别名 + 年份召回 TMDB 候选(对照 MP 豆瓣 → TMDB 投影:原名 / 中文名 / 英文名 + 年份)。
* 打分用的解析 = 条目的名称与年份,季集与类型线索沿用资源标题的解析。拿不到条目 → null。
*/
export async function doubanRecall(
rt: SourcesRuntime,
parsed: ParsedRelease,
doubanId: string,
): Promise<{ subject: DoubanSubject; parsed: ParsedRelease; remote: MetadataCandidate[] } | null> {
const subject = await loadDoubanSubject(rt.ctx, doubanId);
if (!subject) return null;
const subject = await loadSubject(rt, "douban", doubanId);
return subject ? subjectRecall(rt, parsed, subject) : null;
}
/** 已拿到的豆瓣 / bgm 条目 → TMDB 候选(对照 MP Bangumi → TMDB:中文名 / 原名 + 年份) */
export async function subjectRecall(
rt: SourcesRuntime,
parsed: ParsedRelease,
subject: DoubanSubject,
): Promise<{ subject: DoubanSubject; parsed: ParsedRelease; remote: MetadataCandidate[] } | null> {
const tmdb = await rt.tmdb();
if (!tmdb) return null;
const p = doubanParsed(parsed, subject);
@@ -134,22 +143,29 @@ export async function doubanRecall(
return { subject, parsed: p, remote };
}
/** bgm 一季一个条目:「X 第二季」「X 2nd Season」去掉季名,年份是该季首播年,不能拿去比 TMDB 剧集首播年 */
const SEASON_SUFFIX =
/\s*(?:第[一二三四五六七八九十\d]+[季期]|(?:season\s*\d+|\d+(?:st|nd|rd|th)\s+season)|[ⅡⅢⅣⅤ])\s*.*$/i;
const CJK = /[㐀-鿿]/;
/** 豆瓣条目 + 资源解析 → 打分用的解析(名称 / 年份取豆瓣,季集 / 类型取资源) */
export function doubanParsed(parsed: ParsedRelease, s: DoubanSubject): ParsedRelease {
const names = [s.title, ...s.aliases];
const bgm = s.source === "bangumi_api";
const stripped = bgm ? [s.title, ...s.aliases].map((n) => n.replace(SEASON_SUFFIX, "").trim()).filter(Boolean) : [];
const laterSeason = bgm && stripped[0] !== s.title;
const names = bgm ? [...new Set(stripped)] : [s.title, ...s.aliases];
const cn = names.find((n) => CJK.test(n));
const en = names.find((n) => !CJK.test(n) && /[A-Za-z]/.test(n));
const base = parseRelease(s.title);
const base = parseRelease(names[0] ?? s.title);
return {
...parsed,
title: cn ?? en ?? base.title ?? s.title,
title: cn ?? en ?? base.title ?? names[0] ?? s.title,
...(cn ? { titleCn: cn } : {}),
...(en ? { titleEn: en } : {}),
altTitles: names.filter((n) => n !== cn && n !== en).slice(0, 8),
...(s.year !== null ? { year: s.year } : {}),
...(s.year !== null && !laterSeason ? { year: s.year } : {}),
explicitIds: [...parsed.explicitIds],
evidence: [...parsed.evidence, "douban_subject"],
evidence: [...parsed.evidence, bgm ? "bangumi_subject" : "douban_subject"],
};
}
@@ -0,0 +1,75 @@
import {
type BangumiClient,
createBangumiClient,
createDoubanClient,
type DoubanClient,
type MetaSubject,
} from "@umt/adapters";
import { pgCacheFor } from "@umt/db";
import type { SourcesRuntime } from "../runtime.ts";
import { DOUBAN_SUBJECT_REGION, type DoubanSubject, loadDoubanSubject, saveDoubanSubject } from "./douban-subject.ts";
// 豆瓣 / bgm.tv 作为辅助信息源(对照 MP:豆瓣、Bangumi 条目 → 名称 + 年份 → TMDB 投影;身份仍以 TMDB 为准)。
// 条目先查缓存(30 天),没有才在线查一次;查不到 / 网络失败记 6 小时负缓存,不反复打外站。
export const BANGUMI_SUBJECT_REGION = "bangumi.subject";
const MISS_REGION = "meta.subject_miss";
const MISS_TTL_SEC = 6 * 3600;
export type MetaSource = "douban" | "bangumi";
const REGION: Record<MetaSource, string> = { douban: DOUBAN_SUBJECT_REGION, bangumi: BANGUMI_SUBJECT_REGION };
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 }) };
}
export function toCached(s: MetaSubject, now: Date): DoubanSubject {
return {
id: s.id,
title: s.title,
aliases: s.aliases,
year: s.year,
source: s.source === "douban" ? "douban_api" : "bangumi_api",
ref: s.url,
at: now.toISOString(),
};
}
/** 条目(缓存 → 在线);拿不到为 null */
export async function loadSubject(rt: SourcesRuntime, source: MetaSource, id: string): Promise<DoubanSubject | null> {
const hit = await loadDoubanSubject(rt.ctx, id, REGION[source]);
if (hit) return hit;
const clients = metaClients(rt);
if (!clients) return null;
const cache = pgCacheFor(rt.ctx.db);
const missKey = `${source}:${id}`;
if (await cache.get(MISS_REGION, missKey).catch(() => null)) return null;
try {
const s = source === "douban" ? await clients.douban.subject(id) : await clients.bangumi.subject(id);
const cached = toCached(s, rt.now());
await saveDoubanSubject(rt.ctx, cached, REGION[source]);
return cached;
} catch (e) {
rt.ctx.logger.info({ source, id, err: (e as Error).message }, "辅助信息源条目查询失败");
await cache.set(MISS_REGION, missKey, true, { ttl: MISS_TTL_SEC, maxsize: 5000 }).catch(() => {});
return null;
}
}
/** bgm 按标题搜动画条目(公开 BT 站资源兜底):取第一个,顺手写缓存 */
export async function searchBangumi(rt: SourcesRuntime, keyword: string): Promise<DoubanSubject | null> {
const clients = metaClients(rt);
if (!clients || keyword.trim().length < 2) return null;
try {
const top = (await clients.bangumi.search(keyword.trim(), 5))[0];
if (!top) return null;
const cached = toCached(top, rt.now());
await saveDoubanSubject(rt.ctx, cached, BANGUMI_SUBJECT_REGION);
return cached;
} catch (e) {
rt.ctx.logger.info({ keyword, err: (e as Error).message }, "bgm.tv 搜索失败");
return null;
}
}
@@ -35,8 +35,9 @@ import {
looksLikeVariety,
resolveEntityId,
} from "./entities.ts";
import { doubanRecall, fetchDetailForIds, newIds } from "./id-escalation.ts";
import { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.ts";
import { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts";
import { loadSubject, searchBangumi } from "./meta-subjects.ts";
import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts";
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
@@ -224,23 +225,29 @@ async function tmdbByExplicitIds(
return found;
}
/** 站点给出的 IMDb / TMDB ID 作为强证据(ID-3,domain MatchOptions.trustedIds);豆瓣 ID 不直接对应 TMDB 候选 */
/** 站点给出的 IMDb / TMDB ID 作为强证据(ID-3,domain MatchOptions.trustedIds);豆瓣 / bgm ID 不直接对应 TMDB 候选 */
function trustedOf(ids: ReturnType<typeof siteExplicitIds>) {
return { trustedIds: ids.filter((x) => x.provider !== "douban").map((x) => ({ provider: x.provider, id: x.id })) };
return {
trustedIds: ids
.filter((x) => x.provider === "imdb" || x.provider === "tmdb")
.map((x) => ({ provider: x.provider, id: x.id })),
};
}
/** 识别升级链的证据(记在 resolution_evidence.escalation 与池条目 query.escalation) */
export interface EscalationEvidence {
/** 详情页:fetched 本次抓到;skipped:<原因>;deferred:cap_reached(稍后复查时再抓);failed:fetch_failed;not_now:<原因>(本次不抓) */
detail: string;
/** 最后用上的路径:site_id(详情 / 列表的 IMDb、TMDB)/ douban(豆瓣条目 → 标题 + 年份) */
via: "site_id" | "douban" | null;
/** 最后用上的路径:site_id(详情 / 列表的 IMDb、TMDB)/ douban(豆瓣条目 → 标题 + 年份)/ bangumi(bgm 条目,站点给的 ID 或按标题搜) */
via: "site_id" | "douban" | "bangumi" | null;
/** 抓详情后资源上的外部 ID */
ids: string[];
/** 本次没抓、之后还会抓(每站每小时限额已满 / RSS 入库时不抓):不开待处理,尽快复查。抓了失败的不算(不阻塞) */
pending: boolean;
/** 豆瓣条目(豆瓣路径) */
douban?: { id: string; title: string; year: number | null; source: string };
/** bgm 条目(bangumi 路径);search = 按资源标题搜到的 */
bangumi?: { id: string; title: string; year: number | null; search: boolean };
}
/**
@@ -302,7 +309,7 @@ async function escalateIdentity(
if (!tmdb) return { evidence: ev };
let best: EscalationOutcome | null = null;
// 1) 新拿到的 IMDb / TMDB(列表上已有的在第一轮已用过)
const added = newIds(siteIds, after).filter((x) => x.provider !== "douban");
const added = newIds(siteIds, after).filter((x) => x.provider === "imdb" || x.provider === "tmdb");
if (added.length) {
const p: ParsedRelease = { ...parsed, explicitIds: [...parsed.explicitIds] };
for (const x of after)
@@ -328,12 +335,35 @@ async function escalateIdentity(
if (res.status === "auto" || (!best && res.status !== "no_match")) {
ev.via = "douban";
best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed };
if (res.status === "auto") return best;
}
}
}
// 3) bgm 条目:站点给的 bgm ID(AniBT);公开 BT 站资源没有 ID 时按标题搜 bgm 动画条目(对照 MP Bangumi 识别 → TMDB)
const bgmId = after.find((x) => x.provider === "bangumi")?.id;
let subject = bgmId ? await loadSubject(rt, "bangumi", bgmId) : null;
const search = !subject && !best && (await isPublicBt(rt, release.site_id));
if (search) subject = await searchBangumi(rt, parsed.titleCn ?? parsed.title ?? "");
if (subject) {
const d = await subjectRecall(rt, parsed, subject);
if (d) {
ev.bangumi = { id: subject.id, title: subject.title, year: subject.year, search };
const res = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]);
if (res.status === "auto" || (!best && res.status !== "no_match")) {
ev.via = "bangumi";
best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed };
}
}
}
return best ?? { evidence: ev };
}
async function isPublicBt(rt: SourcesRuntime, siteId: string | null): Promise<boolean> {
if (!siteId) return false;
const row = await rt.ctx.db.selectFrom("site").select("kind").where("id", "=", siteId).executeTakeFirst();
return row?.kind === "public_bt";
}
async function loadRelease(rt: SourcesRuntime, input: ResolveInput) {
const { db } = rt.ctx;
const observationId = input.observationId ?? null;