diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js index 077bf74..35a82ee 100644 --- a/configuration/homeScreen-configuration.js +++ b/configuration/homeScreen-configuration.js @@ -6089,49 +6089,29 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; throw new Error('Section has no queries to preview'); } - // Match home-screen selection: picker / random load one query; otherwise merge all - const resolveQueries = window.cardBuilder.resolveQueriesToLoad; - const queriesToLoad = typeof resolveQueries === 'function' - ? resolveQueries(previewSection, { initialize: true }) - : queries; + if (typeof window.cardBuilder.refreshSectionQueries !== 'function') { + throw new Error('Required dependencies not available for preview'); + } const useSpotlightFields = options.useSpotlightFields === true || previewSection.renderMode === 'Spotlight'; + const previewCopy = { + ...previewSection, + queries: queries.map(withPreviewQueryOptions), + renderMode: useSpotlightFields ? 'Spotlight' : previewSection.renderMode + }; - let allItems = []; - for (const query of queriesToLoad) { - const previewQuery = withPreviewQueryOptions(query); - let queryUrl = null; - - if (previewQuery.dataSource) { - queryUrl = null; - } else { - queryUrl = window.apiHelper.buildQueryFromSection(previewQuery, userId, serverUrl, useSpotlightFields, { sectionType: previewSection.type }); - } - - try { - let items = []; - if (previewQuery.dataSource) { - const response = await window.apiHelper.fetchFromDataSource(previewQuery.dataSource, previewQuery.queryOptions, false); - items = response.Items || response || []; - } else if (queryUrl && typeof queryUrl === 'string') { - const result = await apiHelper.getQuery(queryUrl, { useCache: false }); - items = result.Items || result || []; - } - allItems = allItems.concat(items); - } catch (error) { - console.error('[Preview] Error fetching query:', error); - throw new Error(`Error fetching query: ${error.message}`); - } + let items; + try { + items = await window.cardBuilder.refreshSectionQueries(previewCopy); + } catch (error) { + console.error('[Preview] Error fetching query:', error); + throw new Error(`Error fetching query: ${error.message}`); } - if (allItems.length === 0) { - return { items: [], limit: 0, section: previewSection }; - } + if (!Array.isArray(items)) items = []; - allItems = window.cardBuilder.postProcessItems(previewSection, allItems); - - return { items: allItems, limit: 0, section: previewSection }; + return { items, limit: items.length, section: previewSection }; } const EDITOR_PREVIEW_SECTION_ID = 'hsae-editor-preview'; diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js index 7bdff8d..2bdf5d5 100644 --- a/scripts/apiHelper.js +++ b/scripts/apiHelper.js @@ -1264,58 +1264,44 @@ * @returns {Object} - Merged result object with config, result.data, result.dataPromise, result.isStalePromise */ mergeMultiQueryResults: function(results, sectionConfig) { - // Combine all items from all queries - const allItems = []; - const itemsByQuery = []; - - results.forEach(result => { - if (result.data?.Items) { - allItems.push(...result.data.Items); - itemsByQuery.push(result.data.Items); - } - }); - - // Sort merged items if sortBy specified - /* if (sectionConfig.sortBy) { - allItems.sort((a, b) => { - const aVal = a[sectionConfig.sortBy] || (a.UserData?.[sectionConfig.sortBy] ? new Date(a.UserData[sectionConfig.sortBy]).getTime() : 0); - const bVal = b[sectionConfig.sortBy] || (b.UserData?.[sectionConfig.sortBy] ? new Date(b.UserData[sectionConfig.sortBy]).getTime() : 0); - - // Handle DatePlayed specifically for merged Continue/NextUp - if (sectionConfig.sortBy === 'DatePlayed') { - const aDate = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0; - const bDate = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0; - const comparison = aDate > bDate ? 1 : aDate < bDate ? -1 : 0; - return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison; - } - - const comparison = aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison; - }); - } */ - + const queryPayloadItems = (data) => { + if (!data) return []; + if (Array.isArray(data)) return data; + if (Array.isArray(data.Items)) return data.Items; + if (Array.isArray(data.data?.Items)) return data.data.Items; + return []; + }; + // Skip the getQuery stub `{ Items: [] }` (empty arrays are truthy). Keep one slot per query. + const syncQueryItems = (result) => { + if (!result) return []; + if (Array.isArray(result.Items) && result.Items.length > 0) return result.Items; + const items = result.data?.Items; + if (Array.isArray(items) && items.length > 0) return items; + return []; + }; + const itemsByQuery = (results || []).map((result) => syncQueryItems(result)); + const processLists = (lists) => window.cardBuilder.postProcessItemsByQuery(sectionConfig, lists); + let dataPromise = null; const ensureData = function() { if (!dataPromise) { - dataPromise = Promise.all(results.map((r) => ( - typeof r.ensureData === 'function' ? r.ensureData() : r.dataPromise - ))).then((allData) => { - const itemsByFreshQuery = []; - allData.forEach((data) => { - if (data?.Items) { - itemsByFreshQuery.push(data.Items); - } - }); - return window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByFreshQuery); + dataPromise = Promise.all((results || []).map((r) => { + if (!r) return Promise.resolve([]); + if (typeof r.ensureData === 'function') return r.ensureData(); + if (r.dataPromise) return r.dataPromise; + return Promise.resolve(r.data != null ? r.data : r); + })).then((allData) => { + const itemsByFreshQuery = allData.map((data) => queryPayloadItems(data)); + return { Items: processLists(itemsByFreshQuery) }; }); } return dataPromise; }; const mergedResult = { - data: { Items: window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByQuery) }, - isStale: results.some((r) => r.isStale), - isStalePromise: Promise.all(results.map(r => r.isStalePromise || Promise.resolve(false))) + data: { Items: processLists(itemsByQuery) }, + isStale: (results || []).some((r) => r && r.isStale), + isStalePromise: Promise.all((results || []).map(r => r?.isStalePromise || Promise.resolve(false))) .then(staleFlags => staleFlags.some(s => s)), ensureData }; diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index f582936..0260008 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -962,26 +962,106 @@ return sortedContinueWatching; } - function postProcessItemsByQuery(sectionConfig, queryResults) { - let processed = []; - for (let i = 0; i < queryResults.length; i++) { - const queryConfig = sectionConfig.queries[i]; - const queryResult = queryResults[i]; - const items = queryResult?.Items || queryResult || []; - processed.push(...postProcessItems(queryConfig, items)); + function itemsFromQueryPayload(queryResult) { + if (!queryResult) return []; + if (Array.isArray(queryResult)) return queryResult; + if (Array.isArray(queryResult.Items)) return queryResult.Items; + return []; + } + + function mergedSectionItemLimit(sectionConfig) { + const itemLimit = Number(sectionConfig?.itemLimit); + if (itemLimit > 0) return itemLimit; + const total = (sectionConfig?.queries || []).reduce((acc, query) => { + const limit = Number(query?.queryOptions?.Limit); + return acc + (limit > 0 ? limit : 0); + }, 0); + return total > 0 ? total : 0; + } + + function applyMergedSectionRules(sectionConfig, items) { + let processed = Array.isArray(items) ? items : []; + + if (sectionConfig.flattenSeries === true) { + processed = flattenSeriesEpisodes(processed); + } + + if (sectionConfig.type === 'series-episodes' && Array.isArray(processed)) { + processed = [...processed].sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0)); } if (sectionConfig.id === 'continueWatchingAndNextUp') { - // Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them const movies = processed.filter((item) => item.Type === 'Movie'); const episodes = processed.filter((item) => item.Type !== 'Movie'); processed = mergeNextUpAndContinueWatching(episodes, movies); } - return processed; + if (sectionConfig.id === 'upcoming') { + const today = new Date(); + today.setHours(0, 0, 0, 0); + processed = processed.filter((item) => { + if (!item.PremiereDate || item.LocationType !== 'Virtual') return false; + const premiereDate = new Date(item.PremiereDate); + premiereDate.setHours(0, 0, 0, 0); + return premiereDate >= today; + }); + } + + if ( + sectionConfig.id === 'recentlyReleased.movies' || + sectionConfig.id === 'upcoming' || + sectionConfig.id === 'recentlyReleased.episodes' + ) { + processed.forEach((item) => { + if (item.PremiereDate) { + item.CustomFooterText = formatAirDate(item.PremiereDate); + } + }); + } + + const sortBy = sectionConfig.sortBy != null ? String(sectionConfig.sortBy).trim() : ''; + const limit = mergedSectionItemLimit(sectionConfig); + if (sortBy) { + if (sortBy.toLowerCase() === 'random' && limit > 0 && sectionConfig.limitBeforeSort === true) { + processed = processed.slice(0, limit); + } + processed = sortItems(processed, sortBy, sectionConfig.sortOrder || 'Ascending'); + } + + if (limit > 0) { + processed = processed.slice(0, limit); + } + + return deduplicateItems(processed); } - function postProcessItems(sectionConfig, itemsData) { + function postProcessItemsByQuery(sectionConfig, queryResults) { + let processed = []; + const queries = sectionConfig?.queries || []; + const lists = Array.isArray(queryResults) ? queryResults : []; + for (let i = 0; i < lists.length; i++) { + const query = queries[i]; + const items = itemsFromQueryPayload(lists[i]); + if (!query) { + processed.push(...items); + continue; + } + processed.push(...postProcessItems({ queries: [query] }, items, { scope: 'query' })); + } + + const sortAndLimitOptions = { + sortBy: sectionConfig.sortBy, + sortOrder: sectionConfig.sortOrder, + limit: mergedSectionItemLimit(sectionConfig), + limitBeforeSort: sectionConfig.limitBeforeSort, + handleAllSortingOptions: true + }; + + return postProcessItems(sectionConfig, processed, sortAndLimitOptions); + } + + function postProcessItems(sectionConfig, itemsData, options = {}) { + const { sortBy, sortOrder, limit, limitBeforeSort, handleAllSortingOptions } = options; let processed = itemsData?.Items || itemsData || []; //Check if imageTypes is used in the query to ensure only items with these image types are included @@ -1051,20 +1131,7 @@ processed = [...processed].sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0)); } - /* if (sectionConfig.id === 'popularTVNetworks') { - // Sort items randomly - processed = shuffle(processed); - } */ - - if (sectionConfig.id === 'continueWatchingAndNextUp') { - // Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them - const movies = processed.filter((item) => item.Type === 'Movie'); - const episodes = processed.filter((item) => item.Type !== 'Movie'); - processed = mergeNextUpAndContinueWatching(episodes, movies); - } - if (sectionConfig.id === 'upcoming') { - // Remove items which aired before today const today = new Date(); today.setHours(0, 0, 0, 0); processed = processed.filter((item) => { @@ -1075,7 +1142,6 @@ }); } - // Add Custom Secondary Text for Recently Released Movies if ( sectionConfig.id === 'recentlyReleased.movies' || sectionConfig.id === 'upcoming' || @@ -1088,56 +1154,138 @@ }); } - // Sort items by UserData.PlayedPercentage if PlayedPercentage is set as the SortBy - if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'PlayedPercentage') { + if (sectionConfig.id === 'continueWatchingAndNextUp') { + const movies = processed.filter((item) => item.Type === 'Movie'); + const episodes = processed.filter((item) => item.Type !== 'Movie'); + processed = mergeNextUpAndContinueWatching(episodes, movies); + } else { + const sortAndLimitOptions = { + sortBy: sortBy || sectionConfig.queries?.[0]?.queryOptions?.SortBy, + sortOrder: sortOrder || sectionConfig.queries?.[0]?.queryOptions?.SortOrder, + limit: limit || Number(sectionConfig.queries?.[0]?.queryOptions?.Limit || 0), + handleAllSortingOptions: handleAllSortingOptions, + limitBeforeSort: limitBeforeSort + }; + + processed = sortAndLimitItems(processed, sortAndLimitOptions); + } + + // Deduplicate items based on ProviderIds. Look for match imdb, tmdb or tvdb ids + processed = deduplicateItems(processed); + + return processed; + } + + function childCountSortValue(item) { + return (item?.ChildCount || 0) + + (item?.SeriesCount || 0) + + (item?.EpisodeCount || 0) + + (item?.AlbumCount || 0) + + (item?.ArtistCount || 0) + + (item?.ProgramCount || 0) + + (item?.MovieCount || 0) + + (item?.SongCount || 0); + } + + const SORT_FIELD_GETTERS = { + Name: { get: (item) => item?.Name, type: 'string' }, + SortName: { get: (item) => item?.SortName || item?.Name, type: 'string' }, + DateCreated: { get: (item) => item?.DateCreated, type: 'date' }, + PremiereDate: { get: (item) => item?.PremiereDate, type: 'date' }, + StartDate: { get: (item) => item?.StartDate, type: 'date' }, + DateLastContentAdded: { get: (item) => item?.DateLastContentAdded, type: 'date' }, + DatePlayed: { get: (item) => item?.UserData?.LastPlayedDate, type: 'date' }, + SeriesDatePlayed: { get: (item) => item?.UserData?.LastPlayedDate, type: 'date' }, + CommunityRating: { get: (item) => item?.CommunityRating, type: 'number' }, + CriticRating: { get: (item) => item?.CriticRating, type: 'number' }, + ProductionYear: { get: (item) => item?.ProductionYear, type: 'number' }, + Runtime: { get: (item) => item?.RunTimeTicks, type: 'number' }, + PlayCount: { get: (item) => item?.UserData?.PlayCount, type: 'number' }, + PlayedPercentage: { get: (item) => item?.UserData?.PlayedPercentage, type: 'number' }, + ParentIndexNumber: { get: (item) => item?.ParentIndexNumber, type: 'number' }, + IndexNumber: { get: (item) => item?.IndexNumber, type: 'number' }, + SimilarityScore: { get: (item) => item?.SimilarityScore, type: 'number' }, + SearchScore: { get: (item) => item?.SearchScore, type: 'number' }, + ChildCount: { get: childCountSortValue, type: 'number' }, + IsPlayed: { get: (item) => item?.UserData?.Played === true, type: 'boolean' }, + IsUnplayed: { get: (item) => item?.UserData?.Played !== true, type: 'boolean' }, + IsFolder: { get: (item) => item?.IsFolder === true, type: 'boolean' } + }; + + function isMissingSortValue(value, type) { + if (value == null || value === '') return true; + if (type === 'number' && !Number.isFinite(Number(value))) return true; + if (type === 'date' && Number.isNaN(new Date(value).getTime())) return true; + return false; + } + + function compareSortValues(aVal, bVal, type, descending) { + const aMissing = isMissingSortValue(aVal, type); + const bMissing = isMissingSortValue(bVal, type); + if (aMissing && bMissing) return 0; + if (aMissing) return 1; + if (bMissing) return -1; + let cmp = 0; + if (type === 'string') { + cmp = String(aVal).localeCompare(String(bVal), undefined, { sensitivity: 'base' }); + } else if (type === 'boolean') { + cmp = (aVal ? 1 : 0) - (bVal ? 1 : 0); + } else if (type === 'date') { + cmp = new Date(aVal).getTime() - new Date(bVal).getTime(); + } else { + cmp = Number(aVal) - Number(bVal); + } + return descending ? -cmp : cmp; + } + + function sortByFieldGetter(items, sortBy, order) { + const field = SORT_FIELD_GETTERS[sortBy]; + if (!field) return items; + const descending = order === 'Descending'; + return [...items].sort((a, b) => compareSortValues(field.get(a), field.get(b), field.type, descending)); + } + + function sortAndLimitItems(items, options = { handleAllSortingOptions: false }) { + const { sortBy, sortOrder, limit, limitBeforeSort, handleAllSortingOptions } = options; + const order = sortOrder || 'Ascending'; + let processed = items; + + if (limit > 0 && limitBeforeSort === true) { + processed = processed.slice(0, limit); + } + + if (handleAllSortingOptions === true) { + const field = sortBy != null ? String(sortBy).trim() : ''; + if (!field || field === 'Default') { + // Leave concatenated order. + } else if (field === 'Random') { + processed = shuffle(processed); + } else if (field === 'PlayedPercentage') { + processed = processed.filter((item) => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0); + processed = sortByFieldGetter(processed, field, order); + } else { + processed = sortByFieldGetter(processed, field, order); + } + } else if (sortBy === 'PlayedPercentage') { processed = processed.sort((a, b) => { const playedPercentageA = a.UserData?.PlayedPercentage || 0; const playedPercentageB = b.UserData?.PlayedPercentage || 0; - return playedPercentageB - playedPercentageA; + if (order === 'Descending') { + return playedPercentageB - playedPercentageA; + } + return playedPercentageA - playedPercentageB; }); - - // Remove any items which have no UserData.PlayedPercentage processed = processed.filter((item) => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0); - } - - // Check for SortBy ChildCount and use ChildCount to sort the items - // ChildCount = ChildCount + SeriesCount + EpisodeCount + AlbumCount + ArtistCount + ProgramCount + MovieCount + SongCount - if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'ChildCount') { + } else if (sortBy === 'ChildCount') { processed = processed.sort((a, b) => { - const childCountA = - (a.ChildCount || 0) + - (a.SeriesCount || 0) + - (a.EpisodeCount || 0) + - (a.AlbumCount || 0) + - (a.ArtistCount || 0) + - (a.ProgramCount || 0) + - (a.MovieCount || 0) + - (a.SongCount || 0); - const childCountB = - (b.ChildCount || 0) + - (b.SeriesCount || 0) + - (b.EpisodeCount || 0) + - (b.AlbumCount || 0) + - (b.ArtistCount || 0) + - (b.ProgramCount || 0) + - (b.MovieCount || 0) + - (b.SongCount || 0); - - if (sectionConfig.queries?.[0]?.queryOptions?.SortOrder === 'Descending') { + const childCountA = childCountSortValue(a); + const childCountB = childCountSortValue(b); + if (order === 'Descending') { return childCountB - childCountA; } return childCountA - childCountB; }); - } - - // Sum of all query limits - const totalLimit = sectionConfig.queries?.reduce((acc, query) => acc + (query.queryOptions?.Limit || 0), 0); - const limit = sectionConfig.itemLimit || totalLimit || 0; - - if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'Random' || sectionConfig.sortBy === 'Random') { - if (limit > 0 && sectionConfig.limitBeforeSort === true) { - processed = processed.slice(0, limit); - } + } else if (sortBy === 'Random') { processed = shuffle(processed); } @@ -1146,9 +1294,6 @@ processed = processed.slice(0, limit); } - // Deduplicate items based on ProviderIds. Look for match imdb, tmdb or tvdb ids - processed = deduplicateItems(processed); - return processed; } @@ -1387,11 +1532,7 @@ const allData = await Promise.all( results.map((r) => r._refreshPromise || Promise.resolve(r.data)), ); - const itemsByFreshQuery = []; - allData.forEach((data) => { - if (data?.Items) itemsByFreshQuery.push(data.Items); - else if (Array.isArray(data)) itemsByFreshQuery.push(data); - }); + const itemsByFreshQuery = allData.map((data) => itemsFromQueryPayload(data)); return postProcessItemsByQuery(sectionConfig, itemsByFreshQuery); } @@ -1506,9 +1647,10 @@ let finalResult; if (results.length > 1) { finalResult = ApiHelper.mergeMultiQueryResults(results, sectionConfig); - // Extract items from merged result - const items = finalResult.result?.data?.Items || finalResult.result?.data || []; - return items; + const settled = typeof finalResult.result?.ensureData === 'function' + ? await finalResult.result.ensureData() + : finalResult.result?.data; + return settled?.Items || []; } else if (results.length === 1) { // Single query result const data = results[0].data || results[0]; diff --git a/scripts/sectionHelper.js b/scripts/sectionHelper.js index 811db6b..f2a6dd7 100644 --- a/scripts/sectionHelper.js +++ b/scripts/sectionHelper.js @@ -400,6 +400,17 @@ return sortItemsByConfiguredIds(cfg, postProcessedItems); }; + const itemsFromMerged = (raw) => { + if (Array.isArray(raw?.Items)) return raw.Items; + if (Array.isArray(raw)) return raw; + return []; + }; + + const finishMerged = (items) => { + const filtered = applyExcludeItemIdFilter(sectionConfig, items); + return sortItemsByConfiguredIds(sectionConfig, filtered); + }; + let mappedDataPromise = null; const result = { data: { Items: [] }, @@ -463,19 +474,19 @@ result.refreshPromise = Promise.all( results.map((r) => r._refreshPromise || Promise.resolve(r.data)) ).then((allData) => { - const itemsByFreshQuery = []; - allData.forEach((data) => { - if (data?.Items) itemsByFreshQuery.push(data.Items); - else if (Array.isArray(data)) itemsByFreshQuery.push(data); + const itemsByFreshQuery = allData.map((data) => { + if (Array.isArray(data?.Items)) return data.Items; + if (Array.isArray(data)) return data; + return []; }); const mergedFresh = window.cardBuilder?.postProcessItemsByQuery ? window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByFreshQuery) : itemsByFreshQuery.flat(); - return postProcess(sectionConfig, mergedFresh); + return finishMerged(mergedFresh); }); } - return postProcess(sectionConfig, raw); + return finishMerged(itemsFromMerged(raw)); } const queryResult = results[0];