/** * Content Enrichment Service * Matches IPTV content to TMDb entries and fetches posters/metadata */ import type { TMDbClient } from '@visioo/api-client'; import type { Movie, Series } from '../types'; export interface EnrichmentResult { posterUrl: string | null; backdropUrl: string | null; tmdbId: number | null; confidence: number; // 0-100 genres: string[]; // TMDb genre names (e.g., ["Drama", "Crime", "Thriller"]) title: string | null; // TMDb title (clean, standardized title) rating: number | null; // TMDb vote_average (0-10 scale) } export interface ContentEnrichmentService { /** * Enrich a movie with TMDb poster */ enrichMovie(movie: Movie): Promise; /** * Enrich a series with TMDb poster */ enrichSeries(series: Series): Promise; /** * Batch enrich multiple movies * Returns a plain object (Record) instead of Map for JSON serialization compatibility */ enrichMovies(movies: Movie[]): Promise>; /** * Batch enrich multiple series * Returns a plain object (Record) instead of Map for JSON serialization compatibility */ enrichSeriesBatch(seriesList: Series[]): Promise>; } export class TMDbContentEnrichmentService implements ContentEnrichmentService { private cache = new Map(); private searchCache = new Map(); private readonly SEARCH_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours // TMDb genre ID to name mapping (movies and TV share most genres) private readonly GENRE_MAP: Record = { 28: 'Action', 12: 'Adventure', 16: 'Animation', 35: 'Comedy', 80: 'Crime', 99: 'Documentary', 18: 'Drama', 10751: 'Family', 14: 'Fantasy', 36: 'History', 27: 'Horror', 10402: 'Music', 9648: 'Mystery', 10749: 'Romance', 878: 'Science Fiction', 10770: 'TV Movie', 53: 'Thriller', 10752: 'War', 37: 'Western', 10759: 'Action & Adventure', // TV only 10762: 'Kids', // TV only 10763: 'News', // TV only 10764: 'Reality', // TV only 10765: 'Sci-Fi & Fantasy', // TV only 10766: 'Soap', // TV only 10767: 'Talk', // TV only 10768: 'War & Politics', // TV only }; constructor(private tmdb: TMDbClient) {} /** * Map genre IDs to genre names */ private mapGenreIds(genreIds: number[]): string[] { return genreIds .map(id => this.GENRE_MAP[id]) .filter((name): name is string => !!name) .slice(0, 3); // Limit to top 3 genres } /** * Normalize title for matching */ private normalizeTitle(title: string): string { return title .toLowerCase() // Remove common Arabic metadata terms (before removing non-ASCII) .replace(/\bمدبلج\b/g, ' ') // "dubbed" .replace(/\bمترجم\b/g, ' ') // "subtitled" .replace(/\bعربي\b/g, ' ') // "Arabic" // Remove quality indicators .replace(/\b(1080p|720p|4k|hdr|bluray|web-dl|webrip|dvdrip|brrip|xvid|divx|dubbed|subtitled)\b/gi, '') // Remove release groups .replace(/\[.*?\]/g, '') .replace(/\(.*?\)/g, ' ') // Remove parentheses content (like years, but we extract year separately) // Remove file extensions .replace(/\.(mkv|mp4|avi|mov|m4v)$/i, '') // Remove Arabic and other non-Latin characters (keep only ASCII letters, numbers, spaces) .replace(/[^\x00-\x7F]/g, ' ') // Replace separators with spaces .replace(/[._-]/g, ' ') // Remove extra spaces .replace(/\s+/g, ' ') .trim(); } /** * Extract year from title or use provided year */ private extractYear(title: string, providedYear?: number): number | null { if (providedYear) return providedYear; // Try to extract year from title (e.g., "Movie Name (2023)" or "Movie Name 2023") const yearMatch = title.match(/\b(19|20)\d{2}\b/); if (yearMatch) { const year = parseInt(yearMatch[0], 10); if (year >= 1900 && year <= new Date().getFullYear() + 1) { return year; } } return null; } /** * Calculate similarity between two titles using word-based matching */ private calculateSimilarity(title1: string, title2: string): number { const normalized1 = this.normalizeTitle(title1); const normalized2 = this.normalizeTitle(title2); if (normalized1 === normalized2) return 100; // Split into words const words1 = normalized1.split(/\s+/).filter(w => w.length > 0); const words2 = normalized2.split(/\s+/).filter(w => w.length > 0); if (words1.length === 0 || words2.length === 0) return 0; // Check if one title contains all words of the other (substring match) const allWords1In2 = words1.every(w => normalized2.includes(w)); const allWords2In1 = words2.every(w => normalized1.includes(w)); if (allWords1In2 || allWords2In1) { // Calculate word overlap percentage const commonWords = words1.filter(w => words2.includes(w)); const totalUniqueWords = new Set([...words1, ...words2]).size; const wordOverlap = (commonWords.length / totalUniqueWords) * 100; return Math.min(85 + Math.floor(wordOverlap / 10), 95); } // Word-based similarity const commonWords = words1.filter(w => words2.includes(w)); const totalWords = Math.max(words1.length, words2.length); const wordSimilarity = (commonWords.length / totalWords) * 100; // Character-based similarity as fallback const longer = normalized1.length > normalized2.length ? normalized1 : normalized2; const shorter = normalized1.length > normalized2.length ? normalized2 : normalized1; if (longer.length === 0) return 100; if (shorter.length / longer.length < 0.5) return Math.round(wordSimilarity); // Count matching characters let matches = 0; const shorterChars = shorter.split(''); const longerChars = longer.split(''); for (const char of shorterChars) { if (longerChars.includes(char)) { matches++; const index = longerChars.indexOf(char); longerChars.splice(index, 1); } } const charSimilarity = (matches / longer.length) * 100; // Combine word and character similarity (weighted) return Math.round((wordSimilarity * 0.7) + (charSimilarity * 0.3)); } /** * Find best match from TMDb search results */ private findBestMatch( searchResults: Array<{ title?: string; name?: string; release_date?: string; first_air_date?: string; poster_path?: string | null; backdrop_path?: string | null; id: number; genre_ids: number[]; vote_average?: number }>, targetTitle: string, targetYear: number | null ): EnrichmentResult | null { if (searchResults.length === 0) { return { posterUrl: null, backdropUrl: null, tmdbId: null, confidence: 0, genres: [], title: null, rating: null }; } const { detectTitleScript } = require('../utils/content-metadata'); const targetScript = detectTitleScript(targetTitle); let bestMatch: typeof searchResults[0] | null = null; let bestScore = 0; for (const result of searchResults) { const resultTitle = result.title || result.name || ''; const resultYear = this.extractYear( result.release_date || result.first_air_date || '', undefined ); const resultScript = detectTitleScript(resultTitle); // Calculate title similarity let titleSimilarity = this.calculateSimilarity(targetTitle, resultTitle); // Cross-script matching: when target and result are in different scripts, // trust TMDb's search results more (they already did the matching) // Give bonus points for first result when scripts differ if (targetScript !== resultScript && targetScript !== 'mixed' && resultScript !== 'mixed') { // If this is the first result and scripts differ, TMDb matched it internally const isFirstResult = searchResults.indexOf(result) === 0; if (isFirstResult) { // Give a high base score for TMDb's top match when scripts differ titleSimilarity = Math.max(titleSimilarity, 70); } else if (searchResults.length <= 3) { // For small result sets, still trust TMDb's matching titleSimilarity = Math.max(titleSimilarity, 50); } } // Calculate year match bonus let yearBonus = 0; if (targetYear && resultYear) { if (targetYear === resultYear) { yearBonus = 20; // Perfect year match } else if (Math.abs(targetYear - resultYear) === 1) { yearBonus = 10; // Off by one year } } const totalScore = titleSimilarity + yearBonus; if (totalScore > bestScore) { bestScore = totalScore; bestMatch = result; } } // Lower threshold to 35 for better coverage, especially for newer releases if (!bestMatch || bestScore < 35) { return { posterUrl: null, backdropUrl: null, tmdbId: null, confidence: bestScore, genres: [], title: null, rating: null }; } const posterUrl = bestMatch.poster_path ? this.tmdb.getPosterUrl(bestMatch.poster_path, 'w500') : null; const backdropUrl = bestMatch.backdrop_path ? this.tmdb.getBackdropUrl(bestMatch.backdrop_path, 'w780') : null; // Debug: log when we have a match but no poster if (!posterUrl && bestMatch.id) { console.log(`[ContentEnrichment] TMDb match found but no poster_path: "${bestMatch.title || bestMatch.name}" (id: ${bestMatch.id})`); } // Map genre IDs to genre names (genre_ids should always be present in TMDb search results) const genres = bestMatch.genre_ids && bestMatch.genre_ids.length > 0 ? this.mapGenreIds(bestMatch.genre_ids) : []; // Get TMDb title (prefer title for movies, name for TV shows) const tmdbTitle = bestMatch.title || bestMatch.name || null; // Extract rating (vote_average) - TMDb uses 0-10 scale, only include if > 0 const rating = bestMatch.vote_average && bestMatch.vote_average > 0 ? bestMatch.vote_average : null; return { posterUrl, backdropUrl, tmdbId: bestMatch.id, confidence: Math.min(bestScore, 100), genres, title: tmdbTitle, rating, }; } /** * Search TMDb with caching * @param query - Search query * @param type - 'movie' or 'tv' * @param year - Optional year filter for more precise results */ private async searchTMDb( query: string, type: 'movie' | 'tv', year?: number ): Promise> { // Include year in cache key for separate caching const cacheKey = `${type}:${query.toLowerCase()}:${year || 'any'}`; const cached = this.searchCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.SEARCH_CACHE_TTL) { return cached.results; } try { const results = type === 'movie' ? await this.tmdb.searchMovies(query, 1, year ? { primary_release_year: year } : undefined) : await this.tmdb.searchTV(query, 1, year ? { first_air_date_year: year } : undefined); this.searchCache.set(cacheKey, { results, timestamp: Date.now(), }); return results; } catch (error) { console.error(`[ContentEnrichment] TMDb search failed for ${type}: ${query}`, error); return []; } } /** * Search TMDb with multiple fallback strategies for better matching * Strategy 1: Clean title (year stripped) + year filter - most precise * Strategy 2: Clean title (year stripped) without year filter - broader * Strategy 3: Original cleaned title without year filter - fallback * Strategy 4: For pure non-Latin titles, search with native language parameter */ private async searchTMDbWithStrategies( originalTitle: string, type: 'movie' | 'tv', year: number | null ): Promise<{ results: Array<{ title?: string; name?: string; release_date?: string; first_air_date?: string; poster_path?: string | null; backdrop_path?: string | null; id: number; genre_ids: number[]; vote_average?: number }>; strategy: string }> { const { cleanTitleForMatching, stripYearFromTitle, detectTitleScript, getLanguageForScript } = require('../utils/content-metadata'); // Clean the title (removes dubbed/subtitled tags and extracts Latin portion if mixed) const cleanedTitle = cleanTitleForMatching(originalTitle); // Detect the script of the original title to determine if native language search is needed const titleScript = detectTitleScript(originalTitle); const nativeLanguage = getLanguageForScript(titleScript); // Strip year from title for cleaner search: "Big World (2024)" -> "Big World" const titleWithoutYear = stripYearFromTitle(cleanedTitle); // Strategy 1: For pure non-Latin titles (Arabic, Chinese, etc.), try native language search FIRST // This must come first because TMDb's default (English) language won't find Arabic titles // e.g., "احلى الاوقات" with language=ar if (nativeLanguage && titleScript !== 'latin' && titleScript !== 'mixed') { const nativeResults = await this.searchTMDbWithLanguage(originalTitle, type, nativeLanguage, year); if (nativeResults.length > 0) { return { results: nativeResults, strategy: 'native_language_search' }; } // If native search failed, still return empty - don't try other strategies with wrong language return { results: [], strategy: 'no_results' }; } // Strategy 2: Search with title without year + year filter (most precise for Latin titles) // e.g., "Big World" with primary_release_year=2024 if (year && titleWithoutYear.length > 0) { const results = await this.searchTMDb(titleWithoutYear, type, year); if (results.length > 0) { return { results, strategy: 'title_without_year_plus_year_filter' }; } // Debug: log when year-filtered search fails console.log(`[ContentEnrichment] Year-filtered search failed for: "${titleWithoutYear}" (${year})`); } // Strategy 3: Search with title without year, no year filter (broader) // e.g., "Big World" without year filter - returns all movies with that title if (titleWithoutYear.length > 0) { const results = await this.searchTMDb(titleWithoutYear, type); if (results.length > 0) { return { results, strategy: 'title_without_year_no_filter' }; } } // Strategy 4: Original cleaned title without year filter (fallback, only if different) // e.g., "Big World (2024)" as-is if (cleanedTitle !== titleWithoutYear && cleanedTitle.length > 0) { const results = await this.searchTMDb(cleanedTitle, type); if (results.length > 0) { return { results, strategy: 'cleaned_title_no_filter' }; } } console.log(`[ContentEnrichment] All strategies failed for: "${originalTitle}" -> search: "${titleWithoutYear}"`); return { results: [], strategy: 'no_results' }; } /** * Search TMDb with a specific language parameter * Used for searching non-Latin titles in their native language */ private async searchTMDbWithLanguage( query: string, type: 'movie' | 'tv', language: string, year?: number | null ): Promise> { // Include language in cache key const cacheKey = `${type}:${query.toLowerCase()}:${year || 'any'}:${language}`; const cached = this.searchCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.SEARCH_CACHE_TTL) { return cached.results; } try { const options: { primary_release_year?: number; first_air_date_year?: number; language: string } = { language }; if (year) { if (type === 'movie') { (options as any).primary_release_year = year; } else { (options as any).first_air_date_year = year; } } const results = type === 'movie' ? await this.tmdb.searchMovies(query, 1, options as any) : await this.tmdb.searchTV(query, 1, options as any); this.searchCache.set(cacheKey, { results, timestamp: Date.now(), }); return results; } catch (error) { console.error(`[ContentEnrichment] TMDb native language search failed for ${type}: ${query} (${language})`, error); return []; } } async enrichMovie(movie: Movie): Promise { const cacheKey = `movie:${movie.id}`; const cached = this.cache.get(cacheKey); if (cached) { return cached; } // Extract year from movie data or title const { extractYearFromTitle, stripYearFromTitle } = require('../utils/content-metadata'); const year = movie.year || extractYearFromTitle(movie.name); // Use multi-strategy search for better matching with common titles const { results: searchResults } = await this.searchTMDbWithStrategies( movie.name, 'movie', year ); // For matching, use title without year for better similarity scoring const titleForMatching = stripYearFromTitle(movie.name); // Find best match let result = this.findBestMatch(searchResults, titleForMatching, year); // If we have a match but no genres or rating, fetch details to get them if (result && result.tmdbId && ((!result.genres || result.genres.length === 0) || !result.rating)) { try { const details = await this.tmdb.getMovieDetails(result.tmdbId); if (details.genres && details.genres.length > 0) { result.genres = details.genres.map(g => g.name).slice(0, 3); } // Fetch rating from details if not available from search if (!result.rating && details.vote_average && details.vote_average > 0) { result.rating = details.vote_average; } } catch (error) { // If details fetch fails, continue with existing result } } // Cache result (use default if null) const enrichmentResult: EnrichmentResult = result || { posterUrl: null, backdropUrl: null, tmdbId: null, confidence: 0, genres: [], title: null, rating: null, }; // Log only when no poster is found after all strategies if (!enrichmentResult.posterUrl) { console.log(`[ContentEnrichment] No poster found for movie: "${movie.name}"`); } this.cache.set(cacheKey, enrichmentResult); return enrichmentResult; } async enrichSeries(series: Series): Promise { const cacheKey = `series:${series.id}`; const cached = this.cache.get(cacheKey); if (cached) { return cached; } // Extract year from series title if present const { extractYearFromTitle, stripYearFromTitle } = require('../utils/content-metadata'); const year = extractYearFromTitle(series.name); // Use multi-strategy search for better matching with common titles const { results: searchResults } = await this.searchTMDbWithStrategies( series.name, 'tv', year ); // For matching, use title without year for better similarity scoring const titleForMatching = stripYearFromTitle(series.name); // Find best match let result = this.findBestMatch(searchResults, titleForMatching, year); // If we have a match but no genres or rating, fetch details to get them if (result && result.tmdbId && ((!result.genres || result.genres.length === 0) || !result.rating)) { try { const details = await this.tmdb.getTVDetails(result.tmdbId); if (details.genres && details.genres.length > 0) { result.genres = details.genres.map(g => g.name).slice(0, 3); } // Fetch rating from details if not available from search if (!result.rating && details.vote_average && details.vote_average > 0) { result.rating = details.vote_average; } } catch (error) { // If details fetch fails, continue with existing result } } // Cache result (use default if null) const enrichmentResult: EnrichmentResult = result || { posterUrl: null, backdropUrl: null, tmdbId: null, confidence: 0, genres: [], title: null, rating: null, }; // Log only when no poster is found after all strategies if (!enrichmentResult.posterUrl) { console.log(`[ContentEnrichment] No poster found for series: "${series.name}"`); } this.cache.set(cacheKey, enrichmentResult); return enrichmentResult; } async enrichMovies(movies: Movie[]): Promise> { // Use plain object instead of Map for JSON serialization compatibility // (React Query + MMKV cache serializes Maps as empty objects) const results: Record = {}; // PERFORMANCE: Increased batch size from 3 to 5 and reduced delay from 300ms to 100ms // This speeds up enrichment significantly while still being gentle on iOS networking // 150 items: 30 batches × 100ms = ~3 seconds delay + API time const batchSize = 5; for (let i = 0; i < movies.length; i += batchSize) { const batch = movies.slice(i, i + batchSize); const batchResults = await Promise.all( batch.map(async (movie) => { const result = await this.enrichMovie(movie); return [movie.id, result] as [string, EnrichmentResult]; }) ); batchResults.forEach(([id, result]) => { results[id] = result; }); // Small delay between batches to let iOS networking recover if (i + batchSize < movies.length) { await new Promise((resolve) => setTimeout(resolve, 100)); } } return results; } async enrichSeriesBatch(seriesList: Series[]): Promise> { // Use plain object instead of Map for JSON serialization compatibility // (React Query + MMKV cache serializes Maps as empty objects) const results: Record = {}; // PERFORMANCE: Increased batch size from 3 to 5 and reduced delay from 300ms to 100ms // This speeds up enrichment significantly while still being gentle on iOS networking const batchSize = 5; for (let i = 0; i < seriesList.length; i += batchSize) { const batch = seriesList.slice(i, i + batchSize); const batchResults = await Promise.all( batch.map(async (series) => { const result = await this.enrichSeries(series); return [series.id, result] as [string, EnrichmentResult]; }) ); batchResults.forEach(([id, result]) => { results[id] = result; }); // Small delay between batches to let iOS networking recover if (i + batchSize < seriesList.length) { await new Promise((resolve) => setTimeout(resolve, 100)); } } return results; } /** * Clear cache */ clearCache(): void { this.cache.clear(); this.searchCache.clear(); } }