From 709414778e9d4984ba55420eb4051f13fb5972e2 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 2 Oct 2026 21:30:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(identity):=20=E8=B1=86=E7=93=A3=20/=20bgm.?= =?UTF-8?q?tv=20=E8=BE=85=E5=8A=A9=E4=BF=A1=E6=81=AF=E6=BA=90=E4=B8=8E?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adapters/meta:bgm.tv 官方 API(条目 / 搜索 / 每日放送)与豆瓣移动版接口(条目 / 实时热门) - 识别升级链:豆瓣条目缓存没有时在线查;AniBT 的 bgm ID、公开 BT 站标题搜 bgm → 名称 + 年份匹配 TMDB(季条目去季名与年份) - 条目缓存 30 天,查不到 6 小时负缓存;测试进程缺省不出外网 - 发现页:bgm 每日放送、豆瓣热门电影 / 剧集,点条目匹配 TMDB 作品 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + apps/server/src/modules/search/discover.ts | 69 ++++++ apps/server/src/modules/search/routes.ts | 7 + .../src/modules/sources-identity/runtime.ts | 8 + .../sources-identity/service/detail-page.ts | 12 +- .../service/douban-subject.ts | 22 +- .../sources-identity/service/id-escalation.ts | 36 ++- .../sources-identity/service/meta-subjects.ts | 75 ++++++ .../sources-identity/service/resolve.ts | 42 +++- .../sources-identity/id-escalation.test.ts | 65 ++++- apps/web/app/api/discover.ts | 47 ++++ apps/web/app/components/console/nav.test.ts | 4 +- apps/web/app/components/console/nav.ts | 2 + apps/web/app/routes.ts | 1 + apps/web/app/routes/console/discover.tsx | 215 +++++++++++++++++ packages/adapters/src/index.ts | 1 + packages/adapters/src/meta/index.ts | 225 ++++++++++++++++++ packages/adapters/src/meta/meta.test.ts | 75 ++++++ packages/adapters/src/sites/spiders/map.ts | 1 + packages/adapters/src/sites/types.ts | 2 + packages/contracts/src/api/library.ts | 23 ++ packages/contracts/src/search.ts | 52 ++++ packages/mapping/src/site-release.ts | 2 + 23 files changed, 958 insertions(+), 31 deletions(-) create mode 100644 apps/server/src/modules/search/discover.ts create mode 100644 apps/server/src/modules/sources-identity/service/meta-subjects.ts create mode 100644 apps/web/app/api/discover.ts create mode 100644 apps/web/app/routes/console/discover.tsx create mode 100644 packages/adapters/src/meta/index.ts create mode 100644 packages/adapters/src/meta/meta.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e6c3ef..9a98fe89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/server/src/modules/search/discover.ts b/apps/server/src/modules/search/discover.ts new file mode 100644 index 00000000..a46f0d0a --- /dev/null +++ b/apps/server/src/modules/search/discover.ts @@ -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(rt: SourcesRuntime, key: string, load: () => Promise): Promise { + const cache = pgCacheFor(rt.ctx.db); + const hit = await cache.get(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 { + 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(); + 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) }); +} diff --git a/apps/server/src/modules/search/routes.ts b/apps/server/src/modules/search/routes.ts index 758a6e67..cc34b83b 100644 --- a/apps/server/src/modules/search/routes.ts +++ b/apps/server/src/modules/search/routes.ts @@ -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; diff --git a/apps/server/src/modules/sources-identity/runtime.ts b/apps/server/src/modules/sources-identity/runtime.ts index 9bd7ad7c..e5bbc4a0 100644 --- a/apps/server/src/modules/sources-identity/runtime.ts +++ b/apps/server/src/modules/sources-identity/runtime.ts @@ -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 { if (this.overrides.tmdb) { if (this.tmdbClient === undefined) this.tmdbClient = this.overrides.tmdb(); diff --git a/apps/server/src/modules/sources-identity/service/detail-page.ts b/apps/server/src/modules/sources-identity/service/detail-page.ts index 257ad92d..7ba5be70 100644 --- a/apps/server/src/modules/sources-identity/service/detail-page.ts +++ b/apps/server/src/modules/sources-identity/service/detail-page.ts @@ -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; } diff --git a/apps/server/src/modules/sources-identity/service/douban-subject.ts b/apps/server/src/modules/sources-identity/service/douban-subject.ts index d54c82e1..1e12d1b4 100644 --- a/apps/server/src/modules/sources-identity/service/douban-subject.ts +++ b/apps/server/src/modules/sources-identity/service/douban-subject.ts @@ -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/ 的搜索标题与摘要。 +// - web_snippet:元数据补全(META-2)网页搜索结果里 douban.com/subject/ 的搜索标题与摘要; +// - 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; /** 写入缓存;站点详情来源优先(已有 site_detail 条目时不用搜索摘要覆盖) */ -export async function saveDoubanSubject(ctx: Db, s: DoubanSubject): Promise { +export async function saveDoubanSubject(ctx: Db, s: DoubanSubject, region = DOUBAN_SUBJECT_REGION): Promise { const cache = pgCacheFor(ctx.db); if (s.source === "web_snippet") { - const prev = await cache.get(DOUBAN_SUBJECT_REGION, s.id); + const prev = await cache.get(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 { +export async function loadDoubanSubject( + ctx: Db, + id: string, + region = DOUBAN_SUBJECT_REGION, +): Promise { try { - const v = await pgCacheFor(ctx.db).get(DOUBAN_SUBJECT_REGION, id); + const v = await pgCacheFor(ctx.db).get(region, id); return v && typeof v.title === "string" && v.title ? v : null; } catch { return null; diff --git a/apps/server/src/modules/sources-identity/service/id-escalation.ts b/apps/server/src/modules/sources-identity/service/id-escalation.ts index 81f53270..e54861f4 100644 --- a/apps/server/src/modules/sources-identity/service/id-escalation.ts +++ b/apps/server/src/modules/sources-identity/service/id-escalation.ts @@ -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"], }; } diff --git a/apps/server/src/modules/sources-identity/service/meta-subjects.ts b/apps/server/src/modules/sources-identity/service/meta-subjects.ts new file mode 100644 index 00000000..1145d6a9 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/meta-subjects.ts @@ -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 = { 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 { + 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 { + 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; + } +} diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 0ca46895..7123cdaf 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -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) { - 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 { + 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; diff --git a/apps/server/test/sources-identity/id-escalation.test.ts b/apps/server/test/sources-identity/id-escalation.test.ts index 49e20a77..63ada1ec 100644 --- a/apps/server/test/sources-identity/id-escalation.test.ts +++ b/apps/server/test/sources-identity/id-escalation.test.ts @@ -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", diff --git a/apps/web/app/api/discover.ts b/apps/web/app/api/discover.ts new file mode 100644 index 00000000..5d2fd907 --- /dev/null +++ b/apps/web/app/api/discover.ts @@ -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("/discover/tmdb-match", { + source: it.source, + id: it.id, + title: it.title, + aliases: it.aliases, + year: it.year, + kind: it.kind, + }), + "/discover/tmdb-match", + ), + }); +} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index ff47d139..20a94828 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -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); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index b547729f..535efe5d 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -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: "收录策略与偏好标签" }, ], diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index beeb45a3..bcc4aebf 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -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"), diff --git a/apps/web/app/routes/console/discover.tsx b/apps/web/app/routes/console/discover.tsx new file mode 100644 index 00000000..1ae69641 --- /dev/null +++ b/apps/web/app/routes/console/discover.tsx @@ -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(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 ( + <> + + setSp(new URLSearchParams({ view: v }), { replace: true })}> + + {VIEWS.map((v) => ( + + {v.label} + + ))} + + + {view === "anime" && cal.data ? ( +
+ {cal.data.days.map((d) => ( + + ))} +
+ ) : null} + {q.isLoading ? ( + + ) : q.error ? ( + void q.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( +
    + {items.map((it) => ( +
  • + void open(it)} /> +
  • + ))} +
+ )} + {picking ? setPicking(null)} /> : null} + + ); +} + +function ItemCard({ item, busy, onOpen }: { item: DiscoverItem; busy: boolean; onOpen: () => void }) { + const [failed, setFailed] = useState(false); + return ( + + ); +} + +function PickDialog({ + item, + result, + onClose, +}: { + item: DiscoverItem; + result: DiscoverMatchResult; + onClose: () => void; +}) { + return ( + !o && onClose()}> + + + 选择 TMDB 作品 + + 「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。 + + + {result.candidates.length ? ( +
    + {result.candidates.map((c) => ( +
  • + + + {c.posterPath ? ( + + ) : null} + + + {c.title} + + {c.type === "movie" ? "电影" : "剧集"} · {c.year ?? "—"} + + + +
  • + ))} +
+ ) : ( +

TMDB 上没有找到相近的作品。

+ )} + +
+
+ ); +} diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 0981ea17..352c659e 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -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"; diff --git a/packages/adapters/src/meta/index.ts b/packages/adapters/src/meta/index.ts new file mode 100644 index 00000000..225512a1 --- /dev/null +++ b/packages/adapters/src/meta/index.ts @@ -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; +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 { + 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 { + 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 { + 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; + +// ── 豆瓣 ── + +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 { + 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 { + 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; diff --git a/packages/adapters/src/meta/meta.test.ts b/packages/adapters/src/meta/meta.test.ts new file mode 100644 index 00000000..0f96c238 --- /dev/null +++ b/packages/adapters/src/meta/meta.test.ts @@ -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); + }); +}); diff --git a/packages/adapters/src/sites/spiders/map.ts b/packages/adapters/src/sites/spiders/map.ts index 7e0c482c..378fb8c1 100644 --- a/packages/adapters/src/sites/spiders/map.ts +++ b/packages/adapters/src/sites/spiders/map.ts @@ -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, }; diff --git a/packages/adapters/src/sites/types.ts b/packages/adapters/src/sites/types.ts index d297cca7..a140106b 100644 --- a/packages/adapters/src/sites/types.ts +++ b/packages/adapters/src/sites/types.ts @@ -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; diff --git a/packages/contracts/src/api/library.ts b/packages/contracts/src/api/library.ts index dd9162a3..46cf35ba 100644 --- a/packages/contracts/src/api/library.ts +++ b/packages/contracts/src/api/library.ts @@ -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}", diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts index 4d13884c..8f237361 100644 --- a/packages/contracts/src/search.ts +++ b/packages/contracts/src/search.ts @@ -438,3 +438,55 @@ export const TmdbSeasonDetailView = z.object({ episodes: z.array(MediaEpisodeInfo), }); export type TmdbSeasonDetailView = z.infer; + +// ── 发现: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; + +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; + +export const DiscoverHotQuery = z.object({ kind: z.enum(["movie", "tv"]).default("movie") }); +export type DiscoverHotQuery = z.infer; + +export const DiscoverList = z.object({ items: z.array(DiscoverItem) }); +export type DiscoverList = z.infer; + +export const DiscoverMatchRequest = DiscoverItem.pick({ + source: true, + id: true, + title: true, + aliases: true, + year: true, + kind: true, +}); +export type DiscoverMatchRequest = z.infer; + +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; diff --git a/packages/mapping/src/site-release.ts b/packages/mapping/src/site-release.ts index 430ee4e2..0d044118 100644 --- a/packages/mapping/src/site-release.ts +++ b/packages/mapping/src/site-release.ts @@ -32,6 +32,7 @@ export interface SiteReleaseLike { infoHash?: string; imdbId?: string; doubanId?: string; + bangumiId?: string; tags: string[]; raw: Record; 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,