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

+3
View File
@@ -10,6 +10,9 @@
### 新增
- 下载器可以单独设水位(硬阈值 / 恢复水位 / 清理目标)和保种天数,不填用全局;多个下载器共用一块缓存盘时取最严格的。公网 BT 专用的 qB 可设保种 0 天(站点 H&R 仍遵守)。
- 内置公开 BT 站:Nyaa、蜜柑计划、AniBT、AnimeGarden(聚合动漫花园 / 蜜柑 / ANi 等)、萌番组。添加站点 → 公开 BT 站里一键添加,不需要账号;支持资源搜索、定时抓最新资源、按资源 ID 取种子或磁力(蜜柑取剧集页的种子,AnimeGarden 只有磁力)。默认不进资源搜索的默认站点集合;萌番组部分网络直连不通,打开站点的「代理」即可。原来手填 RSS 的方式保留。
- 豆瓣 / bgm.tv 辅助信息源(照 MP 的做法,身份仍以 TMDB 为准):
- 识别:资源只有豆瓣 ID 时,本地缓存没有该条目会在线查一次豆瓣(移动版接口),按中文名 / 又名 + 年份匹配 TMDB;AniBT 资源带的 bgm 条目 ID、公开 BT 站上认不出的番剧标题,走 bgm.tv 条目(第二季等季条目去掉季名与年份)再匹配 TMDB。条目缓存 30 天,查不到 6 小时内不重查。
- 新增「发现」页(获取 → 发现):bgm.tv 每日放送、豆瓣实时热门电影 / 剧集;点条目自动匹配 TMDB 作品进作品页(可订阅、搜资源),不确定时列候选。
## [0.3.16] - 2026-10-02
@@ -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;
@@ -65,6 +65,7 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
let audiences: string;
let hhan: string;
const siteRequests: string[] = [];
const metaRequests: string[] = [];
beforeAll(async () => {
tmdb = await createMockTmdb({
@@ -78,6 +79,8 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
fx("tv", 290001, "Sample Series", "示例剧集", "2026-08-01", "tt45950001"),
// 豆瓣路径:只凭豆瓣条目(标题 + 年份)匹配
fx("tv", 990021, "Lanterns of Qing", "青灯记", "2025-04-01"),
// bgm 路径:罗马音标题搜不到,bgm 条目中文名能搜到
fx("tv", 990031, "Frieren: Beyond Journey's End", "葬送的芙莉莲", "2023-09-29"),
],
});
h = await createHarness("id3", {
@@ -94,6 +97,25 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
if (u.host === "hhanclub.net" && u.pathname === "/viewfilelist.php") return html(HHANCLUB_FILELIST_FRAGMENT);
throw new Error(`测试不允许的站点请求 ${u.href}`);
},
// bgm.tv:第二季条目(按站点给的 ID)与搜索(公开 BT 站兜底)
metaFetch: async (input, init) => {
const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
metaRequests.push(`${init?.method ?? "GET"} ${u.host}${u.pathname}`);
const json = (b: unknown) =>
new Response(JSON.stringify(b), { headers: { "content-type": "application/json" } });
if (u.host === "api.bgm.tv" && u.pathname === "/v0/subjects/515759")
return json({
id: 515759,
name: "葬送のフリーレン 第2期",
name_cn: "葬送的芙莉莲 第二季",
date: "2026-01-16",
});
if (u.host === "api.bgm.tv" && u.pathname === "/v0/search/subjects")
return json({
data: [{ id: 400602, name: "葬送のフリーレン", name_cn: "葬送的芙莉莲", date: "2023-09-29" }],
});
return new Response("", { status: 404 });
},
});
audiences = (await seedSite(h, { key: "audiences", baseUrl: "https://audiences.me/", cookie: "c_secure_uid=1" }))
.site.id;
@@ -304,7 +326,7 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
escalation: { via: "douban", douban: { id: "36000099", title: "青灯记", source: "web_snippet" } },
});
// 缓存里没有这个豆瓣条目:不请求豆瓣,只进池;有 ID 的资源不开「没识别出作品」
// 缓存里没有这个豆瓣条目、在线也查不到(404):只进池;有 ID 的资源不开「没识别出作品」
await sharedCache(h.ctx).delete(DOUBAN_SUBJECT_REGION, "36000098");
const r2 = await release(hhan, "91006", "Unknown Drama S01E01 2025 1080p WEB-DL", {
_detail: { doubanId: "36000098" },
@@ -316,6 +338,47 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
expect(siteRequests.some((x) => x.includes("douban"))).toBe(false);
});
it("bgm 路径:AniBT 给的 bgm ID → 第二季条目去掉季名与年份 → TMDB;公开 BT 站没 ID 时按标题搜 bgm", async () => {
const pub = await h.db
.insertInto("site")
.values({
key: "anibt",
name: "AniBT",
kind: "public_bt",
adapter: "api",
base_url: "https://anibt.net/",
} as never)
.returning("id")
.executeTakeFirstOrThrow();
const r = await release(pub.id, "rel_A1", "[Nix-Raws] Sousou no Frieren S2 - 05 [1080p]", {
_site: { bangumiId: "515759" },
});
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
expect(res.outcome).toBe("auto");
expect(await tmdbIdOf(res.mediaEntityId!)).toBe("990031");
expect((await row(r.id)).resolution_evidence).toMatchObject({
escalation: { via: "bangumi", bangumi: { id: "515759", search: false } },
});
// 条目进缓存:再识别同 ID 不再请求
const n = metaRequests.length;
const r2 = await release(pub.id, "rel_A2", "[Nix-Raws] Sousou no Frieren S2 - 06 [1080p]", {
_site: { bangumiId: "515759" },
});
await resolveRelease(h.rt, { sourceReleaseId: r2.id, trigger: "manual" });
expect(metaRequests.length).toBe(n);
const r3 = await release(pub.id, "rel_A3", "[SubsPlease] Sousou no Frieren - 03 (1080p)");
const res3 = await resolveRelease(h.rt, { sourceReleaseId: r3.id, trigger: "manual" });
expect(res3.outcome).toBe("auto");
expect(await tmdbIdOf(res3.mediaEntityId!)).toBe("990031");
expect(metaRequests).toContain("POST api.bgm.tv/v0/search/subjects");
// PT 站的资源不按标题搜 bgm
const m = metaRequests.length;
const r4 = await release(hhan, "91099", "Sousou no Frieren S01E03 1080p WEB-DL");
await resolveRelease(h.rt, { sourceReleaseId: r4.id, trigger: "manual" });
expect(metaRequests.length).toBe(m);
});
it("升级链没认出(豆瓣召回无匹配)时保留原有歧义:候选不丢,照常开有歧义的事项", async () => {
await saveDoubanSubject(h.ctx, {
id: "36000097",
+47
View File
@@ -0,0 +1,47 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { DiscoverCalendar, type DiscoverItem, DiscoverList, DiscoverMatchResult } from "@umt/contracts";
import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.ts";
// 发现:bgm.tv 每日放送、豆瓣热门(服务端缓存 1 小时);点条目时匹配 TMDB 作品。
export const discoverKeys = {
calendar: ["discover", "calendar"] as const,
hot: (kind: "movie" | "tv") => ["discover", "douban-hot", kind] as const,
};
export function useBangumiCalendar(enabled = true) {
return useQuery({
queryKey: discoverKeys.calendar,
enabled,
queryFn: ({ signal }) => getParsed(DiscoverCalendar, "/discover/bangumi-calendar", {}, signal),
staleTime: 30 * 60_000,
});
}
export function useDoubanHot(kind: "movie" | "tv", enabled = true) {
return useQuery({
queryKey: discoverKeys.hot(kind),
enabled,
queryFn: ({ signal }) => getParsed(DiscoverList, "/discover/douban-hot", { kind }, signal),
staleTime: 30 * 60_000,
});
}
export function useDiscoverMatch() {
return useMutation({
mutationFn: async (it: DiscoverItem) =>
parseOrThrow(
DiscoverMatchResult,
await api.post<unknown>("/discover/tmdb-match", {
source: it.source,
id: it.id,
title: it.title,
aliases: it.aliases,
year: it.year,
kind: it.kind,
}),
"/discover/tmdb-match",
),
});
}
+2 -2
View File
@@ -29,11 +29,11 @@ describe("控制台导航", () => {
expect(ACQUIRE_GROUP_TITLE).toBe("获取");
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
["首页", "计划", "智能体会话"],
["站点", "订阅", "规则"],
["站点", "发现", "订阅", "规则"],
["下载面板", "整理记录", "文件管理", "媒体库"],
["任务", "设置", "审计", "运行状态"],
]);
expect(NAV_ITEMS).toHaveLength(14);
expect(NAV_ITEMS).toHaveLength(15);
expect(NAV_ITEMS.map((i) => i.to)).not.toContain("/console/sources");
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length);
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
+2
View File
@@ -2,6 +2,7 @@ import {
Bot,
CalendarClock,
CalendarCog,
Compass,
Download,
FileStack,
FolderTree,
@@ -76,6 +77,7 @@ export const NAV_GROUPS: NavGroup[] = [
icon: Globe,
description: "PT 站、公开 BT 站与 RSS 源:账号、资源与 RSS",
},
{ to: "/console/discover", label: "发现", icon: Compass, description: "bgm.tv 每日放送与豆瓣热门" },
{ to: "/console/subscriptions", label: "订阅", icon: CalendarClock, description: "单次收录与持续追踪" },
{ to: "/console/rules", label: "规则", icon: ListChecks, description: "收录策略与偏好标签" },
],
+1
View File
@@ -18,6 +18,7 @@ export default [
route("organize/:id", "routes/console/organize.$id.tsx"),
route("files", "routes/console/files.tsx"),
route("files/trash", "routes/console/files.trash.tsx"),
route("discover", "routes/console/discover.tsx"),
route("subscriptions", "routes/console/subscriptions.tsx"),
route("subscriptions/:id", "routes/console/subscriptions.$id.tsx"),
route("preferences", "routes/console/preferences.tsx"),
+215
View File
@@ -0,0 +1,215 @@
import { type DiscoverItem, type DiscoverMatchResult, tmdbImageUrl } from "@umt/contracts";
import { ExternalLink, LoaderCircle, Search, Star } from "lucide-react";
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useBangumiCalendar, useDiscoverMatch, useDoubanHot } from "~/api/discover.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { errorMessage } from "~/lib/api.ts";
// 发现(对照 MP 推荐页):bgm.tv 每日放送、豆瓣热门电影 / 剧集。点条目按名称 + 年份匹配 TMDB 作品,
// 确定的直接进作品页(可订阅、搜资源),不确定的列候选让人选。
export const meta = () => [{ title: "发现 · UHD 媒体自动化控制台" }];
type View = "anime" | "movie" | "tv";
const VIEWS: { value: View; label: string }[] = [
{ value: "anime", label: "番剧放送" },
{ value: "movie", label: "热门电影" },
{ value: "tv", label: "热门剧集" },
];
/** bgm 周一 = 1 … 周日 = 7 */
const todayWeekday = () => ((new Date().getDay() + 6) % 7) + 1;
export default function DiscoverPage() {
const [sp, setSp] = useSearchParams();
const view = (VIEWS.some((v) => v.value === sp.get("view")) ? sp.get("view") : "anime") as View;
const [day, setDay] = useState(todayWeekday);
const cal = useBangumiCalendar(view === "anime");
const hot = useDoubanHot(view === "tv" ? "tv" : "movie", view !== "anime");
const match = useDiscoverMatch();
const navigate = useNavigate();
const [picking, setPicking] = useState<{ item: DiscoverItem; result: DiscoverMatchResult } | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const open = async (it: DiscoverItem) => {
setBusyId(`${it.source}:${it.id}`);
try {
const r = await match.mutateAsync(it);
if (r.match) navigate(consolePath("media", "tmdb", r.match.type, r.match.id));
else setPicking({ item: it, result: r });
} catch (err) {
toast.error(errorMessage(err, "匹配 TMDB 失败"));
} finally {
setBusyId(null);
}
};
const q = view === "anime" ? cal : hot;
const items =
view === "anime" ? (cal.data?.days.find((d) => d.weekday === day)?.items ?? []) : (hot.data?.items ?? []);
return (
<>
<PageHeader title="发现" description="bgm.tv 每日放送与豆瓣热门;点开匹配到 TMDB 作品后可订阅或搜资源" />
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams({ view: v }), { replace: true })}>
<TabsList className="mb-4">
{VIEWS.map((v) => (
<TabsTrigger key={v.value} value={v.value}>
{v.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{view === "anime" && cal.data ? (
<div className="mb-4 flex flex-wrap gap-1.5">
{cal.data.days.map((d) => (
<Button
key={d.weekday}
size="sm"
variant={d.weekday === day ? "default" : "outline"}
onClick={() => setDay(d.weekday)}
>
{d.label}
<span className="text-micro opacity-70">{d.items.length}</span>
</Button>
))}
</div>
) : null}
{q.isLoading ? (
<LoadingState rows={4} />
) : q.error ? (
<ErrorState error={q.error} onRetry={() => void q.refetch()} />
) : items.length === 0 ? (
<EmptyState title="没有条目" />
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((it) => (
<li key={`${it.source}:${it.id}`}>
<ItemCard item={it} busy={busyId === `${it.source}:${it.id}`} onOpen={() => void open(it)} />
</li>
))}
</ul>
)}
{picking ? <PickDialog {...picking} onClose={() => setPicking(null)} /> : null}
</>
);
}
function ItemCard({ item, busy, onOpen }: { item: DiscoverItem; busy: boolean; onOpen: () => void }) {
const [failed, setFailed] = useState(false);
return (
<button
type="button"
onClick={onOpen}
disabled={busy}
className="group flex w-full min-w-0 flex-col overflow-hidden rounded-lg border border-border/70 text-left transition-colors hover:border-foreground/40 disabled:opacity-70"
>
<div className="relative aspect-[2/3] w-full bg-muted">
{item.cover && !failed ? (
<img
src={item.cover}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
className="h-full w-full object-cover"
/>
) : null}
{busy ? (
<span className="absolute inset-0 grid place-items-center bg-background/60">
<LoaderCircle className="h-5 w-5 animate-spin" />
</span>
) : null}
</div>
<div className="min-w-0 px-2 py-1.5">
<p className="truncate text-caption font-medium text-foreground" title={item.title}>
{item.title}
</p>
<p className="flex items-center gap-2 text-meta text-muted-foreground">
<span>{item.date ?? item.year ?? "—"}</span>
{item.rating ? (
<span className="inline-flex items-center gap-0.5">
<Star className="h-3 w-3" />
{item.rating.toFixed(1)}
</span>
) : null}
</p>
</div>
</button>
);
}
function PickDialog({
item,
result,
onClose,
}: {
item: DiscoverItem;
result: DiscoverMatchResult;
onClose: () => void;
}) {
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>选择 TMDB 作品</DialogTitle>
<DialogDescription>
「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。
</DialogDescription>
</DialogHeader>
{result.candidates.length ? (
<ul className="grid gap-2">
{result.candidates.map((c) => (
<li key={`${c.type}:${c.id}`}>
<Link
to={consolePath("media", "tmdb", c.type, c.id)}
onClick={onClose}
className="flex items-center gap-3 rounded-lg border border-border/70 p-2 hover:border-foreground/40 hover:bg-accent"
>
<span className="h-14 w-10 shrink-0 overflow-hidden rounded bg-muted">
{c.posterPath ? (
<img
src={tmdbImageUrl(c.posterPath, "w92") ?? ""}
alt=""
className="h-full w-full object-cover"
/>
) : null}
</span>
<span className="min-w-0">
<span className="block truncate text-caption font-medium">{c.title}</span>
<span className="text-meta text-muted-foreground">
{c.type === "movie" ? "电影" : "剧集"} · {c.year ?? "—"}
</span>
</span>
</Link>
</li>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">TMDB 上没有找到相近的作品。</p>
)}
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("search")}?q=${encodeURIComponent(item.title)}`} onClick={onClose}>
<Search />
按名称搜索
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<a href={item.url} target="_blank" rel="noreferrer">
<ExternalLink />
{item.source === "bangumi" ? "bgm.tv" : "豆瓣"}
</a>
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -3,6 +3,7 @@ export const PACKAGE_NAME = "@umt/adapters";
export * from "./browser/index.ts";
export * from "./emby/index.ts";
export * from "./fs/index.ts";
export * from "./meta/index.ts";
export * from "./qbittorrent/index.ts";
export * from "./rss/index.ts";
export * from "./sites/index.ts";
+225
View File
@@ -0,0 +1,225 @@
/**
* 辅助信息源(对照 MP modules/bangumi、modules/douban):只读公开接口,结果只用来"拿名称 + 年份去匹配 TMDB"与发现页展示,
* 身份仍以 TMDB 为准。
* - bgm.tv:官方 API(api.bgm.tv),条目 / 搜索 / 每日放送,不需要登录;要求带可识别的 User-Agent
* - 豆瓣:移动版 rexxar 接口(m.douban.com,需要 Referer),条目详情与实时热门榜;搜索要登录,不用
*/
export interface MetaSubject {
source: "bangumi" | "douban";
id: string;
/** 显示名(中文名优先) */
title: string;
/** 原名 / 别名 / 又名(不含 title) */
aliases: string[];
year: number | null;
/** 电影 / 剧集;bgm 动画条目按剧集,剧场版(platform 剧场版 / 电影)按电影 */
kind: "movie" | "tv" | null;
cover: string | null;
rating: number | null;
/** 首播 / 上映日期 YYYY-MM-DD */
date: string | null;
episodes: number | null;
url: string;
}
export interface MetaClientOptions {
fetch?: typeof globalThis.fetch;
/** 缺省 15s */
timeoutMs?: number;
baseUrl?: string;
}
export class MetaSourceError extends Error {
constructor(
readonly source: "bangumi" | "douban",
readonly kind: "not_found" | "http" | "parse",
message: string,
) {
super(message);
this.name = "MetaSourceError";
}
}
type Raw = Record<string, unknown>;
const isObj = (v: unknown): v is Raw => !!v && typeof v === "object" && !Array.isArray(v);
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : null);
const numOf = (v: unknown) => {
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : Number.NaN;
return Number.isFinite(n) ? n : null;
};
const yearOf = (v: unknown) => {
const y = Number(/(?:19|20)\d{2}/.exec(String(v ?? ""))?.[0]);
return y >= 1900 && y <= 2100 ? y : null;
};
const https = (u: string | null) => (u ? u.replace(/^http:\/\//, "https://") : null);
const uniq = (xs: (string | null | undefined)[], not: string) => [
...new Set(xs.filter((x): x is string => !!x && x !== not && x.length <= 120)),
];
async function getJson(
source: "bangumi" | "douban",
url: string,
o: MetaClientOptions,
init: RequestInit = {},
): Promise<unknown> {
const f = o.fetch ?? globalThis.fetch;
let res: Response;
try {
res = await f(url, { ...init, signal: AbortSignal.timeout(o.timeoutMs ?? 15_000) });
} catch (e) {
throw new MetaSourceError(source, "http", `请求 ${new URL(url).host} 失败:${(e as Error).message}`);
}
if (res.status === 404) throw new MetaSourceError(source, "not_found", "条目不存在");
if (!res.ok) throw new MetaSourceError(source, "http", `${new URL(url).host} 返回 HTTP ${res.status}`);
try {
return await res.json();
} catch {
throw new MetaSourceError(source, "parse", `${new URL(url).host} 返回的不是 JSON`);
}
}
// ── bgm.tv ──
export const BANGUMI_API = "https://api.bgm.tv";
const BGM_UA = "uhd-media-tool (https://github.com/uhd-media-tool)";
/** bgm 条目(v0 subject、搜索结果、每日放送条目通用) → MetaSubject */
export function bangumiSubject(r: Raw): MetaSubject | null {
const id = numOf(r.id);
const name = str(r.name);
const cn = str(r.name_cn);
const title = cn ?? name;
if (!id || !title) return null;
const infobox = Array.isArray(r.infobox) ? (r.infobox as Raw[]) : [];
const alias = infobox
.filter((x) => x.key === "别名" || x.key === "中文名")
.flatMap((x) => (Array.isArray(x.value) ? (x.value as Raw[]).map((v) => str(v.v)) : [str(x.value)]));
const date = str(r.date) ?? str(r.air_date);
const platform = str(r.platform) ?? "";
const images = isObj(r.images) ? r.images : {};
const rating = isObj(r.rating) ? numOf(r.rating.score) : null;
return {
source: "bangumi",
id: String(id),
title,
aliases: uniq([name, ...alias], title).slice(0, 10),
year: yearOf(date),
kind: /剧场|电影|movie/i.test(platform) ? "movie" : "tv",
cover: https(str(images.large) ?? str(images.common)),
rating: rating || null,
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) && date !== "0000-00-00" ? date : null,
episodes: numOf(r.total_episodes) || numOf(r.eps) || numOf(r.eps_count) || null,
url: `https://bgm.tv/subject/${id}`,
};
}
export function createBangumiClient(o: MetaClientOptions = {}) {
const base = (o.baseUrl ?? BANGUMI_API).replace(/\/+$/, "");
const headers = { "User-Agent": BGM_UA, Accept: "application/json" };
return {
async subject(id: string): Promise<MetaSubject> {
if (!/^\d+$/.test(id)) throw new MetaSourceError("bangumi", "not_found", "bgm 条目 ID 非法");
const r = await getJson("bangumi", `${base}/v0/subjects/${id}`, o, { headers });
const s = isObj(r) ? bangumiSubject(r) : null;
if (!s) throw new MetaSourceError("bangumi", "parse", "bgm 条目格式不对");
return s;
},
/** 动画条目搜索(type 2) */
async search(keyword: string, limit = 10): Promise<MetaSubject[]> {
const r = await getJson("bangumi", `${base}/v0/search/subjects?limit=${limit}`, o, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ keyword, filter: { type: [2] } }),
});
const rows = isObj(r) && Array.isArray(r.data) ? (r.data as Raw[]) : [];
return rows.flatMap((x) => bangumiSubject(x) ?? []);
},
/** 每日放送:周一到周日(weekday 1-7) */
async calendar(): Promise<{ weekday: number; label: string; items: MetaSubject[] }[]> {
const r = await getJson("bangumi", `${base}/calendar`, o, { headers });
if (!Array.isArray(r)) throw new MetaSourceError("bangumi", "parse", "bgm 每日放送格式不对");
return (r as Raw[]).map((d) => {
const wd = isObj(d.weekday) ? d.weekday : {};
return {
weekday: numOf(wd.id) ?? 0,
label: str(wd.cn) ?? "",
items: (Array.isArray(d.items) ? (d.items as Raw[]) : []).flatMap((x) => bangumiSubject(x) ?? []),
};
});
},
};
}
export type BangumiClient = ReturnType<typeof createBangumiClient>;
// ── 豆瓣 ──
export const DOUBAN_API = "https://m.douban.com/rexxar/api/v2";
const DOUBAN_HEADERS = {
Referer: "https://m.douban.com/",
"User-Agent":
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
Accept: "application/json",
};
/** 豆瓣条目(详情或榜单条目) → MetaSubject */
export function doubanSubject(r: Raw): MetaSubject | null {
const id = str(r.id);
const title = str(r.title)?.replace(/‎/g, "").trim();
if (!id || !/^\d+$/.test(id) || !title) return null;
const pic = isObj(r.pic) ? r.pic : {};
const rating = isObj(r.rating) ? numOf(r.rating.value) : null;
const pub = Array.isArray(r.pubdate) ? str(r.pubdate[0]) : null;
const date = /\d{4}-\d{2}-\d{2}/.exec(pub ?? "")?.[0] ?? null;
const type = str(r.type) ?? str(r.subtype);
return {
source: "douban",
id,
title,
aliases: uniq([str(r.original_title), ...(Array.isArray(r.aka) ? r.aka.map(str) : [])], title).slice(0, 10),
year: yearOf(r.year) ?? yearOf(date),
kind: type === "tv" || r.is_tv === true ? "tv" : type === "movie" ? "movie" : null,
cover: str(pic.large) ?? str(pic.normal),
rating: rating || null,
date,
episodes: numOf(r.episodes_count) || null,
url: `https://movie.douban.com/subject/${id}/`,
};
}
export const DOUBAN_HOT = {
movie: "movie_real_time_hotest",
tv: "tv_real_time_hotest",
} as const;
export function createDoubanClient(o: MetaClientOptions = {}) {
const base = (o.baseUrl ?? DOUBAN_API).replace(/\/+$/, "");
const get = (path: string) => getJson("douban", `${base}${path}`, o, { headers: DOUBAN_HEADERS });
return {
/** 条目详情:类型未知时先按电影查,不存在再按剧集查 */
async subject(id: string, kind?: "movie" | "tv" | null): Promise<MetaSubject> {
if (!/^\d+$/.test(id)) throw new MetaSourceError("douban", "not_found", "豆瓣条目 ID 非法");
const order: ("movie" | "tv")[] = kind === "tv" ? ["tv", "movie"] : ["movie", "tv"];
let last: unknown = null;
for (const k of order) {
try {
const r = await get(`/${k}/${id}`);
const s = isObj(r) ? doubanSubject(r) : null;
if (s) return s;
} catch (e) {
last = e;
if (!(e instanceof MetaSourceError) || e.kind === "http") throw e;
}
}
throw last instanceof MetaSourceError ? last : new MetaSourceError("douban", "not_found", "豆瓣条目不存在");
},
/** 实时热门榜(电影 / 剧集) */
async hot(kind: "movie" | "tv", count = 30): Promise<MetaSubject[]> {
const r = await get(`/subject_collection/${DOUBAN_HOT[kind]}/items?start=0&count=${count}`);
const rows = isObj(r) && Array.isArray(r.subject_collection_items) ? (r.subject_collection_items as Raw[]) : null;
if (!rows) throw new MetaSourceError("douban", "parse", "豆瓣榜单格式不对");
return rows.flatMap((x) => doubanSubject(x) ?? []);
},
};
}
export type DoubanClient = ReturnType<typeof createDoubanClient>;
+75
View File
@@ -0,0 +1,75 @@
import { createMockFetch } from "@umt/testkit";
import { describe, expect, it } from "vitest";
import { bangumiSubject, createBangumiClient, createDoubanClient, doubanSubject, MetaSourceError } from "./index.ts";
// 字段取自 2026-10-02 api.bgm.tv / m.douban.com 实际返回(删减)
const BGM = {
id: 400602,
name: "葬送のフリーレン",
name_cn: "葬送的芙莉莲",
date: "2023-09-29",
platform: "TV",
total_episodes: 36,
eps: 28,
images: { large: "http://lain.bgm.tv/pic/cover/l/13/c5/400602_ZI8Y9.jpg" },
rating: { score: 8.5 },
infobox: [
{ key: "中文名", value: "葬送的芙莉莲" },
{ key: "别名", value: [{ v: "Frieren: Beyond Journey's End" }, { v: "Sousou no Frieren" }] },
],
};
const DOUBAN_TV = {
id: "36449295",
title: "兰香如故",
original_title: "",
aka: ["兰香缘"],
year: "2026",
type: "tv",
is_tv: true,
episodes_count: 47,
pubdate: ["2026-09-11(中国大陆)"],
rating: { value: 7.5 },
pic: { large: "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2933527614.jpg" },
};
describe("辅助信息源 bgm.tv / 豆瓣", () => {
it("bgm 条目:中文名优先,原名与别名进 aliases,图片改 https,TV 按剧集", () => {
expect(bangumiSubject(BGM)).toMatchObject({
id: "400602",
title: "葬送的芙莉莲",
aliases: ["葬送のフリーレン", "Frieren: Beyond Journey's End", "Sousou no Frieren"],
year: 2023,
kind: "tv",
cover: "https://lain.bgm.tv/pic/cover/l/13/c5/400602_ZI8Y9.jpg",
rating: 8.5,
episodes: 36,
});
expect(bangumiSubject({ ...BGM, platform: "剧场版" })?.kind).toBe("movie");
});
it("豆瓣条目:去掉标题里的方向符,首播日期与类型", () => {
expect(doubanSubject({ ...DOUBAN_TV, title: "兰香如故‎" })).toMatchObject({
title: "兰香如故",
aliases: ["兰香缘"],
year: 2026,
kind: "tv",
date: "2026-09-11",
episodes: 47,
});
});
it("豆瓣详情类型未知:电影接口 404 再查剧集;bgm 带 UA", async () => {
const m = createMockFetch({
"GET /rexxar/api/v2/movie/36449295": { status: 404, body: "" },
"GET /rexxar/api/v2/tv/36449295": { body: DOUBAN_TV },
"GET https://api.bgm.tv/v0/subjects/400602": { body: BGM },
});
const d = createDoubanClient({ fetch: m.fetch });
expect((await d.subject("36449295")).kind).toBe("tv");
expect(m.requests[0]!.headers.referer).toBe("https://m.douban.com/");
const b = createBangumiClient({ fetch: m.fetch });
expect((await b.subject("400602")).title).toBe("葬送的芙莉莲");
expect(m.requests.at(-1)!.headers["user-agent"]).toContain("uhd-media-tool");
await expect(b.subject("abc")).rejects.toBeInstanceOf(MetaSourceError);
});
});
@@ -73,6 +73,7 @@ export function specializedTorrentToRelease(
detailUrl: t.page_url ?? `${def.domain}`,
tags: t.labels ?? [],
...(t.infohash && /^[0-9a-f]{40}$/i.test(t.infohash) ? { infoHash: t.infohash.toLowerCase() } : {}),
...(t.bangumi_id && /^\d+$/.test(t.bangumi_id) ? { bangumiId: t.bangumi_id } : {}),
raw,
observedAt,
};
+2
View File
@@ -53,6 +53,8 @@ export interface SiteRelease {
infoHash?: string;
imdbId?: string;
doubanId?: string;
/** bgm.tv 条目 ID(AniBT 等公开 BT 站直接给出) */
bangumiId?: string;
tags: string[];
/** 原始字段摘录(可 JSON 化,入 raw jsonb) */
raw: Record<string, unknown>;
+23
View File
@@ -198,6 +198,29 @@ export const libraryApi = {
query: search.TmdbMediaQuery,
response: search.TmdbMediaDetail,
}),
discoverCalendar: ep({
method: "get",
path: "/discover/bangumi-calendar",
scope: "read",
summary: "bgm.tv 每日放送(周一到周日;缓存 1 小时)",
response: search.DiscoverCalendar,
}),
discoverHot: ep({
method: "get",
path: "/discover/douban-hot",
scope: "read",
summary: "豆瓣实时热门电影 / 剧集(缓存 1 小时)",
query: search.DiscoverHotQuery,
response: search.DiscoverList,
}),
discoverMatch: ep({
method: "post",
path: "/discover/tmdb-match",
scope: "read",
summary: "豆瓣 / bgm 条目 → TMDB 作品(名称 + 年份匹配,对照 MP 投影);确定的给 match,否则给候选",
body: search.DiscoverMatchRequest,
response: search.DiscoverMatchResult,
}),
tmdbSeason: ep({
method: "get",
path: "/media/tmdb/tv/{id}/season/{season}",
+52
View File
@@ -438,3 +438,55 @@ export const TmdbSeasonDetailView = z.object({
episodes: z.array(MediaEpisodeInfo),
});
export type TmdbSeasonDetailView = z.infer<typeof TmdbSeasonDetailView>;
// ── 发现:bgm.tv 每日放送、豆瓣热门(辅助信息源,点开后匹配 TMDB 作品) ──
export const DiscoverItem = z.object({
source: z.enum(["bangumi", "douban"]),
id: z.string(),
title: z.string(),
aliases: z.array(z.string()),
year: z.number().int().nullable(),
kind: z.enum(["movie", "tv"]).nullable(),
cover: z.string().nullable(),
rating: z.number().nullable(),
date: z.string().nullable(),
episodes: z.number().int().nullable(),
url: z.string(),
});
export type DiscoverItem = z.infer<typeof DiscoverItem>;
export const DiscoverCalendar = z.object({
days: z.array(z.object({ weekday: z.number().int(), label: z.string(), items: z.array(DiscoverItem) })),
});
export type DiscoverCalendar = z.infer<typeof DiscoverCalendar>;
export const DiscoverHotQuery = z.object({ kind: z.enum(["movie", "tv"]).default("movie") });
export type DiscoverHotQuery = z.infer<typeof DiscoverHotQuery>;
export const DiscoverList = z.object({ items: z.array(DiscoverItem) });
export type DiscoverList = z.infer<typeof DiscoverList>;
export const DiscoverMatchRequest = DiscoverItem.pick({
source: true,
id: true,
title: true,
aliases: true,
year: true,
kind: true,
});
export type DiscoverMatchRequest = z.infer<typeof DiscoverMatchRequest>;
export const DiscoverMatchCandidate = z.object({
type: z.enum(["movie", "tv"]),
id: z.string(),
title: z.string(),
year: z.number().int().nullable(),
posterPath: z.string().nullable(),
});
export const DiscoverMatchResult = z.object({
/** 能确定的 TMDB 作品;不确定为 null,看 candidates */
match: DiscoverMatchCandidate.nullable(),
candidates: z.array(DiscoverMatchCandidate),
});
export type DiscoverMatchResult = z.infer<typeof DiscoverMatchResult>;
+2
View File
@@ -32,6 +32,7 @@ export interface SiteReleaseLike {
infoHash?: string;
imdbId?: string;
doubanId?: string;
bangumiId?: string;
tags: string[];
raw: Record<string, unknown>;
observedAt: string;
@@ -69,6 +70,7 @@ export function siteReleaseToSourceRelease(
detailUrl: r.detailUrl,
...(r.imdbId ? { imdbId: r.imdbId } : {}),
...(r.doubanId ? { doubanId: r.doubanId } : {}),
...(r.bangumiId ? { bangumiId: r.bangumiId } : {}),
};
return {
siteId: opts.siteId,