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