export function normalizeLookupItem(item) { const derivedYear = Number( String( item?.year ?? item?.ProductionYear ?? item?.release_date ?? item?.first_air_date ?? '' ).slice(0, 4) ) || null; const providerIds = item?.providerIds || item?.ProviderIds || (item?.tmdbId ? { Tmdb: String(item.tmdbId) } : {}); return { id: String(item?.id ?? item?.Id ?? ''), name: item?.name || item?.Name || item?.SeriesName || item?.title || 'Unnamed item', type: item?.type || item?.Type || item?.CollectionType || (item?.mediaType === 'tv' ? 'Series' : item?.mediaType === 'movie' ? 'Movie' : 'Item'), overview: item?.overview || item?.Overview || '', year: derivedYear, communityRating: item?.communityRating ?? item?.CommunityRating ?? item?.voteAverage ?? item?.vote_average ?? null, providerIds, genres: Array.isArray(item?.genres) ? item.genres.filter(Boolean) : Array.isArray(item?.Genres) ? item.Genres.filter(Boolean) : Array.isArray(item?.GenreItems) ? item.GenreItems.map((genre) => genre?.Name).filter(Boolean) : [], parentId: item?.parentId ?? item?.ParentId ?? null }; } const POSITIVE_GENRES = { Romance: 18, Comedy: 16, Drama: 10, Music: 4 }; const NEGATIVE_GENRES = { Animation: -30, Horror: -25, 'Science Fiction': -20, Action: -16, Thriller: -14, Crime: -10, War: -18, Western: -14, Documentary: -18, Fantasy: -8, Family: -6 }; const POSITIVE_TERMS = [ 'wedding', 'love', 'romance', 'relationship', 'bride', 'best friend', 'friendship', 'family', 'holiday', 'food', 'small town', 'bookstore', 'restaurant', 'writer', 'divorce', 'second chance', 'starting over', 'feel-good', 'mother', 'daughter', 'sisters', 'chosen family', 'comedy of manners' ]; const NEGATIVE_TERMS = [ 'war', 'serial killer', 'murder spree', 'mercenary', 'zombie', 'post-apocalyptic', 'gang', 'assassin', 'combat', 'superhero', 'multiverse', 'alien invasion', 'dystopian', 'dragon', 'animated adventure' ]; const SEED_BONUS_TERMS = [ 'ensemble', 'relationship', 'family', 'comedy', 'romantic', 'wedding', 'friendship', 'identity', 'midlife', 'second chance' ]; export const RECOMMENDATION_PROFILES = { balanced: { id: 'balanced', label: 'Balanced', description: 'Default Emby-style recommendation overlap ranking.' }, classicComfort: { id: 'classicComfort', label: 'Classic Comfort', description: 'Bias toward older, warm, highly-rated movies and shows inspired by test.py.' } }; function normalizeLimit(limit) { return Math.min(Math.max(Number(limit || 24), 1), 48); } function resolveRankingOptions(limitOrOptions) { if (typeof limitOrOptions === 'number' || limitOrOptions === undefined) { return { limit: normalizeLimit(limitOrOptions), profile: 'balanced', seeds: [], excludeIds: [], allowedTypes: [] }; } return { limit: normalizeLimit(limitOrOptions?.limit), profile: RECOMMENDATION_PROFILES[limitOrOptions?.profile] ? limitOrOptions.profile : 'balanced', seeds: Array.isArray(limitOrOptions?.seeds) ? limitOrOptions.seeds.map(normalizeLookupItem) : [], excludeIds: Array.isArray(limitOrOptions?.excludeIds) ? limitOrOptions.excludeIds.map((id) => String(id).trim()).filter(Boolean) : [], allowedTypes: Array.isArray(limitOrOptions?.allowedTypes) ? limitOrOptions.allowedTypes.map((type) => String(type).trim()).filter(Boolean) : [] }; } function genreBiasScore(genreNames, overview) { let score = 0; for (const genre of genreNames || []) { score += POSITIVE_GENRES[genre] || 0; score += NEGATIVE_GENRES[genre] || 0; } const text = String(overview || '').toLowerCase(); for (const term of POSITIVE_TERMS) { if (text.includes(term)) score += 2; } for (const term of NEGATIVE_TERMS) { if (text.includes(term)) score -= 2; } return score; } function classicBonus(year, seedHits, overview, seedGenres) { let bonus = 0; if (year !== null && year !== undefined) { if (year >= 1985 && year <= 2008) bonus += 10; else if (year >= 2009 && year <= 2012) bonus += 4; else if (year < 1985) bonus += 2; else bonus -= 8; } bonus += Math.min(seedHits * 5, 20); const text = String(overview || '').toLowerCase(); for (const term of SEED_BONUS_TERMS) { if (text.includes(term)) bonus += 1; } for (const genre of seedGenres) { if ((overview || '').toLowerCase().includes(genre.toLowerCase())) { bonus += 1; } } return bonus; } function buildSeedContext(seeds) { const genreHits = new Set(); const mediaTypes = new Set(); for (const seed of seeds || []) { for (const genre of seed.genres || []) { genreHits.add(genre); } if (seed.type) mediaTypes.add(seed.type); } return { genres: genreHits, mediaTypes }; } function scoreSeedAffinity(item, seedContext) { const genres = new Set(item.genres || []); const text = `${item.name || ''} ${item.overview || ''}`.toLowerCase(); let score = 0; for (const genre of seedContext.genres) { if (genres.has(genre)) score += 8; } const warmSeed = seedContext.genres.has('Romance') || seedContext.genres.has('Drama') || seedContext.genres.has('Comedy') || seedContext.genres.has('Family'); if (warmSeed) { if (genres.has('Romance')) score += 12; if (genres.has('Drama')) score += 10; if (genres.has('Comedy')) score += 8; if (genres.has('Family')) score += 5; if (genres.has('Horror')) score -= 28; if (genres.has('Science Fiction')) score -= 18; if (genres.has('Action')) score -= 16; if (genres.has('Thriller')) score -= 14; if (genres.has('War')) score -= 10; if (genres.has('Crime')) score -= 8; for (const term of POSITIVE_TERMS) { if (text.includes(term)) score += 1; } for (const term of NEGATIVE_TERMS) { if (text.includes(term)) score -= 2; } } return score; } function normalizeResultSet(resultSet) { if (Array.isArray(resultSet)) { return { items: resultSet, sourceWeight: 1 }; } return { items: Array.isArray(resultSet?.items) ? resultSet.items : [], sourceWeight: Math.max(1, Number(resultSet?.sourceWeight || 1)) }; } function evaluateClassicComfort(item, matches, seedContext) { const genres = item.genres || []; const year = Number.isFinite(Number(item.year)) ? Number(item.year) : null; const rating = Number.isFinite(Number(item.communityRating)) ? Number(item.communityRating) : null; const hasAnimation = genres.includes('Animation'); if (hasAnimation) return { keep: false }; if (year !== null && (year < 1980 || year > 2012)) return { keep: false }; if (rating !== null && rating < 6.3) return { keep: false }; const bias = genreBiasScore(genres, item.overview); const bonus = classicBonus(year, matches, `${item.name} ${item.overview}`, seedContext.genres); const styleScore = bias + bonus; if (styleScore < 16) { return { keep: false }; } return { keep: true, styleScore, qualityScore: rating !== null ? Math.round(rating * 10) : null }; } export function rankRecommendationResults(seedIds, resultSets, limitOrOptions = 24) { const options = resolveRankingOptions(limitOrOptions); const excluded = new Set([ ...(seedIds || []).map((id) => String(id).trim()), ...options.excludeIds ].filter(Boolean)); const allowedTypes = new Set(options.allowedTypes); const scored = new Map(); for (const resultSet of resultSets || []) { const normalizedSet = normalizeResultSet(resultSet); const seenInSet = new Set(); const items = normalizedSet.items; for (let index = 0; index < items.length; index += 1) { const item = normalizeLookupItem(items[index]); if (!item.id || excluded.has(item.id)) continue; if (allowedTypes.size > 0 && !allowedTypes.has(item.type)) continue; if (seenInSet.has(item.id)) continue; seenInSet.add(item.id); const weight = Math.max(1, items.length - index) * normalizedSet.sourceWeight; const current = scored.get(item.id) || { item, score: 0, matches: 0, sourceStrength: 0, bestRank: Number.POSITIVE_INFINITY }; current.item = item; current.score += weight; current.matches += 1; current.sourceStrength += normalizedSet.sourceWeight; current.bestRank = Math.min(current.bestRank, index); scored.set(item.id, current); } } const seedContext = buildSeedContext(options.seeds); return [...scored.values()] .map((entry) => { if (options.profile !== 'classicComfort') { const affinityScore = scoreSeedAffinity(entry.item, seedContext); return { ...entry, affinityScore, totalScore: (entry.sourceStrength * 20) + entry.score + affinityScore, styleScore: null, qualityScore: entry.item.communityRating !== null ? Math.round(Number(entry.item.communityRating) * 10) : null }; } const style = evaluateClassicComfort(entry.item, entry.matches, seedContext); if (!style.keep) return null; return { ...entry, styleScore: style.styleScore, qualityScore: style.qualityScore, totalScore: (entry.sourceStrength * 20) + entry.score + (style.styleScore * 2) + (style.qualityScore || 0) }; }) .filter(Boolean) .sort((a, b) => { if (b.totalScore !== a.totalScore) return b.totalScore - a.totalScore; if ((b.sourceStrength || 0) !== (a.sourceStrength || 0)) return (b.sourceStrength || 0) - (a.sourceStrength || 0); if (b.matches !== a.matches) return b.matches - a.matches; if ((b.styleScore || 0) !== (a.styleScore || 0)) return (b.styleScore || 0) - (a.styleScore || 0); if ((b.qualityScore || 0) !== (a.qualityScore || 0)) return (b.qualityScore || 0) - (a.qualityScore || 0); if (b.score !== a.score) return b.score - a.score; if (a.bestRank !== b.bestRank) return a.bestRank - b.bestRank; return a.item.name.localeCompare(b.item.name); }) .slice(0, options.limit) .map((entry) => ({ ...entry.item, matchCount: entry.matches, score: entry.score, totalScore: entry.totalScore, sourceStrength: entry.sourceStrength, affinityScore: entry.affinityScore ?? null, styleScore: entry.styleScore, qualityScore: entry.qualityScore })); }