Эх сурвалжийг харах

feat: add TMDB API proxy routes and server-side client

Server-side proxy keeps TMDB_API_KEY off the client. Includes search,
discover, popular, movie details, and videos endpoints with zod
validation, adult content filtering, and cache headers. Adds trailer URL
extraction with domain allowlist validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User 2 сар өмнө
parent
commit
2200dcde86

+ 44 - 0
src/app/api/tmdb/discover/route.ts

@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { tmdbFetch, filterAdultMovies } from "@/lib/tmdb/client";
+import type { TMDBSearchResponse } from "@/types/tmdb";
+
+const discoverParamsSchema = z.object({
+  with_genres: z
+    .string()
+    .regex(/^\d+(,\d+)*$/, "Genre IDs must be comma-separated numbers"),
+  page: z.coerce.number().int().min(1).max(500).optional().default(1),
+  sort_by: z
+    .enum(["popularity.desc", "popularity.asc", "vote_average.desc", "vote_average.asc"])
+    .optional()
+    .default("popularity.desc"),
+});
+
+export async function GET(request: NextRequest) {
+  const rawParams = Object.fromEntries(request.nextUrl.searchParams);
+  const parsed = discoverParamsSchema.safeParse(rawParams);
+
+  if (!parsed.success) {
+    return NextResponse.json(
+      { error: "Invalid parameters", details: parsed.error.flatten().fieldErrors },
+      { status: 400 },
+    );
+  }
+
+  const { with_genres, page, sort_by } = parsed.data;
+
+  try {
+    const data = await tmdbFetch<TMDBSearchResponse>("/discover/movie", {
+      with_genres,
+      page: String(page),
+      sort_by,
+    });
+
+    return NextResponse.json(filterAdultMovies(data), {
+      headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" },
+    });
+  } catch (error) {
+    console.error("TMDB discover error:", error);
+    return NextResponse.json({ error: "Failed to discover movies" }, { status: 500 });
+  }
+}

+ 33 - 0
src/app/api/tmdb/movie/[id]/route.ts

@@ -0,0 +1,33 @@
+import { NextRequest, NextResponse } from "next/server";
+import { tmdbFetch, movieIdSchema } from "@/lib/tmdb/client";
+import type { TMDBMovieDetails } from "@/types/tmdb";
+
+export async function GET(
+  _request: NextRequest,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id: rawId } = await params;
+  const parsed = movieIdSchema.safeParse({ id: rawId });
+
+  if (!parsed.success) {
+    return NextResponse.json(
+      { error: "Invalid movie ID", details: parsed.error.flatten().fieldErrors },
+      { status: 400 },
+    );
+  }
+
+  try {
+    const data = await tmdbFetch<TMDBMovieDetails>(`/movie/${parsed.data.id}`);
+
+    if (data.adult) {
+      return NextResponse.json({ error: "Movie not found" }, { status: 404 });
+    }
+
+    return NextResponse.json(data, {
+      headers: { "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=300" },
+    });
+  } catch (error) {
+    console.error("TMDB movie details error:", error);
+    return NextResponse.json({ error: "Failed to fetch movie details" }, { status: 500 });
+  }
+}

+ 36 - 0
src/app/api/tmdb/movie/[id]/videos/route.ts

@@ -0,0 +1,36 @@
+import { NextRequest, NextResponse } from "next/server";
+import { tmdbFetch, movieIdSchema } from "@/lib/tmdb/client";
+import { extractTrailerUrl } from "@/lib/tmdb/trailer";
+import type { TMDBVideosResponse } from "@/types/tmdb";
+
+export async function GET(
+  _request: NextRequest,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  const { id: rawId } = await params;
+  const parsed = movieIdSchema.safeParse({ id: rawId });
+
+  if (!parsed.success) {
+    return NextResponse.json(
+      { error: "Invalid movie ID", details: parsed.error.flatten().fieldErrors },
+      { status: 400 },
+    );
+  }
+
+  try {
+    const data = await tmdbFetch<TMDBVideosResponse>(`/movie/${parsed.data.id}/videos`);
+    const trailerUrl = extractTrailerUrl(data.results);
+
+    return NextResponse.json(
+      { id: data.id, results: data.results, trailerUrl },
+      {
+        headers: {
+          "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=300",
+        },
+      },
+    );
+  } catch (error) {
+    console.error("TMDB videos error:", error);
+    return NextResponse.json({ error: "Failed to fetch movie videos" }, { status: 500 });
+  }
+}

+ 35 - 0
src/app/api/tmdb/popular/route.ts

@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { tmdbFetch, filterAdultMovies } from "@/lib/tmdb/client";
+import type { TMDBSearchResponse } from "@/types/tmdb";
+
+const popularParamsSchema = z.object({
+  page: z.coerce.number().int().min(1).max(500).optional().default(1),
+});
+
+export async function GET(request: NextRequest) {
+  const rawParams = Object.fromEntries(request.nextUrl.searchParams);
+  const parsed = popularParamsSchema.safeParse(rawParams);
+
+  if (!parsed.success) {
+    return NextResponse.json(
+      { error: "Invalid parameters", details: parsed.error.flatten().fieldErrors },
+      { status: 400 },
+    );
+  }
+
+  const { page } = parsed.data;
+
+  try {
+    const data = await tmdbFetch<TMDBSearchResponse>("/movie/popular", {
+      page: String(page),
+    });
+
+    return NextResponse.json(filterAdultMovies(data), {
+      headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" },
+    });
+  } catch (error) {
+    console.error("TMDB popular error:", error);
+    return NextResponse.json({ error: "Failed to fetch popular movies" }, { status: 500 });
+  }
+}

+ 37 - 0
src/app/api/tmdb/search/route.ts

@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { tmdbFetch, filterAdultMovies } from "@/lib/tmdb/client";
+import type { TMDBSearchResponse } from "@/types/tmdb";
+
+const searchParamsSchema = z.object({
+  query: z.string().min(1).max(200),
+  page: z.coerce.number().int().min(1).max(500).optional().default(1),
+});
+
+export async function GET(request: NextRequest) {
+  const rawParams = Object.fromEntries(request.nextUrl.searchParams);
+  const parsed = searchParamsSchema.safeParse(rawParams);
+
+  if (!parsed.success) {
+    return NextResponse.json(
+      { error: "Invalid parameters", details: parsed.error.flatten().fieldErrors },
+      { status: 400 },
+    );
+  }
+
+  const { query, page } = parsed.data;
+
+  try {
+    const data = await tmdbFetch<TMDBSearchResponse>("/search/movie", {
+      query,
+      page: String(page),
+    });
+
+    return NextResponse.json(filterAdultMovies(data), {
+      headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" },
+    });
+  } catch (error) {
+    console.error("TMDB search error:", error);
+    return NextResponse.json({ error: "Failed to search movies" }, { status: 500 });
+  }
+}

+ 46 - 0
src/lib/tmdb/client.ts

@@ -0,0 +1,46 @@
+import { z } from "zod";
+import { TMDB_API_BASE_URL } from "@/lib/constants";
+import type { TMDBSearchResponse } from "@/types/tmdb";
+
+export const movieIdSchema = z.object({
+  id: z.coerce.number().int().positive(),
+});
+
+export async function tmdbFetch<T>(
+  path: string,
+  params?: Record<string, string>,
+): Promise<T> {
+  const apiKey = process.env.TMDB_API_KEY;
+  if (!apiKey) {
+    throw new Error("TMDB_API_KEY is not configured");
+  }
+
+  const url = new URL(`${TMDB_API_BASE_URL}${path}`);
+  url.searchParams.set("include_adult", "false");
+
+  if (params) {
+    for (const [key, value] of Object.entries(params)) {
+      url.searchParams.set(key, value);
+    }
+  }
+
+  const response = await fetch(url.toString(), {
+    headers: {
+      Authorization: `Bearer ${apiKey}`,
+      Accept: "application/json",
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(`TMDB API error: ${response.status} ${response.statusText}`);
+  }
+
+  return response.json() as Promise<T>;
+}
+
+export function filterAdultMovies(data: TMDBSearchResponse): TMDBSearchResponse {
+  return {
+    ...data,
+    results: data.results.filter((movie) => !movie.adult),
+  };
+}

+ 31 - 0
src/lib/tmdb/trailer.ts

@@ -0,0 +1,31 @@
+import { TRAILER_DOMAIN_ALLOWLIST } from "@/lib/constants";
+import type { TMDBVideo } from "@/types/tmdb";
+
+export function extractTrailerUrl(videos: TMDBVideo[]): string | null {
+  const youtubeTrailers = videos.filter(
+    (v) => v.type === "Trailer" && v.site === "YouTube" && v.key,
+  );
+
+  if (youtubeTrailers.length === 0) {
+    return null;
+  }
+
+  // Prefer official trailers
+  const official = youtubeTrailers.find((v) => v.official);
+  const trailer = official ?? youtubeTrailers[0];
+
+  const url = `https://www.youtube.com/watch?v=${trailer.key}`;
+
+  // Validate against domain allowlist
+  try {
+    const parsed = new URL(url);
+    const hostname = parsed.hostname;
+    if (!TRAILER_DOMAIN_ALLOWLIST.some((domain) => hostname === domain)) {
+      return null;
+    }
+  } catch {
+    return null;
+  }
+
+  return url;
+}