mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
444 lines
16 KiB
JavaScript
444 lines
16 KiB
JavaScript
// KefinTweaks Library Cache Utils
|
|
// Shared strip / provider-id helpers for moviesCache, seriesCache, peopleCache
|
|
(function() {
|
|
'use strict';
|
|
|
|
function stripPerson(person) {
|
|
if (!person) return null;
|
|
const out = {
|
|
Name: person.Name,
|
|
Id: person.Id,
|
|
Role: person.Role,
|
|
Type: person.Type
|
|
};
|
|
if (person.PrimaryImageTag) out.PrimaryImageTag = person.PrimaryImageTag;
|
|
if (person.ImageBlurHashes?.Primary) {
|
|
out.ImageBlurHashes = { Primary: person.ImageBlurHashes.Primary };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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,
|
|
DateLastMediaAdded: item.DateLastMediaAdded,
|
|
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));
|
|
}
|
|
|
|
function isVideoPlaybackRoute() {
|
|
const hash = String(window.location?.hash || '');
|
|
return hash.includes('#/video') || hash.includes('/video');
|
|
}
|
|
|
|
/**
|
|
* Wait while Jellyfin video playback is active or the tab is hidden,
|
|
* so library cache crawls do not compete for network during playback.
|
|
*/
|
|
async function waitWhileCacheNetworkPaused() {
|
|
while (isVideoPlaybackRoute() || (typeof document !== 'undefined' && document.hidden)) {
|
|
await new Promise((resolve) => {
|
|
let settled = false;
|
|
const done = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
const cleanup = () => {
|
|
window.removeEventListener('hashchange', onHash);
|
|
document.removeEventListener('visibilitychange', onVis);
|
|
clearTimeout(timer);
|
|
};
|
|
const onHash = () => {
|
|
if (!isVideoPlaybackRoute() && !(typeof document !== 'undefined' && document.hidden)) {
|
|
done();
|
|
}
|
|
};
|
|
const onVis = () => {
|
|
if (!document.hidden && !isVideoPlaybackRoute()) done();
|
|
};
|
|
window.addEventListener('hashchange', onHash);
|
|
document.addEventListener('visibilitychange', onVis);
|
|
const timer = setTimeout(done, 1000);
|
|
});
|
|
}
|
|
}
|
|
|
|
const IDLE_HOLD_MS = 15000;
|
|
const BOOTSTRAP_FALLBACK_MS = 30000;
|
|
const SESSION_READY_FALLBACK_MS = 30000;
|
|
|
|
function hasMouseIdle() {
|
|
return typeof document !== 'undefined'
|
|
&& document.body
|
|
&& document.body.classList.contains('mouseIdle');
|
|
}
|
|
|
|
/**
|
|
* After login (+ home painted or session-ready fallback), fire onReady once when
|
|
* the first of these happens: 15s continuous body.mouseIdle, document.hidden,
|
|
* or 30s fallback. Background tabs are allowed (and preferred).
|
|
*
|
|
* @param {{ waitForLogin: () => Promise<boolean>, onReady: () => void }} opts
|
|
*/
|
|
function scheduleIdleBootstrap(opts) {
|
|
const waitForLogin = opts?.waitForLogin;
|
|
const onReady = opts?.onReady;
|
|
if (typeof onReady !== 'function') return;
|
|
|
|
let fired = false;
|
|
let idleTimer = null;
|
|
let fallbackTimer = null;
|
|
let sessionReadyTimer = null;
|
|
let mo = null;
|
|
|
|
const cleanup = () => {
|
|
if (idleTimer) {
|
|
clearTimeout(idleTimer);
|
|
idleTimer = null;
|
|
}
|
|
if (fallbackTimer) {
|
|
clearTimeout(fallbackTimer);
|
|
fallbackTimer = null;
|
|
}
|
|
if (sessionReadyTimer) {
|
|
clearTimeout(sessionReadyTimer);
|
|
sessionReadyTimer = null;
|
|
}
|
|
if (mo) {
|
|
mo.disconnect();
|
|
mo = null;
|
|
}
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
document.removeEventListener('kefinTweaksHomePainted', onHomePainted);
|
|
};
|
|
|
|
const fire = () => {
|
|
if (fired) return;
|
|
fired = true;
|
|
cleanup();
|
|
try {
|
|
onReady();
|
|
} catch (e) {
|
|
console.error('[KefinTweaks LibraryCacheUtils] scheduleIdleBootstrap onReady failed:', e);
|
|
}
|
|
};
|
|
|
|
const syncIdleHold = () => {
|
|
if (fired) return;
|
|
if (hasMouseIdle()) {
|
|
if (!idleTimer) {
|
|
idleTimer = setTimeout(() => {
|
|
idleTimer = null;
|
|
if (hasMouseIdle()) fire();
|
|
}, IDLE_HOLD_MS);
|
|
}
|
|
} else if (idleTimer) {
|
|
clearTimeout(idleTimer);
|
|
idleTimer = null;
|
|
}
|
|
};
|
|
|
|
const onVisibility = () => {
|
|
if (document.hidden) fire();
|
|
};
|
|
|
|
const armGates = () => {
|
|
if (fired) return;
|
|
|
|
if (document.hidden) {
|
|
fire();
|
|
return;
|
|
}
|
|
|
|
fallbackTimer = setTimeout(fire, BOOTSTRAP_FALLBACK_MS);
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
|
|
if (typeof MutationObserver !== 'undefined' && document.body) {
|
|
mo = new MutationObserver(syncIdleHold);
|
|
mo.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
|
}
|
|
syncIdleHold();
|
|
};
|
|
|
|
const onHomePainted = () => {
|
|
document.removeEventListener('kefinTweaksHomePainted', onHomePainted);
|
|
if (sessionReadyTimer) {
|
|
clearTimeout(sessionReadyTimer);
|
|
sessionReadyTimer = null;
|
|
}
|
|
armGates();
|
|
};
|
|
|
|
Promise.resolve()
|
|
.then(() => (typeof waitForLogin === 'function' ? waitForLogin() : true))
|
|
.then((ok) => {
|
|
if (fired) return;
|
|
if (!ok) {
|
|
fire();
|
|
return;
|
|
}
|
|
document.addEventListener('kefinTweaksHomePainted', onHomePainted);
|
|
sessionReadyTimer = setTimeout(() => {
|
|
sessionReadyTimer = null;
|
|
document.removeEventListener('kefinTweaksHomePainted', onHomePainted);
|
|
armGates();
|
|
}, SESSION_READY_FALLBACK_MS);
|
|
})
|
|
.catch((e) => {
|
|
console.error('[KefinTweaks LibraryCacheUtils] scheduleIdleBootstrap login wait failed:', e);
|
|
fire();
|
|
});
|
|
}
|
|
|
|
window.LibraryCacheUtils = {
|
|
stripMovieForCache,
|
|
stripSeriesForCache,
|
|
stripPerson,
|
|
stripItemsForCache,
|
|
stripItemsAsync,
|
|
normalizeProviderKeys,
|
|
itemMatchesProviderIds,
|
|
buildProviderIdIndex,
|
|
getLibraryCacheSettings,
|
|
upsertById,
|
|
removeByIds,
|
|
isVideoPlaybackRoute,
|
|
waitWhileCacheNetworkPaused,
|
|
scheduleIdleBootstrap
|
|
};
|
|
})();
|