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