Files
sofa/app/api/search/route.ts
T
jake 0838e5fb74 Replace hand-written TMDB types with openapi-fetch + generated schema
- Add `openapi-fetch` dependency; generate `lib/tmdb/schema.d.ts` from
  TMDB's OpenAPI spec and delete `lib/tmdb/types.ts`
- Rewrite `lib/tmdb/client.ts` to use the typed fetch client against
  the generated schema, exporting `TmdbMovieDetails`, `TmdbTvDetails`,
  `TmdbVideo`, and `TmdbGenre` directly
- Fix null-safety across services (`credits`, `availability`,
  `metadata`, `person`, `webhooks`) and call sites (explore page,
  search route, explore actions) to handle optional fields produced
  by the stricter generated types
- Update `metadata.ts` and `metadata.test.ts` to import shared types
  from `@/lib/tmdb/client` instead of the removed `types.ts`
- Exclude `lib/tmdb/schema.d.ts` from Biome linting
2026-03-08 14:07:32 -04:00

133 lines
3.7 KiB
TypeScript

import { type NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth/session";
import { isTmdbConfigured } from "@/lib/config";
import {
searchMovies,
searchMulti,
searchPerson,
searchTv,
} from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
export async function GET(req: NextRequest) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isTmdbConfigured()) {
return NextResponse.json(
{
error: "TMDB API key is not configured. Visit /setup for instructions.",
code: "TMDB_NOT_CONFIGURED",
},
{ status: 503 },
);
}
const query = req.nextUrl.searchParams.get("query")?.trim();
const rawType = req.nextUrl.searchParams.get("type");
const type: "movie" | "tv" | "person" | null =
rawType === "movie" || rawType === "tv" || rawType === "person"
? rawType
: null;
if (rawType && !type) {
return NextResponse.json(
{ error: "type must be movie, tv, or person" },
{ status: 400 },
);
}
if (!query)
return NextResponse.json(
{ error: "query parameter is required" },
{ status: 400 },
);
try {
// Person-specific search
if (type === "person") {
const personResults = await searchPerson(query);
return NextResponse.json({
results: (personResults.results ?? []).map((r) => ({
tmdbId: r.id,
type: "person" as const,
title: r.name,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
knownForDepartment: r.known_for_department,
knownFor: r.known_for
?.slice(0, 3)
.map((k) => k.title ?? (k as { name?: string }).name)
.filter(Boolean),
})),
});
}
const raw =
type === "movie"
? await searchMovies(query)
: type === "tv"
? await searchTv(query)
: await searchMulti(query);
// Search endpoints return movie, TV, or multi results with slightly
// different fields. Widen to the union we actually access.
type SearchResult = {
id: number;
media_type?: string;
title?: string;
name?: string;
overview?: string;
poster_path?: string | null;
profile_path?: string | null;
release_date?: string;
first_air_date?: string;
popularity?: number;
vote_average?: number;
};
const mapped = ((raw.results ?? []) as SearchResult[])
.map((r) => {
// Include person results from multi search
if (r.media_type === "person") {
return {
tmdbId: r.id,
type: "person" as const,
title: r.name ?? "Unknown",
posterPath: null,
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
overview: "",
releaseDate: null,
popularity: r.popularity,
voteAverage: 0,
};
}
const mediaType =
r.media_type === "movie" || r.media_type === "tv"
? r.media_type
: type;
if (!mediaType) return null;
return {
tmdbId: r.id,
type: mediaType,
title: r.title ?? r.name,
overview: r.overview,
releaseDate: r.release_date ?? r.first_air_date,
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
popularity: r.popularity,
voteAverage: r.vote_average,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
return NextResponse.json({ results: mapped });
} catch {
return NextResponse.json(
{ error: "Failed to fetch search results" },
{ status: 502 },
);
}
}