mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -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:
@@ -13,3 +13,8 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||
# Optional: override TMDB base URLs (advanced)
|
||||
# TMDB_API_BASE_URL=https://api.themoviedb.org/3
|
||||
# TMDB_IMAGE_BASE_URL=https://image.tmdb.org/t/p
|
||||
|
||||
# Image caching — downloads TMDB images to local disk for faster serving
|
||||
# Set to "false" to disable and use TMDB CDN directly (default: enabled)
|
||||
# IMAGE_CACHE_ENABLED=false
|
||||
# IMAGE_CACHE_DIR=/data/images
|
||||
|
||||
@@ -70,18 +70,23 @@ All app tables use UUID text primary keys generated via the `uuid` package. Bett
|
||||
|
||||
### Service layer
|
||||
|
||||
- **metadata.ts**: `importTitle()` fetches from TMDB, inserts into DB, fire-and-forgets availability + recommendations refresh. `refreshTvChildren()` fetches all seasons/episodes with 250ms rate limiting.
|
||||
- **metadata.ts**: `importTitle()` fetches from TMDB, inserts into DB, fire-and-forgets availability + recommendations + image caching. `refreshTvChildren()` fetches all seasons/episodes with 250ms rate limiting.
|
||||
- **image-cache.ts**: Downloads and caches TMDB images to local disk. `cacheImagesForTitle()` caches poster + backdrop, `cacheEpisodeStills()` caches episode stills, `cacheProviderLogos()` caches streaming provider logos.
|
||||
- **tracking.ts**: Auto-transitions — logging a movie watch sets status to `completed`; logging an episode watch sets `in_progress`; all episodes watched auto-completes the series.
|
||||
- **discovery.ts**: Feed generators — continue watching (next unwatched episode per in-progress show), library titles with availability, personalized recommendations from completed/highly-rated titles.
|
||||
- **availability.ts**: Caches US streaming providers from TMDB watch/providers endpoint.
|
||||
|
||||
### TMDB images
|
||||
|
||||
Only paths are stored in DB. Full URLs are constructed at render time via `tmdbImageUrl()` from `lib/tmdb/image.ts` using the `TMDB_IMAGE_BASE_URL` env var (defaults to `https://image.tmdb.org/t/p`). Common sizes: w300, w500, w1280.
|
||||
Only paths are stored in DB. Image URLs are resolved **server-side only** — API routes and server components call `tmdbImageUrl()` from `lib/tmdb/image.ts` before sending data to clients. Client components never import `tmdbImageUrl`; they receive ready-to-use URLs.
|
||||
|
||||
When `IMAGE_CACHE_ENABLED` is set (default), images are downloaded to local disk (`IMAGE_CACHE_DIR`, defaults to `/data/images`) and served via `app/api/images/[...path]/route.ts`. Categories: `posters` (w500), `backdrops` (w1280), `stills` (w1280), `logos` (w92). When disabled, `tmdbImageUrl()` returns direct TMDB CDN URLs using `TMDB_IMAGE_BASE_URL` (defaults to `https://image.tmdb.org/t/p`).
|
||||
|
||||
Core files: `lib/services/image-cache.ts` (caching logic), `lib/tmdb/image.ts` (URL construction), `app/api/images/[...path]/route.ts` (proxy route).
|
||||
|
||||
### Background jobs
|
||||
|
||||
Registered in `lib/jobs/registry.ts`, started via Next.js instrumentation hook (`instrumentation.ts`) in production. Jobs: nightly library refresh (24h), availability refresh (6h), recommendations refresh (12h), TV episodes refresh (12h). All batch jobs use 300ms delay between TMDB calls.
|
||||
Registered in `lib/jobs/registry.ts`, started via Next.js instrumentation hook (`instrumentation.ts`) in production. Jobs: nightly library refresh (24h), availability refresh (6h), recommendations refresh (12h), TV episodes refresh (12h), image caching (12h). All batch jobs use 300ms delay between TMDB calls.
|
||||
|
||||
### Environment variables
|
||||
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ ENV PORT=3000
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs \
|
||||
&& mkdir -p /data && chown nextjs:nodejs /data
|
||||
&& mkdir -p /data/images/posters /data/images/backdrops /data/images/stills /data/images/logos \
|
||||
&& chown -R nextjs:nodejs /data
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
+5
-187
@@ -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'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} />;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useKeyboard } from "@/components/keyboard-provider";
|
||||
@@ -212,7 +211,7 @@ export function CommandPalette() {
|
||||
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
|
||||
{r.posterPath ? (
|
||||
<Image
|
||||
src={tmdbImageUrl(r.posterPath, "w92")!}
|
||||
src={r.posterPath as string}
|
||||
alt={r.title}
|
||||
width={32}
|
||||
height={48}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"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";
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 },
|
||||
];
|
||||
|
||||
export function LandingPage({ posterUrls }: { posterUrls: string[] }) {
|
||||
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">
|
||||
{posterUrls.map((url, i) => {
|
||||
const pos = posterLayout[i];
|
||||
return (
|
||||
<motion.div
|
||||
key={url}
|
||||
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={url}
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
interface SearchResult {
|
||||
tmdbId: number;
|
||||
@@ -166,7 +165,7 @@ export function SearchAutocomplete({
|
||||
<div className="h-[60px] w-10 shrink-0 overflow-hidden rounded-md bg-muted">
|
||||
{r.posterPath ? (
|
||||
<Image
|
||||
src={tmdbImageUrl(r.posterPath, "w92")!}
|
||||
src={r.posterPath as string}
|
||||
alt={r.title}
|
||||
width={40}
|
||||
height={60}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
interface TitleCardProps {
|
||||
id?: string;
|
||||
@@ -28,7 +27,6 @@ export function TitleCard({
|
||||
onImport,
|
||||
}: TitleCardProps) {
|
||||
const year = releaseDate?.slice(0, 4);
|
||||
const posterUrl = tmdbImageUrl(posterPath, "w300");
|
||||
|
||||
const content = (
|
||||
<motion.div
|
||||
@@ -37,9 +35,9 @@ export function TitleCard({
|
||||
transition={{ type: "spring" as const, stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<div className="aspect-[2/3] overflow-hidden rounded-xl bg-card">
|
||||
{posterUrl ? (
|
||||
{posterPath ? (
|
||||
<Image
|
||||
src={posterUrl}
|
||||
src={posterPath}
|
||||
alt={title}
|
||||
width={300}
|
||||
height={450}
|
||||
|
||||
@@ -9,6 +9,14 @@ export async function register() {
|
||||
const { runMigrations } = await import("@/lib/db/migrate");
|
||||
await runMigrations();
|
||||
|
||||
// Ensure image cache directories exist
|
||||
const { ensureImageDirs, imageCacheEnabled } = await import(
|
||||
"@/lib/services/image-cache"
|
||||
);
|
||||
if (imageCacheEnabled()) {
|
||||
ensureImageDirs();
|
||||
}
|
||||
|
||||
const { initJobs } = await import("@/lib/jobs/init");
|
||||
initJobs();
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
userTitleStatus,
|
||||
} from "@/lib/db/schema";
|
||||
import { refreshAvailability } from "@/lib/services/availability";
|
||||
import {
|
||||
cacheEpisodeStills,
|
||||
cacheImagesForTitle,
|
||||
cacheProviderLogos,
|
||||
imageCacheEnabled,
|
||||
} from "@/lib/services/image-cache";
|
||||
import {
|
||||
refreshRecommendations,
|
||||
refreshTitle,
|
||||
@@ -151,6 +157,24 @@ async function refreshTvChildrenJob() {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache images for all library titles (posters, backdrops, stills, logos)
|
||||
async function cacheImagesJob() {
|
||||
if (!imageCacheEnabled()) return;
|
||||
|
||||
const libraryIds = await getLibraryTitleIds();
|
||||
|
||||
for (const titleId of libraryIds) {
|
||||
try {
|
||||
await cacheImagesForTitle(titleId);
|
||||
await cacheEpisodeStills(titleId);
|
||||
await cacheProviderLogos(titleId);
|
||||
} catch {
|
||||
// Continue with remaining titles
|
||||
}
|
||||
await delay(RATE_LIMIT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerJobs() {
|
||||
scheduler.register("nightlyRefreshLibrary", nightlyRefreshLibrary, 24 * HOUR);
|
||||
scheduler.register("refreshAvailability", refreshAvailabilityJob, 6 * HOUR);
|
||||
@@ -160,4 +184,5 @@ export function registerJobs() {
|
||||
12 * HOUR,
|
||||
);
|
||||
scheduler.register("refreshTvChildren", refreshTvChildrenJob, 12 * HOUR);
|
||||
scheduler.register("cacheImages", cacheImagesJob, 12 * HOUR);
|
||||
}
|
||||
|
||||
+19
-3
@@ -1,7 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Vibrant } from "node-vibrant/node";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { titles } from "@/lib/db/schema";
|
||||
import {
|
||||
getLocalImagePath,
|
||||
imageCacheEnabled,
|
||||
isImageCached,
|
||||
} from "@/lib/services/image-cache";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
export interface ColorPalette {
|
||||
@@ -17,11 +23,21 @@ export async function extractAndStoreColors(
|
||||
titleId: string,
|
||||
posterPath: string | null,
|
||||
): Promise<ColorPalette | null> {
|
||||
const url = tmdbImageUrl(posterPath, "w300");
|
||||
if (!url) return null;
|
||||
if (!posterPath) return null;
|
||||
|
||||
// Prefer local file when image cache is active
|
||||
let source: string;
|
||||
const filename = path.basename(posterPath);
|
||||
if (imageCacheEnabled() && isImageCached("posters", filename)) {
|
||||
source = getLocalImagePath("posters", filename);
|
||||
} else {
|
||||
const url = tmdbImageUrl(posterPath, "w300");
|
||||
if (!url) return null;
|
||||
source = url;
|
||||
}
|
||||
|
||||
try {
|
||||
const palette = await Vibrant.from(url).getPalette();
|
||||
const palette = await Vibrant.from(source).getPalette();
|
||||
|
||||
const colors: ColorPalette = {
|
||||
vibrant: palette.Vibrant?.hex ?? null,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema";
|
||||
|
||||
export type ImageCategory = "posters" | "backdrops" | "stills" | "logos";
|
||||
|
||||
const CATEGORY_SIZES: Record<ImageCategory, string> = {
|
||||
posters: "w500",
|
||||
backdrops: "w1280",
|
||||
stills: "w1280",
|
||||
logos: "w92",
|
||||
};
|
||||
|
||||
const IMAGE_BASE_URL =
|
||||
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
|
||||
const CACHE_DIR = process.env.IMAGE_CACHE_DIR || "/data/images";
|
||||
|
||||
export function imageCacheEnabled(): boolean {
|
||||
return process.env.IMAGE_CACHE_ENABLED !== "false";
|
||||
}
|
||||
|
||||
export function ensureImageDirs() {
|
||||
for (const category of Object.keys(CATEGORY_SIZES)) {
|
||||
const dir = path.join(CACHE_DIR, category);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isImageCached(
|
||||
category: ImageCategory,
|
||||
filename: string,
|
||||
): boolean {
|
||||
return existsSync(getLocalImagePath(category, filename));
|
||||
}
|
||||
|
||||
export function getLocalImagePath(
|
||||
category: ImageCategory,
|
||||
filename: string,
|
||||
): string {
|
||||
return path.join(CACHE_DIR, category, path.basename(filename));
|
||||
}
|
||||
|
||||
export async function readCachedImage(
|
||||
category: ImageCategory,
|
||||
filename: string,
|
||||
): Promise<Buffer | null> {
|
||||
const filePath = getLocalImagePath(category, filename);
|
||||
if (!existsSync(filePath)) return null;
|
||||
return readFile(filePath);
|
||||
}
|
||||
|
||||
export async function downloadAndCacheImage(
|
||||
tmdbPath: string,
|
||||
category: ImageCategory,
|
||||
): Promise<Buffer | null> {
|
||||
const size = CATEGORY_SIZES[category];
|
||||
const url = `${IMAGE_BASE_URL}/${size}${tmdbPath}`;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const filename = path.basename(tmdbPath);
|
||||
const finalPath = getLocalImagePath(category, filename);
|
||||
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
|
||||
|
||||
try {
|
||||
await writeFile(tmpPath, buffer);
|
||||
await rename(tmpPath, finalPath);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function fetchAndMaybeCache(
|
||||
tmdbPath: string,
|
||||
category: ImageCategory,
|
||||
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||
// Try local first
|
||||
const filename = path.basename(tmdbPath);
|
||||
const cached = await readCachedImage(category, filename);
|
||||
if (cached) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const contentType =
|
||||
ext === ".png"
|
||||
? "image/png"
|
||||
: ext === ".webp"
|
||||
? "image/webp"
|
||||
: "image/jpeg";
|
||||
return { buffer: cached, contentType };
|
||||
}
|
||||
|
||||
// Fetch from TMDB
|
||||
const size = CATEGORY_SIZES[category];
|
||||
const url = `${IMAGE_BASE_URL}/${size}${tmdbPath}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const contentType = res.headers.get("content-type") || "image/jpeg";
|
||||
|
||||
// Fire-and-forget save to disk
|
||||
const finalPath = getLocalImagePath(category, filename);
|
||||
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
|
||||
writeFile(tmpPath, buffer)
|
||||
.then(() => rename(tmpPath, finalPath))
|
||||
.catch(() => {});
|
||||
|
||||
return { buffer, contentType };
|
||||
}
|
||||
|
||||
export async function cacheImagesForTitle(titleId: string) {
|
||||
const title = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, titleId))
|
||||
.get();
|
||||
if (!title) return;
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (
|
||||
title.posterPath &&
|
||||
!isImageCached("posters", path.basename(title.posterPath))
|
||||
) {
|
||||
tasks.push(downloadAndCacheImage(title.posterPath, "posters"));
|
||||
}
|
||||
if (
|
||||
title.backdropPath &&
|
||||
!isImageCached("backdrops", path.basename(title.backdropPath))
|
||||
) {
|
||||
tasks.push(downloadAndCacheImage(title.backdropPath, "backdrops"));
|
||||
}
|
||||
|
||||
// Season posters
|
||||
if (title.type === "tv") {
|
||||
const allSeasons = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
for (const s of allSeasons) {
|
||||
if (
|
||||
s.posterPath &&
|
||||
!isImageCached("posters", path.basename(s.posterPath))
|
||||
) {
|
||||
tasks.push(downloadAndCacheImage(s.posterPath, "posters"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
|
||||
export async function cacheEpisodeStills(titleId: string) {
|
||||
const allSeasons = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
|
||||
for (const s of allSeasons) {
|
||||
const eps = await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, s.id))
|
||||
.all();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
for (const ep of eps) {
|
||||
if (
|
||||
ep.stillPath &&
|
||||
!isImageCached("stills", path.basename(ep.stillPath))
|
||||
) {
|
||||
tasks.push(downloadAndCacheImage(ep.stillPath, "stills"));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheProviderLogos(titleId: string) {
|
||||
const offers = await db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
.where(eq(availabilityOffers.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const offer of offers) {
|
||||
if (offer.logoPath) {
|
||||
const basename = path.basename(offer.logoPath);
|
||||
if (!seen.has(basename) && !isImageCached("logos", basename)) {
|
||||
seen.add(basename);
|
||||
tasks.push(downloadAndCacheImage(offer.logoPath, "logos"));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
} from "@/lib/tmdb/client";
|
||||
import { refreshAvailability } from "./availability";
|
||||
import { extractAndStoreColors } from "./colors";
|
||||
import {
|
||||
cacheEpisodeStills,
|
||||
cacheImagesForTitle,
|
||||
imageCacheEnabled,
|
||||
} from "./image-cache";
|
||||
|
||||
export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
const existing = await db
|
||||
@@ -52,6 +57,10 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
||||
refreshAvailability(existing.id).catch(() => {});
|
||||
refreshRecommendations(existing.id).catch(() => {});
|
||||
if (imageCacheEnabled()) {
|
||||
cacheImagesForTitle(existing.id).catch(() => {});
|
||||
cacheEpisodeStills(existing.id).catch(() => {});
|
||||
}
|
||||
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
|
||||
}
|
||||
}
|
||||
@@ -81,10 +90,11 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
// Fire-and-forget: fetch availability, recommendations & colors
|
||||
// Fire-and-forget: fetch availability, recommendations, colors & image cache
|
||||
refreshAvailability(row.id).catch(() => {});
|
||||
refreshRecommendations(row.id).catch(() => {});
|
||||
extractAndStoreColors(row.id, movie.poster_path).catch(() => {});
|
||||
if (imageCacheEnabled()) cacheImagesForTitle(row.id).catch(() => {});
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -110,10 +120,14 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
.get();
|
||||
|
||||
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
|
||||
// Fire-and-forget: fetch availability, recommendations & colors
|
||||
// Fire-and-forget: fetch availability, recommendations, colors & image cache
|
||||
refreshAvailability(row.id).catch(() => {});
|
||||
refreshRecommendations(row.id).catch(() => {});
|
||||
extractAndStoreColors(row.id, show.poster_path).catch(() => {});
|
||||
if (imageCacheEnabled()) {
|
||||
cacheImagesForTitle(row.id).catch(() => {});
|
||||
cacheEpisodeStills(row.id).catch(() => {});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -175,6 +189,10 @@ export async function refreshTitle(titleId: string) {
|
||||
.get();
|
||||
if (updated) {
|
||||
extractAndStoreColors(updated.id, updated.posterPath).catch(() => {});
|
||||
if (imageCacheEnabled()) {
|
||||
cacheImagesForTitle(updated.id).catch(() => {});
|
||||
if (updated.type === "tv") cacheEpisodeStills(updated.id).catch(() => {});
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
+21
-2
@@ -1,7 +1,26 @@
|
||||
import type { ImageCategory } from "@/lib/services/image-cache";
|
||||
|
||||
const IMAGE_BASE_URL =
|
||||
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
|
||||
export function tmdbImageUrl(path: string | null, size = "w500") {
|
||||
function sizeToCategory(size: string): ImageCategory {
|
||||
if (size === "w92") return "logos";
|
||||
if (size === "w1280") return "backdrops";
|
||||
return "posters";
|
||||
}
|
||||
|
||||
export function tmdbImageUrl(
|
||||
path: string | null,
|
||||
size = "w500",
|
||||
category?: ImageCategory,
|
||||
) {
|
||||
if (!path) return null;
|
||||
return `${IMAGE_BASE_URL}/${size}${path}`;
|
||||
|
||||
if (process.env.IMAGE_CACHE_ENABLED === "false") {
|
||||
return `${IMAGE_BASE_URL}/${size}${path}`;
|
||||
}
|
||||
|
||||
const resolved = category ?? sizeToCategory(size);
|
||||
const filename = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `/api/images/${resolved}/${filename}`;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ const imageHost = imageBaseUrl
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactCompiler: true,
|
||||
env: {
|
||||
TMDB_IMAGE_BASE_URL: process.env.TMDB_IMAGE_BASE_URL || "",
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user