Optionally select bulk migration match based on highest chapter number

This commit is contained in:
schroda
2026-05-08 15:12:02 +02:00
parent 6b234ff9bc
commit 67bb2248c4
6 changed files with 197 additions and 38 deletions

View File

@@ -37,6 +37,7 @@ export const DEFAULT_MIGRATION_STATE: MigrationState = {
sourceIds: null,
entries: {},
destinationSourceIds: [],
searchOptions: null,
migrateOptions: null,
searchProgress: { total: 0, completed: 0, success: 0, failed: 0 },
migrationProgress: { total: 0, completed: 0, success: 0, failed: 0 },

View File

@@ -58,6 +58,10 @@ export type MetadataMigrationSettings = {
migrateSortSettings: SortSettings;
};
export interface MigrationBulkSearchSettings {
selectHighestChapterNumberSource: boolean;
}
export enum MigrationPhase {
IDLE = 'idle',
SELECT_SOURCE = 'select_source',
@@ -114,6 +118,7 @@ export interface MigrationState {
sourceIds: SourceIdInfo['id'][] | null;
entries: Record<MangaIdInfo['id'], TMigrationEntry>;
destinationSourceIds: SourceIdInfo['id'][];
searchOptions: MigrationBulkSearchSettings | null;
migrateOptions: Omit<MigrateOptions, 'mangaIdToMigrateTo'> | null;
searchProgress: MigrationProgress;
migrationProgress: MigrationProgress;

View File

@@ -14,6 +14,7 @@ import { devtools, persist } from 'zustand/middleware';
import {
type MigratableEntry,
type MigrateOptions,
type MigrationBulkSearchSettings,
MigrationEntryStatus,
type MigrationMatch,
MigrationPhase,
@@ -330,27 +331,28 @@ export class MigrationManager {
});
}
static async startSearch(destinationSourceIds: SourceIdInfo['id'][]): Promise<void> {
static async startSearch(
destinationSourceIds: SourceIdInfo['id'][],
options: MigrationBulkSearchSettings,
): Promise<void> {
MigrationManager.ensureIsInValidPhase([MigrationPhase.SELECTING_SOURCES]);
MigrationManager.updateState((draft) => {
draft.destinationSourceIds = destinationSourceIds;
});
const state = MigrationManager.getState();
const entryIds = Object.keys(state.entries).map(Number);
MigrationManager.updateState((draft) => {
draft.phase = MigrationPhase.SEARCHING;
draft.destinationSourceIds = destinationSourceIds;
draft.searchOptions = options;
draft.searchProgress = { total: entryIds.length, completed: 0, success: 0, failed: 0 };
draft.startedAt = Date.now();
draft.groupExpandState = MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE;
});
await MigrationManager.search(Object.values(state.entries));
await MigrationManager.search(Object.values(state.entries), options);
}
private static async search(entries: TMigrationEntry[]): Promise<void> {
private static async search(entries: TMigrationEntry[], options: MigrationBulkSearchSettings): Promise<void> {
const { signal } = MigrationManager.abortAndCreateAbortController('search');
const searchPromises = entries.map((entry) =>
@@ -359,7 +361,7 @@ export class MigrationManager {
return;
}
await MigrationManager.searchForManga(entry.mangaId, entry.mangaTitle, signal);
await MigrationManager.searchForManga(entry.mangaId, entry.mangaTitle, signal, options);
}),
);
@@ -481,16 +483,16 @@ export class MigrationManager {
}
static async resume(): Promise<void> {
const state = MigrationManager.getState();
const { phase, migrateOptions, searchOptions, entries } = MigrationManager.getState();
const isResumeablePhase = RESUMABLE_PHASES.includes(state.phase);
const isResumeablePhase = RESUMABLE_PHASES.includes(phase);
if (!isResumeablePhase) {
return;
}
const resumeMigrationPhase = state.phase === MigrationPhase.MIGRATING && state.migrateOptions;
const resumeMigrationPhase = phase === MigrationPhase.MIGRATING && migrateOptions;
if (resumeMigrationPhase) {
assertIsDefined(state.migrateOptions);
assertIsDefined(migrateOptions);
const migratableEntries = MigrationManager.getMigratableEntries();
@@ -500,12 +502,14 @@ export class MigrationManager {
});
});
await MigrationManager.migrate(migratableEntries, state.migrateOptions);
await MigrationManager.migrate(migratableEntries, migrateOptions);
return;
}
const pendingEntries = Object.values(state.entries).filter((entry) =>
assertIsDefined(searchOptions);
const pendingEntries = Object.values(entries).filter((entry) =>
[MigrationEntryStatus.PENDING, MigrationEntryStatus.SEARCHING].includes(entry.status),
);
@@ -515,7 +519,7 @@ export class MigrationManager {
});
});
await MigrationManager.search(pendingEntries);
await MigrationManager.search(pendingEntries, searchOptions);
}
static reset(): void {
@@ -658,6 +662,7 @@ export class MigrationManager {
mangaTitle: string,
sourceId: SourceIdInfo['id'],
signal: AbortSignal,
{ selectHighestChapterNumberSource }: MigrationBulkSearchSettings,
): Promise<MangaMigrationFieldsFragment[]> {
if (signal.aborted) {
throw new Error(signal.reason);
@@ -668,7 +673,7 @@ export class MigrationManager {
throw new Error(signal.reason);
}
if (MigrationManager.hasHigherSourcePriorityMatch(mangaId, sourceId)) {
if (!selectHighestChapterNumberSource && MigrationManager.hasHigherSourcePriorityMatch(mangaId, sourceId)) {
throw new Error('Entry already has a selected match from a higher priority source');
}
@@ -723,7 +728,10 @@ export class MigrationManager {
mangaId: MangaIdInfo['id'],
mangaTitle: string,
mainSignal: AbortSignal,
options: MigrationBulkSearchSettings,
): Promise<void> {
const { selectHighestChapterNumberSource } = options;
const state = MigrationManager.getState();
const entry = state.entries[mangaId];
@@ -745,7 +753,10 @@ export class MigrationManager {
return null;
}
if (MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)) {
if (
!selectHighestChapterNumberSource &&
MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)
) {
return null;
}
@@ -756,6 +767,7 @@ export class MigrationManager {
mangaTitle,
destSourceId,
signal,
options,
);
} catch (e) {
MigrationManager.updateState((draft) => {
@@ -780,6 +792,9 @@ export class MigrationManager {
MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId];
const draftMatchEntry = draftEntry.selectedMatchMangaId
? draft.entries[draftEntry.selectedMatchMangaId]
: null;
const matches = foundMatches.map((manga) => ({
id: manga.id,
@@ -793,10 +808,8 @@ export class MigrationManager {
}));
draftEntry.destSourceIdToSearchState[destSourceId] = true;
draftEntry.searchMatches = [...draftEntry.searchMatches, ...matches];
if (!MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)) {
const matchesByChapterNumber = Object.groupBy(
matches,
(match) => match.latestChapterNumber ?? -1,
@@ -808,11 +821,40 @@ export class MigrationManager {
assertIsDefined(bestMatch);
const entryLatestChapterNumber = draftEntry.latestChapterNumber ?? Number.MIN_SAFE_INTEGER;
const selectedMatchLatestChapterNumber =
draftMatchEntry?.latestChapterNumber ?? Number.MIN_SAFE_INTEGER;
const hasNewerChapterVsEntry = latestChapterNumber > entryLatestChapterNumber;
const hasNewerChapterVsSelectedMatch = latestChapterNumber > selectedMatchLatestChapterNumber;
const hasNewerChapter = hasNewerChapterVsEntry && hasNewerChapterVsSelectedMatch;
const hasSameLatestChapterAsSelectedMatch =
!!draftMatchEntry && selectedMatchLatestChapterNumber === latestChapterNumber;
const hasHigherSourcePriority = !MigrationManager.hasHigherSourcePriorityMatch(
mangaId,
destSourceId,
);
const isPreferredSourcePriorityMatch =
hasHigherSourcePriority && !selectHighestChapterNumberSource;
const isPreferredChapterNumberMatch =
selectHighestChapterNumberSource &&
(hasNewerChapter ||
(hasHigherSourcePriority && hasSameLatestChapterAsSelectedMatch) ||
!draftMatchEntry);
const isPreferredMatch = isPreferredSourcePriorityMatch || isPreferredChapterNumberMatch;
if (isPreferredMatch) {
draftEntry.selectedMatchMangaId = bestMatch.id;
draftEntry.selectedMatchSourceId = destSourceId;
}
if (!MigrationManager.isHigherPrioritySourceUnsettled(mangaId, destSourceId)) {
if (
!selectHighestChapterNumberSource &&
!MigrationManager.isHigherPrioritySourceUnsettled(mangaId, destSourceId)
) {
searchController.abort(`Found best match in source "${destSourceId}"`);
}
});
@@ -908,7 +950,8 @@ export class MigrationManager {
}
static async retryEntry(id: MangaIdInfo['id']): Promise<void> {
const entry = MigrationManager.getState().entries[id];
const { entries, migrateOptions, searchOptions } = MigrationManager.getState();
const entry = entries[id];
if (!entry) {
return;
@@ -923,8 +966,10 @@ export class MigrationManager {
draft.searchProgress.failed -= 1;
});
assertIsDefined(searchOptions);
await MigrationManager.mangaProcessQueue(() =>
MigrationManager.searchForManga(id, entry.mangaTitle, signal),
MigrationManager.searchForManga(id, entry.mangaTitle, signal, searchOptions),
);
return;
@@ -936,11 +981,10 @@ export class MigrationManager {
draft.migrationProgress.failed -= 1;
});
const migrationOptions = MigrationManager.getState().migrateOptions;
assertIsDefined(migrationOptions);
assertIsDefined(migrateOptions);
await MigrationManager.mangaProcessQueue(() =>
MigrationManager.migrateSingleEntry(id, migrationOptions, signal),
MigrationManager.migrateSingleEntry(id, migrateOptions, signal),
);
}
}

View File

@@ -0,0 +1,93 @@
/*
* 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 Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import { useLingui } from '@lingui/react/macro';
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
import type { AwaitableComponentProps } from 'awaitable-component';
import Typography from '@mui/material/Typography';
import WarningIcon from '@mui/icons-material/Warning';
import { useState } from 'react';
import type { MigrationBulkSearchSettings } from '@/features/migration/Migration.types.ts';
export const MigrationBulkSearchOptionsDialog = ({
isVisible,
onDismiss,
onSubmit,
onExitComplete,
}: AwaitableComponentProps<MigrationBulkSearchSettings>) => {
const { t } = useLingui();
const [selectHighestChapterNumberSource, setSelectHighestChapterNumberSource] = useState(false);
return (
<Dialog open={isVisible} fullWidth onClose={onDismiss} onTransitionExited={onExitComplete}>
<DialogTitle>{t`Search options`}</DialogTitle>
<DialogContent dividers>
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<WarningIcon color="warning" />
<Typography
variant="body1"
sx={{
marginLeft: '10px',
marginTop: '5px',
whiteSpace: 'pre-line',
}}
color="error"
>
{t`These options are slow and dangerous and may lead to restrictions from sources`}
</Typography>
</Stack>
<CheckboxInput
label={
<Stack
sx={{
// Padding comes from the MUI Checkbox component
pt: '9px',
}}
>
<Typography>{t`Match based on chapter number`}</Typography>
<Typography
variant="body2"
color="textSecondary"
>{t`If enabled, chooses the match furthest ahead.\nOtherwise, picks the first match by source priority.`}</Typography>
</Stack>
}
sx={{
alignItems: 'start',
}}
checked={selectHighestChapterNumberSource}
onChange={(_, checked) => setSelectHighestChapterNumberSource(checked)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onDismiss}>{t`Cancel`}</Button>
<Button
variant="contained"
onClick={() =>
onSubmit({
selectHighestChapterNumberSource,
})
}
>
{t`Search`}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -28,6 +28,8 @@ import { Sources } from '@/features/source/services/Sources.ts';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { MigrationBulkSearchOptionsDialog } from '@/features/migration/components/MigrationBulkSearchOptionsDialog.tsx';
import { AwaitableComponent } from 'awaitable-component';
export const MigrationSelectDestinationSources = () => {
const { t } = useLingui();
@@ -168,7 +170,15 @@ export const MigrationSelectDestinationSources = () => {
bottom: (theme) => theme.spacing(2),
right: (theme) => theme.spacing(2),
}}
onClick={() => MigrationManager.startSearch(selectedItemIds)}
onClick={async () => {
try {
const searchOptions = await AwaitableComponent.show(MigrationBulkSearchOptionsDialog);
await MigrationManager.startSearch(selectedItemIds, searchOptions);
} catch (e) {
// Ignore
}
}}
>
{t`Start Search`}
</Fab>

View File

@@ -670,6 +670,7 @@ msgstr "Call timeout"
#: src/features/category/components/CategoriesInclusionSetting.tsx
#: src/features/category/components/CategorySelect.tsx
#: src/features/category/components/CreateOrEditCategoryDialog.tsx
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
#: src/features/migration/components/MigrationOptionsDialog.tsx
#: src/features/reader/hotkeys/settings/components/RecordHotkey.tsx
#: src/features/settings/components/globalUpdate/GlobalUpdateSettingsEntries.tsx
@@ -1802,7 +1803,7 @@ msgstr "How many chapters should get downloaded while reading."
msgid "How much time FlareSolverr has to handle the request"
msgstr "How much time FlareSolverr has to handle the request"
#: src/features/migration/components/MigrationOptionsDialog.tsx
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
msgid ""
"If enabled, chooses the match furthest ahead.\n"
"Otherwise, picks the first match by source priority."
@@ -2250,7 +2251,7 @@ msgstr "Mark selected as read"
msgid "Mark selected as unread"
msgstr "Mark selected as unread"
#: src/features/migration/components/MigrationOptionsDialog.tsx
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
msgid "Match based on chapter number"
msgstr "Match based on chapter number"
@@ -2930,6 +2931,7 @@ msgid "Scroll forward"
msgstr "Scroll forward"
#: src/base/components/AppbarSearch.tsx
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
msgid "Search"
msgstr "Search"
@@ -2954,6 +2956,10 @@ msgstr ""
"Search for a ID via \"id:<ID>\" (e.g. \"id:13\")\n"
"Limit search to your lists via \"my:<Title>\" (e.g. \"my:One Piece\")"
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
msgid "Search options"
msgstr "Search options"
#: src/features/settings/components/images/Processing.tsx
msgid "Search parameters"
msgstr "Search parameters"
@@ -3483,7 +3489,7 @@ msgstr "There is no next chapter"
msgid "There is no previous chapter"
msgstr "There is no previous chapter"
#: src/features/migration/components/MigrationOptionsDialog.tsx
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
msgid "These options are slow and dangerous and may lead to restrictions from sources"
msgstr "These options are slow and dangerous and may lead to restrictions from sources"