diff --git a/src/lib/graphql/fragments/DownloadFragments.ts b/src/lib/graphql/fragments/DownloadFragments.ts index f1570d5e..8530430b 100644 --- a/src/lib/graphql/fragments/DownloadFragments.ts +++ b/src/lib/graphql/fragments/DownloadFragments.ts @@ -8,27 +8,52 @@ import gql from 'graphql-tag'; +const DOWNLOAD_TYPE_FIELDS = gql` + fragment DOWNLOAD_TYPE_FIELDS on DownloadType { + chapter { + id + name + sourceOrder + isDownloaded + } + + manga { + id + title + downloadCount + } + + progress + state + tries + } +`; + export const DOWNLOAD_STATUS_FIELDS = gql` + ${DOWNLOAD_TYPE_FIELDS} + fragment DOWNLOAD_STATUS_FIELDS on DownloadStatus { state queue { - chapter { - id - name - sourceOrder - isDownloaded - } - - manga { - id - title - downloadCount - } - - progress - state - tries + ...DOWNLOAD_TYPE_FIELDS + } + } +`; + +export const DOWNLOAD_UPDATES_FIELDS = gql` + ${DOWNLOAD_TYPE_FIELDS} + + fragment DOWNLOAD_UPDATES_FIELDS on DownloadUpdates { + state + omittedUpdates + + updates { + type + download { + ...DOWNLOAD_TYPE_FIELDS + position + } } } `; diff --git a/src/lib/graphql/generated/apollo-helpers.ts b/src/lib/graphql/generated/apollo-helpers.ts index 8e731230..f04124b9 100644 --- a/src/lib/graphql/generated/apollo-helpers.ts +++ b/src/lib/graphql/generated/apollo-helpers.ts @@ -214,14 +214,27 @@ export type DownloadStatusFieldPolicy = { queue?: FieldPolicy | FieldReadFunction, state?: FieldPolicy | FieldReadFunction }; -export type DownloadTypeKeySpecifier = ('chapter' | 'manga' | 'progress' | 'state' | 'tries' | DownloadTypeKeySpecifier)[]; +export type DownloadTypeKeySpecifier = ('chapter' | 'manga' | 'position' | 'progress' | 'state' | 'tries' | DownloadTypeKeySpecifier)[]; export type DownloadTypeFieldPolicy = { chapter?: FieldPolicy | FieldReadFunction, manga?: FieldPolicy | FieldReadFunction, + position?: FieldPolicy | FieldReadFunction, progress?: FieldPolicy | FieldReadFunction, state?: FieldPolicy | FieldReadFunction, tries?: FieldPolicy | FieldReadFunction }; +export type DownloadUpdateKeySpecifier = ('download' | 'type' | DownloadUpdateKeySpecifier)[]; +export type DownloadUpdateFieldPolicy = { + download?: FieldPolicy | FieldReadFunction, + type?: FieldPolicy | FieldReadFunction +}; +export type DownloadUpdatesKeySpecifier = ('initial' | 'omittedUpdates' | 'state' | 'updates' | DownloadUpdatesKeySpecifier)[]; +export type DownloadUpdatesFieldPolicy = { + initial?: FieldPolicy | FieldReadFunction, + omittedUpdates?: FieldPolicy | FieldReadFunction, + state?: FieldPolicy | FieldReadFunction, + updates?: FieldPolicy | FieldReadFunction +}; export type EdgeKeySpecifier = ('cursor' | 'node' | EdgeKeySpecifier)[]; export type EdgeFieldPolicy = { cursor?: FieldPolicy | FieldReadFunction, @@ -822,9 +835,10 @@ export type StopDownloaderPayloadFieldPolicy = { clientMutationId?: FieldPolicy | FieldReadFunction, downloadStatus?: FieldPolicy | FieldReadFunction }; -export type SubscriptionKeySpecifier = ('downloadChanged' | 'updateStatusChanged' | 'webUIUpdateStatusChange' | SubscriptionKeySpecifier)[]; +export type SubscriptionKeySpecifier = ('downloadChanged' | 'downloadStatusChanged' | 'updateStatusChanged' | 'webUIUpdateStatusChange' | SubscriptionKeySpecifier)[]; export type SubscriptionFieldPolicy = { downloadChanged?: FieldPolicy | FieldReadFunction, + downloadStatusChanged?: FieldPolicy | FieldReadFunction, updateStatusChanged?: FieldPolicy | FieldReadFunction, webUIUpdateStatusChange?: FieldPolicy | FieldReadFunction }; @@ -1201,6 +1215,14 @@ export type StrictTypedTypePolicies = { keyFields?: false | DownloadTypeKeySpecifier | (() => undefined | DownloadTypeKeySpecifier), fields?: DownloadTypeFieldPolicy, }, + DownloadUpdate?: Omit & { + keyFields?: false | DownloadUpdateKeySpecifier | (() => undefined | DownloadUpdateKeySpecifier), + fields?: DownloadUpdateFieldPolicy, + }, + DownloadUpdates?: Omit & { + keyFields?: false | DownloadUpdatesKeySpecifier | (() => undefined | DownloadUpdatesKeySpecifier), + fields?: DownloadUpdatesFieldPolicy, + }, Edge?: Omit & { keyFields?: false | EdgeKeySpecifier | (() => undefined | EdgeKeySpecifier), fields?: EdgeFieldPolicy, diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index 172665a1..c4808edf 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -467,6 +467,11 @@ export type DoubleFilterInput = { notIn?: InputMaybe>; }; +export type DownloadChangedInput = { + /** Sets a max number of updates that can be contained in a download update message.Everything above this limit will be omitted and the "downloadStatus" should be re-fetched via the corresponding query. Due to the graphql subscription execution strategy not supporting batching for data loaders, the data loaders run into the n+1 problem, which can cause the server to get unresponsive until the status update has been handled. This is an issue e.g. when mass en- or dequeuing downloads. */ + maxUpdates?: InputMaybe; +}; + export type DownloadEdge = Edge & { __typename?: 'DownloadEdge'; cursor: Scalars['Cursor']['output']; @@ -498,11 +503,37 @@ export type DownloadType = { __typename?: 'DownloadType'; chapter: ChapterType; manga: MangaType; + position: Scalars['Int']['output']; progress: Scalars['Float']['output']; state: DownloadState; tries: Scalars['Int']['output']; }; +export type DownloadUpdate = { + __typename?: 'DownloadUpdate'; + download: DownloadType; + type: DownloadUpdateType; +}; + +export enum DownloadUpdateType { + Dequeued = 'DEQUEUED', + Error = 'ERROR', + Finished = 'FINISHED', + Position = 'POSITION', + Progress = 'PROGRESS', + Queued = 'QUEUED' +} + +export type DownloadUpdates = { + __typename?: 'DownloadUpdates'; + /** The current download queue at the time of sending initial message. Is null for all following messages */ + initial?: Maybe>; + /** Indicates whether updates have been omitted based on the "maxUpdates" subscription variable. In case updates have been omitted, the "downloadStatus" query should be re-fetched. */ + omittedUpdates: Scalars['Boolean']['output']; + state: DownloaderState; + updates: Array; +}; + export enum DownloaderState { Started = 'STARTED', Stopped = 'STOPPED' @@ -1415,7 +1446,7 @@ export type MutationUpdateWebUiArgs = { input: WebUiUpdateInput; }; -export type Node = CategoryMetaType | CategoryType | ChapterMetaType | ChapterType | DownloadType | ExtensionType | GlobalMetaType | MangaMetaType | MangaType | PartialSettingsType | SettingsType | SourceMetaType | SourceType | TrackRecordType | TrackerType; +export type Node = CategoryMetaType | CategoryType | ChapterMetaType | ChapterType | DownloadType | DownloadUpdate | ExtensionType | GlobalMetaType | MangaMetaType | MangaType | PartialSettingsType | SettingsType | SourceMetaType | SourceType | TrackRecordType | TrackerType; export type NodeList = { /** A list of edges which contains the [T] and cursor to aid in pagination. */ @@ -2177,11 +2208,18 @@ export type StringFilterInput = { export type Subscription = { __typename?: 'Subscription'; + /** @deprecated Replaced width downloadStatusChanged, replace with downloadStatusChanged(input) */ downloadChanged: DownloadStatus; + downloadStatusChanged: DownloadUpdates; updateStatusChanged: UpdateStatus; webUIUpdateStatusChange: WebUiUpdateStatus; }; + +export type SubscriptionDownloadStatusChangedArgs = { + input: DownloadChangedInput; +}; + export type SwitchPreference = { __typename?: 'SwitchPreference'; currentValue?: Maybe; @@ -2731,8 +2769,12 @@ export type ChapterListFieldsFragment = { __typename?: 'ChapterType', fetchedAt: export type ChapterUpdateListFieldsFragment = { __typename?: 'ChapterType', fetchedAt: string, uploadDate: string, id: number, name: string, mangaId: number, scanlator?: string | null, realUrl?: string | null, sourceOrder: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string } }; +export type DownloadTypeFieldsFragment = { __typename?: 'DownloadType', progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', id: number, name: string, sourceOrder: number, isDownloaded: boolean }, manga: { __typename?: 'MangaType', id: number, title: string, downloadCount: number } }; + export type DownloadStatusFieldsFragment = { __typename?: 'DownloadStatus', state: DownloaderState, queue: Array<{ __typename?: 'DownloadType', progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', id: number, name: string, sourceOrder: number, isDownloaded: boolean }, manga: { __typename?: 'MangaType', id: number, title: string, downloadCount: number } }> }; +export type DownloadUpdatesFieldsFragment = { __typename?: 'DownloadUpdates', state: DownloaderState, omittedUpdates: boolean, updates: Array<{ __typename?: 'DownloadUpdate', type: DownloadUpdateType, download: { __typename?: 'DownloadType', position: number, progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', id: number, name: string, sourceOrder: number, isDownloaded: boolean }, manga: { __typename?: 'MangaType', id: number, title: string, downloadCount: number } } }> }; + export type ExtensionListFieldsFragment = { __typename?: 'ExtensionType', pkgName: string, name: string, lang: string, versionCode: number, versionName: string, iconUrl: string, repo?: string | null, isNsfw: boolean, isInstalled: boolean, isObsolete: boolean, hasUpdate: boolean }; export type PageInfoFragment = { __typename?: 'PageInfo', endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null }; @@ -3554,10 +3596,12 @@ export type GetLastUpdateTimestampQueryVariables = Exact<{ [key: string]: never; export type GetLastUpdateTimestampQuery = { __typename?: 'Query', lastUpdateTimestamp: { __typename?: 'LastUpdateTimestampPayload', timestamp: string } }; -export type DownloadStatusSubscriptionVariables = Exact<{ [key: string]: never; }>; +export type DownloadStatusSubscriptionVariables = Exact<{ + input: DownloadChangedInput; +}>; -export type DownloadStatusSubscription = { __typename?: 'Subscription', downloadChanged: { __typename?: 'DownloadStatus', state: DownloaderState, queue: Array<{ __typename?: 'DownloadType', progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', id: number, name: string, sourceOrder: number, isDownloaded: boolean }, manga: { __typename?: 'MangaType', id: number, title: string, downloadCount: number } }> } }; +export type DownloadStatusSubscription = { __typename?: 'Subscription', downloadStatusChanged: { __typename?: 'DownloadUpdates', state: DownloaderState, omittedUpdates: boolean, updates: Array<{ __typename?: 'DownloadUpdate', type: DownloadUpdateType, download: { __typename?: 'DownloadType', position: number, progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', id: number, name: string, sourceOrder: number, isDownloaded: boolean }, manga: { __typename?: 'MangaType', id: number, title: string, downloadCount: number } } }> } }; export type WebuiUpdateSubscriptionVariables = Exact<{ [key: string]: never; }>; diff --git a/src/lib/graphql/subscriptions/DownloaderSubscription.ts b/src/lib/graphql/subscriptions/DownloaderSubscription.ts index 2d2af0cc..750122ea 100644 --- a/src/lib/graphql/subscriptions/DownloaderSubscription.ts +++ b/src/lib/graphql/subscriptions/DownloaderSubscription.ts @@ -7,14 +7,14 @@ */ import gql from 'graphql-tag'; -import { DOWNLOAD_STATUS_FIELDS } from '@/lib/graphql/fragments/DownloadFragments.ts'; +import { DOWNLOAD_UPDATES_FIELDS } from '@/lib/graphql/fragments/DownloadFragments.ts'; export const DOWNLOAD_STATUS_SUBSCRIPTION = gql` - ${DOWNLOAD_STATUS_FIELDS} + ${DOWNLOAD_UPDATES_FIELDS} - subscription DOWNLOAD_STATUS_SUBSCRIPTION { - downloadChanged { - ...DOWNLOAD_STATUS_FIELDS + subscription DOWNLOAD_STATUS_SUBSCRIPTION($input: DownloadChangedInput!) { + downloadStatusChanged(input: $input) { + ...DOWNLOAD_UPDATES_FIELDS } } `; diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 30099cfd..26c0c472 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -68,6 +68,7 @@ import { DequeueChapterDownloadsMutationVariables, DownloadStatusSubscription, DownloadStatusSubscriptionVariables, + DownloadUpdateType, EnqueueChapterDownloadMutation, EnqueueChapterDownloadMutationVariables, EnqueueChapterDownloadsMutation, @@ -2777,10 +2778,11 @@ export class RequestManager { const wrappedMutate = (mutationOptions: Parameters[0]) => { const variables = mutationOptions?.variables?.input; const cachedDownloadStatus = this.graphQLClient.client.readFragment< - DownloadStatusSubscription['downloadChanged'] + GetDownloadStatusQuery['downloadStatus'] >({ id: 'DownloadStatus:{}', fragment: DOWNLOAD_STATUS_FIELDS, + fragmentName: 'DOWNLOAD_STATUS_FIELDS', }); if (!variables) { @@ -2933,7 +2935,84 @@ export class RequestManager { public useDownloadSubscription( options?: SubscriptionHookOptions, ): SubscriptionResult { - return this.doRequest(GQLMethod.USE_SUBSCRIPTION, DOWNLOAD_STATUS_SUBSCRIPTION, {}, options); + return this.doRequest( + GQLMethod.USE_SUBSCRIPTION, + DOWNLOAD_STATUS_SUBSCRIPTION, + { input: { maxUpdates: 30 } }, + { + ...options, + onData: (onDataOptions) => { + const downloadChanged = onDataOptions.data.data?.downloadStatusChanged; + + const { cache } = this.graphQLClient.client; + + if (downloadChanged?.omittedUpdates) { + cache.evict({ broadcast: true, fieldName: 'downloadStatus' }); + cache.evict({ broadcast: true, id: 'DownloadStatus:{}' }); + return; + } + + const downloadsToRemove = + downloadChanged?.updates + .filter((update) => + [ + DownloadUpdateType.Dequeued, + DownloadUpdateType.Finished, + DownloadUpdateType.Position, + ].includes(update.type), + ) + .map((update) => update.download.chapter.id) ?? []; + const downloadsToAdd = + downloadChanged?.updates + .filter((update) => update.type === DownloadUpdateType.Queued) + .map((update) => update.download) ?? []; + const downloadsToReorder = + downloadChanged?.updates + .filter((update) => update.type === DownloadUpdateType.Position) + .map((update) => update.download) ?? []; + + cache.updateQuery( + { + query: GET_DOWNLOAD_STATUS, + variables: {}, + }, + (data) => { + if (!data) { + return data; + } + + const queueWithAddedDownloads = [...data.downloadStatus.queue, ...downloadsToAdd]; + const queueWithoutRemovedDownloads = !downloadsToRemove.length + ? queueWithAddedDownloads + : queueWithAddedDownloads.filter( + (download) => !downloadsToRemove.includes(download.chapter.id), + ); + + const queueWithReorderedDownloads = !downloadsToReorder.length + ? queueWithoutRemovedDownloads + : (() => { + const tmpQueue = [...queueWithoutRemovedDownloads]; + + downloadsToReorder.forEach((download) => { + tmpQueue.splice(download.position, 0, download); + }); + + return tmpQueue; + })(); + + return { + ...data, + downloadStatus: { + ...data.downloadStatus, + state: downloadChanged?.state ?? data.downloadStatus.state, + queue: queueWithReorderedDownloads, + }, + }; + }, + ); + }, + }, + ); } public useUpdaterSubscription( diff --git a/src/lib/requests/client/GraphQLClient.ts b/src/lib/requests/client/GraphQLClient.ts index 921c6d7e..4e0d05a6 100644 --- a/src/lib/requests/client/GraphQLClient.ts +++ b/src/lib/requests/client/GraphQLClient.ts @@ -87,11 +87,16 @@ const typePolicies: StrictTypedTypePolicies = { key: args?.key, }); }, - downloadStatus(_, { toReference }) { - return toReference({ - __typename: 'DownloadStatus', - key: {}, - }); + downloadStatus: { + read(_, { toReference }) { + return toReference({ + __typename: 'DownloadStatus', + key: {}, + }); + }, + merge(_, incoming) { + return incoming; + }, }, getWebUIUpdateStatus(_, { toReference }) { return toReference({ diff --git a/src/modules/core/components/DownloadStateIndicator.tsx b/src/modules/core/components/DownloadStateIndicator.tsx index 485d7d93..d5bb5624 100644 --- a/src/modules/core/components/DownloadStateIndicator.tsx +++ b/src/modules/core/components/DownloadStateIndicator.tsx @@ -57,7 +57,7 @@ export const DownloadStateIndicator = ({ download }: { download: ChapterDownload }} > <> - {isDownloading && `${Math.round(download.progress * 100)}%`} + {(isDownloading || isPartiallyDownloaded) && `${Math.round(download.progress * 100)}%`} {!isDownloading && !isPartiallyDownloaded && t(DOWNLOAD_STATE_TO_TRANSLATION_KEY_MAP[download.state])} diff --git a/src/modules/downloads/screens/DownloadQueue.tsx b/src/modules/downloads/screens/DownloadQueue.tsx index 9227a471..5c4f6533 100644 --- a/src/modules/downloads/screens/DownloadQueue.tsx +++ b/src/modules/downloads/screens/DownloadQueue.tsx @@ -34,7 +34,7 @@ import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.t import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { ChapterDownloadStatus, ChapterIdInfo } from '@/modules/chapter/services/Chapters.ts'; -import { DownloadState } from '@/lib/graphql/generated/graphql.ts'; +import { DownloaderState, DownloadState } from '@/lib/graphql/generated/graphql.ts'; const HeightPreservingItem = ({ children, ...props }: BoxProps) => ( // the height is necessary to prevent the item container from collapsing, which confuses Virtuoso measurements @@ -145,7 +145,7 @@ export const DownloadQueue: React.FC = () => { }; const toggleQueueStatus = () => { - if (status === 'STOPPED') { + if (status === DownloaderState.Stopped) { requestManager.startDownloads(); } else { requestManager.stopDownloads(); @@ -162,9 +162,9 @@ export const DownloadQueue: React.FC = () => { - + - {status === 'STOPPED' ? : } + {status === DownloaderState.Stopped ? : } , @@ -220,7 +220,7 @@ export const DownloadQueue: React.FC = () => { }; const handleDelete = async (chapter: ChapterIdInfo) => { - const isRunning = status === 'STARTED'; + const isRunning = status === DownloaderState.Started; try { if (isRunning) { @@ -287,9 +287,9 @@ export const DownloadQueue: React.FC = () => { components={{ Item: HeightPreservingItem, }} - computeItemKey={(_, item) => item.manga.id} + computeItemKey={(_, item) => item.chapter.id} itemContent={(index, item) => ( - + {(draggableProvided) => (