Mass migration implementation (#2110)

There is no way to trigger mass migration at the moment. The functionality will be added in a follow up PR.

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
jobobby04
2025-06-13 07:15:29 -04:00
committed by GitHub
parent 8de1fa854d
commit ee19050cc0
16 changed files with 1290 additions and 4 deletions

View File

@ -278,6 +278,9 @@ dependencies {
// Shizuku // Shizuku
implementation(libs.bundles.shizuku) implementation(libs.bundles.shizuku)
// String similarity
implementation(libs.stringSimilarity)
// Tests // Tests
testImplementation(libs.bundles.test) testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.platform.launcher)

View File

@ -11,6 +11,7 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen
import mihon.feature.migration.dialog.MigrateMangaDialog import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.feature.migration.list.MigrationListScreen
class MigrateSearchScreen(private val mangaId: Long) : Screen() { class MigrateSearchScreen(private val mangaId: Long) : Screen() {
@ -31,7 +32,18 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
onChangeSearchFilter = screenModel::setSourceFilter, onChangeSearchFilter = screenModel::setSourceFilter,
onToggleResults = screenModel::toggleFilterResults, onToggleResults = screenModel::toggleFilterResults,
onClickSource = { navigator.push(MigrateSourceSearchScreen(state.from!!, it.id, state.searchQuery)) }, onClickSource = { navigator.push(MigrateSourceSearchScreen(state.from!!, it.id, state.searchQuery)) },
onClickItem = { screenModel.setMigrateDialog(mangaId, it) }, onClickItem = {
val migrateListScreen = navigator.items
.filterIsInstance<MigrationListScreen>()
.lastOrNull()
if (migrateListScreen == null) {
screenModel.setMigrateDialog(mangaId, it)
} else {
migrateListScreen.addMatchOverride(current = mangaId, target = it.id)
navigator.popUntil { screen -> screen is MigrationListScreen }
}
},
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) }, onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
) )

View File

@ -29,6 +29,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mihon.feature.migration.dialog.MigrateMangaDialog import mihon.feature.migration.dialog.MigrateMangaDialog
import mihon.feature.migration.list.MigrationListScreen
import mihon.presentation.core.util.collectAsLazyPagingItems import mihon.presentation.core.util.collectAsLazyPagingItems
import tachiyomi.core.common.Constants import tachiyomi.core.common.Constants
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
@ -83,7 +84,16 @@ data class MigrateSourceSearchScreen(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { paddingValues -> ) { paddingValues ->
val openMigrateDialog: (Manga) -> Unit = { val openMigrateDialog: (Manga) -> Unit = {
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga)) val migrateListScreen = navigator.items
.filterIsInstance<MigrationListScreen>()
.lastOrNull()
if (migrateListScreen == null) {
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga))
} else {
migrateListScreen.addMatchOverride(current = currentManga.id, target = it.id)
navigator.popUntil { screen -> screen is MigrationListScreen }
}
} }
BrowseSourceContent( BrowseSourceContent(
source = screenModel.source, source = screenModel.source,

View File

@ -50,6 +50,7 @@ import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import mihon.feature.migration.list.MigrationListScreen
import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableCollectionItemScope
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.ReorderableLazyListState import sh.calvin.reorderable.ReorderableLazyListState
@ -69,7 +70,9 @@ import tachiyomi.presentation.core.util.shouldExpandFAB
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class MigrationConfigScreen(private val mangaId: Long) : Screen() { class MigrationConfigScreen(private val mangaIds: List<Long>) : Screen() {
constructor(mangaId: Long) : this(listOf(mangaId))
@Composable @Composable
override fun Content() { override fun Content() {
@ -81,7 +84,17 @@ class MigrationConfigScreen(private val mangaId: Long) : Screen() {
var migrationSheetOpen by rememberSaveable { mutableStateOf(false) } var migrationSheetOpen by rememberSaveable { mutableStateOf(false) }
fun continueMigration(openSheet: Boolean, extraSearchQuery: String?) { fun continueMigration(openSheet: Boolean, extraSearchQuery: String?) {
navigator.replace(MigrateSearchScreen(mangaId)) val mangaId = mangaIds.singleOrNull()
if (mangaId == null && openSheet) {
migrationSheetOpen = true
return
}
val screen = if (mangaId == null) {
MigrationListScreen(mangaIds, extraSearchQuery)
} else {
MigrateSearchScreen(mangaId)
}
navigator.replace(screen)
} }
if (state.isLoading) { if (state.isLoading) {

View File

@ -0,0 +1,107 @@
package mihon.feature.migration.list
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
import eu.kanade.tachiyomi.ui.manga.MangaScreen
import eu.kanade.tachiyomi.util.system.toast
import mihon.feature.migration.list.components.MigrationExitDialog
import mihon.feature.migration.list.components.MigrationMangaDialog
import mihon.feature.migration.list.components.MigrationProgressDialog
import tachiyomi.i18n.MR
class MigrationListScreen(private val mangaIds: List<Long>, private val extraSearchQuery: String?) : Screen() {
private var matchOverride: Pair<Long, Long>? by mutableStateOf(null)
fun addMatchOverride(current: Long, target: Long) {
matchOverride = current to target
}
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { MigrationListScreenModel(mangaIds, extraSearchQuery) }
val state by screenModel.state.collectAsState()
val context = LocalContext.current
LaunchedEffect(matchOverride) {
val (current, target) = matchOverride ?: return@LaunchedEffect
screenModel.useMangaForMigration(
current = current,
target = target,
onMissingChapters = {
context.toast(MR.strings.migrationListScreen_matchWithoutChapterToast, Toast.LENGTH_LONG)
},
)
matchOverride = null
}
LaunchedEffect(screenModel) {
screenModel.navigateBackEvent.collect {
navigator.pop()
}
}
MigrationListScreenContent(
items = state.items,
migrationComplete = state.migrationComplete,
finishedCount = state.finishedCount,
onItemClick = {
navigator.push(MangaScreen(it.id, true))
},
onSearchManually = { migrationItem ->
navigator push MigrateSearchScreen(migrationItem.manga.id)
},
onSkip = { screenModel.removeManga(it) },
onMigrate = { screenModel.migrateNow(mangaId = it, replace = true) },
onCopy = { screenModel.migrateNow(mangaId = it, replace = false) },
openMigrationDialog = screenModel::showMigrateDialog,
)
when (val dialog = state.dialog) {
is MigrationListScreenModel.Dialog.Migrate -> {
MigrationMangaDialog(
onDismissRequest = screenModel::dismissDialog,
copy = dialog.copy,
totalCount = dialog.totalCount,
skippedCount = dialog.skippedCount,
onMigrate = {
if (dialog.copy) {
screenModel.copyMangas()
} else {
screenModel.migrateMangas()
}
},
)
}
is MigrationListScreenModel.Dialog.Progress -> {
MigrationProgressDialog(
progress = dialog.progress,
exitMigration = screenModel::cancelMigrate,
)
}
MigrationListScreenModel.Dialog.Exit -> {
MigrationExitDialog(
onDismissRequest = screenModel::dismissDialog,
exitMigration = navigator::pop,
)
}
null -> Unit
}
BackHandler(true) {
screenModel.showExitDialog()
}
}
}

View File

@ -0,0 +1,375 @@
package mihon.feature.migration.list
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.CopyAll
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.manga.components.MangaCover
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.formatChapterNumber
import eu.kanade.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import mihon.feature.migration.list.models.MigratingManga
import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.Badge
import tachiyomi.presentation.core.components.BadgeGroup
import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.components.material.topSmallPaddingValues
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.plus
@Composable
fun MigrationListScreenContent(
items: ImmutableList<MigratingManga>,
migrationComplete: Boolean,
finishedCount: Int,
onItemClick: (Manga) -> Unit,
onSearchManually: (MigratingManga) -> Unit,
onSkip: (Long) -> Unit,
onMigrate: (Long) -> Unit,
onCopy: (Long) -> Unit,
openMigrationDialog: (Boolean) -> Unit,
) {
Scaffold(
topBar = { scrollBehavior ->
AppBar(
title = if (items.isNotEmpty()) {
stringResource(MR.strings.migrationListScreenTitleWithProgress, finishedCount, items.size)
} else {
stringResource(MR.strings.migrationListScreenTitle)
},
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(MR.strings.migrationListScreen_copyActionLabel),
icon = if (items.size == 1) Icons.Outlined.ContentCopy else Icons.Outlined.CopyAll,
onClick = { openMigrationDialog(true) },
enabled = migrationComplete,
),
AppBar.Action(
title = stringResource(MR.strings.migrationListScreen_migrateActionLabel),
icon = if (items.size == 1) Icons.Outlined.Done else Icons.Outlined.DoneAll,
onClick = { openMigrationDialog(false) },
enabled = migrationComplete,
),
),
)
},
scrollBehavior = scrollBehavior,
)
},
) { contentPadding ->
FastScrollLazyColumn(contentPadding = contentPadding + topSmallPaddingValues) {
items(items, key = { it.manga.id }) { item ->
Row(
Modifier
.fillMaxWidth()
.animateItemFastScroll()
.padding(
start = MaterialTheme.padding.medium,
end = MaterialTheme.padding.small,
)
.height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
MigrationListItem(
modifier = Modifier
.weight(1f)
.align(Alignment.Top)
.fillMaxHeight(),
manga = item.manga,
source = item.source,
chapterCount = item.chapterCount,
latestChapter = item.latestChapter,
onClick = { onItemClick(item.manga) },
)
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
contentDescription = null,
modifier = Modifier.weight(0.2f),
)
val result by item.searchResult.collectAsState()
MigrationListItemResult(
modifier = Modifier
.weight(1f)
.align(Alignment.Top)
.fillMaxHeight(),
result = result,
onItemClick = onItemClick,
)
MigrationListItemAction(
modifier = Modifier.weight(0.2f),
result = result,
onSearchManually = { onSearchManually(item) },
onSkip = { onSkip(item.manga.id) },
onMigrate = { onMigrate(item.manga.id) },
onCopy = { onCopy(item.manga.id) },
)
}
}
}
}
}
@Composable
fun MigrationListItem(
modifier: Modifier,
manga: Manga,
source: String,
chapterCount: Int,
latestChapter: Double?,
onClick: () -> Unit,
) {
Column(
modifier = modifier
.widthIn(max = 150.dp)
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onClick)
.padding(4.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio),
) {
MangaCover.Book(
modifier = Modifier.fillMaxWidth(),
data = manga,
)
Box(
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xAA000000),
),
)
.fillMaxHeight(0.33f)
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
Text(
modifier = Modifier
.padding(8.dp)
.align(Alignment.BottomStart),
text = manga.title,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
style = MaterialTheme.typography.labelMedium
.merge(shadow = Shadow(color = Color.Black, blurRadius = 4f)),
)
BadgeGroup(modifier = Modifier.padding(4.dp)) {
Badge(text = "$chapterCount")
}
}
Column(
modifier = Modifier
.padding(MaterialTheme.padding.extraSmall),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = source,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.titleSmall,
)
val formattedLatestChapters = remember(latestChapter) {
latestChapter?.let(::formatChapterNumber)
}
Text(
text = stringResource(
MR.strings.migrationListScreen_latestChapterLabel,
formattedLatestChapters ?: stringResource(MR.strings.migrationListScreen_unknownLatestChapter),
),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
fun MigrationListItemResult(
modifier: Modifier,
result: MigratingManga.SearchResult,
onItemClick: (Manga) -> Unit,
) {
Box(modifier.height(IntrinsicSize.Min)) {
when (result) {
MigratingManga.SearchResult.Searching -> {
Box(
modifier = Modifier
.widthIn(max = 150.dp)
.fillMaxSize()
.aspectRatio(MangaCover.Book.ratio),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
MigratingManga.SearchResult.NotFound -> {
Column(
Modifier
.widthIn(max = 150.dp)
.fillMaxSize()
.padding(4.dp),
) {
Image(
painter = rememberResourceBitmapPainter(id = R.drawable.cover_error),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(MangaCover.Book.ratio)
.clip(MaterialTheme.shapes.extraSmall),
contentScale = ContentScale.Crop,
)
Text(
text = stringResource(MR.strings.migrationListScreen_noMatchFoundText),
modifier = Modifier.padding(MaterialTheme.padding.extraSmall),
style = MaterialTheme.typography.titleSmall,
)
}
}
is MigratingManga.SearchResult.Success -> {
MigrationListItem(
modifier = Modifier.fillMaxSize(),
manga = result.manga,
source = result.source,
chapterCount = result.chapterCount,
latestChapter = result.latestChapter,
onClick = { onItemClick(result.manga) },
)
}
}
}
}
@Composable
private fun MigrationListItemAction(
modifier: Modifier,
result: MigratingManga.SearchResult,
onSearchManually: () -> Unit,
onSkip: () -> Unit,
onMigrate: () -> Unit,
onCopy: () -> Unit,
) {
var menuExpanded by rememberSaveable { mutableStateOf(false) }
val closeMenu = { menuExpanded = false }
Box(modifier) {
when (result) {
MigratingManga.SearchResult.Searching -> {
IconButton(onClick = onSkip) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = null,
)
}
}
MigratingManga.SearchResult.NotFound, is MigratingManga.SearchResult.Success -> {
IconButton(onClick = { menuExpanded = true }) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = null,
)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = closeMenu,
offset = DpOffset(8.dp, (-56).dp),
) {
DropdownMenuItem(
text = { Text(stringResource(MR.strings.migrationListScreen_searchManuallyActionLabel)) },
onClick = {
closeMenu()
onSearchManually()
},
)
DropdownMenuItem(
text = { Text(stringResource(MR.strings.migrationListScreen_skipActionLabel)) },
onClick = {
closeMenu()
onSkip()
},
)
if (result is MigratingManga.SearchResult.Success) {
DropdownMenuItem(
text = { Text(stringResource(MR.strings.migrationListScreen_migrateNowActionLabel)) },
onClick = {
closeMenu()
onMigrate()
},
)
DropdownMenuItem(
text = { Text(stringResource(MR.strings.migrationListScreen_copyNowActionLabel)) },
onClick = {
closeMenu()
onCopy()
},
)
}
}
}
}
}
}

View File

@ -0,0 +1,380 @@
package mihon.feature.migration.list
import androidx.annotation.FloatRange
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.toSManga
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import logcat.LogPriority
import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.feature.migration.list.models.MigratingManga
import mihon.feature.migration.list.models.MigratingManga.SearchResult
import mihon.feature.migration.list.search.SmartSourceSearchEngine
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MigrationListScreenModel(
mangaIds: List<Long>,
extraSearchQuery: String?,
private val preferences: SourcePreferences = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(),
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val migrateManga: MigrateMangaUseCase = Injekt.get(),
) : StateScreenModel<MigrationListScreenModel.State>(State()) {
private val smartSearchEngine = SmartSourceSearchEngine(extraSearchQuery)
val items
inline get() = state.value.items
private val hideUnmatched = preferences.migrationHideUnmatched().get()
private val hideWithoutUpdates = preferences.migrationHideWithoutUpdates().get()
private val navigateBackChannel = Channel<Unit>()
val navigateBackEvent = navigateBackChannel.receiveAsFlow()
private var migrateJob: Job? = null
init {
screenModelScope.launchIO {
val manga = mangaIds
.map {
async {
val manga = getManga.await(it) ?: return@async null
val chapterInfo = getChapterInfo(it)
MigratingManga(
manga = manga,
chapterCount = chapterInfo.chapterCount,
latestChapter = chapterInfo.latestChapter,
source = sourceManager.getOrStub(manga.source).getNameForMangaInfo(),
parentContext = screenModelScope.coroutineContext,
)
}
}
.awaitAll()
.filterNotNull()
mutableState.update { it.copy(items = manga.toImmutableList()) }
runMigrations(manga)
}
}
private suspend fun getChapterInfo(id: Long) = getChaptersByMangaId.await(id).let { chapters ->
ChapterInfo(
latestChapter = chapters.maxOfOrNull { it.chapterNumber },
chapterCount = chapters.size,
)
}
private suspend fun Manga.toSuccessSearchResult(): SearchResult.Success {
val chapterInfo = getChapterInfo(id)
val source = sourceManager.getOrStub(source).getNameForMangaInfo()
return SearchResult.Success(
manga = this,
chapterCount = chapterInfo.chapterCount,
latestChapter = chapterInfo.latestChapter,
source = source,
)
}
private suspend fun runMigrations(mangas: List<MigratingManga>) {
val prioritizeByChapters = preferences.migrationPrioritizeByChapters().get()
val deepSearchMode = preferences.migrationDeepSearchMode().get()
val sources = preferences.migrationSources().get()
.mapNotNull { sourceManager.get(it) as? CatalogueSource }
for (manga in mangas) {
if (!currentCoroutineContext().isActive) break
if (manga.manga.id !in state.value.mangaIds) continue
if (manga.searchResult.value != SearchResult.Searching) continue
if (!manga.migrationScope.isActive) continue
val result = try {
manga.migrationScope.async {
if (prioritizeByChapters) {
val sourceSemaphore = Semaphore(5)
sources.map { source ->
async innerAsync@{
sourceSemaphore.withPermit {
val result = searchSource(manga.manga, source, deepSearchMode)
if (result == null || result.second.chapterCount == 0) return@innerAsync null
result
}
}
}
.mapNotNull { it.await() }
.maxByOrNull { it.second.latestChapter ?: 0.0 }
} else {
sources.forEach { source ->
val result = searchSource(manga.manga, source, deepSearchMode)
if (result != null) return@async result
}
null
}
}
.await()
} catch (e: CancellationException) {
continue
}
if (result != null && result.first.thumbnailUrl == null) {
try {
val newManga = sourceManager.getOrStub(result.first.source).getMangaDetails(result.first.toSManga())
updateManga.awaitUpdateFromSource(result.first, newManga, true)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
}
}
manga.searchResult.value = result?.first?.toSuccessSearchResult() ?: SearchResult.NotFound
if (result == null && hideUnmatched) {
removeManga(manga)
}
if (result != null &&
hideWithoutUpdates &&
(result.second.latestChapter ?: 0.0) <= (manga.latestChapter ?: 0.0)
) {
removeManga(manga)
}
updateMigrationProgress()
}
}
private suspend fun searchSource(
manga: Manga,
source: CatalogueSource,
deepSearchMode: Boolean,
): Pair<Manga, ChapterInfo>? {
return try {
val searchResult = if (deepSearchMode) {
smartSearchEngine.deepSearch(source, manga.title)
} else {
smartSearchEngine.regularSearch(source, manga.title)
}
if (searchResult == null || !(searchResult.url == manga.url && source.id == manga.source)) return null
val localManga = networkToLocalManga(searchResult)
try {
val chapters = source.getChapterList(localManga.toSManga())
syncChaptersWithSource.await(chapters, localManga, source)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
localManga to getChapterInfo(localManga.id)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}
private suspend fun updateMigrationProgress() {
mutableState.update { state ->
state.copy(
finishedCount = items.count { it.searchResult.value != SearchResult.Searching },
migrationComplete = migrationComplete(),
)
}
if (items.isEmpty()) {
navigateBack()
}
}
private fun migrationComplete() = items.all { it.searchResult.value != SearchResult.Searching } &&
items.any { it.searchResult.value is SearchResult.Success }
fun useMangaForMigration(current: Long, target: Long, onMissingChapters: () -> Unit) {
val migratingManga = items.find { it.manga.id == current } ?: return
migratingManga.searchResult.value = SearchResult.Searching
screenModelScope.launchIO {
val result = migratingManga.migrationScope.async {
val manga = getManga.await(target) ?: return@async null
try {
val source = sourceManager.get(manga.source)!!
val chapters = source.getChapterList(manga.toSManga())
syncChaptersWithSource.await(chapters, manga, source)
} catch (_: Exception) {
return@async null
}
manga
}
.await()
if (result == null) {
migratingManga.searchResult.value = SearchResult.NotFound
withUIContext { onMissingChapters() }
return@launchIO
}
try {
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
updateManga.awaitUpdateFromSource(result, newManga, true)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
}
migratingManga.searchResult.value = result.toSuccessSearchResult()
}
}
fun migrateMangas() {
migrateMangas(replace = true)
}
fun copyMangas() {
migrateMangas(replace = false)
}
private fun migrateMangas(replace: Boolean) {
migrateJob = screenModelScope.launchIO {
mutableState.update { it.copy(dialog = Dialog.Progress(0f)) }
val items = items
try {
items.forEachIndexed { index, manga ->
try {
ensureActive()
val target = manga.searchResult.value.let {
if (it is SearchResult.Success) {
it.manga
} else {
null
}
}
if (target != null) {
migrateManga(current = manga.manga, target = target, replace = replace)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
logcat(LogPriority.WARN, throwable = e)
}
mutableState.update {
it.copy(dialog = Dialog.Progress((index.toFloat() / items.size).coerceAtMost(1f)))
}
}
navigateBack()
} finally {
mutableState.update { it.copy(dialog = null) }
migrateJob = null
}
}
}
fun cancelMigrate() {
migrateJob?.cancel()
migrateJob = null
}
private suspend fun navigateBack() {
navigateBackChannel.send(Unit)
}
fun migrateNow(mangaId: Long, replace: Boolean) {
screenModelScope.launchIO {
val manga = items.find { it.manga.id == mangaId } ?: return@launchIO
val target = (manga.searchResult.value as? SearchResult.Success)?.manga ?: return@launchIO
migrateManga(current = manga.manga, target = target, replace = replace)
removeManga(mangaId)
}
}
fun removeManga(mangaId: Long) {
screenModelScope.launchIO {
val item = items.find { it.manga.id == mangaId } ?: return@launchIO
removeManga(item)
item.migrationScope.cancel()
updateMigrationProgress()
}
}
private fun removeManga(item: MigratingManga) {
mutableState.update { it.copy(items = items.toPersistentList().remove(item)) }
}
override fun onDispose() {
super.onDispose()
items.forEach {
it.migrationScope.cancel()
}
}
fun showMigrateDialog(copy: Boolean) {
mutableState.update { state ->
state.copy(
dialog = Dialog.Migrate(
copy = copy,
totalCount = items.size,
skippedCount = items.count { it.searchResult.value == SearchResult.NotFound },
),
)
}
}
fun showExitDialog() {
mutableState.update {
it.copy(dialog = Dialog.Exit)
}
}
fun dismissDialog() {
mutableState.update { it.copy(dialog = null) }
}
data class ChapterInfo(
val latestChapter: Double?,
val chapterCount: Int,
)
sealed interface Dialog {
data class Migrate(val copy: Boolean, val totalCount: Int, val skippedCount: Int) : Dialog
data class Progress(@FloatRange(0.0, 1.0) val progress: Float) : Dialog
data object Exit : Dialog
}
data class State(
val items: ImmutableList<MigratingManga> = persistentListOf(),
val finishedCount: Int = 0,
val migrationComplete: Boolean = false,
val dialog: Dialog? = null,
) {
val mangaIds: List<Long> = items.map { it.manga.id }
}
}

View File

@ -0,0 +1,31 @@
package mihon.feature.migration.list.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun MigrationExitDialog(
onDismissRequest: () -> Unit,
exitMigration: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.migrationListScreen_exitDialogTitle))
},
confirmButton = {
TextButton(onClick = exitMigration) {
Text(text = stringResource(MR.strings.migrationListScreen_exitDialog_stopLabel))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.migrationListScreen_exitDialog_cancelLabel))
}
},
)
}

View File

@ -0,0 +1,64 @@
package mihon.feature.migration.list.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun MigrationMangaDialog(
onDismissRequest: () -> Unit,
copy: Boolean,
totalCount: Int,
skippedCount: Int,
onMigrate: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(
text = pluralStringResource(
resource = if (copy) {
MR.plurals.migrationListScreen_migrateDialog_copyTitle
} else {
MR.plurals.migrationListScreen_migrateDialog_migrateTitle
},
count = totalCount,
totalCount,
),
)
},
text = {
if (skippedCount > 0) {
Text(
text = pluralStringResource(
resource = MR.plurals.migrationListScreen_migrateDialog_skipText,
count = skippedCount,
skippedCount,
),
)
}
},
confirmButton = {
TextButton(onClick = onMigrate) {
Text(
text = stringResource(
resource = if (copy) {
MR.strings.migrationListScreen_migrateDialog_copyLabel
} else {
MR.strings.migrationListScreen_migrateDialog_migrateLabel
},
),
)
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.migrationListScreen_migrateDialog_cancelLabel))
}
},
)
}

View File

@ -0,0 +1,44 @@
package mihon.feature.migration.list.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.window.DialogProperties
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun MigrationProgressDialog(
progress: Float,
exitMigration: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
confirmButton = {
TextButton(onClick = exitMigration) {
Text(text = stringResource(MR.strings.migrationListScreen_progressDialog_cancelLabel))
}
},
text = {
if (!progress.isNaN()) {
val progressAnimated by animateFloatAsState(
targetValue = progress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "migration_progress",
)
LinearProgressIndicator(
progress = { progressAnimated },
)
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
),
)
}

View File

@ -0,0 +1,31 @@
package mihon.feature.migration.list.models
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import tachiyomi.domain.manga.model.Manga
import kotlin.coroutines.CoroutineContext
class MigratingManga(
val manga: Manga,
val chapterCount: Int,
val latestChapter: Double?,
val source: String,
parentContext: CoroutineContext,
) {
val migrationScope = CoroutineScope(parentContext + SupervisorJob() + Dispatchers.Default)
val searchResult = MutableStateFlow<SearchResult>(SearchResult.Searching)
sealed interface SearchResult {
data object Searching : SearchResult
data object NotFound : SearchResult
data class Success(
val manga: Manga,
val chapterCount: Int,
val latestChapter: Double?,
val source: String,
) : SearchResult
}
}

View File

@ -0,0 +1,152 @@
package mihon.feature.migration.list.search
import com.aallam.similarity.NormalizedLevenshtein
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.supervisorScope
import java.util.Locale
typealias SearchAction<T> = suspend (String) -> List<T>
abstract class BaseSmartSearchEngine<T>(
private val extraSearchParams: String? = null,
private val eligibleThreshold: Double = MIN_ELIGIBLE_THRESHOLD,
) {
private val normalizedLevenshtein = NormalizedLevenshtein()
protected abstract fun getTitle(result: T): String
protected suspend fun regularSearch(searchAction: SearchAction<T>, title: String): T? {
return baseSearch(searchAction, listOf(title)) {
normalizedLevenshtein.similarity(title, getTitle(it))
}
}
protected suspend fun deepSearch(searchAction: SearchAction<T>, title: String): T? {
val cleanedTitle = cleanDeepSearchTitle(title)
val queries = getDeepSearchQueries(cleanedTitle)
return baseSearch(searchAction, queries) {
val cleanedMangaTitle = cleanDeepSearchTitle(getTitle(it))
normalizedLevenshtein.similarity(cleanedTitle, cleanedMangaTitle)
}
}
private suspend fun baseSearch(
searchAction: SearchAction<T>,
queries: List<String>,
calculateDistance: (T) -> Double,
): T? {
val eligibleManga = supervisorScope {
queries.map { query ->
async(Dispatchers.Default) {
val builtQuery = if (extraSearchParams != null) {
"$query ${extraSearchParams.trim()}"
} else {
query
}
val candidates = searchAction(builtQuery)
candidates
.map {
val distance = if (queries.size > 1 || candidates.size > 1) {
calculateDistance(it)
} else {
1.0
}
SearchEntry(it, distance)
}
.filter { it.distance >= eligibleThreshold }
}
}
.flatMap { it.await() }
}
return eligibleManga.maxByOrNull { it.distance }?.entry
}
private fun cleanDeepSearchTitle(title: String): String {
val preTitle = title.lowercase(Locale.getDefault())
// Remove text in brackets
var cleanedTitle = removeTextInBrackets(preTitle, true)
if (cleanedTitle.length <= 5) { // Title is suspiciously short, try parsing it backwards
cleanedTitle = removeTextInBrackets(preTitle, false)
}
// Strip chapter reference RU
cleanedTitle = cleanedTitle.replace(chapterRefCyrillicRegexp, " ").trim()
// Strip non-special characters
val cleanedTitleEng = cleanedTitle.replace(titleRegex, " ")
// Do not strip foreign language letters if cleanedTitle is too short
cleanedTitle = if (cleanedTitleEng.length <= 5) {
cleanedTitle.replace(titleCyrillicRegex, " ")
} else {
cleanedTitleEng
}
// Strip splitters and consecutive spaces
cleanedTitle = cleanedTitle.trim().replace(" - ", " ").replace(consecutiveSpacesRegex, " ").trim()
return cleanedTitle
}
private fun removeTextInBrackets(text: String, readForward: Boolean): String {
val openingChars = if (readForward) "([<{ " else ")]}>"
val closingChars = if (readForward) ")]}>" else "([<{ "
var depth = 0
return buildString {
for (char in (if (readForward) text else text.reversed())) {
when (char) {
in openingChars -> depth++
in closingChars -> if (depth > 0) depth-- // Avoid depth going negative on mismatched closing
else -> if (depth == 0) {
// If reading backward, the result is reversed, so prepend
if (readForward) append(char) else insert(0, char)
}
}
}
}
}
private fun getDeepSearchQueries(cleanedTitle: String): List<String> {
val splitCleanedTitle = cleanedTitle.split(" ")
val splitSortedByLargest = splitCleanedTitle.sortedByDescending { it.length }
if (splitCleanedTitle.isEmpty()) {
return emptyList()
}
// Search cleaned title
// Search two largest words
// Search largest word
// Search first two words
// Search first word
val searchQueries = listOf(
listOf(cleanedTitle),
splitSortedByLargest.take(2),
splitSortedByLargest.take(1),
splitCleanedTitle.take(2),
splitCleanedTitle.take(1),
)
return searchQueries
.map { it.joinToString(" ").trim() }
.distinct()
}
companion object {
const val MIN_ELIGIBLE_THRESHOLD = 0.4
private val titleRegex = Regex("[^a-zA-Z0-9- ]")
private val titleCyrillicRegex = Regex("[^\\p{L}0-9- ]")
private val consecutiveSpacesRegex = Regex(" +")
private val chapterRefCyrillicRegexp = Regex("""((- часть|- глава) \d*)""")
}
}
data class SearchEntry<T>(val entry: T, val distance: Double)

View File

@ -0,0 +1,28 @@
package mihon.feature.migration.list.search
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.SManga
import mihon.domain.manga.model.toDomainManga
import tachiyomi.domain.manga.model.Manga
class SmartSourceSearchEngine(extraSearchParams: String?) : BaseSmartSearchEngine<SManga>(extraSearchParams) {
override fun getTitle(result: SManga) = result.title
suspend fun regularSearch(source: CatalogueSource, title: String): Manga? {
return regularSearch(makeSearchAction(source), title).let {
it?.toDomainManga(source.id)
}
}
suspend fun deepSearch(source: CatalogueSource, title: String): Manga? {
return deepSearch(makeSearchAction(source), title).let {
it?.toDomainManga(source.id)
}
}
private fun makeSearchAction(source: CatalogueSource): SearchAction<SManga> = { query ->
source.getSearchManga(1, query, FilterList()).mangas
}
}

View File

@ -106,6 +106,8 @@ ktlint-core = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlin
markdown-core = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdown" } markdown-core = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdown" }
markdown-coil = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "markdown" } markdown-coil = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "markdown" }
stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin", version = "0.1.0" }
[plugins] [plugins]
google-services = { id = "com.google.gms.google-services", version = "4.4.2" } google-services = { id = "com.google.gms.google-services", version = "4.4.2" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlib_version" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlib_version" }

View File

@ -95,4 +95,18 @@
<item quantity="one">%d repo</item> <item quantity="one">%d repo</item>
<item quantity="other">%d repos</item> <item quantity="other">%d repos</item>
</plurals> </plurals>
<!--Migration-->
<plurals name="migrationListScreen.migrateDialog.migrateTitle">
<item quantity="one">Migrate %1$d entry?</item>
<item quantity="other">Migrate %1$d entries?</item>
</plurals>
<plurals name="migrationListScreen.migrateDialog.copyTitle">
<item quantity="one">Copy %1$d entry?</item>
<item quantity="other">Copy %1$d entries?</item>
</plurals>
<plurals name="migrationListScreen.migrateDialog.skipText">
<item quantity="one">An entry was skipped</item>
<item quantity="other">%1$d entries were skipped</item>
</plurals>
</resources> </resources>

View File

@ -1016,4 +1016,24 @@
<string name="migrationConfigScreen.deepSearchModeSubtitle">Breaks down the title into keywords for a wider search</string> <string name="migrationConfigScreen.deepSearchModeSubtitle">Breaks down the title into keywords for a wider search</string>
<string name="migrationConfigScreen.prioritizeByChaptersTitle">Match based on chapter number</string> <string name="migrationConfigScreen.prioritizeByChaptersTitle">Match based on chapter number</string>
<string name="migrationConfigScreen.prioritizeByChaptersSubtitle">If enabled, chooses the match furthest ahead. Otherwise, picks the first match by source priority.</string> <string name="migrationConfigScreen.prioritizeByChaptersSubtitle">If enabled, chooses the match furthest ahead. Otherwise, picks the first match by source priority.</string>
<string name="migrationListScreenTitle">Migration</string>
<string name="migrationListScreenTitleWithProgress">Migration (%1$d/%2$d)</string>
<string name="migrationListScreen.copyActionLabel">Copy</string>
<string name="migrationListScreen.migrateActionLabel">Migrate</string>
<string name="migrationListScreen.noMatchFoundText">No alternatives found</string>
<string name="migrationListScreen.latestChapterLabel">Latest: %1$s</string>
<string name="migrationListScreen.unknownLatestChapter">Unknown</string>
<string name="migrationListScreen.searchManuallyActionLabel">Search manually</string>
<string name="migrationListScreen.skipActionLabel">Don\'t migrate</string>
<string name="migrationListScreen.migrateNowActionLabel">Migrate now</string>
<string name="migrationListScreen.copyNowActionLabel">Copy now</string>
<string name="migrationListScreen.copyNowActionLabel">Copy now</string>
<string name="migrationListScreen.exitDialogTitle">Stop migrating?</string>
<string name="migrationListScreen.exitDialog.stopLabel">Stop</string>
<string name="migrationListScreen.exitDialog.cancelLabel">Cancel</string>
<string name="migrationListScreen.migrateDialog.copyLabel">Copy</string>
<string name="migrationListScreen.migrateDialog.migrateLabel">Migrate</string>
<string name="migrationListScreen.migrateDialog.cancelLabel">Cancel</string>
<string name="migrationListScreen.progressDialog.cancelLabel">Cancel</string>
<string name="migrationListScreen.matchWithoutChapterToast">No chapters found, this entry cannot be used for migration</string>
</resources> </resources>