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

696 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { error, json } from '@sveltejs/kit';
import {
RECOMMENDATION_PROFILES,
rankRecommendationResults,
normalizeLookupItem
} from '../../../lib/collection-tools.js';
import { fetchEmby, fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
import {
fetchTmdbCredits,
fetchTmdbDetails,
fetchTmdbDiscover,
fetchTmdbGenres,
fetchTmdbKeywords,
fetchTmdbRecommendations,
fetchTmdbSimilar,
searchTmdbByTitle
} from '../../../lib/server/tmdb-api.js';
const EMBY_PAGE_SIZE = 200;
function normalizeTitle(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
function normalizeYear(value) {
const year = Number(value || 0);
return Number.isFinite(year) && year > 0 ? year : null;
}
function titleYearKey(name, year) {
return `${normalizeTitle(name)}::${normalizeYear(year) || ''}`;
}
function normalizeCollectionName(name) {
return String(name || '')
.toLowerCase()
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, '');
}
function getSeedTmdbId(item) {
const providerIds = item?.providerIds || {};
return String(providerIds.Tmdb || providerIds.TMDB || providerIds.tmdb || '').trim();
}
function getTmdbMediaType(item) {
return item?.type === 'Series' ? 'tv' : 'movie';
}
function summarizeItems(items, limit = 5) {
return (items || []).slice(0, limit).map((item) => ({
name: item?.name || item?.title || item?.Name || '',
year: item?.year || item?.ProductionYear || null,
id: item?.id || item?.Id || '',
type: item?.type || item?.Type || ''
}));
}
function filterEnglishCandidates(items) {
return (items || []).filter((item) => !item?.originalLanguage || item.originalLanguage === 'en');
}
async function fetchAllUserItems(userId, params) {
const allItems = [];
let startIndex = 0;
while (true) {
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Recursive: true,
GroupItemsIntoCollections: false,
Limit: EMBY_PAGE_SIZE,
StartIndex: startIndex,
...params
}
});
const pageItems = (payload.Items || payload || []).map(normalizeLookupItem);
allItems.push(...pageItems);
const total = Number(payload.TotalRecordCount || 0);
if (!pageItems.length) break;
if (total > 0 && allItems.length >= total) break;
if (pageItems.length < EMBY_PAGE_SIZE) break;
startIndex += pageItems.length;
}
return allItems;
}
async function fetchAllLibraryItems(userId) {
return fetchAllUserItems(userId, {
IncludeItemTypes: 'Movie,Series',
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds'
});
}
async function fetchAllPlayedItems(userId) {
return fetchAllUserItems(userId, {
Filters: 'IsPlayed',
IncludeItemTypes: 'Movie,Series',
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds,UserData'
});
}
function uniqueById(items) {
const seen = new Set();
const unique = [];
for (const item of items || []) {
if (!item?.id || seen.has(item.id)) continue;
seen.add(item.id);
unique.push(item);
}
return unique;
}
function buildLibraryIndex(items) {
const byId = new Map();
const byTmdbId = new Map();
const byTitleYear = new Map();
const byTitle = new Map();
for (const item of items || []) {
if (!item?.id) continue;
byId.set(item.id, item);
const tmdbId = getSeedTmdbId(item);
if (tmdbId && !byTmdbId.has(tmdbId)) {
byTmdbId.set(tmdbId, item);
}
const titleKey = titleYearKey(item.name, item.year);
if (normalizeTitle(item.name) && !byTitleYear.has(titleKey)) {
byTitleYear.set(titleKey, item);
}
const normalizedName = normalizeTitle(item.name);
if (normalizedName && !byTitle.has(normalizedName)) {
byTitle.set(normalizedName, item);
}
}
return { byId, byTmdbId, byTitleYear, byTitle };
}
async function fetchSeedItems(userId, ids, libraryIndex) {
const fromLibrary = ids
.map((id) => libraryIndex.byId.get(String(id)))
.filter(Boolean);
if (fromLibrary.length === ids.length) {
return fromLibrary;
}
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Ids: ids.join(','),
Limit: ids.length,
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds',
GroupItemsIntoCollections: false
}
});
return uniqueById((payload.Items || payload || []).map(normalizeLookupItem));
}
async function findExistingCollection(userId, name) {
const trimmedName = String(name || '').trim();
if (!userId || !trimmedName) return null;
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Recursive: true,
SearchTerm: trimmedName,
Limit: 20,
SortBy: 'SortName',
SortOrder: 'Ascending',
Fields: 'Overview',
IncludeItemTypes: 'BoxSet',
GroupItemsIntoCollections: false
}
});
const normalizedTarget = normalizeCollectionName(trimmedName);
const exact = (payload.Items || payload || [])
.map(normalizeLookupItem)
.find((item) => item.type === 'BoxSet' && normalizeCollectionName(item.name) === normalizedTarget);
return exact || null;
}
async function resolveTmdbMatch(item) {
const directTmdbId = getSeedTmdbId(item);
if (directTmdbId) {
return {
tmdbId: Number(directTmdbId),
mediaType: getTmdbMediaType(item)
};
}
const matches = await searchTmdbByTitle({
mediaType: getTmdbMediaType(item),
name: item.name,
year: item.year
});
const best = matches[0];
if (!best?.tmdbId) return null;
return {
tmdbId: best.tmdbId,
mediaType: best.mediaType
};
}
function resolveLocalCandidate(index, candidate) {
const tmdbId = String(candidate?.tmdbId || '').trim();
if (tmdbId && index.byTmdbId.has(tmdbId)) {
return index.byTmdbId.get(tmdbId);
}
const exactTitleYear = index.byTitleYear.get(titleYearKey(candidate?.name, candidate?.year));
if (exactTitleYear) {
return exactTitleYear;
}
return index.byTitle.get(normalizeTitle(candidate?.name)) || null;
}
function addWeight(map, key, amount = 1) {
if (key === null || key === undefined || key === '') return;
map.set(key, (map.get(key) || 0) + amount);
}
function sortWeightedEntries(map, limit) {
return [...map.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, limit);
}
function getGenreLookup(genres) {
return new Map((genres || []).map((genre) => [normalizeTitle(genre.name), Number(genre.id)]));
}
function resolveGenreIdsFromLocalItems(items, genreLookup, weight, targetMap) {
for (const item of items || []) {
for (const genreName of item.genres || []) {
const id = genreLookup.get(normalizeTitle(genreName));
if (id) addWeight(targetMap, id, weight);
}
}
}
function normalizeKeywordIds(keywords) {
return (keywords || []).map((keyword) => Number(keyword?.id || 0)).filter(Boolean);
}
function extractPeopleIds(credits, mediaType) {
const ids = new Set();
for (const person of (credits?.cast || []).slice(0, 5)) {
if (person?.id) ids.add(Number(person.id));
}
if (mediaType === 'movie') {
for (const crew of credits?.crew || []) {
if (!crew?.id) continue;
if (crew.job === 'Director' || crew.job === 'Writer' || crew.job === 'Screenplay') {
ids.add(Number(crew.id));
}
if (ids.size >= 8) break;
}
}
return [...ids];
}
function buildDiscoverQueries({ mediaType, genreIds, keywordIds, peopleIds }) {
const base = {
page: 1,
sort_by: 'popularity.desc',
'vote_count.gte': mediaType === 'tv' ? 10 : 25,
with_original_language: 'en'
};
const queries = [];
if (genreIds.length) {
queries.push({
label: `${mediaType}-genres`,
params: {
...base,
with_genres: genreIds.slice(0, 4).join('|')
}
});
}
if (keywordIds.length) {
queries.push({
label: `${mediaType}-keywords`,
params: {
...base,
with_keywords: keywordIds.slice(0, 6).join('|')
}
});
}
if (genreIds.length && keywordIds.length) {
queries.push({
label: `${mediaType}-genres-keywords`,
params: {
...base,
with_genres: genreIds.slice(0, 3).join('|'),
with_keywords: keywordIds.slice(0, 4).join('|')
}
});
}
if (mediaType === 'movie' && peopleIds.length) {
queries.push({
label: 'movie-people',
params: {
...base,
with_people: peopleIds.slice(0, 5).join('|')
}
});
}
return queries;
}
function inspectCandidateSets(seedIds, resultSets) {
const excluded = new Set((seedIds || []).map((id) => String(id)));
const uniqueIncluded = new Map();
const uniqueExcluded = new Map();
for (const resultSet of resultSets || []) {
const items = Array.isArray(resultSet) ? resultSet : (Array.isArray(resultSet?.items) ? resultSet.items : []);
for (const item of items) {
const normalized = normalizeLookupItem(item);
if (!normalized.id) continue;
if (excluded.has(normalized.id)) {
if (!uniqueExcluded.has(normalized.id)) uniqueExcluded.set(normalized.id, normalized);
continue;
}
if (!uniqueIncluded.has(normalized.id)) uniqueIncluded.set(normalized.id, normalized);
}
}
return {
uniqueCandidateCount: uniqueIncluded.size,
excludedSeedCandidateCount: uniqueExcluded.size,
sampleExcludedSeedCandidates: summarizeItems([...uniqueExcluded.values()])
};
}
async function buildSeedContext(seed, libraryIndex) {
const diagnostic = {
seedId: seed.id,
seedName: seed.name,
seedType: seed.type,
seedYear: seed.year,
providerTmdbId: getSeedTmdbId(seed) || null,
tmdbMatch: null,
similarCount: 0,
recommendationCount: 0,
localSimilarCount: 0,
localRecommendationCount: 0,
error: null
};
try {
const tmdbMatch = await resolveTmdbMatch(seed);
if (!tmdbMatch?.tmdbId) {
diagnostic.error = 'No TMDB match resolved for seed';
return { seed, diagnostic, resultSets: [], details: null, keywords: [], peopleIds: [] };
}
diagnostic.tmdbMatch = tmdbMatch;
const [details, keywords, credits, similar, recommendations] = await Promise.all([
fetchTmdbDetails(tmdbMatch),
fetchTmdbKeywords(tmdbMatch),
fetchTmdbCredits(tmdbMatch),
fetchTmdbSimilar({ ...tmdbMatch, page: 1 }),
fetchTmdbRecommendations({ ...tmdbMatch, page: 1 })
]);
const englishSimilar = filterEnglishCandidates(similar);
const englishRecommendations = filterEnglishCandidates(recommendations);
const localSimilar = uniqueById(englishSimilar.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean));
const localRecommendations = uniqueById(
englishRecommendations.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean)
);
diagnostic.similarCount = englishSimilar.length;
diagnostic.recommendationCount = englishRecommendations.length;
diagnostic.localSimilarCount = localSimilar.length;
diagnostic.localRecommendationCount = localRecommendations.length;
return {
seed,
details,
keywords,
peopleIds: extractPeopleIds(credits, tmdbMatch.mediaType),
diagnostic,
resultSets: [
{ items: localSimilar, sourceWeight: 5, label: 'tmdb-similar' },
{ items: localRecommendations, sourceWeight: 6, label: 'tmdb-recommendations' }
]
};
} catch (err) {
diagnostic.error = err.message;
return { seed, diagnostic, resultSets: [], details: null, keywords: [], peopleIds: [] };
}
}
async function buildPreview(userId, seedIds, limit, profile) {
const normalizedSeedIds = [...new Set(seedIds.map((id) => String(id).trim()).filter(Boolean))];
const libraryItems = uniqueById(await fetchAllLibraryItems(userId));
const playedItems = uniqueById(await fetchAllPlayedItems(userId));
const excludedIds = [...new Set([...normalizedSeedIds, ...playedItems.map((item) => item.id).filter(Boolean)])];
const libraryIndex = buildLibraryIndex(libraryItems);
const seeds = await fetchSeedItems(userId, normalizedSeedIds, libraryIndex);
const allowedTypes = [...new Set(seeds.map((item) => item.type).filter(Boolean))];
const includeMovies = allowedTypes.includes('Movie');
const includeSeries = allowedTypes.includes('Series');
if (!seeds.length) {
return {
seeds: [],
recommendations: [],
diagnostics: {
libraryItemCount: libraryItems.length,
watchedItemCount: playedItems.length,
seedCount: 0,
embyCandidates: 0,
tmdbEnabled: true,
tmdbCandidates: 0,
tmdbResolved: 0,
uniqueCandidateCount: 0,
excludedSeedCandidateCount: 0,
sampleExcludedSeedCandidates: [],
perSeed: [],
discoverQueries: []
}
};
}
const seedContexts = await Promise.all(seeds.map((seed) => buildSeedContext(seed, libraryIndex)));
const movieGenres = await fetchTmdbGenres('movie');
const tvGenres = await fetchTmdbGenres('tv');
const movieGenreLookup = getGenreLookup(movieGenres);
const tvGenreLookup = getGenreLookup(tvGenres);
const movieGenreWeights = new Map();
const tvGenreWeights = new Map();
const movieKeywordWeights = new Map();
const tvKeywordWeights = new Map();
const moviePeopleWeights = new Map();
for (const context of seedContexts) {
const mediaType = getTmdbMediaType(context.seed);
const genreTarget = mediaType === 'tv' ? tvGenreWeights : movieGenreWeights;
const keywordTarget = mediaType === 'tv' ? tvKeywordWeights : movieKeywordWeights;
for (const genre of context.details?.genres || []) {
addWeight(genreTarget, Number(genre.id), 5);
}
for (const keywordId of normalizeKeywordIds(context.keywords)) {
addWeight(keywordTarget, keywordId, 4);
}
if (mediaType === 'movie') {
for (const personId of context.peopleIds || []) {
addWeight(moviePeopleWeights, personId, 3);
}
}
}
resolveGenreIdsFromLocalItems(seeds.filter((item) => getTmdbMediaType(item) === 'movie'), movieGenreLookup, 3, movieGenreWeights);
resolveGenreIdsFromLocalItems(seeds.filter((item) => getTmdbMediaType(item) === 'tv'), tvGenreLookup, 3, tvGenreWeights);
resolveGenreIdsFromLocalItems(playedItems.filter((item) => getTmdbMediaType(item) === 'movie'), movieGenreLookup, 1, movieGenreWeights);
resolveGenreIdsFromLocalItems(playedItems.filter((item) => getTmdbMediaType(item) === 'tv'), tvGenreLookup, 1, tvGenreWeights);
const discoverQueries = [
...(includeMovies
? buildDiscoverQueries({
mediaType: 'movie',
genreIds: sortWeightedEntries(movieGenreWeights, 6).map(([id]) => id),
keywordIds: sortWeightedEntries(movieKeywordWeights, 8).map(([id]) => id),
peopleIds: sortWeightedEntries(moviePeopleWeights, 6).map(([id]) => id)
})
: []),
...(includeSeries
? buildDiscoverQueries({
mediaType: 'tv',
genreIds: sortWeightedEntries(tvGenreWeights, 6).map(([id]) => id),
keywordIds: sortWeightedEntries(tvKeywordWeights, 8).map(([id]) => id),
peopleIds: []
})
: [])
];
const queryResults = await Promise.all(
discoverQueries.map(async (query) => {
try {
const tmdbResults = filterEnglishCandidates(await fetchTmdbDiscover({
mediaType: query.label.startsWith('tv') ? 'tv' : 'movie',
params: query.params
}));
const localMatches = uniqueById(
tmdbResults.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean)
);
return {
label: query.label,
params: query.params,
tmdbCount: tmdbResults.length,
localCount: localMatches.length,
localMatches,
sourceWeight: 1
};
} catch (err) {
return {
label: query.label,
params: query.params,
tmdbCount: 0,
localCount: 0,
localMatches: [],
error: err.message
};
}
})
);
const resultSets = [
...seedContexts.flatMap((context) => context.resultSets),
...queryResults.map((query) => ({
items: query.localMatches,
sourceWeight: query.sourceWeight || 1,
label: query.label
}))
].filter((resultSet) => (Array.isArray(resultSet) ? resultSet.length : resultSet.items.length) > 0);
const candidateInspection = inspectCandidateSets(excludedIds, resultSets);
const recommendations = rankRecommendationResults(normalizedSeedIds, resultSets, {
limit,
profile,
seeds,
excludeIds: excludedIds,
allowedTypes
});
return {
seeds,
recommendations,
diagnostics: {
libraryItemCount: libraryItems.length,
watchedItemCount: playedItems.length,
seedCount: seeds.length,
embyCandidates: seedContexts.reduce(
(total, context) => total + context.diagnostic.localSimilarCount + context.diagnostic.localRecommendationCount,
0
),
tmdbEnabled: true,
tmdbCandidates:
seedContexts.reduce(
(total, context) => total + context.diagnostic.similarCount + context.diagnostic.recommendationCount,
0
) + queryResults.reduce((total, query) => total + query.tmdbCount, 0),
tmdbResolved:
seedContexts.reduce(
(total, context) => total + context.diagnostic.localSimilarCount + context.diagnostic.localRecommendationCount,
0
) + queryResults.reduce((total, query) => total + query.localCount, 0),
uniqueCandidateCount: candidateInspection.uniqueCandidateCount,
excludedSeedCandidateCount: candidateInspection.excludedSeedCandidateCount,
sampleExcludedSeedCandidates: candidateInspection.sampleExcludedSeedCandidates,
perSeed: seedContexts.map((context) => context.diagnostic),
discoverQueries: queryResults.map((query) => ({
label: query.label,
tmdbCount: query.tmdbCount,
localCount: query.localCount,
error: query.error || null,
params: query.params
}))
}
};
}
async function createOrUpdateCollection(userId, name, itemIds) {
const existingCollection = await findExistingCollection(userId, name);
if (existingCollection?.id) {
await fetchEmby(`/Collections/${encodeURIComponent(existingCollection.id)}/Items`, {
method: 'POST',
params: {
Ids: itemIds.join(',')
}
});
return {
id: existingCollection.id,
name: existingCollection.name || name,
updated: true
};
}
const response = await fetchEmby('/Collections', {
method: 'POST',
params: {
Name: name,
Ids: itemIds.join(',')
}
});
const created = await response.json();
return {
id: String(created?.Id || ''),
name: created?.Name || name,
updated: false
};
}
export async function POST({ request }) {
const body = await request.json();
const mode = body?.mode === 'create' ? 'create' : 'preview';
const userId = normalizeEmbyGuid(body?.userId);
const seedIds = Array.isArray(body?.seedIds) ? body.seedIds : [];
const limit = Math.min(Math.max(Number(body?.limit || 18), 1), 48);
const includeSeeds = body?.includeSeeds !== false;
const name = String(body?.name || '').trim();
const profile = RECOMMENDATION_PROFILES[body?.profile] ? body.profile : 'balanced';
if (!userId) {
throw error(400, 'Missing userId');
}
if (seedIds.length === 0) {
throw error(400, 'Select at least one seed item');
}
try {
const preview = await buildPreview(userId, seedIds, limit, profile);
const existingCollection = name ? await findExistingCollection(userId, name) : null;
if (mode !== 'create') {
return json({
...preview,
profile,
collection: existingCollection
? {
id: existingCollection.id,
name: existingCollection.name,
updated: true
}
: null
});
}
if (!name) {
throw error(400, 'Collection name is required');
}
const collectionIds = [
...(includeSeeds ? preview.seeds.map((item) => item.id) : []),
...preview.recommendations.map((item) => item.id)
];
const uniqueIds = [...new Set(collectionIds.filter(Boolean))];
if (uniqueIds.length === 0) {
throw error(400, 'No items were available to add to the collection');
}
const collection = await createOrUpdateCollection(userId, name, uniqueIds);
return json({
seeds: preview.seeds,
recommendations: preview.recommendations,
profile,
collection
});
} catch (err) {
if (err?.status) throw err;
if (String(err.message || '').includes('not configured')) {
throw error(400, err.message);
}
throw error(502, `Could not build Emby collection: ${err.message}`);
}
}