387 lines
9.9 KiB
JavaScript
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
|
||
|
|
}));
|
||
|
|
}
|