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,
author = state.manga.author,
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 },
coverDataProvider = { state.manga },
status = state.manga.status,
@ -662,7 +662,7 @@ fun MangaScreenLargeImpl(
title = state.manga.title,
author = state.manga.author,
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 },
coverDataProvider = { state.manga },
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.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.manga.MergedMangaData
import eu.kanade.tachiyomi.util.lang.awaitSingle
import rx.Observable
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.getNameForMangaInfo(mergeData: MergedMangaData?): String {
fun Source.getNameForMangaInfo(mergeSources: List<Source>?): String {
val preferences = Injekt.get<PreferencesHelper>()
val enabledLanguages = preferences.enabledLanguages().get()
.filterNot { it in listOf("all", "other") }
@ -104,8 +103,8 @@ fun Source.getNameForMangaInfo(mergeData: MergedMangaData?): String {
val isInEnabledLanguages = lang in enabledLanguages
return when {
// SY -->
mergeData != null -> getMergedSourcesString(
mergeData,
!mergeSources.isNullOrEmpty() -> getMergedSourcesString(
mergeSources,
enabledLanguages,
hasOneActiveLanguages,
)
@ -120,12 +119,12 @@ fun Source.getNameForMangaInfo(mergeData: MergedMangaData?): String {
// SY -->
private fun getMergedSourcesString(
mergeData: MergedMangaData,
mergeSources: List<Source>,
enabledLangs: List<String>,
onlyName: Boolean,
): String {
return if (onlyName) {
mergeData.sources.joinToString { source ->
mergeSources.joinToString { source ->
if (source.lang !in enabledLangs) {
source.toString()
} else {
@ -133,7 +132,7 @@ private fun getMergedSourcesString(
}
}
} else {
mergeData.sources.joinToString()
mergeSources.joinToString()
}
}
// SY <--

View File

@ -31,6 +31,13 @@ class PreMigrationController(bundle: Bundle? = null) :
FlexibleAdapter.OnItemClickListener,
FabController,
StartMigrationListener {
constructor(mangaIds: List<Long>) : this(
bundleOf(
MANGA_IDS_EXTRA to mangaIds.toLongArray(),
),
)
private val sourceManager: SourceManager by injectLazy()
private val prefs: PreferencesHelper by injectLazy()
@ -95,7 +102,7 @@ class PreMigrationController(bundle: Bundle? = null) :
prefs.migrationSources().set(listOfSources)
router.replaceTopController(
MigrationListController.create(
MigrationListController(
MigrationProcedureConfig(
config.toList(),
extraSearchParams = extraParam,
@ -186,21 +193,13 @@ class PreMigrationController(bundle: Bundle? = null) :
fun navigateToMigration(skipPre: Boolean, router: Router, mangaIds: List<Long>) {
router.pushController(
if (skipPre) {
MigrationListController.create(
MigrationListController(
MigrationProcedureConfig(mangaIds, null),
)
} else {
create(mangaIds)
PreMigrationController(mangaIds)
}.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
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import exh.util.DeferredField
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.coroutines.CoroutineContext
class MigratingManga(
private val getManga: GetManga,
private val sourceManager: SourceManager,
val mangaId: Long,
val manga: Manga,
val chapterInfo: ChapterInfo,
val sourcesString: String,
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>
val progress = MutableStateFlow(1 to 0)
val migrationJob = parentContext + SupervisorJob() + Dispatchers.Default
var migrationStatus = MigrationStatus.RUNNING
@Volatile
private var manga: Manga? = null
suspend fun manga(): Manga? {
if (manga == null) manga = getManga.await(mangaId)
return manga
sealed class SearchResult {
object Searching : SearchResult()
object NotFound : SearchResult()
data class Result(val id: Long) : SearchResult()
}
suspend fun mangaSource(): Source {
return sourceManager.getOrStub(manga()?.source ?: -1)
}
data class ChapterInfo(
val latestChapter: Float?,
val chapterCount: Int,
)
fun toModal(): MigrationProcessItem {
// Create the model object.

View File

@ -8,57 +8,39 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.core.graphics.ColorUtils
import androidx.core.os.bundleOf
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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.toDbManga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.MigrationListControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
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.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.withFadeTransaction
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.process.MigratingManga.SearchResult
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
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.getResourceColor
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast
import exh.eh.EHentaiThrottleManager
import exh.smartsearch.SmartSearchEngine
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
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class MigrationListController(bundle: Bundle? = null) :
BaseController<MigrationListControllerBinding>(bundle),
MigrationProcessAdapter.MigrationProcessInterface {
NucleusController<MigrationListControllerBinding, MigrationListPresenter>(bundle) {
constructor(config: MigrationProcedureConfig) : this(
bundleOf(
CONFIG_EXTRA to config,
),
)
init {
setHasOptionsMenu(true)
@ -68,27 +50,19 @@ class MigrationListController(bundle: Bundle? = null) :
val config = args.getParcelableCompat<MigrationProcedureConfig>(CONFIG_EXTRA)
private val preferences: PreferencesHelper by injectLazy()
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 selectedMangaId: Long? = null
private var manualMigrations = 0
private val throttleManager = EHentaiThrottleManager()
override fun getTitle(): String {
return resources?.getString(R.string.migration) + " (${adapter?.items?.count {
it.manga.migrationStatus != MigrationStatus.RUNNING
}}/${adapter?.itemCount ?: 0})"
val notFinished = presenter.migratingItems.value.count {
it.searchResult.value != SearchResult.Searching
}
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)
@ -103,15 +77,6 @@ class MigrationListController(bundle: Bundle? = null) :
}
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)
@ -119,187 +84,30 @@ class MigrationListController(bundle: Bundle? = null) :
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.setHasFixedSize(true)
adapter?.updateDataSet(newMigratingManga.map { it.toModal() })
if (migrationsJob == null) {
migrationsJob = migrationScope.launch {
runMigrations(newMigratingManga)
}
presenter.migratingItems
.onEach {
adapter?.updateDataSet(it.map { it.toModal() })
}
.launchIn(viewScope)
}
private suspend fun runMigrations(mangas: List<MigratingManga>) {
throttleManager.resetThrottle()
if (config == null) return
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 {
fun updateCount() {
if (router.backstack.lastOrNull()?.controller == this@MigrationListController) {
setTitle()
}
}
}
override fun onDestroy() {
super.onDestroy()
migrationScope.cancel()
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
override fun enableButtons() {
private fun enableButtons() {
activity?.invalidateOptionsMenu()
}
override fun removeManga(item: MigrationProcessItem) {
val ids = config?.mangaIds?.toMutableList() ?: return
val index = ids.indexOf(item.manga.mangaId)
if (index > -1) {
ids.removeAt(index)
config.mangaIds = ids
val index2 = migratingManga?.indexOf(item.manga) ?: return
if (index2 > -1) migratingManga?.removeAt(index2)
}
}
override fun noMigration() {
launchUI {
private fun noMigration() {
val res = resources
if (res != null) {
activity?.toast(
@ -310,22 +118,20 @@ class MigrationListController(bundle: Bundle? = null) :
),
)
}
if (adapter?.hideNotFound == false) {
if (!presenter.hideNotFound) {
router.popCurrentController()
}
}
}
override fun onMenuItemClick(position: Int, item: MenuItem) {
fun onMenuItemClick(mangaId: Long, item: MenuItem) {
when (item.itemId) {
R.id.action_search_manually -> {
launchUI {
val manga = adapter?.getItem(position)?.manga?.manga() ?: return@launchUI
selectedPosition = position
val sources = preferences.migrationSources().get().split("/").mapNotNull {
val value = it.toLongOrNull() ?: return@mapNotNull null
sourceManager.get(value) as? CatalogueSource
}
val manga = presenter.migratingItems.value
.find { it.manga.id == mangaId }
?.manga
?: return
selectedMangaId = mangaId
val sources = presenter.getMigrationSources()
val validSources = if (sources.size == 1) {
sources
} else {
@ -335,79 +141,45 @@ class MigrationListController(bundle: Bundle? = null) :
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 -> {
adapter?.migrateManga(position, false)
migrateManga(mangaId, false)
manualMigrations++
}
R.id.action_copy_now -> {
adapter?.migrateManga(position, true)
migrateManga(mangaId, true)
manualMigrations++
}
}
}
fun useMangaForMigration(manga: Manga, source: Source) {
val firstIndex = selectedPosition ?: 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()
}
}
presenter.useMangaForMigration(manga, source, selectedMangaId ?: return)
}
fun migrateMangas() {
launchUI {
adapter?.performMigrations(false)
navigateOut()
}
presenter.migrateMangas()
}
fun copyMangas() {
launchUI {
adapter?.performMigrations(true)
navigateOut()
}
presenter.copyMangas()
}
private fun navigateOut() {
if (migratingManga?.size == 1) {
launchUI {
val hasDetails = router.backstack.any { it.controller is MangaController }
if (hasDetails) {
val manga = migratingManga?.firstOrNull()?.searchResult?.get()?.let {
getManga.await(it)
fun migrateManga(mangaId: Long, copy: Boolean) {
presenter.migrateManga(mangaId, copy)
}
fun removeManga(mangaId: Long) {
presenter.removeManga(mangaId)
}
fun sourceFinished() {
updateCount()
if (presenter.migratingItems.value.isEmpty()) noMigration()
if (presenter.allMangasDone()) enableButtons()
}
fun navigateOut(manga: Manga?) {
if (manga != null) {
val newStack = router.backstack.filter {
it.controller !is MangaController &&
@ -415,13 +187,10 @@ class MigrationListController(bundle: Bundle? = null) :
it.controller !is PreMigrationController
} + MangaController(manga.id).withFadeTransaction()
router.setBackstack(newStack, OneWayFadeChangeHandler())
return@launchUI
}
return
}
router.popCurrentController()
}
} else router.popCurrentController()
}
override fun handleBack(): Boolean {
activity?.let {
@ -429,7 +198,6 @@ class MigrationListController(bundle: Bundle? = null) :
.setTitle(R.string.stop_migrating)
.setPositiveButton(R.string.action_stop) { _, _ ->
router.popCurrentController()
migrationsJob?.cancel()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
@ -444,12 +212,12 @@ class MigrationListController(bundle: Bundle? = null) :
override fun onPrepareOptionsMenu(menu: Menu) {
// Initialize menu items.
val allMangasDone = adapter?.allMangasDone() ?: return
val allMangasDone = presenter.allMangasDone()
val menuCopy = menu.findItem(R.id.action_copy_manga)
val menuMigrate = menu.findItem(R.id.action_migrate_manga)
if (adapter?.itemCount == 1) {
if (presenter.migratingItems.value.size == 1) {
menuMigrate.icon = VectorDrawableCompat.create(
resources!!,
R.drawable.ic_done_24dp,
@ -474,8 +242,8 @@ class MigrationListController(bundle: Bundle? = null) :
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val totalManga = adapter?.itemCount ?: 0
val mangaSkipped = adapter?.mangasSkipped() ?: 0
val totalManga = presenter.migratingItems.value.size
val mangaSkipped = presenter.mangasSkipped()
when (item.itemId) {
R.id.action_copy_manga -> MigrationMangaDialog(
this,
@ -493,34 +261,9 @@ class MigrationListController(bundle: Bundle? = null) :
}
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 {
const val CONFIG_EXTRA = "config_extra"
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
import android.view.MenuItem
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(
val controller: MigrationListController,
) : 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))
}
}
) : FlexibleAdapter<MigrationProcessItem>(null, controller, true)

View File

@ -6,52 +6,51 @@ import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import coil.dispose
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.tachiyomi.R
import eu.kanade.tachiyomi.databinding.MigrationMangaCardBinding
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.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.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.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.onEach
import reactivecircus.flowbinding.android.view.clicks
import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat
import java.util.concurrent.CopyOnWriteArrayList
class MigrationProcessHolder(
private val view: View,
view: View,
private val adapter: MigrationProcessAdapter,
) : 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 val binding = MigrationProcessItemBinding.bind(view)
private val jobs = CopyOnWriteArrayList<Job>()
init {
// 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
// PopupMenu is shown.
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) {
this.item = item
launchUI {
val manga = item.manga.manga()
val source = item.manga.mangaSource()
jobs.removeAll { it.cancel(); true }
jobs += adapter.controller.viewScope.launchUI {
val migrateManga = item.manga
val manga = migrateManga.manga
binding.migrationMenu.setVectorCompat(
R.drawable.ic_more_24dp,
@ -64,9 +63,8 @@ class MigrationProcessHolder(
binding.migrationMenu.isInvisible = true
binding.skipManga.isVisible = true
binding.migrationMangaCardTo.resetManga()
if (manga != null) {
binding.migrationMangaCardFrom.attachManga(manga, source)
binding.migrationMangaCardFrom.root.clicks()
binding.migrationMangaCardFrom.attachManga(manga, item.manga.sourcesString, item.manga.chapterInfo)
jobs += binding.migrationMangaCardFrom.root.clicks()
.onEach {
adapter.controller.router.pushController(
MangaController(
@ -88,43 +86,51 @@ class MigrationProcessHolder(
}
}*/
val searchResult = item.manga.searchResult.get()?.let {
getManga.await(it)
}
val resultSource = searchResult?.source?.let {
sourceManager.get(it)
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
}
if (item.manga.mangaId != this@MigrationProcessHolder.item?.manga?.mangaId ||
item.manga.migrationStatus == MigrationStatus.RUNNING
) {
return@launchUI
val resultManga = withIOContext {
(searchResult as? SearchResult.Result)
?.let { migrateManga.getManga(it) }
}
if (searchResult != null && resultSource != null) {
binding.migrationMangaCardTo.attachManga(searchResult, resultSource)
binding.migrationMangaCardTo.root.clicks()
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(
searchResult.id,
resultManga.id,
true,
),
)
}
.launchIn(adapter.controller.viewScope)
} else {
if (adapter.hideNotFound) {
adapter.removeManga(bindingAdapterPosition)
} else {
binding.migrationMangaCardTo.progress.isVisible = false
binding.migrationMangaCardTo.title.text = view.context.applicationContext
binding.migrationMangaCardTo.title.text = itemView.context
.getString(R.string.no_alternatives_found)
}
}
binding.migrationMenu.isVisible = true
binding.skipManga.isVisible = false
adapter.sourceFinished()
adapter.controller.sourceFinished()
}
.catch {
this@MigrationProcessHolder.logcat(throwable = it) { "Error updating result info" }
}
.launchIn(adapter.controller.viewScope)
}
}
@ -139,39 +145,35 @@ class MigrationProcessHolder(
mangaLastChapterLabel.text = ""
}
private suspend fun MigrationMangaCardBinding.attachManga(manga: Manga, source: Source) {
private fun MigrationMangaCardBinding.attachManga(
manga: Manga,
sourceString: String,
chapterInfo: ChapterInfo,
) {
progress.isVisible = false
thumbnail.loadAutoPause(manga)
title.text = if (manga.title.isBlank()) {
view.context.getString(R.string.unknown)
itemView.context.getString(R.string.unknown)
} else {
manga.ogTitle
}
mangaSourceLabel.text = if (source.id == MERGED_SOURCE_ID) {
getMergedReferencesById.await(manga.id).map {
sourceManager.getOrStub(it.mangaSourceId).toString()
}.distinct().joinToString()
} else {
source.toString()
}
mangaSourceLabel.text = sourceString
val chapters = getChapterByMangaId.await(manga.id)
// For rounded corners
badges.leftBadges.clipToOutline = true
badges.rightBadges.clipToOutline = true
badges.unreadText.isVisible = true
badges.unreadText.text = chapters.size.toString()
val latestChapter = chapters.maxOfOrNull { it.chapterNumber } ?: -1f
badges.unreadText.text = chapterInfo.chapterCount.toString()
if (latestChapter > 0f) {
mangaLastChapterLabel.text = root.context.getString(
if (chapterInfo.latestChapter != null && chapterInfo.latestChapter > 0f) {
mangaLastChapterLabel.text = itemView.context.getString(
R.string.latest_,
DecimalFormat("#.#").format(latestChapter),
DecimalFormat("#.#").format(chapterInfo.latestChapter),
)
} else {
mangaLastChapterLabel.text = root.context.getString(
mangaLastChapterLabel.text = itemView.context.getString(
R.string.latest_,
root.context.getString(R.string.unknown),
)
@ -191,14 +193,14 @@ class MigrationProcessHolder(
popup.menu.findItem(R.id.action_search_manually).isVisible = true
// 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_copy_now).isVisible = true
}
// Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { menuItem ->
adapter.menuItemListener.onMenuItemClick(bindingAdapterPosition, menuItem)
adapter.controller.onMenuItemClick(item.manga.manga.id, menuItem)
true
}

View File

@ -30,12 +30,12 @@ class MigrationProcessItem(val manga: MigratingManga) :
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is MigrationProcessItem) {
return manga.mangaId == other.manga.mangaId
return manga.manga.id == other.manga.manga.id
}
return false
}
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 source = sourceManager.get(manga.source) ?: return@SourceSearchScreen
migrationListController.useMangaForMigration(manga, source)
router.popCurrentController()
router.popCurrentController()
router.popToTag(MigrationListController.TAG)
},
// SY <--
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
}
}