Skip to content

Commit

Permalink
Feature/download ahead while reading (#464)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
schroda authored Nov 19, 2023
1 parent e26907e commit d12ac27
Show file tree
Hide file tree
Showing 10 changed files with 240 additions and 31 deletions.
31 changes: 27 additions & 4 deletions src/components/manga/ChapterCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>) => {
Expand All @@ -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;
}

Expand Down
20 changes: 18 additions & 2 deletions src/components/manga/ChapterList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)}
/>
Expand Down
7 changes: 7 additions & 0 deletions src/lib/graphql/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}>;
Expand Down
8 changes: 8 additions & 0 deletions src/lib/graphql/mutations/DownloaderMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,11 @@ export const STOP_DOWNLOADER = gql`
}
}
`;

export const DOWNLOAD_AHEAD = gql`
mutation DOWNLOAD_AHEAD($input: DownloadAheadInput!) {
downloadAhead(input: $input) {
clientMutationId
}
}
`;
9 changes: 9 additions & 0 deletions src/lib/requests/RequestManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import {
DequeueChapterDownloadMutationVariables,
DequeueChapterDownloadsMutation,
DequeueChapterDownloadsMutationVariables,
DownloadAheadMutation,
DownloadAheadMutationVariables,
DownloadStatusSubscription,
DownloadStatusSubscriptionVariables,
EnqueueChapterDownloadMutation,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
87 changes: 66 additions & 21 deletions src/screens/Reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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(() =>
Expand Down Expand Up @@ -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) {
Expand Down
63 changes: 61 additions & 2 deletions src/screens/settings/DownloadSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -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">
Expand Down
10 changes: 9 additions & 1 deletion src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -226,6 +228,12 @@ export enum ChapterOffset {
NEXT = 1,
}

export type MetadataServerSettings = {
deleteChaptersManuallyMarkedRead: boolean;
deleteChaptersAutoMarkedRead: boolean;
deleteChaptersWithBookmark: boolean;
};

export interface ISearchSettings {
ignoreFilters: boolean;
}
Expand Down
Loading

0 comments on commit d12ac27

Please sign in to comment.