2026-03-13 22:41:28 +01:00
/*
* 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 type { LimitFunction } from 'p-limit' ;
import pLimit from 'p-limit' ;
import { create } from 'zustand' ;
import { immer } from 'zustand/middleware/immer' ;
import { devtools , persist } from 'zustand/middleware' ;
import {
type MigratableEntry ,
type MigrateOptions ,
2026-05-08 15:12:02 +02:00
type MigrationBulkSearchSettings ,
2026-03-13 22:41:28 +01:00
MigrationEntryStatus ,
type MigrationMatch ,
2026-04-23 15:18:35 +02:00
MigrationPhase ,
2026-03-13 22:41:28 +01:00
type MigrationState ,
2026-04-23 15:18:35 +02:00
type TMigrationEntry ,
2026-03-13 22:41:28 +01:00
} from '@/features/migration/Migration.types.ts' ;
import {
DEFAULT_MIGRATION_STATE ,
MAX_MANGAS_IN_PARALLEL ,
MAX_SOURCES_IN_PARALLEL ,
MIGRATE_EXECUTE_ENTRY_GROUP_EXPAND_DEFAULT_STATE ,
MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE ,
MIGRATION_LOCAL_STORAGE_KEY ,
} from '@/features/migration/Migration.constants.ts' ;
import { requestManager } from '@/lib/requests/RequestManager.ts' ;
import { GET_MIGRATION_SOURCE_MANGAS_FETCH } from '@/lib/graphql/source/SourceMutation.ts' ;
import type {
2026-05-17 13:19:21 +02:00
ChapterListFieldsFragment ,
2026-03-13 22:41:28 +01:00
GetMigrationSourceMangasFetchMutation ,
GetMigrationSourceMangasFetchMutationVariables ,
GetServerSettingsQuery ,
GetServerSettingsQueryVariables ,
MangaMigrationFieldsFragment ,
} from '@/lib/graphql/generated/graphql.ts' ;
2026-05-15 20:40:31 +02:00
import { FetchSourceMangaType } from '@/lib/graphql/generated/graphql-base.types.ts' ;
2026-03-13 22:41:28 +01:00
import { GET_SERVER_SETTINGS } from '@/lib/graphql/settings/SettingsQuery.ts' ;
import { MangaMigration } from '@/features/migration/MangaMigration.ts' ;
2026-05-15 20:40:31 +02:00
import type {
MangaArtistInfo ,
MangaAuthorInfo ,
MangaHighestChapterNumberInfo ,
MangaIdInfo ,
MangaSourceIdInfo ,
MangaSourceNameInfo ,
MangaThumbnailInfo ,
MangaTitleInfo ,
} from '@/features/manga/Manga.types.ts' ;
2026-03-13 22:41:28 +01:00
import type { SourceIdInfo } from '@/features/source/Source.types.ts' ;
import { assertIsDefined } from '@/base/Asserts.ts' ;
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts' ;
import { AppRoutes } from '@/base/AppRoute.constants.ts' ;
import { Confirmation } from '@/base/AppAwaitableComponent.ts' ;
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts' ;
import { t } from '@lingui/core/macro' ;
import { enhancedCleanup } from '@/base/utils/Strings.ts' ;
import { BrowseTab } from '@/features/browse/Browse.types.ts' ;
import { Mangas } from '@/features/manga/services/Mangas.ts' ;
import { MANGA_MIGRATION_FIELDS } from '@/lib/graphql/manga/MangaFragments.ts' ;
import { ZustandUtil } from '@/lib/zustand/ZustandUtil.ts' ;
import { getErrorMessage } from '@/lib/HelperFunctions.ts' ;
import isEqual from 'lodash/fp/isEqual' ;
2026-05-08 15:41:58 +02:00
import uniqBy from 'lodash/fp/uniqBy' ;
2026-05-16 02:35:25 +02:00
import { MigrationEntries } from '@/features/migration/MigrationEntries.ts' ;
2026-05-17 13:19:21 +02:00
import { Chapters } from '@/features/chapter/services/Chapters.ts' ;
2026-06-06 17:45:43 +02:00
import { AppSession } from '@/base/AppSession.ts' ;
import { ControlledPromise } from '@/lib/ControlledPromise.ts' ;
import { d } from 'koration' ;
import merge from 'lodash/fp/merge' ;
2026-03-13 22:41:28 +01:00
const RESUMABLE_PHASES : readonly MigrationPhase [] = [ MigrationPhase . SEARCHING , MigrationPhase . MIGRATING ];
2026-06-06 17:45:43 +02:00
let initialResume = true ;
2026-03-13 22:41:28 +01:00
const migrationStore = create < MigrationState >()(
devtools (
persist (
immer (() => ({ ... DEFAULT_MIGRATION_STATE })),
{
name : MIGRATION_LOCAL_STORAGE_KEY ,
merge : ( persistedState , currentState ) => {
const persisted = persistedState as MigrationState | undefined ;
2026-06-06 17:45:43 +02:00
if ( initialResume && ( ! persisted || ! RESUMABLE_PHASES . includes ( persisted . phase ))) {
initialResume = false ;
2026-03-13 22:41:28 +01:00
return currentState ;
}
2026-06-06 17:45:43 +02:00
return merge ( currentState , persisted );
2026-03-13 22:41:28 +01:00
},
},
),
),
);
const useMigrationStore = ZustandUtil . createStoreHook ( migrationStore );
2026-06-06 17:45:43 +02:00
window . addEventListener ( 'storage' , ( e ) => {
const isMigrationStateUpdate = e . key === MIGRATION_LOCAL_STORAGE_KEY ;
if ( isMigrationStateUpdate ) {
migrationStore . persist . rehydrate ();
}
});
2026-03-13 22:41:28 +01:00
export class MigrationManager {
private static abortController : AbortController | null = null ;
private static mangaProcessQueue = pLimit ( MAX_MANGAS_IN_PARALLEL );
private static parallelSourcesQueue : LimitFunction | undefined ;
2026-05-16 22:37:23 +02:00
private static queueBySource = new Map < SourceIdInfo [ 'id' ] , LimitFunction >();
2026-05-22 14:13:56 +02:00
private static abortControllerByManga = new Map < MangaIdInfo [ 'id' ] , AbortController >();
2026-03-13 22:41:28 +01:00
private static abortAndResetAbortController ( reason : unknown ) : void {
MigrationManager . abortController ? . abort ( reason );
MigrationManager . abortController = null ;
}
private static abortAndCreateAbortController ( abortReason : unknown ) : AbortController {
MigrationManager . abortController ? . abort ( abortReason );
MigrationManager . abortController = new AbortController ();
return MigrationManager . abortController ;
}
private static getOrCreateAbortController () : AbortController {
if ( ! MigrationManager . abortController ) {
MigrationManager . abortController = new AbortController ();
}
return MigrationManager . abortController ;
}
private static getParallelSourceQueue () : LimitFunction {
if ( MigrationManager . parallelSourcesQueue ) {
return MigrationManager . parallelSourcesQueue ;
}
try {
const result = requestManager . graphQLClient . client . readQuery <
GetServerSettingsQuery ,
GetServerSettingsQueryVariables
> ({
query : GET_SERVER_SETTINGS ,
});
MigrationManager . parallelSourcesQueue = pLimit (
result ? . settings . maxSourcesInParallel ?? MAX_SOURCES_IN_PARALLEL ,
);
} catch ( error ) {
MigrationManager . parallelSourcesQueue = pLimit ( MAX_SOURCES_IN_PARALLEL );
}
return MigrationManager . parallelSourcesQueue ! ;
}
private static getOrCreateSourceQueue ( sourceId : SourceIdInfo [ 'id' ]) : LimitFunction {
if ( this . queueBySource . has ( sourceId )) {
return this . queueBySource . get ( sourceId ) ! ;
}
const queue = pLimit ( 1 );
this . queueBySource . set ( sourceId , queue );
return queue ;
}
static async confirmAbort () : Promise < boolean > {
try {
return await Confirmation . show ({
title : t `Abort migration` ,
message : t `Are you sure you want to abort the migration?` ,
});
} catch ( e ) {
defaultPromiseErrorHandler ( 'MigrationManager::abort' )( e );
return false ;
}
}
2026-04-23 15:18:35 +02:00
private static ensureIsInValidPhase ( phases : MigrationPhase []) : void {
const { phase } = MigrationManager . getState ();
if ( ! phases . includes ( phase )) {
throw new Error ( `Illegal migration phase " ${ phase } ". Expected: ${ phases } ` );
}
}
2026-03-13 22:41:28 +01:00
static async goToPreviousPhase () : Promise < boolean > {
switch ( MigrationManager . getState (). phase ) {
case MigrationPhase.IDLE :
return true ;
case MigrationPhase.SELECT_SOURCE :
MigrationManager.updateState (( draft ) => {
draft . phase = MigrationPhase . IDLE ;
});
return true ;
case MigrationPhase.SELECT_MANGAS :
MigrationManager.updateState (( draft ) => {
draft . phase = MigrationPhase . SELECT_SOURCE ;
draft . entries = {};
});
ReactRouter . navigate ( AppRoutes . browse . path ( BrowseTab . MIGRATE ));
return false ;
case MigrationPhase.SELECTING_SOURCES :
MigrationManager.updateState (( draft ) => {
draft . phase = MigrationPhase . SELECT_MANGAS ;
draft . destinationSourceIds = [];
});
return false ;
default :
try {
await MigrationManager . abort ( 'goToPreviousPhase' );
return false ;
} catch ( e ) {
return false ;
}
}
}
static isPhaseComplete () : boolean {
const { phase } = MigrationManager . getState ();
switch ( phase ) {
case MigrationPhase.SEARCHING :
return (
MigrationManager . getState (). searchProgress . completed ===
MigrationManager . getState (). searchProgress . total
);
case MigrationPhase.MIGRATING :
return (
2026-05-13 01:36:33 +02:00
MigrationManager . getState (). isAborted ||
2026-03-13 22:41:28 +01:00
MigrationManager . getState (). migrationProgress . completed ===
2026-05-13 01:36:33 +02:00
MigrationManager . getState (). migrationProgress . total
2026-03-13 22:41:28 +01:00
);
default :
return false ;
}
}
private static getUpToDateSearchMatch ( searchMatch : MigrationMatch ) : MigrationMatch {
const cachedEntry = Mangas . getFromCache < MangaMigrationFieldsFragment >(
searchMatch . id ,
MANGA_MIGRATION_FIELDS ,
'MANGA_MIGRATION_FIELDS' ,
);
return {
id : cachedEntry?.id ?? searchMatch . id ,
title : cachedEntry?.title ?? searchMatch . title ,
thumbnailUrl : cachedEntry?.thumbnailUrl ?? searchMatch . thumbnailUrl ,
thumbnailUrlLastFetched : cachedEntry?.thumbnailUrlLastFetched ?? searchMatch . thumbnailUrlLastFetched ,
sourceId : cachedEntry?.sourceId ?? searchMatch . sourceId ,
artist : cachedEntry?.artist ?? searchMatch . artist ,
author : cachedEntry?.author ?? searchMatch . author ,
sourceTitle : cachedEntry?.source?.displayName ?? searchMatch . sourceTitle ,
latestChapterNumber : cachedEntry?.highestNumberedChapter?.chapterNumber ?? searchMatch . latestChapterNumber ,
2026-05-17 13:19:21 +02:00
missingChapters : searchMatch.missingChapters ,
2026-05-28 01:31:23 +02:00
inLibrary : cachedEntry?.inLibrary ?? searchMatch . inLibrary ,
2026-03-13 22:41:28 +01:00
};
}
static getUpToDateMigrationEntry ( entry : TMigrationEntry ) : TMigrationEntry {
const cachedEntry = Mangas . getFromCache < MangaMigrationFieldsFragment >(
entry . mangaId ,
MANGA_MIGRATION_FIELDS ,
'MANGA_MIGRATION_FIELDS' ,
);
const updatedEntry = {
mangaId : cachedEntry?.id ?? entry . mangaId ,
mangaTitle : cachedEntry?.title ?? entry . mangaTitle ,
mangaArtist : cachedEntry?.artist ?? entry . mangaArtist ,
mangaAuthor : cachedEntry?.author ?? entry . mangaAuthor ,
latestChapterNumber : cachedEntry?.highestNumberedChapter?.chapterNumber ?? entry . latestChapterNumber ,
2026-05-17 13:19:21 +02:00
missingChapters : entry.missingChapters ,
2026-03-13 22:41:28 +01:00
mangaThumbnailUrl : cachedEntry?.thumbnailUrl ?? entry . mangaThumbnailUrl ,
sourceId : cachedEntry?.sourceId ?? entry . sourceId ,
sourceTitle : cachedEntry?.source?.displayName ?? entry . sourceTitle ,
status : entry.status ,
searchMatches : entry.searchMatches.map ( MigrationManager . getUpToDateSearchMatch . bind ( MigrationManager )),
manualMatches : entry.manualMatches.map ( MigrationManager . getUpToDateSearchMatch . bind ( MigrationManager )),
selectedMatchMangaId : entry.selectedMatchMangaId ,
selectedMatchSourceId : entry.selectedMatchSourceId ,
destSourceIdToSearchState : entry.destSourceIdToSearchState ,
error : entry.error ,
isExcluded : entry.isExcluded ,
areMatchesExpanded : entry.areMatchesExpanded ,
2026-05-16 22:36:22 +02:00
isManualSelection : entry.isManualSelection ,
2026-03-13 22:41:28 +01:00
} satisfies TMigrationEntry ;
if ( isEqual ( entry , updatedEntry )) {
return entry ;
}
MigrationManager . updateState (( draft ) => {
draft . entries [ entry . mangaId ] = updatedEntry ;
});
return updatedEntry ;
}
2026-04-23 15:18:35 +02:00
static selectSources ( sourceIds : SourceIdInfo [ 'id' ][]) : void {
MigrationManager . ensureIsInValidPhase ([ MigrationPhase . IDLE ]);
2026-03-13 22:41:28 +01:00
MigrationManager . updateState (( draft ) => {
draft . phase = MigrationPhase . SELECT_MANGAS ;
2026-04-23 15:18:35 +02:00
draft . sourceIds = sourceIds ;
2026-03-13 22:41:28 +01:00
draft . entries = {};
});
}
2026-05-15 20:40:31 +02:00
static selectMangas (
mangas : ( MangaIdInfo &
MangaTitleInfo &
MangaArtistInfo &
MangaAuthorInfo &
MangaHighestChapterNumberInfo &
MangaThumbnailInfo &
MangaSourceIdInfo &
MangaSourceNameInfo )[],
) : void {
2026-04-23 15:18:35 +02:00
MigrationManager . ensureIsInValidPhase ([ MigrationPhase . SELECT_MANGAS ]);
const isSingleManga = mangas . length === 1 ;
if ( isSingleManga ) {
const [ manga ] = mangas ;
ReactRouter . navigate (
2026-05-28 00:01:54 +02:00
AppRoutes . migrate . children . singleMangaSearch . path ( manga . sourceId , manga . id , manga . title ),
2026-04-23 15:18:35 +02:00
{
2026-05-28 00:01:54 +02:00
state : AppRoutes.migrate.children.singleMangaSearch.state ({
2026-05-27 23:59:58 +02:00
title : t `Migrate " ${ manga . title } "` ,
2026-05-27 22:28:28 +02:00
mode : 'migrate.select.single' ,
2026-05-27 23:59:58 +02:00
}),
2026-04-23 15:18:35 +02:00
},
);
MigrationManager . reset ();
return ;
}
2026-03-13 22:41:28 +01:00
MigrationManager . updateState (( draft ) => {
draft . phase = MigrationPhase . SELECTING_SOURCES ;
draft . entries = Object . fromEntries (
mangas . map (( manga ) => [
manga . id ,
{
mangaId : manga.id ,
mangaTitle : manga.title ,
mangaArtist : manga.artist ,
mangaAuthor : manga.author ,
latestChapterNumber : manga.highestNumberedChapter?.chapterNumber ,
2026-05-17 13:19:21 +02:00
missingChapters : undefined ,
2026-03-13 22:41:28 +01:00
mangaThumbnailUrl : manga.thumbnailUrl ,
sourceId : manga.sourceId ,
sourceTitle : manga.source?.displayName ,
2026-05-22 14:13:56 +02:00
status : MigrationEntryStatus.SEARCH_PENDING ,
2026-03-13 22:41:28 +01:00
searchMatches : [],
manualMatches : [],
selectedMatchMangaId : null ,
selectedMatchSourceId : null ,
destSourceIdToSearchState : {},
isExcluded : false ,
areMatchesExpanded : false ,
error : undefined ,
2026-05-16 22:36:22 +02:00
isManualSelection : false ,
2026-03-13 22:41:28 +01:00
},
]),
);
});
}
2026-06-06 17:45:43 +02:00
private static async awaitUserConfirmation () : Promise < void > {
await Confirmation . show ({
title : t `Migration information` ,
message : AppSession.isSecureContext ()
? t `The migration runs on the client on the current device, NOT the server. \ nAs long as the client is open, the migration will run in the background. \ nThe client can be closed. The migration will be resumed once it gets opened again on the same device it got started on.`
: t `WebUI must be kept open. Migration can't be resumed` ,
actions : {
confirm : {
title : t `Understood` ,
},
},
});
}
2026-05-08 15:12:02 +02:00
static async startSearch (
destinationSourceIds : SourceIdInfo [ 'id' ][],
options : MigrationBulkSearchSettings ,
) : Promise < void > {
2026-04-23 15:18:35 +02:00
MigrationManager . ensureIsInValidPhase ([ MigrationPhase . SELECTING_SOURCES ]);
2026-06-06 17:45:43 +02:00
await MigrationManager . awaitUserConfirmation ();
2026-03-13 22:41:28 +01:00
const state = MigrationManager . getState ();
const entryIds = Object . keys ( state . entries ). map ( Number );
MigrationManager . updateState (( draft ) => {
draft . phase = MigrationPhase . SEARCHING ;
2026-05-08 15:12:02 +02:00
draft . destinationSourceIds = destinationSourceIds ;
draft . searchOptions = options ;
2026-03-13 22:41:28 +01:00
draft . searchProgress = { total : entryIds.length , completed : 0 , success : 0 , failed : 0 };
draft . startedAt = Date . now ();
draft . groupExpandState = MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE ;
});
2026-05-08 15:12:02 +02:00
await MigrationManager . search ( Object . values ( state . entries ), options );
2026-04-23 15:54:37 +02:00
}
2026-05-08 15:12:02 +02:00
private static async search ( entries : TMigrationEntry [], options : MigrationBulkSearchSettings ) : Promise < void > {
2026-04-23 15:54:37 +02:00
const { signal } = MigrationManager . abortAndCreateAbortController ( 'search' );
2026-05-22 14:13:56 +02:00
MigrationManager . updateState (( draft ) =>
entries . forEach (( entry ) => {
draft . entries [ entry . mangaId ]. status = MigrationEntryStatus . SEARCH_PENDING ;
}),
);
2026-04-23 15:54:37 +02:00
const searchPromises = entries . map (( entry ) =>
MigrationManager . mangaProcessQueue ( async () => {
if ( signal . aborted ) {
2026-05-22 14:13:56 +02:00
throw new Error ( signal . reason );
2026-04-23 15:54:37 +02:00
}
2026-05-08 15:12:02 +02:00
await MigrationManager . searchForManga ( entry . mangaId , entry . mangaTitle , signal , options );
2026-04-23 15:54:37 +02:00
}),
);
2026-03-13 22:41:28 +01:00
try {
2026-04-23 15:54:37 +02:00
await Promise . allSettled ( searchPromises );
2026-03-13 22:41:28 +01:00
} finally {
const { searchProgress } = MigrationManager . getState ();
if ( searchProgress . completed === searchProgress . total ) {
const allSearchesFailed = searchProgress . failed === searchProgress . total ;
MigrationManager . updateState (( draft ) => {
draft . groupExpandState = {
... MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE ,
[ MigrationEntryStatus . SEARCHING ] : false ,
[ MigrationEntryStatus . NO_MATCH ] : ! allSearchesFailed && ! searchProgress . success ,
2026-05-08 15:19:26 +02:00
[ MigrationEntryStatus . OUTDATED ] : ! allSearchesFailed && ! searchProgress . success ,
2026-03-13 22:41:28 +01:00
[ MigrationEntryStatus . SEARCH_FAILED ] : allSearchesFailed ,
[ MigrationEntryStatus . SEARCH_COMPLETE ] : !! searchProgress . success ,
};
});
}
}
}
static async startMigration ( options : Omit < MigrateOptions , 'mangaIdToMigrateTo' >) : Promise < void > {
2026-04-23 15:18:35 +02:00
MigrationManager . ensureIsInValidPhase ([ MigrationPhase . SEARCHING ]);
2026-05-16 02:35:25 +02:00
const migratableEntries = MigrationEntries . getMigratable ( Object . values ( MigrationManager . getState (). entries ));
2026-03-13 22:41:28 +01:00
2026-06-06 17:45:43 +02:00
await MigrationManager . awaitUserConfirmation ();
2026-03-13 22:41:28 +01:00
MigrationManager . updateState (( draft ) => {
draft . phase = MigrationPhase . MIGRATING ;
draft . migrateOptions = options ;
draft . migrationProgress = { total : migratableEntries.length , completed : 0 , success : 0 , failed : 0 };
draft . groupExpandState = MIGRATE_EXECUTE_ENTRY_GROUP_EXPAND_DEFAULT_STATE ;
});
2026-04-23 15:54:37 +02:00
await MigrationManager . migrate ( migratableEntries , options );
2026-03-13 22:41:28 +01:00
}
private static async migrate (
entries : MigratableEntry [],
options : Omit < MigrateOptions , 'mangaIdToMigrateTo' >,
) : Promise < void > {
const { signal } = MigrationManager . abortAndCreateAbortController ( 'migrate' );
2026-05-22 14:13:56 +02:00
MigrationManager . updateState (( draft ) => {
entries . forEach (( entry ) => {
draft . entries [ entry . mangaId ]. status = MigrationEntryStatus . MIGRATION_PENDING ;
});
});
2026-03-13 22:41:28 +01:00
const entriesBySource = Object . groupBy ( entries , ( entry ) => entry . selectedMatchSourceId );
const migrationPromises = Object . values ( entriesBySource ). map (( sourceEntries = []) =>
MigrationManager . mangaProcessQueue ( async () => {
for ( const entry of sourceEntries ) {
if ( signal . aborted ) {
2026-05-22 14:13:56 +02:00
throw new Error ( signal . reason );
2026-03-13 22:41:28 +01:00
}
// oxlint-disable-next-line no-await-in-loop
await MigrationManager . migrateSingleEntry ( entry . mangaId , options , signal );
}
}),
);
2026-04-23 15:54:37 +02:00
try {
await Promise . allSettled ( migrationPromises );
} finally {
const { migrationProgress } = MigrationManager . getState ();
if ( migrationProgress . completed === migrationProgress . total ) {
MigrationManager . updateState (( draft ) => {
draft . groupExpandState = {
... MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE ,
[ MigrationEntryStatus . MIGRATING ] : false ,
[ MigrationEntryStatus . MIGRATION_FAILED ] : !! migrationProgress . failed ,
[ MigrationEntryStatus . MIGRATION_COMPLETE ] : ! migrationProgress . failed ,
};
});
}
}
2026-03-13 22:41:28 +01:00
if ( ! signal . aborted ) {
MigrationManager . updateState (( draft ) => {
draft . lastUpdatedAt = Date . now ();
});
}
}
2026-05-13 01:36:33 +02:00
static async stop ( reason : unknown = 'stopped' ) : Promise < boolean > {
2026-03-13 22:41:28 +01:00
if ( ! ( await MigrationManager . confirmAbort ())) {
return false ;
}
MigrationManager . abortAndResetAbortController ( reason );
2026-05-13 01:36:33 +02:00
MigrationManager . updateState (( draft ) => {
draft . isAborted = true ;
});
return true ;
}
static async abort ( reason : unknown = 'abort' ) : Promise < boolean > {
if ( ! MigrationManager . getState (). isAborted && ! ( await MigrationManager . confirmAbort ())) {
return false ;
}
MigrationManager . abortAndResetAbortController ( reason );
2026-03-13 22:41:28 +01:00
MigrationManager . reset ();
ReactRouter . navigate ( AppRoutes . browse . path ( BrowseTab . MIGRATE ));
return true ;
}
2026-06-06 17:45:43 +02:00
static async awaitCompletion () : Promise < void > {
const completionPromise = new ControlledPromise ();
const checkComplete = () => {
setTimeout (() => {
if ( MigrationManager . getState (). phase !== MigrationPhase . IDLE ) {
checkComplete ();
return ;
}
completionPromise . resolve ();
}, d ( 10 ). seconds . inWholeMilliseconds );
};
checkComplete ();
return completionPromise . promise ;
}
static async resume () : Promise < boolean > {
2026-05-08 15:12:02 +02:00
const { phase , migrateOptions , searchOptions , entries } = MigrationManager . getState ();
2026-03-13 22:41:28 +01:00
2026-06-06 17:45:43 +02:00
if ( ! AppSession . isSecureContext ()) {
return false ;
}
if ( ! MigrationManager . isResumablePhase ()) {
return false ;
2026-03-13 22:41:28 +01:00
}
2026-05-08 15:12:02 +02:00
const resumeMigrationPhase = phase === MigrationPhase . MIGRATING && migrateOptions ;
2026-03-13 22:41:28 +01:00
if ( resumeMigrationPhase ) {
2026-05-08 15:12:02 +02:00
assertIsDefined ( migrateOptions );
2026-03-13 22:41:28 +01:00
2026-05-16 02:35:25 +02:00
const migratableEntries = MigrationEntries . getMigratable ( Object . values ( entries ));
2026-03-13 22:41:28 +01:00
2026-04-23 15:54:37 +02:00
MigrationManager . updateState (( draft ) => {
migratableEntries . forEach (( entry ) => {
2026-05-22 14:13:56 +02:00
draft . entries [ entry . mangaId ]. status = MigrationEntryStatus . MIGRATION_PENDING ;
2026-04-23 15:54:37 +02:00
});
});
2026-05-08 15:12:02 +02:00
await MigrationManager . migrate ( migratableEntries , migrateOptions );
2026-03-13 22:41:28 +01:00
2026-06-06 17:45:43 +02:00
return true ;
2026-03-13 22:41:28 +01:00
}
2026-05-08 15:12:02 +02:00
assertIsDefined ( searchOptions );
2026-05-16 02:35:25 +02:00
const pendingEntries = MigrationEntries . getHaveStatus (
Object . values ( entries ),
2026-05-22 14:13:56 +02:00
MigrationEntryStatus . SEARCH_PENDING ,
2026-05-16 02:35:25 +02:00
MigrationEntryStatus . SEARCHING ,
2026-03-13 22:41:28 +01:00
);
2026-04-23 15:54:37 +02:00
MigrationManager . updateState (( draft ) => {
pendingEntries . forEach (( entry ) => {
2026-05-22 14:13:56 +02:00
draft . entries [ entry . mangaId ]. status = MigrationEntryStatus . SEARCH_PENDING ;
2026-04-23 15:54:37 +02:00
});
});
2026-05-08 15:12:02 +02:00
await MigrationManager . search ( pendingEntries , searchOptions );
2026-06-06 17:45:43 +02:00
return true ;
2026-03-13 22:41:28 +01:00
}
static reset () : void {
MigrationManager . abortAndResetAbortController ( 'reset' );
migrationStore . setState ({ ... DEFAULT_MIGRATION_STATE });
}
static excludeManga ( mangaId : MangaIdInfo [ 'id' ]) : void {
MigrationManager . updateState (( draft ) => {
const entry = draft . entries [ mangaId ];
if ( entry ) {
entry . isExcluded = true ;
}
});
}
static includeManga ( mangaId : MangaIdInfo [ 'id' ]) : void {
MigrationManager . updateState (( draft ) => {
const entry = draft . entries [ mangaId ];
if ( entry ) {
entry . isExcluded = false ;
}
});
}
2026-05-22 14:13:56 +02:00
static abortEntry ( mangaId : MangaIdInfo [ 'id' ]) : void {
const { entries , phase } = MigrationManager . getState ();
const entry = entries [ mangaId ];
assertIsDefined ( entry );
MigrationManager . abortControllerByManga . get ( mangaId ) ? . abort ( 'User aborted' );
MigrationManager . updateState (( draft ) => {
const draftEntry = draft . entries [ mangaId ];
assertIsDefined ( draftEntry );
draftEntry . status = MigrationEntryStatus . ABORTED ;
if ( phase === MigrationPhase . SEARCHING ) {
draft . searchProgress . completed += 1 ;
} else {
draft . migrationProgress . completed += 1 ;
}
});
}
2026-03-13 22:41:28 +01:00
static selectMatch (
mangaId : MangaIdInfo [ 'id' ],
targetMangaId : MangaIdInfo [ 'id' ],
targetSourceId : SourceIdInfo [ 'id' ],
) : void {
MigrationManager . updateState (( draft ) => {
const entry = draft . entries [ mangaId ];
if ( entry ) {
2026-05-22 12:12:00 +02:00
if ( MigrationEntries . isSearching ( entry )) {
2026-05-16 22:33:15 +02:00
draft . searchProgress . completed += 1 ;
draft . searchProgress . success += 1 ;
}
2026-05-22 12:05:23 +02:00
entry . status = MigrationEntryStatus . SEARCH_COMPLETE ;
2026-05-16 22:36:22 +02:00
entry . isManualSelection = true ;
2026-03-13 22:41:28 +01:00
entry . selectedMatchMangaId = targetMangaId ;
entry . selectedMatchSourceId = targetSourceId ;
}
});
}
static selectManualMatch ( mangaId : MangaIdInfo [ 'id' ], match : MigrationMatch ) : void {
MigrationManager . updateState (( draft ) => {
const entry = draft . entries [ mangaId ];
if ( entry ) {
const isExistingSearchMatch = draft . entries [ mangaId ]. searchMatches . some (
( searchMatch ) => searchMatch . id === match . id ,
);
const isExistingManualMatch = draft . entries [ mangaId ]. manualMatches . some (
( manualMatch ) => manualMatch . id === match . id ,
);
const isExistingEntry = isExistingSearchMatch || isExistingManualMatch ;
if ( ! isExistingEntry ) {
draft . entries [ mangaId ]. manualMatches = [... draft . entries [ mangaId ]. manualMatches , match ];
}
2026-05-22 12:12:00 +02:00
const isSearching = MigrationEntries . isSearching ( entry );
2026-05-18 21:36:56 +02:00
2026-05-22 12:05:23 +02:00
entry . status = MigrationEntryStatus . SEARCH_COMPLETE ;
2026-05-18 21:36:56 +02:00
entry . isManualSelection = true ;
entry . selectedMatchMangaId = match . id ;
entry . selectedMatchSourceId = match . sourceId ;
2026-05-16 22:37:23 +02:00
if ( isSearching ) {
2026-05-22 12:12:00 +02:00
draft . searchProgress . completed += 1 ;
draft . searchProgress . success += 1 ;
2026-05-22 14:13:56 +02:00
MigrationManager . abortControllerByManga . get ( mangaId ) ? . abort ( 'Manual match selected' );
2026-05-16 22:37:23 +02:00
}
2026-03-13 22:41:28 +01:00
}
});
}
2026-05-18 22:02:13 +02:00
static openManualSearch ( mangaId : MangaIdInfo [ 'id' ], title : string ) : void {
2026-05-28 00:01:54 +02:00
ReactRouter . navigate ( AppRoutes . migrate . children . manualSearch . path ( mangaId , title ), {
2026-05-27 23:59:58 +02:00
state : AppRoutes.migrate.children.manualSearch.state ({
title : t `Manual migration search for " ${ title } "` ,
mode : 'migrate.select.bulk' ,
}),
2026-05-18 22:02:13 +02:00
});
}
2026-03-13 22:41:28 +01:00
static getState () : MigrationState {
return migrationStore . getState ();
}
2026-06-06 17:45:43 +02:00
static isResumablePhase () : boolean {
return RESUMABLE_PHASES . includes ( MigrationManager . getState (). phase );
2026-03-13 22:41:28 +01:00
}
2026-06-06 17:45:43 +02:00
static isActive () : boolean {
return !! MigrationManager . abortController && MigrationManager . isResumablePhase ();
2026-03-13 22:41:28 +01:00
}
2026-04-23 15:18:35 +02:00
private static getDestinationSourceIds ( mangaSourceId : SourceIdInfo [ 'id' ]) : MigrationState [ 'destinationSourceIds' ] {
const { sourceIds , destinationSourceIds } = MigrationManager . getState ();
2026-03-13 22:41:28 +01:00
2026-04-23 15:18:35 +02:00
// Prevent (most likely) unintentionally matching a manga to itself.
const isMultiSourceMigration = ( sourceIds ? . length ?? - 1 ) > 1 ;
if ( isMultiSourceMigration ) {
const mangaSourceIdIndex = destinationSourceIds . indexOf ( mangaSourceId );
return [
... destinationSourceIds . slice ( 0 , mangaSourceIdIndex ),
... destinationSourceIds . slice ( mangaSourceIdIndex + 1 ),
mangaSourceId ,
];
}
return destinationSourceIds ;
}
2026-05-17 13:52:19 +02:00
public static getSourcePriority ( mangaSourceId : SourceIdInfo [ 'id' ], sourceId : SourceIdInfo [ 'id' ]) : number {
const destinationSourceIds = MigrationManager . getDestinationSourceIds ( mangaSourceId );
return destinationSourceIds . indexOf ( sourceId );
}
2026-04-23 15:18:35 +02:00
private static getHigherPrioritySourceIds (
mangaSourceId : SourceIdInfo [ 'id' ],
destSourceId : SourceIdInfo [ 'id' ],
) : SourceIdInfo [ 'id' ][] {
const destinationSourceIds = MigrationManager . getDestinationSourceIds ( mangaSourceId );
const sourceIdPriority = destinationSourceIds . indexOf ( destSourceId );
2026-03-13 22:41:28 +01:00
return destinationSourceIds . slice ( 0 , Math . max ( 0 , sourceIdPriority - 1 ));
}
2026-04-23 15:18:35 +02:00
private static isHigherPrioritySourceUnsettled (
mangaId : MangaIdInfo [ 'id' ],
destSourceId : SourceIdInfo [ 'id' ],
) : boolean {
2026-03-13 22:41:28 +01:00
const { entries } = MigrationManager . getState ();
const entry = entries [ mangaId ];
assertIsDefined ( entry );
2026-04-23 15:18:35 +02:00
return MigrationManager . getHigherPrioritySourceIds ( entry . sourceId , destSourceId ). some (
2026-03-13 22:41:28 +01:00
( higherPrioritySourceId ) => entry . destSourceIdToSearchState [ higherPrioritySourceId ] == null ,
);
}
2026-04-23 15:18:35 +02:00
private static hasHigherSourcePriorityMatch ( mangaId : MangaIdInfo [ 'id' ], destSourceId : SourceIdInfo [ 'id' ]) : boolean {
2026-03-13 22:41:28 +01:00
const { entries } = MigrationManager . getState ();
const entry = entries [ mangaId ];
if ( ! entry || entry . selectedMatchSourceId == null ) {
return false ;
}
2026-04-23 15:18:35 +02:00
return MigrationManager . getHigherPrioritySourceIds ( entry . sourceId , destSourceId ). some (
2026-03-13 22:41:28 +01:00
( higherPrioritySourceId ) => entry . destSourceIdToSearchState [ higherPrioritySourceId ],
);
}
private static async findMatchesForMangaInSource (
mangaId : MangaIdInfo [ 'id' ],
mangaTitle : string ,
sourceId : SourceIdInfo [ 'id' ],
signal : AbortSignal ,
2026-05-08 15:41:58 +02:00
{ selectHighestChapterNumberSource , performAdvancedSearch } : MigrationBulkSearchSettings ,
2026-05-17 13:19:21 +02:00
) : Promise < { manga : MangaMigrationFieldsFragment ; chapters : ChapterListFieldsFragment [] | null }[] > {
2026-03-13 22:41:28 +01:00
if ( signal . aborted ) {
throw new Error ( signal . reason );
}
2026-05-08 15:41:58 +02:00
if ( ! selectHighestChapterNumberSource && MigrationManager . hasHigherSourcePriorityMatch ( mangaId , sourceId )) {
throw new Error ( 'Entry already has a selected match from a higher priority source' );
}
const searchQueries = performAdvancedSearch
? [
mangaTitle ,
... enhancedCleanup ( mangaTitle )
. split ( ' ' )
. filter (( query ) => query . length >= 3 ),
]
: [ mangaTitle ];
const searchRequests = searchQueries . map (( query ) =>
MigrationManager . getOrCreateSourceQueue ( sourceId )(() =>
requestManager . graphQLClient . client . mutate <
GetMigrationSourceMangasFetchMutation ,
GetMigrationSourceMangasFetchMutationVariables
> ({
mutation : GET_MIGRATION_SOURCE_MANGAS_FETCH ,
variables : {
input : {
source : sourceId ,
query ,
page : 1 ,
type : FetchSourceMangaType . Search ,
},
},
context : { fetchOptions : { signal } },
}),
),
);
const searchResponses = await Promise . allSettled ( searchRequests );
const successfulSearchResponses = searchResponses
. filter (( response ) => response . status === 'fulfilled' )
. map (( response ) => response . value );
if ( ! successfulSearchResponses . length ) {
const failureReasons = searchResponses . map (( response ) => ( response as PromiseRejectedResult ). reason );
throw new Error ( `Search failed due to: ${ failureReasons . join ( '\n\n' ) } ` );
}
const searchResults = successfulSearchResponses . flatMap (
( response ) => response ? . data ? . fetchSourceManga ? . mangas ?? [],
);
const uniqueSearchResults = uniqBy ( 'id' , searchResults );
const matches = uniqueSearchResults . filter (
2026-06-05 14:52:12 +02:00
( searchMatch ) =>
searchMatch . id !== mangaId && enhancedCleanup ( searchMatch . title ) === enhancedCleanup ( mangaTitle ),
2026-05-08 15:41:58 +02:00
);
const matchUpdatePromises = matches . map ( async ( match ) => {
2026-03-13 22:41:28 +01:00
if ( signal . aborted ) {
throw new Error ( signal . reason );
}
2026-05-08 15:41:58 +02:00
return ( async () => {
try {
2026-05-08 15:52:02 +02:00
const updatedMatch = await MigrationManager . getOrCreateSourceQueue ( sourceId )(
() =>
requestManager . refreshManga ( match . id , {
awaitRefetchQueries : true ,
2026-05-22 14:13:56 +02:00
context : { fetchOptions : { signal } },
2026-05-08 15:52:02 +02:00
}). response ,
);
2026-03-13 22:41:28 +01:00
2026-05-17 13:19:21 +02:00
if ( updatedMatch . data ? . fetchManga ? . manga ) {
return {
manga : updatedMatch.data.fetchManga.manga ,
chapters : updatedMatch.data.fetchChapters?.chapters ?? null ,
};
}
2026-05-08 15:41:58 +02:00
} catch ( e ) {
// ignore
2026-03-13 22:41:28 +01:00
}
2026-05-17 13:19:21 +02:00
return {
manga : match ,
chapters : null ,
};
2026-05-08 15:41:58 +02:00
})();
2026-03-13 22:41:28 +01:00
});
2026-05-08 15:41:58 +02:00
const updatedMatches = await Promise . all ( matchUpdatePromises );
return updatedMatches ;
2026-03-13 22:41:28 +01:00
}
private static async searchForManga (
mangaId : MangaIdInfo [ 'id' ],
mangaTitle : string ,
mainSignal : AbortSignal ,
2026-05-08 15:12:02 +02:00
options : MigrationBulkSearchSettings ,
2026-03-13 22:41:28 +01:00
) : Promise < void > {
2026-05-17 13:19:21 +02:00
const {
selectHighestChapterNumberSource ,
ignoreOutdatedMatches ,
requireAdditionalChapters ,
ignoreWithMissingChapters ,
} = options ;
2026-05-08 15:12:02 +02:00
2026-03-13 22:41:28 +01:00
const state = MigrationManager . getState ();
const entry = state . entries [ mangaId ];
const searchController = new AbortController ();
const signal = AbortSignal . any ([ mainSignal , searchController . signal ]);
2026-05-22 14:13:56 +02:00
if ( ! entry || MigrationEntries . hasStatus ( entry , MigrationEntryStatus . ABORTED )) {
2026-03-13 22:41:28 +01:00
return ;
}
2026-05-22 14:13:56 +02:00
MigrationManager . abortControllerByManga . get ( mangaId ) ? . abort ( 'search' );
MigrationManager . abortControllerByManga . set ( mangaId , searchController );
2026-03-13 22:41:28 +01:00
MigrationManager . updateState (( draft ) => {
draft . entries [ mangaId ]. status = MigrationEntryStatus . SEARCHING ;
});
try {
2026-05-16 21:57:44 +02:00
const searchPromises = MigrationManager . getDestinationSourceIds ( entry . sourceId )
. filter (( destSourceId ) => ! entry . destSourceIdToSearchState [ destSourceId ])
. map (( destSourceId ) =>
MigrationManager . getParallelSourceQueue ()( async () => {
if ( signal . aborted ) {
2026-05-22 14:13:56 +02:00
throw new Error ( signal . reason );
2026-05-16 21:57:44 +02:00
}
2026-03-13 22:41:28 +01:00
2026-05-16 21:57:44 +02:00
if (
! selectHighestChapterNumberSource &&
MigrationManager . hasHigherSourcePriorityMatch ( mangaId , destSourceId )
) {
return null ;
}
2026-03-13 22:41:28 +01:00
2026-05-16 21:57:44 +02:00
const foundMatches = await ( async () => {
try {
return await MigrationManager . findMatchesForMangaInSource (
mangaId ,
mangaTitle ,
destSourceId ,
signal ,
options ,
);
} catch ( e ) {
MigrationManager . updateState (( draft ) => {
const draftEntry = draft . entries [ mangaId ];
draftEntry . destSourceIdToSearchState [ destSourceId ] = false ;
});
throw e ;
}
})();
if ( ! foundMatches . length ) {
2026-03-13 22:41:28 +01:00
MigrationManager . updateState (( draft ) => {
const draftEntry = draft . entries [ mangaId ];
draftEntry . destSourceIdToSearchState [ destSourceId ] = false ;
});
2026-05-16 21:57:44 +02:00
return null ;
2026-03-13 22:41:28 +01:00
}
MigrationManager . updateState (( draft ) => {
const draftEntry = draft . entries [ mangaId ];
2026-05-16 21:57:44 +02:00
const draftMatchEntry = draftEntry . selectedMatchMangaId
? draft . entries [ draftEntry . selectedMatchMangaId ]
: null ;
2026-03-13 22:41:28 +01:00
2026-05-16 22:03:42 +02:00
const newMatches = foundMatches . filter (( newMatch ) =>
2026-05-17 13:19:21 +02:00
draftEntry . searchMatches . every (
( existingMatch ) => newMatch . manga . id !== existingMatch . id ,
),
2026-05-16 22:03:42 +02:00
);
2026-05-17 13:19:21 +02:00
const matches = newMatches . map (({ manga , chapters }) => ({
2026-05-16 21:57:44 +02:00
id : manga.id ,
title : manga.title ,
artist : manga.artist ,
author : manga.author ,
latestChapterNumber : manga.highestNumberedChapter?.chapterNumber ,
thumbnailUrl : manga.thumbnailUrl ,
sourceId : manga.sourceId ,
sourceTitle : manga.source?.displayName ,
2026-05-17 13:19:21 +02:00
missingChapters : chapters ? Chapters . getMissingCount ( chapters ) : undefined ,
2026-05-28 01:31:23 +02:00
inLibrary : manga.inLibrary ,
2026-05-16 21:57:44 +02:00
}));
draftEntry . destSourceIdToSearchState [ destSourceId ] = true ;
draftEntry . searchMatches = [... draftEntry . searchMatches , ... matches ];
const matchesByChapterNumber = Object . groupBy (
matches ,
( match ) => match . latestChapterNumber ?? - 1 ,
);
const latestChapterNumber = Math . max (
... Object . keys ( matchesByChapterNumber ). map (( chapterNumber ) => Number ( chapterNumber )),
);
const bestMatch = matchesByChapterNumber [ latestChapterNumber ] ? .[ 0 ];
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 ;
2026-05-22 11:33:33 +02:00
const isOutdated = latestChapterNumber < entryLatestChapterNumber ;
2026-05-17 00:18:51 +02:00
2026-05-16 21:57:44 +02:00
const hasSameLatestChapterAsSelectedMatch =
!! draftMatchEntry && selectedMatchLatestChapterNumber === latestChapterNumber ;
const hasHigherSourcePriority = ! MigrationManager . hasHigherSourcePriorityMatch (
mangaId ,
destSourceId ,
);
2026-05-17 00:18:51 +02:00
const ignoreOutdatedMatch = ignoreOutdatedMatches && isOutdated ;
const satisfiesRequireAdditionalChapters = ! requireAdditionalChapters || hasNewerChapter ;
2026-05-17 13:19:21 +02:00
const ignoreBecauseMissingChapters =
ignoreWithMissingChapters && !! bestMatch . missingChapters ;
2026-05-16 21:57:44 +02:00
const isPreferredSourcePriorityMatch =
hasHigherSourcePriority && ! selectHighestChapterNumberSource ;
const isPreferredChapterNumberMatch =
selectHighestChapterNumberSource &&
( hasNewerChapter ||
( hasHigherSourcePriority && hasSameLatestChapterAsSelectedMatch ) ||
! draftMatchEntry );
const isPreferredMatch =
2026-05-16 22:36:22 +02:00
! draftEntry . isManualSelection &&
2026-05-16 21:57:44 +02:00
! ignoreOutdatedMatch &&
2026-05-17 00:18:51 +02:00
satisfiesRequireAdditionalChapters &&
2026-05-17 13:19:21 +02:00
! ignoreBecauseMissingChapters &&
2026-05-16 21:57:44 +02:00
( isPreferredSourcePriorityMatch || isPreferredChapterNumberMatch );
if ( isPreferredMatch ) {
draftEntry . selectedMatchMangaId = bestMatch . id ;
draftEntry . selectedMatchSourceId = destSourceId ;
}
if (
! selectHighestChapterNumberSource &&
! MigrationManager . isHigherPrioritySourceUnsettled ( mangaId , destSourceId )
) {
searchController . abort ( `Found best match in source " ${ destSourceId } "` );
}
2026-03-13 22:41:28 +01:00
});
2026-05-16 21:57:44 +02:00
}),
);
2026-03-13 22:41:28 +01:00
const searchMatchPromises = await Promise . allSettled ( searchPromises );
const hasFulfilledSearch = searchMatchPromises . some (( result ) => result . status === 'fulfilled' );
if ( ! hasFulfilledSearch ) {
throw new Error ( 'All source searches failed' );
}
MigrationManager . updateState (( draft ) => {
const draftEntry = draft . entries [ mangaId ];
2026-05-22 12:12:00 +02:00
if ( ! MigrationEntries . isSearching ( draftEntry )) {
return ;
}
2026-03-13 22:41:28 +01:00
if ( draftEntry . searchMatches . length ) {
2026-05-08 15:19:26 +02:00
if ( draftEntry . selectedMatchMangaId != null ) {
draftEntry . status = MigrationEntryStatus . SEARCH_COMPLETE ;
} else {
draftEntry . status = MigrationEntryStatus . OUTDATED ;
}
2026-03-13 22:41:28 +01:00
} else {
draftEntry . status = MigrationEntryStatus . NO_MATCH ;
}
2026-05-22 12:55:46 +02:00
draft . searchProgress . success += 1 ;
draft . searchProgress . completed += 1 ;
2026-03-13 22:41:28 +01:00
});
} catch ( error ) {
2026-04-23 19:00:51 +02:00
if ( mainSignal . aborted ) {
2026-03-13 22:41:28 +01:00
return ;
}
MigrationManager . updateState (( draft ) => {
const draftEntry = draft . entries [ mangaId ];
2026-05-22 14:13:56 +02:00
if ( ! MigrationEntries . hasStatus ( draftEntry , MigrationEntryStatus . ABORTED )) {
draftEntry . status = MigrationEntryStatus . SEARCH_FAILED ;
draftEntry . error = getErrorMessage ( error );
draft . searchProgress . completed += 1 ;
draft . searchProgress . failed += 1 ;
}
2026-03-13 22:41:28 +01:00
});
2026-05-16 22:37:23 +02:00
} finally {
2026-05-22 14:13:56 +02:00
MigrationManager . abortControllerByManga . delete ( mangaId );
2026-03-13 22:41:28 +01:00
}
}
private static async migrateSingleEntry (
mangaId : MangaIdInfo [ 'id' ],
options : Omit < MigrateOptions , 'mangaIdToMigrateTo' >,
2026-05-22 14:13:56 +02:00
mainSignal : AbortSignal ,
2026-03-13 22:41:28 +01:00
) : Promise < void > {
const state = MigrationManager . getState ();
const entry = state . entries [ mangaId ];
2026-05-22 14:13:56 +02:00
const migrateController = new AbortController ();
const signal = AbortSignal . any ([ mainSignal , migrateController . signal ]);
if (
! entry ||
! entry . selectedMatchSourceId ||
entry . selectedMatchMangaId == null ||
MigrationEntries . hasStatus ( entry , MigrationEntryStatus . ABORTED )
) {
2026-03-13 22:41:28 +01:00
return ;
}
2026-05-22 14:13:56 +02:00
MigrationManager . abortControllerByManga . get ( mangaId ) ? . abort ( 'migrate' );
MigrationManager . abortControllerByManga . set ( mangaId , migrateController );
2026-03-13 22:41:28 +01:00
MigrationManager . updateState (( draft ) => {
draft . entries [ mangaId ]. status = MigrationEntryStatus . MIGRATING ;
});
try {
if ( signal . aborted ) {
2026-05-22 14:13:56 +02:00
throw new Error ( signal . reason );
2026-03-13 22:41:28 +01:00
}
2026-05-08 01:50:24 +02:00
await MigrationManager . getParallelSourceQueue ()(() => {
assertIsDefined ( entry . selectedMatchSourceId );
2026-03-13 22:41:28 +01:00
2026-05-08 01:50:24 +02:00
return MigrationManager . getOrCreateSourceQueue ( entry . selectedMatchSourceId )( async () => {
if ( signal . aborted ) {
2026-05-22 14:13:56 +02:00
throw new Error ( signal . reason );
2026-05-08 01:50:24 +02:00
}
2026-03-13 22:41:28 +01:00
2026-05-08 01:50:24 +02:00
assertIsDefined ( entry . selectedMatchMangaId );
2026-05-08 16:28:53 +02:00
await MangaMigration . migrateByIdWithQuery ( mangaId , entry . selectedMatchMangaId , options );
2026-05-08 01:50:24 +02:00
});
});
2026-03-13 22:41:28 +01:00
MigrationManager . updateState (( draft ) => {
2026-05-22 14:13:56 +02:00
if ( ! MigrationEntries . isMigrating ( entry )) {
return ;
}
2026-03-13 22:41:28 +01:00
draft . entries [ mangaId ]. status = MigrationEntryStatus . MIGRATION_COMPLETE ;
draft . migrationProgress . success += 1 ;
draft . migrationProgress . completed += 1 ;
});
} catch ( error ) {
2026-05-22 14:13:56 +02:00
if ( mainSignal . aborted ) {
2026-03-13 22:41:28 +01:00
return ;
}
MigrationManager . updateState (( draft ) => {
2026-05-22 14:13:56 +02:00
const draftEntry = draft . entries [ mangaId ];
assertIsDefined ( draftEntry );
if ( ! MigrationEntries . hasStatus ( draftEntry , MigrationEntryStatus . ABORTED )) {
draftEntry . status = MigrationEntryStatus . MIGRATION_FAILED ;
draftEntry . error = getErrorMessage ( error );
draft . migrationProgress . failed += 1 ;
draft . migrationProgress . completed += 1 ;
}
2026-03-13 22:41:28 +01:00
});
2026-05-22 14:13:56 +02:00
} finally {
MigrationManager . abortControllerByManga . delete ( mangaId );
2026-03-13 22:41:28 +01:00
}
}
static async retryEntry ( id : MangaIdInfo [ 'id' ]) : Promise < void > {
2026-05-08 15:12:02 +02:00
const { entries , migrateOptions , searchOptions } = MigrationManager . getState ();
const entry = entries [ id ];
2026-03-13 22:41:28 +01:00
if ( ! entry ) {
return ;
}
const { signal } = MigrationManager . getOrCreateAbortController ();
2026-05-22 14:22:01 +02:00
if ( MigrationEntries . hasStatus ( entry , MigrationEntryStatus . SEARCH_FAILED )) {
2026-03-13 22:41:28 +01:00
MigrationManager . updateState (( draft ) => {
2026-05-22 15:46:58 +02:00
const draftEntry = draft . entries [ id ];
draftEntry . status = MigrationEntryStatus . SEARCH_PENDING ;
draftEntry . error = undefined ;
2026-03-13 22:41:28 +01:00
draft . searchProgress . completed -= 1 ;
draft . searchProgress . failed -= 1 ;
});
2026-05-08 15:12:02 +02:00
assertIsDefined ( searchOptions );
2026-04-23 19:01:58 +02:00
await MigrationManager . mangaProcessQueue (() =>
2026-05-08 15:12:02 +02:00
MigrationManager . searchForManga ( id , entry . mangaTitle , signal , searchOptions ),
2026-04-23 19:01:58 +02:00
);
2026-03-13 22:41:28 +01:00
return ;
}
2026-05-22 14:22:01 +02:00
if ( MigrationEntries . hasStatus ( entry , MigrationEntryStatus . MIGRATION_FAILED )) {
2026-03-13 22:41:28 +01:00
MigrationManager . updateState (( draft ) => {
2026-05-22 15:46:58 +02:00
const draftEntry = draft . entries [ id ];
draftEntry . status = MigrationEntryStatus . MIGRATION_PENDING ;
draftEntry . error = undefined ;
2026-03-13 22:41:28 +01:00
draft . migrationProgress . completed -= 1 ;
draft . migrationProgress . failed -= 1 ;
});
2026-05-08 15:12:02 +02:00
assertIsDefined ( migrateOptions );
2026-03-13 22:41:28 +01:00
2026-04-23 19:01:58 +02:00
await MigrationManager . mangaProcessQueue (() =>
2026-05-08 15:12:02 +02:00
MigrationManager . migrateSingleEntry ( id , migrateOptions , signal ),
2026-04-23 19:01:58 +02:00
);
2026-03-13 22:41:28 +01:00
}
}
private static updateState ( updater : ( draft : MigrationState ) => void ) : void {
2026-06-06 17:45:43 +02:00
// Only update the state if the tab is the active executor. Otherwise, updating the state will break the
// migration due to the executor tab rehydrating its state with a potentially outdated version
if ( MigrationManager . isResumablePhase () && ! MigrationManager . isActive ()) {
return ;
}
2026-03-13 22:41:28 +01:00
migrationStore . setState ( updater );
}
static setEntryMatchesExpandState ( id : MangaIdInfo [ 'id' ], expanded : boolean ) : void {
MigrationManager . updateState (( draft ) => {
const entry = draft . entries [ id ];
if ( entry ) {
entry . areMatchesExpanded = expanded ;
}
});
}
static useEntryMatchesExpandState ( id : MangaIdInfo [ 'id' ]) : boolean {
return useMigrationStore (( state ) => state . entries [ id ] ? . areMatchesExpanded ?? false );
}
static setGroupExpandState ( status : MigrationEntryStatus , expanded : boolean ) : void {
MigrationManager . updateState (( draft ) => {
draft . groupExpandState [ status ] = expanded ;
});
}
static useGroupExpandState ( status : MigrationEntryStatus ) : boolean {
return useMigrationStore (( state ) => state . groupExpandState [ status ] ?? false );
}
static usePhase () : MigrationPhase {
return useMigrationStore (( state ) => state . phase );
}
2026-04-23 15:18:35 +02:00
static useSourceIds () : MigrationState [ 'sourceIds' ] {
return useMigrationStore (( state ) => state . sourceIds );
2026-03-13 22:41:28 +01:00
}
static useEntries () : Record < number , TMigrationEntry > {
return useMigrationStore (( state ) => state . entries );
}
static useSearchProgress () : MigrationState [ 'searchProgress' ] {
return useMigrationStore (( state ) => state . searchProgress );
}
static useMigrationProgress () : MigrationState [ 'migrationProgress' ] {
return useMigrationStore (( state ) => state . migrationProgress );
}
static useIsActive () : boolean {
// Listen to phase changes
MigrationManager . usePhase ();
return MigrationManager . isActive ();
}
}