From 394bb7b3ab3b8b627d4288bbd9ad8750d5adb73e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 3 Mar 2026 02:53:01 +0000 Subject: [PATCH 1/4] feat: cache TMDB API responses in Supabase to reduce API calls - New tmdb_cache table (Supabase migration) - fetchTmdbData checks cache first, returns cached data if <30 days old - Cache key: IMDB ID or SHA256 of cleaned title - Misses cached too (avoids repeated lookups for non-existent content) - Cache writes are non-blocking and failure-tolerant - Extracted cleanTitleForSearch as reusable function Saves 2-3 TMDB API calls per page view for previously-seen content. --- src/lib/imdb/tmdb.ts | 177 +++++++++++++----- .../migrations/20260303030000_tmdb_cache.sql | 30 +++ 2 files changed, 165 insertions(+), 42 deletions(-) create mode 100644 supabase/migrations/20260303030000_tmdb_cache.sql diff --git a/src/lib/imdb/tmdb.ts b/src/lib/imdb/tmdb.ts index 224214c..01aae13 100644 --- a/src/lib/imdb/tmdb.ts +++ b/src/lib/imdb/tmdb.ts @@ -2,8 +2,13 @@ * TMDB Enrichment — fetches rich metadata from TMDB using an IMDB ID. * Makes 2-3 API calls: /find (IMDB→TMDB) + /movie or /tv (details+credits). * Falls back to /search if /find returns nothing (common for obscure IMDB entries). + * + * Results are cached in the tmdb_cache table to avoid repeated API calls. */ +import { createClient } from '@supabase/supabase-js'; +import { createHash } from 'crypto'; + export interface TmdbData { posterUrl: string | null; backdropUrl: string | null; @@ -20,10 +25,106 @@ const EMPTY: TmdbData = { cast: null, writers: null, contentRating: null, tmdbId: null, }; +/** Max age for cache entries (30 days) */ +const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +function getCacheKey(imdbId: string, titleHint?: string): string { + if (imdbId) return imdbId; + if (titleHint) return 'title:' + createHash('sha256').update(titleHint.toLowerCase().trim()).digest('hex').slice(0, 32); + return ''; +} + +function getSupabaseClient() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!url || !key) return null; + return createClient(url, key); +} + +async function getCached(key: string): Promise { + if (!key) return null; + try { + const supabase = getSupabaseClient(); + if (!supabase) return null; + const { data } = await supabase + .from('tmdb_cache') + .select('*') + .eq('lookup_key', key) + .single(); + if (!data) return null; + + // Check freshness + const age = Date.now() - new Date(data.updated_at).getTime(); + if (age > CACHE_MAX_AGE_MS) return null; + + return { + posterUrl: data.poster_url, + backdropUrl: data.backdrop_url, + overview: data.overview, + tagline: data.tagline, + cast: data.cast_names, + writers: data.writers, + contentRating: data.content_rating, + tmdbId: data.tmdb_id, + }; + } catch { + return null; + } +} + +async function setCache(key: string, data: TmdbData): Promise { + if (!key) return; + try { + const supabase = getSupabaseClient(); + if (!supabase) return; + await supabase + .from('tmdb_cache') + .upsert({ + lookup_key: key, + tmdb_id: data.tmdbId, + poster_url: data.posterUrl, + backdrop_url: data.backdropUrl, + overview: data.overview, + tagline: data.tagline, + cast_names: data.cast, + writers: data.writers, + content_rating: data.contentRating, + updated_at: new Date().toISOString(), + }, { onConflict: 'lookup_key' }); + } catch { + // Cache write failure is non-critical + } +} + +function cleanTitleForSearch(titleHint: string): string { + let cleanTitle = titleHint + .replace(/\.\w{2,4}$/, '') + .replace(/\[[^\]]*\]/g, '') + .replace(/\([^)]*\)/g, '') + .replace(/^(www\.)?[a-z0-9_-]+\.(org|com|net|io|tv|cc|to|bargains|club|xyz|me)\s*[-\u2013\u2014]\s*/i, '') + .replace(/[._]/g, ' ') + .replace(/(S\d{1,2}E\d{1,2}).*$/i, '') + .replace(/\b(1080p|720p|2160p|4k|480p|bluray|blu-ray|brrip|bdrip|dvdrip|webrip|web-?dl|webdl|hdtv|hdrip|x264|x265|hevc|avc|aac[0-9. ]*|ac3|dts|flac|mp3|remux|uhd|uhdr|hdr|hdr10|dv|dolby|vision|10bit|8bit|repack|proper|extended|unrated|dubbed|subbed|multi|dual|audio|subs|h264|h265)\b/gi, '') + .replace(/\b(HQ|HDRip|ESub|HDCAM|CAM|DVDScr|PDTV|TS|TC|SCR)\b/gi, '') + .replace(/\b(Malayalam|Tamil|Telugu|Hindi|Kannada|Bengali|Marathi|Punjabi|Gujarati|English|Spanish|French|German|Italian|Korean|Japanese|Chinese|Russian|Arabic|Turkish|Hungarian|Polish|Dutch|Portuguese|Ukrainian|Czech)\b/gi, '') + .replace(/\b\d+(\.\d+)?\s*(MB|GB|TB)\b/gi, '') + .replace(/\s*[-\u2013]\s*[A-Za-z0-9]{2,15}\s*$/, '') + .replace(/(19|20)\d{2}.*$/, '') + .replace(/\s+/g, ' ') + .trim(); + if (cleanTitle.length < 2) cleanTitle = titleHint; + return cleanTitle; +} + export async function fetchTmdbData(imdbId: string, titleHint?: string): Promise { const tmdbKey = process.env.TMDB_API_KEY; if (!tmdbKey) return EMPTY; - if (!imdbId && !titleHint) return EMPTY; + if (!imdbId && !titleHint) return EMPTY; + + // Check cache first + const cacheKey = getCacheKey(imdbId, titleHint); + const cached = await getCached(cacheKey); + if (cached) return cached; try { let tmdbId: number | null = null; @@ -32,50 +133,33 @@ export async function fetchTmdbData(imdbId: string, titleHint?: string): Promise let backdropUrl: string | null = null; let overview: string | null = null; - // Step 1: Find TMDB ID from IMDB ID (skip if no imdbId) + // Step 1: Find TMDB ID from IMDB ID if (imdbId) { - const findRes = await fetch( - `https://api.themoviedb.org/3/find/${imdbId}?api_key=${tmdbKey}&external_source=imdb_id` - ); - if (findRes.ok) { - const findData = await findRes.json() as any; - const movieResult = findData.movie_results?.[0]; - const tvResult = findData.tv_results?.[0]; - const result = movieResult || tvResult; - - if (result) { - tmdbId = result.id; - isTV = !movieResult && !!tvResult; - posterUrl = result.poster_path - ? `https://image.tmdb.org/t/p/w500${result.poster_path}` : null; - backdropUrl = result.backdrop_path - ? `https://image.tmdb.org/t/p/w1280${result.backdrop_path}` : null; - overview = result.overview || null; + const findRes = await fetch( + `https://api.themoviedb.org/3/find/${imdbId}?api_key=${tmdbKey}&external_source=imdb_id` + ); + if (findRes.ok) { + const findData = await findRes.json() as any; + const movieResult = findData.movie_results?.[0]; + const tvResult = findData.tv_results?.[0]; + const result = movieResult || tvResult; + + if (result) { + tmdbId = result.id; + isTV = !movieResult && !!tvResult; + posterUrl = result.poster_path + ? `https://image.tmdb.org/t/p/w500${result.poster_path}` : null; + backdropUrl = result.backdrop_path + ? `https://image.tmdb.org/t/p/w1280${result.backdrop_path}` : null; + overview = result.overview || null; + } } } - } - // Step 1b: Fallback — search TMDB by title if /find returned nothing + // Step 1b: Fallback — search TMDB by title if (!tmdbId && titleHint) { - // Clean the title: strip codecs, quality, brackets, file extensions, season/episode info - let cleanTitle = titleHint - .replace(/\.\w{2,4}$/, '') - .replace(/\[[^\]]*\]/g, '') - .replace(/\([^)]*\)/g, '') - .replace(/^(www\.)?[a-z0-9_-]+\.(org|com|net|io|tv|cc|to|bargains|club|xyz|me)\s*[-\u2013\u2014]\s*/i, '') - .replace(/[._]/g, ' ') - .replace(/(S\d{1,2}E\d{1,2}).*$/i, '') - .replace(/\b(1080p|720p|2160p|4k|480p|bluray|blu-ray|brrip|bdrip|dvdrip|webrip|web-?dl|webdl|hdtv|hdrip|x264|x265|hevc|avc|aac[0-9. ]*|ac3|dts|flac|mp3|remux|uhd|uhdr|hdr|hdr10|dv|dolby|vision|10bit|8bit|repack|proper|extended|unrated|dubbed|subbed|multi|dual|audio|subs|h264|h265)\b/gi, '') - .replace(/\b(HQ|HDRip|ESub|HDCAM|CAM|DVDScr|PDTV|TS|TC|SCR)\b/gi, '') - .replace(/\b(Malayalam|Tamil|Telugu|Hindi|Kannada|Bengali|Marathi|Punjabi|Gujarati|English|Spanish|French|German|Italian|Korean|Japanese|Chinese|Russian|Arabic|Turkish|Hungarian|Polish|Dutch|Portuguese|Ukrainian|Czech)\b/gi, '') - .replace(/\b\d+(\.\d+)?\s*(MB|GB|TB)\b/gi, '') - .replace(/\s*[-\u2013]\s*[A-Za-z0-9]{2,15}\s*$/, '') - .replace(/(19|20)\d{2}.*$/, '') - .replace(/\s+/g, ' ') - .trim(); - if (cleanTitle.length < 2) cleanTitle = titleHint; + const cleanTitle = cleanTitleForSearch(titleHint); const searchQuery = encodeURIComponent(cleanTitle); - // Try TV first, then movie for (const mediaType of ['tv', 'movie'] as const) { const searchRes = await fetch( `https://api.themoviedb.org/3/search/${mediaType}?api_key=${tmdbKey}&query=${searchQuery}&page=1` @@ -97,9 +181,13 @@ export async function fetchTmdbData(imdbId: string, titleHint?: string): Promise } } - if (!tmdbId) return EMPTY; + if (!tmdbId) { + // Cache the miss too (avoid repeated lookups for non-existent content) + await setCache(cacheKey, EMPTY); + return EMPTY; + } - // Step 2: Get credits + release info in one call + // Step 2: Get credits + release info let tagline: string | null = null; let cast: string | null = null; let writers: string | null = null; @@ -139,7 +227,12 @@ export async function fetchTmdbData(imdbId: string, titleHint?: string): Promise } } - return { posterUrl, backdropUrl, overview, tagline, cast, writers, contentRating, tmdbId }; + const result: TmdbData = { posterUrl, backdropUrl, overview, tagline, cast, writers, contentRating, tmdbId }; + + // Cache the result + await setCache(cacheKey, result); + + return result; } catch { return EMPTY; } diff --git a/supabase/migrations/20260303030000_tmdb_cache.sql b/supabase/migrations/20260303030000_tmdb_cache.sql new file mode 100644 index 0000000..af067a6 --- /dev/null +++ b/supabase/migrations/20260303030000_tmdb_cache.sql @@ -0,0 +1,30 @@ +-- Cache table for TMDB API responses +-- Avoids repeated API calls for the same content +CREATE TABLE IF NOT EXISTS tmdb_cache ( + -- Lookup key: either an IMDB ID (tt1234567) or a cleaned title hash + lookup_key text PRIMARY KEY, + tmdb_id integer, + poster_url text, + backdrop_url text, + overview text, + tagline text, + cast_names text, + writers text, + content_rating text, + -- Track freshness + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Index for cleanup of stale entries +CREATE INDEX idx_tmdb_cache_updated_at ON tmdb_cache (updated_at); + +-- Allow the app to read/write +ALTER TABLE tmdb_cache ENABLE ROW LEVEL SECURITY; + +-- Public read/write (no user-scoping needed, this is shared cache) +CREATE POLICY "tmdb_cache_public_read" ON tmdb_cache FOR SELECT USING (true); +CREATE POLICY "tmdb_cache_service_write" ON tmdb_cache FOR ALL USING (true); + +COMMENT ON TABLE tmdb_cache IS 'Cache for TMDB API responses to reduce API calls. Entries keyed by IMDB ID or title hash.'; +COMMENT ON COLUMN tmdb_cache.lookup_key IS 'IMDB ID (e.g. tt1234567) or sha256 of cleaned title for non-IMDB lookups'; From c58f8871ec13c5e2fabe4ff7fae40b4600971cc2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 3 Mar 2026 03:01:27 +0000 Subject: [PATCH 2/4] fix: remove cache TTL - TMDB metadata doesn't change --- src/lib/imdb/tmdb.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/lib/imdb/tmdb.ts b/src/lib/imdb/tmdb.ts index 01aae13..8450e6e 100644 --- a/src/lib/imdb/tmdb.ts +++ b/src/lib/imdb/tmdb.ts @@ -25,9 +25,6 @@ const EMPTY: TmdbData = { cast: null, writers: null, contentRating: null, tmdbId: null, }; -/** Max age for cache entries (30 days) */ -const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; - function getCacheKey(imdbId: string, titleHint?: string): string { if (imdbId) return imdbId; if (titleHint) return 'title:' + createHash('sha256').update(titleHint.toLowerCase().trim()).digest('hex').slice(0, 32); @@ -53,10 +50,6 @@ async function getCached(key: string): Promise { .single(); if (!data) return null; - // Check freshness - const age = Date.now() - new Date(data.updated_at).getTime(); - if (age > CACHE_MAX_AGE_MS) return null; - return { posterUrl: data.poster_url, backdropUrl: data.backdrop_url, @@ -89,7 +82,6 @@ async function setCache(key: string, data: TmdbData): Promise { cast_names: data.cast, writers: data.writers, content_rating: data.contentRating, - updated_at: new Date().toISOString(), }, { onConflict: 'lookup_key' }); } catch { // Cache write failure is non-critical From 331c24c1ee7a912be6ae787d251da863434fa496 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 3 Mar 2026 03:02:08 +0000 Subject: [PATCH 3/4] =?UTF-8?q?rename:=20tmdb=5Fcache=20=E2=86=92=20tmdb?= =?UTF-8?q?=5Fdata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/imdb/tmdb.ts | 6 +++--- ...tmdb_cache.sql => 20260303030000_tmdb_data.sql} | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) rename supabase/migrations/{20260303030000_tmdb_cache.sql => 20260303030000_tmdb_data.sql} (52%) diff --git a/src/lib/imdb/tmdb.ts b/src/lib/imdb/tmdb.ts index 8450e6e..aed9781 100644 --- a/src/lib/imdb/tmdb.ts +++ b/src/lib/imdb/tmdb.ts @@ -3,7 +3,7 @@ * Makes 2-3 API calls: /find (IMDB→TMDB) + /movie or /tv (details+credits). * Falls back to /search if /find returns nothing (common for obscure IMDB entries). * - * Results are cached in the tmdb_cache table to avoid repeated API calls. + * Results are cached in the tmdb_data table to avoid repeated API calls. */ import { createClient } from '@supabase/supabase-js'; @@ -44,7 +44,7 @@ async function getCached(key: string): Promise { const supabase = getSupabaseClient(); if (!supabase) return null; const { data } = await supabase - .from('tmdb_cache') + .from('tmdb_data') .select('*') .eq('lookup_key', key) .single(); @@ -71,7 +71,7 @@ async function setCache(key: string, data: TmdbData): Promise { const supabase = getSupabaseClient(); if (!supabase) return; await supabase - .from('tmdb_cache') + .from('tmdb_data') .upsert({ lookup_key: key, tmdb_id: data.tmdbId, diff --git a/supabase/migrations/20260303030000_tmdb_cache.sql b/supabase/migrations/20260303030000_tmdb_data.sql similarity index 52% rename from supabase/migrations/20260303030000_tmdb_cache.sql rename to supabase/migrations/20260303030000_tmdb_data.sql index af067a6..afed0ff 100644 --- a/supabase/migrations/20260303030000_tmdb_cache.sql +++ b/supabase/migrations/20260303030000_tmdb_data.sql @@ -1,6 +1,6 @@ -- Cache table for TMDB API responses -- Avoids repeated API calls for the same content -CREATE TABLE IF NOT EXISTS tmdb_cache ( +CREATE TABLE IF NOT EXISTS tmdb_data ( -- Lookup key: either an IMDB ID (tt1234567) or a cleaned title hash lookup_key text PRIMARY KEY, tmdb_id integer, @@ -17,14 +17,14 @@ CREATE TABLE IF NOT EXISTS tmdb_cache ( ); -- Index for cleanup of stale entries -CREATE INDEX idx_tmdb_cache_updated_at ON tmdb_cache (updated_at); +CREATE INDEX idx_tmdb_data_updated_at ON tmdb_data (updated_at); -- Allow the app to read/write -ALTER TABLE tmdb_cache ENABLE ROW LEVEL SECURITY; +ALTER TABLE tmdb_data ENABLE ROW LEVEL SECURITY; -- Public read/write (no user-scoping needed, this is shared cache) -CREATE POLICY "tmdb_cache_public_read" ON tmdb_cache FOR SELECT USING (true); -CREATE POLICY "tmdb_cache_service_write" ON tmdb_cache FOR ALL USING (true); +CREATE POLICY "tmdb_data_public_read" ON tmdb_data FOR SELECT USING (true); +CREATE POLICY "tmdb_data_service_write" ON tmdb_data FOR ALL USING (true); -COMMENT ON TABLE tmdb_cache IS 'Cache for TMDB API responses to reduce API calls. Entries keyed by IMDB ID or title hash.'; -COMMENT ON COLUMN tmdb_cache.lookup_key IS 'IMDB ID (e.g. tt1234567) or sha256 of cleaned title for non-IMDB lookups'; +COMMENT ON TABLE tmdb_data IS 'Cache for TMDB API responses to reduce API calls. Entries keyed by IMDB ID or title hash.'; +COMMENT ON COLUMN tmdb_data.lookup_key IS 'IMDB ID (e.g. tt1234567) or sha256 of cleaned title for non-IMDB lookups'; From e9d9971502e206b455ca95589470840cebc8ec07 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 3 Mar 2026 03:17:09 +0000 Subject: [PATCH 4/4] fix: restrict tmdb_data writes to service_role, remove anon key fallback Addresses Copilot review: 1. RLS policy now restricts writes to service_role only 2. getSupabaseClient() requires SUPABASE_SERVICE_ROLE_KEY (no anon key fallback) --- src/lib/imdb/tmdb.ts | 4 ++-- supabase/migrations/20260303030000_tmdb_data.sql | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/imdb/tmdb.ts b/src/lib/imdb/tmdb.ts index aed9781..26397ed 100644 --- a/src/lib/imdb/tmdb.ts +++ b/src/lib/imdb/tmdb.ts @@ -32,8 +32,8 @@ function getCacheKey(imdbId: string, titleHint?: string): string { } function getSupabaseClient() { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; - const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!url || !key) return null; return createClient(url, key); } diff --git a/supabase/migrations/20260303030000_tmdb_data.sql b/supabase/migrations/20260303030000_tmdb_data.sql index afed0ff..7063b28 100644 --- a/supabase/migrations/20260303030000_tmdb_data.sql +++ b/supabase/migrations/20260303030000_tmdb_data.sql @@ -24,7 +24,10 @@ ALTER TABLE tmdb_data ENABLE ROW LEVEL SECURITY; -- Public read/write (no user-scoping needed, this is shared cache) CREATE POLICY "tmdb_data_public_read" ON tmdb_data FOR SELECT USING (true); -CREATE POLICY "tmdb_data_service_write" ON tmdb_data FOR ALL USING (true); +CREATE POLICY "tmdb_data_service_write" ON tmdb_data + FOR ALL + USING (auth.role() = 'service_role') + WITH CHECK (auth.role() = 'service_role'); COMMENT ON TABLE tmdb_data IS 'Cache for TMDB API responses to reduce API calls. Entries keyed by IMDB ID or title hash.'; COMMENT ON COLUMN tmdb_data.lookup_key IS 'IMDB ID (e.g. tt1234567) or sha256 of cleaned title for non-IMDB lookups';