Rewrite migration, shove all logic into a presenter instead of the UI

This commit is contained in:
Jobobby04 2022-09-04 19:10:30 -04:00
parent 6e1e42fefd
commit 96f24e0600
12 changed files with 768 additions and 759 deletions

View File

@ -381,7 +381,7 @@ private fun MangaScreenSmallImpl(
title = state.manga.title, title = state.manga.title,
author = state.manga.author, author = state.manga.author,
artist = state.manga.artist, artist = state.manga.artist,
sourceName = remember { state.source.getNameForMangaInfo(state.mergedData) }, sourceName = remember { state.source.getNameForMangaInfo(state.mergedData?.sources) },
isStubSource = remember { state.source is SourceManager.StubSource }, isStubSource = remember { state.source is SourceManager.StubSource },
coverDataProvider = { state.manga }, coverDataProvider = { state.manga },
status = state.manga.status, status = state.manga.status,
@ -662,7 +662,7 @@ fun MangaScreenLargeImpl(
title = state.manga.title, title = state.manga.title,
author = state.manga.author, author = state.manga.author,
artist = state.manga.artist, artist = state.manga.artist,
sourceName = remember { state.source.getNameForMangaInfo(state.mergedData) }, sourceName = remember { state.source.getNameForMangaInfo(state.mergedData?.sources) },
isStubSource = remember { state.source is SourceManager.StubSource }, isStubSource = remember { state.source is SourceManager.StubSource },
coverDataProvider = { state.manga }, coverDataProvider = { state.manga },
status = state.manga.status, status = state.manga.status,

View File

@ -7,7 +7,6 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.manga.MergedMangaData
import eu.kanade.tachiyomi.util.lang.awaitSingle import eu.kanade.tachiyomi.util.lang.awaitSingle
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -96,7 +95,7 @@ fun Source.getPreferenceKey(): String = "source_$id"
fun Source.toSourceData(): SourceData = SourceData(id = id, lang = lang, name = name) fun Source.toSourceData(): SourceData = SourceData(id = id, lang = lang, name = name)
fun Source.getNameForMangaInfo(mergeData: MergedMangaData?): String { fun Source.getNameForMangaInfo(mergeSources: List<Source>?): String {
val preferences = Injekt.get<PreferencesHelper>() val preferences = Injekt.get<PreferencesHelper>()
val enabledLanguages = preferences.enabledLanguages().get() val enabledLanguages = preferences.enabledLanguages().get()
.filterNot { it in listOf("all", "other") } .filterNot { it in listOf("all", "other") }
@ -104,8 +103,8 @@ fun Source.getNameForMangaInfo(mergeData: MergedMangaData?): String {
val isInEnabledLanguages = lang in enabledLanguages val isInEnabledLanguages = lang in enabledLanguages
return when { return when {
// SY --> // SY -->
mergeData != null -> getMergedSourcesString( !mergeSources.isNullOrEmpty() -> getMergedSourcesString(
mergeData, mergeSources,
enabledLanguages, enabledLanguages,
hasOneActiveLanguages, hasOneActiveLanguages,
) )
@ -120,12 +119,12 @@ fun Source.getNameForMangaInfo(mergeData: MergedMangaData?): String {
// SY --> // SY -->
private fun getMergedSourcesString( private fun getMergedSourcesString(
mergeData: MergedMangaData, mergeSources: List<Source>,
enabledLangs: List<String>, enabledLangs: List<String>,
onlyName: Boolean, onlyName: Boolean,
): String { ): String {
return if (onlyName) { return if (onlyName) {
mergeData.sources.joinToString { source -> mergeSources.joinToString { source ->
if (source.lang !in enabledLangs) { if (source.lang !in enabledLangs) {
source.toString() source.toString()
} else { } else {
@ -133,7 +132,7 @@ private fun getMergedSourcesString(
} }
} }
} else { } else {
mergeData.sources.joinToString() mergeSources.joinToString()
} }
} }
// SY <-- // SY <--

View File

@ -31,6 +31,13 @@ class PreMigrationController(bundle: Bundle? = null) :
FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemClickListener,
FabController, FabController,
StartMigrationListener { StartMigrationListener {
constructor(mangaIds: List<Long>) : this(
bundleOf(
MANGA_IDS_EXTRA to mangaIds.toLongArray(),
),
)
private val sourceManager: SourceManager by injectLazy() private val sourceManager: SourceManager by injectLazy()
private val prefs: PreferencesHelper by injectLazy() private val prefs: PreferencesHelper by injectLazy()
@ -95,7 +102,7 @@ class PreMigrationController(bundle: Bundle? = null) :
prefs.migrationSources().set(listOfSources) prefs.migrationSources().set(listOfSources)
router.replaceTopController( router.replaceTopController(
MigrationListController.create( MigrationListController(
MigrationProcedureConfig( MigrationProcedureConfig(
config.toList(), config.toList(),
extraSearchParams = extraParam, extraSearchParams = extraParam,
@ -186,21 +193,13 @@ class PreMigrationController(bundle: Bundle? = null) :
fun navigateToMigration(skipPre: Boolean, router: Router, mangaIds: List<Long>) { fun navigateToMigration(skipPre: Boolean, router: Router, mangaIds: List<Long>) {
router.pushController( router.pushController(
if (skipPre) { if (skipPre) {
MigrationListController.create( MigrationListController(
MigrationProcedureConfig(mangaIds, null), MigrationProcedureConfig(mangaIds, null),
) )
} else { } else {
create(mangaIds) PreMigrationController(mangaIds)
}.withFadeTransaction().tag(if (skipPre) MigrationListController.TAG else null), }.withFadeTransaction().tag(if (skipPre) MigrationListController.TAG else null),
) )
} }
fun create(mangaIds: List<Long>): PreMigrationController {
return PreMigrationController(
bundleOf(
MANGA_IDS_EXTRA to mangaIds.toLongArray(),
),
)
}
} }
} }

View File

@ -1,40 +1,38 @@
package eu.kanade.tachiyomi.ui.browse.migration.advanced.process package eu.kanade.tachiyomi.ui.browse.migration.advanced.process
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.source.Source import kotlinx.coroutines.CoroutineScope
import eu.kanade.tachiyomi.source.SourceManager
import exh.util.DeferredField
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class MigratingManga( class MigratingManga(
private val getManga: GetManga, val manga: Manga,
private val sourceManager: SourceManager, val chapterInfo: ChapterInfo,
val mangaId: Long, val sourcesString: String,
parentContext: CoroutineContext, parentContext: CoroutineContext,
val getManga: suspend (SearchResult.Result) -> Manga?,
val getChapterInfo: suspend (SearchResult.Result) -> ChapterInfo,
val getSourceName: (Manga) -> String?,
) { ) {
val searchResult = DeferredField<Long?>() val migrationScope = CoroutineScope(parentContext + SupervisorJob() + Dispatchers.Default)
val searchResult = MutableStateFlow<SearchResult>(SearchResult.Searching)
// <MAX, PROGRESS> // <MAX, PROGRESS>
val progress = MutableStateFlow(1 to 0) val progress = MutableStateFlow(1 to 0)
val migrationJob = parentContext + SupervisorJob() + Dispatchers.Default sealed class SearchResult {
object Searching : SearchResult()
var migrationStatus = MigrationStatus.RUNNING object NotFound : SearchResult()
data class Result(val id: Long) : SearchResult()
@Volatile
private var manga: Manga? = null
suspend fun manga(): Manga? {
if (manga == null) manga = getManga.await(mangaId)
return manga
} }
suspend fun mangaSource(): Source { data class ChapterInfo(
return sourceManager.getOrStub(manga()?.source ?: -1) val latestChapter: Float?,
} val chapterCount: Int,
)
fun toModal(): MigrationProcessItem { fun toModal(): MigrationProcessItem {
// Create the model object. // Create the model object.

View File

@ -8,57 +8,39 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.Toast
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.chrisbanes.insetter.applyInsetter import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.MigrationListControllerBinding import eu.kanade.tachiyomi.databinding.MigrationListControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.ui.base.changehandler.OneWayFadeChangeHandler import eu.kanade.tachiyomi.ui.base.changehandler.OneWayFadeChangeHandler
import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.MigrationMangaDialog import eu.kanade.tachiyomi.ui.browse.migration.MigrationMangaDialog
import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationController import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationController
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.SearchResult
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.getParcelableCompat import eu.kanade.tachiyomi.util.system.getParcelableCompat
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import exh.eh.EHentaiThrottleManager import kotlinx.coroutines.flow.launchIn
import exh.smartsearch.SmartSearchEngine import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import logcat.LogPriority
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.atomic.AtomicInteger
class MigrationListController(bundle: Bundle? = null) : class MigrationListController(bundle: Bundle? = null) :
BaseController<MigrationListControllerBinding>(bundle), NucleusController<MigrationListControllerBinding, MigrationListPresenter>(bundle) {
MigrationProcessAdapter.MigrationProcessInterface {
constructor(config: MigrationProcedureConfig) : this(
bundleOf(
CONFIG_EXTRA to config,
),
)
init { init {
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -68,27 +50,19 @@ class MigrationListController(bundle: Bundle? = null) :
val config = args.getParcelableCompat<MigrationProcedureConfig>(CONFIG_EXTRA) val config = args.getParcelableCompat<MigrationProcedureConfig>(CONFIG_EXTRA)
private val preferences: PreferencesHelper by injectLazy() private var selectedMangaId: Long? = null
private val sourceManager: SourceManager by injectLazy()
private val smartSearchEngine = SmartSearchEngine(config?.extraSearchParams)
private val syncChaptersWithSource: SyncChaptersWithSource by injectLazy()
private val getManga: GetManga by injectLazy()
private val updateManga: UpdateManga by injectLazy()
private val migrationScope = CoroutineScope(Job() + Dispatchers.IO)
var migrationsJob: Job? = null
private set
private var migratingManga: MutableList<MigratingManga>? = null
private var selectedPosition: Int? = null
private var manualMigrations = 0 private var manualMigrations = 0
private val throttleManager = EHentaiThrottleManager()
override fun getTitle(): String { override fun getTitle(): String {
return resources?.getString(R.string.migration) + " (${adapter?.items?.count { val notFinished = presenter.migratingItems.value.count {
it.manga.migrationStatus != MigrationStatus.RUNNING it.searchResult.value != SearchResult.Searching
}}/${adapter?.itemCount ?: 0})" }
val total = presenter.migratingItems.value.size
return activity?.getString(R.string.migration) + " ($notFinished/$total)"
}
override fun createPresenter(): MigrationListPresenter {
return MigrationListPresenter(config!!)
} }
override fun createBinding(inflater: LayoutInflater) = MigrationListControllerBinding.inflate(inflater) override fun createBinding(inflater: LayoutInflater) = MigrationListControllerBinding.inflate(inflater)
@ -103,15 +77,6 @@ class MigrationListController(bundle: Bundle? = null) :
} }
setTitle() setTitle()
val config = this.config ?: return
val newMigratingManga = migratingManga ?: run {
val new = config.mangaIds.map {
MigratingManga(getManga, sourceManager, it, migrationScope.coroutineContext)
}
migratingManga = new.toMutableList()
new
}
adapter = MigrationProcessAdapter(this) adapter = MigrationProcessAdapter(this)
@ -119,308 +84,112 @@ class MigrationListController(bundle: Bundle? = null) :
binding.recycler.layoutManager = LinearLayoutManager(view.context) binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.setHasFixedSize(true) binding.recycler.setHasFixedSize(true)
adapter?.updateDataSet(newMigratingManga.map { it.toModal() }) presenter.migratingItems
.onEach {
if (migrationsJob == null) { adapter?.updateDataSet(it.map { it.toModal() })
migrationsJob = migrationScope.launch {
runMigrations(newMigratingManga)
} }
} .launchIn(viewScope)
} }
private suspend fun runMigrations(mangas: List<MigratingManga>) { fun updateCount() {
throttleManager.resetThrottle() if (router.backstack.lastOrNull()?.controller == this@MigrationListController) {
if (config == null) return setTitle()
val useSourceWithMost = preferences.useSourceWithMost().get()
val useSmartSearch = preferences.smartMigration().get()
val sources = preferences.migrationSources().get().split("/").mapNotNull {
val value = it.toLongOrNull() ?: return@mapNotNull null
sourceManager.get(value) as? CatalogueSource
}
for (manga in mangas) {
if (migrationsJob?.isCancelled == true) {
break
}
// in case it was removed
if (manga.mangaId !in config.mangaIds) {
continue
}
if (!manga.searchResult.initialized && manga.migrationJob.isActive) {
val mangaObj = manga.manga()
if (mangaObj == null) {
manga.searchResult.initialize(null)
continue
}
val mangaSource = manga.mangaSource()
val result = try {
CoroutineScope(manga.migrationJob).async {
val validSources = if (sources.size == 1) {
sources
} else {
sources.filter { it.id != mangaSource.id }
}
if (useSourceWithMost) {
val sourceSemaphore = Semaphore(3)
val processedSources = AtomicInteger()
validSources.map { source ->
async async2@{
sourceSemaphore.withPermit {
try {
val searchResult = if (useSmartSearch) {
smartSearchEngine.smartSearch(source, mangaObj.ogTitle)
} else {
smartSearchEngine.normalSearch(source, mangaObj.ogTitle)
}
if (searchResult != null && !(searchResult.url == mangaObj.url && source.id == mangaObj.source)) {
val localManga = smartSearchEngine.networkToLocalManga(
searchResult,
source.id,
)
val chapters = if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
source.getChapterList(localManga.toSManga())
}
try {
syncChaptersWithSource.await(chapters, localManga, source)
} catch (e: Exception) {
return@async2 null
}
manga.progress.value = validSources.size to processedSources.incrementAndGet()
localManga to chapters.size
} else {
null
}
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
null
}
}
}
}.mapNotNull { it.await() }.maxByOrNull { it.second }?.first
} else {
validSources.forEachIndexed { index, source ->
val searchResult = try {
val searchResult = if (useSmartSearch) {
smartSearchEngine.smartSearch(source, mangaObj.ogTitle)
} else {
smartSearchEngine.normalSearch(source, mangaObj.ogTitle)
}
if (searchResult != null) {
val localManga = smartSearchEngine.networkToLocalManga(searchResult, source.id)
val chapters = try {
if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
source.getChapterList(localManga.toSManga())
}
} catch (e: Exception) {
this@MigrationListController.logcat(LogPriority.ERROR, e)
emptyList()
}
syncChaptersWithSource.await(chapters, localManga, source)
localManga
} else null
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
null
}
manga.progress.value = validSources.size to (index + 1)
if (searchResult != null) return@async searchResult
}
null
}
}.await()
} catch (e: CancellationException) {
// Ignore canceled migrations
continue
}
if (result != null && result.thumbnailUrl == null) {
try {
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
updateManga.awaitUpdateFromSource(result, newManga, true)
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
}
}
manga.migrationStatus = if (result == null) MigrationStatus.MANGA_NOT_FOUND else MigrationStatus.MANGA_FOUND
adapter?.sourceFinished()
manga.searchResult.initialize(result?.id)
}
}
}
override fun updateCount() {
launchUI {
if (router.backstack.lastOrNull()?.controller == this@MigrationListController) {
setTitle()
}
} }
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
migrationScope.cancel()
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} }
override fun enableButtons() { private fun enableButtons() {
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
override fun removeManga(item: MigrationProcessItem) { private fun noMigration() {
val ids = config?.mangaIds?.toMutableList() ?: return val res = resources
val index = ids.indexOf(item.manga.mangaId) if (res != null) {
if (index > -1) { activity?.toast(
ids.removeAt(index) res.getQuantityString(
config.mangaIds = ids R.plurals.manga_migrated,
val index2 = migratingManga?.indexOf(item.manga) ?: return manualMigrations,
if (index2 > -1) migratingManga?.removeAt(index2) manualMigrations,
),
)
}
if (!presenter.hideNotFound) {
router.popCurrentController()
} }
} }
override fun noMigration() { fun onMenuItemClick(mangaId: Long, item: MenuItem) {
launchUI {
val res = resources
if (res != null) {
activity?.toast(
res.getQuantityString(
R.plurals.manga_migrated,
manualMigrations,
manualMigrations,
),
)
}
if (adapter?.hideNotFound == false) {
router.popCurrentController()
}
}
}
override fun onMenuItemClick(position: Int, item: MenuItem) {
when (item.itemId) { when (item.itemId) {
R.id.action_search_manually -> { R.id.action_search_manually -> {
launchUI { val manga = presenter.migratingItems.value
val manga = adapter?.getItem(position)?.manga?.manga() ?: return@launchUI .find { it.manga.id == mangaId }
selectedPosition = position ?.manga
val sources = preferences.migrationSources().get().split("/").mapNotNull { ?: return
val value = it.toLongOrNull() ?: return@mapNotNull null selectedMangaId = mangaId
sourceManager.get(value) as? CatalogueSource val sources = presenter.getMigrationSources()
} val validSources = if (sources.size == 1) {
val validSources = if (sources.size == 1) { sources
sources } else {
} else { sources.filter { it.id != manga.source }
sources.filter { it.id != manga.source }
}
val searchController = SearchController(manga, validSources)
searchController.targetController = this@MigrationListController
router.pushController(searchController)
} }
val searchController = SearchController(manga, validSources)
searchController.targetController = this@MigrationListController
router.pushController(searchController)
} }
R.id.action_skip -> adapter?.removeManga(position) R.id.action_skip -> presenter.removeManga(mangaId)
R.id.action_migrate_now -> { R.id.action_migrate_now -> {
adapter?.migrateManga(position, false) migrateManga(mangaId, false)
manualMigrations++ manualMigrations++
} }
R.id.action_copy_now -> { R.id.action_copy_now -> {
adapter?.migrateManga(position, true) migrateManga(mangaId, true)
manualMigrations++ manualMigrations++
} }
} }
} }
fun useMangaForMigration(manga: Manga, source: Source) { fun useMangaForMigration(manga: Manga, source: Source) {
val firstIndex = selectedPosition ?: return presenter.useMangaForMigration(manga, source, selectedMangaId ?: return)
val migratingManga = adapter?.getItem(firstIndex) ?: return
migratingManga.manga.migrationStatus = MigrationStatus.RUNNING
adapter?.notifyItemChanged(firstIndex)
launchUI {
val result = CoroutineScope(migratingManga.manga.migrationJob).async {
val localManga = smartSearchEngine.networkToLocalManga(manga.toDbManga(), source.id)
try {
val chapters = source.getChapterList(localManga.toSManga())
syncChaptersWithSource.await(chapters, localManga, source)
} catch (e: Exception) {
return@async null
}
localManga
}.await()
if (result != null) {
try {
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
updateManga.awaitUpdateFromSource(result, newManga, true)
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
}
migratingManga.manga.migrationStatus = MigrationStatus.MANGA_FOUND
migratingManga.manga.searchResult.set(result.id)
adapter?.notifyDataSetChanged()
} else {
migratingManga.manga.migrationStatus = MigrationStatus.MANGA_NOT_FOUND
activity?.toast(R.string.no_chapters_found_for_migration, Toast.LENGTH_LONG)
adapter?.notifyDataSetChanged()
}
}
} }
fun migrateMangas() { fun migrateMangas() {
launchUI { presenter.migrateMangas()
adapter?.performMigrations(false)
navigateOut()
}
} }
fun copyMangas() { fun copyMangas() {
launchUI { presenter.copyMangas()
adapter?.performMigrations(true)
navigateOut()
}
} }
private fun navigateOut() { fun migrateManga(mangaId: Long, copy: Boolean) {
if (migratingManga?.size == 1) { presenter.migrateManga(mangaId, copy)
launchUI { }
val hasDetails = router.backstack.any { it.controller is MangaController }
if (hasDetails) { fun removeManga(mangaId: Long) {
val manga = migratingManga?.firstOrNull()?.searchResult?.get()?.let { presenter.removeManga(mangaId)
getManga.await(it) }
}
if (manga != null) { fun sourceFinished() {
val newStack = router.backstack.filter { updateCount()
it.controller !is MangaController && if (presenter.migratingItems.value.isEmpty()) noMigration()
it.controller !is MigrationListController && if (presenter.allMangasDone()) enableButtons()
it.controller !is PreMigrationController }
} + MangaController(manga.id).withFadeTransaction()
router.setBackstack(newStack, OneWayFadeChangeHandler()) fun navigateOut(manga: Manga?) {
return@launchUI if (manga != null) {
} val newStack = router.backstack.filter {
} it.controller !is MangaController &&
router.popCurrentController() it.controller !is MigrationListController &&
} it.controller !is PreMigrationController
} else router.popCurrentController() } + MangaController(manga.id).withFadeTransaction()
router.setBackstack(newStack, OneWayFadeChangeHandler())
return
}
router.popCurrentController()
} }
override fun handleBack(): Boolean { override fun handleBack(): Boolean {
@ -429,7 +198,6 @@ class MigrationListController(bundle: Bundle? = null) :
.setTitle(R.string.stop_migrating) .setTitle(R.string.stop_migrating)
.setPositiveButton(R.string.action_stop) { _, _ -> .setPositiveButton(R.string.action_stop) { _, _ ->
router.popCurrentController() router.popCurrentController()
migrationsJob?.cancel()
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
@ -444,12 +212,12 @@ class MigrationListController(bundle: Bundle? = null) :
override fun onPrepareOptionsMenu(menu: Menu) { override fun onPrepareOptionsMenu(menu: Menu) {
// Initialize menu items. // Initialize menu items.
val allMangasDone = adapter?.allMangasDone() ?: return val allMangasDone = presenter.allMangasDone()
val menuCopy = menu.findItem(R.id.action_copy_manga) val menuCopy = menu.findItem(R.id.action_copy_manga)
val menuMigrate = menu.findItem(R.id.action_migrate_manga) val menuMigrate = menu.findItem(R.id.action_migrate_manga)
if (adapter?.itemCount == 1) { if (presenter.migratingItems.value.size == 1) {
menuMigrate.icon = VectorDrawableCompat.create( menuMigrate.icon = VectorDrawableCompat.create(
resources!!, resources!!,
R.drawable.ic_done_24dp, R.drawable.ic_done_24dp,
@ -474,8 +242,8 @@ class MigrationListController(bundle: Bundle? = null) :
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
val totalManga = adapter?.itemCount ?: 0 val totalManga = presenter.migratingItems.value.size
val mangaSkipped = adapter?.mangasSkipped() ?: 0 val mangaSkipped = presenter.mangasSkipped()
when (item.itemId) { when (item.itemId) {
R.id.action_copy_manga -> MigrationMangaDialog( R.id.action_copy_manga -> MigrationMangaDialog(
this, this,
@ -493,34 +261,9 @@ class MigrationListController(bundle: Bundle? = null) :
} }
return true return true
} }
/*
override fun canChangeTabs(block: () -> Unit): Boolean {
if (migrationsJob?.isCancelled == false || adapter?.allMangasDone() == true) {
activity?.let {
MaterialDialog(it).show {
title(R.string.stop_migrating)
positiveButton(R.string.action_stop) {
block()
migrationsJob?.cancel()
}
negativeButton(android.R.string.cancel)
}
}
return false
}
return true
}*/
companion object { companion object {
const val CONFIG_EXTRA = "config_extra" const val CONFIG_EXTRA = "config_extra"
const val TAG = "migration_list" const val TAG = "migration_list"
fun create(config: MigrationProcedureConfig): MigrationListController {
return MigrationListController(
bundleOf(
CONFIG_EXTRA to config,
),
)
}
} }
} }

View File

@ -0,0 +1,546 @@
package eu.kanade.tachiyomi.ui.browse.migration.advanced.process
import android.os.Bundle
import android.widget.Toast
import eu.kanade.domain.category.interactor.GetCategories
import eu.kanade.domain.category.interactor.SetMangaCategories
import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
import eu.kanade.domain.chapter.interactor.UpdateChapter
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.domain.chapter.model.ChapterUpdate
import eu.kanade.domain.history.interactor.GetHistoryByMangaId
import eu.kanade.domain.history.interactor.UpsertHistory
import eu.kanade.domain.history.model.HistoryUpdate
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.interactor.GetMergedReferencesById
import eu.kanade.domain.manga.interactor.InsertManga
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.MangaUpdate
import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.track.interactor.DeleteTrack
import eu.kanade.domain.track.interactor.GetTracks
import eu.kanade.domain.track.interactor.InsertTrack
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.toDomainManga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.SearchResult
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast
import exh.eh.EHentaiThrottleManager
import exh.smartsearch.SmartSearchEngine
import exh.source.MERGED_SOURCE_ID
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import logcat.LogPriority
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.atomic.AtomicInteger
class MigrationListPresenter(
private val config: MigrationProcedureConfig,
private val preferences: PreferencesHelper = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
private val insertManga: InsertManga = Injekt.get(),
private val updateManga: UpdateManga = Injekt.get(),
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(),
private val getMergedReferencesById: GetMergedReferencesById = Injekt.get(),
private val getHistoryByMangaId: GetHistoryByMangaId = Injekt.get(),
private val upsertHistory: UpsertHistory = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(),
private val setMangaCategories: SetMangaCategories = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(),
private val insertTrack: InsertTrack = Injekt.get(),
private val deleteTrack: DeleteTrack = Injekt.get(),
) : BasePresenter<MigrationListController>() {
private val smartSearchEngine = SmartSearchEngine(config.extraSearchParams)
private val throttleManager = EHentaiThrottleManager()
var migrationsJob: Job? = null
private set
val migratingItems = MutableStateFlow<List<MigratingManga>>(emptyList())
val hideNotFound = preferences.hideNotFoundMigration().get()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
if (migrationsJob?.isActive != true) {
migrationsJob = presenterScope.launchIO {
runMigrations(
config.mangaIds
.map {
async {
val manga = getManga.await(it) ?: return@async null
MigratingManga(
manga = manga,
chapterInfo = getChapterInfo(it),
sourcesString = sourceManager.getOrStub(manga.source).getNameForMangaInfo(
if (manga.source == MERGED_SOURCE_ID) {
getMergedReferencesById.await(manga.id)
.map { sourceManager.getOrStub(it.mangaSourceId) }
} else null,
),
parentContext = presenterScope.coroutineContext,
getManga = ::getManga,
getChapterInfo = ::getChapterInfo,
getSourceName = ::getSourceName,
)
}
}
.awaitAll()
.filterNotNull()
.also {
migratingItems.value = it
},
)
}
}
}
private suspend fun getManga(result: SearchResult.Result) = getManga.await(result.id)
private suspend fun getChapterInfo(result: SearchResult.Result) = getChapterInfo(result.id)
private suspend fun getChapterInfo(id: Long) = getChapterByMangaId.await(id).let { chapters ->
MigratingManga.ChapterInfo(
latestChapter = chapters.maxOfOrNull { it.chapterNumber },
chapterCount = chapters.size,
)
}
private fun getSourceName(manga: Manga) = sourceManager.getOrStub(manga.source).getNameForMangaInfo(null)
fun getMigrationSources() = preferences.migrationSources().get().split("/").mapNotNull {
val value = it.toLongOrNull() ?: return@mapNotNull null
sourceManager.get(value) as? CatalogueSource
}
private suspend fun runMigrations(mangas: List<MigratingManga>) {
throttleManager.resetThrottle()
val useSourceWithMost = preferences.useSourceWithMost().get()
val useSmartSearch = preferences.smartMigration().get()
val sources = getMigrationSources()
for (manga in mangas) {
if (migrationsJob?.isCancelled == true) {
break
}
// in case it was removed
if (manga.manga.id !in config.mangaIds) {
continue
}
if (manga.searchResult.value == SearchResult.Searching && manga.migrationScope.isActive) {
val mangaObj = manga.manga
val mangaSource = sourceManager.getOrStub(mangaObj.source)
val result = try {
manga.migrationScope.async {
val validSources = if (sources.size == 1) {
sources
} else {
sources.filter { it.id != mangaSource.id }
}
if (useSourceWithMost) {
val sourceSemaphore = Semaphore(3)
val processedSources = AtomicInteger()
validSources.map { source ->
async async2@{
sourceSemaphore.withPermit {
try {
val searchResult = if (useSmartSearch) {
smartSearchEngine.smartSearch(source, mangaObj.ogTitle)
} else {
smartSearchEngine.normalSearch(source, mangaObj.ogTitle)
}
if (searchResult != null && !(searchResult.url == mangaObj.url && source.id == mangaObj.source)) {
val localManga = networkToLocalManga(
searchResult,
source.id,
)
val chapters = if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
source.getChapterList(localManga.toSManga())
}
try {
syncChaptersWithSource.await(chapters, localManga, source)
} catch (e: Exception) {
return@async2 null
}
manga.progress.value = validSources.size to processedSources.incrementAndGet()
localManga to chapters.size
} else {
null
}
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
null
}
}
}
}.mapNotNull { it.await() }.maxByOrNull { it.second }?.first
} else {
validSources.forEachIndexed { index, source ->
val searchResult = try {
val searchResult = if (useSmartSearch) {
smartSearchEngine.smartSearch(source, mangaObj.ogTitle)
} else {
smartSearchEngine.normalSearch(source, mangaObj.ogTitle)
}
if (searchResult != null) {
val localManga = networkToLocalManga(searchResult, source.id)
val chapters = try {
if (source is EHentai) {
source.getChapterList(localManga.toSManga(), throttleManager::throttle)
} else {
source.getChapterList(localManga.toSManga())
}
} catch (e: Exception) {
this@MigrationListPresenter.logcat(LogPriority.ERROR, e)
emptyList()
}
syncChaptersWithSource.await(chapters, localManga, source)
localManga
} else null
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
null
}
manga.progress.value = validSources.size to (index + 1)
if (searchResult != null) return@async searchResult
}
null
}
}.await()
} catch (e: CancellationException) {
// Ignore canceled migrations
continue
}
if (result != null && result.thumbnailUrl == null) {
try {
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
updateManga.awaitUpdateFromSource(result, newManga, true)
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
}
}
manga.searchResult.value = if (result == null) {
SearchResult.NotFound
} else {
SearchResult.Result(result.id)
}
if (result == null && hideNotFound) {
removeManga(manga)
}
withUIContext {
view?.sourceFinished()
}
}
}
}
fun allMangasDone() = migratingItems.value.all { it.searchResult.value != SearchResult.Searching } &&
migratingItems.value.any { it.searchResult.value is SearchResult.Result }
fun mangasSkipped() = migratingItems.value.count { it.searchResult.value == SearchResult.NotFound }
private suspend fun migrateMangaInternal(
prevManga: Manga,
manga: Manga,
replace: Boolean,
) {
val flags = preferences.migrateFlags().get()
// Update chapters read
if (MigrationFlags.hasChapters(flags)) {
val prevMangaChapters = getChapterByMangaId.await(prevManga.id)
val maxChapterRead =
prevMangaChapters.filter(Chapter::read).maxOfOrNull(Chapter::chapterNumber)
val dbChapters = getChapterByMangaId.await(manga.id)
val prevHistoryList = getHistoryByMangaId.await(prevManga.id)
val chapterUpdates = mutableListOf<ChapterUpdate>()
val historyUpdates = mutableListOf<HistoryUpdate>()
dbChapters.forEach { chapter ->
if (chapter.isRecognizedNumber) {
val prevChapter = prevMangaChapters.find { it.isRecognizedNumber && it.chapterNumber == chapter.chapterNumber }
if (prevChapter != null) {
chapterUpdates += ChapterUpdate(
id = chapter.id,
bookmark = prevChapter.bookmark,
read = prevChapter.read,
dateFetch = prevChapter.dateFetch,
)
prevHistoryList.find { it.chapterId == prevChapter.id }?.let { prevHistory ->
historyUpdates += HistoryUpdate(
chapter.id,
prevHistory.readAt ?: return@let,
prevHistory.readDuration,
)
}
} else if (maxChapterRead != null && chapter.chapterNumber <= maxChapterRead) {
chapterUpdates += ChapterUpdate(
id = chapter.id,
read = true,
)
}
}
}
updateChapter.awaitAll(chapterUpdates)
historyUpdates.forEach {
upsertHistory.await(it)
}
}
// Update categories
if (MigrationFlags.hasCategories(flags)) {
val categories = getCategories.await(prevManga.id)
setMangaCategories.await(manga.id, categories.map { it.id })
}
// Update track
if (MigrationFlags.hasTracks(flags)) {
val tracks = getTracks.await(prevManga.id)
if (tracks.isNotEmpty()) {
getTracks.await(manga.id).forEach {
deleteTrack.await(manga.id, it.syncId)
}
insertTrack.awaitAll(tracks.map { it.copy(mangaId = manga.id) })
}
}
// Update custom cover
if (MigrationFlags.hasCustomCover(flags) && prevManga.hasCustomCover(coverCache)) {
coverCache.setCustomCoverToCache(manga.toDbManga(), coverCache.getCustomCoverFile(prevManga.id).inputStream())
}
var mangaUpdate = MangaUpdate(manga.id, favorite = true, dateAdded = System.currentTimeMillis())
var prevMangaUpdate: MangaUpdate? = null
// Update extras
if (MigrationFlags.hasExtra(flags)) {
mangaUpdate = mangaUpdate.copy(
chapterFlags = prevManga.chapterFlags,
viewerFlags = prevManga.viewerFlags,
)
}
// Update favorite status
if (replace) {
prevMangaUpdate = MangaUpdate(
id = prevManga.id,
favorite = false,
dateAdded = 0,
)
mangaUpdate = mangaUpdate.copy(
dateAdded = prevManga.dateAdded,
)
}
updateManga.awaitAll(listOfNotNull(mangaUpdate, prevMangaUpdate))
}
fun useMangaForMigration(manga: Manga, source: Source, selectedMangaId: Long) {
val migratingManga = migratingItems.value.find { it.manga.id == selectedMangaId }
?: return
migratingManga.searchResult.value = SearchResult.Searching
presenterScope.launchIO {
val result = migratingManga.migrationScope.async {
val localManga = networkToLocalManga(manga.toDbManga(), source.id)
try {
val chapters = source.getChapterList(localManga.toSManga())
syncChaptersWithSource.await(chapters, localManga, source)
} catch (e: Exception) {
return@async null
}
localManga
}.await()
if (result != null) {
try {
val newManga = sourceManager.getOrStub(result.source).getMangaDetails(result.toSManga())
updateManga.awaitUpdateFromSource(result, newManga, true)
} catch (e: CancellationException) {
// Ignore cancellations
throw e
} catch (e: Exception) {
}
migratingManga.searchResult.value = SearchResult.Result(result.id)
} else {
migratingManga.searchResult.value = SearchResult.NotFound
withUIContext {
view?.activity?.toast(R.string.no_chapters_found_for_migration, Toast.LENGTH_LONG)
}
}
}
}
fun migrateMangas() {
presenterScope.launchIO {
migratingItems.value.forEach { manga ->
val searchResult = manga.searchResult.value
if (searchResult is SearchResult.Result) {
val toMangaObj = getManga.await(searchResult.id) ?: return@forEach
migrateMangaInternal(
manga.manga,
toMangaObj,
true,
)
}
}
navigateOut()
}
}
fun copyMangas() {
presenterScope.launchIO {
migratingItems.value.forEach { manga ->
val searchResult = manga.searchResult.value
if (searchResult is SearchResult.Result) {
val toMangaObj = getManga.await(searchResult.id) ?: return@forEach
migrateMangaInternal(
manga.manga,
toMangaObj,
false,
)
}
}
navigateOut()
}
}
private suspend fun navigateOut() {
val view = view ?: return
if (migratingItems.value.size == 1) {
val hasDetails = view.router.backstack.any { it.controller is MangaController }
if (hasDetails) {
val manga = (migratingItems.value.firstOrNull()?.searchResult?.value as? SearchResult.Result)?.let {
getManga.await(it.id)
}
withUIContext {
view.navigateOut(manga)
}
return
}
}
withUIContext {
view.navigateOut(null)
}
}
fun migrateManga(mangaId: Long, copy: Boolean) {
presenterScope.launchIO {
val manga = migratingItems.value.find { it.manga.id == mangaId }
?: return@launchIO
val toMangaObj = getManga.await((manga.searchResult.value as? SearchResult.Result)?.id ?: return@launchIO)
?: return@launchIO
migrateMangaInternal(
manga.manga,
toMangaObj,
!copy,
)
removeManga(mangaId)
}
}
fun removeManga(mangaId: Long) {
presenterScope.launchIO {
val item = migratingItems.value.find { it.manga.id == mangaId }
?: return@launchIO
if (migratingItems.value.size == 1) {
item.searchResult.value = SearchResult.NotFound
item.migrationScope.cancel()
withUIContext {
view?.sourceFinished()
}
return@launchIO
}
removeManga(item)
item.migrationScope.cancel()
withUIContext {
view?.sourceFinished()
}
}
}
fun removeManga(item: MigratingManga) {
val ids = config.mangaIds.toMutableList()
val index = ids.indexOf(item.manga.id)
if (index > -1) {
ids.removeAt(index)
config.mangaIds = ids
val index2 = migratingItems.value.indexOf(item)
if (index2 > -1) migratingItems.value = migratingItems.value - item
}
}
override fun onDestroy() {
super.onDestroy()
migrationsJob?.cancel()
}
/**
* Returns a manga from the database for the given manga from network. It creates a new entry
* if the manga is not yet in the database.
*
* @param sManga the manga from the source.
* @return a manga from the database.
*/
private suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
var localManga = getManga.await(sManga.url, sourceId)
if (localManga == null) {
val newManga = eu.kanade.tachiyomi.data.database.models.Manga.create(sManga.url, sManga.title, sourceId)
newManga.copyFrom(sManga)
newManga.id = -1
val result = run {
val id = insertManga.await(newManga.toDomainManga()!!)
getManga.await(id!!)
}
localManga = result
} else if (!localManga.favorite) {
// if the manga isn't a favorite, set its display title from source
// if it later becomes a favorite, updated title will go to db
localManga = localManga.copy(ogTitle = sManga.title)
}
return localManga!!
}
}

View File

@ -1,220 +1,7 @@
package eu.kanade.tachiyomi.ui.browse.migration.advanced.process package eu.kanade.tachiyomi.ui.browse.migration.advanced.process
import android.view.MenuItem
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.domain.category.interactor.GetCategories
import eu.kanade.domain.category.interactor.SetMangaCategories
import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
import eu.kanade.domain.chapter.interactor.UpdateChapter
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.domain.chapter.model.ChapterUpdate
import eu.kanade.domain.history.interactor.GetHistoryByMangaId
import eu.kanade.domain.history.interactor.UpsertHistory
import eu.kanade.domain.history.model.HistoryUpdate
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.interactor.UpdateManga
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.MangaUpdate
import eu.kanade.domain.manga.model.hasCustomCover
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.domain.track.interactor.DeleteTrack
import eu.kanade.domain.track.interactor.GetTracks
import eu.kanade.domain.track.interactor.InsertTrack
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.cancel
import uy.kohesive.injekt.injectLazy
class MigrationProcessAdapter( class MigrationProcessAdapter(
val controller: MigrationListController, val controller: MigrationListController,
) : FlexibleAdapter<MigrationProcessItem>(null, controller, true) { ) : FlexibleAdapter<MigrationProcessItem>(null, controller, true)
private val preferences: PreferencesHelper by injectLazy()
private val coverCache: CoverCache by injectLazy()
private val getManga: GetManga by injectLazy()
private val updateManga: UpdateManga by injectLazy()
private val updateChapter: UpdateChapter by injectLazy()
private val getChapterByMangaId: GetChapterByMangaId by injectLazy()
private val getHistoryByMangaId: GetHistoryByMangaId by injectLazy()
private val upsertHistory: UpsertHistory by injectLazy()
private val getCategories: GetCategories by injectLazy()
private val setMangaCategories: SetMangaCategories by injectLazy()
private val getTracks: GetTracks by injectLazy()
private val insertTrack: InsertTrack by injectLazy()
private val deleteTrack: DeleteTrack by injectLazy()
var items: List<MigrationProcessItem> = emptyList()
val menuItemListener: MigrationProcessInterface = controller
val hideNotFound = preferences.hideNotFoundMigration().get()
override fun updateDataSet(items: List<MigrationProcessItem>?) {
this.items = items.orEmpty()
super.updateDataSet(items)
}
interface MigrationProcessInterface {
fun onMenuItemClick(position: Int, item: MenuItem)
fun enableButtons()
fun removeManga(item: MigrationProcessItem)
fun noMigration()
fun updateCount()
}
fun sourceFinished() {
menuItemListener.updateCount()
if (itemCount == 0) menuItemListener.noMigration()
if (allMangasDone()) menuItemListener.enableButtons()
}
fun allMangasDone() = items.all { it.manga.migrationStatus != MigrationStatus.RUNNING } &&
items.any { it.manga.migrationStatus == MigrationStatus.MANGA_FOUND }
fun mangasSkipped() = items.count { it.manga.migrationStatus == MigrationStatus.MANGA_NOT_FOUND }
suspend fun performMigrations(copy: Boolean) {
withIOContext {
currentItems.forEach { migratingManga ->
val manga = migratingManga.manga
if (manga.searchResult.initialized) {
val toMangaObj = getManga.await(manga.searchResult.get() ?: return@forEach)
?: return@forEach
migrateMangaInternal(
manga.manga() ?: return@forEach,
toMangaObj,
!copy,
)
}
}
}
}
fun migrateManga(position: Int, copy: Boolean) {
launchUI {
val manga = getItem(position)?.manga ?: return@launchUI
val toMangaObj = getManga.await(manga.searchResult.get() ?: return@launchUI)
?: return@launchUI
migrateMangaInternal(
manga.manga() ?: return@launchUI,
toMangaObj,
!copy,
)
removeManga(position)
}
}
fun removeManga(position: Int) {
val item = getItem(position) ?: return
if (items.size == 1) {
item.manga.migrationStatus = MigrationStatus.MANGA_NOT_FOUND
item.manga.migrationJob.cancel()
item.manga.searchResult.set(null)
sourceFinished()
notifyItemChanged(position)
return
}
menuItemListener.removeManga(item)
item.manga.migrationJob.cancel()
removeItem(position)
items = currentItems
sourceFinished()
}
private suspend fun migrateMangaInternal(
prevManga: Manga,
manga: Manga,
replace: Boolean,
) {
controller.config ?: return
val flags = preferences.migrateFlags().get()
// Update chapters read
if (MigrationFlags.hasChapters(flags)) {
val prevMangaChapters = getChapterByMangaId.await(prevManga.id)
val maxChapterRead =
prevMangaChapters.filter(Chapter::read).maxOfOrNull(Chapter::chapterNumber)
val dbChapters = getChapterByMangaId.await(manga.id)
val prevHistoryList = getHistoryByMangaId.await(prevManga.id)
val chapterUpdates = mutableListOf<ChapterUpdate>()
val historyUpdates = mutableListOf<HistoryUpdate>()
dbChapters.forEach { chapter ->
if (chapter.isRecognizedNumber) {
val prevChapter = prevMangaChapters.find { it.isRecognizedNumber && it.chapterNumber == chapter.chapterNumber }
if (prevChapter != null) {
chapterUpdates += ChapterUpdate(
id = chapter.id,
bookmark = prevChapter.bookmark,
read = prevChapter.read,
dateFetch = prevChapter.dateFetch,
)
prevHistoryList.find { it.chapterId == prevChapter.id }?.let { prevHistory ->
historyUpdates += HistoryUpdate(
chapter.id,
prevHistory.readAt ?: return@let,
prevHistory.readDuration,
)
}
} else if (maxChapterRead != null && chapter.chapterNumber <= maxChapterRead) {
chapterUpdates += ChapterUpdate(
id = chapter.id,
read = true,
)
}
}
}
updateChapter.awaitAll(chapterUpdates)
historyUpdates.forEach {
upsertHistory.await(it)
}
}
// Update categories
if (MigrationFlags.hasCategories(flags)) {
val categories = getCategories.await(prevManga.id)
setMangaCategories.await(manga.id, categories.map { it.id })
}
// Update track
if (MigrationFlags.hasTracks(flags)) {
val tracks = getTracks.await(prevManga.id)
if (tracks.isNotEmpty()) {
getTracks.await(manga.id).forEach {
deleteTrack.await(manga.id, it.syncId)
}
insertTrack.awaitAll(tracks.map { it.copy(mangaId = manga.id) })
}
}
// Update custom cover
if (MigrationFlags.hasCustomCover(flags) && prevManga.hasCustomCover(coverCache)) {
coverCache.setCustomCoverToCache(manga.toDbManga(), coverCache.getCustomCoverFile(prevManga.id).inputStream())
}
var mangaUpdate = MangaUpdate(manga.id, favorite = true, dateAdded = System.currentTimeMillis())
var prevMangaUpdate: MangaUpdate? = null
// Update extras
if (MigrationFlags.hasExtra(flags)) {
mangaUpdate = mangaUpdate.copy(
chapterFlags = prevManga.chapterFlags,
viewerFlags = prevManga.viewerFlags,
)
}
// Update favorite status
if (replace) {
prevMangaUpdate = MangaUpdate(
id = prevManga.id,
favorite = false,
dateAdded = 0,
)
mangaUpdate = mangaUpdate.copy(
dateAdded = prevManga.dateAdded,
)
}
updateManga.awaitAll(listOfNotNull(mangaUpdate, prevMangaUpdate))
}
}

View File

@ -6,52 +6,51 @@ import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import coil.dispose import coil.dispose
import eu.davidea.viewholders.FlexibleViewHolder import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.domain.chapter.interactor.GetChapterByMangaId
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.interactor.GetMergedReferencesById
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.MigrationMangaCardBinding import eu.kanade.tachiyomi.databinding.MigrationMangaCardBinding
import eu.kanade.tachiyomi.databinding.MigrationProcessItemBinding import eu.kanade.tachiyomi.databinding.MigrationProcessItemBinding
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.ChapterInfo
import eu.kanade.tachiyomi.ui.browse.migration.advanced.process.MigratingManga.SearchResult
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.view.loadAutoPause import eu.kanade.tachiyomi.util.view.loadAutoPause
import eu.kanade.tachiyomi.util.view.setVectorCompat import eu.kanade.tachiyomi.util.view.setVectorCompat
import exh.source.MERGED_SOURCE_ID import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks import reactivecircus.flowbinding.android.view.clicks
import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat import java.text.DecimalFormat
import java.util.concurrent.CopyOnWriteArrayList
class MigrationProcessHolder( class MigrationProcessHolder(
private val view: View, view: View,
private val adapter: MigrationProcessAdapter, private val adapter: MigrationProcessAdapter,
) : FlexibleViewHolder(view, adapter) { ) : FlexibleViewHolder(view, adapter) {
private val sourceManager: SourceManager by injectLazy()
private val getManga: GetManga by injectLazy()
private val getChapterByMangaId: GetChapterByMangaId by injectLazy()
private val getMergedReferencesById: GetMergedReferencesById by injectLazy()
private var item: MigrationProcessItem? = null private var item: MigrationProcessItem? = null
private val binding = MigrationProcessItemBinding.bind(view) private val binding = MigrationProcessItemBinding.bind(view)
private val jobs = CopyOnWriteArrayList<Job>()
init { init {
// We need to post a Runnable to show the popup to make sure that the PopupMenu is // We need to post a Runnable to show the popup to make sure that the PopupMenu is
// correctly positioned. The reason being that the view may change position before the // correctly positioned. The reason being that the view may change position before the
// PopupMenu is shown. // PopupMenu is shown.
binding.migrationMenu.setOnClickListener { it.post { showPopupMenu(it) } } binding.migrationMenu.setOnClickListener { it.post { showPopupMenu(it) } }
binding.skipManga.setOnClickListener { it.post { adapter.removeManga(bindingAdapterPosition) } } binding.skipManga.setOnClickListener { it.post { adapter.controller.removeManga(item?.manga?.manga?.id ?: return@post) } }
} }
fun bind(item: MigrationProcessItem) { fun bind(item: MigrationProcessItem) {
this.item = item this.item = item
launchUI { jobs.removeAll { it.cancel(); true }
val manga = item.manga.manga() jobs += adapter.controller.viewScope.launchUI {
val source = item.manga.mangaSource() val migrateManga = item.manga
val manga = migrateManga.manga
binding.migrationMenu.setVectorCompat( binding.migrationMenu.setVectorCompat(
R.drawable.ic_more_24dp, R.drawable.ic_more_24dp,
@ -64,67 +63,74 @@ class MigrationProcessHolder(
binding.migrationMenu.isInvisible = true binding.migrationMenu.isInvisible = true
binding.skipManga.isVisible = true binding.skipManga.isVisible = true
binding.migrationMangaCardTo.resetManga() binding.migrationMangaCardTo.resetManga()
if (manga != null) { binding.migrationMangaCardFrom.attachManga(manga, item.manga.sourcesString, item.manga.chapterInfo)
binding.migrationMangaCardFrom.attachManga(manga, source) jobs += binding.migrationMangaCardFrom.root.clicks()
binding.migrationMangaCardFrom.root.clicks() .onEach {
.onEach { adapter.controller.router.pushController(
adapter.controller.router.pushController( MangaController(
MangaController( manga.id,
manga.id, true,
true, ),
), )
) }
} .launchIn(adapter.controller.viewScope)
.launchIn(adapter.controller.viewScope)
/*launchUI { /*launchUI {
item.manga.progress.asFlow().collect { (max, progress) -> item.manga.progress.asFlow().collect { (max, progress) ->
withUIContext { withUIContext {
migration_manga_card_to.search_progress.let { progressBar -> migration_manga_card_to.search_progress.let { progressBar ->
progressBar.max = max progressBar.max = max
progressBar.progress = progress progressBar.progress = progress
}
}
}
}*/
jobs += migrateManga.searchResult
.onEach { searchResult ->
this@MigrationProcessHolder.logcat { (searchResult to (migrateManga.manga.id to this@MigrationProcessHolder.item?.manga?.manga?.id)).toString() }
if (migrateManga.manga.id != this@MigrationProcessHolder.item?.manga?.manga?.id ||
searchResult == SearchResult.Searching
) {
return@onEach
}
val resultManga = withIOContext {
(searchResult as? SearchResult.Result)
?.let { migrateManga.getManga(it) }
}
if (resultManga != null) {
val (sourceName, latestChapter) = withIOContext {
val sourceNameAsync = async { migrateManga.getSourceName(resultManga).orEmpty() }
val latestChapterAsync = async { migrateManga.getChapterInfo(searchResult as SearchResult.Result) }
sourceNameAsync.await() to latestChapterAsync.await()
}
binding.migrationMangaCardTo.attachManga(resultManga, sourceName, latestChapter)
jobs += binding.migrationMangaCardTo.root.clicks()
.onEach {
adapter.controller.router.pushController(
MangaController(
resultManga.id,
true,
),
)
} }
} .launchIn(adapter.controller.viewScope)
}
}*/
val searchResult = item.manga.searchResult.get()?.let {
getManga.await(it)
}
val resultSource = searchResult?.source?.let {
sourceManager.get(it)
}
if (item.manga.mangaId != this@MigrationProcessHolder.item?.manga?.mangaId ||
item.manga.migrationStatus == MigrationStatus.RUNNING
) {
return@launchUI
}
if (searchResult != null && resultSource != null) {
binding.migrationMangaCardTo.attachManga(searchResult, resultSource)
binding.migrationMangaCardTo.root.clicks()
.onEach {
adapter.controller.router.pushController(
MangaController(
searchResult.id,
true,
),
)
}
.launchIn(adapter.controller.viewScope)
} else {
if (adapter.hideNotFound) {
adapter.removeManga(bindingAdapterPosition)
} else { } else {
binding.migrationMangaCardTo.progress.isVisible = false binding.migrationMangaCardTo.progress.isVisible = false
binding.migrationMangaCardTo.title.text = view.context.applicationContext binding.migrationMangaCardTo.title.text = itemView.context
.getString(R.string.no_alternatives_found) .getString(R.string.no_alternatives_found)
} }
binding.migrationMenu.isVisible = true
binding.skipManga.isVisible = false
adapter.controller.sourceFinished()
} }
binding.migrationMenu.isVisible = true .catch {
binding.skipManga.isVisible = false this@MigrationProcessHolder.logcat(throwable = it) { "Error updating result info" }
adapter.sourceFinished() }
} .launchIn(adapter.controller.viewScope)
} }
} }
@ -139,39 +145,35 @@ class MigrationProcessHolder(
mangaLastChapterLabel.text = "" mangaLastChapterLabel.text = ""
} }
private suspend fun MigrationMangaCardBinding.attachManga(manga: Manga, source: Source) { private fun MigrationMangaCardBinding.attachManga(
manga: Manga,
sourceString: String,
chapterInfo: ChapterInfo,
) {
progress.isVisible = false progress.isVisible = false
thumbnail.loadAutoPause(manga) thumbnail.loadAutoPause(manga)
title.text = if (manga.title.isBlank()) { title.text = if (manga.title.isBlank()) {
view.context.getString(R.string.unknown) itemView.context.getString(R.string.unknown)
} else { } else {
manga.ogTitle manga.ogTitle
} }
mangaSourceLabel.text = if (source.id == MERGED_SOURCE_ID) { mangaSourceLabel.text = sourceString
getMergedReferencesById.await(manga.id).map {
sourceManager.getOrStub(it.mangaSourceId).toString()
}.distinct().joinToString()
} else {
source.toString()
}
val chapters = getChapterByMangaId.await(manga.id)
// For rounded corners // For rounded corners
badges.leftBadges.clipToOutline = true badges.leftBadges.clipToOutline = true
badges.rightBadges.clipToOutline = true badges.rightBadges.clipToOutline = true
badges.unreadText.isVisible = true badges.unreadText.isVisible = true
badges.unreadText.text = chapters.size.toString() badges.unreadText.text = chapterInfo.chapterCount.toString()
val latestChapter = chapters.maxOfOrNull { it.chapterNumber } ?: -1f
if (latestChapter > 0f) { if (chapterInfo.latestChapter != null && chapterInfo.latestChapter > 0f) {
mangaLastChapterLabel.text = root.context.getString( mangaLastChapterLabel.text = itemView.context.getString(
R.string.latest_, R.string.latest_,
DecimalFormat("#.#").format(latestChapter), DecimalFormat("#.#").format(chapterInfo.latestChapter),
) )
} else { } else {
mangaLastChapterLabel.text = root.context.getString( mangaLastChapterLabel.text = itemView.context.getString(
R.string.latest_, R.string.latest_,
root.context.getString(R.string.unknown), root.context.getString(R.string.unknown),
) )
@ -191,14 +193,14 @@ class MigrationProcessHolder(
popup.menu.findItem(R.id.action_search_manually).isVisible = true popup.menu.findItem(R.id.action_search_manually).isVisible = true
// Hide download and show delete if the chapter is downloaded // Hide download and show delete if the chapter is downloaded
if (mangas.searchResult.content != null) { if (mangas.searchResult.value != SearchResult.Searching) {
popup.menu.findItem(R.id.action_migrate_now).isVisible = true popup.menu.findItem(R.id.action_migrate_now).isVisible = true
popup.menu.findItem(R.id.action_copy_now).isVisible = true popup.menu.findItem(R.id.action_copy_now).isVisible = true
} }
// Set a listener so we are notified if a menu item is clicked // Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { menuItem -> popup.setOnMenuItemClickListener { menuItem ->
adapter.menuItemListener.onMenuItemClick(bindingAdapterPosition, menuItem) adapter.controller.onMenuItemClick(item.manga.manga.id, menuItem)
true true
} }

View File

@ -30,12 +30,12 @@ class MigrationProcessItem(val manga: MigratingManga) :
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other is MigrationProcessItem) { if (other is MigrationProcessItem) {
return manga.mangaId == other.manga.mangaId return manga.manga.id == other.manga.manga.id
} }
return false return false
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return manga.mangaId.toInt() return manga.manga.id.hashCode()
} }
} }

View File

@ -1,7 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.migration.advanced.process
enum class MigrationStatus {
RUNNING,
MANGA_FOUND,
MANGA_NOT_FOUND
}

View File

@ -45,8 +45,7 @@ class SourceSearchController(
val sourceManager = Injekt.get<SourceManager>() val sourceManager = Injekt.get<SourceManager>()
val source = sourceManager.get(manga.source) ?: return@SourceSearchScreen val source = sourceManager.get(manga.source) ?: return@SourceSearchScreen
migrationListController.useMangaForMigration(manga, source) migrationListController.useMangaForMigration(manga, source)
router.popCurrentController() router.popToTag(MigrationListController.TAG)
router.popCurrentController()
}, },
// SY <-- // SY <--
onWebViewClick = f@{ onWebViewClick = f@{

View File

@ -1,57 +0,0 @@
package exh.util
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Field that can be initialized later. Users can suspend while waiting for the field to initialize.
*
* @author nulldev
*/
class DeferredField<T> {
@Volatile
var content: T? = null
@Volatile
var initialized = false
private set
private val mutex = Mutex(true)
/**
* Initialize the field
*/
fun initialize(content: T) {
// Fast-path new listeners
this.content = content
initialized = true
// Notify current listeners
mutex.unlock()
}
fun set(content: T) {
mutex.tryLock()
this.content = content
initialized = true
// Notify current listeners
mutex.unlock()
}
/**
* Will only suspend if !initialized.
*/
suspend fun get(): T {
// Check if field is initialized and return immediately if it is
@Suppress("UNCHECKED_CAST")
if (initialized) return content as T
// Wait for field to initialize
mutex.withLock {}
// Field is initialized, return value
@Suppress("UNCHECKED_CAST")
return content as T
}
}