diff --git a/apps/server/src/modules/search/discover.ts b/apps/server/src/modules/search/discover.ts index a46f0d0a..6e72634c 100644 --- a/apps/server/src/modules/search/discover.ts +++ b/apps/server/src/modules/search/discover.ts @@ -1,17 +1,52 @@ -import type { MetadataCandidate } from "@umt/adapters"; -import { type DiscoverMatchRequest, DiscoverMatchResult } from "@umt/contracts"; +import { + type MetadataCandidate, + type MetaSubject, + type parseBangumiBrowser, + TmdbError, + type TmdbListItem, + type TmdbListPage, + tmdbChart, + tmdbDiscover, + tmdbSearchPage, + tmdbTrending, +} from "@umt/adapters"; +import { + DISCOVER_PAGE_SIZE, + type DiscoverBangumiQuery, + type DiscoverCalendar, + type DiscoverDoubanQuery, + type DiscoverItem, + type DiscoverLocal, + type DiscoverMatchCandidate, + type DiscoverMatchRequest, + DiscoverMatchResult, + type DiscoverPage, + type DiscoverTmdbQuery, + type InventoryEntry, +} from "@umt/contracts"; import { pgCacheFor } from "@umt/db"; import { type IdentityCandidate, parseRelease, resolveIdentity } from "@umt/domain"; import { ApiException } from "../../http/errors.ts"; +import { runSandbox } from "../../sandbox/client.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"; +import { localEntities } from "./media.ts"; -// 发现(对照 MP 推荐页的 Bangumi 每日放送、豆瓣热门):列表来自 bgm.tv / 豆瓣,点开时按名称 + 年份匹配 TMDB 作品。 +// 发现(对照 MP 推荐 / 探索,docs/porting/moviepilot-discover.md): +// - TMDB:流行趋势、榜单、按条件筛选、搜索,走 TMDB 请求缓存(列表类 6 小时,失败不缓存); +// - 豆瓣 / bgm:榜单、标签、筛选、搜索、每日放送、热门趋势,走 douban.request / bangumi.request 请求缓存(meta-subjects.ts); +// - bgm 热门趋势没有 API,网页在沙箱里解析(ADR-005); +// - 每条结果按 TMDB 身份标本地状态(已订阅 / 媒体库已有 / 下载中);豆瓣 / bgm 条目匹配过 TMDB 的才知道(discover.match)。 +// 本地状态每次现查,不进缓存。 -const REGION = "discover.list"; -const TTL_SEC = 3600; +/** 豆瓣 / bgm 条目 → TMDB 作品的确定匹配(点开时写入,列表据此标本地状态) */ +const MATCH_REGION = "discover.match"; +const MATCH_TTL_SEC = 30 * 86_400; +const MATCH_MAX = 20_000; + +export type InventoryLookup = (ids: string[]) => Promise>; function clients(rt: SourcesRuntime) { const c = metaClients(rt); @@ -19,29 +54,201 @@ function clients(rt: SourcesRuntime) { return c; } -async function cached(rt: SourcesRuntime, key: string, load: () => Promise): Promise { - const cache = pgCacheFor(rt.ctx.db); - const hit = await cache.get(REGION, key).catch(() => null); - if (hit) return hit; - let v: T; +async function upstream(what: string, fn: () => Promise): Promise { try { - v = await load(); + return await fn(); } catch (e) { - throw new ApiException("upstream_unavailable", (e as Error).message); + if (e instanceof ApiException) throw e; + if (e instanceof TmdbError && e.kind === "auth") + throw new ApiException("precondition_failed", "TMDB 拒绝了 API Key。到设置的元数据分区检查"); + throw new ApiException( + "upstream_unavailable", + `${what}查询失败:${e instanceof TmdbError ? e.kind : (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() })); +const fromMeta = (s: MetaSubject): DiscoverItem => ({ + source: s.source, + id: s.id, + title: s.title, + aliases: s.aliases, + year: s.year, + kind: s.kind, + cover: s.cover, + rating: s.rating, + date: s.date, + episodes: s.episodes, + url: s.url, + genres: s.genres ?? [], + posterPath: null, + tmdb: null, + local: null, +}); + +const fromTmdb = (t: TmdbListItem): DiscoverItem => ({ + source: "tmdb", + id: `${t.type}:${t.id}`, + title: t.title, + aliases: t.originalTitle && t.originalTitle !== t.title ? [t.originalTitle] : [], + year: t.year, + kind: t.type, + cover: null, + rating: t.voteAverage, + date: t.date, + episodes: null, + url: `https://www.themoviedb.org/${t.type}/${t.id}`, + genres: t.genres, + posterPath: t.posterPath, + tmdb: { type: t.type, id: t.id }, + local: null, +}); + +// ── 本地状态 ── + +/** 豆瓣 / bgm 条目补上记住的 TMDB 匹配,再按 TMDB 身份查本地作品与库存 */ +export async function annotate(rt: SourcesRuntime, items: DiscoverItem[], inventory: InventoryLookup) { + const cache = pgCacheFor(rt.ctx.db); + await Promise.all( + items + .filter((it) => !it.tmdb && it.source !== "tmdb") + .map(async (it) => { + const hit = await cache.get(MATCH_REGION, `${it.source}:${it.id}`).catch(() => null); + if (hit) it.tmdb = { type: hit.type, id: hit.id }; + }), + ); + const refs = items.flatMap((it) => (it.tmdb ? [{ entityType: it.tmdb.type, externalId: it.tmdb.id }] : [])); + if (!refs.length) return items; + const local = await localEntities(rt.ctx, refs); + const ids = [...new Set(local.values())]; + const inv = ids.length ? await inventory(ids) : new Map(); + for (const it of items) { + const entityId = it.tmdb ? local.get(`${it.tmdb.type}:${it.tmdb.id}`) : undefined; + if (!entityId) continue; + const e = inv.get(entityId); + it.local = { + entityId, + subscribed: e?.subscribed ?? false, + versionCount: e?.versions.length ?? 0, + downloadingCount: e?.downloading.length ?? 0, + } satisfies DiscoverLocal; + } + return items; } -export function doubanHot(rt: SourcesRuntime, kind: "movie" | "tv") { - return cached(rt, `douban:hot:${kind}`, async () => ({ items: await clients(rt).douban.hot(kind, 30) })); +// ── TMDB ── + +const tmdbPage = (p: TmdbListPage, page: number): DiscoverPage => ({ + items: p.items.map(fromTmdb), + page, + hasMore: page < Math.min(p.totalPages, 500), +}); + +export async function tmdbList(rt: SourcesRuntime, q: DiscoverTmdbQuery): Promise { + const client = await rt.tmdb(); + if (!client) throw new ApiException("precondition_failed", "未配置 TMDB。到设置的元数据分区填写 API Key"); + const type = q.type === "all" ? "movie" : q.type; + const page = q.page; + const p = await upstream("TMDB ", () => { + switch (q.list) { + case "trending": + return tmdbTrending(client, { mediaType: q.type, window: q.window, page }); + case "search": + return tmdbSearchPage(client, { type: q.type, query: q.q ?? "", page }); + case "discover": + return tmdbDiscover(client, { + type, + ...(q.sort ? { sort: q.sort } : {}), + ...(q.genre ? { genre: q.genre } : {}), + ...(q.language ? { language: q.language } : {}), + ...(q.region ? { region: q.region } : {}), + ...(q.year ? { year: q.year } : {}), + ...(q.minRating ? { minRating: q.minRating } : {}), + page, + today: rt.now().toISOString().slice(0, 10), + }); + default: + return tmdbChart(client, { type, chart: q.list, page }); + } + }); + return tmdbPage(p, page); } -const view = (m: MetadataCandidate) => ({ +// ── 豆瓣 ── + +export async function doubanList(rt: SourcesRuntime, q: DiscoverDoubanQuery): Promise { + const { douban } = clients(rt); + const size = DISCOVER_PAGE_SIZE; + const start = (q.page - 1) * size; + return upstream("豆瓣", async () => { + if (q.list === "search") { + // 联想接口不分页 + const items = q.page === 1 ? await douban.suggest(q.q ?? "") : []; + return { items: items.map(fromMeta), page: q.page, hasMore: false }; + } + if (q.list === "tag") { + const items = await douban.tagSubjects(q.type, q.tag ?? "热门", start, size); + return { items: items.map(fromMeta), page: q.page, hasMore: items.length >= size }; + } + const r = + q.list === "explore" + ? await douban.explore( + q.type, + { sort: q.sort ?? "U", tags: [q.genre, q.region, q.year].filter((x): x is string => !!x) }, + start, + size, + ) + : await douban.collection(q.list, start, size); + const hasMore = r.total !== null ? start + size < r.total : r.items.length >= size; + return { items: r.items.map(fromMeta), page: q.page, hasMore }; + }); +} + +// ── bgm ── + +/** 趋势页 HTML 解析(外部字节走沙箱,ADR-005);测试可替换 */ +export let parseTrendsPage = (html: string) => + runSandbox("bangumi.trends", { html }, { timeoutMs: 15_000 }) as Promise>; +export function setTrendsParser(fn: typeof parseTrendsPage) { + parseTrendsPage = fn; +} + +export async function bangumiList(rt: SourcesRuntime, q: DiscoverBangumiQuery): Promise { + const { bangumi } = clients(rt); + const size = DISCOVER_PAGE_SIZE; + const offset = (q.page - 1) * size; + return upstream("Bangumi ", async () => { + if (q.list === "trends") { + const r = await parseTrendsPage(await bangumi.trendsHtml(q.page)); + return { items: r.items.map(fromMeta), page: q.page, hasMore: r.hasMore }; + } + const r = + q.list === "search" + ? await bangumi.searchPage(q.q ?? "", size, offset) + : await bangumi.browse({ + sort: q.list === "rank" ? "rank" : "date", + ...(q.year ? { year: q.year } : {}), + limit: size, + offset, + }); + return { items: r.items.map(fromMeta), page: q.page, hasMore: offset + size < r.total }; + }); +} + +export async function bangumiCalendar(rt: SourcesRuntime, inventory: InventoryLookup): Promise { + const days = await upstream("Bangumi ", () => clients(rt).bangumi.calendar()); + const out = days.map((d) => ({ ...d, items: d.items.map(fromMeta) })); + await annotate( + rt, + out.flatMap((d) => d.items), + inventory, + ); + return { days: out }; +} + +// ── 匹配 TMDB ── + +const view = (m: MetadataCandidate): DiscoverMatchCandidate => ({ type: m.entityType as "movie" | "tv", id: m.externalId, title: m.title, @@ -50,6 +257,10 @@ const view = (m: MetadataCandidate) => ({ }); export async function matchTmdb(rt: SourcesRuntime, req: DiscoverMatchRequest): Promise { + const cache = pgCacheFor(rt.ctx.db); + const memoKey = `${req.source}:${req.id}`; + const memo = await cache.get(MATCH_REGION, memoKey).catch(() => null); + if (memo) return { match: memo, candidates: [memo] }; 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"; @@ -65,5 +276,8 @@ export async function matchTmdb(rt: SourcesRuntime, req: DiscoverMatchRequest): .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) }); + const match = best ? view(best) : null; + // 只记确定的匹配;不确定 / 失败不记,下次重算 + if (match) await cache.set(MATCH_REGION, memoKey, match, { ttl: MATCH_TTL_SEC, maxsize: MATCH_MAX }).catch(() => {}); + return DiscoverMatchResult.parse({ match, candidates: ranked.map(view) }); } diff --git a/apps/server/src/modules/search/routes.ts b/apps/server/src/modules/search/routes.ts index 14df939d..5c6325ca 100644 --- a/apps/server/src/modules/search/routes.ts +++ b/apps/server/src/modules/search/routes.ts @@ -11,8 +11,10 @@ import { type ApiScope, apiResponse, DEFAULT_SETTINGS, - type DiscoverHotQuery, + type DiscoverBangumiQuery, + type DiscoverDoubanQuery, type DiscoverMatchRequest, + type DiscoverTmdbQuery, type EndpointDef, type InventoryEntry, type InventoryResponse, @@ -40,7 +42,7 @@ import { metadataLanguages, metadataTextOptions } from "../sources-identity/serv import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts"; import type { ModuleApp, ServerContext } from "../types.ts"; import { matchActions } from "./actions.ts"; -import { bangumiCalendar, doubanHot, matchTmdb } from "./discover.ts"; +import { annotate, bangumiCalendar, bangumiList, doubanList, matchTmdb, tmdbList } from "./discover.ts"; import { InternalDispatcher } from "./dispatch.ts"; import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts"; import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts"; @@ -383,8 +385,22 @@ 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.discoverCalendar, ({ c }) => bangumiCalendar(d.runtime(), inventory(c))); + const withLocal = + (c: Context) => + async

[1] }>(p: P): Promise

=> { + await annotate(d.runtime(), p.items, inventory(c)); + return p; + }; + route(app, Api.search.discoverTmdb, async ({ query, c }) => + withLocal(c)(await tmdbList(d.runtime(), query as DiscoverTmdbQuery)), + ); + route(app, Api.search.discoverDouban, async ({ query, c }) => + withLocal(c)(await doubanList(d.runtime(), query as DiscoverDoubanQuery)), + ); + route(app, Api.search.discoverBangumi, async ({ query, c }) => + withLocal(c)(await bangumiList(d.runtime(), query as DiscoverBangumiQuery)), + ); route(app, Api.search.discoverMatch, ({ body }) => matchTmdb(d.runtime(), body as DiscoverMatchRequest)); route(app, Api.search.tmdbDetail, async ({ params, query, c }) => { diff --git a/apps/server/src/modules/sources-identity/service/meta-subjects.ts b/apps/server/src/modules/sources-identity/service/meta-subjects.ts index cb19e591..efe038b1 100644 --- a/apps/server/src/modules/sources-identity/service/meta-subjects.ts +++ b/apps/server/src/modules/sources-identity/service/meta-subjects.ts @@ -3,10 +3,13 @@ import { createBangumiClient, createDoubanClient, type DoubanClient, + type MetaRequestCache, type MetaSubject, + RequestLimiter, } from "@umt/adapters"; -import { pgCacheFor } from "@umt/db"; +import { cachedCall, pgCacheFor } from "@umt/db"; import { normalizeTitle, toSimplified } from "@umt/domain"; +import { storageSettings } from "../../../shared-cache.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { DOUBAN_SUBJECT_REGION, type DoubanSubject, loadDoubanSubject, saveDoubanSubject } from "./douban-subject.ts"; @@ -20,10 +23,46 @@ const MISS_TTL_SEC = 6 * 3600; export type MetaSource = "douban" | "bangumi"; const REGION: Record = { douban: DOUBAN_SUBJECT_REGION, bangumi: BANGUMI_SUBJECT_REGION }; +/** 豆瓣 / bgm 请求缓存 region(对照 MP DoubanApi.__invoke / BangumiApi 的 @cached);key 见 metaRequestKey */ +export const META_REQUEST_REGIONS = { douban: "douban.request", bangumi: "bangumi.request" } as const; +const META_REQUEST_MAXSIZE = 5000; +/** 每源限流(进程内):豆瓣风控严,间隔大一些;缓存命中不占额度 */ +const META_LIMITERS = { douban: new RequestLimiter(2, 300), bangumi: new RequestLimiter(3, 100) }; + +/** + * 请求缓存:搜索按 storage.cacheTtl.metaSearchDays,列表(榜单 / 热门 / 每日放送 / 趋势页)按 metaListHours, + * 空结果按 metaEmptyHours(不超过正常 TTL);失败(网络、403、风控页)在适配器里抛出,cachedCall 不写。 + * 条目详情不走这里:douban.subject / bangumi.subject 条目级缓存(30 天)已经存了,避免同一份数据存两遍。 + */ +export function metaRequestCache(ctx: SourcesRuntime["ctx"]): MetaRequestCache { + return { + async cached(req, load) { + const t = (await storageSettings(ctx)).cacheTtl; + const ttl = req.kind === "search" ? t.metaSearchDays * 86_400 : t.metaListHours * 3600; + return cachedCall( + pgCacheFor(ctx.db), + req.key, + { + region: META_REQUEST_REGIONS[req.source], + ttl, + emptyTtl: Math.min(ttl, t.metaEmptyHours * 3600), + emptyIf: req.empty, + maxsize: META_REQUEST_MAXSIZE, + }, + load, + ); + }, + }; +} + 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 }) }; + const cache = metaRequestCache(rt.ctx); + return { + douban: createDoubanClient({ fetch, cache, limiter: META_LIMITERS.douban }), + bangumi: createBangumiClient({ fetch, cache, limiter: META_LIMITERS.bangumi }), + }; } export function toCached(s: MetaSubject, now: Date): DoubanSubject { diff --git a/apps/server/src/sandbox/jobs.ts b/apps/server/src/sandbox/jobs.ts index cd8cb369..102e8c7a 100644 --- a/apps/server/src/sandbox/jobs.ts +++ b/apps/server/src/sandbox/jobs.ts @@ -1,7 +1,7 @@ // 沙箱任务表(docs/adr/ADR-005-sandbox.md):只放「外部不可信字节 → 结构化数据」的纯函数。 // 子进程加载完本表后撤销全部权限(文件 / 网络 / 环境变量 / 子进程),所以任务里不能有任何 I/O; // 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。 -import { parseTorrent } from "@umt/adapters"; +import { parseBangumiBrowser, parseTorrent } from "@umt/adapters"; import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain"; import { buildPreview } from "../modules/file-manager/preview-build.ts"; import { PAGE_PARSERS, type PageKind } from "../modules/subtitles/online/parse.ts"; @@ -121,12 +121,18 @@ function subtitlePage(input: { kind: PageKind; html: string }) { return fn(input.html); } +/** bgm.tv 动画浏览页(热门趋势,发现页)→ 条目列表 */ +function bangumiTrends(input: { html: string }) { + return parseBangumiBrowser(input.html); +} + export const SANDBOX_JOBS = { "subtitle.parse": subtitleParse, "torrent.parse": torrentParse, "file.preview": buildPreview, "captcha.solve": captchaSolve, "subtitle.page": subtitlePage, + "bangumi.trends": bangumiTrends, "self.test": selfTest, } as const; diff --git a/apps/server/test/search/discover.test.ts b/apps/server/test/search/discover.test.ts new file mode 100644 index 00000000..45859a60 --- /dev/null +++ b/apps/server/test/search/discover.test.ts @@ -0,0 +1,274 @@ +import { createTmdbClient, parseBangumiBrowser } from "@umt/adapters"; +import { type DB, type Kysely, pgCacheFor } from "@umt/db"; +import { createMockTmdb, type MockTmdb } from "@umt/testkit"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { createOutboxPort } from "../../src/context.ts"; +import { createApp } from "../../src/http/app.ts"; +import { EventBus } from "../../src/http/sse.ts"; +import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts"; +import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; +import { setTrendsParser } from "../../src/modules/search/discover.ts"; +import { createSearchModule } from "../../src/modules/search/index.ts"; +import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts"; +import { META_REQUEST_REGIONS } from "../../src/modules/sources-identity/service/meta-subjects.ts"; +import type { ServerContext } from "../../src/modules/types.ts"; +import { SANDBOX_JOBS } from "../../src/sandbox/jobs.ts"; +import { DEV_KEY, memoryLogger } from "../helpers.ts"; +import { fakeEngine, fakeSecrets, plainSettings } from "../sources-identity/harness.ts"; +import { createTestSchema, pgAvailable, type TestSchema, uniqueSchema } from "../support/pg.ts"; + +// 发现页接口:TMDB 列表分页、筛选参数校验、豆瓣 / bgm 请求缓存(命中、空结果短 TTL、失败不缓存)、本地状态标注、 +// 豆瓣 / bgm 条目确定匹配后记住并用于标注。外站全部是 fixture(mock TMDB + 假 fetch)。 + +const up = await pgAvailable(); + +// biome-ignore lint/suspicious/noExplicitAny: 测试直接读 JSON +type Loose = any; + +const DOUBAN_ITEM = (id: string, title: string, year = "2023") => ({ + id, + title, + type: "movie", + year, + rating: { value: 8.1 }, + pic: { large: `https://img.doubanio.com/${id}.jpg` }, + card_subtitle: `${year} / 美国 / 剧情 传记 / 导演 / 主演`, +}); + +const BGM_HTML = `

`; + +describe.skipIf(!up)("发现页接口", () => { + let ts: TestSchema; + let db: Kysely; + let tmdb: MockTmdb; + let app: ReturnType; + const meta: string[] = []; + /** 下一次豆瓣榜单请求的应答 */ + let doubanReply: () => Response = () => new Response("{}"); + + const req = async (path: string, body?: unknown) => { + const res = await app.request(`/api/v1${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + authorization: `Bearer ${DEV_KEY}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return { status: res.status, body: (await res.json()) as Loose }; + }; + const json = (b: unknown, status = 200) => + new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json" } }); + + beforeAll(async () => { + tmdb = await createMockTmdb(); + ts = await createTestSchema(uniqueSchema("discover"), { max: 8 }); + db = ts.db; + const { logger } = memoryLogger(); + const ctx = { + role: "all", + db, + config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never, + logger, + events: new EventBus(5), + engine: fakeEngine(), + outbox: createOutboxPort(), + secrets: fakeSecrets(() => db), + settings: plainSettings(() => db), + capabilities: createCapabilityRegistry({ logger }), + } as ServerContext; + const metaFetch: typeof fetch = async (input, init) => { + const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + meta.push(`${init?.method ?? "GET"} ${u.host}${u.pathname}${u.search}`); + if (u.pathname.startsWith("/rexxar/api/v2/subject_collection/")) return doubanReply(); + if (u.pathname === "/j/subject_suggest") + return json([{ id: "26100958", title: "奥本海默", year: "2023", episode: "", type: "movie" }]); + if (u.pathname === "/j/search_subjects") return json({ subjects: [] }); + if (u.host === "bgm.tv" && u.pathname === "/anime/browser") return new Response(BGM_HTML); + if (u.host === "api.bgm.tv" && u.pathname === "/v0/subjects") + return json({ data: [{ id: 1, name: "a", name_cn: "甲" }], total: 45 }); + throw new Error(`测试不允许的请求 ${u.href}`); + }; + const sources = createSourcesIdentityModule({ + tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }), + metaFetch, + }); + const search = createSearchModule({ runtime: (c) => sources.runtime(c) }); + app = createApp({ + config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, + logger, + bus: new EventBus(5), + role: "all", + version: "0.0.0-test", + modules: [sources, createAcquisitionOrganizeModule(), search], + ctx, + }); + // 趋势页解析缺省走沙箱子进程;这里在进程内跑同一个解析函数(沙箱任务登记见下面的用例) + setTrendsParser(async (html) => parseBangumiBrowser(html)); + + // 本地作品:奥本海默(TMDB movie 872585)已订阅 + const e = await db + .insertInto("media_entity") + .values({ kind: "movie", title: "奥本海默", year: 2023 }) + .returning("id") + .executeTakeFirstOrThrow(); + await db + .insertInto("external_identity") + .values({ + media_entity_id: e.id, + provider: "tmdb", + entity_type: "movie", + external_id: "872585", + status: "accepted", + }) + .execute(); + await db + .insertInto("subscription") + .values({ media_entity_id: e.id, media_kind: "movie", status: "active" }) + .execute(); + }, 120_000); + + afterAll(async () => { + await ts?.drop(); + await tmdb?.close(); + }); + + beforeEach(() => { + meta.length = 0; + }); + + it("TMDB 流行趋势:分页、TMDB 引用、本地状态", async () => { + const r = await req("/discover/tmdb?list=trending&type=all&window=week"); + expect(r.status).toBe(200); + const d = r.body.data; + expect(d.page).toBe(1); + expect(d.hasMore).toBe(false); + expect(d.items.length).toBeGreaterThan(3); + const opp = d.items.find((i: Loose) => i.tmdb?.id === "872585"); + expect(opp).toMatchObject({ + source: "tmdb", + id: "movie:872585", + kind: "movie", + posterPath: "/oppenheimer.jpg", + genres: ["剧情", "历史"], + local: { subscribed: true, versionCount: 0, downloadingCount: 0 }, + }); + expect(d.items.filter((i: Loose) => i.tmdb?.id !== "872585").every((i: Loose) => i.local === null)).toBe(true); + expect(tmdb.callsTo("/3/trending/all/week")).toHaveLength(1); + }); + + it("TMDB 榜单与筛选:参数传到 TMDB", async () => { + const r = await req("/discover/tmdb?list=discover&type=movie&genre=878&sort=rating&year=2020s&minRating=6"); + expect(r.status).toBe(200); + const call = tmdb.callsTo("/3/discover/movie").at(-1)!; + expect(call.query).toMatchObject({ + with_genres: "878", + sort_by: "vote_average.desc", + "primary_release_date.gte": "2020-01-01", + "primary_release_date.lte": "2029-12-31", + "vote_average.gte": "6", + }); + const now = await req("/discover/tmdb?list=now_playing&type=movie&page=1"); + expect(now.body.data.items.every((i: Loose) => i.kind === "movie")).toBe(true); + }); + + it("筛选参数校验:不配的类型 / 列表 / 风格 / 关键词 / 页码 → 422", async () => { + for (const q of [ + "list=now_playing&type=tv", + "list=popular&type=all", + "list=popular&type=movie&genre=878", + "list=discover&type=tv&genre=28", + "list=discover&type=movie&year=1888", + "list=discover&type=movie&minRating=11", + "list=search&type=all", + "list=trending&q=x", + "list=trending&page=0", + "list=trending&page=501", + ]) { + const r = await req(`/discover/tmdb?${q}`); + expect(r.status, q).toBe(422); + } + for (const q of ["list=nope", "list=tag&tag=不存在", "list=explore&genre=不存在", "list=movie_top250&sort=S"]) { + expect((await req(`/discover/douban?${q}`)).status, q).toBe(422); + } + expect((await req("/discover/bangumi?list=trends&year=2024")).status).toBe(422); + expect((await req("/discover/bangumi?list=search")).status).toBe(422); + }); + + it("豆瓣榜单:请求缓存命中;空结果短 TTL;失败不缓存", async () => { + const cache = pgCacheFor(db); + // 失败(403)不缓存 + doubanReply = () => new Response("forbidden", { status: 403 }); + expect((await req("/discover/douban?list=movie_showing")).status).toBe(502); + expect(await cache.count(META_REQUEST_REGIONS.douban)).toBe(0); + // 风控页(不是 JSON)不缓存 + doubanReply = () => new Response("验证", { headers: { "content-type": "text/html" } }); + expect((await req("/discover/douban?list=movie_showing")).status).toBe(502); + expect(await cache.count(META_REQUEST_REGIONS.douban)).toBe(0); + + doubanReply = () => + json({ subject_collection_items: [DOUBAN_ITEM("26100958", "奥本海默"), DOUBAN_ITEM("2", "乙")], total: 45 }); + const a = await req("/discover/douban?list=movie_showing&page=2"); + expect(a.status).toBe(200); + expect(a.body.data).toMatchObject({ page: 2, hasMore: true }); + expect(a.body.data.items[0]).toMatchObject({ source: "douban", id: "26100958", genres: ["剧情", "传记"] }); + const b = await req("/discover/douban?list=movie_showing&page=2"); + expect(b.body.data.items).toHaveLength(2); + expect(meta.filter((m) => m.includes("movie_showing"))).toHaveLength(3); + expect(meta.at(-1)).toContain("start=20&count=20"); + + // 空结果:写入但用空结果 TTL(缺省 1 小时),正常 6 小时 + doubanReply = () => json({ subject_collection_items: [], total: 0 }); + await req("/discover/douban?list=tv_korean"); + const rows = await cache.items(META_REQUEST_REGIONS.douban); + const ttlOf = (part: string) => { + const row = rows.find((x) => x.key.includes(part))!; + return Math.round((new Date(row.expiresAt!).getTime() - Date.now()) / 60_000); + }; + expect(ttlOf("tv_korean")).toBeLessThanOrEqual(60); + expect(ttlOf("tv_korean")).toBeGreaterThan(55); + expect(ttlOf("movie_showing")).toBeGreaterThan(5 * 60); + }); + + it("搜索与 bgm 浏览 / 趋势", async () => { + const s = await req("/discover/douban?list=search&q=奥本海默"); + expect(s.body.data).toMatchObject({ hasMore: false, items: [{ id: "26100958", kind: "movie", year: 2023 }] }); + const t = await req("/discover/bangumi?list=trends"); + expect(t.body.data.items[0]).toMatchObject({ source: "bangumi", id: "400602", episodes: 28 }); + const rank = await req("/discover/bangumi?list=rank&year=2024&page=2"); + expect(rank.body.data).toMatchObject({ page: 2, hasMore: true }); + expect(meta.find((m) => m.includes("/v0/subjects"))).toContain("offset=20"); + expect(SANDBOX_JOBS["bangumi.trends"]({ html: BGM_HTML }).items[0]!.id).toBe("400602"); + }); + + it("豆瓣条目确定匹配后记住,列表据此标本地状态", async () => { + const m = await req("/discover/tmdb-match", { + source: "douban", + id: "26100958", + title: "奥本海默", + aliases: ["Oppenheimer"], + year: 2023, + kind: "movie", + }); + expect(m.status).toBe(200); + expect(m.body.data.match).toMatchObject({ type: "movie", id: "872585" }); + const searches = tmdb.callsTo("/3/search").length; + const again = await req("/discover/tmdb-match", { + source: "douban", + id: "26100958", + title: "奥本海默", + aliases: [], + year: 2023, + kind: "movie", + }); + expect(again.body.data.match.id).toBe("872585"); + expect(tmdb.callsTo("/3/search").length).toBe(searches); + + const s = await req("/discover/douban?list=search&q=奥本海默"); + expect(s.body.data.items[0]).toMatchObject({ + tmdb: { type: "movie", id: "872585" }, + local: { subscribed: true }, + }); + }); +}); diff --git a/apps/web/app/api/discover.ts b/apps/web/app/api/discover.ts index 5d2fd907..eb6c4f98 100644 --- a/apps/web/app/api/discover.ts +++ b/apps/web/app/api/discover.ts @@ -1,30 +1,59 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { DiscoverCalendar, type DiscoverItem, DiscoverList, DiscoverMatchResult } from "@umt/contracts"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { DiscoverCalendar, type DiscoverItem, DiscoverMatchResult, DiscoverPage } from "@umt/contracts"; +import type { ApiQuery } from "~/components/discover/discover-state.ts"; import { api } from "~/lib/api.ts"; import { getParsed, parseOrThrow } from "./library.ts"; -// 发现:bgm.tv 每日放送、豆瓣热门(服务端缓存 1 小时);点条目时匹配 TMDB 作品。 +// 发现:TMDB / 豆瓣 / bgm 列表(服务端走请求缓存);点豆瓣 / bgm 条目时匹配 TMDB 作品。 + +const STALE = 10 * 60_000; export const discoverKeys = { calendar: ["discover", "calendar"] as const, - hot: (kind: "movie" | "tv") => ["discover", "douban-hot", kind] as const, + list: (q: ApiQuery) => ["discover", q] as const, }; +async function fetchCalendar(signal?: AbortSignal) { + return getParsed(DiscoverCalendar, "/discover/bangumi-calendar", {}, signal); +} + +/** 一页;每日放送取那一天,不分页 */ +export async function fetchDiscoverPage(q: ApiQuery, page: number, signal?: AbortSignal): Promise { + if (q.kind === "calendar") { + const cal = await fetchCalendar(signal); + const items = cal.days.find((d) => String(d.weekday) === q.weekday)?.items ?? []; + return { items, page: 1, hasMore: false }; + } + return getParsed(DiscoverPage, q.path, { ...q.params, page }, signal); +} + +/** 推荐分区:只取第一页;enabled 由分区是否进入视口决定 */ +export function useDiscoverSection(q: ApiQuery, enabled: boolean) { + return useQuery({ + queryKey: [...discoverKeys.list(q), 1], + enabled, + queryFn: ({ signal }) => fetchDiscoverPage(q, 1, signal), + staleTime: STALE, + }); +} + +/** 探索:无限滚动 */ +export function useDiscoverInfinite(q: ApiQuery) { + return useInfiniteQuery({ + queryKey: [...discoverKeys.list(q), "all"], + initialPageParam: 1, + queryFn: ({ pageParam, signal }) => fetchDiscoverPage(q, pageParam, signal), + getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined), + staleTime: STALE, + }); +} + 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, + queryFn: ({ signal }) => fetchCalendar(signal), + staleTime: STALE, }); } diff --git a/apps/web/app/components/discover/chips.tsx b/apps/web/app/components/discover/chips.tsx new file mode 100644 index 00000000..60a374a7 --- /dev/null +++ b/apps/web/app/components/discover/chips.tsx @@ -0,0 +1,44 @@ +import { cn } from "~/lib/utils.ts"; +import type { Option } from "./discover-state.ts"; + +// 一行单选芯片(选中用 pill-selected);标签在左,窄屏横向滚动。allLabel 给了就多一个「不限」芯片(value 为空)。 + +export function ChipRow({ + label, + options, + value, + onChange, + allLabel, +}: { + label: string; + options: Option[]; + value: string | undefined; + onChange: (v: string | undefined) => void; + allLabel?: string; +}) { + const all = allLabel ? [{ value: "", label: allLabel }, ...options] : options; + return ( +
+ {label} +
+ {all.map((o) => { + const on = (value ?? "") === o.value; + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/web/app/components/discover/discover-card.tsx b/apps/web/app/components/discover/discover-card.tsx new file mode 100644 index 00000000..8f78338f --- /dev/null +++ b/apps/web/app/components/discover/discover-card.tsx @@ -0,0 +1,123 @@ +import { type DiscoverItem, tmdbImageUrl } from "@umt/contracts"; +import { CalendarPlus, LoaderCircle, PackageSearch, Star } from "lucide-react"; +import { useState } from "react"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { cn } from "~/lib/utils.ts"; +import { itemMeta, localBadges } from "./discover-state.ts"; + +// 发现卡片:海报(懒加载)、评分、本地状态、标题、年份 · 类型 · 风格;点海报 / 标题进作品,下面两个动作。 + +export type CardAction = "open" | "subscribe" | "search"; + +export function coverUrl(it: Pick): string | null { + return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : it.cover; +} + +export function DiscoverCard({ + item, + busy, + onAction, + className, +}: { + item: DiscoverItem; + /** 正在为这张卡匹配 TMDB */ + busy: CardAction | null; + onAction: (a: CardAction) => void; + className?: string; +}) { + const [failed, setFailed] = useState(false); + const src = failed ? null : coverUrl(item); + const badges = localBadges(item); + const meta = itemMeta(item); + return ( +
+ +
+ +

{meta || "—"}

+
+ + +
+
+
+ ); +} + +export function DiscoverCardSkeleton({ className }: { className?: string }) { + return ( +
+
+
+
+
+ ); +} diff --git a/apps/web/app/components/discover/discover-state.ts b/apps/web/app/components/discover/discover-state.ts new file mode 100644 index 00000000..d7bdd3a4 --- /dev/null +++ b/apps/web/app/components/discover/discover-state.ts @@ -0,0 +1,389 @@ +import { + DISCOVER_YEAR, + type DiscoverItem, + DOUBAN_COLLECTIONS, + DOUBAN_GENRES, + DOUBAN_REGIONS, + DOUBAN_SORTS, + DOUBAN_TAGS, + DOUBAN_YEAR, + TMDB_LANGUAGES, + TMDB_LIST_TYPES, + TMDB_REGIONS, + TMDB_SORTS, + type TmdbDiscoverList, + tmdbGenres, +} from "@umt/contracts"; +import type { StatusTone } from "~/components/console/status-badge.tsx"; + +// 发现页状态(都在 URL 里):view = recommend | explore;探索的来源 / 列表 / 筛选。 +// 这里是纯函数:URL ↔ 状态、筛选器联动、状态 → 接口参数、卡片状态与去处。 + +export type Src = "tmdb" | "douban" | "bangumi"; +export type View = "recommend" | "explore"; + +export interface ExploreState { + src: Src; + /** TMDB:TmdbDiscoverList;豆瓣:榜单 id / tag / explore / search;bgm:calendar / trends / rank / latest / search */ + list: string; + /** TMDB:all / movie / tv;豆瓣:movie / tv;bgm 不用 */ + type: string; + window?: string; + sort?: string; + genre?: string; + language?: string; + region?: string; + year?: string; + minRating?: string; + tag?: string; + q?: string; + /** bgm 每日放送:1-7 */ + weekday?: string; +} + +export type Option = { value: string; label: string }; +const opts = (r: Record): Option[] => Object.entries(r).map(([value, label]) => ({ value, label })); + +export const SOURCES: Option[] = [ + { value: "tmdb", label: "TMDB" }, + { value: "douban", label: "豆瓣" }, + { value: "bangumi", label: "Bangumi" }, +]; + +export const DEFAULT_LIST: Record = { + tmdb: "trending", + douban: "movie_real_time_hotest", + bangumi: "trends", +}; + +/** TMDB 列表名(按类型):正在上映 / 即将上映只有电影,正在播出 / 今日播出只有剧集 */ +export function tmdbListOptions(type: string): Option[] { + const all: [TmdbDiscoverList, string][] = [ + ["trending", "流行趋势"], + ["popular", "热门"], + ["now_playing", "正在上映"], + ["upcoming", "即将上映"], + ["on_the_air", "正在播出"], + ["airing_today", "今日播出"], + ["top_rated", "高分"], + ["discover", "筛选"], + ]; + return all + .filter(([v]) => { + const only = TMDB_LIST_TYPES[v]; + return !only || type === "all" || only === type; + }) + .map(([value, label]) => ({ value, label })); +} + +export const TMDB_TYPES: Option[] = [ + { value: "all", label: "全部" }, + { value: "movie", label: "电影" }, + { value: "tv", label: "剧集" }, +]; +export const MEDIA_TYPES: Option[] = TMDB_TYPES.slice(1); +export const WINDOWS: Option[] = [ + { value: "week", label: "本周" }, + { value: "day", label: "今日" }, +]; +export const TMDB_SORT_OPTIONS = opts(TMDB_SORTS); +export const TMDB_LANGUAGE_OPTIONS = opts(TMDB_LANGUAGES); +export const TMDB_REGION_OPTIONS = opts(TMDB_REGIONS); +export const RATING_OPTIONS: Option[] = ["6", "7", "8"].map((v) => ({ value: v, label: `${v} 分以上` })); + +/** 年份:最近 6 年 + 年代(TMDB 用 2010s,豆瓣用 2010年代) */ +export function yearOptions(src: Src, now = new Date()): Option[] { + const y = now.getFullYear(); + const years = Array.from({ length: 6 }, (_, i) => String(y - i)); + if (src === "bangumi") + return Array.from({ length: 10 }, (_, i) => String(y - i)).map((v) => ({ value: v, label: v })); + const decades = [2020, 2010, 2000, 1990, 1980]; + return [ + ...years.map((v) => ({ value: v, label: v })), + ...decades.map((d) => + src === "tmdb" + ? { value: `${d}s`, label: `${d} 年代` } + : { value: d >= 2000 ? `${d}年代` : `${String(d).slice(2)}年代`, label: `${d} 年代` }, + ), + ]; +} + +export const DOUBAN_LIST_OPTIONS: Option[] = [ + ...Object.entries(DOUBAN_COLLECTIONS).map(([value, c]) => ({ value, label: c.label })), + { value: "tag", label: "按标签" }, + { value: "explore", label: "筛选" }, +]; +export const DOUBAN_SORT_OPTIONS = opts(DOUBAN_SORTS); +export const DOUBAN_GENRE_OPTIONS = DOUBAN_GENRES.map((g) => ({ value: g, label: g })); +export const DOUBAN_REGION_OPTIONS = DOUBAN_REGIONS.map((g) => ({ value: g, label: g })); + +export const BANGUMI_LIST_OPTIONS: Option[] = [ + { value: "calendar", label: "每日放送" }, + { value: "trends", label: "热门趋势" }, + { value: "rank", label: "排行" }, + { value: "latest", label: "最新" }, +]; +export const WEEKDAYS: Option[] = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"].map((label, i) => ({ + value: String(i + 1), + label, +})); +/** bgm 周一 = 1 … 周日 = 7 */ +export const todayWeekday = (d = new Date()) => String(((d.getDay() + 6) % 7) + 1); + +const KEYS = [ + "src", + "list", + "type", + "window", + "sort", + "genre", + "language", + "region", + "year", + "minRating", + "tag", + "q", + "weekday", +] as const; + +const FILTER_KEYS = ["sort", "genre", "language", "region", "year", "minRating"] as const; + +/** 规整:去掉与当前来源 / 列表 / 类型不配的参数(和服务端校验同一套规则,不发会被拒的请求) */ +export function normalize(input: Partial): ExploreState { + const src: Src = input.src === "douban" || input.src === "bangumi" ? input.src : "tmdb"; + const s: ExploreState = { src, list: input.list || DEFAULT_LIST[src], type: input.type ?? "" }; + const q = input.q?.trim(); + if (q) { + s.list = "search"; + s.q = q; + } else if (s.list === "search") s.list = DEFAULT_LIST[src]; + + if (src === "tmdb") { + s.type = ["all", "movie", "tv"].includes(s.type) ? s.type : "all"; + const valid = ["search", ...tmdbListOptions("all").map((o) => o.value)]; + if (!valid.includes(s.list)) s.list = DEFAULT_LIST.tmdb; + const only = TMDB_LIST_TYPES[s.list as TmdbDiscoverList]; + if (only) s.type = only; + else if (s.type === "all" && !["trending", "search"].includes(s.list)) s.type = "movie"; + if (s.list === "trending") s.window = input.window === "day" ? "day" : "week"; + if (s.list === "discover") { + const t = s.type as "movie" | "tv"; + if (input.sort && input.sort in TMDB_SORTS) s.sort = input.sort; + if (input.genre && input.genre in tmdbGenres(t)) s.genre = input.genre; + if (input.language && input.language in TMDB_LANGUAGES) s.language = input.language; + if (input.region && input.region in TMDB_REGIONS) s.region = input.region; + if (input.year && DISCOVER_YEAR.test(input.year)) s.year = input.year; + if (input.minRating && /^([0-9]|10)$/.test(input.minRating)) s.minRating = input.minRating; + } + } else if (src === "douban") { + s.type = s.type === "tv" ? "tv" : "movie"; + const coll = DOUBAN_COLLECTIONS[s.list]; + if (!coll && !["tag", "explore", "search"].includes(s.list)) s.list = DEFAULT_LIST.douban; + if (s.list === "tag") s.tag = input.tag && DOUBAN_TAGS[s.type as "movie"].includes(input.tag) ? input.tag : "热门"; + if (s.list === "explore") { + if (input.sort && input.sort in DOUBAN_SORTS) s.sort = input.sort; + if (input.genre && DOUBAN_GENRES.includes(input.genre)) s.genre = input.genre; + if (input.region && DOUBAN_REGIONS.includes(input.region)) s.region = input.region; + if (input.year && DOUBAN_YEAR.test(input.year)) s.year = input.year; + } + } else { + s.type = ""; + if (!["calendar", "trends", "rank", "latest", "search"].includes(s.list)) s.list = DEFAULT_LIST.bangumi; + if (s.list === "calendar") s.weekday = /^[1-7]$/.test(input.weekday ?? "") ? input.weekday : todayWeekday(); + if ((s.list === "rank" || s.list === "latest") && input.year && /^\d{4}$/.test(input.year)) s.year = input.year; + } + return s; +} + +export function fromSearchParams(sp: URLSearchParams): ExploreState { + const raw: Partial = {}; + for (const k of KEYS) { + const v = sp.get(k); + if (v) (raw as Record)[k] = v; + } + return normalize(raw); +} + +export function toSearchParams(s: ExploreState, view: View = "explore"): URLSearchParams { + const sp = new URLSearchParams({ view }); + for (const k of KEYS) { + const v = s[k]; + if (v) sp.set(k, v); + } + return sp; +} + +/** + * 筛选器联动:改来源回到该来源的默认列表;改类型时去掉不配的风格 / 列表;离开「筛选」清掉筛选条件; + * 值等于当前值时视为取消(芯片再点一次清除)。 + */ +export function patch(s: ExploreState, p: Partial): ExploreState { + if (p.src && p.src !== s.src) return normalize({ src: p.src }); + const next: Partial = { ...s }; + for (const [k, v] of Object.entries(p) as [keyof ExploreState, string | undefined][]) { + const toggleable = (FILTER_KEYS as readonly string[]).includes(k) || k === "year"; + next[k] = (toggleable && v === s[k] ? undefined : v) as never; + } + if (p.list && p.list !== s.list) { + for (const k of FILTER_KEYS) delete next[k]; + delete next.tag; + if (p.list !== "search") delete next.q; + } + return normalize(next); +} + +/** 接口与参数;bgm 每日放送走日历接口 */ +export type ApiQuery = + | { kind: "list"; path: string; params: Record } + | { kind: "calendar"; weekday: string }; + +export function apiQuery(s: ExploreState): ApiQuery { + if (s.src === "bangumi" && s.list === "calendar") return { kind: "calendar", weekday: s.weekday ?? todayWeekday() }; + const params: Record = { list: s.list }; + const pick = (...ks: (keyof ExploreState)[]) => { + for (const k of ks) if (s[k]) params[k] = s[k]!; + }; + if (s.src === "tmdb") pick("type", "window", ...FILTER_KEYS, "q"); + else if (s.src === "douban") pick("type", "tag", "sort", "genre", "region", "year", "q"); + else pick("year", "q"); + if (s.src === "tmdb" && s.list !== "trending") delete params.window; + return { kind: "list", path: `/discover/${s.src}`, params }; +} + +// ── 卡片 ── + +const TMDB_DETAIL = "/console/media/tmdb"; + +/** 直接能去的作品页:本地作品 → 媒体库详情;有 TMDB 身份 → TMDB 作品详情;豆瓣 / bgm 没匹配过 → null(先匹配) */ +export function itemHref(it: Pick): string | null { + if (it.local) return `/console/library/${it.local.entityId}`; + if (it.tmdb) return `${TMDB_DETAIL}/${it.tmdb.type}/${it.tmdb.id}`; + return null; +} + +/** 本地状态徽章:只列非默认状态(不在媒体库不显示) */ +export function localBadges(it: Pick): { label: string; tone: StatusTone }[] { + const l = it.local; + if (!l) return []; + const out: { label: string; tone: StatusTone }[] = []; + if (l.versionCount > 0) out.push({ label: "媒体库已有", tone: "success" }); + else if (l.downloadingCount > 0) out.push({ label: "下载中", tone: "warning" }); + if (l.subscribed) out.push({ label: "已订阅", tone: "neutral" }); + if (!out.length) out.push({ label: "在媒体库", tone: "neutral" }); + return out; +} + +/** 卡片副标题:年份 · 电影 / 剧集 · 前两个风格 */ +export function itemMeta(it: Pick): string { + const parts: string[] = []; + const y = it.year ?? (it.date ? Number(it.date.slice(0, 4)) : null); + if (y) parts.push(String(y)); + if (it.kind) parts.push(it.kind === "movie" ? "电影" : "剧集"); + parts.push(...it.genres.slice(0, 2)); + return parts.join(" · "); +} + +// ── 推荐分区(对照 MP recommendSources.ts) ── + +export type SectionCategory = "movie" | "tv" | "anime" | "chart"; +export const CATEGORIES: Option[] = [ + { value: "", label: "全部" }, + { value: "movie", label: "电影" }, + { value: "tv", label: "剧集" }, + { value: "anime", label: "动画" }, + { value: "chart", label: "榜单" }, +]; + +export interface RecommendSection { + key: string; + title: string; + category: SectionCategory; + explore: Partial; +} + +export const RECOMMEND_SECTIONS: RecommendSection[] = [ + { key: "tmdb-trending", title: "TMDB 流行趋势", category: "chart", explore: { src: "tmdb", list: "trending" } }, + { + key: "tmdb-now-playing", + title: "正在热映", + category: "movie", + explore: { src: "tmdb", list: "now_playing", type: "movie" }, + }, + { + key: "tmdb-popular-movie", + title: "TMDB 热门电影", + category: "movie", + explore: { src: "tmdb", list: "popular", type: "movie" }, + }, + { + key: "tmdb-popular-tv", + title: "TMDB 热门剧集", + category: "tv", + explore: { src: "tmdb", list: "popular", type: "tv" }, + }, + { + key: "douban-movie-hot", + title: "豆瓣热门电影", + category: "movie", + explore: { src: "douban", list: "movie_real_time_hotest" }, + }, + { + key: "douban-tv-hot", + title: "豆瓣热门剧集", + category: "tv", + explore: { src: "douban", list: "tv_real_time_hotest", type: "tv" }, + }, + { key: "bangumi-today", title: "Bangumi 今日放送", category: "anime", explore: { src: "bangumi", list: "calendar" } }, + { key: "bangumi-trends", title: "Bangumi 热门趋势", category: "anime", explore: { src: "bangumi", list: "trends" } }, + { + key: "tmdb-upcoming", + title: "即将上映", + category: "movie", + explore: { src: "tmdb", list: "upcoming", type: "movie" }, + }, + { + key: "tmdb-on-the-air", + title: "正在播出", + category: "tv", + explore: { src: "tmdb", list: "on_the_air", type: "tv" }, + }, + { + key: "douban-animation", + title: "豆瓣热门动画", + category: "anime", + explore: { src: "douban", list: "tv_animation", type: "tv" }, + }, + { + key: "douban-show", + title: "豆瓣热门综艺", + category: "tv", + explore: { src: "douban", list: "show_hot", type: "tv" }, + }, + { + key: "douban-top250", + title: "豆瓣电影 Top250", + category: "chart", + explore: { src: "douban", list: "movie_top250" }, + }, + { + key: "tmdb-top-rated", + title: "TMDB 高分电影", + category: "chart", + explore: { src: "tmdb", list: "top_rated", type: "movie" }, + }, + { + key: "douban-chinese-weekly", + title: "华语口碑剧集榜", + category: "chart", + explore: { src: "douban", list: "tv_chinese_best_weekly", type: "tv" }, + }, + { + key: "douban-global-weekly", + title: "全球口碑剧集榜", + category: "chart", + explore: { src: "douban", list: "tv_global_best_weekly", type: "tv" }, + }, +]; + +export const sectionsOf = (category: string) => + category ? RECOMMEND_SECTIONS.filter((s) => s.category === category) : RECOMMEND_SECTIONS; diff --git a/apps/web/app/components/discover/discover.test.ts b/apps/web/app/components/discover/discover.test.ts new file mode 100644 index 00000000..b7b37a6b --- /dev/null +++ b/apps/web/app/components/discover/discover.test.ts @@ -0,0 +1,191 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type DiscoverItem, DiscoverItem as DiscoverItemSchema } from "@umt/contracts"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; +import { DiscoverCard } from "./discover-card.tsx"; +import { + apiQuery, + type ExploreState, + fromSearchParams, + itemHref, + itemMeta, + localBadges, + normalize, + patch, + RECOMMEND_SECTIONS, + sectionsOf, + tmdbListOptions, + toSearchParams, +} from "./discover-state.ts"; +import { ExploreFilters } from "./explore-view.tsx"; +import { RecommendView } from "./recommend-view.tsx"; + +// 发现页:URL ↔ 状态、筛选器联动、接口参数、卡片状态与去处、分区与筛选器渲染。 + +const item = (p: Partial = {}): DiscoverItem => + DiscoverItemSchema.parse({ + source: "tmdb", + id: "movie:872585", + title: "奥本海默", + aliases: [], + year: 2023, + kind: "movie", + cover: null, + rating: 8.1, + date: "2023-07-19", + episodes: null, + url: "https://www.themoviedb.org/movie/872585", + genres: ["剧情", "历史", "传记"], + posterPath: "/oppenheimer.jpg", + tmdb: { type: "movie", id: "872585" }, + ...p, + }); + +const render = (el: ReturnType) => + renderToStaticMarkup( + createElement(QueryClientProvider, { client: new QueryClient() }, createElement(MemoryRouter, null, el)), + ); + +describe("探索状态规整与联动", () => { + it("缺省:TMDB 本周流行趋势;URL 往返", () => { + const s = fromSearchParams(new URLSearchParams()); + expect(s).toEqual({ src: "tmdb", list: "trending", type: "all", window: "week" }); + const t = normalize({ src: "tmdb", list: "discover", type: "tv", genre: "10765", year: "2010s", sort: "rating" }); + expect(fromSearchParams(toSearchParams(t))).toEqual(t); + expect(toSearchParams(t).get("view")).toBe("explore"); + }); + + it("类型与列表互相约束:正在上映只有电影,全部只用于趋势 / 搜索", () => { + expect(normalize({ src: "tmdb", list: "now_playing", type: "tv" }).type).toBe("movie"); + expect(normalize({ src: "tmdb", list: "popular", type: "all" }).type).toBe("movie"); + expect(tmdbListOptions("tv").map((o) => o.value)).not.toContain("now_playing"); + expect(tmdbListOptions("movie").map((o) => o.value)).not.toContain("on_the_air"); + }); + + it("改类型去掉不配的风格;离开筛选清掉条件;芯片再点一次取消", () => { + const s = normalize({ src: "tmdb", list: "discover", type: "movie", genre: "28", region: "US" }); + expect(patch(s, { type: "tv" })).toMatchObject({ type: "tv", region: "US" }); + expect(patch(s, { type: "tv" }).genre).toBeUndefined(); // 28 动作 只有电影有 + expect(patch(s, { list: "popular" })).toEqual({ src: "tmdb", list: "popular", type: "movie" }); + expect(patch(s, { region: "US" }).region).toBeUndefined(); + expect(patch(s, { region: "JP" }).region).toBe("JP"); + }); + + it("换来源回到该来源默认列表;关键词切到搜索,清空回到默认", () => { + const s = normalize({ src: "tmdb", list: "discover", type: "movie", genre: "28" }); + expect(patch(s, { src: "douban" })).toEqual({ src: "douban", list: "movie_real_time_hotest", type: "movie" }); + const q = patch(s, { q: " 沙丘 " }); + expect(q).toMatchObject({ list: "search", q: "沙丘", type: "movie" }); + expect(q.genre).toBeUndefined(); + expect(patch(q, { q: "" }).list).toBe("trending"); + }); + + it("豆瓣标签按类型校正;bgm 每日放送补星期,年份只给排行 / 最新", () => { + expect(normalize({ src: "douban", list: "tag", type: "tv", tag: "豆瓣高分" }).tag).toBe("热门"); + expect(normalize({ src: "douban", list: "tag", type: "tv", tag: "美剧" }).tag).toBe("美剧"); + expect(normalize({ src: "douban", list: "nope" }).list).toBe("movie_real_time_hotest"); + expect(normalize({ src: "bangumi", list: "calendar", weekday: "3" }).weekday).toBe("3"); + expect(normalize({ src: "bangumi", list: "calendar" }).weekday).toMatch(/^[1-7]$/); + expect(normalize({ src: "bangumi", list: "trends", year: "2024" }).year).toBeUndefined(); + expect(normalize({ src: "bangumi", list: "rank", year: "2024" }).year).toBe("2024"); + }); + + it("接口参数:只带当前列表用得到的", () => { + expect(apiQuery(normalize({}))).toEqual({ + kind: "list", + path: "/discover/tmdb", + params: { list: "trending", type: "all", window: "week" }, + }); + expect(apiQuery(normalize({ src: "tmdb", list: "discover", type: "movie", minRating: "7" }))).toEqual({ + kind: "list", + path: "/discover/tmdb", + params: { list: "discover", type: "movie", minRating: "7" }, + }); + expect(apiQuery(normalize({ src: "douban", list: "explore", type: "tv", genre: "悬疑", sort: "S" }))).toEqual({ + kind: "list", + path: "/discover/douban", + params: { list: "explore", type: "tv", sort: "S", genre: "悬疑" }, + }); + expect(apiQuery(normalize({ src: "bangumi", list: "calendar", weekday: "5" }))).toEqual({ + kind: "calendar", + weekday: "5", + }); + expect(apiQuery(normalize({ src: "bangumi", q: "芙莉莲" }))).toEqual({ + kind: "list", + path: "/discover/bangumi", + params: { list: "search", q: "芙莉莲" }, + }); + }); +}); + +describe("卡片", () => { + it("去处:本地作品 → 媒体库;TMDB → TMDB 作品页;没匹配过的豆瓣条目 → 先匹配", () => { + const local = { entityId: "e1", subscribed: true, versionCount: 0, downloadingCount: 0 }; + expect(itemHref(item({ local }))).toBe("/console/library/e1"); + expect(itemHref(item())).toBe("/console/media/tmdb/movie/872585"); + expect(itemHref(item({ source: "douban", id: "1", tmdb: null }))).toBeNull(); + }); + + it("本地状态只列非默认:已有 / 下载中 / 已订阅;在媒体库但都没有时写在媒体库", () => { + const l = (p: object) => ({ entityId: "e", subscribed: false, versionCount: 0, downloadingCount: 0, ...p }); + expect(localBadges(item())).toEqual([]); + expect(localBadges(item({ local: l({ versionCount: 2, subscribed: true }) })).map((b) => b.label)).toEqual([ + "媒体库已有", + "已订阅", + ]); + expect(localBadges(item({ local: l({ downloadingCount: 1 }) })).map((b) => b.label)).toEqual(["下载中"]); + expect(localBadges(item({ local: l({}) })).map((b) => b.label)).toEqual(["在媒体库"]); + expect(itemMeta(item())).toBe("2023 · 电影 · 剧情 · 历史"); + }); + + it("渲染:海报懒加载、评分、状态徽章、订阅与搜索资源", () => { + const html = render( + createElement(DiscoverCard, { + item: item({ local: { entityId: "e", subscribed: true, versionCount: 1, downloadingCount: 0 } }), + busy: null, + onAction: () => {}, + }), + ); + expect(html).toContain('loading="lazy"'); + expect(html).toContain("/api/v1/img/tmdb/w342/oppenheimer.jpg"); + expect(html).toContain("8.1"); + expect(html).toContain("媒体库已有"); + expect(html).toContain("已订阅"); + expect(html).toContain("订阅"); + expect(html).toContain("搜索资源"); + }); +}); + +describe("分区与筛选器", () => { + it("推荐分区覆盖 TMDB 趋势 / 热门、豆瓣、Bangumi;按分类筛", () => { + const keys = RECOMMEND_SECTIONS.map((s) => s.key); + expect(keys).toEqual( + expect.arrayContaining(["tmdb-trending", "tmdb-popular-tv", "douban-movie-hot", "bangumi-today"]), + ); + expect(sectionsOf("anime").every((s) => s.category === "anime")).toBe(true); + // 每个分区的参数都是规整后的合法状态(查看全部能直接打开) + for (const s of RECOMMEND_SECTIONS) expect(normalize(normalize(s.explore))).toEqual(normalize(s.explore)); + const html = render(createElement(RecommendView, { category: "tv", onCategory: () => {} })); + for (const s of sectionsOf("tv")) expect(html).toContain(s.title); + expect(html).not.toContain("TMDB 流行趋势"); + expect(html).toContain("查看全部"); + expect(html).toContain('aria-hidden="true"'); // 骨架屏 + }); + + it("筛选器随来源 / 列表出现", () => { + const html = (s: ExploreState) => render(createElement(ExploreFilters, { s, onPatch: () => {} })); + const disc = html(normalize({ src: "tmdb", list: "discover", type: "movie" })); + for (const l of ["风格", "地区", "语言", "年份", "评分", "排序", "科幻"]) expect(disc).toContain(l); + const trending = html(normalize({ src: "tmdb" })); + expect(trending).toContain("本周"); + expect(trending).not.toContain("风格"); + const douban = html(normalize({ src: "douban", list: "explore" })); + expect(douban).toContain("年代"); + expect(douban).toContain("悬疑"); + const bgm = html(normalize({ src: "bangumi", list: "calendar", weekday: "1" })); + expect(bgm).toContain("周一"); + expect(bgm).toContain("在 Bangumi 搜动画"); + }); +}); diff --git a/apps/web/app/components/discover/explore-view.tsx b/apps/web/app/components/discover/explore-view.tsx new file mode 100644 index 00000000..d83f8f2c --- /dev/null +++ b/apps/web/app/components/discover/explore-view.tsx @@ -0,0 +1,299 @@ +import { DOUBAN_TAGS, tmdbGenres } from "@umt/contracts"; +import { Search, X } from "lucide-react"; +import { type FormEvent, useEffect, useRef, useState } from "react"; +import { useDiscoverInfinite } from "~/api/discover.ts"; +import { EmptyState, ErrorState } from "~/components/console/empty-state.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { ChipRow } from "./chips.tsx"; +import { DiscoverCard, DiscoverCardSkeleton } from "./discover-card.tsx"; +import { + apiQuery, + BANGUMI_LIST_OPTIONS, + DOUBAN_GENRE_OPTIONS, + DOUBAN_LIST_OPTIONS, + DOUBAN_REGION_OPTIONS, + DOUBAN_SORT_OPTIONS, + type ExploreState, + MEDIA_TYPES, + type Option, + RATING_OPTIONS, + SOURCES, + type Src, + TMDB_LANGUAGE_OPTIONS, + TMDB_REGION_OPTIONS, + TMDB_SORT_OPTIONS, + TMDB_TYPES, + tmdbListOptions, + WEEKDAYS, + WINDOWS, + yearOptions, +} from "./discover-state.ts"; +import { useDiscoverActions } from "./use-discover-actions.tsx"; + +// 探索(对照 MP discover.vue):选来源后出现该来源的筛选器;关键词搜索;结果网格无限滚动(接口分页)。 + +const GRID = "grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 2xl:grid-cols-8"; + +const SEARCH_HINT: Record = { + tmdb: "在 TMDB 搜作品", + douban: "在豆瓣搜作品", + bangumi: "在 Bangumi 搜动画", +}; + +export function ExploreView({ s, onPatch }: { s: ExploreState; onPatch: (p: Partial) => void }) { + return ( +
+ + +
+ ); +} + +export function ExploreFilters({ s, onPatch }: { s: ExploreState; onPatch: (p: Partial) => void }) { + const [input, setInput] = useState(s.q ?? ""); + useEffect(() => setInput(s.q ?? ""), [s.q]); + const submit = (e: FormEvent) => { + e.preventDefault(); + onPatch({ q: input.trim() }); + }; + const searching = s.list === "search"; + return ( +
+
+ v && onPatch({ src: v as Src })} /> +
+ + setInput(e.target.value)} + placeholder={SEARCH_HINT[s.src]} + aria-label={SEARCH_HINT[s.src]} + className="pr-9 pl-9" + /> + {searching ? ( + + ) : null} + +
+ {searching ? ( + s.src === "tmdb" ? ( + v && onPatch({ type: v })} /> + ) : null + ) : s.src === "tmdb" ? ( + + ) : s.src === "douban" ? ( + + ) : ( + + )} +
+ ); +} + +type FiltersProps = { s: ExploreState; onPatch: (p: Partial) => void }; +const set = (onPatch: FiltersProps["onPatch"], k: keyof ExploreState) => (v: string | undefined) => + onPatch({ [k]: v } as Partial); + +function TmdbFilters({ s, onPatch }: FiltersProps) { + const genres: Option[] = + s.type === "movie" || s.type === "tv" + ? Object.entries(tmdbGenres(s.type)).map(([value, label]) => ({ value, label })) + : []; + const types = s.list === "trending" ? TMDB_TYPES : MEDIA_TYPES; + return ( + <> + v && onPatch({ type: v })} /> + v && onPatch({ list: v })} + /> + {s.list === "trending" ? ( + v && onPatch({ window: v })} /> + ) : null} + {s.list === "discover" ? ( + <> + + + + + + + + ) : null} + + ); +} + +function DoubanFilters({ s, onPatch }: FiltersProps) { + const free = s.list === "tag" || s.list === "explore"; + return ( + <> + v && onPatch({ list: v })} /> + {free ? ( + v && onPatch({ type: v })} /> + ) : null} + {s.list === "tag" ? ( + ({ value: t, label: t }))} + value={s.tag} + onChange={(v) => v && onPatch({ tag: v })} + /> + ) : null} + {s.list === "explore" ? ( + <> + + + + + + ) : null} + + ); +} + +function BangumiFilters({ s, onPatch }: FiltersProps) { + return ( + <> + v && onPatch({ list: v })} /> + {s.list === "calendar" ? ( + v && onPatch({ weekday: v })} /> + ) : null} + {s.list === "rank" || s.list === "latest" ? ( + + ) : null} + + ); +} + +function ExploreResults({ s }: { s: ExploreState }) { + const q = useDiscoverInfinite(apiQuery(s)); + const actions = useDiscoverActions(); + const sentinel = useRef(null); + const { hasNextPage, isFetchingNextPage, fetchNextPage } = q; + useEffect(() => { + const el = sentinel.current; + if (!el || typeof IntersectionObserver === "undefined") return; + const io = new IntersectionObserver( + (es) => { + if (es.some((e) => e.isIntersecting) && hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }, + { rootMargin: "800px 0px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (q.isPending) + return ( +
    + {Array.from({ length: 12 }, (_, i) => ( +
  • + +
  • + ))} +
+ ); + if (q.isError) return void q.refetch()} />; + // 跨页去重(榜单翻页时条目可能挪位) + const seen = new Set(); + const items = q.data.pages + .flatMap((p) => p.items) + .filter((it) => { + const k = `${it.source}:${it.id}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + if (!items.length) + return ( + + ); + return ( + <> +
    + {items.map((it) => ( +
  • + void actions.act(it, a)} /> +
  • + ))} +
+
+ {hasNextPage ? ( + + ) : ( + 共 {items.length} 个 + )} +
+ {actions.dialogs} + + ); +} diff --git a/apps/web/app/components/discover/recommend-view.tsx b/apps/web/app/components/discover/recommend-view.tsx new file mode 100644 index 00000000..b8c21738 --- /dev/null +++ b/apps/web/app/components/discover/recommend-view.tsx @@ -0,0 +1,105 @@ +import { ChevronRight } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router"; +import { useDiscoverSection } from "~/api/discover.ts"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { ChipRow } from "./chips.tsx"; +import { DiscoverCard, DiscoverCardSkeleton } from "./discover-card.tsx"; +import { + apiQuery, + CATEGORIES, + normalize, + type RecommendSection, + sectionsOf, + toSearchParams, +} from "./discover-state.ts"; +import { useDiscoverActions } from "./use-discover-actions.tsx"; + +// 推荐(对照 MP recommend.vue):按分区横排,分区进入视口附近才加载(各分区各自请求、并发), +// 每区「查看全部」带同样的参数切到探索。顶部按 全部 / 电影 / 剧集 / 动画 / 榜单 筛分区。 + +const CARD_W = "w-32 shrink-0 sm:w-36"; + +/** 进入视口(提前 600px)后为 true;没有 IntersectionObserver 时直接 true */ +function useNearViewport() { + const ref = useRef(null); + const [near, setNear] = useState(() => typeof IntersectionObserver === "undefined"); + useEffect(() => { + if (near || !ref.current) return; + const io = new IntersectionObserver((es) => es.some((e) => e.isIntersecting) && setNear(true), { + rootMargin: "600px 0px", + }); + io.observe(ref.current); + return () => io.disconnect(); + }, [near]); + return [ref, near] as const; +} + +export function RecommendView({ + category, + onCategory, +}: { + category: string; + onCategory: (c: string | undefined) => void; +}) { + const actions = useDiscoverActions(); + return ( +
+ + {sectionsOf(category).map((s) => ( +
+ ))} + {actions.dialogs} +
+ ); +} + +function Section({ section, actions }: { section: RecommendSection; actions: ReturnType }) { + const [ref, near] = useNearViewport(); + const state = normalize(section.explore); + const q = useDiscoverSection(apiQuery(state), near); + const items = q.data?.items ?? []; + const id = `discover-${section.key}`; + return ( +
+
+

+ {section.title} +

+ +
+ {q.isError ? ( +
+ {errorMessage(q.error, "加载失败")} + +
+ ) : q.isSuccess && items.length === 0 ? ( +

+ 这里暂时没有条目。 +

+ ) : ( +
    + {q.isSuccess + ? items.slice(0, 20).map((it) => ( +
  • + void actions.act(it, a)} /> +
  • + )) + : Array.from({ length: 8 }, (_, i) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/components/discover/use-discover-actions.tsx b/apps/web/app/components/discover/use-discover-actions.tsx new file mode 100644 index 00000000..83361703 --- /dev/null +++ b/apps/web/app/components/discover/use-discover-actions.tsx @@ -0,0 +1,193 @@ +import { type DiscoverItem, type DiscoverMatchCandidate, tmdbImageUrl } from "@umt/contracts"; +import { ExternalLink, Search } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { toast } from "sonner"; +import { useDiscoverMatch } from "~/api/discover.ts"; +import { consolePath } from "~/components/console/nav.ts"; +import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx"; +import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import type { CardAction } from "./discover-card.tsx"; +import { itemHref } from "./discover-state.ts"; + +// 卡片动作:进作品 / 订阅 / 搜索资源。都要先知道对应的作品:本地作品或 TMDB 条目直接用; +// 豆瓣 / bgm 条目先按名称 + 年份匹配 TMDB(服务端记住确定的匹配),不确定时列候选让人选,选中后继续原来的动作。 + +interface Target { + title: string; + year: number | null; + type: "movie" | "tv"; + tmdbId: string; + entityId: string | null; +} + +const SOURCE_LABEL: Record = { douban: "豆瓣", bangumi: "bgm.tv", tmdb: "TMDB" }; + +export function useDiscoverActions() { + const navigate = useNavigate(); + const match = useDiscoverMatch(); + const launcher = useReleaseSearchLauncher(); + const [busy, setBusy] = useState<{ key: string; action: CardAction } | null>(null); + const [picking, setPicking] = useState<{ + item: DiscoverItem; + action: CardAction; + candidates: DiscoverMatchCandidate[]; + } | null>(null); + const [subscribing, setSubscribing] = useState(null); + + const run = (action: CardAction, t: Target) => { + if (action === "open") { + navigate(t.entityId ? consolePath("library", t.entityId) : consolePath("media", "tmdb", t.type, t.tmdbId)); + } else if (action === "subscribe") setSubscribing(t); + else launcher.open({ title: t.title, ref: t.entityId ? `local:${t.entityId}` : `tmdb:${t.type}:${t.tmdbId}` }); + }; + + const act = async (item: DiscoverItem, action: CardAction) => { + const known = item.tmdb; + if (action === "open") { + const href = itemHref(item); + if (href) return navigate(href); + } + if (known) { + return run(action, { + title: item.title, + year: item.year, + type: known.type, + tmdbId: known.id, + entityId: item.local?.entityId ?? null, + }); + } + setBusy({ key: `${item.source}:${item.id}`, action }); + try { + const r = await match.mutateAsync(item); + if (r.match) run(action, { ...r.match, tmdbId: r.match.id, entityId: null }); + else setPicking({ item, action, candidates: r.candidates }); + } catch (err) { + toast.error(errorMessage(err, "匹配 TMDB 失败")); + } finally { + setBusy(null); + } + }; + + const dialogs: ReactNode = ( + <> + {picking ? ( + { + setPicking(null); + run(picking.action, { ...c, tmdbId: c.id, entityId: null }); + }} + onClose={() => setPicking(null)} + /> + ) : null} + {subscribing ? ( + setSubscribing(null)} + onCreated={() => toast.success(`已订阅「${subscribing.title}」`)} + /> + ) : null} + {launcher.dialog} + + ); + + return { + act, + busyOf: (item: DiscoverItem): CardAction | null => + busy && busy.key === `${item.source}:${item.id}` ? busy.action : null, + dialogs, + }; +} + +function PickDialog({ + item, + candidates, + onPick, + onClose, +}: { + item: DiscoverItem; + candidates: DiscoverMatchCandidate[]; + onPick: (c: DiscoverMatchCandidate) => void; + onClose: () => void; +}) { + return ( + !o && onClose()}> + + + 选择 TMDB 作品 + + 「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。 + + + {candidates.length ? ( +
    + {candidates.map((c) => ( +
  • + +
  • + ))} +
+ ) : ( +

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

+ )} +
+ + +
+
+
+ ); +} diff --git a/apps/web/app/routes/console/discover.tsx b/apps/web/app/routes/console/discover.tsx index b56c47da..6eba0786 100644 --- a/apps/web/app/routes/console/discover.tsx +++ b/apps/web/app/routes/console/discover.tsx @@ -1,215 +1,53 @@ -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 { useSearchParams } from "react-router"; 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 { + type ExploreState, + fromSearchParams, + patch, + toSearchParams, + type View, +} from "~/components/discover/discover-state.ts"; +import { ExploreView } from "~/components/discover/explore-view.tsx"; +import { RecommendView } from "~/components/discover/recommend-view.tsx"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; -import { errorMessage } from "~/lib/api.ts"; -// 发现(对照 MP 推荐页):bgm.tv 每日放送、豆瓣热门电影 / 剧集。点条目按名称 + 年份匹配 TMDB 作品, -// 确定的直接进作品页(可订阅、搜资源),不确定的列候选让人选。 +// 发现(对照 MP 推荐 + 探索,docs/porting/moviepilot-discover.md): +// 「推荐」按分区横排 TMDB / 豆瓣 / Bangumi 的热门与榜单,每区「查看全部」带参数切到「探索」; +// 「探索」选来源后按类型、风格、地区、语言、年份、评分、排序筛选或搜关键词。状态都在 URL 里。 +// 卡片标本地状态;点开进作品,豆瓣 / bgm 条目先匹配 TMDB 作品。 export const meta = () => [{ title: "发现 · UHD 媒体自动化控制台" }]; -type View = "anime" | "movie" | "tv"; -const VIEWS: { value: View; label: string }[] = [ - { value: "anime", label: "番剧放送" }, - { value: "movie", label: "热门电影" }, - { value: "tv", label: "热门剧集" }, -]; - -/** bgm 周一 = 1 … 周日 = 7 */ -const todayWeekday = () => ((new Date().getDay() + 6) % 7) + 1; - export default function DiscoverPage() { const [sp, setSp] = useSearchParams(); - const view = (VIEWS.some((v) => v.value === sp.get("view")) ? sp.get("view") : "anime") as View; - const [day, setDay] = useState(todayWeekday); - const cal = useBangumiCalendar(view === "anime"); - const hot = useDoubanHot(view === "tv" ? "tv" : "movie", view !== "anime"); - const match = useDiscoverMatch(); - const navigate = useNavigate(); - const [picking, setPicking] = useState<{ item: DiscoverItem; result: DiscoverMatchResult } | null>(null); - const [busyId, setBusyId] = useState(null); + const view: View = sp.get("view") === "explore" || sp.has("src") ? "explore" : "recommend"; + const explore = fromSearchParams(sp); + const category = sp.get("cat") ?? ""; - 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 ?? []); + const go = (next: ExploreState) => setSp(toSearchParams(next), { replace: false }); return ( <> - - setSp(new URLSearchParams({ view: v }), { replace: true })}> - - {VIEWS.map((v) => ( - - {v.label} - - ))} + + + setSp(v === "explore" ? toSearchParams(explore) : new URLSearchParams(category ? { cat: category } : {})) + } + > + + 推荐 + 探索 - {view === "anime" && cal.data ? ( -
- {cal.data.days.map((d) => ( - - ))} -
- ) : null} - {q.isLoading ? ( - - ) : q.error ? ( - void q.refetch()} /> - ) : items.length === 0 ? ( - + {view === "recommend" ? ( + setSp(new URLSearchParams(c ? { cat: c } : {}), { replace: true })} + /> ) : ( -
    - {items.map((it) => ( -
  • - void open(it)} /> -
  • - ))} -
+ go(patch(explore, p))} /> )} - {picking ? setPicking(null)} /> : null} ); } - -function ItemCard({ item, busy, onOpen }: { item: DiscoverItem; busy: boolean; onOpen: () => void }) { - const [failed, setFailed] = useState(false); - return ( - - ); -} - -function PickDialog({ - item, - result, - onClose, -}: { - item: DiscoverItem; - result: DiscoverMatchResult; - onClose: () => void; -}) { - return ( - !o && onClose()}> - - - 选择 TMDB 作品 - - 「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。 - - - {result.candidates.length ? ( -
    - {result.candidates.map((c) => ( -
  • - - - {c.posterPath ? ( - - ) : null} - - - {c.title} - - {c.type === "movie" ? "电影" : "剧集"} · {c.year ?? "—"} - - - -
  • - ))} -
- ) : ( -

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

- )} - -
-
- ); -} diff --git a/docs/porting/moviepilot-discover.md b/docs/porting/moviepilot-discover.md new file mode 100644 index 00000000..3fb725fc --- /dev/null +++ b/docs/porting/moviepilot-discover.md @@ -0,0 +1,54 @@ +# MoviePilot 推荐 / 探索对照(发现页) + +上游:前端 `MoviePilot-Frontend/src/pages/{recommend,discover,browse}.vue`、`views/discover/*`、`utils/recommendSources.ts`; +后端 `app/chain/{recommend,tmdb,douban,bangumi}.py`、`app/api/endpoints/{recommend,discover}.py`、`app/modules/douban/apiv2.py`、`app/modules/bangumi/bangumi.py`、`app/modules/themoviedb/tmdbapi.py`(v3.0.10-1)。 + +## MP 有什么 + +| 页面 | 结构 | 数据源 / 分类 | +| --- | --- | --- | +| 推荐 `/recommend` | 按分区横向滑动(`MediaCardSlideView`),每区「查看全部」进 `/browse/`;顶部按「全部 / 电影 / 剧集 / 动漫 / 音乐 / 榜单」筛分区;分区可开关(存用户配置) | TMDB 流行趋势(`trending/all/week`)、TMDB 热门电影 / 剧集(`discover`,剧集限 zh/en/ja/ko)、豆瓣正在热映、豆瓣热门电影 / 剧集 / 动画、豆瓣最新电影 / 剧集(`movie|tv/recommend sort=R`)、豆瓣 Top250、豆瓣华语 / 全球口碑剧集榜、Bangumi 每日放送、AniList、音乐 | +| 探索 `/discover` | 按来源分标签(TMDB / 豆瓣 / Bangumi / …),每个来源一组筛选芯片,下面是无限滚动网格(`MediaCardListView`,`page++`) | TMDB:类型(电影 / 剧集)、排序、风格、原始语言、评分(+最少评分人数);豆瓣:类型、排序(综合 / 首映 / 近期热度 / 高分)、风格、地区、年代(拼成 `tags`);Bangumi:分类、排序(排名 / 日期)、年份 | +| 卡片 | 海报、评分、标题、年份;右上角「已入库 / 已订阅」标记;点开进媒体详情(豆瓣 / bgm 条目在详情页再识别 TMDB);卡片上可订阅 | — | + +缓存:推荐链每个列表 `@cached(ttl=24h, skip_empty=True)`;TMDB / 豆瓣 / bgm 底层请求另有 `@cached(ttl=CONF.meta)`,失败(None)不缓存。 + +## 我们原来有什么 + +只有 bgm 每日放送、豆瓣实时热门电影 / 剧集三个标签,没有 TMDB 列表、榜单与筛选;卡片没有本地状态。 + +## 这次做了什么 + +页面选「综合 + 可拆分」:**推荐**(综合,按分区横排,每区「查看全部」带着同样的参数跳到探索)与**探索**(选来源后出筛选器,无限滚动),两者在同一个路由 `/console/discover?view=recommend|explore`,状态全部放在 URL 里,可分享、可回退。理由:MP 两页的分工(推荐看热点、探索做筛选)好用,但两页之间来回跳要重选条件;放一页、共享 URL 参数后「查看全部」就是带参数切到探索。 + +| 来源 | 列表 | 接口 | +| --- | --- | --- | +| TMDB | 流行趋势(日 / 周 × 全部 / 电影 / 剧集)、热门、正在上映 / 正在播出、即将上映 / 今日播出、高分、按条件筛选(类型、风格、原始语言、出品地区、年份或年代、最低评分、排序)、关键词搜索 | `GET /discover/tmdb` | +| 豆瓣 | 榜单(实时热门、正在热映、热门、即将上映、Top250、国产 / 欧美 / 日 / 韩剧、动画、综艺、华语 / 全球口碑剧集榜)、标签(热门 / 最新 / 豆瓣高分 / 冷门佳片 / 华语 / 美剧…)、筛选(排序 + 风格 + 地区 + 年代,同 MP)、关键词搜索 | `GET /discover/douban` | +| Bangumi | 每日放送、热门趋势、排行(可选年份)、最新、关键词搜索 | `GET /discover/bangumi-calendar`、`GET /discover/bangumi` | + +卡片:海报(懒加载)、评分、标题、年份 · 类型 · 风格;本地状态只显示非默认值(已订阅 / 媒体库已有 / 下载中)。点卡片:有本地作品进媒体库详情;TMDB 条目进 `media/tmdb/:type/:id`;豆瓣 / bgm 条目先按名称 + 年份匹配 TMDB(原有匹配逻辑),确定就进作品页,不确定列候选。卡片上「订阅」「搜索资源」同样先拿到 TMDB 作品再开订阅表单 / 选站点。 + +豆瓣 / bgm 条目确定匹配后记在 `kv_cache discover.match`(30 天),下次列表据此标本地状态,再点也不用重新匹配;不确定 / 失败不记。 + +### 取舍与偏离 + +- **HTTP 直连,不登录,不用浏览器**:豆瓣(rexxar 带 `Referer: https://m.douban.com/`;桌面版 `j/search_subjects`、`j/subject_suggest` 带浏览器 UA)与 bgm(官方 API,只要 UA)2026-10-06 实测都能直接拿到。被风控(403、返回验证页而不是 JSON)时按失败处理、不缓存、界面报错可重试;以后真的频繁被拦,再考虑经内置浏览器代取(适配器的 `fetch` 是注入的,换成浏览器代理的 fetch 即可,本线不实现)。 +- **bgm 热门趋势**没有 API,抓 `bgm.tv/anime/browser?sort=trends` 的服务端渲染 HTML;HTML 是外部不可信字节,按 ADR-005 在沙箱任务 `bangumi.trends` 里解析(`parseBangumiBrowser`)。 +- **豆瓣搜索**用 `j/subject_suggest`(联想,不分页,约 10 条);MP 用要签名的 frodo 接口,我们不用。 +- **豆瓣筛选**用 rexxar `movie|tv/recommend`(MP 用 frodo 同名接口,参数相同);标签列表用桌面版 `j/search_subjects`。 +- **TMDB 列表缓存**:仍走 TMDB 请求缓存(`tmdb.request`,失败不缓存),但列表类路径(`/trending/`、`/discover/`、电影 / 剧集榜单)TTL 单独取 6 小时、空结果不超过它(`TmdbConfig.cache.listTtlMs`);我们详情缓存 30 天,列表不能跟着它。MP 为 24 小时(`CONF.meta` / `recommend_ttl`),这里取短一些,日榜不至于整天不变。 +- **MP 的音乐、AniList、插件扩展来源**不做;推荐分区的开关配置不做(分区不多,按「全部 / 电影 / 剧集 / 动画 / 榜单」筛即可)。 +- TMDB 风格、语言、地区字典写在 `@umt/contracts`(`TMDB_MOVIE_GENRES` 等),界面与服务端校验共用,不请求 `/genre/list`(同 MP)。 + +## 豆瓣 / bgm 请求缓存(对照 `DoubanApi.__invoke / __post`、`BangumiApi` 的 `@cached`) + +| 上游 | 我们 | +| --- | --- | +| 每个请求 `@cached(maxsize=CONF.douban, ttl=CONF.meta, skip_none=True)`,键 = 函数 + 参数 | 列表 / 搜索请求经 `MetaRequestCache` 端口 → 服务端 `cachedCall`(region `douban.request` / `bangumi.request`,上限 5000 条);键 = `方法 + 参数排序后的地址 + 请求体`(`metaRequestKey`) | +| TTL 只有一个 `CONF.meta` | 按类别取 `storage.cacheTtl`:搜索 `metaSearchDays`(缺省 1 天)、榜单 / 热门 / 每日放送 / 趋势页 `metaListHours`(缺省 6 小时)、空结果 `metaEmptyHours`(缺省 1 小时,不超过正常 TTL) | +| `skip_none`:请求失败返回 None 不缓存 | 网络失败、非 2xx(含 403)、返回的不是 JSON(风控 / 验证页)、趋势页不是浏览页,适配器都抛错,`cachedCall` 不写 | +| 条目详情同样走请求缓存 | 条目详情**不走**请求缓存:已有条目级缓存 `douban.subject` / `bangumi.subject`(30 天,存归一化后的标题 / 别名 / 年份,还收站点详情页与网页搜索得到的条目),再缓存一遍原始响应就是同一份数据存两遍;`meta.subject_miss`(6 小时)保留,是识别升级时防止反复打外站的负缓存,不属于请求缓存 | +| 限流:无(靠缓存) | 每源进程内限流(豆瓣 2 并发、起始间隔 300 ms;bgm 3 并发、100 ms),只包真正发出的网络请求,缓存命中不占额度 | + +设置字段只在 `contracts/settings.ts` 追加(`metaSearchDays`、`metaListHours`、`metaEmptyHours`),设置页界面由设置线决定是否展示。 diff --git a/packages/adapters/src/meta/index.ts b/packages/adapters/src/meta/index.ts index 225512a1..10d61dc0 100644 --- a/packages/adapters/src/meta/index.ts +++ b/packages/adapters/src/meta/index.ts @@ -2,8 +2,12 @@ * 辅助信息源(对照 MP modules/bangumi、modules/douban):只读公开接口,结果只用来"拿名称 + 年份去匹配 TMDB"与发现页展示, * 身份仍以 TMDB 为准。 * - bgm.tv:官方 API(api.bgm.tv),条目 / 搜索 / 每日放送,不需要登录;要求带可识别的 User-Agent - * - 豆瓣:移动版 rexxar 接口(m.douban.com,需要 Referer),条目详情与实时热门榜;搜索要登录,不用 + * - 豆瓣:移动版 rexxar 接口(m.douban.com,需要 Referer):条目详情、榜单(subject_collection)、筛选(movie|tv/recommend); + * 桌面版 JSON(movie.douban.com/j/…):标签列表(search_subjects)、搜索联想(subject_suggest)。都不需要登录,HTTP 直连。 + * 发现页(2026-10-06):列表 / 搜索请求经 MetaRequestCache(服务端 PG 请求缓存,对照 MP DoubanApi.__invoke / Bangumi 的 @cached) + * 与限流器;条目详情不走请求缓存(服务端已有条目级缓存 douban.subject / bangumi.subject,避免同一份数据存两遍)。 */ +import { load } from "cheerio"; export interface MetaSubject { source: "bangumi" | "douban"; @@ -21,13 +25,36 @@ export interface MetaSubject { date: string | null; episodes: number | null; url: string; + /** 风格 / 类型(豆瓣 card_subtitle 第三段;没有为空) */ + genres?: string[]; +} + +/** 请求类别:决定缓存 TTL(搜索 / 列表);条目详情不走请求缓存 */ +export type MetaRequestKind = "search" | "list"; + +/** + * 请求缓存端口(服务端用 PG kv_cache 实现,region douban.request / bangumi.request): + * 命中直接返回;否则执行 load,成功的结果按类别 TTL 写入,empty(v) 为真时用空结果短 TTL;load 抛错(网络失败、403、风控页)不写。 + */ +export interface MetaRequestCache { + cached( + req: { source: "bangumi" | "douban"; key: string; kind: MetaRequestKind; empty: (v: T) => boolean }, + load: () => Promise, + ): Promise; } export interface MetaClientOptions { fetch?: typeof globalThis.fetch; /** 缺省 15s */ timeoutMs?: number; + /** API 地址(bgm api.bgm.tv / 豆瓣 rexxar) */ baseUrl?: string; + /** 网页地址(bgm.tv / movie.douban.com) */ + webBaseUrl?: string; + /** 请求缓存;不给就不缓存 */ + cache?: MetaRequestCache; + /** 限流(每源最小间隔);只包住真正发出的网络请求,缓存命中不占额度 */ + limiter?: { run(fn: () => Promise): Promise }; } export class MetaSourceError extends Error { @@ -57,28 +84,70 @@ const uniq = (xs: (string | null | undefined)[], not: string) => [ ...new Set(xs.filter((x): x is string => !!x && x !== not && x.length <= 120)), ]; +async function fetchOk( + source: "bangumi" | "douban", + url: string, + o: MetaClientOptions, + init: RequestInit, +): Promise { + const f = o.fetch ?? globalThis.fetch; + const run = () => f(url, { ...init, signal: AbortSignal.timeout(o.timeoutMs ?? 15_000) }); + let res: Response; + try { + res = await (o.limiter ? o.limiter.run(run) : run()); + } catch (e) { + throw new MetaSourceError(source, "http", `请求 ${new URL(url).host} 失败:${(e as Error).message}`); + } + if (res.status === 404) { + await res.body?.cancel(); + throw new MetaSourceError(source, "not_found", "条目不存在"); + } + if (!res.ok) { + await res.body?.cancel(); + throw new MetaSourceError(source, "http", `${new URL(url).host} 返回 HTTP ${res.status}`); + } + return res; +} + async function getJson( source: "bangumi" | "douban", url: string, o: MetaClientOptions, init: RequestInit = {}, ): Promise { - const f = o.fetch ?? globalThis.fetch; - let res: Response; - try { - res = await f(url, { ...init, signal: AbortSignal.timeout(o.timeoutMs ?? 15_000) }); - } catch (e) { - throw new MetaSourceError(source, "http", `请求 ${new URL(url).host} 失败:${(e as Error).message}`); - } - if (res.status === 404) throw new MetaSourceError(source, "not_found", "条目不存在"); - if (!res.ok) throw new MetaSourceError(source, "http", `${new URL(url).host} 返回 HTTP ${res.status}`); + const res = await fetchOk(source, url, o, init); try { return await res.json(); } catch { + // 风控 / 验证页是 HTML:按解析失败抛出,不缓存 throw new MetaSourceError(source, "parse", `${new URL(url).host} 返回的不是 JSON`); } } +/** 请求缓存键:方法 + 参数排序后的地址 + 请求体 */ +export function metaRequestKey(method: string, url: string, body?: string | null): string { + const u = new URL(url); + const qs = new URLSearchParams([...u.searchParams].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); + const q = qs.toString(); + return `${method.toUpperCase()} ${u.origin}${u.pathname}${q ? `?${q}` : ""}${body ? ` ${body}` : ""}`; +} + +function viaCache( + o: MetaClientOptions, + source: "bangumi" | "douban", + kind: MetaRequestKind, + key: string, + empty: (v: T) => boolean, + load: () => Promise, +): Promise { + return o.cache ? o.cache.cached({ source, key, kind, empty }, load) : load(); +} + +const emptyRows = (field: string) => (v: unknown) => { + const rows = isObj(v) ? v[field] : v; + return Array.isArray(rows) && rows.length === 0; +}; + // ── bgm.tv ── export const BANGUMI_API = "https://api.bgm.tv"; @@ -114,9 +183,71 @@ export function bangumiSubject(r: Raw): MetaSubject | null { }; } +export const BANGUMI_WEB = "https://bgm.tv"; + +/** bgm.tv 网页日期「2023年9月29日」/「2023-09-29」→ YYYY-MM-DD */ +function bgmDate(text: string): string | null { + const m = /((?:19|20)\d{2})[年-](\d{1,2})[月-](\d{1,2})/.exec(text); + return m ? `${m[1]}-${m[2]!.padStart(2, "0")}-${m[3]!.padStart(2, "0")}` : null; +} + +/** + * bgm.tv 动画浏览页(/anime/browser?sort=trends,服务端渲染 HTML)→ 条目列表。外部 HTML 不可信: + * 服务端经沙箱任务 bangumi.trends 调用(ADR-005),这里只是纯函数。 + */ +export function parseBangumiBrowser(html: string, web = BANGUMI_WEB): { items: MetaSubject[]; hasMore: boolean } { + const $ = load(html); + const items: MetaSubject[] = []; + $("#browserItemList > li.item").each((_, el) => { + const li = $(el); + const id = + /(\d+)/.exec(li.attr("id") ?? "")?.[1] ?? /\/subject\/(\d+)/.exec(li.find("h3 a").attr("href") ?? "")?.[1]; + const title = li.find("h3 a.l").first().text().trim(); + if (!id || !title) return; + const original = li.find("h3 small.grey").first().text().trim(); + const info = li.find("p.info").first().text().replace(/\s+/g, " ").trim(); + const date = bgmDate(info); + const eps = numOf(/(\d+)\s*话/.exec(info)?.[1]); + const rating = numOf(li.find("p.rateInfo small.fade").first().text().trim()); + let cover = str(li.find("img.cover").first().attr("src")); + if (cover?.startsWith("//")) cover = `https:${cover}`; + items.push({ + source: "bangumi", + id, + title, + aliases: original && original !== title ? [original] : [], + year: yearOf(date ?? info), + kind: /剧场|电影/.test(info) ? "movie" : "tv", + cover: https(cover), + rating: rating || null, + date, + episodes: eps || null, + url: `${web}/subject/${id}`, + }); + }); + const hasMore = $("#multipage a.p") + .toArray() + .some((a) => $(a).text().includes("››") || $(a).text().includes("\u203a\u203a")); + return { items, hasMore }; +} + export function createBangumiClient(o: MetaClientOptions = {}) { const base = (o.baseUrl ?? BANGUMI_API).replace(/\/+$/, ""); + const web = (o.webBaseUrl ?? BANGUMI_WEB).replace(/\/+$/, ""); const headers = { "User-Agent": BGM_UA, Accept: "application/json" }; + const searchPage = async (keyword: string, limit = 10, offset = 0) => { + const url = `${base}/v0/search/subjects?limit=${limit}&offset=${offset}`; + const body = JSON.stringify({ keyword, filter: { type: [2] } }); + const r = await viaCache(o, "bangumi", "search", metaRequestKey("POST", url, body), emptyRows("data"), () => + getJson("bangumi", url, o, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body, + }), + ); + const rows = isObj(r) && Array.isArray(r.data) ? (r.data as Raw[]) : []; + return { items: rows.flatMap((x) => bangumiSubject(x) ?? []), total: isObj(r) ? (numOf(r.total) ?? 0) : 0 }; + }; return { async subject(id: string): Promise { if (!/^\d+$/.test(id)) throw new MetaSourceError("bangumi", "not_found", "bgm 条目 ID 非法"); @@ -127,17 +258,16 @@ export function createBangumiClient(o: MetaClientOptions = {}) { }, /** 动画条目搜索(type 2) */ async search(keyword: string, limit = 10): Promise { - const r = await getJson("bangumi", `${base}/v0/search/subjects?limit=${limit}`, o, { - method: "POST", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify({ keyword, filter: { type: [2] } }), - }); - const rows = isObj(r) && Array.isArray(r.data) ? (r.data as Raw[]) : []; - return rows.flatMap((x) => bangumiSubject(x) ?? []); + return (await searchPage(keyword, limit)).items; }, + /** 动画条目搜索,带分页与总数(发现页) */ + searchPage, /** 每日放送:周一到周日(weekday 1-7) */ async calendar(): Promise<{ weekday: number; label: string; items: MetaSubject[] }[]> { - const r = await getJson("bangumi", `${base}/calendar`, o, { headers }); + const url = `${base}/calendar`; + const r = await viaCache(o, "bangumi", "list", metaRequestKey("GET", url), emptyRows(""), () => + getJson("bangumi", url, 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 : {}; @@ -148,6 +278,34 @@ export function createBangumiClient(o: MetaClientOptions = {}) { }; }); }, + /** 动画浏览(对照 MP bangumi discover:v0/subjects?type=2&sort=rank|date&year=) */ + async browse(q: { sort: "rank" | "date"; year?: string; limit: number; offset: number }) { + const qs = new URLSearchParams({ type: "2", sort: q.sort, limit: String(q.limit), offset: String(q.offset) }); + if (q.year) qs.set("year", q.year); + const url = `${base}/v0/subjects?${qs}`; + const r = await viaCache(o, "bangumi", "list", metaRequestKey("GET", url), emptyRows("data"), () => + getJson("bangumi", url, o, { headers }), + ); + if (!isObj(r) || !Array.isArray(r.data)) throw new MetaSourceError("bangumi", "parse", "bgm 浏览结果格式不对"); + return { items: (r.data as Raw[]).flatMap((x) => bangumiSubject(x) ?? []), total: numOf(r.total) ?? 0 }; + }, + /** 热门趋势网页(无 API):返回原始 HTML,由调用方在沙箱里用 parseBangumiBrowser 解析 */ + async trendsHtml(page = 1): Promise { + const url = `${web}/anime/browser?sort=trends&page=${page}`; + return viaCache( + o, + "bangumi", + "list", + metaRequestKey("GET", url), + () => false, + async () => { + const res = await fetchOk("bangumi", url, o, { headers: { "User-Agent": BGM_UA, Accept: "text/html" } }); + const text = await res.text(); + if (!text.includes("browserItemList")) throw new MetaSourceError("bangumi", "parse", "bgm 浏览页格式不对"); + return text; + }, + ); + }, }; } export type BangumiClient = ReturnType; @@ -172,6 +330,11 @@ export function doubanSubject(r: Raw): MetaSubject | 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); + // card_subtitle:「2024 / 美国 / 剧情 科幻 / 导演 / 主演」 + const card = (str(r.card_subtitle) ?? "").split(" / "); + const genres = + card.length >= 3 && /^\d{4}$/.test(card[0]!.trim()) ? card[2]!.trim().split(/\s+/).filter(Boolean) : []; + const cover = str(pic.large) ?? str(pic.normal) ?? str(r.cover_url); return { source: "douban", id, @@ -179,11 +342,61 @@ export function doubanSubject(r: Raw): MetaSubject | null { 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), + cover: placeholderCover(cover) ? null : cover, rating: rating || null, date, episodes: numOf(r.episodes_count) || null, url: `https://movie.douban.com/subject/${id}/`, + genres: genres.slice(0, 4), + }; +} + +/** 豆瓣占位海报(对照 MP _project_discover 的 invalid_posters) */ +const placeholderCover = (u: string | null) => + !!u && ["movie_large.jpg", "tv_normal.png", "tv_normal.jpg", "tv_large.jpg"].some((m) => u.includes(m)); + +/** 桌面版 search_subjects 条目({id, title, rate, cover, url, episodes_info}) → MetaSubject */ +export function doubanTagSubject(r: Raw, kind: "movie" | "tv"): MetaSubject | null { + const id = str(r.id); + const title = str(r.title); + if (!id || !/^\d+$/.test(id) || !title) return null; + const cover = str(r.cover); + return { + source: "douban", + id, + title, + aliases: [], + year: null, + kind, + cover: placeholderCover(cover) ? null : cover, + rating: numOf(r.rate) || null, + date: null, + episodes: null, + url: `https://movie.douban.com/subject/${id}/`, + genres: [], + }; +} + +/** 搜索联想 subject_suggest 条目({id, title, sub_title, img, year, episode, type}) → MetaSubject;人物等返回 null */ +export function doubanSuggestSubject(r: Raw): MetaSubject | null { + if (str(r.type) !== "movie") return null; + const id = str(r.id); + const title = str(r.title); + if (!id || !/^\d+$/.test(id) || !title) return null; + const sub = str(r.sub_title); + return { + source: "douban", + id, + title, + aliases: sub && sub !== title ? [sub] : [], + year: yearOf(r.year), + kind: str(r.episode) ? "tv" : "movie", + cover: str(r.img), + rating: null, + date: null, + episodes: numOf(r.episode) || null, + url: `https://movie.douban.com/subject/${id}/`, + genres: [], }; } @@ -192,10 +405,28 @@ export const DOUBAN_HOT = { tv: "tv_real_time_hotest", } as const; +export const DOUBAN_WEB = "https://movie.douban.com"; +const DOUBAN_WEB_HEADERS = { + Referer: "https://movie.douban.com/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", + Accept: "application/json", +}; + export function createDoubanClient(o: MetaClientOptions = {}) { const base = (o.baseUrl ?? DOUBAN_API).replace(/\/+$/, ""); + const web = (o.webBaseUrl ?? DOUBAN_WEB).replace(/\/+$/, ""); const get = (path: string) => getJson("douban", `${base}${path}`, o, { headers: DOUBAN_HEADERS }); - return { + const cachedGet = (kind: MetaRequestKind, url: string, field: string, headers: Record) => + viaCache(o, "douban", kind, metaRequestKey("GET", url), emptyRows(field), () => + getJson("douban", url, o, { headers }), + ); + const listOf = (r: unknown, field: string, what: string) => { + const rows = isObj(r) && Array.isArray(r[field]) ? (r[field] as Raw[]) : null; + if (!rows) throw new MetaSourceError("douban", "parse", `豆瓣${what}格式不对`); + return { rows, total: isObj(r) ? numOf(r.total) : null }; + }; + const client = { /** 条目详情:类型未知时先按电影查,不存在再按剧集查 */ async subject(id: string, kind?: "movie" | "tv" | null): Promise { if (!/^\d+$/.test(id)) throw new MetaSourceError("douban", "not_found", "豆瓣条目 ID 非法"); @@ -213,13 +444,60 @@ export function createDoubanClient(o: MetaClientOptions = {}) { } throw last instanceof MetaSourceError ? last : new MetaSourceError("douban", "not_found", "豆瓣条目不存在"); }, + /** 榜单(rexxar subject_collection):实时热门、正在热映、Top250、各类剧集榜… */ + async collection(name: string, start = 0, count = 20): Promise<{ items: MetaSubject[]; total: number | null }> { + if (!/^[a-z0-9_]+$/.test(name)) throw new MetaSourceError("douban", "not_found", "豆瓣榜单名非法"); + const url = `${base}/subject_collection/${name}/items?start=${start}&count=${count}`; + const { rows, total } = listOf( + await cachedGet("list", url, "subject_collection_items", DOUBAN_HEADERS), + "subject_collection_items", + "榜单", + ); + return { items: rows.flatMap((x) => doubanSubject(x) ?? []), total }; + }, /** 实时热门榜(电影 / 剧集) */ async hot(kind: "movie" | "tv", count = 30): Promise { - const r = await get(`/subject_collection/${DOUBAN_HOT[kind]}/items?start=0&count=${count}`); - const rows = isObj(r) && Array.isArray(r.subject_collection_items) ? (r.subject_collection_items as Raw[]) : null; - if (!rows) throw new MetaSourceError("douban", "parse", "豆瓣榜单格式不对"); - return rows.flatMap((x) => doubanSubject(x) ?? []); + return (await client.collection(DOUBAN_HOT[kind], 0, count)).items; + }, + /** 标签列表(桌面版 j/search_subjects,tag 如 热门 / 最新 / 豆瓣高分 / 美剧) */ + async tagSubjects(kind: "movie" | "tv", tag: string, start = 0, count = 20): Promise { + const qs = new URLSearchParams({ + type: kind, + tag, + sort: "recommend", + page_limit: String(count), + page_start: String(start), + }); + const url = `${web}/j/search_subjects?${qs}`; + const { rows } = listOf(await cachedGet("list", url, "subjects", DOUBAN_WEB_HEADERS), "subjects", "标签列表"); + return rows.flatMap((x) => doubanTagSubject(x, kind) ?? []); + }, + /** 筛选(rexxar movie|tv/recommend,对照 MP douban_discover:sort U/R/T/S,tags 为 风格,地区,年代) */ + async explore( + kind: "movie" | "tv", + q: { sort: string; tags: string[] }, + start = 0, + count = 20, + ): Promise<{ items: MetaSubject[]; total: number | null }> { + const qs = new URLSearchParams({ + refresh: "0", + start: String(start), + count: String(count), + sort: q.sort, + tags: q.tags.join(","), + }); + const url = `${base}/${kind}/recommend?${qs}`; + const { rows, total } = listOf(await cachedGet("list", url, "items", DOUBAN_HEADERS), "items", "筛选结果"); + return { items: rows.flatMap((x) => doubanSubject(x) ?? []), total }; + }, + /** 搜索联想(桌面版 j/subject_suggest,不分页,最多约 10 条) */ + async suggest(q: string): Promise { + const url = `${web}/j/subject_suggest?${new URLSearchParams({ q: q.trim() })}`; + const r = await cachedGet("search", url, "", DOUBAN_WEB_HEADERS); + if (!Array.isArray(r)) throw new MetaSourceError("douban", "parse", "豆瓣搜索结果格式不对"); + return (r as Raw[]).flatMap((x) => doubanSuggestSubject(x) ?? []); }, }; + return client; } export type DoubanClient = ReturnType; diff --git a/packages/adapters/src/meta/meta-lists.test.ts b/packages/adapters/src/meta/meta-lists.test.ts new file mode 100644 index 00000000..cad21d20 --- /dev/null +++ b/packages/adapters/src/meta/meta-lists.test.ts @@ -0,0 +1,228 @@ +import { createMockFetch } from "@umt/testkit"; +import { describe, expect, it } from "vitest"; +import { + createBangumiClient, + createDoubanClient, + doubanSubject, + doubanSuggestSubject, + doubanTagSubject, + type MetaRequestCache, + metaRequestKey, + parseBangumiBrowser, +} from "./index.ts"; + +// 发现页的豆瓣 / bgm 列表接口:字段映射、请求缓存端口(键、类别、空结果、失败不缓存)、限流只包网络请求。 +// 字段形状取自 2026-10-06 协调者实测的返回(删减),不打真实站点。 + +const DOUBAN_ITEM = { + id: "35267208", + title: "流浪地球2", + type: "movie", + year: "2023", + rating: { value: 8.3 }, + pic: { large: "https://img1.doubanio.com/view/photo/m_ratio_poster/public/p2885955777.jpg" }, + card_subtitle: "2023 / 中国大陆 / 科幻 冒险 灾难 / 郭帆 / 吴京 刘德华", +}; + +const BGM_HTML = ` +
    +
  • + +
    +

    葬送的芙莉莲 葬送のフリーレン

    +

    + 28话 / 2023年9月29日 / 斎藤圭一郎 / 山田鐘人 +

    +

    8.9 (25627人评分)

    +
    +
  • +
  • +

    剧场版 某作品

    剧场版 / 2024-07-01

    +
  • +
+ +`; + +function memoryCache() { + const store = new Map(); + const writes: { key: string; kind: string; empty: boolean }[] = []; + const cache: MetaRequestCache = { + async cached(req, load) { + if (store.has(req.key)) return store.get(req.key) as never; + const v = await load(); + writes.push({ key: req.key, kind: req.kind, empty: req.empty(v) }); + store.set(req.key, v); + return v; + }, + }; + return { cache, writes }; +} + +describe("豆瓣列表映射", () => { + it("rexxar 条目:card_subtitle 第三段是风格;占位海报置空", () => { + expect(doubanSubject(DOUBAN_ITEM)).toMatchObject({ + id: "35267208", + year: 2023, + kind: "movie", + rating: 8.3, + genres: ["科幻", "冒险", "灾难"], + }); + expect( + doubanSubject({ + ...DOUBAN_ITEM, + pic: { large: "https://img3.doubanio.com/f/movie/30c6263b/pics/movie/movie_large.jpg" }, + })?.cover, + ).toBeNull(); + }); + + it("标签列表与搜索联想", () => { + expect( + doubanTagSubject({ id: "1", title: "甲", rate: "7.9", cover: "https://x/p.jpg", episodes_info: "" }, "tv"), + ).toMatchObject({ id: "1", kind: "tv", rating: 7.9, year: null }); + expect(doubanTagSubject({ id: "2", title: "乙", rate: "" }, "movie")?.rating).toBeNull(); + expect( + doubanSuggestSubject({ + id: "3", + title: "三体", + sub_title: "Three-Body", + year: "2023", + episode: "30", + type: "movie", + }), + ).toMatchObject({ kind: "tv", year: 2023, aliases: ["Three-Body"], episodes: 30 }); + expect(doubanSuggestSubject({ id: "4", title: "某演员", type: "celebrity" })).toBeNull(); + }); + + it("榜单 / 标签 / 筛选 / 搜索的请求地址与头", async () => { + const m = createMockFetch({ + "GET /rexxar/api/v2/subject_collection/movie_top250/items": { + body: { subject_collection_items: [DOUBAN_ITEM], total: 250 }, + }, + "GET /j/search_subjects": { body: { subjects: [{ id: "1", title: "甲", rate: "8.0", cover: "c" }] } }, + "GET /rexxar/api/v2/tv/recommend": { body: { items: [{ ...DOUBAN_ITEM, type: "tv" }], total: 1 } }, + "GET /j/subject_suggest": { body: [{ id: "3", title: "三体", year: "2023", episode: "30", type: "movie" }] }, + }); + const d = createDoubanClient({ fetch: m.fetch }); + expect(await d.collection("movie_top250", 20, 20)).toMatchObject({ total: 250, items: [{ id: "35267208" }] }); + expect((await d.tagSubjects("movie", "豆瓣高分", 40, 20))[0]).toMatchObject({ id: "1", kind: "movie" }); + expect((await d.explore("tv", { sort: "S", tags: ["悬疑", "美国"] }, 0, 20)).items[0]!.kind).toBe("tv"); + expect((await d.suggest(" 三体 "))[0]!.title).toBe("三体"); + const [col, tag, exp, sug] = m.requests; + expect(col!.url.search).toBe("?start=20&count=20"); + expect(col!.headers.referer).toBe("https://m.douban.com/"); + expect(tag!.url.host).toBe("movie.douban.com"); + expect(Object.fromEntries(tag!.url.searchParams)).toEqual({ + type: "movie", + tag: "豆瓣高分", + sort: "recommend", + page_limit: "20", + page_start: "40", + }); + expect(Object.fromEntries(exp!.url.searchParams)).toMatchObject({ sort: "S", tags: "悬疑,美国", start: "0" }); + expect(sug!.url.searchParams.get("q")).toBe("三体"); + await expect(d.collection("../x")).rejects.toThrow("榜单名非法"); + }); +}); + +describe("bgm 列表", () => { + it("浏览页 HTML:标题 / 原名 / 日期 / 话数 / 评分 / 封面,有下一页", () => { + const r = parseBangumiBrowser(BGM_HTML); + expect(r.hasMore).toBe(true); + expect(r.items).toEqual([ + expect.objectContaining({ + source: "bangumi", + id: "400602", + title: "葬送的芙莉莲", + aliases: ["葬送のフリーレン"], + year: 2023, + date: "2023-09-29", + episodes: 28, + rating: 8.9, + kind: "tv", + cover: "https://lain.bgm.tv/r/400/pic/cover/c/13/c5/400602_ZI8Y9.jpg", + url: "https://bgm.tv/subject/400602", + }), + expect.objectContaining({ id: "9", kind: "movie", date: "2024-07-01", cover: null, rating: null }), + ]); + expect(parseBangumiBrowser("
    ")).toEqual({ items: [], hasMore: false }); + }); + + it("浏览 / 搜索 / 趋势页的请求", async () => { + const m = createMockFetch({ + "GET https://api.bgm.tv/v0/subjects": { + body: { data: [{ id: 1, name: "a", name_cn: "甲", date: "2024-01-01" }], total: 100, limit: 20, offset: 20 }, + }, + "POST https://api.bgm.tv/v0/search/subjects": { body: { data: [{ id: 2, name: "b" }], total: 1 } }, + "GET https://bgm.tv/anime/browser": { body: BGM_HTML }, + }); + const b = createBangumiClient({ fetch: m.fetch }); + expect(await b.browse({ sort: "rank", year: "2024", limit: 20, offset: 20 })).toMatchObject({ total: 100 }); + expect(await b.searchPage("芙莉莲", 20, 40)).toMatchObject({ total: 1, items: [{ id: "2" }] }); + expect(await b.trendsHtml(2)).toContain("browserItemList"); + expect(Object.fromEntries(m.requests[0]!.url.searchParams)).toEqual({ + type: "2", + sort: "rank", + year: "2024", + limit: "20", + offset: "20", + }); + expect(m.requests[1]!.url.search).toBe("?limit=20&offset=40"); + expect(JSON.parse(m.requests[1]!.body!)).toEqual({ keyword: "芙莉莲", filter: { type: [2] } }); + expect(m.requests[2]!.url.search).toBe("?sort=trends&page=2"); + expect(m.requests[2]!.headers["user-agent"]).toContain("uhd-media-tool"); + }); +}); + +describe("请求缓存与限流", () => { + it("键含方法 / 排序后的参数 / 请求体;按类别写入,空结果标记;命中不再请求、不占限流", async () => { + expect(metaRequestKey("get", "https://a.b/x?z=1&a=2")).toBe("GET https://a.b/x?a=2&z=1"); + expect(metaRequestKey("POST", "https://a.b/s", '{"k":1}')).toBe('POST https://a.b/s {"k":1}'); + const m = createMockFetch({ + "GET /rexxar/api/v2/subject_collection/tv_hot/items": { body: { subject_collection_items: [], total: 0 } }, + "GET /j/subject_suggest": { body: [{ id: "3", title: "三体", type: "movie" }] }, + "POST https://api.bgm.tv/v0/search/subjects": { body: { data: [{ id: 2, name: "b" }], total: 1 } }, + }); + const { cache, writes } = memoryCache(); + let limited = 0; + const limiter = { + run(fn: () => Promise) { + limited++; + return fn(); + }, + }; + const d = createDoubanClient({ fetch: m.fetch, cache, limiter }); + const b = createBangumiClient({ fetch: m.fetch, cache, limiter }); + for (let i = 0; i < 2; i++) { + await d.collection("tv_hot"); + await d.suggest("三体"); + await b.search("芙莉莲", 5); + } + expect(m.requests).toHaveLength(3); + expect(limited).toBe(3); + expect(writes.map((w) => [w.kind, w.empty])).toEqual([ + ["list", true], + ["search", false], + ["search", false], + ]); + expect(writes[0]!.key).toBe( + "GET https://m.douban.com/rexxar/api/v2/subject_collection/tv_hot/items?count=20&start=0", + ); + }); + + it("失败(403 / 风控页不是 JSON)抛错不写缓存;条目详情不走请求缓存", async () => { + const m = createMockFetch({ + "GET /rexxar/api/v2/subject_collection/tv_hot/items": { status: 403, body: "forbidden" }, + "GET /j/subject_suggest": { body: "请登录" }, + "GET /rexxar/api/v2/movie/35267208": { body: DOUBAN_ITEM }, + "GET https://bgm.tv/anime/browser": { body: "验证" }, + }); + const { cache, writes } = memoryCache(); + const d = createDoubanClient({ fetch: m.fetch, cache }); + const b = createBangumiClient({ fetch: m.fetch, cache }); + await expect(d.collection("tv_hot")).rejects.toMatchObject({ kind: "http" }); + await expect(d.suggest("x")).rejects.toMatchObject({ kind: "parse" }); + await expect(b.trendsHtml()).rejects.toMatchObject({ kind: "parse" }); + expect((await d.subject("35267208")).title).toBe("流浪地球2"); + expect(writes).toEqual([]); + }); +}); diff --git a/packages/adapters/src/tmdb/client.ts b/packages/adapters/src/tmdb/client.ts index 692e8e84..d94c76fc 100644 --- a/packages/adapters/src/tmdb/client.ts +++ b/packages/adapters/src/tmdb/client.ts @@ -102,8 +102,10 @@ export interface TmdbConfig { /** * 默认 max 1024、ttl 24h;空结果(搜索 0 条)用 emptyTtlMs,默认 30min(对照 MP EMPTY_RESULT_CACHE_TTL); * searchTtlMs:/search/、/find/ 请求的 TTL(缺省同 ttlMs),其余(详情 / 季)用 ttlMs。 + * listTtlMs:流行趋势 / 热门 / 正在上映等列表与 /discover/ 的 TTL(缺省 6 小时且不超过 ttlMs;列表会变,不能按详情缓存 30 天), + * 这类请求的空结果 TTL 也不超过它。 */ - cache?: { max?: number; ttlMs?: number; searchTtlMs?: number; emptyTtlMs?: number }; + cache?: { max?: number; ttlMs?: number; searchTtlMs?: number; emptyTtlMs?: number; listTtlMs?: number }; /** * 跨进程共享的请求缓存(服务端传 PG 缓存);给了就不再用进程内 LRU。 * 语义对照 MP @cached(skip_none、业务失败不缓存、results 为空按 emptyTtlMs)。 @@ -135,6 +137,11 @@ export function tmdbRequestKey(path: string, params: Record + /^\/(trending|discover)\//.test(path) || + /^\/(movie|tv)\/(popular|now_playing|upcoming|top_rated|on_the_air|airing_today)$/.test(path); + /** 空结果(搜索 0 条)用短 TTL(对照 MP EMPTY_RESULT_CACHE_TTL) */ const isEmptyResult = (v: unknown) => Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0; @@ -177,9 +184,13 @@ export class TmdbClient { private async get(path: string, params: Record): Promise { const qs = requestQuery(params); const key = `${path}?${qs}`; - const emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000; + let emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000; const baseTtl = this.cfg.cache?.ttlMs ?? 24 * 3600_000; - const ttl = /^\/(search|find)\//.test(path) ? (this.cfg.cache?.searchTtlMs ?? baseTtl) : baseTtl; + let ttl = /^\/(search|find)\//.test(path) ? (this.cfg.cache?.searchTtlMs ?? baseTtl) : baseTtl; + if (isTmdbListPath(path)) { + ttl = this.cfg.cache?.listTtlMs ?? Math.min(baseTtl, 6 * 3600_000); + emptyTtl = Math.min(emptyTtl, ttl); + } if (this.cfg.sharedCache) { const shared = this.cfg.sharedCache; const hit = await shared.get(key); diff --git a/packages/adapters/src/tmdb/index.ts b/packages/adapters/src/tmdb/index.ts index a3de0f61..e639289f 100644 --- a/packages/adapters/src/tmdb/index.ts +++ b/packages/adapters/src/tmdb/index.ts @@ -3,6 +3,7 @@ export * from "./client.ts"; export * from "./details.ts"; export * from "./episode-groups.ts"; export * from "./language-policy.ts"; +export * from "./lists.ts"; export * from "./match.ts"; export * from "./recall.ts"; export * from "./translations.ts"; diff --git a/packages/adapters/src/tmdb/lists.test.ts b/packages/adapters/src/tmdb/lists.test.ts new file mode 100644 index 00000000..90c924ce --- /dev/null +++ b/packages/adapters/src/tmdb/lists.test.ts @@ -0,0 +1,159 @@ +import { createMockTmdb, type MockTmdb } from "@umt/testkit"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createTmdbClient, + isTmdbListPath, + type TmdbConfig, + tmdbChart, + tmdbDiscover, + tmdbDiscoverParams, + tmdbListItem, + tmdbSearchPage, + tmdbTrending, +} from "./index.ts"; + +// 发现页的 TMDB 列表接口:路径与参数映射(对照 MP tmdb_discover)、条目投影、列表类缓存 TTL。 + +let mock: MockTmdb | null = null; +afterEach(async () => { + await mock?.close(); + mock = null; +}); + +async function setup(extra: Partial = {}) { + mock = await createMockTmdb(); + const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, ...extra }); + return { mock, tmdb }; +} + +describe("TMDB 列表条目", () => { + it("trending 条目按 media_type 定类型,人物跳过,genre_ids 转中文名", () => { + expect( + tmdbListItem({ + id: 1, + media_type: "tv", + name: "熊家餐馆", + original_name: "The Bear", + first_air_date: "2022-06-23", + genre_ids: [35, 18, 999], + vote_average: 8.2, + poster_path: "/b.jpg", + }), + ).toMatchObject({ + type: "tv", + id: "1", + title: "熊家餐馆", + originalTitle: "The Bear", + year: 2022, + genres: ["喜剧", "剧情"], + voteAverage: 8.2, + posterPath: "/b.jpg", + }); + expect(tmdbListItem({ id: 2, media_type: "person", name: "X" })).toBeNull(); + expect(tmdbListItem({ id: 3, title: "无评分", vote_average: 0 }, "movie")?.voteAverage).toBeNull(); + }); +}); + +describe("TMDB discover 参数映射", () => { + it("电影:风格 / 语言 / 地区 / 单年 / 评分;排序映射", () => { + expect( + tmdbDiscoverParams( + { type: "movie", genre: "878", language: "en", region: "US", year: "2024", minRating: 7, sort: "rating" }, + "zh-CN", + ), + ).toEqual({ + language: "zh-CN", + include_adult: "false", + page: "1", + sort_by: "vote_average.desc", + with_genres: "878", + with_original_language: "en", + with_origin_country: "US", + "vote_average.gte": "7", + "vote_count.gte": "200", + primary_release_year: "2024", + }); + }); + + it("剧集:年代展开成首播日期区间;按最新排序不列未播出的", () => { + const p = tmdbDiscoverParams({ type: "tv", year: "2010s", sort: "date", page: 3 }, "zh-CN"); + expect(p).toMatchObject({ + sort_by: "first_air_date.desc", + "first_air_date.gte": "2010-01-01", + "first_air_date.lte": "2019-12-31", + page: "3", + "vote_count.gte": "10", + }); + const q = tmdbDiscoverParams({ type: "movie", sort: "date", today: "2026-10-06" }, "zh-CN"); + expect(q).toMatchObject({ sort_by: "primary_release_date.desc", "primary_release_date.lte": "2026-10-06" }); + expect(q.primary_release_year).toBeUndefined(); + }); +}); + +describe("TMDB 列表请求", () => { + it("流行趋势 / 榜单 / 筛选 / 搜索走对应路径与参数", async () => { + const { tmdb, mock } = await setup(); + const tr = await tmdbTrending(tmdb, { mediaType: "all", window: "day", page: 1 }); + expect(tr.items.length).toBeGreaterThan(3); + expect(new Set(tr.items.map((i) => i.type))).toEqual(new Set(["movie", "tv"])); + expect(tr.items[0]!.popularity).toBeGreaterThanOrEqual(tr.items[1]!.popularity!); + + const pop = await tmdbChart(tmdb, { type: "tv", chart: "on_the_air" }); + expect(pop.items.every((i) => i.type === "tv")).toBe(true); + + const d = await tmdbDiscover(tmdb, { type: "movie", genre: "878" }); + expect(d.items.map((i) => i.id).sort()).toEqual(["693134", "842675"]); + expect(d.items[0]!.genres).toContain("科幻"); + + const s = await tmdbSearchPage(tmdb, { type: "all", query: "奥本海默" }); + expect(s.items.map((i) => `${i.type}:${i.id}`)).toEqual(["movie:872585"]); + + const paths = mock.calls.map((r) => r.path); + expect(paths).toEqual(["/3/trending/all/day", "/3/tv/on_the_air", "/3/discover/movie", "/3/search/multi"]); + expect(mock.calls[2]!.query).toMatchObject({ + with_genres: "878", + sort_by: "popularity.desc", + language: "zh-CN", + }); + }); + + it("列表类请求缓存 6 小时(不按详情 30 天),空结果不超过它;失败不缓存", async () => { + const writes: { key: string; ttlMs: number }[] = []; + const store = new Map(); + const { tmdb, mock } = await setup({ + cache: { ttlMs: 30 * 86_400_000, searchTtlMs: 7 * 86_400_000, emptyTtlMs: 86_400_000 }, + sharedCache: { + get: async (k) => store.get(k) ?? null, + set: async (key, v, ttlMs) => { + writes.push({ key, ttlMs }); + store.set(key, v); + }, + }, + }); + await tmdbTrending(tmdb, { mediaType: "movie", window: "week" }); + await tmdbTrending(tmdb, { mediaType: "movie", window: "week" }); + await tmdbDiscover(tmdb, { type: "movie", genre: "10402" }); + expect(mock.callsTo("/3/trending")).toHaveLength(1); + expect(writes.map((w) => [w.key.split("?")[0], w.ttlMs])).toEqual([ + ["/trending/movie/week", 6 * 3_600_000], + ["/discover/movie", 6 * 3_600_000], + ]); + // 鉴权失败:抛错,不写缓存 + const bad = createTmdbClient({ + baseUrl: `${mock.url}/3`, + apiKey: "wrong", + retries: 0, + sharedCache: { get: async () => null, set: async (key, _v, ttlMs) => void writes.push({ key, ttlMs }) }, + }); + await expect(tmdbChart(bad, { type: "movie", chart: "popular" })).rejects.toMatchObject({ kind: "auth" }); + expect(writes).toHaveLength(2); + }); + + it("isTmdbListPath 只认列表路径", () => { + expect(isTmdbListPath("/trending/all/week")).toBe(true); + expect(isTmdbListPath("/discover/tv")).toBe(true); + expect(isTmdbListPath("/movie/now_playing")).toBe(true); + expect(isTmdbListPath("/movie/872585")).toBe(false); + expect(isTmdbListPath("/search/movie")).toBe(false); + }); +}); diff --git a/packages/adapters/src/tmdb/lists.ts b/packages/adapters/src/tmdb/lists.ts new file mode 100644 index 00000000..0aa0ff58 --- /dev/null +++ b/packages/adapters/src/tmdb/lists.ts @@ -0,0 +1,172 @@ +/** + * TMDB 列表类接口(发现页)。 + * 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py discover_movies / discover_tvs / discover_trending(trending.all_week)、 + * app/chain/recommend.py tmdb_movies / tmdb_tvs / tmdb_trending,与前端 views/discover/TheMovieDbView.vue 的筛选参数。 + * 走 TmdbClient.getJson:同一套请求缓存(列表类 TTL 见 client.ts isTmdbListPath)、限流、重试;失败抛 TmdbError,不缓存。 + * 调整:MP 只给 trending/all/week;这里日 / 周 × 全部 / 电影 / 剧集都给;电影 / 剧集榜单(热门、正在上映…)直接用 TMDB 的榜单接口。 + */ +import { type TmdbDiscoverSort, tmdbGenres } from "@umt/contracts"; +import type { MediaEntityType, TmdbClient } from "./client.ts"; + +export interface TmdbListItem { + type: MediaEntityType; + id: string; + title: string; + originalTitle: string | null; + originalLanguage: string | null; + year: number | null; + date: string | null; + overview: string | null; + posterPath: string | null; + backdropPath: string | null; + voteAverage: number | null; + voteCount: number | null; + popularity: number | null; + /** genre_ids 按字典转中文名 */ + genres: string[]; +} + +export interface TmdbListPage { + page: number; + totalPages: number; + totalResults: number; + items: TmdbListItem[]; +} + +export type TmdbChart = "popular" | "now_playing" | "upcoming" | "top_rated" | "on_the_air" | "airing_today"; + +export interface TmdbDiscoverFilter { + type: MediaEntityType; + sort?: TmdbDiscoverSort; + /** genre id */ + genre?: string; + /** with_original_language */ + language?: string; + /** with_origin_country(ISO 3166-1) */ + region?: string; + /** 单年 2024 或年代 2010s */ + year?: string; + /** vote_average.gte */ + minRating?: number; + page?: number; + /** 按日期排序时不列还没上映的(YYYY-MM-DD) */ + today?: string; +} + +type Json = Record; +const s = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null); +const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); + +/** 一条列表结果 → TmdbListItem;type 缺省取 media_type(trending / multi),人物等非影视条目返回 null */ +export function tmdbListItem(raw: Json, type?: MediaEntityType | null): TmdbListItem | null { + const t = type ?? (raw.media_type === "movie" || raw.media_type === "tv" ? raw.media_type : null); + if (!t || raw.id === undefined || raw.id === null) return null; + const title = s(t === "movie" ? raw.title : raw.name) ?? s(t === "movie" ? raw.original_title : raw.original_name); + if (!title) return null; + const date = s(t === "movie" ? raw.release_date : raw.first_air_date); + const dict = tmdbGenres(t); + return { + type: t, + id: String(raw.id), + title, + originalTitle: s(t === "movie" ? raw.original_title : raw.original_name), + originalLanguage: s(raw.original_language), + year: date && /^\d{4}/.test(date) ? Number(date.slice(0, 4)) : null, + date, + overview: s(raw.overview), + posterPath: s(raw.poster_path), + backdropPath: s(raw.backdrop_path), + voteAverage: n(raw.vote_average) || null, + voteCount: n(raw.vote_count), + popularity: n(raw.popularity), + genres: (Array.isArray(raw.genre_ids) ? raw.genre_ids : []).flatMap((g) => dict[String(g)] ?? []), + }; +} + +function toPage(j: Json, type?: MediaEntityType | null): TmdbListPage { + const results = (Array.isArray(j.results) ? j.results : []) as Json[]; + return { + page: n(j.page) ?? 1, + totalPages: n(j.total_pages) ?? 1, + totalResults: n(j.total_results) ?? results.length, + items: results.flatMap((r) => tmdbListItem(r, type) ?? []), + }; +} + +/** 流行趋势:/trending/{all|movie|tv}/{day|week} */ +export async function tmdbTrending( + client: TmdbClient, + o: { mediaType: "all" | MediaEntityType; window: "day" | "week"; page?: number }, +): Promise { + const j = await client.getJson(`/trending/${o.mediaType}/${o.window}`, { + language: client.language, + page: String(o.page ?? 1), + }); + return toPage(j, o.mediaType === "all" ? null : o.mediaType); +} + +/** 电影 / 剧集榜单:/movie/{popular|now_playing|upcoming|top_rated}、/tv/{popular|on_the_air|airing_today|top_rated} */ +export async function tmdbChart( + client: TmdbClient, + o: { type: MediaEntityType; chart: TmdbChart; page?: number }, +): Promise { + const j = await client.getJson(`/${o.type}/${o.chart}`, { + language: client.language, + page: String(o.page ?? 1), + }); + return toPage(j, o.type); +} + +/** 筛选条件 → /discover/{movie|tv} 参数(对照 MP tmdb_discover 的参数表;纯函数,测试用) */ +export function tmdbDiscoverParams(f: TmdbDiscoverFilter, language: string): Record { + const movie = f.type === "movie"; + const dateKey = movie ? "primary_release_date" : "first_air_date"; + const sort = f.sort ?? "popularity"; + const p: Record = { + language, + include_adult: "false", + page: String(f.page ?? 1), + sort_by: { + popularity: "popularity.desc", + date: `${dateKey}.desc`, + rating: "vote_average.desc", + votes: "vote_count.desc", + }[sort], + with_genres: f.genre, + with_original_language: f.language, + with_origin_country: f.region, + "vote_average.gte": f.minRating ? String(f.minRating) : undefined, + // 评分排序时要求足够的评分人数,否则全是一两票的 10 分(MP 缺省 vote_count 10) + "vote_count.gte": sort === "rating" ? "200" : f.minRating ? "50" : "10", + }; + if (f.year) { + const decade = /^(\d{4})s$/.exec(f.year); + if (decade) { + const y = Number(decade[1]); + p[`${dateKey}.gte`] = `${y}-01-01`; + p[`${dateKey}.lte`] = `${y + 9}-12-31`; + } else p[movie ? "primary_release_year" : "first_air_date_year"] = f.year; + } + if (sort === "date" && f.today && !p[`${dateKey}.lte`]) p[`${dateKey}.lte`] = f.today; + return p; +} + +export async function tmdbDiscover(client: TmdbClient, f: TmdbDiscoverFilter): Promise { + const j = await client.getJson(`/discover/${f.type}`, tmdbDiscoverParams(f, client.language)); + return toPage(j, f.type); +} + +/** 关键词搜索(分页):全部走 /search/multi(过滤人物),电影 / 剧集走 /search/{type} */ +export async function tmdbSearchPage( + client: TmdbClient, + o: { type: "all" | MediaEntityType; query: string; page?: number }, +): Promise { + const path = o.type === "all" ? "/search/multi" : `/search/${o.type}`; + const j = await client.getJson(path, { + query: o.query.trim(), + language: client.language, + include_adult: "false", + page: String(o.page ?? 1), + }); + return toPage(j, o.type === "all" ? null : o.type); +} diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index eea71add..3574b340 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -27,6 +27,7 @@ export { ReleaseIdentityResult, TaskAccepted, } from "./api/shared.ts"; +export * from "./discover.ts"; export * from "./search.ts"; // 所有 REST 端点的路径常量与请求/响应 schema 汇总,按领域分在 ./api/*.ts。 diff --git a/packages/contracts/src/api/library.ts b/packages/contracts/src/api/library.ts index c3d63e6d..48d4669a 100644 --- a/packages/contracts/src/api/library.ts +++ b/packages/contracts/src/api/library.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { MetadataEnrichOutput } from "../capabilities-io.ts"; import { cursorPage, IdParam } from "../common.ts"; +import * as discover from "../discover.ts"; import * as execution from "../execution.ts"; import * as identity from "../identity.ts"; import * as libraryVersions from "../library-versions.ts"; @@ -211,24 +212,41 @@ export const libraryApi = { method: "get", path: "/discover/bangumi-calendar", scope: "read", - summary: "bgm.tv 每日放送(周一到周日;缓存 1 小时)", - response: search.DiscoverCalendar, + summary: "bgm.tv 每日放送(周一到周日;请求缓存 6 小时;带本地状态)", + response: discover.DiscoverCalendar, }), - discoverHot: ep({ + discoverTmdb: ep({ method: "get", - path: "/discover/douban-hot", + path: "/discover/tmdb", scope: "read", - summary: "豆瓣实时热门电影 / 剧集(缓存 1 小时)", - query: search.DiscoverHotQuery, - response: search.DiscoverList, + summary: + "TMDB 发现:流行趋势、热门、正在上映 / 播出、即将上映、高分、按条件筛选、搜索(走 TMDB 请求缓存;带本地状态)", + query: discover.DiscoverTmdbQuery, + response: discover.DiscoverPage, + }), + discoverDouban: ep({ + method: "get", + path: "/discover/douban", + scope: "read", + summary: "豆瓣发现:榜单、标签、筛选、搜索(走豆瓣请求缓存,失败不缓存)", + query: discover.DiscoverDoubanQuery, + response: discover.DiscoverPage, + }), + discoverBangumi: ep({ + method: "get", + path: "/discover/bangumi", + scope: "read", + summary: "Bangumi 发现:热门趋势、排行、最新、搜索(走 bgm 请求缓存,失败不缓存)", + query: discover.DiscoverBangumiQuery, + response: discover.DiscoverPage, }), discoverMatch: ep({ method: "post", path: "/discover/tmdb-match", scope: "read", - summary: "豆瓣 / bgm 条目 → TMDB 作品(名称 + 年份匹配,对照 MP 投影);确定的给 match,否则给候选", - body: search.DiscoverMatchRequest, - response: search.DiscoverMatchResult, + summary: "豆瓣 / bgm 条目 → TMDB 作品(名称 + 年份匹配,对照 MP 投影);确定的给 match 并记住,否则给候选", + body: discover.DiscoverMatchRequest, + response: discover.DiscoverMatchResult, }), tmdbSeason: ep({ method: "get", diff --git a/packages/contracts/src/discover.ts b/packages/contracts/src/discover.ts new file mode 100644 index 00000000..dde18f51 --- /dev/null +++ b/packages/contracts/src/discover.ts @@ -0,0 +1,358 @@ +import { z } from "zod"; + +// ── 发现(对照 MP 推荐 / 探索,docs/porting/moviepilot-discover.md) ── +// 来源:TMDB(流行趋势、热门、正在上映 / 播出、即将上映、高分、按条件筛选、搜索)、豆瓣(榜单、标签、筛选、搜索)、 +// Bangumi(每日放送、热门趋势、排行 / 最新、搜索)。豆瓣 / bgm 条目点开时按名称 + 年份匹配 TMDB 作品。 +// 下面的字典是界面筛选器与服务端校验共用的唯一来源。 + +export const DiscoverSource = z.enum(["tmdb", "douban", "bangumi"]); +export type DiscoverSource = z.infer; + +/** TMDB 风格(genre id → 中文名,对照 MP TheMovieDbView 的字典;不另请求 /genre/list) */ +export const TMDB_MOVIE_GENRES: Record = { + "28": "动作", + "12": "冒险", + "16": "动画", + "35": "喜剧", + "80": "犯罪", + "99": "纪录", + "18": "剧情", + "10751": "家庭", + "14": "奇幻", + "36": "历史", + "27": "恐怖", + "10402": "音乐", + "9648": "悬疑", + "10749": "爱情", + "878": "科幻", + "10770": "电视电影", + "53": "惊悚", + "10752": "战争", + "37": "西部", +}; +export const TMDB_TV_GENRES: Record = { + "10759": "动作冒险", + "16": "动画", + "35": "喜剧", + "80": "犯罪", + "99": "纪录", + "18": "剧情", + "10751": "家庭", + "10762": "儿童", + "9648": "悬疑", + "10763": "新闻", + "10764": "真人秀", + "10765": "科幻奇幻", + "10766": "肥皂剧", + "10767": "脱口秀", + "10768": "战争政治", + "37": "西部", +}; +export const tmdbGenres = (type: "movie" | "tv") => (type === "movie" ? TMDB_MOVIE_GENRES : TMDB_TV_GENRES); + +/** 原始语言(with_original_language) */ +export const TMDB_LANGUAGES: Record = { + zh: "中文", + en: "英语", + ja: "日语", + ko: "韩语", + fr: "法语", + de: "德语", + es: "西班牙语", + it: "意大利语", + ru: "俄语", + pt: "葡萄牙语", + th: "泰语", + hi: "印地语", +}; + +/** 出品地区(with_origin_country,ISO 3166-1) */ +export const TMDB_REGIONS: Record = { + CN: "中国大陆", + HK: "中国香港", + TW: "中国台湾", + US: "美国", + GB: "英国", + JP: "日本", + KR: "韩国", + FR: "法国", + DE: "德国", + IN: "印度", + TH: "泰国", + ES: "西班牙", +}; + +export const TmdbDiscoverSort = z.enum(["popularity", "date", "rating", "votes"]); +export type TmdbDiscoverSort = z.infer; +export const TMDB_SORTS: Record = { + popularity: "热度", + date: "最新", + rating: "评分", + votes: "评分人数", +}; + +/** TMDB 列表:trending 流行趋势;popular 热门;now_playing 正在上映;upcoming 即将上映;on_the_air 正在播出;airing_today 今日播出;top_rated 高分;discover 按条件筛选;search 关键词 */ +export const TmdbDiscoverList = z.enum([ + "trending", + "popular", + "now_playing", + "upcoming", + "on_the_air", + "airing_today", + "top_rated", + "discover", + "search", +]); +export type TmdbDiscoverList = z.infer; +/** 只对一种类型有的列表 */ +export const TMDB_LIST_TYPES: Partial> = { + now_playing: "movie", + upcoming: "movie", + on_the_air: "tv", + airing_today: "tv", +}; + +/** 年份:单年 2024 或年代 2010s */ +export const DISCOVER_YEAR = /^(19|20)\d{2}s?$/; + +/** 豆瓣榜单(rexxar subject_collection;2026-10-06 协调者实测可用的 id) */ +export const DOUBAN_COLLECTIONS: Record = { + movie_real_time_hotest: { label: "实时热门电影", kind: "movie" }, + tv_real_time_hotest: { label: "实时热门剧集", kind: "tv" }, + movie_showing: { label: "正在热映", kind: "movie" }, + movie_hot_gaia: { label: "热门电影", kind: "movie" }, + movie_soon: { label: "即将上映", kind: "movie" }, + movie_top250: { label: "电影 Top250", kind: "movie" }, + tv_hot: { label: "热门剧集", kind: "tv" }, + tv_domestic: { label: "国产剧", kind: "tv" }, + tv_american: { label: "欧美剧", kind: "tv" }, + tv_japanese: { label: "日剧", kind: "tv" }, + tv_korean: { label: "韩剧", kind: "tv" }, + tv_animation: { label: "动画", kind: "tv" }, + show_hot: { label: "综艺", kind: "tv" }, + tv_chinese_best_weekly: { label: "华语口碑剧集榜", kind: "tv" }, + tv_global_best_weekly: { label: "全球口碑剧集榜", kind: "tv" }, +}; + +/** 豆瓣标签(movie.douban.com/j/search_subjects 的 tag) */ +export const DOUBAN_TAGS: Record<"movie" | "tv", string[]> = { + movie: ["热门", "最新", "豆瓣高分", "冷门佳片", "华语", "欧美", "韩国", "日本"], + tv: ["热门", "国产剧", "美剧", "英剧", "韩剧", "日剧", "港剧", "日本动画", "综艺", "纪录片"], +}; + +/** 豆瓣筛选(rexxar movie|tv/recommend,对照 MP DoubanView) */ +export const DoubanExploreSort = z.enum(["U", "R", "T", "S"]); +export type DoubanExploreSort = z.infer; +export const DOUBAN_SORTS: Record = { U: "综合", T: "近期热度", R: "首映时间", S: "高分" }; +export const DOUBAN_GENRES = [ + "喜剧", + "爱情", + "动作", + "科幻", + "动画", + "悬疑", + "犯罪", + "惊悚", + "冒险", + "音乐", + "历史", + "奇幻", + "恐怖", + "战争", + "传记", + "歌舞", + "武侠", + "灾难", + "西部", + "纪录片", + "短片", +]; +export const DOUBAN_REGIONS = [ + "华语", + "欧美", + "韩国", + "日本", + "中国大陆", + "美国", + "中国香港", + "中国台湾", + "英国", + "法国", + "德国", + "意大利", + "西班牙", + "印度", + "泰国", +]; +/** 年代或单年:2020年代 / 90年代 / 2024 */ +export const DOUBAN_YEAR = /^((19|20)\d{2}|(19|20)\d0年代|[6-9]0年代)$/; + +export const BangumiDiscoverList = z.enum(["trends", "rank", "latest", "search"]); +export type BangumiDiscoverList = z.infer; + +export const DISCOVER_PAGE_SIZE = 20; + +// ── 条目与分页 ── + +/** 本地状态:TMDB 身份对应的本地作品(豆瓣 / bgm 条目只有匹配过才知道) */ +export const DiscoverLocal = z.object({ + entityId: z.string(), + subscribed: z.boolean(), + versionCount: z.number().int(), + downloadingCount: z.number().int(), +}); +export type DiscoverLocal = z.infer; + +export const DiscoverTmdbRef = z.object({ type: z.enum(["movie", "tv"]), id: z.string() }); +export type DiscoverTmdbRef = z.infer; + +export const DiscoverItem = z.object({ + source: DiscoverSource, + id: z.string(), + title: z.string(), + aliases: z.array(z.string()), + year: z.number().int().nullable(), + kind: z.enum(["movie", "tv"]).nullable(), + /** 豆瓣 / bgm 封面完整地址;TMDB 用 posterPath */ + cover: z.string().nullable(), + rating: z.number().nullable(), + date: z.string().nullable(), + episodes: z.number().int().nullable(), + url: z.string(), + genres: z.array(z.string()).default([]), + posterPath: z.string().nullable().default(null), + /** TMDB 条目自身;豆瓣 / bgm 条目匹配过且确定时给出 */ + tmdb: DiscoverTmdbRef.nullable().default(null), + local: DiscoverLocal.nullable().default(null), +}); +export type DiscoverItem = z.infer; + +export const DiscoverPage = z.object({ + items: z.array(DiscoverItem), + page: z.number().int(), + hasMore: z.boolean(), +}); +export type DiscoverPage = z.infer; + +export const DiscoverCalendar = z.object({ + days: z.array(z.object({ weekday: z.number().int(), label: z.string(), items: z.array(DiscoverItem) })), +}); +export type DiscoverCalendar = z.infer; + +// ── 查询参数 ── + +const Page = z.coerce.number().int().min(1).max(500).default(1); +const Q = z.string().trim().min(1).max(100); + +const issue = (ctx: z.RefinementCtx, path: string, message: string) => + ctx.addIssue({ code: "custom", path: [path], message }); + +export const DiscoverTmdbQuery = z + .object({ + list: TmdbDiscoverList.default("trending"), + type: z.enum(["all", "movie", "tv"]).default("all"), + /** 只用于 trending */ + window: z.enum(["day", "week"]).default("week"), + sort: TmdbDiscoverSort.optional(), + genre: z + .string() + .regex(/^\d{1,6}$/) + .optional(), + language: z + .string() + .regex(/^[a-z]{2}$/) + .optional(), + region: z + .string() + .regex(/^[A-Z]{2}$/) + .optional(), + year: z.string().regex(DISCOVER_YEAR).optional(), + minRating: z.coerce.number().min(0).max(10).optional(), + q: Q.optional(), + page: Page, + }) + .superRefine((v, ctx) => { + const only = TMDB_LIST_TYPES[v.list]; + if (only && v.type !== only) issue(ctx, "type", `${v.list} 只有${only === "movie" ? "电影" : "剧集"}`); + if (v.type === "all" && !["trending", "search"].includes(v.list) && !only) + issue(ctx, "type", "这个列表要选电影或剧集"); + const filters = (["sort", "genre", "language", "region", "year", "minRating"] as const).filter( + (k) => v[k] !== undefined, + ); + if (v.list !== "discover" && filters.length) issue(ctx, filters[0]!, "筛选条件只用于 list=discover"); + if (v.genre && v.type !== "all" && !(v.genre in tmdbGenres(v.type))) issue(ctx, "genre", "没有这个风格"); + if (v.list === "search" && !v.q) issue(ctx, "q", "搜索要给关键词"); + if (v.list !== "search" && v.q) issue(ctx, "q", "关键词只用于 list=search"); + }); +export type DiscoverTmdbQuery = z.infer; + +export const DiscoverDoubanQuery = z + .object({ + /** 榜单 id、tag(标签)、explore(筛选)、search(关键词) */ + list: z.string().max(40).default("movie_real_time_hotest"), + type: z.enum(["movie", "tv"]).default("movie"), + tag: z.string().max(20).optional(), + sort: DoubanExploreSort.optional(), + genre: z.string().max(10).optional(), + region: z.string().max(10).optional(), + year: z.string().regex(DOUBAN_YEAR).optional(), + q: Q.optional(), + page: z.coerce.number().int().min(1).max(50).default(1), + }) + .superRefine((v, ctx) => { + const special = ["tag", "explore", "search"]; + if (!special.includes(v.list) && !(v.list in DOUBAN_COLLECTIONS)) issue(ctx, "list", "没有这个豆瓣榜单"); + if (v.list === "tag" && !(v.tag && DOUBAN_TAGS[v.type].includes(v.tag))) issue(ctx, "tag", "没有这个标签"); + if (v.list !== "tag" && v.tag) issue(ctx, "tag", "标签只用于 list=tag"); + const filters = (["sort", "genre", "region", "year"] as const).filter((k) => v[k] !== undefined); + if (v.list !== "explore" && filters.length) issue(ctx, filters[0]!, "筛选条件只用于 list=explore"); + if (v.genre && !DOUBAN_GENRES.includes(v.genre)) issue(ctx, "genre", "没有这个风格"); + if (v.region && !DOUBAN_REGIONS.includes(v.region)) issue(ctx, "region", "没有这个地区"); + if (v.list === "search" && !v.q) issue(ctx, "q", "搜索要给关键词"); + if (v.list !== "search" && v.q) issue(ctx, "q", "关键词只用于 list=search"); + }); +export type DiscoverDoubanQuery = z.infer; + +export const DiscoverBangumiQuery = z + .object({ + list: BangumiDiscoverList.default("trends"), + year: z + .string() + .regex(/^(19|20)\d{2}$/) + .optional(), + q: Q.optional(), + page: z.coerce.number().int().min(1).max(100).default(1), + }) + .superRefine((v, ctx) => { + if (v.year && !["rank", "latest"].includes(v.list)) issue(ctx, "year", "年份只用于排行与最新"); + if (v.list === "search" && !v.q) issue(ctx, "q", "搜索要给关键词"); + if (v.list !== "search" && v.q) issue(ctx, "q", "关键词只用于 list=search"); + }); +export type DiscoverBangumiQuery = z.infer; + +// ── 匹配 TMDB ── + +export const DiscoverMatchRequest = z.object({ + source: z.enum(["douban", "bangumi"]), + id: z.string(), + title: z.string(), + aliases: z.array(z.string()), + year: z.number().int().nullable(), + kind: z.enum(["movie", "tv"]).nullable(), +}); +export type DiscoverMatchRequest = z.infer; + +export const DiscoverMatchCandidate = z.object({ + type: z.enum(["movie", "tv"]), + id: z.string(), + title: z.string(), + year: z.number().int().nullable(), + posterPath: z.string().nullable(), +}); +export type DiscoverMatchCandidate = z.infer; +export const DiscoverMatchResult = z.object({ + /** 能确定的 TMDB 作品;不确定为 null,看 candidates */ + match: DiscoverMatchCandidate.nullable(), + candidates: z.array(DiscoverMatchCandidate), +}); +export type DiscoverMatchResult = z.infer; diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts index 146ab03e..53028d0f 100644 --- a/packages/contracts/src/search.ts +++ b/packages/contracts/src/search.ts @@ -475,55 +475,3 @@ export const TmdbSeasonDetailView = z.object({ episodes: z.array(MediaEpisodeInfo), }); export type TmdbSeasonDetailView = z.infer; - -// ── 发现:bgm.tv 每日放送、豆瓣热门(辅助信息源,点开后匹配 TMDB 作品) ── - -export const DiscoverItem = z.object({ - source: z.enum(["bangumi", "douban"]), - id: z.string(), - title: z.string(), - aliases: z.array(z.string()), - year: z.number().int().nullable(), - kind: z.enum(["movie", "tv"]).nullable(), - cover: z.string().nullable(), - rating: z.number().nullable(), - date: z.string().nullable(), - episodes: z.number().int().nullable(), - url: z.string(), -}); -export type DiscoverItem = z.infer; - -export const DiscoverCalendar = z.object({ - days: z.array(z.object({ weekday: z.number().int(), label: z.string(), items: z.array(DiscoverItem) })), -}); -export type DiscoverCalendar = z.infer; - -export const DiscoverHotQuery = z.object({ kind: z.enum(["movie", "tv"]).default("movie") }); -export type DiscoverHotQuery = z.infer; - -export const DiscoverList = z.object({ items: z.array(DiscoverItem) }); -export type DiscoverList = z.infer; - -export const DiscoverMatchRequest = DiscoverItem.pick({ - source: true, - id: true, - title: true, - aliases: true, - year: true, - kind: true, -}); -export type DiscoverMatchRequest = z.infer; - -export const DiscoverMatchCandidate = z.object({ - type: z.enum(["movie", "tv"]), - id: z.string(), - title: z.string(), - year: z.number().int().nullable(), - posterPath: z.string().nullable(), -}); -export const DiscoverMatchResult = z.object({ - /** 能确定的 TMDB 作品;不确定为 null,看 candidates */ - match: DiscoverMatchCandidate.nullable(), - candidates: z.array(DiscoverMatchCandidate), -}); -export type DiscoverMatchResult = z.infer; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8910ce2c..df4c843f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -574,6 +574,12 @@ export const StorageCacheTtl = z.object({ siteDetailDays: Days, /** 网页搜索结果 */ webSearchDays: Days, + /** 豆瓣 / bgm 搜索请求(kv_cache douban.request / bangumi.request;条目详情走 douban.subject / bangumi.subject,见 docs/porting/moviepilot-discover.md) */ + metaSearchDays: Days, + /** 豆瓣 / bgm 榜单、热门、每日放送等列表请求(小时) */ + metaListHours: z.number().int().min(1).max(720), + /** 豆瓣 / bgm 空结果(小时,对照 MP empty_ttl) */ + metaEmptyHours: z.number().int().min(1).max(720), }); export type StorageCacheTtl = z.infer; @@ -596,7 +602,16 @@ export const DEFAULT_STORAGE_SETTINGS: StorageSettings = { capabilityStats: 90, identityPoolArchive: 30, }, - cacheTtl: { tmdbSearchDays: 7, tmdbDetailDays: 30, tmdbMissDays: 1, siteDetailDays: 7, webSearchDays: 1 }, + cacheTtl: { + tmdbSearchDays: 7, + tmdbDetailDays: 30, + tmdbMissDays: 1, + siteDetailDays: 7, + webSearchDays: 1, + metaSearchDays: 1, + metaListHours: 6, + metaEmptyHours: 1, + }, }; /** 存储设置与缺省逐层合并(旧库里缺子字段时补齐) */ diff --git a/packages/contracts/test/schemas.test.ts b/packages/contracts/test/schemas.test.ts index 6a7d39ea..33e9f25d 100644 --- a/packages/contracts/test/schemas.test.ts +++ b/packages/contracts/test/schemas.test.ts @@ -39,6 +39,10 @@ const STRING_CANDIDATES = [ "zh-hans", "movie:157336", "zh-CN", + // 发现页筛选:原始语言、地区、年份 + "zh", + "US", + "2024", ]; const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000]; @@ -117,6 +121,10 @@ const OVERRIDES: Record) => Record ({ ...v, byStatus: { pending: 1 } }), + // 发现页查询:筛选条件只配特定列表(跨字段校验),正例只给列表 + DiscoverTmdbQuery: () => ({ list: "discover", type: "movie", genre: "28", year: "2024" }), + DiscoverDoubanQuery: () => ({ list: "explore", type: "tv", genre: "悬疑", year: "2024" }), + DiscoverBangumiQuery: () => ({ list: "rank", year: "2024" }), TypedEventEnvelope: () => ({ seq: 1, at: "2026-09-27T08:00:00Z", diff --git a/packages/testkit/src/tmdb/fixtures.ts b/packages/testkit/src/tmdb/fixtures.ts index af0454c8..515c0983 100644 --- a/packages/testkit/src/tmdb/fixtures.ts +++ b/packages/testkit/src/tmdb/fixtures.ts @@ -56,6 +56,8 @@ export interface TmdbFixture { seasons?: TmdbFixtureSeason[]; /** 海报路径(拼 image.tmdb.org);缺省 null */ poster_path?: string | null; + /** 列表接口(trending / discover / 榜单)返回的 genre_ids;缺省空 */ + genre_ids?: number[]; /** 剧集组(`/tv/{id}/episode_groups`、`/tv/episode_group/{id}`);组内集按 TMDB 默认季集号引用 seasons 里的集 */ episode_groups?: TmdbFixtureEpisodeGroup[]; } @@ -82,6 +84,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [ { type: "movie", id: 872585, + genre_ids: [18, 36], original_title: "Oppenheimer", original_language: "en", origin_country: ["US"], @@ -100,6 +103,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [ { type: "movie", id: 693134, + genre_ids: [878, 12], original_title: "Dune: Part Two", original_language: "en", origin_country: ["US"], @@ -116,6 +120,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [ { type: "movie", id: 842675, + genre_ids: [878, 28], original_title: "流浪地球2", original_language: "zh", origin_country: ["CN"], @@ -131,6 +136,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [ { type: "tv", id: 136315, + genre_ids: [35, 18], original_title: "The Bear", original_language: "en", origin_country: ["US"], @@ -181,6 +187,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [ { type: "tv", id: 100088, + genre_ids: [18, 10765], original_title: "The Last of Us", original_language: "en", origin_country: ["US"], @@ -209,6 +216,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [ { type: "tv", id: 900001, + genre_ids: [10764], original_title: "向往的生活", original_language: "zh", origin_country: ["CN"], @@ -260,6 +268,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [ { type: "tv", id: 95396, + genre_ids: [18, 9648], original_title: "Severance", original_language: "en", origin_country: ["US"], diff --git a/packages/testkit/src/tmdb/mock-tmdb.ts b/packages/testkit/src/tmdb/mock-tmdb.ts index 8ad5272c..f0fb16e6 100644 --- a/packages/testkit/src/tmdb/mock-tmdb.ts +++ b/packages/testkit/src/tmdb/mock-tmdb.ts @@ -19,7 +19,10 @@ const notFound: MockReply = { json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." }, }; -/** mock TMDB v3:/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n、/3/tv/:id/episode_groups、/3/tv/episode_group/:id */ +/** 列表接口一页的条数(同 TMDB) */ +const LIST_PAGE = 20; + +/** mock TMDB v3:/3/trending、/3/discover、电影 / 剧集榜单(按热度,一页 20 条)、/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n、/3/tv/:id/episode_groups、/3/tv/episode_group/:id */ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { const fx = opts.fixtures ?? TMDB_FIXTURES; const apiKey = opts.apiKey ?? "test-tmdb-key"; @@ -56,8 +59,60 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise ({ + ...brief(f, lang), + genre_ids: f.genre_ids ?? [], + vote_average: f.rich?.vote_average ?? 0, + vote_count: f.rich?.vote_count ?? 0, + }); + const listPage = (rows: TmdbFixture[], q: Record, withType: boolean) => { + const page = Math.max(1, Number(q.page ?? 1)); + const sorted = [...rows].sort((a, b) => b.popularity - a.popularity); + const results = sorted + .slice((page - 1) * LIST_PAGE, page * LIST_PAGE) + .map((f) => ({ ...listItem(f, q.language), ...(withType ? { media_type: f.type } : {}) })); + return { + json: { + page, + results, + total_pages: Math.max(1, Math.ceil(sorted.length / LIST_PAGE)), + total_results: sorted.length, + }, + }; + }; + const http = await startMockHttp( [ + [ + /^\/3\/trending\/(all|movie|tv)\/(day|week)$/, + (m, q) => listPage(m[1] === "all" ? fx : fx.filter((f) => f.type === m[1]), q, true), + ], + [ + /^\/3\/(movie|tv)\/(popular|now_playing|upcoming|top_rated|on_the_air|airing_today)$/, + (m, q) => + listPage( + fx.filter((f) => f.type === m[1]), + q, + false, + ), + ], + [ + /^\/3\/discover\/(movie|tv)$/, + (m, q) => { + const year = q.primary_release_year ?? q.first_air_date_year; + const gte = q["primary_release_date.gte"] ?? q["first_air_date.gte"]; + const lte = q["primary_release_date.lte"] ?? q["first_air_date.lte"]; + const rows = fx + .filter((f) => f.type === m[1]) + .filter((f) => !q.with_genres || (f.genre_ids ?? []).includes(Number(q.with_genres))) + .filter((f) => !q.with_original_language || f.original_language === q.with_original_language) + .filter((f) => !q.with_origin_country || f.origin_country.includes(q.with_origin_country)) + .filter((f) => !year || f.date.startsWith(year)) + .filter((f) => (!gte || f.date >= gte) && (!lte || f.date <= lte)) + .filter((f) => !q["vote_average.gte"] || (f.rich?.vote_average ?? 0) >= Number(q["vote_average.gte"])); + return listPage(rows, q, false); + }, + ], [ /^\/3\/search\/multi$/, (_m, q) => {