import { TMDB_FIXTURES, type TmdbFixture } from "./fixtures.ts"; import { type MockHttp, type MockReply, startMockHttp } from "./mock-http.ts"; export interface MockTmdbOptions { apiKey?: string; accessToken?: string; fixtures?: TmdbFixture[]; port?: number; } export type MockTmdb = MockHttp & { fixtures: TmdbFixture[] }; const norm = (s: string) => s.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ""); const pick = (m: Record, lang: string | undefined, fb: string) => m[lang ?? "en-US"] ?? m[(lang ?? "").split("-")[0] ?? ""] ?? m["en-US"] ?? fb; const notFound: MockReply = { status: 404, 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 */ export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { const fx = opts.fixtures ?? TMDB_FIXTURES; const apiKey = opts.apiKey ?? "test-tmdb-key"; const find = (type: string, id: string) => fx.find((f) => f.type === type && String(f.id) === id); const matchText = (f: TmdbFixture, needle: string) => [f.original_title, ...Object.values(f.titles), ...f.alternative_titles.map((a) => a.title)].some((t) => norm(t).includes(needle), ); const brief = (f: TmdbFixture, lang?: string) => { const title = pick(f.titles, lang, f.original_title); const overview = f.overviews ? pick(f.overviews, lang, "") : ""; return f.type === "movie" ? { id: f.id, title, original_title: f.original_title, release_date: f.date, original_language: f.original_language, overview, popularity: f.popularity, poster_path: f.poster_path ?? null, } : { id: f.id, name: title, original_name: f.original_title, first_air_date: f.date, original_language: f.original_language, origin_country: f.origin_country, overview, popularity: f.popularity, poster_path: f.poster_path ?? null, }; }; const http = await startMockHttp( [ [ /^\/3\/search\/multi$/, (_m, q) => { const needle = norm(q.query ?? ""); const results = fx .filter((f) => needle && matchText(f, needle)) .sort((a, b) => b.popularity - a.popularity) .map((f) => ({ ...brief(f, q.language), media_type: f.type })); return { json: { page: 1, results, total_pages: 1, total_results: results.length } }; }, ], [ /^\/3\/search\/(movie|tv)$/, (m, q) => { const type = m[1]!; const needle = norm(q.query ?? ""); const year = q.year ?? q.primary_release_year ?? q.first_air_date_year; const results = fx .filter((f) => f.type === type) .filter((f) => !year || f.date.startsWith(year)) .filter((f) => !!needle && matchText(f, needle)) .sort((a, b) => b.popularity - a.popularity) .map((f) => brief(f, q.language)); return { json: { page: 1, results, total_pages: 1, total_results: results.length } }; }, ], [ /^\/3\/find\/(tt\d+)$/, (m, q) => { if (q.external_source !== "imdb_id") return { json: { movie_results: [], tv_results: [] } }; const hit = fx.filter((f) => f.external_ids.imdb_id === m[1]); return { json: { movie_results: hit .filter((f) => f.type === "movie") .map((f) => ({ ...brief(f, q.language), media_type: "movie" })), tv_results: hit .filter((f) => f.type === "tv") .map((f) => ({ ...brief(f, q.language), media_type: "tv" })), }, }; }, ], [ /^\/3\/tv\/(\d+)\/season\/(\d+)$/, (m, q) => { const f = find("tv", m[1]!); const s = f?.seasons?.find((x) => String(x.season_number) === m[2]); if (!f || !s) return notFound; const lang = q.language ?? "en-US"; const zh = lang.startsWith("zh"); return { json: { id: f.id * 100 + s.season_number, season_number: s.season_number, name: pick(s.names, q.language, `Season ${s.season_number}`), overview: s.overviews?.[lang] ?? "", poster_path: s.poster_path ?? null, air_date: s.air_date, episodes: s.episodes.map((e) => ({ episode_number: e.episode_number, season_number: s.season_number, // TMDB 缺该语言译名时给占位名 name: e.names[lang] ?? (zh ? `第 ${e.episode_number} 集` : `Episode ${e.episode_number}`), overview: e.overviews?.[lang] ?? "", air_date: e.air_date, runtime: e.runtime ?? null, still_path: e.still_path ?? null, vote_average: e.vote_average ?? 0, vote_count: e.vote_count ?? 0, })), }, }; }, ], [ /^\/3\/(movie|tv)\/(\d+)$/, (m, q) => { const f = find(m[1]!, m[2]!); if (!f) return notFound; const append = new Set((q.append_to_response ?? "").split(",").filter(Boolean)); const out: Record = { ...brief(f, q.language), origin_country: f.origin_country }; const r = f.rich; if (r) { out.genres = (r.genres ?? []).map((name, i) => ({ id: i + 1, name })); out.vote_average = r.vote_average ?? 0; out.vote_count = r.vote_count ?? 0; out.status = r.status ?? null; out.backdrop_path = r.backdrop_path ?? null; out.tagline = r.tagline?.[q.language ?? "en-US"] ?? ""; if (f.type === "movie") out.runtime = r.runtime ?? null; else { out.episode_run_time = r.episode_run_time ?? []; out.last_air_date = r.last_air_date ?? null; out.networks = (r.networks ?? []).map((name, i) => ({ id: i + 1, name })); out.created_by = r.created_by ?? []; out.number_of_episodes = (f.seasons ?? []) .filter((x) => x.season_number > 0) .reduce((a, x) => a + x.episodes.length, 0); } if (append.has("credits")) out.credits = { cast: (r.cast ?? []).map((c, order) => ({ ...c, order })), crew: r.crew ?? [] }; } if (f.type === "tv") { out.number_of_seasons = f.seasons?.filter((s) => s.season_number > 0).length ?? 0; out.seasons = (f.seasons ?? []).map((s) => ({ season_number: s.season_number, episode_count: s.episodes.length, name: pick(s.names, q.language, ""), air_date: s.air_date, overview: s.overviews?.[q.language ?? "en-US"] ?? "", poster_path: s.poster_path ?? null, })); } if (append.has("alternative_titles")) { out.alternative_titles = f.type === "movie" ? { titles: f.alternative_titles } : { results: f.alternative_titles }; } if (append.has("external_ids")) out.external_ids = f.external_ids; if (append.has("translations")) { const key = f.type === "movie" ? "title" : "name"; out.translations = { translations: [ ...Object.entries(f.titles).map(([lang, t]) => { const [l, r] = lang.split("-"); return { iso_639_1: l, iso_3166_1: r ?? "", data: { [key]: t, overview: f.overviews?.[lang] ?? "" }, }; }), ...(f.extra_translations ?? []).map((x) => ({ iso_639_1: x.iso_639_1, iso_3166_1: x.iso_3166_1, data: { [key]: x.title, overview: x.overview ?? "" }, })), ], }; } return { json: out }; }, ], ], { ...(opts.port !== undefined ? { port: opts.port } : {}), guard: (req, q) => { const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1]; const ok = q.api_key === apiKey || (opts.accessToken !== undefined && bearer === opts.accessToken); return ok ? null : { status: 401, json: { success: false, status_code: 7, status_message: "Invalid API key" } }; }, }, ); return Object.assign(http, { fixtures: fx }); }