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

+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,