Pray for me

This commit is contained in:
HighImKevin
2026-09-10 07:25:06 -04:00
parent bd45be2fd6
commit 1dbb3b8518
98 changed files with 47471 additions and 10558 deletions
+319 -243
View File
@@ -13,6 +13,87 @@
throw new Error('ApiClient is not available');
}
}
const DEFAULT_ITEM_FIELDS = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,ProviderIds,MediaSourceCount';
const SPOTLIGHT_ITEM_FIELDS = `${DEFAULT_ITEM_FIELDS},People,Genres,ParentBackdropImageTags,Studios`;
const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds'];
function getDefaultFields(isSpotlight) {
return isSpotlight ? SPOTLIGHT_ITEM_FIELDS : DEFAULT_ITEM_FIELDS;
}
/**
* Resolve Fields param for a query based on section type and explicit queryOptions.Fields.
* @param {Object} queryOptions
* @param {{ isSpotlight?: boolean, sectionType?: string }} context
* @returns {string}
*/
function resolveQueryFields(queryOptions, { isSpotlight = false, sectionType = '' } = {}) {
const explicit = queryOptions?.Fields;
if (!explicit) {
return getDefaultFields(isSpotlight);
}
const isDefaultHome = sectionType === 'home';
if (isDefaultHome && !isSpotlight) {
return mergeFields(null, explicit);
}
if (isDefaultHome && isSpotlight) {
return mergeFields(SPOTLIGHT_ITEM_FIELDS, explicit);
}
return mergeFields(getDefaultFields(isSpotlight), explicit);
}
function getNextUpDateCutoff(userId) {
const raw = userId ? localStorage.getItem(`${userId}-maxDaysForNextUp`) : null;
const days = raw != null && raw !== '' ? parseInt(raw, 10) : 365;
const maxDays = Number.isFinite(days) && days >= 0 ? days : 365;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - maxDays);
return cutoff.toISOString().split('T')[0];
}
function getEnableRewatchingInNextUp(userId) {
if (!userId) return false;
return localStorage.getItem(`${userId}-enableRewatchingInNextUp`) === 'true';
}
function mergeFields(base, extra) {
const seen = new Set();
const result = [];
const add = (value) => {
if (value == null || value === '') return;
const parts = Array.isArray(value) ? value : String(value).split(',');
for (const part of parts) {
const field = String(part).trim();
if (!field) continue;
const key = field.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(field);
}
};
add(base);
add(extra);
return result.join(',');
}
function applyQueryOptionsToParams(params, queryOptions, { skipFields = false } = {}) {
Object.entries(queryOptions || {}).forEach(([key, value]) => {
if (value === undefined || value === null) return;
if (key === 'Limit' && value === 0) return;
if (key === 'Fields') {
if (skipFields) return;
params.set('Fields', mergeFields(params.get('Fields'), value));
return;
}
if (Array.isArray(value)) {
const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ',';
params.set(key, value.join(delimiter));
return;
}
params.set(key, value);
});
}
/**
* Retrieves the active playback session for this device
@@ -21,7 +102,6 @@
async function getActiveSession() {
ensureApiClient();
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const deviceId = ApiClient.deviceId();
@@ -39,7 +119,7 @@
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': getAuthHeader()
}
});
@@ -71,7 +151,6 @@
const session = await getActiveSession();
const sessionId = session.Id;
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const limitedIds = ids.slice(0, MAX_PLAY_NOW_IDS);
@@ -102,7 +181,7 @@
const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, {
method: 'POST',
headers: {
'X-Emby-Token': token,
'Authorization': getAuthHeader(),
}
});
@@ -115,7 +194,6 @@
const session = await getActiveSession();
const sessionId = session.Id;
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
const params = new URLSearchParams({
@@ -131,7 +209,7 @@
const response = await fetch(`${serverUrl}/Sessions/${sessionId}/Playing?${params.toString()}`, {
method: 'POST',
headers: {
'X-Emby-Token': token
'Authorization': getAuthHeader()
}
});
@@ -157,11 +235,12 @@
}
/**
* Build MediaBrowser Authorization header
* @returns {string} - Authorization header
* Build MediaBrowser Authorization header for an explicit access token.
* When token is empty/missing, Token is omitted (required for AuthenticateByName).
* @param {string} [token]
* @returns {string}
*/
function getAuthHeader() {
const token = ApiClient.accessToken();
function getAuthHeaderForToken(token) {
const client = typeof ApiClient.applicationName === 'function' ? ApiClient.applicationName() : 'Jellyfin Web';
const device = typeof ApiClient.deviceName === 'function' ? ApiClient.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser');
const deviceId = typeof ApiClient.deviceId === 'function' ? ApiClient.deviceId() : '';
@@ -170,11 +249,23 @@
`Client="${encodeURIComponent(client)}"`,
`Device="${encodeURIComponent(device)}"`,
`DeviceId="${encodeURIComponent(deviceId)}"`,
`Version="${encodeURIComponent(version)}"`,
`Token="${encodeURIComponent(token)}"`
`Version="${encodeURIComponent(version)}"`
];
if (token) {
parts.push(`Token="${encodeURIComponent(token)}"`);
}
return `MediaBrowser ${parts.join(', ')}`;
}
/**
* Build MediaBrowser Authorization header for the current ApiClient session.
* @returns {string} - Authorization header
*/
function getAuthHeader() {
return getAuthHeaderForToken(typeof ApiClient !== 'undefined' && ApiClient.accessToken
? ApiClient.accessToken()
: '');
}
/**
* Query cache for API responses
@@ -251,11 +342,36 @@
const state = {
cachedServerVersion: null
}
/**
* Build a single top-level Likes query def (shared by getWatchlistItems / getWatchlistQuery).
* Callers pass IncludeItemTypes (and other options) to narrow results.
* @param {Object} options
* @returns {{ queryOptions: Object }}
*/
function buildWatchlistQueryDef(options = {}) {
return {
queryOptions: {
Recursive: true,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
...options,
Filters: 'Likes'
}
};
}
/**
* API Helper functions for Jellyfin operations
*/
const apiHelper = {
/**
* Get the MediaBrowser Authorization header for an explicit access token
* @param {string} [token]
* @returns {string}
*/
getAuthHeaderForToken: getAuthHeaderForToken,
/**
* Get the MediaBrowser Authorization header
* @returns {string} - Authorization header
@@ -274,7 +390,7 @@
ensureApiClient();
const useCache = options.useCache || false;
let ttl = 300000;
let ttl = 60000;
if (Number(options.ttl) >= 0) {
ttl = Number(options.ttl);
}
@@ -284,10 +400,9 @@
// Standard fetch function
const fetchData = async () => {
try {
const token = ApiClient.accessToken();
const response = await fetch(url, {
headers: {
'X-Emby-Token': token,
'Authorization': getAuthHeader(),
'Accept': 'application/json'
}
});
@@ -382,8 +497,11 @@
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). Refreshing...`);
return true;
LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Validating...`);
/* LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Validating...`);
// Cache is expired, check if we can validate it
// Logic: Check SortBy
@@ -403,10 +521,9 @@
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,
'Authorization': getAuthHeader(),
'Accept': 'application/json'
}
});
@@ -474,17 +591,28 @@
} 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
// 3. Lazy data promise — do not start fetchData until ensureData()/dataPromise is used
result.ensureData = function() {
if (!result._dataPromise) {
result._dataPromise = result.isStalePromise.then(isStale => {
if (isStale) {
LOG(`[Fetch] Refreshing data for: ${url}...`);
return fetchData();
}
LOG(`[Fetch] Using cached data for: ${url}...`);
return result.data;
});
}
return result._dataPromise;
};
Object.defineProperty(result, 'dataPromise', {
configurable: true,
enumerable: true,
get() {
return result.ensureData();
}
});
@@ -492,6 +620,7 @@
// Initial check for cached data
const entry = await cachePromise;
result.data = entry ? entry.payload : null;
result.isStale = forceRefresh || !entry || (Date.now() - entry.timestamp > ttl);
return result;
})();
},
@@ -530,10 +659,9 @@
// Fetch from API
try {
const token = ApiClient.accessToken();
const response = await fetch(url, {
headers: {
'X-Emby-Token': token,
'Authorization': getAuthHeader(),
'Accept': 'application/json'
}
});
@@ -558,60 +686,94 @@
},
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 window.dataHelper.getLibraries();
// We now want to include the library items with ColletionType "tvshows", or "movies"
const supportedCollectionTypes = ['tvshows', 'movies', 'homevideos', 'boxsets', 'playlists'];
const supportedLibraryItems = libraryItems.filter(item => supportedCollectionTypes.includes(item.CollectionType) || !item.CollectionType);
// 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 = [];
for (const libraryItem of supportedLibraryItems.reverse()) {
// Get the item types to include based on the library type. if its a Movies library only use the Movie type. If it's tvshows then use Series, Season and Episode
let itemTypes = options.IncludeItemTypes.split(',');
if (itemTypes.length > 1) {
if (libraryItem.CollectionType === 'movies') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie');
} else if (libraryItem.CollectionType === 'tvshows') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Series' || item === 'Season' || item === 'Episode');
} else if (libraryItem.CollectionType === 'boxsets') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'BoxSet');
} else if (libraryItem.CollectionType === 'playlists') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Playlist');
} else if (libraryItem.CollectionType === 'homevideos') {
//itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Video');
} else if (!libraryItem.CollectionType) {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie' || item === 'Series' || item === 'Season' || item === 'Episode' || item === 'Video');
}
}
// Use getItems to fetch the items for the library item
const data = await this.getItems({
ParentId: libraryItem.Id,
Filters: 'Likes',
IncludeItemTypes: itemTypes.join(','),
Recursive: true,
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb',
...options
}, useCache);
//const url = `${ApiClient.serverAddress()}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${ApiClient.getCurrentUserId()}&Recursive=true&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb&ParentId=${libraryItem.Id}`;
//const data = await this.getData(url, useCache);
watchlistItems.push(...data.Items);
}
// Deuplicate any duplicate Id's in the watchlistItems array
watchlistItems = watchlistItems.filter((item, index, self) =>
index === self.findIndex((t) => t.Id === item.Id)
);
const query = buildWatchlistQueryDef(options);
const data = await this.getItems({ ...query.queryOptions }, useCache);
const items = Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []);
return {
Items: watchlistItems,
TotalRecordCount: watchlistItems.length
Items: items,
TotalRecordCount: data?.TotalRecordCount ?? items.length
};
},
/**
* Progressive watchlist query (single top-level Likes getQuery).
* @param {Object} options - Same shape as getWatchlistItems (IncludeItemTypes, Fields, ...)
* @param {Object} [cacheOptions]
* @param {number} [cacheOptions.ttl=300000]
* @param {boolean} [cacheOptions.forceRefresh=false]
* @returns {Promise<Object>} Progressive result: { data, isStale, isStalePromise, ensureData, dataPromise, urls }
*/
getWatchlistQuery: async function(options = {}, cacheOptions = {}) {
ensureApiClient();
let ttl = 300000;
if (Number(cacheOptions.ttl) >= 0) {
ttl = Number(cacheOptions.ttl);
}
const forceRefresh = !!cacheOptions.forceRefresh;
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const query = buildWatchlistQueryDef(options);
const url = this.buildQueryFromSection(query, userId, serverUrl);
if (typeof url !== 'string') {
throw new Error('Invalid watchlist query URL');
}
const queryResult = await this.getQuery(url, {
useCache: true,
ttl,
forceRefresh
});
const normalize = (data) => {
const items = Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []);
return { Items: items, TotalRecordCount: data?.TotalRecordCount ?? items.length };
};
let mappedDataPromise = null;
const ensureData = () => {
if (!mappedDataPromise) {
const raw = typeof queryResult.ensureData === 'function'
? queryResult.ensureData()
: queryResult.dataPromise;
mappedDataPromise = Promise.resolve(raw).then(normalize);
}
return mappedDataPromise;
};
const result = {
data: normalize(queryResult.data),
isStale: queryResult.isStale === true,
isStalePromise: queryResult.isStalePromise || Promise.resolve(false),
ensureData,
urls: [url]
};
Object.defineProperty(result, 'dataPromise', {
configurable: true,
enumerable: true,
get() {
return ensureData();
}
});
return result;
},
/**
* Invalidate cached watchlist query URL for the given IncludeItemTypes options.
* @param {Object} [options]
*/
invalidateWatchlistQueries: async function(options = {}) {
ensureApiClient();
const query = buildWatchlistQueryDef(options);
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const url = this.buildQueryFromSection(query, userId, serverUrl);
if (typeof url === 'string') {
this.invalidateCache(url);
}
},
/**
* Get items from Jellyfin API with optional caching
@@ -688,7 +850,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Token': ApiClient.accessToken()
'Authorization': getAuthHeader()
},
body: JSON.stringify({
Rating: rating,
@@ -764,7 +926,6 @@
throw new Error('No active session found');
}
const token = ApiClient.accessToken();
const serverUrl = ApiClient.serverAddress();
// Build NowPlayingQueue array - each item needs at least an Id
@@ -784,7 +945,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `MediaBrowser Token="${token}"`
'Authorization': getAuthHeader()
},
body: JSON.stringify(payload)
});
@@ -822,9 +983,10 @@
* @param {string} serverUrl - Server URL
* @returns {string|Object} - Query URL string or dataSource marker object
*/
buildQueryFromSection: function(query, userId, serverUrl, isSpotlight = false) {
buildQueryFromSection: function(query, userId, serverUrl, isSpotlight = false, options = {}) {
ensureApiClient();
const sectionType = options.sectionType || '';
const queryOptions = { ...(query.queryOptions || {}) };
// Convert minAge/maxAge to premiere date range
@@ -832,31 +994,29 @@
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
const buildContext = { isSpotlight, sectionType };
if (query.path) {
return this.buildCustomEndpoint(query.path, queryOptions, userId, serverUrl);
return this.buildCustomEndpoint(query.path, queryOptions, userId, serverUrl, buildContext);
}
if (query.dataSource) {
return { dataSource: query.dataSource, options: queryOptions };
}
// Standard /Items query
return this.buildStandardQuery(queryOptions, userId, serverUrl, isSpotlight);
return this.buildStandardQuery(queryOptions, userId, serverUrl, buildContext);
},
/**
@@ -864,48 +1024,21 @@
* @param {Object} queryOptions - Query options
* @param {string} userId - User ID
* @param {string} serverUrl - Server URL
* @param {boolean} isSpotlight - Whether the query is for a spotlight section
* @param {{ isSpotlight?: boolean, sectionType?: string }} context
* @returns {string} - Query URL
*/
buildStandardQuery: function(queryOptions, userId, serverUrl, isSpotlight = false) {
// Fields that require pipe delimiter instead of comma
const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds'];
buildStandardQuery: function(queryOptions, userId, serverUrl, context = {}) {
if (context === true || context === false) {
context = { isSpotlight: context === true };
}
const params = new URLSearchParams({
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData',
UserId: userId
Fields: resolveQueryFields(queryOptions, context),
});
if (isSpotlight) {
params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags,Studios');
}
// 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;
}
// If the key is Fields and this is a spotlight query, merge the fields with the existing fields to ensure we always pull People/Genres/ParentBackdropImageTags/Studios
if (key === 'Fields' && isSpotlight) {
const newValue = params.get('Fields') + ',' + value;
params.set(key, newValue);
return;
}
params.set(key, value);
}
}
});
return `${serverUrl}/Items?${params.toString()}`;
applyQueryOptionsToParams(params, queryOptions, { skipFields: true });
return userId ? `${serverUrl}/Users/${userId}/Items?${params.toString()}` : `${serverUrl}/Items?${params.toString()}`;
},
/**
@@ -914,41 +1047,41 @@
* @param {Object} queryOptions - Query options
* @param {string} userId - User ID
* @param {string} serverUrl - Server URL
* @param {{ isSpotlight?: boolean, sectionType?: string }} context
* @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');
buildCustomEndpoint: function(path, queryOptions, userId, serverUrl, context = {}) {
if (context === true || context === false) {
context = { isSpotlight: context === true };
}
const params = new URLSearchParams({
UserId: userId,
Fields: resolveQueryFields(queryOptions, context),
});
applyQueryOptionsToParams(params, queryOptions, { skipFields: true });
if (path === '/Shows/NextUp') {
// If NextUpDateCutoff is not set, set it to the current date
if (!params.has('NextUpDateCutoff') && queryOptions.NextUpDateCutoff === undefined) {
params.set('NextUpDateCutoff', getNextUpDateCutoff(userId));
}
// If EnableRewatching is not set, set it to the current value
if (!params.has('EnableRewatching') && queryOptions.EnableRewatching === undefined) {
params.set('EnableRewatching', getEnableRewatchingInNextUp(userId) ? 'true' : 'false');
}
// If EnableResumable is not set, set it to false
if (!params.has('EnableResumable') && queryOptions.EnableResumable === undefined) {
params.set('EnableResumable', 'false');
}
}
if (!params.has('EnableTotalRecordCount')) {
params.set('EnableTotalRecordCount', 'false');
}
return `${serverUrl}${path}?${params.toString()}`;
},
@@ -1033,16 +1166,12 @@
// Combine all items from all queries
const allItems = [];
const itemsByQuery = [];
const allPromises = [];
const promisesByQuery = [];
results.forEach(result => {
if (result.data?.Items) {
allItems.push(...result.data.Items);
itemsByQuery.push(result.data.Items);
}
allPromises.push(result.dataPromise);
promisesByQuery.push(result.dataPromise);
});
// Sort merged items if sortBy specified
@@ -1064,99 +1193,46 @@
});
} */
// Create merged data promise
const dataPromisesByQuery = Promise.all(allPromises).then(allData => {
const dataPromises = [];
allData.forEach(data => {
if (data?.Items) {
dataPromises.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;
let dataPromise = null;
const ensureData = function() {
if (!dataPromise) {
dataPromise = Promise.all(results.map((r) => (
typeof r.ensureData === 'function' ? r.ensureData() : r.dataPromise
))).then((allData) => {
const itemsByFreshQuery = [];
allData.forEach((data) => {
if (data?.Items) {
itemsByFreshQuery.push(data.Items);
}
});
return window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByFreshQuery);
});
} */
return dataPromises;
});
}
return dataPromise;
};
/* const dataPromisesByQuery = Promise.all(promisesByQuery).then(allData => {
const dataPromises = [];
allData.forEach(data => {
if (data?.Items) {
dataPromises.push(data.Items);
}
});
return dataPromises;
}); */
const mergedResult = {
data: { Items: window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByQuery) },
isStale: results.some((r) => r.isStale),
isStalePromise: Promise.all(results.map(r => r.isStalePromise || Promise.resolve(false)))
.then(staleFlags => staleFlags.some(s => s)),
ensureData
};
Object.defineProperty(mergedResult, 'dataPromise', {
configurable: true,
enumerable: true,
get() {
return ensureData();
}
});
return {
config: sectionConfig,
result: {
data: { Items: window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByQuery) },
dataPromise: dataPromisesByQuery.then(data => window.cardBuilder.postProcessItemsByQuery(sectionConfig, data)),
isStalePromise: Promise.all(results.map(r => r.isStalePromise || Promise.resolve(false)))
.then(staleFlags => staleFlags.some(s => s))
}
result: mergedResult
};
},
isAdmin: isAdmin,
/**
* Get the current Jellyfin 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
*/
getJellyfinVersion: async function() {
try {
// If we have a cached value, return it
if (state.cachedServerVersion !== null) {
return state.cachedServerVersion;
}
if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) {
return null;
}
if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {
state.cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion);
return state.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));
}
}
state.cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion);
return state.cachedServerVersion;
} catch (error) {
WARN('Error getting server version:', error);
return null;
}
},
isAdmin: isAdmin,
/**
* Get the list of plugins from the server