Переглянути джерело

Merge branch 'worktree-agent-a6c86d5b'

User 2 місяців тому
батько
коміт
341b0f69a2

+ 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;
+}