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:
2026-03-02 13:10:17 -05:00
co-authored by Claude Opus 4.6
parent cb0f366d44
commit ee723e41d6
27 changed files with 665 additions and 242 deletions
+1 -3
View File
@@ -13,7 +13,6 @@ import { useCallback, useEffect, useState } from "react";
import { DashboardSkeleton } from "@/components/skeletons";
import { StatsSummary } from "@/components/stats-summary";
import { TitleCard } from "@/components/title-card";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { useSession } from "@/lib/auth/client";
interface ContinueWatchingItem {
@@ -272,8 +271,7 @@ function FeedSection({
function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
const stillUrl =
tmdbImageUrl(item.nextEpisode?.stillPath ?? null, "w500") ??
tmdbImageUrl(item.title.backdropPath ?? null, "w500");
item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null;
const progress =
item.totalEpisodes > 0
? (item.watchedEpisodes / item.totalEpisodes) * 100
+2 -4
View File
@@ -5,7 +5,6 @@ import { motion } from "motion/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { tmdbImageUrl } from "@/lib/tmdb/image";
interface HeroBannerProps {
tmdbId: number;
@@ -26,7 +25,6 @@ export function HeroBanner({
}: HeroBannerProps) {
const router = useRouter();
const [importing, setImporting] = useState(false);
const backdropUrl = tmdbImageUrl(backdropPath, "w1280");
async function handleImport() {
setImporting(true);
@@ -53,9 +51,9 @@ export function HeroBanner({
transition={{ duration: 0.6 }}
>
<div className="relative aspect-[21/9] min-h-[280px] max-h-[420px]">
{backdropUrl ? (
{backdropPath ? (
<Image
src={backdropUrl}
src={backdropPath}
alt={title}
fill
priority
+3 -2
View File
@@ -1,5 +1,6 @@
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { GenreBrowser } from "./genre-browser";
import { HeroBanner } from "./hero-banner";
import { TitleRow } from "./title-row";
@@ -25,7 +26,7 @@ function mapResults(
? r.media_type
: fallbackType) as "movie" | "tv",
title: r.title ?? r.name ?? "",
posterPath: r.poster_path,
posterPath: tmdbImageUrl(r.poster_path, "w500"),
releaseDate: r.release_date ?? r.first_air_date ?? null,
voteAverage: r.vote_average,
}));
@@ -58,7 +59,7 @@ export default async function ExplorePage() {
type={heroTitle.media_type as "movie" | "tv"}
title={heroTitle.title ?? heroTitle.name ?? ""}
overview={heroTitle.overview}
backdropPath={heroTitle.backdrop_path}
backdropPath={tmdbImageUrl(heroTitle.backdrop_path, "w1280")}
voteAverage={heroTitle.vote_average}
/>
)}
+10 -15
View File
@@ -13,7 +13,6 @@ import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { TitleDetailSkeleton } from "@/components/skeletons";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { StarRating } from "@/components/star-rating";
import { StatusButton } from "@/components/status-button";
import { TitleCard } from "@/components/title-card";
@@ -366,8 +365,6 @@ export default function TitleDetailPage() {
);
}
const posterUrl = tmdbImageUrl(title.posterPath, "w500");
const backdropUrl = tmdbImageUrl(title.backdropPath, "w1280");
const dateStr = title.releaseDate ?? title.firstAirDate;
const year = dateStr?.slice(0, 4);
const palette = title.colorPalette;
@@ -462,10 +459,10 @@ export default function TitleDetailPage() {
</Breadcrumb>
{/* Backdrop hero */}
{backdropUrl && (
{title.backdropPath && (
<div className="relative -mx-4 -mt-4 h-80 overflow-hidden sm:-mx-6 sm:h-[28rem]">
<Image
src={backdropUrl}
src={title.backdropPath}
alt=""
fill
className="object-cover"
@@ -529,12 +526,12 @@ export default function TitleDetailPage() {
{/* Title header */}
<motion.div
className={`flex flex-col gap-8 sm:flex-row ${backdropUrl ? "-mt-32 relative z-10" : ""}`}
className={`flex flex-col gap-8 sm:flex-row ${title.backdropPath ? "-mt-32 relative z-10" : ""}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring" as const, stiffness: 200, damping: 24 }}
>
{posterUrl && (
{title.posterPath && (
<div className="shrink-0">
<div
className="overflow-hidden rounded-2xl ring-1 ring-foreground/5 shadow-2xl transition-shadow duration-500"
@@ -545,7 +542,7 @@ export default function TitleDetailPage() {
}}
>
<Image
src={posterUrl}
src={title.posterPath}
alt={title.title}
width={220}
height={330}
@@ -752,7 +749,7 @@ export default function TitleDetailPage() {
const isWatched = title.episodeWatches?.includes(
ep.id,
);
const stillUrl = tmdbImageUrl(ep.stillPath, "w300");
const { stillPath } = ep;
return (
<div
key={ep.id}
@@ -789,10 +786,10 @@ export default function TitleDetailPage() {
</motion.div>
)}
</button>
{stillUrl && (
{stillPath && (
<div className="hidden h-14 w-24 shrink-0 overflow-hidden rounded-md bg-muted sm:block">
<Image
src={stillUrl}
src={stillPath}
alt={ep.name ?? ""}
width={300}
height={169}
@@ -872,14 +869,12 @@ function ProviderBadge({
name: string;
logoPath: string | null;
}) {
const logoUrl = tmdbImageUrl(logoPath, "w92");
return (
<div className="group relative">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border/30 bg-card transition-transform hover:scale-105">
{logoUrl ? (
{logoPath ? (
<Image
src={logoUrl}
src={logoPath}
alt={name}
width={40}
height={40}
+2 -1
View File
@@ -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,
})),
+21 -1
View File
@@ -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,
})),
);
}
+7 -1
View File
@@ -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"),
})),
);
}
+10 -1
View File
@@ -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"),
})),
);
}
+61
View File
@@ -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,
},
});
}
+2 -1
View File
@@ -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,
})),
+10 -1
View File
@@ -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"),
})),
);
}
+19 -3
View File
@@ -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"),
})),
});
}
+5 -187
View File
@@ -1,25 +1,6 @@
"use client";
import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useSession } from "@/lib/auth/client";
import { LandingPage } from "@/components/landing-page";
import { tmdbImageUrl } from "@/lib/tmdb/image";
/** Check whether the server has TMDB configured. */
async function fetchSetupStatus(): Promise<boolean> {
try {
const res = await fetch("/api/setup/status");
if (!res.ok) return true; // assume configured on error
const data = await res.json();
return !!data.tmdbConfigured;
} catch {
return true;
}
}
// Well-known TMDB poster paths for the background collage
const posterPaths = [
"/1E5baAaEse26fej7uHcjOgEERB2.jpg", // The Dark Knight
@@ -36,173 +17,10 @@ const posterPaths = [
"/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg", // Forrest Gump
];
// Poster positions arranged in angled columns behind the hero
const posterLayout = [
// Left column
{ x: "8%", y: "5%", rotate: -8, delay: 0 },
{ x: "5%", y: "38%", rotate: -12, delay: 0.1 },
{ x: "10%", y: "68%", rotate: -6, delay: 0.2 },
// Left-center column
{ x: "24%", y: "12%", rotate: 4, delay: 0.05 },
{ x: "22%", y: "50%", rotate: -3, delay: 0.15 },
{ x: "26%", y: "78%", rotate: 6, delay: 0.25 },
// Right-center column
{ x: "62%", y: "8%", rotate: -5, delay: 0.08 },
{ x: "64%", y: "42%", rotate: 7, delay: 0.18 },
{ x: "60%", y: "72%", rotate: -4, delay: 0.28 },
// Right column
{ x: "80%", y: "3%", rotate: 10, delay: 0.03 },
{ x: "82%", y: "36%", rotate: -8, delay: 0.13 },
{ x: "78%", y: "66%", rotate: 5, delay: 0.23 },
];
const posterUrls = posterPaths
.map((p) => tmdbImageUrl(p, "w300"))
.filter(Boolean) as string[];
export default function Home() {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (isPending) return;
if (session?.user) {
router.replace("/dashboard");
return;
}
// If not logged in, check whether TMDB is configured
fetchSetupStatus().then((configured) => {
if (!configured) router.replace("/setup");
});
}, [session, isPending, router]);
if (isPending) return null;
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden">
{/* Background grain texture */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
{/* Floating poster collage */}
<div className="pointer-events-none absolute inset-0 hidden overflow-hidden sm:block">
{posterPaths.map((path, i) => {
const pos = posterLayout[i];
return (
<motion.div
key={path}
className="absolute w-28 md:w-32 lg:w-36"
style={{ left: pos.x, top: pos.y }}
initial={{ opacity: 0, scale: 0.8, rotate: pos.rotate }}
animate={{ opacity: 0.12, scale: 1, rotate: pos.rotate }}
transition={{
type: "spring" as const,
stiffness: 100,
damping: 20,
delay: 0.4 + pos.delay,
}}
>
<div className="overflow-hidden rounded-xl shadow-lg">
<Image
src={tmdbImageUrl(path, "w300")!}
alt=""
width={300}
height={450}
className="h-auto w-full"
/>
</div>
</motion.div>
);
})}
</div>
{/* Radial fade over posters to keep center clear */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-background via-background/95 to-background/40" />
{/* Warm primary glow */}
<motion.div
className="pointer-events-none absolute left-1/2 top-1/3 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/5 blur-[120px]"
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{
duration: 6,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
/>
<main className="relative z-10 flex flex-col items-center gap-10 px-6 text-center">
<div className="space-y-4">
<motion.p
className="text-sm font-medium uppercase tracking-[0.3em] text-primary"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring" as const,
stiffness: 200,
damping: 20,
}}
>
Self-hosted movie & TV tracker
</motion.p>
<motion.h1
className="font-display text-6xl tracking-tight sm:text-7xl md:text-8xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring" as const,
stiffness: 200,
damping: 20,
delay: 0.1,
}}
>
Sofa
</motion.h1>
<motion.p
className="mx-auto max-w-md text-lg leading-relaxed text-muted-foreground"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring" as const,
stiffness: 200,
damping: 20,
delay: 0.2,
}}
>
Track what you watch. Know what&apos;s next.
<br />
Your library, your data, your rules.
</motion.p>
</div>
<motion.div
className="flex gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring" as const,
stiffness: 200,
damping: 20,
delay: 0.35,
}}
>
<Link
href="/login"
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-all hover:shadow-lg hover:shadow-primary/20"
>
<span className="relative z-10">Sign In</span>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
<Link
href="/register"
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-all hover:border-primary/40 hover:bg-primary/5"
>
Register
</Link>
</motion.div>
</main>
{/* Bottom fade */}
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-background to-transparent" />
</div>
);
return <LandingPage posterUrls={posterUrls} />;
}