From 3229ba412385141000ab32b6b8dd934bc0e8c0b6 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Thu, 1 Oct 2026 06:48:17 -0400 Subject: [PATCH] Improve support for Client Cache query types --- configuration/homeScreenAdvancedEditor.js | 150 +++++++++++++++++++--- configuration/homeScreenSectionEditor.css | 5 - scripts/libraryCacheUtils.js | 130 ++++++++++++++++++- scripts/moviesCache.js | 32 ++--- scripts/peopleCache.js | 47 +++---- scripts/seriesCache.js | 30 ++--- 6 files changed, 295 insertions(+), 99 deletions(-) diff --git a/configuration/homeScreenAdvancedEditor.js b/configuration/homeScreenAdvancedEditor.js index 14d5f6b..0e7d8ee 100644 --- a/configuration/homeScreenAdvancedEditor.js +++ b/configuration/homeScreenAdvancedEditor.js @@ -20,12 +20,33 @@ { value: 'Custom', label: 'Custom', path: null } ]; + const PEOPLE_CACHE_ITEM_TYPE_VALUES = [ + { value: '', label: 'All' }, + { value: 'Movie', label: 'Movie' }, + { value: 'Series', label: 'Series' }, + { value: 'Episode', label: 'Episode' } + ]; + + const PEOPLE_CACHE_ARGS = [ + { + key: 'ItemType', + type: 'select', + label: 'Item Type', + possibleValues: PEOPLE_CACHE_ITEM_TYPE_VALUES + }, + { key: 'MinCount', type: 'number', label: 'Min Count' } + ]; + const CACHE_SOURCE_OPTIONS = [ { value: 'MoviesCache.getImdbTop250Movies', label: 'IMDb Top 250 Movies' }, - { value: 'StudiosCache.getPopularTVNetworks', label: 'Popular TV Networks' }, - { value: 'PeopleCache.getTopActors', label: 'Top Actors' }, - { value: 'PeopleCache.getTopDirectors', label: 'Top Directors' }, - { value: 'PeopleCache.getTopWriters', label: 'Top Writers' } + { + value: 'StudiosCache.getPopularTVNetworks', + label: 'Popular TV Networks', + args: [{ key: 'MinCount', type: 'number', label: 'Min Count' }] + }, + { value: 'PeopleCache.getTopActors', label: 'Top Actors', args: PEOPLE_CACHE_ARGS }, + { value: 'PeopleCache.getTopDirectors', label: 'Top Directors', args: PEOPLE_CACHE_ARGS }, + { value: 'PeopleCache.getTopWriters', label: 'Top Writers', args: PEOPLE_CACHE_ARGS } ]; const DEFAULT_SECTION_QUERY_LIMIT = window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16; @@ -1388,9 +1409,60 @@ if (list) list.innerHTML = buildStaticItemsListHTML(items); } + function getCacheSourceOption(dataSource) { + const value = dataSource || CACHE_SOURCE_OPTIONS[0]?.value; + return CACHE_SOURCE_OPTIONS.find(o => o.value === value) || CACHE_SOURCE_OPTIONS[0] || null; + } + + function buildCacheArgsHTML(queryIndex, dataSource, dataSourceOptions) { + const { buildSelect, buildTextInput } = getApi(); + const source = getCacheSourceOption(dataSource); + const args = Array.isArray(source?.args) ? source.args : []; + if (!args.length) return ''; + + const fields = args.map((arg) => { + const id = `query-${queryIndex}-dsArg-${arg.key}`; + const raw = dataSourceOptions?.[arg.key]; + const selected = raw != null ? String(raw) : (arg.default != null ? String(arg.default) : ''); + if (arg.type === 'select') { + const options = Array.isArray(arg.possibleValues) ? arg.possibleValues : []; + return buildSelect(id, options, selected, arg.label || arg.key); + } + if (arg.type === 'number') { + return buildTextInput(id, selected, arg.label || arg.key, 'number', '', { step: 1 }); + } + return buildTextInput(id, selected, arg.label || arg.key); + }).join(''); + + return `
${fields}
`; + } + + function collectCacheArgsIntoDataSourceOptions(editor, queryIndex, dataSource, dataSourceOptions) { + const source = getCacheSourceOption(dataSource); + const args = Array.isArray(source?.args) ? source.args : []; + args.forEach((arg) => { + const el = editor.querySelector(`#query-${queryIndex}-dsArg-${arg.key}`); + if (!el) return; + const raw = el.value; + if (raw === '' || raw == null) return; + if (arg.type === 'number') { + const n = parseFloat(raw); + if (Number.isFinite(n)) dataSourceOptions[arg.key] = n; + return; + } + dataSourceOptions[arg.key] = raw; + }); + } + function buildQueryCard(query, index, canDelete, isExpanded = true) { const { buildSelect, buildTextInput, SORT_ORDERS, SORT_ORDER_DIRECTIONS } = getApi(); const queryOptions = query.queryOptions || {}; + // Prefer dataSourceOptions; fall back to legacy ItemType/MinCount stored in queryOptions + const dataSourceOptions = { + ...(query.queryOptions?.ItemType != null ? { ItemType: query.queryOptions.ItemType } : {}), + ...(query.queryOptions?.MinCount != null ? { MinCount: query.queryOptions.MinCount } : {}), + ...(query.dataSourceOptions || {}) + }; const sourceType = inferQuerySourceType(query); const endpointValue = sourceType === 'static' ? 'Items' : inferEndpointValue(query); const customPathDisplay = endpointValue === 'Custom' && sourceType === 'jellyfin' ? 'block' : 'none'; @@ -1398,8 +1470,10 @@ const endpointDisplay = sourceType === 'jellyfin' || sourceType === 'static' ? 'block' : 'none'; const jellyfinDisplay = sourceType === 'jellyfin' ? 'block' : 'none'; const staticDisplay = sourceType === 'static' ? 'block' : 'none'; - const sortExtrasDisplay = sourceType === 'jellyfin' || sourceType === 'static' ? 'block' : 'none'; + const limitDisplay = sourceType === 'jellyfin' || sourceType === 'static' || sourceType === 'cache' ? 'block' : 'none'; + const searchDisplay = sourceType === 'jellyfin' || sourceType === 'static' ? 'block' : 'none'; const staticItems = getQueryStaticItems(query); + const selectedDataSource = query.dataSource || CACHE_SOURCE_OPTIONS[0]?.value; const parentSelection = getQueryParentSelection(query); const includeTypes = Array.isArray(queryOptions.IncludeItemTypes) @@ -1434,12 +1508,15 @@ ${buildEndpointSelectHTML(index, endpointValue, sourceType === 'static')}
- ${buildSelect(`query-${index}-dataSource`, CACHE_SOURCE_OPTIONS, query.dataSource || CACHE_SOURCE_OPTIONS[0].value, 'Cache Source')} + ${buildSelect(`query-${index}-dataSource`, CACHE_SOURCE_OPTIONS, selectedDataSource, 'Cache Source')}
${buildTextInput(`query-${index}-path`, query.path || '', 'Custom Endpoint Path', 'text', '/Shows/Upcoming, etc.')}
+
+ ${sourceType === 'cache' ? buildCacheArgsHTML(index, selectedDataSource, dataSourceOptions) : ''} +
@@ -1448,10 +1525,10 @@
${buildSelect(`query-${index}-SortOrder`, SORT_ORDER_DIRECTIONS, queryOptions.SortOrder || '', 'Sort Order')}
-
+
${buildTextInput(`query-${index}-Limit`, queryOptions.Limit ?? '', 'Limit', 'number', '', { min: 0, step: 1 })}
- @@ -2369,17 +2446,36 @@ } if (sourceType === 'cache') { - const dataSource = editor.querySelector(`#query-${queryIndex}-dataSource`)?.value; + const dataSource = editor.querySelector(`#query-${queryIndex}-dataSource`)?.value + || CACHE_SOURCE_OPTIONS[0]?.value; if (dataSource) query.dataSource = dataSource; - } else { - const endpoint = editor.querySelector(`#query-${queryIndex}-endpoint`)?.value || 'Items'; - const endpointMeta = ENDPOINT_OPTIONS.find(o => o.value === endpoint); - if (endpoint === 'Custom') { - const path = editor.querySelector(`#query-${queryIndex}-path`)?.value?.trim(); - if (path) query.path = path; - } else if (endpointMeta?.path) { - query.path = endpointMeta.path; + query.queryOptions = {}; + ['SortBy', 'SortOrder'].forEach(key => { + const value = editor.querySelector(`#query-${queryIndex}-${key}`)?.value; + if (value) query.queryOptions[key] = value; + }); + const limit = editor.querySelector(`#query-${queryIndex}-Limit`)?.value; + if (limit !== '' && limit != null) { + const limitN = parseInt(limit, 10); + if (Number.isFinite(limitN) && limitN >= 0) { + query.queryOptions.Limit = limitN; + } } + const dataSourceOptions = {}; + collectCacheArgsIntoDataSourceOptions(editor, queryIndex, dataSource, dataSourceOptions); + if (Object.keys(dataSourceOptions).length) { + query.dataSourceOptions = dataSourceOptions; + } + return query; + } + + const endpoint = editor.querySelector(`#query-${queryIndex}-endpoint`)?.value || 'Items'; + const endpointMeta = ENDPOINT_OPTIONS.find(o => o.value === endpoint); + if (endpoint === 'Custom') { + const path = editor.querySelector(`#query-${queryIndex}-path`)?.value?.trim(); + if (path) query.path = path; + } else if (endpointMeta?.path) { + query.path = endpointMeta.path; } query.queryOptions = {}; @@ -2709,12 +2805,15 @@ const sourceSelect = editor.querySelector(`#query-${queryIndex}-sourceType`); const endpointSelect = editor.querySelector(`#query-${queryIndex}-endpoint`); + const dataSourceSelect = editor.querySelector(`#query-${queryIndex}-dataSource`); const pathContainer = editor.querySelector(`#query-${queryIndex}-path-container`); const endpointWrap = editor.querySelector('.hsae-jellyfin-endpoint-wrap'); const cacheWrap = editor.querySelector('.hsae-cache-wrap'); + const cacheArgsWrap = editor.querySelector('.hsae-cache-args-wrap'); const jellyfinOnlyBlocks = editor.querySelectorAll('.hsae-query-jellyfin-only'); const staticOnlyBlocks = editor.querySelectorAll('.hsae-query-static-only'); - const sortExtrasBlocks = editor.querySelectorAll('.hsae-query-sort-extras'); + const limitBlock = editor.querySelector('.hsae-query-limit'); + const searchBlock = editor.querySelector('.hsae-query-search'); const updateSourceVisibility = () => { const sourceType = sourceSelect?.value || 'jellyfin'; @@ -2724,15 +2823,21 @@ if (sourceType === 'static') endpointSelect.value = 'Items'; } if (cacheWrap) cacheWrap.style.display = sourceType === 'cache' ? 'block' : 'none'; + if (cacheArgsWrap) cacheArgsWrap.style.display = sourceType === 'cache' ? 'block' : 'none'; jellyfinOnlyBlocks.forEach(block => { block.style.display = sourceType === 'jellyfin' ? '' : 'none'; }); staticOnlyBlocks.forEach(block => { block.style.display = sourceType === 'static' ? '' : 'none'; }); - sortExtrasBlocks.forEach(block => { - block.style.display = sourceType === 'jellyfin' || sourceType === 'static' ? '' : 'none'; - }); + if (limitBlock) { + limitBlock.style.display = sourceType === 'jellyfin' || sourceType === 'static' || sourceType === 'cache' + ? '' + : 'none'; + } + if (searchBlock) { + searchBlock.style.display = sourceType === 'jellyfin' || sourceType === 'static' ? '' : 'none'; + } }; const updateEndpointVisibility = () => { @@ -2743,6 +2848,9 @@ sourceSelect?.addEventListener('change', () => { refreshEditorBody(modalInstance); }); + dataSourceSelect?.addEventListener('change', () => { + refreshEditorBody(modalInstance); + }); endpointSelect?.addEventListener('change', updateEndpointVisibility); updateSourceVisibility(); diff --git a/configuration/homeScreenSectionEditor.css b/configuration/homeScreenSectionEditor.css index 6f7f729..6780c97 100644 --- a/configuration/homeScreenSectionEditor.css +++ b/configuration/homeScreenSectionEditor.css @@ -85,11 +85,6 @@ margin-right: auto !important; } -[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-flow-body:has(.hsse-wizard) { - display: flex; - align-items: center; -} - [data-modal-id="kefin-homescreen-section-create-flow"] .hsse-subtitle { margin: 0 0 1.25em; font-size: 0.9375rem; diff --git a/scripts/libraryCacheUtils.js b/scripts/libraryCacheUtils.js index 5038fcd..1f6048e 100644 --- a/scripts/libraryCacheUtils.js +++ b/scripts/libraryCacheUtils.js @@ -297,6 +297,133 @@ } } + const IDLE_HOLD_MS = 15000; + const BOOTSTRAP_FALLBACK_MS = 30000; + const SESSION_READY_FALLBACK_MS = 30000; + + function hasMouseIdle() { + return typeof document !== 'undefined' + && document.body + && document.body.classList.contains('mouseIdle'); + } + + /** + * After login (+ home painted or session-ready fallback), fire onReady once when + * the first of these happens: 15s continuous body.mouseIdle, document.hidden, + * or 30s fallback. Background tabs are allowed (and preferred). + * + * @param {{ waitForLogin: () => Promise, onReady: () => void }} opts + */ + function scheduleIdleBootstrap(opts) { + const waitForLogin = opts?.waitForLogin; + const onReady = opts?.onReady; + if (typeof onReady !== 'function') return; + + let fired = false; + let idleTimer = null; + let fallbackTimer = null; + let sessionReadyTimer = null; + let mo = null; + + const cleanup = () => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + if (fallbackTimer) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } + if (sessionReadyTimer) { + clearTimeout(sessionReadyTimer); + sessionReadyTimer = null; + } + if (mo) { + mo.disconnect(); + mo = null; + } + document.removeEventListener('visibilitychange', onVisibility); + document.removeEventListener('kefinTweaksHomePainted', onHomePainted); + }; + + const fire = () => { + if (fired) return; + fired = true; + cleanup(); + try { + onReady(); + } catch (e) { + console.error('[KefinTweaks LibraryCacheUtils] scheduleIdleBootstrap onReady failed:', e); + } + }; + + const syncIdleHold = () => { + if (fired) return; + if (hasMouseIdle()) { + if (!idleTimer) { + idleTimer = setTimeout(() => { + idleTimer = null; + if (hasMouseIdle()) fire(); + }, IDLE_HOLD_MS); + } + } else if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + }; + + const onVisibility = () => { + if (document.hidden) fire(); + }; + + const armGates = () => { + if (fired) return; + + if (document.hidden) { + fire(); + return; + } + + fallbackTimer = setTimeout(fire, BOOTSTRAP_FALLBACK_MS); + document.addEventListener('visibilitychange', onVisibility); + + if (typeof MutationObserver !== 'undefined' && document.body) { + mo = new MutationObserver(syncIdleHold); + mo.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + } + syncIdleHold(); + }; + + const onHomePainted = () => { + document.removeEventListener('kefinTweaksHomePainted', onHomePainted); + if (sessionReadyTimer) { + clearTimeout(sessionReadyTimer); + sessionReadyTimer = null; + } + armGates(); + }; + + Promise.resolve() + .then(() => (typeof waitForLogin === 'function' ? waitForLogin() : true)) + .then((ok) => { + if (fired) return; + if (!ok) { + fire(); + return; + } + document.addEventListener('kefinTweaksHomePainted', onHomePainted); + sessionReadyTimer = setTimeout(() => { + sessionReadyTimer = null; + document.removeEventListener('kefinTweaksHomePainted', onHomePainted); + armGates(); + }, SESSION_READY_FALLBACK_MS); + }) + .catch((e) => { + console.error('[KefinTweaks LibraryCacheUtils] scheduleIdleBootstrap login wait failed:', e); + fire(); + }); + } + window.LibraryCacheUtils = { stripMovieForCache, stripSeriesForCache, @@ -310,6 +437,7 @@ upsertById, removeByIds, isVideoPlaybackRoute, - waitWhileCacheNetworkPaused + waitWhileCacheNetworkPaused, + scheduleIdleBootstrap }; })(); diff --git a/scripts/moviesCache.js b/scripts/moviesCache.js index f729e28..028bc1a 100644 --- a/scripts/moviesCache.js +++ b/scripts/moviesCache.js @@ -448,6 +448,7 @@ }); } + /** Defer crawl until idle/hidden/fallback after home has a chance to paint. */ let bootstrapStarted = false; let bootstrapScheduled = false; @@ -462,30 +463,19 @@ } } - /** Defer crawl until home has painted (or 3s after login) so home getQuery is not starved. */ function scheduleBootstrap() { if (bootstrapScheduled) return; bootstrapScheduled = true; - - const start = () => { bootstrap(); }; - - const onPainted = () => { - document.removeEventListener('kefinTweaksHomePainted', onPainted); - start(); - }; - document.addEventListener('kefinTweaksHomePainted', onPainted); - - waitForLoginReady().then((ok) => { - if (!ok) { - document.removeEventListener('kefinTweaksHomePainted', onPainted); - start(); - return; - } - setTimeout(() => { - document.removeEventListener('kefinTweaksHomePainted', onPainted); - start(); - }, 3000); - }); + const utils = window.LibraryCacheUtils; + if (utils?.scheduleIdleBootstrap) { + utils.scheduleIdleBootstrap({ + waitForLogin: waitForLoginReady, + onReady: () => { bootstrap(); } + }); + return; + } + // Fallback if LibraryCacheUtils not loaded yet + waitForLoginReady().then(() => bootstrap()); } window.MoviesCache = { diff --git a/scripts/peopleCache.js b/scripts/peopleCache.js index 0f06c28..d07ab71 100644 --- a/scripts/peopleCache.js +++ b/scripts/peopleCache.js @@ -7,7 +7,7 @@ const WARN = (...args) => console.warn('[KefinTweaks PeopleCache]', ...args); const ERR = (...args) => console.error('[KefinTweaks PeopleCache]', ...args); - const CACHE_NAME = 'library_top_people_v4'; + const CACHE_NAME = 'library_top_people_v3'; const DEFAULT_MIN = 10; const DEFAULT_PER_TYPE_MAX = 100; const EPISODE_CHUNK_SIZE = 500; @@ -337,7 +337,7 @@ const params = new URLSearchParams({ IncludeItemTypes: 'Episode', Recursive: 'true', - Fields: 'People,ImageBlurHashes', + Fields: 'People', ExcludeLocationTypes: 'Virtual', EnableTotalRecordCount: 'false', SortBy: 'DateCreated', @@ -460,7 +460,7 @@ setEpisodeCrawl(startIndex, newestAt, oldestApplied); await persistPeople({ prune: false }); } - } else { + } else { LOG('Episode people sync: full crawl…'); startIndex = 0; newestAt = null; @@ -747,7 +747,7 @@ try { const cache = window.IndexedDBCache; const userId = window.ApiClient.getCurrentUserId(); - if (cache && await cache.isCacheValid(CACHE_NAME, userId)) { + if (cache) { const cached = await cache.get(CACHE_NAME, userId); if (cached?.isComplete && Array.isArray(cached.peopleData)) { const currentMode = shouldLoadPeopleEpisodeData(); @@ -791,7 +791,7 @@ // Resume partial, incremental watermark, or start full — never wipe on missing watermark alone scheduleEpisodePeopleSync(); } - return; + return; } } } @@ -906,12 +906,7 @@ if (personType === 'Director') list = filterRoleList('Director', itemType); else if (personType === 'Writer') list = filterRoleList('Writer', itemType); else list = filterRoleList('Actor', itemType); - return list.slice(0, limit).map((p) => { - const out = { Id: p.Id, Name: p.Name, Type: 'Person' }; - if (p.PrimaryImageTag) out.PrimaryImageTag = p.PrimaryImageTag; - if (p.ImageBlurHashes) out.ImageBlurHashes = p.ImageBlurHashes; - return out; - }); + return list.slice(0, limit).map((p) => ({ Id: p.Id, Name: p.Name, Type: 'Person' })); } async function waitForLoginReady(maxWaitMs = 60000) { @@ -926,6 +921,7 @@ return window.userHelper.waitForLogin(maxWaitMs); } + /** Defer crawl until idle/hidden/fallback after home has a chance to paint. */ let bootstrapStarted = false; let bootstrapScheduled = false; @@ -945,26 +941,15 @@ function scheduleBootstrap() { if (bootstrapScheduled) return; bootstrapScheduled = true; - - const start = () => { bootstrap(); }; - - const onPainted = () => { - document.removeEventListener('kefinTweaksHomePainted', onPainted); - start(); - }; - document.addEventListener('kefinTweaksHomePainted', onPainted); - - waitForLoginReady().then((ok) => { - if (!ok) { - document.removeEventListener('kefinTweaksHomePainted', onPainted); - start(); - return; - } - setTimeout(() => { - document.removeEventListener('kefinTweaksHomePainted', onPainted); - start(); - }, 3000); - }); + const utils = window.LibraryCacheUtils; + if (utils?.scheduleIdleBootstrap) { + utils.scheduleIdleBootstrap({ + waitForLogin: waitForLoginReady, + onReady: () => { bootstrap(); } + }); + return; + } + waitForLoginReady().then(() => bootstrap()); } window.PeopleCache = { diff --git a/scripts/seriesCache.js b/scripts/seriesCache.js index 4942396..03d7b90 100644 --- a/scripts/seriesCache.js +++ b/scripts/seriesCache.js @@ -316,6 +316,7 @@ }); } + /** Defer crawl until idle/hidden/fallback after home has a chance to paint. */ let bootstrapStarted = false; let bootstrapScheduled = false; @@ -333,26 +334,15 @@ function scheduleBootstrap() { if (bootstrapScheduled) return; bootstrapScheduled = true; - - const start = () => { bootstrap(); }; - - const onPainted = () => { - document.removeEventListener('kefinTweaksHomePainted', onPainted); - start(); - }; - document.addEventListener('kefinTweaksHomePainted', onPainted); - - waitForLoginReady().then((ok) => { - if (!ok) { - document.removeEventListener('kefinTweaksHomePainted', onPainted); - start(); - return; - } - setTimeout(() => { - document.removeEventListener('kefinTweaksHomePainted', onPainted); - start(); - }, 3000); - }); + const utils = window.LibraryCacheUtils; + if (utils?.scheduleIdleBootstrap) { + utils.scheduleIdleBootstrap({ + waitForLogin: waitForLoginReady, + onReady: () => { bootstrap(); } + }); + return; + } + waitForLoginReady().then(() => bootstrap()); } window.SeriesCache = {