diff --git a/src/components/util/SpinnerImage.tsx b/src/components/util/SpinnerImage.tsx index 9727c6e7..0cda0f3c 100644 --- a/src/components/util/SpinnerImage.tsx +++ b/src/components/util/SpinnerImage.tsx @@ -16,6 +16,7 @@ import RefreshIcon from '@mui/icons-material/Refresh'; import { useTranslation } from 'react-i18next'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; +import { Priority } from '@/lib/Queue.ts'; interface IProps { src: string; @@ -48,7 +49,7 @@ export const SpinnerImage = forwardRef((props: IProps, imgRef: ForwardedRef { let tmpImageSourceUrl: string; - const imageRequest = requestManager.requestImage(src); + const imageRequest = requestManager.requestImage(src, Priority.HIGH); let cacheTimeout: NodeJS.Timeout; const fetchImage = async () => { diff --git a/src/lib/ControlledPromise.ts b/src/lib/ControlledPromise.ts new file mode 100644 index 00000000..60c999d7 --- /dev/null +++ b/src/lib/ControlledPromise.ts @@ -0,0 +1,30 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +export class ControlledPromise { + private orgResolve!: (value: T | PromiseLike) => void; + + private orgReject!: (reason?: any) => void; + + public readonly promise: Promise; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.orgResolve = resolve; + this.orgReject = reject; + }); + } + + resolve(value: T): void { + this.orgResolve(value); + } + + reject(reason?: any): void { + this.orgReject(reason); + } +} diff --git a/src/lib/Queue.ts b/src/lib/Queue.ts new file mode 100644 index 00000000..cc40dd06 --- /dev/null +++ b/src/lib/Queue.ts @@ -0,0 +1,75 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import pLimit, { LimitFunction } from 'p-limit'; +import { ControlledPromise } from '@/lib/ControlledPromise.ts'; + +export enum Priority { + LOW = 0, + NORMAL = 1, + HIGH = 2, +} +export type QueuePriority = Priority | number; + +type Key = string; +type QueueItemFunction = () => PromiseLike | T; + +export class Queue { + private readonly queue: LimitFunction; + + private counter: number = 0; + + private pendingKeyToPriorityMap = new Map(); + + private pendingKeyToFnMap = new Map(); + + private pendingKeyToPromiseMap = new Map>(); + + constructor(concurrency: number) { + this.queue = pLimit(concurrency); + } + + enqueue(key: Key, fn: () => PromiseLike | T, priority: QueuePriority = Priority.NORMAL): Promise { + this.counter = (this.counter + 1) % Infinity; + const actualKey = `${key}_${this.counter}`; + + this.pendingKeyToPriorityMap.set(actualKey, priority); + this.pendingKeyToFnMap.set(actualKey, fn); + + const processPromise = new ControlledPromise(); + this.pendingKeyToPromiseMap.set(actualKey, processPromise); + + this.queue(() => this.process()); + + return processPromise.promise; + } + + private async process(): Promise { + const { fn, promise } = this.getNextItemToProcess(); + try { + const result = await fn(); + promise.resolve(result); + } catch (e) { + promise.reject(e); + } + } + + private getNextItemToProcess(): { key: Key; fn: QueueItemFunction; promise: ControlledPromise } { + const [key] = [...this.pendingKeyToPriorityMap.entries()].toSorted( + ([, priorityA], [, priorityB]) => priorityB - priorityA, + )[0]; + const fn = this.pendingKeyToFnMap.get(key) as () => PromiseLike | T; + const promise = this.pendingKeyToPromiseMap.get(key) as ControlledPromise; + + this.pendingKeyToPriorityMap.delete(key); + this.pendingKeyToFnMap.delete(key); + this.pendingKeyToPromiseMap.delete(key); + + return { key, fn, promise }; + } +} diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index cfc047cc..3507bcbc 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -27,7 +27,6 @@ import { } from '@apollo/client'; import { OperationVariables } from '@apollo/client/core'; import { useEffect, useMemo, useRef, useState } from 'react'; -import pLimit from 'p-limit'; import { IRestClient, RestClient } from '@/lib/requests/client/RestClient.ts'; import { GraphQLClient } from '@/lib/requests/client/GraphQLClient.ts'; import { @@ -257,6 +256,7 @@ import { RESET_WEBUI_UPDATE_STATUS, UPDATE_WEBUI } from '@/lib/graphql/mutations import { WEBUI_UPDATE_SUBSCRIPTION } from '@/lib/graphql/subscriptions/ServerInfoSubscription.ts'; import { GET_DOWNLOAD_STATUS } from '@/lib/graphql/queries/DownloaderQuery.ts'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; +import { Queue, QueuePriority } from '@/lib/Queue.ts'; enum GQLMethod { QUERY = 'QUERY', @@ -366,7 +366,7 @@ export class RequestManager { private readonly cache = new CustomCache(); - private readonly imageQueue = pLimit(5); + private readonly imageQueue = new Queue(5); public getClient(): IRestClient { return this.restClient; @@ -768,20 +768,23 @@ export class RequestManager { * img.src = imageUrl; * */ - public requestImage(url: string): { response: Promise } & AbortableRequest { + public requestImage(url: string, priority?: QueuePriority): { response: Promise } & AbortableRequest { const { abortRequest, signal } = this.createAbortController(); - const response = this.imageQueue(() => - this.restClient - .fetcher(url, { - checkResponseIsJson: false, - config: { - signal, - // @ts-ignore - typing has not been updated yet - priority: 'low', - }, - }) - .then((data) => data.blob()) - .then((data) => URL.createObjectURL(data)), + const response = this.imageQueue.enqueue( + url, + () => + this.restClient + .fetcher(url, { + checkResponseIsJson: false, + config: { + signal, + // @ts-ignore - typing has not been updated yet + priority: 'low', + }, + }) + .then((data) => data.blob()) + .then((data) => URL.createObjectURL(data)), + priority, ); return { response, abortRequest };