mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 12:55:42 -04:00
Add TMDB image caching pipeline and resolve image URLs server-side
Introduce a local disk cache for TMDB images served through a proxy API route (/api/images/[...path]), eliminating direct client-side CDN dependencies. Images are cached by category (posters, backdrops, stills, logos) and served with immutable cache headers. Move all tmdbImageUrl() calls from client components to API routes and server components so clients receive ready-to-use URLs. This removes the need to expose TMDB_IMAGE_BASE_URL and IMAGE_CACHE_ENABLED via next.config.ts env block. The landing page is split into a server wrapper (app/page.tsx) and client component (components/landing-page.tsx). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27 files changed
+665
-242
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import { discover } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isTmdbConfigured()) {
|
||||
@@ -38,7 +39,7 @@ export async function GET(req: NextRequest) {
|
||||
title: r.title ?? r.name,
|
||||
overview: r.overview,
|
||||
releaseDate: r.release_date ?? r.first_air_date,
|
||||
posterPath: r.poster_path,
|
||||
posterPath: tmdbImageUrl(r.poster_path, "w500"),
|
||||
popularity: r.popularity,
|
||||
voteAverage: r.vote_average,
|
||||
})),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { getContinueWatchingFeed } from "@/lib/services/discovery";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -11,5 +12,24 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const feed = await getContinueWatchingFeed(session.user.id);
|
||||
return NextResponse.json(feed);
|
||||
return NextResponse.json(
|
||||
feed.map((item) => ({
|
||||
...item,
|
||||
title: {
|
||||
...item.title,
|
||||
posterPath: tmdbImageUrl(item.title.posterPath, "w500"),
|
||||
backdropPath: tmdbImageUrl(item.title.backdropPath, "w1280"),
|
||||
},
|
||||
nextEpisode: item.nextEpisode
|
||||
? {
|
||||
...item.nextEpisode,
|
||||
stillPath: tmdbImageUrl(
|
||||
item.nextEpisode.stillPath,
|
||||
"w1280",
|
||||
"stills",
|
||||
),
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { getNewAvailableFeed } from "@/lib/services/discovery";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -13,5 +14,10 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const days = Number(req.nextUrl.searchParams.get("days") ?? 14);
|
||||
const feed = await getNewAvailableFeed(session.user.id, days);
|
||||
return NextResponse.json(feed);
|
||||
return NextResponse.json(
|
||||
feed.map((item) => ({
|
||||
...item,
|
||||
posterPath: tmdbImageUrl(item.posterPath, "w500"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { getRecommendationsFeed } from "@/lib/services/discovery";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -11,5 +12,13 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const feed = await getRecommendationsFeed(session.user.id);
|
||||
return NextResponse.json(feed);
|
||||
return NextResponse.json(
|
||||
feed
|
||||
.filter((item) => !!item)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
posterPath: tmdbImageUrl(item.posterPath, "w500"),
|
||||
backdropPath: tmdbImageUrl(item.backdropPath, "w1280"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import path from "node:path";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
fetchAndMaybeCache,
|
||||
type ImageCategory,
|
||||
imageCacheEnabled,
|
||||
} from "@/lib/services/image-cache";
|
||||
|
||||
const VALID_CATEGORIES = new Set<ImageCategory>([
|
||||
"posters",
|
||||
"backdrops",
|
||||
"stills",
|
||||
"logos",
|
||||
]);
|
||||
|
||||
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const segments = await params;
|
||||
|
||||
if (!imageCacheEnabled()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Image cache disabled" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (segments.path.length !== 2) {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [category, rawFilename] = segments.path;
|
||||
|
||||
if (!VALID_CATEGORIES.has(category as ImageCategory)) {
|
||||
return NextResponse.json({ error: "Invalid category" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Sanitize filename — only allow basename to prevent path traversal
|
||||
const filename = path.basename(rawFilename);
|
||||
if (!filename || filename !== rawFilename || filename.includes("..")) {
|
||||
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tmdbPath = `/${filename}`;
|
||||
const result = await fetchAndMaybeCache(tmdbPath, category as ImageCategory);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(result.buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": result.contentType,
|
||||
"Cache-Control": IMMUTABLE_CACHE,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import { searchMovies, searchMulti, searchTv } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import type { TmdbSearchResponse } from "@/lib/tmdb/types";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -44,7 +45,7 @@ export async function GET(req: NextRequest) {
|
||||
title: r.title ?? r.name,
|
||||
overview: r.overview,
|
||||
releaseDate: r.release_date ?? r.first_air_date,
|
||||
posterPath: r.poster_path,
|
||||
posterPath: tmdbImageUrl(r.poster_path, "w500"),
|
||||
popularity: r.popularity,
|
||||
voteAverage: r.vote_average,
|
||||
})),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { titleRecommendations, titles } from "@/lib/db/schema";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
@@ -39,5 +40,13 @@ export async function GET(
|
||||
)
|
||||
).filter(Boolean);
|
||||
|
||||
return NextResponse.json(results);
|
||||
return NextResponse.json(
|
||||
results
|
||||
.filter((r) => !!r)
|
||||
.map((r) => ({
|
||||
...r,
|
||||
posterPath: tmdbImageUrl(r.posterPath, "w500"),
|
||||
backdropPath: tmdbImageUrl(r.backdropPath, "w1280"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,13 @@ import { eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema";
|
||||
import { extractAndStoreColors, parseColorPalette } from "@/lib/services/colors";
|
||||
import {
|
||||
extractAndStoreColors,
|
||||
parseColorPalette,
|
||||
} from "@/lib/services/colors";
|
||||
import { refreshTvChildren } from "@/lib/services/metadata";
|
||||
import { getTvDetails } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
@@ -92,8 +96,20 @@ export async function GET(
|
||||
|
||||
return NextResponse.json({
|
||||
...title,
|
||||
posterPath: tmdbImageUrl(title.posterPath, "w500"),
|
||||
backdropPath: tmdbImageUrl(title.backdropPath, "w1280"),
|
||||
colorPalette: parseColorPalette(title.colorPalette),
|
||||
seasons: titleSeasons,
|
||||
availability,
|
||||
seasons: titleSeasons.map((s) => ({
|
||||
...s,
|
||||
posterPath: tmdbImageUrl(s.posterPath, "w500"),
|
||||
episodes: s.episodes.map((ep) => ({
|
||||
...ep,
|
||||
stillPath: tmdbImageUrl(ep.stillPath, "w1280", "stills"),
|
||||
})),
|
||||
})),
|
||||
availability: availability.map((a) => ({
|
||||
...a,
|
||||
logoPath: tmdbImageUrl(a.logoPath, "w92"),
|
||||
})),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user