mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Pray for me
This commit is contained in:
+319
-243
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user