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-06-18 18:52:59 +02:00
type MigrationProgress ,
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-06-21 13:17:13 +02:00
import mapValues from 'lodash/fp/mapValues' ;
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 {
2026-06-18 18:52:59 +02:00
const { phase , isAborted } = MigrationManager . getState ( ) ;
const searchProgress = MigrationManager . getSearchProgress ( ) ;
const migrationProgress = MigrationManager . getMigrationProgress ( ) ;
2026-03-13 22:41:28 +01:00
switch ( phase ) {
case MigrationPhase . SEARCHING :
2026-06-18 18:52:59 +02:00
return searchProgress . completed === searchProgress . total ;
2026-03-13 22:41:28 +01:00
case MigrationPhase . MIGRATING :
2026-06-18 18:52:59 +02:00
return isAborted || migrationProgress . completed === 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 ( ) ;
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 . 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 {
2026-06-18 18:52:59 +02:00
const searchProgress = MigrationManager . getSearchProgress ( ) ;
2026-03-13 22:41:28 +01:00
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 ,
2026-06-18 18:52:59 +02:00
[ MigrationEntryStatus . SEARCH_NO_MATCH ] : ! allSearchesFailed && ! searchProgress . success ,
[ MigrationEntryStatus . SEARCH_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 . 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 {
2026-06-18 18:52:59 +02:00
const migrationProgress = MigrationManager . getMigrationProgress ( ) ;
2026-04-23 15:54:37 +02:00
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 {
2026-06-18 18:52:59 +02:00
const { entries } = MigrationManager . getState ( ) ;
2026-05-22 14:13:56 +02:00
const entry = entries [ mangaId ] ;
assertIsDefined ( entry ) ;
MigrationManager . abortControllerByManga . get ( mangaId ) ? . abort ( 'User aborted' ) ;
MigrationManager . updateState ( ( draft ) = > {
const draftEntry = draft . entries [ mangaId ] ;
assertIsDefined ( draftEntry ) ;
2026-06-18 18:52:59 +02:00
if ( draftEntry . status === MigrationEntryStatus . SEARCHING ) {
draftEntry . status = MigrationEntryStatus . SEARCH_ABORTED ;
2026-05-22 14:13:56 +02:00
} else {
2026-06-18 18:52:59 +02:00
draftEntry . status = MigrationEntryStatus . MIGRATION_ABORTED ;
2026-05-22 14:13:56 +02:00
}
} ) ;
}
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: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 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 ( ) ;
// Prevent (most likely) unintentionally matching a manga to itself.
const isMultiSourceMigration = ( sourceIds ? . length ? ? - 1 ) > 1 ;
if ( isMultiSourceMigration ) {
const mangaSourceIdIndex = destinationSourceIds . indexOf ( mangaSourceId ) ;
2026-03-13 22:41:28 +01:00
2026-04-23 15:18:35 +02:00
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' ) ;
}
2026-03-13 22:41:28 +01:00
2026-05-08 15:41:58 +02:00
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 ,
} ,
2026-03-13 22:41:28 +01:00
} ,
2026-05-08 15:41:58 +02:00
context : { fetchOptions : { signal } } ,
} ) ,
) ,
) ;
2026-03-13 22:41:28 +01:00
2026-05-08 15:41:58 +02:00
const searchResponses = await Promise . allSettled ( searchRequests ) ;
const successfulSearchResponses = searchResponses
. filter ( ( response ) = > response . status === 'fulfilled' )
. map ( ( response ) = > response . value ) ;
2026-03-13 22:41:28 +01:00
2026-05-08 15:41:58 +02:00
if ( ! successfulSearchResponses . length ) {
const failureReasons = searchResponses . map ( ( response ) = > ( response as PromiseRejectedResult ) . reason ) ;
2026-03-13 22:41:28 +01:00
2026-05-08 15:41:58 +02:00
throw new Error ( ` Search failed due to: ${ failureReasons . join ( '\n\n' ) } ` ) ;
}
2026-04-23 19:34:43 +02:00
2026-05-08 15:41:58 +02:00
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
) ;
2026-03-13 22:41:28 +01:00
2026-05-08 15:41:58 +02:00
const matchUpdatePromises = matches . map ( async ( match ) = > {
if ( signal . aborted ) {
throw new Error ( signal . reason ) ;
}
2026-03-13 22:41:28 +01:00
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-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-06-18 18:52:59 +02:00
if ( ! entry || MigrationEntries . hasStatus ( entry , MigrationEntryStatus . SEARCH_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 ) = > {
2026-06-21 13:17:13 +02:00
const draftEntry = draft . entries [ mangaId ] ;
draftEntry . status = MigrationEntryStatus . SEARCHING ;
draftEntry . error = undefined ;
draftEntry . searchMatches = [ ] ;
draftEntry . manualMatches = [ ] ;
draftEntry . isManualSelection = false ;
draftEntry . selectedMatchMangaId = null ;
draftEntry . selectedMatchSourceId = null ;
draftEntry . areMatchesExpanded = false ;
draftEntry . destSourceIdToSearchState = mapValues ( ( ) = > false , draftEntry . destSourceIdToSearchState ) ;
2026-03-13 22:41:28 +01:00
} ) ;
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-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 ] ;
2026-03-13 22:41:28 +01:00
2026-05-16 21:57:44 +02:00
assertIsDefined ( bestMatch ) ;
2026-03-13 22:41:28 +01:00
2026-05-16 21:57:44 +02:00
const entryLatestChapterNumber = draftEntry . latestChapterNumber ? ? Number . MIN_SAFE_INTEGER ;
const selectedMatchLatestChapterNumber =
draftMatchEntry ? . latestChapterNumber ? ? Number . MIN_SAFE_INTEGER ;
2026-03-13 22:41:28 +01:00
2026-05-16 21:57:44 +02:00
const hasNewerChapterVsEntry = latestChapterNumber > entryLatestChapterNumber ;
const hasNewerChapterVsSelectedMatch =
latestChapterNumber > selectedMatchLatestChapterNumber ;
const hasNewerChapter = hasNewerChapterVsEntry && hasNewerChapterVsSelectedMatch ;
2026-03-13 22:41:28 +01:00
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
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 {
2026-06-18 18:52:59 +02:00
draftEntry . status = MigrationEntryStatus . SEARCH_OUTDATED ;
2026-05-08 15:19:26 +02:00
}
2026-03-13 22:41:28 +01:00
} else {
2026-06-18 18:52:59 +02:00
draftEntry . status = MigrationEntryStatus . SEARCH_NO_MATCH ;
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-06-18 18:52:59 +02:00
if ( ! MigrationEntries . hasStatus ( draftEntry , MigrationEntryStatus . SEARCH_ABORTED ) ) {
2026-05-22 14:13:56 +02:00
draftEntry . status = MigrationEntryStatus . SEARCH_FAILED ;
draftEntry . error = getErrorMessage ( error ) ;
}
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 ||
2026-06-18 18:52:59 +02:00
MigrationEntries . hasStatus ( entry , MigrationEntryStatus . MIGRATION_ABORTED )
2026-05-22 14:13:56 +02:00
) {
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 ) = > {
2026-06-21 13:17:13 +02:00
const draftEntry = draft . entries [ mangaId ] ;
draftEntry . status = MigrationEntryStatus . MIGRATING ;
draftEntry . error = undefined ;
2026-03-13 22:41:28 +01:00
} ) ;
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 ;
} ) ;
} 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 ) ;
2026-06-18 18:52:59 +02:00
if ( ! MigrationEntries . hasStatus ( draftEntry , MigrationEntryStatus . MIGRATION_ABORTED ) ) {
2026-05-22 14:13:56 +02:00
draftEntry . status = MigrationEntryStatus . MIGRATION_FAILED ;
draftEntry . error = getErrorMessage ( error ) ;
}
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-06-21 13:17:13 +02:00
draft . entries [ id ] . status = MigrationEntryStatus . SEARCH_PENDING ;
2026-03-13 22:41:28 +01:00
} ) ;
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-06-21 13:17:13 +02:00
draft . entries [ id ] . status = MigrationEntryStatus . MIGRATION_PENDING ;
2026-03-13 22:41:28 +01:00
} ) ;
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 ;
}
} ) ;
}
2026-06-18 18:52:59 +02:00
private static getSearchProgress ( state : MigrationState = MigrationManager . getState ( ) ) : MigrationProgress {
const entries = Object . values ( state . entries ) ;
const success = MigrationEntries . getHaveStatus ( entries , MigrationEntryStatus . SEARCH_COMPLETE ) . length ;
const failed = MigrationEntries . getHaveStatus ( entries , MigrationEntryStatus . SEARCH_FAILED ) . length ;
2026-06-19 11:14:13 +02:00
const outdated = MigrationEntries . getHaveStatus ( entries , MigrationEntryStatus . SEARCH_OUTDATED ) . length ;
2026-06-18 18:52:59 +02:00
const noMatch = MigrationEntries . getHaveStatus ( entries , MigrationEntryStatus . SEARCH_NO_MATCH ) . length ;
const aborted = MigrationEntries . getHaveStatus ( entries , MigrationEntryStatus . SEARCH_ABORTED ) . length ;
return {
total : entries.length ,
2026-06-19 11:14:13 +02:00
completed : success + failed + outdated + noMatch + aborted ,
2026-06-18 18:52:59 +02:00
success ,
failed ,
} ;
}
private static getMigrationProgress ( state : MigrationState = MigrationManager . getState ( ) ) : MigrationProgress {
const entries = Object . values ( state . entries ) ;
const total = MigrationEntries . getHaveStatus (
entries ,
MigrationEntryStatus . MIGRATION_PENDING ,
2026-06-19 11:14:13 +02:00
MigrationEntryStatus . MIGRATING ,
MigrationEntryStatus . MIGRATION_ABORTED ,
2026-06-18 18:52:59 +02:00
MigrationEntryStatus . MIGRATION_FAILED ,
MigrationEntryStatus . MIGRATION_COMPLETE ,
) . length ;
const success = MigrationEntries . getHaveStatus ( entries , MigrationEntryStatus . MIGRATION_COMPLETE ) . length ;
const failed = MigrationEntries . getHaveStatus ( entries , MigrationEntryStatus . MIGRATION_FAILED ) . length ;
const aborted = MigrationEntries . getHaveStatus ( entries , MigrationEntryStatus . MIGRATION_ABORTED ) . length ;
return {
total ,
completed : success + failed + aborted ,
success ,
failed ,
} ;
}
2026-03-13 22:41:28 +01:00
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 ) ;
}
2026-06-18 18:52:59 +02:00
static useSearchProgress ( ) : MigrationProgress {
return useMigrationStore ( ( state ) = > MigrationManager . getSearchProgress ( state ) ) ;
2026-03-13 22:41:28 +01:00
}
2026-06-18 18:52:59 +02:00
static useMigrationProgress ( ) : MigrationProgress {
return useMigrationStore ( ( state ) = > MigrationManager . getMigrationProgress ( state ) ) ;
2026-03-13 22:41:28 +01:00
}
static useIsActive ( ) : boolean {
// Listen to phase changes
MigrationManager . usePhase ( ) ;
return MigrationManager . isActive ( ) ;
}
}