diff --git a/helpers/dataHelper.js b/helpers/dataHelper.js new file mode 100644 index 0000000..1a0856e --- /dev/null +++ b/helpers/dataHelper.js @@ -0,0 +1,91 @@ +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks DataHelper]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks DataHelper]', ...args); + const ERR = (...args) => console.error('[KefinTweaks DataHelper]', ...args); + + /** + * Ensure ApiClient is available before using helper methods + */ + function ensureApiClient() { + if (typeof ApiClient === 'undefined' || !ApiClient) { + throw new Error('ApiClient is not available'); + } + } + + /** + * API Helper functions for Jellyfin operations + */ + const dataHelper = { + getLibraries: async function() { + ensureApiClient(); + + if (__libraries) { + return __libraries; + } + + const response = await ApiClient.getItems(); + return response.Items; + }, + + /** + * Get filter data (Genres, Tags, OfficialRatings, Years) for specified item types + * @param {string|Array} includeItemTypes - Item types to filter (e.g., 'Movie', ['Movie', 'Episode']) + * @param {boolean} useCache - Whether to use cached data (default: true) + * @param {boolean} forceRefresh - Whether to bypass cache (default: false) + * @returns {Promise} - Object with Genres, Tags, OfficialRatings, Years arrays + */ + getFilters: async function(includeItemTypes, useCache = true, forceRefresh = false) { + ensureApiClient(); + + if (!window.apiHelper) { + throw new Error('apiHelper is not available'); + } + + const userId = ApiClient.getCurrentUserId(); + const serverAddress = ApiClient.serverAddress(); + + if (!userId || !serverAddress) { + throw new Error('User ID or server address not available'); + } + + // Normalize includeItemTypes to comma-separated string + const itemTypesStr = Array.isArray(includeItemTypes) + ? includeItemTypes.join(',') + : (includeItemTypes || 'Movie'); + + // Build URL + let url = `${serverAddress}/Items/Filters?UserId=${userId}`; + if (itemTypesStr) { + url += `&IncludeItemTypes=${itemTypesStr}`; + } + + // Use apiHelper.getQuery with 24-hour cache (86400000 ms) + const cacheOptions = { + useCache: useCache && !forceRefresh, + ttl: 24 * 60 * 60 * 1000, // 24 hours + forceRefresh: forceRefresh + }; + + const data = await window.apiHelper.getQuery(url, cacheOptions); + + // Handle both direct response and promise response + const filters = (data && data.data) ? data.data : data; + + return { + Genres: filters?.Genres || [], + Tags: filters?.Tags || [], + OfficialRatings: filters?.OfficialRatings || [], + Years: filters?.Years || [] + }; + } + }; + + let __libraries = null; + + // Expose dataHelper to global window object + window.dataHelper = dataHelper; + + console.log('[KefinTweaks DataHelper] Module loaded and available at window.dataHelper'); +})(); \ No newline at end of file diff --git a/helpers/websocketHelper.js b/helpers/websocketHelper.js new file mode 100644 index 0000000..1d1875b --- /dev/null +++ b/helpers/websocketHelper.js @@ -0,0 +1,128 @@ +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks WebSocketHelper]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks WebSocketHelper]', ...args); + const ERR = (...args) => console.error('[KefinTweaks WebSocketHelper]', ...args); + + // Registry of event listeners: MessageType -> array of callbacks + const listeners = {}; + + // Track initialization state + let isInitialized = false; + let retries = 0; + const maxRetries = 10; + + /** + * Initialize WebSocket monitoring by hooking into ApiClient's WebSocket + */ + function initialize() { + try { + // Grab the actual socket + const socket = (window.ApiClient && (window.ApiClient.webSocket || window.ApiClient._webSocket)) || null; + + if (!socket) { + if (retries >= maxRetries) { + ERR('Max retries reached, giving up on WebSocket initialization'); + return false; + } + retries++; + setTimeout(() => { + if (!isInitialized) { + initialize(); + } + }, 1000); + return; + } + + // Store original handler if it exists + const originalHandler = socket.onmessage; + + // Hook into onmessage + socket.onmessage = function(event) { + try { + // Pass it through so Jellyfin still works normally + if (originalHandler) { + originalHandler.call(this, event); + } + + // Parse the message data + const messageData = event.Data || event.data; + const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData; + + // Get the MessageType + const messageType = data.MessageType; + + if (messageType && listeners[messageType]) { + // Fire all callbacks for this message type + listeners[messageType].forEach(callback => { + try { + callback(data); + } catch (err) { + ERR(`Error in WebSocket listener for ${messageType}:`, err); + } + }); + } + } catch (err) { + // Log parse errors but don't break the original handler + WARN('WebSocket message parse error:', err); + } + }; + + isInitialized = true; + LOG('WebSocket monitoring initialized'); + return true; + } catch (err) { + ERR('Error initializing WebSocket monitoring:', err); + return false; + } + } + + /** + * Register a callback to listen for a specific WebSocket message type + * @param {string} messageType - The MessageType to listen for (e.g., 'UserDataChanged', 'PlaybackStopped') + * @param {Function} callback - Function to call when the message type is received. Receives the parsed message data. + */ + function listen(messageType, callback) { + if (typeof messageType !== 'string' || !messageType) { + ERR('listen() requires a non-empty string messageType'); + return; + } + + if (typeof callback !== 'function') { + ERR('listen() requires a callback function'); + return; + } + + // Initialize listeners array for this message type if it doesn't exist + if (!listeners[messageType]) { + listeners[messageType] = []; + } + + // Add the callback + listeners[messageType].push(callback); + LOG(`Registered listener for message type: ${messageType} (${listeners[messageType].length} total listeners)`); + + // Ensure WebSocket is initialized + if (!isInitialized) { + initialize(); + } + } + + // Expose the API globally + window.websocketHelper = { + listen: listen + }; + + // Try to initialize immediately + if (!initialize()) { + // If WebSocket isn't available yet, retry after a short delay + // This can happen if the page loads before the WebSocket connection is established + setTimeout(() => { + if (!isInitialized) { + initialize(); + } + }, 1000); + } +})(); + diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js index c08ff5e..95b5045 100644 --- a/scripts/apiHelper.js +++ b/scripts/apiHelper.js @@ -138,6 +138,22 @@ if (!response.ok) { throw new Error(`PlayLast request failed with HTTP ${response.status}: ${response.statusText}`); } + } + + /** + * Check if user is admin + * @returns {Promise} - True if user is admin, false otherwise + */ + async function isAdmin() { + try { + if (window.ApiClient && window.ApiClient.getCurrentUser) { + const user = await window.ApiClient.getCurrentUser(); + return user && user.Policy && user.Policy.IsAdministrator === true; + } + } catch (error) { + console.warn('[KefinTweaks APIHelper] Could not check admin status:', error); + } + return false; } /** @@ -216,6 +232,250 @@ * API Helper functions for Jellyfin operations */ const apiHelper = { + /** + * Generic data fetcher with universal caching and progressive loading support + * @param {string} url - Full URL to fetch + * @param {Object} options - Cache options + * @param {boolean} options.useCache - Whether to return cached data first (default: false) + * @param {number} options.ttl - Cache time to live in milliseconds (default: 300000) + * @returns {Promise|Object} - If useCache is true, returns { data, dataPromise, isStalePromise }. Otherwise returns Promise. + */ + getQuery: function(url, options = {}) { + ensureApiClient(); + + const useCache = options.useCache || false; + let ttl = 300000; + if (Number(options.ttl) >= 0) { + ttl = Number(options.ttl); + } + const forceRefresh = options.forceRefresh || false; + const cacheKey = url; + + // Standard fetch function + const fetchData = async () => { + try { + const token = ApiClient.accessToken(); + const response = await fetch(url, { + headers: { + 'X-Emby-Token': token, + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + // Always cache successful responses + if (data) { + setCachedQuery(cacheKey, data); + // Also persist to IndexedDB if available + if (window.IndexedDBCache) { + const cache = window.IndexedDBCache; + // We use the full URL as part of the key, but hashed or sanitized might be safer. + // For now using URL as is common in this codebase. + // IndexedDBCache expects a "name" which it prefixes. + // We'll use a hash of the URL to keep keys clean? + // Or just pass the URL as the cacheName but it might get messy. + // Let's use a simple mapping or just stringify. + // Actually, let's just stick to the in-memory cache for 'getQuery' unless we want persistent storage for home screen. + // The requirement says "use existing indexeddbcache functionality". + // So let's use it. + await cache.set(url, data, null, ttl); + } + } + + return data; + } catch (error) { + ERR('Failed to fetch URL:', error); + throw error; + } + }; + + // If not using cache, just fetch + if (!useCache) { + return fetchData(); + } + + // Progressive loading Logic + const result = { + data: null, + dataPromise: null, + isStalePromise: null + }; + + // 1. Get cached data immediately (async but fast) + const cachePromise = (async () => { + // Try memory cache first + let cached = getCachedQuery(cacheKey, ttl); + + // If not in memory, try IndexedDB + if (!cached && window.IndexedDBCache) { + const dbCache = window.IndexedDBCache; + const entry = await dbCache.getCacheEntry(url); + if (entry) { + cached = entry; // Keep structure { payload, timestamp, ttl } + LOG(`[Cache] Found in IndexedDB: ${url.substring(0, 60)}...`); + } + } else if (cached) { + // Wrap memory cache to match structure + cached = { payload: cached, timestamp: Date.now(), ttl: ttl }; + LOG(`[Cache] Found in Memory: ${url.substring(0, 60)}...`); + } else { + LOG(`[Cache] Miss: ${url.substring(0, 60)}...`); + } + + return cached; + })(); + + // 2. Define staleness check + result.isStalePromise = cachePromise.then(async (entry) => { + if (forceRefresh) { + LOG(`[StaleCheck] Force Refresh -> Stale`); + return true; // Force Refresh = stale + } + + if (!entry) { + LOG(`[StaleCheck] No cache entry -> Stale`); + return true; // No cache = stale + } + + const now = Date.now(); + const age = now - entry.timestamp; + const isExpired = age > ttl; + + result.data = entry.payload; + + if (!isExpired) { + LOG(`[StaleCheck] Cache valid (${(age/1000).toFixed(1)}s old) -> Not Stale`); + return false; // Valid + } + + LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Validating...`); + + // Cache is expired, check if we can validate it + // Logic: Check SortBy + const urlObj = new URL(url); + const sortBy = urlObj.searchParams.get('SortBy'); + const isRandom = !sortBy || sortBy.includes('Random'); + + if (isRandom) { + LOG(`[StaleCheck] Random sort detected -> Stale (Force Refresh)`); + return true; // Random + Expired = Stale + } + + // Fixed Sort: Fetch first 3 items + try { + // Clone URL and modify params + const checkUrl = new URL(url); + checkUrl.searchParams.set('Limit', '3'); + //checkUrl.searchParams.set('StartIndex', '0'); // Ensure start + + const token = ApiClient.accessToken(); + const checkResponse = await fetch(checkUrl.toString(), { + headers: { + 'X-Emby-Token': token, + 'Accept': 'application/json' + } + }); + + if (!checkResponse.ok) { + LOG(`[StaleCheck] Validation fetch failed -> Stale`); + return true; + } + + const checkData = await checkResponse.json(); + + // Compare IDs + const cachedItems = entry.payload.Items || entry.payload || []; + const freshItems = checkData.Items || checkData || []; + + if (freshItems.length === 0 && cachedItems.length === 0) { + LOG(`[StaleCheck] Both empty -> Not Stale`); + return false; + } + if (freshItems.length !== Math.min(cachedItems.length, 3)) { + LOG(`[StaleCheck] Count mismatch (Fresh: ${freshItems.length}, Cached: ${Math.min(cachedItems.length, 3)}) -> Stale`); + return true; + } + + for (let i = 0; i < freshItems.length; i++) { + if (freshItems[i].Id !== cachedItems[i].Id) { + LOG(`[StaleCheck] Item mismatch at index ${i} -> Stale`); + return true; // Mismatch found + } + + // Also check the UserData for the item + if (freshItems[i].UserData) { + // Check each key in the UserData object + for (const key in freshItems[i].UserData) { + if (freshItems[i].UserData[key] !== cachedItems[i].UserData[key]) { + LOG(`[StaleCheck] UserData mismatch for key ${key} -> Stale`); + return true; // Mismatch found + } + } + } + + // Also check the ImageTags for the item + if (freshItems[i].ImageTags) { + // Check each key in the ImageTags object + for (const key in freshItems[i].ImageTags) { + if (freshItems[i].ImageTags[key] !== cachedItems[i].ImageTags[key]) { + LOG(`[StaleCheck] ImageTags mismatch for key ${key} -> Stale`); + return true; // Mismatch found + } + } + } + } + + // Validation Successful: Update timestamp + LOG(`[StaleCheck] Content matches -> Not Stale (Updating Timestamp)`); + if (window.IndexedDBCache) { + const dbCache = window.IndexedDBCache; + // Update with new timestamp but same data + dbCache.set(url, entry.payload, null, ttl); + } + + result.data = entry.payload; // Use cached data + return false; // Not stale + + } catch (e) { + WARN('Error validating cache:', e); + return true; // Assume stale on error + } + }); + + // 3. Define data promise + result.dataPromise = result.isStalePromise.then(isStale => { + if (isStale) { + LOG(`[Fetch] Refreshing data for: ${url.substring(0, 60)}...`); + return fetchData(); + } else { + LOG(`[Fetch] Using cached data for: ${url.substring(0, 60)}...`); + return result.data; // Should be set by isStalePromise logic if valid + } + }); + + return (async () => { + // Initial check for cached data + const entry = await cachePromise; + result.data = entry ? entry.payload : null; + return result; + })(); + }, + + invalidateCache: function(url) { + const dbCache = window.IndexedDBCache; + if (dbCache) { + dbCache.clear(url); + } + if (queryCache) { + queryCache.delete(url); + } + }, + /** * Generic data fetcher with universal caching * Always caches successful responses, but only checks cache when useCache is true @@ -269,10 +529,10 @@ getWatchlistItems: async function(options = {}, useCache = false) { // First fetch the Library Items that contain items of the supported types from ApiClient.getJSON(ApiClient.getUrl("Library/MediaFolders")) - const libraryItems = await ApiClient.getItems(); + const libraryItems = await window.dataHelper.getLibraries(); // We now want to include the library items with ColletionType "tvshows", or "movies" - const supportedLibraryItems = libraryItems.Items.filter(item => item.CollectionType === 'tvshows' || item.CollectionType === 'movies'); + const supportedLibraryItems = libraryItems.filter(item => item.CollectionType === 'tvshows' || item.CollectionType === 'movies'); // Now use these support library items as the parent ids for the Items query, since the Items endpoint doesn't support ParentIds as an array we need to make a different call for each library. Querying with the parent id will dramatically reduce the time for the request to return so this is fine let watchlistItems = []; @@ -496,8 +756,307 @@ } LOG('Queue updated successfully'); - } + }, + /** + * Gets a genre ID from cached movie genres + * @param {string} genreName - Name of the genre to find + * @returns {Promise} - Genre ID or null if not found + */ + getGenreId: async function(genreName) { + if (!cachedMovieGenres) { + const url = `${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`; + // Use getData to fetch the genres with caching + const genres = await this.getData(url, true, GENRE_TTL); + cachedMovieGenres = genres.Items; + } + + // Find the genre by name (case insensitive) + const genre = cachedMovieGenres.find(g => g.Name.toLowerCase() === genreName.toLowerCase()); + return genre ? genre.Id : null; + }, + + /** + * Build query URL from section query configuration + * Handles minAge/maxAge conversion to MinPremiereDate/MaxPremiereDate + * @param {Object} query - Query object with optional path, dataSource, minAge, maxAge, and queryOptions + * @param {string} userId - User ID + * @param {string} serverUrl - Server URL + * @returns {string|Object} - Query URL string or dataSource marker object + */ + buildQueryFromSection: function(query, userId, serverUrl) { + ensureApiClient(); + + const queryOptions = { ...(query.queryOptions || {}) }; + + // Convert minAge/maxAge to premiere date range + if (query.minAge !== undefined || query.maxAge !== undefined) { + const now = new Date(); + + if (query.maxAge !== undefined) { + // maxAge days ago = minimum premiere date (oldest content) + const minDate = new Date(now); + minDate.setDate(minDate.getDate() - query.maxAge); + queryOptions.MinPremiereDate = minDate.toISOString().split('T')[0]; + } + + if (query.minAge !== undefined) { + // minAge days ago = maximum premiere date (newest content) + const maxDate = new Date(now); + maxDate.setDate(maxDate.getDate() - query.minAge); + queryOptions.MaxPremiereDate = maxDate.toISOString().split('T')[0]; + } + } + + // Build query based on type + if (query.path) { + return this.buildCustomEndpoint(query.path, queryOptions, userId, serverUrl); + } + + if (query.dataSource) { + return { dataSource: query.dataSource, options: queryOptions }; + } + + // Standard /Items query + return this.buildStandardQuery(queryOptions, userId, serverUrl); + }, + + /** + * Build standard /Items query URL + * @param {Object} queryOptions - Query options + * @param {string} userId - User ID + * @param {string} serverUrl - Server URL + * @returns {string} - Query URL + */ + buildStandardQuery: function(queryOptions, userId, serverUrl) { + // Fields that require pipe delimiter instead of comma + const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds']; + + const params = new URLSearchParams({ + Recursive: 'true', + Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData', + ImageTypeLimit: 1, + UserId: userId + }); + + // Map all queryOptions to params + Object.entries(queryOptions || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + // Use pipe delimiter for specific fields, comma for others + const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ','; + params.set(key, value.join(delimiter)); + } else { + // If the key is Limit and the value is 0, do not set the param + if (key === 'Limit' && value === 0) { + return; + } + params.set(key, value); + } + } + }); + + return `${serverUrl}/Items?${params.toString()}`; + }, + + /** + * Build custom endpoint URL + * @param {string} path - Custom endpoint path (e.g., '/Shows/NextUp') + * @param {Object} queryOptions - Query options + * @param {string} userId - User ID + * @param {string} serverUrl - Server URL + * @returns {string} - Query URL + */ + buildCustomEndpoint: function(path, queryOptions, userId, serverUrl) { + // Fields that require pipe delimiter instead of comma + const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds']; + + const params = new URLSearchParams({ + UserId: userId + }); + + // Add query options to params + Object.entries(queryOptions || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + // Use pipe delimiter for specific fields, comma for others + const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ','; + params.set(key, value.join(delimiter)); + } else { + // If the key is Limit and the value is 0, do not set the param + if (key === 'Limit' && value === 0) { + return; + } + params.set(key, value); + } + } + }); + + // Add default fields for custom endpoints if not specified + if (!params.has('Fields')) { + params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,MediaSourceCount,UserData'); + } + if (!params.has('ImageTypeLimit')) { + params.set('ImageTypeLimit', '1'); + } + if (!params.has('EnableTotalRecordCount')) { + params.set('EnableTotalRecordCount', 'false'); + } + + return `${serverUrl}${path}?${params.toString()}`; + }, + + /** + * Fetch from data source (cache-based) + * @param {string} dataSource - Data source in format "CacheName.methodName" + * @param {Object} options - Query options (sorting, limits, etc.) + * @returns {Object} - Object with data, dataPromise, and isStalePromise + */ + fetchFromDataSource: async function(dataSource, options, useCache = true) { + const [cacheName, methodName] = dataSource.split('.'); + const cache = window[cacheName]; + + if (!cache || !cache[methodName]) { + WARN(`Data source not available: ${dataSource}`); + return { + data: { Items: [] }, + dataPromise: Promise.resolve({ Items: [] }), + isStalePromise: Promise.resolve(false) + }; + } + + try { + let dataPromise = cache[methodName](); + dataPromise = dataPromise.then(data => { + if (!Array.isArray(data)) { + data = []; + } + + // Filter studios/networks with no thumb + if (cacheName === 'StudiosCache') { + data = data.filter(item => item.ImageTags?.Thumb); + } + + // Apply options (sorting, limits, etc.) + if (options.SortBy === 'Random') { + data = data.sort(() => Math.random() - 0.5); + } else if (options.SortBy) { + data = data.sort((a, b) => { + const aVal = a[options.SortBy]; + const bVal = b[options.SortBy]; + if (aVal === bVal) return 0; + const comparison = aVal > bVal ? 1 : -1; + return options.SortOrder === 'Descending' ? -comparison : comparison; + }); + } + + if (options.Limit) { + data = data.slice(0, options.Limit); + } + return { + Items: data + }; + }); + + if (!useCache) { + return await dataPromise; + } + + return { + data: { Items: [] }, + dataPromise: dataPromise, + isStalePromise: Promise.resolve(false) + }; + } catch (error) { + ERR(`Failed to fetch from ${dataSource}:`, error); + return { + data: { Items: [] }, + dataPromise: Promise.resolve({ Items: [] }), + isStalePromise: Promise.resolve(false) + }; + } + }, + + /** + * Merge results from multiple queries + * @param {Array} results - Array of query result objects + * @param {Object} sectionConfig - Section configuration object + * @returns {Object} - Merged result object with config, result.data, result.dataPromise, result.isStalePromise + */ + mergeMultiQueryResults: function(results, sectionConfig) { + // Combine all items from all queries + const allItems = []; + const allPromises = []; + + results.forEach(result => { + if (result.data?.Items) { + allItems.push(...result.data.Items); + } + allPromises.push(result.dataPromise); + }); + + // Sort merged items if sortBy specified + if (sectionConfig.sortBy) { + allItems.sort((a, b) => { + const aVal = a[sectionConfig.sortBy] || (a.UserData?.[sectionConfig.sortBy] ? new Date(a.UserData[sectionConfig.sortBy]).getTime() : 0); + const bVal = b[sectionConfig.sortBy] || (b.UserData?.[sectionConfig.sortBy] ? new Date(b.UserData[sectionConfig.sortBy]).getTime() : 0); + + // Handle DatePlayed specifically for merged Continue/NextUp + if (sectionConfig.sortBy === 'DatePlayed') { + const aDate = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0; + const bDate = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0; + const comparison = aDate > bDate ? 1 : aDate < bDate ? -1 : 0; + return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison; + } + + const comparison = aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison; + }); + } + + // Create merged data promise + const mergedDataPromise = Promise.all(allPromises).then(allData => { + const mergedItems = []; + allData.forEach(data => { + if (data?.Items) mergedItems.push(...data.Items); + }); + + // Sort merged results + if (sectionConfig.sortBy) { + mergedItems.sort((a, b) => { + const aVal = a[sectionConfig.sortBy] || (a.UserData?.[sectionConfig.sortBy] ? new Date(a.UserData[sectionConfig.sortBy]).getTime() : 0); + const bVal = b[sectionConfig.sortBy] || (b.UserData?.[sectionConfig.sortBy] ? new Date(b.UserData[sectionConfig.sortBy]).getTime() : 0); + + if (sectionConfig.sortBy === 'DatePlayed') { + const aDate = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0; + const bDate = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0; + const comparison = aDate > bDate ? 1 : aDate < bDate ? -1 : 0; + return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison; + } + + const comparison = aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison; + }); + } + + return { Items: mergedItems }; + }); + + return { + config: sectionConfig, + result: { + data: { Items: window.cardBuilder.postProcessItems(sectionConfig, allItems) }, + dataPromise: mergedDataPromise.then(data => window.cardBuilder.postProcessItems(sectionConfig, data)), + isStalePromise: Promise.all(results.map(r => r.isStalePromise || Promise.resolve(false))) + .then(staleFlags => staleFlags.some(s => s)) + } + }; + }, + + isAdmin: isAdmin }; + + const GENRE_TTL = 24 * 60 * 60 * 1000; // 24 hours + let cachedMovieGenres = null; // Expose apiHelper to global window object window.apiHelper = apiHelper; diff --git a/scripts/userHelper.js b/scripts/userHelper.js new file mode 100644 index 0000000..376e857 --- /dev/null +++ b/scripts/userHelper.js @@ -0,0 +1,109 @@ +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks APIHelper]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks APIHelper]', ...args); + const ERR = (...args) => console.error('[KefinTweaks APIHelper]', ...args); + + /** + * Ensure ApiClient is available before using helper methods + */ + function ensureApiClient() { + if (typeof ApiClient === 'undefined' || !ApiClient) { + throw new Error('ApiClient is not available'); + } + } + + /** + * API Helper functions for Jellyfin operations + */ + const userHelper = { + getUserDisplayPreferences: async function() { + ensureApiClient(); + + let cachedDisplayPreferences = null; + + if (__userDisplayPreferences) { + cachedDisplayPreferences = __userDisplayPreferences; + } else if (window.LocalStorageCache) { + const cache = new window.LocalStorageCache(); + const displayPreferencesData = cache.get('userDisplayPreferences'); + if (displayPreferencesData) { + cachedDisplayPreferences = displayPreferencesData; + } + } + + // Wrap this in a promise to return with the cached data + const userDisplayPreferencesPromise = new Promise(async (resolve, reject) => { + try { + // Fetch the user display preferences + LOG('Fetching user display preferences'); + const response = await fetch(`${ApiClient.serverAddress()}/DisplayPreferences/usersettings?userId=${ApiClient.getCurrentUserId()}&client=emby`, { + headers: { + 'X-Emby-Token': ApiClient.accessToken() + } + }); + if (!response.ok) { + reject(new Error(`Failed to fetch display preferences: ${response.status}`)); + return; + } + const data = await response.json(); + __userDisplayPreferences = data; + // Save the display preferences to localStorage + if (window.LocalStorageCache) { + const cache = new window.LocalStorageCache(); + cache.set('userDisplayPreferences', data, ApiClient.getCurrentUserId(), 24 * 60 * 60 * 1000); + } + resolve(data); + } catch (error) { + reject(error); + } + }); + + return { promise: userDisplayPreferencesPromise, cached: cachedDisplayPreferences }; + }, + useEpisodeImages: async function() { + const { promise, cached } = await this.getUserDisplayPreferences(); + const userDisplayPreferences = await promise; + if (!userDisplayPreferences) { + LOG('No user display preferences found'); + return false; + } + return userDisplayPreferences.CustomPrefs?.useEpisodeImagesInNextUpAndResume === 'true'; + }, + updateCustomPrefsByKey: async function(key, value) { + const { promise, cached } = await this.getUserDisplayPreferences(); + const userDisplayPreferences = await promise; + if (!userDisplayPreferences) { + LOG('No user display preferences found'); + return false; + } + userDisplayPreferences.CustomPrefs[key] = value; + return await this.updateDisplayPreferences(userDisplayPreferences); + }, + updateDisplayPreferences: async function(prefs) { + ensureApiClient(); + const userId = ApiClient.getCurrentUserId(); + const serverAddress = ApiClient.serverAddress(); + const accessToken = ApiClient.accessToken(); + const url = `${serverAddress}/DisplayPreferences/usersettings?userId=${userId}&client=emby`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Emby-Token': accessToken + }, + body: JSON.stringify(prefs) + }); + return response.ok; + } + }; + + // TODO -- Update this whenever the user display preferences are updated + let __userDisplayPreferences = null; + + // Expose userHelper to global window object + window.userHelper = userHelper; + + console.log('[KefinTweaks UserHelper] Module loaded and available at window.userHelper'); +})(); \ No newline at end of file