Files
2026-06-08 21:58:16 +12:00

387 lines
9.9 KiB
JavaScript

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
}));
}