// Jellyfin Home Screen Script // Adds custom scrollable sections to the home screen // Home screen sections are generated from playlists // All playlists used for the home screen sections must be public // Requires: cardBuilder.js, localStorageCache.js, utils.js modules to be loaded before this script (function() { 'use strict'; // Common logging function const LOG = (...args) => console.log('[KefinTweaks HomeScreen]', ...args); const WARN = (...args) => console.warn('[KefinTweaks HomeScreen]', ...args); const ERR = (...args) => console.error('[KefinTweaks HomeScreen]', ...args); LOG('Initializing...'); // Add CSS for discovery section loading indicator function addDiscoveryLoadingCSS() { const styleId = 'discovery-loading-styles'; // Check if style already exists if (document.getElementById(styleId)) { return; } const style = document.createElement('style'); style.id = styleId; style.textContent = ` /* Discovery section loading indicator */ .discovery-loading-indicator { flex-direction: column; gap: 10px; justify-content: center; align-items: center; padding: 20px; border-radius: 8px; font-size: 14px; color: #666; transition: opacity 0.3s ease; order: 99999 !important; position: relative; } .discovery-loading-indicator.show { opacity: 1; display: flex; } .discovery-loading-indicator .spinner { width: 20px; height: 20px; border: 2px solid #f3f3f3; border-top: 2px solid #3498db; border-radius: 50%; animation: discovery-spin 1s linear infinite; } @keyframes discovery-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Discovery section smooth reveal animation */ .discovery-section-reveal { opacity: 0; transform: translateY(20px); transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1), transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); } /* When section enters viewport, trigger animation */ .discovery-section-reveal.in-viewport { opacity: 1; transform: translateY(0); } `; document.head.appendChild(style); LOG('Added discovery loading CSS styles'); } // Create loading indicator element function createDiscoveryLoadingIndicator() { let loadingDiv = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (loadingDiv) { return loadingDiv; } loadingDiv = document.createElement('div'); loadingDiv.className = 'discovery-loading-indicator'; loadingDiv.id = 'discovery-loading-indicator'; loadingDiv.style.visibility = 'hidden'; loadingDiv.innerHTML = `
`; // Find the home sections container and append the loading indicator const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (container) { container.appendChild(loadingDiv); } return loadingDiv; } // Show loading indicator function showDiscoveryLoadingIndicator() { let loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (!loadingIndicator) { createDiscoveryLoadingIndicator(); } loadingIndicator.classList.add('show'); loadingIndicator.style.visibility = 'visible'; loadingIndicator.style.display = 'flex'; LOG('Discovery loading indicator shown'); } // Hide loading indicator function hideDiscoveryLoadingIndicator() { const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (loadingIndicator) { loadingIndicator.classList.remove('show'); loadingIndicator.style.visibility = 'hidden'; if (!enableInfiniteScroll) { loadingIndicator.style.display = 'none'; } LOG('Discovery loading indicator hidden'); } } // Body class management for home page and themes function manageBodyClasses() { const body = document.body; const currentView = window.KefinTweaksUtils?.getCurrentView(); const isHomePage = currentView === 'home' || currentView === 'home.html'; // Get seasonal animation setting const homeScreenConfig = window.KefinTweaksConfig?.homeScreen || {}; const seasonalConfig = homeScreenConfig.seasonal || {}; const enableSeasonalAnimations = seasonalConfig.enableSeasonalAnimations !== false; const enableSeasonalBackground = seasonalConfig.enableSeasonalBackground !== false; // Manage home-screen class if (isHomePage && body) { body.classList.add('home-screen'); } else { body.classList.remove('home-screen'); } // Manage theme classes (only on home page) if (isHomePage) { // Remove all existing theme classes const themeClasses = ['halloween-theme', 'christmas-theme', 'valentines-theme', 'newyear-theme', 'with-background']; themeClasses.forEach(themeClass => body.classList.remove(themeClass)); // Add appropriate theme class based on current date if (isHalloweenPeriod()) { body.classList.add('halloween-theme'); } else if (isChristmasPeriod()) { body.classList.add('christmas-theme'); if (enableSeasonalBackground) { body.classList.add('with-background'); } if (enableSeasonalAnimations) { // Use the snowverlay.js script to add snowflakes to the background const snowverlayScriptSrc = (window.KefinTweaksConfig.kefinTweaksRoot || '') + 'scripts/snowverlay.js'; const snowverlayScript = document.createElement('script'); snowverlayScript.src = snowverlayScriptSrc; document.head.appendChild(snowverlayScript); } } else if (isValentinesPeriod()) { body.classList.add('valentines-theme'); } else if (isNewYearsPeriod()) { body.classList.add('newyear-theme'); } } else { // Remove all theme classes when not on home page const themeClasses = ['halloween-theme', 'christmas-theme', 'valentines-theme', 'newyear-theme', 'with-background']; themeClasses.forEach(themeClass => body.classList.remove(themeClass)); } } // Initialize body class management manageBodyClasses(); // Initialize discovery loading CSS addDiscoveryLoadingCSS(); // Set up page view monitoring if (window.KefinTweaksUtils) { window.KefinTweaksUtils.onViewPage(manageBodyClasses, { pages: [] }); LOG('Registered body class management for home page'); } else { WARN('KefinTweaksUtils not available, body class management may not work correctly'); } /** * Migrates old homeScreen config format to new format * Small, transparent function that runs automatically */ function migrateHomeScreenConfig() { if (!window.KefinTweaksConfig || !window.KefinTweaksConfig.homeScreen) { return; } const old = window.KefinTweaksConfig.homeScreen; const migrated = {}; let needsMigration = false; // Check if old format exists (has enableNewAndTrending, etc.) if (old.enableNewAndTrending !== undefined || old.enableNewMovies !== undefined) { needsMigration = true; // Migrate recently released migrated.recentlyReleased = { enabled: old.enableNewAndTrending !== false, order: 30, movies: { enabled: old.enableNewMovies !== false, itemLimit: 16, sortOrder: "ReleaseDate", sortOrderDirection: "Descending", cardFormat: "Poster", order: 30 }, episodes: { enabled: old.enableNewEpisodes !== false, itemLimit: 16, sortOrder: "PremiereDate", sortOrderDirection: "Descending", cardFormat: "Backdrop", order: 31 } }; // Migrate trending if (old.enableTrending !== undefined) { migrated.trending = { enabled: old.enableTrending || false, itemLimit: 16, sortOrder: "Random", sortOrderDirection: "Ascending", cardFormat: "Poster", order: 32 }; } // Migrate popular TV networks if (old.minimumShowsForNetwork !== undefined) { migrated.popularTVNetworks = { enabled: false, minimumShowsForNetwork: old.minimumShowsForNetwork || 5, itemLimit: 16, sortOrder: "Random", sortOrderDirection: "Ascending", cardFormat: "Poster", order: 61 }; } // Migrate watchlist if (old.enableWatchlist !== undefined) { migrated.watchlist = { enabled: old.enableWatchlist || false, itemLimit: 16, sortOrder: "DateAdded", sortOrderDirection: "Descending", cardFormat: "Poster", order: 60 }; } // Migrate seasonal if (old.enableSeasonal !== undefined) { const seasons = []; // Check if old format has seasons array if (old.seasonal && old.seasonal.seasons && Array.isArray(old.seasonal.seasons)) { // Migrate each season from old format to new format for (const oldSeason of old.seasonal.seasons) { const sections = []; // Migrate genres to Genre sections if (oldSeason.genres && Array.isArray(oldSeason.genres)) { for (const genre of oldSeason.genres) { sections.push({ id: `${oldSeason.id || 'season'}-genre-${genre.toLowerCase().replace(/\s+/g, '-')}`, enabled: true, name: `${genre} Movies`, type: "Genre", source: genre, itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, sortOrder: oldSeason.sortOrder || "Random", sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", cardFormat: oldSeason.cardFormat || "Poster", order: sections.length + 50 }); } } // Migrate tags to Tag sections if (oldSeason.tags && Array.isArray(oldSeason.tags)) { for (const tag of oldSeason.tags) { sections.push({ id: `${oldSeason.id || 'season'}-tag-${tag.toLowerCase().replace(/\s+/g, '-')}`, enabled: true, name: `${tag} Movies`, type: "Tag", source: tag, itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, sortOrder: oldSeason.sortOrder || "Random", sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", cardFormat: oldSeason.cardFormat || "Poster", order: sections.length + 50 }); } } // Migrate collections to Collection sections if (oldSeason.collections && Array.isArray(oldSeason.collections)) { for (const collectionId of oldSeason.collections) { sections.push({ id: `${oldSeason.id || 'season'}-collection-${collectionId}`, enabled: true, name: "Collection", type: "Collection", source: collectionId, itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, sortOrder: oldSeason.sortOrder || "Random", sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", cardFormat: oldSeason.cardFormat || "Poster", order: sections.length + 50 }); } } // Migrate playlists to Playlist sections if (oldSeason.playlists && Array.isArray(oldSeason.playlists)) { for (const playlistId of oldSeason.playlists) { sections.push({ id: `${oldSeason.id || 'season'}-playlist-${playlistId}`, enabled: true, name: "Playlist", type: "Playlist", source: playlistId, itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, sortOrder: oldSeason.sortOrder || "Random", sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", cardFormat: oldSeason.cardFormat || "Poster", order: sections.length + 50 }); } } // If no sections were created, create a default one from genres if available if (sections.length === 0 && oldSeason.genres && oldSeason.genres.length > 0) { sections.push({ id: `${oldSeason.id || 'season'}-default`, enabled: true, name: oldSeason.name || "Seasonal Movies", type: "Genre", source: oldSeason.genres.join(', '), itemLimit: oldSeason.itemLimit || old.seasonalItemLimit || 16, sortOrder: oldSeason.sortOrder || "Random", sortOrderDirection: oldSeason.sortOrderDirection || "Ascending", cardFormat: oldSeason.cardFormat || "Poster", order: 50 }); } seasons.push({ id: oldSeason.id || "halloween", name: oldSeason.name || "Halloween", enabled: oldSeason.enabled !== false, startDate: oldSeason.startDate || "10-01", endDate: oldSeason.endDate || "10-31", sections: sections }); } } else { // Default migration: create Halloween and Christmas seasons seasons.push({ id: "halloween", name: "Halloween", enabled: true, startDate: "10-01", endDate: "10-31", sections: [ { id: "halloween-horror", enabled: true, name: "Horror Movies", type: "Genre", source: "Horror", itemLimit: old.seasonalItemLimit || 16, sortOrder: "Random", sortOrderDirection: "Ascending", cardFormat: "Poster", order: 50 }, { id: "halloween-thriller", enabled: true, name: "Thriller Movies", type: "Genre", source: "Thriller", itemLimit: old.seasonalItemLimit || 16, sortOrder: "Random", sortOrderDirection: "Ascending", cardFormat: "Poster", order: 51 } ] }); seasons.push({ id: "christmas", name: "Christmas", enabled: true, startDate: "12-01", endDate: "12-31", sections: [ { id: "christmas-genre", enabled: true, name: "Christmas Movies", type: "Genre", source: "Christmas", itemLimit: old.seasonalItemLimit || 16, sortOrder: "Random", sortOrderDirection: "Ascending", cardFormat: "Poster", order: 60 }, { id: "christmas-family", enabled: true, name: "Family Movies", type: "Genre", source: "Family", itemLimit: old.seasonalItemLimit || 16, sortOrder: "Random", sortOrderDirection: "Ascending", cardFormat: "Poster", order: 61 } ] }); } migrated.seasonal = { enabled: old.enableSeasonal !== false, defaultItemLimit: old.seasonalItemLimit || 16, defaultSortOrder: "Random", defaultCardFormat: "Poster", seasons: seasons }; } // Migrate existing seasonal config if it's in new format but old structure if (old.seasonal && old.seasonal.seasons && Array.isArray(old.seasonal.seasons)) { const needsRestructure = old.seasonal.seasons.some(season => season.genres || season.tags || season.collections || season.playlists ); if (needsRestructure && !migrated.seasonal) { // Already handled above, but ensure we don't lose it migrated.seasonal = migrated.seasonal || { enabled: old.seasonal.enabled !== false, defaultItemLimit: old.seasonal.defaultItemLimit || 16, defaultSortOrder: old.seasonal.defaultSortOrder || "Random", defaultCardFormat: old.seasonal.defaultCardFormat || "Poster", seasons: [] }; } } // Migrate discovery if (old.enableDiscovery !== undefined || old.minPeopleAppearances !== undefined) { migrated.discovery = { enabled: old.enableDiscovery !== false, infiniteScroll: old.enableInfiniteScroll !== false, minPeopleAppearances: old.minPeopleAppearances || 10, minGenreMovieCount: old.minGenreMovieCount || 50, defaultItemLimit: 16, defaultSortOrder: "Random", defaultCardFormat: "Poster", sectionTypes: { spotlightGenre: true, spotlightNetwork: true, genreMovies: true, studioShows: true, collections: { enabled: true, minimumItems: 10, itemLimit: null, sortOrder: null, sortOrderDirection: null, cardFormat: null }, becauseYouWatched: true, becauseYouLiked: true, starringTopActor: true, directedByTopDirector: true, writtenByTopWriter: true, becauseYouRecentlyWatched: true, starringActorRecentlyWatched: true, directedByDirectorRecentlyWatched: true, writtenByWriterRecentlyWatched: true } }; } // Migrate custom sections if (old.customSections && Array.isArray(old.customSections)) { migrated.customSections = old.customSections.map(section => { // If already in new format (has type and source), keep as is if (section.type && section.source !== undefined) { return section; } // Migrate from old format (sourceId) to new format const sourceId = section.sourceId || section.id; if (!sourceId) { return section; // Can't migrate without sourceId } // Try to determine type from existing data or default to Collection let type = section.type || 'Collection'; let source = section.source || sourceId; // If no type specified, we'll auto-detect in renderCustomSection // But for migration, default to Collection return { id: section.id || `custom-${Math.random().toString(36).substr(2, 9)}`, enabled: section.enabled !== false, name: section.name || 'Custom Section', type: type, source: source, itemLimit: section.itemLimit || 16, sortOrder: section.sortOrder || 'Random', sortOrderDirection: section.sortOrderDirection || 'Ascending', cardFormat: section.cardFormat || 'Poster', order: section.order || 100, spotlight: section.spotlight === true, discoveryEnabled: section.discoveryEnabled === true }; }); } // Add new sections that didn't exist in old format (use defaults if not already present) // These sections are new and won't be in old configs, so we add them with default values if (!window.KefinTweaksConfig.homeScreen.upcoming) { migrated.upcoming = { enabled: true, itemLimit: 48, cardFormat: "Thumb", order: 20 }; } if (!window.KefinTweaksConfig.homeScreen.imdbTop250) { migrated.imdbTop250 = { enabled: true, itemLimit: 16, sortOrder: "Random", sortOrderDirection: "Ascending", cardFormat: "Poster", order: 21 }; } if (!window.KefinTweaksConfig.homeScreen.watchAgain) { migrated.watchAgain = { enabled: false, itemLimit: 16, sortOrder: "Random", sortOrderDirection: "Ascending", cardFormat: "Poster", order: 62 }; } // Merge migrated config with existing (new format takes precedence) window.KefinTweaksConfig.homeScreen = { ...migrated, ...window.KefinTweaksConfig.homeScreen }; LOG('Migrated homeScreen config from old format to new format'); // Save migrated config back to JS Injector plugin (async, don't block) if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { window.KefinTweaksUtils.saveConfigToJavaScriptInjector().catch(err => { ERR('Failed to save migrated config to JS Injector:', err); }); } else { WARN('KefinTweaksUtils.saveConfigToJavaScriptInjector not available, config migration not persisted'); } } // --- NEW UPDATE LOGIC --- // Runs even if no major migration was needed, to catch specific updates // 1. Ensure Recently Released date fields exist (Migration Logic) let configChanged = false; // Use the current config (which might have been just migrated) const currentConfig = window.KefinTweaksConfig.homeScreen; if (currentConfig.recentlyReleased) { // Migrate movies config to minAgeInDays / maxAgeInDays if (currentConfig.recentlyReleased.movies) { // If old fields exist, remove them and replace with new defaults if not set if (currentConfig.recentlyReleased.movies.minPremiereDate !== undefined || currentConfig.recentlyReleased.movies.maxPremiereDate !== undefined) { delete currentConfig.recentlyReleased.movies.minPremiereDate; delete currentConfig.recentlyReleased.movies.maxPremiereDate; configChanged = true; } if (currentConfig.recentlyReleased.movies.minAgeInDays === undefined) { currentConfig.recentlyReleased.movies.minAgeInDays = null; configChanged = true; } if (currentConfig.recentlyReleased.movies.maxAgeInDays === undefined) { currentConfig.recentlyReleased.movies.maxAgeInDays = null; configChanged = true; } } // Migrate episodes config to minAgeInDays / maxAgeInDays if (currentConfig.recentlyReleased.episodes) { if (currentConfig.recentlyReleased.episodes.minPremiereDate !== undefined || currentConfig.recentlyReleased.episodes.maxPremiereDate !== undefined) { delete currentConfig.recentlyReleased.episodes.minPremiereDate; delete currentConfig.recentlyReleased.episodes.maxPremiereDate; configChanged = true; } if (currentConfig.recentlyReleased.episodes.minAgeInDays === undefined) { currentConfig.recentlyReleased.episodes.minAgeInDays = null; configChanged = true; } if (currentConfig.recentlyReleased.episodes.maxAgeInDays === undefined) { currentConfig.recentlyReleased.episodes.maxAgeInDays = null; configChanged = true; } } } // 2. Update Seasonal Sections (Christmas/Halloween legacy IDs) if (currentConfig.seasonal && Array.isArray(currentConfig.seasonal.seasons)) { // Helper function to update a section if it matches an old ID const updateSectionIfMatch = (season, oldId, newConfig) => { if (!season.sections || !Array.isArray(season.sections)) return false; const index = season.sections.findIndex(s => s.id === oldId); if (index !== -1) { season.sections[index] = newConfig; return true; } return false; }; // Iterate ALL seasons to find legacy sections, regardless of season ID/Name for (const season of currentConfig.seasonal.seasons) { if (!season.sections || !Array.isArray(season.sections)) continue; // Update old "Christmas Movies" (genre) to new "Christmas Movies" (tag) if (updateSectionIfMatch(season, 'christmas-genre', { "id": "seasonal-1-section-0", "enabled": true, "name": "Christmas Movies", "type": "Tag", "source": "christmas", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", "cardFormat": "Poster", "order": 50, "spotlight": true, "discoveryEnabled": false, "searchTerm": "", "includeItemTypes": ["Movie"], "additionalQueryOptions": [] })) { configChanged = true; } // Update old "Family Movies" (genre) to new "Christmas Episodes" (parent/search) if (updateSectionIfMatch(season, 'christmas-family', { "id": "seasonal-1-section-1", "enabled": true, "name": "Christmas Episodes", "type": "Parent", "source": "", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", "cardFormat": "Thumb", "order": 51, "spotlight": false, "discoveryEnabled": false, "searchTerm": "christmas", "includeItemTypes": ["Episode"], "additionalQueryOptions": [] })) { configChanged = true; } // Update Halloween Horror (ensure correct config) if (updateSectionIfMatch(season, 'halloween-horror', { "id": "halloween-horror", "enabled": true, "name": "Horror Genre", "type": "Genre", "source": "Horror", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", "cardFormat": "Poster", "order": 51 })) { configChanged = true; } // Update Halloween Thriller (ensure correct config) if (updateSectionIfMatch(season, 'halloween-thriller', { "id": "halloween-thriller", "enabled": true, "name": "Thriller Genre", "type": "Genre", "source": "Thriller", "itemLimit": 16, "sortOrder": "Random", "sortOrderDirection": "Ascending", "cardFormat": "Poster", "order": 52 })) { configChanged = true; } } } if (configChanged && !needsMigration) { LOG('Applied specific configuration updates (recently released dates / christmas sections)'); // Save updated config back to JS Injector plugin if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { window.KefinTweaksUtils.saveConfigToJavaScriptInjector().catch(err => { ERR('Failed to save updated config to JS Injector:', err); }); } } } // Run migration before reading config migrateHomeScreenConfig(); // Read new config structure const homeScreenConfig = window.KefinTweaksConfig?.homeScreen || {}; const defaultItemLimit = homeScreenConfig.defaultItemLimit || 16; const defaultSortOrder = homeScreenConfig.defaultSortOrder || 'Random'; const defaultCardFormat = homeScreenConfig.defaultCardFormat || 'Poster'; // Spotlight configuration const spotlightConfig = homeScreenConfig.spotlight || {}; const spotlightAutoPlay = spotlightConfig.autoPlay || true; const spotlightInterval = spotlightConfig.interval || 5000; const spotlightShowDots = spotlightConfig.showDots || true; const spotlightShowNavButtons = spotlightConfig.showNavButtons || true; const spotlightShowClearArt = spotlightConfig.showClearArt || true; // Custom home sections configuration const customHomeSections = homeScreenConfig.customSections || []; // Recently Released sections configuration const recentlyReleasedConfig = homeScreenConfig.recentlyReleased || {}; const enableNewAndTrending = recentlyReleasedConfig.enabled !== false; const newMoviesConfig = recentlyReleasedConfig.movies || {}; const enableNewMovies = newMoviesConfig.enabled !== false; const newEpisodesConfig = recentlyReleasedConfig.episodes || {}; const enableNewEpisodes = newEpisodesConfig.enabled !== false; // Trending sections configuration const trendingConfig = homeScreenConfig.trending || {}; const enableTrending = trendingConfig.enabled || false; // Popular TV Networks configuration const popularTVNetworksConfig = homeScreenConfig.popularTVNetworks || {}; const minimumShowsForNetwork = popularTVNetworksConfig.minimumShowsForNetwork || 5; // Watchlist configuration const watchlistConfig = homeScreenConfig.watchlist || {}; const enableWatchlist = watchlistConfig.enabled || false; // Watch Again configuration const watchAgainConfig = homeScreenConfig.watchAgain || {}; const enableWatchAgain = watchAgainConfig.enabled || false; // Upcoming configuration const upcomingConfig = homeScreenConfig.upcoming || {}; const enableUpcoming = upcomingConfig.enabled !== false; // IMDb Top 250 configuration const imdbTop250Config = homeScreenConfig.imdbTop250 || {}; const enableImdbTop250 = imdbTop250Config.enabled !== false; // Seasonal configuration const seasonalConfig = homeScreenConfig.seasonal || {}; const enableSeasonal = seasonalConfig.enabled !== false; const enableSeasonalAnimations = seasonalConfig.enableSeasonalAnimations !== false; const SEASONAL_ITEM_LIMIT = seasonalConfig.defaultItemLimit || 16; // Discovery sections configuration const discoveryConfig = homeScreenConfig.discovery || {}; const enableDiscovery = discoveryConfig.enabled !== false; const enableInfiniteScroll = discoveryConfig.infiniteScroll !== false; const minPeopleAppearances = discoveryConfig.minPeopleAppearances || 10; const minGenreMovieCount = discoveryConfig.minGenreMovieCount || 50; const DISCOVERY_ORDER = 1000; // Discovery sections use dynamic ordering const DISCOVERY_SECTION_DEFINITIONS = [ { key: 'spotlightGenre', defaultName: 'Spotlight' }, { key: 'spotlightNetwork', defaultName: 'Spotlight' }, { key: 'genreMovies', defaultName: '[Genre] Movies' }, { key: 'studioShows', defaultName: 'Shows from [Studio]' }, { key: 'collections', defaultName: '[Collection Name]', minimumItems: 10 }, { key: 'becauseYouWatched', defaultName: 'Because you watched [Movie]' }, { key: 'becauseYouLiked', defaultName: 'Because you liked [Movie]' }, { key: 'starringTopActor', defaultName: 'Starring [Actor]' }, { key: 'directedByTopDirector', defaultName: 'Directed by [Director]' }, { key: 'writtenByTopWriter', defaultName: 'Written by [Writer]' }, { key: 'becauseYouRecentlyWatched', defaultName: 'Because you recently watched [Movie]' }, { key: 'starringActorRecentlyWatched', defaultName: 'Starring [Actor] because you recently watched [Movie]' }, { key: 'directedByDirectorRecentlyWatched', defaultName: 'Directed by [Director] because you recently watched [Movie]' }, { key: 'writtenByWriterRecentlyWatched', defaultName: 'Written by [Writer] because you recently watched [Movie]' } ]; const discoverySectionDefinitionMap = DISCOVERY_SECTION_DEFINITIONS.reduce((map, definition) => { map[definition.key] = definition; return map; }, {}); const discoverySectionTypeMap = { 'genre': 'genreMovies', 'director': 'directedByTopDirector', 'writer': 'writtenByTopWriter', 'actor': 'starringTopActor', 'watched': 'becauseYouWatched', 'liked': 'becauseYouLiked', 'studio': 'studioShows', 'watched-recent': 'becauseYouRecentlyWatched', 'actor-recent': 'starringActorRecentlyWatched', 'director-recent': 'directedByDirectorRecentlyWatched', 'writer-recent': 'writtenByWriterRecentlyWatched', 'spotlight-genre': 'spotlightGenre', 'spotlight-network': 'spotlightNetwork', 'collection': 'collections' }; const discoverySectionSettingsCache = {}; /** * Helper to get IsPlayed filter string from config * @param {Object} config - Section configuration object * @returns {string|null} - 'IsPlayed', 'IsUnplayed', or null */ function getIsPlayedFilter(config) { if (config && config.isPlayed === true) return 'IsPlayed'; if (config && config.isPlayed === false) return 'IsUnplayed'; return null; } let collectionsData = null; async function getCollectionsData() { if (collectionsData !== null) { return collectionsData; } collectionsData = await ApiClient.getItems(ApiClient.getCurrentUserId(), { IncludeItemTypes: 'BoxSet', Recursive: true, Fields: 'RecursiveItemCount,ChildCount' }); return collectionsData; } if (!discoveryConfig.sectionTypes) { discoveryConfig.sectionTypes = {}; } if (discoveryConfig.collectionSection) { const legacySection = discoveryConfig.collectionSection; const existingCollectionEntry = discoveryConfig.sectionTypes.collections; if (!existingCollectionEntry || typeof existingCollectionEntry !== 'object') { discoveryConfig.sectionTypes.collections = { enabled: legacySection.enabled !== false, name: legacySection.name || discoveryConfig.sectionNames?.collections || '[Collection Name]', minimumItems: legacySection.minimumItems ?? 10, itemLimit: legacySection.itemLimit ?? null, sortOrder: legacySection.sortOrder ?? null, sortOrderDirection: legacySection.sortOrderDirection ?? null, cardFormat: legacySection.cardFormat ?? null, order: legacySection.order ?? 50 }; } else if (legacySection.minimumItems !== undefined && existingCollectionEntry.minimumItems === undefined) { existingCollectionEntry.minimumItems = legacySection.minimumItems; } delete discoveryConfig.collectionSection; } function parseDiscoveryNumber(value, fallback) { if (value === null || value === undefined || value === '') { return fallback; } const parsed = parseInt(value, 10); return Number.isFinite(parsed) ? parsed : fallback; } function getDiscoverySectionKeyFromType(sectionType) { return discoverySectionTypeMap[sectionType] || null; } function getDiscoverySectionSettings(sectionKey) { if (!sectionKey) return null; if (discoverySectionSettingsCache[sectionKey]) { return discoverySectionSettingsCache[sectionKey]; } const definition = discoverySectionDefinitionMap[sectionKey] || {}; const rawConfig = discoveryConfig.sectionTypes?.[sectionKey]; const legacyNames = discoveryConfig.sectionNames || {}; const defaultItemLimitForDiscovery = discoveryConfig.defaultItemLimit ?? defaultItemLimit; const defaultSortOrderForDiscovery = discoveryConfig.defaultSortOrder ?? defaultSortOrder; const defaultCardFormatForDiscovery = discoveryConfig.defaultCardFormat ?? defaultCardFormat; let normalized = { enabled: true, name: legacyNames[sectionKey] || definition.defaultName || '', itemLimit: defaultItemLimitForDiscovery, sortOrder: defaultSortOrderForDiscovery, sortOrderDirection: 'Ascending', cardFormat: defaultCardFormatForDiscovery }; if (definition.minimumItems !== undefined) { normalized.minimumItems = definition.minimumItems; } if (rawConfig === false) { normalized.enabled = false; } else if (rawConfig && typeof rawConfig === 'object') { normalized = { ...normalized, ...rawConfig }; } else if (rawConfig === true) { normalized.enabled = true; } normalized.name = normalized.name || definition.defaultName || ''; normalized.itemLimit = parseDiscoveryNumber(normalized.itemLimit, defaultItemLimitForDiscovery); normalized.sortOrder = normalized.sortOrder || defaultSortOrderForDiscovery; normalized.sortOrderDirection = normalized.sortOrderDirection || 'Ascending'; normalized.cardFormat = normalized.cardFormat || defaultCardFormatForDiscovery; if (normalized.minimumItems !== undefined) { normalized.minimumItems = parseDiscoveryNumber(normalized.minimumItems, definition.minimumItems ?? 10); } discoverySectionSettingsCache[sectionKey] = normalized; return normalized; } function applyDiscoverySectionOrdering(items, sectionConfig) { if (!Array.isArray(items)) return []; // Filter by IsPlayed if configured let filteredItems = items; const isPlayedFilter = sectionConfig?.isPlayed; if (isPlayedFilter === true) { filteredItems = items.filter(item => item.UserData && item.UserData.Played); } else if (isPlayedFilter === false) { filteredItems = items.filter(item => !item.UserData || !item.UserData.Played); } const sortOrder = sectionConfig?.sortOrder || discoveryConfig.defaultSortOrder || defaultSortOrder; const sortDirection = sectionConfig?.sortOrderDirection || 'Ascending'; let sortedItems = filteredItems; if (sortOrder === 'Random') { sortedItems = [...filteredItems].sort(() => Math.random() - 0.5); } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { sortedItems = window.cardBuilder.sortItems(filteredItems, sortOrder, sortDirection); } const limit = parseDiscoveryNumber( sectionConfig?.itemLimit, discoveryConfig.defaultItemLimit ?? defaultItemLimit ); return sortedItems.slice(0, limit); } // Processing flag to prevent parallel execution let isProcessing = false; let isLoadingDiscoveryGroup = false; // Seasonal data storage let halloweenMovies = []; let horrorMovies = []; let thrillerMovies = []; let halloweenViewMoreUrls = { horror: null, thriller: null }; // Discovery sections data storage let moviesTopPeople = null; let renderedSections = new Set(); // Track rendered sections to avoid duplicates let isGeneratingDiscoverySections = false; // Prevent parallel generation (legacy) let isRenderingDiscoveryGroup = false; // Prevent parallel rendering from buffer let isInitializingPeopleCache = false; // Prevent parallel people cache initialization let isPeopleCacheComplete = false; // Track if all people data has been loaded let preloadedSections = []; // Preloaded sections ready for instant rendering // Track rendered items to avoid duplicates let renderedActors = new Set(); // Track rendered actor IDs let renderedDirectors = new Set(); // Track rendered director IDs let renderedWriters = new Set(); // Track rendered writer IDs let renderedStudios = new Set(); // Track rendered studio IDs let renderedWatchedMovies = new Set(); // Track rendered watched movie IDs for "Because you watched" sections let renderedGenres = new Set(); // Track rendered genre IDs for spotlight sections let renderedNetworks = new Set(); // Track rendered network IDs for spotlight sections let renderedCollections = new Set(); // Track rendered collection IDs let renderedCustomDiscoverySections = new Set(); // Track custom sections used in discovery let cachedQualifyingGenres = null; // Cache qualifying genres to avoid refetching let isCachingQualifyingGenres = false; // Prevent parallel caching let renderedFavoriteMovies = new Set(); // Track rendered favorite movie IDs let renderedStarringWatchedMovies = new Set(); // Track movies used for "Starring X since you watched Y" sections let renderedDirectedWatchedMovies = new Set(); // Track movies used for "Directed by X since you watched Y" sections let renderedWrittenWatchedMovies = new Set(); // Track movies used for "Written by X since you watched Y" sections // Pre-rendered sections waiting to be revealed let hiddenDiscoverySections = []; // Array of pre-rendered section DOM elements // Discovery buffer system let discoveryBuffer = []; // Buffer holding groups of discovery items (always 2 groups) let additionalDiscoveryContent = true; // Flag indicating if more discovery content is available let preloadedSectionElements = []; // Preloaded section DOM elements (hidden) let isPreloadingSections = false; // Prevent parallel preloading let discoveryScrollHandler = null; // Reference to scroll handler for cleanup let discoveryWheelHandler = null; // Reference to wheel handler for cleanup let discoveryTouchStartHandler = null; // Reference to touchstart handler for cleanup let discoveryTouchMoveHandler = null; // Reference to touchmove handler for cleanup /************ Helpers ************/ /** * Fetches collection data from Jellyfin API * @param {string} collectionId - The collection ID to fetch * @returns {Promise} - Collection data with Items array */ async function fetchCollectionData(collectionId) { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const url = `${serverUrl}/Users/${userId}/Items?ParentId=${collectionId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CCanDelete%2CMediaSourceCount%2COverview%2CTaglines`; try { const response = await fetch(url, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); return data; } catch (err) { ERR(`Failed to fetch collection data for ${collectionId}:`, err); return { Items: [] }; } } async function loadItemsForSectionConfig(sectionConfig) { const apiClient = window.ApiClient; const serverAddress = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const type = sectionConfig.type || 'Genre'; const source = sectionConfig.source || ''; const searchTerm = sectionConfig.searchTerm || ''; const includeItemTypes = Array.isArray(sectionConfig.includeItemTypes) && sectionConfig.includeItemTypes.length > 0 ? sectionConfig.includeItemTypes.join(',') : (sectionConfig.includeItemTypes || 'Movie'); const sortOrder = sectionConfig.sortOrder || defaultSortOrder; const sortOrderDirection = sectionConfig.sortOrderDirection || 'Ascending'; const itemLimit = sectionConfig.itemLimit || defaultItemLimit; // Parse additional query options const additionalOptions = {}; if (Array.isArray(sectionConfig.additionalQueryOptions)) { sectionConfig.additionalQueryOptions.forEach(option => { if (option && option.key) { let value = option.value; // Handle booleans if (value === 'true' || value === true) value = true; else if (value === 'false' || value === false) value = false; additionalOptions[option.key] = value; } }); } if (Object.keys(additionalOptions).length > 0) { LOG('Applied additional query options:', additionalOptions); } let allItems = []; const sources = source.split(',').map(s => s.trim()).filter(Boolean); // For Parent type, empty sources is valid (means "Any") if (sources.length === 0 && type !== 'Parent') { return allItems; } try { // Base parameters that apply to all queries // Use defaults if not overridden by additionalOptions const baseParams = { userId: userId, Recursive: true, ...additionalOptions }; // Add search term if provided and not already in additional options if (searchTerm && !baseParams.SearchTerm) { baseParams.SearchTerm = searchTerm; } if (type === 'Genre') { // Combine all genres into a single query const genresParam = sources.map(s => encodeURIComponent(s)).join('|'); const options = { Genres: genresParam, IncludeItemTypes: includeItemTypes, Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount', Limit: itemLimit, SortBy: sortOrder, SortOrder: sortOrderDirection, ...baseParams } const data = await window.apiHelper.getItems(options, true); allItems = data.Items || []; } else if (type === 'Tag') { // Combine all tags into a single query const tagsParam = sources.map(s => encodeURIComponent(s)).join(','); const options = { Tags: tagsParam, IncludeItemTypes: includeItemTypes, Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount', Limit: itemLimit, SortBy: sortOrder, SortOrder: sortOrderDirection, ...baseParams } const data = await window.apiHelper.getItems(options, true); allItems = data.Items || []; } else if (type === 'Collection') { // For collections, we need to query each collection separately as they use ParentId // But we can still use a single approach by collecting all items first const existingIds = new Set(); for (const collectionId of sources) { try { const queryParams = { ParentId: collectionId, IncludeItemTypes: includeItemTypes, Fields: 'ItemCounts,Overview,Taglines', Limit: itemLimit, SortBy: sortOrder, SortOrder: sortOrderDirection, ...baseParams }; const collectionData = await window.apiHelper.getItems(queryParams, true); if (collectionData.Items && collectionData.Items.length > 0) { collectionData.Items.forEach(item => { if (item && item.Id && !existingIds.has(item.Id)) { allItems.push(item); existingIds.add(item.Id); } }); } } catch (err) { WARN(`Error fetching collection ${collectionId}:`, err); } } // Apply sorting and limit after collecting all items if (sortOrder === 'Random') { allItems.sort(() => Math.random() - 0.5); } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { allItems = window.cardBuilder.sortItems(allItems, sortOrder, sortOrderDirection); } allItems = allItems.slice(0, itemLimit); } else if (type === 'Playlist') { // For playlists, similar to collections - query each separately const existingIds = new Set(); for (const playlistId of sources) { try { const queryParams = { ParentId: playlistId, IncludeItemTypes: includeItemTypes, Fields: 'Overview,Taglines', Limit: itemLimit, SortBy: sortOrder, SortOrder: sortOrderDirection, ...baseParams }; const playlistData = await window.apiHelper.getItems(queryParams, true); if (playlistData.Items && playlistData.Items.length > 0) { playlistData.Items.forEach(item => { if (item && item.Id && !existingIds.has(item.Id)) { allItems.push(item); existingIds.add(item.Id); } }); } } catch (err) { WARN(`Error fetching playlist ${playlistId}:`, err); } } // Apply sorting and limit after collecting all items if (sortOrder === 'Random') { allItems.sort(() => Math.random() - 0.5); } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { allItems = window.cardBuilder.sortItems(allItems, sortOrder, sortOrderDirection); } allItems = allItems.slice(0, itemLimit); } else if (type === 'Parent') { // Parent type: query with ParentId(s) or without if source is empty ("Any") const existingIds = new Set(); if (sources.length === 0) { // No parent IDs specified - query all items (no ParentId filter) try { const queryParams = { IncludeItemTypes: includeItemTypes, Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear', Limit: itemLimit, SortBy: sortOrder, SortOrder: sortOrderDirection, ...baseParams }; const response = await window.apiHelper.getItems(queryParams, true); if (response.Items && response.Items.length > 0) { allItems = response.Items; } } catch (err) { WARN(`Error fetching items (Parent type, no ParentId):`, err); } } else { // Query each parent ID separately for (const parentId of sources) { try { const queryParams = { ParentId: parentId, IncludeItemTypes: includeItemTypes, Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear', Limit: itemLimit * sources.length, SortBy: sortOrder, SortOrder: sortOrderDirection, ...baseParams }; const parentData = await window.apiHelper.getItems(queryParams, true); if (parentData.Items && parentData.Items.length > 0) { parentData.Items.forEach(item => { if (item && item.Id && !existingIds.has(item.Id)) { allItems.push(item); existingIds.add(item.Id); } }); } } catch (err) { WARN(`Error fetching items for parent ${parentId}:`, err); } } // Apply sorting and limit after collecting all items if (sortOrder === 'Random') { allItems.sort(() => Math.random() - 0.5); } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { allItems = window.cardBuilder.sortItems(allItems, sortOrder, sortOrderDirection); } allItems = allItems.slice(0, itemLimit); } } } catch (err) { WARN('Error loading items for section config:', err); } return allItems; } /** * Renders a custom home section * @param {Object} section - Section configuration object * @param {HTMLElement} container - Container to append the section to * @returns {boolean} - Success status */ async function renderCustomSection(section, container) { try { // Support new structure (type + source) and legacy structure (sourceId) const type = section.type; const source = section.source || section.sourceId || section.id; if (!source) { ERR(`No source provided for section ${section.name}`); return false; } const sortOrder = section.sortOrder ?? defaultSortOrder; const sortOrderDirection = section.sortOrderDirection ?? 'Ascending'; const cardFormat = section.cardFormat ?? defaultCardFormat; const order = section.order ?? 100; const useSpotlight = section.spotlight === true; let viewMoreUrl = null; switch (type) { case 'Genre': viewMoreUrl = `#/list.html?genreId=${source}&serverId=${ApiClient.serverId()}`; break; case 'Studio': viewMoreUrl = `#/list.html?studioId=${source}&serverId=${ApiClient.serverId()}`; break; case 'Playlist': case 'BoxSet': viewMoreUrl = `#/details?id=${source}&serverId=${ApiClient.serverId()}`; break; case 'Parent': viewMoreUrl = `#/list.html?parentId=${source}&serverId=${ApiClient.serverId()}`; break; default: viewMoreUrl = null; break; } // If new structure with type, use renderSection helper if (useSpotlight && window.cardBuilder.renderSpotlightSection) { const items = await loadItemsForSectionConfig(section); const spotlightContainer = window.cardBuilder.renderSpotlightSection( items, section.name ?? 'Spotlight', { autoPlay: spotlightAutoPlay, interval: spotlightInterval, showDots: spotlightShowDots, showNavButtons: spotlightShowNavButtons, showClearArt: spotlightShowClearArt, viewMoreUrl: viewMoreUrl } ); spotlightContainer.setAttribute('data-custom-section-id', section.id || source); spotlightContainer.setAttribute('data-custom-section-name', section.name); spotlightContainer.setAttribute('data-custom-section-type', type); spotlightContainer.style.order = order; container.appendChild(spotlightContainer); return true; } return await renderSection(section, container, 'custom'); } catch (err) { ERR(`Error rendering section ${section.name}:`, err); return false; } } /** * Renders all custom home sections * @param {HTMLElement} container - Container to append sections to */ async function renderAllCustomSections(container) { const results = await Promise.all( customHomeSections.map(section => { if (section.discoveryEnabled ) { return false; } // Check if section is already on the page const sectionContainer = container.querySelector(`[data-custom-section-id="${section.id}"]`); if (sectionContainer) { LOG(`Section ${section.name} already on the page, skipping...`); return false; } return renderCustomSection(section, container); }) ); const successCount = results.filter(Boolean).length; LOG(`Rendered ${successCount}/${customHomeSections.length} custom sections`); } /** * Renders trending movies section (stub for future implementation) * @param {HTMLElement} container - Container to append the section to * @returns {boolean} - Success status */ function renderTrendingMoviesSection(container) { // TODO: Implement trending movies section LOG('Trending Movies section not yet implemented'); return false; } /** * Renders trending series section (stub for future implementation) * @param {HTMLElement} container - Container to append the section to * @returns {boolean} - Success status */ function renderTrendingSeriesSection(container) { // TODO: Implement trending series section LOG('Trending Series section not yet implemented'); return false; } /** * Renders all new and trending sections * @param {HTMLElement} container - Container to append sections to */ async function renderAllNewAndTrendingSections(container) { if (!enableNewAndTrending) { LOG('New and Trending sections disabled, skipping...'); return; } const sectionsToRender = []; // Add new movies section if enabled and not already on the page const newMoviesContainer = container.querySelector('[data-custom-section-id="new-movies"]'); if (enableNewMovies && !newMoviesContainer) { sectionsToRender.push(renderNewMoviesSection(container)); } // Add new episodes section if enabled and not already on the page const newEpisodesContainer = container.querySelector('[data-custom-section-id="new-episodes"]'); if (enableNewEpisodes && !newEpisodesContainer) { sectionsToRender.push(renderNewEpisodesSection(container)); } if (sectionsToRender.length === 0) { return; } const results = await Promise.all(sectionsToRender); const successCount = results.filter(Boolean).length; LOG(`Rendered ${successCount}/${sectionsToRender.length} new and trending sections`); } /************ Seasonal Section Helpers ************/ /** * Checks if current date is within Halloween period (Oct 1-31) * @returns {boolean} - True if within Halloween period */ function isHalloweenPeriod() { const now = new Date(); const month = now.getMonth() + 1; // getMonth() returns 0-11 const day = now.getDate(); return month === 10 && day >= 1 && day <= 31; } /** * Checks if current date is within Christmas period (Dec 1-30) * @returns {boolean} - True if within Christmas period */ function isChristmasPeriod() { const now = new Date(); const month = now.getMonth() + 1; const day = now.getDate(); return month === 12 && day >= 1 && day <= 30; } /** * Checks if current date is within New Years period (Dec 31 + Jan 1) * @returns {boolean} - True if within New Years period */ function isNewYearsPeriod() { const now = new Date(); const month = now.getMonth() + 1; const day = now.getDate(); return (month === 12 && day === 31) || (month === 1 && day === 1); } /** * Checks if current date is within Valentine's Day period (Feb 14) * @returns {boolean} - True if within Valentine's Day period */ function isValentinesPeriod() { const now = new Date(); const month = now.getMonth() + 1; const day = now.getDate(); return month === 2 && day === 14; } /** * Fetches all movie genres from Jellyfin API and caches them * @returns {Promise} - Array of genre objects with Name, ImageTags, and Id */ async function fetchAndCacheMovieGenres() { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); try { const response = await fetch(`${serverUrl}/Genres?IncludeItemTypes=Movie`, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const genres = data.Items || []; // Optimize genres to only include essential fields const optimizedGenres = genres.map(genre => ({ Id: genre.Id, Name: genre.Name, ImageTags: genre.ImageTags || {}, MovieCount: genre.MovieCount })); // Cache the optimized genres const cache = new window.LocalStorageCache(); cache.set('movieGenres', optimizedGenres); LOG(`Cached ${optimizedGenres.length} movie genres`); return optimizedGenres; } catch (err) { ERR('Failed to fetch movie genres:', err); return []; } } /** * Gets a genre ID from cached movie genres * @param {string} genreName - Name of the genre to find * @returns {Promise} - Genre ID or null if not found */ async function getGenreId(genreName) { const cache = new window.LocalStorageCache(); // Try to get from cache first let movieGenres = cache.get('movieGenres'); // If not in cache or cache is invalid, fetch and cache if (!movieGenres || movieGenres.length === 0) { LOG('Movie genres not in cache, fetching...'); movieGenres = await fetchAndCacheMovieGenres(); } // Find the genre by name (case insensitive) const genre = movieGenres.find(g => g.Name.toLowerCase() === genreName.toLowerCase()); return genre ? genre.Id : null; } /** * Fetches Halloween movie data from Jellyfin API * @returns {Promise} - Object containing halloween, horror, and thriller movies */ async function fetchHalloweenMovieData() { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const serverId = apiClient.serverId(); // No longer fetching people data for Halloween const fieldsParam = ''; // Fetch genre IDs for ViewMore URLs const [horrorGenreId, thrillerGenreId] = await Promise.all([ getGenreId('Horror'), getGenreId('Thriller') ]); const queries = [ { name: 'halloween', url: `${serverUrl}/Items?userId=${userId}&Tags=halloween&IncludeItemTypes=Movie&Recursive=true&Limit=200${fieldsParam}`, viewMoreUrl: null }, { name: 'horror', url: `${serverUrl}/Items?userId=${userId}&Genres=Horror&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=200${fieldsParam}`, viewMoreUrl: horrorGenreId ? `/web/#/list.html?genreId=${horrorGenreId}&serverId=${serverId}` : null }, { name: 'thriller', url: `${serverUrl}/Items?userId=${userId}&Genres=Thriller&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=200${fieldsParam}`, viewMoreUrl: thrillerGenreId ? `/web/#/list.html?genreId=${thrillerGenreId}&serverId=${serverId}` : null } ]; try { const results = await Promise.all( queries.map(async (query) => { const response = await fetch(query.url, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); return { name: query.name, items: data.Items || [] }; }) ); // Process results const halloweenItems = results.find(r => r.name === 'halloween')?.items || []; const horrorItems = results.find(r => r.name === 'horror')?.items || []; const thrillerItems = results.find(r => r.name === 'thriller')?.items || []; // Combine all items into halloweenMovies, removing duplicates by ID const allItems = [...halloweenItems]; const existingIds = new Set(halloweenItems.map(item => item.Id)); // Add horror items that aren't already in halloween horrorItems.forEach(item => { if (!existingIds.has(item.Id)) { allItems.push(item); existingIds.add(item.Id); } }); // Add thriller items that aren't already in halloween or horror thrillerItems.forEach(item => { if (!existingIds.has(item.Id)) { allItems.push(item); existingIds.add(item.Id); } }); return { allHalloweenMovies: allItems, halloweenMovies: halloweenItems, horrorMovies: horrorItems, thrillerMovies: thrillerItems, viewMoreUrls: { horror: queries.find(q => q.name === 'horror')?.viewMoreUrl || null, thriller: queries.find(q => q.name === 'thriller')?.viewMoreUrl || null } }; } catch (err) { ERR('Failed to fetch Halloween movie data:', err); return { allHalloweenMovies: [], halloweenMovies: [], horrorMovies: [], thrillerMovies: [], viewMoreUrls: { horror: null, thriller: null } }; } } /** * Fetches and processes top people data from all movies using pagination * @returns {Promise} - Top people data or null if failed */ async function fetchTopPeople() { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const cache = new window.LocalStorageCache(); const indexedDBCache = new window.IndexedDBCache(); try { LOG('Starting paginated fetch of movies for people data processing...'); let startIndex = 0; const limit = 500; let hasMoreData = true; let peopleMap = null; // Will hold raw (unfiltered) people data // Load existing raw people data if available (for resuming) - use IndexedDB const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId); if (existingRawData && !existingRawData.isComplete) { const moviesProcessed = existingRawData.moviesProcessedCount; if (moviesProcessed > 0) { startIndex = moviesProcessed; // Reconstruct Map from cached array peopleMap = new Map(existingRawData.peopleData || []); LOG(`Resuming from existing data: ${moviesProcessed} movies already processed, ${peopleMap.size} people in cache`); } } // Fetch movies in chunks of 500 while (hasMoreData) { const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=Movie&Recursive=true&Fields=People&Limit=${limit}&StartIndex=${startIndex}`; LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`); const response = await fetch(url, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const movies = data.Items || []; if (movies.length === 0) { hasMoreData = false; LOG('No more movies to fetch'); } else { // Process new movies and merge with existing people data peopleMap = processPeopleDataRaw(movies, peopleMap); startIndex += movies.length; LOG(`Fetched ${movies.length} movies, total processed: ${startIndex}, people tracked: ${peopleMap ? peopleMap.size : 0}`); // Filter for display (but cache raw data) const filteredData = filterPeopleData(peopleMap); if (filteredData) { // Update the global moviesTopPeople immediately so discovery sections can use it moviesTopPeople = filteredData; LOG(`Updated moviesTopPeople with partial data: ${filteredData.actors.length} actors, ${filteredData.directors.length} directors, ${filteredData.writers.length} writers (${startIndex} movies processed)`); } // Cache RAW (unfiltered) data for resuming - use IndexedDB for large data // Convert Map to array for JSON serialization const rawDataToCache = { isComplete: movies.length < limit, moviesProcessedCount: startIndex, peopleData: Array.from(peopleMap.entries()) }; await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000); // If we got less than the limit, we've reached the end if (movies.length < limit) { hasMoreData = false; isPeopleCacheComplete = true; LOG('Reached end of movies, people cache is complete'); } } } // Final processing: filter the complete raw data LOG(`Final filtering of people data from ${startIndex} movies...`); const finalPeopleData = filterPeopleData(peopleMap); if (finalPeopleData) { // Mark as complete finalPeopleData.isComplete = true; // Update global variable moviesTopPeople = finalPeopleData; // Cache final filtered data (for quick loading) - use IndexedDBCache for larger data with items await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000); // Also remove raw cache since we're complete (optional, saves space) await indexedDBCache.clear('movies_top_people_raw', userId); LOG(`Final cached people data: ${finalPeopleData.actors.length} actors, ${finalPeopleData.directors.length} directors, ${finalPeopleData.writers.length} writers`); } return finalPeopleData; } catch (err) { ERR('Failed to fetch top people data:', err); return null; } } /** * Extracts necessary item data for caching (similar to watchlist format) * @param {Object} item - Full Jellyfin item object * @returns {Object} - Optimized item data for storage */ function extractItemDataForCache(item) { return { Id: item.Id, Type: item.Type, MediaType: item.MediaType, Name: item.Name, SeriesId: item.SeriesId, SeriesName: item.SeriesName, SeriesPrimaryImageTag: item.SeriesPrimaryImageTag, ParentBackdropItemId: item.ParentBackdropItemId, ParentBackdropImageTags: item.ParentBackdropImageTags, ParentThumbImageTag: item.ParentPrimaryImageTags, IndexNumber: item.IndexNumber, ParentIndexNumber: item.ParentIndexNumber, ImageTags: item.ImageTags, BackdropImageTags: item.BackdropImageTags, PremiereDate: item.PremiereDate, ProductionYear: item.ProductionYear, UserData: { IsFavorite: item.UserData?.IsFavorite || false, Played: item.UserData?.Played || false, LastPlayedDate: item.UserData?.LastPlayedDate || null, PlayCount: item.UserData?.PlayCount || 0, Likes: item.UserData?.Likes || false } }; } /** * Processes people data from movies array and returns RAW data (all people, unfiltered) * @param {Array} movies - Array of movie objects with People data * @param {Map} existingPeopleMap - Optional existing people map to merge with * @returns {Map|null} - Map of all people data (unfiltered) or null if failed */ 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, { id: person.Id, name: person.Name, directorCount: 0, writerCount: 0, actorCount: 0, directorItems: [], writerItems: [], actorItems: [] }); } const personData = peopleMap.get(key); if (person.Type === 'Director') { personData.directorCount++; // Check if item already exists by Id 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); } } }); } }); return peopleMap; } catch (err) { ERR('Failed to process people data:', err); return null; } } /** * Filters and sorts raw people data by minPeopleAppearances, returning top 100 per category * @param {Map} peopleMap - Map of all people data (unfiltered) * @returns {Object|null} - Filtered and sorted people data or null if failed */ function filterPeopleData(peopleMap) { try { if (!peopleMap) { return null; } const allPeople = Array.from(peopleMap.values()); const topDirectors = allPeople .filter(person => person.directorCount >= minPeopleAppearances) .sort((a, b) => b.directorCount - a.directorCount) .slice(0, 100) .map(person => ({ id: person.id, name: person.name, count: person.directorCount, items: person.directorItems || [] })); const topWriters = allPeople .filter(person => person.writerCount >= minPeopleAppearances) .sort((a, b) => b.writerCount - a.writerCount) .slice(0, 100) .map(person => ({ id: person.id, name: person.name, count: person.writerCount, items: person.writerItems || [] })); const topActors = allPeople .filter(person => person.actorCount >= minPeopleAppearances) .sort((a, b) => b.actorCount - a.actorCount) .slice(0, 100) .map(person => ({ id: person.id, name: person.name, count: person.actorCount, items: person.actorItems || [] })); return { actors: topActors, directors: topDirectors, writers: topWriters }; } catch (err) { ERR('Failed to filter people data:', err); return null; } } /** * Processes people data from movies array (legacy function, now uses raw processing + filtering) * @param {Array} movies - Array of movie objects with People data * @returns {Object|null} - Processed people data or null if failed */ function processPeopleData(movies) { const peopleMap = processPeopleDataRaw(movies); return filterPeopleData(peopleMap); } /** * Initializes the people cache in the background */ async function initializePeopleCache() { // Prevent concurrent initialization if (isInitializingPeopleCache) { LOG('People cache initialization already in progress, skipping...'); return; } // Check if we already have complete data if (moviesTopPeople !== null && isPeopleCacheComplete) { LOG('People cache already loaded and complete'); return; } const indexedDBCache = new window.IndexedDBCache(); const apiClient = window.ApiClient; const userId = apiClient.getCurrentUserId(); // First, check if we have valid complete filtered data (in IndexedDBCache) if (await indexedDBCache.isCacheValid('movies_top_people', userId)) { const cachedData = await indexedDBCache.get('movies_top_people', userId); // Check if data is complete if (cachedData && cachedData.isComplete) { moviesTopPeople = cachedData; isPeopleCacheComplete = true; LOG('Loaded complete top people data from IndexedDB cache'); return; } } // If no complete data, check for raw partial data (in IndexedDBCache) if (await indexedDBCache.isCacheValid('movies_top_people_raw', userId)) { const rawCachedData = await indexedDBCache.get('movies_top_people_raw', userId); if (rawCachedData && rawCachedData.peopleData) { // Reconstruct Map and filter for immediate display const peopleMap = new Map(rawCachedData.peopleData); const filteredData = filterPeopleData(peopleMap); if (filteredData) { moviesTopPeople = filteredData; isPeopleCacheComplete = rawCachedData.isComplete || false; LOG(`Loaded partial top people data from raw cache (${rawCachedData.moviesProcessedCount || 0} movies), ${filteredData.actors.length} actors, ${filteredData.directors.length} directors, ${filteredData.writers.length} writers`); // If incomplete, we'll continue fetching in background if (!isPeopleCacheComplete) { // Continue in background } else { // It's marked complete but wasn't in filtered cache, update filtered cache filteredData.isComplete = true; await indexedDBCache.set('movies_top_people', filteredData, userId, 7 * 24 * 60 * 60 * 1000); await indexedDBCache.clear('movies_top_people_raw', userId); return; } } } } // Set flag to prevent concurrent calls isInitializingPeopleCache = true; try { // Fetch/continue fetching data in the background LOG('Fetching/continuing top people data in background...'); const result = await fetchTopPeople(); if (result) { moviesTopPeople = result; isPeopleCacheComplete = result.isComplete || false; } } finally { // Always reset the flag isInitializingPeopleCache = false; } } /** * Navigates to the watchlist tab by clicking the appropriate tab button */ async function getWatchlistUrl() { let homePageSuffix = '.html'; if (ApiClient._serverInfo.Version?.split('.')[1] > 10) { homePageSuffix = ''; } if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.getWatchlistTabIndex || !window.KefinTweaksUtils.getWatchlistTabIndex()) { WARN('KefinTweaksUtils not available'); return null; } const visibleWatchlistTabIndex = await window.KefinTweaksUtils.getWatchlistTabIndex(); if (visibleWatchlistTabIndex) { return `#/home${homePageSuffix}?tab=${visibleWatchlistTabIndex}`; } else { WARN('Watchlist tab not found'); return null; } } /************ Discovery Section Helpers ************/ /** * Fetches similar content for a movie or series * @param {string} itemId - The item ID * @param {string} type - 'Movie' or 'Series' * @returns {Promise} - Array of similar items */ async function fetchSimilarContent(itemId, type) { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const endpoint = type === 'Movie' ? 'Movies' : 'Shows'; // Double the limit to account for filtering out played items // Sadly Jellyfin API doesn't seem to support filtering out played items in the Similar endpoint const url = `${serverUrl}/${endpoint}/${itemId}/Similar?limit=32`; try { const response = await fetch(url, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const allItems = data.Items || []; // Return all items, filtering will happen in applyDiscoverySectionOrdering if needed // (shuffled to avoid always showing the same items) return [...allItems].sort(() => Math.random() - 0.5); } catch (err) { ERR(`Failed to fetch similar content for ${type} ${itemId}:`, err); return []; } } /** * Fetches items with IsFavorite filter * @param {string} includeItemTypes - Item types to include (e.g., 'Movie,Series') * @returns {Promise} - Array of favorite items */ async function fetchFavoriteItems(includeItemTypes = 'Movie,Series') { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=${includeItemTypes}&SortBy=DatePlayed&SortOrder=Descending&Recursive=true&Limit=16&Filters=IsFavorite`; try { const response = await fetch(url, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); return data.Items || []; } catch (err) { ERR(`Failed to fetch favorite items:`, err); return []; } } /** * Gets random item from array * @param {Array} items - Array of items * @param {Set} trackingSet - Optional set to track and filter out already rendered items * @returns {Object|null} - Random item or null if array is empty or all items are already rendered */ function getRandomItem(items, trackingSet = null) { if (!items || items.length === 0) return null; // If tracking set provided, filter out already rendered items let availableItems = items; if (trackingSet) { availableItems = items.filter(item => { const name = item.Name || item.name; return name && !trackingSet.has(name); }); } // Check if we have any available items if (availableItems.length === 0) return null; // Get random item from available items const randomItem = availableItems[Math.floor(Math.random() * availableItems.length)]; // Track the item if tracking set provided if (trackingSet && randomItem) { const name = randomItem.Name || randomItem.name; if (name) { trackingSet.add(name); } } return randomItem; } /** * Shuffles an array and returns a new shuffled copy * @param {Array} array - Array to shuffle * @returns {Array} - New shuffled array */ function shuffleArray(array) { if (!array || array.length === 0) return []; const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } /** * Gets top N most frequent people from movie history * @param {Array} movies - Array of movie objects * @param {string} personType - 'Director' or 'Actor' * @param {number} limit - Number of top people to return * @returns {Array} - Array of person objects with counts */ function getTopPeople(movies, personType, limit = 25) { const personCounts = {}; movies.forEach(movie => { if (movie.People) { movie.People.forEach(person => { if (person.Type === personType) { const key = person.Id; if (!personCounts[key]) { personCounts[key] = { ...person, count: 0 }; } personCounts[key].count++; } }); } }); return Object.values(personCounts) .sort((a, b) => b.count - a.count) .slice(0, limit); } /** * Gets watchlist data from localStorageCache * @returns {Array} - Combined watchlist items (movies + series) */ async function getWatchlistData() { const watchlistData = await window.apiHelper.getWatchlistItems({ IncludeItemTypes: 'Movie,Series,Season,Episode' }, true); return watchlistData.Items.sort((a, b) => { const dateA = new Date(a.PremiereDate || 0); const dateB = new Date(b.PremiereDate || 0); return dateB - dateA; }); } /** * Gets movie history data from localStorageCache * @returns {Array} - Array of watched movies */ function getMovieHistoryData() { if (typeof window.LocalStorageCache === 'undefined') { WARN('LocalStorageCache not available'); return []; } const cache = new window.LocalStorageCache(); return cache.get('movies') || []; } /** * Gets series progress data from localStorageCache * @returns {Array} - Array of series with progress data */ function getSeriesProgressData() { if (typeof window.LocalStorageCache === 'undefined') { WARN('LocalStorageCache not available'); return []; } const cache = new window.LocalStorageCache(); return cache.getChunked('progress') || []; } /** * Gets random watched movie from history (last 25 by DatePlayed) * @returns {Object|null} - Random watched movie or null */ function getRandomWatchedMovie() { const movies = getMovieHistoryData(); if (movies.length === 0) return null; // Sort by DatePlayed descending and take last 25 const sortedMovies = movies .filter(movie => movie.UserData && movie.UserData.Played) .sort((a, b) => new Date(b.UserData.LastPlayedDate || 0) - new Date(a.UserData.LastPlayedDate || 0)) .slice(0, 25); // Filter out already rendered movies const availableMovies = sortedMovies.filter(movie => movie.Id && !renderedWatchedMovies.has(movie.Id) ); if (availableMovies.length === 0) return null; const randomMovie = getRandomItem(availableMovies); // Track rendered movie if (randomMovie && randomMovie.Id) { renderedWatchedMovies.add(randomMovie.Id); } return randomMovie; } /** * Gets random watched series from progress (last 25 by lastWatchedEpisode date) * @returns {Object|null} - Random watched series or null */ function getRandomWatchedSeries() { const series = getSeriesProgressData(); if (series.length === 0) return null; // Sort by lastWatchedEpisode date and take last 25 const sortedSeries = series .filter(s => s.lastWatchedEpisode && s.lastWatchedEpisode.UserData && s.lastWatchedEpisode.UserData.Played) .sort((a, b) => new Date(b.lastWatchedEpisode.UserData.LastPlayedDate || 0) - new Date(a.lastWatchedEpisode.UserData.LastPlayedDate || 0)) .slice(0, 25); const randomSeries = getRandomItem(sortedSeries); return randomSeries ? randomSeries.series : null; } /** * Gets random favorite movie * @returns {Promise} - Random favorite movie or null */ async function getRandomFavoriteMovie() { const favorites = await fetchFavoriteItems('Movie'); if (!favorites || favorites.length === 0) return null; // Filter out already rendered movies const availableMovies = favorites.filter(movie => movie.Id && !renderedFavoriteMovies.has(movie.Id) ); if (availableMovies.length === 0) return null; const randomMovie = getRandomItem(availableMovies); // Track rendered movie if (randomMovie && randomMovie.Id) { renderedFavoriteMovies.add(randomMovie.Id); } return randomMovie; } /** * Gets random favorite series * @returns {Promise} - Random favorite series or null */ async function getRandomFavoriteSeries() { const favorites = await fetchFavoriteItems('Series'); return getRandomItem(favorites); } /** * Gets random recently watched movie with heavy weighting for first 5 * @param {Set} trackingSet - Optional set to track rendered movies (defaults to renderedWatchedMovies) * @returns {Object|null} - Random recently watched movie or null */ function getRandomRecentlyWatchedMovie(trackingSet = null) { const movies = getMovieHistoryData(); if (movies.length === 0) return null; // Sort by LastPlayedDate descending and take top 10 const sortedMovies = movies .filter(movie => movie.UserData && movie.UserData.Played && movie.UserData.LastPlayedDate) .sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate)) .slice(0, 10); if (sortedMovies.length === 0) return null; // Use provided tracking set or default to renderedWatchedMovies const usedTrackingSet = trackingSet || renderedWatchedMovies; // Filter out already rendered movies based on the tracking set const availableMovies = sortedMovies.filter(movie => movie.Id && !usedTrackingSet.has(movie.Id) ); if (availableMovies.length === 0) return null; // Heavy weighting: 70% chance for first 5, 30% for rest let selectedMovie; if (Math.random() < 0.7 && availableMovies.length >= 5) { // Pick from first 5 const firstFive = availableMovies.slice(0, 5); selectedMovie = firstFive[Math.floor(Math.random() * firstFive.length)]; } else { // Pick from all available selectedMovie = availableMovies[Math.floor(Math.random() * availableMovies.length)]; } // Track rendered movie in the appropriate set if (selectedMovie && selectedMovie.Id) { usedTrackingSet.add(selectedMovie.Id); } return selectedMovie; } /** * Fetches and caches Popular TV Networks (studios) from all TV show libraries * @returns {Promise} - Array of studio objects sorted by ChildCount (descending) */ async function fetchAndCachePopularTVNetworks() { const cache = new window.LocalStorageCache(); const cacheKey = 'popularTVNetworks'; // Check cache first const cached = cache.get(cacheKey); if (cached) { LOG('Using cached Popular TV Networks'); return cached; } try { LOG('Fetching Popular TV Networks...'); const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); // Get root libraries const librariesResponse = await apiClient.getItems(); const libraries = librariesResponse.Items || []; // Filter for TV show libraries const tvLibraries = libraries.filter(lib => lib.CollectionType === 'tvshows'); if (tvLibraries.length === 0) { LOG('No TV show libraries found'); return []; } // Fetch studios from all TV libraries const allStudios = []; const isPlayedFilter = getIsPlayedFilter(popularTVNetworksConfig); const filterParam = isPlayedFilter ? `&Filters=${isPlayedFilter}` : ''; for (const library of tvLibraries) { try { const studiosUrl = `${serverUrl}/Studios?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series&Recursive=true&Fields=DateCreated%2CPrimaryImageAspectRatio&StartIndex=0&ParentId=${library.Id}&userId=${userId}${filterParam}`; const response = await fetch(studiosUrl, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { WARN(`Failed to fetch studios for library ${library.Name}: ${response.status}`); continue; } const data = await response.json(); const studios = data.Items || []; allStudios.push(...studios); } catch (err) { ERR(`Error fetching studios for library ${library.Name}:`, err); } } // Deduplicate studios by Id const uniqueStudios = []; const seenIds = new Set(); for (const studio of allStudios) { if (!seenIds.has(studio.Id)) { seenIds.add(studio.Id); uniqueStudios.push(studio); } } // Sort by ChildCount (descending) uniqueStudios.sort((a, b) => (b.ChildCount || 0) - (a.ChildCount || 0)); // Filter by minimum shows const filteredStudios = uniqueStudios.filter(studio => (studio.ChildCount || 0) >= minimumShowsForNetwork ); LOG(`Fetched ${filteredStudios.length} Popular TV Networks (filtered from ${uniqueStudios.length})`); // Cache the results cache.set(cacheKey, filteredStudios); return filteredStudios; } catch (err) { ERR('Error fetching Popular TV Networks:', err); return []; } } /** * Gets Popular TV Networks from cache or fetches if not cached * @returns {Promise} - Array of studio objects */ async function getPopularTVNetworks() { const cache = new window.LocalStorageCache(); const cacheKey = 'popularTVNetworks'; const cached = cache.get(cacheKey); if (cached && cached.length > 0) { return cached; } return await fetchAndCachePopularTVNetworks(); } /** * Fetches items (Series) by studio ID * @param {string} studioId - Studio ID * @returns {Promise} - Array of series items */ async function fetchItemsByStudio(studioId) { try { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); // Use Items endpoint with StudioIds parameter, sort by random const url = `${serverUrl}/Items?IncludeItemTypes=Series&Recursive=true&SortBy=Random&Fields=PrimaryImageAspectRatio%2CDateCreated%2COverview%2CProductionYear%2CPeople&StudioIds=${studioId}&userId=${userId}&Limit=32`; const response = await fetch(url, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); return data.Items || []; } catch (err) { ERR(`Error fetching items by studio ${studioId}:`, err); return []; } } /** * Fetches items by person ID (for actors, directors, writers) * @param {string} personId - Person ID * @param {string} includeItemTypes - Item types to include (default: 'Movie') * @returns {Promise} - Array of items */ async function fetchItemsByPersonId(personId, includeItemTypes = 'Movie') { try { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); // Use Items endpoint with PersonIds parameter, sort by random, include People field const url = `${serverUrl}/Items?IncludeItemTypes=${includeItemTypes}&PersonIds=${personId}&Recursive=true&SortBy=Random&Fields=PrimaryImageAspectRatio%2CDateCreated%2COverview%2CProductionYear%2CPeople%2CUserData&userId=${userId}&Limit=32`; const response = await fetch(url, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); return data.Items || []; } catch (err) { ERR(`Error fetching items by person ${personId}:`, err); return []; } } /** * Filters items to only include those where the person has the specified type (Actor, Director, Writer) * @param {Array} items - Array of items with People field * @param {string} personId - Person ID to check * @param {string} personType - Type to filter by ('Actor', 'Director', 'Writer') * @returns {Array} - Filtered array of items */ function filterPeopleByType(items, personId, personType) { return items.filter(item => { if (!item.People || !Array.isArray(item.People)) return false; return item.People.some(person => person.Id === personId && person.Type === personType ); }); } /** * Fetches newest movies from Jellyfin API * @returns {Promise} - Array of newest movie items */ async function fetchNewMovies() { // Calculate date range based on configuration const minAgeInDays = newMoviesConfig.minAgeInDays !== null && newMoviesConfig.minAgeInDays !== undefined ? newMoviesConfig.minAgeInDays : 0; const maxAgeInDays = newMoviesConfig.maxAgeInDays !== null && newMoviesConfig.maxAgeInDays !== undefined ? newMoviesConfig.maxAgeInDays : 30; // Default 30 days old max // Calculate actual dates: // MaxPremiereDate (latest allowed date) = Today - minAgeInDays // MinPremiereDate (earliest allowed date) = Today - maxAgeInDays const today = new Date(); const maxDateObj = new Date(today); maxDateObj.setDate(today.getDate() - minAgeInDays); const maxPremiereDate = maxDateObj.toISOString().split('T')[0]; const minDateObj = new Date(today); minDateObj.setDate(today.getDate() - maxAgeInDays); const minPremiereDate = minDateObj.toISOString().split('T')[0]; const options = { IncludeItemTypes: 'Movie', SortBy: 'PremiereDate', SortOrder: newMoviesConfig.sortOrderDirection === 'Ascending' ? 'Ascending' : 'Descending', Limit: 16, Fields: 'PremiereDate', Recursive: true, MinPremiereDate: minPremiereDate, MaxPremiereDate: maxPremiereDate } const isPlayedFilter = getIsPlayedFilter(newMoviesConfig); if (isPlayedFilter) { options.Filters = isPlayedFilter; } // Add UserData to Fields if IsPlayed filter is active or generally useful if (!options.Fields.includes('UserData')) { options.Fields += ',UserData'; } const data = await window.apiHelper.getItems(options, true); return data.Items || []; } /** * Fetches newest episodes from the last 7 days * Deduplicates episodes from the same series that aired on the same date, * keeping only the episode with the lowest index number * @returns {Promise} - Array of deduplicated newest episode items */ async function fetchNewEpisodes() { // Calculate date range based on configuration const minAgeInDays = newEpisodesConfig.minAgeInDays !== null && newEpisodesConfig.minAgeInDays !== undefined ? newEpisodesConfig.minAgeInDays : 0; const maxAgeInDays = newEpisodesConfig.maxAgeInDays !== null && newEpisodesConfig.maxAgeInDays !== undefined ? newEpisodesConfig.maxAgeInDays : 7; // Default 7 days old max const today = new Date(); const maxDateObj = new Date(today); maxDateObj.setDate(today.getDate() - minAgeInDays); const maxPremiereDate = maxDateObj.toISOString().split('T')[0]; const minDateObj = new Date(today); minDateObj.setDate(today.getDate() - maxAgeInDays); const minPremiereDate = minDateObj.toISOString().split('T')[0]; const options = { IncludeItemTypes: 'Episode', LocationTypes: 'FileSystem', Recursive: true, SortBy: 'PremiereDate', SortOrder: newEpisodesConfig.sortOrderDirection === 'Ascending' ? 'Ascending' : 'Descending', MinPremiereDate: minPremiereDate, MaxPremiereDate: maxPremiereDate, Limit: 100, Fields: 'PremiereDate,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds,Path' } const isPlayedFilter = getIsPlayedFilter(newEpisodesConfig); if (isPlayedFilter) { options.Filters = isPlayedFilter; } // Add UserData to Fields if IsPlayed filter is active or generally useful if (!options.Fields.includes('UserData')) { options.Fields += ',UserData'; } const data = await window.apiHelper.getItems(options, true); const episodes = data.Items || []; // Deduplicate episodes from the same series that aired on the same date const deduplicatedEpisodes = deduplicateEpisodesBySeriesAndDate(episodes); LOG(`Fetched ${episodes.length} episodes, deduplicated to ${deduplicatedEpisodes.length} episodes`); return deduplicatedEpisodes; } /** * Deduplicates episodes from the same series that aired on the same date * Keeps only the episode with the lowest index number (first episode of season) * @param {Array} episodes - Array of episode objects * @returns {Array} - Deduplicated array of episodes */ function deduplicateEpisodesBySeriesAndDate(episodes) { if (!episodes || episodes.length === 0) return []; // Group episodes by series name and premiere date const groupedEpisodes = new Map(); episodes.forEach(episode => { const seriesName = episode.SeriesName || 'Unknown Series'; const premiereDate = episode.PremiereDate || ''; const key = `${seriesName}|${premiereDate}`; if (!groupedEpisodes.has(key)) { groupedEpisodes.set(key, []); } groupedEpisodes.get(key).push(episode); }); // For each group, keep only the episode with the lowest index number const deduplicatedEpisodes = []; groupedEpisodes.forEach(episodeGroup => { if (episodeGroup.length === 1) { // Only one episode for this series/date, keep it deduplicatedEpisodes.push(episodeGroup[0]); } else { // Multiple episodes for this series/date, find the one with lowest ParentIndexNumber and IndexNumber const episodeWithLowestIndex = episodeGroup.reduce((lowest, current) => { const currentParentIndex = current.ParentIndexNumber ?? 999999; const lowestParentIndex = lowest.ParentIndexNumber ?? 999999; const currentIndex = current.IndexNumber ?? 999999; const lowestIndex = lowest.IndexNumber ?? 999999; // First compare by ParentIndexNumber (season number) if (currentParentIndex < lowestParentIndex) { return current; } else if (currentParentIndex > lowestParentIndex) { return lowest; } else { // Same season, compare by IndexNumber (episode number) return currentIndex < lowestIndex ? current : lowest; } }); deduplicatedEpisodes.push(episodeWithLowestIndex); LOG(`Deduplicated ${episodeGroup.length} episodes from "${episodeGroup[0].SeriesName}" on ${episodeGroup[0].PremiereDate}, kept S${episodeWithLowestIndex.ParentIndexNumber ?? '?'}E${episodeWithLowestIndex.IndexNumber ?? '?'}`); } }); return deduplicatedEpisodes; } /** * Renders New Movies section * @param {HTMLElement} container - Container to append the section to * @returns {Promise} - Success status */ async function renderNewMoviesSection(container) { try { const movies = await fetchNewMovies(); if (movies.length === 0) { return false; // Auto-hide empty sections } // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping New Movies section"); return false; } // Get config values const itemLimit = newMoviesConfig.itemLimit ?? defaultItemLimit; const cardFormat = newMoviesConfig.cardFormat ?? defaultCardFormat; const order = newMoviesConfig.order ?? 30; const sectionName = newMoviesConfig.name || 'Recently Released Movies'; // Apply limit const limitedMovies = movies.slice(0, itemLimit); // Render the scrollable container const scrollableContainer = window.cardBuilder.renderCards( limitedMovies, sectionName, null, true, cardFormat ); // Add data attributes to track rendered sections scrollableContainer.setAttribute('data-custom-section-id', 'new-movies'); scrollableContainer.setAttribute('data-custom-section-name', sectionName); scrollableContainer.style.order = order; // Append to container container.appendChild(scrollableContainer); return true; } catch (err) { ERR('Error rendering New Movies section:', err); return false; } } /** * Renders New Episodes section * @param {HTMLElement} container - Container to append the section to * @returns {Promise} - Success status */ async function renderNewEpisodesSection(container) { try { const episodes = await fetchNewEpisodes(); if (episodes.length === 0) { return false; // Auto-hide empty sections } // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping New Episodes section"); return false; } // Get config values const itemLimit = newEpisodesConfig.itemLimit ?? defaultItemLimit; const cardFormat = newEpisodesConfig.cardFormat ?? defaultCardFormat; const order = newEpisodesConfig.order ?? 31; const sectionName = newEpisodesConfig.name || 'Recently Aired Episodes'; // Apply limit const limitedEpisodes = episodes.slice(0, itemLimit); const viewMoreUrl = `#/tv.html?collectionType=tvshows&tab=2`; // Render the scrollable container const scrollableContainer = window.cardBuilder.renderCards( limitedEpisodes, sectionName, viewMoreUrl, true, cardFormat ); // Add data attributes to track rendered sections scrollableContainer.setAttribute('data-custom-section-id', 'new-episodes'); scrollableContainer.setAttribute('data-custom-section-name', sectionName); scrollableContainer.style.order = order; // Append to container container.appendChild(scrollableContainer); return true; } catch (err) { ERR('Error rendering New Episodes section:', err); return false; } } /** * Renders watchlist section * @param {HTMLElement} container - Container to append the section to * @returns {boolean} - Success status */ async function renderWatchlistSection(container) { try { // Check if section is already on the page const sectionContainer = container.querySelector('[data-custom-section-id="watchlist"]'); if (sectionContainer) { LOG('Watchlist section already on the page, skipping...'); return false; } const watchlistItems = await getWatchlistData(); if (watchlistItems.length === 0) { return false; // Auto-hide empty sections } // Get config values const itemLimit = watchlistConfig.itemLimit ?? defaultItemLimit; const sortOrder = watchlistConfig.sortOrder ?? defaultSortOrder; const sortOrderDirection = watchlistConfig.sortOrderDirection ?? 'Descending'; const cardFormat = watchlistConfig.cardFormat ?? defaultCardFormat; const order = watchlistConfig.order ?? 60; const sectionName = watchlistConfig.name || 'Watchlist'; // Apply sorting and limit let sortedItems = watchlistItems; if (sortOrder === 'Random') { sortedItems = [...watchlistItems].sort(() => Math.random() - 0.5); } else { // Use cardBuilder sort helper if available if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { sortedItems = window.cardBuilder.sortItems(watchlistItems, sortOrder, sortOrderDirection); } } const limitedItems = sortedItems.slice(0, itemLimit); if (limitedItems.length === 0) { return false; // Auto-hide empty sections } // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping watchlist section"); return false; } // Render the scrollable container with function-based navigation const scrollableContainer = window.cardBuilder.renderCards( limitedItems, sectionName, await getWatchlistUrl(), true, cardFormat, sortOrder, sortOrderDirection ); // Add data attribute to track rendered sections scrollableContainer.setAttribute('data-custom-section-id', 'watchlist'); scrollableContainer.setAttribute('data-custom-section-name', sectionName); scrollableContainer.style.order = order; // Append to container container.appendChild(scrollableContainer); return true; } catch (err) { ERR('Error rendering watchlist section:', err); return false; } } /** * Formats air date for display * @param {string} premiereDate - ISO date string * @returns {string} - Formatted date string (MM/DD/YYYY) */ function formatAirDate(premiereDate) { if (!premiereDate) return ''; try { const date = new Date(premiereDate); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const year = date.getFullYear(); return `${month}/${day}/${year}`; } catch (e) { return ''; } } /** * Renders Upcoming section (upcoming TV episodes) * @param {HTMLElement} container - Container to append the section to * @returns {Promise} - Success status */ async function renderUpcomingSection(container) { try { if (!enableUpcoming) { return false; } // Check if section is already on the page const sectionContainer = container.querySelector('[data-custom-section-id="upcoming"]'); if (sectionContainer) { LOG('Upcoming section already on the page, skipping...'); return false; } const userId = ApiClient.getCurrentUserId(); const serverAddress = ApiClient.serverAddress(); const itemLimit = upcomingConfig.itemLimit || 48; const cardFormat = upcomingConfig.cardFormat || 'Backdrop'; const order = upcomingConfig.order || 20; const sectionName = upcomingConfig.name || 'Upcoming'; // Get the parent id from the root libraryies with CollectionType: "tvshows" const libraries = await ApiClient.getItems(); const parentIds = libraries.Items.filter(lib => lib.CollectionType === 'tvshows')?.map(lib => lib.Id); let url = `${serverAddress}/Shows/Upcoming?Limit=${itemLimit}&Fields=AirTime,SeriesName,ParentIndexNumber,IndexNumber&UserId=${userId}&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Banner,Thumb&EnableTotalRecordCount=false&ParentIds=${parentIds.join(',')}`; const isPlayedFilter = getIsPlayedFilter(upcomingConfig); if (isPlayedFilter) { url += `&Filters=${isPlayedFilter}`; } const data = await window.apiHelper.getData(url, true); let episodes = data.Items || []; // Filter out episodes that have already aired (before today) const today = new Date(); today.setHours(0, 0, 0, 0); // Set to start of today episodes = episodes.filter(ep => { if (!ep.PremiereDate) return false; const airDate = new Date(ep.PremiereDate); airDate.setHours(0, 0, 0, 0); // Set to start of air date return airDate >= today; // Only include today and future dates }); // Deduplicate episodes from the same series that air on the same date // Keeps only the episode with the lowest ParentIndexNumber and IndexNumber episodes = deduplicateEpisodesBySeriesAndDate(episodes); if (episodes.length === 0) { return false; // Auto-hide empty sections after deduplication } // Format air dates for display episodes.forEach(ep => { ep._displayAirDate = formatAirDate(ep.PremiereDate); }); // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping Upcoming section"); return false; } const viewMoreUrl = `#/tv.html?collectionType=tvshows&tab=2`; const itemsContainer = window.cardBuilder.renderCards(episodes, sectionName, viewMoreUrl, true, cardFormat); itemsContainer.setAttribute('data-custom-section-id', 'upcoming'); itemsContainer.setAttribute('data-custom-section-name', sectionName); itemsContainer.style.order = order; container.appendChild(itemsContainer); return true; } catch (err) { ERR('Error rendering Upcoming section:', err); return false; } } /** * Caches IMDb IDs in localStorage * @param {Array} imdbIds - Array of IMDb IDs */ function cacheImdbIds(imdbIds) { try { localStorage.setItem('kefinTweaks_imdbIds', JSON.stringify({ ids: imdbIds, timestamp: Date.now() })); } catch (err) { WARN('Failed to cache IMDb IDs:', err); } } /** * Gets cached IMDb IDs from localStorage * @returns {Object|null} - Cached data with ids and timestamp */ function getCachedImdbIds() { try { const cached = localStorage.getItem('kefinTweaks_imdbIds'); return cached ? JSON.parse(cached) : null; } catch (err) { WARN('Failed to get cached IMDb IDs:', err); return null; } } /** * Checks if cache is expired (older than 24 hours) * @param {number} timestamp - Cache timestamp * @param {number} maxAge - Max age in milliseconds (default: 24 hours) * @returns {boolean} - True if expired */ function isCacheExpired(timestamp, maxAge = 24 * 60 * 60 * 1000) { return !timestamp || (Date.now() - timestamp) > maxAge; } /** * Renders IMDb Top 250 section * @param {HTMLElement} container - Container to append the section to * @returns {Promise} - Success status */ async function renderImdbTop250Section(container) { try { if (!enableImdbTop250) { return false; } // Check if section is already on the page const sectionContainer = container.querySelector('[data-custom-section-id="imdb-top250"]'); if (sectionContainer) { LOG('IMDb Top 250 section already on the page, skipping...'); return false; } const itemLimit = imdbTop250Config.itemLimit ?? defaultItemLimit; const sortOrder = imdbTop250Config.sortOrder ?? 'Random'; const sortOrderDirection = imdbTop250Config.sortOrderDirection ?? 'Ascending'; const cardFormat = imdbTop250Config.cardFormat ?? defaultCardFormat; const order = imdbTop250Config.order ?? 21; const sectionName = imdbTop250Config.name || 'IMDb Top 250'; // Get cached IMDb IDs (refresh daily) let cachedData = getCachedImdbIds(); let imdbIds = null; if (!cachedData || isCacheExpired(cachedData.timestamp, 24 * 60 * 60 * 1000)) { // Fetch Top 250 list const top250Response = await fetch('https://raw.githubusercontent.com/theapache64/top250/master/top250_min.json'); if (!top250Response.ok) { throw new Error(`Failed to fetch IMDb Top 250: ${top250Response.statusText}`); } const top250 = await top250Response.json(); // Extract IMDb IDs imdbIds = top250.map(entry => { const match = entry.imdb_url?.match(/\/title\/(tt\d+)\//); return match ? match[1] : null; }).filter(Boolean); // Cache IDs cacheImdbIds(imdbIds); } else { imdbIds = cachedData.ids; } if (!imdbIds || imdbIds.length === 0) { WARN('No IMDb IDs available'); return false; } // Fetch all movies and match by IMDb ID const userId = ApiClient.getCurrentUserId(); const options = { IncludeItemTypes: 'Movie', Recursive: true, Fields: 'ProviderIds' }; const isPlayedFilter = getIsPlayedFilter(imdbTop250Config); if (isPlayedFilter) { options.Filters = isPlayedFilter; } const allMoviesResponse = await ApiClient.getItems(userId, options); const allMovies = allMoviesResponse.Items || []; const matchedMovies = []; for (const imdbId of imdbIds) { const movie = allMovies.find(m => m.ProviderIds?.Imdb === imdbId); if (movie) { matchedMovies.push(movie); } } if (matchedMovies.length === 0) { return false; // Auto-hide empty sections } // Apply sort order (Random only, no rank data available) let sortedMovies = matchedMovies; if (sortOrder === 'Random') { sortedMovies = [...matchedMovies].sort(() => Math.random() - 0.5); } // Apply limit const limited = sortedMovies.slice(0, itemLimit); if (limited.length === 0) { return false; // Auto-hide empty sections } // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping IMDb Top 250 section"); return false; } const scrollableContainer = window.cardBuilder.renderCards( limited, sectionName, null, true, cardFormat, sortOrder, sortOrderDirection ); scrollableContainer.setAttribute('data-custom-section-id', 'imdb-top250'); scrollableContainer.setAttribute('data-custom-section-name', sectionName); scrollableContainer.style.order = order; container.appendChild(scrollableContainer); return true; } catch (err) { ERR('Error rendering IMDb Top 250 section:', err); return false; } } /** * Renders Watch Again section (random watched movies) * @param {HTMLElement} container - Container to append the section to * @returns {Promise} - Success status */ async function renderWatchAgainSection(container) { try { if (!enableWatchAgain) { return false; } // Check if section is already on the page const sectionContainer = container.querySelector('[data-custom-section-id="watch-again"]'); if (sectionContainer) { LOG('Watch Again section already on the page, skipping...'); return false; } const userId = ApiClient.getCurrentUserId(); const itemLimit = watchAgainConfig.itemLimit ?? defaultItemLimit; const sortOrder = watchAgainConfig.sortOrder ?? 'Random'; const sortOrderDirection = watchAgainConfig.sortOrderDirection ?? 'Ascending'; const cardFormat = watchAgainConfig.cardFormat ?? defaultCardFormat; const order = watchAgainConfig.order ?? 62; const sectionName = watchAgainConfig.name || 'Watch Again'; // Fetch watched movies const watchedMoviesResponse = await ApiClient.getItems(userId, { IncludeItemTypes: 'Movie', Filters: 'IsPlayed', SortBy: 'Random', Recursive: true, Limit: itemLimit }); const watchedMovies = watchedMoviesResponse.Items || []; if (watchedMovies.length === 0) { return false; // Auto-hide empty sections } // Apply sort order let sortedMovies = watchedMovies; if (sortOrder !== 'Random') { // Use cardBuilder sort if available if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { sortedMovies = window.cardBuilder.sortItems(watchedMovies, sortOrder, sortOrderDirection); } } // Apply limit const limited = sortedMovies.slice(0, itemLimit); if (limited.length === 0) { return false; // Auto-hide empty sections } // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping Watch Again section"); return false; } const scrollableContainer = window.cardBuilder.renderCards( limited, sectionName, null, true, cardFormat, sortOrder, sortOrderDirection ); scrollableContainer.setAttribute('data-custom-section-id', 'watch-again'); scrollableContainer.setAttribute('data-custom-section-name', sectionName); scrollableContainer.style.order = order; container.appendChild(scrollableContainer); return true; } catch (err) { ERR('Error rendering Watch Again section:', err); return false; } } /** * Renders Popular TV Networks section * @param {HTMLElement} container - Container to append the section to * @returns {Promise} - Success status */ async function renderPopularTVNetworksSection(container) { try { // Check if section is already on the page const sectionContainer = container.querySelector('[data-custom-section-id="popular-tv-networks"]'); if (sectionContainer) { LOG('Popular TV Networks section already on the page, skipping...'); return false; } let networks = await getPopularTVNetworks(); if (networks.length === 0) { return false; // Auto-hide empty sections } // Remove any networks without Thumb images networks = networks.filter(network => network.ImageTags?.Thumb); // Get config values const itemLimit = popularTVNetworksConfig.itemLimit ?? defaultItemLimit; const sortOrder = popularTVNetworksConfig.sortOrder ?? defaultSortOrder; const sortOrderDirection = popularTVNetworksConfig.sortOrderDirection ?? 'Ascending'; const cardFormat = popularTVNetworksConfig.cardFormat ?? defaultCardFormat; const order = popularTVNetworksConfig.order ?? 61; const sectionName = popularTVNetworksConfig.name || 'Popular TV Networks'; // Apply sort order let sortedNetworks = networks; if (sortOrder === 'Random') { sortedNetworks = [...networks].sort(() => Math.random() - 0.5); } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { sortedNetworks = window.cardBuilder.sortItems(networks, sortOrder, sortOrderDirection); } // Apply limit const limitedNetworks = sortedNetworks.slice(0, itemLimit); if (limitedNetworks.length === 0) { return false; // Auto-hide empty sections } // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping Popular TV Networks section"); return false; } // Render the scrollable container with the network/studio items const scrollableContainer = window.cardBuilder.renderCards( limitedNetworks, sectionName, null, true, cardFormat, sortOrder, sortOrderDirection ); // Add data attribute to track rendered sections scrollableContainer.setAttribute('data-custom-section-id', 'popular-tv-networks'); scrollableContainer.setAttribute('data-custom-section-name', sectionName); scrollableContainer.style.order = order; // Append to container container.appendChild(scrollableContainer); return true; } catch (err) { ERR('Error rendering Popular TV Networks section:', err); return false; } } /** * Fetches full item objects from API using item IDs * @param {Array} itemIds - Array of item IDs to fetch * @returns {Promise} Array of full item objects */ async function fetchItemsByIds(itemIds) { try { if (!itemIds || itemIds.length === 0) return []; const userId = ApiClient.getCurrentUserId(); const serverId = ApiClient.serverId(); // Use the Items endpoint to fetch multiple items by IDs const idsParam = itemIds.join(','); const url = `${ApiClient.serverAddress()}/Items?Ids=${idsParam}&UserId=${userId}&EnableTotalRecordCount=false&EnableImageTypes=Primary,Backdrop,Thumb,Logo`; const response = await ApiClient.fetch({ url, method: 'GET' }); const data = await response.json(); return data.Items || []; } catch (err) { ERR('Error fetching items by IDs:', err); return []; } } /** * Generates a single group of discovery sections (6 sections total) * @returns {Promise} Array of section data objects */ async function generateDiscoveryGroup() { try { LOG('Generating single discovery group...'); const spotlightChance = discoveryConfig.spotlightDiscoveryChance ?? 0.5; const renderSpotlightAboveMatching = discoveryConfig.renderSpotlightAboveMatching === true; let renderGenresSpotlight = false; let renderNetworksSpotlight = false; let renderCollectionsSpotlight = false; // Pre-selected data for forced matching let forcedGenre = null; let forcedNetwork = null; if (Math.random() < spotlightChance) { // Randomly pick one of the spotlight sections to render const spotlightSections = ['spotlight-genre', 'spotlight-network', 'spotlight-collection']; const selectedSpotlightSection = spotlightSections[Math.floor(Math.random() * spotlightSections.length)]; renderGenresSpotlight = selectedSpotlightSection === 'spotlight-genre'; renderNetworksSpotlight = selectedSpotlightSection === 'spotlight-network'; renderCollectionsSpotlight = selectedSpotlightSection === 'spotlight-collection'; // If forcing matching, pre-select content now if (renderSpotlightAboveMatching) { if (renderGenresSpotlight) { try { forcedGenre = await getRandomGenre(); if (!forcedGenre) renderGenresSpotlight = false; } catch (e) { ERR('Error pre-selecting genre for forced matching:', e); renderGenresSpotlight = false; } } else if (renderNetworksSpotlight) { try { const networks = await getPopularTVNetworks(); if (networks && networks.length > 0) { // Find one that hasn't been used in either context const availableNetworks = networks.filter(network => network.Id && !renderedStudios.has(network.Id) && !renderedNetworks.has(network.Id) ); if (availableNetworks.length > 0) { forcedNetwork = availableNetworks[Math.floor(Math.random() * availableNetworks.length)]; // Reserve it immediately renderedStudios.add(forcedNetwork.Id); renderedNetworks.add(forcedNetwork.Id); } else { renderNetworksSpotlight = false; } } else { renderNetworksSpotlight = false; } } catch (e) { ERR('Error pre-selecting network for forced matching:', e); renderNetworksSpotlight = false; } } } } // Run all async operations in parallel for maximum speed const preloadPromises = [ // 1. [Genre] Movies (async () => { const sectionConfig = getDiscoverySectionSettings('genreMovies'); if (!sectionConfig || !sectionConfig.enabled) return null; let randomGenre = null; if (forcedGenre) { randomGenre = forcedGenre; } else { randomGenre = await getRandomGenre(); } if (randomGenre) { const genreData = await preloadGenreSection( randomGenre, sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit) ); if (genreData) { return { type: 'genre', sectionKey: 'genreMovies', config: sectionConfig, data: randomGenre, items: genreData.items, viewMoreUrl: genreData.viewMoreUrl }; } } return null; })(), // 2. Directed by [Director] (use cached items directly) (async () => { const sectionConfig = getDiscoverySectionSettings('directedByTopDirector'); if (!sectionConfig || !sectionConfig.enabled) return null; if (moviesTopPeople && moviesTopPeople.directors.length > 0) { const randomDirector = getRandomItem(moviesTopPeople.directors, renderedDirectors); if (randomDirector && randomDirector.items && randomDirector.items.length > 0) { const limit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); const shuffledItems = shuffleArray(randomDirector.items).slice(0, limit); if (shuffledItems && shuffledItems.length > 0) { return { type: 'director', sectionKey: 'directedByTopDirector', config: sectionConfig, data: randomDirector, items: shuffledItems, viewMoreUrl: `#/details?id=${randomDirector.id}&serverId=${ApiClient.serverId()}` }; } } } return null; })(), // 3. Written by [Writer] (use cached items directly) (async () => { const sectionConfig = getDiscoverySectionSettings('writtenByTopWriter'); if (!sectionConfig || !sectionConfig.enabled) return null; if (moviesTopPeople && moviesTopPeople.writers.length > 0) { const randomWriter = getRandomItem(moviesTopPeople.writers, renderedWriters); if (randomWriter && randomWriter.items && randomWriter.items.length > 0) { const limit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); const shuffledItems = shuffleArray(randomWriter.items).slice(0, limit); if (shuffledItems && shuffledItems.length > 0) { return { type: 'writer', sectionKey: 'writtenByTopWriter', config: sectionConfig, data: randomWriter, items: shuffledItems, viewMoreUrl: `#/details?id=${randomWriter.id}&serverId=${ApiClient.serverId()}` }; } } } return null; })(), // 4. Starring [Actor] (use cached items directly) (async () => { const sectionConfig = getDiscoverySectionSettings('starringTopActor'); if (!sectionConfig || !sectionConfig.enabled) return null; if (moviesTopPeople && moviesTopPeople.actors.length > 0) { const randomActor = getRandomItem(moviesTopPeople.actors, renderedActors); if (randomActor && randomActor.items && randomActor.items.length > 0) { const limit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); const shuffledItems = shuffleArray(randomActor.items).slice(0, limit); if (shuffledItems && shuffledItems.length > 0) { return { type: 'actor', sectionKey: 'starringTopActor', config: sectionConfig, data: randomActor, items: shuffledItems, viewMoreUrl: `#/details?id=${randomActor.id}&serverId=${ApiClient.serverId()}` }; } } } return null; })(), // 5. Because you watched [Movie] (async () => { const sectionConfig = getDiscoverySectionSettings('becauseYouWatched'); if (!sectionConfig || !sectionConfig.enabled) return null; const randomWatchedMovie = getRandomWatchedMovie(); if (randomWatchedMovie) { const watchedData = await preloadBecauseYouWatchedSection( randomWatchedMovie, sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit) ); if (watchedData) { return { type: 'watched', sectionKey: 'becauseYouWatched', config: sectionConfig, data: randomWatchedMovie, items: watchedData.items }; } } return null; })(), // 6. Because you liked [Movie] (async () => { const sectionConfig = getDiscoverySectionSettings('becauseYouLiked'); if (!sectionConfig || !sectionConfig.enabled) return null; const randomLikedMovie = await getRandomFavoriteMovie(); if (randomLikedMovie) { const likedData = await preloadBecauseYouLikedSection( randomLikedMovie, sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit) ); if (likedData) { return { type: 'liked', sectionKey: 'becauseYouLiked', config: sectionConfig, data: randomLikedMovie, items: likedData.items }; } } return null; })(), // 7. Shows from [Studio Name] (async () => { const sectionConfig = getDiscoverySectionSettings('studioShows'); if (!sectionConfig || !sectionConfig.enabled) return null; let randomNetwork = null; if (forcedNetwork) { randomNetwork = forcedNetwork; } else { const networks = await getPopularTVNetworks(); if (networks && networks.length > 0) { // Filter out already rendered studios const availableNetworks = networks.filter(network => network.Id && !renderedStudios.has(network.Id) ); if (availableNetworks.length > 0) { randomNetwork = availableNetworks[Math.floor(Math.random() * availableNetworks.length)]; renderedStudios.add(randomNetwork.Id); } } } if (randomNetwork) { const items = await fetchItemsByStudio(randomNetwork.Id); const viewMoreUrl = `#/list.html?studioId=${randomNetwork.Id}&serverId=${ApiClient.serverId()}`; if (items && items.length > 0) { const orderedItems = applyDiscoverySectionOrdering(items, sectionConfig); if (orderedItems.length === 0) { return null; } return { type: 'studio', sectionKey: 'studioShows', config: sectionConfig, data: randomNetwork, items: orderedItems, viewMoreUrl: viewMoreUrl }; } } return null; })(), // 8. Because you recently watched [Movie Name] (async () => { const sectionConfig = getDiscoverySectionSettings('becauseYouRecentlyWatched'); if (!sectionConfig || !sectionConfig.enabled) return null; const randomRecentMovie = getRandomRecentlyWatchedMovie(); if (randomRecentMovie) { const watchedData = await preloadBecauseYouWatchedSection( randomRecentMovie, sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit) ); if (watchedData) { return { type: 'watched-recent', sectionKey: 'becauseYouRecentlyWatched', config: sectionConfig, data: randomRecentMovie, items: watchedData.items }; } } return null; })(), // 9. Starring [Actor Name] since you recently watched [Movie Name] (async () => { const sectionConfig = getDiscoverySectionSettings('starringActorRecentlyWatched'); if (!sectionConfig || !sectionConfig.enabled) return null; const recentMovie = getRandomRecentlyWatchedMovie(renderedStarringWatchedMovies); if (!recentMovie) return null; // Fetch the movie with People field included try { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`; const movieResponse = await fetch(movieUrl, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!movieResponse.ok) return null; const movieData = await movieResponse.json(); if (!movieData.People || !Array.isArray(movieData.People)) return null; // Get first 3 actors const actors = movieData.People.filter(p => p.Type === 'Actor').slice(0, 3); if (actors.length === 0) return null; // Pick random actor const selectedActor = actors[Math.floor(Math.random() * actors.length)]; // Fetch items for this actor const items = await fetchItemsByPersonId(selectedActor.Id, 'Movie'); // Remove the source movie and filter for items where person is Actor const filteredItems = filterPeopleByType(items, selectedActor.Id, 'Actor') .filter(item => item.Id !== recentMovie.Id); if (filteredItems.length > 0) { const orderedItems = applyDiscoverySectionOrdering(filteredItems, sectionConfig); if (orderedItems.length === 0) return null; return { type: 'actor-recent', sectionKey: 'starringActorRecentlyWatched', config: sectionConfig, data: { person: selectedActor, movie: recentMovie }, items: orderedItems, viewMoreUrl: `#/details?id=${selectedActor.Id}&serverId=${ApiClient.serverId()}` }; } } catch (err) { ERR('Error generating actor-recent section:', err); } return null; })(), // 10. Directed by [Director Name] since you recently watched [Movie Name] (async () => { const sectionConfig = getDiscoverySectionSettings('directedByDirectorRecentlyWatched'); if (!sectionConfig || !sectionConfig.enabled) return null; const recentMovie = getRandomRecentlyWatchedMovie(renderedDirectedWatchedMovies); if (!recentMovie) return null; // Fetch the movie with People field included try { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`; const movieResponse = await fetch(movieUrl, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!movieResponse.ok) return null; const movieData = await movieResponse.json(); if (!movieData.People || !Array.isArray(movieData.People)) return null; // Get directors const directors = movieData.People.filter(p => p.Type === 'Director'); if (directors.length === 0) return null; // Pick random director const selectedDirector = directors[Math.floor(Math.random() * directors.length)]; // Check if already rendered (use same set as regular directors) if (renderedDirectors.has(selectedDirector.Name)) return null; // Fetch items for this director const items = await fetchItemsByPersonId(selectedDirector.Id, 'Movie'); // Remove the source movie and filter for items where person is Director const filteredItems = filterPeopleByType(items, selectedDirector.Id, 'Director') .filter(item => item.Id !== recentMovie.Id); if (filteredItems.length > 0) { renderedDirectors.add(selectedDirector.Name); const orderedItems = applyDiscoverySectionOrdering(filteredItems, sectionConfig); if (orderedItems.length === 0) return null; return { type: 'director-recent', sectionKey: 'directedByDirectorRecentlyWatched', config: sectionConfig, data: { person: selectedDirector, movie: recentMovie }, items: orderedItems, viewMoreUrl: `#/details?id=${selectedDirector.Id}&serverId=${ApiClient.serverId()}` }; } } catch (err) { ERR('Error generating director-recent section:', err); } return null; })(), // 11. Written by [Writer Name] since you recently watched [Movie Name] (async () => { const sectionConfig = getDiscoverySectionSettings('writtenByWriterRecentlyWatched'); if (!sectionConfig || !sectionConfig.enabled) return null; const recentMovie = getRandomRecentlyWatchedMovie(renderedWrittenWatchedMovies); if (!recentMovie) return null; // Fetch the movie with People field included try { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`; const movieResponse = await fetch(movieUrl, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!movieResponse.ok) return null; const movieData = await movieResponse.json(); if (!movieData.People || !Array.isArray(movieData.People)) return null; // Get writers const writers = movieData.People.filter(p => p.Type === 'Writer'); if (writers.length === 0) return null; // Pick random writer const selectedWriter = writers[Math.floor(Math.random() * writers.length)]; // Check if already rendered (use same set as regular writers) if (renderedWriters.has(selectedWriter.Name)) return null; // Fetch items for this writer const items = await fetchItemsByPersonId(selectedWriter.Id, 'Movie'); // Remove the source movie and filter for items where person is Writer const filteredItems = filterPeopleByType(items, selectedWriter.Id, 'Writer') .filter(item => item.Id !== recentMovie.Id); if (filteredItems.length > 0) { renderedWriters.add(selectedWriter.Name); const orderedItems = applyDiscoverySectionOrdering(filteredItems, sectionConfig); if (orderedItems.length === 0) return null; return { type: 'writer-recent', sectionKey: 'writtenByWriterRecentlyWatched', config: sectionConfig, data: { person: selectedWriter, movie: recentMovie }, items: orderedItems, viewMoreUrl: `#/details?id=${selectedWriter.Id}&serverId=${ApiClient.serverId()}` }; } } catch (err) { ERR('Error generating writer-recent section:', err); } return null; })(), // 12. Spotlight: Top Rated from [Genre] (async () => { if (!renderGenresSpotlight) return null; const sectionConfig = getDiscoverySectionSettings('spotlightGenre'); if (!sectionConfig || !sectionConfig.enabled) return null; try { let selectedGenre = null; if (forcedGenre) { selectedGenre = forcedGenre; // Ensure it's marked as rendered in spotlight tracking (if not already) renderedGenres.add(selectedGenre.Id); } else { // Get qualifying genres (cached) const allQualifyingGenres = await getQualifyingGenres(); if (allQualifyingGenres.length === 0) return null; // Filter out already rendered genres const availableGenres = allQualifyingGenres.filter(genre => !renderedGenres.has(genre.Id) ); if (availableGenres.length === 0) return null; // Pick random genre selectedGenre = availableGenres[Math.floor(Math.random() * availableGenres.length)]; // Track rendered genre renderedGenres.add(selectedGenre.Id); } const itemLimit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); const moviesResponse = await ApiClient.getItems(ApiClient.getCurrentUserId(), { Genres: selectedGenre.Name, IncludeItemTypes: 'Movie', Recursive: true, SortBy: 'CommunityRating', SortOrder: 'Descending', Limit: itemLimit, Fields: 'BackdropImageTags,ImageTags,People,Genres,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount' }); const movies = moviesResponse.Items || []; if (movies.length === 0) return null; return { type: 'spotlight-genre', sectionKey: 'spotlightGenre', config: sectionConfig, data: selectedGenre, items: movies, viewMoreUrl: `${ApiClient.serverAddress()}/web/#/list.html?genreId=${selectedGenre.Id}&serverId=${ApiClient.serverId()}` }; } catch (err) { ERR('Error generating spotlight genre section:', err); return null; } })(), // 13. Spotlight: Top Rated from [TV Network] (async () => { if (!renderNetworksSpotlight) return null; const sectionConfig = getDiscoverySectionSettings('spotlightNetwork'); if (!sectionConfig || !sectionConfig.enabled) return null; try { let selectedNetwork = null; if (forcedNetwork) { selectedNetwork = forcedNetwork; // Ensure it's marked as rendered in spotlight tracking renderedNetworks.add(selectedNetwork.Id); } else { // Get popular TV networks const networks = await getPopularTVNetworks(); if (networks.length === 0) return null; // Filter out already rendered networks const availableNetworks = networks.filter(network => network.Id && !renderedNetworks.has(network.Id) ); if (availableNetworks.length === 0) return null; // Pick random network selectedNetwork = availableNetworks[Math.floor(Math.random() * availableNetworks.length)]; // Track rendered network renderedNetworks.add(selectedNetwork.Id); } const itemLimit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); const showsResponse = await ApiClient.getItems(ApiClient.getCurrentUserId(), { StudioIds: selectedNetwork.Id, IncludeItemTypes: 'Series', Recursive: true, SortBy: 'CommunityRating', SortOrder: 'Descending', Limit: itemLimit, Fields: 'BackdropImageTags,ImageTags,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount' }); const shows = showsResponse.Items || []; if (shows.length === 0) return null; return { type: 'spotlight-network', sectionKey: 'spotlightNetwork', config: sectionConfig, data: selectedNetwork, items: shows, viewMoreUrl: `${ApiClient.serverAddress()}/web/#/list.html?studioId=${selectedNetwork.Id}&serverId=${ApiClient.serverId()}` }; } catch (err) { ERR('Error generating spotlight network section:', err); return null; } })(), // 14. Collection section (async () => { if (!renderCollectionsSpotlight) return null; const sectionConfig = getDiscoverySectionSettings('collections'); if (!sectionConfig || !sectionConfig.enabled) return null; try { const minItems = sectionConfig.minimumItems ?? 10; const userId = ApiClient.getCurrentUserId(); const itemLimit = sectionConfig.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); const sortOrder = sectionConfig.sortOrder ?? discoveryConfig.defaultSortOrder ?? defaultSortOrder; const sortOrderDirection = sectionConfig.sortOrderDirection ?? 'Ascending'; // Fetch all collections (BoxSet items) with total record count const collectionsData = await getCollectionsData(); if (!collectionsData) return null; // Filter by minimum item count using TotalRecordCount and exclude already rendered const qualifyingCollections = collectionsData.Items.filter(collection => { // Skip if already rendered if (renderedCollections.has(collection.Id)) return false; // Check if collection has enough items using TotalRecordCount // TotalRecordCount is available when EnableTotalRecordCount=true const itemCount = collection.TotalRecordCount ?? collection.RecursiveChildCount ?? collection.ChildCount ?? 0; return itemCount >= minItems; }); if (qualifyingCollections.length === 0) return null; // Randomly select one collection const selectedCollection = qualifyingCollections[Math.floor(Math.random() * qualifyingCollections.length)]; // Now fetch the items for the selected collection const itemsData = await fetchCollectionData(selectedCollection.Id); const items = itemsData.Items || []; if (items.length === 0) return null; // Apply sort order let sortedItems = items; if (sortOrder === 'Random') { sortedItems = [...items].sort(() => Math.random() - 0.5); } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { sortedItems = window.cardBuilder.sortItems(items, sortOrder, sortOrderDirection); } // Apply limit const limited = sortedItems.slice(0, itemLimit); if (limited.length === 0) return null; // Track rendered collection renderedCollections.add(selectedCollection.Id); return { type: 'collection', sectionKey: 'collections', config: sectionConfig, data: selectedCollection, items: limited, viewMoreUrl: `/web/#/collection.html?id=${selectedCollection.Id}`, sortOrder: sortOrder, sortOrderDirection: sortOrderDirection, cardFormat: sectionConfig.cardFormat ?? discoveryConfig.defaultCardFormat ?? defaultCardFormat }; } catch (err) { ERR('Error generating collection section:', err); return null; } })(), // 15. Custom discovery section (async () => { const eligibleCustomSections = (customHomeSections || []).filter(section => section.enabled !== false && section.discoveryEnabled === true); if (eligibleCustomSections.length === 0) return null; let availableSections = eligibleCustomSections.filter(section => section.id && !renderedCustomDiscoverySections.has(section.id)); if (availableSections.length === 0) { LOG('No eligible custom sections for discovery group remaining'); return null; } const selectedSection = availableSections[Math.floor(Math.random() * availableSections.length)]; if (!selectedSection) return null; if (selectedSection.id) { renderedCustomDiscoverySections.add(selectedSection.id); } return { type: 'custom-discovery', data: { ...selectedSection } }; })() ]; // Wait for all parallel operations to complete const preloadResults = await Promise.all(preloadPromises); // Filter out null results and collect valid sections let sections = preloadResults.filter(section => section !== null); if (discoveryConfig.randomizeOrder) { sections = shuffleArray(sections); } else { sections.sort((a, b) => { // Use explicit order on object if available (set by our matching logic), fallback to config order const orderA = typeof a.order === 'number' ? a.order : (typeof a.config?.order === 'number' ? a.config.order : DISCOVERY_ORDER); const orderB = typeof b.order === 'number' ? b.order : (typeof b.config?.order === 'number' ? b.config.order : DISCOVERY_ORDER); return orderA - orderB; }); } // Logic to position forced spotlight sections directly above their content // We run this AFTER randomization to force adjacency if (renderSpotlightAboveMatching) { // Adjust orders to ensure spotlight is right above content if (forcedGenre) { const genreSectionIndex = sections.findIndex(s => s.type === 'genre' && s.data.Id === forcedGenre.Id); const genreSpotlightIndex = sections.findIndex(s => s.type === 'spotlight-genre' && s.data.Id === forcedGenre.Id); if (genreSectionIndex !== -1 && genreSpotlightIndex !== -1) { // Both exist in the array. // Remove spotlight from its current position const [spotlight] = sections.splice(genreSpotlightIndex, 1); // Find the new index of the content section (it might have shifted) const newGenreSectionIndex = sections.findIndex(s => s.type === 'genre' && s.data.Id === forcedGenre.Id); // Insert spotlight immediately before the content section sections.splice(newGenreSectionIndex, 0, spotlight); } } if (forcedNetwork) { const studioSectionIndex = sections.findIndex(s => s.type === 'studio' && s.data.Id === forcedNetwork.Id); const networkSpotlightIndex = sections.findIndex(s => s.type === 'spotlight-network' && s.data.Id === forcedNetwork.Id); if (studioSectionIndex !== -1 && networkSpotlightIndex !== -1) { // Both exist. // Remove spotlight const [spotlight] = sections.splice(networkSpotlightIndex, 1); // Find new content index const newStudioSectionIndex = sections.findIndex(s => s.type === 'studio' && s.data.Id === forcedNetwork.Id); // Insert spotlight immediately before sections.splice(newStudioSectionIndex, 0, spotlight); } } } LOG(`Generated discovery group with ${sections.length} sections`); return sections; } catch (err) { ERR('Error generating discovery group:', err); return []; } } /** * Ensures the discovery buffer has 2 groups of discovery items * Simple approach: always maintain 2 groups in buffer */ async function ensureDiscoveryBuffer() { const targetBufferSize = 2; if (discoveryBuffer.length >= targetBufferSize) { LOG(`Discovery buffer already has ${discoveryBuffer.length} groups (target: ${targetBufferSize})`); // Manage UI state based on buffer content manageDiscoveryUIState().catch(err => ERR('Error managing UI state:', err)); return; } // Don't block recursive calls - only block if actively generating a group if (isLoadingDiscoveryGroup) { LOG('Discovery group already loading, waiting...'); return; } isLoadingDiscoveryGroup = true; LOG(`Generating next discovery group (current: ${discoveryBuffer.length}/${targetBufferSize})`); try { // Generate one group at a time const newGroup = await generateDiscoveryGroup(); // Add non-empty group to buffer if (newGroup && newGroup.length > 0) { discoveryBuffer.push(newGroup); LOG(`Added group to discovery buffer (total: ${discoveryBuffer.length})`); // Mark that additional content is available additionalDiscoveryContent = true; // Update UI state after adding first group (enables handlers earlier) manageDiscoveryUIState().catch(err => ERR('Error managing UI state:', err)); // Recursively call to get more groups if needed if (discoveryBuffer.length < targetBufferSize) { isLoadingDiscoveryGroup = false; // Allow recursive call await ensureDiscoveryBuffer(); } } else { // Couldn't generate any new group, mark that additional content is not available additionalDiscoveryContent = false; LOG('No additional discovery content available'); manageDiscoveryUIState().catch(err => ERR('Error managing UI state:', err)); } } catch (err) { ERR('Error ensuring discovery buffer:', err); additionalDiscoveryContent = false; manageDiscoveryUIState().catch(e => ERR('Error managing UI state:', e)); } finally { isLoadingDiscoveryGroup = false; } } /** * Manages the UI state (handlers and button visibility) based on buffer content */ async function manageDiscoveryUIState() { const homeSectionsContainer = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (discoveryBuffer.length > 0) { // Buffer has content - set up handlers if (homeSectionsContainer) { setupInfiniteLoading(homeSectionsContainer); // Pre-render first group to prepare for smooth reveal if (hiddenDiscoverySections.length === 0) { const firstGroup = discoveryBuffer[0]; if (firstGroup) { LOG('Pre-rendering first discovery group for smooth reveal'); preRenderDiscoveryGroup(discoveryBuffer.shift()).then(preRendered => { hiddenDiscoverySections.push(...preRendered); LOG(`Pre-rendered first group: ${preRendered.length} sections ready`); }).catch(err => { ERR('Error pre-rendering first group:', err); }); } } } LOG(`UI state updated: handlers enabled (buffer has ${discoveryBuffer.length} groups)`); } else { // Buffer empty - remove handlers and hide button removeScrollBasedLoading(); const loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); if (loadMoreButton) { loadMoreButton.style.display = 'none'; } LOG('UI state updated: handlers removed, button hidden (buffer empty)'); } } /** * Preloads the next batch of discovery sections in the background * Uses the new buffer-based system for better performance */ async function preloadNextSections() { if (isPreloadingSections) { LOG('Preloading already in progress, skipping...'); return; } isPreloadingSections = true; try { LOG('Preloading discovery sections using buffer system...'); // Ensure buffer has 2 groups (simple constant buffer) await ensureDiscoveryBuffer(); if (discoveryBuffer.length === 0) { LOG('No discovery sections to preload'); hideDiscoveryLoadingIndicator(); return; } LOG(`Discovery buffer populated with ${discoveryBuffer.length} groups`); } catch (err) { ERR('Error preloading discovery sections:', err); } finally { isPreloadingSections = false; } } /** * Renders the next group of discovery sections from the buffer * This is the unified function used by both infinite scroll and load more button * Uses pre-rendered sections with smooth sequential animation * @param {HTMLElement} container - Container to render sections into * @param {HTMLElement} loadMoreButton - Optional load more button to update state * @returns {Promise} - Success status */ async function renderNextDiscoveryGroup(container, loadMoreButton = null) { if (isRenderingDiscoveryGroup) { LOG('Discovery group rendering already in progress, skipping...'); return false; } try { LOG('Rendering next discovery group from buffer'); const loadMoreButton = container.querySelector('.load-more-discovery-btn'); if (loadMoreButton) { loadMoreButton.remove(); } // Show loading indicator showDiscoveryLoadingIndicator(); isRenderingDiscoveryGroup = true; LOG(`Rendering group from buffer (${discoveryBuffer.length} groups available)`); setTimeout(async () => { async function renderDiscoveryGroup() { // Check if we have pre-rendered sections ready to reveal if (hiddenDiscoverySections.length > 0) { LOG(`Revealing ${hiddenDiscoverySections.length} pre-rendered sections with animation`); const sectionsToReveal = hiddenDiscoverySections.splice(0); // Take all sections // Reveal sections with smooth animation const revealedCount = await revealSectionsSequentially(container, sectionsToReveal); if (revealedCount > 0) { LOG(`Successfully revealed ${revealedCount} pre-rendered sections`); // Pre-render next group from buffer in background if (discoveryBuffer.length > 0) { const nextGroup = discoveryBuffer.shift(); if (nextGroup) { const preRendered = await preRenderDiscoveryGroup(nextGroup); hiddenDiscoverySections.push(...preRendered); } } // Trigger background refill of buffer to update UI state ensureDiscoveryBuffer().catch(err => { ERR('Error refilling discovery buffer:', err); }); // Hide loading indicator hideDiscoveryLoadingIndicator(); return revealedCount > 0; } } // Fallback: if no pre-rendered sections, render directly from buffer (legacy behavior) const groupToRender = discoveryBuffer.shift(); if (groupToRender && groupToRender.length > 0) { // Remove loading indicator from DOM before rendering sections to prevent scroll jump const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); let loadingIndicatorClone = null; if (loadingIndicator && loadingIndicator.parentNode) { loadingIndicatorClone = loadingIndicator.cloneNode(true); loadingIndicator.remove(); LOG('Removed loading indicator from DOM before rendering sections (fallback path)'); } let renderedCount = 0; // Render sections from the group (including spotlight and collection sections via generateDiscoveryGroup) for (const sectionData of groupToRender) { try { const success = await renderDiscoverySection(sectionData, container); if (success) { renderedCount++; } } catch (err) { ERR(`Error rendering section ${sectionData.type}:`, err); } } LOG(`Rendered ${renderedCount}/${groupToRender.length} sections directly from buffer`); // Re-add loading indicator at the end of the container if (loadingIndicatorClone) { container.appendChild(loadingIndicatorClone); LOG('Re-added loading indicator at the end of sections (fallback path)'); } // Trigger background refill of buffer to update UI state ensureDiscoveryBuffer().catch(err => { ERR('Error refilling discovery buffer:', err); }); // Hide loading indicator hideDiscoveryLoadingIndicator(); return renderedCount > 0; } return false; } await renderDiscoveryGroup(); if (!enableInfiniteScroll) { setupLoadMoreButton(container); } // Scroll down a tiny bit with a smooth animation to start revealing the sections window.scrollBy({ top: 100, behavior: 'smooth' }); isRenderingDiscoveryGroup = false; }, 100); return false; } catch (err) { ERR('Error rendering next discovery group:', err); return false; } finally { isRenderingDiscoveryGroup = false; } } /** * Handles the "Discover More" button click using the buffer system * @param {HTMLElement} loadMoreButton - The button element */ async function handleDiscoverMoreClick(loadMoreButton) { try { LOG('Discover More button clicked'); const homeSectionsContainer = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (homeSectionsContainer) { await renderNextDiscoveryGroup(homeSectionsContainer, loadMoreButton); } else { ERR('Home sections container not found'); } } catch (err) { ERR('Error handling Discover More click:', err); } } /** * Pre-renders a discovery group as DOM fragments (not yet in DOM) * @param {Array} group - Array of section data from buffer * @returns {Promise} - Array of DOM elements for each section */ async function preRenderDiscoveryGroup(group) { if (!group || group.length === 0) return []; LOG(`Pre-rendering discovery group with ${group.length} sections`); const preRenderedSections = []; for (const sectionData of group) { try { const sectionElement = await buildDiscoverySectionElement(sectionData); if (sectionElement) { preRenderedSections.push(sectionElement); } } catch (err) { ERR(`Error pre-rendering section ${sectionData.type}:`, err); } } LOG(`Pre-rendered ${preRenderedSections.length}/${group.length} sections`); return preRenderedSections; } /** * Builds a single discovery section DOM element without appending to DOM * @param {Object} sectionData - Section data object * @returns {Promise} - Section DOM element */ async function buildDiscoverySectionElement(sectionData) { // Use a temporary container that we'll remove from DOM after extracting elements const tempContainer = document.createElement('div'); tempContainer.style.display = 'none'; // Hide container // Build the section using existing renderDiscoverySection logic const success = await renderDiscoverySection(sectionData, tempContainer); if (!success || tempContainer.children.length === 0) { return null; } // Extract the first child (the section element) const sectionElement = tempContainer.firstElementChild; // Remove from temp container tempContainer.removeChild(sectionElement); return sectionElement; } /** * Reveals pre-rendered sections with scroll-triggered viewport animation * @param {HTMLElement} container - Container to append sections to * @param {Array} sections - Array of DOM elements to reveal * @returns {Promise} - Number of sections revealed */ async function revealSectionsSequentially(container, sections) { if (!sections || sections.length === 0) return 0; LOG(`Revealing ${sections.length} pre-rendered sections with scroll-triggered animation`); // Remove loading indicator from DOM before revealing sections to prevent scroll jump // We'll re-add it at the end after sections are revealed const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); let loadingIndicatorClone = null; if (loadingIndicator && loadingIndicator.parentNode) { loadingIndicatorClone = loadingIndicator.cloneNode(true); loadingIndicator.remove(); LOG('Removed loading indicator from DOM before revealing sections'); } // Add all sections to DOM with initial animation class (hidden) sections.forEach(section => { section.style.display = 'none'; section.classList.add('discovery-section-reveal'); container.appendChild(section); }); // Create intersection observer for scroll-triggered animations const observerOptions = { threshold: 0.1, // Trigger when 10% of section is visible rootMargin: '0px 0px -50px 0px' // Trigger slightly before fully in view }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { // Add in-viewport class to trigger animation entry.target.classList.add('in-viewport'); // Unobserve after animation is triggered observer.unobserve(entry.target); } }); }, observerOptions); // Remove display: none and observe sections sections.forEach(section => { section.style.display = ''; // Observe section for viewport entry observer.observe(section); }); // Re-add loading indicator at the end of the container (now it's at the end in DOM order too) if (loadingIndicatorClone) { container.appendChild(loadingIndicatorClone); LOG('Re-added loading indicator at the end of sections'); } LOG(`Revealed ${sections.length} sections with scroll-triggered animation`); return sections.length; } /** * Preloads genre section data * @param {Object} genre - Genre object * @returns {Promise} - Genre data with items and viewMoreUrl */ async function preloadGenreSection(genre, limit = 16) { try { const apiClient = window.ApiClient; const serverUrl = apiClient.serverAddress(); const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const serverId = apiClient.serverId(); const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genre.Name)}&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Fields=UserData&Limit=${limit}`; const response = await fetch(url, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const items = data.Items || []; if (items.length === 0) return null; const viewMoreUrl = `/web/#/list.html?genreId=${genre.Id}&serverId=${serverId}`; return { items: items, viewMoreUrl: viewMoreUrl, hasFullItems: true }; } catch (err) { ERR(`Error preloading genre section for ${genre.Name}:`, err); return null; } } /** * Preloads "because you watched" section data * @param {Object} movie - Movie object * @returns {Promise} - Similar content data */ async function preloadBecauseYouWatchedSection(movie, limit = 16) { try { const items = await fetchSimilarContent(movie.Id, 'Movie'); if (items.length === 0) return null; return { items: items, // Return all items, let render logic handle limit hasFullItems: true }; } catch (err) { ERR(`Error preloading because you watched section for ${movie.Name}:`, err); return null; } } /** * Preloads "because you liked" section data * @param {Object} movie - Movie object * @returns {Promise} - Similar content data */ async function preloadBecauseYouLikedSection(movie, limit = 16) { try { const items = await fetchSimilarContent(movie.Id, 'Movie'); if (items.length === 0) return null; return { items: items, // Return all items, let render logic handle limit hasFullItems: true }; } catch (err) { ERR(`Error preloading because you liked section for ${movie.Name}:`, err); return null; } } /** * Renders a discovery section (unified function for both preloaded and dynamic sections) * @param {Object|string} sectionData - Preloaded section data object OR section type string * @param {HTMLElement} container - Container to append section to * @returns {Promise} - Success status */ async function renderDiscoverySection(sectionData, container) { try { let sectionName = ''; let items = []; let viewMoreUrl = null; let sectionId = ''; let order = DISCOVERY_ORDER + renderedSections.size; let sectionConfig = null; let sectionKey = null; if (typeof sectionData === 'object' && sectionData.type === 'custom-discovery') { const baseSection = sectionData.data || {}; const clonedSection = JSON.parse(JSON.stringify(baseSection)); const uniqueSuffix = `${Date.now()}-${Math.floor(Math.random() * 1000)}`; clonedSection.id = `${clonedSection.id || clonedSection.name || 'custom'}-discovery-${uniqueSuffix}`; clonedSection.order = DISCOVERY_ORDER + renderedSections.size; const success = await renderCustomSection(clonedSection, container); if (success) { renderedSections.add(clonedSection.id); } return success; } // Handle preloaded section data if (typeof sectionData === 'object' && sectionData.type) { if (!sectionData.items || sectionData.items.length === 0) return false; sectionKey = sectionData.sectionKey || getDiscoverySectionKeyFromType(sectionData.type); sectionConfig = sectionData.config || getDiscoverySectionSettings(sectionKey); if (!sectionConfig || sectionConfig.enabled === false) return false; items = sectionData.items; viewMoreUrl = sectionData.viewMoreUrl || null; items = applyDiscoverySectionOrdering(items, sectionConfig); if (!items || items.length === 0) return false; // Helper function to format section names with placeholders function formatSectionName(template, replacements) { let name = template; for (const [key, value] of Object.entries(replacements)) { name = name.replace(new RegExp(`\\[${key}\\]`, 'gi'), value || ''); } return name; } switch (sectionData.type) { case 'genre': const genreTemplate = sectionConfig?.name || '[Genre] Movies'; sectionName = formatSectionName(genreTemplate, { Genre: sectionData.data.Name }); sectionId = `genre-${sectionData.data.Name.toLowerCase()}`; break; case 'director': const directorTemplate = sectionConfig?.name || 'Directed by [Director]'; sectionName = formatSectionName(directorTemplate, { Director: sectionData.data.name }); sectionId = `director-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; break; case 'writer': const writerTemplate = sectionConfig?.name || 'Written by [Writer]'; sectionName = formatSectionName(writerTemplate, { Writer: sectionData.data.name }); sectionId = `writer-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; break; case 'actor': const actorTemplate = sectionConfig?.name || 'Starring [Actor]'; sectionName = formatSectionName(actorTemplate, { Actor: sectionData.data.name }); sectionId = `actor-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; break; case 'watched': const watchedTemplate = sectionConfig?.name || 'Because you watched [Movie]'; const watchedMovieName = `${sectionData.data.Name}${sectionData.data.ProductionYear ? ` (${sectionData.data.ProductionYear})` : ''}`; sectionName = formatSectionName(watchedTemplate, { Movie: watchedMovieName }); sectionId = `watched-${sectionData.data.Id}`; break; case 'liked': const likedTemplate = sectionConfig?.name || 'Because you liked [Movie]'; const likedMovieName = `${sectionData.data.Name}${sectionData.data.ProductionYear ? ` (${sectionData.data.ProductionYear})` : ''}`; sectionName = formatSectionName(likedTemplate, { Movie: likedMovieName }); sectionId = `liked-${sectionData.data.Id}`; break; case 'studio': const studioTemplate = sectionConfig?.name || 'Shows from [Studio]'; sectionName = formatSectionName(studioTemplate, { Studio: sectionData.data.Name }); sectionId = `studio-${sectionData.data.Id}`; break; case 'watched-recent': const watchedRecentTemplate = sectionConfig?.name || 'Because you recently watched [Movie]'; const watchedRecentMovieName = `${sectionData.data.Name}${sectionData.data.ProductionYear ? ` (${sectionData.data.ProductionYear})` : ''}`; sectionName = formatSectionName(watchedRecentTemplate, { Movie: watchedRecentMovieName }); sectionId = `watched-recent-${sectionData.data.Id}`; break; case 'actor-recent': const actorRecentTemplate = sectionConfig?.name || 'Starring [Actor] because you recently watched [Movie]'; const actorRecentMovieName = `${sectionData.data.movie.Name}${sectionData.data.movie.ProductionYear ? ` (${sectionData.data.movie.ProductionYear})` : ''}`; sectionName = formatSectionName(actorRecentTemplate, { Actor: sectionData.data.person.Name, Movie: actorRecentMovieName }); sectionId = `actor-recent-${sectionData.data.person.Id}-${sectionData.data.movie.Id}`; break; case 'director-recent': const directorRecentTemplate = sectionConfig?.name || 'Directed by [Director] because you recently watched [Movie]'; const directorRecentMovieName = `${sectionData.data.movie.Name}${sectionData.data.movie.ProductionYear ? ` (${sectionData.data.movie.ProductionYear})` : ''}`; sectionName = formatSectionName(directorRecentTemplate, { Director: sectionData.data.person.Name, Movie: directorRecentMovieName }); sectionId = `director-recent-${sectionData.data.person.Id}-${sectionData.data.movie.Id}`; break; case 'writer-recent': const writerRecentTemplate = sectionConfig?.name || 'Written by [Writer] because you recently watched [Movie]'; const writerRecentMovieName = `${sectionData.data.movie.Name}${sectionData.data.movie.ProductionYear ? ` (${sectionData.data.movie.ProductionYear})` : ''}`; sectionName = formatSectionName(writerRecentTemplate, { Writer: sectionData.data.person.Name, Movie: writerRecentMovieName }); sectionId = `writer-recent-${sectionData.data.person.Id}-${sectionData.data.movie.Id}`; break; case 'spotlight-genre': // Handle spotlight genre section - render as spotlight if (!window.cardBuilder || !window.cardBuilder.renderSpotlightSection) { WARN("cardBuilder.renderSpotlightSection not available"); return false; } const spotlightGenreTemplate = sectionConfig?.name || 'Spotlight'; sectionName = formatSectionName(spotlightGenreTemplate, { Genre: sectionData.data.Name }); sectionId = `spotlight-genre-${sectionData.data.Id}`; const spotlightGenreContainer = window.cardBuilder.renderSpotlightSection( items, sectionName, { autoPlay: true, interval: 5000, showDots: true, showNavButtons: true, showClearArt: true }, ); spotlightGenreContainer.setAttribute('data-custom-section-id', sectionId); spotlightGenreContainer.setAttribute('data-custom-section-name', sectionName); spotlightGenreContainer.style.order = order; container.appendChild(spotlightGenreContainer); renderedSections.add(sectionId); return true; case 'spotlight-network': // Handle spotlight network section - render as spotlight if (!window.cardBuilder || !window.cardBuilder.renderSpotlightSection) { WARN("cardBuilder.renderSpotlightSection not available"); return false; } const spotlightNetworkTemplate = sectionConfig?.name || 'Spotlight'; sectionName = formatSectionName(spotlightNetworkTemplate, { Studio: sectionData.data.Name }); sectionId = `spotlight-network-${sectionData.data.Id}`; const spotlightNetworkContainer = window.cardBuilder.renderSpotlightSection( items, sectionName, { autoPlay: true, interval: 5000, showDots: true, showNavButtons: true, showClearArt: true } ); spotlightNetworkContainer.setAttribute('data-custom-section-id', sectionId); spotlightNetworkContainer.setAttribute('data-custom-section-name', sectionName); spotlightNetworkContainer.style.order = order; container.appendChild(spotlightNetworkContainer); renderedSections.add(sectionId); return true; case 'collection': // Handle collection section - use provided config const collectionTemplate = sectionConfig?.name || '[Collection Name]'; sectionName = formatSectionName(collectionTemplate, { 'Collection Name': sectionData.data.Name || 'Collection' }); sectionId = `collection-${sectionData.data.Id}`; const collectionItemLimit = sectionConfig?.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); const collectionSortOrder = sectionConfig?.sortOrder ?? discoveryConfig.defaultSortOrder ?? defaultSortOrder; const collectionSortOrderDirection = sectionConfig?.sortOrderDirection ?? 'Ascending'; const collectionCardFormat = sectionConfig?.cardFormat ?? discoveryConfig.defaultCardFormat ?? defaultCardFormat; // Apply sort order if needed let collectionItems = items; if (collectionSortOrder === 'Random') { collectionItems = [...items].sort(() => Math.random() - 0.5); } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { collectionItems = window.cardBuilder.sortItems(items, collectionSortOrder, collectionSortOrderDirection); } // Apply limit const limitedCollectionItems = collectionItems.slice(0, collectionItemLimit); if (limitedCollectionItems.length === 0) { return false; } let collectionContainer = null; if (window.cardBuilder && window.cardBuilder.renderSpotlightSection) { collectionContainer = window.cardBuilder.renderSpotlightSection( limitedCollectionItems, sectionName, { autoPlay: true, interval: 5000, showDots: true, showNavButtons: true, showClearArt: true } ); } if (!collectionContainer && window.cardBuilder && window.cardBuilder.renderCards) { collectionContainer = window.cardBuilder.renderCards( limitedCollectionItems, sectionName, sectionData.viewMoreUrl || `/web/#/collection.html?id=${sectionData.data.Id}`, true, collectionCardFormat, collectionSortOrder, collectionSortOrderDirection ); } collectionContainer.setAttribute('data-custom-section-id', sectionId); collectionContainer.setAttribute('data-custom-section-name', sectionName); collectionContainer.style.order = order; container.appendChild(collectionContainer); renderedSections.add(sectionId); return true; default: return false; } } else if (typeof sectionData === 'string') { sectionKey = getDiscoverySectionKeyFromType(sectionData); sectionConfig = getDiscoverySectionSettings(sectionKey); if (!sectionConfig || sectionConfig.enabled === false) { return false; } if (items && items.length > 0) { items = applyDiscoverySectionOrdering(items, sectionConfig); } if (!sectionName) { sectionName = sectionConfig.name || sectionName; } } if (items.length === 0) { return false; // Auto-hide empty sections } const activeSectionConfig = sectionConfig || (sectionKey ? getDiscoverySectionSettings(sectionKey) : null); const itemLimit = activeSectionConfig?.itemLimit ?? (discoveryConfig.defaultItemLimit ?? defaultItemLimit); const sortOrder = activeSectionConfig?.sortOrder ?? discoveryConfig.defaultSortOrder ?? defaultSortOrder; const sortOrderDirection = activeSectionConfig?.sortOrderDirection ?? 'Ascending'; const cardFormat = activeSectionConfig?.cardFormat ?? discoveryConfig.defaultCardFormat ?? defaultCardFormat; const limitedItems = activeSectionConfig ? items : items.slice(0, itemLimit); if (limitedItems.length === 0) { return false; // Auto-hide empty sections } // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined') { WARN("cardBuilder not available, skipping discovery section"); return false; } // Determine if we have full items or just IDs const hasFullItems = limitedItems.length > 0 && limitedItems[0] && typeof limitedItems[0] === 'object' && limitedItems[0].Id; let scrollableContainer; if (hasFullItems) { // Use renderCards for full item objects if (!window.cardBuilder.renderCards) { WARN("cardBuilder.renderCards not available, skipping discovery section"); return false; } scrollableContainer = window.cardBuilder.renderCards( limitedItems, sectionName, viewMoreUrl, true, cardFormat, sortOrder, sortOrderDirection ); } else { // Use renderCardsFromIds for ID arrays if (!window.cardBuilder.renderCardsFromIds) { WARN("cardBuilder.renderCardsFromIds not available, skipping discovery section"); return false; } scrollableContainer = await window.cardBuilder.renderCardsFromIds( limitedItems, sectionName, viewMoreUrl, true, cardFormat, sortOrder, sortOrderDirection ); } // Add data attributes to track rendered sections scrollableContainer.setAttribute('data-custom-section-id', sectionId); scrollableContainer.setAttribute('data-custom-section-name', sectionName); scrollableContainer.style.order = order; // Append to container container.appendChild(scrollableContainer); // Track rendered section for preloaded sections if (typeof sectionData === 'object') { renderedSections.add(sectionId); } return true; } catch (err) { ERR(`Error rendering discovery section:`, err); return false; } } /** * Sets up infinite loading for discovery sections * @param {HTMLElement} container - Container to watch for scroll */ function setupInfiniteLoading(container) { const homePage = document.querySelector('.homePage:not(.hide)'); if (!homePage || homePage.dataset.discoveryReady === 'true') { LOG('Home page not found or already discovery ready, skipping...'); LOG('homePage:', homePage); return; } homePage.dataset.discoveryReady = 'true'; if (enableInfiniteScroll) { homePage.dataset.infiniteScroll = 'true'; setupScrollBasedLoading(container); } else { setupLoadMoreButton(container); } } /** * Removes scroll-based infinite loading handler */ function removeScrollBasedLoading() { // Fade out message for "loading more" const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (discoveryScrollHandler) { window.removeEventListener('scroll', discoveryScrollHandler); discoveryScrollHandler = null; LOG('Scroll-based infinite loading for discovery sections removed'); } if (discoveryWheelHandler) { window.removeEventListener('wheel', discoveryWheelHandler); discoveryWheelHandler = null; LOG('Wheel-based trigger for discovery sections removed'); } if (discoveryTouchStartHandler) { window.removeEventListener('touchstart', discoveryTouchStartHandler); discoveryTouchStartHandler = null; } if (discoveryTouchMoveHandler) { window.removeEventListener('touchmove', discoveryTouchMoveHandler); discoveryTouchMoveHandler = null; LOG('Touch-based trigger for discovery sections removed'); } } /** * Sets up scroll-based infinite loading * @param {HTMLElement} container - Container to watch for scroll */ function setupScrollBasedLoading(container) { // Remove existing handler if present (make idempotent) removeScrollBasedLoading(); let lastScrollTop = 0; let scrollTimeout = null; let lastTouchY = null; const handleScroll = () => { // Ensure we're on the home page before proceeding const currentView = window.KefinTweaksUtils?.getCurrentView(); const isHomePage = currentView === 'home' || currentView === 'home.html'; // Only enable scroll-based loading on the home page if (!isHomePage) { return; } // Only enable scroll-based loading on the home page first tab const activeTab = document.querySelector('.headerTabs .emby-tab-button-active').getAttribute('data-index'); if (activeTab !== '0') { return; } if (isRenderingDiscoveryGroup) return; const scrollTop = window.pageYOffset || document.documentElement.scrollTop; lastScrollTop = scrollTop; }; // Store handler reference and add scroll listener discoveryScrollHandler = handleScroll; window.addEventListener('scroll', handleScroll, { passive: true }); // Also trigger when user attempts to scroll down while already at the bottom const handleWheel = (event) => { // Ensure we're on the home page before proceeding const currentView = window.KefinTweaksUtils?.getCurrentView(); const isHomePage = currentView === 'home' || currentView === 'home.html'; if (!isHomePage) return; // Only on first tab const activeTab = document.querySelector('.headerTabs .emby-tab-button-active').getAttribute('data-index'); if (activeTab !== '0') return; if (isRenderingDiscoveryGroup) return; // Only react to downward scroll attempts if (event.deltaY <= 0) return; const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const windowHeight = window.innerHeight; const documentHeight = document.documentElement.scrollHeight; const atBottom = scrollTop + windowHeight >= documentHeight - 2; if (!atBottom) return; if (scrollTimeout) { clearTimeout(scrollTimeout); } scrollTimeout = setTimeout(async () => { LOG('User attempted to scroll past bottom; loading next discovery group...'); const homeScreenSectionsContainer = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); await renderNextDiscoveryGroup(homeScreenSectionsContainer); }, 200); }; discoveryWheelHandler = handleWheel; window.addEventListener('wheel', handleWheel, { passive: true }); // Touch support: trigger when user swipes up (scroll down) while already at bottom const handleTouchStart = (event) => { const touch = event.touches && event.touches[0]; lastTouchY = touch ? touch.clientY : null; }; const handleTouchMove = (event) => { // Ensure we're on the home page before proceeding const currentView = window.KefinTweaksUtils?.getCurrentView(); const isHomePage = currentView === 'home' || currentView === 'home.html'; if (!isHomePage) return; // Only on first tab const activeTab = document.querySelector('.headerTabs .emby-tab-button-active').getAttribute('data-index'); if (activeTab !== '0') return; if (isRenderingDiscoveryGroup) return; const touch = event.touches && event.touches[0]; if (!touch) return; if (lastTouchY == null) { lastTouchY = touch.clientY; return; } const deltaY = touch.clientY - lastTouchY; // negative when swiping up (scrolling down) lastTouchY = touch.clientY; // Only react to upward swipe which scrolls content down if (deltaY >= 0) return; const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const windowHeight = window.innerHeight; const documentHeight = document.documentElement.scrollHeight; const atBottom = scrollTop + windowHeight >= documentHeight - 2; if (!atBottom) return; if (scrollTimeout) { clearTimeout(scrollTimeout); } scrollTimeout = setTimeout(async () => { LOG('User swiped up at bottom; loading next discovery group...'); await renderNextDiscoveryGroup(container); }, 200); }; discoveryTouchStartHandler = handleTouchStart; discoveryTouchMoveHandler = handleTouchMove; window.addEventListener('touchstart', handleTouchStart, { passive: true }); window.addEventListener('touchmove', handleTouchMove, { passive: true }); LOG('Scroll-based infinite loading for discovery sections enabled'); } /** * Sets up load more button for discovery sections * @param {HTMLElement} container - Container to append button to */ function setupLoadMoreButton(container) { let loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); if (!loadMoreButton) { // Create load more button loadMoreButton = document.createElement('button'); loadMoreButton.textContent = 'Discover More'; loadMoreButton.className = 'load-more-discovery-btn raised button-submit emby-button'; loadMoreButton.style.cssText = ` order: 9998 !important; display: block; width: 100%; max-width: 400px; margin: 20px auto; padding: 12px 24px; background: #673AB7; color: white; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; `; // Add hover effects loadMoreButton.addEventListener('mouseenter', () => { loadMoreButton.style.transform = 'translateY(-2px)'; loadMoreButton.style.boxShadow = '0 4px 12px rgba(0, 164, 220, 0.4)'; }); loadMoreButton.addEventListener('mouseleave', () => { loadMoreButton.style.transform = 'translateY(0)'; loadMoreButton.style.boxShadow = '0 2px 8px rgba(0, 164, 220, 0.3)'; }); // Add click handler using new buffer system loadMoreButton.addEventListener('click', async () => { await handleDiscoverMoreClick(loadMoreButton); }); // Append button to container container.appendChild(loadMoreButton); LOG('Load more button for discovery sections created'); } // Update visibility based on buffer state updateLoadMoreButtonVisibility(); } /** * Updates the visibility of the load more button based on buffer state */ function updateLoadMoreButtonVisibility() { const loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); if (!loadMoreButton) return; if (discoveryBuffer.length > 0 || additionalDiscoveryContent) { loadMoreButton.textContent = 'Discover More'; loadMoreButton.style.display = 'block'; loadMoreButton.disabled = false; LOG('Load more button shown'); } else { loadMoreButton.style.display = 'none'; LOG('Load more button hidden'); } } /** * Gets a random genre that hasn't been rendered yet * @returns {Promise} - Random genre or null */ /** * Gets qualifying genres (genres with enough movies) with caching * @returns {Promise} Array of qualifying genre objects */ async function getQualifyingGenres() { // Return cached if available if (cachedQualifyingGenres !== null) { return cachedQualifyingGenres; } // Prevent parallel caching if (isCachingQualifyingGenres) { // Wait for the other call to complete while (isCachingQualifyingGenres) { await new Promise(resolve => setTimeout(resolve, 100)); } return cachedQualifyingGenres || []; } isCachingQualifyingGenres = true; try { LOG('Fetching and caching qualifying genres...'); // Get all genres const serverAddress = ApiClient.serverAddress(); const token = ApiClient.accessToken(); const genresResponse = await fetch(`${serverAddress}/Genres?IncludeItemTypes=Movie`, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); if (!genresResponse.ok) { ERR(`Failed to fetch genres: ${genresResponse.statusText}`); isCachingQualifyingGenres = false; return []; } const genresData = await genresResponse.json(); const genres = genresData.Items || []; const qualifyingGenres = []; // Check each genre to see if it has enough movies for (const genre of genres) { if (genre.MovieCount < minGenreMovieCount) continue; qualifyingGenres.push(genre); } // Cache the results cachedQualifyingGenres = qualifyingGenres; LOG(`Cached ${qualifyingGenres.length} qualifying genres`); console.log(qualifyingGenres); return qualifyingGenres; } catch (err) { ERR('Error fetching qualifying genres:', err); isCachingQualifyingGenres = false; return []; } finally { isCachingQualifyingGenres = false; } } async function getRandomGenre() { const cache = new window.LocalStorageCache(); let movieGenres = cache.get('movieGenres'); if (!movieGenres || movieGenres.length === 0) { movieGenres = await fetchAndCacheMovieGenres(); } if (movieGenres.length === 0) return null; // Filter out already rendered genres and genres with insufficient movie count const availableGenres = movieGenres.filter(genre => !renderedSections.has(`genre-${genre.Name.toLowerCase()}`) && (genre.MovieCount || 0) >= minGenreMovieCount ); if (availableGenres.length === 0) return null; const randomGenre = getRandomItem(availableGenres); if (randomGenre) { renderedSections.add(`genre-${randomGenre.Name.toLowerCase()}`); } return randomGenre; } /** * Renders Halloween Movies section * @param {HTMLElement} container - Container to append the section to * @returns {boolean} - Success status */ function renderHalloweenMoviesSection(container) { try { if (halloweenMovies.length === 0) return false; const shuffled = [...halloweenMovies].sort(() => Math.random() - 0.5); const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT); if (limitedItems.length === 0) return false; // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping Halloween Movies section"); return false; } // Render the scrollable container const scrollableContainer = window.cardBuilder.renderCards( limitedItems, 'Halloween Movies', null, true ); scrollableContainer.setAttribute('data-custom-section-id', 'halloween-movies'); scrollableContainer.setAttribute('data-custom-section-name', 'Halloween Movies'); scrollableContainer.style.order = 50; container.appendChild(scrollableContainer); return true; } catch (err) { ERR('Error rendering Halloween Movies section:', err); return false; } } /** * Renders Horror Movies section * @param {HTMLElement} container - Container to append the section to * @returns {boolean} - Success status */ function renderHorrorMoviesSection(container) { try { if (horrorMovies.length === 0) return false; const shuffled = [...horrorMovies].sort(() => Math.random() - 0.5); const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT); if (limitedItems.length === 0) return false; // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping Horror Movies section"); return false; } // Render the scrollable container const scrollableContainer = window.cardBuilder.renderCards( limitedItems, 'Horror Movies', halloweenViewMoreUrls.horror, true ); scrollableContainer.setAttribute('data-custom-section-id', 'horror-movies'); scrollableContainer.setAttribute('data-custom-section-name', 'Horror Movies'); scrollableContainer.style.order = 51; container.appendChild(scrollableContainer); return true; } catch (err) { ERR('Error rendering Horror Movies section:', err); return false; } } /** * Renders Thriller Movies section * @param {HTMLElement} container - Container to append the section to * @returns {boolean} - Success status */ function renderThrillerMoviesSection(container) { try { if (thrillerMovies.length === 0) return false; const shuffled = [...thrillerMovies].sort(() => Math.random() - 0.5); const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT); if (limitedItems.length === 0) return false; // Check if cardBuilder is available if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { WARN("cardBuilder not available, skipping Thriller Movies section"); return false; } // Render the scrollable container const scrollableContainer = window.cardBuilder.renderCards( limitedItems, 'Thriller Movies', halloweenViewMoreUrls.thriller, true ); scrollableContainer.setAttribute('data-custom-section-id', 'thriller-movies'); scrollableContainer.setAttribute('data-custom-section-name', 'Thriller Movies'); scrollableContainer.style.order = 52; container.appendChild(scrollableContainer); return true; } catch (err) { ERR('Error rendering Thriller Movies section:', err); return false; } } /** * Renders all Halloween sections * @param {HTMLElement} container - Container to append sections to */ async function renderAllHalloweenSections(container) { if (!isHalloweenPeriod()) { LOG('Not in Halloween period, skipping Halloween sections'); return; } try { // Fetch Halloween data if not already loaded if (halloweenMovies.length === 0) { LOG('Fetching Halloween movie data...'); const movieData = await fetchHalloweenMovieData(); halloweenMovies = movieData.halloweenMovies; horrorMovies = movieData.horrorMovies; thrillerMovies = movieData.thrillerMovies; halloweenViewMoreUrls = movieData.viewMoreUrls; } let sectionsToRender = []; if (halloweenMovies.length > 0) { if (!container.querySelector('[data-custom-section-id="halloween-movies"]')) { sectionsToRender.push(renderHalloweenMoviesSection(container)); } if (!container.querySelector('[data-custom-section-id="horror-movies"]')) { sectionsToRender.push(renderHorrorMoviesSection(container)); } if (!container.querySelector('[data-custom-section-id="thriller-movies"]')) { sectionsToRender.push(renderThrillerMoviesSection(container)); } } if (sectionsToRender.length === 0) { LOG('No Halloween sections to render, skipping...'); return; } const results = await Promise.all(sectionsToRender); const successCount = results.filter(Boolean).length; LOG(`Rendered ${successCount}/${results.length} Halloween sections`); return successCount === results.length; } catch (err) { ERR('Error rendering Halloween sections:', err); } } /** * Renders all Christmas sections (stub for future implementation) * @param {HTMLElement} container - Container to append sections to */ async function renderAllChristmasSections(container) { if (!isChristmasPeriod()) { LOG('Not in Christmas period, skipping Christmas sections'); return; } // TODO: Implement Christmas sections LOG('Christmas sections not yet implemented'); } /** * Renders all New Years sections (stub for future implementation) * @param {HTMLElement} container - Container to append sections to */ async function renderAllNewYearsSections(container) { if (!isNewYearsPeriod()) { LOG('Not in New Years period, skipping New Years sections'); return; } // TODO: Implement New Years sections LOG('New Years sections not yet implemented'); } /** * Renders all Valentine's Day sections (stub for future implementation) * @param {HTMLElement} container - Container to append sections to */ async function renderAllValentinesSections(container) { if (!isValentinesPeriod()) { LOG('Not in Valentine\'s Day period, skipping Valentine\'s Day sections'); return; } // TODO: Implement Valentine's Day sections LOG('Valentine\'s Day sections not yet implemented'); } /** * Checks if current date is within a seasonal period * @param {string} startDate - Start date in MM-DD format * @param {string} endDate - End date in MM-DD format * @returns {boolean} - True if within period */ function isInSeasonalPeriod(startDate, endDate) { const now = new Date(); const currentMonth = now.getMonth() + 1; // 1-12 const currentDay = now.getDate(); const [startMonth, startDay] = startDate.split('-').map(Number); const [endMonth, endDay] = endDate.split('-').map(Number); const currentDateNum = currentMonth * 100 + currentDay; const startDateNum = startMonth * 100 + startDay; const endDateNum = endMonth * 100 + endDay; if (startDateNum <= endDateNum) { // Normal range (e.g., 10-01 to 10-31) return currentDateNum >= startDateNum && currentDateNum <= endDateNum; } else { // Wraps around year (e.g., 12-01 to 01-31) return currentDateNum >= startDateNum || currentDateNum <= endDateNum; } } /** * Renders a seasonal section based on config * @param {Object} seasonConfig - Season configuration * @param {HTMLElement} container - Container to append section to * @returns {Promise} - Success status */ /** * Renders a single section (used by both seasonal and custom sections) * @param {Object} sectionConfig - Section configuration * @param {HTMLElement} container - Container to append the section to * @param {string} sectionIdPrefix - Prefix for section ID * @returns {Promise} - Success status */ async function renderSection(sectionConfig, container, sectionIdPrefix) { try { if (!sectionConfig.enabled) { return false; } const sectionId = `${sectionIdPrefix}-${sectionConfig.id}`; if (container.querySelector(`[data-custom-section-id="${sectionId}"]`)) { return false; } // Get config values const itemLimit = sectionConfig.itemLimit ?? seasonalConfig?.defaultItemLimit ?? defaultItemLimit; const sortOrder = sectionConfig.sortOrder ?? seasonalConfig?.defaultSortOrder ?? defaultSortOrder; const sortOrderDirection = sectionConfig.sortOrderDirection ?? 'Ascending'; const cardFormat = sectionConfig.cardFormat ?? seasonalConfig?.defaultCardFormat ?? defaultCardFormat; const order = sectionConfig.order ?? 100; const allItems = await loadItemsForSectionConfig(sectionConfig, itemLimit); if (!allItems || allItems.length === 0) { return false; // Auto-hide empty sections } // Apply sort order let sortedItems = allItems; if (sortOrder === 'Random') { sortedItems = [...allItems].sort(() => Math.random() - 0.5); } /* else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { sortedItems = window.cardBuilder.sortItems(allItems, sortOrder, sortOrderDirection); } */ // Apply limit const limited = sortedItems.slice(0, itemLimit); if (limited.length === 0) { return false; // Auto-hide empty sections } const type = sectionConfig.type; const source = sectionConfig.source; let viewMoreUrl = null; if (source && source !== '') { switch (type) { case 'Genre': viewMoreUrl = `#/list.html?genreId=${source}&serverId=${ApiClient.serverId()}`; break; case 'Studio': viewMoreUrl = `#/list.html?studioId=${source}&serverId=${ApiClient.serverId()}`; break; case 'Playlist': case 'BoxSet': viewMoreUrl = `#/details?id=${source}&serverId=${ApiClient.serverId()}`; break; case 'Parent': viewMoreUrl = `#/list.html?parentId=${source}&serverId=${ApiClient.serverId()}`; break; case 'Tag': viewMoreUrl = `#/list.html?type=tag&tag=${source}&serverId=${ApiClient.serverId()}`; break; default: viewMoreUrl = null; break; } } let cardContainer = null; if (sectionConfig.spotlight === true) { if (!window.cardBuilder || !window.cardBuilder.renderSpotlightSection) { WARN("cardBuilder.renderSpotlightSection not available"); return false; } cardContainer = window.cardBuilder.renderSpotlightSection( limited, sectionConfig.name, { autoPlay: true, interval: 5000, showDots: true, showNavButtons: true, showClearArt: true, viewMoreUrl: viewMoreUrl } ); } else { if (!window.cardBuilder || !window.cardBuilder.renderCards) { WARN("cardBuilder.renderCards not available"); return false; } cardContainer = window.cardBuilder.renderCards( limited, sectionConfig.name, viewMoreUrl, true, cardFormat, sortOrder, sortOrderDirection ); } cardContainer.setAttribute('data-custom-section-id', sectionId); cardContainer.setAttribute('data-custom-section-name', sectionConfig.name); cardContainer.style.order = order; container.appendChild(cardContainer); return true; } catch (err) { ERR(`Error rendering section ${sectionConfig.name}:`, err); return false; } } /** * Renders a seasonal section (which contains nested sections) * @param {Object} seasonConfig - Seasonal section configuration * @param {HTMLElement} container - Container to append sections to * @returns {Promise} - Success status */ async function renderSeasonalSection(seasonConfig, container) { try { if (!seasonConfig.enabled) { return false; } // Check if in seasonal period if (!isInSeasonalPeriod(seasonConfig.startDate, seasonConfig.endDate)) { return false; } // Render each nested section const sections = seasonConfig.sections || []; let anyRendered = false; for (const section of sections) { if (section.enabled) { const rendered = await renderSection(section, container, `seasonal-${seasonConfig.id}`); if (rendered) { anyRendered = true; } } } return anyRendered; } catch (err) { ERR(`Error rendering seasonal section ${seasonConfig.name}:`, err); return false; } } /** * Renders all seasonal sections based on current date and config * @param {HTMLElement} container - Container to append sections to */ async function renderAllSeasonalSections(container) { const seasons = seasonalConfig.seasons || []; const sectionsToRender = []; // Render each configured season if it's in its period for (const season of seasons) { if (season.enabled && isInSeasonalPeriod(season.startDate, season.endDate)) { sectionsToRender.push(renderSeasonalSection(season, container)); } } if (sectionsToRender.length === 0) { LOG('No seasonal periods active, skipping seasonal sections'); return; } const results = await Promise.all(sectionsToRender); const successCount = results.filter(Boolean).length; LOG(`Rendered ${successCount}/${sectionsToRender.length} seasonal sections`); } /************ Home Screen Observer ************/ /** * Checks if custom sections are already rendered and renders them if not */ async function checkAndRenderCustomSections() { // Try to find the home sections container with retry logic // Retry every 100ms for up to 3 seconds let homeSectionsContainer = null; const maxRetries = 100; let retries = 0; while (!homeSectionsContainer && retries < maxRetries) { homeSectionsContainer = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (!homeSectionsContainer) { retries++; await new Promise(resolve => setTimeout(resolve, 100)); } } if (!homeSectionsContainer) { LOG('Home sections container not found after 3 seconds'); return; } // Check if already processing to prevent parallel execution if (isProcessing && homeSectionsContainer.dataset.customSectionsRendered === 'true') { LOG('Already processing, skipping...'); return; } // Check if sections are already rendered if (homeSectionsContainer.dataset.customSectionsRendered === 'true') { return; } createDiscoveryLoadingIndicator(); // Set processing flag to prevent parallel execution isProcessing = true; homeSectionsContainer.dataset.customSectionsRendered = 'true'; try { LOG('Starting parallel initialization of home screen sections...'); // Run all initialization functions in parallel for faster loading let initPromises = []; // Add people cache and preloading if discovery is enabled LOG('Checking Discovery section'); if (enableDiscovery) { initPromises.push(initializePeopleCache()); initPromises.push(preloadNextSections()); } // Add Upcoming section (always rendered when enabled) LOG('Checking Upcoming section'); if (enableUpcoming) { LOG('Rendering Upcoming section'); initPromises.push(renderUpcomingSection(homeSectionsContainer)); } // Add IMDb Top 250 section (always rendered when enabled) if (enableImdbTop250) { initPromises.push(renderImdbTop250Section(homeSectionsContainer)); } // Add custom sections if enabled if (customHomeSections && customHomeSections.length > 0) { initPromises.push(renderAllCustomSections(homeSectionsContainer)); } // Add new and trending sections if enabled if (enableNewAndTrending) { initPromises.push(renderAllNewAndTrendingSections(homeSectionsContainer)); } // Add seasonal sections if enabled if (enableSeasonal) { initPromises.push(renderAllSeasonalSections(homeSectionsContainer)); } // Add watchlist section if enabled if (enableWatchlist) { initPromises.push(renderWatchlistSection(homeSectionsContainer)); } // Add Watch Again section if enabled if (enableWatchAgain) { initPromises.push(renderWatchAgainSection(homeSectionsContainer)); } // Add popular TV networks section if enabled if (popularTVNetworksConfig.enabled) { initPromises.push(renderPopularTVNetworksSection(homeSectionsContainer)); } // Wait for all parallel operations to complete await Promise.all(initPromises); LOG('All custom sections rendered successfully'); } catch (error) { ERR('Error rendering custom sections:', error); } finally { // Always reset the processing flag isProcessing = false; } } if (window.KefinTweaksUtils) { LOG('Registering home screen handler with KefinTweaksUtils'); // Register handler for all pages (breadcrumbs can appear on any detail page) window.KefinTweaksUtils.onViewPage((view, element) => { try { // Run our custom code checkAndRenderCustomSections(); } catch (err) { ERR('Home screen page change handler failed:', err); } }, { pages: ['home', 'home.html'] }); } else { ERR('KefinTweaksUtils not available, breadcrumbs may not work correctly'); } checkAndRenderCustomSections(); // Debug functions for troubleshooting (available in console) window.debugHomeScreen = function() { LOG('Manual debug trigger called'); checkAndRenderCustomSections(); }; window.debugCustomSections = function() { LOG('Custom sections configuration:', customHomeSections); LOG('New and Trending configuration - enableNewAndTrending:', enableNewAndTrending, 'enableNewMovies:', enableNewMovies, 'enableNewEpisodes:', enableNewEpisodes, 'enableTrending:', enableTrending); LOG('Discovery configuration - enableWatchlist:', enableWatchlist, 'enableDiscovery:', enableDiscovery, 'enableSeasonal:', enableSeasonal); LOG('Seasonal configuration - seasonalItemLimit:', SEASONAL_ITEM_LIMIT); LOG('Processing flag status:', isProcessing); // Check multiple selectors const selectors = [ '.libraryPage:not(.hide) .homeSectionsContainer', '.homeSectionsContainer', '.homeSectionsContainer:not(.hide)', '[class*="homeSections"]' ]; selectors.forEach(selector => { const element = document.querySelector(selector); LOG(`Selector "${selector}":`, element ? 'FOUND' : 'NOT FOUND', element); }); // Log all elements with "home" or "section" in class name const allElements = document.querySelectorAll('*'); const relevantElements = Array.from(allElements).filter(el => el.className && typeof el.className === 'string' && (el.className.toLowerCase().includes('home') || el.className.toLowerCase().includes('section')) ); LOG('Elements with "home" or "section" in class:', relevantElements.map(el => ({ tagName: el.tagName, className: el.className, id: el.id }))); // Check if cardBuilder is available LOG('cardBuilder available:', typeof window.cardBuilder !== 'undefined'); LOG('renderCardsFromIds available:', typeof window.cardBuilder?.renderCardsFromIds === 'function'); // Check if LocalStorageCache is available LOG('LocalStorageCache available:', typeof window.LocalStorageCache !== 'undefined'); // Check discovery section data availability if (enableWatchlist) { const watchlistData = getWatchlistData(); LOG('Watchlist data available:', watchlistData.length, 'items'); } if (enableDiscovery) { const movieHistory = getMovieHistoryData(); const seriesProgress = getSeriesProgressData(); LOG('Movie history available:', movieHistory.length, 'items'); LOG('Series progress available:', seriesProgress.length, 'items'); } }; // Debug function for seasonal sections window.debugSeasonalSections = function() { LOG('=== Seasonal Sections Debug ==='); LOG('Configuration - enableSeasonal:', enableSeasonal, 'seasonalItemLimit:', SEASONAL_ITEM_LIMIT); LOG('Date checks:'); LOG('- Halloween period:', isHalloweenPeriod()); LOG('- Christmas period:', isChristmasPeriod()); LOG('- New Years period:', isNewYearsPeriod()); LOG('- Valentine\'s Day period:', isValentinesPeriod()); if (enableSeasonal) { LOG('Halloween data:'); LOG('- Halloween movies:', halloweenMovies.length); LOG('- Horror movies:', horrorMovies.length); LOG('- Thriller movies:', thrillerMovies.length); LOG('- Halloween people:', halloweenPeople.length); LOG('- ViewMore URLs:', halloweenViewMoreUrls); if (halloweenPeople.length > 0) { const directors = halloweenPeople.filter(p => p.directorCount >= 2); const actors = halloweenPeople.filter(p => p.actorCount >= 2); const writers = halloweenPeople.filter(p => p.writerCount >= 2); LOG('- Directors with 2+ movies:', directors.length); LOG('- Actors with 2+ movies:', actors.length); LOG('- Writers with 2+ movies:', writers.length); } } }; // Debug function for movie genres cache window.debugMovieGenres = async function() { LOG('=== Movie Genres Cache Debug ==='); const cache = new window.LocalStorageCache(); const movieGenres = cache.get('movieGenres'); if (movieGenres && movieGenres.length > 0) { LOG(`Cached genres: ${movieGenres.length}`); LOG('Sample genres:', movieGenres.slice(0, 5).map(g => ({ name: g.Name, id: g.Id }))); // Test genre lookups const horrorId = await getGenreId('Horror'); const thrillerId = await getGenreId('Thriller'); LOG('Horror genre ID:', horrorId); LOG('Thriller genre ID:', thrillerId); } else { LOG('No genres in cache, fetching...'); const genres = await fetchAndCacheMovieGenres(); LOG(`Fetched and cached ${genres.length} genres`); } // Show cache age const cacheAge = cache.getCacheAge('movieGenres'); if (cacheAge !== null) { LOG(`Cache age: ${cacheAge.toFixed(2)} hours`); } }; // Debug function for discovery sections window.debugDiscoverySections = function() { LOG('=== Discovery Sections Debug ==='); LOG('Configuration - enableDiscovery:', enableDiscovery); LOG('Configuration - enableInfiniteScroll:', enableInfiniteScroll); LOG('Configuration - minPeopleAppearances:', minPeopleAppearances); LOG('Configuration - minGenreMovieCount:', minGenreMovieCount); LOG('Movies top people loaded:', moviesTopPeople !== null); LOG('People cache complete:', isPeopleCacheComplete); LOG('Is initializing people cache:', isInitializingPeopleCache); LOG('Rendered sections count:', renderedSections.size); LOG('Rendered sections:', Array.from(renderedSections)); LOG('Is generating discovery sections:', isGeneratingDiscoverySections); LOG('Preloaded sections count:', preloadedSections.length); LOG('Preloaded section elements count:', preloadedSectionElements.length); LOG('Is preloading sections:', isPreloadingSections); if (moviesTopPeople) { LOG('Top people data:'); LOG('- Actors:', moviesTopPeople.actors.length); LOG('- Directors:', moviesTopPeople.directors.length); LOG('- Writers:', moviesTopPeople.writers.length); if (moviesTopPeople.actors.length > 0) { LOG('Sample actor:', moviesTopPeople.actors[0]); } if (moviesTopPeople.directors.length > 0) { LOG('Sample director:', moviesTopPeople.directors[0]); } if (moviesTopPeople.writers.length > 0) { LOG('Sample writer:', moviesTopPeople.writers[0]); } } // Test genre availability const cache = new window.LocalStorageCache(); const movieGenres = cache.get('movieGenres'); LOG('Available genres:', movieGenres ? movieGenres.length : 0); if (movieGenres && movieGenres.length > 0) { const genresWithEnoughMovies = movieGenres.filter(genre => (genre.MovieCount || 0) >= minGenreMovieCount ); LOG(`Genres with ${minGenreMovieCount}+ movies:`, genresWithEnoughMovies.length); if (genresWithEnoughMovies.length > 0) { LOG('Sample qualifying genres:', genresWithEnoughMovies.slice(0, 5).map(g => ({ name: g.Name, movieCount: g.MovieCount }))); } } // Test watchlist data const watchlistData = getWatchlistData(); LOG('Watchlist data available:', watchlistData.length, 'items'); // Test movie history const movieHistory = getMovieHistoryData(); LOG('Movie history available:', movieHistory.length, 'items'); // Test favorites fetchFavoriteItems('Movie').then(favorites => { LOG('Favorite movies available:', favorites.length, 'items'); }); }; // Function to manually trigger discovery sections rendering from buffer window.generateDiscoverySections = async function() { LOG('Manually triggering discovery sections rendering from buffer...'); const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); if (homeSectionsContainer) { const result = await renderNextDiscoveryGroup(homeSectionsContainer); LOG('Discovery sections rendering result:', result); } else { LOG('Home sections container not found'); } }; // Function to manually trigger preloading window.preloadDiscoverySections = async function() { LOG('Manually triggering discovery sections preloading...'); await preloadNextSections(); LOG('Preloading completed. Preloaded sections:', preloadedSections.length); }; // Function to manually continue loading people data window.continueLoadingPeopleData = async function() { LOG('Manually continuing people data loading...'); if (isInitializingPeopleCache) { LOG('People cache initialization already in progress'); return; } isInitializingPeopleCache = true; try { const result = await fetchTopPeople(); if (result) { moviesTopPeople = result; isPeopleCacheComplete = result.isComplete || false; LOG('People data loading result:', result.isComplete ? 'Complete' : 'Partial'); } } finally { isInitializingPeopleCache = false; } }; // Function to manually refresh movie genres cache window.refreshMovieGenres = async function() { LOG('Manually refreshing movie genres cache...'); const cache = new window.LocalStorageCache(); cache.clear('movieGenres'); const genres = await fetchAndCacheMovieGenres(); LOG(`Refreshed cache with ${genres.length} genres`); return genres; }; // Function to manually unhide preloaded sections for testing window.unhidePreloadedSections = function() { LOG('Manually unhiding preloaded sections...'); const hiddenSections = document.querySelectorAll('[data-preloaded="true"]'); LOG(`Found ${hiddenSections.length} hidden preloaded sections`); let unhiddenCount = 0; hiddenSections.forEach(section => { section.style.display = ''; section.removeAttribute('data-preloaded'); unhiddenCount++; }); LOG(`Unhid ${unhiddenCount} preloaded sections`); return unhiddenCount; }; // Function to test new movies section window.testNewMovies = async function() { LOG('Testing New Movies section...'); const movies = await fetchNewMovies(); LOG(`Found ${movies.length} new movies:`, movies.map(m => ({ name: m.Name, premiereDate: m.PremiereDate }))); return movies; }; // Function to test new episodes section window.testNewEpisodes = async function() { LOG('Testing New Episodes section...'); const episodes = await fetchNewEpisodes(); LOG(`Found ${episodes.length} new episodes from last 7 days (after deduplication):`, episodes.map(e => ({ name: e.Name, series: e.SeriesName, episode: `S${e.ParentIndexNumber?.toString().padStart(2,'0') ?? '--'}E${e.IndexNumber?.toString().padStart(2,'0') ?? '--'}`, premiereDate: e.PremiereDate }))); // Group by series to show deduplication effect const seriesGroups = {}; episodes.forEach(episode => { const series = episode.SeriesName || 'Unknown'; if (!seriesGroups[series]) { seriesGroups[series] = []; } seriesGroups[series].push(episode); }); LOG('Episodes grouped by series:', Object.keys(seriesGroups).map(series => ({ series: series, episodeCount: seriesGroups[series].length, episodes: seriesGroups[series].map(e => `S${e.ParentIndexNumber?.toString().padStart(2,'0') ?? '--'}E${e.IndexNumber?.toString().padStart(2,'0') ?? '--'}`) }))); return episodes; }; // Function to test deduplication logic with sample data window.testDeduplication = function() { LOG('Testing episode deduplication logic...'); // Create sample episodes for testing const sampleEpisodes = [ { Name: 'Episode 1', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 1, ParentIndexNumber: 1 }, { Name: 'Episode 2', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 2, ParentIndexNumber: 1 }, { Name: 'Episode 3', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 3, ParentIndexNumber: 1 }, { Name: 'Episode 1', SeriesName: 'Test Series B', PremiereDate: '2024-01-16', IndexNumber: 1, ParentIndexNumber: 1 }, { Name: 'Episode 1', SeriesName: 'Test Series A', PremiereDate: '2024-01-17', IndexNumber: 1, ParentIndexNumber: 2 }, { Name: 'Episode 2', SeriesName: 'Test Series A', PremiereDate: '2024-01-17', IndexNumber: 2, ParentIndexNumber: 2 } ]; LOG('Original episodes:', sampleEpisodes.map(e => ({ series: e.SeriesName, episode: `S${e.ParentIndexNumber}E${e.IndexNumber}`, date: e.PremiereDate }))); const deduplicated = deduplicateEpisodesBySeriesAndDate(sampleEpisodes); LOG('After deduplication:', deduplicated.map(e => ({ series: e.SeriesName, episode: `S${e.ParentIndexNumber}E${e.IndexNumber}`, date: e.PremiereDate }))); LOG(`Deduplication result: ${sampleEpisodes.length} episodes → ${deduplicated.length} episodes`); return deduplicated; }; window.discoveryBuffer = function() { return discoveryBuffer; }; LOG('Home screen functionality initialized'); })();