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:
1 parent
27514f4985
commit
e46eb0e3c3
13 files changed
+1344
-91
No files matched your search
@@ -101,8 +101,10 @@ export interface TmdbConfig {
|
||||
/**
|
||||
* 默认 max 1024、ttl 24h;空结果(搜索 0 条)用 emptyTtlMs,默认 30min(对照 MP EMPTY_RESULT_CACHE_TTL);
|
||||
* searchTtlMs:/search/、/find/ 请求的 TTL(缺省同 ttlMs),其余(详情 / 季)用 ttlMs。
|
||||
* listTtlMs:流行趋势 / 热门 / 正在上映等列表与 /discover/ 的 TTL(缺省 6 小时且不超过 ttlMs;列表会变,不能按详情缓存 30 天),
|
||||
* 这类请求的空结果 TTL 也不超过它。
|
||||
*/
|
||||
cache?: { max?: number; ttlMs?: number; searchTtlMs?: number; emptyTtlMs?: number };
|
||||
cache?: { max?: number; ttlMs?: number; searchTtlMs?: number; emptyTtlMs?: number; listTtlMs?: number };
|
||||
/**
|
||||
* 跨进程共享的请求缓存(服务端传 PG 缓存);给了就不再用进程内 LRU。
|
||||
* 语义对照 MP @cached(skip_none、业务失败不缓存、results 为空按 emptyTtlMs)。
|
||||
@@ -134,6 +136,11 @@ export function tmdbRequestKey(path: string, params: Record<string, string | und
|
||||
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) */
|
||||
const isEmptyResult = (v: unknown) =>
|
||||
Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0;
|
||||
@@ -176,9 +183,13 @@ export class TmdbClient {
|
||||
private async get<T>(path: string, params: Record<string, string | undefined>): Promise<T> {
|
||||
const qs = requestQuery(params);
|
||||
const key = `${path}?${qs}`;
|
||||
const emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000;
|
||||
let emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000;
|
||||
const baseTtl = this.cfg.cache?.ttlMs ?? 24 * 3600_000;
|
||||
const ttl = /^\/(search|find)\//.test(path) ? (this.cfg.cache?.searchTtlMs ?? baseTtl) : baseTtl;
|
||||
let ttl = /^\/(search|find)\//.test(path) ? (this.cfg.cache?.searchTtlMs ?? baseTtl) : baseTtl;
|
||||
if (isTmdbListPath(path)) {
|
||||
ttl = this.cfg.cache?.listTtlMs ?? Math.min(baseTtl, 6 * 3600_000);
|
||||
emptyTtl = Math.min(emptyTtl, ttl);
|
||||
}
|
||||
if (this.cfg.sharedCache) {
|
||||
const shared = this.cfg.sharedCache;
|
||||
const hit = await shared.get(key);
|
||||
|
||||
@@ -2,6 +2,7 @@ export { RequestLimiter, TtlLruCache } from "./cache.ts";
|
||||
export * from "./client.ts";
|
||||
export * from "./details.ts";
|
||||
export * from "./episode-groups.ts";
|
||||
export * from "./lists.ts";
|
||||
export * from "./match.ts";
|
||||
export * from "./recall.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);
|
||||
}
|
||||
Reference in new issue
Block a user