diff --git a/configuration/homeScreenSectionEditor.js b/configuration/homeScreenSectionEditor.js
index 4ec0aaa..29ff924 100644
--- a/configuration/homeScreenSectionEditor.js
+++ b/configuration/homeScreenSectionEditor.js
@@ -55,11 +55,71 @@
];
const WIZARD_PARENT_DATA = {
- PERSON_RESULT_LIMIT: 500,
+ PERSON_RESULT_LIMIT: 100,
+ STUDIO_RESULT_LIMIT: 100,
+ PARENT_LIST_DISPLAY_LIMIT: 100,
SEARCH_DEBOUNCE_MS: 300,
APPEARANCE_PREVIEW_DEBOUNCE_MS: 400
};
+ const LOCAL_SEARCH_PARENT_TYPES = new Set(['Genre', 'Tag', 'Collection', 'Playlist', 'Library']);
+ const SERVER_SEARCH_PARENT_TYPES = new Set(['Person', 'Studio']);
+ const PARENT_FILTER_CHIP_TYPES = new Set(['Genre', 'Tag', 'Studio']);
+
+ function getParentFilterItemTypes(wizardState, parentType = wizardState?.parentType) {
+ if (!parentType || !wizardState?.parentFilterItemTypesByType) return [];
+ const list = wizardState.parentFilterItemTypesByType[parentType];
+ return Array.isArray(list) ? list : [];
+ }
+
+ function ensureParentFilterItemTypes(wizardState, parentType = wizardState?.parentType) {
+ if (!wizardState.parentFilterItemTypesByType) {
+ wizardState.parentFilterItemTypesByType = {};
+ }
+ if (!parentType) return [];
+ if (!Array.isArray(wizardState.parentFilterItemTypesByType[parentType])) {
+ wizardState.parentFilterItemTypesByType[parentType] = [];
+ }
+ return wizardState.parentFilterItemTypesByType[parentType];
+ }
+
+ function getParentSearch(wizardState, parentType = wizardState?.parentType) {
+ if (!parentType || !wizardState?.parentSearchByType) return '';
+ return wizardState.parentSearchByType[parentType] || '';
+ }
+
+ function setParentSearch(wizardState, value, parentType = wizardState?.parentType) {
+ if (!wizardState.parentSearchByType) {
+ wizardState.parentSearchByType = {};
+ }
+ if (!parentType) return;
+ wizardState.parentSearchByType[parentType] = value || '';
+ // Keep legacy mirror in sync for the active type
+ if (parentType === wizardState.parentType) {
+ wizardState.search = wizardState.parentSearchByType[parentType];
+ }
+ }
+
+ function getParentFetchKey(wizardState, parentType = wizardState?.parentType) {
+ if (!parentType) return '';
+ const search = getParentSearch(wizardState, parentType);
+ const types = getParentFilterItemTypes(wizardState, parentType).slice().sort().join(',');
+ return `${search}\0${types}`;
+ }
+
+ function markParentFetchKey(wizardState, parentType = wizardState?.parentType) {
+ if (!wizardState.parentFetchKeyByType) {
+ wizardState.parentFetchKeyByType = {};
+ }
+ if (!parentType) return;
+ wizardState.parentFetchKeyByType[parentType] = getParentFetchKey(wizardState, parentType);
+ }
+
+ function parentFetchKeyMatches(wizardState, parentType = wizardState?.parentType) {
+ if (!parentType) return false;
+ return wizardState.parentFetchKeyByType?.[parentType] === getParentFetchKey(wizardState, parentType);
+ }
+
const PARENT_TYPES = [
{ type: 'Genre', label: 'Genre', plural: 'genres', description: 'Show items that share a Genre, like Action or Comedy.', icon: 'theater_comedy', queryField: 'GenreIds' },
{ type: 'Collection', label: 'Collection', plural: 'collections', description: 'Show items from a specific Collection in your library.', icon: 'collections_bookmark', queryField: 'ParentId' },
@@ -286,49 +346,63 @@
return items.filter(i => i.name.toLowerCase().includes(q));
}
+ function getDisplayParentItems(fullList, search, limit = WIZARD_PARENT_DATA.PARENT_LIST_DISPLAY_LIMIT) {
+ return filterParentItemsBySearch(fullList, search).slice(0, limit);
+ }
+
+ function renderParentItemTypeChipsHTML(activeTypes, actionAttr, toggleAction) {
+ return `
+
+
Included Item Types
+
${escapeHtml(getIncludedItemTypesHintText(activeTypes))}
+
+ ${ITEM_TYPE_OPTIONS.map(type => {
+ const active = activeTypes.includes(type);
+ return ``;
+ }).join('')}
+
+
+ `;
+ }
+
function formatItemCountMeta(count) {
if (count == null || Number.isNaN(count)) return '';
const n = parseInt(count, 10);
return n === 1 ? '1 item' : `${n} items`;
}
- async function fetchGenres(includeItemTypes) {
- const itemTypes = (includeItemTypes && includeItemTypes.length > 0)
- ? includeItemTypes
- : ['Movie'];
- const serverAddress = window.ApiClient?.serverAddress();
- if (!serverAddress) return [];
-
- const typesParam = Array.isArray(itemTypes) ? itemTypes.join(',') : itemTypes;
- const url = `${serverAddress}/Genres?IncludeItemTypes=${typesParam}`;
- let data;
- if (window.apiHelper?.getData) {
- data = await window.apiHelper.getData(url, true, 24 * 60 * 60 * 1000);
- } else if (window.ApiClient?.fetch) {
- const resp = await window.ApiClient.fetch({ url, method: 'GET' });
- data = await resp.json();
- } else {
- return [];
+ /**
+ * Unwrap progressive apiHelper.getQuery results ({ data, dataPromise/ensureData }).
+ * On cache miss, data is null until ensureData()/dataPromise resolves — never treat
+ * the wrapper object itself as the payload.
+ */
+ async function resolveQueryPayload(result) {
+ if (!result) return null;
+ if (typeof result.ensureData === 'function' || result.dataPromise != null) {
+ if (result.data != null) return result.data;
+ return await (typeof result.ensureData === 'function'
+ ? result.ensureData()
+ : result.dataPromise);
}
- return (data?.Items || []).map(item => ({ id: item.Id, name: item.Name }));
+ return result;
+ }
+
+ function asItemArray(payload) {
+ if (Array.isArray(payload?.Items)) return payload.Items;
+ if (Array.isArray(payload)) return payload;
+ return [];
+ }
+
+ async function fetchGenres(includeItemTypes) {
+ if (!window.dataHelper?.getFilterGenreItems) return [];
+ const types = (includeItemTypes && includeItemTypes.length > 0) ? includeItemTypes : null;
+ return window.dataHelper.getFilterGenreItems(types, { useCache: true });
}
async function fetchTags(includeItemTypes) {
- const itemTypes = (includeItemTypes && includeItemTypes.length > 0)
- ? includeItemTypes
- : ['Movie'];
- if (window.dataHelper?.getFilters) {
- const filters = await window.dataHelper.getFilters(itemTypes, true, false);
- const list = filters?.Tags || [];
- return list.map(name => ({ id: name, name }));
- }
- const userId = window.ApiClient?.getCurrentUserId();
- const serverAddress = window.ApiClient?.serverAddress();
- if (!userId || !serverAddress || !window.apiHelper?.getData) return [];
- const url = `${serverAddress}/Items/Filters?UserId=${userId}&IncludeItemTypes=${Array.isArray(itemTypes) ? itemTypes.join(',') : itemTypes}`;
- const data = await window.apiHelper.getData(url, true);
- const list = data?.Tags || [];
- return list.map(name => ({ id: name, name }));
+ if (!window.dataHelper?.getFilterTagItems) return [];
+ const types = (includeItemTypes && includeItemTypes.length > 0) ? includeItemTypes : null;
+ return window.dataHelper.getFilterTagItems(types, { useCache: true });
}
async function fetchCollectionsOrPlaylists(parentType) {
@@ -339,22 +413,46 @@
true,
24 * 60 * 60 * 1000
);
- return (data?.Items || []).map(item => ({
+ return asItemArray(data).map(item => ({
id: item.Id,
name: item.Name,
meta: formatItemCountMeta(item.ChildCount ?? item.UserData?.UnplayedItemCount ?? item.RecursiveItemCount)
}));
}
- async function fetchStudios() {
+ async function fetchStudios(search, includeItemTypes) {
const userId = window.ApiClient?.getCurrentUserId();
const serverAddress = window.ApiClient?.serverAddress();
if (!userId || !serverAddress || !window.apiHelper?.getQuery) return [];
- const url = `${serverAddress}/Studios?UserId=${userId}&Fields=PrimaryImageAspectRatio`;
- const result = await window.apiHelper.getQuery(url, { useCache: true, ttl: 24 * 60 * 60 * 1000 });
- const data = (result && result.data) ? result.data : result;
- const items = data?.Items || data || [];
- return items.map(item => ({
+
+ const params = new URLSearchParams({
+ UserId: userId,
+ Limit: String(WIZARD_PARENT_DATA.STUDIO_RESULT_LIMIT),
+ Fields: 'PrimaryImageAspectRatio'
+ });
+
+ const trimmed = (search || '').trim();
+ if (trimmed.length === 1) {
+ params.set('NameStartsWith', trimmed);
+ } else if (trimmed.length > 1) {
+ params.set('SearchTerm', trimmed);
+ }
+
+ const types = (includeItemTypes && includeItemTypes.length > 0)
+ ? (Array.isArray(includeItemTypes) ? includeItemTypes.join(',') : String(includeItemTypes))
+ : '';
+ if (types) {
+ params.set('IncludeItemTypes', types);
+ }
+
+ const url = `${serverAddress}/Studios?${params.toString()}`;
+ const useCache = !trimmed && !types;
+ const result = await window.apiHelper.getQuery(url, {
+ useCache,
+ ttl: 24 * 60 * 60 * 1000
+ });
+ const data = await resolveQueryPayload(result);
+ return asItemArray(data).map(item => ({
id: item.Id,
name: item.Name,
meta: formatItemCountMeta(item.ChildCount ?? item.SeriesCount)
@@ -384,7 +482,7 @@
let data;
if (window.apiHelper?.getQuery) {
const result = await window.apiHelper.getQuery(url, { useCache: false });
- data = (result && result.data) ? result.data : result;
+ data = await resolveQueryPayload(result);
} else if (window.ApiClient?.fetch) {
const resp = await window.ApiClient.fetch({ url, method: 'GET' });
data = await resp.json();
@@ -392,7 +490,7 @@
return [];
}
- return (data?.Items || []).map(item => ({
+ return asItemArray(data).map(item => ({
id: item.Id,
name: item.Name,
meta: item.ProductionYear ? String(item.ProductionYear) : ''
@@ -406,8 +504,7 @@
try {
if (window.ApiClient?.getUserViews) {
const result = await window.ApiClient.getUserViews({}, userId);
- const items = result?.Items || result || [];
- return items.map(item => ({
+ return asItemArray(result).map(item => ({
id: item.Id,
name: item.Name,
meta: item.CollectionType ? humanizeLabel(item.CollectionType) : ''
@@ -418,9 +515,8 @@
if (!serverAddress || !window.apiHelper?.getQuery) return [];
const url = `${serverAddress}/UserViews?UserId=${userId}`;
const result = await window.apiHelper.getQuery(url, { useCache: true, ttl: 24 * 60 * 60 * 1000 });
- const data = (result && result.data) ? result.data : result;
- const items = data?.Items || data || [];
- return items.map(item => ({
+ const data = await resolveQueryPayload(result);
+ return asItemArray(data).map(item => ({
id: item.Id,
name: item.Name,
meta: item.CollectionType ? humanizeLabel(item.CollectionType) : ''
@@ -446,7 +542,7 @@
case 'Playlist':
return fetchCollectionsOrPlaylists(parentType);
case 'Studio':
- return fetchStudios();
+ return fetchStudios(search, includeItemTypes);
case 'Person':
return fetchPersons(search);
case 'Library':
@@ -594,7 +690,7 @@
await resolveItemsByGetItems(ids, null, idToName);
break;
case 'Genre': {
- (await fetchGenres(includeItemTypes.length ? includeItemTypes : ['Movie']))
+ (await fetchGenres(includeItemTypes.length ? includeItemTypes : null))
.forEach(item => idToName.set(item.id, item.name));
const remaining = ids.filter(id => !idToName.has(id));
if (remaining.length) {
@@ -603,14 +699,15 @@
break;
}
case 'Tag':
- (await fetchTags(includeItemTypes.length ? includeItemTypes : ['Movie']))
+ (await fetchTags(includeItemTypes.length ? includeItemTypes : null))
.forEach(item => idToName.set(item.id, item.name));
break;
case 'Person':
await resolveItemsByGetItems(ids, 'Person', idToName);
break;
case 'Studio': {
- (await fetchStudios()).forEach(item => idToName.set(item.id, item.name));
+ (await fetchStudios('', includeItemTypes.length ? includeItemTypes : null))
+ .forEach(item => idToName.set(item.id, item.name));
const remaining = ids.filter(id => !idToName.has(id));
if (remaining.length) {
await resolveItemsByGetItems(remaining, 'Studio', idToName);
@@ -687,16 +784,30 @@
}).join('');
}
+ function getWizardParentListItems(wizardState) {
+ const type = wizardState.parentType;
+ const search = getParentSearch(wizardState, type);
+ if (type === 'Person') {
+ return wizardState.personSearchResults || [];
+ }
+ if (SERVER_SEARCH_PARENT_TYPES.has(type)) {
+ return wizardState.parentItemsByType[type] || [];
+ }
+ return getDisplayParentItems(
+ wizardState.parentItemsByType[type] || [],
+ search
+ );
+ }
+
function renderParentListHTML(wizardState) {
- const items = wizardState.parentType === 'Person'
- ? (wizardState.personSearchResults || [])
- : filterParentItemsBySearch(wizardState.parentItemsByType[wizardState.parentType] || [], wizardState.search);
+ const items = getWizardParentListItems(wizardState);
+ const search = getParentSearch(wizardState);
return renderSelectionListHTML({
type: wizardState.parentType,
items,
selected: wizardState.parents || [],
- search: wizardState.search,
+ search,
status: wizardState.parentListStatus,
error: wizardState.parentListError,
actionAttr: 'data-hsse-action',
@@ -733,6 +844,7 @@
let search = '';
let itemsCache = [];
let personResults = [];
+ let activeItemTypes = [...(includeItemTypes || [])];
let listStatus = type === 'Custom' ? 'idle' : 'loading';
let listError = '';
let searchTimer = null;
@@ -752,7 +864,8 @@
function getVisibleItems() {
if (type === 'Person') return personResults;
- return filterParentItemsBySearch(itemsCache, search);
+ if (SERVER_SEARCH_PARENT_TYPES.has(type)) return itemsCache;
+ return getDisplayParentItems(itemsCache, search);
}
function renderBody(options = {}) {
@@ -771,7 +884,12 @@
return;
}
+ const filterChips = PARENT_FILTER_CHIP_TYPES.has(type)
+ ? renderParentItemTypeChipsHTML(activeItemTypes, 'data-sel-action', 'toggle-filter-type')
+ : '';
+
content.innerHTML = `
+ ${filterChips}
@@ -806,7 +924,7 @@
try {
const fetched = await fetchSelectionItems(type, {
search: forceSearch != null ? forceSearch : search,
- includeItemTypes
+ includeItemTypes: PARENT_FILTER_CHIP_TYPES.has(type) ? activeItemTypes : includeItemTypes
});
if (requestId !== loadRequestId) return;
if (type === 'Person') {
@@ -863,6 +981,12 @@
toggleItem(actionEl.dataset.parentId, actionEl.dataset.parentName);
} else if (action === 'retry-load') {
loadItems();
+ } else if (action === 'toggle-filter-type') {
+ const chipType = actionEl.dataset.itemType;
+ const idx = activeItemTypes.indexOf(chipType);
+ if (idx >= 0) activeItemTypes.splice(idx, 1);
+ else activeItemTypes.push(chipType);
+ loadItems();
}
});
@@ -870,7 +994,7 @@
if (e.target.id === 'hsse-selection-search') {
search = e.target.value;
if (searchTimer) clearTimeout(searchTimer);
- if (type === 'Person') {
+ if (SERVER_SEARCH_PARENT_TYPES.has(type)) {
searchTimer = setTimeout(() => loadItems(search), WIZARD_PARENT_DATA.SEARCH_DEBOUNCE_MS);
} else {
renderBody();
@@ -1944,10 +2068,19 @@
if (step === 'parent') {
const meta = PARENT_TYPES.find(t => t.type === wizardState.parentType);
+ const parentSearch = getParentSearch(wizardState);
+ const filterChips = PARENT_FILTER_CHIP_TYPES.has(wizardState.parentType)
+ ? renderParentItemTypeChipsHTML(
+ getParentFilterItemTypes(wizardState),
+ 'data-hsse-action',
+ 'wizard-toggle-parent-filter-type'
+ )
+ : '';
return `
+ ${filterChips}
-
+
${renderParentListHTML(wizardState)}
@@ -2821,9 +2954,16 @@
// Calculate itemTypesForFilters early (used for Genres/Tags)
const rawItemTypes = includeItemTypes || queryOptions.IncludeItemTypes;
- const itemTypesForFilters = (type === 'Genres' || type === 'Tags')
- ? (Array.isArray(rawItemTypes) ? rawItemTypes : [rawItemTypes])
- : null;
+ let itemTypesForFilters = null;
+ if (type === 'Genres' || type === 'Tags') {
+ if (Array.isArray(rawItemTypes)) {
+ itemTypesForFilters = rawItemTypes.filter(Boolean);
+ } else if (rawItemTypes) {
+ itemTypesForFilters = [rawItemTypes];
+ } else {
+ itemTypesForFilters = [];
+ }
+ }
// Extract current values from query options
let currentValues = [];
@@ -2834,30 +2974,24 @@
const selectedGenres = queryOptions.Genres || '';
const selectedGenreNames = selectedGenres ? (Array.isArray(selectedGenres) ? selectedGenres : selectedGenres.split(',').map(s => s.trim()).filter(s => s)) : [];
- // Fetch available genres from dataHelper.getFilters() to validate/match selected values
- if (window.dataHelper && window.dataHelper.getFilters && itemTypesForFilters) {
+ if (window.dataHelper?.getFilterGenreItems) {
try {
- const filters = await window.dataHelper.getFilters(itemTypesForFilters, true, false);
- const availableGenres = filters?.Genres || [];
-
- // Match selected genre names against available genres (case-insensitive)
- const genreMap = new Map(availableGenres.map(g => [g.toLowerCase(), g]));
+ const filterTypes = itemTypesForFilters?.length ? itemTypesForFilters : null;
+ const availableGenres = await window.dataHelper.getFilterGenreItems(filterTypes, { useCache: true });
+ const genreNames = availableGenres.map(g => g.name);
+ const genreMap = new Map(genreNames.map(g => [g.toLowerCase(), g]));
currentValues = selectedGenreNames
.map(name => {
- // Try exact match first, then case-insensitive
- const exactMatch = availableGenres.find(g => g === name);
+ const exactMatch = genreNames.find(g => g === name);
if (exactMatch) return exactMatch;
- const caseInsensitiveMatch = genreMap.get(name.toLowerCase());
- return caseInsensitiveMatch || name; // Fallback to original name if not found
+ return genreMap.get(name.toLowerCase()) || name;
})
.filter(Boolean);
} catch (err) {
WARN('Error fetching genres from dataHelper:', err);
- // Fallback to using selected values as-is
currentValues = selectedGenreNames;
}
} else {
- // Fallback to using selected values as-is
currentValues = selectedGenreNames;
}
} else if (type === 'Tags') {
@@ -2865,30 +2999,24 @@
const selectedTags = queryOptions.Tags || '';
const selectedTagNames = selectedTags ? (Array.isArray(selectedTags) ? selectedTags : selectedTags.split(',').map(s => s.trim()).filter(s => s)) : [];
- // Fetch available tags from dataHelper.getFilters() to validate/match selected values
- if (window.dataHelper && window.dataHelper.getFilters && itemTypesForFilters) {
+ if (window.dataHelper?.getFilterTagItems) {
try {
- const filters = await window.dataHelper.getFilters(itemTypesForFilters, true, false);
- const availableTags = filters?.Tags || [];
-
- // Match selected tag names against available tags (case-insensitive)
- const tagMap = new Map(availableTags.map(t => [t.toLowerCase(), t]));
+ const filterTypes = itemTypesForFilters?.length ? itemTypesForFilters : null;
+ const availableTags = await window.dataHelper.getFilterTagItems(filterTypes, { useCache: true });
+ const tagNames = availableTags.map(t => t.name);
+ const tagMap = new Map(tagNames.map(t => [t.toLowerCase(), t]));
currentValues = selectedTagNames
.map(name => {
- // Try exact match first, then case-insensitive
- const exactMatch = availableTags.find(t => t === name);
+ const exactMatch = tagNames.find(t => t === name);
if (exactMatch) return exactMatch;
- const caseInsensitiveMatch = tagMap.get(name.toLowerCase());
- return caseInsensitiveMatch || name; // Fallback to original name if not found
+ return tagMap.get(name.toLowerCase()) || name;
})
.filter(Boolean);
} catch (err) {
WARN('Error fetching tags from dataHelper:', err);
- // Fallback to using selected values as-is
currentValues = selectedTagNames;
}
} else {
- // Fallback to using selected values as-is
currentValues = selectedTagNames;
}
} else if (type === 'Collections') {
@@ -3836,6 +3964,9 @@
parentType: null,
parents: [],
search: '',
+ parentSearchByType: {},
+ parentFilterItemTypesByType: {},
+ parentFetchKeyByType: {},
parentItemsByType: {},
parentListStatus: 'idle',
parentListError: null,
@@ -3882,17 +4013,27 @@
if (list) list.innerHTML = renderParentListHTML(wizardState);
try {
+ const parentSearch = getParentSearch(wizardState);
+ const parentFilterTypes = getParentFilterItemTypes(wizardState);
if (wizardState.parentType === 'Person') {
- const results = await fetchPersons(wizardState.search);
+ const results = await fetchPersons(parentSearch);
if (requestId !== parentLoadRequestId) return;
wizardState.personSearchResults = results;
+ } else if (SERVER_SEARCH_PARENT_TYPES.has(wizardState.parentType)) {
+ const items = await fetchWizardParentItems(wizardState.parentType, {
+ search: parentSearch,
+ includeItemTypes: parentFilterTypes
+ });
+ if (requestId !== parentLoadRequestId) return;
+ wizardState.parentItemsByType[wizardState.parentType] = items;
} else {
const items = await fetchWizardParentItems(wizardState.parentType, {
- includeItemTypes: wizardState.includeItemTypes
+ includeItemTypes: parentFilterTypes
});
if (requestId !== parentLoadRequestId) return;
wizardState.parentItemsByType[wizardState.parentType] = items;
}
+ markParentFetchKey(wizardState);
wizardState.parentListStatus = 'idle';
} catch (err) {
if (requestId !== parentLoadRequestId) return;
@@ -3914,7 +4055,7 @@
function scheduleParentSearch(root) {
if (parentSearchDebounceTimer) clearTimeout(parentSearchDebounceTimer);
- if (wizardState.parentType === 'Person') {
+ if (SERVER_SEARCH_PARENT_TYPES.has(wizardState.parentType)) {
parentSearchDebounceTimer = setTimeout(() => {
loadParentItemsForType(wizardState, root);
}, WIZARD_PARENT_DATA.SEARCH_DEBOUNCE_MS);
@@ -4134,9 +4275,10 @@
if (stage === 'wizard' && wizardState.step === 'parent' && wizardState.parentType) {
const cached = wizardState.parentItemsByType[wizardState.parentType];
+ const keyMatches = parentFetchKeyMatches(wizardState);
const needsLoad = wizardState.parentType === 'Person'
- ? wizardState.parentListStatus === 'idle' && wizardState.personSearchResults.length === 0
- : !cached && wizardState.parentListStatus !== 'loading';
+ ? !keyMatches || (wizardState.parentListStatus === 'idle' && wizardState.personSearchResults.length === 0)
+ : !keyMatches || (!cached && wizardState.parentListStatus !== 'loading');
if (needsLoad || wizardState.parentListStatus === 'error') {
loadParentItemsForType(wizardState, activeRoot);
} else if (wizardState.parents?.length) {
@@ -4202,10 +4344,13 @@
if (selected !== wizardState.parentType) {
wizardState.parentType = selected;
wizardState.parents = [];
- wizardState.search = '';
+ // Restore per-type search; do not share chips/search across Genre/Tag/Studio
+ wizardState.search = getParentSearch(wizardState, selected);
wizardState.parentListStatus = 'idle';
wizardState.parentListError = null;
- wizardState.personSearchResults = [];
+ if (selected !== 'Person') {
+ wizardState.personSearchResults = [];
+ }
wizardState.maxReachedStepIndex = 0;
if (selected === 'Custom') {
wizardState.customItems = [createBlankCustomItem()];
@@ -4355,6 +4500,29 @@
break;
}
+ case 'wizard-toggle-parent-filter-type': {
+ const type = el.dataset.itemType;
+ const filterTypes = ensureParentFilterItemTypes(wizardState);
+ const idx = filterTypes.indexOf(type);
+ if (idx >= 0) {
+ filterTypes.splice(idx, 1);
+ } else {
+ filterTypes.push(type);
+ }
+ el.classList.toggle('hsse-active');
+ updateIncludedItemTypesHint(root, filterTypes);
+ // Invalidate cached list for this parent type so chips stay in sync with results
+ delete wizardState.parentItemsByType[wizardState.parentType];
+ if (wizardState.parentFetchKeyByType) {
+ delete wizardState.parentFetchKeyByType[wizardState.parentType];
+ }
+ if (wizardState.parentType === 'Person') {
+ wizardState.personSearchResults = [];
+ }
+ loadParentItemsForType(wizardState, root);
+ break;
+ }
+
case 'wizard-set-render-mode':
wizardState.renderMode = el.dataset.renderMode;
if (wizardState.renderMode === 'Random') {
@@ -4500,7 +4668,7 @@
root.addEventListener('input', (e) => {
if (e.target.id === 'hsse-parent-search') {
- wizardState.search = e.target.value;
+ setParentSearch(wizardState, e.target.value);
scheduleParentSearch(root);
}
if (e.target.matches('[data-hsse-custom-preview]')) {
diff --git a/helpers/dataHelper.js b/helpers/dataHelper.js
index 33c93a1..54b3821 100644
--- a/helpers/dataHelper.js
+++ b/helpers/dataHelper.js
@@ -13,6 +13,106 @@
throw new Error('ApiClient is not available');
}
}
+
+ /** @type {Map} */
+ const filtersMemoryCache = new Map();
+
+ function parseMajorVersion(version) {
+ if (!version || typeof version !== 'string') return 0;
+ const major = parseInt(version.split('.')[0], 10);
+ return Number.isNaN(major) ? 0 : major;
+ }
+
+ function getServerMajorVersion() {
+ ensureApiClient();
+ const version = ApiClient._serverVersion || ApiClient._appVersion || '';
+ return parseMajorVersion(version);
+ }
+
+ function normalizeIncludeItemTypesKey(includeItemTypes) {
+ if (includeItemTypes == null) return 'ALL';
+ const list = Array.isArray(includeItemTypes)
+ ? includeItemTypes.map(t => String(t).trim()).filter(Boolean)
+ : String(includeItemTypes).split(',').map(s => s.trim()).filter(Boolean);
+ if (list.length === 0) return 'ALL';
+ return [...list].sort().join(',');
+ }
+
+ function buildIncludeItemTypesParam(includeItemTypes) {
+ const key = normalizeIncludeItemTypesKey(includeItemTypes);
+ if (key === 'ALL') return '';
+ return key;
+ }
+
+ async function unwrapGetQueryResult(result) {
+ if (!result) return null;
+ if (typeof result.ensureData === 'function' || result.dataPromise != null) {
+ if (result.data != null) return result.data;
+ return await (typeof result.ensureData === 'function'
+ ? result.ensureData()
+ : result.dataPromise);
+ }
+ return result;
+ }
+
+ function normalizeGenreEntry(entry) {
+ if (entry == null) return null;
+ if (typeof entry === 'string') {
+ const name = entry.trim();
+ return name ? { id: name, name } : null;
+ }
+ const id = entry.Id ?? entry.id;
+ const name = entry.Name ?? entry.name;
+ if (id != null && name != null) {
+ return { id: String(id), name: String(name) };
+ }
+ if (name != null) {
+ const n = String(name);
+ return { id: n, name: n };
+ }
+ return null;
+ }
+
+ function normalizeTagEntry(entry) {
+ if (entry == null) return null;
+ if (typeof entry === 'string') {
+ const name = entry.trim();
+ return name ? { id: name, name } : null;
+ }
+ const name = entry.Name ?? entry.name ?? entry.Id ?? entry.id;
+ if (name == null) return null;
+ const n = String(name);
+ return { id: n, name: n };
+ }
+
+ function normalizeFiltersPayload(raw) {
+ const genres = (Array.isArray(raw?.Genres) ? raw.Genres : [])
+ .map(normalizeGenreEntry)
+ .filter(Boolean);
+ const tags = (Array.isArray(raw?.Tags) ? raw.Tags : [])
+ .map(normalizeTagEntry)
+ .filter(Boolean);
+ return {
+ Genres: genres,
+ Tags: tags,
+ OfficialRatings: Array.isArray(raw?.OfficialRatings) ? raw.OfficialRatings : [],
+ Years: Array.isArray(raw?.Years) ? raw.Years : []
+ };
+ }
+
+ function buildFiltersUrl(serverAddress, userId, includeItemTypes) {
+ const typesParam = buildIncludeItemTypesParam(includeItemTypes);
+ const useFilters2 = getServerMajorVersion() >= 12;
+ const path = useFilters2 ? '/Items/Filters2' : '/Items/Filters';
+ const params = new URLSearchParams({ UserId: userId });
+ if (useFilters2) {
+ params.set('recursive', 'true');
+ }
+ if (typesParam) {
+ params.set('IncludeItemTypes', typesParam);
+ }
+ return `${serverAddress}${path}?${params.toString()}`;
+ }
/**
* API Helper functions for Jellyfin operations
@@ -50,35 +150,39 @@
throw new Error('User ID or server address not available');
}
- // Normalize includeItemTypes to comma-separated string
- const itemTypesStr = Array.isArray(includeItemTypes)
- ? includeItemTypes.join(',')
- : (includeItemTypes || 'Movie');
-
- // Build URL
- let url = `${serverAddress}/Items/Filters?UserId=${userId}`;
- if (itemTypesStr) {
- url += `&IncludeItemTypes=${itemTypesStr}`;
+ const cacheKey = normalizeIncludeItemTypesKey(includeItemTypes);
+ if (forceRefresh) {
+ filtersMemoryCache.delete(cacheKey);
}
+ if (useCache && !forceRefresh && filtersMemoryCache.has(cacheKey)) {
+ return filtersMemoryCache.get(cacheKey);
+ }
+
+ const url = buildFiltersUrl(serverAddress, userId, includeItemTypes);
- // Use apiHelper.getQuery with 24-hour cache (86400000 ms)
const cacheOptions = {
useCache: useCache && !forceRefresh,
- ttl: 24 * 60 * 60 * 1000, // 24 hours
+ ttl: 24 * 60 * 60 * 1000,
forceRefresh: forceRefresh
};
- const data = await window.apiHelper.getQuery(url, cacheOptions);
-
- // Handle both direct response and promise response
- const filters = (data && data.data) ? data.data : data;
-
- return {
- Genres: filters?.Genres || [],
- Tags: filters?.Tags || [],
- OfficialRatings: filters?.OfficialRatings || [],
- Years: filters?.Years || []
- };
+ const result = await window.apiHelper.getQuery(url, cacheOptions);
+ const raw = await unwrapGetQueryResult(result);
+ const normalized = normalizeFiltersPayload(raw);
+ filtersMemoryCache.set(cacheKey, normalized);
+ return normalized;
+ },
+
+ getFilterGenreItems: async function(includeItemTypes, options = {}) {
+ const { useCache = true, forceRefresh = false } = options;
+ const filters = await this.getFilters(includeItemTypes, useCache, forceRefresh);
+ return filters.Genres || [];
+ },
+
+ getFilterTagItems: async function(includeItemTypes, options = {}) {
+ const { useCache = true, forceRefresh = false } = options;
+ const filters = await this.getFilters(includeItemTypes, useCache, forceRefresh);
+ return filters.Tags || [];
},
/**
diff --git a/scripts/ui.js b/scripts/ui.js
index cf73546..590f473 100644
--- a/scripts/ui.js
+++ b/scripts/ui.js
@@ -420,11 +420,11 @@
}
if (type === 'Tag' || type === 'Genre') {
- // Use dataHelper.getFilters with includeItemTypes
- const itemTypes = includeItemTypes || ['Movie'];
+ const itemTypes = (includeItemTypes && includeItemTypes.length) ? includeItemTypes : null;
if (window.dataHelper && window.dataHelper.getFilters) {
const filters = await window.dataHelper.getFilters(itemTypes, !refresh, refresh);
- options = type === 'Tag' ? (filters?.Tags || []) : (filters?.Genres || []);
+ const raw = type === 'Tag' ? (filters?.Tags || []) : (filters?.Genres || []);
+ options = raw.map(entry => (typeof entry === 'string' ? entry : entry.name));
} else {
// Fallback to old method
const url = `${serverAddress}/Items/Filters?UserId=${userId}&IncludeItemTypes=${Array.isArray(itemTypes) ? itemTypes.join(',') : itemTypes}`;