mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 07:42:47 +01:00
Anotha one!
This commit is contained in:
@@ -75,9 +75,6 @@
|
||||
}
|
||||
|
||||
[data-modal-id="kefin-homescreen-config-main"] div#section-content {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 0.75em;
|
||||
max-height: calc(90vh - 19em);
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -245,6 +242,7 @@ span.listItemBodyText.secondary.seasonal-tag {
|
||||
|
||||
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
[data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsc-settings-grid-3 {
|
||||
@@ -336,3 +334,11 @@ span.listItemBodyText.secondary.seasonal-tag {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
[data-modal-id="kefin-homescreen-config-main"] .hsc-settings-span-2 {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
[data-modal-id="kefin-homescreen-config-main"] .hsc-settings-grid .hsc-settings-span-2 .kefin-toggle-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
on: 'Allows users to create pinned sections on their Home Screen from library items by adding a "Pin To Home" button in the item context menu.',
|
||||
off: 'Users cannot pin items to their Home Screen.'
|
||||
},
|
||||
'userHome-pairNativeHomeSections': {
|
||||
on: 'Sections with a matching Native Jellyfin section are automatically kept in sync with your KefinTweaks Home Screen.',
|
||||
off: 'KefinTweaks will disable all native Jellyfin home screen sections in the native Jellyfin User Preferences. When pairing is disabled, the official Jellyfin Android TV and Roku clients will have no sections rendered on the home screen. You should only disable this when you do not use either of those clients. Disabling will have a performance benefit when loading the home screen.'
|
||||
},
|
||||
'home-fadeInSections': {
|
||||
on: 'Sections will be hidden when they are rendered and will fade up and in as they enter the window when you scroll.',
|
||||
off: 'Sections will be rendered normally without any fade in animation.'
|
||||
@@ -62,6 +66,10 @@
|
||||
'home-ensureThumbsForPopularTVNetworks': {
|
||||
on: 'Only Studios with a valid image will be rendered in Popular Studios sections.',
|
||||
off: 'Studios without any image may be rendered in Popular Studios sections. You can enable the Optional CSS Module "Studio Thumbnails" to overlay a background with the Studio Name on items without an image.'
|
||||
},
|
||||
'home-loadPeopleEpisodeData': {
|
||||
on: 'Loads all Person appearances from all episodes in your library. This data takes much longer to cache than just the Movies/Series level data.',
|
||||
off: 'Person appearance data is limited to only Movies and Series.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,8 +79,8 @@
|
||||
off: 'No Discovery Sections will be generated.'
|
||||
},
|
||||
'discovery-infiniteScroll': {
|
||||
on: 'Discovery Sections will render automatically when the user scrolls near the bottom of the page.',
|
||||
off: 'Discovery Sections will only be rendered when the user presses the "Load More" button.'
|
||||
on: 'Discovery Sections will render automatically when the user scrolls near the bottom of the page (one group per scroll; scroll again for more).',
|
||||
off: 'Show “Discover More” at the bottom. Scroll again while at the bottom to load the next discovery group.'
|
||||
},
|
||||
'discovery-renderSpotlightAboveMatching': {
|
||||
on: 'Top Rated Genre/Studio and regular Genre/Studio sections will appear next to each other',
|
||||
@@ -113,7 +121,43 @@
|
||||
let currentGlobalSettingsSubTab = 'general'; // Track active sub-tab in Global Settings (general, spotlight, discovery, cache)
|
||||
let globalSettingsSaveTimer = null;
|
||||
const UPDATE_ALL_CONFIRM_MODAL_ID = 'kefin-homescreen-config-update-all-confirm';
|
||||
const RESTORE_DEFAULTS_MODAL_ID = 'kefin-homescreen-config-restore-defaults';
|
||||
const COMMUNITY_IMPORT_MODAL_ID = 'kefin-homescreen-community-import';
|
||||
const MAX_PEOPLE_COUNT_OPTIONS = [100, 250, 500, 1000, 2500, 5000];
|
||||
const RESTORE_RETAIN_TOGGLE_DESCRIPTIONS = {
|
||||
'restore-retain-status': {
|
||||
on: 'Current Default Status and User Configuration values will be unchanged.',
|
||||
off: 'Default Status and User Configuration will be restored to their defaults.'
|
||||
},
|
||||
'restore-retain-name-caption': {
|
||||
on: 'Current Section Name and Caption values will be unchanged.',
|
||||
off: 'Section Name and Caption will be restored to their defaults.'
|
||||
},
|
||||
'restore-retain-appearance': {
|
||||
on: 'Current Section Appearance settings will be unchanged.',
|
||||
off: 'Section Appearance settings will be restored to their defaults.'
|
||||
},
|
||||
'restore-retain-advanced': {
|
||||
on: 'Current Section Advanced Options will be unchanged.',
|
||||
off: 'Section Advanced Options will be restored to their defaults.'
|
||||
},
|
||||
'restore-retain-customs': {
|
||||
on: 'Current Custom Sections will be unchanged.',
|
||||
off: 'All custom sections will be removed.'
|
||||
}
|
||||
};
|
||||
const RESTORE_APPEARANCE_KEYS = [
|
||||
'renderMode', 'cardFormat', 'spotlight', 'spotlightConfig',
|
||||
'borderStyle', 'borderColor',
|
||||
'hideCardTitles', 'cardTitleVisibility',
|
||||
'cardTitlePosition', 'cardTitleCapitalization', 'cardTitleFontFamily',
|
||||
'cardTitleFontSize', 'cardTitleColor',
|
||||
'itemsLayout', 'useGaplessCards', 'sectionCssClass', 'hideName'
|
||||
];
|
||||
const RESTORE_ADVANCED_KEYS = [
|
||||
'ttl', 'sortBy', 'sortOrder', 'sortOrderDirection',
|
||||
'minimumItems', 'itemLimit', 'flattenSeries', 'limitBeforeSort', 'hideWatched'
|
||||
];
|
||||
const TTL_UNIT_OPTIONS = [
|
||||
{ value: 'seconds', ms: 1000 },
|
||||
{ value: 'minutes', ms: 60000 },
|
||||
@@ -155,28 +199,31 @@
|
||||
return flattened;
|
||||
}
|
||||
|
||||
const CONTINUE_WATCHING_SECTION_IDS = new Set([
|
||||
'continueWatching',
|
||||
'nextUp',
|
||||
'continueWatchingAndNextUp'
|
||||
]);
|
||||
const POPULAR_STUDIO_SECTION_IDS = new Set(['popularTVStudios', 'popularMovieStudios', 'popularStudios']);
|
||||
|
||||
/**
|
||||
* Clear MERGE_NEXT_UP-era hidden flags so CW / Next Up / combined are independent sections.
|
||||
* Sync HOME_SETTINGS.ensureThumbsForPopularTVNetworks onto popular studio section queryOptions.
|
||||
* When on: ImageTypes ['Thumb'] (API + postProcessItems filter). When off: remove ImageTypes.
|
||||
*/
|
||||
function normalizeContinueWatchingVisibility(config) {
|
||||
function syncEnsureThumbsOnPopularStudios(config) {
|
||||
if (!config) return;
|
||||
const groupKeys = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS', 'CUSTOM_SECTION_GROUPS'];
|
||||
groupKeys.forEach((key) => {
|
||||
(config[key] || []).forEach((group) => {
|
||||
(group.sections || []).forEach((section) => {
|
||||
if (section?.id && CONTINUE_WATCHING_SECTION_IDS.has(section.id)) {
|
||||
section.hidden = false;
|
||||
const ensureThumbs = config.HOME_SETTINGS?.ensureThumbsForPopularTVNetworks === true;
|
||||
const groupKeys = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'CUSTOM_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS'];
|
||||
for (const key of groupKeys) {
|
||||
const sections = flattenSectionGroups(config[key] || []);
|
||||
for (const section of sections) {
|
||||
if (!section || !POPULAR_STUDIO_SECTION_IDS.has(section.id) || !Array.isArray(section.queries)) continue;
|
||||
for (const query of section.queries) {
|
||||
if (!query) continue;
|
||||
if (ensureThumbs) {
|
||||
query.queryOptions = { ...(query.queryOptions || {}), ImageTypes: ['Thumb'] };
|
||||
} else if (query.queryOptions && Object.prototype.hasOwnProperty.call(query.queryOptions, 'ImageTypes')) {
|
||||
const { ImageTypes, ...rest } = query.queryOptions;
|
||||
query.queryOptions = rest;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
delete config.MERGE_NEXT_UP;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -951,10 +998,19 @@
|
||||
CACHE: { ...defaults.CACHE, ...(existingConfig.CACHE || {}) },
|
||||
SPOTLIGHT_SETTINGS: { ...defaults.SPOTLIGHT_SETTINGS, ...(existingConfig.SPOTLIGHT_SETTINGS || {}) },
|
||||
HOME_SETTINGS: { ...defaults.HOME_SETTINGS, ...(existingConfig.HOME_SETTINGS || {}) },
|
||||
USER_HOME_SCREEN_SETTINGS: { ...defaults.USER_HOME_SCREEN_SETTINGS, ...(existingConfig.USER_HOME_SCREEN_SETTINGS || {}) }
|
||||
USER_HOME_SCREEN_SETTINGS: { ...defaults.USER_HOME_SCREEN_SETTINGS, ...(existingConfig.USER_HOME_SCREEN_SETTINGS || {}) },
|
||||
LIBRARY_CACHE: { ...defaults.LIBRARY_CACHE, ...(existingConfig.LIBRARY_CACHE || {}) }
|
||||
};
|
||||
|
||||
normalizeContinueWatchingVisibility(mergedConfig);
|
||||
// Soft-migrate legacy DISCOVERY_SETTINGS.minPeopleAppearances → HOME_SETTINGS mins
|
||||
const existingHome = existingConfig.HOME_SETTINGS || {};
|
||||
const legacyPeopleMin = existingConfig.DISCOVERY_SETTINGS?.minPeopleAppearances;
|
||||
if (legacyPeopleMin != null && existingHome.minPeopleAppearancesTotal == null) {
|
||||
mergedConfig.HOME_SETTINGS.minPeopleAppearancesTotal = legacyPeopleMin;
|
||||
mergedConfig.HOME_SETTINGS.minPeopleAppearancesMovies = legacyPeopleMin;
|
||||
mergedConfig.HOME_SETTINGS.minPeopleAppearancesSeries = legacyPeopleMin;
|
||||
mergedConfig.HOME_SETTINGS.minPeopleAppearancesEpisodes = legacyPeopleMin;
|
||||
}
|
||||
|
||||
// Add ENABLED_NORMAL_SECTIONS and ENABLED_DISCOVERY_SECTIONS
|
||||
// ENABLED_NORMAL_SECTIONS is all enabled sections from HOME_SECTION_GROUPS, SEASONAL_SECTION_GROUPS, and CUSTOM_SECTION_GROUPS that aren't discovery sections
|
||||
@@ -1547,11 +1603,13 @@
|
||||
USER_HOME_SCREEN_SETTINGS: {
|
||||
...(savedHomeScreenConfig.USER_HOME_SCREEN_SETTINGS || {}),
|
||||
...(config.USER_HOME_SCREEN_SETTINGS || {})
|
||||
},
|
||||
LIBRARY_CACHE: {
|
||||
...(savedHomeScreenConfig.LIBRARY_CACHE || {}),
|
||||
...(config.LIBRARY_CACHE || {})
|
||||
}
|
||||
};
|
||||
|
||||
normalizeContinueWatchingVisibility(mergedHomeScreenConfig);
|
||||
|
||||
// Empty values (= blank CustomPrefs segments) must not persist to JS Injector
|
||||
omitEmptyPropsFromGroups(mergedHomeScreenConfig.HOME_SECTION_GROUPS);
|
||||
omitEmptyPropsFromGroups(mergedHomeScreenConfig.SEASONAL_SECTION_GROUPS);
|
||||
@@ -1761,7 +1819,7 @@
|
||||
const sectionTypes = [...new Set(sections.map(s => s.sectionType))];
|
||||
|
||||
return `
|
||||
<div class="section-group" data-group-id="${groupId}" data-group-name="${group.name || 'Unnamed Group'}" style="margin-bottom: 1.5em; border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 1em;">
|
||||
<div class="section-group" data-group-id="${groupId}" data-group-name="${group.name || 'Unnamed Group'}" style="margin-bottom: 1.5em;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75em;">
|
||||
<div>
|
||||
<input type="text" class="listItemBodyText editable-group-name" data-group-id="${groupId}" value="${(group.name || 'Unnamed Group').replace(/"/g, '"')}" size="${Math.max((group.name || 'Unnamed Group').length, 15)}" style="font-weight: 500; font-size: 1.1em; cursor: text; padding: 0.25em 0.5em; border-radius: 3px; transition: background-color 0.2s; border: none; outline: none; background: transparent; font-family: inherit; color: inherit;" onmouseover="this.style.backgroundColor='rgba(255,255,255,0.05)'" onmouseout="this.style.backgroundColor='transparent'" onfocus="this.style.backgroundColor='rgba(255,255,255,0.1)'" onblur="this.style.backgroundColor='transparent'" oninput="this.size=Math.max(this.value.length,15)" />
|
||||
@@ -1871,6 +1929,10 @@
|
||||
<span class="material-icons" style="margin-right: 0.35em; font-size: 1.1em; vertical-align: middle;">sync</span>
|
||||
<span>Update</span>
|
||||
</button>
|
||||
<button type="button" class="emby-button raised hsc-toolbar-restore-defaults-btn" title="Restore default Home Screen sections">
|
||||
<span class="material-icons" style="margin-right: 0.35em; font-size: 1.1em; vertical-align: middle;">restart_alt</span>
|
||||
<span>Restore Defaults</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
@@ -1984,19 +2046,49 @@
|
||||
const seasonal = currentConfig.SEASONAL_THEME_SETTINGS || {};
|
||||
const homeSettings = currentConfig.HOME_SETTINGS || {};
|
||||
const userHomeScreen = currentConfig.USER_HOME_SCREEN_SETTINGS || {};
|
||||
const libraryCache = currentConfig.LIBRARY_CACHE || {};
|
||||
const D = GENERAL_TOGGLE_DESCRIPTIONS;
|
||||
const maxPeopleCount = homeSettings.maxPeopleCount ?? 500;
|
||||
const maxPeopleOptions = MAX_PEOPLE_COUNT_OPTIONS.map((n) => ({ value: String(n), label: String(n) }));
|
||||
const settingsGroupStyle = 'margin-bottom: 1.5em; border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 1em;';
|
||||
return `
|
||||
<div class="hsc-settings-grid hsc-settings-grid-2">
|
||||
${hscToggleCard('userHome-inlineConfigure', userHomeScreen.inlineConfigure !== false, 'Inline Section Configuration', D)}
|
||||
${hscToggleCard('userHome-pinning', userHomeScreen.pinning !== false, 'Pin To Home', D)}
|
||||
<div class="hsc-settings-span-2">
|
||||
${hscToggleCard('userHome-pairNativeHomeSections', userHomeScreen.pairNativeHomeSections !== false, 'Pair Home Sections to Native Jellyfin User Preferences', D)}
|
||||
</div>
|
||||
${hscToggleCard('home-fadeInSections', homeSettings.fadeInSections === true, 'Fade In Sections', D)}
|
||||
${hscToggleCard('home-showStaleDataBeforeRefresh', homeSettings.SHOW_STALE_DATA_BEFORE_REFRESH === true, 'Show Stale Items', D)}
|
||||
${hscToggleCard('home-showStaleDataBeforeRefresh', homeSettings.showStaleDataBeforeRefresh === true, 'Show Stale Items', D)}
|
||||
${hscToggleCard('seasonal-enableSeasonalAnimations', seasonal.enableSeasonalAnimations !== false, 'Seasonal Animations', D)}
|
||||
${hscToggleCard('seasonal-enableSeasonalBackground', seasonal.enableSeasonalBackground !== false, 'Seasonal Backgrounds', D)}
|
||||
<div class="hsc-settings-span-2">
|
||||
${hscToggleCard('home-ensureThumbsForPopularTVNetworks', homeSettings.ensureThumbsForPopularTVNetworks === true, 'Require Studio Thumbs', D)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hsc-settings-group" style="${settingsGroupStyle}">
|
||||
<div class="listItemBodyText" style="font-weight: 500; font-size: 1.1em;">Top People</div>
|
||||
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-top: 0.25em; margin-bottom: 0.75em;">
|
||||
Sections related to Top People from your library will use these values to determine which People qualify as "Top People". If a Person meets any of the minimum criteria listed below, they will be considered a Top Person. Top People will be sorted based on their total number of appearances, caching only the Top X number of people based on the Maximum Person Count.
|
||||
</div>
|
||||
<div class="hsc-settings-span-2" style="margin-bottom: 0.75em;">
|
||||
${hscToggleCard('home-loadPeopleEpisodeData', homeSettings.loadPeopleEpisodeData === true, 'Load Episode Data for People', D)}
|
||||
</div>
|
||||
<div class="hsc-settings-grid hsc-settings-grid-2">
|
||||
${buildTextInput('home-minPeopleAppearancesTotal', homeSettings.minPeopleAppearancesTotal ?? 10, 'Minimum Total Appearances', 'number')}
|
||||
${buildTextInput('home-minPeopleAppearancesMovies', homeSettings.minPeopleAppearancesMovies ?? 10, 'Minimum Movies Appearances', 'number')}
|
||||
${buildTextInput('home-minPeopleAppearancesSeries', homeSettings.minPeopleAppearancesSeries ?? 10, 'Minimum Series Appearances', 'number')}
|
||||
${buildTextInput('home-minPeopleAppearancesEpisodes', homeSettings.minPeopleAppearancesEpisodes ?? 10, 'Minimum Episodes Appearances', 'number')}
|
||||
${buildSelect('home-maxPeopleCount', maxPeopleOptions, String(maxPeopleCount), 'Maximum Person Count')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hsc-settings-group" style="${settingsGroupStyle}">
|
||||
<div class="listItemBodyText" style="font-weight: 500; font-size: 1.1em;">External Integrations</div>
|
||||
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-top: 0.25em; margin-bottom: 0.75em;">
|
||||
Enter your personal API keys for the services below to enable support for home sections based on external lists.
|
||||
</div>
|
||||
${buildTextInput('libraryCache-mdblistApiKey', libraryCache.mdblistApiKey || '', 'MDBList API Key', 'password', 'Required for External List sections')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2021,8 +2113,8 @@
|
||||
${hscToggleCard('discovery-randomizeOrder', discovery.randomizeOrder === true, 'Randomize Order', D)}
|
||||
${hscToggleCard('discovery-fadeInSections', discovery.fadeInSections === true, 'Fade In Sections', D)}
|
||||
${buildTextInput('discovery-spotlightDiscoveryChance', discovery.spotlightDiscoveryChance ?? 0.5, 'Spotlight Discovery Chance (0-1)', 'number')}
|
||||
${buildTextInput('discovery-minPeopleAppearances', discovery.minPeopleAppearances || 10, 'Top Person Appearance Count', 'number')}
|
||||
${buildTextInput('discovery-minGenreMovieCount', discovery.minGenreMovieCount || 50, 'Genre Movie Count', 'number')}
|
||||
${buildTextInput('discovery-minGenreSeriesCount', discovery.minGenreSeriesCount || 10, 'Genre Series Count', 'number')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2149,8 +2241,8 @@
|
||||
...(currentConfig.DISCOVERY_SETTINGS || {}),
|
||||
enabled: root.querySelector('#discovery-enabled')?.checked !== false,
|
||||
infiniteScroll: root.querySelector('#discovery-infiniteScroll')?.checked !== false,
|
||||
minPeopleAppearances: parseInt(root.querySelector('#discovery-minPeopleAppearances')?.value || '10', 10),
|
||||
minGenreMovieCount: parseInt(root.querySelector('#discovery-minGenreMovieCount')?.value || '50', 10),
|
||||
minGenreSeriesCount: parseInt(root.querySelector('#discovery-minGenreSeriesCount')?.value || '10', 10),
|
||||
spotlightDiscoveryChance: parseFloat(root.querySelector('#discovery-spotlightDiscoveryChance')?.value || '0.5'),
|
||||
renderSpotlightAboveMatching: root.querySelector('#discovery-renderSpotlightAboveMatching')?.checked === true,
|
||||
randomizeOrder: root.querySelector('#discovery-randomizeOrder')?.checked === true,
|
||||
@@ -2160,7 +2252,11 @@
|
||||
|
||||
if (root.querySelector('#seasonal-enableSeasonalAnimations')
|
||||
|| root.querySelector('#home-fadeInSections')
|
||||
|| root.querySelector('#userHome-inlineConfigure')) {
|
||||
|| root.querySelector('#userHome-inlineConfigure')
|
||||
|| root.querySelector('#libraryCache-mdblistApiKey')
|
||||
|| root.querySelector('#home-minPeopleAppearancesTotal')
|
||||
|| root.querySelector('#home-loadPeopleEpisodeData')) {
|
||||
const prevLoadPeopleEpisodeData = currentConfig.HOME_SETTINGS?.loadPeopleEpisodeData === true;
|
||||
currentConfig.SEASONAL_THEME_SETTINGS = {
|
||||
...(currentConfig.SEASONAL_THEME_SETTINGS || {}),
|
||||
enableSeasonalAnimations: root.querySelector('#seasonal-enableSeasonalAnimations')?.checked !== false,
|
||||
@@ -2176,13 +2272,32 @@
|
||||
...(currentConfig.HOME_SETTINGS || {}),
|
||||
fadeInSections: root.querySelector('#home-fadeInSections')?.checked === true,
|
||||
ensureThumbsForPopularTVNetworks: root.querySelector('#home-ensureThumbsForPopularTVNetworks')?.checked === true,
|
||||
SHOW_STALE_DATA_BEFORE_REFRESH: root.querySelector('#home-showStaleDataBeforeRefresh')?.checked === true
|
||||
showStaleDataBeforeRefresh: root.querySelector('#home-showStaleDataBeforeRefresh')?.checked === true,
|
||||
minPeopleAppearancesTotal: parseInt(root.querySelector('#home-minPeopleAppearancesTotal')?.value || '10', 10),
|
||||
minPeopleAppearancesMovies: parseInt(root.querySelector('#home-minPeopleAppearancesMovies')?.value || '10', 10),
|
||||
minPeopleAppearancesSeries: parseInt(root.querySelector('#home-minPeopleAppearancesSeries')?.value || '10', 10),
|
||||
minPeopleAppearancesEpisodes: parseInt(root.querySelector('#home-minPeopleAppearancesEpisodes')?.value || '10', 10),
|
||||
maxPeopleCount: parseInt(root.querySelector('#home-maxPeopleCount')?.value || '500', 10),
|
||||
loadPeopleEpisodeData: root.querySelector('#home-loadPeopleEpisodeData')?.checked === true
|
||||
};
|
||||
syncEnsureThumbsOnPopularStudios(currentConfig);
|
||||
currentConfig.USER_HOME_SCREEN_SETTINGS = {
|
||||
...(currentConfig.USER_HOME_SCREEN_SETTINGS || {}),
|
||||
inlineConfigure: root.querySelector('#userHome-inlineConfigure')?.checked !== false,
|
||||
pinning: root.querySelector('#userHome-pinning')?.checked !== false
|
||||
pinning: root.querySelector('#userHome-pinning')?.checked !== false,
|
||||
pairNativeHomeSections: root.querySelector('#userHome-pairNativeHomeSections')?.checked !== false
|
||||
};
|
||||
if (root.querySelector('#libraryCache-mdblistApiKey')) {
|
||||
currentConfig.LIBRARY_CACHE = {
|
||||
...(currentConfig.LIBRARY_CACHE || {}),
|
||||
mdblistApiKey: root.querySelector('#libraryCache-mdblistApiKey')?.value || ''
|
||||
};
|
||||
}
|
||||
|
||||
const nextLoadEpisodes = currentConfig.HOME_SETTINGS.loadPeopleEpisodeData === true;
|
||||
if (prevLoadPeopleEpisodeData !== nextLoadEpisodes && window.PeopleCache?.invalidate) {
|
||||
window.PeopleCache.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
if (root.querySelector('#cache-DEFAULT_TTL-preset') || root.querySelector('#cache-VERY_SHORT_TTL')) {
|
||||
@@ -2230,7 +2345,8 @@
|
||||
CACHE: currentConfig?.CACHE,
|
||||
SPOTLIGHT_SETTINGS: currentConfig?.SPOTLIGHT_SETTINGS,
|
||||
HOME_SETTINGS: currentConfig?.HOME_SETTINGS,
|
||||
USER_HOME_SCREEN_SETTINGS: currentConfig?.USER_HOME_SCREEN_SETTINGS
|
||||
USER_HOME_SCREEN_SETTINGS: currentConfig?.USER_HOME_SCREEN_SETTINGS,
|
||||
LIBRARY_CACHE: currentConfig?.LIBRARY_CACHE
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2718,6 +2834,200 @@
|
||||
await saveConfig(currentConfig);
|
||||
}
|
||||
|
||||
function buildSectionIdMapFromGroups(...groupLists) {
|
||||
const map = new Map();
|
||||
groupLists.forEach((groups) => {
|
||||
flattenSectionGroups(groups).forEach((section) => {
|
||||
if (section?.id) map.set(section.id, section);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function copyRetainKeys(target, source, keys) {
|
||||
if (!target || !source) return;
|
||||
keys.forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
target[key] = JSON.parse(JSON.stringify(source[key]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function copyQueryAgeFields(target, source) {
|
||||
if (!target || !source) return;
|
||||
const srcQuery = source.queries?.[0];
|
||||
if (!srcQuery || !Array.isArray(target.queries) || !target.queries[0]) return;
|
||||
if (Object.prototype.hasOwnProperty.call(srcQuery, 'minAge')) {
|
||||
target.queries[0].minAge = srcQuery.minAge;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(srcQuery, 'maxAge')) {
|
||||
target.queries[0].maxAge = srcQuery.maxAge;
|
||||
}
|
||||
}
|
||||
|
||||
function applyRetainOptionsToGroups(groups, priorById, options) {
|
||||
(groups || []).forEach((group) => {
|
||||
(group.sections || []).forEach((section) => {
|
||||
const prior = priorById.get(section.id);
|
||||
if (!prior) return;
|
||||
if (options.retainStatus) {
|
||||
if (Object.prototype.hasOwnProperty.call(prior, 'enabled')) {
|
||||
section.enabled = prior.enabled;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(prior, 'userConfigurable')) {
|
||||
section.userConfigurable = prior.userConfigurable;
|
||||
}
|
||||
}
|
||||
if (options.retainNameCaption) {
|
||||
copyRetainKeys(section, prior, ['name', 'caption']);
|
||||
}
|
||||
if (options.retainAppearance) {
|
||||
copyRetainKeys(section, prior, RESTORE_APPEARANCE_KEYS);
|
||||
}
|
||||
if (options.retainAdvanced) {
|
||||
copyRetainKeys(section, prior, RESTORE_ADVANCED_KEYS);
|
||||
copyQueryAgeFields(section, prior);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stampGroupSectionTypes(groups, sectionType) {
|
||||
(groups || []).forEach((group) => {
|
||||
(group.sections || []).forEach((section) => {
|
||||
section.type = sectionType;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function applyRestoreDefaults(options) {
|
||||
const defaults = window.KefinHomeConfig2;
|
||||
if (!defaults || !currentConfig) {
|
||||
throw new Error('Home Screen defaults are not available.');
|
||||
}
|
||||
|
||||
const priorById = buildSectionIdMapFromGroups(
|
||||
currentConfig.HOME_SECTION_GROUPS,
|
||||
currentConfig.SEASONAL_SECTION_GROUPS,
|
||||
currentConfig.DISCOVERY_SECTION_GROUPS
|
||||
);
|
||||
|
||||
const homeGroups = JSON.parse(JSON.stringify(defaults.HOME_SECTION_GROUPS || []));
|
||||
const seasonalGroups = JSON.parse(JSON.stringify(defaults.SEASONAL_SECTION_GROUPS || []));
|
||||
const discoveryGroups = JSON.parse(JSON.stringify(defaults.DISCOVERY_SECTION_GROUPS || []));
|
||||
|
||||
stampGroupSectionTypes(homeGroups, 'home');
|
||||
stampGroupSectionTypes(seasonalGroups, 'seasonal');
|
||||
stampGroupSectionTypes(discoveryGroups, 'discovery');
|
||||
|
||||
applyRetainOptionsToGroups(homeGroups, priorById, options);
|
||||
applyRetainOptionsToGroups(seasonalGroups, priorById, options);
|
||||
applyRetainOptionsToGroups(discoveryGroups, priorById, options);
|
||||
|
||||
currentConfig.HOME_SECTION_GROUPS = homeGroups;
|
||||
currentConfig.SEASONAL_SECTION_GROUPS = seasonalGroups;
|
||||
currentConfig.DISCOVERY_SECTION_GROUPS = discoveryGroups;
|
||||
if (!options.retainCustoms) {
|
||||
currentConfig.CUSTOM_SECTION_GROUPS = [];
|
||||
}
|
||||
|
||||
// Pre-sync so saveConfig merge does not reintroduce removed sections
|
||||
if (!window.KefinTweaksConfig) window.KefinTweaksConfig = {};
|
||||
if (!window.KefinTweaksConfig.homeScreenConfig) window.KefinTweaksConfig.homeScreenConfig = {};
|
||||
const saved = window.KefinTweaksConfig.homeScreenConfig;
|
||||
saved.HOME_SECTION_GROUPS = JSON.parse(JSON.stringify(homeGroups));
|
||||
saved.SEASONAL_SECTION_GROUPS = JSON.parse(JSON.stringify(seasonalGroups));
|
||||
saved.DISCOVERY_SECTION_GROUPS = JSON.parse(JSON.stringify(discoveryGroups));
|
||||
if (!options.retainCustoms) {
|
||||
saved.CUSTOM_SECTION_GROUPS = [];
|
||||
}
|
||||
|
||||
await saveConfig(currentConfig);
|
||||
refreshMainModal();
|
||||
showToast('Home Screen defaults restored');
|
||||
}
|
||||
|
||||
function confirmAndRestoreDefaults() {
|
||||
if (!window.ModalSystem) {
|
||||
ERR('ModalSystem not available for restore defaults');
|
||||
return;
|
||||
}
|
||||
if (window.ModalSystem.isOpen(RESTORE_DEFAULTS_MODAL_ID)) {
|
||||
window.ModalSystem.close(RESTORE_DEFAULTS_MODAL_ID);
|
||||
}
|
||||
|
||||
const D = RESTORE_RETAIN_TOGGLE_DESCRIPTIONS;
|
||||
const content = document.createElement('div');
|
||||
content.innerHTML = `
|
||||
<div class="listItemBodyText" style="line-height: 1.5; margin-bottom: 1em;">
|
||||
Restoring Defaults for KefinTweaks Home Screen will update the existing Home Screen configuration.
|
||||
</div>
|
||||
<div class="hsc-settings-grid hsc-settings-grid-2">
|
||||
${hscToggleCard('restore-retain-status', true, 'Retain Section Status', D)}
|
||||
${hscToggleCard('restore-retain-name-caption', true, 'Retain Section Name and Caption', D)}
|
||||
${hscToggleCard('restore-retain-appearance', true, 'Retain Section Appearance', D)}
|
||||
${hscToggleCard('restore-retain-advanced', true, 'Retain Section Advanced Options', D)}
|
||||
<div class="hsc-settings-span-2">
|
||||
${hscToggleCard('restore-retain-customs', true, 'Retain Custom Sections', D)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.style.display = 'flex';
|
||||
footer.style.gap = '0.5em';
|
||||
footer.style.justifyContent = 'flex-end';
|
||||
footer.innerHTML = `
|
||||
<button type="button" class="emby-button raised" id="hsc-restore-defaults-cancel">Cancel</button>
|
||||
<button type="button" class="emby-button raised block button-submit" id="hsc-restore-defaults-confirm">Restore Defaults</button>
|
||||
`;
|
||||
|
||||
window.ModalSystem.create({
|
||||
id: RESTORE_DEFAULTS_MODAL_ID,
|
||||
title: 'Restore Defaults',
|
||||
content,
|
||||
footer,
|
||||
closeOnBackdrop: true,
|
||||
closeOnEscape: true,
|
||||
showCloseButton: true,
|
||||
fixedSize: true,
|
||||
onOpen: (modal) => {
|
||||
const root = modal.dialogContent || content;
|
||||
window.KefinTweaksUI?.bindToggleCards?.(root);
|
||||
updateHscToggleCardHints(root, RESTORE_RETAIN_TOGGLE_DESCRIPTIONS);
|
||||
root.addEventListener('change', (e) => {
|
||||
if (e.target?.matches?.('input[type="checkbox"][id^="restore-retain-"]')) {
|
||||
updateHscToggleCardHints(root, RESTORE_RETAIN_TOGGLE_DESCRIPTIONS);
|
||||
}
|
||||
});
|
||||
|
||||
modal.dialogFooter?.querySelector('#hsc-restore-defaults-cancel')?.addEventListener('click', () => {
|
||||
window.ModalSystem.close(RESTORE_DEFAULTS_MODAL_ID);
|
||||
});
|
||||
modal.dialogFooter?.querySelector('#hsc-restore-defaults-confirm')?.addEventListener('click', async () => {
|
||||
const confirmBtn = modal.dialogFooter.querySelector('#hsc-restore-defaults-confirm');
|
||||
if (confirmBtn) confirmBtn.disabled = true;
|
||||
try {
|
||||
const options = {
|
||||
retainStatus: root.querySelector('#restore-retain-status')?.checked !== false,
|
||||
retainNameCaption: root.querySelector('#restore-retain-name-caption')?.checked !== false,
|
||||
retainAppearance: root.querySelector('#restore-retain-appearance')?.checked !== false,
|
||||
retainAdvanced: root.querySelector('#restore-retain-advanced')?.checked !== false,
|
||||
retainCustoms: root.querySelector('#restore-retain-customs')?.checked !== false
|
||||
};
|
||||
await applyRestoreDefaults(options);
|
||||
window.ModalSystem.close(RESTORE_DEFAULTS_MODAL_ID);
|
||||
} catch (e) {
|
||||
ERR('Error restoring Home Screen defaults:', e);
|
||||
showToast('Error restoring defaults');
|
||||
} finally {
|
||||
if (confirmBtn) confirmBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAndUpdateAllUsers() {
|
||||
if (!window.ModalSystem) {
|
||||
ERR('ModalSystem not available for update confirmation');
|
||||
@@ -2965,6 +3275,10 @@
|
||||
}
|
||||
if (e.target.closest('.hsc-toolbar-update-btn')) {
|
||||
confirmAndUpdateAllUsers();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.hsc-toolbar-restore-defaults-btn')) {
|
||||
confirmAndRestoreDefaults();
|
||||
}
|
||||
});
|
||||
/* dialog.querySelectorAll('.section-type-nav-btn').forEach(btn => {
|
||||
@@ -4412,6 +4726,18 @@
|
||||
}
|
||||
|
||||
const queries = section.queries || [];
|
||||
if (Array.isArray(section.externalListUrls) && section.externalListUrls.length > 0) {
|
||||
if (!window.KefinExternalList?.resolveExternalListItems) {
|
||||
throw new Error('External list module not available for preview');
|
||||
}
|
||||
const listResult = await window.KefinExternalList.resolveExternalListItems(section);
|
||||
const items = listResult?.Items || [];
|
||||
const processed = window.cardBuilder.postProcessItems
|
||||
? window.cardBuilder.postProcessItems(section, items)
|
||||
: items;
|
||||
return { items: processed, limit: processed.length };
|
||||
}
|
||||
|
||||
if (section.items?.length) {
|
||||
const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || '';
|
||||
const serverId = ApiClient.serverId();
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
SEASONAL_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.SEASONAL_SECTION_GROUPS || [])),
|
||||
DISCOVERY_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SECTION_GROUPS || [])),
|
||||
CUSTOM_SECTION_GROUPS: [],
|
||||
REMOVE_CONFLICTING_SECTIONS: legacyHomeScreen.removeConflictingSections !== false,
|
||||
DISCOVERY_SETTINGS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SETTINGS || {})),
|
||||
SEASONAL_THEME_SETTINGS: JSON.parse(JSON.stringify(defaults.SEASONAL_THEME_SETTINGS || {}))
|
||||
SEASONAL_THEME_SETTINGS: JSON.parse(JSON.stringify(defaults.SEASONAL_THEME_SETTINGS || {})),
|
||||
HOME_SETTINGS: JSON.parse(JSON.stringify(defaults.HOME_SETTINGS || {}))
|
||||
};
|
||||
|
||||
// Migrate discovery settings
|
||||
@@ -101,7 +101,6 @@
|
||||
|
||||
if (discovery.enabled !== undefined) settings.enabled = discovery.enabled;
|
||||
if (discovery.infiniteScroll !== undefined) settings.infiniteScroll = discovery.infiniteScroll;
|
||||
if (discovery.minPeopleAppearances !== undefined) settings.minPeopleAppearances = discovery.minPeopleAppearances;
|
||||
if (discovery.minGenreMovieCount !== undefined) settings.minGenreMovieCount = discovery.minGenreMovieCount;
|
||||
if (discovery.defaultItemLimit !== undefined) settings.defaultItemLimit = discovery.defaultItemLimit;
|
||||
if (discovery.defaultSortOrder !== undefined) settings.defaultSortOrder = discovery.defaultSortOrder;
|
||||
@@ -113,6 +112,17 @@
|
||||
settings.spotlight = {};
|
||||
Object.assign(settings.spotlight, discovery.spotlight);
|
||||
}
|
||||
|
||||
// Legacy discovery.minPeopleAppearances → HOME_SETTINGS mins
|
||||
if (discovery.minPeopleAppearances !== undefined) {
|
||||
if (!newConfig.HOME_SETTINGS) newConfig.HOME_SETTINGS = {};
|
||||
const min = discovery.minPeopleAppearances;
|
||||
const home = newConfig.HOME_SETTINGS;
|
||||
if (home.minPeopleAppearancesTotal == null) home.minPeopleAppearancesTotal = min;
|
||||
if (home.minPeopleAppearancesMovies == null) home.minPeopleAppearancesMovies = min;
|
||||
if (home.minPeopleAppearancesSeries == null) home.minPeopleAppearancesSeries = min;
|
||||
if (home.minPeopleAppearancesEpisodes == null) home.minPeopleAppearancesEpisodes = min;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,8 +226,8 @@
|
||||
function mergeSectionConfig(defaultSection, legacyConfig) {
|
||||
const merged = JSON.parse(JSON.stringify(defaultSection));
|
||||
|
||||
// Map root fields
|
||||
if (legacyConfig.enabled !== undefined) merged.enabled = legacyConfig.enabled;
|
||||
// Map root fields — missing legacy enabled means on (same as seasonal/custom)
|
||||
merged.enabled = legacyConfig.enabled !== false;
|
||||
if (legacyConfig.name !== undefined) merged.name = legacyConfig.name;
|
||||
if (legacyConfig.order !== undefined) merged.order = legacyConfig.order;
|
||||
if (legacyConfig.cardFormat !== undefined) merged.cardFormat = legacyConfig.cardFormat;
|
||||
@@ -427,6 +437,7 @@
|
||||
watchlist: 'watchlist',
|
||||
upcoming: 'upcoming',
|
||||
imdbTop250: 'imdbTop250',
|
||||
trending: 'trending',
|
||||
popularTVNetworks: 'popularTVNetworks'
|
||||
};
|
||||
|
||||
@@ -550,6 +561,50 @@
|
||||
LOG(` Added as custom section: ${customSection.id}`);
|
||||
}
|
||||
|
||||
const RECENTLY_WATCHED_PERSON_IDS = new Set([
|
||||
'starringActorRecentlyWatched',
|
||||
'directedByDirectorRecentlyWatched',
|
||||
'writtenByWriterRecentlyWatched'
|
||||
]);
|
||||
|
||||
const RECENTLY_WATCHED_CAPTION_DEFAULT = 'because you recently watched {Title}';
|
||||
|
||||
const RECENTLY_WATCHED_NAME_DEFAULTS = {
|
||||
starringActorRecentlyWatched: 'Starring {Person}',
|
||||
directedByDirectorRecentlyWatched: 'Directed by {Person}',
|
||||
writtenByWriterRecentlyWatched: 'Written by {Person}'
|
||||
};
|
||||
|
||||
function normalizeDiscoveryTemplatePlaceholders(text) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
return text
|
||||
.replace(/\[Actor\]/gi, '{Person}')
|
||||
.replace(/\[Director\]/gi, '{Person}')
|
||||
.replace(/\[Writer\]/gi, '{Person}')
|
||||
.replace(/\[Person\]/gi, '{Person}')
|
||||
.replace(/\[Movie\]/gi, '{Title}')
|
||||
.replace(/\[Title\]/gi, '{Title}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split legacy combined "Starring X because you recently watched Y" into name + caption.
|
||||
*/
|
||||
function splitLegacyRecentlyWatchedName(legacyName) {
|
||||
if (!legacyName || typeof legacyName !== 'string') return null;
|
||||
const match = legacyName.match(/^(.*?)\s+because you recently watched\s+(.*)$/i);
|
||||
if (!match) return null;
|
||||
let name = normalizeDiscoveryTemplatePlaceholders(match[1].trim());
|
||||
let captionRight = normalizeDiscoveryTemplatePlaceholders(match[2].trim());
|
||||
const caption = captionRight
|
||||
? `because you recently watched ${captionRight}`
|
||||
: RECENTLY_WATCHED_CAPTION_DEFAULT;
|
||||
// Prefer canonical caption placeholder when right side is Movie/Title token only
|
||||
if (/^\{Title\}$/i.test(captionRight) || /^\[Movie\]$/i.test(match[2].trim())) {
|
||||
return { name, caption: RECENTLY_WATCHED_CAPTION_DEFAULT };
|
||||
}
|
||||
return { name, caption };
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate DISCOVERY sections
|
||||
*/
|
||||
@@ -562,13 +617,10 @@
|
||||
|
||||
const discoverySectionTypes = legacyHomeScreen.discovery.sectionTypes;
|
||||
Object.entries(discoverySectionTypes).forEach(([sectionKey, legacySectionConfig]) => {
|
||||
// Use the key as the section ID
|
||||
const found = findDiscoverySectionById(sectionKey, newConfig.DISCOVERY_SECTION_GROUPS);
|
||||
|
||||
if (found) {
|
||||
// Discovery sections still use legacy format, so we map fields directly
|
||||
if (legacySectionConfig.enabled !== undefined) found.section.enabled = legacySectionConfig.enabled;
|
||||
if (legacySectionConfig.name !== undefined) found.section.name = legacySectionConfig.name;
|
||||
found.section.enabled = legacySectionConfig.enabled !== false;
|
||||
if (legacySectionConfig.itemLimit !== undefined) found.section.itemLimit = legacySectionConfig.itemLimit;
|
||||
if (legacySectionConfig.cardFormat !== undefined) found.section.cardFormat = legacySectionConfig.cardFormat;
|
||||
if (legacySectionConfig.isPlayed !== undefined && legacySectionConfig.isPlayed !== null) {
|
||||
@@ -578,6 +630,29 @@
|
||||
if (legacySectionConfig.sortOrder !== undefined) found.section.sortOrder = legacySectionConfig.sortOrder;
|
||||
if (legacySectionConfig.sortOrderDirection !== undefined) found.section.sortOrderDirection = legacySectionConfig.sortOrderDirection;
|
||||
|
||||
const isRecentlyWatchedPerson = RECENTLY_WATCHED_PERSON_IDS.has(sectionKey);
|
||||
if (legacySectionConfig.name !== undefined) {
|
||||
const split = splitLegacyRecentlyWatchedName(legacySectionConfig.name);
|
||||
if (split) {
|
||||
found.section.name = split.name;
|
||||
found.section.caption = split.caption;
|
||||
} else if (isRecentlyWatchedPerson) {
|
||||
found.section.name = RECENTLY_WATCHED_NAME_DEFAULTS[sectionKey]
|
||||
|| normalizeDiscoveryTemplatePlaceholders(legacySectionConfig.name);
|
||||
if (!found.section.caption) {
|
||||
found.section.caption = RECENTLY_WATCHED_CAPTION_DEFAULT;
|
||||
}
|
||||
} else {
|
||||
found.section.name = legacySectionConfig.name;
|
||||
}
|
||||
} else if (isRecentlyWatchedPerson && !found.section.caption) {
|
||||
found.section.caption = RECENTLY_WATCHED_CAPTION_DEFAULT;
|
||||
}
|
||||
|
||||
if (legacySectionConfig.caption !== undefined) {
|
||||
found.section.caption = normalizeDiscoveryTemplatePlaceholders(legacySectionConfig.caption);
|
||||
}
|
||||
|
||||
LOG(` Migrated discovery section: ${sectionKey}`);
|
||||
} else {
|
||||
WARN(` Discovery section not found in defaults: ${sectionKey}`);
|
||||
|
||||
@@ -57,9 +57,60 @@
|
||||
sections: [],
|
||||
pinnedLists: [],
|
||||
pinnedParents: []
|
||||
// pairNativeHomeSections omitted until user dirties the field
|
||||
};
|
||||
}
|
||||
|
||||
function getServerPairNativeHomeSectionsDefault() {
|
||||
const settings = window.KefinHomeScreen?.getConfig?.()?.USER_HOME_SCREEN_SETTINGS
|
||||
|| window.KefinHomeConfig2?.USER_HOME_SCREEN_SETTINGS
|
||||
|| {};
|
||||
return settings.pairNativeHomeSections !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective pair-native setting: user override if present, else server default.
|
||||
*/
|
||||
function resolvePairNativeHomeSections(homeScreen) {
|
||||
if (homeScreen && typeof homeScreen.pairNativeHomeSections === 'boolean') {
|
||||
return homeScreen.pairNativeHomeSections;
|
||||
}
|
||||
return getServerPairNativeHomeSectionsDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all native Jellyfin home section slots (homesection0–9 = none).
|
||||
*/
|
||||
function disableAllNativeHomeSections(customPrefs, { syncUi = false } = {}) {
|
||||
const prefs = customPrefs || {};
|
||||
let changed = false;
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
const key = `homesection${i}`;
|
||||
const prev = prefs[key];
|
||||
const prevNorm = (!prev || prev === '') ? 'none' : String(prev).toLowerCase();
|
||||
if (prevNorm !== 'none' || prefs[key] !== 'none') {
|
||||
prefs[key] = 'none';
|
||||
if (prevNorm !== 'none' || prev !== 'none') changed = true;
|
||||
}
|
||||
}
|
||||
if (syncUi) {
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
setNativeHomeSectionSelect(findNativeHomeSectionSelect(i + 1), 'none');
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
LOG('Disabled all Jellyfin homesectionN (pair native off)');
|
||||
}
|
||||
return { slots: Array(10).fill('none'), changed };
|
||||
}
|
||||
|
||||
function syncNativeHomeSectionsFromKefin(sections, customPrefs, homeScreen, { syncUi = false } = {}) {
|
||||
if (resolvePairNativeHomeSections(homeScreen)) {
|
||||
return enableJellyfinSectionsFromKefin(sections, customPrefs, { syncUi });
|
||||
}
|
||||
return disableAllNativeHomeSections(customPrefs, { syncUi });
|
||||
}
|
||||
|
||||
function slugifyPinnedName(name) {
|
||||
return String(name || '')
|
||||
.trim()
|
||||
@@ -524,11 +575,15 @@
|
||||
if (parsed) {
|
||||
const hs = parsed?.homeScreen || (Array.isArray(parsed?.sections) ? parsed : null);
|
||||
if (hs) {
|
||||
return {
|
||||
const result = {
|
||||
sections: Array.isArray(hs.sections) ? [...hs.sections] : [],
|
||||
pinnedLists: Array.isArray(hs.pinnedLists) ? [...hs.pinnedLists] : [],
|
||||
pinnedParents: Array.isArray(hs.pinnedParents) ? [...hs.pinnedParents] : []
|
||||
};
|
||||
if (typeof hs.pairNativeHomeSections === 'boolean') {
|
||||
result.pairNativeHomeSections = hs.pairNativeHomeSections;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return migrateLegacyKefinHomeScreen(customPrefs);
|
||||
@@ -839,7 +894,7 @@
|
||||
sections = sections.filter((section) => section.enabled || section.userConfigurable === true || section.userConfigurable === undefined);
|
||||
sections = deduplicateHomeScreenSections(sections);
|
||||
|
||||
const jellyfinSync = enableJellyfinSectionsFromKefin(sections, customPrefs, { syncUi: true });
|
||||
const jellyfinSync = syncNativeHomeSectionsFromKefin(sections, customPrefs, homeScreen, { syncUi: true });
|
||||
if (jellyfinSync.changed && displayPrefs && !sanitizePersistInFlight) {
|
||||
sanitizePersistInFlight = true;
|
||||
try {
|
||||
@@ -1544,8 +1599,12 @@
|
||||
|
||||
/**
|
||||
* Save user preferences to display preferences
|
||||
* @param {Array} sections
|
||||
* @param {{ pairNativeHomeSections?: boolean|null }} [options]
|
||||
* pairNativeHomeSections: true/false to set override; null/undefined to leave unchanged;
|
||||
* pass 'default' symbol via omitOverride to clear dirty override
|
||||
*/
|
||||
async function saveUserPreferences(sections) {
|
||||
async function saveUserPreferences(sections, options = {}) {
|
||||
try {
|
||||
if (!window.userHelper || !window.userHelper.getUserDisplayPreferences || !window.userHelper.updateDisplayPreferences) {
|
||||
ERR('userHelper not available');
|
||||
@@ -1574,6 +1633,21 @@
|
||||
mergeEditorSectionsIntoHomeScreen(existingHomeScreen, sections, serverSectionsById)
|
||||
).homeScreen;
|
||||
|
||||
// Preserve or update dirty-only pairNativeHomeSections
|
||||
if (Object.prototype.hasOwnProperty.call(options, 'pairNativeHomeSections')) {
|
||||
const serverDefault = getServerPairNativeHomeSectionsDefault();
|
||||
const next = options.pairNativeHomeSections;
|
||||
if (next === null || next === undefined) {
|
||||
delete homeScreen.pairNativeHomeSections;
|
||||
} else if (next === serverDefault) {
|
||||
delete homeScreen.pairNativeHomeSections;
|
||||
} else {
|
||||
homeScreen.pairNativeHomeSections = next === true;
|
||||
}
|
||||
} else if (typeof existingHomeScreen.pairNativeHomeSections === 'boolean') {
|
||||
homeScreen.pairNativeHomeSections = existingHomeScreen.pairNativeHomeSections;
|
||||
}
|
||||
|
||||
const kefin = typeof window.userHelper?.parseKefinTweaks === 'function'
|
||||
? window.userHelper.parseKefinTweaks(customPrefs)
|
||||
: {};
|
||||
@@ -1582,7 +1656,7 @@
|
||||
customPrefs.kefinHomeScreen = JSON.stringify(homeScreenToLegacyArray(homeScreen));
|
||||
|
||||
// Rewrite Jellyfin homesectionN (+ native selects) from enabled Kefin sections
|
||||
enableJellyfinSectionsFromKefin(sections, customPrefs, { syncUi: true });
|
||||
syncNativeHomeSectionsFromKefin(sections, customPrefs, homeScreen, { syncUi: true });
|
||||
|
||||
// Save
|
||||
const success = await window.userHelper.updateDisplayPreferences(displayPrefs);
|
||||
@@ -1679,6 +1753,20 @@
|
||||
showUpDownButtons: true
|
||||
});
|
||||
|
||||
const serverPairDefault = getServerPairNativeHomeSectionsDefault();
|
||||
const effectivePair = resolvePairNativeHomeSections(userConfig.homeScreen);
|
||||
const pairDescOn = 'Do NOT enable this if you use the official Jellyfin for Roku or Android TV clients. When this is enabled, your Home Screen on those clients will be empty and you will need to go to your User Settings to re-enable sections.';
|
||||
const pairDescOff = 'Do NOT enable this if you use the official Jellyfin for Roku or Android TV clients. When this is enabled, your Home Screen on those clients will be empty and you will need to go to your User Settings to re-enable sections.';
|
||||
const pairToggleHTML = typeof window.KefinTweaksUI?.buildToggleCard === 'function'
|
||||
? window.KefinTweaksUI.buildToggleCard(
|
||||
'kefin-user-pair-native-home-sections',
|
||||
!effectivePair,
|
||||
'Improve Home Screen Performance',
|
||||
!effectivePair ? pairDescOn : pairDescOff,
|
||||
{ hintKey: 'pairNativeHomeSections' }
|
||||
)
|
||||
: '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="verticalSection verticalSection-extrabottompadding">
|
||||
<div class="sectionTitleContainer" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5em;">
|
||||
@@ -1688,9 +1776,51 @@
|
||||
</button>
|
||||
</div>
|
||||
${editorHTML}
|
||||
${pairToggleHTML ? `
|
||||
<div class="verticalSection" style="margin-top: 1.5em;" id="kefin-user-pair-native-wrap">
|
||||
${pairToggleHTML}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Pair-native toggle: dirty-only persist (INVERTED UI: on in UI means flag == false)
|
||||
const pairCheckbox = container.querySelector('#kefin-user-pair-native-home-sections');
|
||||
const pairCard = pairCheckbox?.closest('.kefin-toggle-card');
|
||||
if (pairCheckbox && pairCard) {
|
||||
const pairSwitch = pairCard.querySelector('.kefin-toggle-switch, .toggle-slider, button');
|
||||
// When the toggle appears "on" in the UI, the flag is actually false (inverse)
|
||||
const updatePairDesc = (checked) => {
|
||||
// checked (UI "on") means flag === false
|
||||
const desc = pairCard.querySelector('.kefin-toggle-card-desc');
|
||||
if (desc) desc.textContent = checked ? pairDescOn : pairDescOff;
|
||||
};
|
||||
const onPairToggle = async () => {
|
||||
// checked (UI 'on') means flag should be false
|
||||
const flagValue = !pairCheckbox.checked;
|
||||
updatePairDesc(pairCheckbox.checked);
|
||||
if (typeof window.KefinTweaksUI?.updateToggleSwitchUI === 'function' && pairSwitch) {
|
||||
window.KefinTweaksUI.updateToggleSwitchUI(pairSwitch, pairCheckbox.checked);
|
||||
}
|
||||
// For persistence: (flagValue === serverPairDefault) ? null : flagValue
|
||||
await saveUserPreferences(allSections, {
|
||||
pairNativeHomeSections: flagValue === serverPairDefault ? null : flagValue
|
||||
});
|
||||
};
|
||||
pairSwitch?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
pairCheckbox.checked = !pairCheckbox.checked;
|
||||
onPairToggle();
|
||||
});
|
||||
pairCheckbox.addEventListener('change', onPairToggle);
|
||||
|
||||
// On initial render, enforce the correct description and checked value
|
||||
// (in case render logic needs to set checked/unchecked based on flag)
|
||||
// (Optional: If not already being handled by upstream render logic)
|
||||
//pairCheckbox.checked = !effectivePair;
|
||||
updatePairDesc(pairCheckbox.checked);
|
||||
}
|
||||
|
||||
// Restore Defaults: confirm then clear kefinHomeScreen and re-render
|
||||
const restoreBtn = container.querySelector('.kefin-restore-defaults-btn');
|
||||
if (restoreBtn && window.ModalSystem) {
|
||||
@@ -1906,6 +2036,9 @@
|
||||
homeScreenToLegacyArray,
|
||||
getConfig,
|
||||
enableJellyfinSectionsFromKefin,
|
||||
syncNativeHomeSectionsFromKefin,
|
||||
resolvePairNativeHomeSections,
|
||||
getServerPairNativeHomeSectionsDefault,
|
||||
buildHomesectionSlotsFromKefin,
|
||||
resolveSectionJellyfinId,
|
||||
updateUserHomeScreenConfiguration,
|
||||
|
||||
@@ -129,6 +129,14 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-basic-group-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75em;
|
||||
margin-top: 0.85em;
|
||||
margin-bottom: 0.85em;
|
||||
}
|
||||
|
||||
:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@@ -137,11 +145,6 @@
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
|
||||
.hsae-query-row-sort.hsae-field-grid-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-seasonal-dates {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -1589,11 +1592,18 @@
|
||||
}
|
||||
|
||||
/* Item source — show only the panel matching the active source (profile rules still gate custom items on non-full profiles) */
|
||||
.hsae-root[data-item-source="jellyfin"] .hsae-custom-items-group {
|
||||
.hsae-root[data-item-source="jellyfin"] .hsae-custom-items-group,
|
||||
.hsae-root[data-item-source="jellyfin"] .hsae-external-list-group {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hsae-root[data-item-source="custom"] .hsae-queries-group {
|
||||
.hsae-root[data-item-source="custom"] .hsae-queries-group,
|
||||
.hsae-root[data-item-source="custom"] .hsae-external-list-group {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hsae-root[data-item-source="external"] .hsae-queries-group,
|
||||
.hsae-root[data-item-source="external"] .hsae-custom-items-group {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -625,7 +625,7 @@
|
||||
function getCachePresets() {
|
||||
const cache = window.KefinHomeConfig2?.CACHE || window.KefinHomeConfig2?.CACHE_CONFIG || {};
|
||||
return [
|
||||
{ key: 'FORCE_REFRESH', label: 'Force Refresh', ms: cache.FORCE_REFRESH_TTL ?? 0 },
|
||||
{ key: 'FORCE_REFRESH', label: 'No Cache', ms: cache.FORCE_REFRESH_TTL ?? 0 },
|
||||
{ key: 'VERY_SHORT', label: 'Very Short (1 min)', ms: cache.VERY_SHORT_TTL ?? 60000 },
|
||||
{ key: 'SHORT', label: 'Short (5 min)', ms: cache.SHORT_TTL ?? 300000 },
|
||||
{ key: 'DEFAULT', label: 'Default (1 hour)', ms: cache.DEFAULT_TTL ?? 3600000 },
|
||||
@@ -637,6 +637,9 @@
|
||||
}
|
||||
|
||||
function inferItemSource(section) {
|
||||
if (Array.isArray(section?.externalListUrls) && section.externalListUrls.length > 0) {
|
||||
return 'external';
|
||||
}
|
||||
if (section?.items?.length > 0 && (!section.queries?.length || section.queries.length === 0)) {
|
||||
return 'custom';
|
||||
}
|
||||
@@ -951,6 +954,7 @@
|
||||
startDate: section.startDate || '',
|
||||
endDate: section.endDate || '',
|
||||
order: section.order != null ? String(section.order) : '',
|
||||
caption: section.caption || '',
|
||||
renderMode,
|
||||
cardFormat: section.cardFormat || 'Poster',
|
||||
useRandomQuery: section.useRandomQuery === true,
|
||||
@@ -970,6 +974,9 @@
|
||||
groupNew: groupSelection.groupNew,
|
||||
queries: (section.queries?.length ? section.queries : [createDefaultQuery()]).map(q => ({ ...q })),
|
||||
customItems: customEditor ? customEditor.staticItemsToDrafts(section.items) : [],
|
||||
externalListUrls: Array.isArray(section.externalListUrls) && section.externalListUrls.length
|
||||
? section.externalListUrls.slice()
|
||||
: [''],
|
||||
customItemEditorIndex: null,
|
||||
customItemAdditionalExpanded: {},
|
||||
ttlPreset: ttlMatch.preset,
|
||||
@@ -987,6 +994,7 @@
|
||||
if (!root || !state) return;
|
||||
|
||||
const nameEl = root.querySelector('#section-name');
|
||||
const captionEl = root.querySelector('#section-caption');
|
||||
const groupEl = root.querySelector('#section-group');
|
||||
const groupNewEl = root.querySelector('#section-group-new');
|
||||
const visibilityEl = root.querySelector('#hsae-section-visibility');
|
||||
@@ -1001,6 +1009,7 @@
|
||||
const sortOrderEl = root.querySelector('#section-sortOrder');
|
||||
|
||||
if (nameEl) state.sectionName = nameEl.value;
|
||||
if (captionEl) state.caption = captionEl.value;
|
||||
if (groupEl) state.groupSelect = groupEl.value;
|
||||
if (groupNewEl) state.groupNew = groupNewEl.value;
|
||||
if (visibilityEl) state.sectionVisibility = visibilityEl.value;
|
||||
@@ -1058,6 +1067,12 @@
|
||||
customEditor.collectCustomItemsFromForm(root, state, customEditor.getCustomItemsOptions());
|
||||
}
|
||||
|
||||
if (state.itemSource === 'external') {
|
||||
const urlInputs = root.querySelectorAll('[id^="hsae-external-list-url-"]');
|
||||
state.externalListUrls = Array.from(urlInputs).map((el) => (el.value || '').trim()).filter(Boolean);
|
||||
if (!state.externalListUrls.length) state.externalListUrls = [''];
|
||||
}
|
||||
|
||||
if (state.itemSource === 'jellyfin') {
|
||||
const hideWatched = root.querySelector('#hsae-hide-watched')?.checked === true;
|
||||
const editors = root.querySelectorAll('.query-editor');
|
||||
@@ -1354,6 +1369,7 @@
|
||||
const endpointDisplay = sourceType === 'jellyfin' || sourceType === 'static' ? 'block' : 'none';
|
||||
const jellyfinDisplay = sourceType === 'jellyfin' ? 'block' : 'none';
|
||||
const staticDisplay = sourceType === 'static' ? 'block' : 'none';
|
||||
const sortExtrasDisplay = sourceType === 'jellyfin' || sourceType === 'static' ? 'block' : 'none';
|
||||
const staticItems = getQueryStaticItems(query);
|
||||
|
||||
const parentSelection = getQueryParentSelection(query);
|
||||
@@ -1396,17 +1412,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hsae-field-grid hsae-query-row-sort${sourceType === 'static' ? ' hsae-field-grid-2' : ''}">
|
||||
<div class="hsae-field-grid hsae-query-row-sort">
|
||||
<div class="hsae-query-sort-by" data-hsae-profiles="full normal dateCutoffs">
|
||||
${buildSelect(`query-${index}-SortBy`, toSortBySelectOptions(SORT_ORDERS), queryOptions.SortBy || '', 'Sort By')}
|
||||
</div>
|
||||
<div class="hsae-query-sort-order" data-hsae-profiles="full normal dateCutoffs">
|
||||
${buildSelect(`query-${index}-SortOrder`, SORT_ORDER_DIRECTIONS, queryOptions.SortOrder || '', 'Sort Order')}
|
||||
</div>
|
||||
<div class="hsae-query-limit hsae-query-jellyfin-only" data-hsae-profiles="full minimal postProcessing normal dateCutoffs" style="display:${jellyfinDisplay};">
|
||||
<div class="hsae-query-limit hsae-query-sort-extras" data-hsae-profiles="full minimal postProcessing normal dateCutoffs" style="display:${sortExtrasDisplay};">
|
||||
${buildTextInput(`query-${index}-Limit`, queryOptions.Limit ?? '', 'Limit', 'number', '', { min: 0, step: 1 })}
|
||||
</div>
|
||||
<div class="hsae-query-search hsae-query-jellyfin-only" data-hsae-profiles="full" style="display:${jellyfinDisplay};">
|
||||
<div class="hsae-query-search hsae-query-sort-extras" data-hsae-profiles="full" style="display:${sortExtrasDisplay};">
|
||||
${buildTextInput(`query-${index}-SearchTerm`, queryOptions.SearchTerm || '', 'Search Term')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1507,13 +1523,15 @@
|
||||
<div class="listItemBodyText secondary hsae-group-desc">General configuration for this section</div>
|
||||
</div>
|
||||
<div class="hsae-card">
|
||||
<div class="hsae-inline-row">
|
||||
<div class="hsae-field-grid hsae-field-grid-2 hsae-basic-name-caption">
|
||||
${buildTextInput('section-name', section.name || state.sectionName || '', 'Section Name')}
|
||||
<div data-hsae-profiles="full">
|
||||
${buildSelect('section-group', groupOptions, selectedGroup, 'Section Group')}
|
||||
<div id="section-group-new-container" class="hsae-group-new" style="display:${selectedGroup === 'New...' ? 'block' : 'none'};">
|
||||
${buildTextInput('section-group-new', state.groupNew || '', 'New Group Name')}
|
||||
</div>
|
||||
${buildTextInput('section-caption', section.caption || state.caption || '', 'Caption', 'text', 'Displayed below the Section Name.')}
|
||||
</div>
|
||||
|
||||
<div class="hsae-basic-group-row" data-hsae-profiles="full">
|
||||
${buildSelect('section-group', groupOptions, selectedGroup, 'Section Group')}
|
||||
<div id="section-group-new-container" class="hsae-group-new" style="display:${selectedGroup === 'New...' ? 'block' : 'none'};">
|
||||
${buildTextInput('section-group-new', state.groupNew || '', 'New Group Name')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1606,11 +1624,49 @@
|
||||
<div class="listItemBodyText secondary hsae-source-desc">${escapeHtml(CUSTOM_SOURCE_DESC)}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" class="hsae-source-card${itemSource === 'external' ? ' hsae-active' : ''}" data-hsae-action="set-item-source" data-item-source="external">
|
||||
<div class="hsae-source-card-text">
|
||||
<div class="listItemBodyText hsae-source-title">
|
||||
<span class="material-icons hsae-source-icon">link</span>
|
||||
External List
|
||||
</div>
|
||||
<div class="listItemBodyText secondary hsae-source-desc">Match an MDBList (or compatible) URL against your Jellyfin library by provider IDs</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildExternalListHTML(state) {
|
||||
const { buildTextInput } = getApi();
|
||||
const urls = Array.isArray(state.externalListUrls) && state.externalListUrls.length
|
||||
? state.externalListUrls
|
||||
: [''];
|
||||
const apiKey = window.LibraryCacheUtils?.getLibraryCacheSettings?.()?.mdblistApiKey
|
||||
|| window.KefinHomeConfig2?.LIBRARY_CACHE?.mdblistApiKey
|
||||
|| '';
|
||||
const warn = !String(apiKey || '').trim()
|
||||
? `<div class="listItemBodyText secondary" style="color: var(--error-color, #e57373); margin-bottom: 0.75em;">MDBList API key is not configured. Add it under Home Screen → General settings before using External Lists.</div>`
|
||||
: '';
|
||||
const rows = urls.map((url, index) => `
|
||||
<div class="hsae-external-list-row" data-external-index="${index}" style="margin-bottom: 0.75em;">
|
||||
${buildTextInput(`hsae-external-list-url-${index}`, url || '', `List URL ${index + 1}`, 'url', 'https://mdblist.com/...')}
|
||||
</div>
|
||||
`).join('');
|
||||
return `
|
||||
<section class="hsae-group hsae-external-list-group" data-hsae-profiles="full">
|
||||
<div class="hsae-group-header">
|
||||
<div class="listItemBodyText hsae-group-title">External List URLs</div>
|
||||
<div class="listItemBodyText secondary hsae-group-desc">Items from these lists are matched to your Jellyfin library by IMDb/TMDB ids</div>
|
||||
</div>
|
||||
${warn}
|
||||
<div class="hsae-external-list-rows">${rows}</div>
|
||||
<button type="button" class="emby-button raised" data-hsae-action="add-external-list-url" style="margin-top: 0.75em;">Add URL</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildQueriesHTML(state) {
|
||||
const queries = state.queries?.length ? state.queries : [createDefaultQuery()];
|
||||
const expandedStates = state.queryExpandedStates;
|
||||
@@ -1790,6 +1846,7 @@
|
||||
itemSource: inferItemSource(draft),
|
||||
queries: draft?.queries,
|
||||
items: draft?.items,
|
||||
externalListUrls: draft?.externalListUrls,
|
||||
hideWatched: Array.isArray(draft?.queries)
|
||||
&& draft.queries.length > 0
|
||||
&& draft.queries.every(q => q?.queryOptions?.IsUnplayed === true)
|
||||
@@ -2157,6 +2214,7 @@
|
||||
${buildItemSourceHTML(state)}
|
||||
${buildQueriesHTML(state)}
|
||||
${buildCustomItemsHTML(state)}
|
||||
${buildExternalListHTML(state)}
|
||||
${buildAdvancedOptionsHTML(state)}
|
||||
`;
|
||||
}
|
||||
@@ -2349,6 +2407,9 @@
|
||||
const userConfigChecked = dialog.querySelector('#hsae-user-configurable')?.checked === true;
|
||||
|
||||
section.name = dialog.querySelector('#section-name')?.value || '';
|
||||
const captionVal = dialog.querySelector('#section-caption')?.value?.trim() || '';
|
||||
if (captionVal) section.caption = captionVal;
|
||||
else section.caption = '';
|
||||
section.enabled = userEnabledChecked;
|
||||
section.userConfigurable = userConfigChecked;
|
||||
|
||||
@@ -2402,8 +2463,16 @@
|
||||
section.items = [];
|
||||
}
|
||||
section.queries = [];
|
||||
delete section.externalListUrls;
|
||||
} else if (itemSource === 'external') {
|
||||
delete section.items;
|
||||
section.queries = [];
|
||||
const urlInputs = dialog.querySelectorAll('[id^="hsae-external-list-url-"]');
|
||||
const urls = Array.from(urlInputs).map((el) => (el.value || '').trim()).filter(Boolean);
|
||||
section.externalListUrls = urls;
|
||||
} else {
|
||||
delete section.items;
|
||||
delete section.externalListUrls;
|
||||
const queryEditors = dialog.querySelectorAll('.query-editor');
|
||||
if (queryEditors.length > 0) {
|
||||
section.queries = Array.from(queryEditors).map(editor => {
|
||||
@@ -2563,6 +2632,7 @@
|
||||
const cacheWrap = editor.querySelector('.hsae-cache-wrap');
|
||||
const jellyfinOnlyBlocks = editor.querySelectorAll('.hsae-query-jellyfin-only');
|
||||
const staticOnlyBlocks = editor.querySelectorAll('.hsae-query-static-only');
|
||||
const sortExtrasBlocks = editor.querySelectorAll('.hsae-query-sort-extras');
|
||||
|
||||
const updateSourceVisibility = () => {
|
||||
const sourceType = sourceSelect?.value || 'jellyfin';
|
||||
@@ -2578,6 +2648,9 @@
|
||||
staticOnlyBlocks.forEach(block => {
|
||||
block.style.display = sourceType === 'static' ? '' : 'none';
|
||||
});
|
||||
sortExtrasBlocks.forEach(block => {
|
||||
block.style.display = sourceType === 'jellyfin' || sourceType === 'static' ? '' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
const updateEndpointVisibility = () => {
|
||||
@@ -3193,13 +3266,17 @@
|
||||
const newSource = actionEl.dataset.itemSource;
|
||||
if (!newSource || state.itemSource === newSource) return;
|
||||
|
||||
// Keep both queries and customItems in memory while switching;
|
||||
// Keep sources in memory while switching;
|
||||
// collectData clears the inactive source only on save/preview.
|
||||
collectStateFromForm(root, state);
|
||||
state.itemSource = newSource;
|
||||
if (newSource === 'custom') {
|
||||
const customEditor = getCustomItemsEditor();
|
||||
if (customEditor) customEditor.ensureCustomItemsInitialized(state);
|
||||
} else if (newSource === 'external') {
|
||||
if (!Array.isArray(state.externalListUrls) || !state.externalListUrls.length) {
|
||||
state.externalListUrls = [''];
|
||||
}
|
||||
} else {
|
||||
state.queries = state.queries?.length ? state.queries : [createDefaultQuery()];
|
||||
}
|
||||
@@ -3207,6 +3284,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'add-external-list-url') {
|
||||
collectStateFromForm(root, state);
|
||||
if (!Array.isArray(state.externalListUrls)) state.externalListUrls = [''];
|
||||
state.externalListUrls.push('');
|
||||
refreshEditorBody(modalInstance, { skipCollect: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete-query') {
|
||||
e.stopPropagation();
|
||||
const index = parseInt(actionEl.dataset.queryIndex, 10);
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
|
||||
const QUERY_WIZARD_STEPS = ['type', 'parent', 'query', 'settings', 'appearance', 'confirm'];
|
||||
const CUSTOM_WIZARD_STEPS = ['type', 'customItems', 'settings', 'appearance', 'confirm'];
|
||||
const EXTERNAL_WIZARD_STEPS = ['type', 'externalList', 'settings', 'appearance', 'confirm'];
|
||||
const WIZARD_STEP_LABELS = {
|
||||
type: 'Type',
|
||||
parent: 'Selection',
|
||||
query: 'Query',
|
||||
customItems: 'Create Items',
|
||||
externalList: 'External List',
|
||||
appearance: 'Appearance',
|
||||
settings: 'Settings',
|
||||
confirm: 'Confirm'
|
||||
@@ -28,6 +30,13 @@
|
||||
icon: 'widgets'
|
||||
};
|
||||
|
||||
const EXTERNAL_TYPE = {
|
||||
type: 'External',
|
||||
label: 'External List Section',
|
||||
description: 'Match an MDBList (or compatible) URL against your Jellyfin library by IMDb/TMDB provider IDs. Requires an MDBList API key in Home Screen General settings.',
|
||||
icon: 'link'
|
||||
};
|
||||
|
||||
const CUSTOM_IMAGE_FIELDS = [
|
||||
{ key: 'posterUrl', label: 'Poster URL', required: true },
|
||||
{ key: 'thumbUrl', label: 'Thumb URL' },
|
||||
@@ -77,13 +86,19 @@
|
||||
// --- Wizard parent data (live Jellyfin API) ---
|
||||
|
||||
function getWizardSteps(state) {
|
||||
return state?.parentType === 'Custom' ? CUSTOM_WIZARD_STEPS : QUERY_WIZARD_STEPS;
|
||||
if (state?.parentType === 'Custom') return CUSTOM_WIZARD_STEPS;
|
||||
if (state?.parentType === 'External') return EXTERNAL_WIZARD_STEPS;
|
||||
return QUERY_WIZARD_STEPS;
|
||||
}
|
||||
|
||||
function isCustomType(state) {
|
||||
return state?.parentType === 'Custom';
|
||||
}
|
||||
|
||||
function isExternalType(state) {
|
||||
return state?.parentType === 'External';
|
||||
}
|
||||
|
||||
function getStepIndex(step, state) {
|
||||
return getWizardSteps(state).indexOf(step);
|
||||
}
|
||||
@@ -251,6 +266,9 @@
|
||||
case 'customItems':
|
||||
collectCustomItemsFromForm(root, wizardState);
|
||||
break;
|
||||
case 'externalList':
|
||||
collectExternalListFromForm(root, wizardState);
|
||||
break;
|
||||
case 'appearance':
|
||||
collectAppearanceStepFromForm(root, wizardState);
|
||||
break;
|
||||
@@ -1093,6 +1111,12 @@
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (isExternalType(wizardState)) {
|
||||
return JSON.stringify({
|
||||
parentType: 'External',
|
||||
externalListUrls: wizardState.externalListUrls || []
|
||||
});
|
||||
}
|
||||
const parents = wizardState.parents || [];
|
||||
return JSON.stringify({
|
||||
parentType: wizardState.parentType,
|
||||
@@ -1372,6 +1396,12 @@
|
||||
`;
|
||||
}
|
||||
|
||||
function collectExternalListFromForm(root, wizardState) {
|
||||
const inputs = root.querySelectorAll('[id^="hsse-external-list-url-"]');
|
||||
wizardState.externalListUrls = Array.from(inputs).map((el) => (el.value || '').trim()).filter(Boolean);
|
||||
if (!wizardState.externalListUrls.length) wizardState.externalListUrls = [''];
|
||||
}
|
||||
|
||||
function collectSettingsFromForm(root, wizardState) {
|
||||
const nameEl = root.querySelector('#hsse-wizard-sectionName');
|
||||
const groupSelect = root.querySelector('#hsse-wizard-sectionGroup');
|
||||
@@ -1410,19 +1440,23 @@
|
||||
|
||||
function renderConfirmOverview(wizardState, config) {
|
||||
const isCustom = isCustomType(wizardState);
|
||||
const meta = isCustom ? CUSTOM_TYPE : PARENT_TYPES.find(t => t.type === wizardState.parentType);
|
||||
const isExternal = isExternalType(wizardState);
|
||||
const meta = isCustom ? CUSTOM_TYPE
|
||||
: (isExternal ? EXTERNAL_TYPE : PARENT_TYPES.find(t => t.type === wizardState.parentType));
|
||||
const parents = wizardState.parents || [];
|
||||
const selectionLabel = isCustom
|
||||
? ((wizardState.customItems || []).map(i => i.name?.trim()).filter(Boolean).join(', ') || '—')
|
||||
: (parents.length ? parents.map(p => p.name).join(', ') : '—');
|
||||
: (isExternal
|
||||
? ((wizardState.externalListUrls || []).filter(Boolean).join(', ') || '—')
|
||||
: (parents.length ? parents.map(p => p.name).join(', ') : '—'));
|
||||
const rows = [
|
||||
['Section Name', wizardState.sectionName || '—'],
|
||||
['Section Group', resolveGroupName(wizardState, config)],
|
||||
['Based On', meta?.label || wizardState.parentType || '—'],
|
||||
[isCustom ? 'Items' : 'Selection', selectionLabel]
|
||||
[isCustom ? 'Items' : (isExternal ? 'List URLs' : 'Selection'), selectionLabel]
|
||||
];
|
||||
|
||||
if (!isCustom) {
|
||||
if (!isCustom && !isExternal) {
|
||||
rows.push(
|
||||
['Item Types', wizardState.includeItemTypes.length ? formatItemTypesLabel(wizardState.includeItemTypes) : 'All types'],
|
||||
['Sort By', wizardState.sortBy === 'Default' ? 'Default' : `${humanizeLabel(wizardState.sortBy)} (${wizardState.sortOrder})`],
|
||||
@@ -1547,6 +1581,39 @@
|
||||
return section;
|
||||
}
|
||||
|
||||
if (parentType === 'External') {
|
||||
const urls = (wizardState.externalListUrls || []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||
const section = {
|
||||
...baseSection,
|
||||
id: baseSection.id,
|
||||
name: sectionName || 'External List',
|
||||
enabled: true,
|
||||
isCustom: true,
|
||||
renderMode: renderMode || 'Normal',
|
||||
cardFormat: renderMode === 'Spotlight' ? undefined : (cardFormat || 'Poster'),
|
||||
externalListUrls: urls,
|
||||
queries: []
|
||||
};
|
||||
|
||||
applySectionVisibilityToSection(section, wizardState, baseSection);
|
||||
|
||||
const targetGroup = resolveGroupName(wizardState, wizardState._config);
|
||||
if (targetGroup) {
|
||||
section._targetGroupName = targetGroup;
|
||||
}
|
||||
|
||||
if (renderMode === 'Spotlight' || renderMode === 'Random') {
|
||||
section.spotlightConfig = {
|
||||
spotlightLayout: spotlightLayout || 'Border',
|
||||
spotlightSize: spotlightSize || 'normal',
|
||||
tileCount: Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)),
|
||||
panAnimation: panAnimation !== false
|
||||
};
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
Limit: DEFAULT_SECTION_QUERY_LIMIT
|
||||
};
|
||||
@@ -1820,6 +1887,27 @@
|
||||
`;
|
||||
}
|
||||
|
||||
function renderExternalListHTML(wizardState) {
|
||||
const urls = Array.isArray(wizardState.externalListUrls) && wizardState.externalListUrls.length
|
||||
? wizardState.externalListUrls
|
||||
: [''];
|
||||
const apiKey = window.LibraryCacheUtils?.getLibraryCacheSettings?.()?.mdblistApiKey
|
||||
|| window.KefinHomeConfig2?.LIBRARY_CACHE?.mdblistApiKey
|
||||
|| '';
|
||||
const warn = !String(apiKey || '').trim()
|
||||
? `<p class="listItemBodyText secondary" style="color: var(--error-color, #e57373);">MDBList API key is not configured. Add it under Home Screen → General settings before using External Lists.</p>`
|
||||
: '';
|
||||
return `
|
||||
<div class="hsse-section-block">
|
||||
<div class="listItemBodyText hsse-section-label">External List URLs</div>
|
||||
<p class="listItemBodyText secondary hsse-field-hint">Paste one or more MDBList URLs. Matched against your library by IMDb/TMDB ids.</p>
|
||||
${warn}
|
||||
${urls.map((url, index) => hsseBuildTextInput(`hsse-external-list-url-${index}`, url || '', `List URL ${index + 1}`, 'url', 'https://mdblist.com/...')).join('')}
|
||||
<button type="button" class="emby-button raised" data-hsse-action="wizard-add-external-list-url" style="margin-top: 0.75em;">Add URL</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWizardBodyHTML(wizardState, config) {
|
||||
const step = wizardState.step;
|
||||
|
||||
@@ -1842,6 +1930,13 @@
|
||||
<div class="listItemBodyText secondary hsse-type-desc">${escapeHtml(CUSTOM_TYPE.description)}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" class="hsse-type-card hsse-type-card-span${wizardState.parentType === 'External' ? ' hsse-active' : ''}" data-hsse-action="wizard-select-type" data-type="External">
|
||||
${renderMaterialIcon(EXTERNAL_TYPE.icon, 'hsse-type-icon')}
|
||||
<div>
|
||||
<div class="listItemBodyText hsse-type-label">${escapeHtml(EXTERNAL_TYPE.label)}</div>
|
||||
<div class="listItemBodyText secondary hsse-type-desc">${escapeHtml(EXTERNAL_TYPE.description)}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1850,6 +1945,10 @@
|
||||
return renderCustomItemsHTML(wizardState);
|
||||
}
|
||||
|
||||
if (step === 'externalList') {
|
||||
return renderExternalListHTML(wizardState);
|
||||
}
|
||||
|
||||
if (step === 'parent') {
|
||||
const meta = PARENT_TYPES.find(t => t.type === wizardState.parentType);
|
||||
|
||||
@@ -1960,6 +2059,7 @@
|
||||
return `Choose a ${meta?.label?.toLowerCase() || 'item'}`;
|
||||
}
|
||||
if (wizardState.step === 'customItems') return 'Create Items';
|
||||
if (wizardState.step === 'externalList') return 'External List';
|
||||
return 'Create a Home Screen Section';
|
||||
}
|
||||
return 'Create a Section';
|
||||
@@ -3748,6 +3848,7 @@
|
||||
parentListError: null,
|
||||
personSearchResults: [],
|
||||
customItems: [],
|
||||
externalListUrls: [''],
|
||||
customItemAdditionalExpanded: {},
|
||||
customItemEditorIndex: null,
|
||||
includeItemTypes: [],
|
||||
@@ -4116,20 +4217,38 @@
|
||||
if (selected === 'Custom') {
|
||||
wizardState.customItems = [createBlankCustomItem()];
|
||||
wizardState.customItemEditorIndex = null;
|
||||
wizardState.externalListUrls = [''];
|
||||
advanceToStep(root, 'customItems');
|
||||
} else if (selected === 'External') {
|
||||
wizardState.customItems = [];
|
||||
wizardState.externalListUrls = wizardState.externalListUrls?.length
|
||||
? wizardState.externalListUrls
|
||||
: [''];
|
||||
advanceToStep(root, 'externalList');
|
||||
} else {
|
||||
wizardState.customItems = [];
|
||||
wizardState.externalListUrls = [''];
|
||||
advanceToStep(root, 'parent');
|
||||
}
|
||||
} else if (selected === 'Custom') {
|
||||
wizardState.customItemEditorIndex = null;
|
||||
advanceToStep(root, 'customItems');
|
||||
} else if (selected === 'External') {
|
||||
advanceToStep(root, 'externalList');
|
||||
} else {
|
||||
advanceToStep(root, 'parent');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'wizard-add-external-list-url': {
|
||||
collectExternalListFromForm(root, wizardState);
|
||||
if (!Array.isArray(wizardState.externalListUrls)) wizardState.externalListUrls = [''];
|
||||
wizardState.externalListUrls.push('');
|
||||
refreshModal(root);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'wizard-open-custom-item-editor': {
|
||||
const openIndex = parseInt(el.dataset.itemIndex, 10);
|
||||
if (Number.isNaN(openIndex)) break;
|
||||
@@ -4297,6 +4416,14 @@
|
||||
wizardState.sectionName = 'Custom Section';
|
||||
}
|
||||
}
|
||||
if (wizardState.step === 'externalList') {
|
||||
const urls = (wizardState.externalListUrls || []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||
if (!urls.length) break;
|
||||
wizardState.externalListUrls = urls;
|
||||
if (!wizardState.sectionName?.trim()) {
|
||||
wizardState.sectionName = 'External List';
|
||||
}
|
||||
}
|
||||
if (wizardState.step === 'settings' && wizardState.sectionVisibility === 'seasonal') {
|
||||
if (!hasValidSeasonalDates(wizardState)) break;
|
||||
}
|
||||
|
||||
+138
-161
@@ -356,7 +356,7 @@
|
||||
name: 'peopleCache',
|
||||
script: 'peopleCache.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
dependencies: ['libraryCacheUtils', 'userHelper', 'moviesCache', 'seriesCache', 'indexedDBCache', 'apiHelper'],
|
||||
description: 'People cache for KefinTweaks'
|
||||
},
|
||||
{
|
||||
@@ -366,12 +366,40 @@
|
||||
dependencies: [],
|
||||
description: 'Studios cache for KefinTweaks'
|
||||
},
|
||||
{
|
||||
name: 'libraryCacheUtils',
|
||||
script: 'libraryCacheUtils.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
description: 'Shared strip/provider helpers for library caches'
|
||||
},
|
||||
{
|
||||
name: 'moviesCache',
|
||||
script: 'moviesCache.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
description: 'Movies cache for KefinTweaks'
|
||||
dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'],
|
||||
description: 'Movies library cache for KefinTweaks'
|
||||
},
|
||||
{
|
||||
name: 'seriesCache',
|
||||
script: 'seriesCache.js',
|
||||
css: null,
|
||||
dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'],
|
||||
description: 'Series library cache for KefinTweaks'
|
||||
},
|
||||
{
|
||||
name: 'libraryCache',
|
||||
script: 'libraryCache.js',
|
||||
css: null,
|
||||
dependencies: ['moviesCache', 'seriesCache'],
|
||||
description: 'Library cache facade for KefinTweaks'
|
||||
},
|
||||
{
|
||||
name: 'externalList',
|
||||
script: 'externalList.js',
|
||||
css: null,
|
||||
dependencies: ['libraryCache', 'libraryCacheUtils', 'apiHelper'],
|
||||
description: 'MDBList external list matching for home sections'
|
||||
},
|
||||
{
|
||||
name: 'skinManager',
|
||||
@@ -388,6 +416,13 @@
|
||||
dependencies: ['dataHelper'],
|
||||
description: 'API helper functions for common Jellyfin operations'
|
||||
},
|
||||
{
|
||||
name: 'sectionHelper',
|
||||
script: 'sectionHelper.js',
|
||||
css: null,
|
||||
dependencies: ['apiHelper', 'cardBuilder', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'homeScreenConfig2', 'dataHelper'],
|
||||
description: 'Section loading and progressive discovery resolve helpers'
|
||||
},
|
||||
{
|
||||
name: 'cardBuilder',
|
||||
script: 'cardBuilder.js',
|
||||
@@ -483,7 +518,7 @@
|
||||
name: 'homeScreen',
|
||||
script: 'homeScreen3.js',
|
||||
css: 'homeScreen.css',
|
||||
dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'],
|
||||
dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'userHelper', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'libraryCacheUtils', 'externalList', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'sectionHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'],
|
||||
priority: true, // Load immediately after dependencies to reduce UI disruption
|
||||
description: 'Adds custom home screen sections'
|
||||
},
|
||||
@@ -1088,6 +1123,32 @@
|
||||
console.log(`[KefinTweaks Injector] Successfully loaded: ${scriptDef.name}`);
|
||||
}
|
||||
|
||||
|
||||
async function ensureKefinTweaksLoader(scriptRoot) {
|
||||
if (window.KefinTweaksLoader) return window.KefinTweaksLoader;
|
||||
const base = scriptRoot.endsWith('/') ? scriptRoot : scriptRoot + '/';
|
||||
const url = base + 'kefinTweaks-loader.js';
|
||||
await new Promise((resolve, reject) => {
|
||||
if (document.querySelector('script[src="' + url + '"]')) {
|
||||
const start = Date.now();
|
||||
const wait = () => {
|
||||
if (window.KefinTweaksLoader) return resolve();
|
||||
if (Date.now() - start > 15000) return reject(new Error('Timed out waiting for KefinTweaksLoader'));
|
||||
setTimeout(wait, 50);
|
||||
};
|
||||
return wait();
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = false;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load kefinTweaks-loader.js'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
if (!window.KefinTweaksLoader) throw new Error('KefinTweaksLoader not available');
|
||||
return window.KefinTweaksLoader;
|
||||
}
|
||||
|
||||
async function loadConfigurationJS() {
|
||||
const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'watchTogether-configuration', 'userManager-configuration', 'apiHelper'];
|
||||
for (const depName of configDependencyNames) {
|
||||
@@ -1106,182 +1167,98 @@
|
||||
await loadCSS('configuration.css');
|
||||
}
|
||||
|
||||
// Main initialization function
|
||||
// Main initialization: live load plan (fills gaps vs baked preload) + stamp sync
|
||||
async function initialize() {
|
||||
console.log(`[KefinTweaks Injector] Starting KefinTweaks initialization...`);
|
||||
|
||||
console.log('[KefinTweaks Injector] Starting KefinTweaks initialization...');
|
||||
|
||||
const scriptRoot = await getScriptRoot();
|
||||
if (!scriptRoot) {
|
||||
console.log('[KefinTweaks Injector] kefinTweaksRoot is not configured. Please configure KefinTweaks using the installer before scripts can be loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve Jellyfin major for version-gated scripts (also used by configuration UI)
|
||||
const majorVersion = await getCurrentMajorServerVersion();
|
||||
syncCachedMajorOnApi(majorVersion);
|
||||
console.log(`[KefinTweaks Injector] Jellyfin major version: ${majorVersion ?? 'unknown'}`);
|
||||
|
||||
// Check if KefinTweaks is enabled in config
|
||||
const isKefinTweaksEnabled = window.KefinTweaksConfig?.enabled !== false;
|
||||
|
||||
if (!isKefinTweaksEnabled) {
|
||||
console.log('[KefinTweaks Injector] KefinTweaks is disabled in configuration. Only loading configuration UI requirements for admin access.');
|
||||
|
||||
try {
|
||||
// Ensure configuration dependencies are loaded so the admin UI works
|
||||
await loadConfigurationJS();
|
||||
console.log('[KefinTweaks Injector] Configuration script loaded (KefinTweaks is disabled)');
|
||||
} catch (error) {
|
||||
console.warn('[KefinTweaks Injector] Failed to load configuration UI while disabled:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if (!validateConfiguration()) {
|
||||
console.error('[KefinTweaks Injector] Configuration validation failed. Aborting.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get enabled scripts compatible with this Jellyfin major
|
||||
const enabledScripts = SCRIPT_DEFINITIONS.filter(script =>
|
||||
ENABLED_SCRIPTS[script.name] && isScriptCompatible(script, majorVersion)
|
||||
);
|
||||
|
||||
// Step 1: Collect all dependencies from all enabled scripts
|
||||
const allDependencyNames = new Set();
|
||||
for (const script of enabledScripts) {
|
||||
const deps = collectAllDependencies(script);
|
||||
deps.forEach(dep => allDependencyNames.add(dep));
|
||||
}
|
||||
|
||||
// Step 2: Separate dependencies from non-dependencies
|
||||
const dependencyScripts = SCRIPT_DEFINITIONS.filter(script =>
|
||||
allDependencyNames.has(script.name) && ENABLED_SCRIPTS[script.name]
|
||||
);
|
||||
|
||||
const nonDependencyScripts = enabledScripts.filter(script =>
|
||||
!allDependencyNames.has(script.name)
|
||||
);
|
||||
|
||||
// Step 2.5: Separate priority scripts from regular non-dependency scripts
|
||||
const priorityScripts = nonDependencyScripts.filter(script => script.priority === true);
|
||||
const regularScripts = nonDependencyScripts.filter(script => !script.priority);
|
||||
|
||||
console.log(`[KefinTweaks Injector] Found ${dependencyScripts.length} dependency scripts:`,
|
||||
dependencyScripts.map(s => s.name));
|
||||
console.log(`[KefinTweaks Injector] Found ${priorityScripts.length} priority scripts:`,
|
||||
priorityScripts.map(s => s.name));
|
||||
console.log(`[KefinTweaks Injector] Found ${regularScripts.length} regular scripts:`,
|
||||
regularScripts.map(s => s.name));
|
||||
|
||||
let Loader;
|
||||
try {
|
||||
// Step 3: Load all dependencies first
|
||||
console.log('[KefinTweaks Injector] Loading dependencies first...');
|
||||
console.log(`[KefinTweaks Injector] Dependency load order:`, dependencyScripts.map(s => s.name));
|
||||
|
||||
let dependencyLoadPromises = [];
|
||||
dependencyLoadPromises.push(...dependencyScripts.map(script => loadScriptSync(script)));
|
||||
await Promise.all(dependencyLoadPromises);
|
||||
|
||||
// Step 4: Load priority scripts immediately after dependencies
|
||||
if (priorityScripts.length > 0) {
|
||||
console.log('[KefinTweaks Injector] Loading priority scripts...');
|
||||
console.log(`[KefinTweaks Injector] Priority load order:`, priorityScripts.map(s => s.name));
|
||||
let priorityLoadPromises = [];
|
||||
priorityLoadPromises.push(...priorityScripts.map(script => loadScriptSync(script)));
|
||||
await Promise.all(priorityLoadPromises);
|
||||
}
|
||||
|
||||
// Step 5: Load regular non-dependencies in parallel (their dependencies are already loaded)
|
||||
let loadPromises = [];
|
||||
console.log('[KefinTweaks Injector] Loading regular non-dependencies...');
|
||||
loadPromises.push(...regularScripts.map(script => loadScriptSync(script)));
|
||||
await Promise.all(loadPromises);
|
||||
|
||||
console.log('[KefinTweaks Injector] All scripts loaded successfully!');
|
||||
|
||||
// Always load configuration.js for admin UI (loads after other scripts)
|
||||
console.log('[KefinTweaks Injector] Loading configuration script...');
|
||||
try {
|
||||
await loadConfigurationJS();
|
||||
console.log('[KefinTweaks Injector] Configuration script loaded successfully');
|
||||
} catch (error) {
|
||||
console.warn('[KefinTweaks Injector] Failed to load configuration script:', error);
|
||||
}
|
||||
|
||||
// Dispatch custom event when all scripts are loaded
|
||||
const event = new CustomEvent('kefinTweaksLoaded', {
|
||||
detail: {
|
||||
loadedScripts: enabledScripts.map(s => s.name),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
Loader = await ensureKefinTweaksLoader(scriptRoot);
|
||||
} catch (err) {
|
||||
console.error('[KefinTweaks Injector] Failed to load kefinTweaks-loader.js:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS);
|
||||
const majorVersion = await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
console.log('[KefinTweaks Injector] Jellyfin major version:', majorVersion ?? 'unknown');
|
||||
|
||||
const config = window.KefinTweaksConfig || {};
|
||||
const root = Loader.normalizeRoot(config.kefinTweaksRoot || '');
|
||||
|
||||
try {
|
||||
const plan = Loader.buildLoadPlan(config, majorVersion, {
|
||||
root,
|
||||
urlSuffix,
|
||||
configOnly: config.enabled === false
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
console.log('[KefinTweaks Injector] Applying ordered assets:', plan.assets.length, 'stamp=', plan.stamp);
|
||||
// Dedupe against tags already injected by baked KefinTweaks-injector
|
||||
Loader.applyAssetsToDocument(plan.assets);
|
||||
document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', {
|
||||
detail: {
|
||||
loadedScripts: plan.scriptNames,
|
||||
stamp: plan.stamp,
|
||||
timestamp: new Date().toISOString(),
|
||||
live: true
|
||||
}
|
||||
}));
|
||||
console.log('[KefinTweaks Injector] Live ordered inject complete');
|
||||
|
||||
if (typeof Loader.syncKefinTweaksInjector === 'function') {
|
||||
const syncResult = await Loader.syncKefinTweaksInjector(plan);
|
||||
if (syncResult?.synced) {
|
||||
console.log('[KefinTweaks Injector] Baked KefinTweaks-injector synced to stamp:', syncResult.stamp);
|
||||
} else if (syncResult?.reason && syncResult.reason !== 'stamp match' && syncResult.reason !== 'not admin' && syncResult.reason !== 'not logged in') {
|
||||
console.warn('[KefinTweaks Injector] Injector sync skipped:', syncResult.reason);
|
||||
} else {
|
||||
console.log('[KefinTweaks Injector] Injector sync:', syncResult?.reason || 'done');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[KefinTweaks Injector] Error during initialization:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Utility functions for debugging and configuration
|
||||
window.KefinTweaks = {
|
||||
// Populated in initialize via KefinTweaksLoader.ensureKefinTweaksApi; stub for early callers
|
||||
window.KefinTweaks = window.KefinTweaks || {
|
||||
_jellyfinMajorVersion: null,
|
||||
|
||||
// Get current configuration
|
||||
getConfig: () => ({ ...ENABLED_SCRIPTS }),
|
||||
|
||||
// Get script definitions
|
||||
getScripts: () => [...SCRIPT_DEFINITIONS],
|
||||
|
||||
getMajorServerVersion,
|
||||
getCurrentMajorServerVersion,
|
||||
getJellyfinMajorVersion: () => cachedJellyfinMajorVersion,
|
||||
|
||||
isScriptCompatible: (scriptNameOrDef) => {
|
||||
const scriptDef = typeof scriptNameOrDef === 'string'
|
||||
? SCRIPT_DEFINITIONS.find(s => s.name === scriptNameOrDef)
|
||||
: scriptNameOrDef;
|
||||
if (!scriptDef) return true;
|
||||
return isScriptCompatible(scriptDef, cachedJellyfinMajorVersion);
|
||||
},
|
||||
|
||||
// Check if a script is loaded
|
||||
isScriptLoaded: (scriptName) => {
|
||||
const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName);
|
||||
if (!scriptDef) return false;
|
||||
|
||||
const scriptElement = document.querySelector(`script[src*="${scriptDef.script}"]`);
|
||||
return !!scriptElement;
|
||||
},
|
||||
|
||||
// Reload all scripts (useful for development)
|
||||
reload: () => {
|
||||
console.log('[KefinTweaks Injector] Reloading all scripts...');
|
||||
initialize();
|
||||
},
|
||||
|
||||
// Load a specific script (useful for dynamic loading)
|
||||
loadScript: async (scriptName) => {
|
||||
const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName);
|
||||
if (!scriptDef) {
|
||||
throw new Error(`Script '${scriptName}' not found`);
|
||||
parseMajorVersion: function (version) {
|
||||
if (!version || typeof version !== 'string') return null;
|
||||
const versionParts = version.split('.');
|
||||
if (versionParts[0] !== '10') {
|
||||
const majorVersion = parseInt(versionParts[0], 10);
|
||||
return Number.isNaN(majorVersion) ? null : majorVersion;
|
||||
}
|
||||
|
||||
if (!ENABLED_SCRIPTS[scriptName]) {
|
||||
throw new Error(`Script '${scriptName}' is disabled in configuration`);
|
||||
if (versionParts.length >= 2) {
|
||||
const majorVersion = parseInt(versionParts[1], 10);
|
||||
if (!Number.isNaN(majorVersion)) return majorVersion;
|
||||
}
|
||||
|
||||
const major = await getCurrentMajorServerVersion();
|
||||
syncCachedMajorOnApi(major);
|
||||
if (!isScriptCompatible(scriptDef, major)) {
|
||||
throw new Error(`Script '${scriptName}' is not compatible with this Jellyfin version`);
|
||||
return null;
|
||||
},
|
||||
getJellyfinMajorVersion: async function () {
|
||||
if (window.KefinTweaksLoader) {
|
||||
window.KefinTweaksLoader.ensureKefinTweaksApi();
|
||||
return window.KefinTweaks.getJellyfinMajorVersion();
|
||||
}
|
||||
|
||||
await loadScriptWithDependencies(scriptDef);
|
||||
}
|
||||
return this._jellyfinMajorVersion;
|
||||
},
|
||||
getScripts: function () {
|
||||
return (window.KefinTweaksLoader && window.KefinTweaksLoader.SCRIPT_DEFINITIONS)
|
||||
? window.KefinTweaksLoader.SCRIPT_DEFINITIONS.slice()
|
||||
: (typeof SCRIPT_DEFINITIONS !== 'undefined' ? SCRIPT_DEFINITIONS.slice() : []);
|
||||
},
|
||||
reload: function () { initialize(); }
|
||||
};
|
||||
|
||||
|
||||
// Check if user is admin (with timeout for login)
|
||||
async function checkAdminWithTimeout(maxWaitMs = 5000) {
|
||||
const startTime = Date.now();
|
||||
|
||||
+255
-18
@@ -186,7 +186,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Save configuration to JavaScript Injector
|
||||
// Save configuration to JavaScript Injector (Config + KefinTweaks-injector preload)
|
||||
async function saveConfigToJavaScriptInjector(config) {
|
||||
try {
|
||||
const pluginId = await findJavaScriptInjectorPlugin();
|
||||
@@ -202,19 +202,53 @@
|
||||
|
||||
window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
|
||||
|
||||
const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex(
|
||||
script => script.Name === 'KefinTweaks-Config'
|
||||
);
|
||||
let injectorScriptContent = null;
|
||||
try {
|
||||
const rootRaw = config.kefinTweaksRoot || '';
|
||||
const root = rootRaw.endsWith('/') ? rootRaw : rootRaw + '/';
|
||||
if (root && root !== '/') {
|
||||
await loadKefinTweaksLoaderFromRoot(root);
|
||||
const Loader = window.KefinTweaksLoader;
|
||||
if (Loader) {
|
||||
Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS);
|
||||
let major = null;
|
||||
try {
|
||||
major = await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks Installer] Could not resolve Jellyfin major for injector bake:', e);
|
||||
}
|
||||
const plan = Loader.buildLoadPlan(config, major, {
|
||||
root,
|
||||
configOnly: config.enabled === false
|
||||
});
|
||||
injectorScriptContent = Loader.buildInjectorScript(plan);
|
||||
console.log('[KefinTweaks Installer] Built KefinTweaks-injector with', plan.assets.length, 'assets');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks Installer] Failed to build KefinTweaks-injector:', e);
|
||||
}
|
||||
|
||||
if (existingScriptIndex !== -1) {
|
||||
injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent;
|
||||
if (window.KefinTweaksLoader) {
|
||||
injectorConfig.CustomJavaScripts = window.KefinTweaksLoader.upsertInjectorEntries(
|
||||
injectorConfig.CustomJavaScripts,
|
||||
{ configScriptContent: scriptContent, injectorScriptContent }
|
||||
);
|
||||
} else {
|
||||
injectorConfig.CustomJavaScripts.push({
|
||||
Name: 'KefinTweaks-Config',
|
||||
Script: scriptContent,
|
||||
Enabled: true,
|
||||
RequiresAuthentication: false
|
||||
});
|
||||
const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex(
|
||||
script => script.Name === 'KefinTweaks-Config'
|
||||
);
|
||||
if (existingScriptIndex !== -1) {
|
||||
injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent;
|
||||
injectorConfig.CustomJavaScripts[existingScriptIndex].Enabled = true;
|
||||
} else {
|
||||
injectorConfig.CustomJavaScripts.push({
|
||||
Name: 'KefinTweaks-Config',
|
||||
Script: scriptContent,
|
||||
Enabled: true,
|
||||
RequiresAuthentication: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const server = ApiClient._serverAddress;
|
||||
@@ -239,6 +273,144 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
|
||||
}
|
||||
}
|
||||
|
||||
function loadKefinTweaksLoaderFromRoot(root) {
|
||||
const normalized = root.endsWith('/') ? root : root + '/';
|
||||
const url = `${normalized}scripts/kefinTweaks-loader.js`;
|
||||
if (window.KefinTweaksLoader) return Promise.resolve(window.KefinTweaksLoader);
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[src="${url}"]`);
|
||||
if (existing) {
|
||||
const start = Date.now();
|
||||
const wait = () => {
|
||||
if (window.KefinTweaksLoader) return resolve(window.KefinTweaksLoader);
|
||||
if (Date.now() - start > 15000) return reject(new Error('Timed out waiting for KefinTweaksLoader'));
|
||||
setTimeout(wait, 50);
|
||||
};
|
||||
return wait();
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = false;
|
||||
script.onload = () => resolve(window.KefinTweaksLoader);
|
||||
script.onerror = () => reject(new Error('Failed to load ' + url));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function isLoggedInForInjectorWrite() {
|
||||
try {
|
||||
return !!(window.ApiClient
|
||||
&& window.ApiClient._loggedIn
|
||||
&& typeof window.ApiClient.accessToken === 'function'
|
||||
&& window.ApiClient.accessToken()
|
||||
&& window.ApiClient._serverAddress);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLoginForInjectorWrite(maxWaitMs = 10000, checkInterval = 250) {
|
||||
if (isLoggedInForInjectorWrite()) return true;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
await new Promise((r) => setTimeout(r, checkInterval));
|
||||
if (isLoggedInForInjectorWrite()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/update KefinTweaks-injector (and Config) in JS Injector when missing.
|
||||
* @returns {Promise<{ ok: boolean, plan?: object, reason?: string }>}
|
||||
*/
|
||||
async function ensureKefinTweaksInjectorEntry(config) {
|
||||
const rootRaw = config?.kefinTweaksRoot || '';
|
||||
if (!rootRaw) {
|
||||
return { ok: false, reason: 'kefinTweaksRoot not configured' };
|
||||
}
|
||||
|
||||
if (!isLoggedInForInjectorWrite()) {
|
||||
return { ok: false, reason: 'not logged in' };
|
||||
}
|
||||
|
||||
try {
|
||||
const root = rootRaw.endsWith('/') ? rootRaw : rootRaw + '/';
|
||||
await loadKefinTweaksLoaderFromRoot(root);
|
||||
const Loader = window.KefinTweaksLoader;
|
||||
if (!Loader) {
|
||||
return { ok: false, reason: 'KefinTweaksLoader unavailable' };
|
||||
}
|
||||
|
||||
Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS);
|
||||
let major = null;
|
||||
try {
|
||||
major = await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks Installer] Could not resolve Jellyfin major for auto-create:', e);
|
||||
}
|
||||
|
||||
const plan = Loader.buildLoadPlan(config, major, {
|
||||
root,
|
||||
configOnly: config.enabled === false
|
||||
});
|
||||
const injectorScriptContent = Loader.buildInjectorScript(plan);
|
||||
const configScriptContent = `// KefinTweaks Configuration
|
||||
// This file is automatically generated by KefinTweaks Installer
|
||||
// Do not edit manually unless you know what you're doing
|
||||
|
||||
window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
|
||||
|
||||
const pluginId = await findJavaScriptInjectorPlugin();
|
||||
if (!pluginId) {
|
||||
return { ok: false, reason: 'JavaScript Injector plugin not found' };
|
||||
}
|
||||
const injectorConfig = await getJavaScriptInjectorConfig(pluginId);
|
||||
if (!injectorConfig) {
|
||||
return { ok: false, reason: 'Could not read JS Injector configuration' };
|
||||
}
|
||||
if (!injectorConfig.CustomJavaScripts) {
|
||||
injectorConfig.CustomJavaScripts = [];
|
||||
}
|
||||
|
||||
injectorConfig.CustomJavaScripts = Loader.upsertInjectorEntries(
|
||||
injectorConfig.CustomJavaScripts,
|
||||
{ configScriptContent, injectorScriptContent }
|
||||
);
|
||||
|
||||
const server = ApiClient._serverAddress;
|
||||
const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': getAuthHeader(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(injectorConfig)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `POST failed HTTP ${response.status}: ${response.statusText}`
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[KefinTweaks Installer] Auto-created KefinTweaks-injector with', plan.assets.length, 'assets');
|
||||
return { ok: true, plan };
|
||||
} catch (err) {
|
||||
console.warn('[KefinTweaks Installer] ensureKefinTweaksInjectorEntry failed:', err);
|
||||
return { ok: false, reason: err?.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
function hasEnabledKefinInjectorEntry(customJavaScripts) {
|
||||
if (window.KefinTweaksLoader?.hasEnabledInjectorEntry) {
|
||||
return window.KefinTweaksLoader.hasEnabledInjectorEntry(customJavaScripts);
|
||||
}
|
||||
return (customJavaScripts || []).some(
|
||||
(s) => s.Name === 'KefinTweaks-injector' && s.Enabled !== false
|
||||
);
|
||||
}
|
||||
|
||||
const LATEST_RELEASE_NAME = 'Latest';
|
||||
const DEVELOPMENT_NAME = 'Development';
|
||||
const EXPERIMENTAL_NAME = 'Experimental';
|
||||
@@ -805,7 +977,20 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
|
||||
versionDisplay = sourceInfo.version;
|
||||
}
|
||||
|
||||
// Load injector.js from the configured root
|
||||
// Always load live injector.js for plan apply + stamp sync (baked preload may be stale)
|
||||
let hasPreload = false;
|
||||
try {
|
||||
const pluginId = await findJavaScriptInjectorPlugin();
|
||||
const injCfg = await getJavaScriptInjectorConfig(pluginId);
|
||||
hasPreload = (injCfg.CustomJavaScripts || []).some(
|
||||
(s) => s.Name === 'KefinTweaks-injector' && s.Enabled !== false
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks Installer] Could not verify KefinTweaks-injector after save:', e);
|
||||
}
|
||||
if (hasPreload) {
|
||||
console.log('[KefinTweaks Installer] KefinTweaks-injector saved; still loading injector.js for live plan + sync');
|
||||
}
|
||||
await loadInjectorFromRoot(kefinTweaksRoot);
|
||||
|
||||
// Show success modal
|
||||
@@ -885,6 +1070,17 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
|
||||
console.log('[KefinTweaks Installer] Renamed KefinTweaks-Config to backup and disabled it');
|
||||
}
|
||||
|
||||
// Also disable/rename KefinTweaks-injector preload entry
|
||||
const injectorScriptIndex = injectorConfig.CustomJavaScripts.findIndex(
|
||||
script => script.Name === 'KefinTweaks-injector'
|
||||
);
|
||||
if (injectorScriptIndex !== -1) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||
injectorConfig.CustomJavaScripts[injectorScriptIndex].Name = `KefinTweaks-injector-Backup-${timestamp}`;
|
||||
injectorConfig.CustomJavaScripts[injectorScriptIndex].Enabled = false;
|
||||
console.log('[KefinTweaks Installer] Renamed KefinTweaks-injector to backup and disabled it');
|
||||
}
|
||||
|
||||
// Step 2: Find and disable installer script
|
||||
// Look for script containing kefinTweaks-plugin.js (flexible pattern to match script.src*kefinTweaks-plugin.js)
|
||||
const installerScriptIndex = injectorConfig.CustomJavaScripts.findIndex(
|
||||
@@ -1625,7 +1821,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
|
||||
});
|
||||
}
|
||||
|
||||
// Check if config exists and load injector.js if ready
|
||||
// Check if config exists; auto-create KefinTweaks-injector when missing; always load live injector.js
|
||||
async function checkAndLoadInjector() {
|
||||
try {
|
||||
const config = await getKefinTweaksConfig();
|
||||
@@ -1633,12 +1829,53 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
|
||||
|
||||
// Before injector/utils: hide fallback page chrome for custom routes
|
||||
injectCustomPageStyles(config);
|
||||
|
||||
if (root && root !== '') {
|
||||
await loadInjectorFromRoot(root);
|
||||
} else {
|
||||
|
||||
if (!root || root === '') {
|
||||
console.log('[KefinTweaks Installer] kefinTweaksRoot not configured. Please configure via the Plugins page.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Plugin config GET/POST needs a session; wait briefly so admin first-load can auto-create
|
||||
await waitForLoginForInjectorWrite();
|
||||
|
||||
let injectorConfig = null;
|
||||
try {
|
||||
const pluginId = await findJavaScriptInjectorPlugin();
|
||||
if (pluginId) {
|
||||
injectorConfig = await getJavaScriptInjectorConfig(pluginId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks Installer] Could not read JS Injector config:', e);
|
||||
}
|
||||
|
||||
const hasPreload = !!(injectorConfig && hasEnabledKefinInjectorEntry(injectorConfig.CustomJavaScripts));
|
||||
if (hasPreload) {
|
||||
console.log('[KefinTweaks Installer] KefinTweaks-injector present; still loading injector.js for live plan + sync');
|
||||
} else {
|
||||
// Entry missing: try to create + apply assets now (admin/logged-in)
|
||||
const ensured = await ensureKefinTweaksInjectorEntry(config);
|
||||
if (ensured.ok && ensured.plan) {
|
||||
window.KefinTweaksScriptsPreloaded = true;
|
||||
if (window.KefinTweaksLoader) {
|
||||
window.KefinTweaksLoader.ensureKefinTweaksApi(window.KefinTweaksLoader.SCRIPT_DEFINITIONS);
|
||||
window.KefinTweaksLoader.applyAssetsToDocument(ensured.plan.assets);
|
||||
}
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', {
|
||||
detail: {
|
||||
stamp: ensured.plan.stamp,
|
||||
timestamp: new Date().toISOString(),
|
||||
autoCreated: true
|
||||
}
|
||||
}));
|
||||
} catch (e) { /* ignore */ }
|
||||
console.log('[KefinTweaks Installer] Applied KefinTweaks-injector assets after auto-create');
|
||||
} else {
|
||||
console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js');
|
||||
}
|
||||
}
|
||||
|
||||
await loadInjectorFromRoot(root);
|
||||
} catch (error) {
|
||||
console.error('[KefinTweaks Installer] Error checking config:', error);
|
||||
}
|
||||
|
||||
@@ -352,7 +352,8 @@ window.KefinTweaksConfig = {
|
||||
},
|
||||
"starringActorRecentlyWatched": {
|
||||
"enabled": true,
|
||||
"name": "Starring [Actor] because you recently watched [Movie]",
|
||||
"name": "Starring {Person}",
|
||||
"caption": "because you recently watched {Title}",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
@@ -360,7 +361,8 @@ window.KefinTweaksConfig = {
|
||||
},
|
||||
"directedByDirectorRecentlyWatched": {
|
||||
"enabled": true,
|
||||
"name": "Directed by [Director] because you recently watched [Movie]",
|
||||
"name": "Directed by {Person}",
|
||||
"caption": "because you recently watched {Title}",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
@@ -368,7 +370,8 @@ window.KefinTweaksConfig = {
|
||||
},
|
||||
"writtenByWriterRecentlyWatched": {
|
||||
"enabled": true,
|
||||
"name": "Written by [Writer] because you recently watched [Movie]",
|
||||
"name": "Written by {Person}",
|
||||
"caption": "because you recently watched {Title}",
|
||||
"itemLimit": 16,
|
||||
"sortOrder": "Random",
|
||||
"sortOrderDirection": "Ascending",
|
||||
|
||||
+4
-1
@@ -151,7 +151,10 @@
|
||||
|
||||
function actorFilmography(actor) {
|
||||
const items = actor.items || actor.actorItems || [];
|
||||
return items.filter((m) => m && m.Id);
|
||||
// PeopleCache may return raw movie Id strings; normalize to { Id } for gatherThreeTrickplayMovies
|
||||
return items
|
||||
.map((m) => (typeof m === 'string' || typeof m === 'number' ? { Id: String(m) } : m))
|
||||
.filter((m) => m && m.Id);
|
||||
}
|
||||
|
||||
async function fetchFullItem(apiClient, itemId) {
|
||||
|
||||
@@ -32,6 +32,10 @@ interface HomeScreenSection {
|
||||
userConfigurable?: boolean; // Whether users can toggle/reorder this section
|
||||
useGaplessCards?: boolean; // When true, remove gaps between cards (row or grid)
|
||||
|
||||
// Loads items from an external URL and loads matches from that list from your Jellyfin library
|
||||
// Currently supports: mdblist.com only
|
||||
externalListUrls?: string[];
|
||||
|
||||
// Multi-Query (only used when queries.length > 1)
|
||||
sortBy?: string; // Field to sort merged results by (e.g., 'DatePlayed', 'DateCreated')
|
||||
sortOrder?: "Ascending" | "Descending"; // Sort direction for merged results
|
||||
@@ -62,7 +66,13 @@ interface HomeScreenSection {
|
||||
isCustom?: boolean;
|
||||
/** How a discovery section resolves dynamically: Person, Genre, Studio, Similar, Collection, … */
|
||||
discoveryType?: string;
|
||||
discoveryItemType?: string;
|
||||
discoveryPersonType?: string;
|
||||
source?: string;
|
||||
|
||||
/** Pool query used to resolve a dynamic discovery entity (optional on non-discovery sections). */
|
||||
discoverySourceQuery?: Query;
|
||||
|
||||
/** Runtime stamp for custom discovery paging (1..N per custom group); not a persisted admin field. */
|
||||
pageNumber?: number;
|
||||
|
||||
|
||||
@@ -1105,17 +1105,12 @@
|
||||
}
|
||||
|
||||
try {
|
||||
let dataPromise = cache[methodName]();
|
||||
let dataPromise = Promise.resolve(cache[methodName](options));
|
||||
dataPromise = dataPromise.then(data => {
|
||||
if (!Array.isArray(data)) {
|
||||
data = [];
|
||||
}
|
||||
|
||||
// Filter studios/networks with no thumb
|
||||
if (cacheName === 'StudiosCache' && window.KefinHomeScreem?.getConfig()?.HOME_SETTINGS?.ensureThumbsForPopularTVNetworks === true) {
|
||||
data = data.filter(item => item.ImageTags?.Thumb);
|
||||
}
|
||||
|
||||
// Apply options (sorting, limits, etc.)
|
||||
if (options.SortBy === 'Random') {
|
||||
data = data.sort(() => Math.random() - 0.5);
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.kefinTweaks-breadcrumbs-wrapper {
|
||||
display: block;
|
||||
@@ -15,6 +21,11 @@
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
|
||||
.MuiToolbar-root .kefinTweaks-breadcrumbs-wrapper:hover {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.kefinTweaks-breadcrumbs {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 1.17em;
|
||||
@@ -27,6 +38,16 @@
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.MuiToolbar-root .kefinTweaks-breadcrumbs,
|
||||
.kefinTweaks-breadcrumbs-wrapper--modern .kefinTweaks-breadcrumbs {
|
||||
font-size: 1em;
|
||||
padding: 0px 12px 8px;
|
||||
}
|
||||
|
||||
.MuiToolbar-root .kefinTweaks-popover {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.kefinTweaks-popover-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
||||
+54
-9
@@ -471,15 +471,28 @@
|
||||
}
|
||||
|
||||
// DOM manipulation
|
||||
function isModernUI() {
|
||||
return !!document.querySelector('.MuiBox-root');
|
||||
}
|
||||
|
||||
function resolveBreadcrumbMount() {
|
||||
if (isModernUI()) {
|
||||
return document.querySelector('.MuiToolbar-root');
|
||||
}
|
||||
return document.querySelector('.skinHeader .headerLeft');
|
||||
}
|
||||
|
||||
function createBreadcrumbContainer() {
|
||||
if (breadcrumbContainer) {
|
||||
return breadcrumbContainer;
|
||||
}
|
||||
|
||||
// Find the header left container
|
||||
const headerLeft = document.querySelector('.skinHeader .headerLeft');
|
||||
if (!headerLeft) {
|
||||
error('Could not find .skinHeader .headerLeft element');
|
||||
|
||||
const isModern = isModernUI();
|
||||
const mount = resolveBreadcrumbMount();
|
||||
if (!mount) {
|
||||
error(isModern
|
||||
? 'Could not find .MuiToolbar-root for breadcrumbs'
|
||||
: 'Could not find .skinHeader .headerLeft element');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -487,6 +500,9 @@
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'kefinTweaks-breadcrumbs-wrapper';
|
||||
wrapper.className = 'kefinTweaks-breadcrumbs-wrapper';
|
||||
if (isModern) {
|
||||
wrapper.classList.add('kefinTweaks-breadcrumbs-wrapper--modern');
|
||||
}
|
||||
wrapper.style.cssText = `
|
||||
display: none;
|
||||
`;
|
||||
@@ -509,11 +525,36 @@
|
||||
|
||||
wrapper.appendChild(breadcrumbContainer);
|
||||
wrapper.appendChild(popoverContainer);
|
||||
headerLeft.appendChild(wrapper);
|
||||
mount.appendChild(wrapper);
|
||||
|
||||
log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
|
||||
log(isModern
|
||||
? 'Created breadcrumb container inside .MuiToolbar-root'
|
||||
: 'Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
|
||||
return breadcrumbContainer;
|
||||
}
|
||||
|
||||
function ensureBreadcrumbContainer(retries = 20) {
|
||||
const existing = createBreadcrumbContainer();
|
||||
if (existing) return Promise.resolve(existing);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
const tryMount = () => {
|
||||
const container = createBreadcrumbContainer();
|
||||
if (container) {
|
||||
resolve(container);
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts >= retries) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
setTimeout(tryMount, 100);
|
||||
};
|
||||
tryMount();
|
||||
});
|
||||
}
|
||||
|
||||
function showBreadcrumbs() {
|
||||
const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
|
||||
@@ -864,9 +905,13 @@
|
||||
|
||||
// Create breadcrumbs from scratch
|
||||
async function createBreadcrumbsFromScratch(targetStructure) {
|
||||
// Create container if it doesn't exist
|
||||
// Create container if it doesn't exist (retry briefly on ModernUI if toolbar not ready)
|
||||
if (!breadcrumbContainer) {
|
||||
breadcrumbContainer = createBreadcrumbContainer();
|
||||
breadcrumbContainer = await ensureBreadcrumbContainer();
|
||||
}
|
||||
if (!breadcrumbContainer) {
|
||||
error('Breadcrumb container unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build all elements first
|
||||
|
||||
+32
-3
@@ -968,8 +968,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
margin-bottom: 45px !important;
|
||||
.skeleton-text-line {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.skeleton-section-title {
|
||||
width: 50%;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.skeleton-section-caption {
|
||||
width: 35%;
|
||||
min-width: 6rem;
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
|
||||
/* Tagline always 1 line - Movie-specific override kept for any edge cases */
|
||||
@@ -1293,7 +1310,7 @@
|
||||
}
|
||||
|
||||
.sectionTitle.sectionCaption {
|
||||
font-size: 0.9em;
|
||||
font-size: 0.9em !important;
|
||||
text-align: left;
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
@@ -2035,6 +2052,18 @@
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Skeletons need a visible padder even when Logo/Clear Art/Disc force transparent cards */
|
||||
.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardPadder,
|
||||
.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardScalable,
|
||||
.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardPadder,
|
||||
.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardScalable,
|
||||
.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardPadder,
|
||||
.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardScalable,
|
||||
.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardPadder,
|
||||
.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardScalable {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
|
||||
.emby-scroller-container[data-card-format="Logo"] .cardImageContainer,
|
||||
.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer,
|
||||
.emby-scroller-container[data-section-id="liveTv-channels"] .cardImageContainer {
|
||||
|
||||
+495
-29
@@ -810,6 +810,16 @@
|
||||
function postProcessItems(sectionConfig, itemsData) {
|
||||
let processed = itemsData?.Items || itemsData || [];
|
||||
|
||||
//Check if imageTypes is used in the query to ensure only items with these image types are included
|
||||
const imageTypes = sectionConfig.queries?.[0]?.queryOptions?.ImageTypes;
|
||||
if (imageTypes && Array.isArray(imageTypes)) {
|
||||
// Filter out items which do not have any of the image types
|
||||
processed = processed.filter(item => {
|
||||
const itemImageTypes = imageTypes.filter(imageType => item.ImageTags?.[imageType] || (imageType === 'Backdrop' && item.BackdropImageTags?.[0]));
|
||||
return itemImageTypes.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// When ParentId is on the query (e.g. Popular Genres per library), assign it to each item for genre card links
|
||||
const queryParentId = sectionConfig.queries?.[0]?.ParentId;
|
||||
if (queryParentId && Array.isArray(processed)) {
|
||||
@@ -1348,6 +1358,8 @@
|
||||
if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
|
||||
const spotlightSettings = { ...(sectionConfig.spotlightConfig || {}) };
|
||||
if (activeViewMoreUrl) spotlightSettings.viewMoreUrl = activeViewMoreUrl;
|
||||
spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
|
||||
spotlightSettings.caption = sectionConfig.caption;
|
||||
skeleton = createSkeletonSpotlightSection(displayTitle, spotlightSettings);
|
||||
} else {
|
||||
skeleton = createProgressivelyEnhancedScrollableContainer(
|
||||
@@ -1744,11 +1756,30 @@
|
||||
});
|
||||
}
|
||||
|
||||
function updateCardWatchlistButtons(itemId, userData) {
|
||||
if (!itemId || !userData || !('Likes' in userData)) return;
|
||||
|
||||
const isLiked = userData.Likes === true;
|
||||
const likedHelper = window.KefinWatchlistLikedIds;
|
||||
if (likedHelper?.set) {
|
||||
likedHelper.set(itemId, isLiked);
|
||||
}
|
||||
|
||||
const escapedId = typeof CSS !== 'undefined' && CSS.escape
|
||||
? CSS.escape(itemId)
|
||||
: itemId.replace(/["\\]/g, '\\$&');
|
||||
document.querySelectorAll(`.watchlist-button[data-id="${escapedId}"]`).forEach((button) => {
|
||||
button.dataset.active = isLiked ? 'true' : 'false';
|
||||
button.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
|
||||
});
|
||||
}
|
||||
|
||||
function applyUserDataEntriesToCards(entries, userId) {
|
||||
if (!isPrimaryUserDataChange(userId)) return;
|
||||
entries.forEach((entry) => {
|
||||
if (entry?.ItemId) {
|
||||
updateCardResumeAttributes(entry.ItemId, entry);
|
||||
updateCardWatchlistButtons(entry.ItemId, entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2018,7 +2049,7 @@
|
||||
// If the section has items, it is a static query
|
||||
const isStaticQuery = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0;
|
||||
|
||||
const renderCachedData = isStaticQuery || (!section.result?.isStale && !isRandomQuerySort) || (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'));
|
||||
const renderCachedData = (isStaticQuery || (!section.result?.isStale && !isRandomQuerySort) || (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'))) && dataItems.length > 0;
|
||||
|
||||
// Paint cached cards when valid, or when stale display is opted in
|
||||
if (renderCachedData) {
|
||||
@@ -2070,6 +2101,8 @@
|
||||
if (getActiveViewMoreUrl(sectionConfig)) {
|
||||
spotlightSettings.viewMoreUrl = getActiveViewMoreUrl(sectionConfig);
|
||||
}
|
||||
spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
|
||||
spotlightSettings.caption = sectionConfig.caption;
|
||||
sectionElement = createSkeletonSpotlightSection(getSectionTitleForMultiQuery(sectionConfig), spotlightSettings);
|
||||
} else {
|
||||
sectionElement = createProgressivelyEnhancedScrollableContainer(getSectionTitleForMultiQuery(sectionConfig), getActiveViewMoreUrl(sectionConfig), finalCardFormat, sectionConfig.overflowCard, sectionConfig);
|
||||
@@ -2118,7 +2151,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
container.appendChild(fragment);
|
||||
// Append one section per frame so scroll/compositor get frames between paints.
|
||||
// Build already happened in the fragment; only the container append is paced.
|
||||
const sectionNodes = Array.from(fragment.childNodes);
|
||||
for (let i = 0; i < sectionNodes.length; i++) {
|
||||
container.appendChild(sectionNodes[i]);
|
||||
if (i + 1 < sectionNodes.length) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
syncAttachedGridTilesLayouts(container);
|
||||
|
||||
// Update the scroll buttons for all scrollable containers
|
||||
@@ -2527,16 +2568,276 @@
|
||||
if (!canvas || !blurhashString) return false;
|
||||
const w = canvas.width || 20;
|
||||
const h = canvas.height || 20;
|
||||
const pixels = blurhashDecode(blurhashString, w, h);
|
||||
const cacheKey = blurhashCacheKey(blurhashString, w, h);
|
||||
let pixels = blurhashCacheGet(cacheKey);
|
||||
if (!pixels) {
|
||||
pixels = blurhashDecode(blurhashString, w, h);
|
||||
if (pixels) blurhashCacheSet(cacheKey, pixels);
|
||||
}
|
||||
if (!pixels) return false;
|
||||
return applyBlurhashPixelsToCanvas(canvas, pixels);
|
||||
}
|
||||
|
||||
// --- Deferred blurhash: cache + worker + near-viewport filler ---
|
||||
const BLURHASH_CACHE_MAX = 200;
|
||||
const blurhashPixelCache = new Map();
|
||||
let blurhashFillObserver = null;
|
||||
let blurhashWorker = null;
|
||||
let blurhashWorkerReqId = 0;
|
||||
const blurhashWorkerPending = new Map();
|
||||
const blurhashIdleQueue = [];
|
||||
let blurhashIdleScheduled = false;
|
||||
|
||||
function blurhashCacheKey(blurhash, width, height) {
|
||||
return blurhash + '|' + width + 'x' + height;
|
||||
}
|
||||
|
||||
function blurhashCacheGet(key) {
|
||||
if (!blurhashPixelCache.has(key)) return null;
|
||||
const val = blurhashPixelCache.get(key);
|
||||
blurhashPixelCache.delete(key);
|
||||
blurhashPixelCache.set(key, val);
|
||||
return val;
|
||||
}
|
||||
|
||||
function blurhashCacheSet(key, pixels) {
|
||||
if (blurhashPixelCache.has(key)) blurhashPixelCache.delete(key);
|
||||
blurhashPixelCache.set(key, pixels);
|
||||
while (blurhashPixelCache.size > BLURHASH_CACHE_MAX) {
|
||||
const oldest = blurhashPixelCache.keys().next().value;
|
||||
blurhashPixelCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipBlurhashDecode() {
|
||||
try {
|
||||
if (navigator.connection && navigator.connection.saveData === true) return true;
|
||||
} catch (e) { /* ignore */ }
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true;
|
||||
} catch (e) { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
function getBlurhashFillRootMargin() {
|
||||
return isMobileLayout() ? '120px' : '200px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Put pixels on canvas and show blurhash until the real image loads.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function applyBlurhashPixelsToCanvas(canvas, pixels) {
|
||||
if (!canvas || !pixels || !canvas.isConnected) return false;
|
||||
const w = canvas.width || 20;
|
||||
const h = canvas.height || 20;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return false;
|
||||
|
||||
const imageData = ctx.createImageData(w, h);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
|
||||
const cardImageContainer = canvas.nextElementSibling;
|
||||
const imageAlreadyLoaded = cardImageContainer
|
||||
&& cardImageContainer.classList
|
||||
&& cardImageContainer.classList.contains('lazy-loaded');
|
||||
|
||||
if (imageAlreadyLoaded) {
|
||||
canvas.classList.add('lazy-hidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
canvas.classList.remove('lazy-hidden');
|
||||
if (cardImageContainer && cardImageContainer.classList.contains('cardImageContainer')) {
|
||||
cardImageContainer.classList.add('lazy-hidden');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getBlurhashWorkerSource() {
|
||||
return [
|
||||
'const BLURHASH_B83 = ' + JSON.stringify(BLURHASH_B83) + ';',
|
||||
blurhashDecode83.toString(),
|
||||
blurhashSignPow.toString(),
|
||||
blurhashLinearToSrgb.toString(),
|
||||
blurhashSrgbToLinear.toString(),
|
||||
blurhashDecode.toString(),
|
||||
'self.onmessage = function(e) {',
|
||||
' var data = e.data || {};',
|
||||
' var id = data.id;',
|
||||
' try {',
|
||||
' var pixels = blurhashDecode(data.blurhash, data.width || 20, data.height || 20);',
|
||||
' if (!pixels) { self.postMessage({ id: id, ok: false }); return; }',
|
||||
' self.postMessage({ id: id, ok: true, pixels: pixels.buffer }, [pixels.buffer]);',
|
||||
' } catch (err) {',
|
||||
' self.postMessage({ id: id, ok: false, error: String(err && err.message || err) });',
|
||||
' }',
|
||||
'};'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ensureBlurhashWorker() {
|
||||
if (blurhashWorker) return blurhashWorker;
|
||||
if (typeof Worker === 'undefined') return null;
|
||||
try {
|
||||
const blob = new Blob([getBlurhashWorkerSource()], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const worker = new Worker(url);
|
||||
worker.onmessage = (e) => {
|
||||
const data = e.data || {};
|
||||
const pending = blurhashWorkerPending.get(data.id);
|
||||
blurhashWorkerPending.delete(data.id);
|
||||
if (!pending) return;
|
||||
if (!data.ok || !data.pixels) {
|
||||
pending.reject(new Error(data.error || 'Blurhash worker decode failed'));
|
||||
return;
|
||||
}
|
||||
pending.resolve(new Uint8ClampedArray(data.pixels));
|
||||
};
|
||||
worker.onerror = (err) => {
|
||||
console.warn('[KefinTweaks CardBuilder] Blurhash worker error:', err);
|
||||
blurhashWorkerPending.forEach((p) => p.reject(err));
|
||||
blurhashWorkerPending.clear();
|
||||
try { worker.terminate(); } catch (e) { /* ignore */ }
|
||||
try { URL.revokeObjectURL(url); } catch (e) { /* ignore */ }
|
||||
blurhashWorker = null;
|
||||
};
|
||||
blurhashWorker = worker;
|
||||
return worker;
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks CardBuilder] Could not create blurhash worker:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBlurhashInWorker(blurhash, width, height) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = ensureBlurhashWorker();
|
||||
if (!worker) {
|
||||
reject(new Error('Blurhash worker unavailable'));
|
||||
return;
|
||||
}
|
||||
const id = ++blurhashWorkerReqId;
|
||||
blurhashWorkerPending.set(id, { resolve, reject });
|
||||
try {
|
||||
worker.postMessage({ id, blurhash, width, height });
|
||||
} catch (e) {
|
||||
blurhashWorkerPending.delete(id);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleIdleBlurhashDrain() {
|
||||
if (blurhashIdleScheduled) return;
|
||||
blurhashIdleScheduled = true;
|
||||
const run = (deadline) => {
|
||||
blurhashIdleScheduled = false;
|
||||
let n = 0;
|
||||
while (blurhashIdleQueue.length && n < 4) {
|
||||
if (deadline && typeof deadline.timeRemaining === 'function' && deadline.timeRemaining() <= 0 && n > 0) {
|
||||
break;
|
||||
}
|
||||
const job = blurhashIdleQueue.shift();
|
||||
n++;
|
||||
if (!job || !job.canvas || !job.canvas.isConnected) continue;
|
||||
if (!job.canvas.hasAttribute('data-blurhash-pending')) continue;
|
||||
const cacheKey = blurhashCacheKey(job.blurhash, job.w, job.h);
|
||||
let pixels = blurhashCacheGet(cacheKey);
|
||||
if (!pixels) {
|
||||
pixels = blurhashDecode(job.blurhash, job.w, job.h);
|
||||
if (pixels) blurhashCacheSet(cacheKey, pixels);
|
||||
}
|
||||
if (pixels) applyBlurhashPixelsToCanvas(job.canvas, pixels);
|
||||
else job.canvas.removeAttribute('data-blurhash-pending');
|
||||
}
|
||||
if (blurhashIdleQueue.length) scheduleIdleBlurhashDrain();
|
||||
};
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(run, { timeout: 500 });
|
||||
} else {
|
||||
requestAnimationFrame(() => run({ timeRemaining: () => 5 }));
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueIdleBlurhashDecode(canvas, blurhash, width, height) {
|
||||
blurhashIdleQueue.push({ canvas, blurhash, w: width, h: height });
|
||||
scheduleIdleBlurhashDrain();
|
||||
}
|
||||
|
||||
async function fillBlurhashCanvas(canvas) {
|
||||
if (!canvas || !canvas.isConnected) return;
|
||||
const blurhash = canvas.getAttribute('data-blurhash-pending');
|
||||
if (!blurhash) return;
|
||||
if (shouldSkipBlurhashDecode()) {
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
canvas.classList.add('lazy-hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const w = canvas.width || 20;
|
||||
const h = canvas.height || 20;
|
||||
const cacheKey = blurhashCacheKey(blurhash, w, h);
|
||||
const cached = blurhashCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
applyBlurhashPixelsToCanvas(canvas, cached);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pixels = await decodeBlurhashInWorker(blurhash, w, h);
|
||||
if (!canvas.isConnected || canvas.getAttribute('data-blurhash-pending') !== blurhash) return;
|
||||
blurhashCacheSet(cacheKey, pixels);
|
||||
applyBlurhashPixelsToCanvas(canvas, pixels);
|
||||
} catch (e) {
|
||||
enqueueIdleBlurhashDecode(canvas, blurhash, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
function observeBlurhashCanvas(canvas) {
|
||||
if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return;
|
||||
if (shouldSkipBlurhashDecode()) {
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
canvas.classList.add('lazy-hidden');
|
||||
return;
|
||||
}
|
||||
initBlurhashFillObserver();
|
||||
if (blurhashFillObserver) {
|
||||
blurhashFillObserver.observe(canvas);
|
||||
} else {
|
||||
fillBlurhashCanvas(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
function initBlurhashFillObserver() {
|
||||
if (blurhashFillObserver) return;
|
||||
if (typeof IntersectionObserver === 'undefined') return;
|
||||
|
||||
blurhashFillObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
const canvas = entry.target;
|
||||
blurhashFillObserver.unobserve(canvas);
|
||||
fillBlurhashCanvas(canvas);
|
||||
});
|
||||
}, {
|
||||
threshold: 0,
|
||||
rootMargin: getBlurhashFillRootMargin()
|
||||
});
|
||||
}
|
||||
|
||||
function observePendingBlurhashInNode(node) {
|
||||
if (!node || node.nodeType !== 1) return;
|
||||
if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) {
|
||||
observeBlurhashCanvas(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blurhash string for the card image when the image is from the current item.
|
||||
* Mirrors the image URL logic; returns undefined when image comes from parent/series (we don't have their ImageBlurHashes).
|
||||
@@ -3022,20 +3323,24 @@
|
||||
const blurhashStr = !item.imageUrl ? getBlurhashForCard(item, cardFormat) : null;
|
||||
if (!blurhashStr || cardFormat === 'logo' || cardFormat === 'clear art' || cardFormat === 'disc') {
|
||||
blurhashCanvas.classList.add('lazy-hidden');
|
||||
} else if (shouldSkipBlurhashDecode()) {
|
||||
// Skip CPU decode; keep default chrome / lazy image path only
|
||||
blurhashCanvas.classList.add('lazy-hidden');
|
||||
} else {
|
||||
// Draw blurhash immediately so placeholder is visible ASAP (canvas is behind cardImageContainer)
|
||||
if (!drawBlurhashToCanvas(blurhashCanvas, blurhashStr)) {
|
||||
blurhashCanvas.classList.add('lazy-hidden');
|
||||
} else {
|
||||
// Hide the image container so the blurhash canvas shows until the real image loads
|
||||
cardImageContainer.classList.add('lazy-hidden');
|
||||
}
|
||||
// Defer decode off the construction path — near-viewport filler paints later
|
||||
blurhashCanvas.classList.add('lazy-hidden');
|
||||
blurhashCanvas.setAttribute('data-blurhash-pending', blurhashStr);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
// Use data-src for lazy loading instead of immediate backgroundImage
|
||||
cardImageContainer.setAttribute('data-src', imageUrl);
|
||||
if (blurhashStr) cardImageContainer.setAttribute('data-blurhash', blurhashStr);
|
||||
if (blurhashStr
|
||||
&& cardFormat !== 'logo'
|
||||
&& cardFormat !== 'clear art'
|
||||
&& cardFormat !== 'disc') {
|
||||
cardImageContainer.setAttribute('data-blurhash', blurhashStr);
|
||||
}
|
||||
// Add lazy class for styling/selection
|
||||
cardImageContainer.classList.add('lazy');
|
||||
}
|
||||
@@ -3154,6 +3459,25 @@
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.className = 'cardOverlayButton-br flex';
|
||||
|
||||
// Watchlist button (active state from UserData.Likes; click bound by watchlist.js)
|
||||
const watchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video'];
|
||||
if (watchlistSupportedTypes.includes(itemType)) {
|
||||
const isLiked = item.UserData?.Likes === true;
|
||||
const watchlistButton = document.createElement('button');
|
||||
watchlistButton.type = 'button';
|
||||
watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat';
|
||||
watchlistButton.setAttribute('data-action', 'none');
|
||||
watchlistButton.setAttribute('data-id', itemId);
|
||||
watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false');
|
||||
watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
|
||||
|
||||
const watchlistIcon = document.createElement('span');
|
||||
watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
|
||||
watchlistIcon.setAttribute('aria-hidden', 'true');
|
||||
watchlistButton.appendChild(watchlistIcon);
|
||||
buttonContainer.appendChild(watchlistButton);
|
||||
}
|
||||
|
||||
// Watched button
|
||||
const watchedButton = document.createElement('button');
|
||||
watchedButton.setAttribute('is', 'emby-playstatebutton');
|
||||
@@ -3203,7 +3527,9 @@
|
||||
|
||||
buttonContainer.appendChild(moreButton);
|
||||
|
||||
cardOverlayContainer.appendChild(playButton);
|
||||
if (item.Type !== 'CollectionFolder' && item.Type !== 'Folder' && item.Type !== 'UserView') {
|
||||
cardOverlayContainer.appendChild(playButton);
|
||||
}
|
||||
cardOverlayContainer.appendChild(buttonContainer);
|
||||
}
|
||||
|
||||
@@ -5256,6 +5582,52 @@
|
||||
return raw > 0 ? raw : 16;
|
||||
}
|
||||
|
||||
const SKELETON_ONE_LINE_ITEM_TYPES = new Set([
|
||||
'Book', 'Genre', 'CollectionFolder', 'BoxSet', 'Playlist', 'Folder', 'Studio'
|
||||
]);
|
||||
|
||||
const SKELETON_CUSTOM_FOOTER_SECTION_IDS = new Set([
|
||||
'upcoming', 'recentlyReleased.movies', 'recentlyReleased.episodes'
|
||||
]);
|
||||
|
||||
function sectionUsesCustomFooterText(sectionConfig) {
|
||||
if (!sectionConfig) return false;
|
||||
if (SKELETON_CUSTOM_FOOTER_SECTION_IDS.has(sectionConfig.id)) return true;
|
||||
if (Array.isArray(sectionConfig.items) && sectionConfig.items.some(
|
||||
(item) => item?.CustomFooterText || item?.cardFooter
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function inferSkeletonItemType(sectionConfig) {
|
||||
if (!sectionConfig) return null;
|
||||
const query = sectionConfig.queries?.[0];
|
||||
const path = typeof query?.path === 'string' ? query.path : '';
|
||||
if (path.includes('/Studios')) return 'Studio';
|
||||
if (path.includes('/Genres')) return 'Genre';
|
||||
|
||||
let includeTypes = query?.queryOptions?.IncludeItemTypes;
|
||||
if (typeof includeTypes === 'string') {
|
||||
includeTypes = includeTypes.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
if (!Array.isArray(includeTypes) || includeTypes.length === 0) return null;
|
||||
if (includeTypes.length === 1) return includeTypes[0];
|
||||
if (includeTypes.every((t) => SKELETON_ONE_LINE_ITEM_TYPES.has(t))) {
|
||||
return includeTypes[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSkeletonFooterLineCount(sectionConfig) {
|
||||
if (sectionConfig?.hideCardFooter === true) return 0;
|
||||
if (sectionUsesCustomFooterText(sectionConfig)) return 3;
|
||||
const itemType = inferSkeletonItemType(sectionConfig);
|
||||
if (itemType && SKELETON_ONE_LINE_ITEM_TYPES.has(itemType)) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function createScrollerElement() {
|
||||
const scroller = document.createElement('div');
|
||||
const isMobile = isMobileLayout();
|
||||
@@ -5582,9 +5954,10 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl }) {
|
||||
function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl, discoveryPending }) {
|
||||
const captionText = caption && String(caption).trim();
|
||||
const hasCaption = !!captionText;
|
||||
const useSkeletonTitle = discoveryPending === true;
|
||||
let parent = sectionTitleContainer;
|
||||
|
||||
if (hasCaption) {
|
||||
@@ -5594,7 +5967,15 @@
|
||||
parent = wrapper;
|
||||
}
|
||||
|
||||
if (viewMoreUrl) {
|
||||
if (useSkeletonTitle) {
|
||||
const titleText = document.createElement('h2');
|
||||
titleText.className = 'sectionTitle sectionTitle-cards';
|
||||
const skeletonTitle = document.createElement('span');
|
||||
skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
|
||||
skeletonTitle.setAttribute('aria-hidden', 'true');
|
||||
titleText.appendChild(skeletonTitle);
|
||||
parent.appendChild(titleText);
|
||||
} else if (viewMoreUrl) {
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button';
|
||||
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
|
||||
@@ -5628,7 +6009,14 @@
|
||||
|
||||
if (hasCaption) {
|
||||
let captionEl;
|
||||
if (captionUrl) {
|
||||
if (useSkeletonTitle) {
|
||||
captionEl = document.createElement('span');
|
||||
captionEl.className = 'sectionTitle sectionCaption';
|
||||
const skeletonCaption = document.createElement('span');
|
||||
skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
|
||||
skeletonCaption.setAttribute('aria-hidden', 'true');
|
||||
captionEl.appendChild(skeletonCaption);
|
||||
} else if (captionUrl) {
|
||||
captionEl = document.createElement('a');
|
||||
captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button';
|
||||
if (typeof captionUrl === 'function') {
|
||||
@@ -5639,11 +6027,12 @@
|
||||
} else {
|
||||
captionEl.href = captionUrl;
|
||||
}
|
||||
captionEl.textContent = captionText;
|
||||
} else {
|
||||
captionEl = document.createElement('span');
|
||||
captionEl.className = 'sectionTitle sectionCaption';
|
||||
captionEl.textContent = captionText;
|
||||
}
|
||||
captionEl.textContent = captionText;
|
||||
parent.appendChild(captionEl);
|
||||
}
|
||||
}
|
||||
@@ -5722,25 +6111,30 @@
|
||||
|
||||
/**
|
||||
* Creates a skeleton card element for loading states
|
||||
* @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square'
|
||||
* @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', 'square', 'logo', 'clear art', 'disc', 'banner', etc.
|
||||
* @param {boolean} overflowCard - Use overflow card classes
|
||||
* @param {Object|null} sectionConfig - Section config used to infer footer line count
|
||||
* @returns {HTMLElement} - Skeleton card element
|
||||
*/
|
||||
function createSkeletonCard(cardFormat = null, overflowCard = false) {
|
||||
function createSkeletonCard(cardFormat = null, overflowCard = false, sectionConfig = null) {
|
||||
const card = document.createElement('div');
|
||||
|
||||
// Determine card classes based on format
|
||||
// Determine card classes based on format (must match createJellyfinCardElement)
|
||||
let cardClass, padderClass;
|
||||
cardFormat = cardFormat?.toLowerCase() || 'portrait';
|
||||
|
||||
if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb') {
|
||||
if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb'
|
||||
|| cardFormat === 'logo' || cardFormat === 'clear art') {
|
||||
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
|
||||
padderClass = 'cardPadder-backdrop';
|
||||
} else if (cardFormat === 'square') {
|
||||
} else if (cardFormat === 'square' || cardFormat === 'disc') {
|
||||
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
|
||||
padderClass = 'cardPadder-square';
|
||||
} else if (cardFormat === 'banner') {
|
||||
cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard';
|
||||
padderClass = 'cardPadder-banner';
|
||||
} else {
|
||||
// portrait (default)
|
||||
// portrait / poster / series poster (default)
|
||||
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
|
||||
padderClass = 'cardPadder-portrait';
|
||||
}
|
||||
@@ -5760,6 +6154,32 @@
|
||||
|
||||
cardScalable.appendChild(cardPadder);
|
||||
cardBox.appendChild(cardScalable);
|
||||
|
||||
const footerLineCount = getSkeletonFooterLineCount(sectionConfig);
|
||||
if (footerLineCount > 0) {
|
||||
const cardTextStack = document.createElement('div');
|
||||
cardTextStack.className = 'cardTextStack';
|
||||
|
||||
const lineWidths = ['70%', '55%', '40%'];
|
||||
for (let lineIndex = 0; lineIndex < footerLineCount; lineIndex++) {
|
||||
const isFirst = lineIndex === 0;
|
||||
const cardText = document.createElement('div');
|
||||
cardText.className = isFirst
|
||||
? 'cardText cardTextCentered cardText-first'
|
||||
: 'cardText cardTextCentered cardText-secondary';
|
||||
|
||||
const bdi = document.createElement('bdi');
|
||||
const skeletonLine = document.createElement('span');
|
||||
skeletonLine.className = 'skeleton-text-line';
|
||||
skeletonLine.style.width = lineWidths[lineIndex] || '40%';
|
||||
bdi.appendChild(skeletonLine);
|
||||
cardText.appendChild(bdi);
|
||||
cardTextStack.appendChild(cardText);
|
||||
}
|
||||
|
||||
cardBox.appendChild(cardTextStack);
|
||||
}
|
||||
|
||||
card.appendChild(cardBox);
|
||||
|
||||
return card;
|
||||
@@ -5773,9 +6193,18 @@
|
||||
* @returns {HTMLElement} - Skeleton spotlight container
|
||||
*/
|
||||
function createSkeletonSpotlightSection(title, options = {}) {
|
||||
const { viewMoreUrl = null, spotlightLayout, spotlightSize, fullScreen } = options;
|
||||
const {
|
||||
viewMoreUrl = null,
|
||||
spotlightLayout,
|
||||
spotlightSize,
|
||||
fullScreen,
|
||||
discoveryPending = false,
|
||||
caption = null
|
||||
} = options;
|
||||
const layout = spotlightLayout ?? (fullScreen === true ? 'Borderless' : 'Border');
|
||||
const size = spotlightSize ?? (fullScreen === true ? 'full' : 'normal');
|
||||
const captionText = caption && String(caption).trim();
|
||||
const useSkeletonTitle = discoveryPending === true;
|
||||
|
||||
// Create main container (same structure as real spotlight)
|
||||
const container = document.createElement('div');
|
||||
@@ -5792,9 +6221,16 @@
|
||||
{
|
||||
const sectionTitleContainer = document.createElement('div');
|
||||
sectionTitleContainer.className = 'spotlight-section-title-container';
|
||||
if (title) {
|
||||
if (useSkeletonTitle || title) {
|
||||
let sectionTitleEl;
|
||||
if (viewMoreUrl) {
|
||||
if (useSkeletonTitle) {
|
||||
sectionTitleEl = document.createElement('div');
|
||||
sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
|
||||
const skeletonTitle = document.createElement('span');
|
||||
skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
|
||||
skeletonTitle.setAttribute('aria-hidden', 'true');
|
||||
sectionTitleEl.appendChild(skeletonTitle);
|
||||
} else if (viewMoreUrl) {
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
|
||||
titleLink.textContent = title;
|
||||
@@ -5819,9 +6255,19 @@
|
||||
sectionTitleEl.textContent = title;
|
||||
}
|
||||
const sectionTitleWrapper = document.createElement('div');
|
||||
sectionTitleWrapper.className = `spotlight-section-title ${viewMoreUrl ? '' : 'spotlight-title-link '}headerTabs sectionTabs`;
|
||||
sectionTitleWrapper.className = `spotlight-section-title ${(!useSkeletonTitle && viewMoreUrl) ? '' : 'spotlight-title-link '}headerTabs sectionTabs`;
|
||||
sectionTitleWrapper.appendChild(sectionTitleEl);
|
||||
sectionTitleContainer.appendChild(sectionTitleWrapper);
|
||||
|
||||
if (useSkeletonTitle && captionText) {
|
||||
const captionEl = document.createElement('span');
|
||||
captionEl.className = 'sectionTitle sectionCaption';
|
||||
const skeletonCaption = document.createElement('span');
|
||||
skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
|
||||
skeletonCaption.setAttribute('aria-hidden', 'true');
|
||||
captionEl.appendChild(skeletonCaption);
|
||||
sectionTitleContainer.appendChild(captionEl);
|
||||
}
|
||||
}
|
||||
bannerContainer.appendChild(sectionTitleContainer);
|
||||
}
|
||||
@@ -5865,11 +6311,13 @@
|
||||
const sectionTitleContainer = document.createElement('div');
|
||||
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
|
||||
|
||||
const discoveryPending = sectionConfig?.discoveryPending === true;
|
||||
appendSectionTitleContent(sectionTitleContainer, {
|
||||
title,
|
||||
caption: getSectionCaption(sectionConfig),
|
||||
captionUrl: getSectionCaptionUrl(sectionConfig),
|
||||
viewMoreUrl
|
||||
viewMoreUrl: discoveryPending ? null : viewMoreUrl,
|
||||
discoveryPending
|
||||
});
|
||||
|
||||
ensureSectionControlsMount(sectionTitleContainer);
|
||||
@@ -5879,7 +6327,7 @@
|
||||
|
||||
const skeletonCount = getSectionSkeletonCount(sectionConfig);
|
||||
for (let i = 0; i < skeletonCount; i++) {
|
||||
const skeletonCard = createSkeletonCard(cardFormat, overflowCard);
|
||||
const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig);
|
||||
skeletonCard.setAttribute('data-index', i);
|
||||
itemsContainer.appendChild(skeletonCard);
|
||||
}
|
||||
@@ -5961,10 +6409,13 @@
|
||||
*/
|
||||
function initLazyImageObserver() {
|
||||
if (lazyImageObserver) return; // Already initialized
|
||||
|
||||
// Mobile: tighter prefetch so image downloads don't stampede during discovery paint
|
||||
const rootMargin = isMobileLayout() ? '300px' : '800px';
|
||||
|
||||
const observerOptions = {
|
||||
threshold: 0.1, // Trigger when 10% of element is visible
|
||||
rootMargin: '800px' // Start loading well before element enters viewport to avoid grey flash
|
||||
rootMargin
|
||||
};
|
||||
|
||||
lazyImageObserver = new IntersectionObserver((entries) => {
|
||||
@@ -5987,6 +6438,7 @@
|
||||
const canvas = cardImageContainer.previousElementSibling;
|
||||
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
|
||||
canvas.classList.add('lazy-hidden');
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
}
|
||||
lazyImageObserver.unobserve(cardImageContainer);
|
||||
};
|
||||
@@ -5999,6 +6451,7 @@
|
||||
const canvas = cardImageContainer.previousElementSibling;
|
||||
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
|
||||
canvas.classList.add('lazy-hidden');
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
}
|
||||
lazyImageObserver.unobserve(cardImageContainer);
|
||||
};
|
||||
@@ -6036,6 +6489,8 @@
|
||||
lazyImageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
observePendingBlurhashInNode(node);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6112,11 +6567,14 @@
|
||||
|
||||
initLazyImageObserver();
|
||||
initLazyMutationObserver();
|
||||
initBlurhashFillObserver();
|
||||
|
||||
const existingLazyImages = document.querySelectorAll('.cardImageContainer[data-src]');
|
||||
existingLazyImages.forEach(img => {
|
||||
lazyImageObserver.observe(img);
|
||||
});
|
||||
|
||||
document.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas);
|
||||
|
||||
if (document.body) {
|
||||
lazyMutationObserver.observe(document.body, {
|
||||
@@ -6772,12 +7230,20 @@
|
||||
const dataItems = section.result?.data?.Items ?? section.result?.data ?? [];
|
||||
const isSpotlight = sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight';
|
||||
const isSkeleton = !!(sectionElement.querySelector('.skeleton-card, .skeleton-spotlight-item'));
|
||||
const hasSkeletonTitle = !!sectionElement.querySelector('.skeleton-section-title');
|
||||
|
||||
if (items.length === 0) {
|
||||
sectionElement.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Discovery pending (and any skeleton with placeholder title) needs a full rebuild for title/viewMore/caption
|
||||
if (isSkeleton && (isSpotlight || hasSkeletonTitle)) {
|
||||
const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially);
|
||||
markSectionEnhanced(content, sectionConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSkeleton && isSpotlight) {
|
||||
const staleIds = (Array.isArray(dataItems) ? dataItems : [])
|
||||
.map((item) => item?.Id)
|
||||
|
||||
+11
-11
@@ -22,9 +22,9 @@
|
||||
{ key: 'directedByTopDirector', label: 'Directed by [Top Director]', defaultName: 'Directed by [Director]' },
|
||||
{ key: 'writtenByTopWriter', label: 'Written by [Top Writer]', defaultName: 'Written by [Writer]' },
|
||||
{ key: 'becauseYouRecentlyWatched', label: 'Because you recently watched [Movie]', defaultName: 'Because you recently watched [Movie]' },
|
||||
{ key: 'starringActorRecentlyWatched', label: 'Starring [Actor] because you recently watched [Movie]', defaultName: 'Starring [Actor] because you recently watched [Movie]' },
|
||||
{ key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Director] because you recently watched [Movie]', defaultName: 'Directed by [Director] because you recently watched [Movie]' },
|
||||
{ key: 'writtenByWriterRecentlyWatched', label: 'Written by [Writer] because you recently watched [Movie]', defaultName: 'Written by [Writer] because you recently watched [Movie]' }
|
||||
{ key: 'starringActorRecentlyWatched', label: 'Starring [Person]', defaultName: 'Starring {Person}', extras: { caption: 'because you recently watched {Title}' } },
|
||||
{ key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Person]', defaultName: 'Directed by {Person}', extras: { caption: 'because you recently watched {Title}' } },
|
||||
{ key: 'writtenByWriterRecentlyWatched', label: 'Written by [Person]', defaultName: 'Written by {Person}', extras: { caption: 'because you recently watched {Title}' } }
|
||||
];
|
||||
|
||||
const SUPPORTED_CUSTOM_SECTION_PARAMS = {
|
||||
@@ -1208,10 +1208,8 @@
|
||||
const defsByName = new Map(
|
||||
(window.KefinTweaks?.getScripts?.() || []).map((def) => [def.name, def])
|
||||
);
|
||||
const major = window.KefinTweaks?._jellyfinMajorVersion
|
||||
?? (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function'
|
||||
? window.KefinTweaks.getJellyfinMajorVersion()
|
||||
: null);
|
||||
// Prefer cache warmed by await getJellyfinMajorVersion() before opening the modal
|
||||
const major = window.KefinTweaks?._jellyfinMajorVersion ?? null;
|
||||
|
||||
const visibleScripts = scriptNames.filter((script) => {
|
||||
const def = defsByName.get(script.key);
|
||||
@@ -1711,6 +1709,9 @@
|
||||
if (definition.extras?.minimumItems !== undefined) {
|
||||
defaults.minimumItems = definition.extras.minimumItems;
|
||||
}
|
||||
if (definition.extras?.caption !== undefined) {
|
||||
defaults.caption = definition.extras.caption;
|
||||
}
|
||||
|
||||
let normalized = { ...defaults };
|
||||
|
||||
@@ -2270,7 +2271,7 @@
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 0.75em; margin-bottom: 0.75em;">
|
||||
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
|
||||
<div class="listItemContent">
|
||||
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Infinite Scroll / Load More Button</div>
|
||||
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Infinite Scroll / Discover More</div>
|
||||
${buildJellyfinCheckbox('homeScreen_discovery_infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2479,9 +2480,8 @@
|
||||
|
||||
// Ensure Jellyfin major is cached so version-gated feature cards filter correctly
|
||||
try {
|
||||
if (typeof window.KefinTweaks?.getCurrentMajorServerVersion === 'function') {
|
||||
const major = await window.KefinTweaks.getCurrentMajorServerVersion();
|
||||
window.KefinTweaks._jellyfinMajorVersion = major;
|
||||
if (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function') {
|
||||
await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks Configuration] Could not resolve Jellyfin major version:', e);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// KefinTweaks External List (MDBList) — match provider IDs against library caches
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks ExternalList]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks ExternalList]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks ExternalList]', ...args);
|
||||
|
||||
function getMdblistApiKey() {
|
||||
const settings = window.LibraryCacheUtils?.getLibraryCacheSettings?.() || {};
|
||||
return (settings.mdblistApiKey || '').trim();
|
||||
}
|
||||
|
||||
function appendApiKey(url, apiKey) {
|
||||
if (!apiKey) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (!u.searchParams.has('apikey') && !u.searchParams.has('api_key')) {
|
||||
u.searchParams.set('apikey', apiKey);
|
||||
}
|
||||
return u.toString();
|
||||
} catch (_) {
|
||||
const join = url.includes('?') ? '&' : '?';
|
||||
return `${url}${join}apikey=${encodeURIComponent(apiKey)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize mdblist (or similar) JSON into { mediaType, imdb?, tmdb? }[].
|
||||
*/
|
||||
function normalizeExternalListPayload(data) {
|
||||
const rows = [];
|
||||
const list = Array.isArray(data) ? data
|
||||
: Array.isArray(data?.movies) || Array.isArray(data?.shows) || Array.isArray(data?.items)
|
||||
? [...(data.movies || []), ...(data.shows || []), ...(data.items || [])]
|
||||
: [];
|
||||
|
||||
list.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return;
|
||||
const mediaType = /show|series|tv/i.test(String(entry.mediatype || entry.media_type || entry.type || ''))
|
||||
? 'Series'
|
||||
: 'Movie';
|
||||
const imdb = entry.imdb_id || entry.imdbid || entry.ids?.imdb || entry.Imdb || null;
|
||||
const tmdb = entry.tmdb_id || entry.tmdbid || entry.ids?.tmdb || entry.Tmdb || null;
|
||||
if (imdb || tmdb) {
|
||||
rows.push({ mediaType, imdb: imdb ? String(imdb) : undefined, tmdb: tmdb ? String(tmdb) : undefined });
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function fetchExternalListUrl(url) {
|
||||
const apiKey = getMdblistApiKey();
|
||||
const fetchUrl = appendApiKey(url, apiKey);
|
||||
const response = await fetch(fetchUrl, { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`External list HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function resolveExternalListItems(sectionConfig) {
|
||||
const urls = sectionConfig?.externalListUrls || [];
|
||||
if (!urls.length) return { Items: [] };
|
||||
|
||||
if (!getMdblistApiKey()) {
|
||||
WARN('MDBList API key not configured');
|
||||
}
|
||||
|
||||
const allRows = [];
|
||||
for (const url of urls) {
|
||||
if (!url) continue;
|
||||
try {
|
||||
const data = await fetchExternalListUrl(url);
|
||||
allRows.push(...normalizeExternalListPayload(data));
|
||||
} catch (e) {
|
||||
ERR('Failed to fetch external list:', url, e);
|
||||
}
|
||||
}
|
||||
|
||||
const movieIds = [];
|
||||
const seriesIds = [];
|
||||
allRows.forEach((row) => {
|
||||
const keys = [];
|
||||
if (row.imdb) keys.push(row.imdb);
|
||||
if (row.tmdb) keys.push(row.tmdb);
|
||||
if (row.mediaType === 'Series') seriesIds.push(...keys);
|
||||
else movieIds.push(...keys);
|
||||
});
|
||||
|
||||
const movies = movieIds.length
|
||||
? await (window.LibraryCache?.getByProviderIds('Movie', movieIds)
|
||||
|| window.MoviesCache?.getMoviesByProviderIds?.(movieIds)
|
||||
|| [])
|
||||
: [];
|
||||
const series = seriesIds.length
|
||||
? await (window.LibraryCache?.getByProviderIds('Series', seriesIds)
|
||||
|| window.SeriesCache?.getSeriesByProviderIds?.(seriesIds)
|
||||
|| [])
|
||||
: [];
|
||||
|
||||
const items = [...movies, ...series];
|
||||
LOG(`External list matched ${items.length} library items from ${allRows.length} rows`);
|
||||
return { Items: items };
|
||||
}
|
||||
|
||||
window.KefinExternalList = {
|
||||
getMdblistApiKey,
|
||||
resolveExternalListItems,
|
||||
normalizeExternalListPayload
|
||||
};
|
||||
|
||||
LOG('Module loaded');
|
||||
})();
|
||||
+45
-1
@@ -3,7 +3,6 @@
|
||||
.sections.homeSectionsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 100px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -36,6 +35,10 @@
|
||||
content: "Discover More";
|
||||
}
|
||||
|
||||
#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::before {
|
||||
content: "No More Content to Discover";
|
||||
}
|
||||
|
||||
div#discovery-loading-indicator:not(.show)::after {
|
||||
content: "\E313";
|
||||
font-family: "Material Icons";
|
||||
@@ -49,10 +52,26 @@ div#discovery-loading-indicator:not(.show)::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
[data-loading-discovery="true"] div#discovery-loading-indicator {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Idle Discover More / exhausted prompt (chevron mode) */
|
||||
#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) .spinner {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
|
||||
display: none;
|
||||
} */
|
||||
@@ -298,3 +317,28 @@ body[data-seasonal-background="true"]:has(.homePage:not(.hide)) #reactRoot:has(.
|
||||
body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay {
|
||||
display: block;
|
||||
} */
|
||||
|
||||
.homePage[data-discovery-ready] div#discovery-loading-indicator:not(.show) {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
@keyframes fadeInDelayed {
|
||||
0%, 99% {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.homeSectionsContainer:not(:has([data-page]), :has([data-section-id]))::after {
|
||||
content: 'Customize your Home Screen from your User Settings > Home in the top right corner.';
|
||||
text-align: center;
|
||||
margin: 3em auto;
|
||||
display: block;
|
||||
animation: fadeInDelayed 0.1s linear 1s forwards;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
+365
-1034
File diff suppressed because it is too large
Load Diff
+735
-71
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
// KefinTweaks Library Cache Facade
|
||||
// Routes provider-id lookups and readiness checks to MoviesCache / SeriesCache
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks LibraryCache]', ...args);
|
||||
|
||||
function normalizeType(type) {
|
||||
const t = String(type || '').toLowerCase();
|
||||
if (t === 'movie' || t === 'movies') return 'Movie';
|
||||
if (t === 'series' || t === 'show' || t === 'shows' || t === 'tv') return 'Series';
|
||||
return type;
|
||||
}
|
||||
|
||||
function isComplete(type) {
|
||||
const t = normalizeType(type);
|
||||
if (t === 'Movie') return window.MoviesCache?.isComplete?.() === true;
|
||||
if (t === 'Series') return window.SeriesCache?.isComplete?.() === true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isReady(type) {
|
||||
return isComplete(type);
|
||||
}
|
||||
|
||||
async function getByProviderIds(type, ids) {
|
||||
const t = normalizeType(type);
|
||||
if (t === 'Movie') {
|
||||
return window.MoviesCache?.getMoviesByProviderIds?.(ids) || [];
|
||||
}
|
||||
if (t === 'Series') {
|
||||
return window.SeriesCache?.getSeriesByProviderIds?.(ids) || [];
|
||||
}
|
||||
LOG('Unknown type for getByProviderIds:', type);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a dataSource string depends on an incomplete Movies/Series/People cache.
|
||||
*/
|
||||
function dataSourceDependsOnIncompleteCache(dataSource) {
|
||||
const ds = typeof dataSource === 'string' ? dataSource : '';
|
||||
if (!ds) return false;
|
||||
if (ds.startsWith('MoviesCache.') && !isComplete('Movie')) return true;
|
||||
if (ds.startsWith('SeriesCache.') && !isComplete('Series')) return true;
|
||||
if (ds.startsWith('PeopleCache.') && window.PeopleCache?.isComplete && !window.PeopleCache.isComplete()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a home section depends on a library/people/external cache that is not ready.
|
||||
*/
|
||||
function sectionDependsOnIncompleteCache(section) {
|
||||
if (!section) return false;
|
||||
if (Array.isArray(section.externalListUrls) && section.externalListUrls.length) {
|
||||
return !(isComplete('Movie') && isComplete('Series'));
|
||||
}
|
||||
if (dataSourceDependsOnIncompleteCache(section.discoverySourceQuery?.dataSource)) {
|
||||
return true;
|
||||
}
|
||||
const queries = section.queries || [];
|
||||
for (const q of queries) {
|
||||
if (dataSourceDependsOnIncompleteCache(q?.dataSource)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
window.LibraryCache = {
|
||||
getByProviderIds,
|
||||
isReady,
|
||||
isComplete,
|
||||
sectionDependsOnIncompleteCache
|
||||
};
|
||||
|
||||
LOG('Module loaded');
|
||||
})();
|
||||
@@ -0,0 +1,268 @@
|
||||
// KefinTweaks Library Cache Utils
|
||||
// Shared strip / provider-id helpers for moviesCache, seriesCache, peopleCache
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function stripPerson(person) {
|
||||
if (!person) return null;
|
||||
return {
|
||||
Name: person.Name,
|
||||
Id: person.Id,
|
||||
Role: person.Role,
|
||||
Type: person.Type
|
||||
};
|
||||
}
|
||||
|
||||
function stripChapter(chapter) {
|
||||
if (!chapter) return null;
|
||||
return {
|
||||
Name: chapter.Name,
|
||||
ImageTag: chapter.ImageTag
|
||||
};
|
||||
}
|
||||
|
||||
function stripMediaStream(stream) {
|
||||
if (!stream) return null;
|
||||
return {
|
||||
Height: stream.Height,
|
||||
Width: stream.Width,
|
||||
DisplayTitle: stream.DisplayTitle,
|
||||
Language: stream.Language,
|
||||
Type: stream.Type
|
||||
};
|
||||
}
|
||||
|
||||
function stripStudio(studio) {
|
||||
if (!studio) return null;
|
||||
return {
|
||||
Name: studio.Name,
|
||||
Id: studio.Id
|
||||
};
|
||||
}
|
||||
|
||||
function stripGenreItem(genre) {
|
||||
if (!genre) return null;
|
||||
return {
|
||||
Name: genre.Name,
|
||||
Id: genre.Id
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a movie item to the durable library-cache allowlist (no UserData).
|
||||
*/
|
||||
function stripMovieForCache(item) {
|
||||
if (!item) return null;
|
||||
return {
|
||||
Name: item.Name,
|
||||
Id: item.Id,
|
||||
HasSubtitles: item.HasSubtitles,
|
||||
PremiereDate: item.PremiereDate,
|
||||
CriticRating: item.CriticRating,
|
||||
OfficialRating: item.OfficialRating,
|
||||
Overview: item.Overview,
|
||||
Taglines: item.Taglines,
|
||||
CommunityRating: item.CommunityRating,
|
||||
RunTimeTicks: item.RunTimeTicks,
|
||||
ProductionYear: item.ProductionYear,
|
||||
ProviderIds: item.ProviderIds || {},
|
||||
Type: item.Type || 'Movie',
|
||||
People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [],
|
||||
Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [],
|
||||
GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [],
|
||||
MediaStreams: Array.isArray(item.MediaStreams) ? item.MediaStreams.map(stripMediaStream).filter(Boolean) : [],
|
||||
ImageTags: item.ImageTags || {},
|
||||
BackdropImageTags: item.BackdropImageTags || [],
|
||||
Chapters: Array.isArray(item.Chapters) ? item.Chapters.map(stripChapter).filter(Boolean) : [],
|
||||
Trickplay: item.Trickplay || undefined,
|
||||
DateCreated: item.DateCreated,
|
||||
DateLastSaved: item.DateLastSaved,
|
||||
UserData: item.UserData || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a series item (no MediaStreams/Chapters/Trickplay).
|
||||
*/
|
||||
function stripSeriesForCache(item) {
|
||||
if (!item) return null;
|
||||
return {
|
||||
Name: item.Name,
|
||||
Id: item.Id,
|
||||
PremiereDate: item.PremiereDate,
|
||||
CriticRating: item.CriticRating,
|
||||
OfficialRating: item.OfficialRating,
|
||||
Overview: item.Overview,
|
||||
Taglines: item.Taglines,
|
||||
CommunityRating: item.CommunityRating,
|
||||
RunTimeTicks: item.RunTimeTicks,
|
||||
ProductionYear: item.ProductionYear,
|
||||
ProviderIds: item.ProviderIds || {},
|
||||
Type: item.Type || 'Series',
|
||||
People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [],
|
||||
Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [],
|
||||
GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [],
|
||||
ImageTags: item.ImageTags || {},
|
||||
BackdropImageTags: item.BackdropImageTags || [],
|
||||
ChildCount: item.ChildCount,
|
||||
RecursiveItemCount: item.RecursiveItemCount,
|
||||
DateCreated: item.DateCreated,
|
||||
DateLastContentAdded: item.DateLastContentAdded,
|
||||
DateLastSaved: item.DateLastSaved,
|
||||
UserData: item.UserData || {}
|
||||
};
|
||||
}
|
||||
|
||||
function stripItemsForCache(items, kind) {
|
||||
const strip = kind === 'Series' ? stripSeriesForCache : stripMovieForCache;
|
||||
return (items || []).map(strip).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize provider id keys for matching (Imdb/Tmdb/Tvdb).
|
||||
* @param {string|Object} idOrObj
|
||||
* @returns {string[]} normalized keys like "imdb:tt123", "tmdb:456"
|
||||
*/
|
||||
function normalizeProviderKeys(idOrObj) {
|
||||
const keys = [];
|
||||
if (!idOrObj) return keys;
|
||||
if (typeof idOrObj === 'string') {
|
||||
const s = idOrObj.trim();
|
||||
if (!s) return keys;
|
||||
if (/^tt\d+$/i.test(s)) keys.push(`imdb:${s.toLowerCase()}`);
|
||||
else if (/^\d+$/.test(s)) {
|
||||
keys.push(`tmdb:${s}`);
|
||||
keys.push(`tvdb:${s}`);
|
||||
} else keys.push(s.toLowerCase());
|
||||
return keys;
|
||||
}
|
||||
const p = idOrObj.ProviderIds || idOrObj;
|
||||
if (p.Imdb) keys.push(`imdb:${String(p.Imdb).toLowerCase()}`);
|
||||
if (p.Tmdb) keys.push(`tmdb:${String(p.Tmdb)}`);
|
||||
if (p.Tvdb) keys.push(`tvdb:${String(p.Tvdb)}`);
|
||||
return keys;
|
||||
}
|
||||
|
||||
function itemMatchesProviderIds(item, idSet) {
|
||||
if (!item || !idSet || idSet.size === 0) return false;
|
||||
return normalizeProviderKeys(item).some(k => idSet.has(k));
|
||||
}
|
||||
|
||||
function buildProviderIdIndex(items) {
|
||||
const index = Object.create(null);
|
||||
(items || []).forEach((item) => {
|
||||
if (!item?.Id) return;
|
||||
normalizeProviderKeys(item).forEach((key) => {
|
||||
if (!index[key]) index[key] = [];
|
||||
if (!index[key].includes(item.Id)) index[key].push(item.Id);
|
||||
});
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
function getLibraryCacheSettings() {
|
||||
const fromHome = window.KefinHomeScreen?.getConfig?.()?.LIBRARY_CACHE;
|
||||
const fromDefault = window.KefinHomeConfig2?.LIBRARY_CACHE;
|
||||
return {
|
||||
movieChunkSize: 1000,
|
||||
seriesChunkSize: 1000,
|
||||
mdblistApiKey: '',
|
||||
CACHE_TTL: 14 * 24 * 60 * 60 * 1000,
|
||||
...(fromDefault || {}),
|
||||
...(fromHome || {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run strip on main thread; yields between microtasks for UI breathing room.
|
||||
* Worker path: Blob worker when available (same strip logic inlined).
|
||||
*/
|
||||
async function stripItemsAsync(items, kind) {
|
||||
if (!items || !items.length) return [];
|
||||
if (typeof Worker === 'undefined') {
|
||||
return stripItemsForCache(items, kind);
|
||||
}
|
||||
try {
|
||||
return await stripItemsInWorker(items, kind);
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks LibraryCacheUtils] Worker strip failed, using main thread:', e);
|
||||
return stripItemsForCache(items, kind);
|
||||
}
|
||||
}
|
||||
|
||||
function stripItemsInWorker(items, kind) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const workerSource = `
|
||||
${stripPerson.toString()}
|
||||
${stripChapter.toString()}
|
||||
${stripMediaStream.toString()}
|
||||
${stripStudio.toString()}
|
||||
${stripGenreItem.toString()}
|
||||
${stripMovieForCache.toString()}
|
||||
${stripSeriesForCache.toString()}
|
||||
${stripItemsForCache.toString()}
|
||||
self.onmessage = function(e) {
|
||||
try {
|
||||
const { items, kind } = e.data;
|
||||
const result = stripItemsForCache(items, kind);
|
||||
self.postMessage({ ok: true, result });
|
||||
} catch (err) {
|
||||
self.postMessage({ ok: false, error: String(err && err.message || err) });
|
||||
}
|
||||
};
|
||||
`;
|
||||
const blob = new Blob([workerSource], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const worker = new Worker(url);
|
||||
const timer = setTimeout(() => {
|
||||
worker.terminate();
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Worker strip timeout'));
|
||||
}, 30000);
|
||||
worker.onmessage = (e) => {
|
||||
clearTimeout(timer);
|
||||
worker.terminate();
|
||||
URL.revokeObjectURL(url);
|
||||
if (e.data?.ok) resolve(e.data.result);
|
||||
else reject(new Error(e.data?.error || 'Worker strip failed'));
|
||||
};
|
||||
worker.onerror = (err) => {
|
||||
clearTimeout(timer);
|
||||
worker.terminate();
|
||||
URL.revokeObjectURL(url);
|
||||
reject(err);
|
||||
};
|
||||
worker.postMessage({ items, kind });
|
||||
});
|
||||
}
|
||||
|
||||
function upsertById(existingItems, incomingItems) {
|
||||
const map = new Map();
|
||||
(existingItems || []).forEach((item) => {
|
||||
if (item?.Id) map.set(item.Id, item);
|
||||
});
|
||||
(incomingItems || []).forEach((item) => {
|
||||
if (item?.Id) map.set(item.Id, item);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function removeByIds(existingItems, removeIds) {
|
||||
if (!removeIds || !removeIds.size) return existingItems || [];
|
||||
return (existingItems || []).filter((item) => item?.Id && !removeIds.has(item.Id));
|
||||
}
|
||||
|
||||
window.LibraryCacheUtils = {
|
||||
stripMovieForCache,
|
||||
stripSeriesForCache,
|
||||
stripPerson,
|
||||
stripItemsForCache,
|
||||
stripItemsAsync,
|
||||
normalizeProviderKeys,
|
||||
itemMatchesProviderIds,
|
||||
buildProviderIdIndex,
|
||||
getLibraryCacheSettings,
|
||||
upsertById,
|
||||
removeByIds
|
||||
};
|
||||
})();
|
||||
+409
-108
@@ -1,97 +1,335 @@
|
||||
// KefinTweaks Studios Cache Manager
|
||||
// Handles fetching, processing, and caching of Popular TV Networks
|
||||
// KefinTweaks Movies Library Cache
|
||||
// Chunked per-user archive of movies (no UserData). Startup after login + incremental sync.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks Movies Cache]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks Movies Cache]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks Movies Cache]', ...args);
|
||||
const LOG = (...args) => console.log('[KefinTweaks Movies Cache]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks Movies Cache]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks Movies Cache]', ...args);
|
||||
|
||||
// 1 week
|
||||
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const IMDB_TOP_250_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours for ID list
|
||||
const CACHE_NAME = 'movies_library';
|
||||
const PARTIAL_CACHE_NAME = 'movies_library_partial';
|
||||
const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedMovieCache_';
|
||||
const IMDB_TOP_250_CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const MOVIE_FIELDS = [
|
||||
'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview', 'PrimaryImageAspectRatio',
|
||||
'DateCreated'
|
||||
].join(',');
|
||||
|
||||
// State
|
||||
let movies = null;
|
||||
let isComplete = false;
|
||||
let imdbTop250Movies = null;
|
||||
let isInitializing = false;
|
||||
let fetchPromise = null;
|
||||
let providerIndex = null;
|
||||
|
||||
function utils() {
|
||||
return window.LibraryCacheUtils || {};
|
||||
}
|
||||
|
||||
function getTtl() {
|
||||
return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function getChunkSize() {
|
||||
return utils().getLibraryCacheSettings?.().movieChunkSize || 1000;
|
||||
}
|
||||
|
||||
function lastFetchStorageKey() {
|
||||
const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous';
|
||||
return `${LAST_FETCH_KEY_PREFIX}${userId}`;
|
||||
}
|
||||
|
||||
function getLastDateFetched() {
|
||||
try {
|
||||
return localStorage.getItem(lastFetchStorageKey()) || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setLastDateFetched(iso) {
|
||||
try {
|
||||
localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString());
|
||||
} catch (e) {
|
||||
WARN('Failed to store lastDateFetchedMovieCache:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getApiHelper() {
|
||||
return window.apiHelper || window.ApiHelper;
|
||||
}
|
||||
|
||||
function buildItemsUrl({ startIndex, limit, extraParams = {} }) {
|
||||
const server = window.ApiClient.serverAddress();
|
||||
const params = new URLSearchParams({
|
||||
IncludeItemTypes: 'Movie',
|
||||
Recursive: 'true',
|
||||
Fields: MOVIE_FIELDS,
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'true',
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
StartIndex: String(startIndex || 0),
|
||||
Limit: String(limit)
|
||||
});
|
||||
Object.entries(extraParams).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') params.set(k, String(v));
|
||||
});
|
||||
return `${server}/Items?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function queryItems(url) {
|
||||
const api = getApiHelper();
|
||||
if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable');
|
||||
const result = await api.getQuery(url, { useCache: false });
|
||||
return result || { Items: [], TotalRecordCount: 0 };
|
||||
}
|
||||
|
||||
async function stripChunk(items) {
|
||||
if (utils().stripItemsAsync) {
|
||||
return utils().stripItemsAsync(items, 'Movie');
|
||||
}
|
||||
return (items || []).map((item) => utils().stripMovieForCache?.(item) || item).filter(Boolean);
|
||||
}
|
||||
|
||||
function rebuildProviderIndex(list) {
|
||||
providerIndex = utils().buildProviderIdIndex?.(list) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and caches all movies across all libraries.
|
||||
* You should not use this for anything related to UserData
|
||||
* The cache for this is long, and this is intended to simply be an "archive" of all movies across all libraries.
|
||||
* @returns {Promise<Array>} - Array of movie objects
|
||||
* Full crawl with partial resume. Commits to movies_library only when complete.
|
||||
*/
|
||||
async function fetchAndCacheMovies() {
|
||||
// IndexedDBCache
|
||||
const cacheName = 'movies_library';
|
||||
const cache = window.IndexedDBCache;
|
||||
|
||||
// Check cache first
|
||||
const cached = await cache.get(cacheName);
|
||||
if (cached) {
|
||||
LOG('Using cached Movies');
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
LOG('Fetching Movies...');
|
||||
|
||||
// We need to refactor this to pull the data in chunks of 100 items at a time in order to backfill the cache
|
||||
// We will populate 100 items at a time into the library cache.
|
||||
// Each group will contain the index of the last item in the group so we can resume from there
|
||||
// When resuming, we should first verify if the previous item based on the same sorting and startindex/limit is still the same
|
||||
// We just check the last item in the indexed db cache and make the same query starting at that index
|
||||
// If it's the same then we can resume from there, if not then we need to start from the beginning
|
||||
const allMoviesQuery = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds`;
|
||||
|
||||
const movieResult = await apiHelper.getQuery(allMoviesQuery);
|
||||
|
||||
// Resolve the data (cached or fresh)
|
||||
let allMovies = [];
|
||||
if (movieResult && movieResult.Items) {
|
||||
allMovies = movieResult.Items;
|
||||
}
|
||||
|
||||
LOG(`Fetched ${allMovies.length} Movies`);
|
||||
|
||||
// Cache the results
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
await cache.set(cacheName, allMovies, userId, CACHE_TTL);
|
||||
|
||||
return allMovies;
|
||||
} catch (err) {
|
||||
ERR('Error fetching Movies:', err);
|
||||
if (!cache) {
|
||||
ERR('IndexedDBCache unavailable');
|
||||
return [];
|
||||
}
|
||||
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const complete = await cache.get(CACHE_NAME, userId);
|
||||
if (complete && Array.isArray(complete)) {
|
||||
movies = complete;
|
||||
isComplete = true;
|
||||
rebuildProviderIndex(movies);
|
||||
LOG(`Using complete cached movies (${movies.length})`);
|
||||
return movies;
|
||||
}
|
||||
|
||||
const chunkSize = getChunkSize();
|
||||
let startIndex = 0;
|
||||
let collected = [];
|
||||
|
||||
const partial = await cache.get(PARTIAL_CACHE_NAME, userId);
|
||||
if (partial && Array.isArray(partial.items) && partial.isComplete !== true) {
|
||||
collected = partial.items;
|
||||
startIndex = partial.startIndex || collected.length;
|
||||
LOG(`Resuming movie crawl from index ${startIndex} (${collected.length} items)`);
|
||||
}
|
||||
|
||||
try {
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const url = buildItemsUrl({ startIndex, limit: chunkSize });
|
||||
const data = await queryItems(url);
|
||||
const batch = data.Items || [];
|
||||
const stripped = await stripChunk(batch);
|
||||
collected = collected.concat(stripped);
|
||||
startIndex += batch.length;
|
||||
|
||||
await cache.set(PARTIAL_CACHE_NAME, {
|
||||
isComplete: false,
|
||||
startIndex,
|
||||
items: collected
|
||||
}, userId, getTtl());
|
||||
|
||||
LOG(`Movie crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`);
|
||||
|
||||
if (batch.length < chunkSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
await yieldToMain();
|
||||
}
|
||||
}
|
||||
|
||||
movies = collected;
|
||||
isComplete = true;
|
||||
rebuildProviderIndex(movies);
|
||||
await cache.set(CACHE_NAME, movies, userId, getTtl());
|
||||
await cache.clear(PARTIAL_CACHE_NAME, userId);
|
||||
setLastDateFetched(new Date().toISOString());
|
||||
LOG(`Movie crawl complete: ${movies.length} items`);
|
||||
return movies;
|
||||
} catch (err) {
|
||||
ERR('Error fetching movies:', err);
|
||||
return movies || collected || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the cache
|
||||
* Incremental sync: upsert by MinDateLastSaved, remove orphans via TotalRecordCount + Id list.
|
||||
*/
|
||||
async function initialize() {
|
||||
if (isInitializing) return;
|
||||
|
||||
if (movies) return;
|
||||
|
||||
isInitializing = true;
|
||||
try {
|
||||
movies = await fetchAndCacheMovies();
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
async function syncMoviesCache() {
|
||||
if (!isComplete || !movies) {
|
||||
LOG('Skip syncMoviesCache: complete cache not ready');
|
||||
return movies || [];
|
||||
}
|
||||
const lastFetched = getLastDateFetched();
|
||||
if (!lastFetched) {
|
||||
LOG('Skip syncMoviesCache: no lastDateFetched');
|
||||
return movies;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches IMDb Top 250 movies using client-side filtering
|
||||
* @returns {Promise<Array>} - List of matched Movie items
|
||||
*/
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const chunkSize = getChunkSize();
|
||||
|
||||
try {
|
||||
// Additions / metadata refreshes
|
||||
let startIndex = 0;
|
||||
let incoming = [];
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const url = buildItemsUrl({
|
||||
startIndex,
|
||||
limit: chunkSize,
|
||||
extraParams: {
|
||||
MinDateLastSaved: lastFetched,
|
||||
MinDateLastSavedForUser: lastFetched,
|
||||
SortBy: 'DateCreated',
|
||||
SortOrder: 'Descending'
|
||||
}
|
||||
});
|
||||
const data = await queryItems(url);
|
||||
const batch = data.Items || [];
|
||||
incoming = incoming.concat(await stripChunk(batch));
|
||||
startIndex += batch.length;
|
||||
if (batch.length < chunkSize) hasMore = false;
|
||||
}
|
||||
|
||||
const beforeIds = new Set((movies || []).map((m) => m.Id).filter(Boolean));
|
||||
let removedIds = [];
|
||||
|
||||
if (incoming.length) {
|
||||
movies = utils().upsertById?.(movies, incoming) || movies;
|
||||
LOG(`Synced ${incoming.length} movie upserts`);
|
||||
}
|
||||
|
||||
// Count check
|
||||
const countUrl = buildItemsUrl({
|
||||
startIndex: 0,
|
||||
limit: 1,
|
||||
extraParams: {
|
||||
Fields: '',
|
||||
EnableTotalRecordCount: 'true'
|
||||
}
|
||||
});
|
||||
// Rebuild without heavy Fields for count
|
||||
const server = window.ApiClient.serverAddress();
|
||||
const countParams = new URLSearchParams({
|
||||
IncludeItemTypes: 'Movie',
|
||||
Recursive: 'true',
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'true',
|
||||
Limit: '1',
|
||||
StartIndex: '0'
|
||||
});
|
||||
const countData = await queryItems(`${server}/Items?${countParams.toString()}`);
|
||||
const serverCount = countData.TotalRecordCount;
|
||||
if (typeof serverCount === 'number' && serverCount !== movies.length) {
|
||||
LOG(`Count mismatch cache=${movies.length} server=${serverCount}; reconciling ids`);
|
||||
const serverIds = new Set();
|
||||
let idx = 0;
|
||||
let more = true;
|
||||
while (more) {
|
||||
const idParams = new URLSearchParams({
|
||||
IncludeItemTypes: 'Movie',
|
||||
Recursive: 'true',
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'false',
|
||||
Limit: String(chunkSize),
|
||||
StartIndex: String(idx),
|
||||
Fields: ''
|
||||
});
|
||||
const idData = await queryItems(`${server}/Items?${idParams.toString()}`);
|
||||
const batch = idData.Items || [];
|
||||
batch.forEach((item) => {
|
||||
if (item?.Id) serverIds.add(item.Id);
|
||||
});
|
||||
idx += batch.length;
|
||||
if (batch.length < chunkSize) more = false;
|
||||
}
|
||||
const before = movies.length;
|
||||
removedIds = [...beforeIds].filter((id) => !serverIds.has(id));
|
||||
movies = movies.filter((m) => m?.Id && serverIds.has(m.Id));
|
||||
// Add any missing ids not in cache (light fetch would lack fields — full upsert via Id list)
|
||||
const cachedIds = new Set(movies.map((m) => m.Id));
|
||||
const missing = [...serverIds].filter((id) => !cachedIds.has(id));
|
||||
if (missing.length) {
|
||||
WARN(`${missing.length} movies on server missing from cache after reconcile; fetching by Ids`);
|
||||
for (let i = 0; i < missing.length; i += chunkSize) {
|
||||
const slice = missing.slice(i, i + chunkSize);
|
||||
const byIdParams = new URLSearchParams({
|
||||
Ids: slice.join(','),
|
||||
Fields: MOVIE_FIELDS,
|
||||
EnableTotalRecordCount: 'false'
|
||||
});
|
||||
const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`);
|
||||
const stripped = await stripChunk(byIdData.Items || []);
|
||||
movies = utils().upsertById?.(movies, stripped) || movies;
|
||||
incoming = incoming.concat(stripped);
|
||||
}
|
||||
}
|
||||
LOG(`Reconcile complete: ${before} → ${movies.length}`);
|
||||
}
|
||||
|
||||
rebuildProviderIndex(movies);
|
||||
await cache.set(CACHE_NAME, movies, userId, getTtl());
|
||||
setLastDateFetched(new Date().toISOString());
|
||||
// Notify people cache of deltas when available
|
||||
if ((incoming.length || removedIds.length) && window.PeopleCache?.applyMovieDelta) {
|
||||
try {
|
||||
await window.PeopleCache.applyMovieDelta(incoming, removedIds);
|
||||
} catch (e) {
|
||||
WARN('PeopleCache.applyMovieDelta failed:', e);
|
||||
}
|
||||
}
|
||||
return movies;
|
||||
} catch (err) {
|
||||
ERR('syncMoviesCache failed:', err);
|
||||
return movies || [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getMovies() {
|
||||
if (movies && isComplete) return movies;
|
||||
if (fetchPromise) return fetchPromise;
|
||||
fetchPromise = (async () => {
|
||||
await fetchAndCacheMovies();
|
||||
return movies || [];
|
||||
})().finally(() => {
|
||||
fetchPromise = null;
|
||||
});
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
function isCacheComplete() {
|
||||
return isComplete === true && Array.isArray(movies);
|
||||
}
|
||||
|
||||
async function getMoviesByProviderIds(ids) {
|
||||
const list = await getMovies();
|
||||
const idSet = new Set();
|
||||
(ids || []).forEach((id) => {
|
||||
(utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k));
|
||||
});
|
||||
if (!idSet.size) return [];
|
||||
return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet));
|
||||
}
|
||||
|
||||
async function fetchImdbTop250Data() {
|
||||
const CACHE_KEY_IDS = 'kefinTweaks_imdbTop250_ids';
|
||||
|
||||
let imdbIds = null;
|
||||
|
||||
// 1. Get IDs (Local Cache or Fetch GitHub)
|
||||
try {
|
||||
const cachedIds = localStorage.getItem(CACHE_KEY_IDS);
|
||||
if (cachedIds) {
|
||||
@@ -100,7 +338,9 @@
|
||||
imdbIds = parsed.ids;
|
||||
}
|
||||
}
|
||||
} catch (e) { WARN('Error reading IMDb Top 250 cache:', e); }
|
||||
} catch (e) {
|
||||
WARN('Error reading IMDb Top 250 cache:', e);
|
||||
}
|
||||
|
||||
if (!imdbIds) {
|
||||
LOG('Fetching IMDb Top 250 list from GitHub...');
|
||||
@@ -108,15 +348,10 @@
|
||||
const response = await fetch('https://raw.githubusercontent.com/theapache64/top250/master/top250_min.json');
|
||||
if (!response.ok) throw new Error('Failed to fetch Top 250 JSON');
|
||||
const data = await response.json();
|
||||
|
||||
// Extract IDs
|
||||
imdbIds = data.map(entry => {
|
||||
// entry.imdb_url e.g. "http://www.imdb.com/title/tt0111161/"
|
||||
imdbIds = data.map((entry) => {
|
||||
const match = entry.imdb_url && entry.imdb_url.match(/\/title\/(tt\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
// Cache
|
||||
localStorage.setItem(CACHE_KEY_IDS, JSON.stringify({
|
||||
timestamp: Date.now(),
|
||||
ids: imdbIds
|
||||
@@ -126,61 +361,127 @@
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!imdbIds || imdbIds.length === 0) return [];
|
||||
|
||||
return imdbIds;
|
||||
return imdbIds || [];
|
||||
}
|
||||
|
||||
async function getImdbTop250Movies() {
|
||||
const cacheName = 'imdbTop250Movies';
|
||||
const cache = window.IndexedDBCache;
|
||||
const cached = await cache.get(cacheName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (cached) return cached;
|
||||
|
||||
if (!imdbTop250Movies) {
|
||||
if (!isCacheComplete()) {
|
||||
LOG('IMDb Top 250 waiting for movie cache…');
|
||||
}
|
||||
const imdbIds = await fetchImdbTop250Data();
|
||||
const allMovies = await getMovies();
|
||||
|
||||
// 3. Client-side Match
|
||||
const matchedMovies = [];
|
||||
const idsSet = new Set(imdbIds); // Faster lookup
|
||||
|
||||
for (const movie of allMovies) {
|
||||
if (movie.ProviderIds && movie.ProviderIds.Imdb && idsSet.has(movie.ProviderIds.Imdb)) {
|
||||
matchedMovies.push(movie);
|
||||
}
|
||||
}
|
||||
imdbTop250Movies = matchedMovies;
|
||||
const idsSet = new Set(imdbIds);
|
||||
imdbTop250Movies = allMovies.filter(
|
||||
(movie) => movie.ProviderIds?.Imdb && idsSet.has(movie.ProviderIds.Imdb)
|
||||
);
|
||||
}
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
await cache.set(cacheName, imdbTop250Movies, userId, IMDB_TOP_250_CACHE_TTL);
|
||||
return imdbTop250Movies || [];
|
||||
}
|
||||
|
||||
async function getMovies() {
|
||||
if (!movies) {
|
||||
movies = await fetchAndCacheMovies();
|
||||
async function initialize() {
|
||||
await getMovies();
|
||||
if (isCacheComplete() && getLastDateFetched()) {
|
||||
await syncMoviesCache();
|
||||
}
|
||||
return movies || [];
|
||||
}
|
||||
|
||||
// Expose API
|
||||
async function waitForLoginReady(maxWaitMs = 60000) {
|
||||
const start = Date.now();
|
||||
while (!window.userHelper?.waitForLogin) {
|
||||
if (Date.now() - start >= maxWaitMs) {
|
||||
ERR('Timed out waiting for userHelper');
|
||||
return false;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return window.userHelper.waitForLogin(maxWaitMs);
|
||||
}
|
||||
|
||||
function yieldToMain() {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(() => resolve(), { timeout: 500 });
|
||||
} else {
|
||||
requestAnimationFrame(() => setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let bootstrapStarted = false;
|
||||
let bootstrapScheduled = false;
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapStarted) return;
|
||||
bootstrapStarted = true;
|
||||
try {
|
||||
await waitForLoginReady();
|
||||
await initialize();
|
||||
} catch (e) {
|
||||
ERR('Bootstrap failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Defer crawl until home has painted (or 3s after login) so home getQuery is not starved. */
|
||||
function scheduleBootstrap() {
|
||||
if (bootstrapScheduled) return;
|
||||
bootstrapScheduled = true;
|
||||
|
||||
const start = () => { bootstrap(); };
|
||||
|
||||
const onPainted = () => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
};
|
||||
document.addEventListener('kefinTweaksHomePainted', onPainted);
|
||||
|
||||
waitForLoginReady().then((ok) => {
|
||||
if (!ok) {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
window.MoviesCache = {
|
||||
init: initialize,
|
||||
getMovies: getMovies,
|
||||
getImdbTop250Movies: getImdbTop250Movies,
|
||||
// Also expose the fetcher if we need to force refresh
|
||||
getMovies,
|
||||
getImdbTop250Movies,
|
||||
getMoviesByProviderIds,
|
||||
syncMoviesCache,
|
||||
fetchAndCacheMovies,
|
||||
isComplete: isCacheComplete,
|
||||
getLastDateFetched,
|
||||
bootstrap,
|
||||
scheduleBootstrap,
|
||||
refresh: async () => {
|
||||
const cache = window.IndexedDBCache;
|
||||
cache.delete('movies_library');
|
||||
movies = await fetchAndCacheMovies();
|
||||
return movies;
|
||||
const userId = window.ApiClient?.getCurrentUserId?.();
|
||||
if (cache) {
|
||||
await cache.clear(CACHE_NAME, userId);
|
||||
await cache.clear(PARTIAL_CACHE_NAME, userId);
|
||||
}
|
||||
movies = null;
|
||||
isComplete = false;
|
||||
providerIndex = null;
|
||||
imdbTop250Movies = null;
|
||||
return fetchAndCacheMovies();
|
||||
}
|
||||
};
|
||||
|
||||
scheduleBootstrap();
|
||||
LOG('Module loaded');
|
||||
|
||||
})();
|
||||
|
||||
+581
-272
@@ -1,5 +1,5 @@
|
||||
// KefinTweaks People Cache Manager
|
||||
// Handles fetching, processing, and caching of top actors, directors, and writers
|
||||
// KefinTweaks People Cache
|
||||
// Built from MoviesCache + SeriesCache; episode credits via /Shows/{id}/Episodes (not a global episode store).
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
@@ -7,338 +7,647 @@
|
||||
const WARN = (...args) => console.warn('[KefinTweaks PeopleCache]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks PeopleCache]', ...args);
|
||||
|
||||
// Configuration
|
||||
const MIN_PEOPLE_APPEARANCES = 2; // Minimum number of items a person must appear in to be tracked
|
||||
const CACHE_NAME = 'library_top_people_v2';
|
||||
const DEFAULT_MIN = 10;
|
||||
const DEFAULT_PER_TYPE_MAX = 100;
|
||||
const EPISODE_FETCH_CONCURRENCY = 6;
|
||||
|
||||
// State
|
||||
let moviesTopPeople = null;
|
||||
let peopleMap = null; // Map<personId, Person>
|
||||
let filteredLists = null; // { actors, directors, writers }
|
||||
let isComplete = false;
|
||||
/** @type {Promise<*>|null} Shared so concurrent callers await the same paginated fetch */
|
||||
let fetchPromise = null;
|
||||
/** @type {Promise<void>|null} Shared so concurrent callers await the same build */
|
||||
let initPromise = null;
|
||||
|
||||
/**
|
||||
* Extracts necessary item data for caching (similar to watchlist format)
|
||||
*/
|
||||
function extractItemDataForCache(item) {
|
||||
function getHomeSettings() {
|
||||
return window.KefinHomeScreen?.getConfig?.()?.HOME_SETTINGS
|
||||
|| window.KefinHomeConfig2?.HOME_SETTINGS
|
||||
|| {};
|
||||
}
|
||||
|
||||
function getMins() {
|
||||
const home = getHomeSettings();
|
||||
return {
|
||||
Id: item.Id,
|
||||
Type: item.Type,
|
||||
Name: item.Name,
|
||||
SeriesId: item.SeriesId,
|
||||
SeriesName: item.SeriesName,
|
||||
IndexNumber: item.IndexNumber,
|
||||
ParentIndexNumber: item.ParentIndexNumber,
|
||||
PremiereDate: item.PremiereDate,
|
||||
ProductionYear: item.ProductionYear,
|
||||
UserData: {
|
||||
Played: item.UserData?.Played || false,
|
||||
IsFavorite: item.UserData?.IsFavorite || false
|
||||
}
|
||||
total: home.minPeopleAppearancesTotal ?? DEFAULT_MIN,
|
||||
movies: home.minPeopleAppearancesMovies ?? DEFAULT_MIN,
|
||||
series: home.minPeopleAppearancesSeries ?? DEFAULT_MIN,
|
||||
episodes: home.minPeopleAppearancesEpisodes ?? DEFAULT_MIN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes people data from movies array and returns RAW data (all people, unfiltered)
|
||||
*/
|
||||
function processPeopleDataRaw(movies, existingPeopleMap = null) {
|
||||
try {
|
||||
const peopleMap = existingPeopleMap ? new Map(existingPeopleMap) : new Map();
|
||||
|
||||
movies.forEach(movie => {
|
||||
// Extract item data for caching
|
||||
const itemData = extractItemDataForCache(movie);
|
||||
|
||||
if (movie.People) {
|
||||
movie.People.forEach(person => {
|
||||
const key = person.Id;
|
||||
if (!peopleMap.has(key)) {
|
||||
peopleMap.set(key, {
|
||||
...person,
|
||||
directorCount: 0,
|
||||
writerCount: 0,
|
||||
actorCount: 0,
|
||||
directorItems: [],
|
||||
writerItems: [],
|
||||
actorItems: []
|
||||
});
|
||||
}
|
||||
|
||||
const personData = peopleMap.get(key);
|
||||
if (person.Type === 'Director') {
|
||||
personData.directorCount++;
|
||||
if (!personData.directorItems.some(item => item.Id === movie.Id)) {
|
||||
personData.directorItems.push(itemData);
|
||||
}
|
||||
} else if (person.Type === 'Writer') {
|
||||
personData.writerCount++;
|
||||
if (!personData.writerItems.some(item => item.Id === movie.Id)) {
|
||||
personData.writerItems.push(itemData);
|
||||
}
|
||||
} else if (person.Type === 'Actor') {
|
||||
personData.actorCount++;
|
||||
if (!personData.actorItems.some(item => item.Id === movie.Id)) {
|
||||
personData.actorItems.push(itemData);
|
||||
}
|
||||
}
|
||||
});
|
||||
function getPerTypeMaxCount() {
|
||||
const max = getHomeSettings().maxPeopleCount;
|
||||
const n = parseInt(max, 10);
|
||||
if (Number.isFinite(n) && n > 0) return Math.min(n, DEFAULT_PER_TYPE_MAX);
|
||||
return DEFAULT_PER_TYPE_MAX;
|
||||
}
|
||||
|
||||
function shouldLoadPeopleEpisodeData() {
|
||||
return getHomeSettings().loadPeopleEpisodeData === true;
|
||||
}
|
||||
|
||||
function isAggregateItemType(itemType) {
|
||||
return itemType == null || itemType === '' || itemType === 'All';
|
||||
}
|
||||
|
||||
function movieRoleCount(person, role) {
|
||||
const m = person.movies || {};
|
||||
if (role === 'Actor') return m.actorCount || 0;
|
||||
if (role === 'Director') return m.directorCount || 0;
|
||||
if (role === 'Writer') return m.writerCount || 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function episodeRoleCount(person, role) {
|
||||
const s = person.series || {};
|
||||
if (role === 'Actor') return s.actorCount || 0;
|
||||
if (role === 'Director') return s.directorCount || 0;
|
||||
if (role === 'Writer') return s.writerCount || 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function totalAppearances(person) {
|
||||
const m = person.movies || {};
|
||||
const s = person.series || {};
|
||||
return (m.actorCount || 0)
|
||||
+ (m.directorCount || 0)
|
||||
+ (m.writerCount || 0)
|
||||
+ (s.count || 0)
|
||||
+ (s.actorCount || 0)
|
||||
+ (s.directorCount || 0)
|
||||
+ (s.writerCount || 0);
|
||||
}
|
||||
|
||||
function personMeetsMin(person, mins) {
|
||||
const m = person.movies || {};
|
||||
const s = person.series || {};
|
||||
const episodeMax = Math.max(s.actorCount || 0, s.directorCount || 0, s.writerCount || 0);
|
||||
return (m.actorCount >= mins.movies)
|
||||
|| (m.directorCount >= mins.movies)
|
||||
|| (m.writerCount >= mins.movies)
|
||||
|| ((s.count || 0) >= mins.series)
|
||||
|| (episodeMax >= mins.episodes)
|
||||
|| (totalAppearances(person) >= mins.total);
|
||||
}
|
||||
|
||||
function prunePeopleMap(map) {
|
||||
if (!map) return map;
|
||||
const mins = getMins();
|
||||
const beforeMin = map.size;
|
||||
for (const [id, person] of map) {
|
||||
if (!personMeetsMin(person, mins)) map.delete(id);
|
||||
}
|
||||
if (beforeMin !== map.size) {
|
||||
LOG(`People prune (mins): ${beforeMin} → ${map.size}`);
|
||||
}
|
||||
|
||||
const maxPerBucket = getPerTypeMaxCount();
|
||||
const keepIds = new Set();
|
||||
const itemTypes = ['Movie', 'Series', 'Episode', null];
|
||||
const roles = ['Actor', 'Director', 'Writer'];
|
||||
for (const itemType of itemTypes) {
|
||||
for (const role of roles) {
|
||||
filterRoleList(role, itemType).slice(0, maxPerBucket).forEach((p) => keepIds.add(p.Id));
|
||||
}
|
||||
}
|
||||
const beforeMax = map.size;
|
||||
for (const id of Array.from(map.keys())) {
|
||||
if (!keepIds.has(id)) map.delete(id);
|
||||
}
|
||||
if (beforeMax !== map.size) {
|
||||
LOG(`People prune (per-type max ${maxPerBucket}): ${beforeMax} → ${map.size}`);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function ensurePerson(map, person) {
|
||||
const key = person?.Id;
|
||||
if (!key) return null;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
Id: person.Id,
|
||||
Name: person.Name,
|
||||
movies: {
|
||||
directorCount: 0,
|
||||
writerCount: 0,
|
||||
actorCount: 0,
|
||||
directorItems: [],
|
||||
writerItems: [],
|
||||
actorItems: []
|
||||
},
|
||||
series: {
|
||||
count: 0,
|
||||
items: [],
|
||||
actorCount: 0,
|
||||
directorCount: 0,
|
||||
writerCount: 0
|
||||
}
|
||||
});
|
||||
|
||||
return peopleMap;
|
||||
} catch (err) {
|
||||
ERR('Failed to process people data:', err);
|
||||
return null;
|
||||
} else if (person.Name && !map.get(key).Name) {
|
||||
map.get(key).Name = person.Name;
|
||||
}
|
||||
return map.get(key);
|
||||
}
|
||||
|
||||
function pushUniqueId(arr, id) {
|
||||
if (!id) return false;
|
||||
if (arr.includes(id)) return false;
|
||||
arr.push(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function bumpEpisodeRole(series, type, delta) {
|
||||
if (!series || !delta) return;
|
||||
if (type === 'Director') {
|
||||
series.directorCount = Math.max(0, (series.directorCount || 0) + delta);
|
||||
} else if (type === 'Writer') {
|
||||
series.writerCount = Math.max(0, (series.writerCount || 0) + delta);
|
||||
} else if (type === 'Actor') {
|
||||
series.actorCount = Math.max(0, (series.actorCount || 0) + delta);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMoviePeople(map, movie) {
|
||||
if (!movie?.People || !movie.Id) return;
|
||||
movie.People.forEach((person) => {
|
||||
const personData = ensurePerson(map, person);
|
||||
if (!personData) return;
|
||||
const m = personData.movies;
|
||||
if (person.Type === 'Director') {
|
||||
if (pushUniqueId(m.directorItems, movie.Id)) m.directorCount = m.directorItems.length;
|
||||
} else if (person.Type === 'Writer') {
|
||||
if (pushUniqueId(m.writerItems, movie.Id)) m.writerCount = m.writerItems.length;
|
||||
} else if (person.Type === 'Actor') {
|
||||
if (pushUniqueId(m.actorItems, movie.Id)) m.actorCount = m.actorItems.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applySeriesPeople(map, seriesItem) {
|
||||
if (!seriesItem?.People || !seriesItem.Id) return;
|
||||
seriesItem.People.forEach((person) => {
|
||||
const personData = ensurePerson(map, person);
|
||||
if (!personData) return;
|
||||
if (pushUniqueId(personData.series.items, seriesItem.Id)) {
|
||||
personData.series.count = personData.series.items.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyEpisodePeople(map, seriesId, episode) {
|
||||
if (!episode?.People || !episode.Id || !seriesId) return;
|
||||
episode.People.forEach((person) => {
|
||||
const personData = ensurePerson(map, person);
|
||||
if (!personData) return;
|
||||
bumpEpisodeRole(personData.series, person.Type, 1);
|
||||
});
|
||||
}
|
||||
|
||||
function decrementEpisodePeople(map, episode) {
|
||||
if (!episode?.People || !map) return;
|
||||
episode.People.forEach((person) => {
|
||||
const personData = person?.Id ? map.get(person.Id) : null;
|
||||
if (!personData) return;
|
||||
bumpEpisodeRole(personData.series, person.Type, -1);
|
||||
});
|
||||
}
|
||||
|
||||
function removeMovieFromPeople(map, movieId) {
|
||||
if (!map || !movieId) return;
|
||||
map.forEach((person) => {
|
||||
const m = person.movies;
|
||||
['directorItems', 'writerItems', 'actorItems'].forEach((key) => {
|
||||
const before = m[key].length;
|
||||
m[key] = m[key].filter((id) => id !== movieId);
|
||||
if (m[key].length !== before) {
|
||||
if (key === 'directorItems') m.directorCount = m[key].length;
|
||||
if (key === 'writerItems') m.writerCount = m[key].length;
|
||||
if (key === 'actorItems') m.actorCount = m[key].length;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeSeriesMembership(map, seriesId) {
|
||||
if (!map || !seriesId) return;
|
||||
map.forEach((person) => {
|
||||
person.series.items = person.series.items.filter((id) => id !== seriesId);
|
||||
person.series.count = person.series.items.length;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchEpisodesForSeries(seriesId) {
|
||||
const api = window.apiHelper || window.ApiHelper;
|
||||
const server = window.ApiClient.serverAddress();
|
||||
const url = `${server}/Shows/${seriesId}/Episodes?Fields=People&EnableTotalRecordCount=false`;
|
||||
const data = await api.getQuery(url, { useCache: false });
|
||||
return data?.Items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters and sorts raw people data
|
||||
* Fetch episodes for many series with limited concurrency.
|
||||
* @param {Array<{Id: string}|string>} seriesList
|
||||
* @param {(seriesId: string, episodes: Array) => void} onSeriesEpisodes
|
||||
*/
|
||||
function filterPeopleData(peopleMap) {
|
||||
async function fetchEpisodesForSeriesBatched(seriesList, onSeriesEpisodes) {
|
||||
const list = (seriesList || [])
|
||||
.map((s) => (typeof s === 'string' ? { Id: s } : s))
|
||||
.filter((s) => s?.Id);
|
||||
let completed = 0;
|
||||
for (let i = 0; i < list.length; i += EPISODE_FETCH_CONCURRENCY) {
|
||||
const chunk = list.slice(i, i + EPISODE_FETCH_CONCURRENCY);
|
||||
await Promise.all(chunk.map(async (s) => {
|
||||
try {
|
||||
const episodes = await fetchEpisodesForSeries(s.Id);
|
||||
onSeriesEpisodes(s.Id, episodes);
|
||||
} catch (e) {
|
||||
WARN(`Failed episodes for series ${s.Id}:`, e);
|
||||
}
|
||||
}));
|
||||
completed += chunk.length;
|
||||
const prev = completed - chunk.length;
|
||||
if (completed === list.length || Math.floor(prev / 25) !== Math.floor(completed / 25)) {
|
||||
LOG(`Episode people progress: ${completed}/${list.length} series`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function subtractEpisodeCreditsForSeriesIds(map, seriesIds) {
|
||||
const ids = (seriesIds || []).filter(Boolean);
|
||||
if (!ids.length || !map) return;
|
||||
await fetchEpisodesForSeriesBatched(ids, (_seriesId, episodes) => {
|
||||
episodes.forEach((ep) => decrementEpisodePeople(map, ep));
|
||||
});
|
||||
}
|
||||
|
||||
function clearEpisodeRoleCounts(map) {
|
||||
if (!map) return;
|
||||
map.forEach((person) => {
|
||||
person.series.actorCount = 0;
|
||||
person.series.directorCount = 0;
|
||||
person.series.writerCount = 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function rebuildAllEpisodeRoleCounts(map) {
|
||||
clearEpisodeRoleCounts(map);
|
||||
const seriesList = await window.SeriesCache?.getSeries?.() || [];
|
||||
await fetchEpisodesForSeriesBatched(seriesList, (seriesId, episodes) => {
|
||||
episodes.forEach((ep) => applyEpisodePeople(map, seriesId, ep));
|
||||
});
|
||||
}
|
||||
|
||||
function roleCount(person, role, itemType) {
|
||||
const movieCount = movieRoleCount(person, role);
|
||||
const seriesCount = person.series?.count || 0;
|
||||
const episodeCount = episodeRoleCount(person, role);
|
||||
if (itemType === 'Movie') return movieCount;
|
||||
if (itemType === 'Series') return seriesCount;
|
||||
if (itemType === 'Episode') return episodeCount;
|
||||
if (isAggregateItemType(itemType)) return movieCount + seriesCount + episodeCount;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function roleItems(person, role, itemType) {
|
||||
if (itemType === 'Movie') {
|
||||
if (role === 'Actor') return person.movies.actorItems || [];
|
||||
if (role === 'Director') return person.movies.directorItems || [];
|
||||
if (role === 'Writer') return person.movies.writerItems || [];
|
||||
return [];
|
||||
}
|
||||
if (itemType === 'Series') return person.series.items || [];
|
||||
if (itemType === 'Episode') return [];
|
||||
if (isAggregateItemType(itemType)) {
|
||||
const movieItems = roleItems(person, role, 'Movie');
|
||||
const seriesItems = person.series.items || [];
|
||||
return [...movieItems, ...seriesItems];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function filterRoleList(role, itemType, minCount) {
|
||||
if (!peopleMap) return [];
|
||||
const mins = getMins();
|
||||
const type = isAggregateItemType(itemType) ? null : itemType;
|
||||
let threshold = minCount;
|
||||
if (threshold == null) {
|
||||
if (type === 'Movie') threshold = mins.movies;
|
||||
else if (type === 'Series') threshold = mins.series;
|
||||
else if (type === 'Episode') threshold = mins.episodes;
|
||||
else threshold = mins.total;
|
||||
}
|
||||
return Array.from(peopleMap.values())
|
||||
.map((person) => {
|
||||
const count = roleCount(person, role, type);
|
||||
return {
|
||||
Id: person.Id,
|
||||
Name: person.Name,
|
||||
count,
|
||||
items: roleItems(person, role, type),
|
||||
movies: person.movies,
|
||||
series: person.series
|
||||
};
|
||||
})
|
||||
.filter((p) => p.count >= threshold)
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
function rebuildFilteredLists() {
|
||||
filteredLists = {
|
||||
actors: filterRoleList('Actor', null),
|
||||
directors: filterRoleList('Director', null),
|
||||
writers: filterRoleList('Writer', null),
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
async function persistPeople() {
|
||||
const cache = window.IndexedDBCache;
|
||||
if (!cache || !peopleMap) return;
|
||||
prunePeopleMap(peopleMap);
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const ttl = window.LibraryCacheUtils?.getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
|
||||
const payload = {
|
||||
isComplete: true,
|
||||
loadPeopleEpisodeData: shouldLoadPeopleEpisodeData(),
|
||||
peopleData: Array.from(peopleMap.entries())
|
||||
};
|
||||
try {
|
||||
if (!peopleMap) return null;
|
||||
|
||||
const allPeople = Array.from(peopleMap.values());
|
||||
|
||||
// Get threshold from config or default
|
||||
const minAppearances = (window.KefinTweaksConfig &&
|
||||
window.KefinTweaksConfig.homeScreen &&
|
||||
window.KefinTweaksConfig.homeScreen.discovery &&
|
||||
window.KefinTweaksConfig.homeScreen.discovery.minPeopleAppearances)
|
||||
|| MIN_PEOPLE_APPEARANCES;
|
||||
|
||||
const topDirectors = allPeople
|
||||
.filter(person => person.directorCount >= minAppearances)
|
||||
.sort((a, b) => b.directorCount - a.directorCount)
|
||||
.map(person => ({
|
||||
...person,
|
||||
count: person.directorCount,
|
||||
items: person.directorItems || []
|
||||
}));
|
||||
|
||||
const topWriters = allPeople
|
||||
.filter(person => person.writerCount >= minAppearances)
|
||||
.sort((a, b) => b.writerCount - a.writerCount)
|
||||
.map(person => ({
|
||||
...person,
|
||||
count: person.writerCount,
|
||||
items: person.writerItems || []
|
||||
}));
|
||||
|
||||
const topActors = allPeople
|
||||
.filter(person => person.actorCount >= minAppearances)
|
||||
.sort((a, b) => b.actorCount - a.actorCount)
|
||||
.map(person => ({
|
||||
...person,
|
||||
count: person.actorCount,
|
||||
items: person.actorItems || []
|
||||
}));
|
||||
|
||||
return {
|
||||
actors: topActors,
|
||||
directors: topDirectors,
|
||||
writers: topWriters
|
||||
};
|
||||
} catch (err) {
|
||||
ERR('Failed to filter people data:', err);
|
||||
return null;
|
||||
await cache.set(CACHE_NAME, payload, userId, ttl);
|
||||
} catch (e) {
|
||||
WARN('Failed to persist people cache:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and processes top people data from all movies using pagination
|
||||
*/
|
||||
async function fetchTopPeople() {
|
||||
if (fetchPromise) {
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
async function fetchAndCachePeople() {
|
||||
if (fetchPromise) return fetchPromise;
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
LOG('Starting paginated fetch of movies for people data processing...');
|
||||
const apiClient = window.ApiClient;
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
const indexedDBCache = window.IndexedDBCache;
|
||||
LOG('Building people cache from movies + series…');
|
||||
const movies = await window.MoviesCache.getMovies();
|
||||
const seriesList = await window.SeriesCache.getSeries();
|
||||
const map = new Map();
|
||||
|
||||
let startIndex = 0;
|
||||
const limit = 500;
|
||||
let hasMoreData = true;
|
||||
let peopleMap = null;
|
||||
(movies || []).forEach((movie) => applyMoviePeople(map, movie));
|
||||
(seriesList || []).forEach((s) => applySeriesPeople(map, s));
|
||||
|
||||
// Load existing raw people data if available (for resuming)
|
||||
const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId);
|
||||
if (existingRawData && !existingRawData.isComplete) {
|
||||
const moviesProcessed = existingRawData.moviesProcessedCount;
|
||||
if (moviesProcessed > 0) {
|
||||
startIndex = moviesProcessed;
|
||||
peopleMap = new Map(existingRawData.peopleData || []);
|
||||
LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`);
|
||||
}
|
||||
if (shouldLoadPeopleEpisodeData()) {
|
||||
await fetchEpisodesForSeriesBatched(seriesList, (seriesId, episodes) => {
|
||||
episodes.forEach((ep) => applyEpisodePeople(map, seriesId, ep));
|
||||
});
|
||||
} else {
|
||||
LOG('Skipping episode people fetch (loadPeopleEpisodeData disabled)');
|
||||
}
|
||||
|
||||
while (hasMoreData) {
|
||||
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`;
|
||||
|
||||
LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`);
|
||||
|
||||
const data = await window.apiHelper.getQuery(url);
|
||||
const movies = (data && data.Items) || [];
|
||||
|
||||
if (movies.length === 0) {
|
||||
hasMoreData = false;
|
||||
isComplete = true;
|
||||
} else {
|
||||
peopleMap = processPeopleDataRaw(movies, peopleMap);
|
||||
if (!peopleMap) {
|
||||
throw new Error('processPeopleDataRaw failed');
|
||||
}
|
||||
startIndex += movies.length;
|
||||
|
||||
// Update global state with partial results for immediate use
|
||||
const filteredData = filterPeopleData(peopleMap);
|
||||
if (filteredData) {
|
||||
moviesTopPeople = filteredData;
|
||||
}
|
||||
|
||||
// Checkpoint raw data (non-fatal if IndexedDB rejects large payloads)
|
||||
const rawDataToCache = {
|
||||
isComplete: movies.length < limit,
|
||||
moviesProcessedCount: startIndex,
|
||||
peopleData: Array.from(peopleMap.entries())
|
||||
};
|
||||
try {
|
||||
await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000);
|
||||
} catch (checkpointErr) {
|
||||
WARN('Failed to checkpoint raw people data (continuing):', checkpointErr);
|
||||
}
|
||||
|
||||
if (movies.length < limit) {
|
||||
hasMoreData = false;
|
||||
isComplete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalPeopleData = filterPeopleData(peopleMap) || {
|
||||
actors: [],
|
||||
directors: [],
|
||||
writers: []
|
||||
};
|
||||
finalPeopleData.isComplete = true;
|
||||
moviesTopPeople = finalPeopleData;
|
||||
peopleMap = map;
|
||||
prunePeopleMap(map);
|
||||
isComplete = true;
|
||||
|
||||
try {
|
||||
await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
|
||||
await indexedDBCache.clear('movies_top_people_raw', userId);
|
||||
} catch (saveErr) {
|
||||
WARN('Failed to persist complete people cache (in-memory still available):', saveErr);
|
||||
}
|
||||
|
||||
LOG(peopleMap ? 'People cache complete and saved' : 'People cache complete (empty library)');
|
||||
return finalPeopleData;
|
||||
rebuildFilteredLists();
|
||||
await persistPeople();
|
||||
LOG(`People cache complete: ${peopleMap.size} people`);
|
||||
return filteredLists;
|
||||
} catch (err) {
|
||||
ERR('Failed to fetch top people data:', err);
|
||||
ERR('fetchAndCachePeople failed:', err);
|
||||
return null;
|
||||
} finally {
|
||||
fetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the people cache (awaits build; concurrent callers share one promise)
|
||||
*/
|
||||
async function initialize() {
|
||||
if (moviesTopPeople !== null && isComplete) {
|
||||
return;
|
||||
}
|
||||
if (initPromise) {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
if (isComplete && peopleMap) return;
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
if (moviesTopPeople !== null && isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexedDBCache = window.IndexedDBCache;
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
|
||||
// Check for valid complete filtered data
|
||||
const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId);
|
||||
if (validComplete) {
|
||||
const cachedData = await indexedDBCache.get('movies_top_people', userId);
|
||||
if (cachedData && cachedData.isComplete) {
|
||||
moviesTopPeople = cachedData;
|
||||
isComplete = true;
|
||||
LOG('Loaded complete top people data from IndexedDB');
|
||||
return;
|
||||
if (cache && await cache.isCacheValid(CACHE_NAME, userId)) {
|
||||
const cached = await cache.get(CACHE_NAME, userId);
|
||||
if (cached?.isComplete && Array.isArray(cached.peopleData)) {
|
||||
const currentMode = shouldLoadPeopleEpisodeData();
|
||||
// Legacy caches (no flag) always included episode data
|
||||
const cachedMode = typeof cached.loadPeopleEpisodeData === 'boolean'
|
||||
? cached.loadPeopleEpisodeData
|
||||
: true;
|
||||
if (cachedMode !== currentMode) {
|
||||
LOG(`People cache episode mode mismatch (cached=${cachedMode}, current=${currentMode}); rebuilding`);
|
||||
await cache.clear?.(CACHE_NAME, userId);
|
||||
} else {
|
||||
peopleMap = new Map(cached.peopleData);
|
||||
const before = peopleMap.size;
|
||||
prunePeopleMap(peopleMap);
|
||||
isComplete = true;
|
||||
rebuildFilteredLists();
|
||||
LOG('Loaded people cache from IndexedDB');
|
||||
if (peopleMap.size < before) {
|
||||
await persistPeople();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await fetchTopPeople();
|
||||
await fetchAndCachePeople();
|
||||
} finally {
|
||||
initPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function getTopPeople() {
|
||||
LOG('Getting top people data');
|
||||
try {
|
||||
if (!moviesTopPeople) {
|
||||
await initialize();
|
||||
async function applyMovieDelta(changedMovies, removedIds) {
|
||||
if (!peopleMap) await initialize();
|
||||
if (!peopleMap) return;
|
||||
(removedIds || []).forEach((id) => removeMovieFromPeople(peopleMap, id));
|
||||
(changedMovies || []).forEach((movie) => {
|
||||
removeMovieFromPeople(peopleMap, movie.Id);
|
||||
applyMoviePeople(peopleMap, movie);
|
||||
});
|
||||
prunePeopleMap(peopleMap);
|
||||
rebuildFilteredLists();
|
||||
await persistPeople();
|
||||
}
|
||||
|
||||
async function applySeriesDelta(changedSeries, removedIds) {
|
||||
if (!peopleMap) await initialize();
|
||||
if (!peopleMap) return;
|
||||
|
||||
const removed = (removedIds || []).filter(Boolean);
|
||||
const changed = (changedSeries || []).filter((s) => s?.Id);
|
||||
const loadEpisodes = shouldLoadPeopleEpisodeData();
|
||||
|
||||
if (removed.length) {
|
||||
removed.forEach((id) => removeSeriesMembership(peopleMap, id));
|
||||
changed.forEach((s) => {
|
||||
removeSeriesMembership(peopleMap, s.Id);
|
||||
applySeriesPeople(peopleMap, s);
|
||||
});
|
||||
if (loadEpisodes) {
|
||||
// Without per-series episode indexes, any removal requires a full episode recount.
|
||||
await rebuildAllEpisodeRoleCounts(peopleMap);
|
||||
} else {
|
||||
clearEpisodeRoleCounts(peopleMap);
|
||||
}
|
||||
return moviesTopPeople;
|
||||
} catch (err) {
|
||||
ERR('Failed to get top people data:', err);
|
||||
return null;
|
||||
} else {
|
||||
if (loadEpisodes) {
|
||||
// Changed-only: decrement this series' episode credits, refresh membership, re-apply episodes.
|
||||
await subtractEpisodeCreditsForSeriesIds(peopleMap, changed.map((s) => s.Id));
|
||||
}
|
||||
changed.forEach((s) => {
|
||||
removeSeriesMembership(peopleMap, s.Id);
|
||||
applySeriesPeople(peopleMap, s);
|
||||
});
|
||||
if (loadEpisodes) {
|
||||
await fetchEpisodesForSeriesBatched(changed, (seriesId, episodes) => {
|
||||
episodes.forEach((ep) => applyEpisodePeople(peopleMap, seriesId, ep));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
prunePeopleMap(peopleMap);
|
||||
rebuildFilteredLists();
|
||||
await persistPeople();
|
||||
}
|
||||
|
||||
async function invalidate() {
|
||||
LOG('Invalidating people cache (in-memory + IndexedDB)');
|
||||
peopleMap = null;
|
||||
filteredLists = null;
|
||||
isComplete = false;
|
||||
fetchPromise = null;
|
||||
initPromise = null;
|
||||
try {
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient?.getCurrentUserId?.();
|
||||
if (cache?.clear) {
|
||||
await cache.clear(CACHE_NAME, userId);
|
||||
}
|
||||
} catch (e) {
|
||||
WARN('Failed to clear people IDB cache:', e);
|
||||
}
|
||||
// Rebuild on next demand; kick off in background if caches are ready
|
||||
try {
|
||||
initialize();
|
||||
} catch (e) {
|
||||
WARN('People cache rebuild after invalidate failed to start:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function getTopActors() {
|
||||
if (!moviesTopPeople) {
|
||||
async function getTopPeople() {
|
||||
if (!filteredLists) await initialize();
|
||||
return filteredLists;
|
||||
}
|
||||
|
||||
function parseRoleListArgs(itemTypeOrOptions, minCount) {
|
||||
if (itemTypeOrOptions && typeof itemTypeOrOptions === 'object') {
|
||||
return {
|
||||
itemType: itemTypeOrOptions.ItemType || itemTypeOrOptions.itemType || null,
|
||||
minCount: itemTypeOrOptions.MinCount ?? itemTypeOrOptions.minCount
|
||||
};
|
||||
}
|
||||
return { itemType: itemTypeOrOptions, minCount };
|
||||
}
|
||||
|
||||
async function getTopActors(itemTypeOrOptions = null, minCount) {
|
||||
if (!peopleMap) await initialize();
|
||||
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
|
||||
return filterRoleList('Actor', itemType, min);
|
||||
}
|
||||
|
||||
async function getTopDirectors(itemTypeOrOptions = null, minCount) {
|
||||
if (!peopleMap) await initialize();
|
||||
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
|
||||
return filterRoleList('Director', itemType, min);
|
||||
}
|
||||
|
||||
async function getTopWriters(itemTypeOrOptions = null, minCount) {
|
||||
if (!peopleMap) await initialize();
|
||||
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
|
||||
return filterRoleList('Writer', itemType, min);
|
||||
}
|
||||
|
||||
async function resolveDiscoveryPeople(queryOptions = {}) {
|
||||
if (!peopleMap) await initialize();
|
||||
const personType = queryOptions.PersonType || queryOptions.personType || 'Actor';
|
||||
const itemType = queryOptions.ItemType || queryOptions.itemType || null;
|
||||
const limit = queryOptions.Limit || 100;
|
||||
let list = [];
|
||||
if (personType === 'Director') list = filterRoleList('Director', itemType);
|
||||
else if (personType === 'Writer') list = filterRoleList('Writer', itemType);
|
||||
else list = filterRoleList('Actor', itemType);
|
||||
return list.slice(0, limit).map((p) => ({ Id: p.Id, Name: p.Name, Type: 'Person' }));
|
||||
}
|
||||
|
||||
async function waitForLoginReady(maxWaitMs = 60000) {
|
||||
const start = Date.now();
|
||||
while (!window.userHelper?.waitForLogin) {
|
||||
if (Date.now() - start >= maxWaitMs) {
|
||||
ERR('Timed out waiting for userHelper');
|
||||
return false;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return window.userHelper.waitForLogin(maxWaitMs);
|
||||
}
|
||||
|
||||
let bootstrapStarted = false;
|
||||
let bootstrapScheduled = false;
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapStarted) return;
|
||||
bootstrapStarted = true;
|
||||
try {
|
||||
await waitForLoginReady();
|
||||
// Wait for library caches to finish first crawl
|
||||
if (window.MoviesCache?.getMovies) await window.MoviesCache.getMovies();
|
||||
if (window.SeriesCache?.getSeries) await window.SeriesCache.getSeries();
|
||||
await initialize();
|
||||
} catch (e) {
|
||||
ERR('Bootstrap failed:', e);
|
||||
}
|
||||
return moviesTopPeople?.actors ?? [];
|
||||
}
|
||||
|
||||
async function getTopDirectors() {
|
||||
if (!moviesTopPeople) {
|
||||
await initialize();
|
||||
}
|
||||
return moviesTopPeople?.directors ?? [];
|
||||
function scheduleBootstrap() {
|
||||
if (bootstrapScheduled) return;
|
||||
bootstrapScheduled = true;
|
||||
|
||||
const start = () => { bootstrap(); };
|
||||
|
||||
const onPainted = () => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
};
|
||||
document.addEventListener('kefinTweaksHomePainted', onPainted);
|
||||
|
||||
waitForLoginReady().then((ok) => {
|
||||
if (!ok) {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
async function getTopWriters() {
|
||||
if (!moviesTopPeople) {
|
||||
await initialize();
|
||||
}
|
||||
return moviesTopPeople?.writers ?? [];
|
||||
}
|
||||
|
||||
function isCacheComplete() {
|
||||
return isComplete;
|
||||
}
|
||||
|
||||
// Expose API
|
||||
window.PeopleCache = {
|
||||
init: initialize,
|
||||
getTopPeople: getTopPeople,
|
||||
getTopActors: getTopActors,
|
||||
getTopDirectors: getTopDirectors,
|
||||
getTopWriters: getTopWriters,
|
||||
isComplete: isCacheComplete
|
||||
fetchAndCachePeople,
|
||||
getTopPeople,
|
||||
getTopActors,
|
||||
getTopDirectors,
|
||||
getTopWriters,
|
||||
resolveDiscoveryPeople,
|
||||
applyMovieDelta,
|
||||
applySeriesDelta,
|
||||
invalidate,
|
||||
isComplete: () => isComplete === true,
|
||||
bootstrap,
|
||||
scheduleBootstrap
|
||||
};
|
||||
|
||||
scheduleBootstrap();
|
||||
LOG('Module loaded');
|
||||
|
||||
})();
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
// Only add button to cards with data-positionticks (in progress items)
|
||||
const positionTicks = card.getAttribute('data-positionticks');
|
||||
if (!positionTicks) {
|
||||
if (!positionTicks || positionTicks === '0') {
|
||||
return; // Not a continue watching item, skip
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
// KefinTweaks Series Library Cache
|
||||
// Chunked per-user archive of series (no UserData / MediaStreams / Chapters / Trickplay).
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks Series Cache]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks Series Cache]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks Series Cache]', ...args);
|
||||
|
||||
const CACHE_NAME = 'series_library';
|
||||
const PARTIAL_CACHE_NAME = 'series_library_partial';
|
||||
const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedSeriesCache_';
|
||||
const SERIES_FIELDS = [
|
||||
'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview',
|
||||
'PrimaryImageAspectRatio', 'ChildCount', 'RecursiveItemCount'
|
||||
].join(',');
|
||||
|
||||
let series = null;
|
||||
let isComplete = false;
|
||||
let fetchPromise = null;
|
||||
|
||||
function utils() {
|
||||
return window.LibraryCacheUtils || {};
|
||||
}
|
||||
|
||||
function getTtl() {
|
||||
return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function getChunkSize() {
|
||||
return utils().getLibraryCacheSettings?.().seriesChunkSize || 1000;
|
||||
}
|
||||
|
||||
function lastFetchStorageKey() {
|
||||
const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous';
|
||||
return `${LAST_FETCH_KEY_PREFIX}${userId}`;
|
||||
}
|
||||
|
||||
function getLastDateFetched() {
|
||||
try {
|
||||
return localStorage.getItem(lastFetchStorageKey()) || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setLastDateFetched(iso) {
|
||||
try {
|
||||
localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString());
|
||||
} catch (e) {
|
||||
WARN('Failed to store lastDateFetchedSeriesCache:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getApiHelper() {
|
||||
return window.apiHelper || window.ApiHelper;
|
||||
}
|
||||
|
||||
function buildItemsUrl({ startIndex, limit, extraParams = {} }) {
|
||||
const server = window.ApiClient.serverAddress();
|
||||
const params = new URLSearchParams({
|
||||
IncludeItemTypes: 'Series',
|
||||
Recursive: 'true',
|
||||
Fields: SERIES_FIELDS,
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'true',
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
StartIndex: String(startIndex || 0),
|
||||
Limit: String(limit)
|
||||
});
|
||||
Object.entries(extraParams).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') params.set(k, String(v));
|
||||
});
|
||||
return `${server}/Items?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function queryItems(url) {
|
||||
const api = getApiHelper();
|
||||
if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable');
|
||||
return (await api.getQuery(url, { useCache: false })) || { Items: [], TotalRecordCount: 0 };
|
||||
}
|
||||
|
||||
async function stripChunk(items) {
|
||||
if (utils().stripItemsAsync) {
|
||||
return utils().stripItemsAsync(items, 'Series');
|
||||
}
|
||||
return (items || []).map((item) => utils().stripSeriesForCache?.(item) || item).filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchAndCacheSeries() {
|
||||
const cache = window.IndexedDBCache;
|
||||
if (!cache) {
|
||||
ERR('IndexedDBCache unavailable');
|
||||
return [];
|
||||
}
|
||||
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const complete = await cache.get(CACHE_NAME, userId);
|
||||
if (complete && Array.isArray(complete)) {
|
||||
series = complete;
|
||||
isComplete = true;
|
||||
LOG(`Using complete cached series (${series.length})`);
|
||||
return series;
|
||||
}
|
||||
|
||||
const chunkSize = getChunkSize();
|
||||
let startIndex = 0;
|
||||
let collected = [];
|
||||
|
||||
const partial = await cache.get(PARTIAL_CACHE_NAME, userId);
|
||||
if (partial && Array.isArray(partial.items) && partial.isComplete !== true) {
|
||||
collected = partial.items;
|
||||
startIndex = partial.startIndex || collected.length;
|
||||
LOG(`Resuming series crawl from index ${startIndex}`);
|
||||
}
|
||||
|
||||
try {
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const data = await queryItems(buildItemsUrl({ startIndex, limit: chunkSize }));
|
||||
const batch = data.Items || [];
|
||||
collected = collected.concat(await stripChunk(batch));
|
||||
startIndex += batch.length;
|
||||
await cache.set(PARTIAL_CACHE_NAME, {
|
||||
isComplete: false,
|
||||
startIndex,
|
||||
items: collected
|
||||
}, userId, getTtl());
|
||||
LOG(`Series crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`);
|
||||
if (batch.length < chunkSize) hasMore = false;
|
||||
else await yieldToMain();
|
||||
}
|
||||
|
||||
series = collected;
|
||||
isComplete = true;
|
||||
await cache.set(CACHE_NAME, series, userId, getTtl());
|
||||
await cache.clear(PARTIAL_CACHE_NAME, userId);
|
||||
setLastDateFetched(new Date().toISOString());
|
||||
LOG(`Series crawl complete: ${series.length} items`);
|
||||
return series;
|
||||
} catch (err) {
|
||||
ERR('Error fetching series:', err);
|
||||
return series || collected || [];
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSeriesCache() {
|
||||
if (!isComplete || !series) {
|
||||
LOG('Skip syncSeriesCache: complete cache not ready');
|
||||
return series || [];
|
||||
}
|
||||
const lastFetched = getLastDateFetched();
|
||||
if (!lastFetched) return series;
|
||||
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const chunkSize = getChunkSize();
|
||||
const server = window.ApiClient.serverAddress();
|
||||
|
||||
try {
|
||||
let startIndex = 0;
|
||||
let incoming = [];
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const data = await queryItems(buildItemsUrl({
|
||||
startIndex,
|
||||
limit: chunkSize,
|
||||
extraParams: {
|
||||
MinDateLastSaved: lastFetched,
|
||||
MinDateLastSavedForUser: lastFetched,
|
||||
SortBy: 'DateLastContentAdded',
|
||||
SortOrder: 'Descending'
|
||||
}
|
||||
}));
|
||||
const batch = data.Items || [];
|
||||
incoming = incoming.concat(await stripChunk(batch));
|
||||
startIndex += batch.length;
|
||||
if (batch.length < chunkSize) hasMore = false;
|
||||
}
|
||||
|
||||
const beforeIds = new Set((series || []).map((s) => s.Id).filter(Boolean));
|
||||
let removedIds = [];
|
||||
|
||||
if (incoming.length) {
|
||||
series = utils().upsertById?.(series, incoming) || series;
|
||||
LOG(`Synced ${incoming.length} series upserts`);
|
||||
}
|
||||
|
||||
const countParams = new URLSearchParams({
|
||||
IncludeItemTypes: 'Series',
|
||||
Recursive: 'true',
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'true',
|
||||
Limit: '1',
|
||||
StartIndex: '0'
|
||||
});
|
||||
const countData = await queryItems(`${server}/Items?${countParams.toString()}`);
|
||||
const serverCount = countData.TotalRecordCount;
|
||||
if (typeof serverCount === 'number' && serverCount !== series.length) {
|
||||
LOG(`Series count mismatch cache=${series.length} server=${serverCount}`);
|
||||
const serverIds = new Set();
|
||||
let idx = 0;
|
||||
let more = true;
|
||||
while (more) {
|
||||
const idParams = new URLSearchParams({
|
||||
IncludeItemTypes: 'Series',
|
||||
Recursive: 'true',
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'false',
|
||||
Limit: String(chunkSize),
|
||||
StartIndex: String(idx)
|
||||
});
|
||||
const idData = await queryItems(`${server}/Items?${idParams.toString()}`);
|
||||
const batch = idData.Items || [];
|
||||
batch.forEach((item) => {
|
||||
if (item?.Id) serverIds.add(item.Id);
|
||||
});
|
||||
idx += batch.length;
|
||||
if (batch.length < chunkSize) more = false;
|
||||
}
|
||||
removedIds = [...beforeIds].filter((id) => !serverIds.has(id));
|
||||
series = series.filter((s) => s?.Id && serverIds.has(s.Id));
|
||||
const cachedIds = new Set(series.map((s) => s.Id));
|
||||
const missing = [...serverIds].filter((id) => !cachedIds.has(id));
|
||||
for (let i = 0; i < missing.length; i += chunkSize) {
|
||||
const slice = missing.slice(i, i + chunkSize);
|
||||
const byIdParams = new URLSearchParams({
|
||||
Ids: slice.join(','),
|
||||
Fields: SERIES_FIELDS,
|
||||
EnableTotalRecordCount: 'false'
|
||||
});
|
||||
const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`);
|
||||
const stripped = await stripChunk(byIdData.Items || []);
|
||||
series = utils().upsertById?.(series, stripped) || series;
|
||||
incoming = incoming.concat(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
await cache.set(CACHE_NAME, series, userId, getTtl());
|
||||
setLastDateFetched(new Date().toISOString());
|
||||
|
||||
if ((incoming.length || removedIds.length) && window.PeopleCache?.applySeriesDelta) {
|
||||
try {
|
||||
await window.PeopleCache.applySeriesDelta(incoming, removedIds);
|
||||
} catch (e) {
|
||||
WARN('PeopleCache.applySeriesDelta failed:', e);
|
||||
}
|
||||
}
|
||||
return series;
|
||||
} catch (err) {
|
||||
ERR('syncSeriesCache failed:', err);
|
||||
return series || [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeries() {
|
||||
if (series && isComplete) return series;
|
||||
if (fetchPromise) return fetchPromise;
|
||||
fetchPromise = (async () => {
|
||||
await fetchAndCacheSeries();
|
||||
return series || [];
|
||||
})().finally(() => {
|
||||
fetchPromise = null;
|
||||
});
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
async function getCompletedSeries() {
|
||||
const list = await getSeries();
|
||||
return (list || []).filter(item => item?.UserData?.Played === true && Number(item.UserData.PlayedPercentage) === 100);
|
||||
}
|
||||
|
||||
function isCacheComplete() {
|
||||
return isComplete === true && Array.isArray(series);
|
||||
}
|
||||
|
||||
async function getSeriesByProviderIds(ids) {
|
||||
const list = await getSeries();
|
||||
const idSet = new Set();
|
||||
(ids || []).forEach((id) => {
|
||||
(utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k));
|
||||
});
|
||||
if (!idSet.size) return [];
|
||||
return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet));
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await getSeries();
|
||||
if (isCacheComplete() && getLastDateFetched()) {
|
||||
await syncSeriesCache();
|
||||
}
|
||||
return series || [];
|
||||
}
|
||||
|
||||
async function waitForLoginReady(maxWaitMs = 60000) {
|
||||
const start = Date.now();
|
||||
while (!window.userHelper?.waitForLogin) {
|
||||
if (Date.now() - start >= maxWaitMs) {
|
||||
ERR('Timed out waiting for userHelper');
|
||||
return false;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return window.userHelper.waitForLogin(maxWaitMs);
|
||||
}
|
||||
|
||||
function yieldToMain() {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(() => resolve(), { timeout: 500 });
|
||||
} else {
|
||||
requestAnimationFrame(() => setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let bootstrapStarted = false;
|
||||
let bootstrapScheduled = false;
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapStarted) return;
|
||||
bootstrapStarted = true;
|
||||
try {
|
||||
await waitForLoginReady();
|
||||
await initialize();
|
||||
} catch (e) {
|
||||
ERR('Bootstrap failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBootstrap() {
|
||||
if (bootstrapScheduled) return;
|
||||
bootstrapScheduled = true;
|
||||
|
||||
const start = () => { bootstrap(); };
|
||||
|
||||
const onPainted = () => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
};
|
||||
document.addEventListener('kefinTweaksHomePainted', onPainted);
|
||||
|
||||
waitForLoginReady().then((ok) => {
|
||||
if (!ok) {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
window.SeriesCache = {
|
||||
init: initialize,
|
||||
getSeries,
|
||||
getCompletedSeries,
|
||||
getSeriesByProviderIds,
|
||||
syncSeriesCache,
|
||||
fetchAndCacheSeries,
|
||||
isComplete: isCacheComplete,
|
||||
getLastDateFetched,
|
||||
bootstrap,
|
||||
scheduleBootstrap,
|
||||
refresh: async () => {
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient?.getCurrentUserId?.();
|
||||
if (cache) {
|
||||
await cache.clear(CACHE_NAME, userId);
|
||||
await cache.clear(PARTIAL_CACHE_NAME, userId);
|
||||
}
|
||||
series = null;
|
||||
isComplete = false;
|
||||
return fetchAndCacheSeries();
|
||||
}
|
||||
};
|
||||
|
||||
scheduleBootstrap();
|
||||
LOG('Module loaded');
|
||||
})();
|
||||
+4
-49
@@ -20,60 +20,15 @@
|
||||
const THEME_STORAGE_KEY = 'kefinTweaks_selectedTheme';
|
||||
const COLOR_SCHEMES_STORAGE_KEY = 'kefinTweaks_selectedColorScheme';
|
||||
|
||||
// Cached server version for synchronous access
|
||||
// Cached server version for synchronous access — prefer window.KefinTweaks
|
||||
let cachedServerVersion = null;
|
||||
|
||||
function getMajorServerVersion(version) {
|
||||
const versionParts = version.split('.');
|
||||
|
||||
if (versionParts[0] !== '10') {
|
||||
const majorVersion = parseInt(versionParts[0], 10);
|
||||
return majorVersion;
|
||||
}
|
||||
|
||||
if (versionParts.length >= 2) {
|
||||
const majorVersion = parseInt(versionParts[1], 10);
|
||||
if (!isNaN(majorVersion)) {
|
||||
return majorVersion;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current major server version from ApiClient
|
||||
* Polls every 500ms for up to 5 seconds in the background if not immediately available
|
||||
* @returns {number|null} The major version number (e.g., 10 for "10.10.X", 11 for "10.11.X"), or null if unavailable
|
||||
*/
|
||||
async function getCurrentMajorServerVersion() {
|
||||
try {
|
||||
// If we have a cached value, return it
|
||||
if (cachedServerVersion !== null) {
|
||||
try {
|
||||
if (window.KefinTweaks?.getJellyfinMajorVersion) {
|
||||
cachedServerVersion = await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
return cachedServerVersion;
|
||||
}
|
||||
|
||||
if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {
|
||||
cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion);
|
||||
return cachedServerVersion;
|
||||
}
|
||||
|
||||
// Check the server version instead of app version
|
||||
if (!window.ApiClient._serverVersion) {
|
||||
// Wait 10s to see if it becomes ready, check every 500ms
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < 10000) {
|
||||
if (window.ApiClient._serverVersion) {
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion);
|
||||
return cachedServerVersion;
|
||||
} catch (error) {
|
||||
WARN('Error getting server version:', error);
|
||||
|
||||
+18
-3
@@ -57,12 +57,18 @@
|
||||
* API Helper functions for Jellyfin operations
|
||||
*/
|
||||
const userHelper = {
|
||||
getUserDisplayPreferences: async function() {
|
||||
/**
|
||||
* Get current-user display preferences.
|
||||
* @param {{ cacheOnly?: boolean }} [options]
|
||||
* @returns {Promise<{ promise: Promise<object>, cached: object|null }>}
|
||||
*/
|
||||
getUserDisplayPreferences: async function(options = {}) {
|
||||
ensureApiClient();
|
||||
ensureLoggedIn();
|
||||
|
||||
const cacheOnly = options.cacheOnly === true;
|
||||
let cachedDisplayPreferences = null;
|
||||
|
||||
|
||||
if (__userDisplayPreferences) {
|
||||
cachedDisplayPreferences = __userDisplayPreferences;
|
||||
} else if (window.LocalStorageCache) {
|
||||
@@ -70,9 +76,18 @@
|
||||
const displayPreferencesData = cache.get('userDisplayPreferences');
|
||||
if (displayPreferencesData) {
|
||||
cachedDisplayPreferences = displayPreferencesData;
|
||||
// Hydrate memory so later cacheOnly calls skip localStorage too
|
||||
__userDisplayPreferences = displayPreferencesData;
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheOnly && cachedDisplayPreferences) {
|
||||
return {
|
||||
promise: Promise.resolve(cachedDisplayPreferences),
|
||||
cached: cachedDisplayPreferences
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap this in a promise to return with the cached data
|
||||
const userDisplayPreferencesPromise = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
@@ -286,7 +301,7 @@
|
||||
waitForLogin: waitForLogin
|
||||
};
|
||||
|
||||
// TODO -- Update this whenever the user display preferences are updated
|
||||
// Updated by getUserDisplayPreferences / updateDisplayPreferencesForUser({ updateCache: true })
|
||||
let __userDisplayPreferences = null;
|
||||
|
||||
// Expose userHelper to global window object
|
||||
|
||||
+190
-21
@@ -1449,6 +1449,10 @@
|
||||
anyOk = injectIntoUserMenu(entry) || anyOk;
|
||||
}
|
||||
|
||||
if (entry.topNavigation === 'main') {
|
||||
syncCustomTopNavActiveState();
|
||||
}
|
||||
|
||||
return anyOk;
|
||||
}
|
||||
|
||||
@@ -1460,6 +1464,91 @@
|
||||
ERR('Error re-applying custom menu link:', entry?.name, err);
|
||||
}
|
||||
});
|
||||
syncCustomTopNavActiveState();
|
||||
}
|
||||
|
||||
function parseNavHashParts(hash) {
|
||||
const raw = String(hash || '#/').replace(/^#/, '') || '/';
|
||||
const qIndex = raw.indexOf('?');
|
||||
const path = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || '/';
|
||||
const query = qIndex >= 0 ? raw.slice(qIndex + 1) : '';
|
||||
const params = new URLSearchParams(query);
|
||||
return { path: path.startsWith('/') ? path : `/${path}`, params };
|
||||
}
|
||||
|
||||
function customTopNavHrefMatchScore(href, currentHash) {
|
||||
if (!href || /^https?:/i.test(href)) return -1;
|
||||
const targetHash = href.startsWith('#') ? href : `#${href}`;
|
||||
const current = parseNavHashParts(currentHash);
|
||||
const target = parseNavHashParts(targetHash);
|
||||
|
||||
if (
|
||||
targetHash === currentHash
|
||||
|| decodeURIComponent(targetHash) === decodeURIComponent(currentHash)
|
||||
) {
|
||||
return 1000 + targetHash.length;
|
||||
}
|
||||
|
||||
if (target.path !== current.path) return -1;
|
||||
|
||||
for (const [key, value] of target.params.entries()) {
|
||||
if (current.params.get(key) !== value) return -1;
|
||||
}
|
||||
|
||||
let score = 100 + targetHash.length;
|
||||
score += [...target.params.keys()].length * 10;
|
||||
|
||||
if (target.path === '/home' && !target.params.has('tab') && current.params.has('tab')) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function resolveCustomTopNavHref(el) {
|
||||
if (!el) return '';
|
||||
const attrHref = el.getAttribute('href');
|
||||
if (attrHref) return attrHref;
|
||||
const key = el.getAttribute('data-kefin-custom-menu-top-link');
|
||||
if (!key) return '';
|
||||
const entry = customMenuLinkRegistry.find((e) => entryKey(e) === key);
|
||||
return entry ? getCustomMenuLinkHref(entry) : '';
|
||||
}
|
||||
|
||||
function syncCustomTopNavActiveState() {
|
||||
const links = Array.from(document.querySelectorAll('[data-kefin-custom-menu-top-link]'));
|
||||
if (!links.length) return;
|
||||
|
||||
const currentHash = window.location.hash || '#/';
|
||||
const normalizedHash = currentHash.startsWith('#') ? currentHash : `#${currentHash}`;
|
||||
let bestScore = -1;
|
||||
const winners = [];
|
||||
|
||||
links.forEach((el) => {
|
||||
el.classList.remove('active');
|
||||
const score = customTopNavHrefMatchScore(resolveCustomTopNavHref(el), normalizedHash);
|
||||
if (score < 0) return;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
winners.length = 0;
|
||||
winners.push(el);
|
||||
} else if (score === bestScore) {
|
||||
winners.push(el);
|
||||
}
|
||||
});
|
||||
|
||||
winners.forEach((el) => el.classList.add('active'));
|
||||
}
|
||||
|
||||
let customTopNavActiveSyncBound = false;
|
||||
function ensureCustomTopNavActiveSync() {
|
||||
if (customTopNavActiveSyncBound) return;
|
||||
customTopNavActiveSyncBound = true;
|
||||
window.addEventListener('hashchange', syncCustomTopNavActiveState);
|
||||
onViewPage(() => {
|
||||
syncCustomTopNavActiveState();
|
||||
}, { pages: [] });
|
||||
syncCustomTopNavActiveState();
|
||||
}
|
||||
|
||||
function ensureCustomMenuLinkObserver() {
|
||||
@@ -1508,6 +1597,39 @@
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-kefin-custom-menu-top-link].active {
|
||||
display: inline-flex;
|
||||
-moz-box-align: center;
|
||||
align-items: center;
|
||||
-moz-box-pack: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
outline: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
appearance: none;
|
||||
text-decoration: none;
|
||||
font-family: "Noto Sans", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.75;
|
||||
text-transform: none;
|
||||
min-width: 64px;
|
||||
border: 0;
|
||||
border-radius: var(--jf-shape-borderRadius);
|
||||
padding: 6px 8px;
|
||||
color: var(--variant-textColor);
|
||||
background-color: var(--variant-textBg);
|
||||
--variant-textColor: var(--jf-palette-primary-main);
|
||||
--variant-outlinedColor: var(--jf-palette-primary-main);
|
||||
--variant-outlinedBorder: rgba(var(--jf-palette-primary-mainChannel) / 0.5);
|
||||
--variant-containedColor: var(--jf-palette-primary-contrastText);
|
||||
--variant-containedBg: var(--jf-palette-primary-main);
|
||||
transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1), border-color 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.userPreferencesPage .readOnlyContent > * {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1716,6 +1838,7 @@ button[data-kefin-custom-menu-more] .MuiSvgIcon-root {
|
||||
*/
|
||||
async function addCustomMenuLink(name, icon, url, openInNewTab = false, containerSelectorOrOptions = DEFAULT_CUSTOM_MENU_SELECTOR) {
|
||||
ensureCustomMenuLinkStyles();
|
||||
ensureCustomTopNavActiveSync();
|
||||
const options = parseCustomMenuLinkOptions(containerSelectorOrOptions);
|
||||
const trimmedUrl = url != null ? String(url).trim() : '';
|
||||
const action = options.action || '';
|
||||
@@ -2086,24 +2209,65 @@ button[data-kefin-custom-menu-more] .MuiSvgIcon-root {
|
||||
// Do not edit manually unless you know what you're doing
|
||||
|
||||
window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`;
|
||||
|
||||
// Find or create KefinTweaks-Config script
|
||||
const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex(
|
||||
script => script.Name === 'KefinTweaks-Config'
|
||||
);
|
||||
|
||||
if (existingScriptIndex !== -1) {
|
||||
// Update existing script
|
||||
injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent;
|
||||
} else {
|
||||
// Add new script
|
||||
injectorConfig.CustomJavaScripts.push({
|
||||
Name: 'KefinTweaks-Config',
|
||||
Script: scriptContent,
|
||||
Enabled: true,
|
||||
RequiresAuthentication: false
|
||||
});
|
||||
|
||||
// Build / refresh KefinTweaks-injector preload entry (same POST)
|
||||
let injectorScriptContent = null;
|
||||
try {
|
||||
const root = (configToSave.kefinTweaksRoot || '').endsWith('/')
|
||||
? configToSave.kefinTweaksRoot
|
||||
: (configToSave.kefinTweaksRoot || '') + '/';
|
||||
if (root && root !== '/') {
|
||||
if (!window.KefinTweaksLoader) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const url = `${root}scripts/kefinTweaks-loader.js`;
|
||||
const existing = document.querySelector(`script[src="${url}"]`);
|
||||
if (existing && window.KefinTweaksLoader) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = false;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load kefinTweaks-loader.js'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
const Loader = window.KefinTweaksLoader;
|
||||
if (Loader) {
|
||||
Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS);
|
||||
const major = await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
const plan = Loader.buildLoadPlan(configToSave, major, {
|
||||
root,
|
||||
configOnly: configToSave.enabled === false
|
||||
});
|
||||
injectorScriptContent = Loader.buildInjectorScript(plan);
|
||||
LOG(`Built KefinTweaks-injector (${plan.assets.length} assets, major=${major})`);
|
||||
}
|
||||
}
|
||||
} catch (loaderErr) {
|
||||
WARN('Could not build KefinTweaks-injector entry:', loaderErr);
|
||||
}
|
||||
|
||||
injectorConfig.CustomJavaScripts = (window.KefinTweaksLoader
|
||||
? window.KefinTweaksLoader.upsertInjectorEntries(injectorConfig.CustomJavaScripts, {
|
||||
configScriptContent: scriptContent,
|
||||
injectorScriptContent: injectorScriptContent
|
||||
})
|
||||
: (() => {
|
||||
// Fallback: Config-only upsert if loader unavailable
|
||||
const list = injectorConfig.CustomJavaScripts || [];
|
||||
const idx = list.findIndex((s) => s.Name === 'KefinTweaks-Config');
|
||||
const entry = {
|
||||
Name: 'KefinTweaks-Config',
|
||||
Script: scriptContent,
|
||||
Enabled: true,
|
||||
RequiresAuthentication: false
|
||||
};
|
||||
if (idx !== -1) list[idx] = { ...list[idx], ...entry };
|
||||
else list.push(entry);
|
||||
return list;
|
||||
})());
|
||||
|
||||
// Save the updated configuration
|
||||
const saveResponse = await fetch(configUrl, {
|
||||
@@ -2119,7 +2283,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`;
|
||||
throw new Error(`Failed to save plugin config: ${saveResponse.statusText}`);
|
||||
}
|
||||
|
||||
LOG('Successfully saved config to JS Injector plugin');
|
||||
LOG('Successfully saved config (+ injector preload) to JS Injector plugin');
|
||||
return true;
|
||||
} catch (err) {
|
||||
ERR('Error saving config to JS Injector:', err);
|
||||
@@ -2453,21 +2617,26 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`;
|
||||
}
|
||||
}
|
||||
|
||||
function syncCustomPagesAndTopNavActive() {
|
||||
syncAllCustomPages();
|
||||
syncCustomTopNavActiveState();
|
||||
}
|
||||
|
||||
function ensureCustomPageViewHandler() {
|
||||
if (customPageViewHandlerUnregister) return;
|
||||
// onViewShow misses custom→custom hash changes (no real Jellyfin view);
|
||||
// hashchange covers those; both are fine when they double-fire.
|
||||
const unregView = onViewPage(() => {
|
||||
syncAllCustomPages();
|
||||
syncCustomPagesAndTopNavActive();
|
||||
}, { pages: [] });
|
||||
if (!customPageHashChangeBound) {
|
||||
window.addEventListener('hashchange', syncAllCustomPages);
|
||||
window.addEventListener('hashchange', syncCustomPagesAndTopNavActive);
|
||||
customPageHashChangeBound = true;
|
||||
}
|
||||
customPageViewHandlerUnregister = () => {
|
||||
if (typeof unregView === 'function') unregView();
|
||||
if (customPageHashChangeBound) {
|
||||
window.removeEventListener('hashchange', syncAllCustomPages);
|
||||
window.removeEventListener('hashchange', syncCustomPagesAndTopNavActive);
|
||||
customPageHashChangeBound = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1882,10 +1882,10 @@ div[data-tab="watchlist"] .loading-message {
|
||||
}
|
||||
|
||||
.card:has(button[data-played="true"]) .watchlist-button,
|
||||
.card:not(:has(button[data-played])) .watchlist-button,
|
||||
.card:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .watchlist-button,
|
||||
.mainDetailButtons:has(button[data-played="true"]) .watchlist-icon,
|
||||
.mainDetailButtons:not(:has(button[data-played])) .watchlist-icon,
|
||||
.mainDetailButtons:not(:has(button[data-played])) .btnRecommend {
|
||||
.mainDetailButtons:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .watchlist-icon,
|
||||
.mainDetailButtons:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .btnRecommend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
+206
-128
@@ -2174,13 +2174,13 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
if (activeContent) activeContent.style.display = 'block';
|
||||
|
||||
if (pageTab === 'progress') {
|
||||
renderProgressContent();
|
||||
initProgressTab().then(() => renderProgressContent()).catch((err) => ERR('Progress tab init failed:', err));
|
||||
} else if (pageTab === 'watchlist') {
|
||||
renderWatchlistContent();
|
||||
} else if (pageTab === 'history') {
|
||||
renderHistoryContent();
|
||||
initHistoryTab().then(() => renderHistoryContent()).catch((err) => ERR('History tab init failed:', err));
|
||||
} else if (pageTab === 'statistics') {
|
||||
renderStatisticsContent();
|
||||
initStatisticsTab().then(() => renderStatisticsContent()).catch((err) => ERR('Statistics tab init failed:', err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2333,21 +2333,45 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all tabs with proper dependencies
|
||||
// Initialize tabs lazily — only the active/default tab; progress/history on demand
|
||||
async function initializeAllTabs() {
|
||||
LOG('Starting tab initialization');
|
||||
|
||||
// Independent tabs can run in parallel
|
||||
const watchlistPromise = initWatchlistTab();
|
||||
|
||||
// Dependent tabs: progress and history first, then statistics
|
||||
const progressPromise = initProgressTab();
|
||||
const historyPromise = initHistoryTab();
|
||||
|
||||
// Wait for progress and history, then init statistics
|
||||
await Promise.all([watchlistPromise, progressPromise, historyPromise]);
|
||||
await initStatisticsTab();
|
||||
LOG('All tabs initialized');
|
||||
const params = getUrlParams();
|
||||
const pageTab = params.pageTab || 'watchlist';
|
||||
|
||||
await initWatchlistTab();
|
||||
|
||||
if (pageTab === 'progress') {
|
||||
await initProgressTab();
|
||||
} else if (pageTab === 'history') {
|
||||
await initHistoryTab();
|
||||
} else if (pageTab === 'statistics') {
|
||||
await initStatisticsTab();
|
||||
}
|
||||
|
||||
LOG('Tab initialization complete (active:', pageTab, ')');
|
||||
}
|
||||
|
||||
/** Run async work over items with a fixed concurrency limit. */
|
||||
async function mapWithConcurrency(items, concurrency, mapper) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const limit = Math.max(1, Number(concurrency) || 1);
|
||||
const results = new Array(list.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < list.length) {
|
||||
const i = nextIndex++;
|
||||
results[i] = await mapper(list[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = [];
|
||||
for (let w = 0; w < Math.min(limit, list.length); w++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function sortInProgressSeries(series) {
|
||||
@@ -2462,14 +2486,11 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
LOG(`Found ${inProgressSeries.length} series with PlayedPercentage > 0`);
|
||||
|
||||
|
||||
LOG(`Fetching progress data for all ${inProgressSeries.length} series`);
|
||||
LOG(`Fetching progress data for ${inProgressSeries.length} series (concurrency 4)`);
|
||||
|
||||
// Fetch progress data for all series in parallel
|
||||
const progressData = await Promise.all(
|
||||
inProgressSeries.map(async (seriesItem) => {
|
||||
return await fetchSeriesProgressWithMissingEpisodes(seriesItem, userId, serverUrl, token);
|
||||
})
|
||||
);
|
||||
const progressData = await mapWithConcurrency(inProgressSeries, 4, async (seriesItem) => {
|
||||
return await fetchSeriesProgressWithMissingEpisodes(seriesItem, userId, serverUrl, token);
|
||||
});
|
||||
|
||||
// Filter out series with no valid progress data
|
||||
const validProgressData = progressData.filter(data => data !== null);
|
||||
@@ -3018,6 +3039,84 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
{ type: 'Video', key: 'homevideos', label: 'Home Videos' }
|
||||
];
|
||||
|
||||
const WATCHLIST_SUPPORTED_TYPES = WATCHLIST_SECTION_TYPES.map(({ type }) => type);
|
||||
const WATCHLIST_SUPPORTED_TYPES_CSV = WATCHLIST_SUPPORTED_TYPES.join(',');
|
||||
|
||||
/** Session Id set for overlay button state only (not Watchlist tab rendering). */
|
||||
const watchlistLikedIds = new Set();
|
||||
let watchlistLikedIdsLoaded = false;
|
||||
let watchlistLikedIdsLoadPromise = null;
|
||||
|
||||
function isWatchlistSupportedType(itemType) {
|
||||
return WATCHLIST_SUPPORTED_TYPES.includes(itemType);
|
||||
}
|
||||
|
||||
function isItemInLikedIdCache(itemId) {
|
||||
return !!itemId && watchlistLikedIds.has(itemId);
|
||||
}
|
||||
|
||||
function setLikedIdInCache(itemId, isLiked) {
|
||||
if (!itemId) return;
|
||||
if (isLiked) watchlistLikedIds.add(itemId);
|
||||
else watchlistLikedIds.delete(itemId);
|
||||
}
|
||||
|
||||
function mergeLikedIdsFromItems(items) {
|
||||
const list = Array.isArray(items) ? items : (items?.Items || []);
|
||||
list.forEach((item) => {
|
||||
if (item?.Id) watchlistLikedIds.add(item.Id);
|
||||
});
|
||||
}
|
||||
|
||||
function applyWatchlistButtonActiveState(button, isActive) {
|
||||
if (!button) return;
|
||||
button.dataset.active = isActive ? 'true' : 'false';
|
||||
button.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
|
||||
}
|
||||
|
||||
function syncOverlayWatchlistButtons() {
|
||||
document.querySelectorAll('.watchlist-button[data-id]').forEach((button) => {
|
||||
const itemId = button.getAttribute('data-id');
|
||||
applyWatchlistButtonActiveState(button, isItemInLikedIdCache(itemId));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWatchlistLikedIdsBaseline() {
|
||||
if (watchlistLikedIdsLoaded) return watchlistLikedIds;
|
||||
if (watchlistLikedIdsLoadPromise) return watchlistLikedIdsLoadPromise;
|
||||
|
||||
watchlistLikedIdsLoadPromise = (async () => {
|
||||
try {
|
||||
if (window.userHelper?.waitForLogin) {
|
||||
await window.userHelper.waitForLogin();
|
||||
}
|
||||
const apiHelper = window.apiHelper;
|
||||
if (!apiHelper?.getWatchlistItems) {
|
||||
WARN('apiHelper.getWatchlistItems unavailable; liked Id cache empty');
|
||||
return watchlistLikedIds;
|
||||
}
|
||||
const result = await apiHelper.getWatchlistItems({
|
||||
IncludeItemTypes: WATCHLIST_SUPPORTED_TYPES_CSV,
|
||||
Fields: 'Id',
|
||||
Limit: 10000
|
||||
}, true);
|
||||
watchlistLikedIds.clear();
|
||||
mergeLikedIdsFromItems(result);
|
||||
watchlistLikedIdsLoaded = true;
|
||||
LOG(`Loaded watchlist liked Id cache: ${watchlistLikedIds.size} items`);
|
||||
syncOverlayWatchlistButtons();
|
||||
processExistingOverlayContainers();
|
||||
} catch (err) {
|
||||
ERR('Failed to load watchlist liked Id baseline:', err);
|
||||
} finally {
|
||||
watchlistLikedIdsLoadPromise = null;
|
||||
}
|
||||
return watchlistLikedIds;
|
||||
})();
|
||||
|
||||
return watchlistLikedIdsLoadPromise;
|
||||
}
|
||||
|
||||
let watchlistRenderGeneration = 0;
|
||||
|
||||
function resetWatchlistItemsByType() {
|
||||
@@ -3170,7 +3269,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
const items = await section.result.ensureData();
|
||||
const list = Array.isArray(items) ? items : (items?.Items || []);
|
||||
watchlistItemsByType[section.itemType] = list;
|
||||
mergeLikedIdsFromItems(list);
|
||||
}));
|
||||
syncOverlayWatchlistButtons();
|
||||
|
||||
if (generation !== watchlistRenderGeneration) return;
|
||||
|
||||
@@ -4676,6 +4777,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Clear query cache + in-memory index
|
||||
await invalidateAllWatchlistQueries();
|
||||
resetWatchlistItemsByType();
|
||||
watchlistLikedIds.clear();
|
||||
watchlistLikedIdsLoaded = true;
|
||||
syncOverlayWatchlistButtons();
|
||||
|
||||
// Refresh watchlist
|
||||
await initWatchlistTab(true);
|
||||
@@ -4687,6 +4791,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
async function updateWatchlistCacheOnToggle(itemId, itemType, isAdded) {
|
||||
try {
|
||||
LOG(`Watchlist membership changed for ${itemType} ${itemId} (added=${isAdded}); refreshing progressive sections`);
|
||||
setLikedIdInCache(itemId, isAdded === true);
|
||||
syncOverlayWatchlistButtons();
|
||||
|
||||
if (itemType) {
|
||||
await window.apiHelper?.invalidateWatchlistQueries?.({ IncludeItemTypes: itemType });
|
||||
} else {
|
||||
@@ -5206,6 +5313,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
LOG(`Found ${playedItems.length} played items in ${type} section`);
|
||||
for (const item of playedItems) {
|
||||
await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), item.Id, 'false');
|
||||
setLikedIdInCache(item.Id, false);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
@@ -5213,6 +5321,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
|
||||
if (removedCount > 0) {
|
||||
LOG(`Sync complete: removed ${removedCount} played items from watchlist; refreshing`);
|
||||
syncOverlayWatchlistButtons();
|
||||
await invalidateAllWatchlistQueries();
|
||||
await renderWatchlistProgressive(true);
|
||||
} else {
|
||||
@@ -6598,15 +6707,15 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
|
||||
updateUrlParams(activeTab, currentPage, currentSort, currentSortDirection, currentMovieSort, currentMovieSortDirection);
|
||||
|
||||
// Render content based on active tab
|
||||
// Render content based on active tab (lazy-init progress/history/statistics)
|
||||
if (activeTab === 'progress') {
|
||||
renderProgressContent();
|
||||
initProgressTab().then(() => renderProgressContent()).catch((err) => ERR('Progress tab init failed:', err));
|
||||
} else if (activeTab === 'watchlist') {
|
||||
renderWatchlistContent();
|
||||
} else if (activeTab === 'history') {
|
||||
renderHistoryContent();
|
||||
initHistoryTab().then(() => renderHistoryContent()).catch((err) => ERR('History tab init failed:', err));
|
||||
} else if (activeTab === 'statistics') {
|
||||
renderStatisticsContent();
|
||||
initStatisticsTab().then(() => renderStatisticsContent()).catch((err) => ERR('Statistics tab init failed:', err));
|
||||
}
|
||||
|
||||
LOG(`Switched to ${activeTab} tab`);
|
||||
@@ -7277,15 +7386,18 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
let watchlistUrl = '#/watchlist';
|
||||
|
||||
// Major version of Jellyfin
|
||||
const jellyfinVersion = window.KefinTweaks.getJellyfinMajorVersion() ?? null;
|
||||
const jellyfinVersion = await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
const isModernUI = document.querySelector('.MuiBox-root') !== null;
|
||||
|
||||
// If watchlist tab index is null or undefined, add to top navigation
|
||||
if (watchlistTabIndex === null || watchlistTabIndex === undefined || jellyfinVersion >= 12) {
|
||||
if (watchlistTabIndex === null || watchlistTabIndex === undefined || isModernUI) {
|
||||
options.topNavigation = 'main';
|
||||
} else {
|
||||
watchlistUrl = `#/home?tab=${watchlistTabIndex}`;
|
||||
}
|
||||
|
||||
window.KefinTweaksUtils._watchlistUrl = watchlistUrl;
|
||||
|
||||
window.KefinTweaksUtils.addCustomMenuLink(
|
||||
'Watchlist',
|
||||
'bookmark',
|
||||
@@ -7335,129 +7447,84 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
|
||||
// Function to add watchlist button to a card overlay container
|
||||
function addWatchlistButton(overlayContainer) {
|
||||
// Check if watchlist button already exists
|
||||
if (overlayContainer && overlayContainer.querySelector('.watchlist-button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the card parent to get the item ID
|
||||
if (!overlayContainer) return;
|
||||
|
||||
const card = overlayContainer.closest('.card');
|
||||
if (!card) {
|
||||
WARN('Could not find card parent for overlay container');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const itemId = card.getAttribute('data-id');
|
||||
if (!itemId) {
|
||||
WARN('Could not find data-id on card element');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if card has data-type attribute - if not, don't add watchlist button
|
||||
|
||||
const itemType = card.getAttribute('data-type');
|
||||
if (!itemType) {
|
||||
LOG('Card has no data-type, skipping watchlist button');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the .cardOverlayButton-br container
|
||||
|
||||
if (!isWatchlistSupportedType(itemType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ApiClient._loggedIn) {
|
||||
LOG('User is not logged in, skipping watchlist button');
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
|
||||
if (!buttonContainer) {
|
||||
WARN('Could not find .cardOverlayButton-br container');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create watchlist button
|
||||
const watchlistButton = document.createElement('button');
|
||||
watchlistButton.type = 'button';
|
||||
watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat';
|
||||
watchlistButton.setAttribute('data-action', 'none');
|
||||
watchlistButton.setAttribute('data-id', itemId);
|
||||
watchlistButton.setAttribute('data-active', 'false');
|
||||
watchlistButton.title = 'Add to Watchlist';
|
||||
|
||||
// Create the bookmark icon
|
||||
const watchlistIcon = document.createElement('span');
|
||||
watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
|
||||
watchlistIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
watchlistButton.appendChild(watchlistIcon);
|
||||
|
||||
// Add click event listener
|
||||
watchlistButton.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Toggle watchlist status
|
||||
const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false';
|
||||
await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
|
||||
watchlistButton.dataset.active = newRating;
|
||||
|
||||
// Update icon and title based on state
|
||||
const isActive = watchlistButton.dataset.active === 'true';
|
||||
// Icon state is handled by CSS class, no need to change textContent
|
||||
watchlistButton.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
|
||||
|
||||
// Update watchlist cache immediately
|
||||
await updateWatchlistCacheOnToggle(itemId, itemType, isActive);
|
||||
});
|
||||
|
||||
// Check if item type is supported for watchlist
|
||||
if (itemType !== "Movie" && itemType !== "Series" && itemType !== "Season" && itemType !== "Episode" && itemType !== "BoxSet" && itemType !== "Playlist" && itemType !== "Video") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ApiClient._loggedIn) {
|
||||
LOG('User is not logged in, skipping watchlist button');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check item's current watchlist status from cache only (no server fetch)
|
||||
// Map itemType to section name
|
||||
let sectionName;
|
||||
switch (itemType) {
|
||||
case 'Movie':
|
||||
sectionName = 'movies';
|
||||
break;
|
||||
case 'Series':
|
||||
sectionName = 'series';
|
||||
break;
|
||||
case 'Season':
|
||||
sectionName = 'seasons';
|
||||
break;
|
||||
case 'Episode':
|
||||
sectionName = 'episodes';
|
||||
break;
|
||||
case 'BoxSet':
|
||||
sectionName = 'boxsets';
|
||||
break;
|
||||
case 'Playlist':
|
||||
sectionName = 'playlists';
|
||||
break;
|
||||
case 'Video':
|
||||
sectionName = 'homevideos';
|
||||
break;
|
||||
default:
|
||||
// Unknown type, skip check
|
||||
break;
|
||||
}
|
||||
|
||||
if (sectionName && itemType) {
|
||||
let isInWatchlist = (watchlistItemsByType[itemType] || []).some(item => item.Id === itemId);
|
||||
let watchlistButton = overlayContainer.querySelector('.watchlist-button');
|
||||
const createdNew = !watchlistButton;
|
||||
|
||||
// Set button state if item is in watchlist
|
||||
if (isInWatchlist) {
|
||||
watchlistButton.dataset.active = 'true';
|
||||
watchlistButton.title = 'Remove from Watchlist';
|
||||
if (createdNew) {
|
||||
watchlistButton = document.createElement('button');
|
||||
watchlistButton.type = 'button';
|
||||
watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat';
|
||||
watchlistButton.setAttribute('data-action', 'none');
|
||||
watchlistButton.setAttribute('data-id', itemId);
|
||||
|
||||
const watchlistIcon = document.createElement('span');
|
||||
watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
|
||||
watchlistIcon.setAttribute('aria-hidden', 'true');
|
||||
watchlistButton.appendChild(watchlistIcon);
|
||||
|
||||
const playStateButton = buttonContainer.querySelector('button[is="emby-playstatebutton"]')
|
||||
|| buttonContainer.querySelector('button:has([data-testid="CheckIcon"])');
|
||||
if (playStateButton) {
|
||||
buttonContainer.insertBefore(watchlistButton, playStateButton);
|
||||
} else {
|
||||
buttonContainer.appendChild(watchlistButton);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the watchlist button to the button container, right before the play state button if it exists
|
||||
const playStateButton = buttonContainer.querySelector('button[is="emby-playstatebutton"]');
|
||||
if (playStateButton) {
|
||||
buttonContainer.insertBefore(watchlistButton, playStateButton);
|
||||
} else {
|
||||
buttonContainer.appendChild(watchlistButton);
|
||||
watchlistButton.setAttribute('data-id', itemId);
|
||||
}
|
||||
|
||||
// Prefer liked-Id cache once loaded; until then keep cardBuilder UserData.Likes state
|
||||
if (watchlistLikedIdsLoaded || createdNew) {
|
||||
applyWatchlistButtonActiveState(watchlistButton, isItemInLikedIdCache(itemId));
|
||||
}
|
||||
|
||||
if (watchlistButton.dataset.watchlistBound !== 'true') {
|
||||
watchlistButton.dataset.watchlistBound = 'true';
|
||||
watchlistButton.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false';
|
||||
await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
|
||||
const isActive = newRating === 'true';
|
||||
applyWatchlistButtonActiveState(watchlistButton, isActive);
|
||||
await updateWatchlistCacheOnToggle(itemId, itemType, isActive);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7518,6 +7585,17 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Initialize the observer
|
||||
setupWatchlistButtonObserver();
|
||||
|
||||
// Baseline liked-Id cache for overlay button state (one query after login)
|
||||
loadWatchlistLikedIdsBaseline();
|
||||
|
||||
// Expose helpers so cardBuilder can keep overlay buttons / Set aligned on UserData.Likes
|
||||
window.KefinWatchlistLikedIds = {
|
||||
has: isItemInLikedIdCache,
|
||||
set: setLikedIdInCache,
|
||||
syncOverlayButtons: syncOverlayWatchlistButtons,
|
||||
isSupportedType: isWatchlistSupportedType
|
||||
};
|
||||
|
||||
/************ Item Detail Page Observer ************/
|
||||
|
||||
// Function to add watchlist button to item detail page
|
||||
|
||||
Reference in New Issue
Block a user