From 36376c067038cb098fa6054e2fb26d0dcbae5eac Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Wed, 28 Jan 2026 17:06:07 -0500 Subject: [PATCH] Add and update data caches: Movies, People, Studios --- scripts/indexedDBCache.js | 49 +++++- scripts/moviesCache.js | 174 +++++++++++++++++++++ scripts/peopleCache.js | 321 ++++++++++++++++++++++++++++++++++++++ scripts/studiosCache.js | 114 ++++++++++++++ 4 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 scripts/moviesCache.js create mode 100644 scripts/peopleCache.js create mode 100644 scripts/studiosCache.js diff --git a/scripts/indexedDBCache.js b/scripts/indexedDBCache.js index 9a27edc..de40daa 100644 --- a/scripts/indexedDBCache.js +++ b/scripts/indexedDBCache.js @@ -87,6 +87,43 @@ } } + // Get cached data entry (payload + metadata) if valid + async getCacheEntry(cacheName, userId = null) { + const key = this.getCacheKey(cacheName, userId); + + try { + const db = await this.openDB(); + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(key); + + return new Promise((resolve) => { + request.onsuccess = () => { + const data = request.result; + if (!data) { + resolve(null); + return; + } + + // Return full entry including timestamp and ttl + resolve({ + payload: data.payload, + timestamp: data.timestamp, + ttl: data.ttl || this.ttl + }); + }; + + request.onerror = () => { + WARN('Error getting cache entry:', request.error); + resolve(null); + }; + }); + } catch (error) { + ERR('Error in getCacheEntry:', error); + return null; + } + } + // Get cached data if valid async get(cacheName, userId = null) { if (!(await this.isCacheValid(cacheName, userId))) { @@ -220,8 +257,18 @@ } } + let _instance = null; + + function getIndexedDBCache() { + if (!_instance) { + _instance = new IndexedDBCache(); + LOG('IndexedDBCache instance created'); + } + return _instance; + } + // Create global instance - window.IndexedDBCache = IndexedDBCache; + window.IndexedDBCache = getIndexedDBCache(); LOG('IndexedDBCache manager loaded'); diff --git a/scripts/moviesCache.js b/scripts/moviesCache.js new file mode 100644 index 0000000..144c7a4 --- /dev/null +++ b/scripts/moviesCache.js @@ -0,0 +1,174 @@ +// KefinTweaks Studios Cache Manager +// Handles fetching, processing, and caching of Popular TV Networks +(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); + + // 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 + + // State + let movies = null; + let imdbTop250Movies = null; + let isInitializing = false; + + 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...'); + + 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); + return []; + } + } + + /** + * Initializes the cache + */ + async function initialize() { + if (isInitializing) return; + + if (movies) return; + + isInitializing = true; + try { + movies = await fetchAndCacheMovies(); + } finally { + isInitializing = false; + } + } + + /** + * Fetches IMDb Top 250 movies using client-side filtering + * @returns {Promise} - List of matched Movie items + */ + 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) { + const parsed = JSON.parse(cachedIds); + if (Date.now() - parsed.timestamp < IMDB_TOP_250_CACHE_TTL) { + imdbIds = parsed.ids; + } + } + } catch (e) { WARN('Error reading IMDb Top 250 cache:', e); } + + if (!imdbIds) { + LOG('Fetching IMDb Top 250 list from GitHub...'); + try { + 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/" + 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 + })); + } catch (err) { + ERR('Failed to fetch IMDb Top 250 list:', err); + return []; + } + } + + if (!imdbIds || imdbIds.length === 0) return []; + + return imdbIds; + } + + async function getImdbTop250Movies() { + const cacheName = 'imdbTop250Movies'; + const cache = window.IndexedDBCache; + const cached = await cache.get(cacheName); + if (cached) { + return cached; + } + + if (!imdbTop250Movies) { + 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 userId = ApiClient.getCurrentUserId(); + await cache.set(cacheName, imdbTop250Movies, userId, IMDB_TOP_250_CACHE_TTL); + return imdbTop250Movies || []; + } + + async function getMovies() { + if (!movies) { + movies = await fetchAndCacheMovies(); + } + return movies || []; + } + + // Expose API + window.MoviesCache = { + init: initialize, + getMovies: getMovies, + getImdbTop250Movies: getImdbTop250Movies, + // Also expose the fetcher if we need to force refresh + refresh: async () => { + const cache = window.IndexedDBCache; + cache.delete('movies_library'); + movies = await fetchAndCacheMovies(); + return movies; + } + }; + + LOG('Module loaded'); + +})(); diff --git a/scripts/peopleCache.js b/scripts/peopleCache.js new file mode 100644 index 0000000..5893429 --- /dev/null +++ b/scripts/peopleCache.js @@ -0,0 +1,321 @@ +// KefinTweaks People Cache Manager +// Handles fetching, processing, and caching of top actors, directors, and writers +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks PeopleCache]', ...args); + 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 + + // State + let moviesTopPeople = null; + let isInitializing = false; + let isComplete = false; + + /** + * Extracts necessary item data for caching (similar to watchlist format) + */ + function extractItemDataForCache(item) { + 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 + } + }; + } + + /** + * 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, { + id: person.Id, + name: person.Name, + 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); + } + } + }); + } + }); + + return peopleMap; + } catch (err) { + ERR('Failed to process people data:', err); + return null; + } + } + + /** + * Filters and sorts raw people data + */ + function filterPeopleData(peopleMap) { + 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) + .slice(0, 100) + .map(person => ({ + id: person.id, + name: person.name, + count: person.directorCount, + items: person.directorItems || [] + })); + + const topWriters = allPeople + .filter(person => person.writerCount >= minAppearances) + .sort((a, b) => b.writerCount - a.writerCount) + .slice(0, 100) + .map(person => ({ + id: person.id, + name: person.name, + count: person.writerCount, + items: person.writerItems || [] + })); + + const topActors = allPeople + .filter(person => person.actorCount >= minAppearances) + .sort((a, b) => b.actorCount - a.actorCount) + .slice(0, 100) + .map(person => ({ + id: person.id, + name: person.name, + count: person.actorCount, + items: person.actorItems || [] + })); + + return { + actors: topActors, + directors: topDirectors, + writers: topWriters + }; + } catch (err) { + ERR('Failed to filter people data:', err); + return null; + } + } + + /** + * Fetches and processes top people data from all movies using pagination + */ + async function fetchTopPeople() { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + const indexedDBCache = window.IndexedDBCache; + + try { + LOG('Starting paginated fetch of movies for people data processing...'); + + let startIndex = 0; + const limit = 500; + let hasMoreData = true; + let peopleMap = null; + + // 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`); + } + } + + while (hasMoreData) { + const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`; + + LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`); + + const response = await fetch(url, { + headers: { 'Authorization': `MediaBrowser Token="${token}"` } + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const data = await response.json(); + const movies = data.Items || []; + + if (movies.length === 0) { + hasMoreData = false; + } else { + peopleMap = processPeopleDataRaw(movies, peopleMap); + startIndex += movies.length; + + // Update global state with partial results for immediate use + const filteredData = filterPeopleData(peopleMap); + if (filteredData) { + moviesTopPeople = filteredData; + } + + // Checkpoint raw data + const rawDataToCache = { + isComplete: movies.length < limit, + moviesProcessedCount: startIndex, + peopleData: Array.from(peopleMap.entries()) + }; + await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000); + + if (movies.length < limit) { + hasMoreData = false; + isComplete = true; + } + } + } + + const finalPeopleData = filterPeopleData(peopleMap); + if (finalPeopleData) { + finalPeopleData.isComplete = true; + moviesTopPeople = finalPeopleData; + + await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000); + await indexedDBCache.clear('movies_top_people_raw', userId); + + LOG('People cache complete and saved'); + } + + return finalPeopleData; + + } catch (err) { + ERR('Failed to fetch top people data:', err); + return null; + } + } + + /** + * Initializes the people cache in the background + */ + async function initialize() { + if (isInitializing) return; + + if (moviesTopPeople !== null && isComplete) { + return; + } + + const indexedDBCache = 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; + } + } + + // Initialize background fetch + isInitializing = true; + try { + await fetchTopPeople(); + } finally { + isInitializing = false; + } + } + + async function getTopPeople() { + if (!moviesTopPeople) { + await initialize(); + } + return moviesTopPeople; + } + + async function getTopActors() { + if (!moviesTopPeople) { + await initialize(); + } + return moviesTopPeople.actors; + } + + async function getTopDirectors() { + if (!moviesTopPeople) { + await initialize(); + } + return moviesTopPeople.directors; + } + + 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 + }; + + LOG('Module loaded'); + +})(); diff --git a/scripts/studiosCache.js b/scripts/studiosCache.js new file mode 100644 index 0000000..b7bfd37 --- /dev/null +++ b/scripts/studiosCache.js @@ -0,0 +1,114 @@ +// KefinTweaks Studios Cache Manager +// Handles fetching, processing, and caching of Series Studios +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks StudiosCache]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks StudiosCache]', ...args); + const ERR = (...args) => console.error('[KefinTweaks StudiosCache]', ...args); + + // State + let _seriesStudios = null; + + /** + * Fetches and caches Series Studios using apiHelper.getQuery + * Uses progressive loading: returns immediate cached data if available, + * then updates in-memory cache when fresh data arrives + */ + async function fetchAndCacheSeriesStudios() { + if (!window.apiHelper) { + ERR('apiHelper is not available'); + return []; + } + + try { + const apiClient = window.ApiClient; + if (!apiClient) { + ERR('ApiClient is not available'); + return []; + } + + const serverUrl = apiClient.serverAddress(); + const url = `${serverUrl}/Studios?IncludeItemTypes=Series`; + + LOG('Fetching Series Studios...'); + const result = await window.apiHelper.getQuery(url, { useCache: true }); + + // Extract immediate data (may be null/empty if uncached) + const immediateData = result.data; + const studios = immediateData?.Items || immediateData || []; + + // When dataPromise resolves, update in-memory cache + result.dataPromise.then(freshData => { + const freshStudios = freshData?.Items || freshData || []; + _seriesStudios = freshStudios; + LOG(`Updated in-memory cache with ${freshStudios.length} Series Studios`); + }).catch(err => { + WARN('Error updating in-memory cache from dataPromise:', err); + }); + + return studios; + } catch (err) { + ERR('Error fetching Series Studios:', err); + return []; + } + } + + /** + * Gets all Series Studios, using in-memory cache if available + * Otherwise fetches and caches the data + */ + async function getSeriesStudios() { + if (_seriesStudios !== null) { + return _seriesStudios; + } + + // Fetch and cache, which will return immediate data and update cache when promise resolves + const studios = await fetchAndCacheSeriesStudios(); + + // Update in-memory cache with immediate data (even if empty) to avoid repeated calls + if (_seriesStudios === null) { + _seriesStudios = studios; + } + + return studios; + } + + /** + * Initializes the cache + */ + async function initialize() { + await getSeriesStudios(); + } + + /** + * Gets popular TV networks (studios) filtered by minimum series count + * @param {number} minimumSeries - Minimum number of series required (default: 0, returns all) + * @returns {Promise} - Array of studios meeting the minimum threshold + */ + async function getPopularTVNetworks(minimumSeries) { + if (!minimumSeries) { + minimumSeries = window.KefinTweaksConfig?.HOME_SETTINGS?.minimumSeriesForPopularTVNetworks ?? window.KefinHomeConfig2?.HOME_SETTINGS?.minimumSeriesForPopularTVNetworks; + } + + const studios = await getSeriesStudios(); + + if (minimumSeries > 0) { + return studios.filter(studio => + (studio.SeriesCount || studio.ChildCount || 0) >= minimumSeries + ); + } + + return studios; + } + + // Expose API + window.StudiosCache = { + init: initialize, + getPopularTVNetworks: getPopularTVNetworks, + getSeriesStudios: getSeriesStudios + }; + + LOG('Module loaded'); + +})();