merge: ui2/t-discover

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei

# Conflicts:
#	packages/adapters/src/tmdb/index.ts
This commit is contained in:
root committed 2026-10-06 18:34:12 +08:00
commit b32d5c028a
29 files changed
+3404 -329

No files matched your search

+234 -20
View File
@@ -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<Map<string, InventoryEntry>>;
function clients(rt: SourcesRuntime) {
const c = metaClients(rt);
@@ -19,29 +54,201 @@ function clients(rt: SourcesRuntime) {
return c;
}
async function cached<T>(rt: SourcesRuntime, key: string, load: () => Promise<T>): Promise<T> {
const cache = pgCacheFor(rt.ctx.db);
const hit = await cache.get<T>(REGION, key).catch(() => null);
if (hit) return hit;
let v: T;
async function upstream<T>(what: string, fn: () => Promise<T>): Promise<T> {
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<DiscoverMatchCandidate>(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<string, InventoryEntry>();
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<DiscoverPage> {
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<DiscoverPage> {
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<ReturnType<typeof parseBangumiBrowser>>;
export function setTrendsParser(fn: typeof parseTrendsPage) {
parseTrendsPage = fn;
}
export async function bangumiList(rt: SourcesRuntime, q: DiscoverBangumiQuery): Promise<DiscoverPage> {
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<DiscoverCalendar> {
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<DiscoverMatchResult> {
const cache = pgCacheFor(rt.ctx.db);
const memoKey = `${req.source}:${req.id}`;
const memo = await cache.get<DiscoverMatchCandidate>(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) });
}
+20 -4
View File
@@ -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<AuthEnv>) =>
async <P extends { items: Parameters<typeof annotate>[1] }>(p: P): Promise<P> => {
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 }) => {
@@ -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<MetaSource, string> = { 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 {
+7 -1
View File
@@ -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;
+274
View File
@@ -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 = `<ul id="browserItemList"><li id="item_400602" class="item"><div class="inner">
<h3><a href="/subject/400602" class="l">葬送的芙莉莲</a></h3><p class="info tip">28话 / 2023年9月29日</p></div></li></ul>`;
describe.skipIf(!up)("发现页接口", () => {
let ts: TestSchema;
let db: Kysely<DB>;
let tmdb: MockTmdb;
let app: ReturnType<typeof createApp>;
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("<html>验证</html>", { 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 },
});
});
});
+44 -15
View File
@@ -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<DiscoverPage> {
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,
});
}
@@ -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 (
<div className="flex min-w-0 items-center gap-3">
<span className="w-10 shrink-0 text-meta text-muted-foreground">{label}</span>
<fieldset aria-label={label} className="scroll-bleed flex min-w-0 gap-1 overflow-x-auto border-0">
{all.map((o) => {
const on = (value ?? "") === o.value;
return (
<button
key={o.value || "_all"}
type="button"
aria-pressed={on}
onClick={() => onChange(o.value || undefined)}
className={cn(
"h-7 shrink-0 cursor-pointer rounded-full border border-transparent px-2.5 text-meta whitespace-nowrap text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
on && "pill-selected text-foreground",
)}
>
{o.label}
</button>
);
})}
</fieldset>
</div>
);
}
@@ -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<DiscoverItem, "cover" | "posterPath">): 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 (
<article className={cn("group flex min-w-0 flex-col", className)} aria-label={item.title}>
<button
type="button"
onClick={() => onAction("open")}
disabled={!!busy}
className="relative block aspect-[2/3] w-full cursor-pointer overflow-hidden rounded-xl border border-border/70 bg-foreground/[0.05] text-left transition-colors duration-base hover:border-foreground/40 disabled:cursor-wait"
>
{src ? (
<img
src={src}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
className="h-full w-full object-cover transition-transform duration-slow group-hover:scale-[1.03]"
/>
) : (
<span className="grid h-full w-full place-items-center px-2 text-center text-title font-semibold text-foreground/30">
{item.title.charAt(0)}
</span>
)}
{item.rating ? (
<span className="absolute top-1.5 left-1.5 inline-flex items-center gap-0.5 rounded-full bg-background/80 px-1.5 py-0.5 text-micro font-medium text-foreground backdrop-blur">
<Star className="h-3 w-3" aria-hidden />
{item.rating.toFixed(1)}
</span>
) : null}
{badges.length ? (
<span className="absolute inset-x-1.5 bottom-1.5 flex flex-wrap gap-1">
{badges.map((b) => (
<StatusBadge key={b.label} tone={b.tone} className="bg-background/85 backdrop-blur">
{b.label}
</StatusBadge>
))}
</span>
) : null}
{busy ? (
<span className="absolute inset-0 grid place-items-center bg-background/60">
<LoaderCircle className="h-5 w-5 animate-spin" aria-label="匹配中" />
</span>
) : null}
</button>
<div className="min-w-0 pt-1.5">
<button
type="button"
onClick={() => onAction("open")}
disabled={!!busy}
className="block w-full cursor-pointer truncate text-left text-caption font-medium text-foreground hover:underline"
title={item.title}
>
{item.title}
</button>
<p className="truncate text-meta text-muted-foreground">{meta || "—"}</p>
<div className="mt-1 flex flex-wrap gap-1">
<Button
variant="ghost"
size="xs"
onClick={() => onAction("subscribe")}
disabled={!!busy}
aria-label={`订阅「${item.title}」`}
>
<CalendarPlus />
订阅
</Button>
<Button
variant="ghost"
size="xs"
onClick={() => onAction("search")}
disabled={!!busy}
aria-label={`搜索「${item.title}」的资源`}
>
<PackageSearch />
搜索资源
</Button>
</div>
</div>
</article>
);
}
export function DiscoverCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col", className)} aria-hidden>
<div className="aspect-[2/3] w-full animate-pulse rounded-xl bg-foreground/[0.07]" />
<div className="mt-2 h-3.5 w-4/5 animate-pulse rounded-tight bg-foreground/[0.07]" />
<div className="mt-1.5 h-3 w-1/2 animate-pulse rounded-tight bg-foreground/[0.05]" />
</div>
);
}
@@ -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<string, string>): 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<Src, string> = {
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>): 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<ExploreState> = {};
for (const k of KEYS) {
const v = sp.get(k);
if (v) (raw as Record<string, string>)[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>): ExploreState {
if (p.src && p.src !== s.src) return normalize({ src: p.src });
const next: Partial<ExploreState> = { ...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<string, string> }
| { 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<string, string> = { 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<DiscoverItem, "local" | "tmdb">): 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<DiscoverItem, "local">): { 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<DiscoverItem, "year" | "date" | "kind" | "genres">): 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<ExploreState>;
}
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;
@@ -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> = {}): 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<typeof createElement>) =>
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 搜动画");
});
});
@@ -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<Src, string> = {
tmdb: "在 TMDB 搜作品",
douban: "在豆瓣搜作品",
bangumi: "在 Bangumi 搜动画",
};
export function ExploreView({ s, onPatch }: { s: ExploreState; onPatch: (p: Partial<ExploreState>) => void }) {
return (
<div className="space-y-5">
<ExploreFilters s={s} onPatch={onPatch} />
<ExploreResults key={JSON.stringify(apiQuery(s))} s={s} />
</div>
);
}
export function ExploreFilters({ s, onPatch }: { s: ExploreState; onPatch: (p: Partial<ExploreState>) => 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 (
<div className="space-y-2.5">
<div className="flex flex-wrap items-center gap-3">
<ChipRow label="来源" options={SOURCES} value={s.src} onChange={(v) => v && onPatch({ src: v as Src })} />
<form onSubmit={submit} className="relative ml-auto w-full sm:w-72">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={SEARCH_HINT[s.src]}
aria-label={SEARCH_HINT[s.src]}
className="pr-9 pl-9"
/>
{searching ? (
<button
type="button"
aria-label="清除搜索"
onClick={() => onPatch({ q: "" })}
className="absolute top-1/2 right-2 grid size-6 -translate-y-1/2 cursor-pointer place-items-center rounded-full text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
) : null}
</form>
</div>
{searching ? (
s.src === "tmdb" ? (
<ChipRow label="类型" options={TMDB_TYPES} value={s.type} onChange={(v) => v && onPatch({ type: v })} />
) : null
) : s.src === "tmdb" ? (
<TmdbFilters s={s} onPatch={onPatch} />
) : s.src === "douban" ? (
<DoubanFilters s={s} onPatch={onPatch} />
) : (
<BangumiFilters s={s} onPatch={onPatch} />
)}
</div>
);
}
type FiltersProps = { s: ExploreState; onPatch: (p: Partial<ExploreState>) => void };
const set = (onPatch: FiltersProps["onPatch"], k: keyof ExploreState) => (v: string | undefined) =>
onPatch({ [k]: v } as Partial<ExploreState>);
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 (
<>
<ChipRow label="类型" options={types} value={s.type} onChange={(v) => v && onPatch({ type: v })} />
<ChipRow
label="列表"
options={tmdbListOptions(s.type)}
value={s.list}
onChange={(v) => v && onPatch({ list: v })}
/>
{s.list === "trending" ? (
<ChipRow label="时间" options={WINDOWS} value={s.window} onChange={(v) => v && onPatch({ window: v })} />
) : null}
{s.list === "discover" ? (
<>
<ChipRow
label="排序"
options={TMDB_SORT_OPTIONS}
value={s.sort ?? "popularity"}
onChange={set(onPatch, "sort")}
/>
<ChipRow label="风格" options={genres} allLabel="不限" value={s.genre} onChange={set(onPatch, "genre")} />
<ChipRow
label="地区"
options={TMDB_REGION_OPTIONS}
allLabel="不限"
value={s.region}
onChange={set(onPatch, "region")}
/>
<ChipRow
label="语言"
options={TMDB_LANGUAGE_OPTIONS}
allLabel="不限"
value={s.language}
onChange={set(onPatch, "language")}
/>
<ChipRow
label="年份"
options={yearOptions("tmdb")}
allLabel="不限"
value={s.year}
onChange={set(onPatch, "year")}
/>
<ChipRow
label="评分"
options={RATING_OPTIONS}
allLabel="不限"
value={s.minRating}
onChange={set(onPatch, "minRating")}
/>
</>
) : null}
</>
);
}
function DoubanFilters({ s, onPatch }: FiltersProps) {
const free = s.list === "tag" || s.list === "explore";
return (
<>
<ChipRow label="列表" options={DOUBAN_LIST_OPTIONS} value={s.list} onChange={(v) => v && onPatch({ list: v })} />
{free ? (
<ChipRow label="类型" options={MEDIA_TYPES} value={s.type} onChange={(v) => v && onPatch({ type: v })} />
) : null}
{s.list === "tag" ? (
<ChipRow
label="标签"
options={DOUBAN_TAGS[s.type as "movie" | "tv"].map((t) => ({ value: t, label: t }))}
value={s.tag}
onChange={(v) => v && onPatch({ tag: v })}
/>
) : null}
{s.list === "explore" ? (
<>
<ChipRow label="排序" options={DOUBAN_SORT_OPTIONS} value={s.sort ?? "U"} onChange={set(onPatch, "sort")} />
<ChipRow
label="风格"
options={DOUBAN_GENRE_OPTIONS}
allLabel="不限"
value={s.genre}
onChange={set(onPatch, "genre")}
/>
<ChipRow
label="地区"
options={DOUBAN_REGION_OPTIONS}
allLabel="不限"
value={s.region}
onChange={set(onPatch, "region")}
/>
<ChipRow
label="年代"
options={yearOptions("douban")}
allLabel="不限"
value={s.year}
onChange={set(onPatch, "year")}
/>
</>
) : null}
</>
);
}
function BangumiFilters({ s, onPatch }: FiltersProps) {
return (
<>
<ChipRow label="列表" options={BANGUMI_LIST_OPTIONS} value={s.list} onChange={(v) => v && onPatch({ list: v })} />
{s.list === "calendar" ? (
<ChipRow label="星期" options={WEEKDAYS} value={s.weekday} onChange={(v) => v && onPatch({ weekday: v })} />
) : null}
{s.list === "rank" || s.list === "latest" ? (
<ChipRow
label="年份"
options={yearOptions("bangumi")}
allLabel="不限"
value={s.year}
onChange={set(onPatch, "year")}
/>
) : null}
</>
);
}
function ExploreResults({ s }: { s: ExploreState }) {
const q = useDiscoverInfinite(apiQuery(s));
const actions = useDiscoverActions();
const sentinel = useRef<HTMLDivElement>(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 (
<ul className={GRID} aria-busy>
{Array.from({ length: 12 }, (_, i) => (
<li key={i}>
<DiscoverCardSkeleton />
</li>
))}
</ul>
);
if (q.isError) return <ErrorState error={q.error} onRetry={() => void q.refetch()} />;
// 跨页去重(榜单翻页时条目可能挪位)
const seen = new Set<string>();
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 (
<EmptyState
title={s.list === "search" ? "没有搜到作品" : "没有条目"}
description={s.list === "search" ? "换个关键词,或换个来源再搜。" : "换个列表或放宽筛选条件。"}
/>
);
return (
<>
<ul className={GRID}>
{items.map((it) => (
<li key={`${it.source}:${it.id}`}>
<DiscoverCard item={it} busy={actions.busyOf(it)} onAction={(a) => void actions.act(it, a)} />
</li>
))}
</ul>
<div ref={sentinel} className="flex justify-center py-4">
{hasNextPage ? (
<Button variant="outline" size="sm" onClick={() => void fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
) : (
<span className="text-meta text-muted-foreground">共 {items.length} 个</span>
)}
</div>
{actions.dialogs}
</>
);
}
@@ -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<T extends Element>() {
const ref = useRef<T>(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 (
<div className="space-y-7">
<ChipRow label="分类" options={CATEGORIES.slice(1)} allLabel="全部" value={category} onChange={onCategory} />
{sectionsOf(category).map((s) => (
<Section key={s.key} section={s} actions={actions} />
))}
{actions.dialogs}
</div>
);
}
function Section({ section, actions }: { section: RecommendSection; actions: ReturnType<typeof useDiscoverActions> }) {
const [ref, near] = useNearViewport<HTMLElement>();
const state = normalize(section.explore);
const q = useDiscoverSection(apiQuery(state), near);
const items = q.data?.items ?? [];
const id = `discover-${section.key}`;
return (
<section ref={ref} aria-labelledby={id} className="min-w-0" data-section={section.key}>
<div className="mb-2 flex items-center justify-between gap-2">
<h2 id={id} className="text-body font-semibold text-foreground">
{section.title}
</h2>
<Button asChild variant="ghost" size="sm">
<Link to={`?${toSearchParams(state)}`}>
查看全部
<ChevronRight />
</Link>
</Button>
</div>
{q.isError ? (
<div className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-6 text-caption text-muted-foreground">
<span className="min-w-0 truncate">{errorMessage(q.error, "加载失败")}</span>
<Button variant="outline" size="sm" onClick={() => void q.refetch()}>
重试
</Button>
</div>
) : q.isSuccess && items.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-caption text-muted-foreground">
这里暂时没有条目。
</p>
) : (
<ul className="scroll-bleed flex gap-3 overflow-x-auto">
{q.isSuccess
? items.slice(0, 20).map((it) => (
<li key={`${it.source}:${it.id}`} className={CARD_W}>
<DiscoverCard item={it} busy={actions.busyOf(it)} onAction={(a) => void actions.act(it, a)} />
</li>
))
: Array.from({ length: 8 }, (_, i) => (
<li key={i} className={CARD_W}>
<DiscoverCardSkeleton />
</li>
))}
</ul>
)}
</section>
);
}
@@ -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<string, string> = { 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<Target | null>(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 ? (
<PickDialog
item={picking.item}
candidates={picking.candidates}
onPick={(c) => {
setPicking(null);
run(picking.action, { ...c, tmdbId: c.id, entityId: null });
}}
onClose={() => setPicking(null)}
/>
) : null}
{subscribing ? (
<SubscriptionFormDialog
initialTarget={
subscribing.entityId
? {
kind: "entity",
id: subscribing.entityId,
title: subscribing.title,
year: subscribing.year,
mediaKind: subscribing.type === "movie" ? "movie" : "series",
}
: {
kind: "tmdb",
entityType: subscribing.type,
externalId: subscribing.tmdbId,
title: subscribing.title,
year: subscribing.year,
mediaKind: subscribing.type === "movie" ? "movie" : "series",
}
}
onClose={() => 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 (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>选择 TMDB 作品</DialogTitle>
<DialogDescription>
「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。
</DialogDescription>
</DialogHeader>
{candidates.length ? (
<ul className="grid gap-2">
{candidates.map((c) => (
<li key={`${c.type}:${c.id}`}>
<button
type="button"
onClick={() => onPick(c)}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg border border-border/70 p-2 text-left hover:border-foreground/40 hover:bg-accent"
>
<span className="h-14 w-10 shrink-0 overflow-hidden rounded-tight bg-muted">
{c.posterPath ? (
<img
src={tmdbImageUrl(c.posterPath, "w92") ?? ""}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
) : null}
</span>
<span className="min-w-0">
<span className="block truncate text-caption font-medium">{c.title}</span>
<span className="text-meta text-muted-foreground">
{c.type === "movie" ? "电影" : "剧集"} · {c.year ?? "—"}
</span>
</span>
</button>
</li>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">TMDB 上没有找到相近的作品。</p>
)}
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("search")}?q=${encodeURIComponent(item.title)}`} onClick={onClose}>
<Search />
按名称搜索
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<a href={item.url} target="_blank" rel="noreferrer">
<ExternalLink />
{SOURCE_LABEL[item.source] ?? item.source}
</a>
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+34 -196
View File
@@ -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<string | null>(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 (
<>
<PageHeader title="发现" description="bgm.tv 每日放送与豆瓣热门;点开匹配到 TMDB 作品后可订阅或搜资源" />
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams({ view: v }), { replace: true })}>
<TabsList className="mb-4">
{VIEWS.map((v) => (
<TabsTrigger key={v.value} value={v.value}>
{v.label}
</TabsTrigger>
))}
<PageHeader title="发现" description="TMDB、豆瓣、Bangumi 的热门与榜单;点开作品可订阅或搜索资源" />
<Tabs
value={view}
onValueChange={(v) =>
setSp(v === "explore" ? toSearchParams(explore) : new URLSearchParams(category ? { cat: category } : {}))
}
>
<TabsList className="mb-5">
<TabsTrigger value="recommend">推荐</TabsTrigger>
<TabsTrigger value="explore">探索</TabsTrigger>
</TabsList>
</Tabs>
{view === "anime" && cal.data ? (
<div className="mb-4 flex flex-wrap gap-1.5">
{cal.data.days.map((d) => (
<Button
key={d.weekday}
size="sm"
variant={d.weekday === day ? "default" : "outline"}
onClick={() => setDay(d.weekday)}
>
{d.label}
<span className="text-micro opacity-70">{d.items.length}</span>
</Button>
))}
</div>
) : null}
{q.isLoading ? (
<LoadingState rows={4} />
) : q.error ? (
<ErrorState error={q.error} onRetry={() => void q.refetch()} />
) : items.length === 0 ? (
<EmptyState title="没有条目" />
{view === "recommend" ? (
<RecommendView
category={category}
onCategory={(c) => setSp(new URLSearchParams(c ? { cat: c } : {}), { replace: true })}
/>
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((it) => (
<li key={`${it.source}:${it.id}`}>
<ItemCard item={it} busy={busyId === `${it.source}:${it.id}`} onOpen={() => void open(it)} />
</li>
))}
</ul>
<ExploreView s={explore} onPatch={(p) => go(patch(explore, p))} />
)}
{picking ? <PickDialog {...picking} onClose={() => setPicking(null)} /> : null}
</>
);
}
function ItemCard({ item, busy, onOpen }: { item: DiscoverItem; busy: boolean; onOpen: () => void }) {
const [failed, setFailed] = useState(false);
return (
<button
type="button"
onClick={onOpen}
disabled={busy}
className="group flex w-full min-w-0 flex-col overflow-hidden rounded-lg border border-border/70 text-left transition-colors hover:border-foreground/40 disabled:opacity-70"
>
<div className="relative aspect-[2/3] w-full bg-muted">
{item.cover && !failed ? (
<img
src={item.cover}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
className="h-full w-full object-cover"
/>
) : null}
{busy ? (
<span className="absolute inset-0 grid place-items-center bg-background/60">
<LoaderCircle className="h-5 w-5 animate-spin" />
</span>
) : null}
</div>
<div className="min-w-0 px-2 py-1.5">
<p className="truncate text-caption font-medium text-foreground" title={item.title}>
{item.title}
</p>
<p className="flex items-center gap-2 text-meta text-muted-foreground">
<span>{item.date ?? item.year ?? "—"}</span>
{item.rating ? (
<span className="inline-flex items-center gap-0.5">
<Star className="h-3 w-3" />
{item.rating.toFixed(1)}
</span>
) : null}
</p>
</div>
</button>
);
}
function PickDialog({
item,
result,
onClose,
}: {
item: DiscoverItem;
result: DiscoverMatchResult;
onClose: () => void;
}) {
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>选择 TMDB 作品</DialogTitle>
<DialogDescription>
「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。
</DialogDescription>
</DialogHeader>
{result.candidates.length ? (
<ul className="grid gap-2">
{result.candidates.map((c) => (
<li key={`${c.type}:${c.id}`}>
<Link
to={consolePath("media", "tmdb", c.type, c.id)}
onClick={onClose}
className="flex items-center gap-3 rounded-lg border border-border/70 p-2 hover:border-foreground/40 hover:bg-accent"
>
<span className="h-14 w-10 shrink-0 overflow-hidden rounded-tight bg-muted">
{c.posterPath ? (
<img
src={tmdbImageUrl(c.posterPath, "w92") ?? ""}
alt=""
className="h-full w-full object-cover"
/>
) : null}
</span>
<span className="min-w-0">
<span className="block truncate text-caption font-medium">{c.title}</span>
<span className="text-meta text-muted-foreground">
{c.type === "movie" ? "电影" : "剧集"} · {c.year ?? "—"}
</span>
</span>
</Link>
</li>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">TMDB 上没有找到相近的作品。</p>
)}
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("search")}?q=${encodeURIComponent(item.title)}`} onClick={onClose}>
<Search />
按名称搜索
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<a href={item.url} target="_blank" rel="noreferrer">
<ExternalLink />
{item.source === "bangumi" ? "bgm.tv" : "豆瓣"}
</a>
</Button>
</div>
</DialogContent>
</Dialog>
);
}