feat(adapters): TMDB 列表与筛选、豆瓣榜单 / 标签 / 筛选 / 搜索、bgm 浏览与趋势,豆瓣 / bgm 请求缓存端口

- TMDB:流行趋势(日 / 周)、电影 / 剧集榜单、discover 参数映射、分页搜索;列表类请求 TTL 6 小时
- 豆瓣 / bgm:列表与搜索请求经 MetaRequestCache 与限流,失败(403 / 风控页)不缓存
- contracts:发现页条目、分页、查询校验与筛选字典;storage.cacheTtl 追加 meta* 字段
- testkit:mock TMDB 加列表接口

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:04:43 +08:00
1 parent 27514f4985
commit e46eb0e3c3
13 files changed
+1344 -91

No files matched your search

+9
View File
@@ -56,6 +56,8 @@ export interface TmdbFixture {
seasons?: TmdbFixtureSeason[];
/** 海报路径(拼 image.tmdb.org);缺省 null */
poster_path?: string | null;
/** 列表接口(trending / discover / 榜单)返回的 genre_ids;缺省空 */
genre_ids?: number[];
/** 剧集组(`/tv/{id}/episode_groups`、`/tv/episode_group/{id}`);组内集按 TMDB 默认季集号引用 seasons 里的集 */
episode_groups?: TmdbFixtureEpisodeGroup[];
}
@@ -82,6 +84,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
{
type: "movie",
id: 872585,
genre_ids: [18, 36],
original_title: "Oppenheimer",
original_language: "en",
origin_country: ["US"],
@@ -100,6 +103,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
{
type: "movie",
id: 693134,
genre_ids: [878, 12],
original_title: "Dune: Part Two",
original_language: "en",
origin_country: ["US"],
@@ -116,6 +120,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
{
type: "movie",
id: 842675,
genre_ids: [878, 28],
original_title: "流浪地球2",
original_language: "zh",
origin_country: ["CN"],
@@ -131,6 +136,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
{
type: "tv",
id: 136315,
genre_ids: [35, 18],
original_title: "The Bear",
original_language: "en",
origin_country: ["US"],
@@ -181,6 +187,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
{
type: "tv",
id: 100088,
genre_ids: [18, 10765],
original_title: "The Last of Us",
original_language: "en",
origin_country: ["US"],
@@ -209,6 +216,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
{
type: "tv",
id: 900001,
genre_ids: [10764],
original_title: "向往的生活",
original_language: "zh",
origin_country: ["CN"],
@@ -260,6 +268,7 @@ export const TMDB_FIXTURES: TmdbFixture[] = [
{
type: "tv",
id: 95396,
genre_ids: [18, 9648],
original_title: "Severance",
original_language: "en",
origin_country: ["US"],
+56 -1
View File
@@ -19,7 +19,10 @@ const notFound: MockReply = {
json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." },
};
/** mock TMDB v3:/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n、/3/tv/:id/episode_groups、/3/tv/episode_group/:id */
/** 列表接口一页的条数(同 TMDB) */
const LIST_PAGE = 20;
/** mock TMDB v3:/3/trending、/3/discover、电影 / 剧集榜单(按热度,一页 20 条)、/3/search/{movie,tv}、/3/find/:imdbId、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n、/3/tv/:id/episode_groups、/3/tv/episode_group/:id */
export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTmdb> {
const fx = opts.fixtures ?? TMDB_FIXTURES;
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(
[
[
/^\/3\/trending\/(all|movie|tv)\/(day|week)$/,
(m, q) => listPage(m[1] === "all" ? fx : fx.filter((f) => f.type === m[1]), q, true),
],
[
/^\/3\/(movie|tv)\/(popular|now_playing|upcoming|top_rated|on_the_air|airing_today)$/,
(m, q) =>
listPage(
fx.filter((f) => f.type === m[1]),
q,
false,
),
],
[
/^\/3\/discover\/(movie|tv)$/,
(m, q) => {
const year = q.primary_release_year ?? q.first_air_date_year;
const gte = q["primary_release_date.gte"] ?? q["first_air_date.gte"];
const lte = q["primary_release_date.lte"] ?? q["first_air_date.lte"];
const rows = fx
.filter((f) => f.type === m[1])
.filter((f) => !q.with_genres || (f.genre_ids ?? []).includes(Number(q.with_genres)))
.filter((f) => !q.with_original_language || f.original_language === q.with_original_language)
.filter((f) => !q.with_origin_country || f.origin_country.includes(q.with_origin_country))
.filter((f) => !year || f.date.startsWith(year))
.filter((f) => (!gte || f.date >= gte) && (!lte || f.date <= lte))
.filter((f) => !q["vote_average.gte"] || (f.rich?.vote_average ?? 0) >= Number(q["vote_average.gte"]));
return listPage(rows, q, false);
},
],
[
/^\/3\/search\/multi$/,
(_m, q) => {