Files
KefinTweaks/scripts/kefinTweaks-loader.js

1307 lines
53 KiB
JavaScript

// 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'
]
},
chartjs: {
js: [
'thirdparty/chartjs/chart.js'
],
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', 'sectionHelper'],
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', 'skinConfigLegacyDefaults', 'skinConfig'],
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: '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', 'websocketHelper'],
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: 'statistics',
script: 'statistics.js',
css: null,
dependencies: [],
description: 'Watchlist statistics charts and activity analytics'
},
{
name: 'watchlist-configuration',
script: '../configuration/watchlist-configuration.js',
css: null,
dependencies: ['modal', 'ui'],
description: 'Configuration UI for Watchlist menu placements'
},
{
name: 'watchlist',
script: 'watchlist.js',
css: 'watchlist.css',
dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils', 'statistics', 'watchlist-configuration', 'websocketHelper'],
thirdParty: ['chartjs'],
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', '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', 'watchlist-configuration', 'userManager-configuration', 'apiHelper',
'sectionHelper'
];
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 + '/';
}
/**
* Prefer kefinTweaksRootResolved (commit SHA / verified URL); fall back to symbolic kefinTweaksRoot.
*/
function getResolvedKefinRoot(config) {
const cfg = config || {};
return normalizeRoot(cfg.kefinTweaksRootResolved || cfg.kefinTweaksRoot || '');
}
/**
* Join kefin root + scripts/ + relativePath, collapsing . / .. for site-absolute roots.
* Shared by live resolveAssetUrl and the baked KefinTweaks-injector joinAssetUrl.
*/
function joinAssetUrlForRoot(resolvedRoot, relativePath) {
if (!relativePath) return null;
if (/^https?:\/\//i.test(relativePath)) return relativePath;
const normalizedRoot = normalizeRoot(resolvedRoot);
if (!normalizedRoot) return null;
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 to collapse */
}
}
// 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 resolveAssetUrl(root, relativePath) {
return joinAssetUrlForRoot(root, relativePath);
}
/**
* ES5 joinAssetUrl source for buildInjectorScript (closes over resolvedRoot).
* Must stay algorithmically identical to joinAssetUrlForRoot.
*/
function getJoinAssetUrlRuntimeSource() {
return [
' function joinAssetUrl(relativePath) {',
' if (!relativePath) return null;',
' if (/^https?:\\/\\//i.test(relativePath)) return relativePath;',
' var base = resolvedRoot + \'scripts/\';',
' var joined = (base + relativePath).replace(/\\\\/g, \'/\');',
' if (/^https?:\\/\\//i.test(resolvedRoot)) {',
' try { return new URL(relativePath, base).href; } catch (e) {}',
' }',
' var isSiteAbsolute = joined.charAt(0) === \'/\';',
' var parts = joined.split(\'/\');',
' var stack = [];',
' for (var pi = 0; pi < parts.length; pi++) {',
' var p = parts[pi];',
' if (!p || p === \'.\') continue;',
' if (p === \'..\') { stack.pop(); continue; }',
' stack.push(p);',
' }',
' var resolved = stack.join(\'/\');',
' return isSiteAbsolute ? \'/\' + resolved : resolved;',
' }'
].join('\n');
}
function expandScriptToAssets(scriptDef, root, urlSuffix, thirdParty) {
const assets = [];
const suffix = urlSuffix || '';
const pushRel = (kind, relativePath, name) => {
if (!relativePath) return;
if (/^https?:\/\//i.test(relativePath)) {
assets.push({ kind: kind, url: relativePath, name: name });
return;
}
const url = resolveAssetUrl(root, relativePath);
if (!url) return;
assets.push({
kind: kind,
url: url + suffix,
path: relativePath,
name: name
});
};
for (const tpName of scriptDef.thirdParty || []) {
const tp = thirdParty[tpName];
if (!tp) continue;
(tp.css || []).forEach((css) => pushRel('css', css, tpName));
(tp.js || []).forEach((js) => pushRel('js', js, tpName));
}
if (scriptDef.css) pushRel('css', scriptDef.css, scriptDef.name);
if (scriptDef.script) pushRel('js', scriptDef.script, 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
|| getResolvedKefinRoot(config)
|| ''
);
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 || a.path || '');
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,
path: CONFIGURATION_FILES.css,
name: 'configuration'
},
{
kind: 'js',
url: resolveAssetUrl(root, CONFIGURATION_FILES.script) + urlSuffix,
path: CONFIGURATION_FILES.script,
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 isExperimentalKefinRoot(root) {
return /@experimental(\/|$)/i.test(String(root || ''));
}
function isOfficialJsDelivrKefinRoot(root) {
return /cdn\.jsdelivr\.net\/gh\/ranaldsgift\/KefinTweaks@/i.test(String(root || ''));
}
/**
* Whether baked KefinTweaks-injector should inject assets for this root.
* Allow Experimental and self-host/custom; no-op for official jsDelivr Latest/Dev/version/SHA.
*/
function allowsKefinInjectorPreload(root) {
const r = String(root || '').trim();
if (!r) return false;
if (isExperimentalKefinRoot(r)) return true;
if (!isOfficialJsDelivrKefinRoot(r)) return true;
return false;
}
function buildInjectorScript(loadPlan) {
// Bake relative paths (and absolute http urls); join with resolved root at runtime
const bakeAssets = (loadPlan.assets || []).map((a) => {
if (!a) return null;
if (a.url && /^https?:\/\//i.test(a.url) && !a.path) {
return { kind: a.kind, url: a.url, name: a.name };
}
if (a.path) {
return { kind: a.kind, path: a.path, name: a.name };
}
if (a.url) {
return { kind: a.kind, url: a.url, name: a.name };
}
return null;
}).filter(Boolean);
const assetsJson = JSON.stringify(bakeAssets);
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.KefinTweaksInjectorAttached = true;',
' var cfg = window.KefinTweaksConfig || {};',
' if (cfg.enabled !== true) return;',
' var symbolicRoot = cfg.kefinTweaksRoot || \'\';',
' var isExperimental = /@experimental(\\/|$)/i.test(String(symbolicRoot));',
' var isOfficialCdn = /cdn\\.jsdelivr\\.net\\/gh\\/ranaldsgift\\/KefinTweaks@/i.test(String(symbolicRoot));',
' if (!String(symbolicRoot).trim()) return;',
' if (!isExperimental && isOfficialCdn) return;',
' var resolvedRoot = String(cfg.kefinTweaksRootResolved || cfg.kefinTweaksRoot || \'\').trim();',
' if (!resolvedRoot) return;',
' if (resolvedRoot.charAt(resolvedRoot.length - 1) !== \'/\') resolvedRoot += \'/\';',
getJoinAssetUrlRuntimeSource(),
' 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) continue;',
' var href = a.url || joinAssetUrl(a.path);',
' if (!href) continue;',
' if (a.kind === \'css\') {',
' if (document.querySelector(\'link[href="\' + href + \'"]\')) continue;',
' var l = document.createElement(\'link\');',
' l.rel = \'stylesheet\';',
' l.type = \'text/css\';',
' l.href = href;',
' document.head.appendChild(l);',
' } else {',
' if (document.querySelector(\'script[src="\' + href + \'"]\')) continue;',
' var s = document.createElement(\'script\');',
' s.src = href;',
' 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,
getResolvedKefinRoot: getResolvedKefinRoot,
joinAssetUrlForRoot: joinAssetUrlForRoot,
resolveAssetUrl: resolveAssetUrl,
buildLoadPlan: buildLoadPlan,
buildInjectorScript: buildInjectorScript,
isExperimentalKefinRoot: isExperimentalKefinRoot,
isOfficialJsDelivrKefinRoot: isOfficialJsDelivrKefinRoot,
allowsKefinInjectorPreload: allowsKefinInjectorPreload,
extractInjectorStamp: extractInjectorStamp,
syncKefinTweaksInjector: syncKefinTweaksInjector,
ensureKefinTweaksApi: ensureKefinTweaksApi,
applyAssetsToDocument: applyAssetsToDocument,
upsertInjectorEntries: upsertInjectorEntries,
hasEnabledInjectorEntry: hasEnabledInjectorEntry
};
LOG('Module loaded (' + SCRIPT_DEFINITIONS.length + ' script definitions)');
})();