696 lines
19 KiB
JavaScript
696 lines
19 KiB
JavaScript
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}`);
|
|||
|
|
}
|
|||
|
|
}
|