From 138a3be8a3c5e47916475df7b99d3e71f4f921d3 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Mon, 1 Dec 2025 16:00:14 -0500 Subject: [PATCH] Support Additional Options for custom Home Screen sections and Spotlight Grouping --- scripts/configuration.js | 409 ++++++++++++++++++++++++---- scripts/homeScreen.js | 556 ++++++++++++++++++++------------------- 2 files changed, 649 insertions(+), 316 deletions(-) diff --git a/scripts/configuration.js b/scripts/configuration.js index 26c52b1..a2a4b40 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -27,6 +27,66 @@ { key: 'writtenByWriterRecentlyWatched', label: 'Written by [Writer] because you recently watched [Movie]', defaultName: 'Written by [Writer] because you recently watched [Movie]' } ]; + const SUPPORTED_CUSTOM_SECTION_PARAMS = { + // Booleans + hasThemeSong: { label: 'Has Theme Song', type: 'boolean' }, + hasThemeVideo: { label: 'Has Theme Video', type: 'boolean' }, + hasSubtitles: { label: 'Has Subtitles', type: 'boolean' }, + hasSpecialFeature: { label: 'Has Special Feature', type: 'boolean' }, + hasTrailer: { label: 'Has Trailer', type: 'boolean' }, + hasParentalRating: { label: 'Has Parental Rating', type: 'boolean' }, + isHd: { label: 'Is HD', type: 'boolean' }, + is4K: { label: 'Is 4K', type: 'boolean' }, + isMissing: { label: 'Is Missing', type: 'boolean' }, + isUnaired: { label: 'Is Unaired', type: 'boolean' }, + hasOverview: { label: 'Has Overview', type: 'boolean' }, + isFavorite: { label: 'Is Favorite', type: 'boolean' }, + isPlayed: { label: 'Is Played', type: 'boolean' }, + hasOfficialRating: { label: 'Has Official Rating', type: 'boolean' }, + recursive: { label: 'Recursive', type: 'boolean', default: true }, + is3D: { label: 'Is 3D', type: 'boolean' }, + isSeries: { label: 'Is Series (Live TV)', type: 'boolean' }, + + // Strings + maxOfficialRating: { label: 'Max Official Rating', type: 'string', hint: 'e.g., PG-13, TV-MA' }, + minOfficialRating: { label: 'Min Official Rating', type: 'string', hint: 'e.g., PG, TV-14' }, + person: { label: 'Person Name', type: 'string' }, + nameStartsWith: { label: 'Name Starts With', type: 'string' }, + nameStartsWithOrGreater: { label: 'Name Starts With Or Greater', type: 'string' }, + nameLessThan: { label: 'Name Less Than', type: 'string' }, + + // Numbers + indexNumber: { label: 'Index Number', type: 'number' }, + parentIndexNumber: { label: 'Parent Index Number', type: 'number' }, + minCommunityRating: { label: 'Min Community Rating', type: 'number' }, + minCriticRating: { label: 'Min Critic Rating', type: 'number' }, + limit: { label: 'Limit', type: 'number' }, + + // Dates (ISO 8601) + minPremiereDate: { label: 'Min Premiere Date', type: 'date', hint: 'YYYY-MM-DD' }, + maxPremiereDate: { label: 'Max Premiere Date', type: 'date', hint: 'YYYY-MM-DD' }, + minDateLastSaved: { label: 'Min Date Last Saved', type: 'date', hint: 'YYYY-MM-DD' }, + minDateLastSavedForUser: { label: 'Min Date Last Saved For User', type: 'date', hint: 'YYYY-MM-DD' }, + + // Arrays (Comma-separated strings in UI) + locationTypes: { label: 'Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' }, + excludeLocationTypes: { label: 'Exclude Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' }, + excludeItemIds: { label: 'Exclude Item IDs', type: 'array', hint: 'Comma-separated GUIDs' }, + excludeItemTypes: { label: 'Exclude Item Types', type: 'array', hint: 'Movie, Series, Episode, etc.' }, + filters: { label: 'Filters', type: 'array', hint: 'IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes' }, + imageTypes: { label: 'Image Types', type: 'array', hint: 'Primary, Backdrop, Thumb, Logo, etc.' }, + officialRatings: { label: 'Official Ratings', type: 'array', hint: 'PG, PG-13, R, etc.' }, + years: { label: 'Years', type: 'array', hint: 'Comma-separated years' }, + personTypes: { label: 'Person Types', type: 'array', hint: 'Actor, Director, Writer, etc.' }, + studios: { label: 'Studios', type: 'array', hint: 'Pipe-delimited names' }, + artists: { label: 'Artists', type: 'array', hint: 'Pipe-delimited names' }, + albums: { label: 'Albums', type: 'array', hint: 'Pipe-delimited names' }, + seriesStatus: { label: 'Series Status', type: 'array', hint: 'Continuing, Ended, Unreleased' }, + personIds: { label: 'Person IDs', type: 'array', hint: 'Comma-separated GUIDs' }, + studioIds: { label: 'Studio IDs', type: 'array', hint: 'Comma-separated GUIDs' }, + ids: { label: 'Item IDs', type: 'array', hint: 'Comma-separated GUIDs' } + }; + // Check if user is admin async function isAdmin() { try { @@ -919,12 +979,15 @@ // Helper function to build Include Item Types badge field function buildIncludeItemTypesField(prefix, sectionIndex, sanitizedPrefix, currentTypes, sourceType) { - // Available item types (excluding Collections and Playlists unless source type is Parent) - const availableTypes = ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book']; - const parentOnlyTypes = ['BoxSet', 'Playlist']; // Only available when source type is Parent + // All available item types + const allAvailableTypes = [ + 'AggregateFolder', 'Audio', 'AudioBook', 'BasePluginFolder', 'Book', 'BoxSet', 'Channel', + 'ChannelFolderItem', 'CollectionFolder', 'Episode', 'Folder', 'Genre', 'ManualPlaylistsFolder', + 'Movie', 'LiveTvChannel', 'LiveTvProgram', 'MusicAlbum', 'MusicArtist', 'MusicGenre', 'MusicVideo', + 'Person', 'Photo', 'PhotoAlbum', 'Playlist', 'PlaylistsFolder', 'Program', 'Recording', 'Season', + 'Series', 'Studio', 'Trailer', 'TvChannel', 'TvProgram', 'UserRootFolder', 'UserView', 'Video', 'Year' + ]; - // All available types for this source type - const allAvailableTypes = sourceType === 'Parent' ? [...availableTypes, ...parentOnlyTypes] : availableTypes; const selectionLabel = currentTypes.length ? `${currentTypes.length} selected` : 'Select item types'; // Current types as badges @@ -974,14 +1037,91 @@ `; } + // Helper function to build Additional Options Controls (Dropdown + Add Button) + function buildAdditionalOptionsControls(prefix, sectionIndex, sanitizedPrefix) { + // Build dropdown options from supported params + const dropdownOptions = Object.entries(SUPPORTED_CUSTOM_SECTION_PARAMS) + .sort((a, b) => a[1].label.localeCompare(b[1].label)) + .map(([key, meta]) => ``) + .join(''); + + return ` +
+
Additional Options
+
+ + +
+
+ `; + } + + // Helper function to build Additional Options List + function buildAdditionalOptionsList(prefix, sectionIndex, sanitizedPrefix, currentOptions) { + const optionsHtml = (currentOptions || []).map((option, index) => { + const meta = SUPPORTED_CUSTOM_SECTION_PARAMS[option.key] || { label: option.key, type: 'string' }; + const inputId = `${sanitizedPrefix}_additionalOption_${index}`; + + let inputField = ''; + if (meta.type === 'boolean') { + const isChecked = option.value === true || option.value === 'true'; + inputField = buildJellyfinCheckbox(inputId, isChecked, meta.label, { + 'data-key': option.key, + 'class': 'additional-option-value', + 'data-type': 'boolean' + }); + } else { + inputField = ` +
+
${meta.label}
+ +
+ `; + } + + return ` +
+ ${inputField} + +
+ `; + }).join(''); + + return ` +
+
+ ${optionsHtml} +
+
+ `; + } + // Helper function to build Jellyfin-style checkbox function buildJellyfinCheckbox(id, checked, label, dataAttributes = {}) { - const dataAttrs = Object.entries(dataAttributes).map(([key, value]) => `${key}="${value}"`).join(' '); + let className = 'emby-checkbox'; + const otherAttrs = {}; + + for (const [key, value] of Object.entries(dataAttributes)) { + if (key === 'class') { + className += ' ' + value; + } else { + otherAttrs[key] = value; + } + } + + const dataAttrs = Object.entries(otherAttrs).map(([key, value]) => `${key}="${value}"`).join(' '); const checkedAttr = checked ? 'checked' : ''; return `
${buildIncludeItemTypesField(prefix, sectionIndex, sanitizedSectionPrefix, includeItemTypes, type)} + + + ${buildAdditionalOptionsControls(prefix, sectionIndex, sanitizedSectionPrefix)} + + + ${buildAdditionalOptionsList(prefix, sectionIndex, sanitizedSectionPrefix, additionalQueryOptions)} `; } @@ -1294,7 +1441,8 @@ nameLabel = 'Section Name', nameInputAttributes = {}, includeCardFormat = true, - includeItemLimit = true + includeItemLimit = true, + includeIsPlayed = false } = options; const nameValue = config.name ?? defaultName ?? ''; @@ -1303,6 +1451,12 @@ const sortOrderDirection = config.sortOrderDirection ?? 'Ascending'; const cardFormat = config.cardFormat ?? 'Poster'; const order = config.order ?? 100; + + // IsPlayed logic + // If isPlayed is undefined or null, it means the filter is disabled. + // If it's true/false, the filter is enabled and set to that value. + const isPlayedFilterEnabled = config.isPlayed !== undefined && config.isPlayed !== null; + const isPlayedValue = config.isPlayed === true; // true if played, false if unplayed let html = ''; @@ -1343,10 +1497,29 @@ @@ -1384,6 +1557,22 @@ `; } + if (includeIsPlayed) { + html += ` +
+
+ ${buildJellyfinCheckbox(`${prefix}_isPlayed_enabled`, isPlayedFilterEnabled, 'Filter by Played Status')} + +
+ +
+
+
`; + } + return html; } @@ -1479,7 +1668,7 @@ ${buildJellyfinCheckbox('homeScreen_recentlyReleased_movies_enabled', (recentlyReleased.movies || {}).enabled !== false, 'Enabled')}
- ${createSectionConfiguration('homeScreen_recentlyReleased_movies', recentlyReleased.movies || {}, { includeName: true, defaultName: 'Recently Released Movies', includeSortOrder: false, includeOrder: true })} + ${createSectionConfiguration('homeScreen_recentlyReleased_movies', recentlyReleased.movies || {}, { includeName: true, defaultName: 'Recently Released Movies', includeSortOrder: false, includeOrder: true, includeIsPlayed: true })}
@@ -1496,7 +1685,7 @@ ${buildJellyfinCheckbox('homeScreen_recentlyReleased_episodes_enabled', (recentlyReleased.episodes || {}).enabled !== false, 'Enabled')}
- ${createSectionConfiguration('homeScreen_recentlyReleased_episodes', recentlyReleased.episodes || {}, { includeName: true, defaultName: 'Recently Aired Episodes', includeSortOrder: false, includeOrder: true })} + ${createSectionConfiguration('homeScreen_recentlyReleased_episodes', recentlyReleased.episodes || {}, { includeName: true, defaultName: 'Recently Aired Episodes', includeSortOrder: false, includeOrder: true, includeIsPlayed: true })}
@@ -1515,7 +1704,7 @@
@@ -1581,7 +1770,7 @@
- ${createSectionConfiguration('homeScreen_imdbTop250', imdbTop250, { includeName: true, defaultName: 'IMDb Top 250' })} + ${createSectionConfiguration('homeScreen_imdbTop250', imdbTop250, { includeName: true, defaultName: 'IMDb Top 250', includeIsPlayed: true })}
@@ -1658,7 +1847,11 @@
0.0 = never, 1.0 = always
- ${createSectionConfiguration('homeScreen_discovery', discovery, { includeName: false, includeOrder: false, includeCardFormat: false, includeSortOrder: false, includeItemLimit: false })} +
+ ${buildJellyfinCheckbox('homeScreen_discovery_renderSpotlightAboveMatching', discovery.renderSpotlightAboveMatching === true, 'Spotlight Grouping')} +
Render Spotlight discovery sections alongside the corresponding discovery section (e.g. Action Spotlight above Action Movies).
+
+ ${createSectionConfiguration('homeScreen_discovery', discovery, { includeName: false, includeOrder: false, includeCardFormat: false, includeSortOrder: false, includeItemLimit: false })}
@@ -1668,7 +1861,7 @@ ${DISCOVERY_SECTION_DEFINITIONS.map(section => { const sectionConfig = normalizedDiscoverySections[section.key]; const prefix = `homeScreen_discovery_sectionTypes_${section.key}`; - const configHtml = createSectionConfiguration(prefix, sectionConfig, { includeName: true, includeOrder: true, includeSortOrder: false, defaultName: section.defaultName }); + const configHtml = createSectionConfiguration(prefix, sectionConfig, { includeName: true, includeOrder: true, includeSortOrder: false, defaultName: section.defaultName, includeIsPlayed: true }); const extraFields = section.extras?.minimumItems !== undefined ? `
@@ -1965,6 +2158,17 @@ setupToggleVisibility('#homeScreen_watchAgain_enabled', '#homeScreen_watchAgain_container'); setupToggleVisibility('#homeScreen_discovery_enabled', '#homeScreen_discovery_container'); + // Discovery Sections IsPlayed Toggles + ['recentlyReleased_movies', 'recentlyReleased_episodes', 'trending', 'popularTVNetworks', 'upcoming', 'imdbTop250'].forEach(key => { + setupToggleVisibility(`#homeScreen_${key}_isPlayed_enabled`, `#homeScreen_${key}_isPlayed_options`); + }); + + // Dynamic Discovery Sections IsPlayed Toggles + DISCOVERY_SECTION_DEFINITIONS.forEach(section => { + const prefix = `homeScreen_discovery_sectionTypes_${section.key}`; + setupToggleVisibility(`#${prefix}_isPlayed_enabled`, `#${prefix}_isPlayed_options`); + }); + // Setup event listeners for section management // Add section to seasonal season modalInstance.dialogContent.addEventListener('click', (e) => { @@ -2356,6 +2560,76 @@ enabledSpan.textContent = enabledCheckbox.checked ? 'Enabled' : 'Disabled'; }); } + + // Setup additional options listeners + const additionalOptionsControls = sectionItem.querySelector(`.${prefix}_section_additionalOptions_controls[data-section-index="${sectionIndex}"]`); + const additionalOptionsListContainer = sectionItem.querySelector(`.${prefix}_section_additionalOptions_list_container[data-section-index="${sectionIndex}"]`); + + if (additionalOptionsControls) { + const addBtn = additionalOptionsControls.querySelector('.add-additional-option'); + const select = additionalOptionsControls.querySelector('.additional-option-select'); + const list = additionalOptionsListContainer ? additionalOptionsListContainer.querySelector('.additional-options-list') : null; + + if (addBtn && select && list) { + addBtn.addEventListener('click', () => { + const key = select.value; + if (!key) return; + + const meta = SUPPORTED_CUSTOM_SECTION_PARAMS[key]; + if (!meta) return; + + const index = list.children.length; + const sanitizedPrefix = `${prefix}_section_${sanitizedIndex}`; + const inputId = `${sanitizedPrefix}_additionalOption_${Date.now()}_${index}`; + + let inputField = ''; + if (meta.type === 'boolean') { + inputField = buildJellyfinCheckbox(inputId, meta.default === true, meta.label, { + 'data-key': key, + 'class': 'additional-option-value', + 'data-type': 'boolean' + }); + } else { + inputField = ` +
+
${meta.label}
+ +
+ `; + } + + const row = document.createElement('div'); + row.className = 'additional-option-row'; + row.style.cssText = 'display: flex; align-items: flex-end; gap: 0.5em; margin-top: 0.5em; padding: 0.5em; background: rgba(255,255,255,0.05); border-radius: 4px;'; + row.innerHTML = ` + ${inputField} + + `; + + // Add delete listener for new row + row.querySelector('.remove-additional-option').addEventListener('click', () => { + row.remove(); + }); + + list.appendChild(row); + + // Reset select + select.value = ''; + }); + } + } + + // Add listeners for existing delete buttons + if (additionalOptionsListContainer) { + additionalOptionsListContainer.querySelectorAll('.remove-additional-option').forEach(btn => { + btn.addEventListener('click', (e) => { + const row = e.target.closest('.additional-option-row'); + if (row) row.remove(); + }); + }); + } } function deleteSection(prefix, sectionIndex, isSeasonalNested) { @@ -4351,6 +4625,28 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; if (includeItemTypes.length === 0 && type !== 'Parent') { includeItemTypes = ['Movie']; } + + const additionalQueryOptions = []; + const additionalOptionsContainer = sectionItem.querySelector(`.${prefix}_section_additionalOptions_list_container[data-section-index="${sectionIndex}"]`); + if (additionalOptionsContainer) { + const optionRows = additionalOptionsContainer.querySelectorAll('.additional-options-list .additional-option-row'); + optionRows.forEach(row => { + const input = row.querySelector('.additional-option-value'); + if (input) { + const key = input.getAttribute('data-key'); + const type = input.getAttribute('data-type'); + let value; + if (type === 'boolean') { + value = input.checked; + } else { + value = input.value; + } + if (key) { + additionalQueryOptions.push({ key, value }); + } + } + }); + } return { id: sectionIdAttr, @@ -4366,7 +4662,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; spotlight: spotlight, discoveryEnabled: discoveryEnabled, searchTerm: searchTerm, - includeItemTypes: includeItemTypes + includeItemTypes: includeItemTypes, + additionalQueryOptions: additionalQueryOptions }; } @@ -4377,8 +4674,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } const userId = apiClient.getCurrentUserId(); - const serverAddress = apiClient.serverAddress(); - const token = apiClient.accessToken(); const type = sectionConfig.type || 'Genre'; const searchTerm = sectionConfig.searchTerm?.trim() || ''; const includeItemTypes = Array.isArray(sectionConfig.includeItemTypes) && sectionConfig.includeItemTypes.length > 0 @@ -4389,8 +4684,20 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const itemLimit = parseInt(sectionConfig.itemLimit, 10) || 16; const sources = (sectionConfig.source || '').split(',').map(s => s.trim()).filter(Boolean); const fields = 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear,ImageTags,BackdropImageTags,ParentBackdropImageTags,ParentThumbImageTag,SeriesPrimaryImageTag'; - const headers = { 'Authorization': `MediaBrowser Token="${token}"` }; + // Parse additional query options + const additionalOptions = {}; + if (Array.isArray(sectionConfig.additionalQueryOptions)) { + sectionConfig.additionalQueryOptions.forEach(option => { + if (option && option.key) { + let value = option.value; + if (value === 'true' || value === true) value = true; + else if (value === 'false' || value === false) value = false; + additionalOptions[option.key] = value; + } + }); + } + const dedupeAndLimit = (items = []) => { let working = Array.isArray(items) ? [...items] : []; if (sortOrder === 'Random') { @@ -4409,42 +4716,42 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; return limited; }; - const fetchJson = async (url) => { - const response = await fetch(url, { headers }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - return response.json(); - }; - try { + const queryBase = { + IncludeItemTypes: includeItemTypes, + Recursive: true, + Fields: fields, + SortBy: sortOrder, + SortOrder: sortOrderDirection, + ...additionalOptions + }; + if (type === 'Genre' || type === 'Tag') { if (sources.length === 0) { return []; } const paramName = type === 'Genre' ? 'Genres' : 'Tags'; - const valueParam = sources.map(s => encodeURIComponent(s)).join('|'); - const url = `${serverAddress}/Items?userId=${userId}&${paramName}=${valueParam}` + - `&IncludeItemTypes=${encodeURIComponent(includeItemTypes)}` + - `&Recursive=true&Fields=${encodeURIComponent(fields)}` + - `&Limit=${itemLimit}&SortBy=${encodeURIComponent(sortOrder)}&SortOrder=${encodeURIComponent(sortOrderDirection)}` + - (searchTerm ? `&SearchTerm=${encodeURIComponent(searchTerm)}` : ''); - const data = await fetchJson(url); - return dedupeAndLimit(data.Items || []); + // Genres use pipe delimiter usually, tags comma? + // homeScreen.js uses pipe for Genres, comma for Tags. + // apiClient handles array/string? usually string. + const delimiter = type === 'Genre' ? '|' : ','; + const valueParam = sources.join(delimiter); + + const params = { + ...queryBase, + [paramName]: valueParam, + Limit: itemLimit + }; + if (searchTerm) params.SearchTerm = searchTerm; + + const response = await apiClient.getItems(userId, params); + return dedupeAndLimit(response.Items || []); } if (type === 'Collection' || type === 'Playlist' || type === 'Parent') { const existingIds = new Set(); let collected = []; - const queryBase = { - IncludeItemTypes: includeItemTypes, - Recursive: true, - Fields: fields, - SortBy: sortOrder, - SortOrder: sortOrderDirection - }; - const runQuery = async (params) => { const response = await apiClient.getItems(userId, params); if (response?.Items?.length) { @@ -4562,6 +4869,17 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; config.order = parseInt(document.getElementById(`${prefix}_order`)?.value || '100', 10); } + // Check for IsPlayed configuration + const isPlayedEnabledCheckbox = document.getElementById(`${prefix}_isPlayed_enabled`); + if (isPlayedEnabledCheckbox) { + if (isPlayedEnabledCheckbox.checked) { + const isPlayedValueSelect = document.getElementById(`${prefix}_isPlayed_value`); + config.isPlayed = isPlayedValueSelect?.value === 'true'; + } else { + config.isPlayed = null; + } + } + return config; } @@ -4683,6 +5001,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; minPeopleAppearances: parseInt(document.getElementById('homeScreen_discovery_minPeopleAppearances')?.value || '10'), minGenreMovieCount: parseInt(document.getElementById('homeScreen_discovery_minGenreMovieCount')?.value || '50'), spotlightDiscoveryChance: parseFloat(document.getElementById('homeScreen_discovery_spotlightDiscoveryChance')?.value || '0.5'), + renderSpotlightAboveMatching: document.getElementById('homeScreen_discovery_renderSpotlightAboveMatching')?.checked === true, defaultItemLimit: parseInt(document.getElementById('homeScreen_discovery_itemLimit')?.value || '16'), defaultSortOrder: document.getElementById('homeScreen_discovery_sortOrder')?.value || 'Random', defaultCardFormat: document.getElementById('homeScreen_discovery_cardFormat')?.value || 'Poster', diff --git a/scripts/homeScreen.js b/scripts/homeScreen.js index 4f284e2..0142c79 100644 --- a/scripts/homeScreen.js +++ b/scripts/homeScreen.js @@ -695,6 +695,17 @@ 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) { @@ -795,14 +806,25 @@ 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 = items; + let sortedItems = filteredItems; if (sortOrder === 'Random') { - sortedItems = [...items].sort(() => Math.random() - 0.5); + sortedItems = [...filteredItems].sort(() => Math.random() - 0.5); } else if (window.cardBuilder && typeof window.cardBuilder.sortItems === 'function') { - sortedItems = window.cardBuilder.sortItems(items, sortOrder, sortDirection); + sortedItems = window.cardBuilder.sortItems(filteredItems, sortOrder, sortDirection); } const limit = parseDiscoveryNumber( @@ -867,74 +889,6 @@ /************ Helpers ************/ - /** - * Fetches item data from Jellyfin API to determine type - * @param {string} itemId - The item ID to fetch - * @returns {Promise} - Item data with type information - */ - async function fetchItemData(itemId) { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - const url = `${serverUrl}/Items/${itemId}`; - - 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 item data for ${itemId}:`, err); - return null; - } - } - - function buildParams(params) { - return Object.entries(params) - .map(([key, value]) => `${key}=${value}`) - .join('&'); - } - - /** - * Fetches playlist data from Jellyfin API - * @param {string} playlistId - The playlist ID to fetch - * @returns {Promise} - Playlist data with ItemIds array - */ - async function fetchPlaylistData(playlistId, params = {}) { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - const url = `${serverUrl}/Playlists/${playlistId}/Items?${buildParams(params)}`; - - 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 playlist data for ${playlistId}:`, err); - return { ItemIds: [] }; - } - } - /** * Fetches collection data from Jellyfin API * @param {string} collectionId - The collection ID to fetch @@ -967,91 +921,6 @@ } } - /** - * Generates the view more URL for a playlist or collection - * @param {string} itemId - The item ID - * @param {string} itemType - The item type ('Playlist' or 'BoxSet') - * @returns {string} - The complete URL for the item page - */ - function generateViewMoreUrl(itemId, itemType) { - const apiClient = window.ApiClient; - const serverId = apiClient.serverId(); - - if (itemType === 'BoxSet') { - // For collections, use the collection page - return `${apiClient._serverAddress}/web/#/details?id=${itemId}&serverId=${serverId}`; - } else { - // For playlists, use the list page - return `#/list.html?parentId=${itemId}&serverId=${serverId}`; - } - } - - /** - * Fetches genre items from Jellyfin API - * @param {string} genreId - The genre ID - * @returns {Promise} - Array of item IDs - */ - async function fetchGenreItems(genreId) { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - - // First get genre name - const genreData = await fetchItemData(genreId); - if (!genreData) return []; - - const genreName = genreData.Name; - - const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genreName)}&Recursive=true&Fields=ItemCounts`; - - 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 { Items: data.Items || [] }; - } catch (err) { - ERR(`Failed to fetch genre items for ${genreName}:`, err); - return { Items: [] }; - } - } - - /** - * Fetches studio items from Jellyfin API - * @param {string} studioId - The studio ID - * @returns {Promise} - Array of item IDs - */ - async function fetchStudioItems(studioId) { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - - const url = `${serverUrl}/Items?userId=${userId}&StudioIds=${studioId}&IncludeItemTypes=Series&Recursive=true&Fields=ItemCounts`; - - 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 { Items: data.Items || [] }; - } catch (err) { - ERR(`Failed to fetch studio items for ${studioId}:`, err); - return { Items: [] }; - } - } - async function loadItemsForSectionConfig(sectionConfig) { const apiClient = window.ApiClient; const serverAddress = apiClient.serverAddress(); @@ -1066,6 +935,26 @@ 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); @@ -1075,20 +964,17 @@ } try { - // Build common query parameters - const commonParams = { + // Base parameters that apply to all queries + // Use defaults if not overridden by additionalOptions + const baseParams = { userId: userId, - IncludeItemTypes: includeItemTypes, Recursive: true, - Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount', - Limit: itemLimit, - SortBy: sortOrder, - SortOrder: sortOrderDirection + ...additionalOptions }; - // Add search term if provided - if (searchTerm) { - commonParams.SearchTerm = searchTerm; + // Add search term if provided and not already in additional options + if (searchTerm && !baseParams.SearchTerm) { + baseParams.SearchTerm = searchTerm; } if (type === 'Genre') { @@ -1097,7 +983,12 @@ const options = { Genres: genresParam, - ...commonParams + 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); @@ -1108,7 +999,12 @@ const options = { Tags: tagsParam, - ...commonParams + 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); @@ -1125,13 +1021,10 @@ Fields: 'ItemCounts,Overview,Taglines', Limit: itemLimit, SortBy: sortOrder, - SortOrder: sortOrderDirection + SortOrder: sortOrderDirection, + ...baseParams }; - if (searchTerm) { - queryParams.SearchTerm = searchTerm; - } - const collectionData = await window.apiHelper.getItems(queryParams, true); if (collectionData.Items && collectionData.Items.length > 0) { collectionData.Items.forEach(item => { @@ -1164,13 +1057,10 @@ Fields: 'Overview,Taglines', Limit: itemLimit, SortBy: sortOrder, - SortOrder: sortOrderDirection + SortOrder: sortOrderDirection, + ...baseParams }; - if (searchTerm) { - queryParams.SearchTerm = searchTerm; - } - const playlistData = await window.apiHelper.getItems(queryParams, true); if (playlistData.Items && playlistData.Items.length > 0) { playlistData.Items.forEach(item => { @@ -1201,17 +1091,13 @@ try { const queryParams = { IncludeItemTypes: includeItemTypes, - Recursive: true, Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear', Limit: itemLimit, SortBy: sortOrder, - SortOrder: sortOrderDirection + SortOrder: sortOrderDirection, + ...baseParams }; - if (searchTerm) { - queryParams.SearchTerm = searchTerm; - } - const response = await window.apiHelper.getItems(queryParams, true); if (response.Items && response.Items.length > 0) { allItems = response.Items; @@ -1226,17 +1112,13 @@ const queryParams = { ParentId: parentId, IncludeItemTypes: includeItemTypes, - Recursive: true, Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear', Limit: itemLimit * sources.length, SortBy: sortOrder, - SortOrder: sortOrderDirection + SortOrder: sortOrderDirection, + ...baseParams }; - if (searchTerm) { - queryParams.SearchTerm = searchTerm; - } - const parentData = await window.apiHelper.getItems(queryParams, true); if (parentData.Items && parentData.Items.length > 0) { parentData.Items.forEach(item => { @@ -2060,19 +1942,9 @@ const data = await response.json(); const allItems = data.Items || []; - // Filter out played items - const unplayedItems = allItems.filter(item => !item.UserData?.Played); - - // If we have enough unplayed items, randomly select 16 - if (unplayedItems.length >= 16) { - // Shuffle array and take first 16 - const shuffled = [...unplayedItems].sort(() => Math.random() - 0.5); - return shuffled.slice(0, 16); - } - - // If we don't have enough unplayed items, return what we have + // Return all items, filtering will happen in applyDiscoverySectionOrdering if needed // (shuffled to avoid always showing the same items) - return [...unplayedItems].sort(() => Math.random() - 0.5); + return [...allItems].sort(() => Math.random() - 0.5); } catch (err) { ERR(`Failed to fetch similar content for ${type} ${itemId}:`, err); @@ -2402,9 +2274,12 @@ // 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}`; + 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}"` @@ -2517,7 +2392,7 @@ 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&userId=${userId}&Limit=32`; + 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: { @@ -2574,6 +2449,11 @@ MaxPremiereDate: today } + const isPlayedFilter = getIsPlayedFilter(newMoviesConfig); + if (isPlayedFilter) { + options.Filters = isPlayedFilter; + } + const data = await window.apiHelper.getItems(options, true); return data.Items || []; } @@ -2604,6 +2484,11 @@ Fields: 'PremiereDate,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds,Path' } + const isPlayedFilter = getIsPlayedFilter(newEpisodesConfig); + if (isPlayedFilter) { + options.Filters = isPlayedFilter; + } + const data = await window.apiHelper.getItems(options, true); const episodes = data.Items || []; @@ -2912,7 +2797,12 @@ const libraries = await ApiClient.getItems(); const parentIds = libraries.Items.filter(lib => lib.CollectionType === 'tvshows')?.map(lib => lib.Id); - const 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(',')}`; + 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 || []; @@ -3060,11 +2950,18 @@ // Fetch all movies and match by IMDb ID const userId = ApiClient.getCurrentUserId(); - const allMoviesResponse = await ApiClient.getItems(userId, { + 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 = []; @@ -3325,11 +3222,16 @@ 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']; @@ -3337,6 +3239,45 @@ 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 @@ -3345,7 +3286,14 @@ (async () => { const sectionConfig = getDiscoverySectionSettings('genreMovies'); if (!sectionConfig || !sectionConfig.enabled) return null; - const randomGenre = await getRandomGenre(); + + let randomGenre = null; + if (forcedGenre) { + randomGenre = forcedGenre; + } else { + randomGenre = await getRandomGenre(); + } + if (randomGenre) { const genreData = await preloadGenreSection( randomGenre, @@ -3490,37 +3438,47 @@ (async () => { const sectionConfig = getDiscoverySectionSettings('studioShows'); if (!sectionConfig || !sectionConfig.enabled) return null; - 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) { - const randomNetwork = availableNetworks[Math.floor(Math.random() * availableNetworks.length)]; - const items = await fetchItemsByStudio(randomNetwork.Id); - const viewMoreUrl = `#/list.html?studioId=${randomNetwork.Id}&serverId=${ApiClient.serverId()}`; + + 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 (items && items.length > 0) { + if (availableNetworks.length > 0) { + randomNetwork = availableNetworks[Math.floor(Math.random() * availableNetworks.length)]; renderedStudios.add(randomNetwork.Id); - - const orderedItems = applyDiscoverySectionOrdering(items, sectionConfig); - if (orderedItems.length === 0) { - return null; - } - - return { - type: 'studio', - sectionKey: 'studioShows', - config: sectionConfig, - data: randomNetwork, - items: orderedItems, - viewMoreUrl: viewMoreUrl - }; } } } + + 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; })(), @@ -3752,19 +3710,30 @@ if (!sectionConfig || !sectionConfig.enabled) return null; try { - // Get qualifying genres (cached) - const allQualifyingGenres = await getQualifyingGenres(); - if (allQualifyingGenres.length === 0) return null; + let selectedGenre = null; - // Filter out already rendered genres - const availableGenres = allQualifyingGenres.filter(genre => - !renderedGenres.has(genre.Id) - ); - - if (availableGenres.length === 0) return null; - - // Pick random genre - const selectedGenre = availableGenres[Math.floor(Math.random() * availableGenres.length)]; + 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(), { @@ -3780,9 +3749,6 @@ const movies = moviesResponse.Items || []; if (movies.length === 0) return null; - // Track rendered genre - renderedGenres.add(selectedGenre.Id); - return { type: 'spotlight-genre', sectionKey: 'spotlightGenre', @@ -3804,19 +3770,30 @@ if (!sectionConfig || !sectionConfig.enabled) return null; try { - // 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 - const selectedNetwork = availableNetworks[Math.floor(Math.random() * availableNetworks.length)]; + 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(), { @@ -3832,9 +3809,6 @@ const shows = showsResponse.Items || []; if (shows.length === 0) return null; - // Track rendered network - renderedNetworks.add(selectedNetwork.Id); - return { type: 'spotlight-network', sectionKey: 'spotlightNetwork', @@ -3951,16 +3925,56 @@ // 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) => { - const orderA = typeof a?.order === 'number' ? a.order : DISCOVERY_ORDER; - const orderB = typeof b?.order === 'number' ? b.order : DISCOVERY_ORDER; + // 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; @@ -4384,7 +4398,7 @@ const userId = apiClient.getCurrentUserId(); const serverId = apiClient.serverId(); - const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genre.Name)}&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=${limit}`; + 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: { @@ -4426,7 +4440,7 @@ if (items.length === 0) return null; return { - items: items.slice(0, limit), + items: items, // Return all items, let render logic handle limit hasFullItems: true }; @@ -4447,7 +4461,7 @@ if (items.length === 0) return null; return { - items: items.slice(0, limit), + items: items, // Return all items, let render logic handle limit hasFullItems: true };