|
@@ -0,0 +1,148 @@
|
|
|
|
|
+import { NextResponse, type NextRequest } from "next/server";
|
|
|
|
|
+import { z } from "zod";
|
|
|
|
|
+import { getSupabaseServerClient } from "@/lib/supabase/server";
|
|
|
|
|
+import { env } from "@/env";
|
|
|
|
|
+import { TMDB_API_BASE_URL, TRAILER_DOMAIN_ALLOWLIST } from "@/lib/constants";
|
|
|
|
|
+import type { Database } from "@/types/database";
|
|
|
|
|
+import type { TMDBMovieDetails, TMDBVideosResponse } from "@/types/tmdb";
|
|
|
|
|
+
|
|
|
|
|
+type MovieInsert = Database["public"]["Tables"]["movies"]["Insert"];
|
|
|
|
|
+
|
|
|
|
|
+const addMovieSchema = z.object({
|
|
|
|
|
+ tmdb_id: z.number().int().positive(),
|
|
|
|
|
+ group_id: z.string().uuid(),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function buildTrailerUrl(videos: TMDBVideosResponse): string | null {
|
|
|
|
|
+ // Prefer official YouTube trailers, then any YouTube video
|
|
|
|
|
+ const sorted = [...videos.results]
|
|
|
|
|
+ .filter((v) => v.site === "YouTube" && v.key)
|
|
|
|
|
+ .sort((a, b) => {
|
|
|
|
|
+ if (a.official !== b.official) return a.official ? -1 : 1;
|
|
|
|
|
+ if (a.type !== b.type) {
|
|
|
|
|
+ if (a.type === "Trailer") return -1;
|
|
|
|
|
+ if (b.type === "Trailer") return 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const video = sorted[0];
|
|
|
|
|
+ if (!video) return null;
|
|
|
|
|
+
|
|
|
|
|
+ return `https://www.youtube.com/watch?v=${video.key}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isAllowedTrailerDomain(url: string): boolean {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { hostname } = new URL(url);
|
|
|
|
|
+ return TRAILER_DOMAIN_ALLOWLIST.some(
|
|
|
|
|
+ (domain) => hostname === domain || hostname.endsWith(`.${domain}`),
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export async function POST(request: NextRequest) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const supabase = await getSupabaseServerClient();
|
|
|
|
|
+
|
|
|
|
|
+ const {
|
|
|
|
|
+ data: { user },
|
|
|
|
|
+ } = await supabase.auth.getUser();
|
|
|
|
|
+ if (!user) {
|
|
|
|
|
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const body = await request.json();
|
|
|
|
|
+ const parsed = addMovieSchema.safeParse(body);
|
|
|
|
|
+ if (!parsed.success) {
|
|
|
|
|
+ return NextResponse.json(
|
|
|
|
|
+ { error: "Invalid input", details: parsed.error.flatten().fieldErrors },
|
|
|
|
|
+ { status: 400 },
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { tmdb_id, group_id } = parsed.data;
|
|
|
|
|
+
|
|
|
|
|
+ // Verify user is a member of this group
|
|
|
|
|
+ const { data: membership } = await supabase
|
|
|
|
|
+ .from("group_members")
|
|
|
|
|
+ .select("user_id")
|
|
|
|
|
+ .eq("group_id", group_id)
|
|
|
|
|
+ .eq("user_id", user.id)
|
|
|
|
|
+ .single();
|
|
|
|
|
+
|
|
|
|
|
+ if (!membership) {
|
|
|
|
|
+ return NextResponse.json({ error: "Not a member of this group" }, { status: 403 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Check if movie already exists in this group
|
|
|
|
|
+ const { data: existing } = await supabase
|
|
|
|
|
+ .from("movies")
|
|
|
|
|
+ .select("id")
|
|
|
|
|
+ .eq("group_id", group_id)
|
|
|
|
|
+ .eq("tmdb_id", tmdb_id)
|
|
|
|
|
+ .single();
|
|
|
|
|
+
|
|
|
|
|
+ if (existing) {
|
|
|
|
|
+ return NextResponse.json({ error: "Movie already in group" }, { status: 409 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Fetch movie details from TMDB
|
|
|
|
|
+ const [detailsRes, videosRes] = await Promise.all([
|
|
|
|
|
+ fetch(`${TMDB_API_BASE_URL}/movie/${tmdb_id}?api_key=${env.TMDB_API_KEY}`),
|
|
|
|
|
+ fetch(`${TMDB_API_BASE_URL}/movie/${tmdb_id}/videos?api_key=${env.TMDB_API_KEY}`),
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ if (!detailsRes.ok) {
|
|
|
|
|
+ return NextResponse.json({ error: "Movie not found on TMDB" }, { status: 404 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const details: TMDBMovieDetails = await detailsRes.json();
|
|
|
|
|
+
|
|
|
|
|
+ if (details.adult) {
|
|
|
|
|
+ return NextResponse.json({ error: "Adult content not allowed" }, { status: 400 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let trailerUrl: string | null = null;
|
|
|
|
|
+ if (videosRes.ok) {
|
|
|
|
|
+ const videos: TMDBVideosResponse = await videosRes.json();
|
|
|
|
|
+ const url = buildTrailerUrl(videos);
|
|
|
|
|
+ if (url && isAllowedTrailerDomain(url)) {
|
|
|
|
|
+ trailerUrl = url;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const year = details.release_date ? parseInt(details.release_date.substring(0, 4), 10) : 0;
|
|
|
|
|
+ const genres = details.genres.map((g) => g.name);
|
|
|
|
|
+
|
|
|
|
|
+ const insertData: MovieInsert = {
|
|
|
|
|
+ group_id,
|
|
|
|
|
+ tmdb_id,
|
|
|
|
|
+ title: details.title,
|
|
|
|
|
+ year,
|
|
|
|
|
+ poster_path: details.poster_path,
|
|
|
|
|
+ genres,
|
|
|
|
|
+ trailer_url: trailerUrl,
|
|
|
|
|
+ trailer_url_refreshed_at: trailerUrl ? new Date().toISOString() : null,
|
|
|
|
|
+ added_by: user.id,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
+ const { data: movie, error } = await (supabase.from("movies") as any)
|
|
|
|
|
+ .insert(insertData)
|
|
|
|
|
+ .select()
|
|
|
|
|
+ .single();
|
|
|
|
|
+
|
|
|
|
|
+ if (error) {
|
|
|
|
|
+ console.error("Failed to insert movie:", error);
|
|
|
|
|
+ return NextResponse.json({ error: "Failed to add movie" }, { status: 500 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return NextResponse.json({ movie }, { status: 201 });
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error("POST /api/movies error:", err);
|
|
|
|
|
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|