Anotha one!

This commit is contained in:
HighImKevin
2026-09-15 01:21:54 -04:00
parent dc0cda699d
commit 04d3ef0e92
35 changed files with 7577 additions and 2002 deletions
+10
View File
@@ -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;
+1 -6
View File
@@ -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);
+21
View File
@@ -3,6 +3,12 @@
display: none;
}
.MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-rows: 1fr auto;
}
@media (min-width: 768px) {
.kefinTweaks-breadcrumbs-wrapper {
display: block;
@@ -15,6 +21,11 @@
border-radius: 0.5em;
}
.MuiToolbar-root .kefinTweaks-breadcrumbs-wrapper:hover {
background: transparent;
border-radius: 0;
}
.kefinTweaks-breadcrumbs {
color: rgba(255, 255, 255, 0.8);
font-size: 1.17em;
@@ -27,6 +38,16 @@
display: inline-block;
}
.MuiToolbar-root .kefinTweaks-breadcrumbs,
.kefinTweaks-breadcrumbs-wrapper--modern .kefinTweaks-breadcrumbs {
font-size: 1em;
padding: 0px 12px 8px;
}
.MuiToolbar-root .kefinTweaks-popover {
margin-top: 20px;
}
.kefinTweaks-popover-container {
position: absolute;
top: 100%;
+54 -9
View File
@@ -471,15 +471,28 @@
}
// DOM manipulation
function isModernUI() {
return !!document.querySelector('.MuiBox-root');
}
function resolveBreadcrumbMount() {
if (isModernUI()) {
return document.querySelector('.MuiToolbar-root');
}
return document.querySelector('.skinHeader .headerLeft');
}
function createBreadcrumbContainer() {
if (breadcrumbContainer) {
return breadcrumbContainer;
}
// Find the header left container
const headerLeft = document.querySelector('.skinHeader .headerLeft');
if (!headerLeft) {
error('Could not find .skinHeader .headerLeft element');
const isModern = isModernUI();
const mount = resolveBreadcrumbMount();
if (!mount) {
error(isModern
? 'Could not find .MuiToolbar-root for breadcrumbs'
: 'Could not find .skinHeader .headerLeft element');
return null;
}
@@ -487,6 +500,9 @@
const wrapper = document.createElement('div');
wrapper.id = 'kefinTweaks-breadcrumbs-wrapper';
wrapper.className = 'kefinTweaks-breadcrumbs-wrapper';
if (isModern) {
wrapper.classList.add('kefinTweaks-breadcrumbs-wrapper--modern');
}
wrapper.style.cssText = `
display: none;
`;
@@ -509,11 +525,36 @@
wrapper.appendChild(breadcrumbContainer);
wrapper.appendChild(popoverContainer);
headerLeft.appendChild(wrapper);
mount.appendChild(wrapper);
log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
log(isModern
? 'Created breadcrumb container inside .MuiToolbar-root'
: 'Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
return breadcrumbContainer;
}
function ensureBreadcrumbContainer(retries = 20) {
const existing = createBreadcrumbContainer();
if (existing) return Promise.resolve(existing);
return new Promise((resolve) => {
let attempts = 0;
const tryMount = () => {
const container = createBreadcrumbContainer();
if (container) {
resolve(container);
return;
}
attempts += 1;
if (attempts >= retries) {
resolve(null);
return;
}
setTimeout(tryMount, 100);
};
tryMount();
});
}
function showBreadcrumbs() {
const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
@@ -864,9 +905,13 @@
// Create breadcrumbs from scratch
async function createBreadcrumbsFromScratch(targetStructure) {
// Create container if it doesn't exist
// Create container if it doesn't exist (retry briefly on ModernUI if toolbar not ready)
if (!breadcrumbContainer) {
breadcrumbContainer = createBreadcrumbContainer();
breadcrumbContainer = await ensureBreadcrumbContainer();
}
if (!breadcrumbContainer) {
error('Breadcrumb container unavailable');
return;
}
// Build all elements first
+32 -3
View File
@@ -968,8 +968,25 @@
}
}
.skeleton-card {
margin-bottom: 45px !important;
.skeleton-text-line {
display: inline-block;
height: 1em;
max-width: 100%;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
animation: skeleton-pulse 1.5s ease-in-out infinite;
vertical-align: middle;
}
.skeleton-section-title {
width: 50%;
min-width: 8rem;
}
.skeleton-section-caption {
width: 35%;
min-width: 6rem;
margin-top: 0.15em;
}
/* Tagline always 1 line - Movie-specific override kept for any edge cases */
@@ -1293,7 +1310,7 @@
}
.sectionTitle.sectionCaption {
font-size: 0.9em;
font-size: 0.9em !important;
text-align: left;
grid-column: 1 / -1;
grid-row: 2;
@@ -2035,6 +2052,18 @@
border: none !important;
}
/* Skeletons need a visible padder even when Logo/Clear Art/Disc force transparent cards */
.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardPadder,
.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardScalable,
.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardPadder,
.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardScalable,
.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardPadder,
.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardScalable,
.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardPadder,
.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardScalable {
background: rgba(255, 255, 255, 0.12) !important;
}
.emby-scroller-container[data-card-format="Logo"] .cardImageContainer,
.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer,
.emby-scroller-container[data-section-id="liveTv-channels"] .cardImageContainer {
+495 -29
View File
@@ -810,6 +810,16 @@
function postProcessItems(sectionConfig, itemsData) {
let processed = itemsData?.Items || itemsData || [];
//Check if imageTypes is used in the query to ensure only items with these image types are included
const imageTypes = sectionConfig.queries?.[0]?.queryOptions?.ImageTypes;
if (imageTypes && Array.isArray(imageTypes)) {
// Filter out items which do not have any of the image types
processed = processed.filter(item => {
const itemImageTypes = imageTypes.filter(imageType => item.ImageTags?.[imageType] || (imageType === 'Backdrop' && item.BackdropImageTags?.[0]));
return itemImageTypes.length > 0;
});
}
// When ParentId is on the query (e.g. Popular Genres per library), assign it to each item for genre card links
const queryParentId = sectionConfig.queries?.[0]?.ParentId;
if (queryParentId && Array.isArray(processed)) {
@@ -1348,6 +1358,8 @@
if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
const spotlightSettings = { ...(sectionConfig.spotlightConfig || {}) };
if (activeViewMoreUrl) spotlightSettings.viewMoreUrl = activeViewMoreUrl;
spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
spotlightSettings.caption = sectionConfig.caption;
skeleton = createSkeletonSpotlightSection(displayTitle, spotlightSettings);
} else {
skeleton = createProgressivelyEnhancedScrollableContainer(
@@ -1744,11 +1756,30 @@
});
}
function updateCardWatchlistButtons(itemId, userData) {
if (!itemId || !userData || !('Likes' in userData)) return;
const isLiked = userData.Likes === true;
const likedHelper = window.KefinWatchlistLikedIds;
if (likedHelper?.set) {
likedHelper.set(itemId, isLiked);
}
const escapedId = typeof CSS !== 'undefined' && CSS.escape
? CSS.escape(itemId)
: itemId.replace(/["\\]/g, '\\$&');
document.querySelectorAll(`.watchlist-button[data-id="${escapedId}"]`).forEach((button) => {
button.dataset.active = isLiked ? 'true' : 'false';
button.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
});
}
function applyUserDataEntriesToCards(entries, userId) {
if (!isPrimaryUserDataChange(userId)) return;
entries.forEach((entry) => {
if (entry?.ItemId) {
updateCardResumeAttributes(entry.ItemId, entry);
updateCardWatchlistButtons(entry.ItemId, entry);
}
});
}
@@ -2018,7 +2049,7 @@
// If the section has items, it is a static query
const isStaticQuery = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0;
const renderCachedData = isStaticQuery || (!section.result?.isStale && !isRandomQuerySort) || (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'));
const renderCachedData = (isStaticQuery || (!section.result?.isStale && !isRandomQuerySort) || (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'))) && dataItems.length > 0;
// Paint cached cards when valid, or when stale display is opted in
if (renderCachedData) {
@@ -2070,6 +2101,8 @@
if (getActiveViewMoreUrl(sectionConfig)) {
spotlightSettings.viewMoreUrl = getActiveViewMoreUrl(sectionConfig);
}
spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
spotlightSettings.caption = sectionConfig.caption;
sectionElement = createSkeletonSpotlightSection(getSectionTitleForMultiQuery(sectionConfig), spotlightSettings);
} else {
sectionElement = createProgressivelyEnhancedScrollableContainer(getSectionTitleForMultiQuery(sectionConfig), getActiveViewMoreUrl(sectionConfig), finalCardFormat, sectionConfig.overflowCard, sectionConfig);
@@ -2118,7 +2151,15 @@
}
}
container.appendChild(fragment);
// Append one section per frame so scroll/compositor get frames between paints.
// Build already happened in the fragment; only the container append is paced.
const sectionNodes = Array.from(fragment.childNodes);
for (let i = 0; i < sectionNodes.length; i++) {
container.appendChild(sectionNodes[i]);
if (i + 1 < sectionNodes.length) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}
syncAttachedGridTilesLayouts(container);
// Update the scroll buttons for all scrollable containers
@@ -2527,16 +2568,276 @@
if (!canvas || !blurhashString) return false;
const w = canvas.width || 20;
const h = canvas.height || 20;
const pixels = blurhashDecode(blurhashString, w, h);
const cacheKey = blurhashCacheKey(blurhashString, w, h);
let pixels = blurhashCacheGet(cacheKey);
if (!pixels) {
pixels = blurhashDecode(blurhashString, w, h);
if (pixels) blurhashCacheSet(cacheKey, pixels);
}
if (!pixels) return false;
return applyBlurhashPixelsToCanvas(canvas, pixels);
}
// --- Deferred blurhash: cache + worker + near-viewport filler ---
const BLURHASH_CACHE_MAX = 200;
const blurhashPixelCache = new Map();
let blurhashFillObserver = null;
let blurhashWorker = null;
let blurhashWorkerReqId = 0;
const blurhashWorkerPending = new Map();
const blurhashIdleQueue = [];
let blurhashIdleScheduled = false;
function blurhashCacheKey(blurhash, width, height) {
return blurhash + '|' + width + 'x' + height;
}
function blurhashCacheGet(key) {
if (!blurhashPixelCache.has(key)) return null;
const val = blurhashPixelCache.get(key);
blurhashPixelCache.delete(key);
blurhashPixelCache.set(key, val);
return val;
}
function blurhashCacheSet(key, pixels) {
if (blurhashPixelCache.has(key)) blurhashPixelCache.delete(key);
blurhashPixelCache.set(key, pixels);
while (blurhashPixelCache.size > BLURHASH_CACHE_MAX) {
const oldest = blurhashPixelCache.keys().next().value;
blurhashPixelCache.delete(oldest);
}
}
function shouldSkipBlurhashDecode() {
try {
if (navigator.connection && navigator.connection.saveData === true) return true;
} catch (e) { /* ignore */ }
try {
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true;
} catch (e) { /* ignore */ }
return false;
}
function getBlurhashFillRootMargin() {
return isMobileLayout() ? '120px' : '200px';
}
/**
* Put pixels on canvas and show blurhash until the real image loads.
* @returns {boolean}
*/
function applyBlurhashPixelsToCanvas(canvas, pixels) {
if (!canvas || !pixels || !canvas.isConnected) return false;
const w = canvas.width || 20;
const h = canvas.height || 20;
const ctx = canvas.getContext('2d');
if (!ctx) return false;
const imageData = ctx.createImageData(w, h);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
canvas.removeAttribute('data-blurhash-pending');
const cardImageContainer = canvas.nextElementSibling;
const imageAlreadyLoaded = cardImageContainer
&& cardImageContainer.classList
&& cardImageContainer.classList.contains('lazy-loaded');
if (imageAlreadyLoaded) {
canvas.classList.add('lazy-hidden');
return true;
}
canvas.classList.remove('lazy-hidden');
if (cardImageContainer && cardImageContainer.classList.contains('cardImageContainer')) {
cardImageContainer.classList.add('lazy-hidden');
}
return true;
}
function getBlurhashWorkerSource() {
return [
'const BLURHASH_B83 = ' + JSON.stringify(BLURHASH_B83) + ';',
blurhashDecode83.toString(),
blurhashSignPow.toString(),
blurhashLinearToSrgb.toString(),
blurhashSrgbToLinear.toString(),
blurhashDecode.toString(),
'self.onmessage = function(e) {',
' var data = e.data || {};',
' var id = data.id;',
' try {',
' var pixels = blurhashDecode(data.blurhash, data.width || 20, data.height || 20);',
' if (!pixels) { self.postMessage({ id: id, ok: false }); return; }',
' self.postMessage({ id: id, ok: true, pixels: pixels.buffer }, [pixels.buffer]);',
' } catch (err) {',
' self.postMessage({ id: id, ok: false, error: String(err && err.message || err) });',
' }',
'};'
].join('\n');
}
function ensureBlurhashWorker() {
if (blurhashWorker) return blurhashWorker;
if (typeof Worker === 'undefined') return null;
try {
const blob = new Blob([getBlurhashWorkerSource()], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
worker.onmessage = (e) => {
const data = e.data || {};
const pending = blurhashWorkerPending.get(data.id);
blurhashWorkerPending.delete(data.id);
if (!pending) return;
if (!data.ok || !data.pixels) {
pending.reject(new Error(data.error || 'Blurhash worker decode failed'));
return;
}
pending.resolve(new Uint8ClampedArray(data.pixels));
};
worker.onerror = (err) => {
console.warn('[KefinTweaks CardBuilder] Blurhash worker error:', err);
blurhashWorkerPending.forEach((p) => p.reject(err));
blurhashWorkerPending.clear();
try { worker.terminate(); } catch (e) { /* ignore */ }
try { URL.revokeObjectURL(url); } catch (e) { /* ignore */ }
blurhashWorker = null;
};
blurhashWorker = worker;
return worker;
} catch (e) {
console.warn('[KefinTweaks CardBuilder] Could not create blurhash worker:', e);
return null;
}
}
function decodeBlurhashInWorker(blurhash, width, height) {
return new Promise((resolve, reject) => {
const worker = ensureBlurhashWorker();
if (!worker) {
reject(new Error('Blurhash worker unavailable'));
return;
}
const id = ++blurhashWorkerReqId;
blurhashWorkerPending.set(id, { resolve, reject });
try {
worker.postMessage({ id, blurhash, width, height });
} catch (e) {
blurhashWorkerPending.delete(id);
reject(e);
}
});
}
function scheduleIdleBlurhashDrain() {
if (blurhashIdleScheduled) return;
blurhashIdleScheduled = true;
const run = (deadline) => {
blurhashIdleScheduled = false;
let n = 0;
while (blurhashIdleQueue.length && n < 4) {
if (deadline && typeof deadline.timeRemaining === 'function' && deadline.timeRemaining() <= 0 && n > 0) {
break;
}
const job = blurhashIdleQueue.shift();
n++;
if (!job || !job.canvas || !job.canvas.isConnected) continue;
if (!job.canvas.hasAttribute('data-blurhash-pending')) continue;
const cacheKey = blurhashCacheKey(job.blurhash, job.w, job.h);
let pixels = blurhashCacheGet(cacheKey);
if (!pixels) {
pixels = blurhashDecode(job.blurhash, job.w, job.h);
if (pixels) blurhashCacheSet(cacheKey, pixels);
}
if (pixels) applyBlurhashPixelsToCanvas(job.canvas, pixels);
else job.canvas.removeAttribute('data-blurhash-pending');
}
if (blurhashIdleQueue.length) scheduleIdleBlurhashDrain();
};
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(run, { timeout: 500 });
} else {
requestAnimationFrame(() => run({ timeRemaining: () => 5 }));
}
}
function enqueueIdleBlurhashDecode(canvas, blurhash, width, height) {
blurhashIdleQueue.push({ canvas, blurhash, w: width, h: height });
scheduleIdleBlurhashDrain();
}
async function fillBlurhashCanvas(canvas) {
if (!canvas || !canvas.isConnected) return;
const blurhash = canvas.getAttribute('data-blurhash-pending');
if (!blurhash) return;
if (shouldSkipBlurhashDecode()) {
canvas.removeAttribute('data-blurhash-pending');
canvas.classList.add('lazy-hidden');
return;
}
const w = canvas.width || 20;
const h = canvas.height || 20;
const cacheKey = blurhashCacheKey(blurhash, w, h);
const cached = blurhashCacheGet(cacheKey);
if (cached) {
applyBlurhashPixelsToCanvas(canvas, cached);
return;
}
try {
const pixels = await decodeBlurhashInWorker(blurhash, w, h);
if (!canvas.isConnected || canvas.getAttribute('data-blurhash-pending') !== blurhash) return;
blurhashCacheSet(cacheKey, pixels);
applyBlurhashPixelsToCanvas(canvas, pixels);
} catch (e) {
enqueueIdleBlurhashDecode(canvas, blurhash, w, h);
}
}
function observeBlurhashCanvas(canvas) {
if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return;
if (shouldSkipBlurhashDecode()) {
canvas.removeAttribute('data-blurhash-pending');
canvas.classList.add('lazy-hidden');
return;
}
initBlurhashFillObserver();
if (blurhashFillObserver) {
blurhashFillObserver.observe(canvas);
} else {
fillBlurhashCanvas(canvas);
}
}
function initBlurhashFillObserver() {
if (blurhashFillObserver) return;
if (typeof IntersectionObserver === 'undefined') return;
blurhashFillObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const canvas = entry.target;
blurhashFillObserver.unobserve(canvas);
fillBlurhashCanvas(canvas);
});
}, {
threshold: 0,
rootMargin: getBlurhashFillRootMargin()
});
}
function observePendingBlurhashInNode(node) {
if (!node || node.nodeType !== 1) return;
if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) {
observeBlurhashCanvas(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas);
}
}
/**
* Get blurhash string for the card image when the image is from the current item.
* Mirrors the image URL logic; returns undefined when image comes from parent/series (we don't have their ImageBlurHashes).
@@ -3022,20 +3323,24 @@
const blurhashStr = !item.imageUrl ? getBlurhashForCard(item, cardFormat) : null;
if (!blurhashStr || cardFormat === 'logo' || cardFormat === 'clear art' || cardFormat === 'disc') {
blurhashCanvas.classList.add('lazy-hidden');
} else if (shouldSkipBlurhashDecode()) {
// Skip CPU decode; keep default chrome / lazy image path only
blurhashCanvas.classList.add('lazy-hidden');
} else {
// Draw blurhash immediately so placeholder is visible ASAP (canvas is behind cardImageContainer)
if (!drawBlurhashToCanvas(blurhashCanvas, blurhashStr)) {
blurhashCanvas.classList.add('lazy-hidden');
} else {
// Hide the image container so the blurhash canvas shows until the real image loads
cardImageContainer.classList.add('lazy-hidden');
}
// Defer decode off the construction path — near-viewport filler paints later
blurhashCanvas.classList.add('lazy-hidden');
blurhashCanvas.setAttribute('data-blurhash-pending', blurhashStr);
}
if (imageUrl) {
// Use data-src for lazy loading instead of immediate backgroundImage
cardImageContainer.setAttribute('data-src', imageUrl);
if (blurhashStr) cardImageContainer.setAttribute('data-blurhash', blurhashStr);
if (blurhashStr
&& cardFormat !== 'logo'
&& cardFormat !== 'clear art'
&& cardFormat !== 'disc') {
cardImageContainer.setAttribute('data-blurhash', blurhashStr);
}
// Add lazy class for styling/selection
cardImageContainer.classList.add('lazy');
}
@@ -3154,6 +3459,25 @@
const buttonContainer = document.createElement('div');
buttonContainer.className = 'cardOverlayButton-br flex';
// Watchlist button (active state from UserData.Likes; click bound by watchlist.js)
const watchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video'];
if (watchlistSupportedTypes.includes(itemType)) {
const isLiked = item.UserData?.Likes === true;
const watchlistButton = document.createElement('button');
watchlistButton.type = 'button';
watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat';
watchlistButton.setAttribute('data-action', 'none');
watchlistButton.setAttribute('data-id', itemId);
watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false');
watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
const watchlistIcon = document.createElement('span');
watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
watchlistIcon.setAttribute('aria-hidden', 'true');
watchlistButton.appendChild(watchlistIcon);
buttonContainer.appendChild(watchlistButton);
}
// Watched button
const watchedButton = document.createElement('button');
watchedButton.setAttribute('is', 'emby-playstatebutton');
@@ -3203,7 +3527,9 @@
buttonContainer.appendChild(moreButton);
cardOverlayContainer.appendChild(playButton);
if (item.Type !== 'CollectionFolder' && item.Type !== 'Folder' && item.Type !== 'UserView') {
cardOverlayContainer.appendChild(playButton);
}
cardOverlayContainer.appendChild(buttonContainer);
}
@@ -5256,6 +5582,52 @@
return raw > 0 ? raw : 16;
}
const SKELETON_ONE_LINE_ITEM_TYPES = new Set([
'Book', 'Genre', 'CollectionFolder', 'BoxSet', 'Playlist', 'Folder', 'Studio'
]);
const SKELETON_CUSTOM_FOOTER_SECTION_IDS = new Set([
'upcoming', 'recentlyReleased.movies', 'recentlyReleased.episodes'
]);
function sectionUsesCustomFooterText(sectionConfig) {
if (!sectionConfig) return false;
if (SKELETON_CUSTOM_FOOTER_SECTION_IDS.has(sectionConfig.id)) return true;
if (Array.isArray(sectionConfig.items) && sectionConfig.items.some(
(item) => item?.CustomFooterText || item?.cardFooter
)) {
return true;
}
return false;
}
function inferSkeletonItemType(sectionConfig) {
if (!sectionConfig) return null;
const query = sectionConfig.queries?.[0];
const path = typeof query?.path === 'string' ? query.path : '';
if (path.includes('/Studios')) return 'Studio';
if (path.includes('/Genres')) return 'Genre';
let includeTypes = query?.queryOptions?.IncludeItemTypes;
if (typeof includeTypes === 'string') {
includeTypes = includeTypes.split(',').map((t) => t.trim()).filter(Boolean);
}
if (!Array.isArray(includeTypes) || includeTypes.length === 0) return null;
if (includeTypes.length === 1) return includeTypes[0];
if (includeTypes.every((t) => SKELETON_ONE_LINE_ITEM_TYPES.has(t))) {
return includeTypes[0];
}
return null;
}
function getSkeletonFooterLineCount(sectionConfig) {
if (sectionConfig?.hideCardFooter === true) return 0;
if (sectionUsesCustomFooterText(sectionConfig)) return 3;
const itemType = inferSkeletonItemType(sectionConfig);
if (itemType && SKELETON_ONE_LINE_ITEM_TYPES.has(itemType)) return 1;
return 2;
}
function createScrollerElement() {
const scroller = document.createElement('div');
const isMobile = isMobileLayout();
@@ -5582,9 +5954,10 @@
return null;
}
function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl }) {
function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl, discoveryPending }) {
const captionText = caption && String(caption).trim();
const hasCaption = !!captionText;
const useSkeletonTitle = discoveryPending === true;
let parent = sectionTitleContainer;
if (hasCaption) {
@@ -5594,7 +5967,15 @@
parent = wrapper;
}
if (viewMoreUrl) {
if (useSkeletonTitle) {
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
const skeletonTitle = document.createElement('span');
skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
skeletonTitle.setAttribute('aria-hidden', 'true');
titleText.appendChild(skeletonTitle);
parent.appendChild(titleText);
} else if (viewMoreUrl) {
const titleLink = document.createElement('a');
titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button';
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
@@ -5628,7 +6009,14 @@
if (hasCaption) {
let captionEl;
if (captionUrl) {
if (useSkeletonTitle) {
captionEl = document.createElement('span');
captionEl.className = 'sectionTitle sectionCaption';
const skeletonCaption = document.createElement('span');
skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
skeletonCaption.setAttribute('aria-hidden', 'true');
captionEl.appendChild(skeletonCaption);
} else if (captionUrl) {
captionEl = document.createElement('a');
captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button';
if (typeof captionUrl === 'function') {
@@ -5639,11 +6027,12 @@
} else {
captionEl.href = captionUrl;
}
captionEl.textContent = captionText;
} else {
captionEl = document.createElement('span');
captionEl.className = 'sectionTitle sectionCaption';
captionEl.textContent = captionText;
}
captionEl.textContent = captionText;
parent.appendChild(captionEl);
}
}
@@ -5722,25 +6111,30 @@
/**
* Creates a skeleton card element for loading states
* @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square'
* @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', 'square', 'logo', 'clear art', 'disc', 'banner', etc.
* @param {boolean} overflowCard - Use overflow card classes
* @param {Object|null} sectionConfig - Section config used to infer footer line count
* @returns {HTMLElement} - Skeleton card element
*/
function createSkeletonCard(cardFormat = null, overflowCard = false) {
function createSkeletonCard(cardFormat = null, overflowCard = false, sectionConfig = null) {
const card = document.createElement('div');
// Determine card classes based on format
// Determine card classes based on format (must match createJellyfinCardElement)
let cardClass, padderClass;
cardFormat = cardFormat?.toLowerCase() || 'portrait';
if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb') {
if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb'
|| cardFormat === 'logo' || cardFormat === 'clear art') {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
} else if (cardFormat === 'square') {
} else if (cardFormat === 'square' || cardFormat === 'disc') {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
} else if (cardFormat === 'banner') {
cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard';
padderClass = 'cardPadder-banner';
} else {
// portrait (default)
// portrait / poster / series poster (default)
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
}
@@ -5760,6 +6154,32 @@
cardScalable.appendChild(cardPadder);
cardBox.appendChild(cardScalable);
const footerLineCount = getSkeletonFooterLineCount(sectionConfig);
if (footerLineCount > 0) {
const cardTextStack = document.createElement('div');
cardTextStack.className = 'cardTextStack';
const lineWidths = ['70%', '55%', '40%'];
for (let lineIndex = 0; lineIndex < footerLineCount; lineIndex++) {
const isFirst = lineIndex === 0;
const cardText = document.createElement('div');
cardText.className = isFirst
? 'cardText cardTextCentered cardText-first'
: 'cardText cardTextCentered cardText-secondary';
const bdi = document.createElement('bdi');
const skeletonLine = document.createElement('span');
skeletonLine.className = 'skeleton-text-line';
skeletonLine.style.width = lineWidths[lineIndex] || '40%';
bdi.appendChild(skeletonLine);
cardText.appendChild(bdi);
cardTextStack.appendChild(cardText);
}
cardBox.appendChild(cardTextStack);
}
card.appendChild(cardBox);
return card;
@@ -5773,9 +6193,18 @@
* @returns {HTMLElement} - Skeleton spotlight container
*/
function createSkeletonSpotlightSection(title, options = {}) {
const { viewMoreUrl = null, spotlightLayout, spotlightSize, fullScreen } = options;
const {
viewMoreUrl = null,
spotlightLayout,
spotlightSize,
fullScreen,
discoveryPending = false,
caption = null
} = options;
const layout = spotlightLayout ?? (fullScreen === true ? 'Borderless' : 'Border');
const size = spotlightSize ?? (fullScreen === true ? 'full' : 'normal');
const captionText = caption && String(caption).trim();
const useSkeletonTitle = discoveryPending === true;
// Create main container (same structure as real spotlight)
const container = document.createElement('div');
@@ -5792,9 +6221,16 @@
{
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'spotlight-section-title-container';
if (title) {
if (useSkeletonTitle || title) {
let sectionTitleEl;
if (viewMoreUrl) {
if (useSkeletonTitle) {
sectionTitleEl = document.createElement('div');
sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
const skeletonTitle = document.createElement('span');
skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
skeletonTitle.setAttribute('aria-hidden', 'true');
sectionTitleEl.appendChild(skeletonTitle);
} else if (viewMoreUrl) {
const titleLink = document.createElement('a');
titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
titleLink.textContent = title;
@@ -5819,9 +6255,19 @@
sectionTitleEl.textContent = title;
}
const sectionTitleWrapper = document.createElement('div');
sectionTitleWrapper.className = `spotlight-section-title ${viewMoreUrl ? '' : 'spotlight-title-link '}headerTabs sectionTabs`;
sectionTitleWrapper.className = `spotlight-section-title ${(!useSkeletonTitle && viewMoreUrl) ? '' : 'spotlight-title-link '}headerTabs sectionTabs`;
sectionTitleWrapper.appendChild(sectionTitleEl);
sectionTitleContainer.appendChild(sectionTitleWrapper);
if (useSkeletonTitle && captionText) {
const captionEl = document.createElement('span');
captionEl.className = 'sectionTitle sectionCaption';
const skeletonCaption = document.createElement('span');
skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
skeletonCaption.setAttribute('aria-hidden', 'true');
captionEl.appendChild(skeletonCaption);
sectionTitleContainer.appendChild(captionEl);
}
}
bannerContainer.appendChild(sectionTitleContainer);
}
@@ -5865,11 +6311,13 @@
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
const discoveryPending = sectionConfig?.discoveryPending === true;
appendSectionTitleContent(sectionTitleContainer, {
title,
caption: getSectionCaption(sectionConfig),
captionUrl: getSectionCaptionUrl(sectionConfig),
viewMoreUrl
viewMoreUrl: discoveryPending ? null : viewMoreUrl,
discoveryPending
});
ensureSectionControlsMount(sectionTitleContainer);
@@ -5879,7 +6327,7 @@
const skeletonCount = getSectionSkeletonCount(sectionConfig);
for (let i = 0; i < skeletonCount; i++) {
const skeletonCard = createSkeletonCard(cardFormat, overflowCard);
const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig);
skeletonCard.setAttribute('data-index', i);
itemsContainer.appendChild(skeletonCard);
}
@@ -5961,10 +6409,13 @@
*/
function initLazyImageObserver() {
if (lazyImageObserver) return; // Already initialized
// Mobile: tighter prefetch so image downloads don't stampede during discovery paint
const rootMargin = isMobileLayout() ? '300px' : '800px';
const observerOptions = {
threshold: 0.1, // Trigger when 10% of element is visible
rootMargin: '800px' // Start loading well before element enters viewport to avoid grey flash
rootMargin
};
lazyImageObserver = new IntersectionObserver((entries) => {
@@ -5987,6 +6438,7 @@
const canvas = cardImageContainer.previousElementSibling;
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
canvas.classList.add('lazy-hidden');
canvas.removeAttribute('data-blurhash-pending');
}
lazyImageObserver.unobserve(cardImageContainer);
};
@@ -5999,6 +6451,7 @@
const canvas = cardImageContainer.previousElementSibling;
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
canvas.classList.add('lazy-hidden');
canvas.removeAttribute('data-blurhash-pending');
}
lazyImageObserver.unobserve(cardImageContainer);
};
@@ -6036,6 +6489,8 @@
lazyImageObserver.observe(img);
});
}
observePendingBlurhashInNode(node);
});
});
});
@@ -6112,11 +6567,14 @@
initLazyImageObserver();
initLazyMutationObserver();
initBlurhashFillObserver();
const existingLazyImages = document.querySelectorAll('.cardImageContainer[data-src]');
existingLazyImages.forEach(img => {
lazyImageObserver.observe(img);
});
document.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas);
if (document.body) {
lazyMutationObserver.observe(document.body, {
@@ -6772,12 +7230,20 @@
const dataItems = section.result?.data?.Items ?? section.result?.data ?? [];
const isSpotlight = sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight';
const isSkeleton = !!(sectionElement.querySelector('.skeleton-card, .skeleton-spotlight-item'));
const hasSkeletonTitle = !!sectionElement.querySelector('.skeleton-section-title');
if (items.length === 0) {
sectionElement.remove();
return;
}
// Discovery pending (and any skeleton with placeholder title) needs a full rebuild for title/viewMore/caption
if (isSkeleton && (isSpotlight || hasSkeletonTitle)) {
const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially);
markSectionEnhanced(content, sectionConfig);
return;
}
if (!isSkeleton && isSpotlight) {
const staleIds = (Array.isArray(dataItems) ? dataItems : [])
.map((item) => item?.Id)
+11 -11
View File
@@ -22,9 +22,9 @@
{ key: 'directedByTopDirector', label: 'Directed by [Top Director]', defaultName: 'Directed by [Director]' },
{ key: 'writtenByTopWriter', label: 'Written by [Top Writer]', defaultName: 'Written by [Writer]' },
{ key: 'becauseYouRecentlyWatched', label: 'Because you recently watched [Movie]', defaultName: 'Because you recently watched [Movie]' },
{ key: 'starringActorRecentlyWatched', label: 'Starring [Actor] because you recently watched [Movie]', defaultName: 'Starring [Actor] because you recently watched [Movie]' },
{ key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Director] because you recently watched [Movie]', defaultName: 'Directed by [Director] because you recently watched [Movie]' },
{ key: 'writtenByWriterRecentlyWatched', label: 'Written by [Writer] because you recently watched [Movie]', defaultName: 'Written by [Writer] because you recently watched [Movie]' }
{ key: 'starringActorRecentlyWatched', label: 'Starring [Person]', defaultName: 'Starring {Person}', extras: { caption: 'because you recently watched {Title}' } },
{ key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Person]', defaultName: 'Directed by {Person}', extras: { caption: 'because you recently watched {Title}' } },
{ key: 'writtenByWriterRecentlyWatched', label: 'Written by [Person]', defaultName: 'Written by {Person}', extras: { caption: 'because you recently watched {Title}' } }
];
const SUPPORTED_CUSTOM_SECTION_PARAMS = {
@@ -1208,10 +1208,8 @@
const defsByName = new Map(
(window.KefinTweaks?.getScripts?.() || []).map((def) => [def.name, def])
);
const major = window.KefinTweaks?._jellyfinMajorVersion
?? (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function'
? window.KefinTweaks.getJellyfinMajorVersion()
: null);
// Prefer cache warmed by await getJellyfinMajorVersion() before opening the modal
const major = window.KefinTweaks?._jellyfinMajorVersion ?? null;
const visibleScripts = scriptNames.filter((script) => {
const def = defsByName.get(script.key);
@@ -1711,6 +1709,9 @@
if (definition.extras?.minimumItems !== undefined) {
defaults.minimumItems = definition.extras.minimumItems;
}
if (definition.extras?.caption !== undefined) {
defaults.caption = definition.extras.caption;
}
let normalized = { ...defaults };
@@ -2270,7 +2271,7 @@
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 0.75em; margin-bottom: 0.75em;">
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<div class="listItemContent">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Infinite Scroll / Load More Button</div>
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Infinite Scroll / Discover More</div>
${buildJellyfinCheckbox('homeScreen_discovery_infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll')}
</div>
</div>
@@ -2479,9 +2480,8 @@
// Ensure Jellyfin major is cached so version-gated feature cards filter correctly
try {
if (typeof window.KefinTweaks?.getCurrentMajorServerVersion === 'function') {
const major = await window.KefinTweaks.getCurrentMajorServerVersion();
window.KefinTweaks._jellyfinMajorVersion = major;
if (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function') {
await window.KefinTweaks.getJellyfinMajorVersion();
}
} catch (e) {
console.warn('[KefinTweaks Configuration] Could not resolve Jellyfin major version:', e);
+114
View File
@@ -0,0 +1,114 @@
// KefinTweaks External List (MDBList) — match provider IDs against library caches
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks ExternalList]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks ExternalList]', ...args);
const ERR = (...args) => console.error('[KefinTweaks ExternalList]', ...args);
function getMdblistApiKey() {
const settings = window.LibraryCacheUtils?.getLibraryCacheSettings?.() || {};
return (settings.mdblistApiKey || '').trim();
}
function appendApiKey(url, apiKey) {
if (!apiKey) return url;
try {
const u = new URL(url);
if (!u.searchParams.has('apikey') && !u.searchParams.has('api_key')) {
u.searchParams.set('apikey', apiKey);
}
return u.toString();
} catch (_) {
const join = url.includes('?') ? '&' : '?';
return `${url}${join}apikey=${encodeURIComponent(apiKey)}`;
}
}
/**
* Normalize mdblist (or similar) JSON into { mediaType, imdb?, tmdb? }[].
*/
function normalizeExternalListPayload(data) {
const rows = [];
const list = Array.isArray(data) ? data
: Array.isArray(data?.movies) || Array.isArray(data?.shows) || Array.isArray(data?.items)
? [...(data.movies || []), ...(data.shows || []), ...(data.items || [])]
: [];
list.forEach((entry) => {
if (!entry || typeof entry !== 'object') return;
const mediaType = /show|series|tv/i.test(String(entry.mediatype || entry.media_type || entry.type || ''))
? 'Series'
: 'Movie';
const imdb = entry.imdb_id || entry.imdbid || entry.ids?.imdb || entry.Imdb || null;
const tmdb = entry.tmdb_id || entry.tmdbid || entry.ids?.tmdb || entry.Tmdb || null;
if (imdb || tmdb) {
rows.push({ mediaType, imdb: imdb ? String(imdb) : undefined, tmdb: tmdb ? String(tmdb) : undefined });
}
});
return rows;
}
async function fetchExternalListUrl(url) {
const apiKey = getMdblistApiKey();
const fetchUrl = appendApiKey(url, apiKey);
const response = await fetch(fetchUrl, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`External list HTTP ${response.status}`);
}
return response.json();
}
async function resolveExternalListItems(sectionConfig) {
const urls = sectionConfig?.externalListUrls || [];
if (!urls.length) return { Items: [] };
if (!getMdblistApiKey()) {
WARN('MDBList API key not configured');
}
const allRows = [];
for (const url of urls) {
if (!url) continue;
try {
const data = await fetchExternalListUrl(url);
allRows.push(...normalizeExternalListPayload(data));
} catch (e) {
ERR('Failed to fetch external list:', url, e);
}
}
const movieIds = [];
const seriesIds = [];
allRows.forEach((row) => {
const keys = [];
if (row.imdb) keys.push(row.imdb);
if (row.tmdb) keys.push(row.tmdb);
if (row.mediaType === 'Series') seriesIds.push(...keys);
else movieIds.push(...keys);
});
const movies = movieIds.length
? await (window.LibraryCache?.getByProviderIds('Movie', movieIds)
|| window.MoviesCache?.getMoviesByProviderIds?.(movieIds)
|| [])
: [];
const series = seriesIds.length
? await (window.LibraryCache?.getByProviderIds('Series', seriesIds)
|| window.SeriesCache?.getSeriesByProviderIds?.(seriesIds)
|| [])
: [];
const items = [...movies, ...series];
LOG(`External list matched ${items.length} library items from ${allRows.length} rows`);
return { Items: items };
}
window.KefinExternalList = {
getMdblistApiKey,
resolveExternalListItems,
normalizeExternalListPayload
};
LOG('Module loaded');
})();
+45 -1
View File
@@ -3,7 +3,6 @@
.sections.homeSectionsContainer {
display: flex;
flex-direction: column;
padding-bottom: 100px;
gap: 1rem;
}
@@ -36,6 +35,10 @@
content: "Discover More";
}
#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::before {
content: "No More Content to Discover";
}
div#discovery-loading-indicator:not(.show)::after {
content: "\E313";
font-family: "Material Icons";
@@ -49,10 +52,26 @@ div#discovery-loading-indicator:not(.show)::after {
opacity: 1;
}
#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::after {
content: "";
}
[data-loading-discovery="true"] div#discovery-loading-indicator {
display: flex;
}
/* Idle Discover More / exhausted prompt (chevron mode) */
#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) {
display: flex !important;
visibility: visible !important;
opacity: 1 !important;
}
#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) .spinner {
visibility: hidden;
opacity: 0;
}
/* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
display: none;
} */
@@ -298,3 +317,28 @@ body[data-seasonal-background="true"]:has(.homePage:not(.hide)) #reactRoot:has(.
body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay {
display: block;
} */
.homePage[data-discovery-ready] div#discovery-loading-indicator:not(.show) {
min-height: 50px;
}
@keyframes fadeInDelayed {
0%, 99% {
opacity: 0;
visibility: hidden;
}
100% {
opacity: 1;
visibility: visible;
}
}
.homeSectionsContainer:not(:has([data-page]), :has([data-section-id]))::after {
content: 'Customize your Home Screen from your User Settings > Home in the top right corner.';
text-align: center;
margin: 3em auto;
display: block;
animation: fadeInDelayed 0.1s linear 1s forwards;
opacity: 0;
visibility: hidden;
}
+365 -1034
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+78
View File
@@ -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');
})();
+268
View File
@@ -0,0 +1,268 @@
// KefinTweaks Library Cache Utils
// Shared strip / provider-id helpers for moviesCache, seriesCache, peopleCache
(function() {
'use strict';
function stripPerson(person) {
if (!person) return null;
return {
Name: person.Name,
Id: person.Id,
Role: person.Role,
Type: person.Type
};
}
function stripChapter(chapter) {
if (!chapter) return null;
return {
Name: chapter.Name,
ImageTag: chapter.ImageTag
};
}
function stripMediaStream(stream) {
if (!stream) return null;
return {
Height: stream.Height,
Width: stream.Width,
DisplayTitle: stream.DisplayTitle,
Language: stream.Language,
Type: stream.Type
};
}
function stripStudio(studio) {
if (!studio) return null;
return {
Name: studio.Name,
Id: studio.Id
};
}
function stripGenreItem(genre) {
if (!genre) return null;
return {
Name: genre.Name,
Id: genre.Id
};
}
/**
* Strip a movie item to the durable library-cache allowlist (no UserData).
*/
function stripMovieForCache(item) {
if (!item) return null;
return {
Name: item.Name,
Id: item.Id,
HasSubtitles: item.HasSubtitles,
PremiereDate: item.PremiereDate,
CriticRating: item.CriticRating,
OfficialRating: item.OfficialRating,
Overview: item.Overview,
Taglines: item.Taglines,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
ProviderIds: item.ProviderIds || {},
Type: item.Type || 'Movie',
People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [],
Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [],
GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [],
MediaStreams: Array.isArray(item.MediaStreams) ? item.MediaStreams.map(stripMediaStream).filter(Boolean) : [],
ImageTags: item.ImageTags || {},
BackdropImageTags: item.BackdropImageTags || [],
Chapters: Array.isArray(item.Chapters) ? item.Chapters.map(stripChapter).filter(Boolean) : [],
Trickplay: item.Trickplay || undefined,
DateCreated: item.DateCreated,
DateLastSaved: item.DateLastSaved,
UserData: item.UserData || {}
};
}
/**
* Strip a series item (no MediaStreams/Chapters/Trickplay).
*/
function stripSeriesForCache(item) {
if (!item) return null;
return {
Name: item.Name,
Id: item.Id,
PremiereDate: item.PremiereDate,
CriticRating: item.CriticRating,
OfficialRating: item.OfficialRating,
Overview: item.Overview,
Taglines: item.Taglines,
CommunityRating: item.CommunityRating,
RunTimeTicks: item.RunTimeTicks,
ProductionYear: item.ProductionYear,
ProviderIds: item.ProviderIds || {},
Type: item.Type || 'Series',
People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [],
Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [],
GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [],
ImageTags: item.ImageTags || {},
BackdropImageTags: item.BackdropImageTags || [],
ChildCount: item.ChildCount,
RecursiveItemCount: item.RecursiveItemCount,
DateCreated: item.DateCreated,
DateLastContentAdded: item.DateLastContentAdded,
DateLastSaved: item.DateLastSaved,
UserData: item.UserData || {}
};
}
function stripItemsForCache(items, kind) {
const strip = kind === 'Series' ? stripSeriesForCache : stripMovieForCache;
return (items || []).map(strip).filter(Boolean);
}
/**
* Normalize provider id keys for matching (Imdb/Tmdb/Tvdb).
* @param {string|Object} idOrObj
* @returns {string[]} normalized keys like "imdb:tt123", "tmdb:456"
*/
function normalizeProviderKeys(idOrObj) {
const keys = [];
if (!idOrObj) return keys;
if (typeof idOrObj === 'string') {
const s = idOrObj.trim();
if (!s) return keys;
if (/^tt\d+$/i.test(s)) keys.push(`imdb:${s.toLowerCase()}`);
else if (/^\d+$/.test(s)) {
keys.push(`tmdb:${s}`);
keys.push(`tvdb:${s}`);
} else keys.push(s.toLowerCase());
return keys;
}
const p = idOrObj.ProviderIds || idOrObj;
if (p.Imdb) keys.push(`imdb:${String(p.Imdb).toLowerCase()}`);
if (p.Tmdb) keys.push(`tmdb:${String(p.Tmdb)}`);
if (p.Tvdb) keys.push(`tvdb:${String(p.Tvdb)}`);
return keys;
}
function itemMatchesProviderIds(item, idSet) {
if (!item || !idSet || idSet.size === 0) return false;
return normalizeProviderKeys(item).some(k => idSet.has(k));
}
function buildProviderIdIndex(items) {
const index = Object.create(null);
(items || []).forEach((item) => {
if (!item?.Id) return;
normalizeProviderKeys(item).forEach((key) => {
if (!index[key]) index[key] = [];
if (!index[key].includes(item.Id)) index[key].push(item.Id);
});
});
return index;
}
function getLibraryCacheSettings() {
const fromHome = window.KefinHomeScreen?.getConfig?.()?.LIBRARY_CACHE;
const fromDefault = window.KefinHomeConfig2?.LIBRARY_CACHE;
return {
movieChunkSize: 1000,
seriesChunkSize: 1000,
mdblistApiKey: '',
CACHE_TTL: 14 * 24 * 60 * 60 * 1000,
...(fromDefault || {}),
...(fromHome || {})
};
}
/**
* Run strip on main thread; yields between microtasks for UI breathing room.
* Worker path: Blob worker when available (same strip logic inlined).
*/
async function stripItemsAsync(items, kind) {
if (!items || !items.length) return [];
if (typeof Worker === 'undefined') {
return stripItemsForCache(items, kind);
}
try {
return await stripItemsInWorker(items, kind);
} catch (e) {
console.warn('[KefinTweaks LibraryCacheUtils] Worker strip failed, using main thread:', e);
return stripItemsForCache(items, kind);
}
}
function stripItemsInWorker(items, kind) {
return new Promise((resolve, reject) => {
const workerSource = `
${stripPerson.toString()}
${stripChapter.toString()}
${stripMediaStream.toString()}
${stripStudio.toString()}
${stripGenreItem.toString()}
${stripMovieForCache.toString()}
${stripSeriesForCache.toString()}
${stripItemsForCache.toString()}
self.onmessage = function(e) {
try {
const { items, kind } = e.data;
const result = stripItemsForCache(items, kind);
self.postMessage({ ok: true, result });
} catch (err) {
self.postMessage({ ok: false, error: String(err && err.message || err) });
}
};
`;
const blob = new Blob([workerSource], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
const timer = setTimeout(() => {
worker.terminate();
URL.revokeObjectURL(url);
reject(new Error('Worker strip timeout'));
}, 30000);
worker.onmessage = (e) => {
clearTimeout(timer);
worker.terminate();
URL.revokeObjectURL(url);
if (e.data?.ok) resolve(e.data.result);
else reject(new Error(e.data?.error || 'Worker strip failed'));
};
worker.onerror = (err) => {
clearTimeout(timer);
worker.terminate();
URL.revokeObjectURL(url);
reject(err);
};
worker.postMessage({ items, kind });
});
}
function upsertById(existingItems, incomingItems) {
const map = new Map();
(existingItems || []).forEach((item) => {
if (item?.Id) map.set(item.Id, item);
});
(incomingItems || []).forEach((item) => {
if (item?.Id) map.set(item.Id, item);
});
return Array.from(map.values());
}
function removeByIds(existingItems, removeIds) {
if (!removeIds || !removeIds.size) return existingItems || [];
return (existingItems || []).filter((item) => item?.Id && !removeIds.has(item.Id));
}
window.LibraryCacheUtils = {
stripMovieForCache,
stripSeriesForCache,
stripPerson,
stripItemsForCache,
stripItemsAsync,
normalizeProviderKeys,
itemMatchesProviderIds,
buildProviderIdIndex,
getLibraryCacheSettings,
upsertById,
removeByIds
};
})();
+409 -108
View File
@@ -1,97 +1,335 @@
// KefinTweaks Studios Cache Manager
// Handles fetching, processing, and caching of Popular TV Networks
// KefinTweaks Movies Library Cache
// Chunked per-user archive of movies (no UserData). Startup after login + incremental sync.
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Movies Cache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Movies Cache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Movies Cache]', ...args);
const LOG = (...args) => console.log('[KefinTweaks Movies Cache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Movies Cache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Movies Cache]', ...args);
// 1 week
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
const IMDB_TOP_250_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours for ID list
const CACHE_NAME = 'movies_library';
const PARTIAL_CACHE_NAME = 'movies_library_partial';
const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedMovieCache_';
const IMDB_TOP_250_CACHE_TTL = 24 * 60 * 60 * 1000;
const MOVIE_FIELDS = [
'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview', 'PrimaryImageAspectRatio',
'DateCreated'
].join(',');
// State
let movies = null;
let isComplete = false;
let imdbTop250Movies = null;
let isInitializing = false;
let fetchPromise = null;
let providerIndex = null;
function utils() {
return window.LibraryCacheUtils || {};
}
function getTtl() {
return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
}
function getChunkSize() {
return utils().getLibraryCacheSettings?.().movieChunkSize || 1000;
}
function lastFetchStorageKey() {
const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous';
return `${LAST_FETCH_KEY_PREFIX}${userId}`;
}
function getLastDateFetched() {
try {
return localStorage.getItem(lastFetchStorageKey()) || null;
} catch (_) {
return null;
}
}
function setLastDateFetched(iso) {
try {
localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString());
} catch (e) {
WARN('Failed to store lastDateFetchedMovieCache:', e);
}
}
function getApiHelper() {
return window.apiHelper || window.ApiHelper;
}
function buildItemsUrl({ startIndex, limit, extraParams = {} }) {
const server = window.ApiClient.serverAddress();
const params = new URLSearchParams({
IncludeItemTypes: 'Movie',
Recursive: 'true',
Fields: MOVIE_FIELDS,
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'true',
SortBy: 'SortName',
SortOrder: 'Ascending',
StartIndex: String(startIndex || 0),
Limit: String(limit)
});
Object.entries(extraParams).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') params.set(k, String(v));
});
return `${server}/Items?${params.toString()}`;
}
async function queryItems(url) {
const api = getApiHelper();
if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable');
const result = await api.getQuery(url, { useCache: false });
return result || { Items: [], TotalRecordCount: 0 };
}
async function stripChunk(items) {
if (utils().stripItemsAsync) {
return utils().stripItemsAsync(items, 'Movie');
}
return (items || []).map((item) => utils().stripMovieForCache?.(item) || item).filter(Boolean);
}
function rebuildProviderIndex(list) {
providerIndex = utils().buildProviderIdIndex?.(list) || null;
}
/**
* Fetches and caches all movies across all libraries.
* You should not use this for anything related to UserData
* The cache for this is long, and this is intended to simply be an "archive" of all movies across all libraries.
* @returns {Promise<Array>} - Array of movie objects
* Full crawl with partial resume. Commits to movies_library only when complete.
*/
async function fetchAndCacheMovies() {
// IndexedDBCache
const cacheName = 'movies_library';
const cache = window.IndexedDBCache;
// Check cache first
const cached = await cache.get(cacheName);
if (cached) {
LOG('Using cached Movies');
return cached;
}
try {
LOG('Fetching Movies...');
// We need to refactor this to pull the data in chunks of 100 items at a time in order to backfill the cache
// We will populate 100 items at a time into the library cache.
// Each group will contain the index of the last item in the group so we can resume from there
// When resuming, we should first verify if the previous item based on the same sorting and startindex/limit is still the same
// We just check the last item in the indexed db cache and make the same query starting at that index
// If it's the same then we can resume from there, if not then we need to start from the beginning
const allMoviesQuery = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds`;
const movieResult = await apiHelper.getQuery(allMoviesQuery);
// Resolve the data (cached or fresh)
let allMovies = [];
if (movieResult && movieResult.Items) {
allMovies = movieResult.Items;
}
LOG(`Fetched ${allMovies.length} Movies`);
// Cache the results
const userId = ApiClient.getCurrentUserId();
await cache.set(cacheName, allMovies, userId, CACHE_TTL);
return allMovies;
} catch (err) {
ERR('Error fetching Movies:', err);
if (!cache) {
ERR('IndexedDBCache unavailable');
return [];
}
const userId = window.ApiClient.getCurrentUserId();
const complete = await cache.get(CACHE_NAME, userId);
if (complete && Array.isArray(complete)) {
movies = complete;
isComplete = true;
rebuildProviderIndex(movies);
LOG(`Using complete cached movies (${movies.length})`);
return movies;
}
const chunkSize = getChunkSize();
let startIndex = 0;
let collected = [];
const partial = await cache.get(PARTIAL_CACHE_NAME, userId);
if (partial && Array.isArray(partial.items) && partial.isComplete !== true) {
collected = partial.items;
startIndex = partial.startIndex || collected.length;
LOG(`Resuming movie crawl from index ${startIndex} (${collected.length} items)`);
}
try {
let hasMore = true;
while (hasMore) {
const url = buildItemsUrl({ startIndex, limit: chunkSize });
const data = await queryItems(url);
const batch = data.Items || [];
const stripped = await stripChunk(batch);
collected = collected.concat(stripped);
startIndex += batch.length;
await cache.set(PARTIAL_CACHE_NAME, {
isComplete: false,
startIndex,
items: collected
}, userId, getTtl());
LOG(`Movie crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`);
if (batch.length < chunkSize) {
hasMore = false;
} else {
await yieldToMain();
}
}
movies = collected;
isComplete = true;
rebuildProviderIndex(movies);
await cache.set(CACHE_NAME, movies, userId, getTtl());
await cache.clear(PARTIAL_CACHE_NAME, userId);
setLastDateFetched(new Date().toISOString());
LOG(`Movie crawl complete: ${movies.length} items`);
return movies;
} catch (err) {
ERR('Error fetching movies:', err);
return movies || collected || [];
}
}
/**
* Initializes the cache
* Incremental sync: upsert by MinDateLastSaved, remove orphans via TotalRecordCount + Id list.
*/
async function initialize() {
if (isInitializing) return;
if (movies) return;
isInitializing = true;
try {
movies = await fetchAndCacheMovies();
} finally {
isInitializing = false;
async function syncMoviesCache() {
if (!isComplete || !movies) {
LOG('Skip syncMoviesCache: complete cache not ready');
return movies || [];
}
const lastFetched = getLastDateFetched();
if (!lastFetched) {
LOG('Skip syncMoviesCache: no lastDateFetched');
return movies;
}
}
/**
* Fetches IMDb Top 250 movies using client-side filtering
* @returns {Promise<Array>} - List of matched Movie items
*/
const cache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
const chunkSize = getChunkSize();
try {
// Additions / metadata refreshes
let startIndex = 0;
let incoming = [];
let hasMore = true;
while (hasMore) {
const url = buildItemsUrl({
startIndex,
limit: chunkSize,
extraParams: {
MinDateLastSaved: lastFetched,
MinDateLastSavedForUser: lastFetched,
SortBy: 'DateCreated',
SortOrder: 'Descending'
}
});
const data = await queryItems(url);
const batch = data.Items || [];
incoming = incoming.concat(await stripChunk(batch));
startIndex += batch.length;
if (batch.length < chunkSize) hasMore = false;
}
const beforeIds = new Set((movies || []).map((m) => m.Id).filter(Boolean));
let removedIds = [];
if (incoming.length) {
movies = utils().upsertById?.(movies, incoming) || movies;
LOG(`Synced ${incoming.length} movie upserts`);
}
// Count check
const countUrl = buildItemsUrl({
startIndex: 0,
limit: 1,
extraParams: {
Fields: '',
EnableTotalRecordCount: 'true'
}
});
// Rebuild without heavy Fields for count
const server = window.ApiClient.serverAddress();
const countParams = new URLSearchParams({
IncludeItemTypes: 'Movie',
Recursive: 'true',
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'true',
Limit: '1',
StartIndex: '0'
});
const countData = await queryItems(`${server}/Items?${countParams.toString()}`);
const serverCount = countData.TotalRecordCount;
if (typeof serverCount === 'number' && serverCount !== movies.length) {
LOG(`Count mismatch cache=${movies.length} server=${serverCount}; reconciling ids`);
const serverIds = new Set();
let idx = 0;
let more = true;
while (more) {
const idParams = new URLSearchParams({
IncludeItemTypes: 'Movie',
Recursive: 'true',
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'false',
Limit: String(chunkSize),
StartIndex: String(idx),
Fields: ''
});
const idData = await queryItems(`${server}/Items?${idParams.toString()}`);
const batch = idData.Items || [];
batch.forEach((item) => {
if (item?.Id) serverIds.add(item.Id);
});
idx += batch.length;
if (batch.length < chunkSize) more = false;
}
const before = movies.length;
removedIds = [...beforeIds].filter((id) => !serverIds.has(id));
movies = movies.filter((m) => m?.Id && serverIds.has(m.Id));
// Add any missing ids not in cache (light fetch would lack fields — full upsert via Id list)
const cachedIds = new Set(movies.map((m) => m.Id));
const missing = [...serverIds].filter((id) => !cachedIds.has(id));
if (missing.length) {
WARN(`${missing.length} movies on server missing from cache after reconcile; fetching by Ids`);
for (let i = 0; i < missing.length; i += chunkSize) {
const slice = missing.slice(i, i + chunkSize);
const byIdParams = new URLSearchParams({
Ids: slice.join(','),
Fields: MOVIE_FIELDS,
EnableTotalRecordCount: 'false'
});
const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`);
const stripped = await stripChunk(byIdData.Items || []);
movies = utils().upsertById?.(movies, stripped) || movies;
incoming = incoming.concat(stripped);
}
}
LOG(`Reconcile complete: ${before} → ${movies.length}`);
}
rebuildProviderIndex(movies);
await cache.set(CACHE_NAME, movies, userId, getTtl());
setLastDateFetched(new Date().toISOString());
// Notify people cache of deltas when available
if ((incoming.length || removedIds.length) && window.PeopleCache?.applyMovieDelta) {
try {
await window.PeopleCache.applyMovieDelta(incoming, removedIds);
} catch (e) {
WARN('PeopleCache.applyMovieDelta failed:', e);
}
}
return movies;
} catch (err) {
ERR('syncMoviesCache failed:', err);
return movies || [];
}
}
async function getMovies() {
if (movies && isComplete) return movies;
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
await fetchAndCacheMovies();
return movies || [];
})().finally(() => {
fetchPromise = null;
});
return fetchPromise;
}
function isCacheComplete() {
return isComplete === true && Array.isArray(movies);
}
async function getMoviesByProviderIds(ids) {
const list = await getMovies();
const idSet = new Set();
(ids || []).forEach((id) => {
(utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k));
});
if (!idSet.size) return [];
return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet));
}
async function fetchImdbTop250Data() {
const CACHE_KEY_IDS = 'kefinTweaks_imdbTop250_ids';
let imdbIds = null;
// 1. Get IDs (Local Cache or Fetch GitHub)
try {
const cachedIds = localStorage.getItem(CACHE_KEY_IDS);
if (cachedIds) {
@@ -100,7 +338,9 @@
imdbIds = parsed.ids;
}
}
} catch (e) { WARN('Error reading IMDb Top 250 cache:', e); }
} catch (e) {
WARN('Error reading IMDb Top 250 cache:', e);
}
if (!imdbIds) {
LOG('Fetching IMDb Top 250 list from GitHub...');
@@ -108,15 +348,10 @@
const response = await fetch('https://raw.githubusercontent.com/theapache64/top250/master/top250_min.json');
if (!response.ok) throw new Error('Failed to fetch Top 250 JSON');
const data = await response.json();
// Extract IDs
imdbIds = data.map(entry => {
// entry.imdb_url e.g. "http://www.imdb.com/title/tt0111161/"
imdbIds = data.map((entry) => {
const match = entry.imdb_url && entry.imdb_url.match(/\/title\/(tt\d+)/);
return match ? match[1] : null;
}).filter(Boolean);
// Cache
localStorage.setItem(CACHE_KEY_IDS, JSON.stringify({
timestamp: Date.now(),
ids: imdbIds
@@ -126,61 +361,127 @@
return [];
}
}
if (!imdbIds || imdbIds.length === 0) return [];
return imdbIds;
return imdbIds || [];
}
async function getImdbTop250Movies() {
const cacheName = 'imdbTop250Movies';
const cache = window.IndexedDBCache;
const cached = await cache.get(cacheName);
if (cached) {
return cached;
}
if (cached) return cached;
if (!imdbTop250Movies) {
if (!isCacheComplete()) {
LOG('IMDb Top 250 waiting for movie cache…');
}
const imdbIds = await fetchImdbTop250Data();
const allMovies = await getMovies();
// 3. Client-side Match
const matchedMovies = [];
const idsSet = new Set(imdbIds); // Faster lookup
for (const movie of allMovies) {
if (movie.ProviderIds && movie.ProviderIds.Imdb && idsSet.has(movie.ProviderIds.Imdb)) {
matchedMovies.push(movie);
}
}
imdbTop250Movies = matchedMovies;
const idsSet = new Set(imdbIds);
imdbTop250Movies = allMovies.filter(
(movie) => movie.ProviderIds?.Imdb && idsSet.has(movie.ProviderIds.Imdb)
);
}
const userId = ApiClient.getCurrentUserId();
const userId = window.ApiClient.getCurrentUserId();
await cache.set(cacheName, imdbTop250Movies, userId, IMDB_TOP_250_CACHE_TTL);
return imdbTop250Movies || [];
}
async function getMovies() {
if (!movies) {
movies = await fetchAndCacheMovies();
async function initialize() {
await getMovies();
if (isCacheComplete() && getLastDateFetched()) {
await syncMoviesCache();
}
return movies || [];
}
// Expose API
async function waitForLoginReady(maxWaitMs = 60000) {
const start = Date.now();
while (!window.userHelper?.waitForLogin) {
if (Date.now() - start >= maxWaitMs) {
ERR('Timed out waiting for userHelper');
return false;
}
await new Promise((r) => setTimeout(r, 100));
}
return window.userHelper.waitForLogin(maxWaitMs);
}
function yieldToMain() {
return new Promise((resolve) => {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => resolve(), { timeout: 500 });
} else {
requestAnimationFrame(() => setTimeout(resolve, 0));
}
});
}
let bootstrapStarted = false;
let bootstrapScheduled = false;
async function bootstrap() {
if (bootstrapStarted) return;
bootstrapStarted = true;
try {
await waitForLoginReady();
await initialize();
} catch (e) {
ERR('Bootstrap failed:', e);
}
}
/** Defer crawl until home has painted (or 3s after login) so home getQuery is not starved. */
function scheduleBootstrap() {
if (bootstrapScheduled) return;
bootstrapScheduled = true;
const start = () => { bootstrap(); };
const onPainted = () => {
document.removeEventListener('kefinTweaksHomePainted', onPainted);
start();
};
document.addEventListener('kefinTweaksHomePainted', onPainted);
waitForLoginReady().then((ok) => {
if (!ok) {
document.removeEventListener('kefinTweaksHomePainted', onPainted);
start();
return;
}
setTimeout(() => {
document.removeEventListener('kefinTweaksHomePainted', onPainted);
start();
}, 3000);
});
}
window.MoviesCache = {
init: initialize,
getMovies: getMovies,
getImdbTop250Movies: getImdbTop250Movies,
// Also expose the fetcher if we need to force refresh
getMovies,
getImdbTop250Movies,
getMoviesByProviderIds,
syncMoviesCache,
fetchAndCacheMovies,
isComplete: isCacheComplete,
getLastDateFetched,
bootstrap,
scheduleBootstrap,
refresh: async () => {
const cache = window.IndexedDBCache;
cache.delete('movies_library');
movies = await fetchAndCacheMovies();
return movies;
const userId = window.ApiClient?.getCurrentUserId?.();
if (cache) {
await cache.clear(CACHE_NAME, userId);
await cache.clear(PARTIAL_CACHE_NAME, userId);
}
movies = null;
isComplete = false;
providerIndex = null;
imdbTop250Movies = null;
return fetchAndCacheMovies();
}
};
scheduleBootstrap();
LOG('Module loaded');
})();
+581 -272
View File
@@ -1,5 +1,5 @@
// KefinTweaks People Cache Manager
// Handles fetching, processing, and caching of top actors, directors, and writers
// KefinTweaks People Cache
// Built from MoviesCache + SeriesCache; episode credits via /Shows/{id}/Episodes (not a global episode store).
(function() {
'use strict';
@@ -7,338 +7,647 @@
const WARN = (...args) => console.warn('[KefinTweaks PeopleCache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks PeopleCache]', ...args);
// Configuration
const MIN_PEOPLE_APPEARANCES = 2; // Minimum number of items a person must appear in to be tracked
const CACHE_NAME = 'library_top_people_v2';
const DEFAULT_MIN = 10;
const DEFAULT_PER_TYPE_MAX = 100;
const EPISODE_FETCH_CONCURRENCY = 6;
// State
let moviesTopPeople = null;
let peopleMap = null; // Map<personId, Person>
let filteredLists = null; // { actors, directors, writers }
let isComplete = false;
/** @type {Promise<*>|null} Shared so concurrent callers await the same paginated fetch */
let fetchPromise = null;
/** @type {Promise<void>|null} Shared so concurrent callers await the same build */
let initPromise = null;
/**
* Extracts necessary item data for caching (similar to watchlist format)
*/
function extractItemDataForCache(item) {
function getHomeSettings() {
return window.KefinHomeScreen?.getConfig?.()?.HOME_SETTINGS
|| window.KefinHomeConfig2?.HOME_SETTINGS
|| {};
}
function getMins() {
const home = getHomeSettings();
return {
Id: item.Id,
Type: item.Type,
Name: item.Name,
SeriesId: item.SeriesId,
SeriesName: item.SeriesName,
IndexNumber: item.IndexNumber,
ParentIndexNumber: item.ParentIndexNumber,
PremiereDate: item.PremiereDate,
ProductionYear: item.ProductionYear,
UserData: {
Played: item.UserData?.Played || false,
IsFavorite: item.UserData?.IsFavorite || false
}
total: home.minPeopleAppearancesTotal ?? DEFAULT_MIN,
movies: home.minPeopleAppearancesMovies ?? DEFAULT_MIN,
series: home.minPeopleAppearancesSeries ?? DEFAULT_MIN,
episodes: home.minPeopleAppearancesEpisodes ?? DEFAULT_MIN
};
}
/**
* Processes people data from movies array and returns RAW data (all people, unfiltered)
*/
function processPeopleDataRaw(movies, existingPeopleMap = null) {
try {
const peopleMap = existingPeopleMap ? new Map(existingPeopleMap) : new Map();
movies.forEach(movie => {
// Extract item data for caching
const itemData = extractItemDataForCache(movie);
if (movie.People) {
movie.People.forEach(person => {
const key = person.Id;
if (!peopleMap.has(key)) {
peopleMap.set(key, {
...person,
directorCount: 0,
writerCount: 0,
actorCount: 0,
directorItems: [],
writerItems: [],
actorItems: []
});
}
const personData = peopleMap.get(key);
if (person.Type === 'Director') {
personData.directorCount++;
if (!personData.directorItems.some(item => item.Id === movie.Id)) {
personData.directorItems.push(itemData);
}
} else if (person.Type === 'Writer') {
personData.writerCount++;
if (!personData.writerItems.some(item => item.Id === movie.Id)) {
personData.writerItems.push(itemData);
}
} else if (person.Type === 'Actor') {
personData.actorCount++;
if (!personData.actorItems.some(item => item.Id === movie.Id)) {
personData.actorItems.push(itemData);
}
}
});
function getPerTypeMaxCount() {
const max = getHomeSettings().maxPeopleCount;
const n = parseInt(max, 10);
if (Number.isFinite(n) && n > 0) return Math.min(n, DEFAULT_PER_TYPE_MAX);
return DEFAULT_PER_TYPE_MAX;
}
function shouldLoadPeopleEpisodeData() {
return getHomeSettings().loadPeopleEpisodeData === true;
}
function isAggregateItemType(itemType) {
return itemType == null || itemType === '' || itemType === 'All';
}
function movieRoleCount(person, role) {
const m = person.movies || {};
if (role === 'Actor') return m.actorCount || 0;
if (role === 'Director') return m.directorCount || 0;
if (role === 'Writer') return m.writerCount || 0;
return 0;
}
function episodeRoleCount(person, role) {
const s = person.series || {};
if (role === 'Actor') return s.actorCount || 0;
if (role === 'Director') return s.directorCount || 0;
if (role === 'Writer') return s.writerCount || 0;
return 0;
}
function totalAppearances(person) {
const m = person.movies || {};
const s = person.series || {};
return (m.actorCount || 0)
+ (m.directorCount || 0)
+ (m.writerCount || 0)
+ (s.count || 0)
+ (s.actorCount || 0)
+ (s.directorCount || 0)
+ (s.writerCount || 0);
}
function personMeetsMin(person, mins) {
const m = person.movies || {};
const s = person.series || {};
const episodeMax = Math.max(s.actorCount || 0, s.directorCount || 0, s.writerCount || 0);
return (m.actorCount >= mins.movies)
|| (m.directorCount >= mins.movies)
|| (m.writerCount >= mins.movies)
|| ((s.count || 0) >= mins.series)
|| (episodeMax >= mins.episodes)
|| (totalAppearances(person) >= mins.total);
}
function prunePeopleMap(map) {
if (!map) return map;
const mins = getMins();
const beforeMin = map.size;
for (const [id, person] of map) {
if (!personMeetsMin(person, mins)) map.delete(id);
}
if (beforeMin !== map.size) {
LOG(`People prune (mins): ${beforeMin} → ${map.size}`);
}
const maxPerBucket = getPerTypeMaxCount();
const keepIds = new Set();
const itemTypes = ['Movie', 'Series', 'Episode', null];
const roles = ['Actor', 'Director', 'Writer'];
for (const itemType of itemTypes) {
for (const role of roles) {
filterRoleList(role, itemType).slice(0, maxPerBucket).forEach((p) => keepIds.add(p.Id));
}
}
const beforeMax = map.size;
for (const id of Array.from(map.keys())) {
if (!keepIds.has(id)) map.delete(id);
}
if (beforeMax !== map.size) {
LOG(`People prune (per-type max ${maxPerBucket}): ${beforeMax} → ${map.size}`);
}
return map;
}
function ensurePerson(map, person) {
const key = person?.Id;
if (!key) return null;
if (!map.has(key)) {
map.set(key, {
Id: person.Id,
Name: person.Name,
movies: {
directorCount: 0,
writerCount: 0,
actorCount: 0,
directorItems: [],
writerItems: [],
actorItems: []
},
series: {
count: 0,
items: [],
actorCount: 0,
directorCount: 0,
writerCount: 0
}
});
return peopleMap;
} catch (err) {
ERR('Failed to process people data:', err);
return null;
} else if (person.Name && !map.get(key).Name) {
map.get(key).Name = person.Name;
}
return map.get(key);
}
function pushUniqueId(arr, id) {
if (!id) return false;
if (arr.includes(id)) return false;
arr.push(id);
return true;
}
function bumpEpisodeRole(series, type, delta) {
if (!series || !delta) return;
if (type === 'Director') {
series.directorCount = Math.max(0, (series.directorCount || 0) + delta);
} else if (type === 'Writer') {
series.writerCount = Math.max(0, (series.writerCount || 0) + delta);
} else if (type === 'Actor') {
series.actorCount = Math.max(0, (series.actorCount || 0) + delta);
}
}
function applyMoviePeople(map, movie) {
if (!movie?.People || !movie.Id) return;
movie.People.forEach((person) => {
const personData = ensurePerson(map, person);
if (!personData) return;
const m = personData.movies;
if (person.Type === 'Director') {
if (pushUniqueId(m.directorItems, movie.Id)) m.directorCount = m.directorItems.length;
} else if (person.Type === 'Writer') {
if (pushUniqueId(m.writerItems, movie.Id)) m.writerCount = m.writerItems.length;
} else if (person.Type === 'Actor') {
if (pushUniqueId(m.actorItems, movie.Id)) m.actorCount = m.actorItems.length;
}
});
}
function applySeriesPeople(map, seriesItem) {
if (!seriesItem?.People || !seriesItem.Id) return;
seriesItem.People.forEach((person) => {
const personData = ensurePerson(map, person);
if (!personData) return;
if (pushUniqueId(personData.series.items, seriesItem.Id)) {
personData.series.count = personData.series.items.length;
}
});
}
function applyEpisodePeople(map, seriesId, episode) {
if (!episode?.People || !episode.Id || !seriesId) return;
episode.People.forEach((person) => {
const personData = ensurePerson(map, person);
if (!personData) return;
bumpEpisodeRole(personData.series, person.Type, 1);
});
}
function decrementEpisodePeople(map, episode) {
if (!episode?.People || !map) return;
episode.People.forEach((person) => {
const personData = person?.Id ? map.get(person.Id) : null;
if (!personData) return;
bumpEpisodeRole(personData.series, person.Type, -1);
});
}
function removeMovieFromPeople(map, movieId) {
if (!map || !movieId) return;
map.forEach((person) => {
const m = person.movies;
['directorItems', 'writerItems', 'actorItems'].forEach((key) => {
const before = m[key].length;
m[key] = m[key].filter((id) => id !== movieId);
if (m[key].length !== before) {
if (key === 'directorItems') m.directorCount = m[key].length;
if (key === 'writerItems') m.writerCount = m[key].length;
if (key === 'actorItems') m.actorCount = m[key].length;
}
});
});
}
function removeSeriesMembership(map, seriesId) {
if (!map || !seriesId) return;
map.forEach((person) => {
person.series.items = person.series.items.filter((id) => id !== seriesId);
person.series.count = person.series.items.length;
});
}
async function fetchEpisodesForSeries(seriesId) {
const api = window.apiHelper || window.ApiHelper;
const server = window.ApiClient.serverAddress();
const url = `${server}/Shows/${seriesId}/Episodes?Fields=People&EnableTotalRecordCount=false`;
const data = await api.getQuery(url, { useCache: false });
return data?.Items || [];
}
/**
* Filters and sorts raw people data
* Fetch episodes for many series with limited concurrency.
* @param {Array<{Id: string}|string>} seriesList
* @param {(seriesId: string, episodes: Array) => void} onSeriesEpisodes
*/
function filterPeopleData(peopleMap) {
async function fetchEpisodesForSeriesBatched(seriesList, onSeriesEpisodes) {
const list = (seriesList || [])
.map((s) => (typeof s === 'string' ? { Id: s } : s))
.filter((s) => s?.Id);
let completed = 0;
for (let i = 0; i < list.length; i += EPISODE_FETCH_CONCURRENCY) {
const chunk = list.slice(i, i + EPISODE_FETCH_CONCURRENCY);
await Promise.all(chunk.map(async (s) => {
try {
const episodes = await fetchEpisodesForSeries(s.Id);
onSeriesEpisodes(s.Id, episodes);
} catch (e) {
WARN(`Failed episodes for series ${s.Id}:`, e);
}
}));
completed += chunk.length;
const prev = completed - chunk.length;
if (completed === list.length || Math.floor(prev / 25) !== Math.floor(completed / 25)) {
LOG(`Episode people progress: ${completed}/${list.length} series`);
}
}
}
async function subtractEpisodeCreditsForSeriesIds(map, seriesIds) {
const ids = (seriesIds || []).filter(Boolean);
if (!ids.length || !map) return;
await fetchEpisodesForSeriesBatched(ids, (_seriesId, episodes) => {
episodes.forEach((ep) => decrementEpisodePeople(map, ep));
});
}
function clearEpisodeRoleCounts(map) {
if (!map) return;
map.forEach((person) => {
person.series.actorCount = 0;
person.series.directorCount = 0;
person.series.writerCount = 0;
});
}
async function rebuildAllEpisodeRoleCounts(map) {
clearEpisodeRoleCounts(map);
const seriesList = await window.SeriesCache?.getSeries?.() || [];
await fetchEpisodesForSeriesBatched(seriesList, (seriesId, episodes) => {
episodes.forEach((ep) => applyEpisodePeople(map, seriesId, ep));
});
}
function roleCount(person, role, itemType) {
const movieCount = movieRoleCount(person, role);
const seriesCount = person.series?.count || 0;
const episodeCount = episodeRoleCount(person, role);
if (itemType === 'Movie') return movieCount;
if (itemType === 'Series') return seriesCount;
if (itemType === 'Episode') return episodeCount;
if (isAggregateItemType(itemType)) return movieCount + seriesCount + episodeCount;
return 0;
}
function roleItems(person, role, itemType) {
if (itemType === 'Movie') {
if (role === 'Actor') return person.movies.actorItems || [];
if (role === 'Director') return person.movies.directorItems || [];
if (role === 'Writer') return person.movies.writerItems || [];
return [];
}
if (itemType === 'Series') return person.series.items || [];
if (itemType === 'Episode') return [];
if (isAggregateItemType(itemType)) {
const movieItems = roleItems(person, role, 'Movie');
const seriesItems = person.series.items || [];
return [...movieItems, ...seriesItems];
}
return [];
}
function filterRoleList(role, itemType, minCount) {
if (!peopleMap) return [];
const mins = getMins();
const type = isAggregateItemType(itemType) ? null : itemType;
let threshold = minCount;
if (threshold == null) {
if (type === 'Movie') threshold = mins.movies;
else if (type === 'Series') threshold = mins.series;
else if (type === 'Episode') threshold = mins.episodes;
else threshold = mins.total;
}
return Array.from(peopleMap.values())
.map((person) => {
const count = roleCount(person, role, type);
return {
Id: person.Id,
Name: person.Name,
count,
items: roleItems(person, role, type),
movies: person.movies,
series: person.series
};
})
.filter((p) => p.count >= threshold)
.sort((a, b) => b.count - a.count);
}
function rebuildFilteredLists() {
filteredLists = {
actors: filterRoleList('Actor', null),
directors: filterRoleList('Director', null),
writers: filterRoleList('Writer', null),
isComplete: true
};
}
async function persistPeople() {
const cache = window.IndexedDBCache;
if (!cache || !peopleMap) return;
prunePeopleMap(peopleMap);
const userId = window.ApiClient.getCurrentUserId();
const ttl = window.LibraryCacheUtils?.getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
const payload = {
isComplete: true,
loadPeopleEpisodeData: shouldLoadPeopleEpisodeData(),
peopleData: Array.from(peopleMap.entries())
};
try {
if (!peopleMap) return null;
const allPeople = Array.from(peopleMap.values());
// Get threshold from config or default
const minAppearances = (window.KefinTweaksConfig &&
window.KefinTweaksConfig.homeScreen &&
window.KefinTweaksConfig.homeScreen.discovery &&
window.KefinTweaksConfig.homeScreen.discovery.minPeopleAppearances)
|| MIN_PEOPLE_APPEARANCES;
const topDirectors = allPeople
.filter(person => person.directorCount >= minAppearances)
.sort((a, b) => b.directorCount - a.directorCount)
.map(person => ({
...person,
count: person.directorCount,
items: person.directorItems || []
}));
const topWriters = allPeople
.filter(person => person.writerCount >= minAppearances)
.sort((a, b) => b.writerCount - a.writerCount)
.map(person => ({
...person,
count: person.writerCount,
items: person.writerItems || []
}));
const topActors = allPeople
.filter(person => person.actorCount >= minAppearances)
.sort((a, b) => b.actorCount - a.actorCount)
.map(person => ({
...person,
count: person.actorCount,
items: person.actorItems || []
}));
return {
actors: topActors,
directors: topDirectors,
writers: topWriters
};
} catch (err) {
ERR('Failed to filter people data:', err);
return null;
await cache.set(CACHE_NAME, payload, userId, ttl);
} catch (e) {
WARN('Failed to persist people cache:', e);
}
}
/**
* Fetches and processes top people data from all movies using pagination
*/
async function fetchTopPeople() {
if (fetchPromise) {
return fetchPromise;
}
async function fetchAndCachePeople() {
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
try {
LOG('Starting paginated fetch of movies for people data processing...');
const apiClient = window.ApiClient;
const serverUrl = apiClient.serverAddress();
const userId = apiClient.getCurrentUserId();
const indexedDBCache = window.IndexedDBCache;
LOG('Building people cache from movies + series…');
const movies = await window.MoviesCache.getMovies();
const seriesList = await window.SeriesCache.getSeries();
const map = new Map();
let startIndex = 0;
const limit = 500;
let hasMoreData = true;
let peopleMap = null;
(movies || []).forEach((movie) => applyMoviePeople(map, movie));
(seriesList || []).forEach((s) => applySeriesPeople(map, s));
// Load existing raw people data if available (for resuming)
const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId);
if (existingRawData && !existingRawData.isComplete) {
const moviesProcessed = existingRawData.moviesProcessedCount;
if (moviesProcessed > 0) {
startIndex = moviesProcessed;
peopleMap = new Map(existingRawData.peopleData || []);
LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`);
}
if (shouldLoadPeopleEpisodeData()) {
await fetchEpisodesForSeriesBatched(seriesList, (seriesId, episodes) => {
episodes.forEach((ep) => applyEpisodePeople(map, seriesId, ep));
});
} else {
LOG('Skipping episode people fetch (loadPeopleEpisodeData disabled)');
}
while (hasMoreData) {
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`;
LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`);
const data = await window.apiHelper.getQuery(url);
const movies = (data && data.Items) || [];
if (movies.length === 0) {
hasMoreData = false;
isComplete = true;
} else {
peopleMap = processPeopleDataRaw(movies, peopleMap);
if (!peopleMap) {
throw new Error('processPeopleDataRaw failed');
}
startIndex += movies.length;
// Update global state with partial results for immediate use
const filteredData = filterPeopleData(peopleMap);
if (filteredData) {
moviesTopPeople = filteredData;
}
// Checkpoint raw data (non-fatal if IndexedDB rejects large payloads)
const rawDataToCache = {
isComplete: movies.length < limit,
moviesProcessedCount: startIndex,
peopleData: Array.from(peopleMap.entries())
};
try {
await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000);
} catch (checkpointErr) {
WARN('Failed to checkpoint raw people data (continuing):', checkpointErr);
}
if (movies.length < limit) {
hasMoreData = false;
isComplete = true;
}
}
}
const finalPeopleData = filterPeopleData(peopleMap) || {
actors: [],
directors: [],
writers: []
};
finalPeopleData.isComplete = true;
moviesTopPeople = finalPeopleData;
peopleMap = map;
prunePeopleMap(map);
isComplete = true;
try {
await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
await indexedDBCache.clear('movies_top_people_raw', userId);
} catch (saveErr) {
WARN('Failed to persist complete people cache (in-memory still available):', saveErr);
}
LOG(peopleMap ? 'People cache complete and saved' : 'People cache complete (empty library)');
return finalPeopleData;
rebuildFilteredLists();
await persistPeople();
LOG(`People cache complete: ${peopleMap.size} people`);
return filteredLists;
} catch (err) {
ERR('Failed to fetch top people data:', err);
ERR('fetchAndCachePeople failed:', err);
return null;
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
/**
* Initializes the people cache (awaits build; concurrent callers share one promise)
*/
async function initialize() {
if (moviesTopPeople !== null && isComplete) {
return;
}
if (initPromise) {
return initPromise;
}
if (isComplete && peopleMap) return;
if (initPromise) return initPromise;
initPromise = (async () => {
try {
if (moviesTopPeople !== null && isComplete) {
return;
}
const indexedDBCache = window.IndexedDBCache;
const cache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
// Check for valid complete filtered data
const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId);
if (validComplete) {
const cachedData = await indexedDBCache.get('movies_top_people', userId);
if (cachedData && cachedData.isComplete) {
moviesTopPeople = cachedData;
isComplete = true;
LOG('Loaded complete top people data from IndexedDB');
return;
if (cache && await cache.isCacheValid(CACHE_NAME, userId)) {
const cached = await cache.get(CACHE_NAME, userId);
if (cached?.isComplete && Array.isArray(cached.peopleData)) {
const currentMode = shouldLoadPeopleEpisodeData();
// Legacy caches (no flag) always included episode data
const cachedMode = typeof cached.loadPeopleEpisodeData === 'boolean'
? cached.loadPeopleEpisodeData
: true;
if (cachedMode !== currentMode) {
LOG(`People cache episode mode mismatch (cached=${cachedMode}, current=${currentMode}); rebuilding`);
await cache.clear?.(CACHE_NAME, userId);
} else {
peopleMap = new Map(cached.peopleData);
const before = peopleMap.size;
prunePeopleMap(peopleMap);
isComplete = true;
rebuildFilteredLists();
LOG('Loaded people cache from IndexedDB');
if (peopleMap.size < before) {
await persistPeople();
}
return;
}
}
}
await fetchTopPeople();
await fetchAndCachePeople();
} finally {
initPromise = null;
}
})();
return initPromise;
}
async function getTopPeople() {
LOG('Getting top people data');
try {
if (!moviesTopPeople) {
await initialize();
async function applyMovieDelta(changedMovies, removedIds) {
if (!peopleMap) await initialize();
if (!peopleMap) return;
(removedIds || []).forEach((id) => removeMovieFromPeople(peopleMap, id));
(changedMovies || []).forEach((movie) => {
removeMovieFromPeople(peopleMap, movie.Id);
applyMoviePeople(peopleMap, movie);
});
prunePeopleMap(peopleMap);
rebuildFilteredLists();
await persistPeople();
}
async function applySeriesDelta(changedSeries, removedIds) {
if (!peopleMap) await initialize();
if (!peopleMap) return;
const removed = (removedIds || []).filter(Boolean);
const changed = (changedSeries || []).filter((s) => s?.Id);
const loadEpisodes = shouldLoadPeopleEpisodeData();
if (removed.length) {
removed.forEach((id) => removeSeriesMembership(peopleMap, id));
changed.forEach((s) => {
removeSeriesMembership(peopleMap, s.Id);
applySeriesPeople(peopleMap, s);
});
if (loadEpisodes) {
// Without per-series episode indexes, any removal requires a full episode recount.
await rebuildAllEpisodeRoleCounts(peopleMap);
} else {
clearEpisodeRoleCounts(peopleMap);
}
return moviesTopPeople;
} catch (err) {
ERR('Failed to get top people data:', err);
return null;
} else {
if (loadEpisodes) {
// Changed-only: decrement this series' episode credits, refresh membership, re-apply episodes.
await subtractEpisodeCreditsForSeriesIds(peopleMap, changed.map((s) => s.Id));
}
changed.forEach((s) => {
removeSeriesMembership(peopleMap, s.Id);
applySeriesPeople(peopleMap, s);
});
if (loadEpisodes) {
await fetchEpisodesForSeriesBatched(changed, (seriesId, episodes) => {
episodes.forEach((ep) => applyEpisodePeople(peopleMap, seriesId, ep));
});
}
}
prunePeopleMap(peopleMap);
rebuildFilteredLists();
await persistPeople();
}
async function invalidate() {
LOG('Invalidating people cache (in-memory + IndexedDB)');
peopleMap = null;
filteredLists = null;
isComplete = false;
fetchPromise = null;
initPromise = null;
try {
const cache = window.IndexedDBCache;
const userId = window.ApiClient?.getCurrentUserId?.();
if (cache?.clear) {
await cache.clear(CACHE_NAME, userId);
}
} catch (e) {
WARN('Failed to clear people IDB cache:', e);
}
// Rebuild on next demand; kick off in background if caches are ready
try {
initialize();
} catch (e) {
WARN('People cache rebuild after invalidate failed to start:', e);
}
}
async function getTopActors() {
if (!moviesTopPeople) {
async function getTopPeople() {
if (!filteredLists) await initialize();
return filteredLists;
}
function parseRoleListArgs(itemTypeOrOptions, minCount) {
if (itemTypeOrOptions && typeof itemTypeOrOptions === 'object') {
return {
itemType: itemTypeOrOptions.ItemType || itemTypeOrOptions.itemType || null,
minCount: itemTypeOrOptions.MinCount ?? itemTypeOrOptions.minCount
};
}
return { itemType: itemTypeOrOptions, minCount };
}
async function getTopActors(itemTypeOrOptions = null, minCount) {
if (!peopleMap) await initialize();
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
return filterRoleList('Actor', itemType, min);
}
async function getTopDirectors(itemTypeOrOptions = null, minCount) {
if (!peopleMap) await initialize();
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
return filterRoleList('Director', itemType, min);
}
async function getTopWriters(itemTypeOrOptions = null, minCount) {
if (!peopleMap) await initialize();
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
return filterRoleList('Writer', itemType, min);
}
async function resolveDiscoveryPeople(queryOptions = {}) {
if (!peopleMap) await initialize();
const personType = queryOptions.PersonType || queryOptions.personType || 'Actor';
const itemType = queryOptions.ItemType || queryOptions.itemType || null;
const limit = queryOptions.Limit || 100;
let list = [];
if (personType === 'Director') list = filterRoleList('Director', itemType);
else if (personType === 'Writer') list = filterRoleList('Writer', itemType);
else list = filterRoleList('Actor', itemType);
return list.slice(0, limit).map((p) => ({ Id: p.Id, Name: p.Name, Type: 'Person' }));
}
async function waitForLoginReady(maxWaitMs = 60000) {
const start = Date.now();
while (!window.userHelper?.waitForLogin) {
if (Date.now() - start >= maxWaitMs) {
ERR('Timed out waiting for userHelper');
return false;
}
await new Promise((r) => setTimeout(r, 100));
}
return window.userHelper.waitForLogin(maxWaitMs);
}
let bootstrapStarted = false;
let bootstrapScheduled = false;
async function bootstrap() {
if (bootstrapStarted) return;
bootstrapStarted = true;
try {
await waitForLoginReady();
// Wait for library caches to finish first crawl
if (window.MoviesCache?.getMovies) await window.MoviesCache.getMovies();
if (window.SeriesCache?.getSeries) await window.SeriesCache.getSeries();
await initialize();
} catch (e) {
ERR('Bootstrap failed:', e);
}
return moviesTopPeople?.actors ?? [];
}
async function getTopDirectors() {
if (!moviesTopPeople) {
await initialize();
}
return moviesTopPeople?.directors ?? [];
function scheduleBootstrap() {
if (bootstrapScheduled) return;
bootstrapScheduled = true;
const start = () => { bootstrap(); };
const onPainted = () => {
document.removeEventListener('kefinTweaksHomePainted', onPainted);
start();
};
document.addEventListener('kefinTweaksHomePainted', onPainted);
waitForLoginReady().then((ok) => {
if (!ok) {
document.removeEventListener('kefinTweaksHomePainted', onPainted);
start();
return;
}
setTimeout(() => {
document.removeEventListener('kefinTweaksHomePainted', onPainted);
start();
}, 3000);
});
}
async function getTopWriters() {
if (!moviesTopPeople) {
await initialize();
}
return moviesTopPeople?.writers ?? [];
}
function isCacheComplete() {
return isComplete;
}
// Expose API
window.PeopleCache = {
init: initialize,
getTopPeople: getTopPeople,
getTopActors: getTopActors,
getTopDirectors: getTopDirectors,
getTopWriters: getTopWriters,
isComplete: isCacheComplete
fetchAndCachePeople,
getTopPeople,
getTopActors,
getTopDirectors,
getTopWriters,
resolveDiscoveryPeople,
applyMovieDelta,
applySeriesDelta,
invalidate,
isComplete: () => isComplete === true,
bootstrap,
scheduleBootstrap
};
scheduleBootstrap();
LOG('Module loaded');
})();
+1 -1
View File
@@ -78,7 +78,7 @@
// Only add button to cards with data-positionticks (in progress items)
const positionTicks = card.getAttribute('data-positionticks');
if (!positionTicks) {
if (!positionTicks || positionTicks === '0') {
return; // Not a continue watching item, skip
}
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
// KefinTweaks Series Library Cache
// Chunked per-user archive of series (no UserData / MediaStreams / Chapters / Trickplay).
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Series Cache]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks Series Cache]', ...args);
const ERR = (...args) => console.error('[KefinTweaks Series Cache]', ...args);
const CACHE_NAME = 'series_library';
const PARTIAL_CACHE_NAME = 'series_library_partial';
const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedSeriesCache_';
const SERIES_FIELDS = [
'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview',
'PrimaryImageAspectRatio', 'ChildCount', 'RecursiveItemCount'
].join(',');
let series = null;
let isComplete = false;
let fetchPromise = null;
function utils() {
return window.LibraryCacheUtils || {};
}
function getTtl() {
return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
}
function getChunkSize() {
return utils().getLibraryCacheSettings?.().seriesChunkSize || 1000;
}
function lastFetchStorageKey() {
const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous';
return `${LAST_FETCH_KEY_PREFIX}${userId}`;
}
function getLastDateFetched() {
try {
return localStorage.getItem(lastFetchStorageKey()) || null;
} catch (_) {
return null;
}
}
function setLastDateFetched(iso) {
try {
localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString());
} catch (e) {
WARN('Failed to store lastDateFetchedSeriesCache:', e);
}
}
function getApiHelper() {
return window.apiHelper || window.ApiHelper;
}
function buildItemsUrl({ startIndex, limit, extraParams = {} }) {
const server = window.ApiClient.serverAddress();
const params = new URLSearchParams({
IncludeItemTypes: 'Series',
Recursive: 'true',
Fields: SERIES_FIELDS,
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'true',
SortBy: 'SortName',
SortOrder: 'Ascending',
StartIndex: String(startIndex || 0),
Limit: String(limit)
});
Object.entries(extraParams).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') params.set(k, String(v));
});
return `${server}/Items?${params.toString()}`;
}
async function queryItems(url) {
const api = getApiHelper();
if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable');
return (await api.getQuery(url, { useCache: false })) || { Items: [], TotalRecordCount: 0 };
}
async function stripChunk(items) {
if (utils().stripItemsAsync) {
return utils().stripItemsAsync(items, 'Series');
}
return (items || []).map((item) => utils().stripSeriesForCache?.(item) || item).filter(Boolean);
}
async function fetchAndCacheSeries() {
const cache = window.IndexedDBCache;
if (!cache) {
ERR('IndexedDBCache unavailable');
return [];
}
const userId = window.ApiClient.getCurrentUserId();
const complete = await cache.get(CACHE_NAME, userId);
if (complete && Array.isArray(complete)) {
series = complete;
isComplete = true;
LOG(`Using complete cached series (${series.length})`);
return series;
}
const chunkSize = getChunkSize();
let startIndex = 0;
let collected = [];
const partial = await cache.get(PARTIAL_CACHE_NAME, userId);
if (partial && Array.isArray(partial.items) && partial.isComplete !== true) {
collected = partial.items;
startIndex = partial.startIndex || collected.length;
LOG(`Resuming series crawl from index ${startIndex}`);
}
try {
let hasMore = true;
while (hasMore) {
const data = await queryItems(buildItemsUrl({ startIndex, limit: chunkSize }));
const batch = data.Items || [];
collected = collected.concat(await stripChunk(batch));
startIndex += batch.length;
await cache.set(PARTIAL_CACHE_NAME, {
isComplete: false,
startIndex,
items: collected
}, userId, getTtl());
LOG(`Series crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`);
if (batch.length < chunkSize) hasMore = false;
else await yieldToMain();
}
series = collected;
isComplete = true;
await cache.set(CACHE_NAME, series, userId, getTtl());
await cache.clear(PARTIAL_CACHE_NAME, userId);
setLastDateFetched(new Date().toISOString());
LOG(`Series crawl complete: ${series.length} items`);
return series;
} catch (err) {
ERR('Error fetching series:', err);
return series || collected || [];
}
}
async function syncSeriesCache() {
if (!isComplete || !series) {
LOG('Skip syncSeriesCache: complete cache not ready');
return series || [];
}
const lastFetched = getLastDateFetched();
if (!lastFetched) return series;
const cache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
const chunkSize = getChunkSize();
const server = window.ApiClient.serverAddress();
try {
let startIndex = 0;
let incoming = [];
let hasMore = true;
while (hasMore) {
const data = await queryItems(buildItemsUrl({
startIndex,
limit: chunkSize,
extraParams: {
MinDateLastSaved: lastFetched,
MinDateLastSavedForUser: lastFetched,
SortBy: 'DateLastContentAdded',
SortOrder: 'Descending'
}
}));
const batch = data.Items || [];
incoming = incoming.concat(await stripChunk(batch));
startIndex += batch.length;
if (batch.length < chunkSize) hasMore = false;
}
const beforeIds = new Set((series || []).map((s) => s.Id).filter(Boolean));
let removedIds = [];
if (incoming.length) {
series = utils().upsertById?.(series, incoming) || series;
LOG(`Synced ${incoming.length} series upserts`);
}
const countParams = new URLSearchParams({
IncludeItemTypes: 'Series',
Recursive: 'true',
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'true',
Limit: '1',
StartIndex: '0'
});
const countData = await queryItems(`${server}/Items?${countParams.toString()}`);
const serverCount = countData.TotalRecordCount;
if (typeof serverCount === 'number' && serverCount !== series.length) {
LOG(`Series count mismatch cache=${series.length} server=${serverCount}`);
const serverIds = new Set();
let idx = 0;
let more = true;
while (more) {
const idParams = new URLSearchParams({
IncludeItemTypes: 'Series',
Recursive: 'true',
ExcludeLocationTypes: 'Virtual',
EnableTotalRecordCount: 'false',
Limit: String(chunkSize),
StartIndex: String(idx)
});
const idData = await queryItems(`${server}/Items?${idParams.toString()}`);
const batch = idData.Items || [];
batch.forEach((item) => {
if (item?.Id) serverIds.add(item.Id);
});
idx += batch.length;
if (batch.length < chunkSize) more = false;
}
removedIds = [...beforeIds].filter((id) => !serverIds.has(id));
series = series.filter((s) => s?.Id && serverIds.has(s.Id));
const cachedIds = new Set(series.map((s) => s.Id));
const missing = [...serverIds].filter((id) => !cachedIds.has(id));
for (let i = 0; i < missing.length; i += chunkSize) {
const slice = missing.slice(i, i + chunkSize);
const byIdParams = new URLSearchParams({
Ids: slice.join(','),
Fields: SERIES_FIELDS,
EnableTotalRecordCount: 'false'
});
const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`);
const stripped = await stripChunk(byIdData.Items || []);
series = utils().upsertById?.(series, stripped) || series;
incoming = incoming.concat(stripped);
}
}
await cache.set(CACHE_NAME, series, userId, getTtl());
setLastDateFetched(new Date().toISOString());
if ((incoming.length || removedIds.length) && window.PeopleCache?.applySeriesDelta) {
try {
await window.PeopleCache.applySeriesDelta(incoming, removedIds);
} catch (e) {
WARN('PeopleCache.applySeriesDelta failed:', e);
}
}
return series;
} catch (err) {
ERR('syncSeriesCache failed:', err);
return series || [];
}
}
async function getSeries() {
if (series && isComplete) return series;
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
await fetchAndCacheSeries();
return series || [];
})().finally(() => {
fetchPromise = null;
});
return fetchPromise;
}
async function getCompletedSeries() {
const list = await getSeries();
return (list || []).filter(item => item?.UserData?.Played === true && Number(item.UserData.PlayedPercentage) === 100);
}
function isCacheComplete() {
return isComplete === true && Array.isArray(series);
}
async function getSeriesByProviderIds(ids) {
const list = await getSeries();
const idSet = new Set();
(ids || []).forEach((id) => {
(utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k));
});
if (!idSet.size) return [];
return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet));
}
async function initialize() {
await getSeries();
if (isCacheComplete() && getLastDateFetched()) {
await syncSeriesCache();
}
return series || [];
}
async function waitForLoginReady(maxWaitMs = 60000) {
const start = Date.now();
while (!window.userHelper?.waitForLogin) {
if (Date.now() - start >= maxWaitMs) {
ERR('Timed out waiting for userHelper');
return false;
}
await new Promise((r) => setTimeout(r, 100));
}
return window.userHelper.waitForLogin(maxWaitMs);
}
function yieldToMain() {
return new Promise((resolve) => {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => resolve(), { timeout: 500 });
} else {
requestAnimationFrame(() => setTimeout(resolve, 0));
}
});
}
let bootstrapStarted = false;
let bootstrapScheduled = false;
async function bootstrap() {
if (bootstrapStarted) return;
bootstrapStarted = true;
try {
await waitForLoginReady();
await initialize();
} catch (e) {
ERR('Bootstrap failed:', e);
}
}
function scheduleBootstrap() {
if (bootstrapScheduled) return;
bootstrapScheduled = true;
const start = () => { bootstrap(); };
const onPainted = () => {
document.removeEventListener('kefinTweaksHomePainted', onPainted);
start();
};
document.addEventListener('kefinTweaksHomePainted', onPainted);
waitForLoginReady().then((ok) => {
if (!ok) {
document.removeEventListener('kefinTweaksHomePainted', onPainted);
start();
return;
}
setTimeout(() => {
document.removeEventListener('kefinTweaksHomePainted', onPainted);
start();
}, 3000);
});
}
window.SeriesCache = {
init: initialize,
getSeries,
getCompletedSeries,
getSeriesByProviderIds,
syncSeriesCache,
fetchAndCacheSeries,
isComplete: isCacheComplete,
getLastDateFetched,
bootstrap,
scheduleBootstrap,
refresh: async () => {
const cache = window.IndexedDBCache;
const userId = window.ApiClient?.getCurrentUserId?.();
if (cache) {
await cache.clear(CACHE_NAME, userId);
await cache.clear(PARTIAL_CACHE_NAME, userId);
}
series = null;
isComplete = false;
return fetchAndCacheSeries();
}
};
scheduleBootstrap();
LOG('Module loaded');
})();
+4 -49
View File
@@ -20,60 +20,15 @@
const THEME_STORAGE_KEY = 'kefinTweaks_selectedTheme';
const COLOR_SCHEMES_STORAGE_KEY = 'kefinTweaks_selectedColorScheme';
// Cached server version for synchronous access
// Cached server version for synchronous access — prefer window.KefinTweaks
let cachedServerVersion = null;
function getMajorServerVersion(version) {
const versionParts = version.split('.');
if (versionParts[0] !== '10') {
const majorVersion = parseInt(versionParts[0], 10);
return majorVersion;
}
if (versionParts.length >= 2) {
const majorVersion = parseInt(versionParts[1], 10);
if (!isNaN(majorVersion)) {
return majorVersion;
}
}
return null;
}
/**
* Get the current major server version from ApiClient
* Polls every 500ms for up to 5 seconds in the background if not immediately available
* @returns {number|null} The major version number (e.g., 10 for "10.10.X", 11 for "10.11.X"), or null if unavailable
*/
async function getCurrentMajorServerVersion() {
try {
// If we have a cached value, return it
if (cachedServerVersion !== null) {
try {
if (window.KefinTweaks?.getJellyfinMajorVersion) {
cachedServerVersion = await window.KefinTweaks.getJellyfinMajorVersion();
return cachedServerVersion;
}
if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) {
return null;
}
if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {
cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion);
return cachedServerVersion;
}
// Check the server version instead of app version
if (!window.ApiClient._serverVersion) {
// Wait 10s to see if it becomes ready, check every 500ms
const startTime = Date.now();
while (Date.now() - startTime < 10000) {
if (window.ApiClient._serverVersion) {
break;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion);
return cachedServerVersion;
} catch (error) {
WARN('Error getting server version:', error);
+18 -3
View File
@@ -57,12 +57,18 @@
* API Helper functions for Jellyfin operations
*/
const userHelper = {
getUserDisplayPreferences: async function() {
/**
* Get current-user display preferences.
* @param {{ cacheOnly?: boolean }} [options]
* @returns {Promise<{ promise: Promise<object>, cached: object|null }>}
*/
getUserDisplayPreferences: async function(options = {}) {
ensureApiClient();
ensureLoggedIn();
const cacheOnly = options.cacheOnly === true;
let cachedDisplayPreferences = null;
if (__userDisplayPreferences) {
cachedDisplayPreferences = __userDisplayPreferences;
} else if (window.LocalStorageCache) {
@@ -70,9 +76,18 @@
const displayPreferencesData = cache.get('userDisplayPreferences');
if (displayPreferencesData) {
cachedDisplayPreferences = displayPreferencesData;
// Hydrate memory so later cacheOnly calls skip localStorage too
__userDisplayPreferences = displayPreferencesData;
}
}
if (cacheOnly && cachedDisplayPreferences) {
return {
promise: Promise.resolve(cachedDisplayPreferences),
cached: cachedDisplayPreferences
};
}
// Wrap this in a promise to return with the cached data
const userDisplayPreferencesPromise = new Promise(async (resolve, reject) => {
try {
@@ -286,7 +301,7 @@
waitForLogin: waitForLogin
};
// TODO -- Update this whenever the user display preferences are updated
// Updated by getUserDisplayPreferences / updateDisplayPreferencesForUser({ updateCache: true })
let __userDisplayPreferences = null;
// Expose userHelper to global window object
+190 -21
View File
@@ -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;
}
};
+3 -3
View File
@@ -1882,10 +1882,10 @@ div[data-tab="watchlist"] .loading-message {
}
.card:has(button[data-played="true"]) .watchlist-button,
.card:not(:has(button[data-played])) .watchlist-button,
.card:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .watchlist-button,
.mainDetailButtons:has(button[data-played="true"]) .watchlist-icon,
.mainDetailButtons:not(:has(button[data-played])) .watchlist-icon,
.mainDetailButtons:not(:has(button[data-played])) .btnRecommend {
.mainDetailButtons:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .watchlist-icon,
.mainDetailButtons:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .btnRecommend {
display: none;
}
+206 -128
View File
@@ -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