From e8f969071255619889af5236e9ca69f04d8c54df Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Thu, 8 Oct 2026 10:31:08 -0400 Subject: [PATCH] Defer initial queries for genres, studios, watchlist until after startup --- configuration/homeScreen-configuration.js | 6 +- scripts/apiHelper.js | 190 ++++++++++++++++++++-- scripts/sectionHelper.js | 97 ++++++++++- scripts/watchlist.js | 90 +++++++++- 4 files changed, 361 insertions(+), 22 deletions(-) diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js index 31badc9..e35f07f 100644 --- a/configuration/homeScreen-configuration.js +++ b/configuration/homeScreen-configuration.js @@ -6065,6 +6065,10 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; // Load user config script after a short delay to ensure this script is fully initialized //setTimeout(loadUserConfigurationScript, 100); - runStartupDefaultSectionSync().catch(err => ERR('Startup default section sync failed:', err)); + // Defer admin library/studio catalog sync so it does not compete with home first paint + const STARTUP_DEFAULT_SECTION_SYNC_DELAY_MS = 15000; + setTimeout(() => { + runStartupDefaultSectionSync().catch(err => ERR('Startup default section sync failed:', err)); + }, STARTUP_DEFAULT_SECTION_SYNC_DELAY_MS); })(); \ No newline at end of file diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js index 12840d5..6b5f286 100644 --- a/scripts/apiHelper.js +++ b/scripts/apiHelper.js @@ -1014,23 +1014,37 @@ LOG('Queue updated successfully'); }, /** - * Gets a genre ID from cached movie genres + * Lookup a genre ID from the local movie-genres catalog (localStorage / memory). + * Never awaits network — schedules a background sync when missing or older than 24h. * @param {string} genreName - Name of the genre to find - * @returns {Promise} - Genre ID or null if not found + * @returns {Promise} - Genre ID or null if not found / not yet cached */ getGenreId: async function(genreName) { - if (!cachedMovieGenres) { - const url = `${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`; - // Use getData to fetch the genres with caching - const genres = await this.getData(url, true, GENRE_TTL); - cachedMovieGenres = genres.Items; + if (!genreName) return null; + + hydrateMovieGenresFromStorage(); + const empty = !cachedMovieGenres || cachedMovieGenres.length === 0; + if (empty || isMovieGenresSyncDue()) { + ensureGenreSyncScheduled(); } - - // Find the genre by name (case insensitive) - const genre = cachedMovieGenres.find(g => g.Name.toLowerCase() === genreName.toLowerCase()); + if (!cachedMovieGenres || cachedMovieGenres.length === 0) return null; + + const needle = String(genreName).toLowerCase(); + const genre = cachedMovieGenres.find((g) => g?.Name && g.Name.toLowerCase() === needle); return genre ? genre.Id : null; }, + /** + * Subscribe to movie-genres catalog sync completion (localStorage + memory refreshed). + * @param {Function} callback + * @returns {Function} unsubscribe + */ + onMovieGenresSynced: function(callback) { + if (typeof callback !== 'function') return () => {}; + movieGenresSyncedListeners.add(callback); + return () => movieGenresSyncedListeners.delete(callback); + }, + /** * Build query URL from section query configuration * Handles minAge/maxAge conversion to MinPremiereDate/MaxPremiereDate @@ -1336,10 +1350,162 @@ }; const GENRE_TTL = 24 * 60 * 60 * 1000; // 24 hours + const GENRE_SETTLE_DELAY_MS = 1500; let cachedMovieGenres = null; - + let movieGenresLastSyncedAt = null; + let genreSyncScheduledPromise = null; + let genreSyncInFlightPromise = null; + const movieGenresSyncedListeners = new Set(); + + function normalizeServerAddressForStorage(serverAddress) { + if (!serverAddress) return ''; + return String(serverAddress).replace(/\/+$/, ''); + } + + function getMovieGenresStorageKey() { + try { + const serverAddress = normalizeServerAddressForStorage( + (typeof ApiClient !== 'undefined' && ApiClient?.serverAddress?.()) || '' + ); + if (!serverAddress) return null; + return `kefinTweaks_movieGenres_${encodeURIComponent(serverAddress)}`; + } catch (_) { + return null; + } + } + + function readMovieGenresFromStorage() { + const key = getMovieGenresStorageKey(); + if (!key) return null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.items)) return null; + return { + items: parsed.items.filter((g) => g && g.Id && g.Name), + lastSyncedAt: Number(parsed.lastSyncedAt) || 0 + }; + } catch (err) { + WARN('Failed to read movie genres from localStorage:', err); + return null; + } + } + + function writeMovieGenresToStorage(items, lastSyncedAt) { + const key = getMovieGenresStorageKey(); + if (!key) return; + try { + localStorage.setItem(key, JSON.stringify({ + items: (items || []).map((g) => ({ Id: g.Id, Name: g.Name })), + lastSyncedAt: lastSyncedAt || Date.now() + })); + } catch (err) { + WARN('Failed to write movie genres to localStorage:', err); + } + } + + function hydrateMovieGenresFromStorage() { + if (cachedMovieGenres && cachedMovieGenres.length > 0) return true; + const stored = readMovieGenresFromStorage(); + if (!stored?.items?.length) return false; + cachedMovieGenres = stored.items; + movieGenresLastSyncedAt = stored.lastSyncedAt || movieGenresLastSyncedAt; + LOG(`Hydrated movie genres from localStorage: ${cachedMovieGenres.length} items`); + return true; + } + + function isMovieGenresSyncDue() { + const stored = readMovieGenresFromStorage(); + const last = movieGenresLastSyncedAt || stored?.lastSyncedAt || 0; + const hasItems = (cachedMovieGenres && cachedMovieGenres.length > 0) || (stored?.items?.length > 0); + if (!hasItems) return true; + if (!last) return true; + return (Date.now() - last) >= GENRE_TTL; + } + + function waitForDocumentComplete() { + if (document.readyState === 'complete') return Promise.resolve(); + return new Promise((resolve) => { + window.addEventListener('load', () => resolve(), { once: true }); + }); + } + + function notifyMovieGenresSynced() { + try { + window.dispatchEvent(new CustomEvent('kefinTweaksMovieGenresSynced')); + } catch (_) { /* ignore */ } + movieGenresSyncedListeners.forEach((cb) => { + try { + cb(); + } catch (err) { + WARN('onMovieGenresSynced listener failed:', err); + } + }); + } + + async function runMovieGenresSync() { + if (genreSyncInFlightPromise) return genreSyncInFlightPromise; + + genreSyncInFlightPromise = (async () => { + try { + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + ensureApiClient(); + const url = `${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`; + const genres = await apiHelper.getData(url, true, GENRE_TTL); + const items = Array.isArray(genres?.Items) ? genres.Items : []; + cachedMovieGenres = items; + movieGenresLastSyncedAt = Date.now(); + writeMovieGenresToStorage(items, movieGenresLastSyncedAt); + LOG(`Synced movie genres: ${items.length} items`); + notifyMovieGenresSynced(); + } catch (err) { + ERR('Failed to sync movie genres:', err); + } finally { + genreSyncInFlightPromise = null; + } + })(); + + return genreSyncInFlightPromise; + } + + /** + * Deduped: after settle, sync Genres when cache is missing or older than 24h. + */ + function ensureGenreSyncScheduled() { + hydrateMovieGenresFromStorage(); + if (!isMovieGenresSyncDue()) { + return genreSyncScheduledPromise || Promise.resolve(); + } + if (genreSyncScheduledPromise) return genreSyncScheduledPromise; + + genreSyncScheduledPromise = (async () => { + try { + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + await waitForDocumentComplete(); + await new Promise((resolve) => setTimeout(resolve, GENRE_SETTLE_DELAY_MS)); + hydrateMovieGenresFromStorage(); + if (!isMovieGenresSyncDue()) return; + await runMovieGenresSync(); + } catch (err) { + ERR('Failed to schedule movie genres sync:', err); + } finally { + genreSyncScheduledPromise = null; + } + })(); + + return genreSyncScheduledPromise; + } + // Expose apiHelper to global window object window.apiHelper = apiHelper; - + + // Daily / empty-cache sync without waiting for a getGenreId caller + ensureGenreSyncScheduled(); + console.log('[KefinTweaks APIHelper] Module loaded and available at window.apiHelper'); })(); \ No newline at end of file diff --git a/scripts/sectionHelper.js b/scripts/sectionHelper.js index 490f46c..3afbd83 100644 --- a/scripts/sectionHelper.js +++ b/scripts/sectionHelper.js @@ -95,11 +95,89 @@ } } + /** Section configs waiting for movie-genres catalog before See All can resolve. */ + const pendingGenreViewMoreConfigs = new Set(); + + function sectionNeedsGenreNameResolve(sectionConfig) { + if (!sectionConfig || sectionConfig.viewMoreUrl) return false; + const genres = sectionConfig.queries?.[0]?.queryOptions?.Genres; + if (typeof genres !== 'string' || !genres) return false; + return !/^\d+$/.test(genres); + } + + function applyViewMoreUrlToSection(sectionConfig, viewMoreUrl) { + if (!sectionConfig || !viewMoreUrl) return; + sectionConfig.viewMoreUrl = viewMoreUrl; + const id = sectionConfig.id; + if (!id) return; + const escaped = typeof CSS !== 'undefined' && CSS.escape + ? CSS.escape(id) + : String(id).replace(/["\\]/g, '\\$&'); + document.querySelectorAll( + `.verticalSection[data-section-id="${escaped}"], .spotlight-section[data-section-id="${escaped}"]` + ).forEach((sectionElement) => { + const existingLink = + sectionElement.querySelector('a.sectionTitle-link') || + sectionElement.querySelector('.spotlight-section-title a.emby-tab-button'); + if (existingLink) { + existingLink.href = viewMoreUrl; + return; + } + const plainTitle = sectionElement.querySelector( + '.sectionTitleContainer .sectionTitle.sectionTitle-cards:not(a)' + ); + if (plainTitle) { + const link = document.createElement('a'); + link.className = 'sectionTitle sectionTitle-cards sectionTitle-link emby-button'; + link.href = viewMoreUrl; + link.textContent = plainTitle.textContent; + plainTitle.replaceWith(link); + return; + } + const spotTitle = sectionElement.querySelector( + '.spotlight-section-title .emby-tab-button:not(a)' + ); + if (spotTitle) { + const link = document.createElement('a'); + link.className = 'emby-tab-button emby-tab-button-active emby-button'; + link.href = viewMoreUrl; + link.title = 'See All'; + link.style.textDecoration = 'none'; + link.textContent = spotTitle.textContent; + spotTitle.replaceWith(link); + sectionElement.querySelector('.spotlight-section-title')?.classList.remove('spotlight-title-link'); + } + }); + } + function scheduleViewMoreUrl(sectionConfig) { resolveViewMoreUrl(sectionConfig).then((viewMoreUrl) => { - if (viewMoreUrl) sectionConfig.viewMoreUrl = viewMoreUrl; + if (viewMoreUrl) { + applyViewMoreUrlToSection(sectionConfig, viewMoreUrl); + pendingGenreViewMoreConfigs.delete(sectionConfig); + return; + } + if (sectionNeedsGenreNameResolve(sectionConfig)) { + pendingGenreViewMoreConfigs.add(sectionConfig); + } }).catch((err) => { WARN(`Failed to resolve viewMoreUrl for ${sectionConfig?.id}:`, err); + if (sectionNeedsGenreNameResolve(sectionConfig)) { + pendingGenreViewMoreConfigs.add(sectionConfig); + } + }); + } + + function reschedulePendingGenreViewMoreUrls() { + const pending = Array.from(pendingGenreViewMoreConfigs); + pending.forEach((sectionConfig) => { + resolveViewMoreUrl(sectionConfig).then((viewMoreUrl) => { + if (!viewMoreUrl) return; + applyViewMoreUrlToSection(sectionConfig, viewMoreUrl); + pendingGenreViewMoreConfigs.delete(sectionConfig); + }).catch((err) => { + WARN(`Failed to re-resolve viewMoreUrl for ${sectionConfig?.id}:`, err); + }); }); } @@ -477,14 +555,13 @@ return `#/list.html?type=tag&tag=${encodeURIComponent(queryOptions.Tags)}&serverId=${serverId}`; } - // Check for Genres + // Check for Genres (name → Id via local catalog; never use unresolved names as genreId) if (queryOptions.Genres) { let genreId = queryOptions.Genres; if (typeof genreId === 'string' && !genreId.match(/^\d+$/)) { const resolvedId = await ApiHelper.getGenreId(genreId); - if (resolvedId) { - genreId = resolvedId; - } + if (!resolvedId) return null; + genreId = resolvedId; } return `#/list.html?genreId=${genreId}&serverId=${serverId}`; } @@ -1252,5 +1329,15 @@ isEmptyDiscoverySourceTemplate }; + // After movie-genres catalog sync, fill in See All links that waited on name→Id + function bindMovieGenresSynced() { + const api = window.apiHelper || ApiHelper; + if (api?.onMovieGenresSynced) { + api.onMovieGenresSynced(reschedulePendingGenreViewMoreUrls); + } + window.addEventListener('kefinTweaksMovieGenresSynced', reschedulePendingGenreViewMoreUrls); + } + bindMovieGenresSynced(); + LOG('sectionHelper ready'); })(); diff --git a/scripts/watchlist.js b/scripts/watchlist.js index 16ddb50..9ddec95 100644 --- a/scripts/watchlist.js +++ b/scripts/watchlist.js @@ -3132,11 +3132,67 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const watchlistLikedIds = new Set(); let watchlistLikedIdsLoaded = false; let watchlistLikedIdsLoadPromise = null; + let watchlistLikedIdsBaselineScheduled = false; + const WATCHLIST_LIKED_IDS_SETTLE_DELAY_MS = 15000; function isWatchlistSupportedType(itemType) { return WATCHLIST_SUPPORTED_TYPES.includes(itemType); } + function normalizeServerAddressForStorage(serverAddress) { + if (!serverAddress) return ''; + return String(serverAddress).replace(/\/+$/, ''); + } + + function getLikedIdsStorageKey() { + const userId = window.ApiClient?.getCurrentUserId?.(); + const serverAddress = normalizeServerAddressForStorage(window.ApiClient?.serverAddress?.()); + if (!userId || !serverAddress) return null; + return `kefinTweaks_watchlistLikedIds_${userId}_${encodeURIComponent(serverAddress)}`; + } + + function readLikedIdsFromStorage() { + const key = getLikedIdsStorageKey(); + if (!key) return null; + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + return parsed.filter((id) => typeof id === 'string' && id); + } catch (err) { + WARN('Failed to read watchlist liked Ids from localStorage:', err); + return null; + } + } + + function writeLikedIdsToStorage() { + const key = getLikedIdsStorageKey(); + if (!key) return; + try { + localStorage.setItem(key, JSON.stringify([...watchlistLikedIds])); + } catch (err) { + WARN('Failed to write watchlist liked Ids to localStorage:', err); + } + } + + function hydrateWatchlistLikedIdsFromStorage() { + const ids = readLikedIdsFromStorage(); + if (!ids) return false; + watchlistLikedIds.clear(); + ids.forEach((id) => watchlistLikedIds.add(id)); + watchlistLikedIdsLoaded = true; + LOG(`Hydrated watchlist liked Id cache from localStorage: ${watchlistLikedIds.size} items`); + return true; + } + + function waitForDocumentComplete() { + if (document.readyState === 'complete') return Promise.resolve(); + return new Promise((resolve) => { + window.addEventListener('load', () => resolve(), { once: true }); + }); + } + function isItemInLikedIdCache(itemId) { return !!itemId && watchlistLikedIds.has(itemId); } @@ -3145,6 +3201,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: if (!itemId) return; if (isLiked) watchlistLikedIds.add(itemId); else watchlistLikedIds.delete(itemId); + writeLikedIdsToStorage(); } function mergeLikedIdsFromItems(items) { @@ -3152,6 +3209,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: list.forEach((item) => { if (item?.Id) watchlistLikedIds.add(item.Id); }); + writeLikedIdsToStorage(); } function applyWatchlistButtonActiveState(button, isActive) { @@ -3167,8 +3225,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: }); } - async function loadWatchlistLikedIdsBaseline() { - if (watchlistLikedIdsLoaded) return watchlistLikedIds; + /** Network refresh of liked-Id set (always runs when scheduled; not skipped after hydrate). */ + async function refreshWatchlistLikedIdsFromNetwork() { if (watchlistLikedIdsLoadPromise) return watchlistLikedIdsLoadPromise; watchlistLikedIdsLoadPromise = (async () => { @@ -3188,6 +3246,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: watchlistLikedIds.clear(); mergeLikedIdsFromItems(result); watchlistLikedIdsLoaded = true; + writeLikedIdsToStorage(); LOG(`Loaded watchlist liked Id cache: ${watchlistLikedIds.size} items`); syncOverlayWatchlistButtons(); processExistingOverlayContainers(); @@ -3202,6 +3261,28 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: return watchlistLikedIdsLoadPromise; } + async function scheduleWatchlistLikedIdsBaseline() { + if (watchlistLikedIdsBaselineScheduled) return; + watchlistLikedIdsBaselineScheduled = true; + + try { + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + + if (hydrateWatchlistLikedIdsFromStorage()) { + syncOverlayWatchlistButtons(); + processExistingOverlayContainers(); + } + + await waitForDocumentComplete(); + await new Promise((resolve) => setTimeout(resolve, WATCHLIST_LIKED_IDS_SETTLE_DELAY_MS)); + await refreshWatchlistLikedIdsFromNetwork(); + } catch (err) { + ERR('Failed to schedule watchlist liked Id baseline:', err); + } + } + let watchlistRenderGeneration = 0; function resetWatchlistItemsByType() { @@ -4876,6 +4957,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: resetWatchlistItemsByType(); watchlistLikedIds.clear(); watchlistLikedIdsLoaded = true; + writeLikedIdsToStorage(); syncOverlayWatchlistButtons(); // Refresh watchlist @@ -7750,8 +7832,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Initialize the observer setupWatchlistButtonObserver(); - // Baseline liked-Id cache for overlay button state (one query after login) - loadWatchlistLikedIdsBaseline(); + // Hydrate liked-Id cache from localStorage; network refresh after settle + 5s + scheduleWatchlistLikedIdsBaseline(); // Expose helpers so cardBuilder can keep overlay buttons / Set aligned on UserData.Likes window.KefinWatchlistLikedIds = {