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