diff --git a/src/components/manga/ChapterCard.tsx b/src/components/manga/ChapterCard.tsx index 877b294e..818f49f4 100644 --- a/src/components/manga/ChapterCard.tsx +++ b/src/components/manga/ChapterCard.tsx @@ -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 = (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); const handleMenuClick = (event: React.MouseEvent) => { @@ -67,9 +70,29 @@ export const ChapterCard: React.FC = (props: IProps) => { const sendChange = (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; } diff --git a/src/components/manga/ChapterList.tsx b/src/components/manga/ChapterList.tsx index e5efd95b..5b2f35f9 100644 --- a/src/components/manga/ChapterList.tsx +++ b/src/components/manga/ChapterList.tsx @@ -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 = ({ 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 = ({ 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 = ({ manga, isRefreshing }) => { return ( handleSelection(index)} /> diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index 7c88c79a..ff4688fb 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -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, 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; }>; diff --git a/src/lib/graphql/mutations/DownloaderMutation.ts b/src/lib/graphql/mutations/DownloaderMutation.ts index 1a8e31c5..49b0db09 100644 --- a/src/lib/graphql/mutations/DownloaderMutation.ts +++ b/src/lib/graphql/mutations/DownloaderMutation.ts @@ -128,3 +128,11 @@ export const STOP_DOWNLOADER = gql` } } `; + +export const DOWNLOAD_AHEAD = gql` + mutation DOWNLOAD_AHEAD($input: DownloadAheadInput!) { + downloadAhead(input: $input) { + clientMutationId + } + } +`; diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index b4487118..2d028aca 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -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 { return this.doRequest(GQLMethod.USE_MUTATION, UPDATE_SERVER_SETTINGS, undefined, options); } + + public useDownloadAhead( + options?: MutationHookOptions, + ): AbortableApolloUseMutationResponse { + return this.doRequest(GQLMethod.USE_MUTATION, DOWNLOAD_AHEAD, undefined, options); + } } export const requestManager = new RequestManager(); diff --git a/src/screens/Reader.tsx b/src/screens/Reader.tsx index fb320a0e..57729190 100644 --- a/src/screens/Reader.tsx +++ b/src/screens/Reader.tsx @@ -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) { diff --git a/src/screens/settings/DownloadSettings.tsx b/src/screens/settings/DownloadSettings.tsx index 7ebb0c7e..14105c04 100644 --- a/src/screens/settings/DownloadSettings.tsx +++ b/src/screens/settings/DownloadSettings.tsx @@ -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: 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: 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 = () => { /> + + Delete chapters + + } + > + + + + + updateMetadataSetting('deleteChaptersManuallyMarkedRead', e.target.checked) + } + /> + + + + + + updateMetadataSetting('deleteChaptersAutoMarkedRead', e.target.checked)} + /> + + + + + + updateMetadataSetting('deleteChaptersWithBookmark', e.target.checked)} + /> + + + diff --git a/src/typings.ts b/src/typings.ts index 1639edd8..b5650b20 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -88,11 +88,13 @@ export type MetadataHolder = { 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; } diff --git a/src/util/metadata.ts b/src/util/metadata.ts index 78847f59..c1bf83dd 100644 --- a/src/util/metadata.ts +++ b/src/util/metadata.ts @@ -344,7 +344,7 @@ export const requestUpdateMetadataValue = async ( export const requestUpdateMetadata = async ( metadataHolder: GqlMetaHolder, holderType: MetadataHolderType, - keysToValues: [AppMetadataKeys, AllowedMetadataValueTypes][], + keysToValues: MetadataKeyValuePair[], ): Promise => Promise.all(keysToValues.map(([key, value]) => requestUpdateMetadataValue(metadataHolder, holderType, key, value))); diff --git a/src/util/metadataServerSettings.ts b/src/util/metadataServerSettings.ts new file mode 100644 index 00000000..a22d8b2f --- /dev/null +++ b/src/util/metadataServerSettings.ts @@ -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 }; +};