Homelabtoolkit v2
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
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
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user