Add and update helpers: Data, WebSocket, Api and User

This commit is contained in:
HighImKevin committed 2026-01-28 17:07:29 -05:00
1 parent 36376c0670
commit bc5379bf48
4 files changed
+890 -3

No files matched your search

+91
View File
@@ -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<string>} 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>} - 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');
})();
+128
View File
@@ -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);
}
})();
+562 -3
View File
@@ -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<boolean>} - 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>|Object} - If useCache is true, returns { data, dataPromise, isStalePromise }. Otherwise returns Promise<data>.
*/
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<string|null>} - 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;
+109
View File
@@ -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');
})();