Feature/download ahead trigger chapter downloads client side (#597)

* Trigger download ahead while reading client side

* Trigger download ahead while reading earlier

Download ahead was only triggered once the chapter got marked as read

* Optionally ignore dupe chapters for download ahead

* Consider current chapters scanlator for dupe check

* Update download ahead related settings

- add new metadata setting for download ahead limit
- previous setting
  - rename
  - update usage (auto download new chapters limit)

* Select chapter to auto delete while reading depending on ignore dupe setting

Currently, the n-th to last chapter always got deleted ignoring if "ignore dupes" setting was enabled or not.

* Only auto delete chapter while reading if it is read

In case not the last read but the n-th to last read chapter should get auto deleted while reading, it currently just got deleted ignoring if it has been read or not.
This commit is contained in:
schroda
2024-02-16 19:16:18 +01:00
committed by GitHub
parent 0edec685f9
commit 2acf2b6d33
10 changed files with 232 additions and 116 deletions

View File

@@ -8,10 +8,12 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { List, ListItem, ListItemText, Switch } from '@mui/material'; import { List, ListItem, ListItemText, Switch } from '@mui/material';
import { useCallback } from 'react';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { NumberSetting } from '@/components/settings/NumberSetting.tsx'; import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
import { getPersistedServerSetting, usePersistedValue } from '@/util/usePersistedValue.tsx'; import { getPersistedServerSetting, usePersistedValue } from '@/util/usePersistedValue.tsx';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { MetadataServerSettings } from '@/typings.ts';
import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.ts';
import { makeToast } from '@/components/util/Toast.tsx';
const MIN_LIMIT = 2; const MIN_LIMIT = 2;
const MAX_LIMIT = 10; const MAX_LIMIT = 10;
@@ -20,10 +22,12 @@ const DEFAULT_LIMIT = MIN_LIMIT;
export const DownloadAheadSetting = () => { export const DownloadAheadSetting = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = requestManager.useGetServerSettings(); const {
const downloadAheadLimit = data?.settings.autoDownloadAheadLimit; metadata,
settings: { downloadAheadLimit },
} = useMetadataServerSettings();
const shouldDownloadAhead = !!downloadAheadLimit; const shouldDownloadAhead = !!downloadAheadLimit;
const [mutateSettings] = requestManager.useUpdateServerSettings();
const [currentDownloadAheadLimit, persistDownloadAheadLimit] = usePersistedValue( const [currentDownloadAheadLimit, persistDownloadAheadLimit] = usePersistedValue(
'lastDownloadAheadLimit', 'lastDownloadAheadLimit',
DEFAULT_LIMIT, DEFAULT_LIMIT,
@@ -31,15 +35,12 @@ export const DownloadAheadSetting = () => {
getPersistedServerSetting, getPersistedServerSetting,
); );
const updateSetting = useCallback( const updateSetting = (value: MetadataServerSettings['downloadAheadLimit']) => {
(autoDownloadAheadLimit: number) => { persistDownloadAheadLimit(value === 0 ? currentDownloadAheadLimit : value);
persistDownloadAheadLimit( requestUpdateServerMetadata(convertToGqlMeta(metadata)! ?? {}, [['downloadAheadLimit', value]]).catch(() =>
autoDownloadAheadLimit === 0 ? currentDownloadAheadLimit : autoDownloadAheadLimit, makeToast(t('search.error.label.failed_to_save_settings'), 'warning'),
);
mutateSettings({ variables: { input: { settings: { autoDownloadAheadLimit } } } });
},
[currentDownloadAheadLimit],
); );
};
const setDoAutoUpdates = (enable: boolean) => { const setDoAutoUpdates = (enable: boolean) => {
const globalUpdateInterval = enable ? currentDownloadAheadLimit : 0; const globalUpdateInterval = enable ? currentDownloadAheadLimit : 0;
@@ -68,7 +69,6 @@ export const DownloadAheadSetting = () => {
defaultValue={DEFAULT_LIMIT} defaultValue={DEFAULT_LIMIT}
showSlider showSlider
dialogDescription={t('download.settings.download_ahead.label.description')} dialogDescription={t('download.settings.download_ahead.label.description')}
dialogDisclaimer={t('download.settings.download_ahead.label.disclaimer')}
valueUnit={t('chapter.title')} valueUnit={t('chapter.title')}
handleUpdate={updateSetting} handleUpdate={updateSetting}
disabled={!shouldDownloadAhead} disabled={!shouldDownloadAhead}

View File

@@ -167,6 +167,12 @@
"include_in_download": "Entries in excluded categories will not be downloaded even if they are also in included categories" "include_in_download": "Entries in excluded categories will not be downloaded even if they are also in included categories"
} }
}, },
"download_limit": {
"label": {
"description": "Limit the amount of new chapters that are going to get downloaded.",
"title": "Chapter download limit"
}
},
"label": { "label": {
"ignore_with_unread_chapters": "Ignore automatic chapter downloads for entries with unread chapters", "ignore_with_unread_chapters": "Ignore automatic chapter downloads for entries with unread chapters",
"new_chapters": "Download new chapters" "new_chapters": "Download new chapters"
@@ -197,7 +203,6 @@
"download_ahead": { "download_ahead": {
"label": { "label": {
"description": "How many chapters should get downloaded when marking a chapter as read while reading.", "description": "How many chapters should get downloaded when marking a chapter as read while reading.",
"disclaimer": "This limit will also be applied to the automatic download of new chapters during an update",
"unread_chapters_to_download": "Number of unread chapters to download", "unread_chapters_to_download": "Number of unread chapters to download",
"value": "{{chapters}} $t(chapter.title)", "value": "{{chapters}} $t(chapter.title)",
"while_reading": "Auto download while reading" "while_reading": "Auto download while reading"

View File

@@ -7,10 +7,13 @@
*/ */
import { t as translate } from 'i18next'; import { t as translate } from 'i18next';
import { TChapter, TranslationKey } from '@/typings.ts'; import gql from 'graphql-tag';
import { DocumentNode } from '@apollo/client';
import { ChapterOffset, TChapter, TranslationKey } from '@/typings.ts';
import { makeToast } from '@/components/util/Toast.tsx'; import { makeToast } from '@/components/util/Toast.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { getMetadataServerSettings } from '@/util/metadataServerSettings.ts'; import { getMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { FULL_CHAPTER_FIELDS } from '@/lib/graphql/Fragments.ts';
export type ChapterAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread'; export type ChapterAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
@@ -79,12 +82,48 @@ export type ChapterDownloadInfo = ChapterIdInfo & Pick<TChapter, 'isDownloaded'>
export type ChapterBookmarkInfo = ChapterIdInfo & Pick<TChapter, 'isBookmarked'>; export type ChapterBookmarkInfo = ChapterIdInfo & Pick<TChapter, 'isBookmarked'>;
export type ChapterReadInfo = ChapterIdInfo & Pick<TChapter, 'isRead'>; export type ChapterReadInfo = ChapterIdInfo & Pick<TChapter, 'isRead'>;
export type ChapterNumberInfo = ChapterIdInfo & Pick<TChapter, 'chapterNumber'>; export type ChapterNumberInfo = ChapterIdInfo & Pick<TChapter, 'chapterNumber'>;
export type ChapterScanlatorInfo = ChapterIdInfo & Pick<TChapter, 'scanlator'>;
export class Chapters { export class Chapters {
static getIds(chapters: { id: number }[]): number[] { static getIds(chapters: { id: number }[]): number[] {
return chapters.map((chapter) => chapter.id); return chapters.map((chapter) => chapter.id);
} }
static getFromCache<T>(
id: number,
fragment: DocumentNode = FULL_CHAPTER_FIELDS,
fragmentName: string = 'FULL_CHAPTER_FIELDS',
): T | null {
return requestManager.graphQLClient.client.cache.readFragment<T>({
id: requestManager.graphQLClient.client.cache.identify({
__typename: 'ChapterType',
id,
}),
fragment,
fragmentName,
});
}
static isDownloading(id: number): boolean {
return !!requestManager.graphQLClient.client.cache.readFragment<TChapter>({
id: requestManager.graphQLClient.client.cache.identify({
__typename: 'DownloadType',
chapter: {
__ref: requestManager.graphQLClient.client.cache.identify({
__typename: 'ChapterType',
id,
}),
},
}),
fragment: gql`
fragment CHAPTER_DOWNLOAD_QUEUE_CHECK on ChapterType {
id
}
`,
fragmentName: 'CHAPTER_DOWNLOAD_QUEUE_CHECK',
});
}
static isDownloaded({ isDownloaded }: ChapterDownloadInfo): boolean { static isDownloaded({ isDownloaded }: ChapterDownloadInfo): boolean {
return isDownloaded; return isDownloaded;
} }
@@ -258,4 +297,60 @@ export class Chapters {
throw new Error(`Chapters::performAction: unknown action "${action}"`); throw new Error(`Chapters::performAction: unknown action "${action}"`);
} }
} }
static removeDuplicates<T extends ChapterScanlatorInfo & ChapterNumberInfo>(currentChapter: T, chapters: T[]): T[] {
const chapterNumberToChapters = new Map<ChapterNumberInfo['chapterNumber'], T[]>();
chapters.forEach((chapter) => {
const duplicateChapters = chapterNumberToChapters.get(chapter.chapterNumber) ?? [];
chapterNumberToChapters.set(chapter.chapterNumber, [...duplicateChapters, chapter]);
});
return [...chapterNumberToChapters.values()].map(
(groupedChapters) =>
groupedChapters.find((chapter) => chapter.id === currentChapter.id) ??
groupedChapters.findLast((chapter) => chapter.scanlator === currentChapter.scanlator) ??
groupedChapters.slice(-1)[0],
);
}
static getNextChapter<Chapter extends ChapterScanlatorInfo & ChapterNumberInfo & ChapterReadInfo>(
currentChapter: Chapter,
chapters: Chapter[],
{
offset = ChapterOffset.NEXT,
...options
}: { offset?: ChapterOffset; onlyUnread?: boolean; skipDupe?: boolean } = {},
): Chapter | undefined {
const nextChapters = Chapters.getNextChapters(currentChapter, chapters, { offset, ...options });
const isNextChapterOffset = offset === ChapterOffset.NEXT;
const sliceStartIndex = isNextChapterOffset ? -1 : 0;
const sliceEndIndex = isNextChapterOffset ? undefined : 1;
return nextChapters.slice(sliceStartIndex, sliceEndIndex)[0];
}
static getNextChapters<Chapter extends ChapterScanlatorInfo & ChapterNumberInfo & ChapterReadInfo>(
fromChapter: Chapter,
chapters: Chapter[],
{
offset = ChapterOffset.NEXT,
onlyUnread = false,
skipDupe = false,
}: { offset?: ChapterOffset; onlyUnread?: boolean; skipDupe?: boolean } = {},
): Chapter[] {
const fromChapterIndex = chapters.findIndex((chapter) => chapter.id === fromChapter.id);
const isNextChapterOffset = offset === ChapterOffset.NEXT;
const sliceStartIndex = isNextChapterOffset ? 0 : fromChapterIndex;
const sliceEndIndex = isNextChapterOffset ? fromChapterIndex + 1 : undefined;
const nextChaptersIncludingCurrent = chapters.slice(sliceStartIndex, sliceEndIndex);
const uniqueNextChapters = skipDupe
? Chapters.removeDuplicates(fromChapter, nextChaptersIncludingCurrent)
: nextChaptersIncludingCurrent;
const nextChapters = uniqueNextChapters.toSpliced(isNextChapterOffset ? -1 : 0, 1);
return onlyUnread ? Chapters.getNonRead(nextChapters) : nextChapters;
}
} }

View File

@@ -2608,15 +2608,12 @@ export type UpdateChapterMutationVariables = Exact<{
getBookmarked: Scalars['Boolean']['input']; getBookmarked: Scalars['Boolean']['input'];
getRead: Scalars['Boolean']['input']; getRead: Scalars['Boolean']['input'];
getLastPageRead: Scalars['Boolean']['input']; getLastPageRead: Scalars['Boolean']['input'];
id: Scalars['Int']['input'];
chapterIdToDelete: Scalars['Int']['input']; chapterIdToDelete: Scalars['Int']['input'];
deleteChapter: Scalars['Boolean']['input']; deleteChapter: Scalars['Boolean']['input'];
mangaId: Scalars['Int']['input'];
downloadAhead: Scalars['Boolean']['input'];
}>; }>;
export type UpdateChapterMutation = { __typename?: 'Mutation', updateChapter: { __typename?: 'UpdateChapterPayload', clientMutationId?: string | null, chapter: { __typename?: 'ChapterType', id: number, isBookmarked?: boolean, isRead?: boolean, lastReadAt?: any, lastPageRead?: number, manga?: { __typename?: 'MangaType', id: number, unreadCount: number, lastReadChapter?: { __typename?: 'ChapterType', id: number } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number } | null } } }, deleteDownloadedChapter?: { __typename?: 'DeleteDownloadedChapterPayload', clientMutationId?: string | null, chapters: { __typename?: 'ChapterType', id: number, isDownloaded: boolean, manga: { __typename?: 'MangaType', id: number, downloadCount: number } } }, downloadAhead?: { __typename?: 'DownloadAheadPayload', clientMutationId?: string | null } }; export type UpdateChapterMutation = { __typename?: 'Mutation', updateChapter: { __typename?: 'UpdateChapterPayload', clientMutationId?: string | null, chapter: { __typename?: 'ChapterType', id: number, isBookmarked?: boolean, isRead?: boolean, lastReadAt?: any, lastPageRead?: number, manga?: { __typename?: 'MangaType', id: number, unreadCount: number, lastReadChapter?: { __typename?: 'ChapterType', id: number } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number } | null } } }, deleteDownloadedChapter?: { __typename?: 'DeleteDownloadedChapterPayload', clientMutationId?: string | null, chapters: { __typename?: 'ChapterType', id: number, isDownloaded: boolean, manga: { __typename?: 'MangaType', id: number, downloadCount: number } } } };
export type UpdateChaptersMutationVariables = Exact<{ export type UpdateChaptersMutationVariables = Exact<{
input: UpdateChaptersInput; input: UpdateChaptersInput;
@@ -2625,13 +2622,10 @@ export type UpdateChaptersMutationVariables = Exact<{
getLastPageRead: Scalars['Boolean']['input']; getLastPageRead: Scalars['Boolean']['input'];
chapterIdsToDelete: Array<Scalars['Int']['input']> | Scalars['Int']['input']; chapterIdsToDelete: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
deleteChapters: Scalars['Boolean']['input']; deleteChapters: Scalars['Boolean']['input'];
mangaIds: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
latestReadChapterIds: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
downloadAhead: Scalars['Boolean']['input'];
}>; }>;
export type UpdateChaptersMutation = { __typename?: 'Mutation', updateChapters: { __typename?: 'UpdateChaptersPayload', clientMutationId?: string | null, chapters: Array<{ __typename?: 'ChapterType', id: number, isBookmarked?: boolean, isRead?: boolean, lastReadAt?: any, lastPageRead?: number, manga?: { __typename?: 'MangaType', id: number, unreadCount: number, lastReadChapter?: { __typename?: 'ChapterType', id: number } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number } | null } }> }, deleteDownloadedChapters?: { __typename?: 'DeleteDownloadedChaptersPayload', clientMutationId?: string | null, chapters: Array<{ __typename?: 'ChapterType', id: number, isDownloaded: boolean, manga: { __typename?: 'MangaType', id: number, downloadCount: number } }> }, downloadAhead?: { __typename?: 'DownloadAheadPayload', clientMutationId?: string | null } }; export type UpdateChaptersMutation = { __typename?: 'Mutation', updateChapters: { __typename?: 'UpdateChaptersPayload', clientMutationId?: string | null, chapters: Array<{ __typename?: 'ChapterType', id: number, isBookmarked?: boolean, isRead?: boolean, lastReadAt?: any, lastPageRead?: number, manga?: { __typename?: 'MangaType', id: number, unreadCount: number, lastReadChapter?: { __typename?: 'ChapterType', id: number } | null, latestReadChapter?: { __typename?: 'ChapterType', id: number } | null } }> }, deleteDownloadedChapters?: { __typename?: 'DeleteDownloadedChaptersPayload', clientMutationId?: string | null, chapters: Array<{ __typename?: 'ChapterType', id: number, isDownloaded: boolean, manga: { __typename?: 'MangaType', id: number, downloadCount: number } }> } };
export type ClearDownloaderMutationVariables = Exact<{ export type ClearDownloaderMutationVariables = Exact<{
input?: InputMaybe<ClearDownloaderInput>; input?: InputMaybe<ClearDownloaderInput>;

View File

@@ -93,11 +93,8 @@ export const UPDATE_CHAPTER = gql`
$getBookmarked: Boolean! $getBookmarked: Boolean!
$getRead: Boolean! $getRead: Boolean!
$getLastPageRead: Boolean! $getLastPageRead: Boolean!
$id: Int!
$chapterIdToDelete: Int! $chapterIdToDelete: Int!
$deleteChapter: Boolean! $deleteChapter: Boolean!
$mangaId: Int!
$downloadAhead: Boolean!
) { ) {
updateChapter(input: $input) { updateChapter(input: $input) {
clientMutationId clientMutationId
@@ -130,9 +127,6 @@ export const UPDATE_CHAPTER = gql`
} }
} }
} }
downloadAhead(input: { mangaIds: [$mangaId], latestReadChapterIds: [$id] }) @include(if: $downloadAhead) {
clientMutationId
}
} }
`; `;
@@ -144,9 +138,6 @@ export const UPDATE_CHAPTERS = gql`
$getLastPageRead: Boolean! $getLastPageRead: Boolean!
$chapterIdsToDelete: [Int!]! $chapterIdsToDelete: [Int!]!
$deleteChapters: Boolean! $deleteChapters: Boolean!
$mangaIds: [Int!]!
$latestReadChapterIds: [Int!]!
$downloadAhead: Boolean!
) { ) {
updateChapters(input: $input) { updateChapters(input: $input) {
clientMutationId clientMutationId
@@ -179,9 +170,5 @@ export const UPDATE_CHAPTERS = gql`
} }
} }
} }
downloadAhead(input: { mangaIds: $mangaIds, latestReadChapterIds: $latestReadChapterIds })
@include(if: $downloadAhead) {
clientMutationId
}
} }
`; `;

View File

@@ -1762,25 +1762,21 @@ export class RequestManager {
id: number, id: number,
patch: UpdateChapterPatchInput & { patch: UpdateChapterPatchInput & {
chapterIdToDelete?: number; chapterIdToDelete?: number;
downloadAheadMangaId?: number;
}, },
options?: MutationOptions<UpdateChapterMutation, UpdateChapterMutationVariables>, options?: MutationOptions<UpdateChapterMutation, UpdateChapterMutationVariables>,
): AbortableApolloMutationResponse<UpdateChapterMutation> { ): AbortableApolloMutationResponse<UpdateChapterMutation> {
const { chapterIdToDelete = -1, downloadAheadMangaId = -1, ...updatePatch } = patch; const { chapterIdToDelete = -1, ...updatePatch } = patch;
return this.doRequest<UpdateChapterMutation, UpdateChapterMutationVariables>( return this.doRequest<UpdateChapterMutation, UpdateChapterMutationVariables>(
GQLMethod.MUTATION, GQLMethod.MUTATION,
UPDATE_CHAPTER, UPDATE_CHAPTER,
{ {
id,
input: { id, patch: updatePatch }, input: { id, patch: updatePatch },
getBookmarked: patch.isBookmarked != null, getBookmarked: patch.isBookmarked != null,
getRead: patch.isRead != null, getRead: patch.isRead != null,
getLastPageRead: patch.lastPageRead != null, getLastPageRead: patch.lastPageRead != null,
chapterIdToDelete, chapterIdToDelete,
deleteChapter: chapterIdToDelete >= 0, deleteChapter: chapterIdToDelete >= 0,
mangaId: downloadAheadMangaId,
downloadAhead: downloadAheadMangaId !== -1,
}, },
options, options,
); );
@@ -1809,12 +1805,10 @@ export class RequestManager {
public updateChapters( public updateChapters(
ids: number[], ids: number[],
patch: UpdateChapterPatchInput & { chapterIdsToDelete?: number[]; mangaIds?: number[] }, patch: UpdateChapterPatchInput & { chapterIdsToDelete?: number[] },
options?: MutationOptions<UpdateChaptersMutation, UpdateChaptersMutationVariables>, options?: MutationOptions<UpdateChaptersMutation, UpdateChaptersMutationVariables>,
): AbortableApolloMutationResponse<UpdateChaptersMutation> { ): AbortableApolloMutationResponse<UpdateChaptersMutation> {
const { chapterIdsToDelete = [], mangaIds = [], ...updatePatch } = patch; const { chapterIdsToDelete = [], ...updatePatch } = patch;
const downloadAhead = !!mangaIds.length;
return this.doRequest<UpdateChaptersMutation, UpdateChaptersMutationVariables>( return this.doRequest<UpdateChaptersMutation, UpdateChaptersMutationVariables>(
GQLMethod.MUTATION, GQLMethod.MUTATION,
@@ -1826,9 +1820,6 @@ export class RequestManager {
getLastPageRead: patch.lastPageRead != null, getLastPageRead: patch.lastPageRead != null,
chapterIdsToDelete, chapterIdsToDelete,
deleteChapters: !!chapterIdsToDelete.length, deleteChapters: !!chapterIdsToDelete.length,
mangaIds,
downloadAhead,
latestReadChapterIds: downloadAhead ? ids : [],
}, },
options, options,
); );

View File

@@ -39,32 +39,7 @@ import { useDebounce } from '@/util/useDebounce.ts';
import { UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts'; import { UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts';
import { FULL_CHAPTER_FIELDS } from '@/lib/graphql/Fragments.ts'; import { Chapters } from '@/lib/data/Chapters.ts';
const isDupChapter = async (chapterIndex: number, currentChapter: TChapter) => {
const nextChapter = await requestManager.getChapter(currentChapter.manga.id, chapterIndex).response;
return nextChapter.data.chapter.chapterNumber === currentChapter.chapterNumber;
};
/**
* In case duplicated chapters should be skipped the function will check all next/prev chapters until
* - a non duplicated chapter was found
* - no prev/next chapter exists => chapter request will fail and error will be raised up
*/
const getOffsetChapter = async (
chapterIndex: number,
currentChapter: TChapter,
skipDupChapters: boolean,
offset: ChapterOffset,
): Promise<number> => {
const shouldSkipChapter = skipDupChapters && (await isDupChapter(chapterIndex, currentChapter));
if (shouldSkipChapter) {
return getOffsetChapter(chapterIndex + offset, currentChapter, skipDupChapters, offset);
}
return chapterIndex;
};
const getReaderComponent = (readerType: ReaderType) => { const getReaderComponent = (readerType: ReaderType) => {
switch (readerType) { switch (readerType) {
@@ -184,38 +159,53 @@ export function Reader() {
const { settings: metadataSettings } = useMetadataServerSettings(); const { settings: metadataSettings } = useMetadataServerSettings();
const updateChapter = (patch: UpdateChapterPatchInput) => { const prevChapters = useMemo(
const getChapterFromCache = (id: number) => () =>
requestManager.graphQLClient.client.cache.readFragment<TChapter>({ Chapters.getNextChapters(chapter, mangaChapters ?? [], {
id: requestManager.graphQLClient.client.cache.identify({ offset: ChapterOffset.PREV,
__typename: 'ChapterType', skipDupe: settings.skipDupChapters,
id,
}), }),
fragment: FULL_CHAPTER_FIELDS, [chapter, mangaChapters, settings.skipDupChapters],
fragmentName: 'FULL_CHAPTER_FIELDS', );
}); const nextChapters = useMemo(
() => Chapters.getNextChapters(chapter, mangaChapters ?? [], { skipDupe: settings.skipDupChapters }),
const isAutoDeletionEnabled = !!patch.isRead && !!metadataSettings.deleteChaptersWhileReading; [chapter, mangaChapters, settings.skipDupChapters],
);
const prevChapter = useMemo(
() =>
Chapters.getNextChapter(chapter, mangaChapters ?? [], {
offset: ChapterOffset.PREV,
skipDupe: settings.skipDupChapters,
}),
[chapter, mangaChapters, settings.skipDupChapters],
);
const nextChapter = useMemo(
() =>
Chapters.getNextChapter(chapter, mangaChapters ?? [], {
skipDupe: settings.skipDupChapters,
}),
[chapter, mangaChapters, settings.skipDupChapters],
);
const updateChapter = (patch: UpdateChapterPatchInput) => {
const getChapterIdToDelete = () => { const getChapterIdToDelete = () => {
const isAutoDeletionEnabled = !!patch.isRead && !!metadataSettings.deleteChaptersWhileReading;
if (!isAutoDeletionEnabled || !mangaChapters) { if (!isAutoDeletionEnabled || !mangaChapters) {
return -1; return -1;
} }
const chapterToDeleteSourceOrder = Number(chapterIndex) - (metadataSettings.deleteChaptersWhileReading - 1); const chapterToDelete = [chapter, ...prevChapters][metadataSettings.deleteChaptersWhileReading - 1];
const chapterToDelete = mangaChapters.find(
(mangaChapter) => mangaChapter.sourceOrder === chapterToDeleteSourceOrder,
);
if (!chapterToDelete) { if (!chapterToDelete) {
return -1; return -1;
} }
const chapterToDeleteUpToDateData = getChapterFromCache(chapterToDelete.id); // chapter has to exist in the cache since the reader fetches all chapters of the manga
const chapterToDeleteUpToDateData = Chapters.getFromCache<TChapter>(chapterToDelete.id)!;
const shouldDeleteChapter = const shouldDeleteChapter =
chapterToDeleteUpToDateData?.isDownloaded && chapterToDeleteUpToDateData.isRead &&
(!chapterToDeleteUpToDateData?.isBookmarked || metadataSettings.deleteChaptersWithBookmark); Chapters.isAutoDeletable(chapterToDeleteUpToDateData, metadataSettings.deleteChaptersWithBookmark);
if (!shouldDeleteChapter) { if (!shouldDeleteChapter) {
return -1; return -1;
} }
@@ -223,23 +213,48 @@ export function Reader() {
return chapterToDelete.id; return chapterToDelete.id;
}; };
const currentChapter = getChapterFromCache(chapter.id); const downloadAhead = () => {
const nextChapterId = mangaChapters?.[(mangaChapters?.length ?? 0) - Number(chapterIndex) - 1]?.id; const currentChapter = Chapters.getFromCache<TChapter>(chapter.id);
const nextChapter = nextChapterId ? getChapterFromCache(nextChapterId) : null;
const shouldDownloadAhead = const inDownloadRange = (patch.lastPageRead ?? 0) / chapter.pageCount > 0.25;
isDownloadAheadEnabled && const shouldCheckDownloadAhead =
chapter.manga.inLibrary && isDownloadAheadEnabled && chapter.manga.inLibrary && !!currentChapter?.isDownloaded && inDownloadRange;
!chapter.isRead &&
!!patch.isRead && if (shouldCheckDownloadAhead) {
!!currentChapter?.isDownloaded && const nextChapterUpToDate = nextChapter ? Chapters.getFromCache<TChapter>(nextChapter.id) : null;
!!nextChapter?.isDownloaded;
if (!nextChapterUpToDate?.isDownloaded) {
return;
}
const nextChaptersUpToDate = Chapters.getNonRead(nextChapters).map(
// the chapters have to be in the cache since the reader fetches the whole chapter list of the manga
(mangaChapter) => Chapters.getFromCache<TChapter>(mangaChapter.id)!,
);
const chapterIdsToDownload = nextChaptersUpToDate
// "settingsData" can't be undefined since this would not get executed otherwise
.slice(-settingsData!.settings.autoDownloadAheadLimit)
.filter((mangaChapter) => !mangaChapter.isDownloaded)
.map((mangaChapter) => mangaChapter.id)
.filter((id) => !Chapters.isDownloading(id));
if (!chapterIdsToDownload.length) {
return;
}
Chapters.download(chapterIdsToDownload!).catch(
defaultPromiseErrorHandler('Reader::updateChapter: shouldDownloadAhead'),
);
}
};
downloadAhead();
requestManager requestManager
.updateChapter(chapter.id, { .updateChapter(chapter.id, {
...patch, ...patch,
chapterIdToDelete: getChapterIdToDelete(), chapterIdToDelete: getChapterIdToDelete(),
downloadAheadMangaId: shouldDownloadAhead ? chapter.manga.id : undefined,
}) })
.response.catch(); .response.catch();
}; };
@@ -256,9 +271,12 @@ export function Reader() {
setRetrievingNextChapter(true); setRetrievingNextChapter(true);
setCurPage(0); setCurPage(0);
try { try {
setHistory( const chapterToOpen = offset === ChapterOffset.NEXT ? nextChapter : prevChapter;
await getOffsetChapter(chapter.sourceOrder + offset, chapter, settings.skipDupChapters, offset), if (!chapterToOpen) {
); throw new Error('Failed to find next chapter');
}
setHistory(chapterToOpen.sourceOrder);
} catch (error) { } catch (error) {
const offsetToTranslationKeyMap: { [chapterOffset in ChapterOffset]: TranslationKey } = { const offsetToTranslationKeyMap: { [chapterOffset in ChapterOffset]: TranslationKey } = {
[ChapterOffset.PREV]: 'reader.error.label.unable_to_get_prev_chapter_skip_dup', [ChapterOffset.PREV]: 'reader.error.label.unable_to_get_prev_chapter_skip_dup',
@@ -344,7 +362,7 @@ export function Reader() {
}); });
}, [curPageDebounced, isDownloadAheadEnabled]); }, [curPageDebounced, isDownloadAheadEnabled]);
const nextChapter = useCallback(() => { const loadNextChapter = useCallback(() => {
const doesNextChapterExist = chapter.sourceOrder < manga.chapters.totalCount; const doesNextChapterExist = chapter.sourceOrder < manga.chapters.totalCount;
if (!doesNextChapterExist) { if (!doesNextChapterExist) {
return; return;
@@ -366,11 +384,11 @@ export function Reader() {
manga.chapters.totalCount, manga.chapters.totalCount,
chapter.pageCount, chapter.pageCount,
manga.id, manga.id,
settings.skipDupChapters,
isDownloadAheadEnabled, isDownloadAheadEnabled,
nextChapter?.id,
]); ]);
const prevChapter = useCallback(() => { const loadPrevChapter = useCallback(() => {
if (chapter.sourceOrder > 1) { if (chapter.sourceOrder > 1) {
openNextChapter(ChapterOffset.PREV, (prevChapterIndex) => openNextChapter(ChapterOffset.PREV, (prevChapterIndex) =>
navigate(`/manga/${manga.id}/chapter/${prevChapterIndex}`, { navigate(`/manga/${manga.id}/chapter/${prevChapterIndex}`, {
@@ -379,7 +397,7 @@ export function Reader() {
}), }),
); );
} }
}, [chapter.sourceOrder, manga.id, settings.skipDupChapters]); }, [chapter.sourceOrder, manga.id, prevChapter?.id]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -431,8 +449,8 @@ export function Reader() {
settings={settings} settings={settings}
manga={manga} manga={manga}
chapter={chapter} chapter={chapter}
nextChapter={nextChapter} nextChapter={loadNextChapter}
prevChapter={prevChapter} prevChapter={loadPrevChapter}
/> />
</Box> </Box>
); );

View File

@@ -21,6 +21,7 @@ import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.t
import { makeToast } from '@/components/util/Toast.tsx'; import { makeToast } from '@/components/util/Toast.tsx';
import { DeleteChaptersWhileReadingSetting } from '@/components/settings/downloads/DeleteChaptersWhileReadingSetting.tsx'; import { DeleteChaptersWhileReadingSetting } from '@/components/settings/downloads/DeleteChaptersWhileReadingSetting.tsx';
import { CategoriesInclusionSetting } from '@/components/settings/CategoriesInclusionSetting.tsx'; import { CategoriesInclusionSetting } from '@/components/settings/CategoriesInclusionSetting.tsx';
import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
type DownloadSettingsType = Pick< type DownloadSettingsType = Pick<
ServerSettings, ServerSettings,
@@ -100,7 +101,7 @@ export const DownloadSettings = () => {
</ListItem> </ListItem>
<List <List
subheader={ subheader={
<ListSubheader component="div" id="download-settings-auto-download"> <ListSubheader component="div" id="download-settings-auto-delete-downloads">
{t('download.settings.delete_chapters.title')} {t('download.settings.delete_chapters.title')}
</ListSubheader> </ListSubheader>
} }
@@ -143,6 +144,29 @@ export const DownloadSettings = () => {
onChange={(e) => updateSetting('autoDownloadNewChapters', e.target.checked)} onChange={(e) => updateSetting('autoDownloadNewChapters', e.target.checked)}
/> />
</ListItem> </ListItem>
<NumberSetting
disabled={!downloadSettings?.autoDownloadNewChapters}
settingTitle={t('download.settings.auto_download.download_limit.label.title')}
dialogDescription={t('download.settings.auto_download.download_limit.label.description')}
value={downloadSettings?.autoDownloadAheadLimit ?? 0}
settingValue={
// eslint-disable-next-line no-nested-ternary
downloadSettings?.autoDownloadAheadLimit !== undefined
? !downloadSettings.autoDownloadAheadLimit
? t('global.label.none')
: t('download.settings.download_ahead.label.value', {
chapters: downloadSettings.autoDownloadAheadLimit,
count: downloadSettings.autoDownloadAheadLimit,
})
: undefined
}
defaultValue={0}
minValue={0}
maxValue={20}
showSlider
valueUnit={t('chapter.title')}
handleUpdate={(downloadAheadLimit) => updateSetting('autoDownloadAheadLimit', downloadAheadLimit)}
/>
<ListItem disabled={!downloadSettings?.autoDownloadNewChapters}> <ListItem disabled={!downloadSettings?.autoDownloadNewChapters}>
<ListItemText primary={t('download.settings.auto_download.label.ignore_with_unread_chapters')} /> <ListItemText primary={t('download.settings.auto_download.label.ignore_with_unread_chapters')} />
<Switch <Switch

View File

@@ -222,6 +222,7 @@ export type MetadataServerSettings = {
deleteChaptersManuallyMarkedRead: boolean; deleteChaptersManuallyMarkedRead: boolean;
deleteChaptersWhileReading: number; deleteChaptersWhileReading: number;
deleteChaptersWithBookmark: boolean; deleteChaptersWithBookmark: boolean;
downloadAheadLimit: number;
// library // library
showAddToLibraryCategorySelectDialog: boolean; showAddToLibraryCategorySelectDialog: boolean;

View File

@@ -15,6 +15,7 @@ export const getDefaultSettings = (): MetadataServerSettings => ({
deleteChaptersManuallyMarkedRead: false, deleteChaptersManuallyMarkedRead: false,
deleteChaptersWhileReading: 0, deleteChaptersWhileReading: 0,
deleteChaptersWithBookmark: false, deleteChaptersWithBookmark: false,
downloadAheadLimit: 0,
// library // library
showAddToLibraryCategorySelectDialog: true, showAddToLibraryCategorySelectDialog: true,