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
@@ -10,6 +10,9 @@
|
|||||||
### 新增
|
### 新增
|
||||||
- 下载器可以单独设水位(硬阈值 / 恢复水位 / 清理目标)和保种天数,不填用全局;多个下载器共用一块缓存盘时取最严格的。公网 BT 专用的 qB 可设保种 0 天(站点 H&R 仍遵守)。
|
- 下载器可以单独设水位(硬阈值 / 恢复水位 / 清理目标)和保种天数,不填用全局;多个下载器共用一块缓存盘时取最严格的。公网 BT 专用的 qB 可设保种 0 天(站点 H&R 仍遵守)。
|
||||||
- 内置公开 BT 站:Nyaa、蜜柑计划、AniBT、AnimeGarden(聚合动漫花园 / 蜜柑 / ANi 等)、萌番组。添加站点 → 公开 BT 站里一键添加,不需要账号;支持资源搜索、定时抓最新资源、按资源 ID 取种子或磁力(蜜柑取剧集页的种子,AnimeGarden 只有磁力)。默认不进资源搜索的默认站点集合;萌番组部分网络直连不通,打开站点的「代理」即可。原来手填 RSS 的方式保留。
|
- 内置公开 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.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) });
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
type ApiScope,
|
type ApiScope,
|
||||||
apiResponse,
|
apiResponse,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
|
type DiscoverHotQuery,
|
||||||
|
type DiscoverMatchRequest,
|
||||||
type EndpointDef,
|
type EndpointDef,
|
||||||
type InventoryEntry,
|
type InventoryEntry,
|
||||||
type InventoryResponse,
|
type InventoryResponse,
|
||||||
@@ -32,6 +34,7 @@ import { metadataLanguages, metadataTextOptions } from "../sources-identity/serv
|
|||||||
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
||||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||||
import { matchActions } from "./actions.ts";
|
import { matchActions } from "./actions.ts";
|
||||||
|
import { bangumiCalendar, doubanHot, matchTmdb } from "./discover.ts";
|
||||||
import { InternalDispatcher } from "./dispatch.ts";
|
import { InternalDispatcher } from "./dispatch.ts";
|
||||||
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
|
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
|
||||||
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.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 }) => {
|
route(app, Api.search.tmdbDetail, async ({ params, query, c }) => {
|
||||||
const p = params as TmdbMediaParams;
|
const p = params as TmdbMediaParams;
|
||||||
const inc = (query as TmdbMediaQuery).include;
|
const inc = (query as TmdbMediaQuery).include;
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface RuntimeOverrides {
|
|||||||
schedulerIntervalMs?: number;
|
schedulerIntervalMs?: number;
|
||||||
/** false = 站点限流只用进程内令牌桶(缺省走 PG 跨进程令牌桶 rate_limit_bucket) */
|
/** false = 站点限流只用进程内令牌桶(缺省走 PG 跨进程令牌桶 rate_limit_bucket) */
|
||||||
sharedRateLimit?: boolean;
|
sharedRateLimit?: boolean;
|
||||||
|
/** 豆瓣 / bgm.tv 请求用的 fetch;null = 不在线查(测试缺省不出外网) */
|
||||||
|
metaFetch?: typeof globalThis.fetch | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CachedAdapter {
|
interface CachedAdapter {
|
||||||
@@ -80,6 +82,12 @@ export class SourcesRuntime {
|
|||||||
* TMDB 客户端:优先注入(测试);否则读设置 metadata.tmdbApiKeySecretRef(secret 现场解密)与 tmdbBaseUrl,
|
* TMDB 客户端:优先注入(测试);否则读设置 metadata.tmdbApiKeySecretRef(secret 现场解密)与 tmdbBaseUrl,
|
||||||
* 未配置时回退环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。设置变化(引用/地址)时重建。
|
* 未配置时回退环境变量 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> {
|
async tmdb(): Promise<TmdbClient | null> {
|
||||||
if (this.overrides.tmdb) {
|
if (this.overrides.tmdb) {
|
||||||
if (this.tmdbClient === undefined) this.tmdbClient = 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) */
|
/** source_release.raw 里站点给出的外部 ID(列表 _site + 详情 _detail) */
|
||||||
export function siteExplicitIds(
|
export function siteExplicitIds(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
): { provider: "imdb" | "douban" | "tmdb"; id: string; type?: "movie" | "tv" }[] {
|
): { provider: "imdb" | "douban" | "tmdb" | "bangumi"; id: string; type?: "movie" | "tv" }[] {
|
||||||
const r = (raw ?? {}) as { _site?: { imdbId?: unknown; doubanId?: unknown }; _detail?: DetailIds };
|
const r = (raw ?? {}) as {
|
||||||
const out: { provider: "imdb" | "douban" | "tmdb"; id: string; type?: "movie" | "tv" }[] = [];
|
_site?: { imdbId?: unknown; doubanId?: unknown; bangumiId?: unknown };
|
||||||
const add = (provider: "imdb" | "douban" | "tmdb", id: unknown, type?: "movie" | "tv") => {
|
_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;
|
if (typeof id !== "string" || !id || out.some((x) => x.provider === provider && x.id === id)) return;
|
||||||
out.push({ provider, id, ...(type ? { type } : {}) });
|
out.push({ provider, id, ...(type ? { type } : {}) });
|
||||||
};
|
};
|
||||||
@@ -149,6 +152,7 @@ export function siteExplicitIds(
|
|||||||
add("imdb", r._site?.imdbId);
|
add("imdb", r._site?.imdbId);
|
||||||
add("douban", r._detail?.doubanId);
|
add("douban", r._detail?.doubanId);
|
||||||
add("douban", r._site?.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);
|
if (r._detail?.tmdb) add("tmdb", r._detail.tmdb.id, r._detail.tmdb.type);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,10 @@ import type { ServerContext } from "../../types.ts";
|
|||||||
|
|
||||||
// ID-3 豆瓣条目缓存(kv_cache region douban.subject,key = 豆瓣 subject id):只存标题 / 别名 / 年份,供「详情页只有豆瓣 ID」时
|
// ID-3 豆瓣条目缓存(kv_cache region douban.subject,key = 豆瓣 subject id):只存标题 / 别名 / 年份,供「详情页只有豆瓣 ID」时
|
||||||
// 按标题 + 年份匹配 TMDB(对照 MP chain/media/projection.py 豆瓣 → TMDB:取豆瓣条目的原名 / 中文名 / 英文名 + 年份做 TMDB 名称匹配)。
|
// 按标题 + 年份匹配 TMDB(对照 MP chain/media/projection.py 豆瓣 → TMDB:取豆瓣条目的原名 / 中文名 / 英文名 + 年份做 TMDB 名称匹配)。
|
||||||
// 本工具不请求豆瓣页面或接口,条目只从已经拿到的数据里抽:
|
// 条目来源(缓存没有时才在线查,见 meta-subjects.ts):
|
||||||
// - site_detail:站点详情页的豆瓣信息块(◎片名 / ◎译名 / ◎年代,多数由 PT-Gen 从豆瓣生成),详情页同时给了豆瓣 ID 时写入;
|
// - 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";
|
export const DOUBAN_SUBJECT_REGION = "douban.subject";
|
||||||
const TTL_SEC = 30 * 86_400;
|
const TTL_SEC = 30 * 86_400;
|
||||||
@@ -19,7 +20,8 @@ export interface DoubanSubject {
|
|||||||
/** 译名 / 又名 / 原名 */
|
/** 译名 / 又名 / 原名 */
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
year: number | null;
|
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 或搜索结果地址 */
|
/** 出处:站点资源 id 或搜索结果地址 */
|
||||||
ref: string;
|
ref: string;
|
||||||
at: string;
|
at: string;
|
||||||
@@ -93,18 +95,22 @@ export function doubanSubjectFromSnippet(
|
|||||||
type Db = Pick<ServerContext, "db">;
|
type Db = Pick<ServerContext, "db">;
|
||||||
|
|
||||||
/** 写入缓存;站点详情来源优先(已有 site_detail 条目时不用搜索摘要覆盖) */
|
/** 写入缓存;站点详情来源优先(已有 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);
|
const cache = pgCacheFor(ctx.db);
|
||||||
if (s.source === "web_snippet") {
|
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;
|
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 {
|
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;
|
return v && typeof v.title === "string" && v.title ? v : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { type SourceReleaseRow, sql } from "@umt/db";
|
|||||||
import { type ParsedRelease, parseRelease } from "@umt/domain";
|
import { type ParsedRelease, parseRelease } from "@umt/domain";
|
||||||
import type { SourcesRuntime } from "../runtime.ts";
|
import type { SourcesRuntime } from "../runtime.ts";
|
||||||
import type { siteExplicitIds } from "./detail-page.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 { refreshReleaseDetail } from "./snapshot.ts";
|
||||||
import { tmdbRecallInput } from "./tmdb-candidate.ts";
|
import { tmdbRecallInput } from "./tmdb-candidate.ts";
|
||||||
|
|
||||||
@@ -117,16 +118,24 @@ export function newIds(before: SiteIds, after: SiteIds): SiteIds {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 豆瓣 ID → 缓存的豆瓣条目 → 按条目标题 / 别名 + 年份召回 TMDB 候选(对照 MP 豆瓣 → TMDB 投影:原名 / 中文名 / 英文名 + 年份)。
|
* 豆瓣 ID → 豆瓣条目(缓存,没有再在线查)→ 按条目标题 / 别名 + 年份召回 TMDB 候选(对照 MP 豆瓣 → TMDB 投影:原名 / 中文名 / 英文名 + 年份)。
|
||||||
* 打分用的解析 = 豆瓣条目的名称与年份,季集与类型线索沿用资源标题的解析。缓存里没有这个条目 → null(不请求豆瓣)。
|
* 打分用的解析 = 条目的名称与年份,季集与类型线索沿用资源标题的解析。拿不到条目 → null。
|
||||||
*/
|
*/
|
||||||
export async function doubanRecall(
|
export async function doubanRecall(
|
||||||
rt: SourcesRuntime,
|
rt: SourcesRuntime,
|
||||||
parsed: ParsedRelease,
|
parsed: ParsedRelease,
|
||||||
doubanId: string,
|
doubanId: string,
|
||||||
): Promise<{ subject: DoubanSubject; parsed: ParsedRelease; remote: MetadataCandidate[] } | null> {
|
): Promise<{ subject: DoubanSubject; parsed: ParsedRelease; remote: MetadataCandidate[] } | null> {
|
||||||
const subject = await loadDoubanSubject(rt.ctx, doubanId);
|
const subject = await loadSubject(rt, "douban", doubanId);
|
||||||
if (!subject) return null;
|
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();
|
const tmdb = await rt.tmdb();
|
||||||
if (!tmdb) return null;
|
if (!tmdb) return null;
|
||||||
const p = doubanParsed(parsed, subject);
|
const p = doubanParsed(parsed, subject);
|
||||||
@@ -134,22 +143,29 @@ export async function doubanRecall(
|
|||||||
return { subject, parsed: p, remote };
|
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 = /[㐀-鿿]/;
|
const CJK = /[㐀-鿿]/;
|
||||||
|
|
||||||
/** 豆瓣条目 + 资源解析 → 打分用的解析(名称 / 年份取豆瓣,季集 / 类型取资源) */
|
/** 豆瓣条目 + 资源解析 → 打分用的解析(名称 / 年份取豆瓣,季集 / 类型取资源) */
|
||||||
export function doubanParsed(parsed: ParsedRelease, s: DoubanSubject): ParsedRelease {
|
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 cn = names.find((n) => CJK.test(n));
|
||||||
const en = names.find((n) => !CJK.test(n) && /[A-Za-z]/.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 {
|
return {
|
||||||
...parsed,
|
...parsed,
|
||||||
title: cn ?? en ?? base.title ?? s.title,
|
title: cn ?? en ?? base.title ?? names[0] ?? s.title,
|
||||||
...(cn ? { titleCn: cn } : {}),
|
...(cn ? { titleCn: cn } : {}),
|
||||||
...(en ? { titleEn: en } : {}),
|
...(en ? { titleEn: en } : {}),
|
||||||
altTitles: names.filter((n) => n !== cn && n !== en).slice(0, 8),
|
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],
|
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,
|
looksLikeVariety,
|
||||||
resolveEntityId,
|
resolveEntityId,
|
||||||
} from "./entities.ts";
|
} 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 { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts";
|
||||||
|
import { loadSubject, searchBangumi } from "./meta-subjects.ts";
|
||||||
import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts";
|
import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts";
|
||||||
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
|
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
|
||||||
|
|
||||||
@@ -224,23 +225,29 @@ async function tmdbByExplicitIds(
|
|||||||
return found;
|
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>) {
|
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) */
|
/** 识别升级链的证据(记在 resolution_evidence.escalation 与池条目 query.escalation) */
|
||||||
export interface EscalationEvidence {
|
export interface EscalationEvidence {
|
||||||
/** 详情页:fetched 本次抓到;skipped:<原因>;deferred:cap_reached(稍后复查时再抓);failed:fetch_failed;not_now:<原因>(本次不抓) */
|
/** 详情页:fetched 本次抓到;skipped:<原因>;deferred:cap_reached(稍后复查时再抓);failed:fetch_failed;not_now:<原因>(本次不抓) */
|
||||||
detail: string;
|
detail: string;
|
||||||
/** 最后用上的路径:site_id(详情 / 列表的 IMDb、TMDB)/ douban(豆瓣条目 → 标题 + 年份) */
|
/** 最后用上的路径:site_id(详情 / 列表的 IMDb、TMDB)/ douban(豆瓣条目 → 标题 + 年份)/ bangumi(bgm 条目,站点给的 ID 或按标题搜) */
|
||||||
via: "site_id" | "douban" | null;
|
via: "site_id" | "douban" | "bangumi" | null;
|
||||||
/** 抓详情后资源上的外部 ID */
|
/** 抓详情后资源上的外部 ID */
|
||||||
ids: string[];
|
ids: string[];
|
||||||
/** 本次没抓、之后还会抓(每站每小时限额已满 / RSS 入库时不抓):不开待处理,尽快复查。抓了失败的不算(不阻塞) */
|
/** 本次没抓、之后还会抓(每站每小时限额已满 / RSS 入库时不抓):不开待处理,尽快复查。抓了失败的不算(不阻塞) */
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
/** 豆瓣条目(豆瓣路径) */
|
/** 豆瓣条目(豆瓣路径) */
|
||||||
douban?: { id: string; title: string; year: number | null; source: string };
|
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 };
|
if (!tmdb) return { evidence: ev };
|
||||||
let best: EscalationOutcome | null = null;
|
let best: EscalationOutcome | null = null;
|
||||||
// 1) 新拿到的 IMDb / TMDB(列表上已有的在第一轮已用过)
|
// 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) {
|
if (added.length) {
|
||||||
const p: ParsedRelease = { ...parsed, explicitIds: [...parsed.explicitIds] };
|
const p: ParsedRelease = { ...parsed, explicitIds: [...parsed.explicitIds] };
|
||||||
for (const x of after)
|
for (const x of after)
|
||||||
@@ -328,12 +335,35 @@ async function escalateIdentity(
|
|||||||
if (res.status === "auto" || (!best && res.status !== "no_match")) {
|
if (res.status === "auto" || (!best && res.status !== "no_match")) {
|
||||||
ev.via = "douban";
|
ev.via = "douban";
|
||||||
best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed };
|
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 };
|
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) {
|
async function loadRelease(rt: SourcesRuntime, input: ResolveInput) {
|
||||||
const { db } = rt.ctx;
|
const { db } = rt.ctx;
|
||||||
const observationId = input.observationId ?? null;
|
const observationId = input.observationId ?? null;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
|||||||
let audiences: string;
|
let audiences: string;
|
||||||
let hhan: string;
|
let hhan: string;
|
||||||
const siteRequests: string[] = [];
|
const siteRequests: string[] = [];
|
||||||
|
const metaRequests: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tmdb = await createMockTmdb({
|
tmdb = await createMockTmdb({
|
||||||
@@ -78,6 +79,8 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
|||||||
fx("tv", 290001, "Sample Series", "示例剧集", "2026-08-01", "tt45950001"),
|
fx("tv", 290001, "Sample Series", "示例剧集", "2026-08-01", "tt45950001"),
|
||||||
// 豆瓣路径:只凭豆瓣条目(标题 + 年份)匹配
|
// 豆瓣路径:只凭豆瓣条目(标题 + 年份)匹配
|
||||||
fx("tv", 990021, "Lanterns of Qing", "青灯记", "2025-04-01"),
|
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", {
|
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);
|
if (u.host === "hhanclub.net" && u.pathname === "/viewfilelist.php") return html(HHANCLUB_FILELIST_FRAGMENT);
|
||||||
throw new Error(`测试不允许的站点请求 ${u.href}`);
|
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" }))
|
audiences = (await seedSite(h, { key: "audiences", baseUrl: "https://audiences.me/", cookie: "c_secure_uid=1" }))
|
||||||
.site.id;
|
.site.id;
|
||||||
@@ -304,7 +326,7 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
|||||||
escalation: { via: "douban", douban: { id: "36000099", title: "青灯记", source: "web_snippet" } },
|
escalation: { via: "douban", douban: { id: "36000099", title: "青灯记", source: "web_snippet" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 缓存里没有这个豆瓣条目:不请求豆瓣,只进池;有 ID 的资源不开「没识别出作品」
|
// 缓存里没有这个豆瓣条目、在线也查不到(404):只进池;有 ID 的资源不开「没识别出作品」
|
||||||
await sharedCache(h.ctx).delete(DOUBAN_SUBJECT_REGION, "36000098");
|
await sharedCache(h.ctx).delete(DOUBAN_SUBJECT_REGION, "36000098");
|
||||||
const r2 = await release(hhan, "91006", "Unknown Drama S01E01 2025 1080p WEB-DL", {
|
const r2 = await release(hhan, "91006", "Unknown Drama S01E01 2025 1080p WEB-DL", {
|
||||||
_detail: { doubanId: "36000098" },
|
_detail: { doubanId: "36000098" },
|
||||||
@@ -316,6 +338,47 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
|||||||
expect(siteRequests.some((x) => x.includes("douban"))).toBe(false);
|
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 () => {
|
it("升级链没认出(豆瓣召回无匹配)时保留原有歧义:候选不丢,照常开有歧义的事项", async () => {
|
||||||
await saveDoubanSubject(h.ctx, {
|
await saveDoubanSubject(h.ctx, {
|
||||||
id: "36000097",
|
id: "36000097",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,11 +29,11 @@ describe("控制台导航", () => {
|
|||||||
expect(ACQUIRE_GROUP_TITLE).toBe("获取");
|
expect(ACQUIRE_GROUP_TITLE).toBe("获取");
|
||||||
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
|
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(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(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length);
|
||||||
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CalendarCog,
|
CalendarCog,
|
||||||
|
Compass,
|
||||||
Download,
|
Download,
|
||||||
FileStack,
|
FileStack,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
@@ -76,6 +77,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
icon: Globe,
|
icon: Globe,
|
||||||
description: "PT 站、公开 BT 站与 RSS 源:账号、资源与 RSS",
|
description: "PT 站、公开 BT 站与 RSS 源:账号、资源与 RSS",
|
||||||
},
|
},
|
||||||
|
{ to: "/console/discover", label: "发现", icon: Compass, description: "bgm.tv 每日放送与豆瓣热门" },
|
||||||
{ to: "/console/subscriptions", label: "订阅", icon: CalendarClock, description: "单次收录与持续追踪" },
|
{ to: "/console/subscriptions", label: "订阅", icon: CalendarClock, description: "单次收录与持续追踪" },
|
||||||
{ to: "/console/rules", label: "规则", icon: ListChecks, description: "收录策略与偏好标签" },
|
{ to: "/console/rules", label: "规则", icon: ListChecks, description: "收录策略与偏好标签" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default [
|
|||||||
route("organize/:id", "routes/console/organize.$id.tsx"),
|
route("organize/:id", "routes/console/organize.$id.tsx"),
|
||||||
route("files", "routes/console/files.tsx"),
|
route("files", "routes/console/files.tsx"),
|
||||||
route("files/trash", "routes/console/files.trash.tsx"),
|
route("files/trash", "routes/console/files.trash.tsx"),
|
||||||
|
route("discover", "routes/console/discover.tsx"),
|
||||||
route("subscriptions", "routes/console/subscriptions.tsx"),
|
route("subscriptions", "routes/console/subscriptions.tsx"),
|
||||||
route("subscriptions/:id", "routes/console/subscriptions.$id.tsx"),
|
route("subscriptions/:id", "routes/console/subscriptions.$id.tsx"),
|
||||||
route("preferences", "routes/console/preferences.tsx"),
|
route("preferences", "routes/console/preferences.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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export const PACKAGE_NAME = "@umt/adapters";
|
|||||||
export * from "./browser/index.ts";
|
export * from "./browser/index.ts";
|
||||||
export * from "./emby/index.ts";
|
export * from "./emby/index.ts";
|
||||||
export * from "./fs/index.ts";
|
export * from "./fs/index.ts";
|
||||||
|
export * from "./meta/index.ts";
|
||||||
export * from "./qbittorrent/index.ts";
|
export * from "./qbittorrent/index.ts";
|
||||||
export * from "./rss/index.ts";
|
export * from "./rss/index.ts";
|
||||||
export * from "./sites/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}`,
|
detailUrl: t.page_url ?? `${def.domain}`,
|
||||||
tags: t.labels ?? [],
|
tags: t.labels ?? [],
|
||||||
...(t.infohash && /^[0-9a-f]{40}$/i.test(t.infohash) ? { infoHash: t.infohash.toLowerCase() } : {}),
|
...(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,
|
raw,
|
||||||
observedAt,
|
observedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export interface SiteRelease {
|
|||||||
infoHash?: string;
|
infoHash?: string;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
doubanId?: string;
|
doubanId?: string;
|
||||||
|
/** bgm.tv 条目 ID(AniBT 等公开 BT 站直接给出) */
|
||||||
|
bangumiId?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
/** 原始字段摘录(可 JSON 化,入 raw jsonb) */
|
/** 原始字段摘录(可 JSON 化,入 raw jsonb) */
|
||||||
raw: Record<string, unknown>;
|
raw: Record<string, unknown>;
|
||||||
|
|||||||
@@ -198,6 +198,29 @@ export const libraryApi = {
|
|||||||
query: search.TmdbMediaQuery,
|
query: search.TmdbMediaQuery,
|
||||||
response: search.TmdbMediaDetail,
|
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({
|
tmdbSeason: ep({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/media/tmdb/tv/{id}/season/{season}",
|
path: "/media/tmdb/tv/{id}/season/{season}",
|
||||||
|
|||||||
@@ -438,3 +438,55 @@ export const TmdbSeasonDetailView = z.object({
|
|||||||
episodes: z.array(MediaEpisodeInfo),
|
episodes: z.array(MediaEpisodeInfo),
|
||||||
});
|
});
|
||||||
export type TmdbSeasonDetailView = z.infer<typeof TmdbSeasonDetailView>;
|
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;
|
infoHash?: string;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
doubanId?: string;
|
doubanId?: string;
|
||||||
|
bangumiId?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
raw: Record<string, unknown>;
|
raw: Record<string, unknown>;
|
||||||
observedAt: string;
|
observedAt: string;
|
||||||
@@ -69,6 +70,7 @@ export function siteReleaseToSourceRelease(
|
|||||||
detailUrl: r.detailUrl,
|
detailUrl: r.detailUrl,
|
||||||
...(r.imdbId ? { imdbId: r.imdbId } : {}),
|
...(r.imdbId ? { imdbId: r.imdbId } : {}),
|
||||||
...(r.doubanId ? { doubanId: r.doubanId } : {}),
|
...(r.doubanId ? { doubanId: r.doubanId } : {}),
|
||||||
|
...(r.bangumiId ? { bangumiId: r.bangumiId } : {}),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
siteId: opts.siteId,
|
siteId: opts.siteId,
|
||||||
|
|||||||
Reference in new issue
Block a user