diff --git a/src/base/AppRoute.constants.ts b/src/base/AppRoute.constants.ts index f6d4b5d3..9f3dccca 100644 --- a/src/base/AppRoute.constants.ts +++ b/src/base/AppRoute.constants.ts @@ -12,6 +12,7 @@ import { MangaIdInfo } from '@/features/manga/Manga.types.ts'; import { ChapterSourceOrderInfo } from '@/features/chapter/Chapter.types.ts'; import { BrowseTab } from '@/features/browse/Browse.types.ts'; import { SearchParam } from '@/base/Base.types.ts'; +import { UrlUtil } from '@/lib/UrlUtil.ts'; type AppRouteInfo = { match: string; @@ -20,16 +21,6 @@ type AppRouteInfo = { type TAppRoutes = Record; -const createParam = (name: string, value: any): string => (value ? `${name}=${encodeURIComponent(value)}` : ''); - -const createQueryParam = (query: string | null | undefined): string => createParam(SearchParam.QUERY, query); - -const addParams = (path: string, ...params: string[]) => { - const joinedParams = params.filter(Boolean).join('&'); - - return `${path}${joinedParams ? `?${joinedParams}` : ''}`; -}; - export const AppRoutes = { root: { match: '/', @@ -120,7 +111,7 @@ export const AppRoutes = { browse: { match: ':sourceId', path: (sourceId: SourceType['id'], query?: string | null | undefined) => - addParams(`/sources/${sourceId}`, createQueryParam(query)), + UrlUtil.addQueryParam(`/sources/${sourceId}`, query), }, configure: { match: ':sourceId/configure', @@ -128,7 +119,7 @@ export const AppRoutes = { }, searchAll: { match: 'all/search', - path: (query?: string | null | undefined) => addParams('/sources/all/search', createQueryParam(query)), + path: (query?: string | null | undefined) => UrlUtil.addQueryParam('/sources/all/search', query), }, }, }, @@ -162,7 +153,10 @@ export const AppRoutes = { library: { match: 'library', path: (tab?: string, search?: string) => - addParams('/library', createParam(SearchParam.TAB, tab), createQueryParam(search)), + UrlUtil.addParams('/library', { + ...UrlUtil.createTabParam(tab), + ...UrlUtil.createQueryParam(search), + }), }, updates: { match: 'updates', @@ -178,7 +172,10 @@ export const AppRoutes = { }, browse: { match: 'browse', - path: (tab?: BrowseTab) => addParams('/browse', createParam(SearchParam.TAB, tab)), + path: (tab?: BrowseTab) => + UrlUtil.addParams('/browse', { + [SearchParam.TAB]: tab, + }), }, migrate: { match: 'migrate/source/:sourceId', @@ -188,7 +185,7 @@ export const AppRoutes = { search: { match: 'manga/:mangaId/search', path: (sourceId: SourceType['id'], mangaId: MangaIdInfo['id'], query?: string | null | undefined) => - addParams(`/migrate/source/${sourceId}/manga/${mangaId}/search`, createQueryParam(query)), + UrlUtil.addQueryParam(`/migrate/source/${sourceId}/manga/${mangaId}/search`, query), }, }, }, diff --git a/src/features/history/components/ChapterHistoryCard.tsx b/src/features/history/components/ChapterHistoryCard.tsx index a86390e9..aa48a92c 100644 --- a/src/features/history/components/ChapterHistoryCard.tsx +++ b/src/features/history/components/ChapterHistoryCard.tsx @@ -39,6 +39,7 @@ export const ChapterHistoryCard = memo(({ chapter }: { chapter: ChapterHistoryLi ; export type MangaDownloadInfo = Pick & MangaChapterCountInfo; export type MangaUnreadInfo = Pick & MangaChapterCountInfo; -export type MangaThumbnailInfo = Pick; +export type MangaThumbnailInfo = Pick; export type MangaTrackRecordInfo = MangaIdInfo & { trackRecords: { nodes: Pick[] }; }; diff --git a/src/features/manga/services/Mangas.ts b/src/features/manga/services/Mangas.ts index 99da954b..306e9f27 100644 --- a/src/features/manga/services/Mangas.ts +++ b/src/features/manga/services/Mangas.ts @@ -51,6 +51,7 @@ import { import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { assertIsDefined } from '@/base/Asserts.ts'; import { Confirmation } from '@/base/AppAwaitableComponent.ts'; +import { UrlUtil } from '@/lib/UrlUtil.ts'; type MangaToMigrate = NonNullable; type MangaToMigrateTo = NonNullable['manga']; @@ -172,10 +173,12 @@ export class Mangas { } static getThumbnailUrl(manga: Partial): string { - const thumbnailUrl = manga.thumbnailUrl - ? `${manga.thumbnailUrl}?fetchedAt=${manga.thumbnailUrlLastFetched}` - : ''; - return requestManager.getValidImgUrlFor(thumbnailUrl); + const url = UrlUtil.addParams(manga.thumbnailUrl ?? '', { + fetchedAt: manga.thumbnailUrlLastFetched, + sourceId: manga.sourceId, + }); + + return requestManager.getValidImgUrlFor(url); } static getDuplicateLibraryMangas( diff --git a/src/features/reader/viewer/hooks/useReaderSetPagesState.ts b/src/features/reader/viewer/hooks/useReaderSetPagesState.ts index c42b53e1..c7d8ef14 100644 --- a/src/features/reader/viewer/hooks/useReaderSetPagesState.ts +++ b/src/features/reader/viewer/hooks/useReaderSetPagesState.ts @@ -18,6 +18,8 @@ import { import { requestManager } from '@/lib/requests/RequestManager.ts'; import { TChapterReader } from '@/features/chapter/Chapter.types.ts'; import { ReaderChaptersStoreSlice } from '@/features/reader/stores/ReaderChaptersStore.ts'; +import { getReaderStore } from '@/features/reader/stores/ReaderStore.ts'; +import { UrlUtil } from '@/lib/UrlUtil.ts'; export const useReaderSetPagesState = ( isCurrentChapter: boolean, @@ -48,7 +50,10 @@ export const useReaderSetPagesState = ( } const { pages: pagesFromResponse } = pagesPayload; - const newPages = pagesFromResponse.length ? pagesFromResponse : ['']; + const tmpPages = pagesFromResponse.length ? pagesFromResponse : ['']; + const newPages = tmpPages.map((page) => + UrlUtil.addParams(page, { sourceId: getReaderStore().manga?.sourceId }), + ); const initialReaderPageIndex = getInitialReaderPageIndex(resumeMode, lastPageRead ?? 0, newPages.length - 1); const didPagesChange = previousPageData.current !== pagesPayload?.pages; diff --git a/src/features/updates/components/ChapterUpdateCard.tsx b/src/features/updates/components/ChapterUpdateCard.tsx index 1fa4237a..da148c4b 100644 --- a/src/features/updates/components/ChapterUpdateCard.tsx +++ b/src/features/updates/components/ChapterUpdateCard.tsx @@ -38,6 +38,7 @@ export const ChapterUpdateCard = memo(({ chapter }: { chapter: ChapterUpdateList ([ + [SourceAwareQueue.DEFAULT_ID, new Queue(this.concurrencyPerSource)], + ]); + + constructor( + private readonly areConnectionsLimited: boolean, + private readonly concurrencyPerSource = 5, + ) {} + + private getConcurrencyFor(sourceId: string | null): number { + const isLocalSource = sourceId === Sources.LOCAL_SOURCE_ID; + + if (isLocalSource) { + return Number.MAX_SAFE_INTEGER; + } + + return this.concurrencyPerSource; + } + + private getQueueFor(sourceId: string | null): Queue { + const finalSourceId = sourceId ?? SourceAwareQueue.DEFAULT_ID; + const queueKey = this.areConnectionsLimited ? SourceAwareQueue.DEFAULT_ID : finalSourceId; + + if (!this.queueBySource.has(queueKey)) { + this.queueBySource.set(queueKey, new Queue(this.getConcurrencyFor(finalSourceId))); + } + + return this.queueBySource.get(queueKey)!; + } + + enqueue(sourceId: string | null, key: string, fn: () => PromiseLike | T, priority?: QueuePriority) { + const queue = this.getQueueFor(sourceId); + + return queue.enqueue(key, fn, priority); + } + + isProcessing(sourceId: string | null, key: string): boolean { + const queue = this.getQueueFor(sourceId); + + return queue.isProcessing(key); + } + + clear(): void { + for (const queue of this.queueBySource.values()) { + queue.clear(); + } + + this.queueBySource.clear(); + } +} diff --git a/src/lib/UrlUtil.ts b/src/lib/UrlUtil.ts new file mode 100644 index 00000000..851e3d9a --- /dev/null +++ b/src/lib/UrlUtil.ts @@ -0,0 +1,45 @@ +/* + * 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 { SearchParam } from '@/base/Base.types.ts'; + +export class UrlUtil { + static createParams(params: Record): URLSearchParams { + const paramEntries = Object.entries(params).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ); + + return new URLSearchParams(paramEntries); + } + + static createTabParam(tab: string | null | undefined): { [SearchParam.TAB]: string } { + return { ...this.createParams({ [SearchParam.TAB]: tab }).entries() } as unknown as { + [SearchParam.TAB]: string; + }; + } + + static createQueryParam(query: string | null | undefined): { [SearchParam.QUERY]: string } { + return { ...this.createParams({ [SearchParam.QUERY]: query }).entries() } as unknown as { + [SearchParam.QUERY]: string; + }; + } + + static addParams(path: string, params: Record): string { + const urlParams = this.createParams(params).toString(); + + if (urlParams) { + return `${path}?${urlParams}`; + } + + return path; + } + + static addQueryParam(path: string, query: string | null | undefined): string { + return this.addParams(path, this.createQueryParam(query)); + } +} diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 95134a7d..dfc99818 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -315,7 +315,8 @@ 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 '@/lib/DefaultPromiseErrorHandler.ts'; -import { Queue, QueuePriority } from '@/lib/Queue.ts'; +import { QueuePriority } from '@/lib/Queue.ts'; +import { SourceAwareQueue } from '@/lib/SourceAwareQueue.ts'; import { TRACKER_SEARCH } from '@/lib/graphql/queries/TrackerQuery.ts'; import { TRACKER_BIND, @@ -460,14 +461,29 @@ export class RequestManager { private readonly cache = new CustomCache(); - private readonly imageQueue = new Queue(5); + private readonly imageQueue: SourceAwareQueue; constructor() { + const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'; + const isHttp2 = isHttps && this.detectHttp2(); + + this.imageQueue = new SourceAwareQueue(isHttp2, 5); + BaseClient.setTokenRefreshCompleteCallback(() => { this.processQueues(); }); } + private detectHttp2(): boolean { + const entries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[]; + + if (!entries.length) { + return false; + } + + return ['h2', 'h3'].includes(entries[0].nextHopProtocol); + } + public getClient(): IRestClient { return this.restClient; } @@ -896,8 +912,8 @@ export class RequestManager { * will just cause new source image requests to be sent to the server, which then will cause the server * to become really slow for image requests to the same source */ - private abortImageRequest(key: string, abort: () => void): void { - if (this.imageQueue.isProcessing(key)) { + private abortImageRequest(key: string, sourceId: string | null, abort: () => void): void { + if (this.imageQueue.isProcessing(sourceId, key)) { return; } @@ -936,12 +952,23 @@ export class RequestManager { return url; } + private getSourceIdFromUrl(url: string): string | null { + try { + console.log('asdf', new URL(url).searchParams.get('sourceId')); + return new URL(url).searchParams.get('sourceId'); + } catch { + return null; + } + } + private async maybeEnqueueImageRequest( url: string, request: () => Promise, priority?: QueuePriority, ignoreQueue?: boolean, ): Promise> & { fromCache?: boolean }> { + const sourceId = this.getSourceIdFromUrl(url); + try { const isCached = await ImageCache.has(url); if (!!ignoreQueue || isCached) { @@ -952,9 +979,9 @@ export class RequestManager { }; } - return this.imageQueue.enqueue(url, request, priority); + return this.imageQueue.enqueue(sourceId, url, request, priority); } catch (error) { - return this.imageQueue.enqueue(url, request, priority); + return this.imageQueue.enqueue(sourceId, url, request, priority); } } @@ -1009,7 +1036,8 @@ export class RequestManager { return { response, - abortRequest: (reason?: any) => this.abortImageRequest(key, () => abortRequest(reason)), + abortRequest: (reason?: any) => + this.abortImageRequest(key, this.getSourceIdFromUrl(url), () => abortRequest(reason)), cleanup: () => {}, fromCache: !!fromCache, }; @@ -1065,7 +1093,8 @@ export class RequestManager { return { response, - abortRequest: (reason?: any) => this.abortImageRequest(key, () => abortRequest(reason)), + abortRequest: (reason?: any) => + this.abortImageRequest(key, this.getSourceIdFromUrl(url), () => abortRequest(reason)), cleanup: () => URL.revokeObjectURL(objectUrl), fromCache: !!fromCache, };