mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Support Additional Options for custom Home Screen sections and Spotlight Grouping
This commit is contained in:
+364
-45
@@ -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]) => `<option value="${key}">${meta.label}</option>`)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="${prefix}_section_additionalOptions_controls" data-section-index="${sectionIndex}" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; position: static;">
|
||||
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Additional Options</div>
|
||||
<div style="display: flex; gap: 0.5em; align-items: center;">
|
||||
<select class="fld emby-select-withcolor emby-select additional-option-select" style="flex: 1; margin: 0; min-width: 0;">
|
||||
<option value="">Select an option to add...</option>
|
||||
${dropdownOptions}
|
||||
</select>
|
||||
<button type="button" class="raised emby-button add-additional-option" style="background: rgba(0, 164, 220, 0.2); min-width: auto; padding: 0.5em 1em;">
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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 = `
|
||||
<div style="flex: 1;">
|
||||
<div class="listItemBodyText" style="margin-bottom: 0.25em;">${meta.label}</div>
|
||||
<input type="text" id="${inputId}" class="fld emby-input additional-option-value" value="${option.value || ''}" data-key="${option.key}" data-type="${meta.type}" placeholder="${meta.hint || ''}" style="width: 100%;">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="additional-option-row" style="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;">
|
||||
${inputField}
|
||||
<button type="button" class="remove-additional-option" style="background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0.25em; display: flex; align-items: center;" title="Remove Option">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="${prefix}_section_additionalOptions_list_container" data-section-index="${sectionIndex}" style="margin-top: 0.75em;">
|
||||
<div class="additional-options-list">
|
||||
${optionsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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 `
|
||||
<div class="checkboxContainer">
|
||||
<label class="emby-checkbox-label">
|
||||
<input is="emby-checkbox" type="checkbox" id="${id}" class="emby-checkbox" data-embycheckbox="true" ${checkedAttr} ${dataAttrs}>
|
||||
<input is="emby-checkbox" type="checkbox" id="${id}" class="${className}" data-embycheckbox="true" ${checkedAttr} ${dataAttrs}>
|
||||
<span class="checkboxLabel">${label}</span>
|
||||
<span class="checkboxOutline">
|
||||
<span class="material-icons checkboxIcon checkboxIcon-checked check" aria-hidden="true"></span>
|
||||
@@ -1008,6 +1148,7 @@
|
||||
const discoveryEnabled = section.discoveryEnabled === true;
|
||||
const searchTerm = section.searchTerm || '';
|
||||
const includeItemTypes = Array.isArray(section.includeItemTypes) ? section.includeItemTypes : (section.includeItemTypes ? section.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean) : (type === 'Parent' ? [] : ['Movie']));
|
||||
const additionalQueryOptions = section.additionalQueryOptions || [];
|
||||
|
||||
// Determine source field based on type
|
||||
let sourceFieldHtml = '';
|
||||
@@ -1134,7 +1275,13 @@
|
||||
</div>
|
||||
<!-- Include Item Types field -->
|
||||
${buildIncludeItemTypesField(prefix, sectionIndex, sanitizedSectionPrefix, includeItemTypes, type)}
|
||||
|
||||
<!-- Additional Options Controls -->
|
||||
${buildAdditionalOptionsControls(prefix, sectionIndex, sanitizedSectionPrefix)}
|
||||
</div>
|
||||
|
||||
<!-- Additional Options List -->
|
||||
${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 @@
|
||||
<select id="${prefix}_sortOrder" class="fld emby-select-withcolor emby-select" style="width: 100%; max-width: 200px;">
|
||||
<option value="Random" ${sortOrder === 'Random' ? 'selected' : ''}>Random</option>
|
||||
<option value="PremiereDate" ${sortOrder === 'PremiereDate' ? 'selected' : ''}>Premiere Date</option>
|
||||
<option value="CriticRating" ${sortOrder === 'CriticRating' ? 'selected' : ''}>Critic Rating</option>
|
||||
<option value="CommunityRating" ${sortOrder === 'CommunityRating' ? 'selected' : ''}>Community Rating</option>
|
||||
<option value="SortTitle" ${sortOrder === 'SortTitle' ? 'selected' : ''}>Sort Title</option>
|
||||
<option value="DateCreated" ${sortOrder === 'DateCreated' ? 'selected' : ''}>Date Created</option>
|
||||
<option value="DateAdded" ${sortOrder === 'DateAdded' ? 'selected' : ''}>Date Added</option>
|
||||
<option value="SortName" ${sortOrder === 'SortName' ? 'selected' : ''}>Sort Name</option>
|
||||
<option value="Name" ${sortOrder === 'Name' ? 'selected' : ''}>Name</option>
|
||||
<option value="CommunityRating" ${sortOrder === 'CommunityRating' ? 'selected' : ''}>Community Rating</option>
|
||||
<option value="CriticRating" ${sortOrder === 'CriticRating' ? 'selected' : ''}>Critic Rating</option>
|
||||
<option value="OfficialRating" ${sortOrder === 'OfficialRating' ? 'selected' : ''}>Official Rating</option>
|
||||
<option value="ProductionYear" ${sortOrder === 'ProductionYear' ? 'selected' : ''}>Production Year</option>
|
||||
<option value="PlayCount" ${sortOrder === 'PlayCount' ? 'selected' : ''}>Play Count</option>
|
||||
<option value="Runtime" ${sortOrder === 'Runtime' ? 'selected' : ''}>Runtime</option>
|
||||
<option value="Default" ${sortOrder === 'Default' ? 'selected' : ''}>Default</option>
|
||||
<option value="AiredEpisodeOrder" ${sortOrder === 'AiredEpisodeOrder' ? 'selected' : ''}>Aired Episode Order</option>
|
||||
<option value="DatePlayed" ${sortOrder === 'DatePlayed' ? 'selected' : ''}>Date Played</option>
|
||||
<option value="StartDate" ${sortOrder === 'StartDate' ? 'selected' : ''}>Start Date</option>
|
||||
<option value="IsFolder" ${sortOrder === 'IsFolder' ? 'selected' : ''}>Is Folder</option>
|
||||
<option value="IsUnplayed" ${sortOrder === 'IsUnplayed' ? 'selected' : ''}>Is Unplayed</option>
|
||||
<option value="IsPlayed" ${sortOrder === 'IsPlayed' ? 'selected' : ''}>Is Played</option>
|
||||
<option value="SeriesSortName" ${sortOrder === 'SeriesSortName' ? 'selected' : ''}>Series Sort Name</option>
|
||||
<option value="AirTime" ${sortOrder === 'AirTime' ? 'selected' : ''}>Air Time</option>
|
||||
<option value="Studio" ${sortOrder === 'Studio' ? 'selected' : ''}>Studio</option>
|
||||
<option value="IsFavoriteOrLiked" ${sortOrder === 'IsFavoriteOrLiked' ? 'selected' : ''}>Is Favorite Or Liked</option>
|
||||
<option value="ParentIndexNumber" ${sortOrder === 'ParentIndexNumber' ? 'selected' : ''}>Parent Index Number</option>
|
||||
<option value="IndexNumber" ${sortOrder === 'IndexNumber' ? 'selected' : ''}>Index Number</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1384,6 +1557,22 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (includeIsPlayed) {
|
||||
html += `
|
||||
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
|
||||
<div class="listItemContent">
|
||||
${buildJellyfinCheckbox(`${prefix}_isPlayed_enabled`, isPlayedFilterEnabled, 'Filter by Played Status')}
|
||||
|
||||
<div id="${prefix}_isPlayed_options" style="margin-top: 0.5em; margin-left: 1.5em; display: ${isPlayedFilterEnabled ? 'block' : 'none'};">
|
||||
<select id="${prefix}_isPlayed_value" class="fld emby-select-withcolor emby-select" style="width: 100%; max-width: 200px;">
|
||||
<option value="false" ${!isPlayedValue ? 'selected' : ''}>Unplayed</option>
|
||||
<option value="true" ${isPlayedValue ? 'selected' : ''}>Played</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -1479,7 +1668,7 @@
|
||||
${buildJellyfinCheckbox('homeScreen_recentlyReleased_movies_enabled', (recentlyReleased.movies || {}).enabled !== false, 'Enabled')}
|
||||
</div>
|
||||
<div class="listItemContent" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 0.75em;">
|
||||
${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 })}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -1496,7 +1685,7 @@
|
||||
${buildJellyfinCheckbox('homeScreen_recentlyReleased_episodes_enabled', (recentlyReleased.episodes || {}).enabled !== false, 'Enabled')}
|
||||
</div>
|
||||
<div class="listItemContent" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 0.75em;">
|
||||
${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 })}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -1515,7 +1704,7 @@
|
||||
<div style="padding: 0.75em 0 0 0;">
|
||||
<div id="homeScreen_trending_container" style="${trending.enabled !== true ? 'display: none;' : ''}">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 0.75em;">
|
||||
${createSectionConfiguration('homeScreen_trending', trending, { includeName: true, defaultName: 'Trending' })}
|
||||
${createSectionConfiguration('homeScreen_trending', trending, { includeName: true, defaultName: 'Trending', includeIsPlayed: true })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1581,7 +1770,7 @@
|
||||
<div style="padding: 0.75em 0 0 0;">
|
||||
<div id="homeScreen_imdbTop250_container" style="${imdbTop250.enabled === false ? 'display: none;' : ''}">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 0.75em;">
|
||||
${createSectionConfiguration('homeScreen_imdbTop250', imdbTop250, { includeName: true, defaultName: 'IMDb Top 250' })}
|
||||
${createSectionConfiguration('homeScreen_imdbTop250', imdbTop250, { includeName: true, defaultName: 'IMDb Top 250', includeIsPlayed: true })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1658,7 +1847,11 @@
|
||||
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-top: 0.25em;">0.0 = never, 1.0 = always</div>
|
||||
</div>
|
||||
</div>
|
||||
${createSectionConfiguration('homeScreen_discovery', discovery, { includeName: false, includeOrder: false, includeCardFormat: false, includeSortOrder: false, includeItemLimit: false })}
|
||||
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; display: grid;">
|
||||
${buildJellyfinCheckbox('homeScreen_discovery_renderSpotlightAboveMatching', discovery.renderSpotlightAboveMatching === true, 'Spotlight Grouping')}
|
||||
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-top: 0.25em; margin-left: 2em;">Render Spotlight discovery sections alongside the corresponding discovery section (e.g. Action Spotlight above Action Movies).</div>
|
||||
</div>
|
||||
${createSectionConfiguration('homeScreen_discovery', discovery, { includeName: false, includeOrder: false, includeCardFormat: false, includeSortOrder: false, includeItemLimit: false })}
|
||||
</div>
|
||||
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 0.75em;">
|
||||
<div class="listItemContent">
|
||||
@@ -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
|
||||
? `
|
||||
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
|
||||
@@ -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 = `
|
||||
<div style="flex: 1;">
|
||||
<div class="listItemBodyText" style="margin-bottom: 0.25em;">${meta.label}</div>
|
||||
<input type="text" id="${inputId}" class="fld emby-input additional-option-value" value="" data-key="${key}" data-type="${meta.type}" placeholder="${meta.hint || ''}" style="width: 100%;">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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}
|
||||
<button type="button" class="remove-additional-option" style="background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0.25em; display: flex; align-items: center;" title="Remove Option">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// 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',
|
||||
|
||||
+285
-271
@@ -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<Object>} - 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<Object>} - 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>} - 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>} - 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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user