diff --git a/src/lib/imdb/tmdb.ts b/src/lib/imdb/tmdb.ts index 224214c..26397ed 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_data 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,98 @@ const EMPTY: TmdbData = { cast: null, writers: null, contentRating: null, tmdbId: null, }; +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.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); +} + +async function getCached(key: string): Promise { + if (!key) return null; + try { + const supabase = getSupabaseClient(); + if (!supabase) return null; + const { data } = await supabase + .from('tmdb_data') + .select('*') + .eq('lookup_key', key) + .single(); + if (!data) 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_data') + .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, + }, { 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 +125,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 +173,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 +219,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_data.sql b/supabase/migrations/20260303030000_tmdb_data.sql new file mode 100644 index 0000000..7063b28 --- /dev/null +++ b/supabase/migrations/20260303030000_tmdb_data.sql @@ -0,0 +1,33 @@ +-- Cache table for TMDB API responses +-- Avoids repeated API calls for the same content +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, + 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_data_updated_at ON tmdb_data (updated_at); + +-- Allow the app to read/write +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 (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';