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/src/lib/streaming/streaming.ts b/src/lib/streaming/streaming.ts index ad5f77f..9238f99 100644 --- a/src/lib/streaming/streaming.ts +++ b/src/lib/streaming/streaming.ts @@ -303,8 +303,9 @@ function getMemoryThresholds(): { warning: number; critical: number; severe: num } const { warning: MEMORY_WARNING_THRESHOLD, critical: MEMORY_CRITICAL_THRESHOLD, severe: MEMORY_SEVERE_THRESHOLD } = getMemoryThresholds(); -const MEMORY_CHECK_INTERVAL_MS = 30000; // Check every 30 seconds +const MEMORY_CHECK_INTERVAL_MS = 15000; // Check every 15 seconds const TORRENT_MIN_AGE_MS = 30 * 60 * 1000; // Don't cleanup torrents younger than 30 minutes +const TORRENT_MIN_AGE_CRITICAL_MS = 5 * 60 * 1000; // Shortened to 5 minutes under critical/severe pressure const ORPHAN_TORRENT_MAX_AGE_MS = 2 * 60 * 60 * 1000; // Auto-cleanup torrents with 0 watchers after 2 hours /** @@ -344,7 +345,7 @@ export class StreamingService { ensureDir(this.downloadPath); this.options = options; - this.maxConcurrentStreams = options.maxConcurrentStreams ?? 10; + this.maxConcurrentStreams = options.maxConcurrentStreams ?? 4; this.streamTimeout = options.streamTimeout ?? 120000; this.torrentCleanupDelay = options.torrentCleanupDelay ?? DEFAULT_CLEANUP_DELAY; this.activeStreams = new Map(); @@ -724,6 +725,7 @@ export class StreamingService { }); this.killOldestStreams(Math.max(3, Math.floor(this.activeStreams.size / 2))); this.emergencyCleanup(); + this.aggressiveCleanup('severe'); } else if (memUsage.rss >= MEMORY_CRITICAL_THRESHOLD) { logger.error('CRITICAL memory pressure - triggering emergency cleanup', { rssMB, @@ -732,6 +734,7 @@ export class StreamingService { activeStreams: this.activeStreams.size, }); this.emergencyCleanup(); + this.aggressiveCleanup('critical'); } else if (memUsage.rss >= MEMORY_WARNING_THRESHOLD) { logger.warn('High memory pressure - triggering aggressive cleanup', { rssMB, @@ -747,19 +750,60 @@ export class StreamingService { * Kill the oldest N streams to free memory during severe pressure */ private killOldestStreams(count: number): void { - // NEVER kill active streams. Users are watching these — killing them mid-playback - // is a terrible UX. If memory is truly critical, systemd's MemoryMax will handle it. - // Emergency/aggressive cleanup already removes idle torrents without active watchers. - logger.warn('killOldestStreams called but SKIPPING — active streams are protected', { - requestedKill: count, - activeStreams: this.activeStreams.size, + // Only kill streams that have NO active watchers (nobody is watching). + // Active watchers = someone has an SSE connection open for this torrent. + // This protects users mid-playback while still freeing unwatched resources. + const unwatchedStreams: Array<[string, ActiveStream]> = []; + + for (const [id, stream] of this.activeStreams) { + const watcherInfo = this.torrentWatchers.get(stream.infohash); + const watcherCount = watcherInfo?.watchers.size ?? 0; + if (watcherCount === 0) { + unwatchedStreams.push([id, stream]); + } + } + + if (unwatchedStreams.length === 0) { + logger.warn('killOldestStreams: all streams have active watchers — skipping to protect playback', { + requestedKill: count, + activeStreams: this.activeStreams.size, + }); + return; + } + + // Sort by creation time (oldest first) and kill up to `count` + const toKill = unwatchedStreams.slice(0, count); + for (const [id, stream] of toKill) { + logger.warn('Killing unwatched stream under memory pressure', { + streamId: id, + infohash: stream.infohash, + }); + // Destroy the stream's torrent + const torrent = (this.client?.torrents ?? []).find(t => t.infoHash === stream.infohash); + if (torrent) { + (torrent.destroy as (opts: { destroyStore: boolean }, callback?: (err: Error | null) => void) => void)( + { destroyStore: true }, + () => { + this.deleteTorrentFolder(torrent.name, stream.infohash).catch(() => {}); + } + ); + } + this.activeStreams.delete(id); + this.torrentWatchers.delete(stream.infohash); + this.torrentAddedAt.delete(stream.infohash); + } + + logger.warn('killOldestStreams completed', { + killed: toKill.length, + skippedWatched: unwatchedStreams.length - toKill.length + (this.activeStreams.size - unwatchedStreams.length), + remaining: this.activeStreams.size, }); } /** * Aggressive cleanup - remove torrents with no active watchers */ - private aggressiveCleanup(): void { + private aggressiveCleanup(pressureLevel: 'warning' | 'critical' | 'severe' = 'warning'): void { let cleaned = 0; let skippedYoung = 0; const now = Date.now(); @@ -772,7 +816,8 @@ export class StreamingService { // Skip young torrents — they may be actively downloading or transcoding const addedAt = this.torrentAddedAt.get(infohash) ?? 0; - if (addedAt && (now - addedAt) < TORRENT_MIN_AGE_MS) { + const minAge = pressureLevel === 'warning' ? TORRENT_MIN_AGE_MS : TORRENT_MIN_AGE_CRITICAL_MS; + if (addedAt && (now - addedAt) < minAge) { skippedYoung++; continue; } 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';