mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
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>
427 lines
12 KiB
TypeScript
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));
|
|
}
|