mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Anotha one!
This commit is contained in:
@@ -32,6 +32,10 @@ interface HomeScreenSection {
|
||||
userConfigurable?: boolean; // Whether users can toggle/reorder this section
|
||||
useGaplessCards?: boolean; // When true, remove gaps between cards (row or grid)
|
||||
|
||||
// Loads items from an external URL and loads matches from that list from your Jellyfin library
|
||||
// Currently supports: mdblist.com only
|
||||
externalListUrls?: string[];
|
||||
|
||||
// Multi-Query (only used when queries.length > 1)
|
||||
sortBy?: string; // Field to sort merged results by (e.g., 'DatePlayed', 'DateCreated')
|
||||
sortOrder?: "Ascending" | "Descending"; // Sort direction for merged results
|
||||
@@ -62,7 +66,13 @@ interface HomeScreenSection {
|
||||
isCustom?: boolean;
|
||||
/** How a discovery section resolves dynamically: Person, Genre, Studio, Similar, Collection, … */
|
||||
discoveryType?: string;
|
||||
discoveryItemType?: string;
|
||||
discoveryPersonType?: string;
|
||||
source?: string;
|
||||
|
||||
/** Pool query used to resolve a dynamic discovery entity (optional on non-discovery sections). */
|
||||
discoverySourceQuery?: Query;
|
||||
|
||||
/** Runtime stamp for custom discovery paging (1..N per custom group); not a persisted admin field. */
|
||||
pageNumber?: number;
|
||||
|
||||
|
||||
@@ -1105,17 +1105,12 @@
|
||||
}
|
||||
|
||||
try {
|
||||
let dataPromise = cache[methodName]();
|
||||
let dataPromise = Promise.resolve(cache[methodName](options));
|
||||
dataPromise = dataPromise.then(data => {
|
||||
if (!Array.isArray(data)) {
|
||||
data = [];
|
||||
}
|
||||
|
||||
// Filter studios/networks with no thumb
|
||||
if (cacheName === 'StudiosCache' && window.KefinHomeScreem?.getConfig()?.HOME_SETTINGS?.ensureThumbsForPopularTVNetworks === true) {
|
||||
data = data.filter(item => item.ImageTags?.Thumb);
|
||||
}
|
||||
|
||||
// Apply options (sorting, limits, etc.)
|
||||
if (options.SortBy === 'Random') {
|
||||
data = data.sort(() => Math.random() - 0.5);
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.kefinTweaks-breadcrumbs-wrapper {
|
||||
display: block;
|
||||
@@ -15,6 +21,11 @@
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
|
||||
.MuiToolbar-root .kefinTweaks-breadcrumbs-wrapper:hover {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.kefinTweaks-breadcrumbs {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 1.17em;
|
||||
@@ -27,6 +38,16 @@
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.MuiToolbar-root .kefinTweaks-breadcrumbs,
|
||||
.kefinTweaks-breadcrumbs-wrapper--modern .kefinTweaks-breadcrumbs {
|
||||
font-size: 1em;
|
||||
padding: 0px 12px 8px;
|
||||
}
|
||||
|
||||
.MuiToolbar-root .kefinTweaks-popover {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.kefinTweaks-popover-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
||||
+54
-9
@@ -471,15 +471,28 @@
|
||||
}
|
||||
|
||||
// DOM manipulation
|
||||
function isModernUI() {
|
||||
return !!document.querySelector('.MuiBox-root');
|
||||
}
|
||||
|
||||
function resolveBreadcrumbMount() {
|
||||
if (isModernUI()) {
|
||||
return document.querySelector('.MuiToolbar-root');
|
||||
}
|
||||
return document.querySelector('.skinHeader .headerLeft');
|
||||
}
|
||||
|
||||
function createBreadcrumbContainer() {
|
||||
if (breadcrumbContainer) {
|
||||
return breadcrumbContainer;
|
||||
}
|
||||
|
||||
// Find the header left container
|
||||
const headerLeft = document.querySelector('.skinHeader .headerLeft');
|
||||
if (!headerLeft) {
|
||||
error('Could not find .skinHeader .headerLeft element');
|
||||
|
||||
const isModern = isModernUI();
|
||||
const mount = resolveBreadcrumbMount();
|
||||
if (!mount) {
|
||||
error(isModern
|
||||
? 'Could not find .MuiToolbar-root for breadcrumbs'
|
||||
: 'Could not find .skinHeader .headerLeft element');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -487,6 +500,9 @@
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'kefinTweaks-breadcrumbs-wrapper';
|
||||
wrapper.className = 'kefinTweaks-breadcrumbs-wrapper';
|
||||
if (isModern) {
|
||||
wrapper.classList.add('kefinTweaks-breadcrumbs-wrapper--modern');
|
||||
}
|
||||
wrapper.style.cssText = `
|
||||
display: none;
|
||||
`;
|
||||
@@ -509,11 +525,36 @@
|
||||
|
||||
wrapper.appendChild(breadcrumbContainer);
|
||||
wrapper.appendChild(popoverContainer);
|
||||
headerLeft.appendChild(wrapper);
|
||||
mount.appendChild(wrapper);
|
||||
|
||||
log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
|
||||
log(isModern
|
||||
? 'Created breadcrumb container inside .MuiToolbar-root'
|
||||
: 'Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
|
||||
return breadcrumbContainer;
|
||||
}
|
||||
|
||||
function ensureBreadcrumbContainer(retries = 20) {
|
||||
const existing = createBreadcrumbContainer();
|
||||
if (existing) return Promise.resolve(existing);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
const tryMount = () => {
|
||||
const container = createBreadcrumbContainer();
|
||||
if (container) {
|
||||
resolve(container);
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts >= retries) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
setTimeout(tryMount, 100);
|
||||
};
|
||||
tryMount();
|
||||
});
|
||||
}
|
||||
|
||||
function showBreadcrumbs() {
|
||||
const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
|
||||
@@ -864,9 +905,13 @@
|
||||
|
||||
// Create breadcrumbs from scratch
|
||||
async function createBreadcrumbsFromScratch(targetStructure) {
|
||||
// Create container if it doesn't exist
|
||||
// Create container if it doesn't exist (retry briefly on ModernUI if toolbar not ready)
|
||||
if (!breadcrumbContainer) {
|
||||
breadcrumbContainer = createBreadcrumbContainer();
|
||||
breadcrumbContainer = await ensureBreadcrumbContainer();
|
||||
}
|
||||
if (!breadcrumbContainer) {
|
||||
error('Breadcrumb container unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build all elements first
|
||||
|
||||
+32
-3
@@ -968,8 +968,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
margin-bottom: 45px !important;
|
||||
.skeleton-text-line {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.skeleton-section-title {
|
||||
width: 50%;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.skeleton-section-caption {
|
||||
width: 35%;
|
||||
min-width: 6rem;
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
|
||||
/* Tagline always 1 line - Movie-specific override kept for any edge cases */
|
||||
@@ -1293,7 +1310,7 @@
|
||||
}
|
||||
|
||||
.sectionTitle.sectionCaption {
|
||||
font-size: 0.9em;
|
||||
font-size: 0.9em !important;
|
||||
text-align: left;
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
@@ -2035,6 +2052,18 @@
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Skeletons need a visible padder even when Logo/Clear Art/Disc force transparent cards */
|
||||
.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardPadder,
|
||||
.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardScalable,
|
||||
.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardPadder,
|
||||
.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardScalable,
|
||||
.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardPadder,
|
||||
.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardScalable,
|
||||
.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardPadder,
|
||||
.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardScalable {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
|
||||
.emby-scroller-container[data-card-format="Logo"] .cardImageContainer,
|
||||
.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer,
|
||||
.emby-scroller-container[data-section-id="liveTv-channels"] .cardImageContainer {
|
||||
|
||||
+495
-29
@@ -810,6 +810,16 @@
|
||||
function postProcessItems(sectionConfig, itemsData) {
|
||||
let processed = itemsData?.Items || itemsData || [];
|
||||
|
||||
//Check if imageTypes is used in the query to ensure only items with these image types are included
|
||||
const imageTypes = sectionConfig.queries?.[0]?.queryOptions?.ImageTypes;
|
||||
if (imageTypes && Array.isArray(imageTypes)) {
|
||||
// Filter out items which do not have any of the image types
|
||||
processed = processed.filter(item => {
|
||||
const itemImageTypes = imageTypes.filter(imageType => item.ImageTags?.[imageType] || (imageType === 'Backdrop' && item.BackdropImageTags?.[0]));
|
||||
return itemImageTypes.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// When ParentId is on the query (e.g. Popular Genres per library), assign it to each item for genre card links
|
||||
const queryParentId = sectionConfig.queries?.[0]?.ParentId;
|
||||
if (queryParentId && Array.isArray(processed)) {
|
||||
@@ -1348,6 +1358,8 @@
|
||||
if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
|
||||
const spotlightSettings = { ...(sectionConfig.spotlightConfig || {}) };
|
||||
if (activeViewMoreUrl) spotlightSettings.viewMoreUrl = activeViewMoreUrl;
|
||||
spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
|
||||
spotlightSettings.caption = sectionConfig.caption;
|
||||
skeleton = createSkeletonSpotlightSection(displayTitle, spotlightSettings);
|
||||
} else {
|
||||
skeleton = createProgressivelyEnhancedScrollableContainer(
|
||||
@@ -1744,11 +1756,30 @@
|
||||
});
|
||||
}
|
||||
|
||||
function updateCardWatchlistButtons(itemId, userData) {
|
||||
if (!itemId || !userData || !('Likes' in userData)) return;
|
||||
|
||||
const isLiked = userData.Likes === true;
|
||||
const likedHelper = window.KefinWatchlistLikedIds;
|
||||
if (likedHelper?.set) {
|
||||
likedHelper.set(itemId, isLiked);
|
||||
}
|
||||
|
||||
const escapedId = typeof CSS !== 'undefined' && CSS.escape
|
||||
? CSS.escape(itemId)
|
||||
: itemId.replace(/["\\]/g, '\\$&');
|
||||
document.querySelectorAll(`.watchlist-button[data-id="${escapedId}"]`).forEach((button) => {
|
||||
button.dataset.active = isLiked ? 'true' : 'false';
|
||||
button.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
|
||||
});
|
||||
}
|
||||
|
||||
function applyUserDataEntriesToCards(entries, userId) {
|
||||
if (!isPrimaryUserDataChange(userId)) return;
|
||||
entries.forEach((entry) => {
|
||||
if (entry?.ItemId) {
|
||||
updateCardResumeAttributes(entry.ItemId, entry);
|
||||
updateCardWatchlistButtons(entry.ItemId, entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2018,7 +2049,7 @@
|
||||
// If the section has items, it is a static query
|
||||
const isStaticQuery = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0;
|
||||
|
||||
const renderCachedData = isStaticQuery || (!section.result?.isStale && !isRandomQuerySort) || (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'));
|
||||
const renderCachedData = (isStaticQuery || (!section.result?.isStale && !isRandomQuerySort) || (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'))) && dataItems.length > 0;
|
||||
|
||||
// Paint cached cards when valid, or when stale display is opted in
|
||||
if (renderCachedData) {
|
||||
@@ -2070,6 +2101,8 @@
|
||||
if (getActiveViewMoreUrl(sectionConfig)) {
|
||||
spotlightSettings.viewMoreUrl = getActiveViewMoreUrl(sectionConfig);
|
||||
}
|
||||
spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
|
||||
spotlightSettings.caption = sectionConfig.caption;
|
||||
sectionElement = createSkeletonSpotlightSection(getSectionTitleForMultiQuery(sectionConfig), spotlightSettings);
|
||||
} else {
|
||||
sectionElement = createProgressivelyEnhancedScrollableContainer(getSectionTitleForMultiQuery(sectionConfig), getActiveViewMoreUrl(sectionConfig), finalCardFormat, sectionConfig.overflowCard, sectionConfig);
|
||||
@@ -2118,7 +2151,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
container.appendChild(fragment);
|
||||
// Append one section per frame so scroll/compositor get frames between paints.
|
||||
// Build already happened in the fragment; only the container append is paced.
|
||||
const sectionNodes = Array.from(fragment.childNodes);
|
||||
for (let i = 0; i < sectionNodes.length; i++) {
|
||||
container.appendChild(sectionNodes[i]);
|
||||
if (i + 1 < sectionNodes.length) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
syncAttachedGridTilesLayouts(container);
|
||||
|
||||
// Update the scroll buttons for all scrollable containers
|
||||
@@ -2527,16 +2568,276 @@
|
||||
if (!canvas || !blurhashString) return false;
|
||||
const w = canvas.width || 20;
|
||||
const h = canvas.height || 20;
|
||||
const pixels = blurhashDecode(blurhashString, w, h);
|
||||
const cacheKey = blurhashCacheKey(blurhashString, w, h);
|
||||
let pixels = blurhashCacheGet(cacheKey);
|
||||
if (!pixels) {
|
||||
pixels = blurhashDecode(blurhashString, w, h);
|
||||
if (pixels) blurhashCacheSet(cacheKey, pixels);
|
||||
}
|
||||
if (!pixels) return false;
|
||||
return applyBlurhashPixelsToCanvas(canvas, pixels);
|
||||
}
|
||||
|
||||
// --- Deferred blurhash: cache + worker + near-viewport filler ---
|
||||
const BLURHASH_CACHE_MAX = 200;
|
||||
const blurhashPixelCache = new Map();
|
||||
let blurhashFillObserver = null;
|
||||
let blurhashWorker = null;
|
||||
let blurhashWorkerReqId = 0;
|
||||
const blurhashWorkerPending = new Map();
|
||||
const blurhashIdleQueue = [];
|
||||
let blurhashIdleScheduled = false;
|
||||
|
||||
function blurhashCacheKey(blurhash, width, height) {
|
||||
return blurhash + '|' + width + 'x' + height;
|
||||
}
|
||||
|
||||
function blurhashCacheGet(key) {
|
||||
if (!blurhashPixelCache.has(key)) return null;
|
||||
const val = blurhashPixelCache.get(key);
|
||||
blurhashPixelCache.delete(key);
|
||||
blurhashPixelCache.set(key, val);
|
||||
return val;
|
||||
}
|
||||
|
||||
function blurhashCacheSet(key, pixels) {
|
||||
if (blurhashPixelCache.has(key)) blurhashPixelCache.delete(key);
|
||||
blurhashPixelCache.set(key, pixels);
|
||||
while (blurhashPixelCache.size > BLURHASH_CACHE_MAX) {
|
||||
const oldest = blurhashPixelCache.keys().next().value;
|
||||
blurhashPixelCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipBlurhashDecode() {
|
||||
try {
|
||||
if (navigator.connection && navigator.connection.saveData === true) return true;
|
||||
} catch (e) { /* ignore */ }
|
||||
try {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true;
|
||||
} catch (e) { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
function getBlurhashFillRootMargin() {
|
||||
return isMobileLayout() ? '120px' : '200px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Put pixels on canvas and show blurhash until the real image loads.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function applyBlurhashPixelsToCanvas(canvas, pixels) {
|
||||
if (!canvas || !pixels || !canvas.isConnected) return false;
|
||||
const w = canvas.width || 20;
|
||||
const h = canvas.height || 20;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return false;
|
||||
|
||||
const imageData = ctx.createImageData(w, h);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
|
||||
const cardImageContainer = canvas.nextElementSibling;
|
||||
const imageAlreadyLoaded = cardImageContainer
|
||||
&& cardImageContainer.classList
|
||||
&& cardImageContainer.classList.contains('lazy-loaded');
|
||||
|
||||
if (imageAlreadyLoaded) {
|
||||
canvas.classList.add('lazy-hidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
canvas.classList.remove('lazy-hidden');
|
||||
if (cardImageContainer && cardImageContainer.classList.contains('cardImageContainer')) {
|
||||
cardImageContainer.classList.add('lazy-hidden');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getBlurhashWorkerSource() {
|
||||
return [
|
||||
'const BLURHASH_B83 = ' + JSON.stringify(BLURHASH_B83) + ';',
|
||||
blurhashDecode83.toString(),
|
||||
blurhashSignPow.toString(),
|
||||
blurhashLinearToSrgb.toString(),
|
||||
blurhashSrgbToLinear.toString(),
|
||||
blurhashDecode.toString(),
|
||||
'self.onmessage = function(e) {',
|
||||
' var data = e.data || {};',
|
||||
' var id = data.id;',
|
||||
' try {',
|
||||
' var pixels = blurhashDecode(data.blurhash, data.width || 20, data.height || 20);',
|
||||
' if (!pixels) { self.postMessage({ id: id, ok: false }); return; }',
|
||||
' self.postMessage({ id: id, ok: true, pixels: pixels.buffer }, [pixels.buffer]);',
|
||||
' } catch (err) {',
|
||||
' self.postMessage({ id: id, ok: false, error: String(err && err.message || err) });',
|
||||
' }',
|
||||
'};'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ensureBlurhashWorker() {
|
||||
if (blurhashWorker) return blurhashWorker;
|
||||
if (typeof Worker === 'undefined') return null;
|
||||
try {
|
||||
const blob = new Blob([getBlurhashWorkerSource()], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const worker = new Worker(url);
|
||||
worker.onmessage = (e) => {
|
||||
const data = e.data || {};
|
||||
const pending = blurhashWorkerPending.get(data.id);
|
||||
blurhashWorkerPending.delete(data.id);
|
||||
if (!pending) return;
|
||||
if (!data.ok || !data.pixels) {
|
||||
pending.reject(new Error(data.error || 'Blurhash worker decode failed'));
|
||||
return;
|
||||
}
|
||||
pending.resolve(new Uint8ClampedArray(data.pixels));
|
||||
};
|
||||
worker.onerror = (err) => {
|
||||
console.warn('[KefinTweaks CardBuilder] Blurhash worker error:', err);
|
||||
blurhashWorkerPending.forEach((p) => p.reject(err));
|
||||
blurhashWorkerPending.clear();
|
||||
try { worker.terminate(); } catch (e) { /* ignore */ }
|
||||
try { URL.revokeObjectURL(url); } catch (e) { /* ignore */ }
|
||||
blurhashWorker = null;
|
||||
};
|
||||
blurhashWorker = worker;
|
||||
return worker;
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks CardBuilder] Could not create blurhash worker:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBlurhashInWorker(blurhash, width, height) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = ensureBlurhashWorker();
|
||||
if (!worker) {
|
||||
reject(new Error('Blurhash worker unavailable'));
|
||||
return;
|
||||
}
|
||||
const id = ++blurhashWorkerReqId;
|
||||
blurhashWorkerPending.set(id, { resolve, reject });
|
||||
try {
|
||||
worker.postMessage({ id, blurhash, width, height });
|
||||
} catch (e) {
|
||||
blurhashWorkerPending.delete(id);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleIdleBlurhashDrain() {
|
||||
if (blurhashIdleScheduled) return;
|
||||
blurhashIdleScheduled = true;
|
||||
const run = (deadline) => {
|
||||
blurhashIdleScheduled = false;
|
||||
let n = 0;
|
||||
while (blurhashIdleQueue.length && n < 4) {
|
||||
if (deadline && typeof deadline.timeRemaining === 'function' && deadline.timeRemaining() <= 0 && n > 0) {
|
||||
break;
|
||||
}
|
||||
const job = blurhashIdleQueue.shift();
|
||||
n++;
|
||||
if (!job || !job.canvas || !job.canvas.isConnected) continue;
|
||||
if (!job.canvas.hasAttribute('data-blurhash-pending')) continue;
|
||||
const cacheKey = blurhashCacheKey(job.blurhash, job.w, job.h);
|
||||
let pixels = blurhashCacheGet(cacheKey);
|
||||
if (!pixels) {
|
||||
pixels = blurhashDecode(job.blurhash, job.w, job.h);
|
||||
if (pixels) blurhashCacheSet(cacheKey, pixels);
|
||||
}
|
||||
if (pixels) applyBlurhashPixelsToCanvas(job.canvas, pixels);
|
||||
else job.canvas.removeAttribute('data-blurhash-pending');
|
||||
}
|
||||
if (blurhashIdleQueue.length) scheduleIdleBlurhashDrain();
|
||||
};
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(run, { timeout: 500 });
|
||||
} else {
|
||||
requestAnimationFrame(() => run({ timeRemaining: () => 5 }));
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueIdleBlurhashDecode(canvas, blurhash, width, height) {
|
||||
blurhashIdleQueue.push({ canvas, blurhash, w: width, h: height });
|
||||
scheduleIdleBlurhashDrain();
|
||||
}
|
||||
|
||||
async function fillBlurhashCanvas(canvas) {
|
||||
if (!canvas || !canvas.isConnected) return;
|
||||
const blurhash = canvas.getAttribute('data-blurhash-pending');
|
||||
if (!blurhash) return;
|
||||
if (shouldSkipBlurhashDecode()) {
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
canvas.classList.add('lazy-hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const w = canvas.width || 20;
|
||||
const h = canvas.height || 20;
|
||||
const cacheKey = blurhashCacheKey(blurhash, w, h);
|
||||
const cached = blurhashCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
applyBlurhashPixelsToCanvas(canvas, cached);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pixels = await decodeBlurhashInWorker(blurhash, w, h);
|
||||
if (!canvas.isConnected || canvas.getAttribute('data-blurhash-pending') !== blurhash) return;
|
||||
blurhashCacheSet(cacheKey, pixels);
|
||||
applyBlurhashPixelsToCanvas(canvas, pixels);
|
||||
} catch (e) {
|
||||
enqueueIdleBlurhashDecode(canvas, blurhash, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
function observeBlurhashCanvas(canvas) {
|
||||
if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return;
|
||||
if (shouldSkipBlurhashDecode()) {
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
canvas.classList.add('lazy-hidden');
|
||||
return;
|
||||
}
|
||||
initBlurhashFillObserver();
|
||||
if (blurhashFillObserver) {
|
||||
blurhashFillObserver.observe(canvas);
|
||||
} else {
|
||||
fillBlurhashCanvas(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
function initBlurhashFillObserver() {
|
||||
if (blurhashFillObserver) return;
|
||||
if (typeof IntersectionObserver === 'undefined') return;
|
||||
|
||||
blurhashFillObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
const canvas = entry.target;
|
||||
blurhashFillObserver.unobserve(canvas);
|
||||
fillBlurhashCanvas(canvas);
|
||||
});
|
||||
}, {
|
||||
threshold: 0,
|
||||
rootMargin: getBlurhashFillRootMargin()
|
||||
});
|
||||
}
|
||||
|
||||
function observePendingBlurhashInNode(node) {
|
||||
if (!node || node.nodeType !== 1) return;
|
||||
if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) {
|
||||
observeBlurhashCanvas(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blurhash string for the card image when the image is from the current item.
|
||||
* Mirrors the image URL logic; returns undefined when image comes from parent/series (we don't have their ImageBlurHashes).
|
||||
@@ -3022,20 +3323,24 @@
|
||||
const blurhashStr = !item.imageUrl ? getBlurhashForCard(item, cardFormat) : null;
|
||||
if (!blurhashStr || cardFormat === 'logo' || cardFormat === 'clear art' || cardFormat === 'disc') {
|
||||
blurhashCanvas.classList.add('lazy-hidden');
|
||||
} else if (shouldSkipBlurhashDecode()) {
|
||||
// Skip CPU decode; keep default chrome / lazy image path only
|
||||
blurhashCanvas.classList.add('lazy-hidden');
|
||||
} else {
|
||||
// Draw blurhash immediately so placeholder is visible ASAP (canvas is behind cardImageContainer)
|
||||
if (!drawBlurhashToCanvas(blurhashCanvas, blurhashStr)) {
|
||||
blurhashCanvas.classList.add('lazy-hidden');
|
||||
} else {
|
||||
// Hide the image container so the blurhash canvas shows until the real image loads
|
||||
cardImageContainer.classList.add('lazy-hidden');
|
||||
}
|
||||
// Defer decode off the construction path — near-viewport filler paints later
|
||||
blurhashCanvas.classList.add('lazy-hidden');
|
||||
blurhashCanvas.setAttribute('data-blurhash-pending', blurhashStr);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
// Use data-src for lazy loading instead of immediate backgroundImage
|
||||
cardImageContainer.setAttribute('data-src', imageUrl);
|
||||
if (blurhashStr) cardImageContainer.setAttribute('data-blurhash', blurhashStr);
|
||||
if (blurhashStr
|
||||
&& cardFormat !== 'logo'
|
||||
&& cardFormat !== 'clear art'
|
||||
&& cardFormat !== 'disc') {
|
||||
cardImageContainer.setAttribute('data-blurhash', blurhashStr);
|
||||
}
|
||||
// Add lazy class for styling/selection
|
||||
cardImageContainer.classList.add('lazy');
|
||||
}
|
||||
@@ -3154,6 +3459,25 @@
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.className = 'cardOverlayButton-br flex';
|
||||
|
||||
// Watchlist button (active state from UserData.Likes; click bound by watchlist.js)
|
||||
const watchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video'];
|
||||
if (watchlistSupportedTypes.includes(itemType)) {
|
||||
const isLiked = item.UserData?.Likes === true;
|
||||
const watchlistButton = document.createElement('button');
|
||||
watchlistButton.type = 'button';
|
||||
watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat';
|
||||
watchlistButton.setAttribute('data-action', 'none');
|
||||
watchlistButton.setAttribute('data-id', itemId);
|
||||
watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false');
|
||||
watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
|
||||
|
||||
const watchlistIcon = document.createElement('span');
|
||||
watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
|
||||
watchlistIcon.setAttribute('aria-hidden', 'true');
|
||||
watchlistButton.appendChild(watchlistIcon);
|
||||
buttonContainer.appendChild(watchlistButton);
|
||||
}
|
||||
|
||||
// Watched button
|
||||
const watchedButton = document.createElement('button');
|
||||
watchedButton.setAttribute('is', 'emby-playstatebutton');
|
||||
@@ -3203,7 +3527,9 @@
|
||||
|
||||
buttonContainer.appendChild(moreButton);
|
||||
|
||||
cardOverlayContainer.appendChild(playButton);
|
||||
if (item.Type !== 'CollectionFolder' && item.Type !== 'Folder' && item.Type !== 'UserView') {
|
||||
cardOverlayContainer.appendChild(playButton);
|
||||
}
|
||||
cardOverlayContainer.appendChild(buttonContainer);
|
||||
}
|
||||
|
||||
@@ -5256,6 +5582,52 @@
|
||||
return raw > 0 ? raw : 16;
|
||||
}
|
||||
|
||||
const SKELETON_ONE_LINE_ITEM_TYPES = new Set([
|
||||
'Book', 'Genre', 'CollectionFolder', 'BoxSet', 'Playlist', 'Folder', 'Studio'
|
||||
]);
|
||||
|
||||
const SKELETON_CUSTOM_FOOTER_SECTION_IDS = new Set([
|
||||
'upcoming', 'recentlyReleased.movies', 'recentlyReleased.episodes'
|
||||
]);
|
||||
|
||||
function sectionUsesCustomFooterText(sectionConfig) {
|
||||
if (!sectionConfig) return false;
|
||||
if (SKELETON_CUSTOM_FOOTER_SECTION_IDS.has(sectionConfig.id)) return true;
|
||||
if (Array.isArray(sectionConfig.items) && sectionConfig.items.some(
|
||||
(item) => item?.CustomFooterText || item?.cardFooter
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function inferSkeletonItemType(sectionConfig) {
|
||||
if (!sectionConfig) return null;
|
||||
const query = sectionConfig.queries?.[0];
|
||||
const path = typeof query?.path === 'string' ? query.path : '';
|
||||
if (path.includes('/Studios')) return 'Studio';
|
||||
if (path.includes('/Genres')) return 'Genre';
|
||||
|
||||
let includeTypes = query?.queryOptions?.IncludeItemTypes;
|
||||
if (typeof includeTypes === 'string') {
|
||||
includeTypes = includeTypes.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
if (!Array.isArray(includeTypes) || includeTypes.length === 0) return null;
|
||||
if (includeTypes.length === 1) return includeTypes[0];
|
||||
if (includeTypes.every((t) => SKELETON_ONE_LINE_ITEM_TYPES.has(t))) {
|
||||
return includeTypes[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSkeletonFooterLineCount(sectionConfig) {
|
||||
if (sectionConfig?.hideCardFooter === true) return 0;
|
||||
if (sectionUsesCustomFooterText(sectionConfig)) return 3;
|
||||
const itemType = inferSkeletonItemType(sectionConfig);
|
||||
if (itemType && SKELETON_ONE_LINE_ITEM_TYPES.has(itemType)) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function createScrollerElement() {
|
||||
const scroller = document.createElement('div');
|
||||
const isMobile = isMobileLayout();
|
||||
@@ -5582,9 +5954,10 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl }) {
|
||||
function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl, discoveryPending }) {
|
||||
const captionText = caption && String(caption).trim();
|
||||
const hasCaption = !!captionText;
|
||||
const useSkeletonTitle = discoveryPending === true;
|
||||
let parent = sectionTitleContainer;
|
||||
|
||||
if (hasCaption) {
|
||||
@@ -5594,7 +5967,15 @@
|
||||
parent = wrapper;
|
||||
}
|
||||
|
||||
if (viewMoreUrl) {
|
||||
if (useSkeletonTitle) {
|
||||
const titleText = document.createElement('h2');
|
||||
titleText.className = 'sectionTitle sectionTitle-cards';
|
||||
const skeletonTitle = document.createElement('span');
|
||||
skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
|
||||
skeletonTitle.setAttribute('aria-hidden', 'true');
|
||||
titleText.appendChild(skeletonTitle);
|
||||
parent.appendChild(titleText);
|
||||
} else if (viewMoreUrl) {
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button';
|
||||
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
|
||||
@@ -5628,7 +6009,14 @@
|
||||
|
||||
if (hasCaption) {
|
||||
let captionEl;
|
||||
if (captionUrl) {
|
||||
if (useSkeletonTitle) {
|
||||
captionEl = document.createElement('span');
|
||||
captionEl.className = 'sectionTitle sectionCaption';
|
||||
const skeletonCaption = document.createElement('span');
|
||||
skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
|
||||
skeletonCaption.setAttribute('aria-hidden', 'true');
|
||||
captionEl.appendChild(skeletonCaption);
|
||||
} else if (captionUrl) {
|
||||
captionEl = document.createElement('a');
|
||||
captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button';
|
||||
if (typeof captionUrl === 'function') {
|
||||
@@ -5639,11 +6027,12 @@
|
||||
} else {
|
||||
captionEl.href = captionUrl;
|
||||
}
|
||||
captionEl.textContent = captionText;
|
||||
} else {
|
||||
captionEl = document.createElement('span');
|
||||
captionEl.className = 'sectionTitle sectionCaption';
|
||||
captionEl.textContent = captionText;
|
||||
}
|
||||
captionEl.textContent = captionText;
|
||||
parent.appendChild(captionEl);
|
||||
}
|
||||
}
|
||||
@@ -5722,25 +6111,30 @@
|
||||
|
||||
/**
|
||||
* Creates a skeleton card element for loading states
|
||||
* @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square'
|
||||
* @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', 'square', 'logo', 'clear art', 'disc', 'banner', etc.
|
||||
* @param {boolean} overflowCard - Use overflow card classes
|
||||
* @param {Object|null} sectionConfig - Section config used to infer footer line count
|
||||
* @returns {HTMLElement} - Skeleton card element
|
||||
*/
|
||||
function createSkeletonCard(cardFormat = null, overflowCard = false) {
|
||||
function createSkeletonCard(cardFormat = null, overflowCard = false, sectionConfig = null) {
|
||||
const card = document.createElement('div');
|
||||
|
||||
// Determine card classes based on format
|
||||
// Determine card classes based on format (must match createJellyfinCardElement)
|
||||
let cardClass, padderClass;
|
||||
cardFormat = cardFormat?.toLowerCase() || 'portrait';
|
||||
|
||||
if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb') {
|
||||
if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb'
|
||||
|| cardFormat === 'logo' || cardFormat === 'clear art') {
|
||||
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
|
||||
padderClass = 'cardPadder-backdrop';
|
||||
} else if (cardFormat === 'square') {
|
||||
} else if (cardFormat === 'square' || cardFormat === 'disc') {
|
||||
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
|
||||
padderClass = 'cardPadder-square';
|
||||
} else if (cardFormat === 'banner') {
|
||||
cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard';
|
||||
padderClass = 'cardPadder-banner';
|
||||
} else {
|
||||
// portrait (default)
|
||||
// portrait / poster / series poster (default)
|
||||
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
|
||||
padderClass = 'cardPadder-portrait';
|
||||
}
|
||||
@@ -5760,6 +6154,32 @@
|
||||
|
||||
cardScalable.appendChild(cardPadder);
|
||||
cardBox.appendChild(cardScalable);
|
||||
|
||||
const footerLineCount = getSkeletonFooterLineCount(sectionConfig);
|
||||
if (footerLineCount > 0) {
|
||||
const cardTextStack = document.createElement('div');
|
||||
cardTextStack.className = 'cardTextStack';
|
||||
|
||||
const lineWidths = ['70%', '55%', '40%'];
|
||||
for (let lineIndex = 0; lineIndex < footerLineCount; lineIndex++) {
|
||||
const isFirst = lineIndex === 0;
|
||||
const cardText = document.createElement('div');
|
||||
cardText.className = isFirst
|
||||
? 'cardText cardTextCentered cardText-first'
|
||||
: 'cardText cardTextCentered cardText-secondary';
|
||||
|
||||
const bdi = document.createElement('bdi');
|
||||
const skeletonLine = document.createElement('span');
|
||||
skeletonLine.className = 'skeleton-text-line';
|
||||
skeletonLine.style.width = lineWidths[lineIndex] || '40%';
|
||||
bdi.appendChild(skeletonLine);
|
||||
cardText.appendChild(bdi);
|
||||
cardTextStack.appendChild(cardText);
|
||||
}
|
||||
|
||||
cardBox.appendChild(cardTextStack);
|
||||
}
|
||||
|
||||
card.appendChild(cardBox);
|
||||
|
||||
return card;
|
||||
@@ -5773,9 +6193,18 @@
|
||||
* @returns {HTMLElement} - Skeleton spotlight container
|
||||
*/
|
||||
function createSkeletonSpotlightSection(title, options = {}) {
|
||||
const { viewMoreUrl = null, spotlightLayout, spotlightSize, fullScreen } = options;
|
||||
const {
|
||||
viewMoreUrl = null,
|
||||
spotlightLayout,
|
||||
spotlightSize,
|
||||
fullScreen,
|
||||
discoveryPending = false,
|
||||
caption = null
|
||||
} = options;
|
||||
const layout = spotlightLayout ?? (fullScreen === true ? 'Borderless' : 'Border');
|
||||
const size = spotlightSize ?? (fullScreen === true ? 'full' : 'normal');
|
||||
const captionText = caption && String(caption).trim();
|
||||
const useSkeletonTitle = discoveryPending === true;
|
||||
|
||||
// Create main container (same structure as real spotlight)
|
||||
const container = document.createElement('div');
|
||||
@@ -5792,9 +6221,16 @@
|
||||
{
|
||||
const sectionTitleContainer = document.createElement('div');
|
||||
sectionTitleContainer.className = 'spotlight-section-title-container';
|
||||
if (title) {
|
||||
if (useSkeletonTitle || title) {
|
||||
let sectionTitleEl;
|
||||
if (viewMoreUrl) {
|
||||
if (useSkeletonTitle) {
|
||||
sectionTitleEl = document.createElement('div');
|
||||
sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
|
||||
const skeletonTitle = document.createElement('span');
|
||||
skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
|
||||
skeletonTitle.setAttribute('aria-hidden', 'true');
|
||||
sectionTitleEl.appendChild(skeletonTitle);
|
||||
} else if (viewMoreUrl) {
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
|
||||
titleLink.textContent = title;
|
||||
@@ -5819,9 +6255,19 @@
|
||||
sectionTitleEl.textContent = title;
|
||||
}
|
||||
const sectionTitleWrapper = document.createElement('div');
|
||||
sectionTitleWrapper.className = `spotlight-section-title ${viewMoreUrl ? '' : 'spotlight-title-link '}headerTabs sectionTabs`;
|
||||
sectionTitleWrapper.className = `spotlight-section-title ${(!useSkeletonTitle && viewMoreUrl) ? '' : 'spotlight-title-link '}headerTabs sectionTabs`;
|
||||
sectionTitleWrapper.appendChild(sectionTitleEl);
|
||||
sectionTitleContainer.appendChild(sectionTitleWrapper);
|
||||
|
||||
if (useSkeletonTitle && captionText) {
|
||||
const captionEl = document.createElement('span');
|
||||
captionEl.className = 'sectionTitle sectionCaption';
|
||||
const skeletonCaption = document.createElement('span');
|
||||
skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
|
||||
skeletonCaption.setAttribute('aria-hidden', 'true');
|
||||
captionEl.appendChild(skeletonCaption);
|
||||
sectionTitleContainer.appendChild(captionEl);
|
||||
}
|
||||
}
|
||||
bannerContainer.appendChild(sectionTitleContainer);
|
||||
}
|
||||
@@ -5865,11 +6311,13 @@
|
||||
const sectionTitleContainer = document.createElement('div');
|
||||
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
|
||||
|
||||
const discoveryPending = sectionConfig?.discoveryPending === true;
|
||||
appendSectionTitleContent(sectionTitleContainer, {
|
||||
title,
|
||||
caption: getSectionCaption(sectionConfig),
|
||||
captionUrl: getSectionCaptionUrl(sectionConfig),
|
||||
viewMoreUrl
|
||||
viewMoreUrl: discoveryPending ? null : viewMoreUrl,
|
||||
discoveryPending
|
||||
});
|
||||
|
||||
ensureSectionControlsMount(sectionTitleContainer);
|
||||
@@ -5879,7 +6327,7 @@
|
||||
|
||||
const skeletonCount = getSectionSkeletonCount(sectionConfig);
|
||||
for (let i = 0; i < skeletonCount; i++) {
|
||||
const skeletonCard = createSkeletonCard(cardFormat, overflowCard);
|
||||
const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig);
|
||||
skeletonCard.setAttribute('data-index', i);
|
||||
itemsContainer.appendChild(skeletonCard);
|
||||
}
|
||||
@@ -5961,10 +6409,13 @@
|
||||
*/
|
||||
function initLazyImageObserver() {
|
||||
if (lazyImageObserver) return; // Already initialized
|
||||
|
||||
// Mobile: tighter prefetch so image downloads don't stampede during discovery paint
|
||||
const rootMargin = isMobileLayout() ? '300px' : '800px';
|
||||
|
||||
const observerOptions = {
|
||||
threshold: 0.1, // Trigger when 10% of element is visible
|
||||
rootMargin: '800px' // Start loading well before element enters viewport to avoid grey flash
|
||||
rootMargin
|
||||
};
|
||||
|
||||
lazyImageObserver = new IntersectionObserver((entries) => {
|
||||
@@ -5987,6 +6438,7 @@
|
||||
const canvas = cardImageContainer.previousElementSibling;
|
||||
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
|
||||
canvas.classList.add('lazy-hidden');
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
}
|
||||
lazyImageObserver.unobserve(cardImageContainer);
|
||||
};
|
||||
@@ -5999,6 +6451,7 @@
|
||||
const canvas = cardImageContainer.previousElementSibling;
|
||||
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
|
||||
canvas.classList.add('lazy-hidden');
|
||||
canvas.removeAttribute('data-blurhash-pending');
|
||||
}
|
||||
lazyImageObserver.unobserve(cardImageContainer);
|
||||
};
|
||||
@@ -6036,6 +6489,8 @@
|
||||
lazyImageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
observePendingBlurhashInNode(node);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6112,11 +6567,14 @@
|
||||
|
||||
initLazyImageObserver();
|
||||
initLazyMutationObserver();
|
||||
initBlurhashFillObserver();
|
||||
|
||||
const existingLazyImages = document.querySelectorAll('.cardImageContainer[data-src]');
|
||||
existingLazyImages.forEach(img => {
|
||||
lazyImageObserver.observe(img);
|
||||
});
|
||||
|
||||
document.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas);
|
||||
|
||||
if (document.body) {
|
||||
lazyMutationObserver.observe(document.body, {
|
||||
@@ -6772,12 +7230,20 @@
|
||||
const dataItems = section.result?.data?.Items ?? section.result?.data ?? [];
|
||||
const isSpotlight = sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight';
|
||||
const isSkeleton = !!(sectionElement.querySelector('.skeleton-card, .skeleton-spotlight-item'));
|
||||
const hasSkeletonTitle = !!sectionElement.querySelector('.skeleton-section-title');
|
||||
|
||||
if (items.length === 0) {
|
||||
sectionElement.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Discovery pending (and any skeleton with placeholder title) needs a full rebuild for title/viewMore/caption
|
||||
if (isSkeleton && (isSpotlight || hasSkeletonTitle)) {
|
||||
const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially);
|
||||
markSectionEnhanced(content, sectionConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSkeleton && isSpotlight) {
|
||||
const staleIds = (Array.isArray(dataItems) ? dataItems : [])
|
||||
.map((item) => item?.Id)
|
||||
|
||||
+11
-11
@@ -22,9 +22,9 @@
|
||||
{ key: 'directedByTopDirector', label: 'Directed by [Top Director]', defaultName: 'Directed by [Director]' },
|
||||
{ key: 'writtenByTopWriter', label: 'Written by [Top Writer]', defaultName: 'Written by [Writer]' },
|
||||
{ key: 'becauseYouRecentlyWatched', label: 'Because you recently watched [Movie]', defaultName: 'Because you recently watched [Movie]' },
|
||||
{ key: 'starringActorRecentlyWatched', label: 'Starring [Actor] because you recently watched [Movie]', defaultName: 'Starring [Actor] because you recently watched [Movie]' },
|
||||
{ key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Director] because you recently watched [Movie]', defaultName: 'Directed by [Director] because you recently watched [Movie]' },
|
||||
{ key: 'writtenByWriterRecentlyWatched', label: 'Written by [Writer] because you recently watched [Movie]', defaultName: 'Written by [Writer] because you recently watched [Movie]' }
|
||||
{ key: 'starringActorRecentlyWatched', label: 'Starring [Person]', defaultName: 'Starring {Person}', extras: { caption: 'because you recently watched {Title}' } },
|
||||
{ key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Person]', defaultName: 'Directed by {Person}', extras: { caption: 'because you recently watched {Title}' } },
|
||||
{ key: 'writtenByWriterRecentlyWatched', label: 'Written by [Person]', defaultName: 'Written by {Person}', extras: { caption: 'because you recently watched {Title}' } }
|
||||
];
|
||||
|
||||
const SUPPORTED_CUSTOM_SECTION_PARAMS = {
|
||||
@@ -1208,10 +1208,8 @@
|
||||
const defsByName = new Map(
|
||||
(window.KefinTweaks?.getScripts?.() || []).map((def) => [def.name, def])
|
||||
);
|
||||
const major = window.KefinTweaks?._jellyfinMajorVersion
|
||||
?? (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function'
|
||||
? window.KefinTweaks.getJellyfinMajorVersion()
|
||||
: null);
|
||||
// Prefer cache warmed by await getJellyfinMajorVersion() before opening the modal
|
||||
const major = window.KefinTweaks?._jellyfinMajorVersion ?? null;
|
||||
|
||||
const visibleScripts = scriptNames.filter((script) => {
|
||||
const def = defsByName.get(script.key);
|
||||
@@ -1711,6 +1709,9 @@
|
||||
if (definition.extras?.minimumItems !== undefined) {
|
||||
defaults.minimumItems = definition.extras.minimumItems;
|
||||
}
|
||||
if (definition.extras?.caption !== undefined) {
|
||||
defaults.caption = definition.extras.caption;
|
||||
}
|
||||
|
||||
let normalized = { ...defaults };
|
||||
|
||||
@@ -2270,7 +2271,7 @@
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 0.75em; margin-bottom: 0.75em;">
|
||||
<div class="listItem" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
|
||||
<div class="listItemContent">
|
||||
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Infinite Scroll / Load More Button</div>
|
||||
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Infinite Scroll / Discover More</div>
|
||||
${buildJellyfinCheckbox('homeScreen_discovery_infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2479,9 +2480,8 @@
|
||||
|
||||
// Ensure Jellyfin major is cached so version-gated feature cards filter correctly
|
||||
try {
|
||||
if (typeof window.KefinTweaks?.getCurrentMajorServerVersion === 'function') {
|
||||
const major = await window.KefinTweaks.getCurrentMajorServerVersion();
|
||||
window.KefinTweaks._jellyfinMajorVersion = major;
|
||||
if (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function') {
|
||||
await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks Configuration] Could not resolve Jellyfin major version:', e);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// KefinTweaks External List (MDBList) — match provider IDs against library caches
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks ExternalList]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks ExternalList]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks ExternalList]', ...args);
|
||||
|
||||
function getMdblistApiKey() {
|
||||
const settings = window.LibraryCacheUtils?.getLibraryCacheSettings?.() || {};
|
||||
return (settings.mdblistApiKey || '').trim();
|
||||
}
|
||||
|
||||
function appendApiKey(url, apiKey) {
|
||||
if (!apiKey) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (!u.searchParams.has('apikey') && !u.searchParams.has('api_key')) {
|
||||
u.searchParams.set('apikey', apiKey);
|
||||
}
|
||||
return u.toString();
|
||||
} catch (_) {
|
||||
const join = url.includes('?') ? '&' : '?';
|
||||
return `${url}${join}apikey=${encodeURIComponent(apiKey)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize mdblist (or similar) JSON into { mediaType, imdb?, tmdb? }[].
|
||||
*/
|
||||
function normalizeExternalListPayload(data) {
|
||||
const rows = [];
|
||||
const list = Array.isArray(data) ? data
|
||||
: Array.isArray(data?.movies) || Array.isArray(data?.shows) || Array.isArray(data?.items)
|
||||
? [...(data.movies || []), ...(data.shows || []), ...(data.items || [])]
|
||||
: [];
|
||||
|
||||
list.forEach((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return;
|
||||
const mediaType = /show|series|tv/i.test(String(entry.mediatype || entry.media_type || entry.type || ''))
|
||||
? 'Series'
|
||||
: 'Movie';
|
||||
const imdb = entry.imdb_id || entry.imdbid || entry.ids?.imdb || entry.Imdb || null;
|
||||
const tmdb = entry.tmdb_id || entry.tmdbid || entry.ids?.tmdb || entry.Tmdb || null;
|
||||
if (imdb || tmdb) {
|
||||
rows.push({ mediaType, imdb: imdb ? String(imdb) : undefined, tmdb: tmdb ? String(tmdb) : undefined });
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function fetchExternalListUrl(url) {
|
||||
const apiKey = getMdblistApiKey();
|
||||
const fetchUrl = appendApiKey(url, apiKey);
|
||||
const response = await fetch(fetchUrl, { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`External list HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function resolveExternalListItems(sectionConfig) {
|
||||
const urls = sectionConfig?.externalListUrls || [];
|
||||
if (!urls.length) return { Items: [] };
|
||||
|
||||
if (!getMdblistApiKey()) {
|
||||
WARN('MDBList API key not configured');
|
||||
}
|
||||
|
||||
const allRows = [];
|
||||
for (const url of urls) {
|
||||
if (!url) continue;
|
||||
try {
|
||||
const data = await fetchExternalListUrl(url);
|
||||
allRows.push(...normalizeExternalListPayload(data));
|
||||
} catch (e) {
|
||||
ERR('Failed to fetch external list:', url, e);
|
||||
}
|
||||
}
|
||||
|
||||
const movieIds = [];
|
||||
const seriesIds = [];
|
||||
allRows.forEach((row) => {
|
||||
const keys = [];
|
||||
if (row.imdb) keys.push(row.imdb);
|
||||
if (row.tmdb) keys.push(row.tmdb);
|
||||
if (row.mediaType === 'Series') seriesIds.push(...keys);
|
||||
else movieIds.push(...keys);
|
||||
});
|
||||
|
||||
const movies = movieIds.length
|
||||
? await (window.LibraryCache?.getByProviderIds('Movie', movieIds)
|
||||
|| window.MoviesCache?.getMoviesByProviderIds?.(movieIds)
|
||||
|| [])
|
||||
: [];
|
||||
const series = seriesIds.length
|
||||
? await (window.LibraryCache?.getByProviderIds('Series', seriesIds)
|
||||
|| window.SeriesCache?.getSeriesByProviderIds?.(seriesIds)
|
||||
|| [])
|
||||
: [];
|
||||
|
||||
const items = [...movies, ...series];
|
||||
LOG(`External list matched ${items.length} library items from ${allRows.length} rows`);
|
||||
return { Items: items };
|
||||
}
|
||||
|
||||
window.KefinExternalList = {
|
||||
getMdblistApiKey,
|
||||
resolveExternalListItems,
|
||||
normalizeExternalListPayload
|
||||
};
|
||||
|
||||
LOG('Module loaded');
|
||||
})();
|
||||
+45
-1
@@ -3,7 +3,6 @@
|
||||
.sections.homeSectionsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 100px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -36,6 +35,10 @@
|
||||
content: "Discover More";
|
||||
}
|
||||
|
||||
#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::before {
|
||||
content: "No More Content to Discover";
|
||||
}
|
||||
|
||||
div#discovery-loading-indicator:not(.show)::after {
|
||||
content: "\E313";
|
||||
font-family: "Material Icons";
|
||||
@@ -49,10 +52,26 @@ div#discovery-loading-indicator:not(.show)::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
[data-loading-discovery="true"] div#discovery-loading-indicator {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Idle Discover More / exhausted prompt (chevron mode) */
|
||||
#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) .spinner {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
|
||||
display: none;
|
||||
} */
|
||||
@@ -298,3 +317,28 @@ body[data-seasonal-background="true"]:has(.homePage:not(.hide)) #reactRoot:has(.
|
||||
body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay {
|
||||
display: block;
|
||||
} */
|
||||
|
||||
.homePage[data-discovery-ready] div#discovery-loading-indicator:not(.show) {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
@keyframes fadeInDelayed {
|
||||
0%, 99% {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.homeSectionsContainer:not(:has([data-page]), :has([data-section-id]))::after {
|
||||
content: 'Customize your Home Screen from your User Settings > Home in the top right corner.';
|
||||
text-align: center;
|
||||
margin: 3em auto;
|
||||
display: block;
|
||||
animation: fadeInDelayed 0.1s linear 1s forwards;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
+365
-1034
File diff suppressed because it is too large
Load Diff
+735
-71
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
// KefinTweaks Library Cache Facade
|
||||
// Routes provider-id lookups and readiness checks to MoviesCache / SeriesCache
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks LibraryCache]', ...args);
|
||||
|
||||
function normalizeType(type) {
|
||||
const t = String(type || '').toLowerCase();
|
||||
if (t === 'movie' || t === 'movies') return 'Movie';
|
||||
if (t === 'series' || t === 'show' || t === 'shows' || t === 'tv') return 'Series';
|
||||
return type;
|
||||
}
|
||||
|
||||
function isComplete(type) {
|
||||
const t = normalizeType(type);
|
||||
if (t === 'Movie') return window.MoviesCache?.isComplete?.() === true;
|
||||
if (t === 'Series') return window.SeriesCache?.isComplete?.() === true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isReady(type) {
|
||||
return isComplete(type);
|
||||
}
|
||||
|
||||
async function getByProviderIds(type, ids) {
|
||||
const t = normalizeType(type);
|
||||
if (t === 'Movie') {
|
||||
return window.MoviesCache?.getMoviesByProviderIds?.(ids) || [];
|
||||
}
|
||||
if (t === 'Series') {
|
||||
return window.SeriesCache?.getSeriesByProviderIds?.(ids) || [];
|
||||
}
|
||||
LOG('Unknown type for getByProviderIds:', type);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a dataSource string depends on an incomplete Movies/Series/People cache.
|
||||
*/
|
||||
function dataSourceDependsOnIncompleteCache(dataSource) {
|
||||
const ds = typeof dataSource === 'string' ? dataSource : '';
|
||||
if (!ds) return false;
|
||||
if (ds.startsWith('MoviesCache.') && !isComplete('Movie')) return true;
|
||||
if (ds.startsWith('SeriesCache.') && !isComplete('Series')) return true;
|
||||
if (ds.startsWith('PeopleCache.') && window.PeopleCache?.isComplete && !window.PeopleCache.isComplete()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a home section depends on a library/people/external cache that is not ready.
|
||||
*/
|
||||
function sectionDependsOnIncompleteCache(section) {
|
||||
if (!section) return false;
|
||||
if (Array.isArray(section.externalListUrls) && section.externalListUrls.length) {
|
||||
return !(isComplete('Movie') && isComplete('Series'));
|
||||
}
|
||||
if (dataSourceDependsOnIncompleteCache(section.discoverySourceQuery?.dataSource)) {
|
||||
return true;
|
||||
}
|
||||
const queries = section.queries || [];
|
||||
for (const q of queries) {
|
||||
if (dataSourceDependsOnIncompleteCache(q?.dataSource)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
window.LibraryCache = {
|
||||
getByProviderIds,
|
||||
isReady,
|
||||
isComplete,
|
||||
sectionDependsOnIncompleteCache
|
||||
};
|
||||
|
||||
LOG('Module loaded');
|
||||
})();
|
||||
@@ -0,0 +1,268 @@
|
||||
// KefinTweaks Library Cache Utils
|
||||
// Shared strip / provider-id helpers for moviesCache, seriesCache, peopleCache
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function stripPerson(person) {
|
||||
if (!person) return null;
|
||||
return {
|
||||
Name: person.Name,
|
||||
Id: person.Id,
|
||||
Role: person.Role,
|
||||
Type: person.Type
|
||||
};
|
||||
}
|
||||
|
||||
function stripChapter(chapter) {
|
||||
if (!chapter) return null;
|
||||
return {
|
||||
Name: chapter.Name,
|
||||
ImageTag: chapter.ImageTag
|
||||
};
|
||||
}
|
||||
|
||||
function stripMediaStream(stream) {
|
||||
if (!stream) return null;
|
||||
return {
|
||||
Height: stream.Height,
|
||||
Width: stream.Width,
|
||||
DisplayTitle: stream.DisplayTitle,
|
||||
Language: stream.Language,
|
||||
Type: stream.Type
|
||||
};
|
||||
}
|
||||
|
||||
function stripStudio(studio) {
|
||||
if (!studio) return null;
|
||||
return {
|
||||
Name: studio.Name,
|
||||
Id: studio.Id
|
||||
};
|
||||
}
|
||||
|
||||
function stripGenreItem(genre) {
|
||||
if (!genre) return null;
|
||||
return {
|
||||
Name: genre.Name,
|
||||
Id: genre.Id
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a movie item to the durable library-cache allowlist (no UserData).
|
||||
*/
|
||||
function stripMovieForCache(item) {
|
||||
if (!item) return null;
|
||||
return {
|
||||
Name: item.Name,
|
||||
Id: item.Id,
|
||||
HasSubtitles: item.HasSubtitles,
|
||||
PremiereDate: item.PremiereDate,
|
||||
CriticRating: item.CriticRating,
|
||||
OfficialRating: item.OfficialRating,
|
||||
Overview: item.Overview,
|
||||
Taglines: item.Taglines,
|
||||
CommunityRating: item.CommunityRating,
|
||||
RunTimeTicks: item.RunTimeTicks,
|
||||
ProductionYear: item.ProductionYear,
|
||||
ProviderIds: item.ProviderIds || {},
|
||||
Type: item.Type || 'Movie',
|
||||
People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [],
|
||||
Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [],
|
||||
GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [],
|
||||
MediaStreams: Array.isArray(item.MediaStreams) ? item.MediaStreams.map(stripMediaStream).filter(Boolean) : [],
|
||||
ImageTags: item.ImageTags || {},
|
||||
BackdropImageTags: item.BackdropImageTags || [],
|
||||
Chapters: Array.isArray(item.Chapters) ? item.Chapters.map(stripChapter).filter(Boolean) : [],
|
||||
Trickplay: item.Trickplay || undefined,
|
||||
DateCreated: item.DateCreated,
|
||||
DateLastSaved: item.DateLastSaved,
|
||||
UserData: item.UserData || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a series item (no MediaStreams/Chapters/Trickplay).
|
||||
*/
|
||||
function stripSeriesForCache(item) {
|
||||
if (!item) return null;
|
||||
return {
|
||||
Name: item.Name,
|
||||
Id: item.Id,
|
||||
PremiereDate: item.PremiereDate,
|
||||
CriticRating: item.CriticRating,
|
||||
OfficialRating: item.OfficialRating,
|
||||
Overview: item.Overview,
|
||||
Taglines: item.Taglines,
|
||||
CommunityRating: item.CommunityRating,
|
||||
RunTimeTicks: item.RunTimeTicks,
|
||||
ProductionYear: item.ProductionYear,
|
||||
ProviderIds: item.ProviderIds || {},
|
||||
Type: item.Type || 'Series',
|
||||
People: Array.isArray(item.People) ? item.People.map(stripPerson).filter(Boolean) : [],
|
||||
Studios: Array.isArray(item.Studios) ? item.Studios.map(stripStudio).filter(Boolean) : [],
|
||||
GenreItems: Array.isArray(item.GenreItems) ? item.GenreItems.map(stripGenreItem).filter(Boolean) : [],
|
||||
ImageTags: item.ImageTags || {},
|
||||
BackdropImageTags: item.BackdropImageTags || [],
|
||||
ChildCount: item.ChildCount,
|
||||
RecursiveItemCount: item.RecursiveItemCount,
|
||||
DateCreated: item.DateCreated,
|
||||
DateLastContentAdded: item.DateLastContentAdded,
|
||||
DateLastSaved: item.DateLastSaved,
|
||||
UserData: item.UserData || {}
|
||||
};
|
||||
}
|
||||
|
||||
function stripItemsForCache(items, kind) {
|
||||
const strip = kind === 'Series' ? stripSeriesForCache : stripMovieForCache;
|
||||
return (items || []).map(strip).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize provider id keys for matching (Imdb/Tmdb/Tvdb).
|
||||
* @param {string|Object} idOrObj
|
||||
* @returns {string[]} normalized keys like "imdb:tt123", "tmdb:456"
|
||||
*/
|
||||
function normalizeProviderKeys(idOrObj) {
|
||||
const keys = [];
|
||||
if (!idOrObj) return keys;
|
||||
if (typeof idOrObj === 'string') {
|
||||
const s = idOrObj.trim();
|
||||
if (!s) return keys;
|
||||
if (/^tt\d+$/i.test(s)) keys.push(`imdb:${s.toLowerCase()}`);
|
||||
else if (/^\d+$/.test(s)) {
|
||||
keys.push(`tmdb:${s}`);
|
||||
keys.push(`tvdb:${s}`);
|
||||
} else keys.push(s.toLowerCase());
|
||||
return keys;
|
||||
}
|
||||
const p = idOrObj.ProviderIds || idOrObj;
|
||||
if (p.Imdb) keys.push(`imdb:${String(p.Imdb).toLowerCase()}`);
|
||||
if (p.Tmdb) keys.push(`tmdb:${String(p.Tmdb)}`);
|
||||
if (p.Tvdb) keys.push(`tvdb:${String(p.Tvdb)}`);
|
||||
return keys;
|
||||
}
|
||||
|
||||
function itemMatchesProviderIds(item, idSet) {
|
||||
if (!item || !idSet || idSet.size === 0) return false;
|
||||
return normalizeProviderKeys(item).some(k => idSet.has(k));
|
||||
}
|
||||
|
||||
function buildProviderIdIndex(items) {
|
||||
const index = Object.create(null);
|
||||
(items || []).forEach((item) => {
|
||||
if (!item?.Id) return;
|
||||
normalizeProviderKeys(item).forEach((key) => {
|
||||
if (!index[key]) index[key] = [];
|
||||
if (!index[key].includes(item.Id)) index[key].push(item.Id);
|
||||
});
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
function getLibraryCacheSettings() {
|
||||
const fromHome = window.KefinHomeScreen?.getConfig?.()?.LIBRARY_CACHE;
|
||||
const fromDefault = window.KefinHomeConfig2?.LIBRARY_CACHE;
|
||||
return {
|
||||
movieChunkSize: 1000,
|
||||
seriesChunkSize: 1000,
|
||||
mdblistApiKey: '',
|
||||
CACHE_TTL: 14 * 24 * 60 * 60 * 1000,
|
||||
...(fromDefault || {}),
|
||||
...(fromHome || {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run strip on main thread; yields between microtasks for UI breathing room.
|
||||
* Worker path: Blob worker when available (same strip logic inlined).
|
||||
*/
|
||||
async function stripItemsAsync(items, kind) {
|
||||
if (!items || !items.length) return [];
|
||||
if (typeof Worker === 'undefined') {
|
||||
return stripItemsForCache(items, kind);
|
||||
}
|
||||
try {
|
||||
return await stripItemsInWorker(items, kind);
|
||||
} catch (e) {
|
||||
console.warn('[KefinTweaks LibraryCacheUtils] Worker strip failed, using main thread:', e);
|
||||
return stripItemsForCache(items, kind);
|
||||
}
|
||||
}
|
||||
|
||||
function stripItemsInWorker(items, kind) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const workerSource = `
|
||||
${stripPerson.toString()}
|
||||
${stripChapter.toString()}
|
||||
${stripMediaStream.toString()}
|
||||
${stripStudio.toString()}
|
||||
${stripGenreItem.toString()}
|
||||
${stripMovieForCache.toString()}
|
||||
${stripSeriesForCache.toString()}
|
||||
${stripItemsForCache.toString()}
|
||||
self.onmessage = function(e) {
|
||||
try {
|
||||
const { items, kind } = e.data;
|
||||
const result = stripItemsForCache(items, kind);
|
||||
self.postMessage({ ok: true, result });
|
||||
} catch (err) {
|
||||
self.postMessage({ ok: false, error: String(err && err.message || err) });
|
||||
}
|
||||
};
|
||||
`;
|
||||
const blob = new Blob([workerSource], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const worker = new Worker(url);
|
||||
const timer = setTimeout(() => {
|
||||
worker.terminate();
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Worker strip timeout'));
|
||||
}, 30000);
|
||||
worker.onmessage = (e) => {
|
||||
clearTimeout(timer);
|
||||
worker.terminate();
|
||||
URL.revokeObjectURL(url);
|
||||
if (e.data?.ok) resolve(e.data.result);
|
||||
else reject(new Error(e.data?.error || 'Worker strip failed'));
|
||||
};
|
||||
worker.onerror = (err) => {
|
||||
clearTimeout(timer);
|
||||
worker.terminate();
|
||||
URL.revokeObjectURL(url);
|
||||
reject(err);
|
||||
};
|
||||
worker.postMessage({ items, kind });
|
||||
});
|
||||
}
|
||||
|
||||
function upsertById(existingItems, incomingItems) {
|
||||
const map = new Map();
|
||||
(existingItems || []).forEach((item) => {
|
||||
if (item?.Id) map.set(item.Id, item);
|
||||
});
|
||||
(incomingItems || []).forEach((item) => {
|
||||
if (item?.Id) map.set(item.Id, item);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function removeByIds(existingItems, removeIds) {
|
||||
if (!removeIds || !removeIds.size) return existingItems || [];
|
||||
return (existingItems || []).filter((item) => item?.Id && !removeIds.has(item.Id));
|
||||
}
|
||||
|
||||
window.LibraryCacheUtils = {
|
||||
stripMovieForCache,
|
||||
stripSeriesForCache,
|
||||
stripPerson,
|
||||
stripItemsForCache,
|
||||
stripItemsAsync,
|
||||
normalizeProviderKeys,
|
||||
itemMatchesProviderIds,
|
||||
buildProviderIdIndex,
|
||||
getLibraryCacheSettings,
|
||||
upsertById,
|
||||
removeByIds
|
||||
};
|
||||
})();
|
||||
+409
-108
@@ -1,97 +1,335 @@
|
||||
// KefinTweaks Studios Cache Manager
|
||||
// Handles fetching, processing, and caching of Popular TV Networks
|
||||
// KefinTweaks Movies Library Cache
|
||||
// Chunked per-user archive of movies (no UserData). Startup after login + incremental sync.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks Movies Cache]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks Movies Cache]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks Movies Cache]', ...args);
|
||||
const LOG = (...args) => console.log('[KefinTweaks Movies Cache]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks Movies Cache]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks Movies Cache]', ...args);
|
||||
|
||||
// 1 week
|
||||
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const IMDB_TOP_250_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours for ID list
|
||||
const CACHE_NAME = 'movies_library';
|
||||
const PARTIAL_CACHE_NAME = 'movies_library_partial';
|
||||
const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedMovieCache_';
|
||||
const IMDB_TOP_250_CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const MOVIE_FIELDS = [
|
||||
'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview', 'PrimaryImageAspectRatio',
|
||||
'DateCreated'
|
||||
].join(',');
|
||||
|
||||
// State
|
||||
let movies = null;
|
||||
let isComplete = false;
|
||||
let imdbTop250Movies = null;
|
||||
let isInitializing = false;
|
||||
let fetchPromise = null;
|
||||
let providerIndex = null;
|
||||
|
||||
function utils() {
|
||||
return window.LibraryCacheUtils || {};
|
||||
}
|
||||
|
||||
function getTtl() {
|
||||
return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function getChunkSize() {
|
||||
return utils().getLibraryCacheSettings?.().movieChunkSize || 1000;
|
||||
}
|
||||
|
||||
function lastFetchStorageKey() {
|
||||
const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous';
|
||||
return `${LAST_FETCH_KEY_PREFIX}${userId}`;
|
||||
}
|
||||
|
||||
function getLastDateFetched() {
|
||||
try {
|
||||
return localStorage.getItem(lastFetchStorageKey()) || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setLastDateFetched(iso) {
|
||||
try {
|
||||
localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString());
|
||||
} catch (e) {
|
||||
WARN('Failed to store lastDateFetchedMovieCache:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getApiHelper() {
|
||||
return window.apiHelper || window.ApiHelper;
|
||||
}
|
||||
|
||||
function buildItemsUrl({ startIndex, limit, extraParams = {} }) {
|
||||
const server = window.ApiClient.serverAddress();
|
||||
const params = new URLSearchParams({
|
||||
IncludeItemTypes: 'Movie',
|
||||
Recursive: 'true',
|
||||
Fields: MOVIE_FIELDS,
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'true',
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
StartIndex: String(startIndex || 0),
|
||||
Limit: String(limit)
|
||||
});
|
||||
Object.entries(extraParams).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') params.set(k, String(v));
|
||||
});
|
||||
return `${server}/Items?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function queryItems(url) {
|
||||
const api = getApiHelper();
|
||||
if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable');
|
||||
const result = await api.getQuery(url, { useCache: false });
|
||||
return result || { Items: [], TotalRecordCount: 0 };
|
||||
}
|
||||
|
||||
async function stripChunk(items) {
|
||||
if (utils().stripItemsAsync) {
|
||||
return utils().stripItemsAsync(items, 'Movie');
|
||||
}
|
||||
return (items || []).map((item) => utils().stripMovieForCache?.(item) || item).filter(Boolean);
|
||||
}
|
||||
|
||||
function rebuildProviderIndex(list) {
|
||||
providerIndex = utils().buildProviderIdIndex?.(list) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and caches all movies across all libraries.
|
||||
* You should not use this for anything related to UserData
|
||||
* The cache for this is long, and this is intended to simply be an "archive" of all movies across all libraries.
|
||||
* @returns {Promise<Array>} - Array of movie objects
|
||||
* Full crawl with partial resume. Commits to movies_library only when complete.
|
||||
*/
|
||||
async function fetchAndCacheMovies() {
|
||||
// IndexedDBCache
|
||||
const cacheName = 'movies_library';
|
||||
const cache = window.IndexedDBCache;
|
||||
|
||||
// Check cache first
|
||||
const cached = await cache.get(cacheName);
|
||||
if (cached) {
|
||||
LOG('Using cached Movies');
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
LOG('Fetching Movies...');
|
||||
|
||||
// We need to refactor this to pull the data in chunks of 100 items at a time in order to backfill the cache
|
||||
// We will populate 100 items at a time into the library cache.
|
||||
// Each group will contain the index of the last item in the group so we can resume from there
|
||||
// When resuming, we should first verify if the previous item based on the same sorting and startindex/limit is still the same
|
||||
// We just check the last item in the indexed db cache and make the same query starting at that index
|
||||
// If it's the same then we can resume from there, if not then we need to start from the beginning
|
||||
const allMoviesQuery = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=ProviderIds`;
|
||||
|
||||
const movieResult = await apiHelper.getQuery(allMoviesQuery);
|
||||
|
||||
// Resolve the data (cached or fresh)
|
||||
let allMovies = [];
|
||||
if (movieResult && movieResult.Items) {
|
||||
allMovies = movieResult.Items;
|
||||
}
|
||||
|
||||
LOG(`Fetched ${allMovies.length} Movies`);
|
||||
|
||||
// Cache the results
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
await cache.set(cacheName, allMovies, userId, CACHE_TTL);
|
||||
|
||||
return allMovies;
|
||||
} catch (err) {
|
||||
ERR('Error fetching Movies:', err);
|
||||
if (!cache) {
|
||||
ERR('IndexedDBCache unavailable');
|
||||
return [];
|
||||
}
|
||||
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const complete = await cache.get(CACHE_NAME, userId);
|
||||
if (complete && Array.isArray(complete)) {
|
||||
movies = complete;
|
||||
isComplete = true;
|
||||
rebuildProviderIndex(movies);
|
||||
LOG(`Using complete cached movies (${movies.length})`);
|
||||
return movies;
|
||||
}
|
||||
|
||||
const chunkSize = getChunkSize();
|
||||
let startIndex = 0;
|
||||
let collected = [];
|
||||
|
||||
const partial = await cache.get(PARTIAL_CACHE_NAME, userId);
|
||||
if (partial && Array.isArray(partial.items) && partial.isComplete !== true) {
|
||||
collected = partial.items;
|
||||
startIndex = partial.startIndex || collected.length;
|
||||
LOG(`Resuming movie crawl from index ${startIndex} (${collected.length} items)`);
|
||||
}
|
||||
|
||||
try {
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const url = buildItemsUrl({ startIndex, limit: chunkSize });
|
||||
const data = await queryItems(url);
|
||||
const batch = data.Items || [];
|
||||
const stripped = await stripChunk(batch);
|
||||
collected = collected.concat(stripped);
|
||||
startIndex += batch.length;
|
||||
|
||||
await cache.set(PARTIAL_CACHE_NAME, {
|
||||
isComplete: false,
|
||||
startIndex,
|
||||
items: collected
|
||||
}, userId, getTtl());
|
||||
|
||||
LOG(`Movie crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`);
|
||||
|
||||
if (batch.length < chunkSize) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
await yieldToMain();
|
||||
}
|
||||
}
|
||||
|
||||
movies = collected;
|
||||
isComplete = true;
|
||||
rebuildProviderIndex(movies);
|
||||
await cache.set(CACHE_NAME, movies, userId, getTtl());
|
||||
await cache.clear(PARTIAL_CACHE_NAME, userId);
|
||||
setLastDateFetched(new Date().toISOString());
|
||||
LOG(`Movie crawl complete: ${movies.length} items`);
|
||||
return movies;
|
||||
} catch (err) {
|
||||
ERR('Error fetching movies:', err);
|
||||
return movies || collected || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the cache
|
||||
* Incremental sync: upsert by MinDateLastSaved, remove orphans via TotalRecordCount + Id list.
|
||||
*/
|
||||
async function initialize() {
|
||||
if (isInitializing) return;
|
||||
|
||||
if (movies) return;
|
||||
|
||||
isInitializing = true;
|
||||
try {
|
||||
movies = await fetchAndCacheMovies();
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
async function syncMoviesCache() {
|
||||
if (!isComplete || !movies) {
|
||||
LOG('Skip syncMoviesCache: complete cache not ready');
|
||||
return movies || [];
|
||||
}
|
||||
const lastFetched = getLastDateFetched();
|
||||
if (!lastFetched) {
|
||||
LOG('Skip syncMoviesCache: no lastDateFetched');
|
||||
return movies;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches IMDb Top 250 movies using client-side filtering
|
||||
* @returns {Promise<Array>} - List of matched Movie items
|
||||
*/
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const chunkSize = getChunkSize();
|
||||
|
||||
try {
|
||||
// Additions / metadata refreshes
|
||||
let startIndex = 0;
|
||||
let incoming = [];
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const url = buildItemsUrl({
|
||||
startIndex,
|
||||
limit: chunkSize,
|
||||
extraParams: {
|
||||
MinDateLastSaved: lastFetched,
|
||||
MinDateLastSavedForUser: lastFetched,
|
||||
SortBy: 'DateCreated',
|
||||
SortOrder: 'Descending'
|
||||
}
|
||||
});
|
||||
const data = await queryItems(url);
|
||||
const batch = data.Items || [];
|
||||
incoming = incoming.concat(await stripChunk(batch));
|
||||
startIndex += batch.length;
|
||||
if (batch.length < chunkSize) hasMore = false;
|
||||
}
|
||||
|
||||
const beforeIds = new Set((movies || []).map((m) => m.Id).filter(Boolean));
|
||||
let removedIds = [];
|
||||
|
||||
if (incoming.length) {
|
||||
movies = utils().upsertById?.(movies, incoming) || movies;
|
||||
LOG(`Synced ${incoming.length} movie upserts`);
|
||||
}
|
||||
|
||||
// Count check
|
||||
const countUrl = buildItemsUrl({
|
||||
startIndex: 0,
|
||||
limit: 1,
|
||||
extraParams: {
|
||||
Fields: '',
|
||||
EnableTotalRecordCount: 'true'
|
||||
}
|
||||
});
|
||||
// Rebuild without heavy Fields for count
|
||||
const server = window.ApiClient.serverAddress();
|
||||
const countParams = new URLSearchParams({
|
||||
IncludeItemTypes: 'Movie',
|
||||
Recursive: 'true',
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'true',
|
||||
Limit: '1',
|
||||
StartIndex: '0'
|
||||
});
|
||||
const countData = await queryItems(`${server}/Items?${countParams.toString()}`);
|
||||
const serverCount = countData.TotalRecordCount;
|
||||
if (typeof serverCount === 'number' && serverCount !== movies.length) {
|
||||
LOG(`Count mismatch cache=${movies.length} server=${serverCount}; reconciling ids`);
|
||||
const serverIds = new Set();
|
||||
let idx = 0;
|
||||
let more = true;
|
||||
while (more) {
|
||||
const idParams = new URLSearchParams({
|
||||
IncludeItemTypes: 'Movie',
|
||||
Recursive: 'true',
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'false',
|
||||
Limit: String(chunkSize),
|
||||
StartIndex: String(idx),
|
||||
Fields: ''
|
||||
});
|
||||
const idData = await queryItems(`${server}/Items?${idParams.toString()}`);
|
||||
const batch = idData.Items || [];
|
||||
batch.forEach((item) => {
|
||||
if (item?.Id) serverIds.add(item.Id);
|
||||
});
|
||||
idx += batch.length;
|
||||
if (batch.length < chunkSize) more = false;
|
||||
}
|
||||
const before = movies.length;
|
||||
removedIds = [...beforeIds].filter((id) => !serverIds.has(id));
|
||||
movies = movies.filter((m) => m?.Id && serverIds.has(m.Id));
|
||||
// Add any missing ids not in cache (light fetch would lack fields — full upsert via Id list)
|
||||
const cachedIds = new Set(movies.map((m) => m.Id));
|
||||
const missing = [...serverIds].filter((id) => !cachedIds.has(id));
|
||||
if (missing.length) {
|
||||
WARN(`${missing.length} movies on server missing from cache after reconcile; fetching by Ids`);
|
||||
for (let i = 0; i < missing.length; i += chunkSize) {
|
||||
const slice = missing.slice(i, i + chunkSize);
|
||||
const byIdParams = new URLSearchParams({
|
||||
Ids: slice.join(','),
|
||||
Fields: MOVIE_FIELDS,
|
||||
EnableTotalRecordCount: 'false'
|
||||
});
|
||||
const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`);
|
||||
const stripped = await stripChunk(byIdData.Items || []);
|
||||
movies = utils().upsertById?.(movies, stripped) || movies;
|
||||
incoming = incoming.concat(stripped);
|
||||
}
|
||||
}
|
||||
LOG(`Reconcile complete: ${before} → ${movies.length}`);
|
||||
}
|
||||
|
||||
rebuildProviderIndex(movies);
|
||||
await cache.set(CACHE_NAME, movies, userId, getTtl());
|
||||
setLastDateFetched(new Date().toISOString());
|
||||
// Notify people cache of deltas when available
|
||||
if ((incoming.length || removedIds.length) && window.PeopleCache?.applyMovieDelta) {
|
||||
try {
|
||||
await window.PeopleCache.applyMovieDelta(incoming, removedIds);
|
||||
} catch (e) {
|
||||
WARN('PeopleCache.applyMovieDelta failed:', e);
|
||||
}
|
||||
}
|
||||
return movies;
|
||||
} catch (err) {
|
||||
ERR('syncMoviesCache failed:', err);
|
||||
return movies || [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getMovies() {
|
||||
if (movies && isComplete) return movies;
|
||||
if (fetchPromise) return fetchPromise;
|
||||
fetchPromise = (async () => {
|
||||
await fetchAndCacheMovies();
|
||||
return movies || [];
|
||||
})().finally(() => {
|
||||
fetchPromise = null;
|
||||
});
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
function isCacheComplete() {
|
||||
return isComplete === true && Array.isArray(movies);
|
||||
}
|
||||
|
||||
async function getMoviesByProviderIds(ids) {
|
||||
const list = await getMovies();
|
||||
const idSet = new Set();
|
||||
(ids || []).forEach((id) => {
|
||||
(utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k));
|
||||
});
|
||||
if (!idSet.size) return [];
|
||||
return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet));
|
||||
}
|
||||
|
||||
async function fetchImdbTop250Data() {
|
||||
const CACHE_KEY_IDS = 'kefinTweaks_imdbTop250_ids';
|
||||
|
||||
let imdbIds = null;
|
||||
|
||||
// 1. Get IDs (Local Cache or Fetch GitHub)
|
||||
try {
|
||||
const cachedIds = localStorage.getItem(CACHE_KEY_IDS);
|
||||
if (cachedIds) {
|
||||
@@ -100,7 +338,9 @@
|
||||
imdbIds = parsed.ids;
|
||||
}
|
||||
}
|
||||
} catch (e) { WARN('Error reading IMDb Top 250 cache:', e); }
|
||||
} catch (e) {
|
||||
WARN('Error reading IMDb Top 250 cache:', e);
|
||||
}
|
||||
|
||||
if (!imdbIds) {
|
||||
LOG('Fetching IMDb Top 250 list from GitHub...');
|
||||
@@ -108,15 +348,10 @@
|
||||
const response = await fetch('https://raw.githubusercontent.com/theapache64/top250/master/top250_min.json');
|
||||
if (!response.ok) throw new Error('Failed to fetch Top 250 JSON');
|
||||
const data = await response.json();
|
||||
|
||||
// Extract IDs
|
||||
imdbIds = data.map(entry => {
|
||||
// entry.imdb_url e.g. "http://www.imdb.com/title/tt0111161/"
|
||||
imdbIds = data.map((entry) => {
|
||||
const match = entry.imdb_url && entry.imdb_url.match(/\/title\/(tt\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
// Cache
|
||||
localStorage.setItem(CACHE_KEY_IDS, JSON.stringify({
|
||||
timestamp: Date.now(),
|
||||
ids: imdbIds
|
||||
@@ -126,61 +361,127 @@
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!imdbIds || imdbIds.length === 0) return [];
|
||||
|
||||
return imdbIds;
|
||||
return imdbIds || [];
|
||||
}
|
||||
|
||||
async function getImdbTop250Movies() {
|
||||
const cacheName = 'imdbTop250Movies';
|
||||
const cache = window.IndexedDBCache;
|
||||
const cached = await cache.get(cacheName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (cached) return cached;
|
||||
|
||||
if (!imdbTop250Movies) {
|
||||
if (!isCacheComplete()) {
|
||||
LOG('IMDb Top 250 waiting for movie cache…');
|
||||
}
|
||||
const imdbIds = await fetchImdbTop250Data();
|
||||
const allMovies = await getMovies();
|
||||
|
||||
// 3. Client-side Match
|
||||
const matchedMovies = [];
|
||||
const idsSet = new Set(imdbIds); // Faster lookup
|
||||
|
||||
for (const movie of allMovies) {
|
||||
if (movie.ProviderIds && movie.ProviderIds.Imdb && idsSet.has(movie.ProviderIds.Imdb)) {
|
||||
matchedMovies.push(movie);
|
||||
}
|
||||
}
|
||||
imdbTop250Movies = matchedMovies;
|
||||
const idsSet = new Set(imdbIds);
|
||||
imdbTop250Movies = allMovies.filter(
|
||||
(movie) => movie.ProviderIds?.Imdb && idsSet.has(movie.ProviderIds.Imdb)
|
||||
);
|
||||
}
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
await cache.set(cacheName, imdbTop250Movies, userId, IMDB_TOP_250_CACHE_TTL);
|
||||
return imdbTop250Movies || [];
|
||||
}
|
||||
|
||||
async function getMovies() {
|
||||
if (!movies) {
|
||||
movies = await fetchAndCacheMovies();
|
||||
async function initialize() {
|
||||
await getMovies();
|
||||
if (isCacheComplete() && getLastDateFetched()) {
|
||||
await syncMoviesCache();
|
||||
}
|
||||
return movies || [];
|
||||
}
|
||||
|
||||
// Expose API
|
||||
async function waitForLoginReady(maxWaitMs = 60000) {
|
||||
const start = Date.now();
|
||||
while (!window.userHelper?.waitForLogin) {
|
||||
if (Date.now() - start >= maxWaitMs) {
|
||||
ERR('Timed out waiting for userHelper');
|
||||
return false;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return window.userHelper.waitForLogin(maxWaitMs);
|
||||
}
|
||||
|
||||
function yieldToMain() {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(() => resolve(), { timeout: 500 });
|
||||
} else {
|
||||
requestAnimationFrame(() => setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let bootstrapStarted = false;
|
||||
let bootstrapScheduled = false;
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapStarted) return;
|
||||
bootstrapStarted = true;
|
||||
try {
|
||||
await waitForLoginReady();
|
||||
await initialize();
|
||||
} catch (e) {
|
||||
ERR('Bootstrap failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Defer crawl until home has painted (or 3s after login) so home getQuery is not starved. */
|
||||
function scheduleBootstrap() {
|
||||
if (bootstrapScheduled) return;
|
||||
bootstrapScheduled = true;
|
||||
|
||||
const start = () => { bootstrap(); };
|
||||
|
||||
const onPainted = () => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
};
|
||||
document.addEventListener('kefinTweaksHomePainted', onPainted);
|
||||
|
||||
waitForLoginReady().then((ok) => {
|
||||
if (!ok) {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
window.MoviesCache = {
|
||||
init: initialize,
|
||||
getMovies: getMovies,
|
||||
getImdbTop250Movies: getImdbTop250Movies,
|
||||
// Also expose the fetcher if we need to force refresh
|
||||
getMovies,
|
||||
getImdbTop250Movies,
|
||||
getMoviesByProviderIds,
|
||||
syncMoviesCache,
|
||||
fetchAndCacheMovies,
|
||||
isComplete: isCacheComplete,
|
||||
getLastDateFetched,
|
||||
bootstrap,
|
||||
scheduleBootstrap,
|
||||
refresh: async () => {
|
||||
const cache = window.IndexedDBCache;
|
||||
cache.delete('movies_library');
|
||||
movies = await fetchAndCacheMovies();
|
||||
return movies;
|
||||
const userId = window.ApiClient?.getCurrentUserId?.();
|
||||
if (cache) {
|
||||
await cache.clear(CACHE_NAME, userId);
|
||||
await cache.clear(PARTIAL_CACHE_NAME, userId);
|
||||
}
|
||||
movies = null;
|
||||
isComplete = false;
|
||||
providerIndex = null;
|
||||
imdbTop250Movies = null;
|
||||
return fetchAndCacheMovies();
|
||||
}
|
||||
};
|
||||
|
||||
scheduleBootstrap();
|
||||
LOG('Module loaded');
|
||||
|
||||
})();
|
||||
|
||||
+581
-272
@@ -1,5 +1,5 @@
|
||||
// KefinTweaks People Cache Manager
|
||||
// Handles fetching, processing, and caching of top actors, directors, and writers
|
||||
// KefinTweaks People Cache
|
||||
// Built from MoviesCache + SeriesCache; episode credits via /Shows/{id}/Episodes (not a global episode store).
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
@@ -7,338 +7,647 @@
|
||||
const WARN = (...args) => console.warn('[KefinTweaks PeopleCache]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks PeopleCache]', ...args);
|
||||
|
||||
// Configuration
|
||||
const MIN_PEOPLE_APPEARANCES = 2; // Minimum number of items a person must appear in to be tracked
|
||||
const CACHE_NAME = 'library_top_people_v2';
|
||||
const DEFAULT_MIN = 10;
|
||||
const DEFAULT_PER_TYPE_MAX = 100;
|
||||
const EPISODE_FETCH_CONCURRENCY = 6;
|
||||
|
||||
// State
|
||||
let moviesTopPeople = null;
|
||||
let peopleMap = null; // Map<personId, Person>
|
||||
let filteredLists = null; // { actors, directors, writers }
|
||||
let isComplete = false;
|
||||
/** @type {Promise<*>|null} Shared so concurrent callers await the same paginated fetch */
|
||||
let fetchPromise = null;
|
||||
/** @type {Promise<void>|null} Shared so concurrent callers await the same build */
|
||||
let initPromise = null;
|
||||
|
||||
/**
|
||||
* Extracts necessary item data for caching (similar to watchlist format)
|
||||
*/
|
||||
function extractItemDataForCache(item) {
|
||||
function getHomeSettings() {
|
||||
return window.KefinHomeScreen?.getConfig?.()?.HOME_SETTINGS
|
||||
|| window.KefinHomeConfig2?.HOME_SETTINGS
|
||||
|| {};
|
||||
}
|
||||
|
||||
function getMins() {
|
||||
const home = getHomeSettings();
|
||||
return {
|
||||
Id: item.Id,
|
||||
Type: item.Type,
|
||||
Name: item.Name,
|
||||
SeriesId: item.SeriesId,
|
||||
SeriesName: item.SeriesName,
|
||||
IndexNumber: item.IndexNumber,
|
||||
ParentIndexNumber: item.ParentIndexNumber,
|
||||
PremiereDate: item.PremiereDate,
|
||||
ProductionYear: item.ProductionYear,
|
||||
UserData: {
|
||||
Played: item.UserData?.Played || false,
|
||||
IsFavorite: item.UserData?.IsFavorite || false
|
||||
}
|
||||
total: home.minPeopleAppearancesTotal ?? DEFAULT_MIN,
|
||||
movies: home.minPeopleAppearancesMovies ?? DEFAULT_MIN,
|
||||
series: home.minPeopleAppearancesSeries ?? DEFAULT_MIN,
|
||||
episodes: home.minPeopleAppearancesEpisodes ?? DEFAULT_MIN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes people data from movies array and returns RAW data (all people, unfiltered)
|
||||
*/
|
||||
function processPeopleDataRaw(movies, existingPeopleMap = null) {
|
||||
try {
|
||||
const peopleMap = existingPeopleMap ? new Map(existingPeopleMap) : new Map();
|
||||
|
||||
movies.forEach(movie => {
|
||||
// Extract item data for caching
|
||||
const itemData = extractItemDataForCache(movie);
|
||||
|
||||
if (movie.People) {
|
||||
movie.People.forEach(person => {
|
||||
const key = person.Id;
|
||||
if (!peopleMap.has(key)) {
|
||||
peopleMap.set(key, {
|
||||
...person,
|
||||
directorCount: 0,
|
||||
writerCount: 0,
|
||||
actorCount: 0,
|
||||
directorItems: [],
|
||||
writerItems: [],
|
||||
actorItems: []
|
||||
});
|
||||
}
|
||||
|
||||
const personData = peopleMap.get(key);
|
||||
if (person.Type === 'Director') {
|
||||
personData.directorCount++;
|
||||
if (!personData.directorItems.some(item => item.Id === movie.Id)) {
|
||||
personData.directorItems.push(itemData);
|
||||
}
|
||||
} else if (person.Type === 'Writer') {
|
||||
personData.writerCount++;
|
||||
if (!personData.writerItems.some(item => item.Id === movie.Id)) {
|
||||
personData.writerItems.push(itemData);
|
||||
}
|
||||
} else if (person.Type === 'Actor') {
|
||||
personData.actorCount++;
|
||||
if (!personData.actorItems.some(item => item.Id === movie.Id)) {
|
||||
personData.actorItems.push(itemData);
|
||||
}
|
||||
}
|
||||
});
|
||||
function getPerTypeMaxCount() {
|
||||
const max = getHomeSettings().maxPeopleCount;
|
||||
const n = parseInt(max, 10);
|
||||
if (Number.isFinite(n) && n > 0) return Math.min(n, DEFAULT_PER_TYPE_MAX);
|
||||
return DEFAULT_PER_TYPE_MAX;
|
||||
}
|
||||
|
||||
function shouldLoadPeopleEpisodeData() {
|
||||
return getHomeSettings().loadPeopleEpisodeData === true;
|
||||
}
|
||||
|
||||
function isAggregateItemType(itemType) {
|
||||
return itemType == null || itemType === '' || itemType === 'All';
|
||||
}
|
||||
|
||||
function movieRoleCount(person, role) {
|
||||
const m = person.movies || {};
|
||||
if (role === 'Actor') return m.actorCount || 0;
|
||||
if (role === 'Director') return m.directorCount || 0;
|
||||
if (role === 'Writer') return m.writerCount || 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function episodeRoleCount(person, role) {
|
||||
const s = person.series || {};
|
||||
if (role === 'Actor') return s.actorCount || 0;
|
||||
if (role === 'Director') return s.directorCount || 0;
|
||||
if (role === 'Writer') return s.writerCount || 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function totalAppearances(person) {
|
||||
const m = person.movies || {};
|
||||
const s = person.series || {};
|
||||
return (m.actorCount || 0)
|
||||
+ (m.directorCount || 0)
|
||||
+ (m.writerCount || 0)
|
||||
+ (s.count || 0)
|
||||
+ (s.actorCount || 0)
|
||||
+ (s.directorCount || 0)
|
||||
+ (s.writerCount || 0);
|
||||
}
|
||||
|
||||
function personMeetsMin(person, mins) {
|
||||
const m = person.movies || {};
|
||||
const s = person.series || {};
|
||||
const episodeMax = Math.max(s.actorCount || 0, s.directorCount || 0, s.writerCount || 0);
|
||||
return (m.actorCount >= mins.movies)
|
||||
|| (m.directorCount >= mins.movies)
|
||||
|| (m.writerCount >= mins.movies)
|
||||
|| ((s.count || 0) >= mins.series)
|
||||
|| (episodeMax >= mins.episodes)
|
||||
|| (totalAppearances(person) >= mins.total);
|
||||
}
|
||||
|
||||
function prunePeopleMap(map) {
|
||||
if (!map) return map;
|
||||
const mins = getMins();
|
||||
const beforeMin = map.size;
|
||||
for (const [id, person] of map) {
|
||||
if (!personMeetsMin(person, mins)) map.delete(id);
|
||||
}
|
||||
if (beforeMin !== map.size) {
|
||||
LOG(`People prune (mins): ${beforeMin} → ${map.size}`);
|
||||
}
|
||||
|
||||
const maxPerBucket = getPerTypeMaxCount();
|
||||
const keepIds = new Set();
|
||||
const itemTypes = ['Movie', 'Series', 'Episode', null];
|
||||
const roles = ['Actor', 'Director', 'Writer'];
|
||||
for (const itemType of itemTypes) {
|
||||
for (const role of roles) {
|
||||
filterRoleList(role, itemType).slice(0, maxPerBucket).forEach((p) => keepIds.add(p.Id));
|
||||
}
|
||||
}
|
||||
const beforeMax = map.size;
|
||||
for (const id of Array.from(map.keys())) {
|
||||
if (!keepIds.has(id)) map.delete(id);
|
||||
}
|
||||
if (beforeMax !== map.size) {
|
||||
LOG(`People prune (per-type max ${maxPerBucket}): ${beforeMax} → ${map.size}`);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function ensurePerson(map, person) {
|
||||
const key = person?.Id;
|
||||
if (!key) return null;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
Id: person.Id,
|
||||
Name: person.Name,
|
||||
movies: {
|
||||
directorCount: 0,
|
||||
writerCount: 0,
|
||||
actorCount: 0,
|
||||
directorItems: [],
|
||||
writerItems: [],
|
||||
actorItems: []
|
||||
},
|
||||
series: {
|
||||
count: 0,
|
||||
items: [],
|
||||
actorCount: 0,
|
||||
directorCount: 0,
|
||||
writerCount: 0
|
||||
}
|
||||
});
|
||||
|
||||
return peopleMap;
|
||||
} catch (err) {
|
||||
ERR('Failed to process people data:', err);
|
||||
return null;
|
||||
} else if (person.Name && !map.get(key).Name) {
|
||||
map.get(key).Name = person.Name;
|
||||
}
|
||||
return map.get(key);
|
||||
}
|
||||
|
||||
function pushUniqueId(arr, id) {
|
||||
if (!id) return false;
|
||||
if (arr.includes(id)) return false;
|
||||
arr.push(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function bumpEpisodeRole(series, type, delta) {
|
||||
if (!series || !delta) return;
|
||||
if (type === 'Director') {
|
||||
series.directorCount = Math.max(0, (series.directorCount || 0) + delta);
|
||||
} else if (type === 'Writer') {
|
||||
series.writerCount = Math.max(0, (series.writerCount || 0) + delta);
|
||||
} else if (type === 'Actor') {
|
||||
series.actorCount = Math.max(0, (series.actorCount || 0) + delta);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMoviePeople(map, movie) {
|
||||
if (!movie?.People || !movie.Id) return;
|
||||
movie.People.forEach((person) => {
|
||||
const personData = ensurePerson(map, person);
|
||||
if (!personData) return;
|
||||
const m = personData.movies;
|
||||
if (person.Type === 'Director') {
|
||||
if (pushUniqueId(m.directorItems, movie.Id)) m.directorCount = m.directorItems.length;
|
||||
} else if (person.Type === 'Writer') {
|
||||
if (pushUniqueId(m.writerItems, movie.Id)) m.writerCount = m.writerItems.length;
|
||||
} else if (person.Type === 'Actor') {
|
||||
if (pushUniqueId(m.actorItems, movie.Id)) m.actorCount = m.actorItems.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applySeriesPeople(map, seriesItem) {
|
||||
if (!seriesItem?.People || !seriesItem.Id) return;
|
||||
seriesItem.People.forEach((person) => {
|
||||
const personData = ensurePerson(map, person);
|
||||
if (!personData) return;
|
||||
if (pushUniqueId(personData.series.items, seriesItem.Id)) {
|
||||
personData.series.count = personData.series.items.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyEpisodePeople(map, seriesId, episode) {
|
||||
if (!episode?.People || !episode.Id || !seriesId) return;
|
||||
episode.People.forEach((person) => {
|
||||
const personData = ensurePerson(map, person);
|
||||
if (!personData) return;
|
||||
bumpEpisodeRole(personData.series, person.Type, 1);
|
||||
});
|
||||
}
|
||||
|
||||
function decrementEpisodePeople(map, episode) {
|
||||
if (!episode?.People || !map) return;
|
||||
episode.People.forEach((person) => {
|
||||
const personData = person?.Id ? map.get(person.Id) : null;
|
||||
if (!personData) return;
|
||||
bumpEpisodeRole(personData.series, person.Type, -1);
|
||||
});
|
||||
}
|
||||
|
||||
function removeMovieFromPeople(map, movieId) {
|
||||
if (!map || !movieId) return;
|
||||
map.forEach((person) => {
|
||||
const m = person.movies;
|
||||
['directorItems', 'writerItems', 'actorItems'].forEach((key) => {
|
||||
const before = m[key].length;
|
||||
m[key] = m[key].filter((id) => id !== movieId);
|
||||
if (m[key].length !== before) {
|
||||
if (key === 'directorItems') m.directorCount = m[key].length;
|
||||
if (key === 'writerItems') m.writerCount = m[key].length;
|
||||
if (key === 'actorItems') m.actorCount = m[key].length;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeSeriesMembership(map, seriesId) {
|
||||
if (!map || !seriesId) return;
|
||||
map.forEach((person) => {
|
||||
person.series.items = person.series.items.filter((id) => id !== seriesId);
|
||||
person.series.count = person.series.items.length;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchEpisodesForSeries(seriesId) {
|
||||
const api = window.apiHelper || window.ApiHelper;
|
||||
const server = window.ApiClient.serverAddress();
|
||||
const url = `${server}/Shows/${seriesId}/Episodes?Fields=People&EnableTotalRecordCount=false`;
|
||||
const data = await api.getQuery(url, { useCache: false });
|
||||
return data?.Items || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters and sorts raw people data
|
||||
* Fetch episodes for many series with limited concurrency.
|
||||
* @param {Array<{Id: string}|string>} seriesList
|
||||
* @param {(seriesId: string, episodes: Array) => void} onSeriesEpisodes
|
||||
*/
|
||||
function filterPeopleData(peopleMap) {
|
||||
async function fetchEpisodesForSeriesBatched(seriesList, onSeriesEpisodes) {
|
||||
const list = (seriesList || [])
|
||||
.map((s) => (typeof s === 'string' ? { Id: s } : s))
|
||||
.filter((s) => s?.Id);
|
||||
let completed = 0;
|
||||
for (let i = 0; i < list.length; i += EPISODE_FETCH_CONCURRENCY) {
|
||||
const chunk = list.slice(i, i + EPISODE_FETCH_CONCURRENCY);
|
||||
await Promise.all(chunk.map(async (s) => {
|
||||
try {
|
||||
const episodes = await fetchEpisodesForSeries(s.Id);
|
||||
onSeriesEpisodes(s.Id, episodes);
|
||||
} catch (e) {
|
||||
WARN(`Failed episodes for series ${s.Id}:`, e);
|
||||
}
|
||||
}));
|
||||
completed += chunk.length;
|
||||
const prev = completed - chunk.length;
|
||||
if (completed === list.length || Math.floor(prev / 25) !== Math.floor(completed / 25)) {
|
||||
LOG(`Episode people progress: ${completed}/${list.length} series`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function subtractEpisodeCreditsForSeriesIds(map, seriesIds) {
|
||||
const ids = (seriesIds || []).filter(Boolean);
|
||||
if (!ids.length || !map) return;
|
||||
await fetchEpisodesForSeriesBatched(ids, (_seriesId, episodes) => {
|
||||
episodes.forEach((ep) => decrementEpisodePeople(map, ep));
|
||||
});
|
||||
}
|
||||
|
||||
function clearEpisodeRoleCounts(map) {
|
||||
if (!map) return;
|
||||
map.forEach((person) => {
|
||||
person.series.actorCount = 0;
|
||||
person.series.directorCount = 0;
|
||||
person.series.writerCount = 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function rebuildAllEpisodeRoleCounts(map) {
|
||||
clearEpisodeRoleCounts(map);
|
||||
const seriesList = await window.SeriesCache?.getSeries?.() || [];
|
||||
await fetchEpisodesForSeriesBatched(seriesList, (seriesId, episodes) => {
|
||||
episodes.forEach((ep) => applyEpisodePeople(map, seriesId, ep));
|
||||
});
|
||||
}
|
||||
|
||||
function roleCount(person, role, itemType) {
|
||||
const movieCount = movieRoleCount(person, role);
|
||||
const seriesCount = person.series?.count || 0;
|
||||
const episodeCount = episodeRoleCount(person, role);
|
||||
if (itemType === 'Movie') return movieCount;
|
||||
if (itemType === 'Series') return seriesCount;
|
||||
if (itemType === 'Episode') return episodeCount;
|
||||
if (isAggregateItemType(itemType)) return movieCount + seriesCount + episodeCount;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function roleItems(person, role, itemType) {
|
||||
if (itemType === 'Movie') {
|
||||
if (role === 'Actor') return person.movies.actorItems || [];
|
||||
if (role === 'Director') return person.movies.directorItems || [];
|
||||
if (role === 'Writer') return person.movies.writerItems || [];
|
||||
return [];
|
||||
}
|
||||
if (itemType === 'Series') return person.series.items || [];
|
||||
if (itemType === 'Episode') return [];
|
||||
if (isAggregateItemType(itemType)) {
|
||||
const movieItems = roleItems(person, role, 'Movie');
|
||||
const seriesItems = person.series.items || [];
|
||||
return [...movieItems, ...seriesItems];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function filterRoleList(role, itemType, minCount) {
|
||||
if (!peopleMap) return [];
|
||||
const mins = getMins();
|
||||
const type = isAggregateItemType(itemType) ? null : itemType;
|
||||
let threshold = minCount;
|
||||
if (threshold == null) {
|
||||
if (type === 'Movie') threshold = mins.movies;
|
||||
else if (type === 'Series') threshold = mins.series;
|
||||
else if (type === 'Episode') threshold = mins.episodes;
|
||||
else threshold = mins.total;
|
||||
}
|
||||
return Array.from(peopleMap.values())
|
||||
.map((person) => {
|
||||
const count = roleCount(person, role, type);
|
||||
return {
|
||||
Id: person.Id,
|
||||
Name: person.Name,
|
||||
count,
|
||||
items: roleItems(person, role, type),
|
||||
movies: person.movies,
|
||||
series: person.series
|
||||
};
|
||||
})
|
||||
.filter((p) => p.count >= threshold)
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
function rebuildFilteredLists() {
|
||||
filteredLists = {
|
||||
actors: filterRoleList('Actor', null),
|
||||
directors: filterRoleList('Director', null),
|
||||
writers: filterRoleList('Writer', null),
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
async function persistPeople() {
|
||||
const cache = window.IndexedDBCache;
|
||||
if (!cache || !peopleMap) return;
|
||||
prunePeopleMap(peopleMap);
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const ttl = window.LibraryCacheUtils?.getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
|
||||
const payload = {
|
||||
isComplete: true,
|
||||
loadPeopleEpisodeData: shouldLoadPeopleEpisodeData(),
|
||||
peopleData: Array.from(peopleMap.entries())
|
||||
};
|
||||
try {
|
||||
if (!peopleMap) return null;
|
||||
|
||||
const allPeople = Array.from(peopleMap.values());
|
||||
|
||||
// Get threshold from config or default
|
||||
const minAppearances = (window.KefinTweaksConfig &&
|
||||
window.KefinTweaksConfig.homeScreen &&
|
||||
window.KefinTweaksConfig.homeScreen.discovery &&
|
||||
window.KefinTweaksConfig.homeScreen.discovery.minPeopleAppearances)
|
||||
|| MIN_PEOPLE_APPEARANCES;
|
||||
|
||||
const topDirectors = allPeople
|
||||
.filter(person => person.directorCount >= minAppearances)
|
||||
.sort((a, b) => b.directorCount - a.directorCount)
|
||||
.map(person => ({
|
||||
...person,
|
||||
count: person.directorCount,
|
||||
items: person.directorItems || []
|
||||
}));
|
||||
|
||||
const topWriters = allPeople
|
||||
.filter(person => person.writerCount >= minAppearances)
|
||||
.sort((a, b) => b.writerCount - a.writerCount)
|
||||
.map(person => ({
|
||||
...person,
|
||||
count: person.writerCount,
|
||||
items: person.writerItems || []
|
||||
}));
|
||||
|
||||
const topActors = allPeople
|
||||
.filter(person => person.actorCount >= minAppearances)
|
||||
.sort((a, b) => b.actorCount - a.actorCount)
|
||||
.map(person => ({
|
||||
...person,
|
||||
count: person.actorCount,
|
||||
items: person.actorItems || []
|
||||
}));
|
||||
|
||||
return {
|
||||
actors: topActors,
|
||||
directors: topDirectors,
|
||||
writers: topWriters
|
||||
};
|
||||
} catch (err) {
|
||||
ERR('Failed to filter people data:', err);
|
||||
return null;
|
||||
await cache.set(CACHE_NAME, payload, userId, ttl);
|
||||
} catch (e) {
|
||||
WARN('Failed to persist people cache:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and processes top people data from all movies using pagination
|
||||
*/
|
||||
async function fetchTopPeople() {
|
||||
if (fetchPromise) {
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
async function fetchAndCachePeople() {
|
||||
if (fetchPromise) return fetchPromise;
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
LOG('Starting paginated fetch of movies for people data processing...');
|
||||
const apiClient = window.ApiClient;
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const userId = apiClient.getCurrentUserId();
|
||||
const indexedDBCache = window.IndexedDBCache;
|
||||
LOG('Building people cache from movies + series…');
|
||||
const movies = await window.MoviesCache.getMovies();
|
||||
const seriesList = await window.SeriesCache.getSeries();
|
||||
const map = new Map();
|
||||
|
||||
let startIndex = 0;
|
||||
const limit = 500;
|
||||
let hasMoreData = true;
|
||||
let peopleMap = null;
|
||||
(movies || []).forEach((movie) => applyMoviePeople(map, movie));
|
||||
(seriesList || []).forEach((s) => applySeriesPeople(map, s));
|
||||
|
||||
// Load existing raw people data if available (for resuming)
|
||||
const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId);
|
||||
if (existingRawData && !existingRawData.isComplete) {
|
||||
const moviesProcessed = existingRawData.moviesProcessedCount;
|
||||
if (moviesProcessed > 0) {
|
||||
startIndex = moviesProcessed;
|
||||
peopleMap = new Map(existingRawData.peopleData || []);
|
||||
LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`);
|
||||
}
|
||||
if (shouldLoadPeopleEpisodeData()) {
|
||||
await fetchEpisodesForSeriesBatched(seriesList, (seriesId, episodes) => {
|
||||
episodes.forEach((ep) => applyEpisodePeople(map, seriesId, ep));
|
||||
});
|
||||
} else {
|
||||
LOG('Skipping episode people fetch (loadPeopleEpisodeData disabled)');
|
||||
}
|
||||
|
||||
while (hasMoreData) {
|
||||
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`;
|
||||
|
||||
LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`);
|
||||
|
||||
const data = await window.apiHelper.getQuery(url);
|
||||
const movies = (data && data.Items) || [];
|
||||
|
||||
if (movies.length === 0) {
|
||||
hasMoreData = false;
|
||||
isComplete = true;
|
||||
} else {
|
||||
peopleMap = processPeopleDataRaw(movies, peopleMap);
|
||||
if (!peopleMap) {
|
||||
throw new Error('processPeopleDataRaw failed');
|
||||
}
|
||||
startIndex += movies.length;
|
||||
|
||||
// Update global state with partial results for immediate use
|
||||
const filteredData = filterPeopleData(peopleMap);
|
||||
if (filteredData) {
|
||||
moviesTopPeople = filteredData;
|
||||
}
|
||||
|
||||
// Checkpoint raw data (non-fatal if IndexedDB rejects large payloads)
|
||||
const rawDataToCache = {
|
||||
isComplete: movies.length < limit,
|
||||
moviesProcessedCount: startIndex,
|
||||
peopleData: Array.from(peopleMap.entries())
|
||||
};
|
||||
try {
|
||||
await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000);
|
||||
} catch (checkpointErr) {
|
||||
WARN('Failed to checkpoint raw people data (continuing):', checkpointErr);
|
||||
}
|
||||
|
||||
if (movies.length < limit) {
|
||||
hasMoreData = false;
|
||||
isComplete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalPeopleData = filterPeopleData(peopleMap) || {
|
||||
actors: [],
|
||||
directors: [],
|
||||
writers: []
|
||||
};
|
||||
finalPeopleData.isComplete = true;
|
||||
moviesTopPeople = finalPeopleData;
|
||||
peopleMap = map;
|
||||
prunePeopleMap(map);
|
||||
isComplete = true;
|
||||
|
||||
try {
|
||||
await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000);
|
||||
await indexedDBCache.clear('movies_top_people_raw', userId);
|
||||
} catch (saveErr) {
|
||||
WARN('Failed to persist complete people cache (in-memory still available):', saveErr);
|
||||
}
|
||||
|
||||
LOG(peopleMap ? 'People cache complete and saved' : 'People cache complete (empty library)');
|
||||
return finalPeopleData;
|
||||
rebuildFilteredLists();
|
||||
await persistPeople();
|
||||
LOG(`People cache complete: ${peopleMap.size} people`);
|
||||
return filteredLists;
|
||||
} catch (err) {
|
||||
ERR('Failed to fetch top people data:', err);
|
||||
ERR('fetchAndCachePeople failed:', err);
|
||||
return null;
|
||||
} finally {
|
||||
fetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the people cache (awaits build; concurrent callers share one promise)
|
||||
*/
|
||||
async function initialize() {
|
||||
if (moviesTopPeople !== null && isComplete) {
|
||||
return;
|
||||
}
|
||||
if (initPromise) {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
if (isComplete && peopleMap) return;
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
if (moviesTopPeople !== null && isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexedDBCache = window.IndexedDBCache;
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
|
||||
// Check for valid complete filtered data
|
||||
const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId);
|
||||
if (validComplete) {
|
||||
const cachedData = await indexedDBCache.get('movies_top_people', userId);
|
||||
if (cachedData && cachedData.isComplete) {
|
||||
moviesTopPeople = cachedData;
|
||||
isComplete = true;
|
||||
LOG('Loaded complete top people data from IndexedDB');
|
||||
return;
|
||||
if (cache && await cache.isCacheValid(CACHE_NAME, userId)) {
|
||||
const cached = await cache.get(CACHE_NAME, userId);
|
||||
if (cached?.isComplete && Array.isArray(cached.peopleData)) {
|
||||
const currentMode = shouldLoadPeopleEpisodeData();
|
||||
// Legacy caches (no flag) always included episode data
|
||||
const cachedMode = typeof cached.loadPeopleEpisodeData === 'boolean'
|
||||
? cached.loadPeopleEpisodeData
|
||||
: true;
|
||||
if (cachedMode !== currentMode) {
|
||||
LOG(`People cache episode mode mismatch (cached=${cachedMode}, current=${currentMode}); rebuilding`);
|
||||
await cache.clear?.(CACHE_NAME, userId);
|
||||
} else {
|
||||
peopleMap = new Map(cached.peopleData);
|
||||
const before = peopleMap.size;
|
||||
prunePeopleMap(peopleMap);
|
||||
isComplete = true;
|
||||
rebuildFilteredLists();
|
||||
LOG('Loaded people cache from IndexedDB');
|
||||
if (peopleMap.size < before) {
|
||||
await persistPeople();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await fetchTopPeople();
|
||||
await fetchAndCachePeople();
|
||||
} finally {
|
||||
initPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function getTopPeople() {
|
||||
LOG('Getting top people data');
|
||||
try {
|
||||
if (!moviesTopPeople) {
|
||||
await initialize();
|
||||
async function applyMovieDelta(changedMovies, removedIds) {
|
||||
if (!peopleMap) await initialize();
|
||||
if (!peopleMap) return;
|
||||
(removedIds || []).forEach((id) => removeMovieFromPeople(peopleMap, id));
|
||||
(changedMovies || []).forEach((movie) => {
|
||||
removeMovieFromPeople(peopleMap, movie.Id);
|
||||
applyMoviePeople(peopleMap, movie);
|
||||
});
|
||||
prunePeopleMap(peopleMap);
|
||||
rebuildFilteredLists();
|
||||
await persistPeople();
|
||||
}
|
||||
|
||||
async function applySeriesDelta(changedSeries, removedIds) {
|
||||
if (!peopleMap) await initialize();
|
||||
if (!peopleMap) return;
|
||||
|
||||
const removed = (removedIds || []).filter(Boolean);
|
||||
const changed = (changedSeries || []).filter((s) => s?.Id);
|
||||
const loadEpisodes = shouldLoadPeopleEpisodeData();
|
||||
|
||||
if (removed.length) {
|
||||
removed.forEach((id) => removeSeriesMembership(peopleMap, id));
|
||||
changed.forEach((s) => {
|
||||
removeSeriesMembership(peopleMap, s.Id);
|
||||
applySeriesPeople(peopleMap, s);
|
||||
});
|
||||
if (loadEpisodes) {
|
||||
// Without per-series episode indexes, any removal requires a full episode recount.
|
||||
await rebuildAllEpisodeRoleCounts(peopleMap);
|
||||
} else {
|
||||
clearEpisodeRoleCounts(peopleMap);
|
||||
}
|
||||
return moviesTopPeople;
|
||||
} catch (err) {
|
||||
ERR('Failed to get top people data:', err);
|
||||
return null;
|
||||
} else {
|
||||
if (loadEpisodes) {
|
||||
// Changed-only: decrement this series' episode credits, refresh membership, re-apply episodes.
|
||||
await subtractEpisodeCreditsForSeriesIds(peopleMap, changed.map((s) => s.Id));
|
||||
}
|
||||
changed.forEach((s) => {
|
||||
removeSeriesMembership(peopleMap, s.Id);
|
||||
applySeriesPeople(peopleMap, s);
|
||||
});
|
||||
if (loadEpisodes) {
|
||||
await fetchEpisodesForSeriesBatched(changed, (seriesId, episodes) => {
|
||||
episodes.forEach((ep) => applyEpisodePeople(peopleMap, seriesId, ep));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
prunePeopleMap(peopleMap);
|
||||
rebuildFilteredLists();
|
||||
await persistPeople();
|
||||
}
|
||||
|
||||
async function invalidate() {
|
||||
LOG('Invalidating people cache (in-memory + IndexedDB)');
|
||||
peopleMap = null;
|
||||
filteredLists = null;
|
||||
isComplete = false;
|
||||
fetchPromise = null;
|
||||
initPromise = null;
|
||||
try {
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient?.getCurrentUserId?.();
|
||||
if (cache?.clear) {
|
||||
await cache.clear(CACHE_NAME, userId);
|
||||
}
|
||||
} catch (e) {
|
||||
WARN('Failed to clear people IDB cache:', e);
|
||||
}
|
||||
// Rebuild on next demand; kick off in background if caches are ready
|
||||
try {
|
||||
initialize();
|
||||
} catch (e) {
|
||||
WARN('People cache rebuild after invalidate failed to start:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function getTopActors() {
|
||||
if (!moviesTopPeople) {
|
||||
async function getTopPeople() {
|
||||
if (!filteredLists) await initialize();
|
||||
return filteredLists;
|
||||
}
|
||||
|
||||
function parseRoleListArgs(itemTypeOrOptions, minCount) {
|
||||
if (itemTypeOrOptions && typeof itemTypeOrOptions === 'object') {
|
||||
return {
|
||||
itemType: itemTypeOrOptions.ItemType || itemTypeOrOptions.itemType || null,
|
||||
minCount: itemTypeOrOptions.MinCount ?? itemTypeOrOptions.minCount
|
||||
};
|
||||
}
|
||||
return { itemType: itemTypeOrOptions, minCount };
|
||||
}
|
||||
|
||||
async function getTopActors(itemTypeOrOptions = null, minCount) {
|
||||
if (!peopleMap) await initialize();
|
||||
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
|
||||
return filterRoleList('Actor', itemType, min);
|
||||
}
|
||||
|
||||
async function getTopDirectors(itemTypeOrOptions = null, minCount) {
|
||||
if (!peopleMap) await initialize();
|
||||
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
|
||||
return filterRoleList('Director', itemType, min);
|
||||
}
|
||||
|
||||
async function getTopWriters(itemTypeOrOptions = null, minCount) {
|
||||
if (!peopleMap) await initialize();
|
||||
const { itemType, minCount: min } = parseRoleListArgs(itemTypeOrOptions, minCount);
|
||||
return filterRoleList('Writer', itemType, min);
|
||||
}
|
||||
|
||||
async function resolveDiscoveryPeople(queryOptions = {}) {
|
||||
if (!peopleMap) await initialize();
|
||||
const personType = queryOptions.PersonType || queryOptions.personType || 'Actor';
|
||||
const itemType = queryOptions.ItemType || queryOptions.itemType || null;
|
||||
const limit = queryOptions.Limit || 100;
|
||||
let list = [];
|
||||
if (personType === 'Director') list = filterRoleList('Director', itemType);
|
||||
else if (personType === 'Writer') list = filterRoleList('Writer', itemType);
|
||||
else list = filterRoleList('Actor', itemType);
|
||||
return list.slice(0, limit).map((p) => ({ Id: p.Id, Name: p.Name, Type: 'Person' }));
|
||||
}
|
||||
|
||||
async function waitForLoginReady(maxWaitMs = 60000) {
|
||||
const start = Date.now();
|
||||
while (!window.userHelper?.waitForLogin) {
|
||||
if (Date.now() - start >= maxWaitMs) {
|
||||
ERR('Timed out waiting for userHelper');
|
||||
return false;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return window.userHelper.waitForLogin(maxWaitMs);
|
||||
}
|
||||
|
||||
let bootstrapStarted = false;
|
||||
let bootstrapScheduled = false;
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapStarted) return;
|
||||
bootstrapStarted = true;
|
||||
try {
|
||||
await waitForLoginReady();
|
||||
// Wait for library caches to finish first crawl
|
||||
if (window.MoviesCache?.getMovies) await window.MoviesCache.getMovies();
|
||||
if (window.SeriesCache?.getSeries) await window.SeriesCache.getSeries();
|
||||
await initialize();
|
||||
} catch (e) {
|
||||
ERR('Bootstrap failed:', e);
|
||||
}
|
||||
return moviesTopPeople?.actors ?? [];
|
||||
}
|
||||
|
||||
async function getTopDirectors() {
|
||||
if (!moviesTopPeople) {
|
||||
await initialize();
|
||||
}
|
||||
return moviesTopPeople?.directors ?? [];
|
||||
function scheduleBootstrap() {
|
||||
if (bootstrapScheduled) return;
|
||||
bootstrapScheduled = true;
|
||||
|
||||
const start = () => { bootstrap(); };
|
||||
|
||||
const onPainted = () => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
};
|
||||
document.addEventListener('kefinTweaksHomePainted', onPainted);
|
||||
|
||||
waitForLoginReady().then((ok) => {
|
||||
if (!ok) {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
async function getTopWriters() {
|
||||
if (!moviesTopPeople) {
|
||||
await initialize();
|
||||
}
|
||||
return moviesTopPeople?.writers ?? [];
|
||||
}
|
||||
|
||||
function isCacheComplete() {
|
||||
return isComplete;
|
||||
}
|
||||
|
||||
// Expose API
|
||||
window.PeopleCache = {
|
||||
init: initialize,
|
||||
getTopPeople: getTopPeople,
|
||||
getTopActors: getTopActors,
|
||||
getTopDirectors: getTopDirectors,
|
||||
getTopWriters: getTopWriters,
|
||||
isComplete: isCacheComplete
|
||||
fetchAndCachePeople,
|
||||
getTopPeople,
|
||||
getTopActors,
|
||||
getTopDirectors,
|
||||
getTopWriters,
|
||||
resolveDiscoveryPeople,
|
||||
applyMovieDelta,
|
||||
applySeriesDelta,
|
||||
invalidate,
|
||||
isComplete: () => isComplete === true,
|
||||
bootstrap,
|
||||
scheduleBootstrap
|
||||
};
|
||||
|
||||
scheduleBootstrap();
|
||||
LOG('Module loaded');
|
||||
|
||||
})();
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
// Only add button to cards with data-positionticks (in progress items)
|
||||
const positionTicks = card.getAttribute('data-positionticks');
|
||||
if (!positionTicks) {
|
||||
if (!positionTicks || positionTicks === '0') {
|
||||
return; // Not a continue watching item, skip
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
// KefinTweaks Series Library Cache
|
||||
// Chunked per-user archive of series (no UserData / MediaStreams / Chapters / Trickplay).
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const LOG = (...args) => console.log('[KefinTweaks Series Cache]', ...args);
|
||||
const WARN = (...args) => console.warn('[KefinTweaks Series Cache]', ...args);
|
||||
const ERR = (...args) => console.error('[KefinTweaks Series Cache]', ...args);
|
||||
|
||||
const CACHE_NAME = 'series_library';
|
||||
const PARTIAL_CACHE_NAME = 'series_library_partial';
|
||||
const LAST_FETCH_KEY_PREFIX = 'kefinTweaks_lastDateFetchedSeriesCache_';
|
||||
const SERIES_FIELDS = [
|
||||
'ProviderIds', 'People', 'Studios', 'Taglines', 'Genres', 'Overview',
|
||||
'PrimaryImageAspectRatio', 'ChildCount', 'RecursiveItemCount'
|
||||
].join(',');
|
||||
|
||||
let series = null;
|
||||
let isComplete = false;
|
||||
let fetchPromise = null;
|
||||
|
||||
function utils() {
|
||||
return window.LibraryCacheUtils || {};
|
||||
}
|
||||
|
||||
function getTtl() {
|
||||
return utils().getLibraryCacheSettings?.().CACHE_TTL || (14 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function getChunkSize() {
|
||||
return utils().getLibraryCacheSettings?.().seriesChunkSize || 1000;
|
||||
}
|
||||
|
||||
function lastFetchStorageKey() {
|
||||
const userId = window.ApiClient?.getCurrentUserId?.() || 'anonymous';
|
||||
return `${LAST_FETCH_KEY_PREFIX}${userId}`;
|
||||
}
|
||||
|
||||
function getLastDateFetched() {
|
||||
try {
|
||||
return localStorage.getItem(lastFetchStorageKey()) || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setLastDateFetched(iso) {
|
||||
try {
|
||||
localStorage.setItem(lastFetchStorageKey(), iso || new Date().toISOString());
|
||||
} catch (e) {
|
||||
WARN('Failed to store lastDateFetchedSeriesCache:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getApiHelper() {
|
||||
return window.apiHelper || window.ApiHelper;
|
||||
}
|
||||
|
||||
function buildItemsUrl({ startIndex, limit, extraParams = {} }) {
|
||||
const server = window.ApiClient.serverAddress();
|
||||
const params = new URLSearchParams({
|
||||
IncludeItemTypes: 'Series',
|
||||
Recursive: 'true',
|
||||
Fields: SERIES_FIELDS,
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'true',
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
StartIndex: String(startIndex || 0),
|
||||
Limit: String(limit)
|
||||
});
|
||||
Object.entries(extraParams).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') params.set(k, String(v));
|
||||
});
|
||||
return `${server}/Items?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function queryItems(url) {
|
||||
const api = getApiHelper();
|
||||
if (!api?.getQuery) throw new Error('apiHelper.getQuery unavailable');
|
||||
return (await api.getQuery(url, { useCache: false })) || { Items: [], TotalRecordCount: 0 };
|
||||
}
|
||||
|
||||
async function stripChunk(items) {
|
||||
if (utils().stripItemsAsync) {
|
||||
return utils().stripItemsAsync(items, 'Series');
|
||||
}
|
||||
return (items || []).map((item) => utils().stripSeriesForCache?.(item) || item).filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchAndCacheSeries() {
|
||||
const cache = window.IndexedDBCache;
|
||||
if (!cache) {
|
||||
ERR('IndexedDBCache unavailable');
|
||||
return [];
|
||||
}
|
||||
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const complete = await cache.get(CACHE_NAME, userId);
|
||||
if (complete && Array.isArray(complete)) {
|
||||
series = complete;
|
||||
isComplete = true;
|
||||
LOG(`Using complete cached series (${series.length})`);
|
||||
return series;
|
||||
}
|
||||
|
||||
const chunkSize = getChunkSize();
|
||||
let startIndex = 0;
|
||||
let collected = [];
|
||||
|
||||
const partial = await cache.get(PARTIAL_CACHE_NAME, userId);
|
||||
if (partial && Array.isArray(partial.items) && partial.isComplete !== true) {
|
||||
collected = partial.items;
|
||||
startIndex = partial.startIndex || collected.length;
|
||||
LOG(`Resuming series crawl from index ${startIndex}`);
|
||||
}
|
||||
|
||||
try {
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const data = await queryItems(buildItemsUrl({ startIndex, limit: chunkSize }));
|
||||
const batch = data.Items || [];
|
||||
collected = collected.concat(await stripChunk(batch));
|
||||
startIndex += batch.length;
|
||||
await cache.set(PARTIAL_CACHE_NAME, {
|
||||
isComplete: false,
|
||||
startIndex,
|
||||
items: collected
|
||||
}, userId, getTtl());
|
||||
LOG(`Series crawl progress: ${collected.length}${data.TotalRecordCount != null ? ` / ${data.TotalRecordCount}` : ''}`);
|
||||
if (batch.length < chunkSize) hasMore = false;
|
||||
else await yieldToMain();
|
||||
}
|
||||
|
||||
series = collected;
|
||||
isComplete = true;
|
||||
await cache.set(CACHE_NAME, series, userId, getTtl());
|
||||
await cache.clear(PARTIAL_CACHE_NAME, userId);
|
||||
setLastDateFetched(new Date().toISOString());
|
||||
LOG(`Series crawl complete: ${series.length} items`);
|
||||
return series;
|
||||
} catch (err) {
|
||||
ERR('Error fetching series:', err);
|
||||
return series || collected || [];
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSeriesCache() {
|
||||
if (!isComplete || !series) {
|
||||
LOG('Skip syncSeriesCache: complete cache not ready');
|
||||
return series || [];
|
||||
}
|
||||
const lastFetched = getLastDateFetched();
|
||||
if (!lastFetched) return series;
|
||||
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient.getCurrentUserId();
|
||||
const chunkSize = getChunkSize();
|
||||
const server = window.ApiClient.serverAddress();
|
||||
|
||||
try {
|
||||
let startIndex = 0;
|
||||
let incoming = [];
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const data = await queryItems(buildItemsUrl({
|
||||
startIndex,
|
||||
limit: chunkSize,
|
||||
extraParams: {
|
||||
MinDateLastSaved: lastFetched,
|
||||
MinDateLastSavedForUser: lastFetched,
|
||||
SortBy: 'DateLastContentAdded',
|
||||
SortOrder: 'Descending'
|
||||
}
|
||||
}));
|
||||
const batch = data.Items || [];
|
||||
incoming = incoming.concat(await stripChunk(batch));
|
||||
startIndex += batch.length;
|
||||
if (batch.length < chunkSize) hasMore = false;
|
||||
}
|
||||
|
||||
const beforeIds = new Set((series || []).map((s) => s.Id).filter(Boolean));
|
||||
let removedIds = [];
|
||||
|
||||
if (incoming.length) {
|
||||
series = utils().upsertById?.(series, incoming) || series;
|
||||
LOG(`Synced ${incoming.length} series upserts`);
|
||||
}
|
||||
|
||||
const countParams = new URLSearchParams({
|
||||
IncludeItemTypes: 'Series',
|
||||
Recursive: 'true',
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'true',
|
||||
Limit: '1',
|
||||
StartIndex: '0'
|
||||
});
|
||||
const countData = await queryItems(`${server}/Items?${countParams.toString()}`);
|
||||
const serverCount = countData.TotalRecordCount;
|
||||
if (typeof serverCount === 'number' && serverCount !== series.length) {
|
||||
LOG(`Series count mismatch cache=${series.length} server=${serverCount}`);
|
||||
const serverIds = new Set();
|
||||
let idx = 0;
|
||||
let more = true;
|
||||
while (more) {
|
||||
const idParams = new URLSearchParams({
|
||||
IncludeItemTypes: 'Series',
|
||||
Recursive: 'true',
|
||||
ExcludeLocationTypes: 'Virtual',
|
||||
EnableTotalRecordCount: 'false',
|
||||
Limit: String(chunkSize),
|
||||
StartIndex: String(idx)
|
||||
});
|
||||
const idData = await queryItems(`${server}/Items?${idParams.toString()}`);
|
||||
const batch = idData.Items || [];
|
||||
batch.forEach((item) => {
|
||||
if (item?.Id) serverIds.add(item.Id);
|
||||
});
|
||||
idx += batch.length;
|
||||
if (batch.length < chunkSize) more = false;
|
||||
}
|
||||
removedIds = [...beforeIds].filter((id) => !serverIds.has(id));
|
||||
series = series.filter((s) => s?.Id && serverIds.has(s.Id));
|
||||
const cachedIds = new Set(series.map((s) => s.Id));
|
||||
const missing = [...serverIds].filter((id) => !cachedIds.has(id));
|
||||
for (let i = 0; i < missing.length; i += chunkSize) {
|
||||
const slice = missing.slice(i, i + chunkSize);
|
||||
const byIdParams = new URLSearchParams({
|
||||
Ids: slice.join(','),
|
||||
Fields: SERIES_FIELDS,
|
||||
EnableTotalRecordCount: 'false'
|
||||
});
|
||||
const byIdData = await queryItems(`${server}/Items?${byIdParams.toString()}`);
|
||||
const stripped = await stripChunk(byIdData.Items || []);
|
||||
series = utils().upsertById?.(series, stripped) || series;
|
||||
incoming = incoming.concat(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
await cache.set(CACHE_NAME, series, userId, getTtl());
|
||||
setLastDateFetched(new Date().toISOString());
|
||||
|
||||
if ((incoming.length || removedIds.length) && window.PeopleCache?.applySeriesDelta) {
|
||||
try {
|
||||
await window.PeopleCache.applySeriesDelta(incoming, removedIds);
|
||||
} catch (e) {
|
||||
WARN('PeopleCache.applySeriesDelta failed:', e);
|
||||
}
|
||||
}
|
||||
return series;
|
||||
} catch (err) {
|
||||
ERR('syncSeriesCache failed:', err);
|
||||
return series || [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeries() {
|
||||
if (series && isComplete) return series;
|
||||
if (fetchPromise) return fetchPromise;
|
||||
fetchPromise = (async () => {
|
||||
await fetchAndCacheSeries();
|
||||
return series || [];
|
||||
})().finally(() => {
|
||||
fetchPromise = null;
|
||||
});
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
async function getCompletedSeries() {
|
||||
const list = await getSeries();
|
||||
return (list || []).filter(item => item?.UserData?.Played === true && Number(item.UserData.PlayedPercentage) === 100);
|
||||
}
|
||||
|
||||
function isCacheComplete() {
|
||||
return isComplete === true && Array.isArray(series);
|
||||
}
|
||||
|
||||
async function getSeriesByProviderIds(ids) {
|
||||
const list = await getSeries();
|
||||
const idSet = new Set();
|
||||
(ids || []).forEach((id) => {
|
||||
(utils().normalizeProviderKeys?.(id) || []).forEach((k) => idSet.add(k));
|
||||
});
|
||||
if (!idSet.size) return [];
|
||||
return list.filter((item) => utils().itemMatchesProviderIds?.(item, idSet));
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await getSeries();
|
||||
if (isCacheComplete() && getLastDateFetched()) {
|
||||
await syncSeriesCache();
|
||||
}
|
||||
return series || [];
|
||||
}
|
||||
|
||||
async function waitForLoginReady(maxWaitMs = 60000) {
|
||||
const start = Date.now();
|
||||
while (!window.userHelper?.waitForLogin) {
|
||||
if (Date.now() - start >= maxWaitMs) {
|
||||
ERR('Timed out waiting for userHelper');
|
||||
return false;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
return window.userHelper.waitForLogin(maxWaitMs);
|
||||
}
|
||||
|
||||
function yieldToMain() {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(() => resolve(), { timeout: 500 });
|
||||
} else {
|
||||
requestAnimationFrame(() => setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let bootstrapStarted = false;
|
||||
let bootstrapScheduled = false;
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapStarted) return;
|
||||
bootstrapStarted = true;
|
||||
try {
|
||||
await waitForLoginReady();
|
||||
await initialize();
|
||||
} catch (e) {
|
||||
ERR('Bootstrap failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBootstrap() {
|
||||
if (bootstrapScheduled) return;
|
||||
bootstrapScheduled = true;
|
||||
|
||||
const start = () => { bootstrap(); };
|
||||
|
||||
const onPainted = () => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
};
|
||||
document.addEventListener('kefinTweaksHomePainted', onPainted);
|
||||
|
||||
waitForLoginReady().then((ok) => {
|
||||
if (!ok) {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
document.removeEventListener('kefinTweaksHomePainted', onPainted);
|
||||
start();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
window.SeriesCache = {
|
||||
init: initialize,
|
||||
getSeries,
|
||||
getCompletedSeries,
|
||||
getSeriesByProviderIds,
|
||||
syncSeriesCache,
|
||||
fetchAndCacheSeries,
|
||||
isComplete: isCacheComplete,
|
||||
getLastDateFetched,
|
||||
bootstrap,
|
||||
scheduleBootstrap,
|
||||
refresh: async () => {
|
||||
const cache = window.IndexedDBCache;
|
||||
const userId = window.ApiClient?.getCurrentUserId?.();
|
||||
if (cache) {
|
||||
await cache.clear(CACHE_NAME, userId);
|
||||
await cache.clear(PARTIAL_CACHE_NAME, userId);
|
||||
}
|
||||
series = null;
|
||||
isComplete = false;
|
||||
return fetchAndCacheSeries();
|
||||
}
|
||||
};
|
||||
|
||||
scheduleBootstrap();
|
||||
LOG('Module loaded');
|
||||
})();
|
||||
+4
-49
@@ -20,60 +20,15 @@
|
||||
const THEME_STORAGE_KEY = 'kefinTweaks_selectedTheme';
|
||||
const COLOR_SCHEMES_STORAGE_KEY = 'kefinTweaks_selectedColorScheme';
|
||||
|
||||
// Cached server version for synchronous access
|
||||
// Cached server version for synchronous access — prefer window.KefinTweaks
|
||||
let cachedServerVersion = null;
|
||||
|
||||
function getMajorServerVersion(version) {
|
||||
const versionParts = version.split('.');
|
||||
|
||||
if (versionParts[0] !== '10') {
|
||||
const majorVersion = parseInt(versionParts[0], 10);
|
||||
return majorVersion;
|
||||
}
|
||||
|
||||
if (versionParts.length >= 2) {
|
||||
const majorVersion = parseInt(versionParts[1], 10);
|
||||
if (!isNaN(majorVersion)) {
|
||||
return majorVersion;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current major server version from ApiClient
|
||||
* Polls every 500ms for up to 5 seconds in the background if not immediately available
|
||||
* @returns {number|null} The major version number (e.g., 10 for "10.10.X", 11 for "10.11.X"), or null if unavailable
|
||||
*/
|
||||
async function getCurrentMajorServerVersion() {
|
||||
try {
|
||||
// If we have a cached value, return it
|
||||
if (cachedServerVersion !== null) {
|
||||
try {
|
||||
if (window.KefinTweaks?.getJellyfinMajorVersion) {
|
||||
cachedServerVersion = await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
return cachedServerVersion;
|
||||
}
|
||||
|
||||
if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {
|
||||
cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion);
|
||||
return cachedServerVersion;
|
||||
}
|
||||
|
||||
// Check the server version instead of app version
|
||||
if (!window.ApiClient._serverVersion) {
|
||||
// Wait 10s to see if it becomes ready, check every 500ms
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < 10000) {
|
||||
if (window.ApiClient._serverVersion) {
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion);
|
||||
return cachedServerVersion;
|
||||
} catch (error) {
|
||||
WARN('Error getting server version:', error);
|
||||
|
||||
+18
-3
@@ -57,12 +57,18 @@
|
||||
* API Helper functions for Jellyfin operations
|
||||
*/
|
||||
const userHelper = {
|
||||
getUserDisplayPreferences: async function() {
|
||||
/**
|
||||
* Get current-user display preferences.
|
||||
* @param {{ cacheOnly?: boolean }} [options]
|
||||
* @returns {Promise<{ promise: Promise<object>, cached: object|null }>}
|
||||
*/
|
||||
getUserDisplayPreferences: async function(options = {}) {
|
||||
ensureApiClient();
|
||||
ensureLoggedIn();
|
||||
|
||||
const cacheOnly = options.cacheOnly === true;
|
||||
let cachedDisplayPreferences = null;
|
||||
|
||||
|
||||
if (__userDisplayPreferences) {
|
||||
cachedDisplayPreferences = __userDisplayPreferences;
|
||||
} else if (window.LocalStorageCache) {
|
||||
@@ -70,9 +76,18 @@
|
||||
const displayPreferencesData = cache.get('userDisplayPreferences');
|
||||
if (displayPreferencesData) {
|
||||
cachedDisplayPreferences = displayPreferencesData;
|
||||
// Hydrate memory so later cacheOnly calls skip localStorage too
|
||||
__userDisplayPreferences = displayPreferencesData;
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheOnly && cachedDisplayPreferences) {
|
||||
return {
|
||||
promise: Promise.resolve(cachedDisplayPreferences),
|
||||
cached: cachedDisplayPreferences
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap this in a promise to return with the cached data
|
||||
const userDisplayPreferencesPromise = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
@@ -286,7 +301,7 @@
|
||||
waitForLogin: waitForLogin
|
||||
};
|
||||
|
||||
// TODO -- Update this whenever the user display preferences are updated
|
||||
// Updated by getUserDisplayPreferences / updateDisplayPreferencesForUser({ updateCache: true })
|
||||
let __userDisplayPreferences = null;
|
||||
|
||||
// Expose userHelper to global window object
|
||||
|
||||
+190
-21
@@ -1449,6 +1449,10 @@
|
||||
anyOk = injectIntoUserMenu(entry) || anyOk;
|
||||
}
|
||||
|
||||
if (entry.topNavigation === 'main') {
|
||||
syncCustomTopNavActiveState();
|
||||
}
|
||||
|
||||
return anyOk;
|
||||
}
|
||||
|
||||
@@ -1460,6 +1464,91 @@
|
||||
ERR('Error re-applying custom menu link:', entry?.name, err);
|
||||
}
|
||||
});
|
||||
syncCustomTopNavActiveState();
|
||||
}
|
||||
|
||||
function parseNavHashParts(hash) {
|
||||
const raw = String(hash || '#/').replace(/^#/, '') || '/';
|
||||
const qIndex = raw.indexOf('?');
|
||||
const path = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || '/';
|
||||
const query = qIndex >= 0 ? raw.slice(qIndex + 1) : '';
|
||||
const params = new URLSearchParams(query);
|
||||
return { path: path.startsWith('/') ? path : `/${path}`, params };
|
||||
}
|
||||
|
||||
function customTopNavHrefMatchScore(href, currentHash) {
|
||||
if (!href || /^https?:/i.test(href)) return -1;
|
||||
const targetHash = href.startsWith('#') ? href : `#${href}`;
|
||||
const current = parseNavHashParts(currentHash);
|
||||
const target = parseNavHashParts(targetHash);
|
||||
|
||||
if (
|
||||
targetHash === currentHash
|
||||
|| decodeURIComponent(targetHash) === decodeURIComponent(currentHash)
|
||||
) {
|
||||
return 1000 + targetHash.length;
|
||||
}
|
||||
|
||||
if (target.path !== current.path) return -1;
|
||||
|
||||
for (const [key, value] of target.params.entries()) {
|
||||
if (current.params.get(key) !== value) return -1;
|
||||
}
|
||||
|
||||
let score = 100 + targetHash.length;
|
||||
score += [...target.params.keys()].length * 10;
|
||||
|
||||
if (target.path === '/home' && !target.params.has('tab') && current.params.has('tab')) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function resolveCustomTopNavHref(el) {
|
||||
if (!el) return '';
|
||||
const attrHref = el.getAttribute('href');
|
||||
if (attrHref) return attrHref;
|
||||
const key = el.getAttribute('data-kefin-custom-menu-top-link');
|
||||
if (!key) return '';
|
||||
const entry = customMenuLinkRegistry.find((e) => entryKey(e) === key);
|
||||
return entry ? getCustomMenuLinkHref(entry) : '';
|
||||
}
|
||||
|
||||
function syncCustomTopNavActiveState() {
|
||||
const links = Array.from(document.querySelectorAll('[data-kefin-custom-menu-top-link]'));
|
||||
if (!links.length) return;
|
||||
|
||||
const currentHash = window.location.hash || '#/';
|
||||
const normalizedHash = currentHash.startsWith('#') ? currentHash : `#${currentHash}`;
|
||||
let bestScore = -1;
|
||||
const winners = [];
|
||||
|
||||
links.forEach((el) => {
|
||||
el.classList.remove('active');
|
||||
const score = customTopNavHrefMatchScore(resolveCustomTopNavHref(el), normalizedHash);
|
||||
if (score < 0) return;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
winners.length = 0;
|
||||
winners.push(el);
|
||||
} else if (score === bestScore) {
|
||||
winners.push(el);
|
||||
}
|
||||
});
|
||||
|
||||
winners.forEach((el) => el.classList.add('active'));
|
||||
}
|
||||
|
||||
let customTopNavActiveSyncBound = false;
|
||||
function ensureCustomTopNavActiveSync() {
|
||||
if (customTopNavActiveSyncBound) return;
|
||||
customTopNavActiveSyncBound = true;
|
||||
window.addEventListener('hashchange', syncCustomTopNavActiveState);
|
||||
onViewPage(() => {
|
||||
syncCustomTopNavActiveState();
|
||||
}, { pages: [] });
|
||||
syncCustomTopNavActiveState();
|
||||
}
|
||||
|
||||
function ensureCustomMenuLinkObserver() {
|
||||
@@ -1508,6 +1597,39 @@
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-kefin-custom-menu-top-link].active {
|
||||
display: inline-flex;
|
||||
-moz-box-align: center;
|
||||
align-items: center;
|
||||
-moz-box-pack: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
outline: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
appearance: none;
|
||||
text-decoration: none;
|
||||
font-family: "Noto Sans", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.75;
|
||||
text-transform: none;
|
||||
min-width: 64px;
|
||||
border: 0;
|
||||
border-radius: var(--jf-shape-borderRadius);
|
||||
padding: 6px 8px;
|
||||
color: var(--variant-textColor);
|
||||
background-color: var(--variant-textBg);
|
||||
--variant-textColor: var(--jf-palette-primary-main);
|
||||
--variant-outlinedColor: var(--jf-palette-primary-main);
|
||||
--variant-outlinedBorder: rgba(var(--jf-palette-primary-mainChannel) / 0.5);
|
||||
--variant-containedColor: var(--jf-palette-primary-contrastText);
|
||||
--variant-containedBg: var(--jf-palette-primary-main);
|
||||
transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1), border-color 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.userPreferencesPage .readOnlyContent > * {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1716,6 +1838,7 @@ button[data-kefin-custom-menu-more] .MuiSvgIcon-root {
|
||||
*/
|
||||
async function addCustomMenuLink(name, icon, url, openInNewTab = false, containerSelectorOrOptions = DEFAULT_CUSTOM_MENU_SELECTOR) {
|
||||
ensureCustomMenuLinkStyles();
|
||||
ensureCustomTopNavActiveSync();
|
||||
const options = parseCustomMenuLinkOptions(containerSelectorOrOptions);
|
||||
const trimmedUrl = url != null ? String(url).trim() : '';
|
||||
const action = options.action || '';
|
||||
@@ -2086,24 +2209,65 @@ button[data-kefin-custom-menu-more] .MuiSvgIcon-root {
|
||||
// Do not edit manually unless you know what you're doing
|
||||
|
||||
window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`;
|
||||
|
||||
// Find or create KefinTweaks-Config script
|
||||
const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex(
|
||||
script => script.Name === 'KefinTweaks-Config'
|
||||
);
|
||||
|
||||
if (existingScriptIndex !== -1) {
|
||||
// Update existing script
|
||||
injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent;
|
||||
} else {
|
||||
// Add new script
|
||||
injectorConfig.CustomJavaScripts.push({
|
||||
Name: 'KefinTweaks-Config',
|
||||
Script: scriptContent,
|
||||
Enabled: true,
|
||||
RequiresAuthentication: false
|
||||
});
|
||||
|
||||
// Build / refresh KefinTweaks-injector preload entry (same POST)
|
||||
let injectorScriptContent = null;
|
||||
try {
|
||||
const root = (configToSave.kefinTweaksRoot || '').endsWith('/')
|
||||
? configToSave.kefinTweaksRoot
|
||||
: (configToSave.kefinTweaksRoot || '') + '/';
|
||||
if (root && root !== '/') {
|
||||
if (!window.KefinTweaksLoader) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const url = `${root}scripts/kefinTweaks-loader.js`;
|
||||
const existing = document.querySelector(`script[src="${url}"]`);
|
||||
if (existing && window.KefinTweaksLoader) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = false;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load kefinTweaks-loader.js'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
const Loader = window.KefinTweaksLoader;
|
||||
if (Loader) {
|
||||
Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS);
|
||||
const major = await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
const plan = Loader.buildLoadPlan(configToSave, major, {
|
||||
root,
|
||||
configOnly: configToSave.enabled === false
|
||||
});
|
||||
injectorScriptContent = Loader.buildInjectorScript(plan);
|
||||
LOG(`Built KefinTweaks-injector (${plan.assets.length} assets, major=${major})`);
|
||||
}
|
||||
}
|
||||
} catch (loaderErr) {
|
||||
WARN('Could not build KefinTweaks-injector entry:', loaderErr);
|
||||
}
|
||||
|
||||
injectorConfig.CustomJavaScripts = (window.KefinTweaksLoader
|
||||
? window.KefinTweaksLoader.upsertInjectorEntries(injectorConfig.CustomJavaScripts, {
|
||||
configScriptContent: scriptContent,
|
||||
injectorScriptContent: injectorScriptContent
|
||||
})
|
||||
: (() => {
|
||||
// Fallback: Config-only upsert if loader unavailable
|
||||
const list = injectorConfig.CustomJavaScripts || [];
|
||||
const idx = list.findIndex((s) => s.Name === 'KefinTweaks-Config');
|
||||
const entry = {
|
||||
Name: 'KefinTweaks-Config',
|
||||
Script: scriptContent,
|
||||
Enabled: true,
|
||||
RequiresAuthentication: false
|
||||
};
|
||||
if (idx !== -1) list[idx] = { ...list[idx], ...entry };
|
||||
else list.push(entry);
|
||||
return list;
|
||||
})());
|
||||
|
||||
// Save the updated configuration
|
||||
const saveResponse = await fetch(configUrl, {
|
||||
@@ -2119,7 +2283,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`;
|
||||
throw new Error(`Failed to save plugin config: ${saveResponse.statusText}`);
|
||||
}
|
||||
|
||||
LOG('Successfully saved config to JS Injector plugin');
|
||||
LOG('Successfully saved config (+ injector preload) to JS Injector plugin');
|
||||
return true;
|
||||
} catch (err) {
|
||||
ERR('Error saving config to JS Injector:', err);
|
||||
@@ -2453,21 +2617,26 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`;
|
||||
}
|
||||
}
|
||||
|
||||
function syncCustomPagesAndTopNavActive() {
|
||||
syncAllCustomPages();
|
||||
syncCustomTopNavActiveState();
|
||||
}
|
||||
|
||||
function ensureCustomPageViewHandler() {
|
||||
if (customPageViewHandlerUnregister) return;
|
||||
// onViewShow misses custom→custom hash changes (no real Jellyfin view);
|
||||
// hashchange covers those; both are fine when they double-fire.
|
||||
const unregView = onViewPage(() => {
|
||||
syncAllCustomPages();
|
||||
syncCustomPagesAndTopNavActive();
|
||||
}, { pages: [] });
|
||||
if (!customPageHashChangeBound) {
|
||||
window.addEventListener('hashchange', syncAllCustomPages);
|
||||
window.addEventListener('hashchange', syncCustomPagesAndTopNavActive);
|
||||
customPageHashChangeBound = true;
|
||||
}
|
||||
customPageViewHandlerUnregister = () => {
|
||||
if (typeof unregView === 'function') unregView();
|
||||
if (customPageHashChangeBound) {
|
||||
window.removeEventListener('hashchange', syncAllCustomPages);
|
||||
window.removeEventListener('hashchange', syncCustomPagesAndTopNavActive);
|
||||
customPageHashChangeBound = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1882,10 +1882,10 @@ div[data-tab="watchlist"] .loading-message {
|
||||
}
|
||||
|
||||
.card:has(button[data-played="true"]) .watchlist-button,
|
||||
.card:not(:has(button[data-played])) .watchlist-button,
|
||||
.card:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .watchlist-button,
|
||||
.mainDetailButtons:has(button[data-played="true"]) .watchlist-icon,
|
||||
.mainDetailButtons:not(:has(button[data-played])) .watchlist-icon,
|
||||
.mainDetailButtons:not(:has(button[data-played])) .btnRecommend {
|
||||
.mainDetailButtons:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .watchlist-icon,
|
||||
.mainDetailButtons:not(:has(button[data-played], button:has([data-testid="CheckIcon"]))) .btnRecommend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
+206
-128
@@ -2174,13 +2174,13 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
if (activeContent) activeContent.style.display = 'block';
|
||||
|
||||
if (pageTab === 'progress') {
|
||||
renderProgressContent();
|
||||
initProgressTab().then(() => renderProgressContent()).catch((err) => ERR('Progress tab init failed:', err));
|
||||
} else if (pageTab === 'watchlist') {
|
||||
renderWatchlistContent();
|
||||
} else if (pageTab === 'history') {
|
||||
renderHistoryContent();
|
||||
initHistoryTab().then(() => renderHistoryContent()).catch((err) => ERR('History tab init failed:', err));
|
||||
} else if (pageTab === 'statistics') {
|
||||
renderStatisticsContent();
|
||||
initStatisticsTab().then(() => renderStatisticsContent()).catch((err) => ERR('Statistics tab init failed:', err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2333,21 +2333,45 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all tabs with proper dependencies
|
||||
// Initialize tabs lazily — only the active/default tab; progress/history on demand
|
||||
async function initializeAllTabs() {
|
||||
LOG('Starting tab initialization');
|
||||
|
||||
// Independent tabs can run in parallel
|
||||
const watchlistPromise = initWatchlistTab();
|
||||
|
||||
// Dependent tabs: progress and history first, then statistics
|
||||
const progressPromise = initProgressTab();
|
||||
const historyPromise = initHistoryTab();
|
||||
|
||||
// Wait for progress and history, then init statistics
|
||||
await Promise.all([watchlistPromise, progressPromise, historyPromise]);
|
||||
await initStatisticsTab();
|
||||
LOG('All tabs initialized');
|
||||
const params = getUrlParams();
|
||||
const pageTab = params.pageTab || 'watchlist';
|
||||
|
||||
await initWatchlistTab();
|
||||
|
||||
if (pageTab === 'progress') {
|
||||
await initProgressTab();
|
||||
} else if (pageTab === 'history') {
|
||||
await initHistoryTab();
|
||||
} else if (pageTab === 'statistics') {
|
||||
await initStatisticsTab();
|
||||
}
|
||||
|
||||
LOG('Tab initialization complete (active:', pageTab, ')');
|
||||
}
|
||||
|
||||
/** Run async work over items with a fixed concurrency limit. */
|
||||
async function mapWithConcurrency(items, concurrency, mapper) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const limit = Math.max(1, Number(concurrency) || 1);
|
||||
const results = new Array(list.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < list.length) {
|
||||
const i = nextIndex++;
|
||||
results[i] = await mapper(list[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = [];
|
||||
for (let w = 0; w < Math.min(limit, list.length); w++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function sortInProgressSeries(series) {
|
||||
@@ -2462,14 +2486,11 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
LOG(`Found ${inProgressSeries.length} series with PlayedPercentage > 0`);
|
||||
|
||||
|
||||
LOG(`Fetching progress data for all ${inProgressSeries.length} series`);
|
||||
LOG(`Fetching progress data for ${inProgressSeries.length} series (concurrency 4)`);
|
||||
|
||||
// Fetch progress data for all series in parallel
|
||||
const progressData = await Promise.all(
|
||||
inProgressSeries.map(async (seriesItem) => {
|
||||
return await fetchSeriesProgressWithMissingEpisodes(seriesItem, userId, serverUrl, token);
|
||||
})
|
||||
);
|
||||
const progressData = await mapWithConcurrency(inProgressSeries, 4, async (seriesItem) => {
|
||||
return await fetchSeriesProgressWithMissingEpisodes(seriesItem, userId, serverUrl, token);
|
||||
});
|
||||
|
||||
// Filter out series with no valid progress data
|
||||
const validProgressData = progressData.filter(data => data !== null);
|
||||
@@ -3018,6 +3039,84 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
{ type: 'Video', key: 'homevideos', label: 'Home Videos' }
|
||||
];
|
||||
|
||||
const WATCHLIST_SUPPORTED_TYPES = WATCHLIST_SECTION_TYPES.map(({ type }) => type);
|
||||
const WATCHLIST_SUPPORTED_TYPES_CSV = WATCHLIST_SUPPORTED_TYPES.join(',');
|
||||
|
||||
/** Session Id set for overlay button state only (not Watchlist tab rendering). */
|
||||
const watchlistLikedIds = new Set();
|
||||
let watchlistLikedIdsLoaded = false;
|
||||
let watchlistLikedIdsLoadPromise = null;
|
||||
|
||||
function isWatchlistSupportedType(itemType) {
|
||||
return WATCHLIST_SUPPORTED_TYPES.includes(itemType);
|
||||
}
|
||||
|
||||
function isItemInLikedIdCache(itemId) {
|
||||
return !!itemId && watchlistLikedIds.has(itemId);
|
||||
}
|
||||
|
||||
function setLikedIdInCache(itemId, isLiked) {
|
||||
if (!itemId) return;
|
||||
if (isLiked) watchlistLikedIds.add(itemId);
|
||||
else watchlistLikedIds.delete(itemId);
|
||||
}
|
||||
|
||||
function mergeLikedIdsFromItems(items) {
|
||||
const list = Array.isArray(items) ? items : (items?.Items || []);
|
||||
list.forEach((item) => {
|
||||
if (item?.Id) watchlistLikedIds.add(item.Id);
|
||||
});
|
||||
}
|
||||
|
||||
function applyWatchlistButtonActiveState(button, isActive) {
|
||||
if (!button) return;
|
||||
button.dataset.active = isActive ? 'true' : 'false';
|
||||
button.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
|
||||
}
|
||||
|
||||
function syncOverlayWatchlistButtons() {
|
||||
document.querySelectorAll('.watchlist-button[data-id]').forEach((button) => {
|
||||
const itemId = button.getAttribute('data-id');
|
||||
applyWatchlistButtonActiveState(button, isItemInLikedIdCache(itemId));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWatchlistLikedIdsBaseline() {
|
||||
if (watchlistLikedIdsLoaded) return watchlistLikedIds;
|
||||
if (watchlistLikedIdsLoadPromise) return watchlistLikedIdsLoadPromise;
|
||||
|
||||
watchlistLikedIdsLoadPromise = (async () => {
|
||||
try {
|
||||
if (window.userHelper?.waitForLogin) {
|
||||
await window.userHelper.waitForLogin();
|
||||
}
|
||||
const apiHelper = window.apiHelper;
|
||||
if (!apiHelper?.getWatchlistItems) {
|
||||
WARN('apiHelper.getWatchlistItems unavailable; liked Id cache empty');
|
||||
return watchlistLikedIds;
|
||||
}
|
||||
const result = await apiHelper.getWatchlistItems({
|
||||
IncludeItemTypes: WATCHLIST_SUPPORTED_TYPES_CSV,
|
||||
Fields: 'Id',
|
||||
Limit: 10000
|
||||
}, true);
|
||||
watchlistLikedIds.clear();
|
||||
mergeLikedIdsFromItems(result);
|
||||
watchlistLikedIdsLoaded = true;
|
||||
LOG(`Loaded watchlist liked Id cache: ${watchlistLikedIds.size} items`);
|
||||
syncOverlayWatchlistButtons();
|
||||
processExistingOverlayContainers();
|
||||
} catch (err) {
|
||||
ERR('Failed to load watchlist liked Id baseline:', err);
|
||||
} finally {
|
||||
watchlistLikedIdsLoadPromise = null;
|
||||
}
|
||||
return watchlistLikedIds;
|
||||
})();
|
||||
|
||||
return watchlistLikedIdsLoadPromise;
|
||||
}
|
||||
|
||||
let watchlistRenderGeneration = 0;
|
||||
|
||||
function resetWatchlistItemsByType() {
|
||||
@@ -3170,7 +3269,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
const items = await section.result.ensureData();
|
||||
const list = Array.isArray(items) ? items : (items?.Items || []);
|
||||
watchlistItemsByType[section.itemType] = list;
|
||||
mergeLikedIdsFromItems(list);
|
||||
}));
|
||||
syncOverlayWatchlistButtons();
|
||||
|
||||
if (generation !== watchlistRenderGeneration) return;
|
||||
|
||||
@@ -4676,6 +4777,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Clear query cache + in-memory index
|
||||
await invalidateAllWatchlistQueries();
|
||||
resetWatchlistItemsByType();
|
||||
watchlistLikedIds.clear();
|
||||
watchlistLikedIdsLoaded = true;
|
||||
syncOverlayWatchlistButtons();
|
||||
|
||||
// Refresh watchlist
|
||||
await initWatchlistTab(true);
|
||||
@@ -4687,6 +4791,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
async function updateWatchlistCacheOnToggle(itemId, itemType, isAdded) {
|
||||
try {
|
||||
LOG(`Watchlist membership changed for ${itemType} ${itemId} (added=${isAdded}); refreshing progressive sections`);
|
||||
setLikedIdInCache(itemId, isAdded === true);
|
||||
syncOverlayWatchlistButtons();
|
||||
|
||||
if (itemType) {
|
||||
await window.apiHelper?.invalidateWatchlistQueries?.({ IncludeItemTypes: itemType });
|
||||
} else {
|
||||
@@ -5206,6 +5313,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
LOG(`Found ${playedItems.length} played items in ${type} section`);
|
||||
for (const item of playedItems) {
|
||||
await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), item.Id, 'false');
|
||||
setLikedIdInCache(item.Id, false);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
@@ -5213,6 +5321,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
|
||||
if (removedCount > 0) {
|
||||
LOG(`Sync complete: removed ${removedCount} played items from watchlist; refreshing`);
|
||||
syncOverlayWatchlistButtons();
|
||||
await invalidateAllWatchlistQueries();
|
||||
await renderWatchlistProgressive(true);
|
||||
} else {
|
||||
@@ -6598,15 +6707,15 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
|
||||
updateUrlParams(activeTab, currentPage, currentSort, currentSortDirection, currentMovieSort, currentMovieSortDirection);
|
||||
|
||||
// Render content based on active tab
|
||||
// Render content based on active tab (lazy-init progress/history/statistics)
|
||||
if (activeTab === 'progress') {
|
||||
renderProgressContent();
|
||||
initProgressTab().then(() => renderProgressContent()).catch((err) => ERR('Progress tab init failed:', err));
|
||||
} else if (activeTab === 'watchlist') {
|
||||
renderWatchlistContent();
|
||||
} else if (activeTab === 'history') {
|
||||
renderHistoryContent();
|
||||
initHistoryTab().then(() => renderHistoryContent()).catch((err) => ERR('History tab init failed:', err));
|
||||
} else if (activeTab === 'statistics') {
|
||||
renderStatisticsContent();
|
||||
initStatisticsTab().then(() => renderStatisticsContent()).catch((err) => ERR('Statistics tab init failed:', err));
|
||||
}
|
||||
|
||||
LOG(`Switched to ${activeTab} tab`);
|
||||
@@ -7277,15 +7386,18 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
let watchlistUrl = '#/watchlist';
|
||||
|
||||
// Major version of Jellyfin
|
||||
const jellyfinVersion = window.KefinTweaks.getJellyfinMajorVersion() ?? null;
|
||||
const jellyfinVersion = await window.KefinTweaks.getJellyfinMajorVersion();
|
||||
const isModernUI = document.querySelector('.MuiBox-root') !== null;
|
||||
|
||||
// If watchlist tab index is null or undefined, add to top navigation
|
||||
if (watchlistTabIndex === null || watchlistTabIndex === undefined || jellyfinVersion >= 12) {
|
||||
if (watchlistTabIndex === null || watchlistTabIndex === undefined || isModernUI) {
|
||||
options.topNavigation = 'main';
|
||||
} else {
|
||||
watchlistUrl = `#/home?tab=${watchlistTabIndex}`;
|
||||
}
|
||||
|
||||
window.KefinTweaksUtils._watchlistUrl = watchlistUrl;
|
||||
|
||||
window.KefinTweaksUtils.addCustomMenuLink(
|
||||
'Watchlist',
|
||||
'bookmark',
|
||||
@@ -7335,129 +7447,84 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
|
||||
// Function to add watchlist button to a card overlay container
|
||||
function addWatchlistButton(overlayContainer) {
|
||||
// Check if watchlist button already exists
|
||||
if (overlayContainer && overlayContainer.querySelector('.watchlist-button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the card parent to get the item ID
|
||||
if (!overlayContainer) return;
|
||||
|
||||
const card = overlayContainer.closest('.card');
|
||||
if (!card) {
|
||||
WARN('Could not find card parent for overlay container');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const itemId = card.getAttribute('data-id');
|
||||
if (!itemId) {
|
||||
WARN('Could not find data-id on card element');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if card has data-type attribute - if not, don't add watchlist button
|
||||
|
||||
const itemType = card.getAttribute('data-type');
|
||||
if (!itemType) {
|
||||
LOG('Card has no data-type, skipping watchlist button');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the .cardOverlayButton-br container
|
||||
|
||||
if (!isWatchlistSupportedType(itemType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ApiClient._loggedIn) {
|
||||
LOG('User is not logged in, skipping watchlist button');
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br');
|
||||
if (!buttonContainer) {
|
||||
WARN('Could not find .cardOverlayButton-br container');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create watchlist button
|
||||
const watchlistButton = document.createElement('button');
|
||||
watchlistButton.type = 'button';
|
||||
watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat';
|
||||
watchlistButton.setAttribute('data-action', 'none');
|
||||
watchlistButton.setAttribute('data-id', itemId);
|
||||
watchlistButton.setAttribute('data-active', 'false');
|
||||
watchlistButton.title = 'Add to Watchlist';
|
||||
|
||||
// Create the bookmark icon
|
||||
const watchlistIcon = document.createElement('span');
|
||||
watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
|
||||
watchlistIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
watchlistButton.appendChild(watchlistIcon);
|
||||
|
||||
// Add click event listener
|
||||
watchlistButton.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Toggle watchlist status
|
||||
const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false';
|
||||
await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
|
||||
watchlistButton.dataset.active = newRating;
|
||||
|
||||
// Update icon and title based on state
|
||||
const isActive = watchlistButton.dataset.active === 'true';
|
||||
// Icon state is handled by CSS class, no need to change textContent
|
||||
watchlistButton.title = isActive ? 'Remove from Watchlist' : 'Add to Watchlist';
|
||||
|
||||
// Update watchlist cache immediately
|
||||
await updateWatchlistCacheOnToggle(itemId, itemType, isActive);
|
||||
});
|
||||
|
||||
// Check if item type is supported for watchlist
|
||||
if (itemType !== "Movie" && itemType !== "Series" && itemType !== "Season" && itemType !== "Episode" && itemType !== "BoxSet" && itemType !== "Playlist" && itemType !== "Video") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ApiClient._loggedIn) {
|
||||
LOG('User is not logged in, skipping watchlist button');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check item's current watchlist status from cache only (no server fetch)
|
||||
// Map itemType to section name
|
||||
let sectionName;
|
||||
switch (itemType) {
|
||||
case 'Movie':
|
||||
sectionName = 'movies';
|
||||
break;
|
||||
case 'Series':
|
||||
sectionName = 'series';
|
||||
break;
|
||||
case 'Season':
|
||||
sectionName = 'seasons';
|
||||
break;
|
||||
case 'Episode':
|
||||
sectionName = 'episodes';
|
||||
break;
|
||||
case 'BoxSet':
|
||||
sectionName = 'boxsets';
|
||||
break;
|
||||
case 'Playlist':
|
||||
sectionName = 'playlists';
|
||||
break;
|
||||
case 'Video':
|
||||
sectionName = 'homevideos';
|
||||
break;
|
||||
default:
|
||||
// Unknown type, skip check
|
||||
break;
|
||||
}
|
||||
|
||||
if (sectionName && itemType) {
|
||||
let isInWatchlist = (watchlistItemsByType[itemType] || []).some(item => item.Id === itemId);
|
||||
let watchlistButton = overlayContainer.querySelector('.watchlist-button');
|
||||
const createdNew = !watchlistButton;
|
||||
|
||||
// Set button state if item is in watchlist
|
||||
if (isInWatchlist) {
|
||||
watchlistButton.dataset.active = 'true';
|
||||
watchlistButton.title = 'Remove from Watchlist';
|
||||
if (createdNew) {
|
||||
watchlistButton = document.createElement('button');
|
||||
watchlistButton.type = 'button';
|
||||
watchlistButton.className = 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat';
|
||||
watchlistButton.setAttribute('data-action', 'none');
|
||||
watchlistButton.setAttribute('data-id', itemId);
|
||||
|
||||
const watchlistIcon = document.createElement('span');
|
||||
watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
|
||||
watchlistIcon.setAttribute('aria-hidden', 'true');
|
||||
watchlistButton.appendChild(watchlistIcon);
|
||||
|
||||
const playStateButton = buttonContainer.querySelector('button[is="emby-playstatebutton"]')
|
||||
|| buttonContainer.querySelector('button:has([data-testid="CheckIcon"])');
|
||||
if (playStateButton) {
|
||||
buttonContainer.insertBefore(watchlistButton, playStateButton);
|
||||
} else {
|
||||
buttonContainer.appendChild(watchlistButton);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the watchlist button to the button container, right before the play state button if it exists
|
||||
const playStateButton = buttonContainer.querySelector('button[is="emby-playstatebutton"]');
|
||||
if (playStateButton) {
|
||||
buttonContainer.insertBefore(watchlistButton, playStateButton);
|
||||
} else {
|
||||
buttonContainer.appendChild(watchlistButton);
|
||||
watchlistButton.setAttribute('data-id', itemId);
|
||||
}
|
||||
|
||||
// Prefer liked-Id cache once loaded; until then keep cardBuilder UserData.Likes state
|
||||
if (watchlistLikedIdsLoaded || createdNew) {
|
||||
applyWatchlistButtonActiveState(watchlistButton, isItemInLikedIdCache(itemId));
|
||||
}
|
||||
|
||||
if (watchlistButton.dataset.watchlistBound !== 'true') {
|
||||
watchlistButton.dataset.watchlistBound = 'true';
|
||||
watchlistButton.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false';
|
||||
await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
|
||||
const isActive = newRating === 'true';
|
||||
applyWatchlistButtonActiveState(watchlistButton, isActive);
|
||||
await updateWatchlistCacheOnToggle(itemId, itemType, isActive);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7518,6 +7585,17 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Initialize the observer
|
||||
setupWatchlistButtonObserver();
|
||||
|
||||
// Baseline liked-Id cache for overlay button state (one query after login)
|
||||
loadWatchlistLikedIdsBaseline();
|
||||
|
||||
// Expose helpers so cardBuilder can keep overlay buttons / Set aligned on UserData.Likes
|
||||
window.KefinWatchlistLikedIds = {
|
||||
has: isItemInLikedIdCache,
|
||||
set: setLikedIdInCache,
|
||||
syncOverlayButtons: syncOverlayWatchlistButtons,
|
||||
isSupportedType: isWatchlistSupportedType
|
||||
};
|
||||
|
||||
/************ Item Detail Page Observer ************/
|
||||
|
||||
// Function to add watchlist button to item detail page
|
||||
|
||||
Reference in New Issue
Block a user