From 04d3ef0e9219f901e70570d4e7f2663e4f7ee9db Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Tue, 15 Sep 2026 01:21:54 -0400 Subject: [PATCH] Anotha one! --- configuration/homeScreen-configuration.css | 12 +- configuration/homeScreen-configuration.js | 388 ++++- configuration/homeScreen-migration.js | 93 +- .../homeScreen-user-configuration.js | 141 +- configuration/homeScreenAdvancedEditor.css | 24 +- configuration/homeScreenAdvancedEditor.js | 107 +- configuration/homeScreenSectionEditor.js | 137 +- injector.js | 299 ++-- kefinTweaks-plugin.js | 273 +++- kefinTweaks.minimal.js | 9 +- pages/movieLut.js | 5 +- scripts/HomeScreenSection.ts | 10 + scripts/apiHelper.js | 7 +- scripts/breadcrumbNav.css | 21 + scripts/breadcrumbs.js | 63 +- scripts/cardBuilder.css | 35 +- scripts/cardBuilder.js | 524 +++++- scripts/configuration.js | 22 +- scripts/externalList.js | 114 ++ scripts/homeScreen.css | 46 +- scripts/homeScreen3.js | 1399 +++++------------ scripts/homeScreenConfig2.js | 806 +++++++++- scripts/kefinTweaks-loader.js | 1173 ++++++++++++++ scripts/libraryCache.js | 78 + scripts/libraryCacheUtils.js | 268 ++++ scripts/moviesCache.js | 517 ++++-- scripts/peopleCache.js | 853 ++++++---- scripts/removeContinue.js | 2 +- scripts/sectionHelper.js | 1145 ++++++++++++++ scripts/seriesCache.js | 383 +++++ scripts/skinManager.js | 53 +- scripts/userHelper.js | 21 +- scripts/utils.js | 211 ++- scripts/watchlist.css | 6 +- scripts/watchlist.js | 334 ++-- 35 files changed, 7577 insertions(+), 2002 deletions(-) create mode 100644 scripts/externalList.js create mode 100644 scripts/kefinTweaks-loader.js create mode 100644 scripts/libraryCache.js create mode 100644 scripts/libraryCacheUtils.js create mode 100644 scripts/sectionHelper.js create mode 100644 scripts/seriesCache.js diff --git a/configuration/homeScreen-configuration.css b/configuration/homeScreen-configuration.css index daf44cc..ba0f8db 100644 --- a/configuration/homeScreen-configuration.css +++ b/configuration/homeScreen-configuration.css @@ -75,9 +75,6 @@ } [data-modal-id="kefin-homescreen-config-main"] div#section-content { - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - padding: 0.75em; max-height: calc(90vh - 19em); overflow: auto; } @@ -245,6 +242,7 @@ span.listItemBodyText.secondary.seasonal-tag { [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1fr; } [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-3 { @@ -336,3 +334,11 @@ span.listItemBodyText.secondary.seasonal-tag { grid-template-columns: 1fr; } } + +[data-modal-id="kefin-homescreen-config-main"] .hsc-settings-span-2 { + display: flex; +} + +[data-modal-id="kefin-homescreen-config-main"] .hsc-settings-grid .hsc-settings-span-2 .kefin-toggle-card { + flex: 1; +} diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js index e18c915..620f51e 100644 --- a/configuration/homeScreen-configuration.js +++ b/configuration/homeScreen-configuration.js @@ -43,6 +43,10 @@ on: 'Allows users to create pinned sections on their Home Screen from library items by adding a "Pin To Home" button in the item context menu.', off: 'Users cannot pin items to their Home Screen.' }, + 'userHome-pairNativeHomeSections': { + on: 'Sections with a matching Native Jellyfin section are automatically kept in sync with your KefinTweaks Home Screen.', + off: 'KefinTweaks will disable all native Jellyfin home screen sections in the native Jellyfin User Preferences. When pairing is disabled, the official Jellyfin Android TV and Roku clients will have no sections rendered on the home screen. You should only disable this when you do not use either of those clients. Disabling will have a performance benefit when loading the home screen.' + }, 'home-fadeInSections': { on: 'Sections will be hidden when they are rendered and will fade up and in as they enter the window when you scroll.', off: 'Sections will be rendered normally without any fade in animation.' @@ -62,6 +66,10 @@ 'home-ensureThumbsForPopularTVNetworks': { on: 'Only Studios with a valid image will be rendered in Popular Studios sections.', off: 'Studios without any image may be rendered in Popular Studios sections. You can enable the Optional CSS Module "Studio Thumbnails" to overlay a background with the Studio Name on items without an image.' + }, + 'home-loadPeopleEpisodeData': { + on: 'Loads all Person appearances from all episodes in your library. This data takes much longer to cache than just the Movies/Series level data.', + off: 'Person appearance data is limited to only Movies and Series.' } }; @@ -71,8 +79,8 @@ off: 'No Discovery Sections will be generated.' }, 'discovery-infiniteScroll': { - on: 'Discovery Sections will render automatically when the user scrolls near the bottom of the page.', - off: 'Discovery Sections will only be rendered when the user presses the "Load More" button.' + on: 'Discovery Sections will render automatically when the user scrolls near the bottom of the page (one group per scroll; scroll again for more).', + off: 'Show “Discover More” at the bottom. Scroll again while at the bottom to load the next discovery group.' }, 'discovery-renderSpotlightAboveMatching': { on: 'Top Rated Genre/Studio and regular Genre/Studio sections will appear next to each other', @@ -113,7 +121,43 @@ let currentGlobalSettingsSubTab = 'general'; // Track active sub-tab in Global Settings (general, spotlight, discovery, cache) let globalSettingsSaveTimer = null; const UPDATE_ALL_CONFIRM_MODAL_ID = 'kefin-homescreen-config-update-all-confirm'; + const RESTORE_DEFAULTS_MODAL_ID = 'kefin-homescreen-config-restore-defaults'; const COMMUNITY_IMPORT_MODAL_ID = 'kefin-homescreen-community-import'; + const MAX_PEOPLE_COUNT_OPTIONS = [100, 250, 500, 1000, 2500, 5000]; + const RESTORE_RETAIN_TOGGLE_DESCRIPTIONS = { + 'restore-retain-status': { + on: 'Current Default Status and User Configuration values will be unchanged.', + off: 'Default Status and User Configuration will be restored to their defaults.' + }, + 'restore-retain-name-caption': { + on: 'Current Section Name and Caption values will be unchanged.', + off: 'Section Name and Caption will be restored to their defaults.' + }, + 'restore-retain-appearance': { + on: 'Current Section Appearance settings will be unchanged.', + off: 'Section Appearance settings will be restored to their defaults.' + }, + 'restore-retain-advanced': { + on: 'Current Section Advanced Options will be unchanged.', + off: 'Section Advanced Options will be restored to their defaults.' + }, + 'restore-retain-customs': { + on: 'Current Custom Sections will be unchanged.', + off: 'All custom sections will be removed.' + } + }; + const RESTORE_APPEARANCE_KEYS = [ + 'renderMode', 'cardFormat', 'spotlight', 'spotlightConfig', + 'borderStyle', 'borderColor', + 'hideCardTitles', 'cardTitleVisibility', + 'cardTitlePosition', 'cardTitleCapitalization', 'cardTitleFontFamily', + 'cardTitleFontSize', 'cardTitleColor', + 'itemsLayout', 'useGaplessCards', 'sectionCssClass', 'hideName' + ]; + const RESTORE_ADVANCED_KEYS = [ + 'ttl', 'sortBy', 'sortOrder', 'sortOrderDirection', + 'minimumItems', 'itemLimit', 'flattenSeries', 'limitBeforeSort', 'hideWatched' + ]; const TTL_UNIT_OPTIONS = [ { value: 'seconds', ms: 1000 }, { value: 'minutes', ms: 60000 }, @@ -155,28 +199,31 @@ return flattened; } - const CONTINUE_WATCHING_SECTION_IDS = new Set([ - 'continueWatching', - 'nextUp', - 'continueWatchingAndNextUp' - ]); + const POPULAR_STUDIO_SECTION_IDS = new Set(['popularTVStudios', 'popularMovieStudios', 'popularStudios']); /** - * Clear MERGE_NEXT_UP-era hidden flags so CW / Next Up / combined are independent sections. + * Sync HOME_SETTINGS.ensureThumbsForPopularTVNetworks onto popular studio section queryOptions. + * When on: ImageTypes ['Thumb'] (API + postProcessItems filter). When off: remove ImageTypes. */ - function normalizeContinueWatchingVisibility(config) { + function syncEnsureThumbsOnPopularStudios(config) { if (!config) return; - const groupKeys = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS', 'CUSTOM_SECTION_GROUPS']; - groupKeys.forEach((key) => { - (config[key] || []).forEach((group) => { - (group.sections || []).forEach((section) => { - if (section?.id && CONTINUE_WATCHING_SECTION_IDS.has(section.id)) { - section.hidden = false; + const ensureThumbs = config.HOME_SETTINGS?.ensureThumbsForPopularTVNetworks === true; + const groupKeys = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'CUSTOM_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS']; + for (const key of groupKeys) { + const sections = flattenSectionGroups(config[key] || []); + for (const section of sections) { + if (!section || !POPULAR_STUDIO_SECTION_IDS.has(section.id) || !Array.isArray(section.queries)) continue; + for (const query of section.queries) { + if (!query) continue; + if (ensureThumbs) { + query.queryOptions = { ...(query.queryOptions || {}), ImageTypes: ['Thumb'] }; + } else if (query.queryOptions && Object.prototype.hasOwnProperty.call(query.queryOptions, 'ImageTypes')) { + const { ImageTypes, ...rest } = query.queryOptions; + query.queryOptions = rest; } - }); - }); - }); - delete config.MERGE_NEXT_UP; + } + } + } } /** @@ -951,10 +998,19 @@ CACHE: { ...defaults.CACHE, ...(existingConfig.CACHE || {}) }, SPOTLIGHT_SETTINGS: { ...defaults.SPOTLIGHT_SETTINGS, ...(existingConfig.SPOTLIGHT_SETTINGS || {}) }, HOME_SETTINGS: { ...defaults.HOME_SETTINGS, ...(existingConfig.HOME_SETTINGS || {}) }, - USER_HOME_SCREEN_SETTINGS: { ...defaults.USER_HOME_SCREEN_SETTINGS, ...(existingConfig.USER_HOME_SCREEN_SETTINGS || {}) } + USER_HOME_SCREEN_SETTINGS: { ...defaults.USER_HOME_SCREEN_SETTINGS, ...(existingConfig.USER_HOME_SCREEN_SETTINGS || {}) }, + LIBRARY_CACHE: { ...defaults.LIBRARY_CACHE, ...(existingConfig.LIBRARY_CACHE || {}) } }; - normalizeContinueWatchingVisibility(mergedConfig); + // Soft-migrate legacy DISCOVERY_SETTINGS.minPeopleAppearances → HOME_SETTINGS mins + const existingHome = existingConfig.HOME_SETTINGS || {}; + const legacyPeopleMin = existingConfig.DISCOVERY_SETTINGS?.minPeopleAppearances; + if (legacyPeopleMin != null && existingHome.minPeopleAppearancesTotal == null) { + mergedConfig.HOME_SETTINGS.minPeopleAppearancesTotal = legacyPeopleMin; + mergedConfig.HOME_SETTINGS.minPeopleAppearancesMovies = legacyPeopleMin; + mergedConfig.HOME_SETTINGS.minPeopleAppearancesSeries = legacyPeopleMin; + mergedConfig.HOME_SETTINGS.minPeopleAppearancesEpisodes = legacyPeopleMin; + } // Add ENABLED_NORMAL_SECTIONS and ENABLED_DISCOVERY_SECTIONS // ENABLED_NORMAL_SECTIONS is all enabled sections from HOME_SECTION_GROUPS, SEASONAL_SECTION_GROUPS, and CUSTOM_SECTION_GROUPS that aren't discovery sections @@ -1547,11 +1603,13 @@ USER_HOME_SCREEN_SETTINGS: { ...(savedHomeScreenConfig.USER_HOME_SCREEN_SETTINGS || {}), ...(config.USER_HOME_SCREEN_SETTINGS || {}) + }, + LIBRARY_CACHE: { + ...(savedHomeScreenConfig.LIBRARY_CACHE || {}), + ...(config.LIBRARY_CACHE || {}) } }; - normalizeContinueWatchingVisibility(mergedHomeScreenConfig); - // Empty values (= blank CustomPrefs segments) must not persist to JS Injector omitEmptyPropsFromGroups(mergedHomeScreenConfig.HOME_SECTION_GROUPS); omitEmptyPropsFromGroups(mergedHomeScreenConfig.SEASONAL_SECTION_GROUPS); @@ -1761,7 +1819,7 @@ const sectionTypes = [...new Set(sections.map(s => s.sectionType))]; return ` -
+
@@ -1871,6 +1929,10 @@ sync Update +
@@ -1984,19 +2046,49 @@ const seasonal = currentConfig.SEASONAL_THEME_SETTINGS || {}; const homeSettings = currentConfig.HOME_SETTINGS || {}; const userHomeScreen = currentConfig.USER_HOME_SCREEN_SETTINGS || {}; + const libraryCache = currentConfig.LIBRARY_CACHE || {}; const D = GENERAL_TOGGLE_DESCRIPTIONS; + const maxPeopleCount = homeSettings.maxPeopleCount ?? 500; + const maxPeopleOptions = MAX_PEOPLE_COUNT_OPTIONS.map((n) => ({ value: String(n), label: String(n) })); + const settingsGroupStyle = 'margin-bottom: 1.5em; border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 1em;'; return `
${hscToggleCard('userHome-inlineConfigure', userHomeScreen.inlineConfigure !== false, 'Inline Section Configuration', D)} ${hscToggleCard('userHome-pinning', userHomeScreen.pinning !== false, 'Pin To Home', D)} +
+ ${hscToggleCard('userHome-pairNativeHomeSections', userHomeScreen.pairNativeHomeSections !== false, 'Pair Home Sections to Native Jellyfin User Preferences', D)} +
${hscToggleCard('home-fadeInSections', homeSettings.fadeInSections === true, 'Fade In Sections', D)} - ${hscToggleCard('home-showStaleDataBeforeRefresh', homeSettings.SHOW_STALE_DATA_BEFORE_REFRESH === true, 'Show Stale Items', D)} + ${hscToggleCard('home-showStaleDataBeforeRefresh', homeSettings.showStaleDataBeforeRefresh === true, 'Show Stale Items', D)} ${hscToggleCard('seasonal-enableSeasonalAnimations', seasonal.enableSeasonalAnimations !== false, 'Seasonal Animations', D)} ${hscToggleCard('seasonal-enableSeasonalBackground', seasonal.enableSeasonalBackground !== false, 'Seasonal Backgrounds', D)}
${hscToggleCard('home-ensureThumbsForPopularTVNetworks', homeSettings.ensureThumbsForPopularTVNetworks === true, 'Require Studio Thumbs', D)}
+
+
Top People
+
+ Sections related to Top People from your library will use these values to determine which People qualify as "Top People". If a Person meets any of the minimum criteria listed below, they will be considered a Top Person. Top People will be sorted based on their total number of appearances, caching only the Top X number of people based on the Maximum Person Count. +
+
+ ${hscToggleCard('home-loadPeopleEpisodeData', homeSettings.loadPeopleEpisodeData === true, 'Load Episode Data for People', D)} +
+
+ ${buildTextInput('home-minPeopleAppearancesTotal', homeSettings.minPeopleAppearancesTotal ?? 10, 'Minimum Total Appearances', 'number')} + ${buildTextInput('home-minPeopleAppearancesMovies', homeSettings.minPeopleAppearancesMovies ?? 10, 'Minimum Movies Appearances', 'number')} + ${buildTextInput('home-minPeopleAppearancesSeries', homeSettings.minPeopleAppearancesSeries ?? 10, 'Minimum Series Appearances', 'number')} + ${buildTextInput('home-minPeopleAppearancesEpisodes', homeSettings.minPeopleAppearancesEpisodes ?? 10, 'Minimum Episodes Appearances', 'number')} + ${buildSelect('home-maxPeopleCount', maxPeopleOptions, String(maxPeopleCount), 'Maximum Person Count')} +
+
+
+
External Integrations
+
+ Enter your personal API keys for the services below to enable support for home sections based on external lists. +
+ ${buildTextInput('libraryCache-mdblistApiKey', libraryCache.mdblistApiKey || '', 'MDBList API Key', 'password', 'Required for External List sections')} +
`; } @@ -2021,8 +2113,8 @@ ${hscToggleCard('discovery-randomizeOrder', discovery.randomizeOrder === true, 'Randomize Order', D)} ${hscToggleCard('discovery-fadeInSections', discovery.fadeInSections === true, 'Fade In Sections', D)} ${buildTextInput('discovery-spotlightDiscoveryChance', discovery.spotlightDiscoveryChance ?? 0.5, 'Spotlight Discovery Chance (0-1)', 'number')} - ${buildTextInput('discovery-minPeopleAppearances', discovery.minPeopleAppearances || 10, 'Top Person Appearance Count', 'number')} ${buildTextInput('discovery-minGenreMovieCount', discovery.minGenreMovieCount || 50, 'Genre Movie Count', 'number')} + ${buildTextInput('discovery-minGenreSeriesCount', discovery.minGenreSeriesCount || 10, 'Genre Series Count', 'number')}
`; @@ -2149,8 +2241,8 @@ ...(currentConfig.DISCOVERY_SETTINGS || {}), enabled: root.querySelector('#discovery-enabled')?.checked !== false, infiniteScroll: root.querySelector('#discovery-infiniteScroll')?.checked !== false, - minPeopleAppearances: parseInt(root.querySelector('#discovery-minPeopleAppearances')?.value || '10', 10), minGenreMovieCount: parseInt(root.querySelector('#discovery-minGenreMovieCount')?.value || '50', 10), + minGenreSeriesCount: parseInt(root.querySelector('#discovery-minGenreSeriesCount')?.value || '10', 10), spotlightDiscoveryChance: parseFloat(root.querySelector('#discovery-spotlightDiscoveryChance')?.value || '0.5'), renderSpotlightAboveMatching: root.querySelector('#discovery-renderSpotlightAboveMatching')?.checked === true, randomizeOrder: root.querySelector('#discovery-randomizeOrder')?.checked === true, @@ -2160,7 +2252,11 @@ if (root.querySelector('#seasonal-enableSeasonalAnimations') || root.querySelector('#home-fadeInSections') - || root.querySelector('#userHome-inlineConfigure')) { + || root.querySelector('#userHome-inlineConfigure') + || root.querySelector('#libraryCache-mdblistApiKey') + || root.querySelector('#home-minPeopleAppearancesTotal') + || root.querySelector('#home-loadPeopleEpisodeData')) { + const prevLoadPeopleEpisodeData = currentConfig.HOME_SETTINGS?.loadPeopleEpisodeData === true; currentConfig.SEASONAL_THEME_SETTINGS = { ...(currentConfig.SEASONAL_THEME_SETTINGS || {}), enableSeasonalAnimations: root.querySelector('#seasonal-enableSeasonalAnimations')?.checked !== false, @@ -2176,13 +2272,32 @@ ...(currentConfig.HOME_SETTINGS || {}), fadeInSections: root.querySelector('#home-fadeInSections')?.checked === true, ensureThumbsForPopularTVNetworks: root.querySelector('#home-ensureThumbsForPopularTVNetworks')?.checked === true, - SHOW_STALE_DATA_BEFORE_REFRESH: root.querySelector('#home-showStaleDataBeforeRefresh')?.checked === true + showStaleDataBeforeRefresh: root.querySelector('#home-showStaleDataBeforeRefresh')?.checked === true, + minPeopleAppearancesTotal: parseInt(root.querySelector('#home-minPeopleAppearancesTotal')?.value || '10', 10), + minPeopleAppearancesMovies: parseInt(root.querySelector('#home-minPeopleAppearancesMovies')?.value || '10', 10), + minPeopleAppearancesSeries: parseInt(root.querySelector('#home-minPeopleAppearancesSeries')?.value || '10', 10), + minPeopleAppearancesEpisodes: parseInt(root.querySelector('#home-minPeopleAppearancesEpisodes')?.value || '10', 10), + maxPeopleCount: parseInt(root.querySelector('#home-maxPeopleCount')?.value || '500', 10), + loadPeopleEpisodeData: root.querySelector('#home-loadPeopleEpisodeData')?.checked === true }; + syncEnsureThumbsOnPopularStudios(currentConfig); currentConfig.USER_HOME_SCREEN_SETTINGS = { ...(currentConfig.USER_HOME_SCREEN_SETTINGS || {}), inlineConfigure: root.querySelector('#userHome-inlineConfigure')?.checked !== false, - pinning: root.querySelector('#userHome-pinning')?.checked !== false + pinning: root.querySelector('#userHome-pinning')?.checked !== false, + pairNativeHomeSections: root.querySelector('#userHome-pairNativeHomeSections')?.checked !== false }; + if (root.querySelector('#libraryCache-mdblistApiKey')) { + currentConfig.LIBRARY_CACHE = { + ...(currentConfig.LIBRARY_CACHE || {}), + mdblistApiKey: root.querySelector('#libraryCache-mdblistApiKey')?.value || '' + }; + } + + const nextLoadEpisodes = currentConfig.HOME_SETTINGS.loadPeopleEpisodeData === true; + if (prevLoadPeopleEpisodeData !== nextLoadEpisodes && window.PeopleCache?.invalidate) { + window.PeopleCache.invalidate(); + } } if (root.querySelector('#cache-DEFAULT_TTL-preset') || root.querySelector('#cache-VERY_SHORT_TTL')) { @@ -2230,7 +2345,8 @@ CACHE: currentConfig?.CACHE, SPOTLIGHT_SETTINGS: currentConfig?.SPOTLIGHT_SETTINGS, HOME_SETTINGS: currentConfig?.HOME_SETTINGS, - USER_HOME_SCREEN_SETTINGS: currentConfig?.USER_HOME_SCREEN_SETTINGS + USER_HOME_SCREEN_SETTINGS: currentConfig?.USER_HOME_SCREEN_SETTINGS, + LIBRARY_CACHE: currentConfig?.LIBRARY_CACHE }; } @@ -2718,6 +2834,200 @@ await saveConfig(currentConfig); } + function buildSectionIdMapFromGroups(...groupLists) { + const map = new Map(); + groupLists.forEach((groups) => { + flattenSectionGroups(groups).forEach((section) => { + if (section?.id) map.set(section.id, section); + }); + }); + return map; + } + + function copyRetainKeys(target, source, keys) { + if (!target || !source) return; + keys.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = JSON.parse(JSON.stringify(source[key])); + } + }); + } + + function copyQueryAgeFields(target, source) { + if (!target || !source) return; + const srcQuery = source.queries?.[0]; + if (!srcQuery || !Array.isArray(target.queries) || !target.queries[0]) return; + if (Object.prototype.hasOwnProperty.call(srcQuery, 'minAge')) { + target.queries[0].minAge = srcQuery.minAge; + } + if (Object.prototype.hasOwnProperty.call(srcQuery, 'maxAge')) { + target.queries[0].maxAge = srcQuery.maxAge; + } + } + + function applyRetainOptionsToGroups(groups, priorById, options) { + (groups || []).forEach((group) => { + (group.sections || []).forEach((section) => { + const prior = priorById.get(section.id); + if (!prior) return; + if (options.retainStatus) { + if (Object.prototype.hasOwnProperty.call(prior, 'enabled')) { + section.enabled = prior.enabled; + } + if (Object.prototype.hasOwnProperty.call(prior, 'userConfigurable')) { + section.userConfigurable = prior.userConfigurable; + } + } + if (options.retainNameCaption) { + copyRetainKeys(section, prior, ['name', 'caption']); + } + if (options.retainAppearance) { + copyRetainKeys(section, prior, RESTORE_APPEARANCE_KEYS); + } + if (options.retainAdvanced) { + copyRetainKeys(section, prior, RESTORE_ADVANCED_KEYS); + copyQueryAgeFields(section, prior); + } + }); + }); + } + + function stampGroupSectionTypes(groups, sectionType) { + (groups || []).forEach((group) => { + (group.sections || []).forEach((section) => { + section.type = sectionType; + }); + }); + } + + async function applyRestoreDefaults(options) { + const defaults = window.KefinHomeConfig2; + if (!defaults || !currentConfig) { + throw new Error('Home Screen defaults are not available.'); + } + + const priorById = buildSectionIdMapFromGroups( + currentConfig.HOME_SECTION_GROUPS, + currentConfig.SEASONAL_SECTION_GROUPS, + currentConfig.DISCOVERY_SECTION_GROUPS + ); + + const homeGroups = JSON.parse(JSON.stringify(defaults.HOME_SECTION_GROUPS || [])); + const seasonalGroups = JSON.parse(JSON.stringify(defaults.SEASONAL_SECTION_GROUPS || [])); + const discoveryGroups = JSON.parse(JSON.stringify(defaults.DISCOVERY_SECTION_GROUPS || [])); + + stampGroupSectionTypes(homeGroups, 'home'); + stampGroupSectionTypes(seasonalGroups, 'seasonal'); + stampGroupSectionTypes(discoveryGroups, 'discovery'); + + applyRetainOptionsToGroups(homeGroups, priorById, options); + applyRetainOptionsToGroups(seasonalGroups, priorById, options); + applyRetainOptionsToGroups(discoveryGroups, priorById, options); + + currentConfig.HOME_SECTION_GROUPS = homeGroups; + currentConfig.SEASONAL_SECTION_GROUPS = seasonalGroups; + currentConfig.DISCOVERY_SECTION_GROUPS = discoveryGroups; + if (!options.retainCustoms) { + currentConfig.CUSTOM_SECTION_GROUPS = []; + } + + // Pre-sync so saveConfig merge does not reintroduce removed sections + if (!window.KefinTweaksConfig) window.KefinTweaksConfig = {}; + if (!window.KefinTweaksConfig.homeScreenConfig) window.KefinTweaksConfig.homeScreenConfig = {}; + const saved = window.KefinTweaksConfig.homeScreenConfig; + saved.HOME_SECTION_GROUPS = JSON.parse(JSON.stringify(homeGroups)); + saved.SEASONAL_SECTION_GROUPS = JSON.parse(JSON.stringify(seasonalGroups)); + saved.DISCOVERY_SECTION_GROUPS = JSON.parse(JSON.stringify(discoveryGroups)); + if (!options.retainCustoms) { + saved.CUSTOM_SECTION_GROUPS = []; + } + + await saveConfig(currentConfig); + refreshMainModal(); + showToast('Home Screen defaults restored'); + } + + function confirmAndRestoreDefaults() { + if (!window.ModalSystem) { + ERR('ModalSystem not available for restore defaults'); + return; + } + if (window.ModalSystem.isOpen(RESTORE_DEFAULTS_MODAL_ID)) { + window.ModalSystem.close(RESTORE_DEFAULTS_MODAL_ID); + } + + const D = RESTORE_RETAIN_TOGGLE_DESCRIPTIONS; + const content = document.createElement('div'); + content.innerHTML = ` +
+ Restoring Defaults for KefinTweaks Home Screen will update the existing Home Screen configuration. +
+
+ ${hscToggleCard('restore-retain-status', true, 'Retain Section Status', D)} + ${hscToggleCard('restore-retain-name-caption', true, 'Retain Section Name and Caption', D)} + ${hscToggleCard('restore-retain-appearance', true, 'Retain Section Appearance', D)} + ${hscToggleCard('restore-retain-advanced', true, 'Retain Section Advanced Options', D)} +
+ ${hscToggleCard('restore-retain-customs', true, 'Retain Custom Sections', D)} +
+
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.5em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: RESTORE_DEFAULTS_MODAL_ID, + title: 'Restore Defaults', + content, + footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + fixedSize: true, + onOpen: (modal) => { + const root = modal.dialogContent || content; + window.KefinTweaksUI?.bindToggleCards?.(root); + updateHscToggleCardHints(root, RESTORE_RETAIN_TOGGLE_DESCRIPTIONS); + root.addEventListener('change', (e) => { + if (e.target?.matches?.('input[type="checkbox"][id^="restore-retain-"]')) { + updateHscToggleCardHints(root, RESTORE_RETAIN_TOGGLE_DESCRIPTIONS); + } + }); + + modal.dialogFooter?.querySelector('#hsc-restore-defaults-cancel')?.addEventListener('click', () => { + window.ModalSystem.close(RESTORE_DEFAULTS_MODAL_ID); + }); + modal.dialogFooter?.querySelector('#hsc-restore-defaults-confirm')?.addEventListener('click', async () => { + const confirmBtn = modal.dialogFooter.querySelector('#hsc-restore-defaults-confirm'); + if (confirmBtn) confirmBtn.disabled = true; + try { + const options = { + retainStatus: root.querySelector('#restore-retain-status')?.checked !== false, + retainNameCaption: root.querySelector('#restore-retain-name-caption')?.checked !== false, + retainAppearance: root.querySelector('#restore-retain-appearance')?.checked !== false, + retainAdvanced: root.querySelector('#restore-retain-advanced')?.checked !== false, + retainCustoms: root.querySelector('#restore-retain-customs')?.checked !== false + }; + await applyRestoreDefaults(options); + window.ModalSystem.close(RESTORE_DEFAULTS_MODAL_ID); + } catch (e) { + ERR('Error restoring Home Screen defaults:', e); + showToast('Error restoring defaults'); + } finally { + if (confirmBtn) confirmBtn.disabled = false; + } + }); + } + }); + } + function confirmAndUpdateAllUsers() { if (!window.ModalSystem) { ERR('ModalSystem not available for update confirmation'); @@ -2965,6 +3275,10 @@ } if (e.target.closest('.hsc-toolbar-update-btn')) { confirmAndUpdateAllUsers(); + return; + } + if (e.target.closest('.hsc-toolbar-restore-defaults-btn')) { + confirmAndRestoreDefaults(); } }); /* dialog.querySelectorAll('.section-type-nav-btn').forEach(btn => { @@ -4412,6 +4726,18 @@ } const queries = section.queries || []; + if (Array.isArray(section.externalListUrls) && section.externalListUrls.length > 0) { + if (!window.KefinExternalList?.resolveExternalListItems) { + throw new Error('External list module not available for preview'); + } + const listResult = await window.KefinExternalList.resolveExternalListItems(section); + const items = listResult?.Items || []; + const processed = window.cardBuilder.postProcessItems + ? window.cardBuilder.postProcessItems(section, items) + : items; + return { items: processed, limit: processed.length }; + } + if (section.items?.length) { const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || ''; const serverId = ApiClient.serverId(); diff --git a/configuration/homeScreen-migration.js b/configuration/homeScreen-migration.js index 033caec..2f0bfcf 100644 --- a/configuration/homeScreen-migration.js +++ b/configuration/homeScreen-migration.js @@ -48,9 +48,9 @@ SEASONAL_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.SEASONAL_SECTION_GROUPS || [])), DISCOVERY_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SECTION_GROUPS || [])), CUSTOM_SECTION_GROUPS: [], - REMOVE_CONFLICTING_SECTIONS: legacyHomeScreen.removeConflictingSections !== false, DISCOVERY_SETTINGS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SETTINGS || {})), - SEASONAL_THEME_SETTINGS: JSON.parse(JSON.stringify(defaults.SEASONAL_THEME_SETTINGS || {})) + SEASONAL_THEME_SETTINGS: JSON.parse(JSON.stringify(defaults.SEASONAL_THEME_SETTINGS || {})), + HOME_SETTINGS: JSON.parse(JSON.stringify(defaults.HOME_SETTINGS || {})) }; // Migrate discovery settings @@ -101,7 +101,6 @@ if (discovery.enabled !== undefined) settings.enabled = discovery.enabled; if (discovery.infiniteScroll !== undefined) settings.infiniteScroll = discovery.infiniteScroll; - if (discovery.minPeopleAppearances !== undefined) settings.minPeopleAppearances = discovery.minPeopleAppearances; if (discovery.minGenreMovieCount !== undefined) settings.minGenreMovieCount = discovery.minGenreMovieCount; if (discovery.defaultItemLimit !== undefined) settings.defaultItemLimit = discovery.defaultItemLimit; if (discovery.defaultSortOrder !== undefined) settings.defaultSortOrder = discovery.defaultSortOrder; @@ -113,6 +112,17 @@ settings.spotlight = {}; Object.assign(settings.spotlight, discovery.spotlight); } + + // Legacy discovery.minPeopleAppearances → HOME_SETTINGS mins + if (discovery.minPeopleAppearances !== undefined) { + if (!newConfig.HOME_SETTINGS) newConfig.HOME_SETTINGS = {}; + const min = discovery.minPeopleAppearances; + const home = newConfig.HOME_SETTINGS; + if (home.minPeopleAppearancesTotal == null) home.minPeopleAppearancesTotal = min; + if (home.minPeopleAppearancesMovies == null) home.minPeopleAppearancesMovies = min; + if (home.minPeopleAppearancesSeries == null) home.minPeopleAppearancesSeries = min; + if (home.minPeopleAppearancesEpisodes == null) home.minPeopleAppearancesEpisodes = min; + } } /** @@ -216,8 +226,8 @@ function mergeSectionConfig(defaultSection, legacyConfig) { const merged = JSON.parse(JSON.stringify(defaultSection)); - // Map root fields - if (legacyConfig.enabled !== undefined) merged.enabled = legacyConfig.enabled; + // Map root fields — missing legacy enabled means on (same as seasonal/custom) + merged.enabled = legacyConfig.enabled !== false; if (legacyConfig.name !== undefined) merged.name = legacyConfig.name; if (legacyConfig.order !== undefined) merged.order = legacyConfig.order; if (legacyConfig.cardFormat !== undefined) merged.cardFormat = legacyConfig.cardFormat; @@ -427,6 +437,7 @@ watchlist: 'watchlist', upcoming: 'upcoming', imdbTop250: 'imdbTop250', + trending: 'trending', popularTVNetworks: 'popularTVNetworks' }; @@ -550,6 +561,50 @@ LOG(` Added as custom section: ${customSection.id}`); } + const RECENTLY_WATCHED_PERSON_IDS = new Set([ + 'starringActorRecentlyWatched', + 'directedByDirectorRecentlyWatched', + 'writtenByWriterRecentlyWatched' + ]); + + const RECENTLY_WATCHED_CAPTION_DEFAULT = 'because you recently watched {Title}'; + + const RECENTLY_WATCHED_NAME_DEFAULTS = { + starringActorRecentlyWatched: 'Starring {Person}', + directedByDirectorRecentlyWatched: 'Directed by {Person}', + writtenByWriterRecentlyWatched: 'Written by {Person}' + }; + + function normalizeDiscoveryTemplatePlaceholders(text) { + if (!text || typeof text !== 'string') return text; + return text + .replace(/\[Actor\]/gi, '{Person}') + .replace(/\[Director\]/gi, '{Person}') + .replace(/\[Writer\]/gi, '{Person}') + .replace(/\[Person\]/gi, '{Person}') + .replace(/\[Movie\]/gi, '{Title}') + .replace(/\[Title\]/gi, '{Title}'); + } + + /** + * Split legacy combined "Starring X because you recently watched Y" into name + caption. + */ + function splitLegacyRecentlyWatchedName(legacyName) { + if (!legacyName || typeof legacyName !== 'string') return null; + const match = legacyName.match(/^(.*?)\s+because you recently watched\s+(.*)$/i); + if (!match) return null; + let name = normalizeDiscoveryTemplatePlaceholders(match[1].trim()); + let captionRight = normalizeDiscoveryTemplatePlaceholders(match[2].trim()); + const caption = captionRight + ? `because you recently watched ${captionRight}` + : RECENTLY_WATCHED_CAPTION_DEFAULT; + // Prefer canonical caption placeholder when right side is Movie/Title token only + if (/^\{Title\}$/i.test(captionRight) || /^\[Movie\]$/i.test(match[2].trim())) { + return { name, caption: RECENTLY_WATCHED_CAPTION_DEFAULT }; + } + return { name, caption }; + } + /** * Migrate DISCOVERY sections */ @@ -562,13 +617,10 @@ const discoverySectionTypes = legacyHomeScreen.discovery.sectionTypes; Object.entries(discoverySectionTypes).forEach(([sectionKey, legacySectionConfig]) => { - // Use the key as the section ID const found = findDiscoverySectionById(sectionKey, newConfig.DISCOVERY_SECTION_GROUPS); if (found) { - // Discovery sections still use legacy format, so we map fields directly - if (legacySectionConfig.enabled !== undefined) found.section.enabled = legacySectionConfig.enabled; - if (legacySectionConfig.name !== undefined) found.section.name = legacySectionConfig.name; + found.section.enabled = legacySectionConfig.enabled !== false; if (legacySectionConfig.itemLimit !== undefined) found.section.itemLimit = legacySectionConfig.itemLimit; if (legacySectionConfig.cardFormat !== undefined) found.section.cardFormat = legacySectionConfig.cardFormat; if (legacySectionConfig.isPlayed !== undefined && legacySectionConfig.isPlayed !== null) { @@ -578,6 +630,29 @@ if (legacySectionConfig.sortOrder !== undefined) found.section.sortOrder = legacySectionConfig.sortOrder; if (legacySectionConfig.sortOrderDirection !== undefined) found.section.sortOrderDirection = legacySectionConfig.sortOrderDirection; + const isRecentlyWatchedPerson = RECENTLY_WATCHED_PERSON_IDS.has(sectionKey); + if (legacySectionConfig.name !== undefined) { + const split = splitLegacyRecentlyWatchedName(legacySectionConfig.name); + if (split) { + found.section.name = split.name; + found.section.caption = split.caption; + } else if (isRecentlyWatchedPerson) { + found.section.name = RECENTLY_WATCHED_NAME_DEFAULTS[sectionKey] + || normalizeDiscoveryTemplatePlaceholders(legacySectionConfig.name); + if (!found.section.caption) { + found.section.caption = RECENTLY_WATCHED_CAPTION_DEFAULT; + } + } else { + found.section.name = legacySectionConfig.name; + } + } else if (isRecentlyWatchedPerson && !found.section.caption) { + found.section.caption = RECENTLY_WATCHED_CAPTION_DEFAULT; + } + + if (legacySectionConfig.caption !== undefined) { + found.section.caption = normalizeDiscoveryTemplatePlaceholders(legacySectionConfig.caption); + } + LOG(` Migrated discovery section: ${sectionKey}`); } else { WARN(` Discovery section not found in defaults: ${sectionKey}`); diff --git a/configuration/homeScreen-user-configuration.js b/configuration/homeScreen-user-configuration.js index 7d5b1c6..74a1295 100644 --- a/configuration/homeScreen-user-configuration.js +++ b/configuration/homeScreen-user-configuration.js @@ -57,9 +57,60 @@ sections: [], pinnedLists: [], pinnedParents: [] + // pairNativeHomeSections omitted until user dirties the field }; } + function getServerPairNativeHomeSectionsDefault() { + const settings = window.KefinHomeScreen?.getConfig?.()?.USER_HOME_SCREEN_SETTINGS + || window.KefinHomeConfig2?.USER_HOME_SCREEN_SETTINGS + || {}; + return settings.pairNativeHomeSections !== false; + } + + /** + * Effective pair-native setting: user override if present, else server default. + */ + function resolvePairNativeHomeSections(homeScreen) { + if (homeScreen && typeof homeScreen.pairNativeHomeSections === 'boolean') { + return homeScreen.pairNativeHomeSections; + } + return getServerPairNativeHomeSectionsDefault(); + } + + /** + * Disable all native Jellyfin home section slots (homesection0–9 = none). + */ + function disableAllNativeHomeSections(customPrefs, { syncUi = false } = {}) { + const prefs = customPrefs || {}; + let changed = false; + for (let i = 0; i <= 9; i++) { + const key = `homesection${i}`; + const prev = prefs[key]; + const prevNorm = (!prev || prev === '') ? 'none' : String(prev).toLowerCase(); + if (prevNorm !== 'none' || prefs[key] !== 'none') { + prefs[key] = 'none'; + if (prevNorm !== 'none' || prev !== 'none') changed = true; + } + } + if (syncUi) { + for (let i = 0; i <= 9; i++) { + setNativeHomeSectionSelect(findNativeHomeSectionSelect(i + 1), 'none'); + } + } + if (changed) { + LOG('Disabled all Jellyfin homesectionN (pair native off)'); + } + return { slots: Array(10).fill('none'), changed }; + } + + function syncNativeHomeSectionsFromKefin(sections, customPrefs, homeScreen, { syncUi = false } = {}) { + if (resolvePairNativeHomeSections(homeScreen)) { + return enableJellyfinSectionsFromKefin(sections, customPrefs, { syncUi }); + } + return disableAllNativeHomeSections(customPrefs, { syncUi }); + } + function slugifyPinnedName(name) { return String(name || '') .trim() @@ -524,11 +575,15 @@ if (parsed) { const hs = parsed?.homeScreen || (Array.isArray(parsed?.sections) ? parsed : null); if (hs) { - return { + const result = { sections: Array.isArray(hs.sections) ? [...hs.sections] : [], pinnedLists: Array.isArray(hs.pinnedLists) ? [...hs.pinnedLists] : [], pinnedParents: Array.isArray(hs.pinnedParents) ? [...hs.pinnedParents] : [] }; + if (typeof hs.pairNativeHomeSections === 'boolean') { + result.pairNativeHomeSections = hs.pairNativeHomeSections; + } + return result; } } return migrateLegacyKefinHomeScreen(customPrefs); @@ -839,7 +894,7 @@ sections = sections.filter((section) => section.enabled || section.userConfigurable === true || section.userConfigurable === undefined); sections = deduplicateHomeScreenSections(sections); - const jellyfinSync = enableJellyfinSectionsFromKefin(sections, customPrefs, { syncUi: true }); + const jellyfinSync = syncNativeHomeSectionsFromKefin(sections, customPrefs, homeScreen, { syncUi: true }); if (jellyfinSync.changed && displayPrefs && !sanitizePersistInFlight) { sanitizePersistInFlight = true; try { @@ -1544,8 +1599,12 @@ /** * Save user preferences to display preferences + * @param {Array} sections + * @param {{ pairNativeHomeSections?: boolean|null }} [options] + * pairNativeHomeSections: true/false to set override; null/undefined to leave unchanged; + * pass 'default' symbol via omitOverride to clear dirty override */ - async function saveUserPreferences(sections) { + async function saveUserPreferences(sections, options = {}) { try { if (!window.userHelper || !window.userHelper.getUserDisplayPreferences || !window.userHelper.updateDisplayPreferences) { ERR('userHelper not available'); @@ -1574,6 +1633,21 @@ mergeEditorSectionsIntoHomeScreen(existingHomeScreen, sections, serverSectionsById) ).homeScreen; + // Preserve or update dirty-only pairNativeHomeSections + if (Object.prototype.hasOwnProperty.call(options, 'pairNativeHomeSections')) { + const serverDefault = getServerPairNativeHomeSectionsDefault(); + const next = options.pairNativeHomeSections; + if (next === null || next === undefined) { + delete homeScreen.pairNativeHomeSections; + } else if (next === serverDefault) { + delete homeScreen.pairNativeHomeSections; + } else { + homeScreen.pairNativeHomeSections = next === true; + } + } else if (typeof existingHomeScreen.pairNativeHomeSections === 'boolean') { + homeScreen.pairNativeHomeSections = existingHomeScreen.pairNativeHomeSections; + } + const kefin = typeof window.userHelper?.parseKefinTweaks === 'function' ? window.userHelper.parseKefinTweaks(customPrefs) : {}; @@ -1582,7 +1656,7 @@ customPrefs.kefinHomeScreen = JSON.stringify(homeScreenToLegacyArray(homeScreen)); // Rewrite Jellyfin homesectionN (+ native selects) from enabled Kefin sections - enableJellyfinSectionsFromKefin(sections, customPrefs, { syncUi: true }); + syncNativeHomeSectionsFromKefin(sections, customPrefs, homeScreen, { syncUi: true }); // Save const success = await window.userHelper.updateDisplayPreferences(displayPrefs); @@ -1679,6 +1753,20 @@ showUpDownButtons: true }); + const serverPairDefault = getServerPairNativeHomeSectionsDefault(); + const effectivePair = resolvePairNativeHomeSections(userConfig.homeScreen); + const pairDescOn = 'Do NOT enable this if you use the official Jellyfin for Roku or Android TV clients. When this is enabled, your Home Screen on those clients will be empty and you will need to go to your User Settings to re-enable sections.'; + const pairDescOff = 'Do NOT enable this if you use the official Jellyfin for Roku or Android TV clients. When this is enabled, your Home Screen on those clients will be empty and you will need to go to your User Settings to re-enable sections.'; + const pairToggleHTML = typeof window.KefinTweaksUI?.buildToggleCard === 'function' + ? window.KefinTweaksUI.buildToggleCard( + 'kefin-user-pair-native-home-sections', + !effectivePair, + 'Improve Home Screen Performance', + !effectivePair ? pairDescOn : pairDescOff, + { hintKey: 'pairNativeHomeSections' } + ) + : ''; + container.innerHTML = `
@@ -1688,9 +1776,51 @@
${editorHTML} + ${pairToggleHTML ? ` +
+ ${pairToggleHTML} +
+ ` : ''}
`; + // Pair-native toggle: dirty-only persist (INVERTED UI: on in UI means flag == false) + const pairCheckbox = container.querySelector('#kefin-user-pair-native-home-sections'); + const pairCard = pairCheckbox?.closest('.kefin-toggle-card'); + if (pairCheckbox && pairCard) { + const pairSwitch = pairCard.querySelector('.kefin-toggle-switch, .toggle-slider, button'); + // When the toggle appears "on" in the UI, the flag is actually false (inverse) + const updatePairDesc = (checked) => { + // checked (UI "on") means flag === false + const desc = pairCard.querySelector('.kefin-toggle-card-desc'); + if (desc) desc.textContent = checked ? pairDescOn : pairDescOff; + }; + const onPairToggle = async () => { + // checked (UI 'on') means flag should be false + const flagValue = !pairCheckbox.checked; + updatePairDesc(pairCheckbox.checked); + if (typeof window.KefinTweaksUI?.updateToggleSwitchUI === 'function' && pairSwitch) { + window.KefinTweaksUI.updateToggleSwitchUI(pairSwitch, pairCheckbox.checked); + } + // For persistence: (flagValue === serverPairDefault) ? null : flagValue + await saveUserPreferences(allSections, { + pairNativeHomeSections: flagValue === serverPairDefault ? null : flagValue + }); + }; + pairSwitch?.addEventListener('click', (e) => { + e.preventDefault(); + pairCheckbox.checked = !pairCheckbox.checked; + onPairToggle(); + }); + pairCheckbox.addEventListener('change', onPairToggle); + + // On initial render, enforce the correct description and checked value + // (in case render logic needs to set checked/unchecked based on flag) + // (Optional: If not already being handled by upstream render logic) + //pairCheckbox.checked = !effectivePair; + updatePairDesc(pairCheckbox.checked); + } + // Restore Defaults: confirm then clear kefinHomeScreen and re-render const restoreBtn = container.querySelector('.kefin-restore-defaults-btn'); if (restoreBtn && window.ModalSystem) { @@ -1906,6 +2036,9 @@ homeScreenToLegacyArray, getConfig, enableJellyfinSectionsFromKefin, + syncNativeHomeSectionsFromKefin, + resolvePairNativeHomeSections, + getServerPairNativeHomeSectionsDefault, buildHomesectionSlotsFromKefin, resolveSectionJellyfinId, updateUserHomeScreenConfiguration, diff --git a/configuration/homeScreenAdvancedEditor.css b/configuration/homeScreenAdvancedEditor.css index b774e28..b7e8f42 100644 --- a/configuration/homeScreenAdvancedEditor.css +++ b/configuration/homeScreenAdvancedEditor.css @@ -129,6 +129,14 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-basic-group-row { + display: grid; + grid-template-columns: 1fr; + gap: 0.75em; + margin-top: 0.85em; + margin-bottom: 0.85em; +} + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -137,11 +145,6 @@ grid-template-columns: repeat(4, minmax(0, 1fr)); } -:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) - .hsae-query-row-sort.hsae-field-grid-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-seasonal-dates { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1589,11 +1592,18 @@ } /* Item source — show only the panel matching the active source (profile rules still gate custom items on non-full profiles) */ -.hsae-root[data-item-source="jellyfin"] .hsae-custom-items-group { +.hsae-root[data-item-source="jellyfin"] .hsae-custom-items-group, +.hsae-root[data-item-source="jellyfin"] .hsae-external-list-group { display: none !important; } -.hsae-root[data-item-source="custom"] .hsae-queries-group { +.hsae-root[data-item-source="custom"] .hsae-queries-group, +.hsae-root[data-item-source="custom"] .hsae-external-list-group { + display: none !important; +} + +.hsae-root[data-item-source="external"] .hsae-queries-group, +.hsae-root[data-item-source="external"] .hsae-custom-items-group { display: none !important; } diff --git a/configuration/homeScreenAdvancedEditor.js b/configuration/homeScreenAdvancedEditor.js index d388c58..5f34ad3 100644 --- a/configuration/homeScreenAdvancedEditor.js +++ b/configuration/homeScreenAdvancedEditor.js @@ -625,7 +625,7 @@ function getCachePresets() { const cache = window.KefinHomeConfig2?.CACHE || window.KefinHomeConfig2?.CACHE_CONFIG || {}; return [ - { key: 'FORCE_REFRESH', label: 'Force Refresh', ms: cache.FORCE_REFRESH_TTL ?? 0 }, + { key: 'FORCE_REFRESH', label: 'No Cache', ms: cache.FORCE_REFRESH_TTL ?? 0 }, { key: 'VERY_SHORT', label: 'Very Short (1 min)', ms: cache.VERY_SHORT_TTL ?? 60000 }, { key: 'SHORT', label: 'Short (5 min)', ms: cache.SHORT_TTL ?? 300000 }, { key: 'DEFAULT', label: 'Default (1 hour)', ms: cache.DEFAULT_TTL ?? 3600000 }, @@ -637,6 +637,9 @@ } function inferItemSource(section) { + if (Array.isArray(section?.externalListUrls) && section.externalListUrls.length > 0) { + return 'external'; + } if (section?.items?.length > 0 && (!section.queries?.length || section.queries.length === 0)) { return 'custom'; } @@ -951,6 +954,7 @@ startDate: section.startDate || '', endDate: section.endDate || '', order: section.order != null ? String(section.order) : '', + caption: section.caption || '', renderMode, cardFormat: section.cardFormat || 'Poster', useRandomQuery: section.useRandomQuery === true, @@ -970,6 +974,9 @@ groupNew: groupSelection.groupNew, queries: (section.queries?.length ? section.queries : [createDefaultQuery()]).map(q => ({ ...q })), customItems: customEditor ? customEditor.staticItemsToDrafts(section.items) : [], + externalListUrls: Array.isArray(section.externalListUrls) && section.externalListUrls.length + ? section.externalListUrls.slice() + : [''], customItemEditorIndex: null, customItemAdditionalExpanded: {}, ttlPreset: ttlMatch.preset, @@ -987,6 +994,7 @@ if (!root || !state) return; const nameEl = root.querySelector('#section-name'); + const captionEl = root.querySelector('#section-caption'); const groupEl = root.querySelector('#section-group'); const groupNewEl = root.querySelector('#section-group-new'); const visibilityEl = root.querySelector('#hsae-section-visibility'); @@ -1001,6 +1009,7 @@ const sortOrderEl = root.querySelector('#section-sortOrder'); if (nameEl) state.sectionName = nameEl.value; + if (captionEl) state.caption = captionEl.value; if (groupEl) state.groupSelect = groupEl.value; if (groupNewEl) state.groupNew = groupNewEl.value; if (visibilityEl) state.sectionVisibility = visibilityEl.value; @@ -1058,6 +1067,12 @@ customEditor.collectCustomItemsFromForm(root, state, customEditor.getCustomItemsOptions()); } + if (state.itemSource === 'external') { + const urlInputs = root.querySelectorAll('[id^="hsae-external-list-url-"]'); + state.externalListUrls = Array.from(urlInputs).map((el) => (el.value || '').trim()).filter(Boolean); + if (!state.externalListUrls.length) state.externalListUrls = ['']; + } + if (state.itemSource === 'jellyfin') { const hideWatched = root.querySelector('#hsae-hide-watched')?.checked === true; const editors = root.querySelectorAll('.query-editor'); @@ -1354,6 +1369,7 @@ 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 staticItems = getQueryStaticItems(query); const parentSelection = getQueryParentSelection(query); @@ -1396,17 +1412,17 @@
-
+
${buildSelect(`query-${index}-SortBy`, toSortBySelectOptions(SORT_ORDERS), queryOptions.SortBy || '', 'Sort By')}
${buildSelect(`query-${index}-SortOrder`, SORT_ORDER_DIRECTIONS, queryOptions.SortOrder || '', 'Sort Order')}
-
+
${buildTextInput(`query-${index}-Limit`, queryOptions.Limit ?? '', 'Limit', 'number', '', { min: 0, step: 1 })}
- @@ -1507,13 +1523,15 @@
General configuration for this section
-
+
${buildTextInput('section-name', section.name || state.sectionName || '', 'Section Name')} -
- ${buildSelect('section-group', groupOptions, selectedGroup, 'Section Group')} -
- ${buildTextInput('section-group-new', state.groupNew || '', 'New Group Name')} -
+ ${buildTextInput('section-caption', section.caption || state.caption || '', 'Caption', 'text', 'Displayed below the Section Name.')} +
+ +
+ ${buildSelect('section-group', groupOptions, selectedGroup, 'Section Group')} +
+ ${buildTextInput('section-group-new', state.groupNew || '', 'New Group Name')}
@@ -1606,11 +1624,49 @@
${escapeHtml(CUSTOM_SOURCE_DESC)}
+
`; } + function buildExternalListHTML(state) { + const { buildTextInput } = getApi(); + const urls = Array.isArray(state.externalListUrls) && state.externalListUrls.length + ? state.externalListUrls + : ['']; + const apiKey = window.LibraryCacheUtils?.getLibraryCacheSettings?.()?.mdblistApiKey + || window.KefinHomeConfig2?.LIBRARY_CACHE?.mdblistApiKey + || ''; + const warn = !String(apiKey || '').trim() + ? `
MDBList API key is not configured. Add it under Home Screen → General settings before using External Lists.
` + : ''; + const rows = urls.map((url, index) => ` +
+ ${buildTextInput(`hsae-external-list-url-${index}`, url || '', `List URL ${index + 1}`, 'url', 'https://mdblist.com/...')} +
+ `).join(''); + return ` +
+
+
External List URLs
+
Items from these lists are matched to your Jellyfin library by IMDb/TMDB ids
+
+ ${warn} +
${rows}
+ +
+ `; + } + function buildQueriesHTML(state) { const queries = state.queries?.length ? state.queries : [createDefaultQuery()]; const expandedStates = state.queryExpandedStates; @@ -1790,6 +1846,7 @@ itemSource: inferItemSource(draft), queries: draft?.queries, items: draft?.items, + externalListUrls: draft?.externalListUrls, hideWatched: Array.isArray(draft?.queries) && draft.queries.length > 0 && draft.queries.every(q => q?.queryOptions?.IsUnplayed === true) @@ -2157,6 +2214,7 @@ ${buildItemSourceHTML(state)} ${buildQueriesHTML(state)} ${buildCustomItemsHTML(state)} + ${buildExternalListHTML(state)} ${buildAdvancedOptionsHTML(state)} `; } @@ -2349,6 +2407,9 @@ const userConfigChecked = dialog.querySelector('#hsae-user-configurable')?.checked === true; section.name = dialog.querySelector('#section-name')?.value || ''; + const captionVal = dialog.querySelector('#section-caption')?.value?.trim() || ''; + if (captionVal) section.caption = captionVal; + else section.caption = ''; section.enabled = userEnabledChecked; section.userConfigurable = userConfigChecked; @@ -2402,8 +2463,16 @@ section.items = []; } section.queries = []; + delete section.externalListUrls; + } else if (itemSource === 'external') { + delete section.items; + section.queries = []; + const urlInputs = dialog.querySelectorAll('[id^="hsae-external-list-url-"]'); + const urls = Array.from(urlInputs).map((el) => (el.value || '').trim()).filter(Boolean); + section.externalListUrls = urls; } else { delete section.items; + delete section.externalListUrls; const queryEditors = dialog.querySelectorAll('.query-editor'); if (queryEditors.length > 0) { section.queries = Array.from(queryEditors).map(editor => { @@ -2563,6 +2632,7 @@ const cacheWrap = editor.querySelector('.hsae-cache-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 updateSourceVisibility = () => { const sourceType = sourceSelect?.value || 'jellyfin'; @@ -2578,6 +2648,9 @@ staticOnlyBlocks.forEach(block => { block.style.display = sourceType === 'static' ? '' : 'none'; }); + sortExtrasBlocks.forEach(block => { + block.style.display = sourceType === 'jellyfin' || sourceType === 'static' ? '' : 'none'; + }); }; const updateEndpointVisibility = () => { @@ -3193,13 +3266,17 @@ const newSource = actionEl.dataset.itemSource; if (!newSource || state.itemSource === newSource) return; - // Keep both queries and customItems in memory while switching; + // Keep sources in memory while switching; // collectData clears the inactive source only on save/preview. collectStateFromForm(root, state); state.itemSource = newSource; if (newSource === 'custom') { const customEditor = getCustomItemsEditor(); if (customEditor) customEditor.ensureCustomItemsInitialized(state); + } else if (newSource === 'external') { + if (!Array.isArray(state.externalListUrls) || !state.externalListUrls.length) { + state.externalListUrls = ['']; + } } else { state.queries = state.queries?.length ? state.queries : [createDefaultQuery()]; } @@ -3207,6 +3284,14 @@ return; } + if (action === 'add-external-list-url') { + collectStateFromForm(root, state); + if (!Array.isArray(state.externalListUrls)) state.externalListUrls = ['']; + state.externalListUrls.push(''); + refreshEditorBody(modalInstance, { skipCollect: true }); + return; + } + if (action === 'delete-query') { e.stopPropagation(); const index = parseInt(actionEl.dataset.queryIndex, 10); diff --git a/configuration/homeScreenSectionEditor.js b/configuration/homeScreenSectionEditor.js index 6337994..f8ac14e 100644 --- a/configuration/homeScreenSectionEditor.js +++ b/configuration/homeScreenSectionEditor.js @@ -11,11 +11,13 @@ const QUERY_WIZARD_STEPS = ['type', 'parent', 'query', 'settings', 'appearance', 'confirm']; const CUSTOM_WIZARD_STEPS = ['type', 'customItems', 'settings', 'appearance', 'confirm']; + const EXTERNAL_WIZARD_STEPS = ['type', 'externalList', 'settings', 'appearance', 'confirm']; const WIZARD_STEP_LABELS = { type: 'Type', parent: 'Selection', query: 'Query', customItems: 'Create Items', + externalList: 'External List', appearance: 'Appearance', settings: 'Settings', confirm: 'Confirm' @@ -28,6 +30,13 @@ icon: 'widgets' }; + const EXTERNAL_TYPE = { + type: 'External', + label: 'External List Section', + description: 'Match an MDBList (or compatible) URL against your Jellyfin library by IMDb/TMDB provider IDs. Requires an MDBList API key in Home Screen General settings.', + icon: 'link' + }; + const CUSTOM_IMAGE_FIELDS = [ { key: 'posterUrl', label: 'Poster URL', required: true }, { key: 'thumbUrl', label: 'Thumb URL' }, @@ -77,13 +86,19 @@ // --- Wizard parent data (live Jellyfin API) --- function getWizardSteps(state) { - return state?.parentType === 'Custom' ? CUSTOM_WIZARD_STEPS : QUERY_WIZARD_STEPS; + if (state?.parentType === 'Custom') return CUSTOM_WIZARD_STEPS; + if (state?.parentType === 'External') return EXTERNAL_WIZARD_STEPS; + return QUERY_WIZARD_STEPS; } function isCustomType(state) { return state?.parentType === 'Custom'; } + function isExternalType(state) { + return state?.parentType === 'External'; + } + function getStepIndex(step, state) { return getWizardSteps(state).indexOf(step); } @@ -251,6 +266,9 @@ case 'customItems': collectCustomItemsFromForm(root, wizardState); break; + case 'externalList': + collectExternalListFromForm(root, wizardState); + break; case 'appearance': collectAppearanceStepFromForm(root, wizardState); break; @@ -1093,6 +1111,12 @@ })) }); } + if (isExternalType(wizardState)) { + return JSON.stringify({ + parentType: 'External', + externalListUrls: wizardState.externalListUrls || [] + }); + } const parents = wizardState.parents || []; return JSON.stringify({ parentType: wizardState.parentType, @@ -1372,6 +1396,12 @@ `; } + function collectExternalListFromForm(root, wizardState) { + const inputs = root.querySelectorAll('[id^="hsse-external-list-url-"]'); + wizardState.externalListUrls = Array.from(inputs).map((el) => (el.value || '').trim()).filter(Boolean); + if (!wizardState.externalListUrls.length) wizardState.externalListUrls = ['']; + } + function collectSettingsFromForm(root, wizardState) { const nameEl = root.querySelector('#hsse-wizard-sectionName'); const groupSelect = root.querySelector('#hsse-wizard-sectionGroup'); @@ -1410,19 +1440,23 @@ function renderConfirmOverview(wizardState, config) { const isCustom = isCustomType(wizardState); - const meta = isCustom ? CUSTOM_TYPE : PARENT_TYPES.find(t => t.type === wizardState.parentType); + const isExternal = isExternalType(wizardState); + const meta = isCustom ? CUSTOM_TYPE + : (isExternal ? EXTERNAL_TYPE : PARENT_TYPES.find(t => t.type === wizardState.parentType)); const parents = wizardState.parents || []; const selectionLabel = isCustom ? ((wizardState.customItems || []).map(i => i.name?.trim()).filter(Boolean).join(', ') || '—') - : (parents.length ? parents.map(p => p.name).join(', ') : '—'); + : (isExternal + ? ((wizardState.externalListUrls || []).filter(Boolean).join(', ') || '—') + : (parents.length ? parents.map(p => p.name).join(', ') : '—')); const rows = [ ['Section Name', wizardState.sectionName || '—'], ['Section Group', resolveGroupName(wizardState, config)], ['Based On', meta?.label || wizardState.parentType || '—'], - [isCustom ? 'Items' : 'Selection', selectionLabel] + [isCustom ? 'Items' : (isExternal ? 'List URLs' : 'Selection'), selectionLabel] ]; - if (!isCustom) { + if (!isCustom && !isExternal) { rows.push( ['Item Types', wizardState.includeItemTypes.length ? formatItemTypesLabel(wizardState.includeItemTypes) : 'All types'], ['Sort By', wizardState.sortBy === 'Default' ? 'Default' : `${humanizeLabel(wizardState.sortBy)} (${wizardState.sortOrder})`], @@ -1547,6 +1581,39 @@ return section; } + if (parentType === 'External') { + const urls = (wizardState.externalListUrls || []).map((u) => String(u || '').trim()).filter(Boolean); + const section = { + ...baseSection, + id: baseSection.id, + name: sectionName || 'External List', + enabled: true, + isCustom: true, + renderMode: renderMode || 'Normal', + cardFormat: renderMode === 'Spotlight' ? undefined : (cardFormat || 'Poster'), + externalListUrls: urls, + queries: [] + }; + + applySectionVisibilityToSection(section, wizardState, baseSection); + + const targetGroup = resolveGroupName(wizardState, wizardState._config); + if (targetGroup) { + section._targetGroupName = targetGroup; + } + + if (renderMode === 'Spotlight' || renderMode === 'Random') { + section.spotlightConfig = { + spotlightLayout: spotlightLayout || 'Border', + spotlightSize: spotlightSize || 'normal', + tileCount: Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)), + panAnimation: panAnimation !== false + }; + } + + return section; + } + const queryOptions = { Limit: DEFAULT_SECTION_QUERY_LIMIT }; @@ -1820,6 +1887,27 @@ `; } + function renderExternalListHTML(wizardState) { + const urls = Array.isArray(wizardState.externalListUrls) && wizardState.externalListUrls.length + ? wizardState.externalListUrls + : ['']; + const apiKey = window.LibraryCacheUtils?.getLibraryCacheSettings?.()?.mdblistApiKey + || window.KefinHomeConfig2?.LIBRARY_CACHE?.mdblistApiKey + || ''; + const warn = !String(apiKey || '').trim() + ? `

MDBList API key is not configured. Add it under Home Screen → General settings before using External Lists.

` + : ''; + return ` +
+ +

Paste one or more MDBList URLs. Matched against your library by IMDb/TMDB ids.

+ ${warn} + ${urls.map((url, index) => hsseBuildTextInput(`hsse-external-list-url-${index}`, url || '', `List URL ${index + 1}`, 'url', 'https://mdblist.com/...')).join('')} + +
+ `; + } + function renderWizardBodyHTML(wizardState, config) { const step = wizardState.step; @@ -1842,6 +1930,13 @@
${escapeHtml(CUSTOM_TYPE.description)}
+
`; } @@ -1850,6 +1945,10 @@ return renderCustomItemsHTML(wizardState); } + if (step === 'externalList') { + return renderExternalListHTML(wizardState); + } + if (step === 'parent') { const meta = PARENT_TYPES.find(t => t.type === wizardState.parentType); @@ -1960,6 +2059,7 @@ return `Choose a ${meta?.label?.toLowerCase() || 'item'}`; } if (wizardState.step === 'customItems') return 'Create Items'; + if (wizardState.step === 'externalList') return 'External List'; return 'Create a Home Screen Section'; } return 'Create a Section'; @@ -3748,6 +3848,7 @@ parentListError: null, personSearchResults: [], customItems: [], + externalListUrls: [''], customItemAdditionalExpanded: {}, customItemEditorIndex: null, includeItemTypes: [], @@ -4116,20 +4217,38 @@ if (selected === 'Custom') { wizardState.customItems = [createBlankCustomItem()]; wizardState.customItemEditorIndex = null; + wizardState.externalListUrls = ['']; advanceToStep(root, 'customItems'); + } else if (selected === 'External') { + wizardState.customItems = []; + wizardState.externalListUrls = wizardState.externalListUrls?.length + ? wizardState.externalListUrls + : ['']; + advanceToStep(root, 'externalList'); } else { wizardState.customItems = []; + wizardState.externalListUrls = ['']; advanceToStep(root, 'parent'); } } else if (selected === 'Custom') { wizardState.customItemEditorIndex = null; advanceToStep(root, 'customItems'); + } else if (selected === 'External') { + advanceToStep(root, 'externalList'); } else { advanceToStep(root, 'parent'); } break; } + case 'wizard-add-external-list-url': { + collectExternalListFromForm(root, wizardState); + if (!Array.isArray(wizardState.externalListUrls)) wizardState.externalListUrls = ['']; + wizardState.externalListUrls.push(''); + refreshModal(root); + break; + } + case 'wizard-open-custom-item-editor': { const openIndex = parseInt(el.dataset.itemIndex, 10); if (Number.isNaN(openIndex)) break; @@ -4297,6 +4416,14 @@ wizardState.sectionName = 'Custom Section'; } } + if (wizardState.step === 'externalList') { + const urls = (wizardState.externalListUrls || []).map((u) => String(u || '').trim()).filter(Boolean); + if (!urls.length) break; + wizardState.externalListUrls = urls; + if (!wizardState.sectionName?.trim()) { + wizardState.sectionName = 'External List'; + } + } if (wizardState.step === 'settings' && wizardState.sectionVisibility === 'seasonal') { if (!hasValidSeasonalDates(wizardState)) break; } diff --git a/injector.js b/injector.js index 5dc59f1..4c146cb 100644 --- a/injector.js +++ b/injector.js @@ -356,7 +356,7 @@ name: 'peopleCache', script: 'peopleCache.js', css: null, - dependencies: [], + dependencies: ['libraryCacheUtils', 'userHelper', 'moviesCache', 'seriesCache', 'indexedDBCache', 'apiHelper'], description: 'People cache for KefinTweaks' }, { @@ -366,12 +366,40 @@ dependencies: [], description: 'Studios cache for KefinTweaks' }, + { + name: 'libraryCacheUtils', + script: 'libraryCacheUtils.js', + css: null, + dependencies: [], + description: 'Shared strip/provider helpers for library caches' + }, { name: 'moviesCache', script: 'moviesCache.js', css: null, - dependencies: [], - description: 'Movies cache for KefinTweaks' + dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'], + description: 'Movies library cache for KefinTweaks' + }, + { + name: 'seriesCache', + script: 'seriesCache.js', + css: null, + dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'], + description: 'Series library cache for KefinTweaks' + }, + { + name: 'libraryCache', + script: 'libraryCache.js', + css: null, + dependencies: ['moviesCache', 'seriesCache'], + description: 'Library cache facade for KefinTweaks' + }, + { + name: 'externalList', + script: 'externalList.js', + css: null, + dependencies: ['libraryCache', 'libraryCacheUtils', 'apiHelper'], + description: 'MDBList external list matching for home sections' }, { name: 'skinManager', @@ -388,6 +416,13 @@ dependencies: ['dataHelper'], description: 'API helper functions for common Jellyfin operations' }, + { + name: 'sectionHelper', + script: 'sectionHelper.js', + css: null, + dependencies: ['apiHelper', 'cardBuilder', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'homeScreenConfig2', 'dataHelper'], + description: 'Section loading and progressive discovery resolve helpers' + }, { name: 'cardBuilder', script: 'cardBuilder.js', @@ -483,7 +518,7 @@ name: 'homeScreen', script: 'homeScreen3.js', css: 'homeScreen.css', - dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'], + dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'userHelper', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'libraryCacheUtils', 'externalList', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'sectionHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'], priority: true, // Load immediately after dependencies to reduce UI disruption description: 'Adds custom home screen sections' }, @@ -1088,6 +1123,32 @@ console.log(`[KefinTweaks Injector] Successfully loaded: ${scriptDef.name}`); } + + async function ensureKefinTweaksLoader(scriptRoot) { + if (window.KefinTweaksLoader) return window.KefinTweaksLoader; + const base = scriptRoot.endsWith('/') ? scriptRoot : scriptRoot + '/'; + const url = base + 'kefinTweaks-loader.js'; + await new Promise((resolve, reject) => { + if (document.querySelector('script[src="' + url + '"]')) { + const start = Date.now(); + const wait = () => { + if (window.KefinTweaksLoader) return resolve(); + if (Date.now() - start > 15000) return reject(new Error('Timed out waiting for KefinTweaksLoader')); + setTimeout(wait, 50); + }; + return wait(); + } + const script = document.createElement('script'); + script.src = url; + script.async = false; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load kefinTweaks-loader.js')); + document.head.appendChild(script); + }); + if (!window.KefinTweaksLoader) throw new Error('KefinTweaksLoader not available'); + return window.KefinTweaksLoader; + } + async function loadConfigurationJS() { const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'watchTogether-configuration', 'userManager-configuration', 'apiHelper']; for (const depName of configDependencyNames) { @@ -1106,182 +1167,98 @@ await loadCSS('configuration.css'); } - // Main initialization function + // Main initialization: live load plan (fills gaps vs baked preload) + stamp sync async function initialize() { - console.log(`[KefinTweaks Injector] Starting KefinTweaks initialization...`); - + console.log('[KefinTweaks Injector] Starting KefinTweaks initialization...'); + const scriptRoot = await getScriptRoot(); if (!scriptRoot) { console.log('[KefinTweaks Injector] kefinTweaksRoot is not configured. Please configure KefinTweaks using the installer before scripts can be loaded.'); return; } - - // Resolve Jellyfin major for version-gated scripts (also used by configuration UI) - const majorVersion = await getCurrentMajorServerVersion(); - syncCachedMajorOnApi(majorVersion); - console.log(`[KefinTweaks Injector] Jellyfin major version: ${majorVersion ?? 'unknown'}`); - // Check if KefinTweaks is enabled in config - const isKefinTweaksEnabled = window.KefinTweaksConfig?.enabled !== false; - - if (!isKefinTweaksEnabled) { - console.log('[KefinTweaks Injector] KefinTweaks is disabled in configuration. Only loading configuration UI requirements for admin access.'); - - try { - // Ensure configuration dependencies are loaded so the admin UI works - await loadConfigurationJS(); - console.log('[KefinTweaks Injector] Configuration script loaded (KefinTweaks is disabled)'); - } catch (error) { - console.warn('[KefinTweaks Injector] Failed to load configuration UI while disabled:', error); - } - return; - } - - // Validate configuration - if (!validateConfiguration()) { - console.error('[KefinTweaks Injector] Configuration validation failed. Aborting.'); - return; - } - - // Get enabled scripts compatible with this Jellyfin major - const enabledScripts = SCRIPT_DEFINITIONS.filter(script => - ENABLED_SCRIPTS[script.name] && isScriptCompatible(script, majorVersion) - ); - - // Step 1: Collect all dependencies from all enabled scripts - const allDependencyNames = new Set(); - for (const script of enabledScripts) { - const deps = collectAllDependencies(script); - deps.forEach(dep => allDependencyNames.add(dep)); - } - - // Step 2: Separate dependencies from non-dependencies - const dependencyScripts = SCRIPT_DEFINITIONS.filter(script => - allDependencyNames.has(script.name) && ENABLED_SCRIPTS[script.name] - ); - - const nonDependencyScripts = enabledScripts.filter(script => - !allDependencyNames.has(script.name) - ); - - // Step 2.5: Separate priority scripts from regular non-dependency scripts - const priorityScripts = nonDependencyScripts.filter(script => script.priority === true); - const regularScripts = nonDependencyScripts.filter(script => !script.priority); - - console.log(`[KefinTweaks Injector] Found ${dependencyScripts.length} dependency scripts:`, - dependencyScripts.map(s => s.name)); - console.log(`[KefinTweaks Injector] Found ${priorityScripts.length} priority scripts:`, - priorityScripts.map(s => s.name)); - console.log(`[KefinTweaks Injector] Found ${regularScripts.length} regular scripts:`, - regularScripts.map(s => s.name)); - + let Loader; try { - // Step 3: Load all dependencies first - console.log('[KefinTweaks Injector] Loading dependencies first...'); - console.log(`[KefinTweaks Injector] Dependency load order:`, dependencyScripts.map(s => s.name)); - - let dependencyLoadPromises = []; - dependencyLoadPromises.push(...dependencyScripts.map(script => loadScriptSync(script))); - await Promise.all(dependencyLoadPromises); - - // Step 4: Load priority scripts immediately after dependencies - if (priorityScripts.length > 0) { - console.log('[KefinTweaks Injector] Loading priority scripts...'); - console.log(`[KefinTweaks Injector] Priority load order:`, priorityScripts.map(s => s.name)); - let priorityLoadPromises = []; - priorityLoadPromises.push(...priorityScripts.map(script => loadScriptSync(script))); - await Promise.all(priorityLoadPromises); - } - - // Step 5: Load regular non-dependencies in parallel (their dependencies are already loaded) - let loadPromises = []; - console.log('[KefinTweaks Injector] Loading regular non-dependencies...'); - loadPromises.push(...regularScripts.map(script => loadScriptSync(script))); - await Promise.all(loadPromises); - - console.log('[KefinTweaks Injector] All scripts loaded successfully!'); - - // Always load configuration.js for admin UI (loads after other scripts) - console.log('[KefinTweaks Injector] Loading configuration script...'); - try { - await loadConfigurationJS(); - console.log('[KefinTweaks Injector] Configuration script loaded successfully'); - } catch (error) { - console.warn('[KefinTweaks Injector] Failed to load configuration script:', error); - } - - // Dispatch custom event when all scripts are loaded - const event = new CustomEvent('kefinTweaksLoaded', { - detail: { - loadedScripts: enabledScripts.map(s => s.name), - timestamp: new Date().toISOString() - } + Loader = await ensureKefinTweaksLoader(scriptRoot); + } catch (err) { + console.error('[KefinTweaks Injector] Failed to load kefinTweaks-loader.js:', err); + return; + } + + Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); + const majorVersion = await window.KefinTweaks.getJellyfinMajorVersion(); + console.log('[KefinTweaks Injector] Jellyfin major version:', majorVersion ?? 'unknown'); + + const config = window.KefinTweaksConfig || {}; + const root = Loader.normalizeRoot(config.kefinTweaksRoot || ''); + + try { + const plan = Loader.buildLoadPlan(config, majorVersion, { + root, + urlSuffix, + configOnly: config.enabled === false }); - document.dispatchEvent(event); - + console.log('[KefinTweaks Injector] Applying ordered assets:', plan.assets.length, 'stamp=', plan.stamp); + // Dedupe against tags already injected by baked KefinTweaks-injector + Loader.applyAssetsToDocument(plan.assets); + document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', { + detail: { + loadedScripts: plan.scriptNames, + stamp: plan.stamp, + timestamp: new Date().toISOString(), + live: true + } + })); + console.log('[KefinTweaks Injector] Live ordered inject complete'); + + if (typeof Loader.syncKefinTweaksInjector === 'function') { + const syncResult = await Loader.syncKefinTweaksInjector(plan); + if (syncResult?.synced) { + console.log('[KefinTweaks Injector] Baked KefinTweaks-injector synced to stamp:', syncResult.stamp); + } else if (syncResult?.reason && syncResult.reason !== 'stamp match' && syncResult.reason !== 'not admin' && syncResult.reason !== 'not logged in') { + console.warn('[KefinTweaks Injector] Injector sync skipped:', syncResult.reason); + } else { + console.log('[KefinTweaks Injector] Injector sync:', syncResult?.reason || 'done'); + } + } } catch (error) { console.error('[KefinTweaks Injector] Error during initialization:', error); } } - + + // Utility functions for debugging and configuration - window.KefinTweaks = { + // Populated in initialize via KefinTweaksLoader.ensureKefinTweaksApi; stub for early callers + window.KefinTweaks = window.KefinTweaks || { _jellyfinMajorVersion: null, - - // Get current configuration - getConfig: () => ({ ...ENABLED_SCRIPTS }), - - // Get script definitions - getScripts: () => [...SCRIPT_DEFINITIONS], - - getMajorServerVersion, - getCurrentMajorServerVersion, - getJellyfinMajorVersion: () => cachedJellyfinMajorVersion, - - isScriptCompatible: (scriptNameOrDef) => { - const scriptDef = typeof scriptNameOrDef === 'string' - ? SCRIPT_DEFINITIONS.find(s => s.name === scriptNameOrDef) - : scriptNameOrDef; - if (!scriptDef) return true; - return isScriptCompatible(scriptDef, cachedJellyfinMajorVersion); - }, - - // Check if a script is loaded - isScriptLoaded: (scriptName) => { - const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName); - if (!scriptDef) return false; - - const scriptElement = document.querySelector(`script[src*="${scriptDef.script}"]`); - return !!scriptElement; - }, - - // Reload all scripts (useful for development) - reload: () => { - console.log('[KefinTweaks Injector] Reloading all scripts...'); - initialize(); - }, - - // Load a specific script (useful for dynamic loading) - loadScript: async (scriptName) => { - const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName); - if (!scriptDef) { - throw new Error(`Script '${scriptName}' not found`); + parseMajorVersion: function (version) { + if (!version || typeof version !== 'string') return null; + const versionParts = version.split('.'); + if (versionParts[0] !== '10') { + const majorVersion = parseInt(versionParts[0], 10); + return Number.isNaN(majorVersion) ? null : majorVersion; } - - if (!ENABLED_SCRIPTS[scriptName]) { - throw new Error(`Script '${scriptName}' is disabled in configuration`); + if (versionParts.length >= 2) { + const majorVersion = parseInt(versionParts[1], 10); + if (!Number.isNaN(majorVersion)) return majorVersion; } - - const major = await getCurrentMajorServerVersion(); - syncCachedMajorOnApi(major); - if (!isScriptCompatible(scriptDef, major)) { - throw new Error(`Script '${scriptName}' is not compatible with this Jellyfin version`); + return null; + }, + getJellyfinMajorVersion: async function () { + if (window.KefinTweaksLoader) { + window.KefinTweaksLoader.ensureKefinTweaksApi(); + return window.KefinTweaks.getJellyfinMajorVersion(); } - - await loadScriptWithDependencies(scriptDef); - } + return this._jellyfinMajorVersion; + }, + getScripts: function () { + return (window.KefinTweaksLoader && window.KefinTweaksLoader.SCRIPT_DEFINITIONS) + ? window.KefinTweaksLoader.SCRIPT_DEFINITIONS.slice() + : (typeof SCRIPT_DEFINITIONS !== 'undefined' ? SCRIPT_DEFINITIONS.slice() : []); + }, + reload: function () { initialize(); } }; - + // Check if user is admin (with timeout for login) async function checkAdminWithTimeout(maxWaitMs = 5000) { const startTime = Date.now(); diff --git a/kefinTweaks-plugin.js b/kefinTweaks-plugin.js index a488f4e..6040523 100644 --- a/kefinTweaks-plugin.js +++ b/kefinTweaks-plugin.js @@ -186,7 +186,7 @@ }); } - // Save configuration to JavaScript Injector + // Save configuration to JavaScript Injector (Config + KefinTweaks-injector preload) async function saveConfigToJavaScriptInjector(config) { try { const pluginId = await findJavaScriptInjectorPlugin(); @@ -202,19 +202,53 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; - const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex( - script => script.Name === 'KefinTweaks-Config' - ); + let injectorScriptContent = null; + try { + const rootRaw = config.kefinTweaksRoot || ''; + const root = rootRaw.endsWith('/') ? rootRaw : rootRaw + '/'; + if (root && root !== '/') { + await loadKefinTweaksLoaderFromRoot(root); + const Loader = window.KefinTweaksLoader; + if (Loader) { + Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); + let major = null; + try { + major = await window.KefinTweaks.getJellyfinMajorVersion(); + } catch (e) { + console.warn('[KefinTweaks Installer] Could not resolve Jellyfin major for injector bake:', e); + } + const plan = Loader.buildLoadPlan(config, major, { + root, + configOnly: config.enabled === false + }); + injectorScriptContent = Loader.buildInjectorScript(plan); + console.log('[KefinTweaks Installer] Built KefinTweaks-injector with', plan.assets.length, 'assets'); + } + } + } catch (e) { + console.warn('[KefinTweaks Installer] Failed to build KefinTweaks-injector:', e); + } - if (existingScriptIndex !== -1) { - injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent; + if (window.KefinTweaksLoader) { + injectorConfig.CustomJavaScripts = window.KefinTweaksLoader.upsertInjectorEntries( + injectorConfig.CustomJavaScripts, + { configScriptContent: scriptContent, injectorScriptContent } + ); } else { - injectorConfig.CustomJavaScripts.push({ - Name: 'KefinTweaks-Config', - Script: scriptContent, - Enabled: true, - RequiresAuthentication: false - }); + const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex( + script => script.Name === 'KefinTweaks-Config' + ); + if (existingScriptIndex !== -1) { + injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent; + injectorConfig.CustomJavaScripts[existingScriptIndex].Enabled = true; + } else { + injectorConfig.CustomJavaScripts.push({ + Name: 'KefinTweaks-Config', + Script: scriptContent, + Enabled: true, + RequiresAuthentication: false + }); + } } const server = ApiClient._serverAddress; @@ -239,6 +273,144 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } } + function loadKefinTweaksLoaderFromRoot(root) { + const normalized = root.endsWith('/') ? root : root + '/'; + const url = `${normalized}scripts/kefinTweaks-loader.js`; + if (window.KefinTweaksLoader) return Promise.resolve(window.KefinTweaksLoader); + return new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${url}"]`); + if (existing) { + const start = Date.now(); + const wait = () => { + if (window.KefinTweaksLoader) return resolve(window.KefinTweaksLoader); + if (Date.now() - start > 15000) return reject(new Error('Timed out waiting for KefinTweaksLoader')); + setTimeout(wait, 50); + }; + return wait(); + } + const script = document.createElement('script'); + script.src = url; + script.async = false; + script.onload = () => resolve(window.KefinTweaksLoader); + script.onerror = () => reject(new Error('Failed to load ' + url)); + document.head.appendChild(script); + }); + } + + function isLoggedInForInjectorWrite() { + try { + return !!(window.ApiClient + && window.ApiClient._loggedIn + && typeof window.ApiClient.accessToken === 'function' + && window.ApiClient.accessToken() + && window.ApiClient._serverAddress); + } catch (e) { + return false; + } + } + + async function waitForLoginForInjectorWrite(maxWaitMs = 10000, checkInterval = 250) { + if (isLoggedInForInjectorWrite()) return true; + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + await new Promise((r) => setTimeout(r, checkInterval)); + if (isLoggedInForInjectorWrite()) return true; + } + return false; + } + + /** + * Create/update KefinTweaks-injector (and Config) in JS Injector when missing. + * @returns {Promise<{ ok: boolean, plan?: object, reason?: string }>} + */ + async function ensureKefinTweaksInjectorEntry(config) { + const rootRaw = config?.kefinTweaksRoot || ''; + if (!rootRaw) { + return { ok: false, reason: 'kefinTweaksRoot not configured' }; + } + + if (!isLoggedInForInjectorWrite()) { + return { ok: false, reason: 'not logged in' }; + } + + try { + const root = rootRaw.endsWith('/') ? rootRaw : rootRaw + '/'; + await loadKefinTweaksLoaderFromRoot(root); + const Loader = window.KefinTweaksLoader; + if (!Loader) { + return { ok: false, reason: 'KefinTweaksLoader unavailable' }; + } + + Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); + let major = null; + try { + major = await window.KefinTweaks.getJellyfinMajorVersion(); + } catch (e) { + console.warn('[KefinTweaks Installer] Could not resolve Jellyfin major for auto-create:', e); + } + + const plan = Loader.buildLoadPlan(config, major, { + root, + configOnly: config.enabled === false + }); + const injectorScriptContent = Loader.buildInjectorScript(plan); + const configScriptContent = `// KefinTweaks Configuration +// This file is automatically generated by KefinTweaks Installer +// Do not edit manually unless you know what you're doing + +window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; + + const pluginId = await findJavaScriptInjectorPlugin(); + if (!pluginId) { + return { ok: false, reason: 'JavaScript Injector plugin not found' }; + } + const injectorConfig = await getJavaScriptInjectorConfig(pluginId); + if (!injectorConfig) { + return { ok: false, reason: 'Could not read JS Injector configuration' }; + } + if (!injectorConfig.CustomJavaScripts) { + injectorConfig.CustomJavaScripts = []; + } + + injectorConfig.CustomJavaScripts = Loader.upsertInjectorEntries( + injectorConfig.CustomJavaScripts, + { configScriptContent, injectorScriptContent } + ); + + const server = ApiClient._serverAddress; + const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { + method: 'POST', + headers: { + 'Authorization': getAuthHeader(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(injectorConfig) + }); + + if (!response.ok) { + return { + ok: false, + reason: `POST failed HTTP ${response.status}: ${response.statusText}` + }; + } + + console.log('[KefinTweaks Installer] Auto-created KefinTweaks-injector with', plan.assets.length, 'assets'); + return { ok: true, plan }; + } catch (err) { + console.warn('[KefinTweaks Installer] ensureKefinTweaksInjectorEntry failed:', err); + return { ok: false, reason: err?.message || String(err) }; + } + } + + function hasEnabledKefinInjectorEntry(customJavaScripts) { + if (window.KefinTweaksLoader?.hasEnabledInjectorEntry) { + return window.KefinTweaksLoader.hasEnabledInjectorEntry(customJavaScripts); + } + return (customJavaScripts || []).some( + (s) => s.Name === 'KefinTweaks-injector' && s.Enabled !== false + ); + } + const LATEST_RELEASE_NAME = 'Latest'; const DEVELOPMENT_NAME = 'Development'; const EXPERIMENTAL_NAME = 'Experimental'; @@ -805,7 +977,20 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; versionDisplay = sourceInfo.version; } - // Load injector.js from the configured root + // Always load live injector.js for plan apply + stamp sync (baked preload may be stale) + let hasPreload = false; + try { + const pluginId = await findJavaScriptInjectorPlugin(); + const injCfg = await getJavaScriptInjectorConfig(pluginId); + hasPreload = (injCfg.CustomJavaScripts || []).some( + (s) => s.Name === 'KefinTweaks-injector' && s.Enabled !== false + ); + } catch (e) { + console.warn('[KefinTweaks Installer] Could not verify KefinTweaks-injector after save:', e); + } + if (hasPreload) { + console.log('[KefinTweaks Installer] KefinTweaks-injector saved; still loading injector.js for live plan + sync'); + } await loadInjectorFromRoot(kefinTweaksRoot); // Show success modal @@ -885,6 +1070,17 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; console.log('[KefinTweaks Installer] Renamed KefinTweaks-Config to backup and disabled it'); } + // Also disable/rename KefinTweaks-injector preload entry + const injectorScriptIndex = injectorConfig.CustomJavaScripts.findIndex( + script => script.Name === 'KefinTweaks-injector' + ); + if (injectorScriptIndex !== -1) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + injectorConfig.CustomJavaScripts[injectorScriptIndex].Name = `KefinTweaks-injector-Backup-${timestamp}`; + injectorConfig.CustomJavaScripts[injectorScriptIndex].Enabled = false; + console.log('[KefinTweaks Installer] Renamed KefinTweaks-injector to backup and disabled it'); + } + // Step 2: Find and disable installer script // Look for script containing kefinTweaks-plugin.js (flexible pattern to match script.src*kefinTweaks-plugin.js) const installerScriptIndex = injectorConfig.CustomJavaScripts.findIndex( @@ -1625,7 +1821,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; }); } - // Check if config exists and load injector.js if ready + // Check if config exists; auto-create KefinTweaks-injector when missing; always load live injector.js async function checkAndLoadInjector() { try { const config = await getKefinTweaksConfig(); @@ -1633,12 +1829,53 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; // Before injector/utils: hide fallback page chrome for custom routes injectCustomPageStyles(config); - - if (root && root !== '') { - await loadInjectorFromRoot(root); - } else { + + if (!root || root === '') { console.log('[KefinTweaks Installer] kefinTweaksRoot not configured. Please configure via the Plugins page.'); + return; } + + // Plugin config GET/POST needs a session; wait briefly so admin first-load can auto-create + await waitForLoginForInjectorWrite(); + + let injectorConfig = null; + try { + const pluginId = await findJavaScriptInjectorPlugin(); + if (pluginId) { + injectorConfig = await getJavaScriptInjectorConfig(pluginId); + } + } catch (e) { + console.warn('[KefinTweaks Installer] Could not read JS Injector config:', e); + } + + const hasPreload = !!(injectorConfig && hasEnabledKefinInjectorEntry(injectorConfig.CustomJavaScripts)); + if (hasPreload) { + console.log('[KefinTweaks Installer] KefinTweaks-injector present; still loading injector.js for live plan + sync'); + } else { + // Entry missing: try to create + apply assets now (admin/logged-in) + const ensured = await ensureKefinTweaksInjectorEntry(config); + if (ensured.ok && ensured.plan) { + window.KefinTweaksScriptsPreloaded = true; + if (window.KefinTweaksLoader) { + window.KefinTweaksLoader.ensureKefinTweaksApi(window.KefinTweaksLoader.SCRIPT_DEFINITIONS); + window.KefinTweaksLoader.applyAssetsToDocument(ensured.plan.assets); + } + try { + document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', { + detail: { + stamp: ensured.plan.stamp, + timestamp: new Date().toISOString(), + autoCreated: true + } + })); + } catch (e) { /* ignore */ } + console.log('[KefinTweaks Installer] Applied KefinTweaks-injector assets after auto-create'); + } else { + console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js'); + } + } + + await loadInjectorFromRoot(root); } catch (error) { console.error('[KefinTweaks Installer] Error checking config:', error); } diff --git a/kefinTweaks.minimal.js b/kefinTweaks.minimal.js index 5f8ca4b..82045f5 100644 --- a/kefinTweaks.minimal.js +++ b/kefinTweaks.minimal.js @@ -352,7 +352,8 @@ window.KefinTweaksConfig = { }, "starringActorRecentlyWatched": { "enabled": true, - "name": "Starring [Actor] because you recently watched [Movie]", + "name": "Starring {Person}", + "caption": "because you recently watched {Title}", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", @@ -360,7 +361,8 @@ window.KefinTweaksConfig = { }, "directedByDirectorRecentlyWatched": { "enabled": true, - "name": "Directed by [Director] because you recently watched [Movie]", + "name": "Directed by {Person}", + "caption": "because you recently watched {Title}", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", @@ -368,7 +370,8 @@ window.KefinTweaksConfig = { }, "writtenByWriterRecentlyWatched": { "enabled": true, - "name": "Written by [Writer] because you recently watched [Movie]", + "name": "Written by {Person}", + "caption": "because you recently watched {Title}", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", diff --git a/pages/movieLut.js b/pages/movieLut.js index de06c0b..406da21 100644 --- a/pages/movieLut.js +++ b/pages/movieLut.js @@ -151,7 +151,10 @@ function actorFilmography(actor) { const items = actor.items || actor.actorItems || []; - return items.filter((m) => m && m.Id); + // PeopleCache may return raw movie Id strings; normalize to { Id } for gatherThreeTrickplayMovies + return items + .map((m) => (typeof m === 'string' || typeof m === 'number' ? { Id: String(m) } : m)) + .filter((m) => m && m.Id); } async function fetchFullItem(apiClient, itemId) { diff --git a/scripts/HomeScreenSection.ts b/scripts/HomeScreenSection.ts index b2a21f8..7d5159b 100644 --- a/scripts/HomeScreenSection.ts +++ b/scripts/HomeScreenSection.ts @@ -32,6 +32,10 @@ interface HomeScreenSection { userConfigurable?: boolean; // Whether users can toggle/reorder this section useGaplessCards?: boolean; // When true, remove gaps between cards (row or grid) + // Loads items from an external URL and loads matches from that list from your Jellyfin library + // Currently supports: mdblist.com only + externalListUrls?: string[]; + // Multi-Query (only used when queries.length > 1) sortBy?: string; // Field to sort merged results by (e.g., 'DatePlayed', 'DateCreated') sortOrder?: "Ascending" | "Descending"; // Sort direction for merged results @@ -62,7 +66,13 @@ interface HomeScreenSection { isCustom?: boolean; /** How a discovery section resolves dynamically: Person, Genre, Studio, Similar, Collection, … */ discoveryType?: string; + discoveryItemType?: string; + discoveryPersonType?: string; source?: string; + + /** Pool query used to resolve a dynamic discovery entity (optional on non-discovery sections). */ + discoverySourceQuery?: Query; + /** Runtime stamp for custom discovery paging (1..N per custom group); not a persisted admin field. */ pageNumber?: number; diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js index 5001d06..623cd7a 100644 --- a/scripts/apiHelper.js +++ b/scripts/apiHelper.js @@ -1105,17 +1105,12 @@ } try { - let dataPromise = cache[methodName](); + let dataPromise = Promise.resolve(cache[methodName](options)); dataPromise = dataPromise.then(data => { if (!Array.isArray(data)) { data = []; } - // Filter studios/networks with no thumb - if (cacheName === 'StudiosCache' && window.KefinHomeScreem?.getConfig()?.HOME_SETTINGS?.ensureThumbsForPopularTVNetworks === true) { - data = data.filter(item => item.ImageTags?.Thumb); - } - // Apply options (sorting, limits, etc.) if (options.SortBy === 'Random') { data = data.sort(() => Math.random() - 0.5); diff --git a/scripts/breadcrumbNav.css b/scripts/breadcrumbNav.css index c02b703..3730c02 100644 --- a/scripts/breadcrumbNav.css +++ b/scripts/breadcrumbNav.css @@ -3,6 +3,12 @@ display: none; } +.MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: 1fr auto; +} + @media (min-width: 768px) { .kefinTweaks-breadcrumbs-wrapper { display: block; @@ -15,6 +21,11 @@ border-radius: 0.5em; } + .MuiToolbar-root .kefinTweaks-breadcrumbs-wrapper:hover { + background: transparent; + border-radius: 0; + } + .kefinTweaks-breadcrumbs { color: rgba(255, 255, 255, 0.8); font-size: 1.17em; @@ -27,6 +38,16 @@ display: inline-block; } + .MuiToolbar-root .kefinTweaks-breadcrumbs, + .kefinTweaks-breadcrumbs-wrapper--modern .kefinTweaks-breadcrumbs { + font-size: 1em; + padding: 0px 12px 8px; + } + + .MuiToolbar-root .kefinTweaks-popover { + margin-top: 20px; + } + .kefinTweaks-popover-container { position: absolute; top: 100%; diff --git a/scripts/breadcrumbs.js b/scripts/breadcrumbs.js index f5747d5..e3001db 100644 --- a/scripts/breadcrumbs.js +++ b/scripts/breadcrumbs.js @@ -471,15 +471,28 @@ } // DOM manipulation + function isModernUI() { + return !!document.querySelector('.MuiBox-root'); + } + + function resolveBreadcrumbMount() { + if (isModernUI()) { + return document.querySelector('.MuiToolbar-root'); + } + return document.querySelector('.skinHeader .headerLeft'); + } + function createBreadcrumbContainer() { if (breadcrumbContainer) { return breadcrumbContainer; } - - // Find the header left container - const headerLeft = document.querySelector('.skinHeader .headerLeft'); - if (!headerLeft) { - error('Could not find .skinHeader .headerLeft element'); + + const isModern = isModernUI(); + const mount = resolveBreadcrumbMount(); + if (!mount) { + error(isModern + ? 'Could not find .MuiToolbar-root for breadcrumbs' + : 'Could not find .skinHeader .headerLeft element'); return null; } @@ -487,6 +500,9 @@ const wrapper = document.createElement('div'); wrapper.id = 'kefinTweaks-breadcrumbs-wrapper'; wrapper.className = 'kefinTweaks-breadcrumbs-wrapper'; + if (isModern) { + wrapper.classList.add('kefinTweaks-breadcrumbs-wrapper--modern'); + } wrapper.style.cssText = ` display: none; `; @@ -509,11 +525,36 @@ wrapper.appendChild(breadcrumbContainer); wrapper.appendChild(popoverContainer); - headerLeft.appendChild(wrapper); + mount.appendChild(wrapper); - log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft'); + log(isModern + ? 'Created breadcrumb container inside .MuiToolbar-root' + : 'Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft'); return breadcrumbContainer; } + + function ensureBreadcrumbContainer(retries = 20) { + const existing = createBreadcrumbContainer(); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve) => { + let attempts = 0; + const tryMount = () => { + const container = createBreadcrumbContainer(); + if (container) { + resolve(container); + return; + } + attempts += 1; + if (attempts >= retries) { + resolve(null); + return; + } + setTimeout(tryMount, 100); + }; + tryMount(); + }); + } function showBreadcrumbs() { const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper'); @@ -864,9 +905,13 @@ // Create breadcrumbs from scratch async function createBreadcrumbsFromScratch(targetStructure) { - // Create container if it doesn't exist + // Create container if it doesn't exist (retry briefly on ModernUI if toolbar not ready) if (!breadcrumbContainer) { - breadcrumbContainer = createBreadcrumbContainer(); + breadcrumbContainer = await ensureBreadcrumbContainer(); + } + if (!breadcrumbContainer) { + error('Breadcrumb container unavailable'); + return; } // Build all elements first diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index a90e61d..4852b18 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -968,8 +968,25 @@ } } -.skeleton-card { - margin-bottom: 45px !important; +.skeleton-text-line { + display: inline-block; + height: 1em; + max-width: 100%; + border-radius: 4px; + background: rgba(255, 255, 255, 0.12); + animation: skeleton-pulse 1.5s ease-in-out infinite; + vertical-align: middle; +} + +.skeleton-section-title { + width: 50%; + min-width: 8rem; +} + +.skeleton-section-caption { + width: 35%; + min-width: 6rem; + margin-top: 0.15em; } /* Tagline always 1 line - Movie-specific override kept for any edge cases */ @@ -1293,7 +1310,7 @@ } .sectionTitle.sectionCaption { - font-size: 0.9em; + font-size: 0.9em !important; text-align: left; grid-column: 1 / -1; grid-row: 2; @@ -2035,6 +2052,18 @@ border: none !important; } +/* Skeletons need a visible padder even when Logo/Clear Art/Disc force transparent cards */ +.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardPadder, +.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardScalable, +.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardPadder, +.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardScalable, +.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardPadder, +.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardScalable, +.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardPadder, +.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardScalable { + background: rgba(255, 255, 255, 0.12) !important; +} + .emby-scroller-container[data-card-format="Logo"] .cardImageContainer, .emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer, .emby-scroller-container[data-section-id="liveTv-channels"] .cardImageContainer { diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 7ab2963..dff3f49 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -810,6 +810,16 @@ function postProcessItems(sectionConfig, itemsData) { let processed = itemsData?.Items || itemsData || []; + //Check if imageTypes is used in the query to ensure only items with these image types are included + const imageTypes = sectionConfig.queries?.[0]?.queryOptions?.ImageTypes; + if (imageTypes && Array.isArray(imageTypes)) { + // Filter out items which do not have any of the image types + processed = processed.filter(item => { + const itemImageTypes = imageTypes.filter(imageType => item.ImageTags?.[imageType] || (imageType === 'Backdrop' && item.BackdropImageTags?.[0])); + return itemImageTypes.length > 0; + }); + } + // When ParentId is on the query (e.g. Popular Genres per library), assign it to each item for genre card links const queryParentId = sectionConfig.queries?.[0]?.ParentId; if (queryParentId && Array.isArray(processed)) { @@ -1348,6 +1358,8 @@ if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') { const spotlightSettings = { ...(sectionConfig.spotlightConfig || {}) }; if (activeViewMoreUrl) spotlightSettings.viewMoreUrl = activeViewMoreUrl; + spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true; + spotlightSettings.caption = sectionConfig.caption; skeleton = createSkeletonSpotlightSection(displayTitle, spotlightSettings); } else { skeleton = createProgressivelyEnhancedScrollableContainer( @@ -1744,11 +1756,30 @@ }); } + function updateCardWatchlistButtons(itemId, userData) { + if (!itemId || !userData || !('Likes' in userData)) return; + + const isLiked = userData.Likes === true; + const likedHelper = window.KefinWatchlistLikedIds; + if (likedHelper?.set) { + likedHelper.set(itemId, isLiked); + } + + const escapedId = typeof CSS !== 'undefined' && CSS.escape + ? CSS.escape(itemId) + : itemId.replace(/["\\]/g, '\\$&'); + document.querySelectorAll(`.watchlist-button[data-id="${escapedId}"]`).forEach((button) => { + button.dataset.active = isLiked ? 'true' : 'false'; + button.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist'; + }); + } + function applyUserDataEntriesToCards(entries, userId) { if (!isPrimaryUserDataChange(userId)) return; entries.forEach((entry) => { if (entry?.ItemId) { updateCardResumeAttributes(entry.ItemId, entry); + updateCardWatchlistButtons(entry.ItemId, entry); } }); } @@ -2018,7 +2049,7 @@ // If the section has items, it is a static query const isStaticQuery = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0; - const renderCachedData = isStaticQuery || (!section.result?.isStale && !isRandomQuerySort) || (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight')); + const renderCachedData = (isStaticQuery || (!section.result?.isStale && !isRandomQuerySort) || (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'))) && dataItems.length > 0; // Paint cached cards when valid, or when stale display is opted in if (renderCachedData) { @@ -2070,6 +2101,8 @@ if (getActiveViewMoreUrl(sectionConfig)) { spotlightSettings.viewMoreUrl = getActiveViewMoreUrl(sectionConfig); } + spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true; + spotlightSettings.caption = sectionConfig.caption; sectionElement = createSkeletonSpotlightSection(getSectionTitleForMultiQuery(sectionConfig), spotlightSettings); } else { sectionElement = createProgressivelyEnhancedScrollableContainer(getSectionTitleForMultiQuery(sectionConfig), getActiveViewMoreUrl(sectionConfig), finalCardFormat, sectionConfig.overflowCard, sectionConfig); @@ -2118,7 +2151,15 @@ } } - container.appendChild(fragment); + // Append one section per frame so scroll/compositor get frames between paints. + // Build already happened in the fragment; only the container append is paced. + const sectionNodes = Array.from(fragment.childNodes); + for (let i = 0; i < sectionNodes.length; i++) { + container.appendChild(sectionNodes[i]); + if (i + 1 < sectionNodes.length) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + } syncAttachedGridTilesLayouts(container); // Update the scroll buttons for all scrollable containers @@ -2527,16 +2568,276 @@ if (!canvas || !blurhashString) return false; const w = canvas.width || 20; const h = canvas.height || 20; - const pixels = blurhashDecode(blurhashString, w, h); + const cacheKey = blurhashCacheKey(blurhashString, w, h); + let pixels = blurhashCacheGet(cacheKey); + if (!pixels) { + pixels = blurhashDecode(blurhashString, w, h); + if (pixels) blurhashCacheSet(cacheKey, pixels); + } if (!pixels) return false; + return applyBlurhashPixelsToCanvas(canvas, pixels); + } + + // --- Deferred blurhash: cache + worker + near-viewport filler --- + const BLURHASH_CACHE_MAX = 200; + const blurhashPixelCache = new Map(); + let blurhashFillObserver = null; + let blurhashWorker = null; + let blurhashWorkerReqId = 0; + const blurhashWorkerPending = new Map(); + const blurhashIdleQueue = []; + let blurhashIdleScheduled = false; + + function blurhashCacheKey(blurhash, width, height) { + return blurhash + '|' + width + 'x' + height; + } + + function blurhashCacheGet(key) { + if (!blurhashPixelCache.has(key)) return null; + const val = blurhashPixelCache.get(key); + blurhashPixelCache.delete(key); + blurhashPixelCache.set(key, val); + return val; + } + + function blurhashCacheSet(key, pixels) { + if (blurhashPixelCache.has(key)) blurhashPixelCache.delete(key); + blurhashPixelCache.set(key, pixels); + while (blurhashPixelCache.size > BLURHASH_CACHE_MAX) { + const oldest = blurhashPixelCache.keys().next().value; + blurhashPixelCache.delete(oldest); + } + } + + function shouldSkipBlurhashDecode() { + try { + if (navigator.connection && navigator.connection.saveData === true) return true; + } catch (e) { /* ignore */ } + try { + if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true; + } catch (e) { /* ignore */ } + return false; + } + + function getBlurhashFillRootMargin() { + return isMobileLayout() ? '120px' : '200px'; + } + + /** + * Put pixels on canvas and show blurhash until the real image loads. + * @returns {boolean} + */ + function applyBlurhashPixelsToCanvas(canvas, pixels) { + if (!canvas || !pixels || !canvas.isConnected) return false; + const w = canvas.width || 20; + const h = canvas.height || 20; const ctx = canvas.getContext('2d'); if (!ctx) return false; + const imageData = ctx.createImageData(w, h); imageData.data.set(pixels); ctx.putImageData(imageData, 0, 0); + canvas.removeAttribute('data-blurhash-pending'); + + const cardImageContainer = canvas.nextElementSibling; + const imageAlreadyLoaded = cardImageContainer + && cardImageContainer.classList + && cardImageContainer.classList.contains('lazy-loaded'); + + if (imageAlreadyLoaded) { + canvas.classList.add('lazy-hidden'); + return true; + } + + canvas.classList.remove('lazy-hidden'); + if (cardImageContainer && cardImageContainer.classList.contains('cardImageContainer')) { + cardImageContainer.classList.add('lazy-hidden'); + } return true; } + function getBlurhashWorkerSource() { + return [ + 'const BLURHASH_B83 = ' + JSON.stringify(BLURHASH_B83) + ';', + blurhashDecode83.toString(), + blurhashSignPow.toString(), + blurhashLinearToSrgb.toString(), + blurhashSrgbToLinear.toString(), + blurhashDecode.toString(), + 'self.onmessage = function(e) {', + ' var data = e.data || {};', + ' var id = data.id;', + ' try {', + ' var pixels = blurhashDecode(data.blurhash, data.width || 20, data.height || 20);', + ' if (!pixels) { self.postMessage({ id: id, ok: false }); return; }', + ' self.postMessage({ id: id, ok: true, pixels: pixels.buffer }, [pixels.buffer]);', + ' } catch (err) {', + ' self.postMessage({ id: id, ok: false, error: String(err && err.message || err) });', + ' }', + '};' + ].join('\n'); + } + + function ensureBlurhashWorker() { + if (blurhashWorker) return blurhashWorker; + if (typeof Worker === 'undefined') return null; + try { + const blob = new Blob([getBlurhashWorkerSource()], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + const worker = new Worker(url); + worker.onmessage = (e) => { + const data = e.data || {}; + const pending = blurhashWorkerPending.get(data.id); + blurhashWorkerPending.delete(data.id); + if (!pending) return; + if (!data.ok || !data.pixels) { + pending.reject(new Error(data.error || 'Blurhash worker decode failed')); + return; + } + pending.resolve(new Uint8ClampedArray(data.pixels)); + }; + worker.onerror = (err) => { + console.warn('[KefinTweaks CardBuilder] Blurhash worker error:', err); + blurhashWorkerPending.forEach((p) => p.reject(err)); + blurhashWorkerPending.clear(); + try { worker.terminate(); } catch (e) { /* ignore */ } + try { URL.revokeObjectURL(url); } catch (e) { /* ignore */ } + blurhashWorker = null; + }; + blurhashWorker = worker; + return worker; + } catch (e) { + console.warn('[KefinTweaks CardBuilder] Could not create blurhash worker:', e); + return null; + } + } + + function decodeBlurhashInWorker(blurhash, width, height) { + return new Promise((resolve, reject) => { + const worker = ensureBlurhashWorker(); + if (!worker) { + reject(new Error('Blurhash worker unavailable')); + return; + } + const id = ++blurhashWorkerReqId; + blurhashWorkerPending.set(id, { resolve, reject }); + try { + worker.postMessage({ id, blurhash, width, height }); + } catch (e) { + blurhashWorkerPending.delete(id); + reject(e); + } + }); + } + + function scheduleIdleBlurhashDrain() { + if (blurhashIdleScheduled) return; + blurhashIdleScheduled = true; + const run = (deadline) => { + blurhashIdleScheduled = false; + let n = 0; + while (blurhashIdleQueue.length && n < 4) { + if (deadline && typeof deadline.timeRemaining === 'function' && deadline.timeRemaining() <= 0 && n > 0) { + break; + } + const job = blurhashIdleQueue.shift(); + n++; + if (!job || !job.canvas || !job.canvas.isConnected) continue; + if (!job.canvas.hasAttribute('data-blurhash-pending')) continue; + const cacheKey = blurhashCacheKey(job.blurhash, job.w, job.h); + let pixels = blurhashCacheGet(cacheKey); + if (!pixels) { + pixels = blurhashDecode(job.blurhash, job.w, job.h); + if (pixels) blurhashCacheSet(cacheKey, pixels); + } + if (pixels) applyBlurhashPixelsToCanvas(job.canvas, pixels); + else job.canvas.removeAttribute('data-blurhash-pending'); + } + if (blurhashIdleQueue.length) scheduleIdleBlurhashDrain(); + }; + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(run, { timeout: 500 }); + } else { + requestAnimationFrame(() => run({ timeRemaining: () => 5 })); + } + } + + function enqueueIdleBlurhashDecode(canvas, blurhash, width, height) { + blurhashIdleQueue.push({ canvas, blurhash, w: width, h: height }); + scheduleIdleBlurhashDrain(); + } + + async function fillBlurhashCanvas(canvas) { + if (!canvas || !canvas.isConnected) return; + const blurhash = canvas.getAttribute('data-blurhash-pending'); + if (!blurhash) return; + if (shouldSkipBlurhashDecode()) { + canvas.removeAttribute('data-blurhash-pending'); + canvas.classList.add('lazy-hidden'); + return; + } + + const w = canvas.width || 20; + const h = canvas.height || 20; + const cacheKey = blurhashCacheKey(blurhash, w, h); + const cached = blurhashCacheGet(cacheKey); + if (cached) { + applyBlurhashPixelsToCanvas(canvas, cached); + return; + } + + try { + const pixels = await decodeBlurhashInWorker(blurhash, w, h); + if (!canvas.isConnected || canvas.getAttribute('data-blurhash-pending') !== blurhash) return; + blurhashCacheSet(cacheKey, pixels); + applyBlurhashPixelsToCanvas(canvas, pixels); + } catch (e) { + enqueueIdleBlurhashDecode(canvas, blurhash, w, h); + } + } + + function observeBlurhashCanvas(canvas) { + if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return; + if (shouldSkipBlurhashDecode()) { + canvas.removeAttribute('data-blurhash-pending'); + canvas.classList.add('lazy-hidden'); + return; + } + initBlurhashFillObserver(); + if (blurhashFillObserver) { + blurhashFillObserver.observe(canvas); + } else { + fillBlurhashCanvas(canvas); + } + } + + function initBlurhashFillObserver() { + if (blurhashFillObserver) return; + if (typeof IntersectionObserver === 'undefined') return; + + blurhashFillObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const canvas = entry.target; + blurhashFillObserver.unobserve(canvas); + fillBlurhashCanvas(canvas); + }); + }, { + threshold: 0, + rootMargin: getBlurhashFillRootMargin() + }); + } + + function observePendingBlurhashInNode(node) { + if (!node || node.nodeType !== 1) return; + if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) { + observeBlurhashCanvas(node); + } + if (node.querySelectorAll) { + node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas); + } + } + /** * Get blurhash string for the card image when the image is from the current item. * Mirrors the image URL logic; returns undefined when image comes from parent/series (we don't have their ImageBlurHashes). @@ -3022,20 +3323,24 @@ const blurhashStr = !item.imageUrl ? getBlurhashForCard(item, cardFormat) : null; if (!blurhashStr || cardFormat === 'logo' || cardFormat === 'clear art' || cardFormat === 'disc') { blurhashCanvas.classList.add('lazy-hidden'); + } else if (shouldSkipBlurhashDecode()) { + // Skip CPU decode; keep default chrome / lazy image path only + blurhashCanvas.classList.add('lazy-hidden'); } else { - // Draw blurhash immediately so placeholder is visible ASAP (canvas is behind cardImageContainer) - if (!drawBlurhashToCanvas(blurhashCanvas, blurhashStr)) { - blurhashCanvas.classList.add('lazy-hidden'); - } else { - // Hide the image container so the blurhash canvas shows until the real image loads - cardImageContainer.classList.add('lazy-hidden'); - } + // Defer decode off the construction path — near-viewport filler paints later + blurhashCanvas.classList.add('lazy-hidden'); + blurhashCanvas.setAttribute('data-blurhash-pending', blurhashStr); } if (imageUrl) { // Use data-src for lazy loading instead of immediate backgroundImage cardImageContainer.setAttribute('data-src', imageUrl); - if (blurhashStr) cardImageContainer.setAttribute('data-blurhash', blurhashStr); + if (blurhashStr + && cardFormat !== 'logo' + && cardFormat !== 'clear art' + && cardFormat !== 'disc') { + cardImageContainer.setAttribute('data-blurhash', blurhashStr); + } // Add lazy class for styling/selection cardImageContainer.classList.add('lazy'); } @@ -3154,6 +3459,25 @@ const buttonContainer = document.createElement('div'); buttonContainer.className = 'cardOverlayButton-br flex'; + // Watchlist button (active state from UserData.Likes; click bound by watchlist.js) + const watchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video']; + if (watchlistSupportedTypes.includes(itemType)) { + const isLiked = item.UserData?.Likes === true; + const watchlistButton = document.createElement('button'); + watchlistButton.type = 'button'; + watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat'; + watchlistButton.setAttribute('data-action', 'none'); + watchlistButton.setAttribute('data-id', itemId); + watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false'); + watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist'; + + const watchlistIcon = document.createElement('span'); + watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist'; + watchlistIcon.setAttribute('aria-hidden', 'true'); + watchlistButton.appendChild(watchlistIcon); + buttonContainer.appendChild(watchlistButton); + } + // Watched button const watchedButton = document.createElement('button'); watchedButton.setAttribute('is', 'emby-playstatebutton'); @@ -3203,7 +3527,9 @@ buttonContainer.appendChild(moreButton); - cardOverlayContainer.appendChild(playButton); + if (item.Type !== 'CollectionFolder' && item.Type !== 'Folder' && item.Type !== 'UserView') { + cardOverlayContainer.appendChild(playButton); + } cardOverlayContainer.appendChild(buttonContainer); } @@ -5256,6 +5582,52 @@ return raw > 0 ? raw : 16; } + const SKELETON_ONE_LINE_ITEM_TYPES = new Set([ + 'Book', 'Genre', 'CollectionFolder', 'BoxSet', 'Playlist', 'Folder', 'Studio' + ]); + + const SKELETON_CUSTOM_FOOTER_SECTION_IDS = new Set([ + 'upcoming', 'recentlyReleased.movies', 'recentlyReleased.episodes' + ]); + + function sectionUsesCustomFooterText(sectionConfig) { + if (!sectionConfig) return false; + if (SKELETON_CUSTOM_FOOTER_SECTION_IDS.has(sectionConfig.id)) return true; + if (Array.isArray(sectionConfig.items) && sectionConfig.items.some( + (item) => item?.CustomFooterText || item?.cardFooter + )) { + return true; + } + return false; + } + + function inferSkeletonItemType(sectionConfig) { + if (!sectionConfig) return null; + const query = sectionConfig.queries?.[0]; + const path = typeof query?.path === 'string' ? query.path : ''; + if (path.includes('/Studios')) return 'Studio'; + if (path.includes('/Genres')) return 'Genre'; + + let includeTypes = query?.queryOptions?.IncludeItemTypes; + if (typeof includeTypes === 'string') { + includeTypes = includeTypes.split(',').map((t) => t.trim()).filter(Boolean); + } + if (!Array.isArray(includeTypes) || includeTypes.length === 0) return null; + if (includeTypes.length === 1) return includeTypes[0]; + if (includeTypes.every((t) => SKELETON_ONE_LINE_ITEM_TYPES.has(t))) { + return includeTypes[0]; + } + return null; + } + + function getSkeletonFooterLineCount(sectionConfig) { + if (sectionConfig?.hideCardFooter === true) return 0; + if (sectionUsesCustomFooterText(sectionConfig)) return 3; + const itemType = inferSkeletonItemType(sectionConfig); + if (itemType && SKELETON_ONE_LINE_ITEM_TYPES.has(itemType)) return 1; + return 2; + } + function createScrollerElement() { const scroller = document.createElement('div'); const isMobile = isMobileLayout(); @@ -5582,9 +5954,10 @@ return null; } - function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl }) { + function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl, discoveryPending }) { const captionText = caption && String(caption).trim(); const hasCaption = !!captionText; + const useSkeletonTitle = discoveryPending === true; let parent = sectionTitleContainer; if (hasCaption) { @@ -5594,7 +5967,15 @@ parent = wrapper; } - if (viewMoreUrl) { + if (useSkeletonTitle) { + const titleText = document.createElement('h2'); + titleText.className = 'sectionTitle sectionTitle-cards'; + const skeletonTitle = document.createElement('span'); + skeletonTitle.className = 'skeleton-text-line skeleton-section-title'; + skeletonTitle.setAttribute('aria-hidden', 'true'); + titleText.appendChild(skeletonTitle); + parent.appendChild(titleText); + } else if (viewMoreUrl) { const titleLink = document.createElement('a'); titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button'; titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;'; @@ -5628,7 +6009,14 @@ if (hasCaption) { let captionEl; - if (captionUrl) { + if (useSkeletonTitle) { + captionEl = document.createElement('span'); + captionEl.className = 'sectionTitle sectionCaption'; + const skeletonCaption = document.createElement('span'); + skeletonCaption.className = 'skeleton-text-line skeleton-section-caption'; + skeletonCaption.setAttribute('aria-hidden', 'true'); + captionEl.appendChild(skeletonCaption); + } else if (captionUrl) { captionEl = document.createElement('a'); captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button'; if (typeof captionUrl === 'function') { @@ -5639,11 +6027,12 @@ } else { captionEl.href = captionUrl; } + captionEl.textContent = captionText; } else { captionEl = document.createElement('span'); captionEl.className = 'sectionTitle sectionCaption'; + captionEl.textContent = captionText; } - captionEl.textContent = captionText; parent.appendChild(captionEl); } } @@ -5722,25 +6111,30 @@ /** * Creates a skeleton card element for loading states - * @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square' + * @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', 'square', 'logo', 'clear art', 'disc', 'banner', etc. * @param {boolean} overflowCard - Use overflow card classes + * @param {Object|null} sectionConfig - Section config used to infer footer line count * @returns {HTMLElement} - Skeleton card element */ - function createSkeletonCard(cardFormat = null, overflowCard = false) { + function createSkeletonCard(cardFormat = null, overflowCard = false, sectionConfig = null) { const card = document.createElement('div'); - // Determine card classes based on format + // Determine card classes based on format (must match createJellyfinCardElement) let cardClass, padderClass; cardFormat = cardFormat?.toLowerCase() || 'portrait'; - if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb') { + if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb' + || cardFormat === 'logo' || cardFormat === 'clear art') { cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; padderClass = 'cardPadder-backdrop'; - } else if (cardFormat === 'square') { + } else if (cardFormat === 'square' || cardFormat === 'disc') { cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard'; padderClass = 'cardPadder-square'; + } else if (cardFormat === 'banner') { + cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard'; + padderClass = 'cardPadder-banner'; } else { - // portrait (default) + // portrait / poster / series poster (default) cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard'; padderClass = 'cardPadder-portrait'; } @@ -5760,6 +6154,32 @@ cardScalable.appendChild(cardPadder); cardBox.appendChild(cardScalable); + + const footerLineCount = getSkeletonFooterLineCount(sectionConfig); + if (footerLineCount > 0) { + const cardTextStack = document.createElement('div'); + cardTextStack.className = 'cardTextStack'; + + const lineWidths = ['70%', '55%', '40%']; + for (let lineIndex = 0; lineIndex < footerLineCount; lineIndex++) { + const isFirst = lineIndex === 0; + const cardText = document.createElement('div'); + cardText.className = isFirst + ? 'cardText cardTextCentered cardText-first' + : 'cardText cardTextCentered cardText-secondary'; + + const bdi = document.createElement('bdi'); + const skeletonLine = document.createElement('span'); + skeletonLine.className = 'skeleton-text-line'; + skeletonLine.style.width = lineWidths[lineIndex] || '40%'; + bdi.appendChild(skeletonLine); + cardText.appendChild(bdi); + cardTextStack.appendChild(cardText); + } + + cardBox.appendChild(cardTextStack); + } + card.appendChild(cardBox); return card; @@ -5773,9 +6193,18 @@ * @returns {HTMLElement} - Skeleton spotlight container */ function createSkeletonSpotlightSection(title, options = {}) { - const { viewMoreUrl = null, spotlightLayout, spotlightSize, fullScreen } = options; + const { + viewMoreUrl = null, + spotlightLayout, + spotlightSize, + fullScreen, + discoveryPending = false, + caption = null + } = options; const layout = spotlightLayout ?? (fullScreen === true ? 'Borderless' : 'Border'); const size = spotlightSize ?? (fullScreen === true ? 'full' : 'normal'); + const captionText = caption && String(caption).trim(); + const useSkeletonTitle = discoveryPending === true; // Create main container (same structure as real spotlight) const container = document.createElement('div'); @@ -5792,9 +6221,16 @@ { const sectionTitleContainer = document.createElement('div'); sectionTitleContainer.className = 'spotlight-section-title-container'; - if (title) { + if (useSkeletonTitle || title) { let sectionTitleEl; - if (viewMoreUrl) { + if (useSkeletonTitle) { + sectionTitleEl = document.createElement('div'); + sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground'; + const skeletonTitle = document.createElement('span'); + skeletonTitle.className = 'skeleton-text-line skeleton-section-title'; + skeletonTitle.setAttribute('aria-hidden', 'true'); + sectionTitleEl.appendChild(skeletonTitle); + } else if (viewMoreUrl) { const titleLink = document.createElement('a'); titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground'; titleLink.textContent = title; @@ -5819,9 +6255,19 @@ sectionTitleEl.textContent = title; } const sectionTitleWrapper = document.createElement('div'); - sectionTitleWrapper.className = `spotlight-section-title ${viewMoreUrl ? '' : 'spotlight-title-link '}headerTabs sectionTabs`; + sectionTitleWrapper.className = `spotlight-section-title ${(!useSkeletonTitle && viewMoreUrl) ? '' : 'spotlight-title-link '}headerTabs sectionTabs`; sectionTitleWrapper.appendChild(sectionTitleEl); sectionTitleContainer.appendChild(sectionTitleWrapper); + + if (useSkeletonTitle && captionText) { + const captionEl = document.createElement('span'); + captionEl.className = 'sectionTitle sectionCaption'; + const skeletonCaption = document.createElement('span'); + skeletonCaption.className = 'skeleton-text-line skeleton-section-caption'; + skeletonCaption.setAttribute('aria-hidden', 'true'); + captionEl.appendChild(skeletonCaption); + sectionTitleContainer.appendChild(captionEl); + } } bannerContainer.appendChild(sectionTitleContainer); } @@ -5865,11 +6311,13 @@ const sectionTitleContainer = document.createElement('div'); sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; + const discoveryPending = sectionConfig?.discoveryPending === true; appendSectionTitleContent(sectionTitleContainer, { title, caption: getSectionCaption(sectionConfig), captionUrl: getSectionCaptionUrl(sectionConfig), - viewMoreUrl + viewMoreUrl: discoveryPending ? null : viewMoreUrl, + discoveryPending }); ensureSectionControlsMount(sectionTitleContainer); @@ -5879,7 +6327,7 @@ const skeletonCount = getSectionSkeletonCount(sectionConfig); for (let i = 0; i < skeletonCount; i++) { - const skeletonCard = createSkeletonCard(cardFormat, overflowCard); + const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig); skeletonCard.setAttribute('data-index', i); itemsContainer.appendChild(skeletonCard); } @@ -5961,10 +6409,13 @@ */ function initLazyImageObserver() { if (lazyImageObserver) return; // Already initialized + + // Mobile: tighter prefetch so image downloads don't stampede during discovery paint + const rootMargin = isMobileLayout() ? '300px' : '800px'; const observerOptions = { threshold: 0.1, // Trigger when 10% of element is visible - rootMargin: '800px' // Start loading well before element enters viewport to avoid grey flash + rootMargin }; lazyImageObserver = new IntersectionObserver((entries) => { @@ -5987,6 +6438,7 @@ const canvas = cardImageContainer.previousElementSibling; if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) { canvas.classList.add('lazy-hidden'); + canvas.removeAttribute('data-blurhash-pending'); } lazyImageObserver.unobserve(cardImageContainer); }; @@ -5999,6 +6451,7 @@ const canvas = cardImageContainer.previousElementSibling; if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) { canvas.classList.add('lazy-hidden'); + canvas.removeAttribute('data-blurhash-pending'); } lazyImageObserver.unobserve(cardImageContainer); }; @@ -6036,6 +6489,8 @@ lazyImageObserver.observe(img); }); } + + observePendingBlurhashInNode(node); }); }); }); @@ -6112,11 +6567,14 @@ initLazyImageObserver(); initLazyMutationObserver(); + initBlurhashFillObserver(); const existingLazyImages = document.querySelectorAll('.cardImageContainer[data-src]'); existingLazyImages.forEach(img => { lazyImageObserver.observe(img); }); + + document.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas); if (document.body) { lazyMutationObserver.observe(document.body, { @@ -6772,12 +7230,20 @@ const dataItems = section.result?.data?.Items ?? section.result?.data ?? []; const isSpotlight = sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight'; const isSkeleton = !!(sectionElement.querySelector('.skeleton-card, .skeleton-spotlight-item')); + const hasSkeletonTitle = !!sectionElement.querySelector('.skeleton-section-title'); if (items.length === 0) { sectionElement.remove(); return; } + // Discovery pending (and any skeleton with placeholder title) needs a full rebuild for title/viewMore/caption + if (isSkeleton && (isSpotlight || hasSkeletonTitle)) { + const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially); + markSectionEnhanced(content, sectionConfig); + return; + } + if (!isSkeleton && isSpotlight) { const staleIds = (Array.isArray(dataItems) ? dataItems : []) .map((item) => item?.Id) diff --git a/scripts/configuration.js b/scripts/configuration.js index 8685742..2ec530b 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -22,9 +22,9 @@ { key: 'directedByTopDirector', label: 'Directed by [Top Director]', defaultName: 'Directed by [Director]' }, { key: 'writtenByTopWriter', label: 'Written by [Top Writer]', defaultName: 'Written by [Writer]' }, { key: 'becauseYouRecentlyWatched', label: 'Because you recently watched [Movie]', defaultName: 'Because you recently watched [Movie]' }, - { key: 'starringActorRecentlyWatched', label: 'Starring [Actor] because you recently watched [Movie]', defaultName: 'Starring [Actor] because you recently watched [Movie]' }, - { key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Director] because you recently watched [Movie]', defaultName: 'Directed by [Director] because you recently watched [Movie]' }, - { key: 'writtenByWriterRecentlyWatched', label: 'Written by [Writer] because you recently watched [Movie]', defaultName: 'Written by [Writer] because you recently watched [Movie]' } + { key: 'starringActorRecentlyWatched', label: 'Starring [Person]', defaultName: 'Starring {Person}', extras: { caption: 'because you recently watched {Title}' } }, + { key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Person]', defaultName: 'Directed by {Person}', extras: { caption: 'because you recently watched {Title}' } }, + { key: 'writtenByWriterRecentlyWatched', label: 'Written by [Person]', defaultName: 'Written by {Person}', extras: { caption: 'because you recently watched {Title}' } } ]; const SUPPORTED_CUSTOM_SECTION_PARAMS = { @@ -1208,10 +1208,8 @@ const defsByName = new Map( (window.KefinTweaks?.getScripts?.() || []).map((def) => [def.name, def]) ); - const major = window.KefinTweaks?._jellyfinMajorVersion - ?? (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function' - ? window.KefinTweaks.getJellyfinMajorVersion() - : null); + // Prefer cache warmed by await getJellyfinMajorVersion() before opening the modal + const major = window.KefinTweaks?._jellyfinMajorVersion ?? null; const visibleScripts = scriptNames.filter((script) => { const def = defsByName.get(script.key); @@ -1711,6 +1709,9 @@ if (definition.extras?.minimumItems !== undefined) { defaults.minimumItems = definition.extras.minimumItems; } + if (definition.extras?.caption !== undefined) { + defaults.caption = definition.extras.caption; + } let normalized = { ...defaults }; @@ -2270,7 +2271,7 @@
-
Infinite Scroll / Load More Button
+
Infinite Scroll / Discover More
${buildJellyfinCheckbox('homeScreen_discovery_infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll')}
@@ -2479,9 +2480,8 @@ // Ensure Jellyfin major is cached so version-gated feature cards filter correctly try { - if (typeof window.KefinTweaks?.getCurrentMajorServerVersion === 'function') { - const major = await window.KefinTweaks.getCurrentMajorServerVersion(); - window.KefinTweaks._jellyfinMajorVersion = major; + if (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function') { + await window.KefinTweaks.getJellyfinMajorVersion(); } } catch (e) { console.warn('[KefinTweaks Configuration] Could not resolve Jellyfin major version:', e); diff --git a/scripts/externalList.js b/scripts/externalList.js new file mode 100644 index 0000000..7aab7d4 --- /dev/null +++ b/scripts/externalList.js @@ -0,0 +1,114 @@ +// KefinTweaks External List (MDBList) — match provider IDs against library caches +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks ExternalList]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks ExternalList]', ...args); + const ERR = (...args) => console.error('[KefinTweaks ExternalList]', ...args); + + function getMdblistApiKey() { + const settings = window.LibraryCacheUtils?.getLibraryCacheSettings?.() || {}; + return (settings.mdblistApiKey || '').trim(); + } + + function appendApiKey(url, apiKey) { + if (!apiKey) return url; + try { + const u = new URL(url); + if (!u.searchParams.has('apikey') && !u.searchParams.has('api_key')) { + u.searchParams.set('apikey', apiKey); + } + return u.toString(); + } catch (_) { + const join = url.includes('?') ? '&' : '?'; + return `${url}${join}apikey=${encodeURIComponent(apiKey)}`; + } + } + + /** + * Normalize mdblist (or similar) JSON into { mediaType, imdb?, tmdb? }[]. + */ + function normalizeExternalListPayload(data) { + const rows = []; + const list = Array.isArray(data) ? data + : Array.isArray(data?.movies) || Array.isArray(data?.shows) || Array.isArray(data?.items) + ? [...(data.movies || []), ...(data.shows || []), ...(data.items || [])] + : []; + + list.forEach((entry) => { + if (!entry || typeof entry !== 'object') return; + const mediaType = /show|series|tv/i.test(String(entry.mediatype || entry.media_type || entry.type || '')) + ? 'Series' + : 'Movie'; + const imdb = entry.imdb_id || entry.imdbid || entry.ids?.imdb || entry.Imdb || null; + const tmdb = entry.tmdb_id || entry.tmdbid || entry.ids?.tmdb || entry.Tmdb || null; + if (imdb || tmdb) { + rows.push({ mediaType, imdb: imdb ? String(imdb) : undefined, tmdb: tmdb ? String(tmdb) : undefined }); + } + }); + return rows; + } + + async function fetchExternalListUrl(url) { + const apiKey = getMdblistApiKey(); + const fetchUrl = appendApiKey(url, apiKey); + const response = await fetch(fetchUrl, { headers: { Accept: 'application/json' } }); + if (!response.ok) { + throw new Error(`External list HTTP ${response.status}`); + } + return response.json(); + } + + async function resolveExternalListItems(sectionConfig) { + const urls = sectionConfig?.externalListUrls || []; + if (!urls.length) return { Items: [] }; + + if (!getMdblistApiKey()) { + WARN('MDBList API key not configured'); + } + + const allRows = []; + for (const url of urls) { + if (!url) continue; + try { + const data = await fetchExternalListUrl(url); + allRows.push(...normalizeExternalListPayload(data)); + } catch (e) { + ERR('Failed to fetch external list:', url, e); + } + } + + const movieIds = []; + const seriesIds = []; + allRows.forEach((row) => { + const keys = []; + if (row.imdb) keys.push(row.imdb); + if (row.tmdb) keys.push(row.tmdb); + if (row.mediaType === 'Series') seriesIds.push(...keys); + else movieIds.push(...keys); + }); + + const movies = movieIds.length + ? await (window.LibraryCache?.getByProviderIds('Movie', movieIds) + || window.MoviesCache?.getMoviesByProviderIds?.(movieIds) + || []) + : []; + const series = seriesIds.length + ? await (window.LibraryCache?.getByProviderIds('Series', seriesIds) + || window.SeriesCache?.getSeriesByProviderIds?.(seriesIds) + || []) + : []; + + const items = [...movies, ...series]; + LOG(`External list matched ${items.length} library items from ${allRows.length} rows`); + return { Items: items }; + } + + window.KefinExternalList = { + getMdblistApiKey, + resolveExternalListItems, + normalizeExternalListPayload + }; + + LOG('Module loaded'); +})(); diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css index 15c7c3e..ace4b37 100644 --- a/scripts/homeScreen.css +++ b/scripts/homeScreen.css @@ -3,7 +3,6 @@ .sections.homeSectionsContainer { display: flex; flex-direction: column; - padding-bottom: 100px; gap: 1rem; } @@ -36,6 +35,10 @@ content: "Discover More"; } +#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::before { + content: "No More Content to Discover"; +} + div#discovery-loading-indicator:not(.show)::after { content: "\E313"; font-family: "Material Icons"; @@ -49,10 +52,26 @@ div#discovery-loading-indicator:not(.show)::after { opacity: 1; } +#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::after { + content: ""; +} + [data-loading-discovery="true"] div#discovery-loading-indicator { display: flex; } +/* Idle Discover More / exhausted prompt (chevron mode) */ +#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) { + display: flex !important; + visibility: visible !important; + opacity: 1 !important; +} + +#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) .spinner { + visibility: hidden; + opacity: 0; +} + /* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator { display: none; } */ @@ -298,3 +317,28 @@ body[data-seasonal-background="true"]:has(.homePage:not(.hide)) #reactRoot:has(. body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay { display: block; } */ + +.homePage[data-discovery-ready] div#discovery-loading-indicator:not(.show) { + min-height: 50px; +} + +@keyframes fadeInDelayed { + 0%, 99% { + opacity: 0; + visibility: hidden; + } + 100% { + opacity: 1; + visibility: visible; + } + } + + .homeSectionsContainer:not(:has([data-page]), :has([data-section-id]))::after { + content: 'Customize your Home Screen from your User Settings > Home in the top right corner.'; + text-align: center; + margin: 3em auto; + display: block; + animation: fadeInDelayed 0.1s linear 1s forwards; + opacity: 0; + visibility: hidden; + } \ No newline at end of file diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js index f096ef2..95a9ca3 100644 --- a/scripts/homeScreen3.js +++ b/scripts/homeScreen3.js @@ -41,20 +41,17 @@ renderedSections: new Set(), renderedDiscoveryIds: { genres: new Set(), - networks: new Set(), + genresSeries: new Set(), + seriesStudios: new Set(), + seriesStudiosTopRated: new Set(), collections: new Set(), - studios: new Set(), people: new Set(), - actors: new Set(), - directors: new Set(), - writers: new Set(), - watchedMovies: new Set(), - likedMovies: new Set(), + similar: new Set(), + watchlist: new Set(), customDiscoverySections: new Set() }, currentDiscoveryGenre: null, currentDiscoveryStudio: null, - cachedFavorites: null, isPeopleCacheComplete: false, discoveryGroupIndex: 0, isRenderingDiscovery: false, @@ -64,16 +61,35 @@ discoverySectionOrder: 100000000, discoverySectionsRemain: true, ensuringDiscoveryBuffer: false, + discoveryNeedsMoreScroll: false, + discoveryMode: null, // 'infinite' | 'chevron' infiniteScrollHandler: null, + discoveryWheelHandler: null, + discoveryTouchStartHandler: null, + discoveryTouchMoveHandler: null, + discoveryScrollDebounceTimer: null, userDisplayPreferences: null, firstDiscoveryBuffer: null, - getDisplayPrefernces: fetchDisplayPreferences, + getDisplayPrefernces: null, kefinNextUp: false, kefinContinueWatching: false, kefinLatestMedia: false, - jellyfinOrders: {} + jellyfinOrders: {}, + homePaintedEventFired: false }; + async function fetchDisplayPreferences() { + if (!window.userHelper?.getUserDisplayPreferences) { + WARN('userHelper.getUserDisplayPreferences unavailable'); + return null; + } + const { promise, cached } = await window.userHelper.getUserDisplayPreferences({ cacheOnly: true }); + const prefs = cached || await promise; + state.userDisplayPreferences = prefs; + return prefs; + } + state.getDisplayPrefernces = fetchDisplayPreferences; + const performanceTimer = { loadTimeStart: null, loadTimeEnd: null, @@ -98,14 +114,16 @@ // Create loading indicator element function createDiscoveryLoadingIndicator(container) { - let loadingDiv = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator'); + let loadingDiv = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator') + || document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (loadingDiv) { return loadingDiv; } // Prefer the provided container, but fall back to the standard sections container if (!container) { - container = document.querySelector('.homePage:not(.hide) #homeTab .sections'); + container = document.querySelector('.homePage:not(.hide) #homeTab .sections') + || document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); } if (!container) { @@ -127,6 +145,97 @@ return loadingDiv; } + function getDiscoveryHomePageEl() { + return document.querySelector('#indexPage') + || document.querySelector('.homePage:not(.hide)') + || document.querySelector('.libraryPage:not(.hide)'); + } + + function setDiscoveryPageAttrs({ ready = false, infinite = false, exhausted = false } = {}) { + const page = getDiscoveryHomePageEl(); + if (!page) return; + if (ready) page.dataset.discoveryReady = 'true'; + else delete page.dataset.discoveryReady; + if (infinite) page.dataset.infiniteScroll = 'true'; + else delete page.dataset.infiniteScroll; + if (exhausted) page.dataset.discoveryExhausted = 'true'; + else delete page.dataset.discoveryExhausted; + } + + function showDiscoveryLoadingIndicator() { + const loadingIndicator = createDiscoveryLoadingIndicator( + document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer') + ); + if (!loadingIndicator) return; + loadingIndicator.classList.add('show'); + loadingIndicator.style.visibility = 'visible'; + loadingIndicator.style.display = 'flex'; + } + + function hideDiscoveryLoadingIndicator() { + const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); + if (!loadingIndicator) return; + loadingIndicator.classList.remove('show'); + // Chevron / exhausted idle UI is shown via data-discovery-ready CSS + if (state.discoveryMode === 'chevron' || state.discoverySectionsRemain === false) { + loadingIndicator.style.visibility = ''; + loadingIndicator.style.display = ''; + } else { + loadingIndicator.style.visibility = 'hidden'; + loadingIndicator.style.display = 'none'; + } + } + + function isDiscoveryHomeContext() { + const currentView = window.KefinTweaksUtils?.getCurrentView(); + const isHomePage = currentView === 'home' || currentView === 'home.html'; + if (!isHomePage) return false; + const activeTab = document.querySelector('.headerTabs .emby-tab-button-active'); + if (!activeTab || activeTab.getAttribute('data-index') !== '0') return false; + return true; + } + + function removeDiscoveryScrollListeners() { + if (state.discoveryScrollDebounceTimer) { + clearTimeout(state.discoveryScrollDebounceTimer); + state.discoveryScrollDebounceTimer = null; + } + if (state.infiniteScrollHandler) { + window.removeEventListener('scroll', state.infiniteScrollHandler); + state.infiniteScrollHandler = null; + } + if (state.discoveryWheelHandler) { + window.removeEventListener('wheel', state.discoveryWheelHandler); + state.discoveryWheelHandler = null; + } + if (state.discoveryTouchStartHandler) { + window.removeEventListener('touchstart', state.discoveryTouchStartHandler); + state.discoveryTouchStartHandler = null; + } + if (state.discoveryTouchMoveHandler) { + window.removeEventListener('touchmove', state.discoveryTouchMoveHandler); + state.discoveryTouchMoveHandler = null; + } + const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); + if (container) delete container.dataset.discoveryHandler; + } + + function markDiscoveryExhausted() { + state.discoverySectionsRemain = false; + state.discoveryBuffer = null; + removeDiscoveryScrollListeners(); + setDiscoveryPageAttrs({ ready: true, infinite: false, exhausted: true }); + const loadingIndicator = createDiscoveryLoadingIndicator( + document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer') + ); + if (loadingIndicator) { + loadingIndicator.classList.remove('show'); + loadingIndicator.style.visibility = ''; + loadingIndicator.style.display = ''; + } + LOG('Discovery exhausted — No More Content to Discover'); + } + // Legacy helper kept for compatibility – no longer used in the main flow async function waitForHomeSectionsContainer() { @@ -858,11 +967,12 @@ if (section.enabled && section.discoveryEnabled) { const legacyPage = section.pageNumber == null ? 1 : Number(section.pageNumber); if (legacyPage !== page) return; - if (state.renderedDiscoveryIds.customDiscoverySections.has(section.id)) { + const customDedupe = window.sectionHelper?.getDiscoveryState?.()?.renderedDiscoveryIds?.customDiscoverySections; + if (customDedupe?.has(section.id)) { return; } mergedSections.push(section); - state.renderedDiscoveryIds.customDiscoverySections.add(section.id); + customDedupe?.add(section.id); } }); } @@ -1161,7 +1271,7 @@ } const homeConfig = await window.KefinHomeScreen.getConfig(); - const showStaleDataBeforeRefresh = homeConfig?.HOME_SETTINGS?.SHOW_STALE_DATA_BEFORE_REFRESH === true; + const showStaleDataBeforeRefresh = homeConfig?.HOME_SETTINGS?.showStaleDataBeforeRefresh === true; const targetContainer = container; @@ -1175,752 +1285,51 @@ const renderDuration = renderEndTime - renderStartTime; LOG(`Home Screen v3 Render progressive sections initialization time: ${renderDuration.toFixed(2)}ms`); + // Allow deferred library-cache crawls to start (movies/series/people scheduleBootstrap) + if (!state.homePaintedEventFired) { + state.homePaintedEventFired = true; + try { + document.dispatchEvent(new CustomEvent('kefinTweaksHomePainted')); + } catch (_) { /* ignore */ } + } + // Check if target container is homeSectionsContainer if (!targetContainer.classList.contains('homeSectionsContainer')) { targetContainer.dataset.sectionsPrerendered = 'true'; } } - function flattenSeriesEpisodes(items) { - if (!items || items.length === 0) return items; - - // Keep only the first item in the array for any given series - const seriesMap = new Map(); - items.forEach(item => { - if (item.Type !== 'Episode') { - return; - } - - if (seriesMap.has(item.SeriesId)) { - return; - } - - seriesMap.set(item.SeriesId, item); - }); - - return Array.from(seriesMap.values()); - } - - function formatAirDate(premiereDate) { - if (!premiereDate) return ''; - - // Ignore timezone offset - const date = new Date(premiereDate.replace('Z', '')); - - // Use format: Tues Jan 4 - const options = { weekday: 'short', month: 'short', day: 'numeric' }; - return date.toLocaleDateString('en-US', options); - } - - let _displayPreferencesPromise = null; - - async function fetchDisplayPreferences() { - try { - const { promise, cached } = await window.userHelper.getUserDisplayPreferences(); - return await promise; - } catch (e) { - ERR('Failed to fetch display preferences:', e); - return null; - } - } - function isRecentlyAddedEnabled() { const userConfig = window.KefinTweaksConfig?.homeScreen; return userConfig?.recentlyAddedInLibrary && Object.keys(userConfig.recentlyAddedInLibrary).length > 0; } - /** - * Refactored: Load section for rendering using queries array - */ async function loadSectionForRendering(sectionConfig) { - LOG(`Loading Section for Rendering: ${sectionConfig.id} (${sectionConfig.name})`); - - const loadSectionTimerStart = performance.now(); - - // Prepare section (template replacements, view more URL, etc.) - const viewMoreUrl = await resolveViewMoreUrl(sectionConfig); - const cardFormat = resolveCardFormat(sectionConfig); - const sectionName = fillTemplate(sectionConfig.name, sectionConfig.metadata); -/* const viewMoreUrl = ''; - const cardFormat = ''; - const sectionName = ''; */ - - sectionConfig.overflowCard = true; - - if (viewMoreUrl) { - sectionConfig.viewMoreUrl = viewMoreUrl; - } - if (cardFormat) { - sectionConfig.cardFormat = cardFormat; - } - if (sectionName) { - sectionConfig.name = sectionName; - } - - // Check renderMode first (preferred), fallback to spotlight boolean for backward compatibility - const shouldRenderSpotlight = sectionConfig.renderMode === 'Spotlight' || sectionConfig.renderMode === 'Random' || sectionConfig.spotlight === true; - if (shouldRenderSpotlight) { - const spotlightConfig = { ...window.KefinHomeScreen.getConfig().SPOTLIGHT_SETTINGS, ...sectionConfig.spotlightConfig }; - sectionConfig.spotlightConfig = spotlightConfig; - // Set renderMode if not already set (for backward compatibility) - if (!sectionConfig.renderMode && sectionConfig.spotlight) { - sectionConfig.renderMode = 'Spotlight'; - } - } - - // Static items: normalize and return immediately (no queries, no dataPromise) - if (sectionConfig.items && sectionConfig.items.length > 0) { - const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || ''; - const serverId = ApiClient.serverId(); - const normalizeTemplate = (value) => (value || '') - .replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot) - .replace(/\$\{serverId\}/g, serverId); - - const normalizedItems = sectionConfig.items.map((item, index) => { - const posterUrl = normalizeTemplate(item.posterUrl); - const thumbUrl = normalizeTemplate(item.thumbUrl); - const squareUrl = normalizeTemplate(item.squareUrl); - const imageUrl = normalizeTemplate(item.imageUrl); - const cardUrl = normalizeTemplate(item.cardUrl); - const backdropUrl = normalizeTemplate(item.backdropUrl); - const bannerUrl = normalizeTemplate(item.bannerUrl); - const logoUrl = normalizeTemplate(item.logoUrl); - - return { - Name: item.Name, - Id: item.Id || 'static-' + sectionConfig.id + '-' + index, - Type: item.Type || 'Folder', - posterUrl: posterUrl, - thumbUrl: thumbUrl, - squareUrl: squareUrl, - imageUrl: imageUrl, - cardUrl: cardUrl, - backdropUrl: backdropUrl, - bannerUrl: bannerUrl, - logoUrl: logoUrl, - CustomFooterText: item.cardFooter || undefined - }; - }); - const loadSectionTimerEnd = performance.now(); - const loadSectionDuration = loadSectionTimerEnd - loadSectionTimerStart; - LOG(`Section ${sectionConfig.id} (static items) loaded for rendering in time: ${loadSectionDuration.toFixed(2)}ms`); - let postProcessedItems = normalizedItems; - if (window.cardBuilder.postProcessItems) { - postProcessedItems = window.cardBuilder.postProcessItems(sectionConfig, normalizedItems); - } - - return { - config: sectionConfig, - result: { data: postProcessedItems } - }; - } - - // Ensure queries array exists when section has no static items - if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) { - WARN(`Section ${sectionConfig.id} has no queries array and no static items`); - return null; - } - - const userId = ApiClient.getCurrentUserId(); - const serverUrl = ApiClient.serverAddress(); - const results = []; - - const resolveQueries = window.cardBuilder?.resolveQueriesToLoad; - const queriesToLoad = typeof resolveQueries === 'function' - ? resolveQueries(sectionConfig, { initialize: true }) - : (() => { - if (sectionConfig.queries.length > 1 && sectionConfig.useRandomQuery === true) { - const randomIndex = Math.floor(Math.random() * sectionConfig.queries.length); - LOG(`Section ${sectionConfig.id}: useRandomQuery — selected query index ${randomIndex}`); - return [sectionConfig.queries[randomIndex]]; - } - return sectionConfig.queries; - })(); - - if (sectionConfig.useMultiQueryPicker === true && sectionConfig.queries.length > 1) { - const index = sectionConfig._selectedQueryIndex; - LOG(`Section ${sectionConfig.id}: useMultiQueryPicker — selected query index ${index}`); - } else if (queriesToLoad.length === 1 && sectionConfig.queries.length > 1 && sectionConfig.useRandomQuery === true) { - const index = sectionConfig.queries.indexOf(queriesToLoad[0]); - LOG(`Section ${sectionConfig.id}: useRandomQuery — selected query index ${index}`); - } - - // Process each query in the queries array - for (const query of queriesToLoad) { - let queryResult; - - if (query.dataSource) { - // Handle cache-based data sources - queryResult = await ApiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {}); - } else { - // Build and execute query - const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', { sectionType: sectionConfig.type }); - - if (typeof queryUrl === 'string') { - // Standard query - let sectionTtl = Config.CACHE.DEFAULT_TTL; - if (Number(sectionConfig.ttl) >= 0) { - sectionTtl = Number(sectionConfig.ttl); - } - - queryResult = await ApiHelper.getQuery(queryUrl, { - useCache: true, - ttl: sectionTtl - }); - } else { - WARN(`Invalid query URL for section ${sectionConfig.id}`); - continue; - } - } - - results.push(queryResult); - } - - // If multiple queries, merge results using section-level sortBy/sortOrder - if (results.length > 1) { - const loadSectionTimerEnd = performance.now(); - const loadSectionDuration = loadSectionTimerEnd - loadSectionTimerStart; - LOG(`Section ${sectionConfig.id} loaded for rendering in time: ${loadSectionDuration.toFixed(2)}ms`); - return ApiHelper.mergeMultiQueryResults(results, sectionConfig); - } - - const loadSectionTimerEnd = performance.now(); - const loadSectionDuration = loadSectionTimerEnd - loadSectionTimerStart; - LOG(`Section ${sectionConfig.id} loaded for rendering in time: ${loadSectionDuration.toFixed(2)}ms`); - - const postProcess = (sectionConfig, items) => { - let postProcessedItems = items; - if (window.cardBuilder.postProcessItems) { - postProcessedItems = window.cardBuilder.postProcessItems(sectionConfig, items); - } - return sortItemsByConfiguredIds(sectionConfig, postProcessedItems); - }; - - const queryResult = results[0]; - let mappedDataPromise = null; - const ensureData = () => { - if (!mappedDataPromise) { - const raw = typeof queryResult.ensureData === 'function' - ? queryResult.ensureData() - : queryResult.dataPromise; - mappedDataPromise = Promise.resolve(raw).then((data) => postProcess(sectionConfig, data)); - } - return mappedDataPromise; - }; - - const result = { - data: postProcess(sectionConfig, queryResult.data), - isStale: queryResult.isStale === true, - isStalePromise: queryResult.isStalePromise, - ensureData - }; - Object.defineProperty(result, 'dataPromise', { - configurable: true, - enumerable: true, - get() { - return ensureData(); - } - }); - - // Single query result - return { - config: sectionConfig, - queryUrl: null, - result - }; + return window.sectionHelper.loadSectionForRendering(sectionConfig); } - /** - * Jellyfin /Items?Ids= does not reliably return items in request order. - * When queryOptions.Ids is set, re-sort the item list to match that sequence. - */ - function sortItemsByConfiguredIds(sectionConfig, items) { - const list = Array.isArray(items) ? items : (items?.Items || []); - if (!Array.isArray(list) || list.length === 0) return items; - - const rawIds = sectionConfig?.queries?.[0]?.queryOptions?.Ids; - if (!rawIds) return items; - - const ids = Array.isArray(rawIds) - ? rawIds.filter(Boolean) - : String(rawIds).split(',').map((s) => s.trim()).filter(Boolean); - if (!ids.length) return items; - - const order = new Map(ids.map((id, i) => [String(id), i])); - const sorted = [...list].sort((a, b) => { - const ai = order.has(String(a?.Id)) ? order.get(String(a.Id)) : 1e9; - const bi = order.has(String(b?.Id)) ? order.get(String(b.Id)) : 1e9; - return ai - bi; - }); - - if (Array.isArray(items)) return sorted; - if (items && typeof items === 'object' && Array.isArray(items.Items)) { - return { ...items, Items: sorted }; - } - return sorted; - } - - function deduplicateItems(items) { - const deduplicated = []; - const ids = new Set(); - for (const item of items) { - if (!item.ProviderIds) { - deduplicated.push(item); - continue; - } - - if (item.ProviderIds.Imdb && ids.has(item.ProviderIds.Imdb)) continue; - if (item.ProviderIds.Tmdb && ids.has(item.ProviderIds.Tmdb)) continue; - if (item.ProviderIds.Tvdb && ids.has(item.ProviderIds.Tvdb)) continue; - - deduplicated.push(item); - ids.add(item.ProviderIds.Imdb); - ids.add(item.ProviderIds.Tmdb); - ids.add(item.ProviderIds.Tvdb); - } - return deduplicated; - } - - let _getWatchlistUrlPromise = null; - - async function getWatchlistUrl() { - return '#/watchlist'; - } - - /** - * Updated: Resolve view more URL from queryOptions - */ - async function resolveViewMoreUrl(sectionConfig) { - if (sectionConfig.viewMoreUrl) return sectionConfig.viewMoreUrl; - - const { id } = sectionConfig; - - // Handle specific section IDs - if (id === 'continueWatching') { - return `#/tv.html?collectionType=tvshows&tab=1&serverId=${ApiClient.serverId()}`; - } - - if (id === 'popularTVNetworks') { - return `#/tv.html?collectionType=tvshows&tab=4&serverId=${ApiClient.serverId()}`; - } - - if (id === 'upcoming' || id === 'recentlyReleased.episodes') { - return `#/tv.html?collectionType=tvshows&tab=2&serverId=${ApiClient.serverId()}`; - } - - if (id === 'nextUp') { - return `#/list.html?type=nextup&serverId=${ApiClient.serverId()}`; - } - - if (id === 'watchlist') { - return await getWatchlistUrl(); - } - - if (id === 'watchAgain') { - const watchlistUrl = await getWatchlistUrl(); - - if (watchlistUrl) { - return `${watchlistUrl}?pageTab=history`; - } - return null; - } - - // Infer from queries array - if (sectionConfig.queries && sectionConfig.queries.length > 0) { - const firstQuery = sectionConfig.queries[0]; - const queryOptions = firstQuery.queryOptions || {}; - const serverId = window.ApiClient ? window.ApiClient.serverId() : ''; - - // Check for Tags - if (queryOptions.Tags) { - return `#/list.html?type=tag&tag=${encodeURIComponent(queryOptions.Tags)}&serverId=${serverId}`; - } - - // Check for Genres - if (queryOptions.Genres) { - let genreId = queryOptions.Genres; - if (typeof genreId === 'string' && !genreId.match(/^\d+$/)) { - const id = await ApiHelper.getGenreId(genreId); - if (id) { - genreId = id; - } - } - return `#/list.html?genreId=${genreId}&serverId=${serverId}`; - } - - // Check for GenreIds - if (queryOptions.GenreIds) { - return `#/list.html?genreId=${queryOptions.GenreIds.join(',')}&serverId=${serverId}`; - } - - // Check for PersonIds - if (queryOptions.PersonIds) { - return `#/details?id=${queryOptions.PersonIds.join(',')}&serverId=${serverId}`; - } - - // Check for StudioIds - if (queryOptions.StudioIds) { - return `#/list.html?studioId=${queryOptions.StudioIds.join(',')}&serverId=${serverId}`; - } - - // Check for Studios -/* if (queryOptions.Studios) { - return `#/list.html?studioId=${queryOptions.Studios}&serverId=${serverId}`; - } */ - - // Check for ParentId - if (queryOptions.ParentId) { - if (sectionConfig.discoveryType === 'Collection' || sectionConfig.discoveryType === 'Playlist') { - return `#/details?id=${queryOptions.ParentId}&serverId=${serverId}`; - } - - return `#/list.html?parentId=${queryOptions.ParentId}&serverId=${serverId}`; - } - - // Check for custom path - if (firstQuery.path) { - // Derive URL from path if possible - if (firstQuery.path.includes('NextUp')) { - return `#/tv.html?collectionType=tvshows&tab=1`; - } - if (firstQuery.path.includes('Upcoming')) { - return `#/tv.html?collectionType=tvshows&tab=2`; - } - // If query path is Items/itemid/Similar then return the details page for the item - if (firstQuery.path.includes('Similar')) { - const itemId = firstQuery.path.split('/')[2]; - return `#/details?id=${itemId}&serverId=${serverId}`; - } - } - } - - return null; - } - - function postProcessItems(sectionConfig, itemsData) { - let processed = itemsData?.Items || itemsData || []; - - // Flatten Series Episodes - if (sectionConfig.flattenSeries === true) { - processed = flattenSeriesEpisodes(processed); - } - - if (sectionConfig.id === 'upcoming') { - // Remove items which aired before today - const today = new Date(); - today.setHours(0, 0, 0, 0); - processed = processed.filter(item => { - if (!item.PremiereDate || item.LocationType !== 'Virtual') - return false; - const premiereDate = new Date(item.PremiereDate); - premiereDate.setHours(0, 0, 0, 0); - return premiereDate >= today; - }); - } - - // Add Custom Secondary Text for Recently Released Movies - if (sectionConfig.id === 'recentlyReleased.movies' || sectionConfig.id === 'upcoming' || sectionConfig.id === 'recentlyReleased.episodes') { - processed.forEach(item => { - if (item.PremiereDate) { - item.CustomFooterText = formatAirDate(item.PremiereDate); - } - }); - } - - // Apply local limits for non-API sources - if (sectionConfig.itemLimit) { - processed = processed.slice(0, sectionConfig.itemLimit); - } - - // Deduplicate items based on ProviderIds. Look for match imdb, tmdb or tvdb ids - processed = deduplicateItems(processed); - - return processed; - } - - function resolveCardFormat(sectionConfig) { - return sectionConfig.cardFormat || 'Poster'; - } - - /** - * Logic to determine the "Source" for dynamic sections - * Returns { id, name, metadata: {} } - */ - async function resolveDynamicSource(config) { - switch (config.discoveryType) { - case 'Genre': - const genre = await getRandomGenre(); - return genre ? { id: genre.Id, name: genre.Name, metadata: { Genre: genre.Name } } : null; - - case 'Person': - if (config.sourceType && config.sourceType.includes('watched')) { - const result = await getRandomPersonFromHistory(config.personType, config.sourceType); - if (!result) return null; - - return { - id: result.person.id, - name: result.person.name, - excludeItemId: result.sourceItem.Id, - metadata: { - Person: result.person.name, - Actor: result.person.name, - Director: result.person.name, - Writer: result.person.name, - Title: result.sourceItem.Name, - Movie: result.sourceItem.Name - } - }; - } - - const person = await getRandomPerson(config.personType); - return person ? { - id: person.Id, - name: person.Name || person.name, - metadata: { - Person: person.Name || person.name, - Actor: person.Name || person.name, - Director: person.Name || person.name, - Writer: person.Name || person.name - } - } : null; - - case 'Studio': - const networks = await StudiosCache.getPopularTVNetworks(); // getPopularTVNetworks(); - if (!networks || !networks.length) return null; - const network = networks[Math.floor(Math.random() * networks.length)]; - return { id: network.Id, name: network.Name, metadata: { Studio: network.Name } }; - - case 'Similar': - if (config.sourceType === 'watched') { - const m = getRandomWatchedMovie(); - return m ? { - id: m.Id, - metadata: { - Title: m.Name, - Movie: m.Name - } - } : null; - } - if (config.sourceType === 'liked') { - const m = await getRandomFavoriteMovie(); - return m ? { - id: m.Id, - metadata: { - Title: m.Name, - Movie: m.Name - } - } : null; - } - if (config.sourceType === 'watched-recent') { - const m = getRandomWatchedMovie(); - return m ? { - id: m.Id, - metadata: { - Title: m.Name, - Movie: m.Name - } - } : null; - } - return null; - - case 'Collection': - const selectedCollection = await getRandomCollectionForDiscovery(config.minimumItems || 3); - if (!selectedCollection) return null; - - return { - id: selectedCollection.Id, - name: selectedCollection.Name, - metadata: { Collection: selectedCollection.Name, "Collection Name": selectedCollection.Name } - }; - } - - return null; - } - - function fillTemplate(template, data) { - if (!data || !template) return template; - - // Support both {Key} and [Key] formats, allowing spaces in keys - return template.replace(/\{([^}]+)\}|\[([^\]]+)\]/g, (match, key1, key2) => { - const key = key1 || key2; - return data[key] || match; - }); - } - - /** - * Resolves a dynamic discovery template into a concrete section config with queries. - * Returns null when no source could be resolved (e.g. no eligible genre/person left). - * @param {Object} template - Discovery section template - * @param {Object} [options] - * @param {boolean} [options.pairSpotlight] - Reuse the paired spotlight source (home render behaviour) - * @param {boolean} [options.resolveViewMore] - Resolve the "view more" URL (may issue extra requests) - */ async function buildDiscoverySectionInstance(template, options = {}) { - const pairSpotlight = options.pairSpotlight !== false; - const resolveViewMore = options.resolveViewMore !== false; - - const instanceConfig = { - ...template, - id: `${template.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - type: 'discovery', - }; - if (!instanceConfig.discoveryType && template.type - && !['home', 'seasonal', 'discovery'].includes(String(template.type).toLowerCase())) { - instanceConfig.discoveryType = template.type; - } - - const dynamicResult = await resolveDynamicSource(instanceConfig); - if (!dynamicResult) return null; - - instanceConfig.source = dynamicResult.id || dynamicResult.name; - instanceConfig.metadata = dynamicResult.metadata; - instanceConfig.excludeItemId = dynamicResult.excludeItemId; - - if (pairSpotlight && getDiscoverySettings().renderSpotlightAboveMatching) { - const configId = instanceConfig.id.split('-')[0]; - - if (configId === 'genreMovies' || configId === 'spotlightGenre') { - if (state.currentDiscoveryGenre) { - instanceConfig.source = state.currentDiscoveryGenre.id; - dynamicResult.metadata = state.currentDiscoveryGenre.metadata; - state.currentDiscoveryGenre = null; - } - else { - state.currentDiscoveryGenre = dynamicResult; - } - } - if (configId === 'studioShows' || configId === 'spotlightNetwork') { - if (state.currentDiscoveryStudio) { - instanceConfig.source = state.currentDiscoveryStudio.id; - dynamicResult.metadata = state.currentDiscoveryStudio.metadata; - state.currentDiscoveryStudio = null; - } - else { - state.currentDiscoveryStudio = dynamicResult; - } - } - } - - // Create queries array with proper queryOptions - const queryOptions = { - Recursive: 'true', - Limit: instanceConfig.itemLimit || 20 - }; - - // Add IncludeItemTypes if specified - if (instanceConfig.includeItemTypes && instanceConfig.includeItemTypes.length) { - queryOptions.IncludeItemTypes = instanceConfig.includeItemTypes; - } - - // Add SortBy and SortOrder if specified - if (instanceConfig.sortOrder) { - queryOptions.SortBy = instanceConfig.sortOrder; - } - if (instanceConfig.sortOrderDirection) { - queryOptions.SortOrder = instanceConfig.sortOrderDirection; - } - - // Handle discoveryType-specific query options - const resolvedSource = instanceConfig.source; - const discoveryKind = instanceConfig.discoveryType || instanceConfig.type; - if (discoveryKind === 'Similar') { - // Similar type uses custom path - instanceConfig.queries = [{ - path: `/Items/${resolvedSource}/Similar`, - queryOptions: { - Limit: instanceConfig.itemLimit || 20, - Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData' - } - }]; - } else { - // Standard query types - switch (discoveryKind) { - case 'Genre': - if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { - queryOptions.GenreIds = [resolvedSource]; - } else if (resolvedSource) { - queryOptions.Genres = resolvedSource; - } - break; - case 'Tag': - if (resolvedSource) { - queryOptions.Tags = resolvedSource; - } - break; - case 'Person': - if (resolvedSource) { - queryOptions.PersonIds = [resolvedSource]; - } - if (instanceConfig.excludeItemId) { - queryOptions.ExcludeItemIds = Array.isArray(instanceConfig.excludeItemId) - ? instanceConfig.excludeItemId - : [instanceConfig.excludeItemId]; - } - break; - case 'Studio': - if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { - queryOptions.StudioIds = [resolvedSource]; - } else if (resolvedSource) { - queryOptions.Studios = resolvedSource; - } - break; - case 'Collection': - case 'Parent': - if (resolvedSource) { - queryOptions.ParentId = resolvedSource; - } - break; - } - - // Add spotlight fields if needed - if (instanceConfig.spotlight || instanceConfig.renderMode === 'Spotlight') { - queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags,Studios'; - - const spotlightConfig = instanceConfig.spotlightConfig || {}; - const adminSpotlightConfig = window.KefinHomeScreen.getConfig()?.SPOTLIGHT_SETTINGS || {}; - - instanceConfig.spotlightConfig = { ...adminSpotlightConfig, ...spotlightConfig }; - } else { - queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData'; - } - - instanceConfig.queries = [{ - queryOptions: queryOptions - }]; - } - - instanceConfig.name = fillTemplate(instanceConfig.name, dynamicResult.metadata); - if (instanceConfig.caption) { - instanceConfig.caption = fillTemplate(instanceConfig.caption, dynamicResult.metadata); - if (dynamicResult.excludeItemId) { - instanceConfig.captionUrl = `#/details?id=${dynamicResult.excludeItemId}&serverId=${ApiClient.serverId()}`; - } - } - instanceConfig.cardFormat = resolveCardFormat(instanceConfig); - if (resolveViewMore) { - instanceConfig.viewMoreUrl = await resolveViewMoreUrl(instanceConfig); - } - - return instanceConfig; + return window.sectionHelper.buildDiscoverySectionInstance(template, options); } function disconnectDiscoveryInteraction() { - // If we are using infinite scroll, disconnect the scroll event listener - if (state.infiniteScrollHandler) { - window.removeEventListener('scroll', state.infiniteScrollHandler); - state.infiniteScrollHandler = null; - } else { - const loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); - if (loadMoreButton) { - loadMoreButton.remove(); - } + removeDiscoveryScrollListeners(); + + const loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); + if (loadMoreButton) { + loadMoreButton.remove(); } - // Remove the discovery loading indicator - const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); - if (loadingIndicator) { - loadingIndicator.remove(); + // Remove the discovery loading indicator (exhausted path keeps it via markDiscoveryExhausted) + if (state.discoverySectionsRemain !== false) { + const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); + if (loadingIndicator) { + loadingIndicator.remove(); + } + setDiscoveryPageAttrs({ ready: false, infinite: false, exhausted: false }); } + + state.discoveryMode = null; } /** @@ -1935,40 +1344,52 @@ await new Promise(resolve => setTimeout(resolve, 100)); } - return; + return state.discoveryBuffer; } LOG('Ensuring discovery buffer...'); state.ensuringDiscoveryBuffer = true; - if (state.discoveryBuffer && state.discoveryBuffer.length > 0) { - LOG('Discovery buffer already exists...'); - state.ensuringDiscoveryBuffer = false; + try { + if (state.discoveryBuffer && state.discoveryBuffer.length > 0) { + LOG('Discovery buffer already exists...'); + return state.discoveryBuffer; + } + + LOG('Fetching next discovery group data...'); + + const groupSections = await generateDiscoveryGroup(); + + if (!groupSections || groupSections.length === 0) { + LOG('No more discovery sections available for buffer.'); + state.discoveryBuffer = []; + state.discoverySectionsRemain = false; + return state.discoveryBuffer; + } + + state.discoveryBuffer = await Promise.all(groupSections.map(async (sectionConfig) => { + const isDynamic = !!(sectionConfig.discoveryType || sectionConfig.source === 'Dynamic'); + if (isDynamic) { + // Sync stub + lazy resolve; do not await ensureData here + return window.sectionHelper.buildDiscoverySectionPromise(sectionConfig, { + order: sectionConfig.order, + pairSpotlight: true + }); + } + return await loadSectionForRendering(sectionConfig); + })); + + // Filter hard failures only; progressive stubs that later return empty are removed by cardBuilder enhance + state.discoveryBuffer = state.discoveryBuffer.filter(result => result !== null); + + if (!state.discoveryBuffer.length) { + state.discoverySectionsRemain = false; + } + return state.discoveryBuffer; - } - - LOG('Fetching next discovery group data...'); - - const groupSections = await generateDiscoveryGroup(); - - if (!groupSections || groupSections.length === 0) { - LOG('No more discovery sections available for buffer.'); + } finally { state.ensuringDiscoveryBuffer = false; - state.discoverySectionsRemain = false; - disconnectDiscoveryInteraction(); - return; } - - state.discoveryBuffer = await Promise.all(groupSections.map(async (sectionConfig) => { - // Use loadSectionForRendering which handles all queries, multi-query merging, and post-processing - return await loadSectionForRendering(sectionConfig); - })); - - // Filter out any null results - state.discoveryBuffer = state.discoveryBuffer.filter(result => result !== null); - - state.ensuringDiscoveryBuffer = false; - return state.discoveryBuffer; } @@ -1994,6 +1415,12 @@ return; } + // getDiscoverySections() above may mark legacy customDiscoverySections; clear only that + // so generateDiscoveryGroup can claim them without wiping genre/person/similar dedupe. + window.sectionHelper?.getDiscoveryState?.()?.renderedDiscoveryIds?.customDiscoverySections?.clear?.(); + state.discoverySectionsRemain = true; + state.discoveryNeedsMoreScroll = false; + if (PRE_FETCH_DISCOVERY_DATA) { ensureDiscoveryBuffer(); } @@ -2005,9 +1432,13 @@ const useInfiniteScroll = userConfig ? (userConfig.infiniteScroll !== false) : true; if (useInfiniteScroll) { + state.discoveryMode = 'infinite'; + setDiscoveryPageAttrs({ ready: false, infinite: true, exhausted: false }); setupInfiniteScroll(container); } else { - setupLoadMoreButton(container); + state.discoveryMode = 'chevron'; + setDiscoveryPageAttrs({ ready: true, infinite: false, exhausted: false }); + setupChevronDiscoverMore(container); } } @@ -2015,24 +1446,38 @@ const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (!container) return; + // Single-flight: set synchronously before any await if (state.isRenderingDiscovery) return; - state.isRenderingDiscovery = true; - container.dataset.loadingDiscovery = 'true'; + if (state.discoverySectionsRemain === false) return; - await new Promise(resolve => requestAnimationFrame(resolve)); - await new Promise(resolve => setTimeout(resolve, 0)); + state.isRenderingDiscovery = true; + // Require another user scroll before auto-loading the next group (infinite mode) + state.discoveryNeedsMoreScroll = true; + container.dataset.loadingDiscovery = 'true'; + container.classList.add('loading-discovery'); + showDiscoveryLoadingIndicator(); try { + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => setTimeout(resolve, 0)); + await ensureDiscoveryBuffer(); - const bufferedSections = state.discoveryBuffer; + let bufferedSections = state.discoveryBuffer; if (!bufferedSections || bufferedSections.length === 0) { LOG('No more discovery sections available.'); - state.discoveryBuffer = null; + markDiscoveryExhausted(); return; } + // Stable append: honor pre-assigned section.order after parallel loads + bufferedSections = bufferedSections.slice().sort((a, b) => { + const ao = Number(a?.config?.order); + const bo = Number(b?.config?.order); + return (Number.isFinite(ao) ? ao : 0) - (Number.isFinite(bo) ? bo : 0); + }); + LOG(`Rendering Discovery Group ${state.discoveryGroupIndex + 1} (${bufferedSections.length} sections)...`); const config = await window.KefinHomeScreen.getConfig(); @@ -2040,13 +1485,9 @@ await window.cardBuilder.renderProgressiveSections(container, bufferedSections, { revealSectionsSequentially, enhanceOnVisible: true, - showStaleDataBeforeRefresh: config.HOME_SETTINGS?.SHOW_STALE_DATA_BEFORE_REFRESH === true + showStaleDataBeforeRefresh: config.HOME_SETTINGS?.showStaleDataBeforeRefresh === true }); - if (!getDiscoverySettings().infiniteScroll) { - setupLoadMoreButton(container); - } - container.dataset.loadingDiscovery = 'false'; container.classList.remove('loading-discovery'); @@ -2057,12 +1498,21 @@ ensureDiscoveryBuffer(); } + // Chevron mode: nudge scroll so newly appended sections start entering the viewport + if (state.discoveryMode === 'chevron') { + window.scrollBy({ top: 100, behavior: 'smooth' }); + } + } catch (e) { ERR('Error rendering discovery group:', e); state.discoveryBuffer = null; } finally { state.isRenderingDiscovery = false; - updateLoadMoreButtonVisibility(); + if (state.discoverySectionsRemain !== false) { + hideDiscoveryLoadingIndicator(); + container.dataset.loadingDiscovery = 'false'; + container.classList.remove('loading-discovery'); + } } } @@ -2104,26 +1554,23 @@ discoveryTemplates = [...discoveryTemplates].sort(() => 0.5 - Math.random()); } + // Collect enabled templates unresolved — progressive resolve happens in ensureDiscoveryBuffer for (const template of discoveryTemplates) { if (template.enabled === false) continue; - - if (template.queries && template.queries.length > 0) { - selectedConfigs.push(template); - continue; - } - - const instanceConfig = await buildDiscoverySectionInstance(template); - if (instanceConfig) { - selectedConfigs.push(instanceConfig); - } + selectedConfigs.push(template); } if (getDiscoverySettings().renderSpotlightAboveMatching) { - const spotlightGenreSection = selectedConfigs.find(section => section.id.split('-')[0] === 'spotlightGenre'); + const spotlightGenreSection = selectedConfigs.find((section) => { + const id = section.id.split('-')[0]; + return id === 'spotlightGenre' || id === 'spotlightGenreSeries'; + }); const spotlightNetworkSection = selectedConfigs.find(section => section.id.split('-')[0] === 'spotlightNetwork'); if (spotlightGenreSection) { - const genreSection = selectedConfigs.find(section => section.id.split('-')[0] === 'genreMovies'); + const spotlightBaseId = spotlightGenreSection.id.split('-')[0]; + const genreBaseId = spotlightBaseId === 'spotlightGenreSeries' ? 'genreSeries' : 'genreMovies'; + const genreSection = selectedConfigs.find(section => section.id.split('-')[0] === genreBaseId); if (genreSection) { const genreSectionIndex = selectedConfigs.indexOf(genreSection); const spotlightGenreSectionIndex = selectedConfigs.indexOf(spotlightGenreSection); @@ -2194,60 +1641,139 @@ return selectedConfigs; } - function setupInfiniteScroll() { - const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); - if (!container) return; + function setupInfiniteScroll(container) { + const target = container + || document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); + if (!target) return; - if (container.dataset.discoveryHandler) return; - container.dataset.discoveryHandler = 'true'; + removeDiscoveryScrollListeners(); + target.dataset.discoveryHandler = 'true'; - state.infiniteScrollHandler = window.addEventListener('scroll', () => { - // Check if the user is on the home page - const currentView = window.KefinTweaksUtils?.getCurrentView(); - const isHomePage = currentView === 'home' || currentView === 'home.html'; - if (!isHomePage) return; - - // Check if the user is on the first tab - const activeTab = document.querySelector('.headerTabs .emby-tab-button-active').getAttribute('data-index'); - if (activeTab !== '0') return; - - if (!container.dataset.sectionsRendered) return; + let lastScrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; + const handleScroll = () => { + if (!isDiscoveryHomeContext()) return; + if (!target.dataset.sectionsRendered) return; if (state.isRenderingDiscovery) return; - + if (state.discoverySectionsRemain === false) return; + + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const delta = Math.abs(scrollTop - lastScrollTop); + lastScrollTop = scrollTop; + + // After a group loads, ignore threshold until the user scrolls again + if (state.discoveryNeedsMoreScroll) { + if (delta < 2) return; + state.discoveryNeedsMoreScroll = false; + } + + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + const threshold = windowHeight * 1.5; + + if (scrollTop + windowHeight < documentHeight - threshold) return; + + if (state.discoveryScrollDebounceTimer) { + clearTimeout(state.discoveryScrollDebounceTimer); + } + state.discoveryScrollDebounceTimer = setTimeout(() => { + state.discoveryScrollDebounceTimer = null; + if (state.isRenderingDiscovery || state.discoveryNeedsMoreScroll) return; + if (!isDiscoveryHomeContext()) return; + const st = window.pageYOffset || document.documentElement.scrollTop; + const wh = window.innerHeight; + const dh = document.documentElement.scrollHeight; + if (st + wh >= dh - wh * 1.5) { + renderNextDiscoveryGroup(); + } + }, 200); + }; + + state.infiniteScrollHandler = handleScroll; + window.addEventListener('scroll', handleScroll, { passive: true }); + LOG('Infinite scroll enabled for discovery (debounced + scroll-gated re-arm).'); + } + + /** + * infiniteScroll=false: Discover More + chevron; load when user scrolls again at bottom. + */ + function setupChevronDiscoverMore(container) { + const target = container + || document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); + if (!target) return; + + removeDiscoveryScrollListeners(); + target.dataset.discoveryHandler = 'true'; + createDiscoveryLoadingIndicator(target); + hideDiscoveryLoadingIndicator(); + + let lastTouchY = null; + + const scheduleLoadFromBottomGesture = () => { + if (state.isRenderingDiscovery) return; + if (state.discoverySectionsRemain === false) return; + if (state.discoveryScrollDebounceTimer) { + clearTimeout(state.discoveryScrollDebounceTimer); + } + state.discoveryScrollDebounceTimer = setTimeout(() => { + state.discoveryScrollDebounceTimer = null; + if (state.isRenderingDiscovery) return; + if (!isDiscoveryHomeContext()) return; + LOG('User scrolled at bottom (chevron mode); loading next discovery group...'); + renderNextDiscoveryGroup(); + }, 200); + }; + + const isAtDocumentBottom = () => { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const windowHeight = window.innerHeight; const documentHeight = document.documentElement.scrollHeight; - - const threshold = windowHeight * 1.5; - - if (scrollTop + windowHeight >= documentHeight - threshold) { - renderNextDiscoveryGroup(container); - } - }, { passive: true }); - LOG('Infinite scroll enabled for discovery.'); - } - - function setupLoadMoreButton(container) { - const loadMoreButton = container.querySelector('.load-more-discovery-btn'); - if (loadMoreButton) return; - - let btn = document.createElement('button'); - btn.className = 'raised button-submit emby-button load-more-discovery-btn'; - btn.textContent = 'Discover More'; - btn.onclick = () => { - btn.remove(); - renderNextDiscoveryGroup(container) + return scrollTop + windowHeight >= documentHeight - 2; }; - container.appendChild(btn); - LOG('Load More button enabled for discovery.'); - } - - function updateLoadMoreButtonVisibility() { - const btn = document.querySelector('.load-more-discovery-btn'); - if (btn) { - btn.style.display = 'block'; - } + + const handleWheel = (event) => { + if (!isDiscoveryHomeContext()) return; + if (!target.dataset.sectionsRendered) return; + if (state.isRenderingDiscovery) return; + if (event.deltaY <= 0) return; + if (!isAtDocumentBottom()) return; + scheduleLoadFromBottomGesture(); + }; + + const handleTouchStart = (event) => { + const touch = event.touches && event.touches[0]; + lastTouchY = touch ? touch.clientY : null; + }; + + const handleTouchMove = (event) => { + if (!isDiscoveryHomeContext()) return; + if (!target.dataset.sectionsRendered) return; + if (state.isRenderingDiscovery) return; + + const touch = event.touches && event.touches[0]; + if (!touch) return; + + if (lastTouchY == null) { + lastTouchY = touch.clientY; + return; + } + + const deltaY = touch.clientY - lastTouchY; + lastTouchY = touch.clientY; + + // Upward swipe scrolls content down + if (deltaY >= 0) return; + if (!isAtDocumentBottom()) return; + scheduleLoadFromBottomGesture(); + }; + + state.discoveryWheelHandler = handleWheel; + state.discoveryTouchStartHandler = handleTouchStart; + state.discoveryTouchMoveHandler = handleTouchMove; + window.addEventListener('wheel', handleWheel, { passive: true }); + window.addEventListener('touchstart', handleTouchStart, { passive: true }); + window.addEventListener('touchmove', handleTouchMove, { passive: true }); + LOG('Chevron Discover More enabled for discovery (scroll-again at bottom).'); } function isInSeasonalPeriod(start, end) { @@ -2290,202 +1816,6 @@ return isInSeasonalPeriod(activeSection.startDate, activeSection.endDate); } - const localCache = new window.LocalStorageCache(); - - async function getRandomGenre() { - let movieGenres = localCache.get('movieGenres'); - if (!movieGenres) movieGenres = await fetchAndCacheMovieGenres(); - if (!movieGenres || !movieGenres.length) return null; - - const minCount = (Config.DISCOVERY_SETTINGS && Config.DISCOVERY_SETTINGS.minGenreMovieCount) - || (window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen && window.KefinTweaksConfig.homeScreen.discovery && window.KefinTweaksConfig.homeScreen.discovery.minGenreMovieCount) - || 50; - - const valid = movieGenres.filter(g => !state.renderedDiscoveryIds.genres.has(g.Id) && (g.MovieCount || 0) > minCount); - if (!valid.length) return null; - - const selected = valid[Math.floor(Math.random() * valid.length)]; - state.renderedDiscoveryIds.genres.add(selected.Id); - return selected; - } - - async function fetchAndCacheMovieGenres() { - try { - const userId = ApiClient.getCurrentUserId(); - const response = await fetch(`${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`, { - headers: { 'Authorization': window.apiHelper.getAuthHeader() } - }); - const data = await response.json(); - const genres = (data.Items || []).map(g => ({ Id: g.Id, Name: g.Name, MovieCount: g.MovieCount })); - localCache.set('movieGenres', genres); - return genres; - } catch (e) { - ERR('Failed to fetch genres', e); - return []; - } - } - - async function getRandomCollectionForDiscovery(minimumItems) { - const collections = await getCollectionsData(); - if (!collections || !collections.length) return null; - const unrenderedCollections = collections.filter(c => !state.renderedDiscoveryIds.collections.has(c.Id)); - const eligibleCollections = unrenderedCollections.filter(c => c.TotalRecordCount ?? c.RecursiveItemCount ?? c.ChildCount ?? 0 >= minimumItems); - if (!eligibleCollections.length) return null; - const selected = eligibleCollections[Math.floor(Math.random() * eligibleCollections.length)]; - state.renderedDiscoveryIds.collections.add(selected.Id); - return selected; - } - - async function getCollectionsData() { - const query = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=BoxSet&Recursive=true&Fields=RecursiveItemCount,ChildCount,TotalRecordCount&StartIndex=0&Limit=500&SortBy=TotalRecordCount`; - - try { - const result = await ApiHelper.getQuery(query, { - useCache: true, - ttl: Config.CACHE.LONG_TTL - }); - - let items = []; - if (result.data && result.data.Items) { - items = result.data.Items; - } else { - const fresh = await result.dataPromise; - items = fresh.Items || []; - } - return items; - } catch (e) { - ERR('Failed to fetch collections', e); - return []; - } - } - - async function getRandomPersonFromHistory(personType, sourceType) { - let sourceItems = [];//window.apiHelper.getQuery(`${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`, { useCache: true, ttl: Config.CACHE.LONG_TTL }); - let queryUrl = null; - - if (sourceType === 'watched-recent') { - queryUrl = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending&Limit=5`; - /* sourceItems = localCache.get('movies') || []; - if (sourceItems.length === 0) return null; */ - - sourceItems = sourceItems.sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate)).slice(0, 5); - } else if (sourceType === 'watched') { - queryUrl = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&SortBy=Random&SortOrder=Descending`; - /* sourceItems = localCache.get('movies') || []; - if (sourceItems.length === 0) return null; - sourceItems = sourceItems.sort(() => 0.5 - Math.random()).slice(0, 20); */ - } - - if (queryUrl) { - const response = await window.apiHelper.getQuery(queryUrl, { useCache: true, ttl: Config.CACHE.LONG_TTL }); - // Only check the cached data, ignore the promise - sourceItems = response?.data?.Items || response?.data || []; - } - - if (!sourceItems.length) return null; - - sourceItems.sort(() => 0.5 - Math.random()); - - for (const item of sourceItems) { - if (!item.People || !item.People.length) continue; - - const candidates = item.People.filter(p => p.Type === personType); - if (!candidates.length) continue; - - for (const person of candidates) { - let trackedSet = null; - if (personType === 'Director') trackedSet = state.renderedDiscoveryIds.directors; - else if (personType === 'Actor') trackedSet = state.renderedDiscoveryIds.actors; - else if (personType === 'Writer') trackedSet = state.renderedDiscoveryIds.writers; - - if (trackedSet && !trackedSet.has(person.Id)) { - trackedSet.add(person.Id); - return { - person: { id: person.Id, name: person.Name }, - sourceItem: item - }; - } - } - } - - return null; - } - - let _topPeople = null; - async function getTopPeople() { - if (_topPeople) return _topPeople; - _topPeople = await PeopleCache.getTopPeople(); - return _topPeople; - } - - async function getRandomPerson(type) { - if (!PeopleCache) return null; - - const peopleData = await getTopPeople(); - if (!peopleData) return null; - - let list = []; - let trackedSet = null; - - if (type === 'Director') { - list = peopleData.directors; - trackedSet = state.renderedDiscoveryIds.directors; - } else if (type === 'Actor') { - list = peopleData.actors; - trackedSet = state.renderedDiscoveryIds.actors; - } else if (type === 'Writer') { - list = peopleData.writers; - trackedSet = state.renderedDiscoveryIds.writers; - } - - if (!list || !list.length || !trackedSet) return null; - - const valid = list.filter(p => !trackedSet.has(p.Id)); - if (!valid.length) return null; - - const selected = valid[Math.floor(Math.random() * valid.length)]; - if (selected && selected.Id) { - trackedSet.add(selected.Id); - } - return selected; - } - - function getRandomWatchedMovie() { - const movies = localCache.get('movies') || []; - if (!movies.length) return null; - const valid = movies.filter(m => !state.renderedDiscoveryIds.watchedMovies.has(m.Id)); - if (!valid.length) return null; - const selected = valid[Math.floor(Math.random() * Math.min(valid.length, 10))]; - state.renderedDiscoveryIds.watchedMovies.add(selected.Id); - return selected; - } - - async function getRandomFavoriteMovie() { - if (state.cachedFavorites) { - const valid = state.cachedFavorites.filter(m => !state.renderedDiscoveryIds.likedMovies.has(m.Id)); - if (valid.length > 0) { - const selected = valid[Math.floor(Math.random() * valid.length)]; - state.renderedDiscoveryIds.likedMovies.add(selected.Id); - return selected; - } - } - const userId = ApiClient.getCurrentUserId(); - try { - const response = await ApiClient.getItems(userId, { IncludeItemTypes: 'Movie', Filters: 'IsFavorite', Limit: 50, SortBy: 'Random', Recursive: true }); - const items = response.Items || []; - if (!items.length) return null; - state.cachedFavorites = items; - const valid = items.filter(m => !state.renderedDiscoveryIds.likedMovies.has(m.Id)); - if (!valid.length) return null; - const selected = valid[0]; - state.renderedDiscoveryIds.likedMovies.add(selected.Id); - return selected; - } catch (e) { - ERR('Failed to get favorite movies:', e); - return null; - } - } - async function getPopularTVNetworks() { if (!StudiosCache) return []; @@ -2516,7 +1846,8 @@ window.homeScreen3 = { init: enhanceHomeScreen, refreshHomeSections, - resolveDiscoverySection: buildDiscoverySectionInstance + resolveDiscoverySection: buildDiscoverySectionInstance, + sectionHelper: () => window.sectionHelper }; //enhanceHomeScreen(); diff --git a/scripts/homeScreenConfig2.js b/scripts/homeScreenConfig2.js index 0ff80a2..5cfc235 100644 --- a/scripts/homeScreenConfig2.js +++ b/scripts/homeScreenConfig2.js @@ -569,7 +569,7 @@ enabled: true, order: 72, cardFormat: 'Thumb', - ttl: CACHE_CONFIG.LONG_TTL, + ttl: CACHE_CONFIG.STATIC_TTL, itemLimit: 20, sortBy: 'Random', limitBeforeSort: true, @@ -593,7 +593,7 @@ enabled: true, order: 72, cardFormat: 'Thumb', - ttl: CACHE_CONFIG.LONG_TTL, + ttl: CACHE_CONFIG.STATIC_TTL, itemLimit: 20, sortBy: 'Random', limitBeforeSort: true, @@ -617,7 +617,7 @@ enabled: false, order: 72, cardFormat: 'Thumb', - ttl: CACHE_CONFIG.LONG_TTL, + ttl: CACHE_CONFIG.STATIC_TTL, itemLimit: 20, sortBy: 'Random', limitBeforeSort: true, @@ -680,7 +680,7 @@ enabled: true, order: 71, cardFormat: 'Poster', - ttl: CACHE_CONFIG.DEFAULT_TTL, + ttl: CACHE_CONFIG.FORCE_REFRESH_TTL, userConfigurable: true, queries: [ { @@ -1240,9 +1240,9 @@ } ]; - // Discovery Templates - // These use "Dynamic" types where 'source' is determined at runtime - // Organized into groups + const DISCOVERY_CONTENT_FIELDS = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData'; + + // Discovery Templates — discoverySourceQuery picks the entity; queries load content const KEFINTWEAKS_DISCOVERY_SECTION_GROUPS = [ // Genre Movies Group { @@ -1253,30 +1253,55 @@ id: 'genreMovies', type: 'discovery', discoveryType: 'Genre', - source: 'Dynamic', // Will be filled with a random Genre ID/Name + discoveryItemType: 'Movie', + discoverySourceQuery: { + path: '/Genres', + queryOptions: { IncludeItemTypes: ['Movie'], Limit: 0 } + }, name: '{Genre} Movies', enabled: true, userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] }, { id: 'spotlightGenre', type: 'discovery', discoveryType: 'Genre', - source: 'Dynamic', + discoveryItemType: 'Movie', + discoverySourceQuery: { + path: '/Genres', + queryOptions: { IncludeItemTypes: ['Movie'], Limit: 0 } + }, name: 'Top Rated {Genre}', enabled: true, userConfigurable: true, itemLimit: 20, sortOrder: 'CommunityRating', sortOrderDirection: 'Descending', - includeItemTypes: ['Movie'], - renderMode: 'Spotlight', // Replaces 'type: spotlight-genre' with attribute - ttl: CACHE_CONFIG.DISCOVERY_TTL + renderMode: 'Spotlight', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Movie'], + SortBy: 'CommunityRating', + SortOrder: 'Descending', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] } ] }, @@ -1289,46 +1314,82 @@ id: 'directedByTopDirector', type: 'discovery', discoveryType: 'Person', - source: 'Dynamic', // Random Director ID - personType: 'Director', // Extra metadata for dynamic logic + discoveryPersonType: 'Director', + discoveryItemType: 'Movie', + discoverySourceQuery: { + dataSource: 'PeopleCache.getTopDirectors', + queryOptions: { ItemType: 'Movie', Limit: 100 } + }, name: 'Directed by {Person}', enabled: true, userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] }, { id: 'writtenByTopWriter', type: 'discovery', discoveryType: 'Person', - source: 'Dynamic', // Random Writer ID - personType: 'Writer', + discoveryPersonType: 'Writer', + discoveryItemType: 'Movie', + discoverySourceQuery: { + dataSource: 'PeopleCache.getTopWriters', + queryOptions: { ItemType: 'Movie', Limit: 100 } + }, name: 'Written by {Person}', enabled: true, userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] }, { id: 'starringTopActor', type: 'discovery', discoveryType: 'Person', - source: 'Dynamic', // Random Actor ID - personType: 'Actor', + discoveryPersonType: 'Actor', + discoveryItemType: 'Movie', + discoverySourceQuery: { + dataSource: 'PeopleCache.getTopActors', + queryOptions: { ItemType: 'Movie', Limit: 100 } + }, name: 'Starring {Person}', enabled: true, userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] } ] }, @@ -1341,9 +1402,20 @@ id: 'starringActorRecentlyWatched', type: 'discovery', discoveryType: 'Person', - source: 'Dynamic', // Actor from Recently Watched - personType: 'Actor', - sourceType: 'watched-recent', // Hint for source generator + discoveryPersonType: 'Actor', + discoveryItemType: 'Movie', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Movie'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }, name: 'Starring {Person}', caption: 'because you recently watched {Title}', enabled: true, @@ -1351,16 +1423,35 @@ itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] }, { id: 'directedByDirectorRecentlyWatched', type: 'discovery', discoveryType: 'Person', - source: 'Dynamic', // Director from Recently Watched - personType: 'Director', - sourceType: 'watched-recent', + discoveryPersonType: 'Director', + discoveryItemType: 'Movie', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Movie'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }, name: 'Directed by {Person}', caption: 'because you recently watched {Title}', enabled: true, @@ -1368,16 +1459,35 @@ itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] }, { id: 'writtenByWriterRecentlyWatched', type: 'discovery', discoveryType: 'Person', - source: 'Dynamic', - personType: 'Writer', - sourceType: 'watched-recent', + discoveryPersonType: 'Writer', + discoveryItemType: 'Movie', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Movie'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }, name: 'Written by {Person}', caption: 'because you recently watched {Title}', enabled: true, @@ -1385,8 +1495,16 @@ itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] } ] }, @@ -1399,30 +1517,63 @@ id: 'becauseYouWatched', type: 'discovery', discoveryType: 'Similar', - source: 'Dynamic', // Random Watched Item ID - sourceType: 'watched', + discoveryItemType: 'Movie', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Movie'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'Random', + Limit: 20 + } + }, name: 'Because you watched {Title}', enabled: true, userConfigurable: true, itemLimit: 20, - sortOrder: 'Random', // Typically 'Similarity' but usually returned sorted by API - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + sortOrder: 'Random', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + IncludeItemTypes: ['Movie'], + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] }, { id: 'becauseYouRecentlyWatched', type: 'discovery', discoveryType: 'Similar', - source: 'Dynamic', // Random Recently Watched ID - sourceType: 'watched-recent', + discoveryItemType: 'Movie', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Movie'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }, name: 'Because you recently watched {Title}', enabled: true, userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + IncludeItemTypes: ['Movie'], + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] } ] }, @@ -1435,16 +1586,32 @@ id: 'becauseYouLiked', type: 'discovery', discoveryType: 'Similar', - source: 'Dynamic', // Random Favorite Item ID - sourceType: 'liked', + discoveryItemType: 'Movie', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Movie'], + Recursive: true, + Filters: 'IsFavorite', + SortBy: 'Random', + Limit: 20, + Fields: 'UserData,People' + } + }, name: 'Because you liked {Title}', enabled: true, userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Movie'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + IncludeItemTypes: ['Movie'], + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] } ] }, @@ -1457,30 +1624,65 @@ id: 'studioShows', type: 'discovery', discoveryType: 'Studio', - source: 'Dynamic', // Random Studio ID + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Studios', + queryOptions: { + IncludeItemTypes: ['Series'], + SortBy: 'ChildCount', + SortOrder: 'Descending', + Limit: 0 + } + }, name: 'Shows from {Studio}', enabled: true, userConfigurable: true, itemLimit: 20, sortOrder: 'Random', cardFormat: 'Poster', - includeItemTypes: ['Series'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Series'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] }, { id: 'spotlightNetwork', type: 'discovery', discoveryType: 'Studio', - source: 'Dynamic', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Studios', + queryOptions: { + IncludeItemTypes: ['Series'], + SortBy: 'ChildCount', + SortOrder: 'Descending', + Limit: 0 + } + }, name: 'Top Rated from {Studio}', enabled: true, userConfigurable: true, itemLimit: 20, sortOrder: 'CommunityRating', sortOrderDirection: 'Descending', - includeItemTypes: ['Series'], renderMode: 'Spotlight', - ttl: CACHE_CONFIG.DISCOVERY_TTL + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Series'], + SortBy: 'CommunityRating', + SortOrder: 'Descending', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] } ] }, @@ -1493,14 +1695,464 @@ id: 'collections', type: 'discovery', discoveryType: 'Collection', - source: 'Dynamic', + discoveryItemType: 'Movie', + minimumItems: 3, + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['BoxSet'], + Recursive: true, + Fields: 'RecursiveItemCount,ChildCount,TotalRecordCount', + Limit: 500, + SortBy: 'TotalRecordCount' + } + }, name: '{Collection}', enabled: true, userConfigurable: true, itemLimit: 20, - sortOrder: 'Random', // Order within the collection - includeItemTypes: ['Movie', 'Series'], - ttl: CACHE_CONFIG.DISCOVERY_TTL + sortOrder: 'Random', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Movie', 'Series'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + } + ] + }, + // Genre Series Group + { + id: 'discovery-genre-series', + name: 'Genre Series', + sections: [ + { + id: 'genreSeries', + type: 'discovery', + discoveryType: 'Genre', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Genres', + queryOptions: { IncludeItemTypes: ['Series'], Limit: 0 } + }, + name: '{Genre} Series', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Series'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + }, + { + id: 'spotlightGenreSeries', + type: 'discovery', + discoveryType: 'Genre', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Genres', + queryOptions: { IncludeItemTypes: ['Series'], Limit: 0 } + }, + name: 'Top Rated {Genre} Series', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'CommunityRating', + sortOrderDirection: 'Descending', + renderMode: 'Spotlight', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Series'], + SortBy: 'CommunityRating', + SortOrder: 'Descending', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + } + ] + }, + // Series Similar / Watchlist Group + { + id: 'discovery-series-similar', + name: 'Series Similar', + sections: [ + { + id: 'becauseYouWatchedSeries', + type: 'discovery', + discoveryType: 'Similar', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Episode'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'Random', + Limit: 20 + } + }, + name: 'Because you watched {Title}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + IncludeItemTypes: ['Series'], + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + }, + { + id: 'becauseYouRecentlyWatchedSeries', + type: 'discovery', + discoveryType: 'Similar', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Episode'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }, + name: 'Because you recently watched {Title}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + IncludeItemTypes: ['Series'], + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + }, + { + id: 'becauseYouLikedSeries', + type: 'discovery', + discoveryType: 'Watchlist', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Episode'], + Recursive: true, + Filters: 'Likes', + SortBy: 'Random', + Limit: 20, + Fields: 'UserData,People' + } + }, + name: 'Because you liked {Title}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + IncludeItemTypes: ['Series'], + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + }, + { + id: 'becauseYouFinishedSeries', + type: 'discovery', + discoveryType: 'Similar', + discoveryItemType: 'Series', + discoverySourceQuery: { + dataSource: 'SeriesCache.getCompletedSeries', + queryOptions: { Limit: 5, SortBy: 'Random' } + }, + name: 'Because you finished {Title}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + IncludeItemTypes: ['Series'], + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + } + ] + }, + // Series People Group + { + id: 'discovery-people-series', + name: 'People Series', + sections: [ + { + id: 'directedByTopDirectorSeries', + type: 'discovery', + discoveryType: 'Person', + discoveryPersonType: 'Director', + discoveryItemType: 'Series', + discoverySourceQuery: { + dataSource: 'PeopleCache.getTopDirectors', + queryOptions: { ItemType: 'Series', Limit: 100 } + }, + name: 'Directed by {Person}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Thumb', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Episode'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + }, + { + id: 'writtenByTopWriterSeries', + type: 'discovery', + discoveryType: 'Person', + discoveryPersonType: 'Writer', + discoveryItemType: 'Series', + discoverySourceQuery: { + dataSource: 'PeopleCache.getTopWriters', + queryOptions: { ItemType: 'Series', Limit: 100 } + }, + name: 'Written by {Person}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Thumb', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Episode'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + }, + { + id: 'starringTopActorSeries', + type: 'discovery', + discoveryType: 'Person', + discoveryPersonType: 'Actor', + discoveryItemType: 'Series', + discoverySourceQuery: { + dataSource: 'PeopleCache.getTopActors', + queryOptions: { ItemType: 'Series', Limit: 100 } + }, + name: 'Starring {Person}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Series'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + } + ] + }, + // Recently Watched People Series Group + { + id: 'discovery-recently-watched-people-series', + name: 'Recently Watched People Series', + sections: [ + { + id: 'starringActorRecentlyWatchedSeries', + type: 'discovery', + discoveryType: 'Person', + discoveryPersonType: 'Actor', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Episode'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }, + name: 'Starring {Person}', + caption: 'because you recently watched {Title}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Series'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + }, + { + id: 'directedByDirectorRecentlyWatchedSeries', + type: 'discovery', + discoveryType: 'Person', + discoveryPersonType: 'Director', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Episode'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }, + name: 'Directed by {Person}', + caption: 'because you recently watched {Title}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Thumb', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Episode'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + }, + { + id: 'writtenByWriterRecentlyWatchedSeries', + type: 'discovery', + discoveryType: 'Person', + discoveryPersonType: 'Writer', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Episode'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }, + name: 'Written by {Person}', + caption: 'because you recently watched {Title}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Thumb', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Episode'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] + } + ] + }, + // Guest Star Group + { + id: 'discovery-guest-star', + name: 'Guest Star', + sections: [ + { + id: 'guestStarringBecauseYouRecentlyWatched', + type: 'discovery', + discoveryType: 'Person', + discoveryPersonType: 'GuestStar', + discoveryItemType: 'Series', + discoverySourceQuery: { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Episode'], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }, + name: 'Guest starring {Person}', + caption: 'because you recently watched {Title}', + enabled: true, + userConfigurable: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Thumb', + ttl: CACHE_CONFIG.DISCOVERY_TTL, + queries: [{ + queryOptions: { + Recursive: true, + IncludeItemTypes: ['Episode'], + SortBy: 'Random', + Limit: 20, + Fields: DISCOVERY_CONTENT_FIELDS + } + }] } ] } @@ -1508,16 +2160,16 @@ const DISCOVERY_SETTINGS = { enabled: true, - infiniteScroll: true, - minPeopleAppearances: 10, + infiniteScroll: false, minGenreMovieCount: 50, + minGenreSeriesCount: 10, defaultItemLimit: 16, defaultSortOrder: "Random", defaultCardFormat: "Poster", spotlightDiscoveryChance: 0.5, renderSpotlightAboveMatching: false, randomizeOrder: false, - fadeInSections: false, + fadeInSections: true, } const SEASONAL_THEME_SETTINGS = { @@ -1547,12 +2199,19 @@ minimumSeriesForPopularTVNetworks: 10, fadeInSections: false, ensureThumbsForPopularTVNetworks: false, - SHOW_STALE_DATA_BEFORE_REFRESH: false, + showStaleDataBeforeRefresh: true, + minPeopleAppearancesTotal: 10, + minPeopleAppearancesMovies: 10, + minPeopleAppearancesSeries: 10, + minPeopleAppearancesEpisodes: 10, + maxPeopleCount: 1000, + loadPeopleEpisodeData: false, } const USER_HOME_SCREEN_SETTINGS = { inlineConfigure: true, - pinning: true + pinning: true, + pairNativeHomeSections: true } const SPOTLIGHT_SETTINGS = { @@ -1571,12 +2230,17 @@ SEASONAL_SECTION_GROUPS: KEFINTWEAKS_SEASONAL_SECTION_GROUPS, DISCOVERY_SECTION_GROUPS: KEFINTWEAKS_DISCOVERY_SECTION_GROUPS, CUSTOM_SECTION_GROUPS: [], // Empty by default - user-created/imported sections - REMOVE_CONFLICTING_SECTIONS: true, HOME_SETTINGS: HOME_SETTINGS, USER_HOME_SCREEN_SETTINGS: USER_HOME_SCREEN_SETTINGS, DISCOVERY_SETTINGS: DISCOVERY_SETTINGS, SEASONAL_THEME_SETTINGS: SEASONAL_THEME_SETTINGS, - SPOTLIGHT_SETTINGS: SPOTLIGHT_SETTINGS + SPOTLIGHT_SETTINGS: SPOTLIGHT_SETTINGS, + LIBRARY_CACHE: { + movieChunkSize: 1000, + seriesChunkSize: 1000, + mdblistApiKey: '', + CACHE_TTL: 14 * 24 * 60 * 60 * 1000 + } }; /* diff --git a/scripts/kefinTweaks-loader.js b/scripts/kefinTweaks-loader.js new file mode 100644 index 0000000..c144529 --- /dev/null +++ b/scripts/kefinTweaks-loader.js @@ -0,0 +1,1173 @@ +// KefinTweaks Loader — shared catalog, load plan, and KefinTweaks-injector script builder +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks Loader]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks Loader]', ...args); + + const KEFIN_INJECTOR_ENTRY_NAME = 'KefinTweaks-injector'; + const KEFIN_CONFIG_ENTRY_NAME = 'KefinTweaks-Config'; + + const DEFAULT_ENABLED_SCRIPTS = { + watchlist: true, + homeScreen: true, + search: true, + headerTabs: true, + customMenuLinks: true, + hamburgerMenu: false, + updoot: false, + backdropLeakFix: true, + dashboardButtonFix: true, + infiniteScroll: true, + removeContinue: true, + subtitleSearch: true, + playlist: true, + itemDetailsCollections: true, + flattenSingleSeasonShows: true, + seriesInfo: true, + versionPreferences: true, + collections: true, + skinManager: true, + thumbnailScrubber: false, + watchTogether: false, + userManager: false, + games: true + }; + + const THIRD_PARTY_SCRIPTS = { + pickr: { + js: [ + 'thirdparty/pickr/pickr.min.js' + ], + css: [ + 'thirdparty/pickr/pickr-nano.min.css' + ] + } + }; + + const SCRIPT_DEFINITIONS = [ + { + name: 'utils', + script: 'utils.js', + css: null, + dependencies: [], + description: 'Common utilities for page view management and MutationObserver conversion' + }, + { + name: 'skinConfigLegacyDefaults', + script: 'skinConfig-0.3.5-defaults.js', + css: null, + dependencies: [], + description: 'Legacy skin defaults (v0.3.5) for duplicate detection' + }, + { + name: 'skinConfig', + script: 'skinConfig.js', + css: null, + dependencies: [], + description: 'Default skin configuration for KefinTweaks' + }, + { + name: 'ui', + script: 'ui.js', + css: null, + dependencies: [], + description: 'UI helper functions for KefinTweaks' + }, + { + name: 'homeScreenConfig', + script: 'homeScreenConfig.js', + css: null, + dependencies: [], + description: 'Default home screen configuration for KefinTweaks' + }, + { + name: 'homeScreenConfig2', + script: 'homeScreenConfig2.js', + css: null, + dependencies: [], + description: 'Default home screen configuration for KefinTweaks' + }, + { + name: 'homeScreen-migration', + script: '../configuration/homeScreen-migration.js', + css: null, + dependencies: [], + description: 'Home screen migration for KefinTweaks' + }, + { + name: 'homeScreen-user-configuration', + script: '../configuration/homeScreen-user-configuration.js', + css: null, + dependencies: ['ui', 'utils'], + description: 'User home screen section preferences and bulk order editor' + }, + { + name: 'homeScreenSectionConfigure', + script: 'homeScreenSectionConfigure.js', + css: null, + dependencies: ['homeScreen-user-configuration', 'modal', 'ui', 'cardBuilder'], + thirdParty: ['pickr'], + description: 'Inline configure popover for user home screen sections' + }, + { + name: 'homeScreenPin', + script: 'homeScreenPin.js', + css: null, + dependencies: ['homeScreen-user-configuration', 'modal', 'utils'], + description: 'Pin items to the home screen from context menus' + }, + { + name: 'homeScreenCustomItemsEditor', + script: '../configuration/homeScreenCustomItemsEditor.js', + css: null, + dependencies: [], + description: 'Shared custom items editor (wizard + advanced)' + }, + { + name: 'homeScreenSectionEditor', + script: '../configuration/homeScreenSectionEditor.js', + css: '../configuration/homeScreenSectionEditor.css', + dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui', 'homeScreenCustomItemsEditor', 'homeScreenAdvancedEditor'], + description: 'Home screen section editor (wizard + advanced)' + }, + { + name: 'homeScreenEditorProfiles', + script: '../configuration/homeScreenEditorProfiles.js', + css: null, + dependencies: [], + description: 'Editor profile registry for home screen section editor' + }, + { + name: 'homeScreenAdvancedEditor', + script: '../configuration/homeScreenAdvancedEditor.js', + css: '../configuration/homeScreenAdvancedEditor.css', + dependencies: ['ui', 'homeScreenCustomItemsEditor', 'homeScreenEditorProfiles'], + thirdParty: ['pickr'], + description: 'Advanced section editor UI for custom sections' + }, + { + name: 'homescreenBenchmark', + script: '../configuration/homescreenBenchmark.js', + css: null, + dependencies: ['apiHelper'], + description: 'Home screen section query benchmarks for admin config' + }, + { + name: 'homeScreen-configuration', + script: '../configuration/homeScreen-configuration.js', + css: '../configuration/homeScreen-configuration.css', + dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui', 'homeScreenCustomItemsEditor', 'homeScreenSectionEditor', 'homescreenBenchmark'], + description: 'Home screen configuration for KefinTweaks' + }, + { + name: 'search-configuration', + script: '../configuration/search-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Search configuration for KefinTweaks' + }, + { + name: 'seriesEpisodes-configuration', + script: '../configuration/seriesEpisodes-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Series episodes configuration for KefinTweaks' + }, + { + name: 'seriesInfo-configuration', + script: '../configuration/seriesInfo-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Series info configuration for KefinTweaks' + }, + { + name: 'versionPreferences-configuration', + script: '../configuration/versionPreferences-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Item version preferences configuration for KefinTweaks' + }, + { + name: 'skinManager-configuration', + script: '../configuration/skinManager-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Skin manager configuration for KefinTweaks' + }, + { + name: 'customMenuLinks-configuration', + script: '../configuration/customMenuLinks-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Custom menu links configuration for KefinTweaks' + }, + { + name: 'homeScreenConfigCommunity', + script: 'homeScreenConfig-community.js', + css: null, + dependencies: [], + description: 'Community home screen sections for KefinTweaks' + }, + { + name: 'peopleCache', + script: 'peopleCache.js', + css: null, + dependencies: ['libraryCacheUtils', 'userHelper', 'moviesCache', 'seriesCache', 'indexedDBCache', 'apiHelper'], + description: 'People cache for KefinTweaks' + }, + { + name: 'studiosCache', + script: 'studiosCache.js', + css: null, + dependencies: [], + description: 'Studios cache for KefinTweaks' + }, + { + name: 'libraryCacheUtils', + script: 'libraryCacheUtils.js', + css: null, + dependencies: [], + description: 'Shared strip/provider helpers for library caches' + }, + { + name: 'moviesCache', + script: 'moviesCache.js', + css: null, + dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'], + description: 'Movies library cache for KefinTweaks' + }, + { + name: 'seriesCache', + script: 'seriesCache.js', + css: null, + dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'], + description: 'Series library cache for KefinTweaks' + }, + { + name: 'libraryCache', + script: 'libraryCache.js', + css: null, + dependencies: ['moviesCache', 'seriesCache'], + description: 'Library cache facade for KefinTweaks' + }, + { + name: 'externalList', + script: 'externalList.js', + css: null, + dependencies: ['libraryCache', 'libraryCacheUtils', 'apiHelper'], + description: 'MDBList external list matching for home sections' + }, + { + name: 'skinManager', + script: 'skinManager.js', + css: 'defaultSkin.css', + dependencies: ['utils', 'skinConfigLegacyDefaults', 'skinConfig', 'modal'], + priority: true, // Load immediately after dependencies to reduce UI disruption + description: 'Adds skin selection dropdown to display preferences page and manages skin CSS loading' + }, + { + name: 'apiHelper', + script: 'apiHelper.js', + css: null, + dependencies: ['dataHelper'], + description: 'API helper functions for common Jellyfin operations' + }, + { + name: 'sectionHelper', + script: 'sectionHelper.js', + css: null, + dependencies: ['apiHelper', 'cardBuilder', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'homeScreenConfig2', 'dataHelper'], + description: 'Section loading and progressive discovery resolve helpers' + }, + { + name: 'cardBuilder', + script: 'cardBuilder.js', + css: 'cardBuilder.css', + dependencies: ['apiHelper', 'userHelper'], + description: 'Core card building functionality (required by other scripts)' + }, + { + name: 'localStorageCache', + script: 'localStorageCache.js', + css: null, + dependencies: [], + description: 'localStorage-based caching layer with 24-hour TTL and manual refresh' + }, + { + name: 'indexedDBCache', + script: 'indexedDBCache.js', + css: null, + dependencies: [], + description: 'IndexedDB-based caching layer for large datasets with TTL support' + }, + { + name: 'modal', + script: 'modal.js', + css: 'modal.css', + dependencies: [], + description: 'Generic modal system for Jellyfin-style dialogs' + }, + { + name: 'userHelper', + script: 'userHelper.js', + css: null, + dependencies: [], + description: 'User helper functions for Jellyfin operations' + }, + { + name: 'dataHelper', + script: '../helpers/dataHelper.js', + css: null, + dependencies: [], + description: 'Data helper functions for Jellyfin operations' + }, + { + name: 'websocketHelper', + script: '../helpers/websocketHelper.js', + css: null, + dependencies: [], + description: 'WebSocket helper functions for Jellyfin operations' + }, + { + name: 'watchTogether-configuration', + script: '../configuration/watchTogether-configuration.js', + css: null, + dependencies: ['modal', 'ui'], + description: 'Configuration UI for Watch Together' + }, + { + name: 'watchTogether', + script: 'watchTogether.js', + css: null, + dependencies: ['utils', 'modal', 'apiHelper', 'userHelper', 'websocketHelper', 'ui', 'watchTogether-configuration'], + description: 'Watch Together: sync watched/in-progress state to selected group accounts' + }, + { + name: 'userManager-configuration', + script: '../configuration/userManager-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils', 'apiHelper', 'ui'], + description: 'Configuration UI for User Manager templates' + }, + { + name: 'userManager', + script: 'userManager.js', + css: 'userManager.css', + dependencies: ['utils', 'modal', 'apiHelper', 'userHelper', 'toaster', 'userManager-configuration'], + description: 'User Manager: apply policy templates on new user creation and bulk-edit existing users' + }, + { + name: 'toaster', + script: 'toaster.js', + css: null, + dependencies: [], + description: 'Toast notification system using Jellyfin\'s existing toast functionality' + }, + { + name: 'watchlist', + script: 'watchlist.js', + css: 'watchlist.css', + dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils'], + description: 'Adds watchlist functionality throughout Jellyfin interface' + }, + { + name: 'homeScreen', + script: 'homeScreen3.js', + css: 'homeScreen.css', + dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'userHelper', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'libraryCacheUtils', 'externalList', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'sectionHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'], + priority: true, // Load immediately after dependencies to reduce UI disruption + description: 'Adds custom home screen sections' + }, + { + name: 'search', + script: 'search.js', + css: 'search.css', + dependencies: ['cardBuilder', 'utils'], + description: 'Enhanced search functionality' + }, + { + name: 'headerTabs', + script: 'headerTabs.js', + css: null, + dependencies: [], + description: 'Header tab improvements' + }, + { + name: 'customMenuLinks', + script: 'customMenuLinks.js', + css: null, + dependencies: ['utils'], + description: 'Load and add custom menu links from configuration' + }, + { + name: 'hamburgerMenu', + script: 'hamburgerMenu.js', + css: null, + dependencies: ['utils'], + versions: [12], + description: 'Desktop hamburger button that opens a v12-style left navigation drawer' + }, + { + name: 'games', + script: 'games.js', + css: null, + dependencies: ['utils'], + description: 'Adds a Games sidebar link to the games hub page' + }, + { + name: 'backdropLeakFix', + script: 'backdropLeakFix.js', + css: null, + dependencies: [], + versions: [10, 11], + description: 'Fixes issue that causes backdrop images to be continuously added to the page if the tab isn\'t focused.' + }, + { + name: 'updoot', + script: 'updoot.js', + css: null, + dependencies: [], + description: 'Upvote functionality provided by https://github.com/BobHasNoSoul/jellyfin-updoot' + }, + { + name: 'dashboardButtonFix', + script: 'dashboardButtonFix.js', + css: null, + dependencies: [], + versions: [10, 11], + description: 'Fixes the dashboard button to redirect to the home page when the back button is clicked and there is no history to go back to' + }, + { + name: 'thumbnailScrubber-configuration', + script: '../configuration/thumbnailScrubber-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Thumbnail scrubber configuration for KefinTweaks' + }, + { + name: 'thumbnailScrubber', + script: 'thumbnailScrubber.js', + css: null, + dependencies: ['thumbnailScrubber-configuration'], + description: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card for 2+ seconds' + }, + { + name: 'infiniteScroll', + script: 'infiniteScroll.js', + css: null, + dependencies: ['cardBuilder'], + description: 'Adds infinite scroll functionality to media library pages' + }, + { + name: 'removeContinue', + script: 'removeContinue.js', + css: null, + dependencies: [], + description: 'Adds remove from continue watching functionality to cards with data-position-ticks' + }, + { + name: 'subtitleSearch', + script: 'subtitleSearch.js', + css: 'subtitleSearch.css', + dependencies: ['toaster'], + description: 'Adds subtitle search functionality to the video OSD, allowing users to search and download subtitles from remote sources' + }, + { + name: 'breadcrumbs', + script: 'breadcrumbs.js', + css: 'breadcrumbNav.css', + dependencies: ['utils'], + description: 'Adds breadcrumb navigation to item detail pages for Movies, Series, Seasons, Episodes, Music Artists, and Music Albums' + }, + { + name: 'playlist', + script: 'playlist.js', + css: null, + dependencies: ['cardBuilder', 'utils', 'modal'], + description: 'Modifies playlist view page behavior to navigate to item details instead of playing, adds play button to playlist items, and adds sorting functionality' + }, + { + name: 'itemDetailsCollections', + script: 'itemDetailsCollections.js', + css: null, + dependencies: ['indexedDBCache', 'utils', 'cardBuilder'], + versions: [10, 11], + description: 'Adds related collections to item details pages showing which collections contain the current item' + }, + { + name: 'flattenSingleSeasonShows', + script: 'seriesEpisodes.js', + css: 'seriesEpisodes.css', + dependencies: ['cardBuilder', 'utils'], + description: 'Displays episodes directly on series page with season selection. Works for both single and multi-season shows when enabled.' + }, + { + name: 'seriesInfo', + script: 'seriesInfo.js', + css: null, + dependencies: ['utils'], + description: 'Adds series and season information (seasons count, episodes count, end time) to details pages' + }, + { + name: 'versionPreferences', + script: 'versionPreferences.js', + css: null, + dependencies: ['utils'], + description: 'Reorders and sanitizes media source version/edition options on item details pages' + }, + { + name: 'collections', + script: 'collections.js', + css: null, + dependencies: ['utils', 'modal'], + description: 'Adds sorting functionality to collection pages' + }, + ]; + + const CONFIG_DEPENDENCY_NAMES = [ + 'modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', + 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', + 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', + 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', + 'watchTogether-configuration', 'userManager-configuration', 'apiHelper' + ]; + + const CONFIGURATION_FILES = { script: 'configuration.js', css: 'configuration.css' }; + + function parseMajorVersion(version) { + if (!version || typeof version !== 'string') return null; + const versionParts = version.split('.'); + if (versionParts[0] !== '10') { + const majorVersion = parseInt(versionParts[0], 10); + return Number.isNaN(majorVersion) ? null : majorVersion; + } + if (versionParts.length >= 2) { + const majorVersion = parseInt(versionParts[1], 10); + if (!Number.isNaN(majorVersion)) return majorVersion; + } + return null; + } + + function isScriptCompatible(scriptDef, majorVersion) { + if (!scriptDef || !scriptDef.versions || !scriptDef.versions.length) return true; + if (majorVersion == null) return false; + return scriptDef.versions.includes(majorVersion); + } + + function definitionsByName() { + const map = new Map(); + SCRIPT_DEFINITIONS.forEach((d) => map.set(d.name, d)); + return map; + } + + function mergeEnabledScripts(config) { + const enabled = Object.assign({}, DEFAULT_ENABLED_SCRIPTS, (config && config.scripts) || {}); + let iterations = 0; + while (iterations < 10) { + let changed = false; + SCRIPT_DEFINITIONS.forEach((script) => { + if (!enabled[script.name]) return; + (script.dependencies || []).forEach((dep) => { + if (!enabled[dep]) { + enabled[dep] = true; + changed = true; + } + }); + }); + if (!changed) break; + iterations++; + } + return enabled; + } + + function collectAllDependencies(scriptDef, enabled, byName, collected, visited) { + collected = collected || new Set(); + visited = visited || new Set(); + if (visited.has(scriptDef.name)) return collected; + visited.add(scriptDef.name); + for (const depName of scriptDef.dependencies || []) { + if (!enabled[depName]) continue; + const depScript = byName.get(depName); + if (!depScript) continue; + if (!collected.has(depScript.name)) { + collected.add(depScript.name); + collectAllDependencies(depScript, enabled, byName, collected, visited); + } + } + return collected; + } + + function topoSortScriptNames(names, byName) { + const nameSet = new Set(names); + const indegree = new Map(); + const edges = new Map(); + names.forEach((n) => { + indegree.set(n, 0); + edges.set(n, []); + }); + names.forEach((n) => { + const def = byName.get(n); + (def && def.dependencies || []).forEach((dep) => { + if (!nameSet.has(dep)) return; + edges.get(dep).push(n); + indegree.set(n, (indegree.get(n) || 0) + 1); + }); + }); + const orderIndex = new Map(names.map((n, i) => [n, i])); + const queue = names.filter((n) => (indegree.get(n) || 0) === 0); + queue.sort((a, b) => orderIndex.get(a) - orderIndex.get(b)); + const result = []; + while (queue.length) { + const n = queue.shift(); + result.push(n); + const nexts = edges.get(n) || []; + nexts.sort((a, b) => orderIndex.get(a) - orderIndex.get(b)); + nexts.forEach((m) => { + indegree.set(m, indegree.get(m) - 1); + if (indegree.get(m) === 0) queue.push(m); + }); + queue.sort((a, b) => orderIndex.get(a) - orderIndex.get(b)); + } + if (result.length !== names.length) { + WARN('Topo sort incomplete; appending remaining names'); + names.forEach((n) => { if (!result.includes(n)) result.push(n); }); + } + return result; + } + + function normalizeRoot(root) { + if (!root || typeof root !== 'string') return ''; + return root.endsWith('/') ? root : root + '/'; + } + + function resolveAssetUrl(root, relativePath) { + if (!relativePath) return null; + if (/^https?:\/\//i.test(relativePath)) return relativePath; + + const normalizedRoot = normalizeRoot(root); + const base = normalizedRoot + 'scripts/'; + const joined = (base + relativePath).replace(/\\/g, '/'); + + // Absolute http(s) root — use URL so .. segments resolve correctly + if (/^https?:\/\//i.test(normalizedRoot)) { + try { + return new URL(relativePath, base).href; + } catch (e) { + /* fall through */ + } + } + + // Site-absolute or relative path — collapse . / .. while preserving a leading "/" + const isSiteAbsolute = joined.charAt(0) === '/'; + const parts = joined.split('/'); + const stack = []; + for (const p of parts) { + if (!p || p === '.') continue; + if (p === '..') { + stack.pop(); + continue; + } + stack.push(p); + } + const resolved = stack.join('/'); + return isSiteAbsolute ? '/' + resolved : resolved; + } + + function expandScriptToAssets(scriptDef, root, urlSuffix, thirdParty) { + const assets = []; + const suffix = urlSuffix || ''; + for (const tpName of scriptDef.thirdParty || []) { + const tp = thirdParty[tpName]; + if (!tp) continue; + (tp.css || []).forEach((css) => { + const url = resolveAssetUrl(root, css); + if (url) assets.push({ kind: 'css', url: url + (/^https?:/i.test(css) ? '' : suffix), name: tpName }); + }); + (tp.js || []).forEach((js) => { + const url = resolveAssetUrl(root, js); + if (url) assets.push({ kind: 'js', url: url + (/^https?:/i.test(js) ? '' : suffix), name: tpName }); + }); + } + if (scriptDef.css) { + const url = resolveAssetUrl(root, scriptDef.css); + if (url) assets.push({ kind: 'css', url: url + suffix, name: scriptDef.name }); + } + if (scriptDef.script) { + const url = resolveAssetUrl(root, scriptDef.script); + if (url) assets.push({ kind: 'js', url: url + suffix, name: scriptDef.name }); + } + return assets; + } + + function buildOrderedScriptList(enabled, majorVersion, byName) { + const enabledScripts = SCRIPT_DEFINITIONS.filter( + (s) => enabled[s.name] && isScriptCompatible(s, majorVersion) + ); + const allDependencyNames = new Set(); + enabledScripts.forEach((script) => { + collectAllDependencies(script, enabled, byName).forEach((d) => allDependencyNames.add(d)); + }); + const dependencyNames = topoSortScriptNames( + SCRIPT_DEFINITIONS.map((s) => s.name).filter((n) => allDependencyNames.has(n) && enabled[n]), + byName + ); + const nonDeps = enabledScripts.filter((s) => !allDependencyNames.has(s.name)); + const priority = nonDeps.filter((s) => s.priority === true).map((s) => s.name); + const regular = nonDeps.filter((s) => !s.priority).map((s) => s.name); + return dependencyNames.concat(priority, regular); + } + + function buildConfigScriptList(enabled, majorVersion, byName) { + const names = CONFIG_DEPENDENCY_NAMES.filter((n) => { + const def = byName.get(n); + if (!def) return false; + return isScriptCompatible(def, majorVersion); + }); + let changed = true; + while (changed) { + changed = false; + names.slice().forEach((n) => { + const def = byName.get(n); + (def && def.dependencies || []).forEach((dep) => { + if (!names.includes(dep) && byName.has(dep) && isScriptCompatible(byName.get(dep), majorVersion)) { + names.push(dep); + changed = true; + } + }); + }); + } + return topoSortScriptNames(names, byName); + } + + function buildLoadPlan(config, majorVersion, options) { + options = options || {}; + const root = normalizeRoot(options.root || (config && config.kefinTweaksRoot) || ''); + if (!root) throw new Error('kefinTweaksRoot is required to build load plan'); + const urlSuffix = options.urlSuffix || ''; + const byName = definitionsByName(); + const enabled = mergeEnabledScripts(config); + const featuresDisabled = (config && config.enabled === false) || options.configOnly === true; + + let scriptNames; + if (featuresDisabled) { + scriptNames = buildConfigScriptList(enabled, majorVersion, byName); + } else { + scriptNames = buildOrderedScriptList(enabled, majorVersion, byName); + const configNames = buildConfigScriptList(enabled, majorVersion, byName); + configNames.forEach((n) => { + if (!scriptNames.includes(n)) scriptNames.push(n); + }); + } + + const assets = []; + const seen = new Set(); + const pushAssets = (list) => { + list.forEach((a) => { + const key = a.kind + '|' + a.url; + if (seen.has(key)) return; + seen.add(key); + assets.push(a); + }); + }; + + scriptNames.forEach((name) => { + const def = byName.get(name); + if (!def) return; + pushAssets(expandScriptToAssets(def, root, urlSuffix, THIRD_PARTY_SCRIPTS)); + }); + + pushAssets([ + { kind: 'css', url: resolveAssetUrl(root, CONFIGURATION_FILES.css) + urlSuffix, name: 'configuration' }, + { kind: 'js', url: resolveAssetUrl(root, CONFIGURATION_FILES.script) + urlSuffix, name: 'configuration' } + ]); + + const stamp = [ + root, + String(majorVersion == null ? 'unknown' : majorVersion), + featuresDisabled ? 'config-only' : 'full', + scriptNames.join(',') + ].join('|'); + + return { + assets, + stamp, + scriptNames, + definitions: SCRIPT_DEFINITIONS, + root, + majorVersion + }; + } + + function ensureKefinTweaksApi(definitions) { + const defs = definitions || SCRIPT_DEFINITIONS; + const existing = window.KefinTweaks || {}; + window.KefinTweaks = { + _jellyfinMajorVersion: existing._jellyfinMajorVersion != null ? existing._jellyfinMajorVersion : null, + parseMajorVersion: parseMajorVersion, + getJellyfinMajorVersion: async function getJellyfinMajorVersion() { + try { + if (window.KefinTweaks._jellyfinMajorVersion != null) { + return window.KefinTweaks._jellyfinMajorVersion; + } + if (!window.ApiClient || (!window.ApiClient._appVersion && !window.ApiClient._serverVersion)) { + const startTime = Date.now(); + while (Date.now() - startTime < 5000) { + if (window.ApiClient && (window.ApiClient._appVersion || window.ApiClient._serverVersion)) break; + await new Promise((r) => setTimeout(r, 200)); + } + } + if (!window.ApiClient) return null; + if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) { + window.KefinTweaks._jellyfinMajorVersion = parseMajorVersion(window.ApiClient._appVersion); + return window.KefinTweaks._jellyfinMajorVersion; + } + if (!window.ApiClient._serverVersion) { + const startTime = Date.now(); + while (Date.now() - startTime < 10000) { + if (window.ApiClient._serverVersion) break; + await new Promise((r) => setTimeout(r, 500)); + } + } + window.KefinTweaks._jellyfinMajorVersion = parseMajorVersion(window.ApiClient._serverVersion); + return window.KefinTweaks._jellyfinMajorVersion; + } catch (e) { + WARN('getJellyfinMajorVersion failed:', e); + return null; + } + }, + getScripts: function () { return defs.slice(); } + }; + return window.KefinTweaks; + } + + function applyAssetsToDocument(assets) { + (assets || []).forEach((a) => { + if (!a || !a.url) return; + if (a.kind === 'css') { + if (document.querySelector('link[href="' + a.url + '"]')) return; + const l = document.createElement('link'); + l.rel = 'stylesheet'; + l.type = 'text/css'; + l.href = a.url; + document.head.appendChild(l); + } else { + if (document.querySelector('script[src="' + a.url + '"]')) return; + const s = document.createElement('script'); + s.src = a.url; + s.async = false; + document.head.appendChild(s); + } + }); + } + + function extractInjectorStamp(scriptText) { + if (typeof scriptText === 'string' && scriptText) { + const marker = scriptText.match(/\/\/\s*kefinTweaksInjectorStamp=(.+)/); + if (marker && marker[1]) { + return String(marker[1]).trim(); + } + const winAssign = scriptText.match(/window\.KefinTweaksInjectorStamp\s*=\s*("(?:\\.|[^"\\])*")/); + if (winAssign && winAssign[1]) { + try { + return JSON.parse(winAssign[1]); + } catch (e) { /* ignore */ } + } + const eventStamp = scriptText.match(/stamp:\s*("(?:\\.|[^"\\])*")/); + if (eventStamp && eventStamp[1]) { + try { + return JSON.parse(eventStamp[1]); + } catch (e) { /* ignore */ } + } + } + if (typeof window !== 'undefined' && window.KefinTweaksInjectorStamp != null) { + return String(window.KefinTweaksInjectorStamp); + } + return null; + } + + function buildInjectorScript(loadPlan) { + const assetsJson = JSON.stringify(loadPlan.assets || []); + const defsJson = JSON.stringify(loadPlan.definitions || SCRIPT_DEFINITIONS); + const stamp = loadPlan.stamp || ''; + const stampJson = JSON.stringify(stamp); + return [ + '// KefinTweaks-injector — auto-generated; do not edit', + '// kefinTweaksInjectorStamp=' + stamp, + '(function () {', + ' if (window.KefinTweaksScriptsPreloaded) return;', + ' window.KefinTweaksScriptsPreloaded = true;', + ' window.KefinTweaksInjectorStamp = ' + stampJson + ';', + ' var SCRIPT_DEFINITIONS_BAKED = ' + defsJson + ';', + ' function parseMajorVersion(version) {', + " if (!version || typeof version !== 'string') return null;", + " var versionParts = version.split('.');", + " if (versionParts[0] !== '10') {", + ' var majorVersion = parseInt(versionParts[0], 10);', + ' return isNaN(majorVersion) ? null : majorVersion;', + ' }', + ' if (versionParts.length >= 2) {', + ' var majorVersion2 = parseInt(versionParts[1], 10);', + ' if (!isNaN(majorVersion2)) return majorVersion2;', + ' }', + ' return null;', + ' }', + ' window.KefinTweaks = {', + ' _jellyfinMajorVersion: null,', + ' parseMajorVersion: parseMajorVersion,', + ' getJellyfinMajorVersion: async function () {', + ' try {', + ' if (window.KefinTweaks._jellyfinMajorVersion != null) return window.KefinTweaks._jellyfinMajorVersion;', + ' if (!window.ApiClient || (!window.ApiClient._appVersion && !window.ApiClient._serverVersion)) {', + ' var start = Date.now();', + ' while (Date.now() - start < 5000) {', + ' if (window.ApiClient && (window.ApiClient._appVersion || window.ApiClient._serverVersion)) break;', + ' await new Promise(function (r) { setTimeout(r, 200); });', + ' }', + ' }', + ' if (!window.ApiClient) return null;', + " if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {", + ' window.KefinTweaks._jellyfinMajorVersion = parseMajorVersion(window.ApiClient._appVersion);', + ' return window.KefinTweaks._jellyfinMajorVersion;', + ' }', + ' if (!window.ApiClient._serverVersion) {', + ' var start2 = Date.now();', + ' while (Date.now() - start2 < 10000) {', + ' if (window.ApiClient._serverVersion) break;', + ' await new Promise(function (r) { setTimeout(r, 500); });', + ' }', + ' }', + ' window.KefinTweaks._jellyfinMajorVersion = parseMajorVersion(window.ApiClient._serverVersion);', + ' return window.KefinTweaks._jellyfinMajorVersion;', + ' } catch (e) { return null; }', + ' },', + ' getScripts: function () { return SCRIPT_DEFINITIONS_BAKED.slice(); }', + ' };', + ' window.KefinTweaks.getJellyfinMajorVersion();', + ' var assets = ' + assetsJson + ';', + ' for (var i = 0; i < assets.length; i++) {', + ' var a = assets[i];', + ' if (!a || !a.url) continue;', + ' if (a.kind === \'css\') {', + ' if (document.querySelector(\'link[href="\' + a.url + \'"]\')) continue;', + ' var l = document.createElement(\'link\');', + ' l.rel = \'stylesheet\';', + ' l.type = \'text/css\';', + ' l.href = a.url;', + ' document.head.appendChild(l);', + ' } else {', + ' if (document.querySelector(\'script[src="\' + a.url + \'"]\')) continue;', + ' var s = document.createElement(\'script\');', + ' s.src = a.url;', + ' s.async = false;', + ' document.head.appendChild(s);', + ' }', + ' }', + ' try {', + ' document.dispatchEvent(new CustomEvent(\'kefinTweaksLoaded\', {', + ' detail: { stamp: ' + stampJson + ', timestamp: new Date().toISOString() }', + ' }));', + ' } catch (e) {}', + '})();' + ].join('\n'); + } + + function upsertInjectorEntries(customJavaScripts, opts) { + opts = opts || {}; + const list = Array.isArray(customJavaScripts) ? customJavaScripts.slice() : []; + const upsert = (name, scriptContent) => { + const idx = list.findIndex((s) => s.Name === name); + const entry = { + Name: name, + Script: scriptContent, + Enabled: true, + RequiresAuthentication: false + }; + if (idx !== -1) list[idx] = Object.assign({}, list[idx], entry); + else list.push(entry); + }; + if (opts.configScriptContent != null) upsert(KEFIN_CONFIG_ENTRY_NAME, opts.configScriptContent); + if (opts.injectorScriptContent != null) upsert(KEFIN_INJECTOR_ENTRY_NAME, opts.injectorScriptContent); + + const configIdx = list.findIndex((s) => s.Name === KEFIN_CONFIG_ENTRY_NAME); + const injIdx = list.findIndex((s) => s.Name === KEFIN_INJECTOR_ENTRY_NAME); + if (configIdx !== -1 && injIdx !== -1 && configIdx > injIdx) { + const cfg = list.splice(configIdx, 1)[0]; + const newInjIdx = list.findIndex((s) => s.Name === KEFIN_INJECTOR_ENTRY_NAME); + list.splice(newInjIdx, 0, cfg); + } + return list; + } + + function hasEnabledInjectorEntry(customJavaScripts) { + const list = Array.isArray(customJavaScripts) ? customJavaScripts : []; + const entry = list.find((s) => s.Name === KEFIN_INJECTOR_ENTRY_NAME); + return !!(entry && entry.Enabled !== false); + } + + function getInjectorAuthHeader() { + const token = window.ApiClient && typeof window.ApiClient.accessToken === 'function' + ? window.ApiClient.accessToken() + : ''; + if (!token) return null; + const client = typeof window.ApiClient.applicationName === 'function' + ? window.ApiClient.applicationName() + : 'Jellyfin Web'; + const device = typeof window.ApiClient.deviceName === 'function' + ? window.ApiClient.deviceName() + : 'Browser'; + const deviceId = typeof window.ApiClient.deviceId === 'function' + ? window.ApiClient.deviceId() + : ''; + const version = window.ApiClient._appVersion || window.ApiClient._serverVersion || ''; + return 'MediaBrowser Client="' + encodeURIComponent(client) + + '", Device="' + encodeURIComponent(device) + + '", DeviceId="' + encodeURIComponent(deviceId) + + '", Version="' + encodeURIComponent(version) + + '", Token="' + encodeURIComponent(token) + '"'; + } + + function isLoggedInForInjectorSync() { + try { + return !!(window.ApiClient + && window.ApiClient._loggedIn + && typeof window.ApiClient.accessToken === 'function' + && window.ApiClient.accessToken() + && window.ApiClient._serverAddress); + } catch (e) { + return false; + } + } + + async function isAdminForInjectorSync() { + try { + if (!window.ApiClient || typeof window.ApiClient.getCurrentUser !== 'function') return false; + const user = await window.ApiClient.getCurrentUser(); + return !!(user && user.Policy && user.Policy.IsAdministrator === true); + } catch (e) { + return false; + } + } + + async function findJavaScriptInjectorPluginId() { + const server = window.ApiClient && window.ApiClient._serverAddress; + const auth = getInjectorAuthHeader(); + if (!server || !auth) return null; + const response = await fetch(server + '/Plugins', { + headers: { Authorization: auth } + }); + if (!response.ok) throw new Error('HTTP ' + response.status + ': ' + response.statusText); + const plugins = await response.json(); + const list = Array.isArray(plugins) ? plugins : (plugins.Items || []); + const plugin = list.find((p) => p.Name === 'JavaScript Injector' || p.Name === 'JS Injector'); + return plugin ? plugin.Id : null; + } + + /** + * Rewrite baked KefinTweaks-injector when live plan stamp differs. + * Admin + logged-in only. Leaves KefinTweaks-Config unchanged. + * @param {{ stamp?: string, assets?: Array, definitions?: Array }} loadPlan + * @returns {Promise<{ synced: boolean, reason?: string, stamp?: string }>} + */ + async function syncKefinTweaksInjector(loadPlan) { + if (!loadPlan || !loadPlan.stamp) { + return { synced: false, reason: 'no load plan stamp' }; + } + if (!isLoggedInForInjectorSync()) { + return { synced: false, reason: 'not logged in' }; + } + if (!(await isAdminForInjectorSync())) { + return { synced: false, reason: 'not admin' }; + } + + try { + const pluginId = await findJavaScriptInjectorPluginId(); + if (!pluginId) { + return { synced: false, reason: 'JavaScript Injector plugin not found' }; + } + + const server = window.ApiClient._serverAddress; + const auth = getInjectorAuthHeader(); + const configUrl = server + '/Plugins/' + pluginId + '/Configuration'; + const getResponse = await fetch(configUrl, { + headers: { Authorization: auth } + }); + if (!getResponse.ok) { + return { synced: false, reason: 'GET failed HTTP ' + getResponse.status }; + } + + const injectorConfig = await getResponse.json(); + if (!injectorConfig.CustomJavaScripts) { + injectorConfig.CustomJavaScripts = []; + } + + const existing = (injectorConfig.CustomJavaScripts || []).find( + (s) => s.Name === KEFIN_INJECTOR_ENTRY_NAME + ); + const existingStamp = existing ? extractInjectorStamp(existing.Script) : null; + if (existingStamp && existingStamp === loadPlan.stamp) { + LOG('KefinTweaks-injector stamp up to date:', loadPlan.stamp); + return { synced: false, reason: 'stamp match', stamp: loadPlan.stamp }; + } + + const injectorScriptContent = buildInjectorScript(loadPlan); + injectorConfig.CustomJavaScripts = upsertInjectorEntries( + injectorConfig.CustomJavaScripts, + { injectorScriptContent } + ); + + const postResponse = await fetch(configUrl, { + method: 'POST', + headers: { + Authorization: auth, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(injectorConfig) + }); + if (!postResponse.ok) { + return { + synced: false, + reason: 'POST failed HTTP ' + postResponse.status + ': ' + postResponse.statusText + }; + } + + window.KefinTweaksInjectorStamp = loadPlan.stamp; + LOG( + 'Synced KefinTweaks-injector (', + (loadPlan.assets && loadPlan.assets.length) || 0, + 'assets). stamp:', + existingStamp || '(none)', + '->', + loadPlan.stamp + ); + return { synced: true, stamp: loadPlan.stamp }; + } catch (err) { + WARN('syncKefinTweaksInjector failed:', err); + return { synced: false, reason: String(err && err.message ? err.message : err) }; + } + } + + window.KefinTweaksLoader = { + KEFIN_INJECTOR_ENTRY_NAME: KEFIN_INJECTOR_ENTRY_NAME, + KEFIN_CONFIG_ENTRY_NAME: KEFIN_CONFIG_ENTRY_NAME, + SCRIPT_DEFINITIONS: SCRIPT_DEFINITIONS, + DEFAULT_ENABLED_SCRIPTS: DEFAULT_ENABLED_SCRIPTS, + THIRD_PARTY_SCRIPTS: THIRD_PARTY_SCRIPTS, + CONFIG_DEPENDENCY_NAMES: CONFIG_DEPENDENCY_NAMES, + parseMajorVersion: parseMajorVersion, + isScriptCompatible: isScriptCompatible, + mergeEnabledScripts: mergeEnabledScripts, + normalizeRoot: normalizeRoot, + resolveAssetUrl: resolveAssetUrl, + buildLoadPlan: buildLoadPlan, + buildInjectorScript: buildInjectorScript, + extractInjectorStamp: extractInjectorStamp, + syncKefinTweaksInjector: syncKefinTweaksInjector, + ensureKefinTweaksApi: ensureKefinTweaksApi, + applyAssetsToDocument: applyAssetsToDocument, + upsertInjectorEntries: upsertInjectorEntries, + hasEnabledInjectorEntry: hasEnabledInjectorEntry + }; + + LOG('Module loaded (' + SCRIPT_DEFINITIONS.length + ' script definitions)'); +})(); diff --git a/scripts/libraryCache.js b/scripts/libraryCache.js new file mode 100644 index 0000000..7fe0d23 --- /dev/null +++ b/scripts/libraryCache.js @@ -0,0 +1,78 @@ +// KefinTweaks Library Cache Facade +// Routes provider-id lookups and readiness checks to MoviesCache / SeriesCache +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks LibraryCache]', ...args); + + function normalizeType(type) { + const t = String(type || '').toLowerCase(); + if (t === 'movie' || t === 'movies') return 'Movie'; + if (t === 'series' || t === 'show' || t === 'shows' || t === 'tv') return 'Series'; + return type; + } + + function isComplete(type) { + const t = normalizeType(type); + if (t === 'Movie') return window.MoviesCache?.isComplete?.() === true; + if (t === 'Series') return window.SeriesCache?.isComplete?.() === true; + return false; + } + + function isReady(type) { + return isComplete(type); + } + + async function getByProviderIds(type, ids) { + const t = normalizeType(type); + if (t === 'Movie') { + return window.MoviesCache?.getMoviesByProviderIds?.(ids) || []; + } + if (t === 'Series') { + return window.SeriesCache?.getSeriesByProviderIds?.(ids) || []; + } + LOG('Unknown type for getByProviderIds:', type); + return []; + } + + /** + * Whether a dataSource string depends on an incomplete Movies/Series/People cache. + */ + function dataSourceDependsOnIncompleteCache(dataSource) { + const ds = typeof dataSource === 'string' ? dataSource : ''; + if (!ds) return false; + if (ds.startsWith('MoviesCache.') && !isComplete('Movie')) return true; + if (ds.startsWith('SeriesCache.') && !isComplete('Series')) return true; + if (ds.startsWith('PeopleCache.') && window.PeopleCache?.isComplete && !window.PeopleCache.isComplete()) { + return true; + } + return false; + } + + /** + * Whether a home section depends on a library/people/external cache that is not ready. + */ + function sectionDependsOnIncompleteCache(section) { + if (!section) return false; + if (Array.isArray(section.externalListUrls) && section.externalListUrls.length) { + return !(isComplete('Movie') && isComplete('Series')); + } + if (dataSourceDependsOnIncompleteCache(section.discoverySourceQuery?.dataSource)) { + return true; + } + const queries = section.queries || []; + for (const q of queries) { + if (dataSourceDependsOnIncompleteCache(q?.dataSource)) return true; + } + return false; + } + + window.LibraryCache = { + getByProviderIds, + isReady, + isComplete, + sectionDependsOnIncompleteCache + }; + + LOG('Module loaded'); +})(); diff --git a/scripts/libraryCacheUtils.js b/scripts/libraryCacheUtils.js new file mode 100644 index 0000000..b957c1b --- /dev/null +++ b/scripts/libraryCacheUtils.js @@ -0,0 +1,268 @@ +// KefinTweaks Library Cache Utils +// Shared strip / provider-id helpers for moviesCache, seriesCache, peopleCache +(function() { + 'use strict'; + + function stripPerson(person) { + if (!person) return null; + return { + Name: person.Name, + Id: person.Id, + Role: person.Role, + Type: person.Type + }; + } + + function stripChapter(chapter) { + if (!chapter) return null; + return { + Name: chapter.Name, + ImageTag: chapter.ImageTag + }; + } + + function stripMediaStream(stream) { + if (!stream) return null; + return { + Height: stream.Height, + Width: stream.Width, + DisplayTitle: stream.DisplayTitle, + Language: stream.Language, + Type: stream.Type + }; + } + + function stripStudio(studio) { + if (!studio) return null; + return { + Name: studio.Name, + Id: studio.Id + }; + } + + function stripGenreItem(genre) { + if (!genre) return null; + return { + Name: genre.Name, + Id: genre.Id + }; + } + + /** + * Strip a movie item to the durable library-cache allowlist (no UserData). + */ + function stripMovieForCache(item) { + if (!item) return null; + return { + Name: item.Name, + Id: item.Id, + HasSubtitles: item.HasSubtitles, + PremiereDate: item.PremiereDate, + CriticRating: item.CriticRating, + OfficialRating: item.OfficialRating, + Overview: item.Overview, + Taglines: item.Taglines, + CommunityRating: item.CommunityRating, + RunTimeTicks: item.RunTimeTicks, + ProductionYear: item.ProductionYear, + ProviderIds: item.ProviderIds || {}, + Type: item.Type || 'Movie', + People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [], + Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [], + GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [], + MediaStreams: Array.isArray(item.MediaStreams) ? item.MediaStreams.map(stripMediaStream).filter(Boolean) : [], + ImageTags: item.ImageTags || {}, + BackdropImageTags: item.BackdropImageTags || [], + Chapters: Array.isArray(item.Chapters) ? item.Chapters.map(stripChapter).filter(Boolean) : [], + Trickplay: item.Trickplay || undefined, + DateCreated: item.DateCreated, + DateLastSaved: item.DateLastSaved, + UserData: item.UserData || {} + }; + } + + /** + * Strip a series item (no MediaStreams/Chapters/Trickplay). + */ + function stripSeriesForCache(item) { + if (!item) return null; + return { + Name: item.Name, + Id: item.Id, + PremiereDate: item.PremiereDate, + CriticRating: item.CriticRating, + OfficialRating: item.OfficialRating, + Overview: item.Overview, + Taglines: item.Taglines, + CommunityRating: item.CommunityRating, + RunTimeTicks: item.RunTimeTicks, + ProductionYear: item.ProductionYear, + ProviderIds: item.ProviderIds || {}, + Type: item.Type || 'Series', + People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [], + Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [], + GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [], + ImageTags: item.ImageTags || {}, + BackdropImageTags: item.BackdropImageTags || [], + ChildCount: item.ChildCount, + RecursiveItemCount: item.RecursiveItemCount, + DateCreated: item.DateCreated, + DateLastContentAdded: item.DateLastContentAdded, + DateLastSaved: item.DateLastSaved, + UserData: item.UserData || {} + }; + } + + function stripItemsForCache(items, kind) { + const strip = kind === 'Series' ? stripSeriesForCache : stripMovieForCache; + return (items || []).map(strip).filter(Boolean); + } + + /** + * Normalize provider id keys for matching (Imdb/Tmdb/Tvdb). + * @param {string|Object} idOrObj + * @returns {string[]} normalized keys like "imdb:tt123", "tmdb:456" + */ + function normalizeProviderKeys(idOrObj) { + const keys = []; + if (!idOrObj) return keys; + if (typeof idOrObj === 'string') { + const s = idOrObj.trim(); + if (!s) return keys; + if (/^tt\d+$/i.test(s)) keys.push(`imdb:${s.toLowerCase()}`); + else if (/^\d+$/.test(s)) { + keys.push(`tmdb:${s}`); + keys.push(`tvdb:${s}`); + } else keys.push(s.toLowerCase()); + return keys; + } + const p = idOrObj.ProviderIds || idOrObj; + if (p.Imdb) keys.push(`imdb:${String(p.Imdb).toLowerCase()}`); + if (p.Tmdb) keys.push(`tmdb:${String(p.Tmdb)}`); + if (p.Tvdb) keys.push(`tvdb:${String(p.Tvdb)}`); + return keys; + } + + function itemMatchesProviderIds(item, idSet) { + if (!item || !idSet || idSet.size === 0) return false; + return normalizeProviderKeys(item).some(k => idSet.has(k)); + } + + function buildProviderIdIndex(items) { + const index = Object.create(null); + (items || []).forEach((item) => { + if (!item?.Id) return; + normalizeProviderKeys(item).forEach((key) => { + if (!index[key]) index[key] = []; + if (!index[key].includes(item.Id)) index[key].push(item.Id); + }); + }); + return index; + } + + function getLibraryCacheSettings() { + const fromHome = window.KefinHomeScreen?.getConfig?.()?.LIBRARY_CACHE; + const fromDefault = window.KefinHomeConfig2?.LIBRARY_CACHE; + return { + movieChunkSize: 1000, + seriesChunkSize: 1000, + mdblistApiKey: '', + CACHE_TTL: 14 * 24 * 60 * 60 * 1000, + ...(fromDefault || {}), + ...(fromHome || {}) + }; + } + + /** + * Run strip on main thread; yields between microtasks for UI breathing room. + * Worker path: Blob worker when available (same strip logic inlined). + */ + async function stripItemsAsync(items, kind) { + if (!items || !items.length) return []; + if (typeof Worker === 'undefined') { + return stripItemsForCache(items, kind); + } + try { + return await stripItemsInWorker(items, kind); + } catch (e) { + console.warn('[KefinTweaks LibraryCacheUtils] Worker strip failed, using main thread:', e); + return stripItemsForCache(items, kind); + } + } + + function stripItemsInWorker(items, kind) { + return new Promise((resolve, reject) => { + const workerSource = ` + ${stripPerson.toString()} + ${stripChapter.toString()} + ${stripMediaStream.toString()} + ${stripStudio.toString()} + ${stripGenreItem.toString()} + ${stripMovieForCache.toString()} + ${stripSeriesForCache.toString()} + ${stripItemsForCache.toString()} + self.onmessage = function(e) { + try { + const { items, kind } = e.data; + const result = stripItemsForCache(items, kind); + self.postMessage({ ok: true, result }); + } catch (err) { + self.postMessage({ ok: false, error: String(err && err.message || err) }); + } + }; + `; + const blob = new Blob([workerSource], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + const worker = new Worker(url); + const timer = setTimeout(() => { + worker.terminate(); + URL.revokeObjectURL(url); + reject(new Error('Worker strip timeout')); + }, 30000); + worker.onmessage = (e) => { + clearTimeout(timer); + worker.terminate(); + URL.revokeObjectURL(url); + if (e.data?.ok) resolve(e.data.result); + else reject(new Error(e.data?.error || 'Worker strip failed')); + }; + worker.onerror = (err) => { + clearTimeout(timer); + worker.terminate(); + URL.revokeObjectURL(url); + reject(err); + }; + worker.postMessage({ items, kind }); + }); + } + + function upsertById(existingItems, incomingItems) { + const map = new Map(); + (existingItems || []).forEach((item) => { + if (item?.Id) map.set(item.Id, item); + }); + (incomingItems || []).forEach((item) => { + if (item?.Id) map.set(item.Id, item); + }); + return Array.from(map.values()); + } + + function removeByIds(existingItems, removeIds) { + if (!removeIds || !removeIds.size) return existingItems || []; + return (existingItems || []).filter((item) => item?.Id && !removeIds.has(item.Id)); + } + + window.LibraryCacheUtils = { + stripMovieForCache, + stripSeriesForCache, + stripPerson, + stripItemsForCache, + stripItemsAsync, + normalizeProviderKeys, + itemMatchesProviderIds, + buildProviderIdIndex, + getLibraryCacheSettings, + upsertById, + removeByIds + }; +})(); diff --git a/scripts/moviesCache.js b/scripts/moviesCache.js index b7dc5f7..cd8b528 100644 --- a/scripts/moviesCache.js +++ b/scripts/moviesCache.js @@ -1,97 +1,335 @@ -// KefinTweaks Studios Cache Manager -// Handles fetching, processing, and caching of Popular TV Networks +// KefinTweaks Movies Library Cache +// Chunked per-user archive of movies (no UserData). Startup after login + incremental sync. (function() { 'use strict'; - const LOG = (...args) => console.log('[KefinTweaks Movies Cache]', ...args); - const WARN = (...args) => console.warn('[KefinTweaks Movies Cache]', ...args); - const ERR = (...args) => console.error('[KefinTweaks Movies Cache]', ...args); + const LOG = (...args) => console.log('[KefinTweaks Movies Cache]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks Movies Cache]', ...args); + const ERR = (...args) => console.error('[KefinTweaks Movies Cache]', ...args); - // 1 week - const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; - const IMDB_TOP_250_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours for ID list + const CACHE_NAME = 'movies_library'; + const PARTIAL_CACHE_NAME = 'movies_library_partial'; + const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedMovieCache_'; + const IMDB_TOP_250_CACHE_TTL = 24 * 60 * 60 * 1000; + const MOVIE_FIELDS = [ + 'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview', 'PrimaryImageAspectRatio', + 'DateCreated' + ].join(','); - // State let movies = null; + let isComplete = false; let imdbTop250Movies = null; - let isInitializing = false; + let fetchPromise = null; + let providerIndex = null; + + function utils() { + return window.LibraryCacheUtils || {}; + } + + function getTtl() { + return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000); + } + + function getChunkSize() { + return utils().getLibraryCacheSettings?.().movieChunkSize || 1000; + } + + function lastFetchStorageKey() { + const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous'; + return `${LAST_FETCH_KEY_PREFIX}${userId}`; + } + + function getLastDateFetched() { + try { + return localStorage.getItem(lastFetchStorageKey()) || null; + } catch (_) { + return null; + } + } + + function setLastDateFetched(iso) { + try { + localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString()); + } catch (e) { + WARN('Failed to store lastDateFetchedMovieCache:', e); + } + } + + function getApiHelper() { + return window.apiHelper || window.ApiHelper; + } + + function buildItemsUrl({ startIndex, limit, extraParams = {} }) { + const server = window.ApiClient.serverAddress(); + const params = new URLSearchParams({ + IncludeItemTypes: 'Movie', + Recursive: 'true', + Fields: MOVIE_FIELDS, + ExcludeLocationTypes: 'Virtual', + EnableTotalRecordCount: 'true', + SortBy: 'SortName', + SortOrder: 'Ascending', + StartIndex: String(startIndex || 0), + Limit: String(limit) + }); + Object.entries(extraParams).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') params.set(k, String(v)); + }); + return `${server}/Items?${params.toString()}`; + } + + async function queryItems(url) { + const api = getApiHelper(); + if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable'); + const result = await api.getQuery(url, { useCache: false }); + return result || { Items: [], TotalRecordCount: 0 }; + } + + async function stripChunk(items) { + if (utils().stripItemsAsync) { + return utils().stripItemsAsync(items, 'Movie'); + } + return (items || []).map((item) => utils().stripMovieForCache?.(item) || item).filter(Boolean); + } + + function rebuildProviderIndex(list) { + providerIndex = utils().buildProviderIdIndex?.(list) || null; + } /** - * Fetches and caches all movies across all libraries. - * You should not use this for anything related to UserData - * The cache for this is long, and this is intended to simply be an "archive" of all movies across all libraries. - * @returns {Promise} - Array of movie objects + * Full crawl with partial resume. Commits to movies_library only when complete. */ async function fetchAndCacheMovies() { - // IndexedDBCache - const cacheName = 'movies_library'; const cache = window.IndexedDBCache; - - // Check cache first - const cached = await cache.get(cacheName); - if (cached) { - LOG('Using cached Movies'); - return cached; - } - - try { - LOG('Fetching Movies...'); - - // We need to refactor this to pull the data in chunks of 100 items at a time in order to backfill the cache - // We will populate 100 items at a time into the library cache. - // Each group will contain the index of the last item in the group so we can resume from there - // When resuming, we should first verify if the previous item based on the same sorting and startindex/limit is still the same - // We just check the last item in the indexed db cache and make the same query starting at that index - // If it's the same then we can resume from there, if not then we need to start from the beginning - const allMoviesQuery = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds`; - - const movieResult = await apiHelper.getQuery(allMoviesQuery); - - // Resolve the data (cached or fresh) - let allMovies = []; - if (movieResult && movieResult.Items) { - allMovies = movieResult.Items; - } - - LOG(`Fetched ${allMovies.length} Movies`); - - // Cache the results - const userId = ApiClient.getCurrentUserId(); - await cache.set(cacheName, allMovies, userId, CACHE_TTL); - - return allMovies; - } catch (err) { - ERR('Error fetching Movies:', err); + if (!cache) { + ERR('IndexedDBCache unavailable'); return []; } + + const userId = window.ApiClient.getCurrentUserId(); + const complete = await cache.get(CACHE_NAME, userId); + if (complete && Array.isArray(complete)) { + movies = complete; + isComplete = true; + rebuildProviderIndex(movies); + LOG(`Using complete cached movies (${movies.length})`); + return movies; + } + + const chunkSize = getChunkSize(); + let startIndex = 0; + let collected = []; + + const partial = await cache.get(PARTIAL_CACHE_NAME, userId); + if (partial && Array.isArray(partial.items) && partial.isComplete !== true) { + collected = partial.items; + startIndex = partial.startIndex || collected.length; + LOG(`Resuming movie crawl from index ${startIndex} (${collected.length} items)`); + } + + try { + let hasMore = true; + while (hasMore) { + const url = buildItemsUrl({ startIndex, limit: chunkSize }); + const data = await queryItems(url); + const batch = data.Items || []; + const stripped = await stripChunk(batch); + collected = collected.concat(stripped); + startIndex += batch.length; + + await cache.set(PARTIAL_CACHE_NAME, { + isComplete: false, + startIndex, + items: collected + }, userId, getTtl()); + + LOG(`Movie crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`); + + if (batch.length < chunkSize) { + hasMore = false; + } else { + await yieldToMain(); + } + } + + movies = collected; + isComplete = true; + rebuildProviderIndex(movies); + await cache.set(CACHE_NAME, movies, userId, getTtl()); + await cache.clear(PARTIAL_CACHE_NAME, userId); + setLastDateFetched(new Date().toISOString()); + LOG(`Movie crawl complete: ${movies.length} items`); + return movies; + } catch (err) { + ERR('Error fetching movies:', err); + return movies || collected || []; + } } /** - * Initializes the cache + * Incremental sync: upsert by MinDateLastSaved, remove orphans via TotalRecordCount + Id list. */ - async function initialize() { - if (isInitializing) return; - - if (movies) return; - - isInitializing = true; - try { - movies = await fetchAndCacheMovies(); - } finally { - isInitializing = false; + async function syncMoviesCache() { + if (!isComplete || !movies) { + LOG('Skip syncMoviesCache: complete cache not ready'); + return movies || []; + } + const lastFetched = getLastDateFetched(); + if (!lastFetched) { + LOG('Skip syncMoviesCache: no lastDateFetched'); + return movies; } - } - /** - * Fetches IMDb Top 250 movies using client-side filtering - * @returns {Promise} - List of matched Movie items - */ + const cache = window.IndexedDBCache; + const userId = window.ApiClient.getCurrentUserId(); + const chunkSize = getChunkSize(); + + try { + // Additions / metadata refreshes + let startIndex = 0; + let incoming = []; + let hasMore = true; + while (hasMore) { + const url = buildItemsUrl({ + startIndex, + limit: chunkSize, + extraParams: { + MinDateLastSaved: lastFetched, + MinDateLastSavedForUser: lastFetched, + SortBy: 'DateCreated', + SortOrder: 'Descending' + } + }); + const data = await queryItems(url); + const batch = data.Items || []; + incoming = incoming.concat(await stripChunk(batch)); + startIndex += batch.length; + if (batch.length < chunkSize) hasMore = false; + } + + const beforeIds = new Set((movies || []).map((m) => m.Id).filter(Boolean)); + let removedIds = []; + + if (incoming.length) { + movies = utils().upsertById?.(movies, incoming) || movies; + LOG(`Synced ${incoming.length} movie upserts`); + } + + // Count check + const countUrl = buildItemsUrl({ + startIndex: 0, + limit: 1, + extraParams: { + Fields: '', + EnableTotalRecordCount: 'true' + } + }); + // Rebuild without heavy Fields for count + const server = window.ApiClient.serverAddress(); + const countParams = new URLSearchParams({ + IncludeItemTypes: 'Movie', + Recursive: 'true', + ExcludeLocationTypes: 'Virtual', + EnableTotalRecordCount: 'true', + Limit: '1', + StartIndex: '0' + }); + const countData = await queryItems(`${server}/Items?${countParams.toString()}`); + const serverCount = countData.TotalRecordCount; + if (typeof serverCount === 'number' && serverCount !== movies.length) { + LOG(`Count mismatch cache=${movies.length} server=${serverCount}; reconciling ids`); + const serverIds = new Set(); + let idx = 0; + let more = true; + while (more) { + const idParams = new URLSearchParams({ + IncludeItemTypes: 'Movie', + Recursive: 'true', + ExcludeLocationTypes: 'Virtual', + EnableTotalRecordCount: 'false', + Limit: String(chunkSize), + StartIndex: String(idx), + Fields: '' + }); + const idData = await queryItems(`${server}/Items?${idParams.toString()}`); + const batch = idData.Items || []; + batch.forEach((item) => { + if (item?.Id) serverIds.add(item.Id); + }); + idx += batch.length; + if (batch.length < chunkSize) more = false; + } + const before = movies.length; + removedIds = [...beforeIds].filter((id) => !serverIds.has(id)); + movies = movies.filter((m) => m?.Id && serverIds.has(m.Id)); + // Add any missing ids not in cache (light fetch would lack fields — full upsert via Id list) + const cachedIds = new Set(movies.map((m) => m.Id)); + const missing = [...serverIds].filter((id) => !cachedIds.has(id)); + if (missing.length) { + WARN(`${missing.length} movies on server missing from cache after reconcile; fetching by Ids`); + for (let i = 0; i < missing.length; i += chunkSize) { + const slice = missing.slice(i, i + chunkSize); + const byIdParams = new URLSearchParams({ + Ids: slice.join(','), + Fields: MOVIE_FIELDS, + EnableTotalRecordCount: 'false' + }); + const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`); + const stripped = await stripChunk(byIdData.Items || []); + movies = utils().upsertById?.(movies, stripped) || movies; + incoming = incoming.concat(stripped); + } + } + LOG(`Reconcile complete: ${before} → ${movies.length}`); + } + + rebuildProviderIndex(movies); + await cache.set(CACHE_NAME, movies, userId, getTtl()); + setLastDateFetched(new Date().toISOString()); + // Notify people cache of deltas when available + if ((incoming.length || removedIds.length) && window.PeopleCache?.applyMovieDelta) { + try { + await window.PeopleCache.applyMovieDelta(incoming, removedIds); + } catch (e) { + WARN('PeopleCache.applyMovieDelta failed:', e); + } + } + return movies; + } catch (err) { + ERR('syncMoviesCache failed:', err); + return movies || []; + } + } + + async function getMovies() { + if (movies && isComplete) return movies; + if (fetchPromise) return fetchPromise; + fetchPromise = (async () => { + await fetchAndCacheMovies(); + return movies || []; + })().finally(() => { + fetchPromise = null; + }); + return fetchPromise; + } + + function isCacheComplete() { + return isComplete === true && Array.isArray(movies); + } + + async function getMoviesByProviderIds(ids) { + const list = await getMovies(); + const idSet = new Set(); + (ids || []).forEach((id) => { + (utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k)); + }); + if (!idSet.size) return []; + return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet)); + } + async function fetchImdbTop250Data() { const CACHE_KEY_IDS = 'kefinTweaks_imdbTop250_ids'; - let imdbIds = null; - - // 1. Get IDs (Local Cache or Fetch GitHub) try { const cachedIds = localStorage.getItem(CACHE_KEY_IDS); if (cachedIds) { @@ -100,7 +338,9 @@ imdbIds = parsed.ids; } } - } catch (e) { WARN('Error reading IMDb Top 250 cache:', e); } + } catch (e) { + WARN('Error reading IMDb Top 250 cache:', e); + } if (!imdbIds) { LOG('Fetching IMDb Top 250 list from GitHub...'); @@ -108,15 +348,10 @@ const response = await fetch('https://raw.githubusercontent.com/theapache64/top250/master/top250_min.json'); if (!response.ok) throw new Error('Failed to fetch Top 250 JSON'); const data = await response.json(); - - // Extract IDs - imdbIds = data.map(entry => { - // entry.imdb_url e.g. "http://www.imdb.com/title/tt0111161/" + imdbIds = data.map((entry) => { const match = entry.imdb_url && entry.imdb_url.match(/\/title\/(tt\d+)/); return match ? match[1] : null; }).filter(Boolean); - - // Cache localStorage.setItem(CACHE_KEY_IDS, JSON.stringify({ timestamp: Date.now(), ids: imdbIds @@ -126,61 +361,127 @@ return []; } } - - if (!imdbIds || imdbIds.length === 0) return []; - - return imdbIds; + return imdbIds || []; } async function getImdbTop250Movies() { const cacheName = 'imdbTop250Movies'; const cache = window.IndexedDBCache; const cached = await cache.get(cacheName); - if (cached) { - return cached; - } + if (cached) return cached; if (!imdbTop250Movies) { + if (!isCacheComplete()) { + LOG('IMDb Top 250 waiting for movie cache…'); + } const imdbIds = await fetchImdbTop250Data(); const allMovies = await getMovies(); - - // 3. Client-side Match - const matchedMovies = []; - const idsSet = new Set(imdbIds); // Faster lookup - - for (const movie of allMovies) { - if (movie.ProviderIds && movie.ProviderIds.Imdb && idsSet.has(movie.ProviderIds.Imdb)) { - matchedMovies.push(movie); - } - } - imdbTop250Movies = matchedMovies; + const idsSet = new Set(imdbIds); + imdbTop250Movies = allMovies.filter( + (movie) => movie.ProviderIds?.Imdb && idsSet.has(movie.ProviderIds.Imdb) + ); } - const userId = ApiClient.getCurrentUserId(); + const userId = window.ApiClient.getCurrentUserId(); await cache.set(cacheName, imdbTop250Movies, userId, IMDB_TOP_250_CACHE_TTL); return imdbTop250Movies || []; } - async function getMovies() { - if (!movies) { - movies = await fetchAndCacheMovies(); + async function initialize() { + await getMovies(); + if (isCacheComplete() && getLastDateFetched()) { + await syncMoviesCache(); } return movies || []; } - // Expose API + async function waitForLoginReady(maxWaitMs = 60000) { + const start = Date.now(); + while (!window.userHelper?.waitForLogin) { + if (Date.now() - start >= maxWaitMs) { + ERR('Timed out waiting for userHelper'); + return false; + } + await new Promise((r) => setTimeout(r, 100)); + } + return window.userHelper.waitForLogin(maxWaitMs); + } + + function yieldToMain() { + return new Promise((resolve) => { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(() => resolve(), { timeout: 500 }); + } else { + requestAnimationFrame(() => setTimeout(resolve, 0)); + } + }); + } + + let bootstrapStarted = false; + let bootstrapScheduled = false; + + async function bootstrap() { + if (bootstrapStarted) return; + bootstrapStarted = true; + try { + await waitForLoginReady(); + await initialize(); + } catch (e) { + ERR('Bootstrap failed:', e); + } + } + + /** 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); + }); + } + window.MoviesCache = { init: initialize, - getMovies: getMovies, - getImdbTop250Movies: getImdbTop250Movies, - // Also expose the fetcher if we need to force refresh + getMovies, + getImdbTop250Movies, + getMoviesByProviderIds, + syncMoviesCache, + fetchAndCacheMovies, + isComplete: isCacheComplete, + getLastDateFetched, + bootstrap, + scheduleBootstrap, refresh: async () => { const cache = window.IndexedDBCache; - cache.delete('movies_library'); - movies = await fetchAndCacheMovies(); - return movies; + const userId = window.ApiClient?.getCurrentUserId?.(); + if (cache) { + await cache.clear(CACHE_NAME, userId); + await cache.clear(PARTIAL_CACHE_NAME, userId); + } + movies = null; + isComplete = false; + providerIndex = null; + imdbTop250Movies = null; + return fetchAndCacheMovies(); } }; + scheduleBootstrap(); LOG('Module loaded'); - })(); diff --git a/scripts/peopleCache.js b/scripts/peopleCache.js index cba0c1a..284b589 100644 --- a/scripts/peopleCache.js +++ b/scripts/peopleCache.js @@ -1,5 +1,5 @@ -// KefinTweaks People Cache Manager -// Handles fetching, processing, and caching of top actors, directors, and writers +// KefinTweaks People Cache +// Built from MoviesCache + SeriesCache; episode credits via /Shows/{id}/Episodes (not a global episode store). (function() { 'use strict'; @@ -7,338 +7,647 @@ const WARN = (...args) => console.warn('[KefinTweaks PeopleCache]', ...args); const ERR = (...args) => console.error('[KefinTweaks PeopleCache]', ...args); - // Configuration - const MIN_PEOPLE_APPEARANCES = 2; // Minimum number of items a person must appear in to be tracked + const CACHE_NAME = 'library_top_people_v2'; + const DEFAULT_MIN = 10; + const DEFAULT_PER_TYPE_MAX = 100; + const EPISODE_FETCH_CONCURRENCY = 6; - // State - let moviesTopPeople = null; + let peopleMap = null; // Map + let filteredLists = null; // { actors, directors, writers } let isComplete = false; - /** @type {Promise<*>|null} Shared so concurrent callers await the same paginated fetch */ let fetchPromise = null; - /** @type {Promise|null} Shared so concurrent callers await the same build */ let initPromise = null; - /** - * Extracts necessary item data for caching (similar to watchlist format) - */ - function extractItemDataForCache(item) { + function getHomeSettings() { + return window.KefinHomeScreen?.getConfig?.()?.HOME_SETTINGS + || window.KefinHomeConfig2?.HOME_SETTINGS + || {}; + } + + function getMins() { + const home = getHomeSettings(); return { - Id: item.Id, - Type: item.Type, - Name: item.Name, - SeriesId: item.SeriesId, - SeriesName: item.SeriesName, - IndexNumber: item.IndexNumber, - ParentIndexNumber: item.ParentIndexNumber, - PremiereDate: item.PremiereDate, - ProductionYear: item.ProductionYear, - UserData: { - Played: item.UserData?.Played || false, - IsFavorite: item.UserData?.IsFavorite || false - } + total: home.minPeopleAppearancesTotal ?? DEFAULT_MIN, + movies: home.minPeopleAppearancesMovies ?? DEFAULT_MIN, + series: home.minPeopleAppearancesSeries ?? DEFAULT_MIN, + episodes: home.minPeopleAppearancesEpisodes ?? DEFAULT_MIN }; } - /** - * Processes people data from movies array and returns RAW data (all people, unfiltered) - */ - function processPeopleDataRaw(movies, existingPeopleMap = null) { - try { - const peopleMap = existingPeopleMap ? new Map(existingPeopleMap) : new Map(); - - movies.forEach(movie => { - // Extract item data for caching - const itemData = extractItemDataForCache(movie); - - if (movie.People) { - movie.People.forEach(person => { - const key = person.Id; - if (!peopleMap.has(key)) { - peopleMap.set(key, { - ...person, - directorCount: 0, - writerCount: 0, - actorCount: 0, - directorItems: [], - writerItems: [], - actorItems: [] - }); - } - - const personData = peopleMap.get(key); - if (person.Type === 'Director') { - personData.directorCount++; - if (!personData.directorItems.some(item => item.Id === movie.Id)) { - personData.directorItems.push(itemData); - } - } else if (person.Type === 'Writer') { - personData.writerCount++; - if (!personData.writerItems.some(item => item.Id === movie.Id)) { - personData.writerItems.push(itemData); - } - } else if (person.Type === 'Actor') { - personData.actorCount++; - if (!personData.actorItems.some(item => item.Id === movie.Id)) { - personData.actorItems.push(itemData); - } - } - }); + function getPerTypeMaxCount() { + const max = getHomeSettings().maxPeopleCount; + const n = parseInt(max, 10); + if (Number.isFinite(n) && n > 0) return Math.min(n, DEFAULT_PER_TYPE_MAX); + return DEFAULT_PER_TYPE_MAX; + } + + function shouldLoadPeopleEpisodeData() { + return getHomeSettings().loadPeopleEpisodeData === true; + } + + function isAggregateItemType(itemType) { + return itemType == null || itemType === '' || itemType === 'All'; + } + + function movieRoleCount(person, role) { + const m = person.movies || {}; + if (role === 'Actor') return m.actorCount || 0; + if (role === 'Director') return m.directorCount || 0; + if (role === 'Writer') return m.writerCount || 0; + return 0; + } + + function episodeRoleCount(person, role) { + const s = person.series || {}; + if (role === 'Actor') return s.actorCount || 0; + if (role === 'Director') return s.directorCount || 0; + if (role === 'Writer') return s.writerCount || 0; + return 0; + } + + function totalAppearances(person) { + const m = person.movies || {}; + const s = person.series || {}; + return (m.actorCount || 0) + + (m.directorCount || 0) + + (m.writerCount || 0) + + (s.count || 0) + + (s.actorCount || 0) + + (s.directorCount || 0) + + (s.writerCount || 0); + } + + function personMeetsMin(person, mins) { + const m = person.movies || {}; + const s = person.series || {}; + const episodeMax = Math.max(s.actorCount || 0, s.directorCount || 0, s.writerCount || 0); + return (m.actorCount >= mins.movies) + || (m.directorCount >= mins.movies) + || (m.writerCount >= mins.movies) + || ((s.count || 0) >= mins.series) + || (episodeMax >= mins.episodes) + || (totalAppearances(person) >= mins.total); + } + + function prunePeopleMap(map) { + if (!map) return map; + const mins = getMins(); + const beforeMin = map.size; + for (const [id, person] of map) { + if (!personMeetsMin(person, mins)) map.delete(id); + } + if (beforeMin !== map.size) { + LOG(`People prune (mins): ${beforeMin} → ${map.size}`); + } + + const maxPerBucket = getPerTypeMaxCount(); + const keepIds = new Set(); + const itemTypes = ['Movie', 'Series', 'Episode', null]; + const roles = ['Actor', 'Director', 'Writer']; + for (const itemType of itemTypes) { + for (const role of roles) { + filterRoleList(role, itemType).slice(0, maxPerBucket).forEach((p) => keepIds.add(p.Id)); + } + } + const beforeMax = map.size; + for (const id of Array.from(map.keys())) { + if (!keepIds.has(id)) map.delete(id); + } + if (beforeMax !== map.size) { + LOG(`People prune (per-type max ${maxPerBucket}): ${beforeMax} → ${map.size}`); + } + return map; + } + + function ensurePerson(map, person) { + const key = person?.Id; + if (!key) return null; + if (!map.has(key)) { + map.set(key, { + Id: person.Id, + Name: person.Name, + movies: { + directorCount: 0, + writerCount: 0, + actorCount: 0, + directorItems: [], + writerItems: [], + actorItems: [] + }, + series: { + count: 0, + items: [], + actorCount: 0, + directorCount: 0, + writerCount: 0 } }); - - return peopleMap; - } catch (err) { - ERR('Failed to process people data:', err); - return null; + } else if (person.Name && !map.get(key).Name) { + map.get(key).Name = person.Name; + } + return map.get(key); + } + + function pushUniqueId(arr, id) { + if (!id) return false; + if (arr.includes(id)) return false; + arr.push(id); + return true; + } + + function bumpEpisodeRole(series, type, delta) { + if (!series || !delta) return; + if (type === 'Director') { + series.directorCount = Math.max(0, (series.directorCount || 0) + delta); + } else if (type === 'Writer') { + series.writerCount = Math.max(0, (series.writerCount || 0) + delta); + } else if (type === 'Actor') { + series.actorCount = Math.max(0, (series.actorCount || 0) + delta); } } + function applyMoviePeople(map, movie) { + if (!movie?.People || !movie.Id) return; + movie.People.forEach((person) => { + const personData = ensurePerson(map, person); + if (!personData) return; + const m = personData.movies; + if (person.Type === 'Director') { + if (pushUniqueId(m.directorItems, movie.Id)) m.directorCount = m.directorItems.length; + } else if (person.Type === 'Writer') { + if (pushUniqueId(m.writerItems, movie.Id)) m.writerCount = m.writerItems.length; + } else if (person.Type === 'Actor') { + if (pushUniqueId(m.actorItems, movie.Id)) m.actorCount = m.actorItems.length; + } + }); + } + + function applySeriesPeople(map, seriesItem) { + if (!seriesItem?.People || !seriesItem.Id) return; + seriesItem.People.forEach((person) => { + const personData = ensurePerson(map, person); + if (!personData) return; + if (pushUniqueId(personData.series.items, seriesItem.Id)) { + personData.series.count = personData.series.items.length; + } + }); + } + + function applyEpisodePeople(map, seriesId, episode) { + if (!episode?.People || !episode.Id || !seriesId) return; + episode.People.forEach((person) => { + const personData = ensurePerson(map, person); + if (!personData) return; + bumpEpisodeRole(personData.series, person.Type, 1); + }); + } + + function decrementEpisodePeople(map, episode) { + if (!episode?.People || !map) return; + episode.People.forEach((person) => { + const personData = person?.Id ? map.get(person.Id) : null; + if (!personData) return; + bumpEpisodeRole(personData.series, person.Type, -1); + }); + } + + function removeMovieFromPeople(map, movieId) { + if (!map || !movieId) return; + map.forEach((person) => { + const m = person.movies; + ['directorItems', 'writerItems', 'actorItems'].forEach((key) => { + const before = m[key].length; + m[key] = m[key].filter((id) => id !== movieId); + if (m[key].length !== before) { + if (key === 'directorItems') m.directorCount = m[key].length; + if (key === 'writerItems') m.writerCount = m[key].length; + if (key === 'actorItems') m.actorCount = m[key].length; + } + }); + }); + } + + function removeSeriesMembership(map, seriesId) { + if (!map || !seriesId) return; + map.forEach((person) => { + person.series.items = person.series.items.filter((id) => id !== seriesId); + person.series.count = person.series.items.length; + }); + } + + async function fetchEpisodesForSeries(seriesId) { + const api = window.apiHelper || window.ApiHelper; + const server = window.ApiClient.serverAddress(); + const url = `${server}/Shows/${seriesId}/Episodes?Fields=People&EnableTotalRecordCount=false`; + const data = await api.getQuery(url, { useCache: false }); + return data?.Items || []; + } + /** - * Filters and sorts raw people data + * Fetch episodes for many series with limited concurrency. + * @param {Array<{Id: string}|string>} seriesList + * @param {(seriesId: string, episodes: Array) => void} onSeriesEpisodes */ - function filterPeopleData(peopleMap) { + async function fetchEpisodesForSeriesBatched(seriesList, onSeriesEpisodes) { + const list = (seriesList || []) + .map((s) => (typeof s === 'string' ? { Id: s } : s)) + .filter((s) => s?.Id); + let completed = 0; + for (let i = 0; i < list.length; i += EPISODE_FETCH_CONCURRENCY) { + const chunk = list.slice(i, i + EPISODE_FETCH_CONCURRENCY); + await Promise.all(chunk.map(async (s) => { + try { + const episodes = await fetchEpisodesForSeries(s.Id); + onSeriesEpisodes(s.Id, episodes); + } catch (e) { + WARN(`Failed episodes for series ${s.Id}:`, e); + } + })); + completed += chunk.length; + const prev = completed - chunk.length; + if (completed === list.length || Math.floor(prev / 25) !== Math.floor(completed / 25)) { + LOG(`Episode people progress: ${completed}/${list.length} series`); + } + } + } + + async function subtractEpisodeCreditsForSeriesIds(map, seriesIds) { + const ids = (seriesIds || []).filter(Boolean); + if (!ids.length || !map) return; + await fetchEpisodesForSeriesBatched(ids, (_seriesId, episodes) => { + episodes.forEach((ep) => decrementEpisodePeople(map, ep)); + }); + } + + function clearEpisodeRoleCounts(map) { + if (!map) return; + map.forEach((person) => { + person.series.actorCount = 0; + person.series.directorCount = 0; + person.series.writerCount = 0; + }); + } + + async function rebuildAllEpisodeRoleCounts(map) { + clearEpisodeRoleCounts(map); + const seriesList = await window.SeriesCache?.getSeries?.() || []; + await fetchEpisodesForSeriesBatched(seriesList, (seriesId, episodes) => { + episodes.forEach((ep) => applyEpisodePeople(map, seriesId, ep)); + }); + } + + function roleCount(person, role, itemType) { + const movieCount = movieRoleCount(person, role); + const seriesCount = person.series?.count || 0; + const episodeCount = episodeRoleCount(person, role); + if (itemType === 'Movie') return movieCount; + if (itemType === 'Series') return seriesCount; + if (itemType === 'Episode') return episodeCount; + if (isAggregateItemType(itemType)) return movieCount + seriesCount + episodeCount; + return 0; + } + + function roleItems(person, role, itemType) { + if (itemType === 'Movie') { + if (role === 'Actor') return person.movies.actorItems || []; + if (role === 'Director') return person.movies.directorItems || []; + if (role === 'Writer') return person.movies.writerItems || []; + return []; + } + if (itemType === 'Series') return person.series.items || []; + if (itemType === 'Episode') return []; + if (isAggregateItemType(itemType)) { + const movieItems = roleItems(person, role, 'Movie'); + const seriesItems = person.series.items || []; + return [...movieItems, ...seriesItems]; + } + return []; + } + + function filterRoleList(role, itemType, minCount) { + if (!peopleMap) return []; + const mins = getMins(); + const type = isAggregateItemType(itemType) ? null : itemType; + let threshold = minCount; + if (threshold == null) { + if (type === 'Movie') threshold = mins.movies; + else if (type === 'Series') threshold = mins.series; + else if (type === 'Episode') threshold = mins.episodes; + else threshold = mins.total; + } + return Array.from(peopleMap.values()) + .map((person) => { + const count = roleCount(person, role, type); + return { + Id: person.Id, + Name: person.Name, + count, + items: roleItems(person, role, type), + movies: person.movies, + series: person.series + }; + }) + .filter((p) => p.count >= threshold) + .sort((a, b) => b.count - a.count); + } + + function rebuildFilteredLists() { + filteredLists = { + actors: filterRoleList('Actor', null), + directors: filterRoleList('Director', null), + writers: filterRoleList('Writer', null), + isComplete: true + }; + } + + async function persistPeople() { + const cache = window.IndexedDBCache; + if (!cache || !peopleMap) return; + prunePeopleMap(peopleMap); + const userId = window.ApiClient.getCurrentUserId(); + const ttl = window.LibraryCacheUtils?.getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000); + const payload = { + isComplete: true, + loadPeopleEpisodeData: shouldLoadPeopleEpisodeData(), + peopleData: Array.from(peopleMap.entries()) + }; try { - if (!peopleMap) return null; - - const allPeople = Array.from(peopleMap.values()); - - // Get threshold from config or default - const minAppearances = (window.KefinTweaksConfig && - window.KefinTweaksConfig.homeScreen && - window.KefinTweaksConfig.homeScreen.discovery && - window.KefinTweaksConfig.homeScreen.discovery.minPeopleAppearances) - || MIN_PEOPLE_APPEARANCES; - - const topDirectors = allPeople - .filter(person => person.directorCount >= minAppearances) - .sort((a, b) => b.directorCount - a.directorCount) - .map(person => ({ - ...person, - count: person.directorCount, - items: person.directorItems || [] - })); - - const topWriters = allPeople - .filter(person => person.writerCount >= minAppearances) - .sort((a, b) => b.writerCount - a.writerCount) - .map(person => ({ - ...person, - count: person.writerCount, - items: person.writerItems || [] - })); - - const topActors = allPeople - .filter(person => person.actorCount >= minAppearances) - .sort((a, b) => b.actorCount - a.actorCount) - .map(person => ({ - ...person, - count: person.actorCount, - items: person.actorItems || [] - })); - - return { - actors: topActors, - directors: topDirectors, - writers: topWriters - }; - } catch (err) { - ERR('Failed to filter people data:', err); - return null; + await cache.set(CACHE_NAME, payload, userId, ttl); + } catch (e) { + WARN('Failed to persist people cache:', e); } } - /** - * Fetches and processes top people data from all movies using pagination - */ - async function fetchTopPeople() { - if (fetchPromise) { - return fetchPromise; - } - + async function fetchAndCachePeople() { + if (fetchPromise) return fetchPromise; fetchPromise = (async () => { try { - LOG('Starting paginated fetch of movies for people data processing...'); - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const userId = apiClient.getCurrentUserId(); - const indexedDBCache = window.IndexedDBCache; + LOG('Building people cache from movies + series…'); + const movies = await window.MoviesCache.getMovies(); + const seriesList = await window.SeriesCache.getSeries(); + const map = new Map(); - let startIndex = 0; - const limit = 500; - let hasMoreData = true; - let peopleMap = null; + (movies || []).forEach((movie) => applyMoviePeople(map, movie)); + (seriesList || []).forEach((s) => applySeriesPeople(map, s)); - // Load existing raw people data if available (for resuming) - const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId); - if (existingRawData && !existingRawData.isComplete) { - const moviesProcessed = existingRawData.moviesProcessedCount; - if (moviesProcessed > 0) { - startIndex = moviesProcessed; - peopleMap = new Map(existingRawData.peopleData || []); - LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`); - } + if (shouldLoadPeopleEpisodeData()) { + await fetchEpisodesForSeriesBatched(seriesList, (seriesId, episodes) => { + episodes.forEach((ep) => applyEpisodePeople(map, seriesId, ep)); + }); + } else { + LOG('Skipping episode people fetch (loadPeopleEpisodeData disabled)'); } - while (hasMoreData) { - const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`; - - LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`); - - const data = await window.apiHelper.getQuery(url); - const movies = (data && data.Items) || []; - - if (movies.length === 0) { - hasMoreData = false; - isComplete = true; - } else { - peopleMap = processPeopleDataRaw(movies, peopleMap); - if (!peopleMap) { - throw new Error('processPeopleDataRaw failed'); - } - startIndex += movies.length; - - // Update global state with partial results for immediate use - const filteredData = filterPeopleData(peopleMap); - if (filteredData) { - moviesTopPeople = filteredData; - } - - // Checkpoint raw data (non-fatal if IndexedDB rejects large payloads) - const rawDataToCache = { - isComplete: movies.length < limit, - moviesProcessedCount: startIndex, - peopleData: Array.from(peopleMap.entries()) - }; - try { - await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000); - } catch (checkpointErr) { - WARN('Failed to checkpoint raw people data (continuing):', checkpointErr); - } - - if (movies.length < limit) { - hasMoreData = false; - isComplete = true; - } - } - } - - const finalPeopleData = filterPeopleData(peopleMap) || { - actors: [], - directors: [], - writers: [] - }; - finalPeopleData.isComplete = true; - moviesTopPeople = finalPeopleData; + peopleMap = map; + prunePeopleMap(map); isComplete = true; - - try { - await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000); - await indexedDBCache.clear('movies_top_people_raw', userId); - } catch (saveErr) { - WARN('Failed to persist complete people cache (in-memory still available):', saveErr); - } - - LOG(peopleMap ? 'People cache complete and saved' : 'People cache complete (empty library)'); - return finalPeopleData; + rebuildFilteredLists(); + await persistPeople(); + LOG(`People cache complete: ${peopleMap.size} people`); + return filteredLists; } catch (err) { - ERR('Failed to fetch top people data:', err); + ERR('fetchAndCachePeople failed:', err); return null; } finally { fetchPromise = null; } })(); - return fetchPromise; } - /** - * Initializes the people cache (awaits build; concurrent callers share one promise) - */ async function initialize() { - if (moviesTopPeople !== null && isComplete) { - return; - } - if (initPromise) { - return initPromise; - } - + if (isComplete && peopleMap) return; + if (initPromise) return initPromise; initPromise = (async () => { try { - if (moviesTopPeople !== null && isComplete) { - return; - } - - const indexedDBCache = window.IndexedDBCache; + const cache = window.IndexedDBCache; const userId = window.ApiClient.getCurrentUserId(); - - // Check for valid complete filtered data - const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId); - if (validComplete) { - const cachedData = await indexedDBCache.get('movies_top_people', userId); - if (cachedData && cachedData.isComplete) { - moviesTopPeople = cachedData; - isComplete = true; - LOG('Loaded complete top people data from IndexedDB'); - return; + if (cache && await cache.isCacheValid(CACHE_NAME, userId)) { + const cached = await cache.get(CACHE_NAME, userId); + if (cached?.isComplete && Array.isArray(cached.peopleData)) { + const currentMode = shouldLoadPeopleEpisodeData(); + // Legacy caches (no flag) always included episode data + const cachedMode = typeof cached.loadPeopleEpisodeData === 'boolean' + ? cached.loadPeopleEpisodeData + : true; + if (cachedMode !== currentMode) { + LOG(`People cache episode mode mismatch (cached=${cachedMode}, current=${currentMode}); rebuilding`); + await cache.clear?.(CACHE_NAME, userId); + } else { + peopleMap = new Map(cached.peopleData); + const before = peopleMap.size; + prunePeopleMap(peopleMap); + isComplete = true; + rebuildFilteredLists(); + LOG('Loaded people cache from IndexedDB'); + if (peopleMap.size < before) { + await persistPeople(); + } + return; + } } } - await fetchTopPeople(); + await fetchAndCachePeople(); } finally { initPromise = null; } })(); - return initPromise; } - async function getTopPeople() { - LOG('Getting top people data'); - try { - if (!moviesTopPeople) { - await initialize(); + async function applyMovieDelta(changedMovies, removedIds) { + if (!peopleMap) await initialize(); + if (!peopleMap) return; + (removedIds || []).forEach((id) => removeMovieFromPeople(peopleMap, id)); + (changedMovies || []).forEach((movie) => { + removeMovieFromPeople(peopleMap, movie.Id); + applyMoviePeople(peopleMap, movie); + }); + prunePeopleMap(peopleMap); + rebuildFilteredLists(); + await persistPeople(); + } + + async function applySeriesDelta(changedSeries, removedIds) { + if (!peopleMap) await initialize(); + if (!peopleMap) return; + + const removed = (removedIds || []).filter(Boolean); + const changed = (changedSeries || []).filter((s) => s?.Id); + const loadEpisodes = shouldLoadPeopleEpisodeData(); + + if (removed.length) { + removed.forEach((id) => removeSeriesMembership(peopleMap, id)); + changed.forEach((s) => { + removeSeriesMembership(peopleMap, s.Id); + applySeriesPeople(peopleMap, s); + }); + if (loadEpisodes) { + // Without per-series episode indexes, any removal requires a full episode recount. + await rebuildAllEpisodeRoleCounts(peopleMap); + } else { + clearEpisodeRoleCounts(peopleMap); } - return moviesTopPeople; - } catch (err) { - ERR('Failed to get top people data:', err); - return null; + } else { + if (loadEpisodes) { + // Changed-only: decrement this series' episode credits, refresh membership, re-apply episodes. + await subtractEpisodeCreditsForSeriesIds(peopleMap, changed.map((s) => s.Id)); + } + changed.forEach((s) => { + removeSeriesMembership(peopleMap, s.Id); + applySeriesPeople(peopleMap, s); + }); + if (loadEpisodes) { + await fetchEpisodesForSeriesBatched(changed, (seriesId, episodes) => { + episodes.forEach((ep) => applyEpisodePeople(peopleMap, seriesId, ep)); + }); + } + } + + prunePeopleMap(peopleMap); + rebuildFilteredLists(); + await persistPeople(); + } + + async function invalidate() { + LOG('Invalidating people cache (in-memory + IndexedDB)'); + peopleMap = null; + filteredLists = null; + isComplete = false; + fetchPromise = null; + initPromise = null; + try { + const cache = window.IndexedDBCache; + const userId = window.ApiClient?.getCurrentUserId?.(); + if (cache?.clear) { + await cache.clear(CACHE_NAME, userId); + } + } catch (e) { + WARN('Failed to clear people IDB cache:', e); + } + // Rebuild on next demand; kick off in background if caches are ready + try { + initialize(); + } catch (e) { + WARN('People cache rebuild after invalidate failed to start:', e); } } - async function getTopActors() { - if (!moviesTopPeople) { + async function getTopPeople() { + if (!filteredLists) await initialize(); + return filteredLists; + } + + function parseRoleListArgs(itemTypeOrOptions, minCount) { + if (itemTypeOrOptions && typeof itemTypeOrOptions === 'object') { + return { + itemType: itemTypeOrOptions.ItemType || itemTypeOrOptions.itemType || null, + minCount: itemTypeOrOptions.MinCount ?? itemTypeOrOptions.minCount + }; + } + return { itemType: itemTypeOrOptions, minCount }; + } + + async function getTopActors(itemTypeOrOptions = null, minCount) { + if (!peopleMap) await initialize(); + const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount); + return filterRoleList('Actor', itemType, min); + } + + async function getTopDirectors(itemTypeOrOptions = null, minCount) { + if (!peopleMap) await initialize(); + const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount); + return filterRoleList('Director', itemType, min); + } + + async function getTopWriters(itemTypeOrOptions = null, minCount) { + if (!peopleMap) await initialize(); + const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount); + return filterRoleList('Writer', itemType, min); + } + + async function resolveDiscoveryPeople(queryOptions = {}) { + if (!peopleMap) await initialize(); + const personType = queryOptions.PersonType || queryOptions.personType || 'Actor'; + const itemType = queryOptions.ItemType || queryOptions.itemType || null; + const limit = queryOptions.Limit || 100; + let list = []; + 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) => ({ Id: p.Id, Name: p.Name, Type: 'Person' })); + } + + async function waitForLoginReady(maxWaitMs = 60000) { + const start = Date.now(); + while (!window.userHelper?.waitForLogin) { + if (Date.now() - start >= maxWaitMs) { + ERR('Timed out waiting for userHelper'); + return false; + } + await new Promise((r) => setTimeout(r, 100)); + } + return window.userHelper.waitForLogin(maxWaitMs); + } + + let bootstrapStarted = false; + let bootstrapScheduled = false; + + async function bootstrap() { + if (bootstrapStarted) return; + bootstrapStarted = true; + try { + await waitForLoginReady(); + // Wait for library caches to finish first crawl + if (window.MoviesCache?.getMovies) await window.MoviesCache.getMovies(); + if (window.SeriesCache?.getSeries) await window.SeriesCache.getSeries(); await initialize(); + } catch (e) { + ERR('Bootstrap failed:', e); } - return moviesTopPeople?.actors ?? []; } - async function getTopDirectors() { - if (!moviesTopPeople) { - await initialize(); - } - return moviesTopPeople?.directors ?? []; + 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); + }); } - async function getTopWriters() { - if (!moviesTopPeople) { - await initialize(); - } - return moviesTopPeople?.writers ?? []; - } - - function isCacheComplete() { - return isComplete; - } - - // Expose API window.PeopleCache = { init: initialize, - getTopPeople: getTopPeople, - getTopActors: getTopActors, - getTopDirectors: getTopDirectors, - getTopWriters: getTopWriters, - isComplete: isCacheComplete + fetchAndCachePeople, + getTopPeople, + getTopActors, + getTopDirectors, + getTopWriters, + resolveDiscoveryPeople, + applyMovieDelta, + applySeriesDelta, + invalidate, + isComplete: () => isComplete === true, + bootstrap, + scheduleBootstrap }; + scheduleBootstrap(); LOG('Module loaded'); - })(); diff --git a/scripts/removeContinue.js b/scripts/removeContinue.js index 0d42f8c..b3453a9 100644 --- a/scripts/removeContinue.js +++ b/scripts/removeContinue.js @@ -78,7 +78,7 @@ // Only add button to cards with data-positionticks (in progress items) const positionTicks = card.getAttribute('data-positionticks'); - if (!positionTicks) { + if (!positionTicks || positionTicks === '0') { return; // Not a continue watching item, skip } diff --git a/scripts/sectionHelper.js b/scripts/sectionHelper.js new file mode 100644 index 0000000..05c7241 --- /dev/null +++ b/scripts/sectionHelper.js @@ -0,0 +1,1145 @@ +// KefinTweaks Section Helper +// Discovery source resolution + section load helpers (extracted from homeScreen3). +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks SectionHelper]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks SectionHelper]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SectionHelper]', ...args); + + const ApiHelper = window.apiHelper || window.ApiHelper; + const Config = () => window.KefinHomeConfig2 || window.KefinHomeScreen?.getConfig?.() || {}; + + const discoveryState = { + renderedDiscoveryIds: { + genres: new Set(), + genresSeries: new Set(), + seriesStudios: new Set(), + seriesStudiosTopRated: new Set(), + collections: new Set(), + people: new Set(), + similar: new Set(), + watchlist: new Set(), + customDiscoverySections: new Set() + }, + currentDiscoveryGenre: null, + currentDiscoveryStudio: null, + // Map of pairKey -> Promise resolving to dynamicResult for spotlight pairing + pairResolvePromises: new Map() + }; + + function getDiscoverySettings() { + const defaults = window.KefinHomeConfig2?.DISCOVERY_SETTINGS || {}; + const fromHome = window.KefinHomeScreen?.getConfig?.()?.DISCOVERY_SETTINGS; + const fromUser = window.KefinTweaksConfig?.homeScreenConfig?.DISCOVERY_SETTINGS; + return { + ...defaults, + ...(fromHome || {}), + ...(fromUser || {}) + }; + } + + function getPairKey(configId) { + if (configId === 'genreMovies' || configId === 'spotlightGenre') return 'genre-movie'; + if (configId === 'genreSeries' || configId === 'spotlightGenreSeries') return 'genre-series'; + if (configId === 'studioShows' || configId === 'spotlightNetwork') return 'studio-series'; + return null; + } + + function resetDiscoveryDedupe() { + const ids = discoveryState.renderedDiscoveryIds; + Object.keys(ids).forEach((key) => { + if (ids[key] instanceof Set) ids[key].clear(); + }); + discoveryState.currentDiscoveryGenre = null; + discoveryState.currentDiscoveryStudio = null; + discoveryState.pairResolvePromises.clear(); + } + + async function loadSectionForRendering(sectionConfig) { + LOG(`Loading Section for Rendering: ${sectionConfig.id} (${sectionConfig.name})`); + + const loadSectionTimerStart = performance.now(); + + // Omit sections that would wait on incomplete Movies/Series/People crawls + // (static-item sections never depend on those caches) + const hasStaticItems = sectionConfig.items && sectionConfig.items.length > 0; + if (!hasStaticItems && window.LibraryCache?.sectionDependsOnIncompleteCache?.(sectionConfig) === true) { + LOG(`Skipping section ${sectionConfig.id}: depends on incomplete library cache`); + return null; + } + + // Prepare section (template replacements, view more URL, etc.) + const viewMoreUrl = await resolveViewMoreUrl(sectionConfig); + const cardFormat = resolveCardFormat(sectionConfig); + const sectionName = fillTemplate(sectionConfig.name, sectionConfig.metadata); + + sectionConfig.overflowCard = true; + + if (viewMoreUrl) { + sectionConfig.viewMoreUrl = viewMoreUrl; + } + if (cardFormat) { + sectionConfig.cardFormat = cardFormat; + } + if (sectionName) { + sectionConfig.name = sectionName; + } + + // Check renderMode first (preferred), fallback to spotlight boolean for backward compatibility + const shouldRenderSpotlight = sectionConfig.renderMode === 'Spotlight' || sectionConfig.renderMode === 'Random' || sectionConfig.spotlight === true; + if (shouldRenderSpotlight) { + const spotlightConfig = { + ...(window.KefinHomeScreen?.getConfig?.()?.SPOTLIGHT_SETTINGS || {}), + ...sectionConfig.spotlightConfig + }; + sectionConfig.spotlightConfig = spotlightConfig; + // Set renderMode if not already set (for backward compatibility) + if (!sectionConfig.renderMode && sectionConfig.spotlight) { + sectionConfig.renderMode = 'Spotlight'; + } + } + + // Static items: normalize and return immediately (no queries, no dataPromise) + if (hasStaticItems) { + const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || ''; + const serverId = ApiClient.serverId(); + const normalizeTemplate = (value) => (value || '') + .replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot) + .replace(/\$\{serverId\}/g, serverId); + + const normalizedItems = sectionConfig.items.map((item, index) => { + const posterUrl = normalizeTemplate(item.posterUrl); + const thumbUrl = normalizeTemplate(item.thumbUrl); + const squareUrl = normalizeTemplate(item.squareUrl); + const imageUrl = normalizeTemplate(item.imageUrl); + const cardUrl = normalizeTemplate(item.cardUrl); + const backdropUrl = normalizeTemplate(item.backdropUrl); + const bannerUrl = normalizeTemplate(item.bannerUrl); + const logoUrl = normalizeTemplate(item.logoUrl); + + return { + Name: item.Name, + Id: item.Id || 'static-' + sectionConfig.id + '-' + index, + Type: item.Type || 'Folder', + posterUrl: posterUrl, + thumbUrl: thumbUrl, + squareUrl: squareUrl, + imageUrl: imageUrl, + cardUrl: cardUrl, + backdropUrl: backdropUrl, + bannerUrl: bannerUrl, + logoUrl: logoUrl, + CustomFooterText: item.cardFooter || undefined + }; + }); + const loadSectionTimerEnd = performance.now(); + const loadSectionDuration = loadSectionTimerEnd - loadSectionTimerStart; + LOG(`Section ${sectionConfig.id} (static items) loaded for rendering in time: ${loadSectionDuration.toFixed(2)}ms`); + let postProcessedItems = normalizedItems; + if (window.cardBuilder.postProcessItems) { + postProcessedItems = window.cardBuilder.postProcessItems(sectionConfig, normalizedItems); + } + + return { + config: sectionConfig, + result: { data: postProcessedItems } + }; + } + + // External list (MDBList) — progressive for network fetch when caches are already ready + if (Array.isArray(sectionConfig.externalListUrls) && sectionConfig.externalListUrls.length > 0) { + if (!window.KefinExternalList?.resolveExternalListItems) { + WARN(`Section ${sectionConfig.id}: externalListUrls set but KefinExternalList unavailable`); + return null; + } + + let mappedDataPromise = null; + const ensureData = () => { + if (!mappedDataPromise) { + mappedDataPromise = (async () => { + const listResult = await window.KefinExternalList.resolveExternalListItems(sectionConfig); + let items = listResult?.Items || []; + if (window.cardBuilder.postProcessItems) { + items = window.cardBuilder.postProcessItems(sectionConfig, items); + } + return items; + })(); + } + return mappedDataPromise; + }; + + // Kick off list resolve immediately; first paint may still be empty + ensureData(); + + const result = { + data: { Items: [] }, + ensureData + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { return ensureData(); } + }); + + const loadSectionTimerEnd = performance.now(); + LOG(`Section ${sectionConfig.id} (external list) progressive stub in ${(loadSectionTimerEnd - loadSectionTimerStart).toFixed(2)}ms`); + return { + config: sectionConfig, + result + }; + } + + // Ensure queries array exists when section has no static items + if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) { + WARN(`Section ${sectionConfig.id} has no queries array and no static items`); + return null; + } + + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + const results = []; + + const resolveQueries = window.cardBuilder?.resolveQueriesToLoad; + const queriesToLoad = typeof resolveQueries === 'function' + ? resolveQueries(sectionConfig, { initialize: true }) + : (() => { + if (sectionConfig.queries.length > 1 && sectionConfig.useRandomQuery === true) { + const randomIndex = Math.floor(Math.random() * sectionConfig.queries.length); + LOG(`Section ${sectionConfig.id}: useRandomQuery — selected query index ${randomIndex}`); + return [sectionConfig.queries[randomIndex]]; + } + return sectionConfig.queries; + })(); + + if (sectionConfig.useMultiQueryPicker === true && sectionConfig.queries.length > 1) { + const index = sectionConfig._selectedQueryIndex; + LOG(`Section ${sectionConfig.id}: useMultiQueryPicker — selected query index ${index}`); + } else if (queriesToLoad.length === 1 && sectionConfig.queries.length > 1 && sectionConfig.useRandomQuery === true) { + const index = sectionConfig.queries.indexOf(queriesToLoad[0]); + LOG(`Section ${sectionConfig.id}: useRandomQuery — selected query index ${index}`); + } + + // Process each query in the queries array + for (const query of queriesToLoad) { + let queryResult; + + if (query.dataSource) { + // Handle cache-based data sources + queryResult = await ApiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {}); + } else { + // Build and execute query + const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', { sectionType: sectionConfig.type }); + + if (typeof queryUrl === 'string') { + // Standard query + const cacheCfg = Config().CACHE || {}; + let sectionTtl = cacheCfg.DEFAULT_TTL; + if (Number(sectionConfig.ttl) >= 0) { + sectionTtl = Number(sectionConfig.ttl); + } + + queryResult = await ApiHelper.getQuery(queryUrl, { + useCache: true, + ttl: sectionTtl + }); + } else { + WARN(`Invalid query URL for section ${sectionConfig.id}`); + continue; + } + } + + results.push(queryResult); + } + + // If multiple queries, merge results using section-level sortBy/sortOrder + if (results.length > 1) { + const loadSectionTimerEnd = performance.now(); + const loadSectionDuration = loadSectionTimerEnd - loadSectionTimerStart; + LOG(`Section ${sectionConfig.id} loaded for rendering in time: ${loadSectionDuration.toFixed(2)}ms`); + return ApiHelper.mergeMultiQueryResults(results, sectionConfig); + } + + const loadSectionTimerEnd = performance.now(); + const loadSectionDuration = loadSectionTimerEnd - loadSectionTimerStart; + LOG(`Section ${sectionConfig.id} loaded for rendering in time: ${loadSectionDuration.toFixed(2)}ms`); + + const postProcess = (sectionConfig, items) => { + let postProcessedItems = items; + if (window.cardBuilder.postProcessItems) { + postProcessedItems = window.cardBuilder.postProcessItems(sectionConfig, items); + } + return sortItemsByConfiguredIds(sectionConfig, postProcessedItems); + }; + + const queryResult = results[0]; + let mappedDataPromise = null; + const ensureData = () => { + if (!mappedDataPromise) { + const raw = typeof queryResult.ensureData === 'function' + ? queryResult.ensureData() + : queryResult.dataPromise; + mappedDataPromise = Promise.resolve(raw).then((data) => postProcess(sectionConfig, data)); + } + return mappedDataPromise; + }; + + const result = { + data: postProcess(sectionConfig, queryResult.data), + isStale: queryResult.isStale === true, + isStalePromise: queryResult.isStalePromise, + ensureData + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { + return ensureData(); + } + }); + + // Single query result + return { + config: sectionConfig, + queryUrl: null, + result + }; + } + + /** + * Jellyfin /Items?Ids= does not reliably return items in request order. + * When queryOptions.Ids is set, re-sort the item list to match that sequence. + */ + function sortItemsByConfiguredIds(sectionConfig, items) { + const list = Array.isArray(items) ? items : (items?.Items || []); + if (!Array.isArray(list) || list.length === 0) return items; + + const rawIds = sectionConfig?.queries?.[0]?.queryOptions?.Ids; + if (!rawIds) return items; + + const ids = Array.isArray(rawIds) + ? rawIds.filter(Boolean) + : String(rawIds).split(',').map((s) => s.trim()).filter(Boolean); + if (!ids.length) return items; + + const order = new Map(ids.map((id, i) => [String(id), i])); + const sorted = [...list].sort((a, b) => { + const ai = order.has(String(a?.Id)) ? order.get(String(a.Id)) : 1e9; + const bi = order.has(String(b?.Id)) ? order.get(String(b.Id)) : 1e9; + return ai - bi; + }); + + if (Array.isArray(items)) return sorted; + if (items && typeof items === 'object' && Array.isArray(items.Items)) { + return { ...items, Items: sorted }; + } + return sorted; + } + + async function getWatchlistUrl() { + return window.KefinTweaksUtils._watchlistUrl || '#/watchlist'; + } + + /** + * Resolve view more URL from queryOptions / known section ids + */ + async function resolveViewMoreUrl(sectionConfig) { + if (sectionConfig.viewMoreUrl) return sectionConfig.viewMoreUrl; + + const { id } = sectionConfig; + + // Handle specific section IDs + if (id === 'continueWatching') { + return `#/tv.html?collectionType=tvshows&tab=1&serverId=${ApiClient.serverId()}`; + } + + if (id === 'popularTVNetworks') { + return `#/tv.html?collectionType=tvshows&tab=4&serverId=${ApiClient.serverId()}`; + } + + if (id === 'upcoming' || id === 'recentlyReleased.episodes') { + return `#/tv.html?collectionType=tvshows&tab=2&serverId=${ApiClient.serverId()}`; + } + + if (id === 'nextUp') { + return `#/list.html?type=nextup&serverId=${ApiClient.serverId()}`; + } + + if (id === 'watchlist') { + return await getWatchlistUrl(); + } + + if (id === 'watchAgain') { + const watchlistUrl = await getWatchlistUrl(); + + if (watchlistUrl) { + return `${watchlistUrl}?pageTab=history`; + } + return null; + } + + // Infer from queries array + if (sectionConfig.queries && sectionConfig.queries.length > 0) { + const firstQuery = sectionConfig.queries[0]; + const queryOptions = firstQuery.queryOptions || {}; + const serverId = window.ApiClient ? window.ApiClient.serverId() : ''; + + // Check for Tags + if (queryOptions.Tags) { + return `#/list.html?type=tag&tag=${encodeURIComponent(queryOptions.Tags)}&serverId=${serverId}`; + } + + // Check for Genres + if (queryOptions.Genres) { + let genreId = queryOptions.Genres; + if (typeof genreId === 'string' && !genreId.match(/^\d+$/)) { + const resolvedId = await ApiHelper.getGenreId(genreId); + if (resolvedId) { + genreId = resolvedId; + } + } + return `#/list.html?genreId=${genreId}&serverId=${serverId}`; + } + + // Check for GenreIds + if (queryOptions.GenreIds) { + return `#/list.html?genreId=${queryOptions.GenreIds.join(',')}&serverId=${serverId}`; + } + + // Check for PersonIds + if (queryOptions.PersonIds) { + return `#/details?id=${queryOptions.PersonIds.join(',')}&serverId=${serverId}`; + } + + // Check for StudioIds + if (queryOptions.StudioIds) { + return `#/list.html?studioId=${queryOptions.StudioIds.join(',')}&serverId=${serverId}`; + } + + // Check for ParentId + if (queryOptions.ParentId) { + if (sectionConfig.discoveryType === 'Collection' || sectionConfig.discoveryType === 'Playlist') { + return `#/details?id=${queryOptions.ParentId}&serverId=${serverId}`; + } + + return `#/list.html?parentId=${queryOptions.ParentId}&serverId=${serverId}`; + } + + // Check for custom path + if (firstQuery.path) { + // Derive URL from path if possible + if (firstQuery.path.includes('NextUp')) { + return `#/tv.html?collectionType=tvshows&tab=1`; + } + if (firstQuery.path.includes('Upcoming')) { + return `#/tv.html?collectionType=tvshows&tab=2`; + } + // If query path is Items/itemid/Similar then return the details page for the item + if (firstQuery.path.includes('Similar')) { + const itemId = firstQuery.path.split('/')[2]; + return `#/details?id=${itemId}&serverId=${serverId}`; + } + } + } + + return null; + } + + function resolveCardFormat(sectionConfig) { + return sectionConfig.cardFormat || 'Poster'; + } + + function normalizeDiscoveryConfig(config) { + const normalized = { ...config }; + normalized.discoveryType = normalized.discoveryType + || (normalized.source === 'Dynamic' ? normalized.type : null); + normalized.discoveryPersonType = normalized.discoveryPersonType || normalized.personType || 'Actor'; + normalized.discoveryItemType = normalized.discoveryItemType + || (Array.isArray(normalized.includeItemTypes) && normalized.includeItemTypes[0]) + || 'Movie'; + if (!normalized.discoverySourceQuery) { + normalized.discoverySourceQuery = inferLegacyDiscoverySourceQuery(normalized); + } + return normalized; + } + + function inferLegacyDiscoverySourceQuery(config) { + const itemType = config.discoveryItemType || config.includeItemTypes?.[0] || 'Movie'; + const discoveryType = config.discoveryType; + + if (discoveryType === 'Genre') { + return { + path: '/Genres', + queryOptions: { IncludeItemTypes: [itemType], Limit: 0 } + }; + } + if (discoveryType === 'Studio') { + return { + path: '/Studios', + queryOptions: { + IncludeItemTypes: [itemType === 'Series' ? 'Series' : 'Movie'], + SortBy: 'ChildCount', + SortOrder: 'Descending', + Limit: 0 + } + }; + } + if (discoveryType === 'Collection') { + return { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['BoxSet'], + Recursive: true, + Fields: 'RecursiveItemCount,ChildCount,TotalRecordCount', + Limit: 500, + SortBy: 'TotalRecordCount' + } + }; + } + if (discoveryType === 'Person') { + if (config.sourceType && String(config.sourceType).includes('watched')) { + const poolType = itemType === 'Series' ? 'Episode' : itemType; + return { + path: '/Items', + queryOptions: { + IncludeItemTypes: [poolType], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: config.sourceType === 'watched-recent' ? 'DatePlayed' : 'Random', + SortOrder: config.sourceType === 'watched-recent' ? 'Descending' : undefined, + Limit: config.sourceType === 'watched-recent' ? 5 : 20 + } + }; + } + const role = config.discoveryPersonType || config.personType || 'Actor'; + const method = role === 'Director' ? 'getTopDirectors' + : role === 'Writer' ? 'getTopWriters' + : 'getTopActors'; + return { + dataSource: `PeopleCache.${method}`, + queryOptions: { ItemType: itemType, Limit: 100 } + }; + } + if (discoveryType === 'Similar' || discoveryType === 'Watchlist') { + if (config.sourceType === 'liked' || discoveryType === 'Watchlist') { + return { + path: '/Items', + queryOptions: { + IncludeItemTypes: [itemType === 'Series' ? 'Episode' : itemType], + Recursive: true, + Filters: discoveryType === 'Watchlist' ? 'Likes' : 'IsFavorite', + SortBy: 'Random', + Limit: 20, + Fields: 'UserData,People' + } + }; + } + if (config.sourceType === 'watched-recent') { + return { + path: '/Items', + queryOptions: { + IncludeItemTypes: [itemType === 'Series' ? 'Episode' : itemType], + Recursive: true, + Filters: 'IsPlayed', + Fields: 'UserData,People', + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Limit: 5 + } + }; + } + if (config.sourceType === 'finished') { + return { + path: '/Items', + queryOptions: { + IncludeItemTypes: ['Series'], + Recursive: true, + Filters: 'IsPlayed', + SortBy: 'PlayedPercentage', + SortOrder: 'Descending', + Limit: 5, + Fields: 'UserData,People' + } + }; + } + return { + path: '/Items', + queryOptions: { + IncludeItemTypes: [itemType === 'Series' ? 'Episode' : itemType], + Recursive: true, + Filters: 'IsPlayed', + SortBy: 'Random', + Limit: 20, + Fields: 'UserData,People' + } + }; + } + return null; + } + + async function unwrapQueryItems(result) { + if (!result) return []; + if (Array.isArray(result)) return result; + if (Array.isArray(result.Items)) return result.Items; + if (Array.isArray(result.data?.Items)) return result.data.Items; + if (typeof result.ensureData === 'function') { + const fresh = await result.ensureData(); + return fresh?.Items || []; + } + if (result.dataPromise) { + const fresh = await result.dataPromise; + return fresh?.Items || []; + } + return []; + } + + async function fetchDiscoveryPool(sectionConfig) { + const config = normalizeDiscoveryConfig(sectionConfig); + const sourceQuery = config.discoverySourceQuery; + if (!sourceQuery) { + WARN(`Discovery section ${config.id}: missing discoverySourceQuery`); + return []; + } + + if (window.LibraryCache?.sectionDependsOnIncompleteCache?.({ + discoverySourceQuery: sourceQuery, + queries: [] + }) === true) { + LOG(`Discovery section ${config.id}: skipping pool fetch — incomplete library cache`); + return []; + } + + const cfg = Config(); + const ttl = cfg.CACHE?.DISCOVERY_TTL ?? cfg.CACHE?.LONG_TTL ?? 3600000; + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + const built = ApiHelper.buildQueryFromSection(sourceQuery, userId, serverUrl, false); + + if (typeof built === 'object' && built.dataSource) { + const result = await ApiHelper.fetchFromDataSource(built.dataSource, built.options || {}, false); + return unwrapQueryItems(result); + } + + if (typeof built !== 'string') return []; + const result = await ApiHelper.getQuery(built, { useCache: true, ttl }); + return unwrapQueryItems(result); + } + + function shuffleArray(arr) { + const copy = arr.slice(); + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; + } + + function personDedupeKey(itemType, personType, personId) { + return `${itemType}:${personType}:${personId}`; + } + + function isPersonUsed(itemType, personType, personId) { + return discoveryState.renderedDiscoveryIds.people.has(personDedupeKey(itemType, personType, personId)); + } + + function markPersonUsed(itemType, personType, personId) { + discoveryState.renderedDiscoveryIds.people.add(personDedupeKey(itemType, personType, personId)); + } + + function getStudioDedupeSet(config) { + const baseId = String(config.id || '').split('-')[0]; + if (baseId === 'spotlightNetwork' || baseId === 'spotlightNetworkSeries') { + return discoveryState.renderedDiscoveryIds.seriesStudiosTopRated; + } + return discoveryState.renderedDiscoveryIds.seriesStudios; + } + + function getGenreDedupeSet(itemType) { + return itemType === 'Series' ? discoveryState.renderedDiscoveryIds.genresSeries : discoveryState.renderedDiscoveryIds.genres; + } + + function resolveSimilarSourceId(item) { + if (!item) return null; + if (item.Type === 'Episode' && item.SeriesId) return item.SeriesId; + return item.Id; + } + + function resolveTitleMetadata(item, itemType) { + const title = item?.SeriesName || item?.Name || ''; + const meta = { Title: title }; + if (itemType === 'Series' || item?.Type === 'Series' || item?.Type === 'Episode') { + meta.Series = title; + } else { + meta.Movie = title; + } + return meta; + } + + function buildPersonMetadata(personName, personType, sourceItem, itemType) { + const titleMeta = sourceItem ? resolveTitleMetadata(sourceItem, itemType) : {}; + const meta = { Person: personName, ...titleMeta }; + meta[personType] = personName; + return meta; + } + + function pickPersonFromHistoryPool(pool, personType, itemType) { + const shuffled = shuffleArray(pool); + for (const item of shuffled) { + if (!item?.People?.length) continue; + const candidates = item.People.filter((p) => p.Type === personType).slice(0, 10); + if (!candidates.length) continue; + const shuffledPeople = shuffleArray(candidates); + for (const person of shuffledPeople) { + if (!person?.Id) continue; + if (isPersonUsed(itemType, personType, person.Id)) continue; + markPersonUsed(itemType, personType, person.Id); + return { + id: person.Id, + name: person.Name, + excludeItemId: item.Id, + metadata: buildPersonMetadata(person.Name, personType, item, itemType) + }; + } + } + return null; + } + + function pickPersonFromPeopleCachePool(pool, personType, itemType) { + const shuffled = shuffleArray(pool); + for (const person of shuffled) { + if (!person?.Id) continue; + if (isPersonUsed(itemType, personType, person.Id)) continue; + markPersonUsed(itemType, personType, person.Id); + return { + id: person.Id, + name: person.Name, + metadata: buildPersonMetadata(person.Name, personType, null, itemType) + }; + } + return null; + } + + async function resolveDiscoverySource(rawConfig) { + const config = normalizeDiscoveryConfig(rawConfig); + const discoveryType = config.discoveryType; + const itemType = config.discoveryItemType || 'Movie'; + const personType = config.discoveryPersonType || 'Actor'; + + if (!discoveryType || !config.discoverySourceQuery) { + WARN(`Discovery section misconfigured: ${config.id}`); + return null; + } + + const pool = await fetchDiscoveryPool(config); + if (!pool.length) return null; + + if (discoveryType === 'Genre') { + const settings = getDiscoverySettings(); + const minCount = itemType === 'Series' + ? (settings.minGenreSeriesCount ?? settings.minGenreMovieCount ?? 50) + : (settings.minGenreMovieCount ?? 50); + const countField = itemType === 'Series' ? 'SeriesCount' : 'MovieCount'; + const dedupe = getGenreDedupeSet(itemType); + const eligible = pool.filter((g) => !dedupe.has(g.Id) && (g[countField] || 0) > minCount); + if (!eligible.length) return null; + const selected = eligible[Math.floor(Math.random() * eligible.length)]; + dedupe.add(selected.Id); + return { + id: selected.Id, + name: selected.Name, + metadata: { Genre: selected.Name } + }; + } + + if (discoveryType === 'Studio') { + const dedupe = getStudioDedupeSet(config); + const eligible = pool.filter((s) => s?.Id && !dedupe.has(s.Id)); + if (!eligible.length) return null; + const selected = eligible[Math.floor(Math.random() * eligible.length)]; + dedupe.add(selected.Id); + return { + id: selected.Id, + name: selected.Name, + metadata: { Studio: selected.Name } + }; + } + + if (discoveryType === 'Collection') { + const minimumItems = config.minimumItems || 3; + const eligible = pool.filter((c) => { + if (!c?.Id || discoveryState.renderedDiscoveryIds.collections.has(c.Id)) return false; + const count = (c.TotalRecordCount ?? c.RecursiveItemCount ?? c.ChildCount ?? 0); + return count >= minimumItems; + }); + if (!eligible.length) return null; + const selected = eligible[Math.floor(Math.random() * eligible.length)]; + discoveryState.renderedDiscoveryIds.collections.add(selected.Id); + return { + id: selected.Id, + name: selected.Name, + metadata: { Collection: selected.Name, 'Collection Name': selected.Name } + }; + } + + if (discoveryType === 'Similar' || discoveryType === 'Watchlist') { + const dedupe = discoveryState.renderedDiscoveryIds.similar; + const eligible = shuffleArray(pool).filter((item) => { + const sourceId = resolveSimilarSourceId(item); + return sourceId && !dedupe.has(sourceId); + }); + if (!eligible.length) return null; + const selected = eligible[0]; + const similarSourceId = resolveSimilarSourceId(selected); + dedupe.add(similarSourceId); + return { + id: similarSourceId, + similarSourceId, + excludeItemId: selected.Id, + metadata: resolveTitleMetadata(selected, itemType) + }; + } + + if (discoveryType === 'Person') { + const sourceQuery = config.discoverySourceQuery; + if (sourceQuery.dataSource && String(sourceQuery.dataSource).startsWith('PeopleCache.')) { + return pickPersonFromPeopleCachePool(pool, personType, itemType); + } + return pickPersonFromHistoryPool(pool, personType, itemType); + } + + WARN(`Unsupported discoveryType: ${discoveryType}`); + return null; + } + + function fillTemplate(template, data) { + if (!data || !template) return template; + + // Support both {Key} and [Key] formats, allowing spaces in keys + return template.replace(/\{([^}]+)\}|\[([^\]]+)\]/g, (match, key1, key2) => { + const key = key1 || key2; + return data[key] || match; + }); + } + + /** + * Resolves a dynamic discovery template into a concrete section config with queries. + * Returns null when no source could be resolved (e.g. no eligible genre/person left). + * @param {Object} template - Discovery section template + * @param {Object} [options] + * @param {boolean} [options.pairSpotlight] - Reuse the paired spotlight source (home render behaviour) + * @param {boolean} [options.resolveViewMore] - Resolve the "view more" URL (may issue extra requests) + * @param {string} [options.stubId] - Stable id from progressive stub + * @param {number} [options.stubOrder] - Stable order from progressive stub + */ + async function buildDiscoverySectionInstance(template, options = {}) { + if (window.LibraryCache?.sectionDependsOnIncompleteCache?.(template) === true) { + LOG(`Skipping discovery instance ${template?.id}: depends on incomplete library cache`); + return null; + } + + const pairSpotlight = options.pairSpotlight !== false; + const resolveViewMore = options.resolveViewMore !== false; + + const instanceConfig = { + ...template, + id: options.stubId || `${template.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'discovery', + }; + if (options.stubOrder != null) { + instanceConfig.order = options.stubOrder; + } + if (!instanceConfig.discoveryType && template.type + && !['home', 'seasonal', 'discovery'].includes(String(template.type).toLowerCase())) { + instanceConfig.discoveryType = template.type; + } + + const normalized = normalizeDiscoveryConfig(instanceConfig); + Object.assign(instanceConfig, normalized); + + const baseId = String(template.id || instanceConfig.id || '').split('-')[0]; + const pairKey = (pairSpotlight && getDiscoverySettings().renderSpotlightAboveMatching) + ? getPairKey(baseId) + : null; + + let dynamicResult = null; + if (pairKey) { + // Sequential partner already finished: reuse stored result (do not resolve again) + if ((pairKey === 'genre-movie' || pairKey === 'genre-series') && discoveryState.currentDiscoveryGenre) { + dynamicResult = discoveryState.currentDiscoveryGenre; + discoveryState.currentDiscoveryGenre = null; + } else if (pairKey === 'studio-series' && discoveryState.currentDiscoveryStudio) { + dynamicResult = discoveryState.currentDiscoveryStudio; + discoveryState.currentDiscoveryStudio = null; + } else { + // Parallel partner in-flight (or we are first): share one resolve promise + if (!discoveryState.pairResolvePromises.has(pairKey)) { + const resolvePromise = resolveDiscoverySource(instanceConfig); + discoveryState.pairResolvePromises.set(pairKey, resolvePromise); + resolvePromise.finally(() => { + // Clear after settle so the next discovery group can pick a new entity. + // Parallel awaiters already hold this promise reference. + setTimeout(() => { + if (discoveryState.pairResolvePromises.get(pairKey) === resolvePromise) { + discoveryState.pairResolvePromises.delete(pairKey); + } + }, 0); + }); + } + dynamicResult = await discoveryState.pairResolvePromises.get(pairKey); + + // Leave for a sequential partner that starts after we finish + if (dynamicResult) { + if (pairKey === 'genre-movie' || pairKey === 'genre-series') { + if (!discoveryState.currentDiscoveryGenre) { + discoveryState.currentDiscoveryGenre = dynamicResult; + } else { + // Partner (or race twin) already stored/consumed — clear our leave-behind + discoveryState.currentDiscoveryGenre = null; + } + } else if (pairKey === 'studio-series') { + if (!discoveryState.currentDiscoveryStudio) { + discoveryState.currentDiscoveryStudio = dynamicResult; + } else { + discoveryState.currentDiscoveryStudio = null; + } + } + } + } + } else { + dynamicResult = await resolveDiscoverySource(instanceConfig); + + // Legacy sequential pairing when Map is not used (pairSpotlight off path already skipped) + if (pairSpotlight && getDiscoverySettings().renderSpotlightAboveMatching) { + const configId = baseId; + const genrePairIds = new Set(['genreMovies', 'spotlightGenre', 'genreSeries', 'spotlightGenreSeries']); + const studioPairIds = new Set(['studioShows', 'spotlightNetwork']); + + if (genrePairIds.has(configId)) { + if (discoveryState.currentDiscoveryGenre) { + dynamicResult = discoveryState.currentDiscoveryGenre; + discoveryState.currentDiscoveryGenre = null; + } else if (dynamicResult) { + discoveryState.currentDiscoveryGenre = dynamicResult; + } + } + if (studioPairIds.has(configId)) { + if (discoveryState.currentDiscoveryStudio) { + dynamicResult = discoveryState.currentDiscoveryStudio; + discoveryState.currentDiscoveryStudio = null; + } else if (dynamicResult) { + discoveryState.currentDiscoveryStudio = dynamicResult; + } + } + } + } + if (!dynamicResult) return null; + + instanceConfig.source = dynamicResult.id || dynamicResult.name; + instanceConfig.metadata = dynamicResult.metadata; + instanceConfig.excludeItemId = dynamicResult.excludeItemId; + + const discoveryKind = instanceConfig.discoveryType || instanceConfig.type; + const resolvedSource = instanceConfig.source; + const contentTypes = instanceConfig.discoveryItemType + ? [instanceConfig.discoveryItemType] + : (instanceConfig.includeItemTypes || ['Movie']); + + const hasTemplateQueries = Array.isArray(instanceConfig.queries) && instanceConfig.queries.length > 0; + const queryOptions = hasTemplateQueries + ? { ...(instanceConfig.queries[0].queryOptions || {}) } + : { + Recursive: 'true', + Limit: instanceConfig.itemLimit || 20, + IncludeItemTypes: contentTypes + }; + + if (!hasTemplateQueries) { + if (instanceConfig.sortOrder) queryOptions.SortBy = instanceConfig.sortOrder; + if (instanceConfig.sortOrderDirection) queryOptions.SortOrder = instanceConfig.sortOrderDirection; + } else if (!queryOptions.IncludeItemTypes?.length) { + queryOptions.IncludeItemTypes = contentTypes; + } + if (!queryOptions.Limit) queryOptions.Limit = instanceConfig.itemLimit || 20; + + if (discoveryKind === 'Similar' || discoveryKind === 'Watchlist') { + const similarId = dynamicResult.similarSourceId || resolvedSource; + instanceConfig.queries = [{ + ...(hasTemplateQueries ? instanceConfig.queries[0] : {}), + path: `/Items/${similarId}/Similar`, + queryOptions: { + Limit: instanceConfig.itemLimit || 20, + Fields: queryOptions.Fields || 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData', + IncludeItemTypes: queryOptions.IncludeItemTypes || contentTypes + } + }]; + } else { + switch (discoveryKind) { + case 'Genre': + if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { + queryOptions.GenreIds = [resolvedSource]; + } else if (resolvedSource) { + queryOptions.Genres = resolvedSource; + } + break; + case 'Tag': + if (resolvedSource) queryOptions.Tags = resolvedSource; + break; + case 'Person': + if (resolvedSource) queryOptions.PersonIds = [resolvedSource]; + { + const personType = instanceConfig.discoveryPersonType || instanceConfig.personType || 'Actor'; + queryOptions.PersonTypes = [personType]; + // Series-scoped person rows: actors → Series; director/writer/guest → Episode + const itemScope = instanceConfig.discoveryItemType + || (Array.isArray(contentTypes) && contentTypes[0]) + || 'Movie'; + if (itemScope === 'Series' && personType !== 'Actor') { + queryOptions.IncludeItemTypes = ['Episode']; + } + } + if (instanceConfig.excludeItemId) { + queryOptions.ExcludeItemIds = Array.isArray(instanceConfig.excludeItemId) + ? instanceConfig.excludeItemId + : [instanceConfig.excludeItemId]; + } + break; + case 'Studio': + if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { + queryOptions.StudioIds = [resolvedSource]; + } else if (resolvedSource) { + queryOptions.Studios = resolvedSource; + } + break; + case 'Collection': + case 'Parent': + if (resolvedSource) queryOptions.ParentId = resolvedSource; + break; + } + + if (instanceConfig.spotlight || instanceConfig.renderMode === 'Spotlight') { + queryOptions.Fields = queryOptions.Fields || 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags,Studios'; + const spotlightConfig = instanceConfig.spotlightConfig || {}; + const adminSpotlightConfig = window.KefinHomeScreen?.getConfig?.()?.SPOTLIGHT_SETTINGS || {}; + instanceConfig.spotlightConfig = { ...adminSpotlightConfig, ...spotlightConfig }; + } else if (!queryOptions.Fields) { + queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData'; + } + + instanceConfig.queries = [{ + ...(hasTemplateQueries ? instanceConfig.queries[0] : {}), + queryOptions + }]; + } + + instanceConfig.name = fillTemplate(instanceConfig.name, dynamicResult.metadata); + if (instanceConfig.caption) { + instanceConfig.caption = fillTemplate(instanceConfig.caption, dynamicResult.metadata); + if (dynamicResult.excludeItemId) { + instanceConfig.captionUrl = `#/details?id=${dynamicResult.excludeItemId}&serverId=${ApiClient.serverId()}`; + } + } + instanceConfig.cardFormat = resolveCardFormat(instanceConfig); + if (resolveViewMore) { + instanceConfig.viewMoreUrl = await resolveViewMoreUrl(instanceConfig); + } + + instanceConfig.discoveryPending = false; + return instanceConfig; + } + + /** + * Progressive discovery: return a stub config + lazy-loading result immediately. + * Concrete resolve + load runs on first ensureData / dataPromise / isStalePromise access. + */ + function buildDiscoverySectionPromise(template, options = {}) { + if (window.LibraryCache?.sectionDependsOnIncompleteCache?.(template) === true) { + LOG(`Skipping discovery stub ${template?.id}: depends on incomplete library cache`); + return null; + } + + const pairSpotlight = options.pairSpotlight !== false; + const order = options.order; + const uniqueId = `${template.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const config = { + ...template, + id: uniqueId, + type: 'discovery', + discoveryPending: true, + order: order != null ? order : template.order, + overflowCard: true + }; + if (config.cardFormat) { + /* keep */ + } else { + config.cardFormat = resolveCardFormat(config); + } + + // Shared load: resolve + loadSectionForRendering + let loadConcretePromise = null; + const loadConcrete = () => { + if (!loadConcretePromise) { + loadConcretePromise = (async () => { + const resolved = await buildDiscoverySectionInstance(template, { + pairSpotlight, + resolveViewMore: true, + stubId: uniqueId, + stubOrder: config.order + }); + if (!resolved) return null; + resolved.discoveryPending = false; + // Keep stable id/order for DOM + resolved.id = uniqueId; + if (config.order != null) resolved.order = config.order; + Object.assign(config, resolved); + config.discoveryPending = false; + const qr = await loadSectionForRendering(resolved); + return qr; + })(); + } + return loadConcretePromise; + }; + + const ensureData = async () => { + const qr = await loadConcrete(); + if (!qr) return { Items: [] }; + const items = typeof qr.result?.ensureData === 'function' + ? await qr.result.ensureData() + : (qr.result?.dataPromise ?? qr.result?.data); + return items; + }; + + const result = { + data: { Items: [] }, + isStalePromise: loadConcrete().then(async (qr) => { + if (!qr) return true; + if (qr.result?.isStalePromise) return await qr.result.isStalePromise; + return qr.result?.isStale === true; + }), + ensureData + }; + Object.defineProperty(result, 'dataPromise', { + configurable: true, + enumerable: true, + get() { return ensureData(); } + }); + + return { config, result }; + } + + window.sectionHelper = { + loadSectionForRendering, + buildDiscoverySectionInstance, + buildDiscoverySectionPromise, + resolveDiscoverySource, + fillTemplate, + resolveCardFormat, + resolveViewMoreUrl, + getDiscoverySettings, + getDiscoveryState: () => discoveryState, + resetDiscoveryDedupe + }; + + LOG('sectionHelper ready'); +})(); diff --git a/scripts/seriesCache.js b/scripts/seriesCache.js new file mode 100644 index 0000000..9d6b2e1 --- /dev/null +++ b/scripts/seriesCache.js @@ -0,0 +1,383 @@ +// KefinTweaks Series Library Cache +// Chunked per-user archive of series (no UserData / MediaStreams / Chapters / Trickplay). +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks Series Cache]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks Series Cache]', ...args); + const ERR = (...args) => console.error('[KefinTweaks Series Cache]', ...args); + + const CACHE_NAME = 'series_library'; + const PARTIAL_CACHE_NAME = 'series_library_partial'; + const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedSeriesCache_'; + const SERIES_FIELDS = [ + 'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview', + 'PrimaryImageAspectRatio', 'ChildCount', 'RecursiveItemCount' + ].join(','); + + let series = null; + let isComplete = false; + let fetchPromise = null; + + function utils() { + return window.LibraryCacheUtils || {}; + } + + function getTtl() { + return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000); + } + + function getChunkSize() { + return utils().getLibraryCacheSettings?.().seriesChunkSize || 1000; + } + + function lastFetchStorageKey() { + const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous'; + return `${LAST_FETCH_KEY_PREFIX}${userId}`; + } + + function getLastDateFetched() { + try { + return localStorage.getItem(lastFetchStorageKey()) || null; + } catch (_) { + return null; + } + } + + function setLastDateFetched(iso) { + try { + localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString()); + } catch (e) { + WARN('Failed to store lastDateFetchedSeriesCache:', e); + } + } + + function getApiHelper() { + return window.apiHelper || window.ApiHelper; + } + + function buildItemsUrl({ startIndex, limit, extraParams = {} }) { + const server = window.ApiClient.serverAddress(); + const params = new URLSearchParams({ + IncludeItemTypes: 'Series', + Recursive: 'true', + Fields: SERIES_FIELDS, + ExcludeLocationTypes: 'Virtual', + EnableTotalRecordCount: 'true', + SortBy: 'SortName', + SortOrder: 'Ascending', + StartIndex: String(startIndex || 0), + Limit: String(limit) + }); + Object.entries(extraParams).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') params.set(k, String(v)); + }); + return `${server}/Items?${params.toString()}`; + } + + async function queryItems(url) { + const api = getApiHelper(); + if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable'); + return (await api.getQuery(url, { useCache: false })) || { Items: [], TotalRecordCount: 0 }; + } + + async function stripChunk(items) { + if (utils().stripItemsAsync) { + return utils().stripItemsAsync(items, 'Series'); + } + return (items || []).map((item) => utils().stripSeriesForCache?.(item) || item).filter(Boolean); + } + + async function fetchAndCacheSeries() { + const cache = window.IndexedDBCache; + if (!cache) { + ERR('IndexedDBCache unavailable'); + return []; + } + + const userId = window.ApiClient.getCurrentUserId(); + const complete = await cache.get(CACHE_NAME, userId); + if (complete && Array.isArray(complete)) { + series = complete; + isComplete = true; + LOG(`Using complete cached series (${series.length})`); + return series; + } + + const chunkSize = getChunkSize(); + let startIndex = 0; + let collected = []; + + const partial = await cache.get(PARTIAL_CACHE_NAME, userId); + if (partial && Array.isArray(partial.items) && partial.isComplete !== true) { + collected = partial.items; + startIndex = partial.startIndex || collected.length; + LOG(`Resuming series crawl from index ${startIndex}`); + } + + try { + let hasMore = true; + while (hasMore) { + const data = await queryItems(buildItemsUrl({ startIndex, limit: chunkSize })); + const batch = data.Items || []; + collected = collected.concat(await stripChunk(batch)); + startIndex += batch.length; + await cache.set(PARTIAL_CACHE_NAME, { + isComplete: false, + startIndex, + items: collected + }, userId, getTtl()); + LOG(`Series crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`); + if (batch.length < chunkSize) hasMore = false; + else await yieldToMain(); + } + + series = collected; + isComplete = true; + await cache.set(CACHE_NAME, series, userId, getTtl()); + await cache.clear(PARTIAL_CACHE_NAME, userId); + setLastDateFetched(new Date().toISOString()); + LOG(`Series crawl complete: ${series.length} items`); + return series; + } catch (err) { + ERR('Error fetching series:', err); + return series || collected || []; + } + } + + async function syncSeriesCache() { + if (!isComplete || !series) { + LOG('Skip syncSeriesCache: complete cache not ready'); + return series || []; + } + const lastFetched = getLastDateFetched(); + if (!lastFetched) return series; + + const cache = window.IndexedDBCache; + const userId = window.ApiClient.getCurrentUserId(); + const chunkSize = getChunkSize(); + const server = window.ApiClient.serverAddress(); + + try { + let startIndex = 0; + let incoming = []; + let hasMore = true; + while (hasMore) { + const data = await queryItems(buildItemsUrl({ + startIndex, + limit: chunkSize, + extraParams: { + MinDateLastSaved: lastFetched, + MinDateLastSavedForUser: lastFetched, + SortBy: 'DateLastContentAdded', + SortOrder: 'Descending' + } + })); + const batch = data.Items || []; + incoming = incoming.concat(await stripChunk(batch)); + startIndex += batch.length; + if (batch.length < chunkSize) hasMore = false; + } + + const beforeIds = new Set((series || []).map((s) => s.Id).filter(Boolean)); + let removedIds = []; + + if (incoming.length) { + series = utils().upsertById?.(series, incoming) || series; + LOG(`Synced ${incoming.length} series upserts`); + } + + const countParams = new URLSearchParams({ + IncludeItemTypes: 'Series', + Recursive: 'true', + ExcludeLocationTypes: 'Virtual', + EnableTotalRecordCount: 'true', + Limit: '1', + StartIndex: '0' + }); + const countData = await queryItems(`${server}/Items?${countParams.toString()}`); + const serverCount = countData.TotalRecordCount; + if (typeof serverCount === 'number' && serverCount !== series.length) { + LOG(`Series count mismatch cache=${series.length} server=${serverCount}`); + const serverIds = new Set(); + let idx = 0; + let more = true; + while (more) { + const idParams = new URLSearchParams({ + IncludeItemTypes: 'Series', + Recursive: 'true', + ExcludeLocationTypes: 'Virtual', + EnableTotalRecordCount: 'false', + Limit: String(chunkSize), + StartIndex: String(idx) + }); + const idData = await queryItems(`${server}/Items?${idParams.toString()}`); + const batch = idData.Items || []; + batch.forEach((item) => { + if (item?.Id) serverIds.add(item.Id); + }); + idx += batch.length; + if (batch.length < chunkSize) more = false; + } + removedIds = [...beforeIds].filter((id) => !serverIds.has(id)); + series = series.filter((s) => s?.Id && serverIds.has(s.Id)); + const cachedIds = new Set(series.map((s) => s.Id)); + const missing = [...serverIds].filter((id) => !cachedIds.has(id)); + for (let i = 0; i < missing.length; i += chunkSize) { + const slice = missing.slice(i, i + chunkSize); + const byIdParams = new URLSearchParams({ + Ids: slice.join(','), + Fields: SERIES_FIELDS, + EnableTotalRecordCount: 'false' + }); + const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`); + const stripped = await stripChunk(byIdData.Items || []); + series = utils().upsertById?.(series, stripped) || series; + incoming = incoming.concat(stripped); + } + } + + await cache.set(CACHE_NAME, series, userId, getTtl()); + setLastDateFetched(new Date().toISOString()); + + if ((incoming.length || removedIds.length) && window.PeopleCache?.applySeriesDelta) { + try { + await window.PeopleCache.applySeriesDelta(incoming, removedIds); + } catch (e) { + WARN('PeopleCache.applySeriesDelta failed:', e); + } + } + return series; + } catch (err) { + ERR('syncSeriesCache failed:', err); + return series || []; + } + } + + async function getSeries() { + if (series && isComplete) return series; + if (fetchPromise) return fetchPromise; + fetchPromise = (async () => { + await fetchAndCacheSeries(); + return series || []; + })().finally(() => { + fetchPromise = null; + }); + return fetchPromise; + } + + async function getCompletedSeries() { + const list = await getSeries(); + return (list || []).filter(item => item?.UserData?.Played === true && Number(item.UserData.PlayedPercentage) === 100); + } + + function isCacheComplete() { + return isComplete === true && Array.isArray(series); + } + + async function getSeriesByProviderIds(ids) { + const list = await getSeries(); + const idSet = new Set(); + (ids || []).forEach((id) => { + (utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k)); + }); + if (!idSet.size) return []; + return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet)); + } + + async function initialize() { + await getSeries(); + if (isCacheComplete() && getLastDateFetched()) { + await syncSeriesCache(); + } + return series || []; + } + + async function waitForLoginReady(maxWaitMs = 60000) { + const start = Date.now(); + while (!window.userHelper?.waitForLogin) { + if (Date.now() - start >= maxWaitMs) { + ERR('Timed out waiting for userHelper'); + return false; + } + await new Promise((r) => setTimeout(r, 100)); + } + return window.userHelper.waitForLogin(maxWaitMs); + } + + function yieldToMain() { + return new Promise((resolve) => { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(() => resolve(), { timeout: 500 }); + } else { + requestAnimationFrame(() => setTimeout(resolve, 0)); + } + }); + } + + let bootstrapStarted = false; + let bootstrapScheduled = false; + + async function bootstrap() { + if (bootstrapStarted) return; + bootstrapStarted = true; + try { + await waitForLoginReady(); + await initialize(); + } catch (e) { + ERR('Bootstrap failed:', e); + } + } + + 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); + }); + } + + window.SeriesCache = { + init: initialize, + getSeries, + getCompletedSeries, + getSeriesByProviderIds, + syncSeriesCache, + fetchAndCacheSeries, + isComplete: isCacheComplete, + getLastDateFetched, + bootstrap, + scheduleBootstrap, + refresh: async () => { + const cache = window.IndexedDBCache; + const userId = window.ApiClient?.getCurrentUserId?.(); + if (cache) { + await cache.clear(CACHE_NAME, userId); + await cache.clear(PARTIAL_CACHE_NAME, userId); + } + series = null; + isComplete = false; + return fetchAndCacheSeries(); + } + }; + + scheduleBootstrap(); + LOG('Module loaded'); +})(); diff --git a/scripts/skinManager.js b/scripts/skinManager.js index de5d339..da2134d 100644 --- a/scripts/skinManager.js +++ b/scripts/skinManager.js @@ -20,60 +20,15 @@ const THEME_STORAGE_KEY = 'kefinTweaks_selectedTheme'; const COLOR_SCHEMES_STORAGE_KEY = 'kefinTweaks_selectedColorScheme'; - // Cached server version for synchronous access + // Cached server version for synchronous access — prefer window.KefinTweaks let cachedServerVersion = null; - function getMajorServerVersion(version) { - const versionParts = version.split('.'); - - if (versionParts[0] !== '10') { - const majorVersion = parseInt(versionParts[0], 10); - return majorVersion; - } - - if (versionParts.length >= 2) { - const majorVersion = parseInt(versionParts[1], 10); - if (!isNaN(majorVersion)) { - return majorVersion; - } - } - return null; - } - - /** - * Get the current major server version from ApiClient - * Polls every 500ms for up to 5 seconds in the background if not immediately available - * @returns {number|null} The major version number (e.g., 10 for "10.10.X", 11 for "10.11.X"), or null if unavailable - */ async function getCurrentMajorServerVersion() { - try { - // If we have a cached value, return it - if (cachedServerVersion !== null) { + try { + if (window.KefinTweaks?.getJellyfinMajorVersion) { + cachedServerVersion = await window.KefinTweaks.getJellyfinMajorVersion(); return cachedServerVersion; } - - if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) { - return null; - } - - if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) { - cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion); - return cachedServerVersion; - } - - // Check the server version instead of app version - if (!window.ApiClient._serverVersion) { - // Wait 10s to see if it becomes ready, check every 500ms - const startTime = Date.now(); - while (Date.now() - startTime < 10000) { - if (window.ApiClient._serverVersion) { - break; - } - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion); return cachedServerVersion; } catch (error) { WARN('Error getting server version:', error); diff --git a/scripts/userHelper.js b/scripts/userHelper.js index 5fc828f..8780482 100644 --- a/scripts/userHelper.js +++ b/scripts/userHelper.js @@ -57,12 +57,18 @@ * API Helper functions for Jellyfin operations */ const userHelper = { - getUserDisplayPreferences: async function() { + /** + * Get current-user display preferences. + * @param {{ cacheOnly?: boolean }} [options] + * @returns {Promise<{ promise: Promise, cached: object|null }>} + */ + getUserDisplayPreferences: async function(options = {}) { ensureApiClient(); ensureLoggedIn(); + const cacheOnly = options.cacheOnly === true; let cachedDisplayPreferences = null; - + if (__userDisplayPreferences) { cachedDisplayPreferences = __userDisplayPreferences; } else if (window.LocalStorageCache) { @@ -70,9 +76,18 @@ const displayPreferencesData = cache.get('userDisplayPreferences'); if (displayPreferencesData) { cachedDisplayPreferences = displayPreferencesData; + // Hydrate memory so later cacheOnly calls skip localStorage too + __userDisplayPreferences = displayPreferencesData; } } + if (cacheOnly && cachedDisplayPreferences) { + return { + promise: Promise.resolve(cachedDisplayPreferences), + cached: cachedDisplayPreferences + }; + } + // Wrap this in a promise to return with the cached data const userDisplayPreferencesPromise = new Promise(async (resolve, reject) => { try { @@ -286,7 +301,7 @@ waitForLogin: waitForLogin }; - // TODO -- Update this whenever the user display preferences are updated + // Updated by getUserDisplayPreferences / updateDisplayPreferencesForUser({ updateCache: true }) let __userDisplayPreferences = null; // Expose userHelper to global window object diff --git a/scripts/utils.js b/scripts/utils.js index 645a46c..5eef807 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -1449,6 +1449,10 @@ anyOk = injectIntoUserMenu(entry) || anyOk; } + if (entry.topNavigation === 'main') { + syncCustomTopNavActiveState(); + } + return anyOk; } @@ -1460,6 +1464,91 @@ ERR('Error re-applying custom menu link:', entry?.name, err); } }); + syncCustomTopNavActiveState(); + } + + function parseNavHashParts(hash) { + const raw = String(hash || '#/').replace(/^#/, '') || '/'; + const qIndex = raw.indexOf('?'); + const path = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || '/'; + const query = qIndex >= 0 ? raw.slice(qIndex + 1) : ''; + const params = new URLSearchParams(query); + return { path: path.startsWith('/') ? path : `/${path}`, params }; + } + + function customTopNavHrefMatchScore(href, currentHash) { + if (!href || /^https?:/i.test(href)) return -1; + const targetHash = href.startsWith('#') ? href : `#${href}`; + const current = parseNavHashParts(currentHash); + const target = parseNavHashParts(targetHash); + + if ( + targetHash === currentHash + || decodeURIComponent(targetHash) === decodeURIComponent(currentHash) + ) { + return 1000 + targetHash.length; + } + + if (target.path !== current.path) return -1; + + for (const [key, value] of target.params.entries()) { + if (current.params.get(key) !== value) return -1; + } + + let score = 100 + targetHash.length; + score += [...target.params.keys()].length * 10; + + if (target.path === '/home' && !target.params.has('tab') && current.params.has('tab')) { + return -1; + } + + return score; + } + + function resolveCustomTopNavHref(el) { + if (!el) return ''; + const attrHref = el.getAttribute('href'); + if (attrHref) return attrHref; + const key = el.getAttribute('data-kefin-custom-menu-top-link'); + if (!key) return ''; + const entry = customMenuLinkRegistry.find((e) => entryKey(e) === key); + return entry ? getCustomMenuLinkHref(entry) : ''; + } + + function syncCustomTopNavActiveState() { + const links = Array.from(document.querySelectorAll('[data-kefin-custom-menu-top-link]')); + if (!links.length) return; + + const currentHash = window.location.hash || '#/'; + const normalizedHash = currentHash.startsWith('#') ? currentHash : `#${currentHash}`; + let bestScore = -1; + const winners = []; + + links.forEach((el) => { + el.classList.remove('active'); + const score = customTopNavHrefMatchScore(resolveCustomTopNavHref(el), normalizedHash); + if (score < 0) return; + if (score > bestScore) { + bestScore = score; + winners.length = 0; + winners.push(el); + } else if (score === bestScore) { + winners.push(el); + } + }); + + winners.forEach((el) => el.classList.add('active')); + } + + let customTopNavActiveSyncBound = false; + function ensureCustomTopNavActiveSync() { + if (customTopNavActiveSyncBound) return; + customTopNavActiveSyncBound = true; + window.addEventListener('hashchange', syncCustomTopNavActiveState); + onViewPage(() => { + syncCustomTopNavActiveState(); + }, { pages: [] }); + syncCustomTopNavActiveState(); } function ensureCustomMenuLinkObserver() { @@ -1508,6 +1597,39 @@ text-transform: none; white-space: nowrap; } +[data-kefin-custom-menu-top-link].active { + display: inline-flex; + -moz-box-align: center; + align-items: center; + -moz-box-pack: center; + justify-content: center; + position: relative; + box-sizing: border-box; + outline: 0; + margin: 0; + cursor: pointer; + user-select: none; + vertical-align: middle; + appearance: none; + text-decoration: none; + font-family: "Noto Sans", sans-serif; + font-weight: 500; + font-size: 0.875rem; + line-height: 1.75; + text-transform: none; + min-width: 64px; + border: 0; + border-radius: var(--jf-shape-borderRadius); + padding: 6px 8px; + color: var(--variant-textColor); + background-color: var(--variant-textBg); + --variant-textColor: var(--jf-palette-primary-main); + --variant-outlinedColor: var(--jf-palette-primary-main); + --variant-outlinedBorder: rgba(var(--jf-palette-primary-mainChannel) / 0.5); + --variant-containedColor: var(--jf-palette-primary-contrastText); + --variant-containedBg: var(--jf-palette-primary-main); + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1), border-color 250ms cubic-bezier(0.4, 0, 0.2, 1); +} .userPreferencesPage .readOnlyContent > * { display: flex; flex-direction: column; @@ -1716,6 +1838,7 @@ button[data-kefin-custom-menu-more] .MuiSvgIcon-root { */ async function addCustomMenuLink(name, icon, url, openInNewTab = false, containerSelectorOrOptions = DEFAULT_CUSTOM_MENU_SELECTOR) { ensureCustomMenuLinkStyles(); + ensureCustomTopNavActiveSync(); const options = parseCustomMenuLinkOptions(containerSelectorOrOptions); const trimmedUrl = url != null ? String(url).trim() : ''; const action = options.action || ''; @@ -2086,24 +2209,65 @@ button[data-kefin-custom-menu-more] .MuiSvgIcon-root { // Do not edit manually unless you know what you're doing window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; - - // Find or create KefinTweaks-Config script - const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex( - script => script.Name === 'KefinTweaks-Config' - ); - - if (existingScriptIndex !== -1) { - // Update existing script - injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent; - } else { - // Add new script - injectorConfig.CustomJavaScripts.push({ - Name: 'KefinTweaks-Config', - Script: scriptContent, - Enabled: true, - RequiresAuthentication: false - }); + + // Build / refresh KefinTweaks-injector preload entry (same POST) + let injectorScriptContent = null; + try { + const root = (configToSave.kefinTweaksRoot || '').endsWith('/') + ? configToSave.kefinTweaksRoot + : (configToSave.kefinTweaksRoot || '') + '/'; + if (root && root !== '/') { + if (!window.KefinTweaksLoader) { + await new Promise((resolve, reject) => { + const url = `${root}scripts/kefinTweaks-loader.js`; + const existing = document.querySelector(`script[src="${url}"]`); + if (existing && window.KefinTweaksLoader) { + resolve(); + return; + } + const script = document.createElement('script'); + script.src = url; + script.async = false; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load kefinTweaks-loader.js')); + document.head.appendChild(script); + }); + } + const Loader = window.KefinTweaksLoader; + if (Loader) { + Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); + const major = await window.KefinTweaks.getJellyfinMajorVersion(); + const plan = Loader.buildLoadPlan(configToSave, major, { + root, + configOnly: configToSave.enabled === false + }); + injectorScriptContent = Loader.buildInjectorScript(plan); + LOG(`Built KefinTweaks-injector (${plan.assets.length} assets, major=${major})`); + } + } + } catch (loaderErr) { + WARN('Could not build KefinTweaks-injector entry:', loaderErr); } + + injectorConfig.CustomJavaScripts = (window.KefinTweaksLoader + ? window.KefinTweaksLoader.upsertInjectorEntries(injectorConfig.CustomJavaScripts, { + configScriptContent: scriptContent, + injectorScriptContent: injectorScriptContent + }) + : (() => { + // Fallback: Config-only upsert if loader unavailable + const list = injectorConfig.CustomJavaScripts || []; + const idx = list.findIndex((s) => s.Name === 'KefinTweaks-Config'); + const entry = { + Name: 'KefinTweaks-Config', + Script: scriptContent, + Enabled: true, + RequiresAuthentication: false + }; + if (idx !== -1) list[idx] = { ...list[idx], ...entry }; + else list.push(entry); + return list; + })()); // Save the updated configuration const saveResponse = await fetch(configUrl, { @@ -2119,7 +2283,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; throw new Error(`Failed to save plugin config: ${saveResponse.statusText}`); } - LOG('Successfully saved config to JS Injector plugin'); + LOG('Successfully saved config (+ injector preload) to JS Injector plugin'); return true; } catch (err) { ERR('Error saving config to JS Injector:', err); @@ -2453,21 +2617,26 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; } } + function syncCustomPagesAndTopNavActive() { + syncAllCustomPages(); + syncCustomTopNavActiveState(); + } + function ensureCustomPageViewHandler() { if (customPageViewHandlerUnregister) return; // onViewShow misses custom→custom hash changes (no real Jellyfin view); // hashchange covers those; both are fine when they double-fire. const unregView = onViewPage(() => { - syncAllCustomPages(); + syncCustomPagesAndTopNavActive(); }, { pages: [] }); if (!customPageHashChangeBound) { - window.addEventListener('hashchange', syncAllCustomPages); + window.addEventListener('hashchange', syncCustomPagesAndTopNavActive); customPageHashChangeBound = true; } customPageViewHandlerUnregister = () => { if (typeof unregView === 'function') unregView(); if (customPageHashChangeBound) { - window.removeEventListener('hashchange', syncAllCustomPages); + window.removeEventListener('hashchange', syncCustomPagesAndTopNavActive); customPageHashChangeBound = false; } }; diff --git a/scripts/watchlist.css b/scripts/watchlist.css index 7670106..2a272b7 100644 --- a/scripts/watchlist.css +++ b/scripts/watchlist.css @@ -1882,10 +1882,10 @@ div[data-tab="watchlist"] .loading-message { } .card:has(button[data-played="true"]) .watchlist-button, -.card:not(:has(button[data-played])) .watchlist-button, +.card:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .watchlist-button, .mainDetailButtons:has(button[data-played="true"]) .watchlist-icon, -.mainDetailButtons:not(:has(button[data-played])) .watchlist-icon, -.mainDetailButtons:not(:has(button[data-played])) .btnRecommend { +.mainDetailButtons:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .watchlist-icon, +.mainDetailButtons:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .btnRecommend { display: none; } diff --git a/scripts/watchlist.js b/scripts/watchlist.js index cc1c5e4..d2c9728 100644 --- a/scripts/watchlist.js +++ b/scripts/watchlist.js @@ -2174,13 +2174,13 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: if (activeContent) activeContent.style.display = 'block'; if (pageTab === 'progress') { - renderProgressContent(); + initProgressTab().then(() => renderProgressContent()).catch((err) => ERR('Progress tab init failed:', err)); } else if (pageTab === 'watchlist') { renderWatchlistContent(); } else if (pageTab === 'history') { - renderHistoryContent(); + initHistoryTab().then(() => renderHistoryContent()).catch((err) => ERR('History tab init failed:', err)); } else if (pageTab === 'statistics') { - renderStatisticsContent(); + initStatisticsTab().then(() => renderStatisticsContent()).catch((err) => ERR('Statistics tab init failed:', err)); } } } @@ -2333,21 +2333,45 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } } - // Initialize all tabs with proper dependencies + // Initialize tabs lazily — only the active/default tab; progress/history on demand async function initializeAllTabs() { LOG('Starting tab initialization'); - - // Independent tabs can run in parallel - const watchlistPromise = initWatchlistTab(); - - // Dependent tabs: progress and history first, then statistics - const progressPromise = initProgressTab(); - const historyPromise = initHistoryTab(); - - // Wait for progress and history, then init statistics - await Promise.all([watchlistPromise, progressPromise, historyPromise]); - await initStatisticsTab(); - LOG('All tabs initialized'); + const params = getUrlParams(); + const pageTab = params.pageTab || 'watchlist'; + + await initWatchlistTab(); + + if (pageTab === 'progress') { + await initProgressTab(); + } else if (pageTab === 'history') { + await initHistoryTab(); + } else if (pageTab === 'statistics') { + await initStatisticsTab(); + } + + LOG('Tab initialization complete (active:', pageTab, ')'); + } + + /** Run async work over items with a fixed concurrency limit. */ + async function mapWithConcurrency(items, concurrency, mapper) { + const list = Array.isArray(items) ? items : []; + const limit = Math.max(1, Number(concurrency) || 1); + const results = new Array(list.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < list.length) { + const i = nextIndex++; + results[i] = await mapper(list[i], i); + } + } + + const workers = []; + for (let w = 0; w < Math.min(limit, list.length); w++) { + workers.push(worker()); + } + await Promise.all(workers); + return results; } function sortInProgressSeries(series) { @@ -2462,14 +2486,11 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: LOG(`Found ${inProgressSeries.length} series with PlayedPercentage > 0`); - LOG(`Fetching progress data for all ${inProgressSeries.length} series`); + LOG(`Fetching progress data for ${inProgressSeries.length} series (concurrency 4)`); - // Fetch progress data for all series in parallel - const progressData = await Promise.all( - inProgressSeries.map(async (seriesItem) => { - return await fetchSeriesProgressWithMissingEpisodes(seriesItem, userId, serverUrl, token); - }) - ); + const progressData = await mapWithConcurrency(inProgressSeries, 4, async (seriesItem) => { + return await fetchSeriesProgressWithMissingEpisodes(seriesItem, userId, serverUrl, token); + }); // Filter out series with no valid progress data const validProgressData = progressData.filter(data => data !== null); @@ -3018,6 +3039,84 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: { type: 'Video', key: 'homevideos', label: 'Home Videos' } ]; + const WATCHLIST_SUPPORTED_TYPES = WATCHLIST_SECTION_TYPES.map(({ type }) => type); + const WATCHLIST_SUPPORTED_TYPES_CSV = WATCHLIST_SUPPORTED_TYPES.join(','); + + /** Session Id set for overlay button state only (not Watchlist tab rendering). */ + const watchlistLikedIds = new Set(); + let watchlistLikedIdsLoaded = false; + let watchlistLikedIdsLoadPromise = null; + + function isWatchlistSupportedType(itemType) { + return WATCHLIST_SUPPORTED_TYPES.includes(itemType); + } + + function isItemInLikedIdCache(itemId) { + return !!itemId && watchlistLikedIds.has(itemId); + } + + function setLikedIdInCache(itemId, isLiked) { + if (!itemId) return; + if (isLiked) watchlistLikedIds.add(itemId); + else watchlistLikedIds.delete(itemId); + } + + function mergeLikedIdsFromItems(items) { + const list = Array.isArray(items) ? items : (items?.Items || []); + list.forEach((item) => { + if (item?.Id) watchlistLikedIds.add(item.Id); + }); + } + + function applyWatchlistButtonActiveState(button, isActive) { + if (!button) return; + button.dataset.active = isActive ? 'true' : 'false'; + button.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist'; + } + + function syncOverlayWatchlistButtons() { + document.querySelectorAll('.watchlist-button[data-id]').forEach((button) => { + const itemId = button.getAttribute('data-id'); + applyWatchlistButtonActiveState(button, isItemInLikedIdCache(itemId)); + }); + } + + async function loadWatchlistLikedIdsBaseline() { + if (watchlistLikedIdsLoaded) return watchlistLikedIds; + if (watchlistLikedIdsLoadPromise) return watchlistLikedIdsLoadPromise; + + watchlistLikedIdsLoadPromise = (async () => { + try { + if (window.userHelper?.waitForLogin) { + await window.userHelper.waitForLogin(); + } + const apiHelper = window.apiHelper; + if (!apiHelper?.getWatchlistItems) { + WARN('apiHelper.getWatchlistItems unavailable; liked Id cache empty'); + return watchlistLikedIds; + } + const result = await apiHelper.getWatchlistItems({ + IncludeItemTypes: WATCHLIST_SUPPORTED_TYPES_CSV, + Fields: 'Id', + Limit: 10000 + }, true); + watchlistLikedIds.clear(); + mergeLikedIdsFromItems(result); + watchlistLikedIdsLoaded = true; + LOG(`Loaded watchlist liked Id cache: ${watchlistLikedIds.size} items`); + syncOverlayWatchlistButtons(); + processExistingOverlayContainers(); + } catch (err) { + ERR('Failed to load watchlist liked Id baseline:', err); + } finally { + watchlistLikedIdsLoadPromise = null; + } + return watchlistLikedIds; + })(); + + return watchlistLikedIdsLoadPromise; + } + let watchlistRenderGeneration = 0; function resetWatchlistItemsByType() { @@ -3170,7 +3269,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const items = await section.result.ensureData(); const list = Array.isArray(items) ? items : (items?.Items || []); watchlistItemsByType[section.itemType] = list; + mergeLikedIdsFromItems(list); })); + syncOverlayWatchlistButtons(); if (generation !== watchlistRenderGeneration) return; @@ -4676,6 +4777,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Clear query cache + in-memory index await invalidateAllWatchlistQueries(); resetWatchlistItemsByType(); + watchlistLikedIds.clear(); + watchlistLikedIdsLoaded = true; + syncOverlayWatchlistButtons(); // Refresh watchlist await initWatchlistTab(true); @@ -4687,6 +4791,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: async function updateWatchlistCacheOnToggle(itemId, itemType, isAdded) { try { LOG(`Watchlist membership changed for ${itemType} ${itemId} (added=${isAdded}); refreshing progressive sections`); + setLikedIdInCache(itemId, isAdded === true); + syncOverlayWatchlistButtons(); + if (itemType) { await window.apiHelper?.invalidateWatchlistQueries?.({ IncludeItemTypes: itemType }); } else { @@ -5206,6 +5313,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: LOG(`Found ${playedItems.length} played items in ${type} section`); for (const item of playedItems) { await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), item.Id, 'false'); + setLikedIdInCache(item.Id, false); removedCount++; } } @@ -5213,6 +5321,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: if (removedCount > 0) { LOG(`Sync complete: removed ${removedCount} played items from watchlist; refreshing`); + syncOverlayWatchlistButtons(); await invalidateAllWatchlistQueries(); await renderWatchlistProgressive(true); } else { @@ -6598,15 +6707,15 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: updateUrlParams(activeTab, currentPage, currentSort, currentSortDirection, currentMovieSort, currentMovieSortDirection); - // Render content based on active tab + // Render content based on active tab (lazy-init progress/history/statistics) if (activeTab === 'progress') { - renderProgressContent(); + initProgressTab().then(() => renderProgressContent()).catch((err) => ERR('Progress tab init failed:', err)); } else if (activeTab === 'watchlist') { renderWatchlistContent(); } else if (activeTab === 'history') { - renderHistoryContent(); + initHistoryTab().then(() => renderHistoryContent()).catch((err) => ERR('History tab init failed:', err)); } else if (activeTab === 'statistics') { - renderStatisticsContent(); + initStatisticsTab().then(() => renderStatisticsContent()).catch((err) => ERR('Statistics tab init failed:', err)); } LOG(`Switched to ${activeTab} tab`); @@ -7277,15 +7386,18 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: let watchlistUrl = '#/watchlist'; // Major version of Jellyfin - const jellyfinVersion = window.KefinTweaks.getJellyfinMajorVersion() ?? null; + const jellyfinVersion = await window.KefinTweaks.getJellyfinMajorVersion(); + const isModernUI = document.querySelector('.MuiBox-root') !== null; // If watchlist tab index is null or undefined, add to top navigation - if (watchlistTabIndex === null || watchlistTabIndex === undefined || jellyfinVersion >= 12) { + if (watchlistTabIndex === null || watchlistTabIndex === undefined || isModernUI) { options.topNavigation = 'main'; } else { watchlistUrl = `#/home?tab=${watchlistTabIndex}`; } + window.KefinTweaksUtils._watchlistUrl = watchlistUrl; + window.KefinTweaksUtils.addCustomMenuLink( 'Watchlist', 'bookmark', @@ -7335,129 +7447,84 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Function to add watchlist button to a card overlay container function addWatchlistButton(overlayContainer) { - // Check if watchlist button already exists - if (overlayContainer && overlayContainer.querySelector('.watchlist-button')) { - return; - } - - // Find the card parent to get the item ID + if (!overlayContainer) return; + const card = overlayContainer.closest('.card'); if (!card) { WARN('Could not find card parent for overlay container'); return; } - + const itemId = card.getAttribute('data-id'); if (!itemId) { WARN('Could not find data-id on card element'); return; } - - // Check if card has data-type attribute - if not, don't add watchlist button + const itemType = card.getAttribute('data-type'); if (!itemType) { LOG('Card has no data-type, skipping watchlist button'); return; } - - // Find the .cardOverlayButton-br container + + if (!isWatchlistSupportedType(itemType)) { + return; + } + + if (!ApiClient._loggedIn) { + LOG('User is not logged in, skipping watchlist button'); + return; + } + const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br'); if (!buttonContainer) { WARN('Could not find .cardOverlayButton-br container'); return; } - - // Create watchlist button - const watchlistButton = document.createElement('button'); - watchlistButton.type = 'button'; - watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat'; - watchlistButton.setAttribute('data-action', 'none'); - watchlistButton.setAttribute('data-id', itemId); - watchlistButton.setAttribute('data-active', 'false'); - watchlistButton.title = 'Add to Watchlist'; - - // Create the bookmark icon - const watchlistIcon = document.createElement('span'); - watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist'; - watchlistIcon.setAttribute('aria-hidden', 'true'); - - watchlistButton.appendChild(watchlistIcon); - - // Add click event listener - watchlistButton.addEventListener('click', async (e) => { - e.preventDefault(); - e.stopPropagation(); - - // Toggle watchlist status - const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false'; - await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating); - watchlistButton.dataset.active = newRating; - - // Update icon and title based on state - const isActive = watchlistButton.dataset.active === 'true'; - // Icon state is handled by CSS class, no need to change textContent - watchlistButton.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist'; - - // Update watchlist cache immediately - await updateWatchlistCacheOnToggle(itemId, itemType, isActive); - }); - - // Check if item type is supported for watchlist - if (itemType !== "Movie" && itemType !== "Series" && itemType !== "Season" && itemType !== "Episode" && itemType !== "BoxSet" && itemType !== "Playlist" && itemType !== "Video") { - return; - } - if (!ApiClient._loggedIn) { - LOG('User is not logged in, skipping watchlist button'); - return; - } - - // Check item's current watchlist status from cache only (no server fetch) - // Map itemType to section name - let sectionName; - switch (itemType) { - case 'Movie': - sectionName = 'movies'; - break; - case 'Series': - sectionName = 'series'; - break; - case 'Season': - sectionName = 'seasons'; - break; - case 'Episode': - sectionName = 'episodes'; - break; - case 'BoxSet': - sectionName = 'boxsets'; - break; - case 'Playlist': - sectionName = 'playlists'; - break; - case 'Video': - sectionName = 'homevideos'; - break; - default: - // Unknown type, skip check - break; - } - - if (sectionName && itemType) { - let isInWatchlist = (watchlistItemsByType[itemType] || []).some(item => item.Id === itemId); + let watchlistButton = overlayContainer.querySelector('.watchlist-button'); + const createdNew = !watchlistButton; - // Set button state if item is in watchlist - if (isInWatchlist) { - watchlistButton.dataset.active = 'true'; - watchlistButton.title = 'Remove from Watchlist'; + if (createdNew) { + watchlistButton = document.createElement('button'); + watchlistButton.type = 'button'; + watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat'; + watchlistButton.setAttribute('data-action', 'none'); + watchlistButton.setAttribute('data-id', itemId); + + const watchlistIcon = document.createElement('span'); + watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist'; + watchlistIcon.setAttribute('aria-hidden', 'true'); + watchlistButton.appendChild(watchlistIcon); + + const playStateButton = buttonContainer.querySelector('button[is="emby-playstatebutton"]') + || buttonContainer.querySelector('button:has([data-testid="CheckIcon"])'); + if (playStateButton) { + buttonContainer.insertBefore(watchlistButton, playStateButton); + } else { + buttonContainer.appendChild(watchlistButton); } - } - - // Add the watchlist button to the button container, right before the play state button if it exists - const playStateButton = buttonContainer.querySelector('button[is="emby-playstatebutton"]'); - if (playStateButton) { - buttonContainer.insertBefore(watchlistButton, playStateButton); } else { - buttonContainer.appendChild(watchlistButton); + watchlistButton.setAttribute('data-id', itemId); + } + + // Prefer liked-Id cache once loaded; until then keep cardBuilder UserData.Likes state + if (watchlistLikedIdsLoaded || createdNew) { + applyWatchlistButtonActiveState(watchlistButton, isItemInLikedIdCache(itemId)); + } + + if (watchlistButton.dataset.watchlistBound !== 'true') { + watchlistButton.dataset.watchlistBound = 'true'; + watchlistButton.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false'; + await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating); + const isActive = newRating === 'true'; + applyWatchlistButtonActiveState(watchlistButton, isActive); + await updateWatchlistCacheOnToggle(itemId, itemType, isActive); + }); } } @@ -7518,6 +7585,17 @@ 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(); + + // Expose helpers so cardBuilder can keep overlay buttons / Set aligned on UserData.Likes + window.KefinWatchlistLikedIds = { + has: isItemInLikedIdCache, + set: setLikedIdInCache, + syncOverlayButtons: syncOverlayWatchlistButtons, + isSupportedType: isWatchlistSupportedType + }; + /************ Item Detail Page Observer ************/ // Function to add watchlist button to item detail page