Fix issue with Studios/Genres/Tags types in the Home Screen Section Wizard

This commit is contained in:
HighImKevin
2026-09-16 07:27:06 -04:00
parent 3fd8f75ab2
commit 1853fd2e4f
3 changed files with 394 additions and 122 deletions
+265 -97
View File
@@ -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 `
<div class="hsse-section-block">
<div class="listItemBodyText hsse-section-label">Included Item Types</div>
<p id="hsse-item-types-hint" class="listItemBodyText secondary hsse-field-hint">${escapeHtml(getIncludedItemTypesHintText(activeTypes))}</p>
<div class="hsse-chip-row">
${ITEM_TYPE_OPTIONS.map(type => {
const active = activeTypes.includes(type);
return `<button type="button" class="kefin-chip hsse-chip listItemBodyText${active ? ' hsse-active' : ''}" ${actionAttr}="${toggleAction}" data-item-type="${escapeHtml(type)}">${escapeHtml(humanizeLabel(type))}</button>`;
}).join('')}
</div>
</div>
`;
}
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}
<div class="hsse-section-block">
<input type="text" id="hsse-selection-search" class="fld emby-input" value="${escapeHtml(search)}" placeholder="Search..." aria-label="Search ${escapeHtml(meta?.plural || 'items')}">
</div>
@@ -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}
<div class="hsse-section-block">
<input type="text" id="hsse-parent-search" class="fld emby-input" value="${escapeHtml(wizardState.search)}" placeholder="Search..." aria-label="Search ${escapeHtml(meta?.plural || 'items')}">
<input type="text" id="hsse-parent-search" class="fld emby-input" value="${escapeHtml(parentSearch)}" placeholder="Search..." aria-label="Search ${escapeHtml(meta?.plural || 'items')}">
</div>
<div class="hsse-parent-list" id="hsse-parent-list">
${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]')) {
+126 -22
View File
@@ -13,6 +13,106 @@
throw new Error('ApiClient is not available');
}
}
/** @type {Map<string, { Genres: Array, Tags: Array, OfficialRatings: Array, Years: Array }>} */
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 || [];
},
/**
+3 -3
View File
@@ -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}`;