feat(identity): 豆瓣 / bgm.tv 辅助信息源与发现页
- adapters/meta:bgm.tv 官方 API(条目 / 搜索 / 每日放送)与豆瓣移动版接口(条目 / 实时热门) - 识别升级链:豆瓣条目缓存没有时在线查;AniBT 的 bgm ID、公开 BT 站标题搜 bgm → 名称 + 年份匹配 TMDB(季条目去季名与年份) - 条目缓存 30 天,查不到 6 小时负缓存;测试进程缺省不出外网 - 发现页:bgm 每日放送、豆瓣热门电影 / 剧集,点条目匹配 TMDB 作品 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b8876542b8
commit
709414778e
23 files changed
+958
-31
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user