Fix multi-query previews, and ensure multi query merging respects section level sortby/sortorder

This commit is contained in:
HighImKevin
2026-10-21 19:07:57 -04:00
parent 688023a1d9
commit 820c2cec1f
4 changed files with 279 additions and 160 deletions
+16 -36
View File
@@ -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';
+29 -43
View File
@@ -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
};
+217 -75
View File
@@ -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];
+17 -6
View File
@@ -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];