215 lines
8.6 KiB
TypeScript
215 lines
8.6 KiB
TypeScript
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<string, string>, 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<MockTmdb> {
|
|
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<string, unknown> = { ...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 });
|
|
}
|