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:
commit
b32d5c028a
29 files changed
+3404
-329
No files matched your search
@@ -1,17 +1,52 @@
|
|||||||
import type { MetadataCandidate } from "@umt/adapters";
|
import {
|
||||||
import { type DiscoverMatchRequest, DiscoverMatchResult } from "@umt/contracts";
|
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 { pgCacheFor } from "@umt/db";
|
||||||
import { type IdentityCandidate, parseRelease, resolveIdentity } from "@umt/domain";
|
import { type IdentityCandidate, parseRelease, resolveIdentity } from "@umt/domain";
|
||||||
import { ApiException } from "../../http/errors.ts";
|
import { ApiException } from "../../http/errors.ts";
|
||||||
|
import { runSandbox } from "../../sandbox/client.ts";
|
||||||
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||||
import { subjectRecall } from "../sources-identity/service/id-escalation.ts";
|
import { subjectRecall } from "../sources-identity/service/id-escalation.ts";
|
||||||
import { metaClients, toCached } from "../sources-identity/service/meta-subjects.ts";
|
import { metaClients, toCached } from "../sources-identity/service/meta-subjects.ts";
|
||||||
import { tmdbToCandidate } from "../sources-identity/service/tmdb-candidate.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";
|
/** 豆瓣 / bgm 条目 → TMDB 作品的确定匹配(点开时写入,列表据此标本地状态) */
|
||||||
const TTL_SEC = 3600;
|
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) {
|
function clients(rt: SourcesRuntime) {
|
||||||
const c = metaClients(rt);
|
const c = metaClients(rt);
|
||||||
@@ -19,29 +54,201 @@ function clients(rt: SourcesRuntime) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cached<T>(rt: SourcesRuntime, key: string, load: () => Promise<T>): Promise<T> {
|
async function upstream<T>(what: string, fn: () => Promise<T>): Promise<T> {
|
||||||
const cache = pgCacheFor(rt.ctx.db);
|
|
||||||
const hit = await cache.get<T>(REGION, key).catch(() => null);
|
|
||||||
if (hit) return hit;
|
|
||||||
let v: T;
|
|
||||||
try {
|
try {
|
||||||
v = await load();
|
return await fn();
|
||||||
} catch (e) {
|
} 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) {
|
const fromMeta = (s: MetaSubject): DiscoverItem => ({
|
||||||
return cached(rt, "bangumi:calendar", async () => ({ days: await clients(rt).bangumi.calendar() }));
|
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") {
|
// ── TMDB ──
|
||||||
return cached(rt, `douban:hot:${kind}`, async () => ({ items: await clients(rt).douban.hot(kind, 30) }));
|
|
||||||
|
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",
|
type: m.entityType as "movie" | "tv",
|
||||||
id: m.externalId,
|
id: m.externalId,
|
||||||
title: m.title,
|
title: m.title,
|
||||||
@@ -50,6 +257,10 @@ const view = (m: MetadataCandidate) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function matchTmdb(rt: SourcesRuntime, req: DiscoverMatchRequest): Promise<DiscoverMatchResult> {
|
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 subject = toCached({ ...req, cover: null, rating: null, date: null, episodes: null, url: "" }, rt.now());
|
||||||
const base = parseRelease(req.title);
|
const base = parseRelease(req.title);
|
||||||
if (req.kind === "tv") base.mediaTypeHint = "tv";
|
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) ?? [])
|
.flatMap((r) => byCand.get(r.candidate) ?? [])
|
||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
const best = res.status === "auto" && res.best ? byCand.get(res.best.candidate) : undefined;
|
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) });
|
||||||
}
|
}
|
||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
type ApiScope,
|
type ApiScope,
|
||||||
apiResponse,
|
apiResponse,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
type DiscoverHotQuery,
|
type DiscoverBangumiQuery,
|
||||||
|
type DiscoverDoubanQuery,
|
||||||
type DiscoverMatchRequest,
|
type DiscoverMatchRequest,
|
||||||
|
type DiscoverTmdbQuery,
|
||||||
type EndpointDef,
|
type EndpointDef,
|
||||||
type InventoryEntry,
|
type InventoryEntry,
|
||||||
type InventoryResponse,
|
type InventoryResponse,
|
||||||
@@ -40,7 +42,7 @@ import { metadataLanguages, metadataTextOptions } from "../sources-identity/serv
|
|||||||
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
||||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||||
import { matchActions } from "./actions.ts";
|
import { matchActions } from "./actions.ts";
|
||||||
import { bangumiCalendar, doubanHot, matchTmdb } from "./discover.ts";
|
import { annotate, bangumiCalendar, bangumiList, doubanList, matchTmdb, tmdbList } from "./discover.ts";
|
||||||
import { InternalDispatcher } from "./dispatch.ts";
|
import { InternalDispatcher } from "./dispatch.ts";
|
||||||
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
|
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
|
||||||
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts";
|
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts";
|
||||||
@@ -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.discoverCalendar, ({ c }) => bangumiCalendar(d.runtime(), inventory(c)));
|
||||||
route(app, Api.search.discoverHot, ({ query }) => doubanHot(d.runtime(), (query as DiscoverHotQuery).kind));
|
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.discoverMatch, ({ body }) => matchTmdb(d.runtime(), body as DiscoverMatchRequest));
|
||||||
|
|
||||||
route(app, Api.search.tmdbDetail, async ({ params, query, c }) => {
|
route(app, Api.search.tmdbDetail, async ({ params, query, c }) => {
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import {
|
|||||||
createBangumiClient,
|
createBangumiClient,
|
||||||
createDoubanClient,
|
createDoubanClient,
|
||||||
type DoubanClient,
|
type DoubanClient,
|
||||||
|
type MetaRequestCache,
|
||||||
type MetaSubject,
|
type MetaSubject,
|
||||||
|
RequestLimiter,
|
||||||
} from "@umt/adapters";
|
} from "@umt/adapters";
|
||||||
import { pgCacheFor } from "@umt/db";
|
import { cachedCall, pgCacheFor } from "@umt/db";
|
||||||
import { normalizeTitle, toSimplified } from "@umt/domain";
|
import { normalizeTitle, toSimplified } from "@umt/domain";
|
||||||
|
import { storageSettings } from "../../../shared-cache.ts";
|
||||||
import type { SourcesRuntime } from "../runtime.ts";
|
import type { SourcesRuntime } from "../runtime.ts";
|
||||||
import { DOUBAN_SUBJECT_REGION, type DoubanSubject, loadDoubanSubject, saveDoubanSubject } from "./douban-subject.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";
|
export type MetaSource = "douban" | "bangumi";
|
||||||
const REGION: Record<MetaSource, string> = { douban: DOUBAN_SUBJECT_REGION, bangumi: BANGUMI_SUBJECT_REGION };
|
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 {
|
export function metaClients(rt: SourcesRuntime): { douban: DoubanClient; bangumi: BangumiClient } | null {
|
||||||
const fetch = rt.metaFetch();
|
const fetch = rt.metaFetch();
|
||||||
if (!fetch) return null;
|
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 {
|
export function toCached(s: MetaSubject, now: Date): DoubanSubject {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 沙箱任务表(docs/adr/ADR-005-sandbox.md):只放「外部不可信字节 → 结构化数据」的纯函数。
|
// 沙箱任务表(docs/adr/ADR-005-sandbox.md):只放「外部不可信字节 → 结构化数据」的纯函数。
|
||||||
// 子进程加载完本表后撤销全部权限(文件 / 网络 / 环境变量 / 子进程),所以任务里不能有任何 I/O;
|
// 子进程加载完本表后撤销全部权限(文件 / 网络 / 环境变量 / 子进程),所以任务里不能有任何 I/O;
|
||||||
// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。
|
// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。
|
||||||
import { parseTorrent } from "@umt/adapters";
|
import { parseBangumiBrowser, parseTorrent } from "@umt/adapters";
|
||||||
import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain";
|
import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain";
|
||||||
import { buildPreview } from "../modules/file-manager/preview-build.ts";
|
import { buildPreview } from "../modules/file-manager/preview-build.ts";
|
||||||
import { PAGE_PARSERS, type PageKind } from "../modules/subtitles/online/parse.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);
|
return fn(input.html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** bgm.tv 动画浏览页(热门趋势,发现页)→ 条目列表 */
|
||||||
|
function bangumiTrends(input: { html: string }) {
|
||||||
|
return parseBangumiBrowser(input.html);
|
||||||
|
}
|
||||||
|
|
||||||
export const SANDBOX_JOBS = {
|
export const SANDBOX_JOBS = {
|
||||||
"subtitle.parse": subtitleParse,
|
"subtitle.parse": subtitleParse,
|
||||||
"torrent.parse": torrentParse,
|
"torrent.parse": torrentParse,
|
||||||
"file.preview": buildPreview,
|
"file.preview": buildPreview,
|
||||||
"captcha.solve": captchaSolve,
|
"captcha.solve": captchaSolve,
|
||||||
"subtitle.page": subtitlePage,
|
"subtitle.page": subtitlePage,
|
||||||
|
"bangumi.trends": bangumiTrends,
|
||||||
"self.test": selfTest,
|
"self.test": selfTest,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,59 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { DiscoverCalendar, type DiscoverItem, DiscoverList, DiscoverMatchResult } from "@umt/contracts";
|
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 { api } from "~/lib/api.ts";
|
||||||
import { getParsed, parseOrThrow } from "./library.ts";
|
import { getParsed, parseOrThrow } from "./library.ts";
|
||||||
|
|
||||||
// 发现:bgm.tv 每日放送、豆瓣热门(服务端缓存 1 小时);点条目时匹配 TMDB 作品。
|
// 发现:TMDB / 豆瓣 / bgm 列表(服务端走请求缓存);点豆瓣 / bgm 条目时匹配 TMDB 作品。
|
||||||
|
|
||||||
|
const STALE = 10 * 60_000;
|
||||||
|
|
||||||
export const discoverKeys = {
|
export const discoverKeys = {
|
||||||
calendar: ["discover", "calendar"] as const,
|
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) {
|
export function useBangumiCalendar(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: discoverKeys.calendar,
|
queryKey: discoverKeys.calendar,
|
||||||
enabled,
|
enabled,
|
||||||
queryFn: ({ signal }) => getParsed(DiscoverCalendar, "/discover/bangumi-calendar", {}, signal),
|
queryFn: ({ signal }) => fetchCalendar(signal),
|
||||||
staleTime: 30 * 60_000,
|
staleTime: STALE,
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,215 +1,53 @@
|
|||||||
import { type DiscoverItem, type DiscoverMatchResult, tmdbImageUrl } from "@umt/contracts";
|
import { useSearchParams } from "react-router";
|
||||||
import { ExternalLink, LoaderCircle, Search, Star } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useBangumiCalendar, useDiscoverMatch, useDoubanHot } from "~/api/discover.ts";
|
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
|
||||||
import { consolePath } from "~/components/console/nav.ts";
|
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import {
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
|
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 { 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 媒体自动化控制台" }];
|
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() {
|
export default function DiscoverPage() {
|
||||||
const [sp, setSp] = useSearchParams();
|
const [sp, setSp] = useSearchParams();
|
||||||
const view = (VIEWS.some((v) => v.value === sp.get("view")) ? sp.get("view") : "anime") as View;
|
const view: View = sp.get("view") === "explore" || sp.has("src") ? "explore" : "recommend";
|
||||||
const [day, setDay] = useState(todayWeekday);
|
const explore = fromSearchParams(sp);
|
||||||
const cal = useBangumiCalendar(view === "anime");
|
const category = sp.get("cat") ?? "";
|
||||||
const hot = useDoubanHot(view === "tv" ? "tv" : "movie", view !== "anime");
|
|
||||||
const match = useDiscoverMatch();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [picking, setPicking] = useState<{ item: DiscoverItem; result: DiscoverMatchResult } | null>(null);
|
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const open = async (it: DiscoverItem) => {
|
const go = (next: ExploreState) => setSp(toSearchParams(next), { replace: false });
|
||||||
setBusyId(`${it.source}:${it.id}`);
|
|
||||||
try {
|
|
||||||
const r = await match.mutateAsync(it);
|
|
||||||
if (r.match) navigate(consolePath("media", "tmdb", r.match.type, r.match.id));
|
|
||||||
else setPicking({ item: it, result: r });
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(errorMessage(err, "匹配 TMDB 失败"));
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const q = view === "anime" ? cal : hot;
|
|
||||||
const items =
|
|
||||||
view === "anime" ? (cal.data?.days.find((d) => d.weekday === day)?.items ?? []) : (hot.data?.items ?? []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="发现" description="bgm.tv 每日放送与豆瓣热门;点开匹配到 TMDB 作品后可订阅或搜资源" />
|
<PageHeader title="发现" description="TMDB、豆瓣、Bangumi 的热门与榜单;点开作品可订阅或搜索资源" />
|
||||||
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams({ view: v }), { replace: true })}>
|
<Tabs
|
||||||
<TabsList className="mb-4">
|
value={view}
|
||||||
{VIEWS.map((v) => (
|
onValueChange={(v) =>
|
||||||
<TabsTrigger key={v.value} value={v.value}>
|
setSp(v === "explore" ? toSearchParams(explore) : new URLSearchParams(category ? { cat: category } : {}))
|
||||||
{v.label}
|
}
|
||||||
</TabsTrigger>
|
>
|
||||||
))}
|
<TabsList className="mb-5">
|
||||||
|
<TabsTrigger value="recommend">推荐</TabsTrigger>
|
||||||
|
<TabsTrigger value="explore">探索</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
{view === "anime" && cal.data ? (
|
{view === "recommend" ? (
|
||||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
<RecommendView
|
||||||
{cal.data.days.map((d) => (
|
category={category}
|
||||||
<Button
|
onCategory={(c) => setSp(new URLSearchParams(c ? { cat: c } : {}), { replace: true })}
|
||||||
key={d.weekday}
|
/>
|
||||||
size="sm"
|
|
||||||
variant={d.weekday === day ? "default" : "outline"}
|
|
||||||
onClick={() => setDay(d.weekday)}
|
|
||||||
>
|
|
||||||
{d.label}
|
|
||||||
<span className="text-micro opacity-70">{d.items.length}</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{q.isLoading ? (
|
|
||||||
<LoadingState rows={4} />
|
|
||||||
) : q.error ? (
|
|
||||||
<ErrorState error={q.error} onRetry={() => void q.refetch()} />
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<EmptyState title="没有条目" />
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
<ExploreView s={explore} onPatch={(p) => go(patch(explore, p))} />
|
||||||
{items.map((it) => (
|
|
||||||
<li key={`${it.source}:${it.id}`}>
|
|
||||||
<ItemCard item={it} busy={busyId === `${it.source}:${it.id}`} onOpen={() => void open(it)} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
{picking ? <PickDialog {...picking} onClose={() => setPicking(null)} /> : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemCard({ item, busy, onOpen }: { item: DiscoverItem; busy: boolean; onOpen: () => void }) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpen}
|
|
||||||
disabled={busy}
|
|
||||||
className="group flex w-full min-w-0 flex-col overflow-hidden rounded-lg border border-border/70 text-left transition-colors hover:border-foreground/40 disabled:opacity-70"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-[2/3] w-full bg-muted">
|
|
||||||
{item.cover && !failed ? (
|
|
||||||
<img
|
|
||||||
src={item.cover}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{busy ? (
|
|
||||||
<span className="absolute inset-0 grid place-items-center bg-background/60">
|
|
||||||
<LoaderCircle className="h-5 w-5 animate-spin" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 px-2 py-1.5">
|
|
||||||
<p className="truncate text-caption font-medium text-foreground" title={item.title}>
|
|
||||||
{item.title}
|
|
||||||
</p>
|
|
||||||
<p className="flex items-center gap-2 text-meta text-muted-foreground">
|
|
||||||
<span>{item.date ?? item.year ?? "—"}</span>
|
|
||||||
{item.rating ? (
|
|
||||||
<span className="inline-flex items-center gap-0.5">
|
|
||||||
<Star className="h-3 w-3" />
|
|
||||||
{item.rating.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PickDialog({
|
|
||||||
item,
|
|
||||||
result,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
item: DiscoverItem;
|
|
||||||
result: DiscoverMatchResult;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>选择 TMDB 作品</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{result.candidates.length ? (
|
|
||||||
<ul className="grid gap-2">
|
|
||||||
{result.candidates.map((c) => (
|
|
||||||
<li key={`${c.type}:${c.id}`}>
|
|
||||||
<Link
|
|
||||||
to={consolePath("media", "tmdb", c.type, c.id)}
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex items-center gap-3 rounded-lg border border-border/70 p-2 hover:border-foreground/40 hover:bg-accent"
|
|
||||||
>
|
|
||||||
<span className="h-14 w-10 shrink-0 overflow-hidden rounded-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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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/<api>`;顶部按「全部 / 电影 / 剧集 / 动漫 / 音乐 / 榜单」筛分区;分区可开关(存用户配置) | 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`),设置页界面由设置线决定是否展示。
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
* 辅助信息源(对照 MP modules/bangumi、modules/douban):只读公开接口,结果只用来"拿名称 + 年份去匹配 TMDB"与发现页展示,
|
* 辅助信息源(对照 MP modules/bangumi、modules/douban):只读公开接口,结果只用来"拿名称 + 年份去匹配 TMDB"与发现页展示,
|
||||||
* 身份仍以 TMDB 为准。
|
* 身份仍以 TMDB 为准。
|
||||||
* - bgm.tv:官方 API(api.bgm.tv),条目 / 搜索 / 每日放送,不需要登录;要求带可识别的 User-Agent
|
* - 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 {
|
export interface MetaSubject {
|
||||||
source: "bangumi" | "douban";
|
source: "bangumi" | "douban";
|
||||||
@@ -21,13 +25,36 @@ export interface MetaSubject {
|
|||||||
date: string | null;
|
date: string | null;
|
||||||
episodes: number | null;
|
episodes: number | null;
|
||||||
url: string;
|
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<T>(
|
||||||
|
req: { source: "bangumi" | "douban"; key: string; kind: MetaRequestKind; empty: (v: T) => boolean },
|
||||||
|
load: () => Promise<T>,
|
||||||
|
): Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetaClientOptions {
|
export interface MetaClientOptions {
|
||||||
fetch?: typeof globalThis.fetch;
|
fetch?: typeof globalThis.fetch;
|
||||||
/** 缺省 15s */
|
/** 缺省 15s */
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
/** API 地址(bgm api.bgm.tv / 豆瓣 rexxar) */
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
/** 网页地址(bgm.tv / movie.douban.com) */
|
||||||
|
webBaseUrl?: string;
|
||||||
|
/** 请求缓存;不给就不缓存 */
|
||||||
|
cache?: MetaRequestCache;
|
||||||
|
/** 限流(每源最小间隔);只包住真正发出的网络请求,缓存命中不占额度 */
|
||||||
|
limiter?: { run<T>(fn: () => Promise<T>): Promise<T> };
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MetaSourceError extends Error {
|
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)),
|
...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<Response> {
|
||||||
|
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(
|
async function getJson(
|
||||||
source: "bangumi" | "douban",
|
source: "bangumi" | "douban",
|
||||||
url: string,
|
url: string,
|
||||||
o: MetaClientOptions,
|
o: MetaClientOptions,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const f = o.fetch ?? globalThis.fetch;
|
const res = await fetchOk(source, url, o, init);
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await f(url, { ...init, signal: AbortSignal.timeout(o.timeoutMs ?? 15_000) });
|
|
||||||
} catch (e) {
|
|
||||||
throw new MetaSourceError(source, "http", `请求 ${new URL(url).host} 失败:${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
if (res.status === 404) throw new MetaSourceError(source, "not_found", "条目不存在");
|
|
||||||
if (!res.ok) throw new MetaSourceError(source, "http", `${new URL(url).host} 返回 HTTP ${res.status}`);
|
|
||||||
try {
|
try {
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch {
|
} catch {
|
||||||
|
// 风控 / 验证页是 HTML:按解析失败抛出,不缓存
|
||||||
throw new MetaSourceError(source, "parse", `${new URL(url).host} 返回的不是 JSON`);
|
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<T>(
|
||||||
|
o: MetaClientOptions,
|
||||||
|
source: "bangumi" | "douban",
|
||||||
|
kind: MetaRequestKind,
|
||||||
|
key: string,
|
||||||
|
empty: (v: T) => boolean,
|
||||||
|
load: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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 ──
|
// ── bgm.tv ──
|
||||||
|
|
||||||
export const BANGUMI_API = "https://api.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 = {}) {
|
export function createBangumiClient(o: MetaClientOptions = {}) {
|
||||||
const base = (o.baseUrl ?? BANGUMI_API).replace(/\/+$/, "");
|
const base = (o.baseUrl ?? BANGUMI_API).replace(/\/+$/, "");
|
||||||
|
const web = (o.webBaseUrl ?? BANGUMI_WEB).replace(/\/+$/, "");
|
||||||
const headers = { "User-Agent": BGM_UA, Accept: "application/json" };
|
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 {
|
return {
|
||||||
async subject(id: string): Promise<MetaSubject> {
|
async subject(id: string): Promise<MetaSubject> {
|
||||||
if (!/^\d+$/.test(id)) throw new MetaSourceError("bangumi", "not_found", "bgm 条目 ID 非法");
|
if (!/^\d+$/.test(id)) throw new MetaSourceError("bangumi", "not_found", "bgm 条目 ID 非法");
|
||||||
@@ -127,17 +258,16 @@ export function createBangumiClient(o: MetaClientOptions = {}) {
|
|||||||
},
|
},
|
||||||
/** 动画条目搜索(type 2) */
|
/** 动画条目搜索(type 2) */
|
||||||
async search(keyword: string, limit = 10): Promise<MetaSubject[]> {
|
async search(keyword: string, limit = 10): Promise<MetaSubject[]> {
|
||||||
const r = await getJson("bangumi", `${base}/v0/search/subjects?limit=${limit}`, o, {
|
return (await searchPage(keyword, limit)).items;
|
||||||
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) ?? []);
|
|
||||||
},
|
},
|
||||||
|
/** 动画条目搜索,带分页与总数(发现页) */
|
||||||
|
searchPage,
|
||||||
/** 每日放送:周一到周日(weekday 1-7) */
|
/** 每日放送:周一到周日(weekday 1-7) */
|
||||||
async calendar(): Promise<{ weekday: number; label: string; items: MetaSubject[] }[]> {
|
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 每日放送格式不对");
|
if (!Array.isArray(r)) throw new MetaSourceError("bangumi", "parse", "bgm 每日放送格式不对");
|
||||||
return (r as Raw[]).map((d) => {
|
return (r as Raw[]).map((d) => {
|
||||||
const wd = isObj(d.weekday) ? d.weekday : {};
|
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<string> {
|
||||||
|
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<typeof createBangumiClient>;
|
export type BangumiClient = ReturnType<typeof createBangumiClient>;
|
||||||
@@ -172,6 +330,11 @@ export function doubanSubject(r: Raw): MetaSubject | null {
|
|||||||
const pub = Array.isArray(r.pubdate) ? str(r.pubdate[0]) : 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 date = /\d{4}-\d{2}-\d{2}/.exec(pub ?? "")?.[0] ?? null;
|
||||||
const type = str(r.type) ?? str(r.subtype);
|
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 {
|
return {
|
||||||
source: "douban",
|
source: "douban",
|
||||||
id,
|
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),
|
aliases: uniq([str(r.original_title), ...(Array.isArray(r.aka) ? r.aka.map(str) : [])], title).slice(0, 10),
|
||||||
year: yearOf(r.year) ?? yearOf(date),
|
year: yearOf(r.year) ?? yearOf(date),
|
||||||
kind: type === "tv" || r.is_tv === true ? "tv" : type === "movie" ? "movie" : null,
|
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,
|
rating: rating || null,
|
||||||
date,
|
date,
|
||||||
episodes: numOf(r.episodes_count) || null,
|
episodes: numOf(r.episodes_count) || null,
|
||||||
url: `https://movie.douban.com/subject/${id}/`,
|
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",
|
tv: "tv_real_time_hotest",
|
||||||
} as const;
|
} 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 = {}) {
|
export function createDoubanClient(o: MetaClientOptions = {}) {
|
||||||
const base = (o.baseUrl ?? DOUBAN_API).replace(/\/+$/, "");
|
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 });
|
const get = (path: string) => getJson("douban", `${base}${path}`, o, { headers: DOUBAN_HEADERS });
|
||||||
return {
|
const cachedGet = (kind: MetaRequestKind, url: string, field: string, headers: Record<string, string>) =>
|
||||||
|
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<MetaSubject> {
|
async subject(id: string, kind?: "movie" | "tv" | null): Promise<MetaSubject> {
|
||||||
if (!/^\d+$/.test(id)) throw new MetaSourceError("douban", "not_found", "豆瓣条目 ID 非法");
|
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", "豆瓣条目不存在");
|
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<MetaSubject[]> {
|
async hot(kind: "movie" | "tv", count = 30): Promise<MetaSubject[]> {
|
||||||
const r = await get(`/subject_collection/${DOUBAN_HOT[kind]}/items?start=0&count=${count}`);
|
return (await client.collection(DOUBAN_HOT[kind], 0, count)).items;
|
||||||
const rows = isObj(r) && Array.isArray(r.subject_collection_items) ? (r.subject_collection_items as Raw[]) : null;
|
},
|
||||||
if (!rows) throw new MetaSourceError("douban", "parse", "豆瓣榜单格式不对");
|
/** 标签列表(桌面版 j/search_subjects,tag 如 热门 / 最新 / 豆瓣高分 / 美剧) */
|
||||||
return rows.flatMap((x) => doubanSubject(x) ?? []);
|
async tagSubjects(kind: "movie" | "tv", tag: string, start = 0, count = 20): Promise<MetaSubject[]> {
|
||||||
|
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<MetaSubject[]> {
|
||||||
|
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<typeof createDoubanClient>;
|
export type DoubanClient = ReturnType<typeof createDoubanClient>;
|
||||||
@@ -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 = `<html><body>
|
||||||
|
<ul id="browserItemList" class="browserFull">
|
||||||
|
<li id="item_400602" class="item odd clearit">
|
||||||
|
<a href="/subject/400602" class="subjectCover cover ll"><span class="image"><img src="//lain.bgm.tv/r/400/pic/cover/c/13/c5/400602_ZI8Y9.jpg" class="cover" /></span></a>
|
||||||
|
<div class="inner">
|
||||||
|
<h3><span class="ico_subject_type subject_type_2 ll"></span><a href="/subject/400602" class="l">葬送的芙莉莲</a> <small class="grey">葬送のフリーレン</small></h3>
|
||||||
|
<p class="info tip">
|
||||||
|
28话 / 2023年9月29日 / 斎藤圭一郎 / 山田鐘人
|
||||||
|
</p>
|
||||||
|
<p class="rateInfo"><span class="starstop-s"><span class="starlight stars9"></span></span> <small class="fade">8.9</small> <span class="tip_j">(25627人评分)</span></p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li id="item_9" class="item even clearit">
|
||||||
|
<div class="inner"><h3><a href="/subject/9" class="l">剧场版 某作品</a></h3><p class="info tip">剧场版 / 2024-07-01</p></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div id="multipage"><div class="page_inner"><strong class="p_cur">1</strong><a href="?sort=trends&page=2" class="p">2</a><a href="?sort=trends&page=2" class="p">››</a></div></div>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
function memoryCache() {
|
||||||
|
const store = new Map<string, unknown>();
|
||||||
|
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("<ul id='browserItemList'></ul>")).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<T>(fn: () => Promise<T>) {
|
||||||
|
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: "<html>请登录</html>" },
|
||||||
|
"GET /rexxar/api/v2/movie/35267208": { body: DOUBAN_ITEM },
|
||||||
|
"GET https://bgm.tv/anime/browser": { body: "<html>验证</html>" },
|
||||||
|
});
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -102,8 +102,10 @@ export interface TmdbConfig {
|
|||||||
/**
|
/**
|
||||||
* 默认 max 1024、ttl 24h;空结果(搜索 0 条)用 emptyTtlMs,默认 30min(对照 MP EMPTY_RESULT_CACHE_TTL);
|
* 默认 max 1024、ttl 24h;空结果(搜索 0 条)用 emptyTtlMs,默认 30min(对照 MP EMPTY_RESULT_CACHE_TTL);
|
||||||
* searchTtlMs:/search/、/find/ 请求的 TTL(缺省同 ttlMs),其余(详情 / 季)用 ttlMs。
|
* 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。
|
* 跨进程共享的请求缓存(服务端传 PG 缓存);给了就不再用进程内 LRU。
|
||||||
* 语义对照 MP @cached(skip_none、业务失败不缓存、results 为空按 emptyTtlMs)。
|
* 语义对照 MP @cached(skip_none、业务失败不缓存、results 为空按 emptyTtlMs)。
|
||||||
@@ -135,6 +137,11 @@ export function tmdbRequestKey(path: string, params: Record<string, string | und
|
|||||||
return `${path}?${requestQuery(params)}`;
|
return `${path}?${requestQuery(params)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 列表类请求(发现页):流行趋势、按条件筛选、电影 / 剧集榜单 */
|
||||||
|
export const isTmdbListPath = (path: string) =>
|
||||||
|
/^\/(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) */
|
/** 空结果(搜索 0 条)用短 TTL(对照 MP EMPTY_RESULT_CACHE_TTL) */
|
||||||
const isEmptyResult = (v: unknown) =>
|
const isEmptyResult = (v: unknown) =>
|
||||||
Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0;
|
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<T>(path: string, params: Record<string, string | undefined>): Promise<T> {
|
private async get<T>(path: string, params: Record<string, string | undefined>): Promise<T> {
|
||||||
const qs = requestQuery(params);
|
const qs = requestQuery(params);
|
||||||
const key = `${path}?${qs}`;
|
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 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) {
|
if (this.cfg.sharedCache) {
|
||||||
const shared = this.cfg.sharedCache;
|
const shared = this.cfg.sharedCache;
|
||||||
const hit = await shared.get(key);
|
const hit = await shared.get(key);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * from "./client.ts";
|
|||||||
export * from "./details.ts";
|
export * from "./details.ts";
|
||||||
export * from "./episode-groups.ts";
|
export * from "./episode-groups.ts";
|
||||||
export * from "./language-policy.ts";
|
export * from "./language-policy.ts";
|
||||||
|
export * from "./lists.ts";
|
||||||
export * from "./match.ts";
|
export * from "./match.ts";
|
||||||
export * from "./recall.ts";
|
export * from "./recall.ts";
|
||||||
export * from "./translations.ts";
|
export * from "./translations.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<TmdbConfig> = {}) {
|
||||||
|
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<string, unknown>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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<TmdbListPage> {
|
||||||
|
const j = await client.getJson<Json>(`/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<TmdbListPage> {
|
||||||
|
const j = await client.getJson<Json>(`/${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<string, string | undefined> {
|
||||||
|
const movie = f.type === "movie";
|
||||||
|
const dateKey = movie ? "primary_release_date" : "first_air_date";
|
||||||
|
const sort = f.sort ?? "popularity";
|
||||||
|
const p: Record<string, string | undefined> = {
|
||||||
|
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<TmdbListPage> {
|
||||||
|
const j = await client.getJson<Json>(`/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<TmdbListPage> {
|
||||||
|
const path = o.type === "all" ? "/search/multi" : `/search/${o.type}`;
|
||||||
|
const j = await client.getJson<Json>(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);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export {
|
|||||||
ReleaseIdentityResult,
|
ReleaseIdentityResult,
|
||||||
TaskAccepted,
|
TaskAccepted,
|
||||||
} from "./api/shared.ts";
|
} from "./api/shared.ts";
|
||||||
|
export * from "./discover.ts";
|
||||||
export * from "./search.ts";
|
export * from "./search.ts";
|
||||||
|
|
||||||
// 所有 REST 端点的路径常量与请求/响应 schema 汇总,按领域分在 ./api/*.ts。
|
// 所有 REST 端点的路径常量与请求/响应 schema 汇总,按领域分在 ./api/*.ts。
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { MetadataEnrichOutput } from "../capabilities-io.ts";
|
import { MetadataEnrichOutput } from "../capabilities-io.ts";
|
||||||
import { cursorPage, IdParam } from "../common.ts";
|
import { cursorPage, IdParam } from "../common.ts";
|
||||||
|
import * as discover from "../discover.ts";
|
||||||
import * as execution from "../execution.ts";
|
import * as execution from "../execution.ts";
|
||||||
import * as identity from "../identity.ts";
|
import * as identity from "../identity.ts";
|
||||||
import * as libraryVersions from "../library-versions.ts";
|
import * as libraryVersions from "../library-versions.ts";
|
||||||
@@ -211,24 +212,41 @@ export const libraryApi = {
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/discover/bangumi-calendar",
|
path: "/discover/bangumi-calendar",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
summary: "bgm.tv 每日放送(周一到周日;缓存 1 小时)",
|
summary: "bgm.tv 每日放送(周一到周日;请求缓存 6 小时;带本地状态)",
|
||||||
response: search.DiscoverCalendar,
|
response: discover.DiscoverCalendar,
|
||||||
}),
|
}),
|
||||||
discoverHot: ep({
|
discoverTmdb: ep({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/discover/douban-hot",
|
path: "/discover/tmdb",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
summary: "豆瓣实时热门电影 / 剧集(缓存 1 小时)",
|
summary:
|
||||||
query: search.DiscoverHotQuery,
|
"TMDB 发现:流行趋势、热门、正在上映 / 播出、即将上映、高分、按条件筛选、搜索(走 TMDB 请求缓存;带本地状态)",
|
||||||
response: search.DiscoverList,
|
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({
|
discoverMatch: ep({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/discover/tmdb-match",
|
path: "/discover/tmdb-match",
|
||||||
scope: "read",
|
scope: "read",
|
||||||
summary: "豆瓣 / bgm 条目 → TMDB 作品(名称 + 年份匹配,对照 MP 投影);确定的给 match,否则给候选",
|
summary: "豆瓣 / bgm 条目 → TMDB 作品(名称 + 年份匹配,对照 MP 投影);确定的给 match 并记住,否则给候选",
|
||||||
body: search.DiscoverMatchRequest,
|
body: discover.DiscoverMatchRequest,
|
||||||
response: search.DiscoverMatchResult,
|
response: discover.DiscoverMatchResult,
|
||||||
}),
|
}),
|
||||||
tmdbSeason: ep({
|
tmdbSeason: ep({
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|||||||
@@ -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<typeof DiscoverSource>;
|
||||||
|
|
||||||
|
/** TMDB 风格(genre id → 中文名,对照 MP TheMovieDbView 的字典;不另请求 /genre/list) */
|
||||||
|
export const TMDB_MOVIE_GENRES: Record<string, string> = {
|
||||||
|
"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<string, string> = {
|
||||||
|
"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<string, string> = {
|
||||||
|
zh: "中文",
|
||||||
|
en: "英语",
|
||||||
|
ja: "日语",
|
||||||
|
ko: "韩语",
|
||||||
|
fr: "法语",
|
||||||
|
de: "德语",
|
||||||
|
es: "西班牙语",
|
||||||
|
it: "意大利语",
|
||||||
|
ru: "俄语",
|
||||||
|
pt: "葡萄牙语",
|
||||||
|
th: "泰语",
|
||||||
|
hi: "印地语",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 出品地区(with_origin_country,ISO 3166-1) */
|
||||||
|
export const TMDB_REGIONS: Record<string, string> = {
|
||||||
|
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<typeof TmdbDiscoverSort>;
|
||||||
|
export const TMDB_SORTS: Record<TmdbDiscoverSort, string> = {
|
||||||
|
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<typeof TmdbDiscoverList>;
|
||||||
|
/** 只对一种类型有的列表 */
|
||||||
|
export const TMDB_LIST_TYPES: Partial<Record<TmdbDiscoverList, "movie" | "tv">> = {
|
||||||
|
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<string, { label: string; kind: "movie" | "tv" }> = {
|
||||||
|
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<typeof DoubanExploreSort>;
|
||||||
|
export const DOUBAN_SORTS: Record<DoubanExploreSort, string> = { 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<typeof BangumiDiscoverList>;
|
||||||
|
|
||||||
|
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<typeof DiscoverLocal>;
|
||||||
|
|
||||||
|
export const DiscoverTmdbRef = z.object({ type: z.enum(["movie", "tv"]), id: z.string() });
|
||||||
|
export type DiscoverTmdbRef = z.infer<typeof DiscoverTmdbRef>;
|
||||||
|
|
||||||
|
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<typeof DiscoverItem>;
|
||||||
|
|
||||||
|
export const DiscoverPage = z.object({
|
||||||
|
items: z.array(DiscoverItem),
|
||||||
|
page: z.number().int(),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
});
|
||||||
|
export type DiscoverPage = z.infer<typeof DiscoverPage>;
|
||||||
|
|
||||||
|
export const DiscoverCalendar = z.object({
|
||||||
|
days: z.array(z.object({ weekday: z.number().int(), label: z.string(), items: z.array(DiscoverItem) })),
|
||||||
|
});
|
||||||
|
export type DiscoverCalendar = z.infer<typeof DiscoverCalendar>;
|
||||||
|
|
||||||
|
// ── 查询参数 ──
|
||||||
|
|
||||||
|
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<typeof DiscoverTmdbQuery>;
|
||||||
|
|
||||||
|
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<typeof DiscoverDoubanQuery>;
|
||||||
|
|
||||||
|
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<typeof DiscoverBangumiQuery>;
|
||||||
|
|
||||||
|
// ── 匹配 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<typeof DiscoverMatchRequest>;
|
||||||
|
|
||||||
|
export const DiscoverMatchCandidate = z.object({
|
||||||
|
type: z.enum(["movie", "tv"]),
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
year: z.number().int().nullable(),
|
||||||
|
posterPath: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type DiscoverMatchCandidate = z.infer<typeof DiscoverMatchCandidate>;
|
||||||
|
export const DiscoverMatchResult = z.object({
|
||||||
|
/** 能确定的 TMDB 作品;不确定为 null,看 candidates */
|
||||||
|
match: DiscoverMatchCandidate.nullable(),
|
||||||
|
candidates: z.array(DiscoverMatchCandidate),
|
||||||
|
});
|
||||||
|
export type DiscoverMatchResult = z.infer<typeof DiscoverMatchResult>;
|
||||||
@@ -475,55 +475,3 @@ export const TmdbSeasonDetailView = z.object({
|
|||||||
episodes: z.array(MediaEpisodeInfo),
|
episodes: z.array(MediaEpisodeInfo),
|
||||||
});
|
});
|
||||||
export type TmdbSeasonDetailView = z.infer<typeof TmdbSeasonDetailView>;
|
export type TmdbSeasonDetailView = z.infer<typeof TmdbSeasonDetailView>;
|
||||||
|
|
||||||
// ── 发现:bgm.tv 每日放送、豆瓣热门(辅助信息源,点开后匹配 TMDB 作品) ──
|
|
||||||
|
|
||||||
export const DiscoverItem = z.object({
|
|
||||||
source: z.enum(["bangumi", "douban"]),
|
|
||||||
id: z.string(),
|
|
||||||
title: z.string(),
|
|
||||||
aliases: z.array(z.string()),
|
|
||||||
year: z.number().int().nullable(),
|
|
||||||
kind: z.enum(["movie", "tv"]).nullable(),
|
|
||||||
cover: z.string().nullable(),
|
|
||||||
rating: z.number().nullable(),
|
|
||||||
date: z.string().nullable(),
|
|
||||||
episodes: z.number().int().nullable(),
|
|
||||||
url: z.string(),
|
|
||||||
});
|
|
||||||
export type DiscoverItem = z.infer<typeof DiscoverItem>;
|
|
||||||
|
|
||||||
export const DiscoverCalendar = z.object({
|
|
||||||
days: z.array(z.object({ weekday: z.number().int(), label: z.string(), items: z.array(DiscoverItem) })),
|
|
||||||
});
|
|
||||||
export type DiscoverCalendar = z.infer<typeof DiscoverCalendar>;
|
|
||||||
|
|
||||||
export const DiscoverHotQuery = z.object({ kind: z.enum(["movie", "tv"]).default("movie") });
|
|
||||||
export type DiscoverHotQuery = z.infer<typeof DiscoverHotQuery>;
|
|
||||||
|
|
||||||
export const DiscoverList = z.object({ items: z.array(DiscoverItem) });
|
|
||||||
export type DiscoverList = z.infer<typeof DiscoverList>;
|
|
||||||
|
|
||||||
export const DiscoverMatchRequest = DiscoverItem.pick({
|
|
||||||
source: true,
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
aliases: true,
|
|
||||||
year: true,
|
|
||||||
kind: true,
|
|
||||||
});
|
|
||||||
export type DiscoverMatchRequest = z.infer<typeof DiscoverMatchRequest>;
|
|
||||||
|
|
||||||
export const DiscoverMatchCandidate = z.object({
|
|
||||||
type: z.enum(["movie", "tv"]),
|
|
||||||
id: z.string(),
|
|
||||||
title: z.string(),
|
|
||||||
year: z.number().int().nullable(),
|
|
||||||
posterPath: z.string().nullable(),
|
|
||||||
});
|
|
||||||
export const DiscoverMatchResult = z.object({
|
|
||||||
/** 能确定的 TMDB 作品;不确定为 null,看 candidates */
|
|
||||||
match: DiscoverMatchCandidate.nullable(),
|
|
||||||
candidates: z.array(DiscoverMatchCandidate),
|
|
||||||
});
|
|
||||||
export type DiscoverMatchResult = z.infer<typeof DiscoverMatchResult>;
|
|
||||||
@@ -574,6 +574,12 @@ export const StorageCacheTtl = z.object({
|
|||||||
siteDetailDays: Days,
|
siteDetailDays: Days,
|
||||||
/** 网页搜索结果 */
|
/** 网页搜索结果 */
|
||||||
webSearchDays: 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<typeof StorageCacheTtl>;
|
export type StorageCacheTtl = z.infer<typeof StorageCacheTtl>;
|
||||||
|
|
||||||
@@ -596,7 +602,16 @@ export const DEFAULT_STORAGE_SETTINGS: StorageSettings = {
|
|||||||
capabilityStats: 90,
|
capabilityStats: 90,
|
||||||
identityPoolArchive: 30,
|
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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 存储设置与缺省逐层合并(旧库里缺子字段时补齐) */
|
/** 存储设置与缺省逐层合并(旧库里缺子字段时补齐) */
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ const STRING_CANDIDATES = [
|
|||||||
"zh-hans",
|
"zh-hans",
|
||||||
"movie:157336",
|
"movie:157336",
|
||||||
"zh-CN",
|
"zh-CN",
|
||||||
|
// 发现页筛选:原始语言、地区、年份
|
||||||
|
"zh",
|
||||||
|
"US",
|
||||||
|
"2024",
|
||||||
];
|
];
|
||||||
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
|
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
|
||||||
|
|
||||||
@@ -117,6 +121,10 @@ const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, u
|
|||||||
return { ...v, options: [o, { ...o, id: "b" }] };
|
return { ...v, options: [o, { ...o, id: "b" }] };
|
||||||
},
|
},
|
||||||
EngineStats: (v) => ({ ...v, byStatus: { pending: 1 } }),
|
EngineStats: (v) => ({ ...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: () => ({
|
TypedEventEnvelope: () => ({
|
||||||
seq: 1,
|
seq: 1,
|
||||||
at: "2026-09-27T08:00:00Z",
|
at: "2026-09-27T08:00:00Z",
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface TmdbFixture {
|
|||||||
seasons?: TmdbFixtureSeason[];
|
seasons?: TmdbFixtureSeason[];
|
||||||
/** 海报路径(拼 image.tmdb.org);缺省 null */
|
/** 海报路径(拼 image.tmdb.org);缺省 null */
|
||||||
poster_path?: string | null;
|
poster_path?: string | null;
|
||||||
|
/** 列表接口(trending / discover / 榜单)返回的 genre_ids;缺省空 */
|
||||||
|
genre_ids?: number[];
|
||||||
/** 剧集组(`/tv/{id}/episode_groups`、`/tv/episode_group/{id}`);组内集按 TMDB 默认季集号引用 seasons 里的集 */
|
/** 剧集组(`/tv/{id}/episode_groups`、`/tv/episode_group/{id}`);组内集按 TMDB 默认季集号引用 seasons 里的集 */
|
||||||
episode_groups?: TmdbFixtureEpisodeGroup[];
|
episode_groups?: TmdbFixtureEpisodeGroup[];
|
||||||
}
|
}
|
||||||
@@ -82,6 +84,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
|
|||||||
{
|
{
|
||||||
type: "movie",
|
type: "movie",
|
||||||
id: 872585,
|
id: 872585,
|
||||||
|
genre_ids: [18, 36],
|
||||||
original_title: "Oppenheimer",
|
original_title: "Oppenheimer",
|
||||||
original_language: "en",
|
original_language: "en",
|
||||||
origin_country: ["US"],
|
origin_country: ["US"],
|
||||||
@@ -100,6 +103,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
|
|||||||
{
|
{
|
||||||
type: "movie",
|
type: "movie",
|
||||||
id: 693134,
|
id: 693134,
|
||||||
|
genre_ids: [878, 12],
|
||||||
original_title: "Dune: Part Two",
|
original_title: "Dune: Part Two",
|
||||||
original_language: "en",
|
original_language: "en",
|
||||||
origin_country: ["US"],
|
origin_country: ["US"],
|
||||||
@@ -116,6 +120,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
|
|||||||
{
|
{
|
||||||
type: "movie",
|
type: "movie",
|
||||||
id: 842675,
|
id: 842675,
|
||||||
|
genre_ids: [878, 28],
|
||||||
original_title: "流浪地球2",
|
original_title: "流浪地球2",
|
||||||
original_language: "zh",
|
original_language: "zh",
|
||||||
origin_country: ["CN"],
|
origin_country: ["CN"],
|
||||||
@@ -131,6 +136,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
|
|||||||
{
|
{
|
||||||
type: "tv",
|
type: "tv",
|
||||||
id: 136315,
|
id: 136315,
|
||||||
|
genre_ids: [35, 18],
|
||||||
original_title: "The Bear",
|
original_title: "The Bear",
|
||||||
original_language: "en",
|
original_language: "en",
|
||||||
origin_country: ["US"],
|
origin_country: ["US"],
|
||||||
@@ -181,6 +187,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
|
|||||||
{
|
{
|
||||||
type: "tv",
|
type: "tv",
|
||||||
id: 100088,
|
id: 100088,
|
||||||
|
genre_ids: [18, 10765],
|
||||||
original_title: "The Last of Us",
|
original_title: "The Last of Us",
|
||||||
original_language: "en",
|
original_language: "en",
|
||||||
origin_country: ["US"],
|
origin_country: ["US"],
|
||||||
@@ -209,6 +216,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
|
|||||||
{
|
{
|
||||||
type: "tv",
|
type: "tv",
|
||||||
id: 900001,
|
id: 900001,
|
||||||
|
genre_ids: [10764],
|
||||||
original_title: "向往的生活",
|
original_title: "向往的生活",
|
||||||
original_language: "zh",
|
original_language: "zh",
|
||||||
origin_country: ["CN"],
|
origin_country: ["CN"],
|
||||||
@@ -260,6 +268,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
|
|||||||
{
|
{
|
||||||
type: "tv",
|
type: "tv",
|
||||||
id: 95396,
|
id: 95396,
|
||||||
|
genre_ids: [18, 9648],
|
||||||
original_title: "Severance",
|
original_title: "Severance",
|
||||||
original_language: "en",
|
original_language: "en",
|
||||||
origin_country: ["US"],
|
origin_country: ["US"],
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ const notFound: MockReply = {
|
|||||||
json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." },
|
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<MockTmdb> {
|
export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTmdb> {
|
||||||
const fx = opts.fixtures ?? TMDB_FIXTURES;
|
const fx = opts.fixtures ?? TMDB_FIXTURES;
|
||||||
const apiKey = opts.apiKey ?? "test-tmdb-key";
|
const apiKey = opts.apiKey ?? "test-tmdb-key";
|
||||||
@@ -56,8 +59,60 @@ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTm
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listItem = (f: TmdbFixture, lang?: string) => ({
|
||||||
|
...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<string, string>, 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(
|
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$/,
|
/^\/3\/search\/multi$/,
|
||||||
(_m, q) => {
|
(_m, q) => {
|
||||||
|
|||||||
Reference in new issue
Block a user