Defer initial queries for genres, studios, watchlist until after startup

This commit is contained in:
HighImKevin committed 2026-10-08 10:31:08 -04:00
1 parent e434a5461f
commit e8f9690712
4 files changed
+361 -22

No files matched your search

+178 -12
View File
@@ -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<string|null>} - Genre ID or null if not found
* @returns {Promise<string|null>} - 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');
})();
+92 -5
View File
@@ -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');
})();
+86 -4
View File
@@ -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 = {