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:
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) });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user