Files
sofa/lib/services/metadata.ts
T
jakeandClaude Opus 4.6 ee723e41d6 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>
2026-03-02 13:10:17 -05:00

427 lines
12 KiB
TypeScript

import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
episodes,
seasons,
titleRecommendations,
titles,
} from "@/lib/db/schema";
import {
getMovieDetails,
getRecommendations,
getSimilar,
getTvDetails,
getTvSeasonDetails,
} 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
.select()
.from(titles)
.where(eq(titles.tmdbId, tmdbId))
.get();
if (existing) {
// For TV shows, check if seasons/episodes were actually loaded.
// They may be missing if a prior fetch failed or the title was created
// as a shell by the recommendations system (lastFetchedAt: null).
if (existing.type === "tv") {
const seasonCount = (
await db
.select()
.from(seasons)
.where(eq(seasons.titleId, existing.id))
.all()
).length;
if (seasonCount === 0) {
const show = await getTvDetails(tmdbId);
if (!existing.lastFetchedAt) {
await db
.update(titles)
.set({
overview: show.overview,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
status: show.status,
lastFetchedAt: new Date(),
})
.where(eq(titles.id, existing.id))
.run();
}
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();
}
}
return existing;
}
const now = new Date();
if (type === "movie") {
const movie = await getMovieDetails(tmdbId);
const row = await db
.insert(titles)
.values({
tmdbId: movie.id,
type: "movie",
title: movie.title,
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
lastFetchedAt: now,
})
.returning()
.get();
// 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;
}
const show = await getTvDetails(tmdbId);
const row = await db
.insert(titles)
.values({
tmdbId: show.id,
type: "tv",
title: show.name,
originalTitle: show.original_name,
overview: show.overview,
firstAirDate: show.first_air_date || null,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
popularity: show.popularity,
voteAverage: show.vote_average,
voteCount: show.vote_count,
status: show.status,
lastFetchedAt: now,
})
.returning()
.get();
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
// 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;
}
export async function refreshTitle(titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title) return null;
const now = new Date();
if (title.type === "movie") {
const movie = await getMovieDetails(title.tmdbId);
await db
.update(titles)
.set({
title: movie.title,
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
lastFetchedAt: now,
})
.where(eq(titles.id, titleId))
.run();
} else {
const show = await getTvDetails(title.tmdbId);
await db
.update(titles)
.set({
title: show.name,
originalTitle: show.original_name,
overview: show.overview,
firstAirDate: show.first_air_date || null,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
popularity: show.popularity,
voteAverage: show.vote_average,
voteCount: show.vote_count,
status: show.status,
lastFetchedAt: now,
})
.where(eq(titles.id, titleId))
.run();
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
}
const updated = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.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;
}
export async function refreshTvChildren(
titleId: string,
tmdbId: number,
numberOfSeasons: number,
) {
const now = new Date();
for (let sn = 1; sn <= numberOfSeasons; sn++) {
// Rate-limit: 250ms between TMDB calls
if (sn > 1) await delay(250);
try {
const seasonData = await getTvSeasonDetails(tmdbId, sn);
const seasonRow = await db
.insert(seasons)
.values({
titleId,
seasonNumber: seasonData.season_number,
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
})
.onConflictDoUpdate({
target: [seasons.titleId, seasons.seasonNumber],
set: {
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
},
})
.returning()
.get();
for (const ep of seasonData.episodes) {
await db
.insert(episodes)
.values({
seasonId: seasonRow.id,
episodeNumber: ep.episode_number,
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
})
.onConflictDoUpdate({
target: [episodes.seasonId, episodes.episodeNumber],
set: {
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
},
})
.run();
}
} catch {
// Skip this season and continue with the rest — partial data is
// better than aborting entirely. The next refresh cycle will retry.
console.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}`);
}
}
}
export async function refreshRecommendations(titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title) return;
const now = new Date();
// Fetch both recommendations and similar
const [recs, similar] = await Promise.all([
getRecommendations(title.tmdbId, title.type),
getSimilar(title.tmdbId, title.type),
]);
// Process recommendations
for (let i = 0; i < recs.results.length && i < 20; i++) {
const r = recs.results[i];
const type = r.media_type ?? title.type;
if (type !== "movie" && type !== "tv") continue;
// Minimal upsert of the recommended title
const existing = await db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
.get();
let recTitleId: string;
if (existing) {
recTitleId = existing.id;
} else {
const row = await db
.insert(titles)
.values({
tmdbId: r.id,
type,
title: r.title ?? r.name ?? "Unknown",
originalTitle: r.original_title ?? r.original_name,
overview: r.overview,
releaseDate: r.release_date,
firstAirDate: r.first_air_date,
posterPath: r.poster_path,
backdropPath: r.backdrop_path,
popularity: r.popularity,
voteAverage: r.vote_average,
voteCount: r.vote_count,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
if (!row) {
const found = await db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
.get();
if (!found) continue;
recTitleId = found.id;
} else {
recTitleId = row.id;
}
}
await db
.insert(titleRecommendations)
.values({
titleId,
recommendedTitleId: recTitleId,
source: "tmdb_recommendations",
rank: i + 1,
lastFetchedAt: now,
})
.onConflictDoUpdate({
target: [
titleRecommendations.titleId,
titleRecommendations.recommendedTitleId,
titleRecommendations.source,
],
set: { rank: i + 1, lastFetchedAt: now },
})
.run();
}
// Process similar
for (let i = 0; i < similar.results.length && i < 20; i++) {
const r = similar.results[i];
const type = r.media_type ?? title.type;
if (type !== "movie" && type !== "tv") continue;
const existing = await db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
.get();
let recTitleId: string;
if (existing) {
recTitleId = existing.id;
} else {
const row = await db
.insert(titles)
.values({
tmdbId: r.id,
type,
title: r.title ?? r.name ?? "Unknown",
originalTitle: r.original_title ?? r.original_name,
overview: r.overview,
releaseDate: r.release_date,
firstAirDate: r.first_air_date,
posterPath: r.poster_path,
backdropPath: r.backdrop_path,
popularity: r.popularity,
voteAverage: r.vote_average,
voteCount: r.vote_count,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
if (!row) {
const found = await db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
.get();
if (!found) continue;
recTitleId = found.id;
} else {
recTitleId = row.id;
}
}
await db
.insert(titleRecommendations)
.values({
titleId,
recommendedTitleId: recTitleId,
source: "tmdb_similar",
rank: i + 1,
lastFetchedAt: now,
})
.onConflictDoUpdate({
target: [
titleRecommendations.titleId,
titleRecommendations.recommendedTitleId,
titleRecommendations.source,
],
set: { rank: i + 1, lastFetchedAt: now },
})
.run();
}
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}