Feature/download ahead while reading (#464)

* Download next chapters when marking chapter as read while reading

* Add "auto delete chapter" settings

* Delete chapter after finished reading

* Delete chapters after marking them as read

* Improve "mark previous as read" action

Send only ids of chapters that are actually unread
This commit is contained in:
schroda
2023-11-19 21:17:35 +01:00
committed by GitHub
parent e26907e2ce
commit d12ac27b61
10 changed files with 240 additions and 31 deletions

View File

@@ -32,10 +32,11 @@ import { getUploadDateString } from '@/util/date';
import { DownloadStateIndicator } from '@/components/molecules/DownloadStateIndicator';
import { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
import { TChapter } from '@/typings.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
interface IProps {
chapter: TChapter;
chapterIds: number[];
allChapters: TChapter[];
downloadChapter: DownloadType | undefined;
showChapterNumber: boolean;
onSelect: (selected: boolean) => void;
@@ -46,9 +47,11 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
const { t } = useTranslation();
const theme = useTheme();
const { chapter, chapterIds, downloadChapter: dc, showChapterNumber, onSelect, selected } = props;
const { chapter, allChapters, downloadChapter: dc, showChapterNumber, onSelect, selected } = props;
const isSelecting = selected !== null;
const { settings: metadataServerSettings } = useMetadataServerSettings();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -67,9 +70,29 @@ export const ChapterCard: React.FC<IProps> = (props: IProps) => {
const sendChange = <Key extends keyof UpdatePatchInput>(key: Key, value: UpdatePatchInput[Key]) => {
handleClose();
const isMarkAsRead = (key === 'isRead' && value) || key === 'markPrevRead';
const shouldAutoDeleteChapters = isMarkAsRead && metadataServerSettings.deleteChaptersManuallyMarkedRead;
if (shouldAutoDeleteChapters) {
const shouldDeleteChapter = ({ isBookmarked, isDownloaded }: TChapter) =>
isDownloaded && (!isBookmarked || metadataServerSettings.deleteChaptersWithBookmark);
const chaptersToDelete = key === 'isRead' ? [chapter] : allChapters;
const chapterIdsToDelete = chaptersToDelete
.filter(shouldDeleteChapter)
.map(({ id: chapterId }) => chapterId);
requestManager.deleteDownloadedChapters(chapterIdsToDelete).response.catch(() => {});
}
if (key === 'markPrevRead') {
const index = chapterIds.findIndex((chapterId) => chapterId === chapter.id);
requestManager.updateChapters(chapterIds.slice(index), { isRead: true });
const index = allChapters.findIndex(({ id: chapterId }) => chapterId === chapter.id);
requestManager.updateChapters(
allChapters
.slice(index)
.filter(({ isRead }) => !isRead)
.map(({ id: chapterId }) => chapterId),
{ isRead: true },
);
return;
}

View File

@@ -22,6 +22,7 @@ import { ChaptersToolbarMenu } from '@/components/manga/ChaptersToolbarMenu';
import { SelectionFAB } from '@/components/manga/SelectionFAB';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab';
import { DownloadType, UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
const StyledVirtuoso = styled(Virtuoso)(({ theme }) => ({
listStyle: 'none',
@@ -89,7 +90,8 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
const [options, dispatch] = useChapterOptions(manga.id);
const { data: chaptersData, loading: isLoading, refetch } = requestManager.useGetMangaChapters(manga.id);
const chapters = useMemo(() => chaptersData?.chapters.nodes ?? [], [chaptersData?.chapters.nodes]);
const mangaChapterIds = useMemo(() => chapters.map((chapter) => chapter.id), [chapters]);
const { settings: metadataServerSettings } = useMetadataServerSettings();
useEffect(() => {
if (prevQueueRef.current && queue) {
@@ -164,6 +166,20 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
} else {
actionPromise = requestManager.updateChapters(chapterIds, change).response;
}
const shouldDeleteChapters =
action === 'mark_as_read' && metadataServerSettings.deleteChaptersManuallyMarkedRead;
if (shouldDeleteChapters) {
const chapterIdsToDelete = actionChapters
.filter(
({ chapter }) =>
chapter.isDownloaded &&
(!chapter.isBookmarked || metadataServerSettings.deleteChaptersWithBookmark),
)
.map(({ chapter }) => chapter.id);
requestManager.deleteDownloadedChapters(chapterIdsToDelete).response.catch(() => {});
}
}
actionPromise
@@ -285,7 +301,7 @@ export const ChapterList: React.FC<IProps> = ({ manga, isRefreshing }) => {
return (
<ChapterCard
{...chaptersWithMeta[index]}
chapterIds={mangaChapterIds}
allChapters={chapters}
showChapterNumber={options.showChapterNumber}
onSelect={() => handleSelection(index)}
/>

View File

@@ -2285,6 +2285,13 @@ export type StopDownloaderMutationVariables = Exact<{
export type StopDownloaderMutation = { __typename?: 'Mutation', stopDownloader: { __typename?: 'StopDownloaderPayload', clientMutationId?: string | null, downloadStatus: { __typename?: 'DownloadStatus', state: DownloaderState, queue: Array<{ __typename?: 'DownloadType', progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeInUpdate, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean } | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } }> } } };
export type DownloadAheadMutationVariables = Exact<{
input: DownloadAheadInput;
}>;
export type DownloadAheadMutation = { __typename?: 'Mutation', downloadAhead: { __typename?: 'DownloadAheadPayload', clientMutationId?: string | null } };
export type GetExtensionsFetchMutationVariables = Exact<{
input?: InputMaybe<FetchExtensionsInput>;
}>;

View File

@@ -128,3 +128,11 @@ export const STOP_DOWNLOADER = gql`
}
}
`;
export const DOWNLOAD_AHEAD = gql`
mutation DOWNLOAD_AHEAD($input: DownloadAheadInput!) {
downloadAhead(input: $input) {
clientMutationId
}
}
`;

View File

@@ -49,6 +49,8 @@ import {
DequeueChapterDownloadMutationVariables,
DequeueChapterDownloadsMutation,
DequeueChapterDownloadsMutationVariables,
DownloadAheadMutation,
DownloadAheadMutationVariables,
DownloadStatusSubscription,
DownloadStatusSubscriptionVariables,
EnqueueChapterDownloadMutation,
@@ -171,6 +173,7 @@ import {
DELETE_DOWNLOADED_CHAPTERS,
DEQUEUE_CHAPTER_DOWNLOAD,
DEQUEUE_CHAPTER_DOWNLOADS,
DOWNLOAD_AHEAD,
ENQUEUE_CHAPTER_DOWNLOAD,
ENQUEUE_CHAPTER_DOWNLOADS,
REORDER_CHAPTER_DOWNLOAD,
@@ -1909,6 +1912,12 @@ export class RequestManager {
): AbortableApolloUseMutationResponse<UpdateServerSettingsMutation, UpdateServerSettingsMutationVariables> {
return this.doRequest(GQLMethod.USE_MUTATION, UPDATE_SERVER_SETTINGS, undefined, options);
}
public useDownloadAhead(
options?: MutationHookOptions<DownloadAheadMutation, DownloadAheadMutationVariables>,
): AbortableApolloUseMutationResponse<DownloadAheadMutation, DownloadAheadMutationVariables> {
return this.doRequest(GQLMethod.USE_MUTATION, DOWNLOAD_AHEAD, undefined, options);
}
}
export const requestManager = new RequestManager();

View File

@@ -28,6 +28,8 @@ import { ReaderNavBar } from '@/components/navbar/ReaderNavBar';
import { makeToast } from '@/components/util/Toast';
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
import { useDebounce } from '@/components/manga/hooks.ts';
import { UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
const isDupChapter = async (chapterIndex: number, currentChapter: TChapter) => {
const nextChapter = await requestManager.getChapter(currentChapter.manga.id, chapterIndex).response;
@@ -121,6 +123,10 @@ export function Reader() {
});
const [arePagesUpdated, setArePagesUpdated] = useState(false);
const { data: settingsData } = requestManager.useGetServerSettings();
const isDownloadAheadEnabled = !!settingsData?.settings.autoDownloadAheadLimit;
const [downloadAhead] = requestManager.useDownloadAhead();
const getLoadedChapter = () => {
const isAChapterLoaded = loadedChapter.current;
@@ -168,6 +174,34 @@ export function Reader() {
const { settings: defaultSettings, loading: areDefaultSettingsLoading } = useDefaultReaderSettings();
const [settings, setSettings] = useState(getReaderSettingsFor(manga, defaultSettings));
const { settings: metadataSettings } = useMetadataServerSettings();
const updateChapter = (patch: UpdateChapterPatchInput) => {
requestManager.updateChapter(chapter.id, patch).response.catch(() => {});
const shouldDeleteChapter =
patch.isRead &&
metadataSettings.deleteChaptersAutoMarkedRead &&
chapter.isDownloaded &&
(!chapter.isBookmarked || metadataSettings.deleteChaptersWithBookmark);
if (shouldDeleteChapter) {
requestManager.deleteDownloadedChapter(chapter.id).response.catch(() => {});
}
const shouldDownloadAhead =
chapter.manga.inLibrary && !chapter.isRead && patch.isRead && isDownloadAheadEnabled;
if (shouldDownloadAhead) {
downloadAhead({
variables: {
input: {
mangaIds: [chapter.manga.id],
latestReadChapterIds: [chapter.id],
},
},
}).catch(() => {});
}
};
const setSettingValue = (key: keyof IReaderSettings, value: string | boolean) => {
setSettings({ ...settings, [key]: value });
requestUpdateMangaMetadata(manga, [[key, value]]).catch(() =>
@@ -255,31 +289,42 @@ export function Reader() {
// do not mutate the chapter, this will cause the page to jump around due to always scrolling to the last read page
const updateLastPageRead = curPageDebounced !== -1;
const updateIsRead = curPageDebounced === chapter.pageCount - 1;
const updateChapter = updateLastPageRead || updateIsRead;
if (updateChapter) {
requestManager.updateChapter(chapter.id, {
lastPageRead: updateLastPageRead ? curPageDebounced : undefined,
isRead: updateIsRead ? true : undefined,
});
const shouldUpdateChapter = updateLastPageRead || updateIsRead;
if (!shouldUpdateChapter) {
return;
}
}, [curPageDebounced]);
updateChapter({
lastPageRead: updateLastPageRead ? curPageDebounced : undefined,
isRead: updateIsRead ? true : undefined,
});
}, [curPageDebounced, isDownloadAheadEnabled]);
const nextChapter = useCallback(() => {
if (chapter.sourceOrder < manga.chapters.totalCount) {
requestManager.updateChapter(chapter.id, {
lastPageRead: chapter.pageCount - 1,
isRead: true,
});
openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) =>
navigate(`/manga/${manga.id}/chapter/${nextChapterIndex}`, {
replace: true,
state: location.state,
}),
);
const doesNextChapterExist = chapter.sourceOrder < manga.chapters.totalCount;
if (!doesNextChapterExist) {
return;
}
}, [chapter.sourceOrder, manga.chapters.totalCount, chapter.pageCount, manga.id, settings.skipDupChapters]);
updateChapter({
lastPageRead: chapter.pageCount - 1,
isRead: true,
});
openNextChapter(ChapterOffset.NEXT, (nextChapterIndex) =>
navigate(`/manga/${manga.id}/chapter/${nextChapterIndex}`, {
replace: true,
state: location.state,
}),
);
}, [
chapter.sourceOrder,
manga.chapters.totalCount,
chapter.pageCount,
manga.id,
settings.skipDupChapters,
isDownloadAheadEnabled,
]);
const prevChapter = useCallback(() => {
if (chapter.sourceOrder > 1) {

View File

@@ -14,9 +14,12 @@ import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
import ListSubheader from '@mui/material/ListSubheader';
import { TextSetting } from '@/components/settings/TextSetting.tsx';
import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx';
import { ServerSettings } from '@/typings.ts';
import { MetadataServerSettingKeys, MetadataServerSettings, ServerSettings } from '@/typings.ts';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { DownloadAheadSetting } from '@/components/settings/downloads/DownloadAheadSetting.tsx';
import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts';
import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.ts';
import { makeToast } from '@/components/util/Toast.tsx';
type DownloadSettingsType = Pick<
ServerSettings,
@@ -49,12 +52,28 @@ export const DownloadSettings = () => {
const { data } = requestManager.useGetServerSettings();
const downloadSettings = data ? extractDownloadSettings(data.settings) : undefined;
const [mutateSettings] = requestManager.useUpdateServerSettings();
const { metadata, settings: metadataSettings } = useMetadataServerSettings();
const updateSetting = <Setting extends keyof DownloadSettingsType>(
setting: Setting,
value: DownloadSettingsType[Setting],
) => {
mutateSettings({ variables: { input: { settings: { [setting]: value } } } });
mutateSettings({ variables: { input: { settings: { [setting]: value } } } }).catch(() =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error'),
);
};
const updateMetadataSetting = <Setting extends MetadataServerSettingKeys>(
setting: Setting,
value: MetadataServerSettings[Setting],
) => {
if (!metadata) {
return;
}
requestUpdateServerMetadata(convertToGqlMeta(metadata) ?? [], [[setting, value]]).catch(() =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error'),
);
};
return (
@@ -75,6 +94,46 @@ export const DownloadSettings = () => {
/>
</ListItemSecondaryAction>
</ListItem>
<List
subheader={
<ListSubheader component="div" id="download-settings-auto-download">
Delete chapters
</ListSubheader>
}
>
<ListItem>
<ListItemText primary="Delete chapter after manually marking it as read" />
<ListItemSecondaryAction>
<Switch
edge="end"
checked={metadataSettings.deleteChaptersManuallyMarkedRead}
onChange={(e) =>
updateMetadataSetting('deleteChaptersManuallyMarkedRead', e.target.checked)
}
/>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemText primary="Delete finished chapters while reading" />
<ListItemSecondaryAction>
<Switch
edge="end"
checked={metadataSettings.deleteChaptersAutoMarkedRead}
onChange={(e) => updateMetadataSetting('deleteChaptersAutoMarkedRead', e.target.checked)}
/>
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemText primary="Allow deleting bookmarked chapters" />
<ListItemSecondaryAction>
<Switch
edge="end"
checked={metadataSettings.deleteChaptersWithBookmark}
onChange={(e) => updateMetadataSetting('deleteChaptersWithBookmark', e.target.checked)}
/>
</ListItemSecondaryAction>
</ListItem>
</List>
<List
subheader={
<ListSubheader component="div" id="download-settings-auto-download">

View File

@@ -88,11 +88,13 @@ export type MetadataHolder<Keys extends string = string, Values = string> = {
export type AllowedMetadataValueTypes = string | boolean | number | undefined;
export type MetadataServerSettingKeys = keyof MetadataServerSettings;
export type MangaMetadataKeys = keyof IReaderSettings;
export type SearchMetadataKeys = keyof ISearchSettings;
export type AppMetadataKeys = MangaMetadataKeys | SearchMetadataKeys;
export type AppMetadataKeys = MetadataServerSettingKeys | MangaMetadataKeys | SearchMetadataKeys;
export type MetadataKeyValuePair = [AppMetadataKeys, AllowedMetadataValueTypes];
@@ -226,6 +228,12 @@ export enum ChapterOffset {
NEXT = 1,
}
export type MetadataServerSettings = {
deleteChaptersManuallyMarkedRead: boolean;
deleteChaptersAutoMarkedRead: boolean;
deleteChaptersWithBookmark: boolean;
};
export interface ISearchSettings {
ignoreFilters: boolean;
}

View File

@@ -344,7 +344,7 @@ export const requestUpdateMetadataValue = async (
export const requestUpdateMetadata = async (
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
keysToValues: [AppMetadataKeys, AllowedMetadataValueTypes][],
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> =>
Promise.all(keysToValues.map(([key, value]) => requestUpdateMetadataValue(metadataHolder, holderType, key, value)));

View File

@@ -0,0 +1,34 @@
/*
* 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 { Metadata, MetadataServerSettings } from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { convertFromGqlMeta, getMetadataFrom } from '@/util/metadata';
export const getDefaultSettings = (): MetadataServerSettings => ({
deleteChaptersManuallyMarkedRead: false,
deleteChaptersAutoMarkedRead: false,
deleteChaptersWithBookmark: false,
});
const getMetadataServerSettingsWithDefaultFallback = (
meta?: Metadata,
defaultSettings: MetadataServerSettings = getDefaultSettings(),
applyMetadataMigration: boolean = true,
): MetadataServerSettings => getMetadataFrom({ meta }, defaultSettings, applyMetadataMigration);
export const useMetadataServerSettings = (): {
metadata?: Metadata;
settings: MetadataServerSettings;
loading: boolean;
} => {
const { data, loading } = requestManager.useGetGlobalMeta();
const metadata = convertFromGqlMeta(data?.metas.nodes);
const settings = getMetadataServerSettingsWithDefaultFallback(metadata);
return { metadata, settings, loading };
};