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

View File

@@ -14,6 +14,7 @@ import { devtools, persist } from 'zustand/middleware';
import { import {
type MigratableEntry, type MigratableEntry,
type MigrateOptions, type MigrateOptions,
type MigrationBulkSearchSettings,
MigrationEntryStatus, MigrationEntryStatus,
type MigrationMatch, type MigrationMatch,
MigrationPhase, 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.ensureIsInValidPhase([MigrationPhase.SELECTING_SOURCES]);
MigrationManager.updateState((draft) => {
draft.destinationSourceIds = destinationSourceIds;
});
const state = MigrationManager.getState(); const state = MigrationManager.getState();
const entryIds = Object.keys(state.entries).map(Number); const entryIds = Object.keys(state.entries).map(Number);
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
draft.phase = MigrationPhase.SEARCHING; draft.phase = MigrationPhase.SEARCHING;
draft.destinationSourceIds = destinationSourceIds;
draft.searchOptions = options;
draft.searchProgress = { total: entryIds.length, completed: 0, success: 0, failed: 0 }; draft.searchProgress = { total: entryIds.length, completed: 0, success: 0, failed: 0 };
draft.startedAt = Date.now(); draft.startedAt = Date.now();
draft.groupExpandState = MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE; 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 { signal } = MigrationManager.abortAndCreateAbortController('search');
const searchPromises = entries.map((entry) => const searchPromises = entries.map((entry) =>
@@ -359,7 +361,7 @@ export class MigrationManager {
return; 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> { 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) { if (!isResumeablePhase) {
return; return;
} }
const resumeMigrationPhase = state.phase === MigrationPhase.MIGRATING && state.migrateOptions; const resumeMigrationPhase = phase === MigrationPhase.MIGRATING && migrateOptions;
if (resumeMigrationPhase) { if (resumeMigrationPhase) {
assertIsDefined(state.migrateOptions); assertIsDefined(migrateOptions);
const migratableEntries = MigrationManager.getMigratableEntries(); const migratableEntries = MigrationManager.getMigratableEntries();
@@ -500,12 +502,14 @@ export class MigrationManager {
}); });
}); });
await MigrationManager.migrate(migratableEntries, state.migrateOptions); await MigrationManager.migrate(migratableEntries, migrateOptions);
return; 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), [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 { static reset(): void {
@@ -658,6 +662,7 @@ export class MigrationManager {
mangaTitle: string, mangaTitle: string,
sourceId: SourceIdInfo['id'], sourceId: SourceIdInfo['id'],
signal: AbortSignal, signal: AbortSignal,
{ selectHighestChapterNumberSource }: MigrationBulkSearchSettings,
): Promise<MangaMigrationFieldsFragment[]> { ): Promise<MangaMigrationFieldsFragment[]> {
if (signal.aborted) { if (signal.aborted) {
throw new Error(signal.reason); throw new Error(signal.reason);
@@ -668,7 +673,7 @@ export class MigrationManager {
throw new Error(signal.reason); 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'); throw new Error('Entry already has a selected match from a higher priority source');
} }
@@ -723,7 +728,10 @@ export class MigrationManager {
mangaId: MangaIdInfo['id'], mangaId: MangaIdInfo['id'],
mangaTitle: string, mangaTitle: string,
mainSignal: AbortSignal, mainSignal: AbortSignal,
options: MigrationBulkSearchSettings,
): Promise<void> { ): Promise<void> {
const { selectHighestChapterNumberSource } = options;
const state = MigrationManager.getState(); const state = MigrationManager.getState();
const entry = state.entries[mangaId]; const entry = state.entries[mangaId];
@@ -745,7 +753,10 @@ export class MigrationManager {
return null; return null;
} }
if (MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)) { if (
!selectHighestChapterNumberSource &&
MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)
) {
return null; return null;
} }
@@ -756,6 +767,7 @@ export class MigrationManager {
mangaTitle, mangaTitle,
destSourceId, destSourceId,
signal, signal,
options,
); );
} catch (e) { } catch (e) {
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
@@ -780,6 +792,9 @@ export class MigrationManager {
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId]; const draftEntry = draft.entries[mangaId];
const draftMatchEntry = draftEntry.selectedMatchMangaId
? draft.entries[draftEntry.selectedMatchMangaId]
: null;
const matches = foundMatches.map((manga) => ({ const matches = foundMatches.map((manga) => ({
id: manga.id, id: manga.id,
@@ -793,26 +808,53 @@ export class MigrationManager {
})); }));
draftEntry.destSourceIdToSearchState[destSourceId] = true; draftEntry.destSourceIdToSearchState[destSourceId] = true;
draftEntry.searchMatches = [...draftEntry.searchMatches, ...matches]; draftEntry.searchMatches = [...draftEntry.searchMatches, ...matches];
if (!MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)) { const matchesByChapterNumber = Object.groupBy(
const matchesByChapterNumber = Object.groupBy( matches,
matches, (match) => match.latestChapterNumber ?? -1,
(match) => match.latestChapterNumber ?? -1, );
); const latestChapterNumber = Math.max(
const latestChapterNumber = Math.max( ...Object.keys(matchesByChapterNumber).map((chapterNumber) => Number(chapterNumber)),
...Object.keys(matchesByChapterNumber).map((chapterNumber) => Number(chapterNumber)), );
); const bestMatch = matchesByChapterNumber[latestChapterNumber]?.[0];
const bestMatch = matchesByChapterNumber[latestChapterNumber]?.[0];
assertIsDefined(bestMatch); 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.selectedMatchMangaId = bestMatch.id;
draftEntry.selectedMatchSourceId = destSourceId; draftEntry.selectedMatchSourceId = destSourceId;
} }
if (!MigrationManager.isHigherPrioritySourceUnsettled(mangaId, destSourceId)) { if (
!selectHighestChapterNumberSource &&
!MigrationManager.isHigherPrioritySourceUnsettled(mangaId, destSourceId)
) {
searchController.abort(`Found best match in source "${destSourceId}"`); searchController.abort(`Found best match in source "${destSourceId}"`);
} }
}); });
@@ -908,7 +950,8 @@ export class MigrationManager {
} }
static async retryEntry(id: MangaIdInfo['id']): Promise<void> { 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) { if (!entry) {
return; return;
@@ -923,8 +966,10 @@ export class MigrationManager {
draft.searchProgress.failed -= 1; draft.searchProgress.failed -= 1;
}); });
assertIsDefined(searchOptions);
await MigrationManager.mangaProcessQueue(() => await MigrationManager.mangaProcessQueue(() =>
MigrationManager.searchForManga(id, entry.mangaTitle, signal), MigrationManager.searchForManga(id, entry.mangaTitle, signal, searchOptions),
); );
return; return;
@@ -936,11 +981,10 @@ export class MigrationManager {
draft.migrationProgress.failed -= 1; draft.migrationProgress.failed -= 1;
}); });
const migrationOptions = MigrationManager.getState().migrateOptions; assertIsDefined(migrateOptions);
assertIsDefined(migrationOptions);
await MigrationManager.mangaProcessQueue(() => 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 { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import ToggleOnIcon from '@mui/icons-material/ToggleOn'; import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { MigrationBulkSearchOptionsDialog } from '@/features/migration/components/MigrationBulkSearchOptionsDialog.tsx';
import { AwaitableComponent } from 'awaitable-component';
export const MigrationSelectDestinationSources = () => { export const MigrationSelectDestinationSources = () => {
const { t } = useLingui(); const { t } = useLingui();
@@ -168,7 +170,15 @@ export const MigrationSelectDestinationSources = () => {
bottom: (theme) => theme.spacing(2), bottom: (theme) => theme.spacing(2),
right: (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`} {t`Start Search`}
</Fab> </Fab>

View File

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