Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 127 additions & 42 deletions src/lib/imdb/tmdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<TmdbData | null> {
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<void> {
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<TmdbData> {
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;
Expand All @@ -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`
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
33 changes: 33 additions & 0 deletions supabase/migrations/20260303030000_tmdb_data.sql
Original file line number Diff line number Diff line change
@@ -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';
Loading