Improve alternatives search functionality

This commit is contained in:
Koitharu
2025-03-08 14:13:44 +02:00
parent 5373e58807
commit 2c8476cabd
9 changed files with 138 additions and 57 deletions

View File

@@ -7,6 +7,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
@@ -14,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.domain.SearchV2Helper import org.koitharu.kotatsu.search.domain.SearchV2Helper
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
private const val MAX_PARALLELISM = 4 private const val MAX_PARALLELISM = 4
@@ -24,8 +26,8 @@ class AlternativesUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) { ) {
suspend operator fun invoke(manga: Manga): Flow<Manga> { suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow<Manga> {
val sources = getSources(manga.source) val sources = getSources(manga.source, throughDisabledSources)
if (sources.isEmpty()) { if (sources.isEmpty()) {
return emptyFlow() return emptyFlow()
} }
@@ -39,12 +41,14 @@ class AlternativesUseCase @Inject constructor(
searchHelper(manga.title, SearchKind.TITLE)?.manga searchHelper(manga.title, SearchKind.TITLE)?.manga
} }
}.getOrNull() }.getOrNull()
list?.forEach { list?.forEach { m ->
launch { if (m.id != manga.id) {
val details = runCatchingCancellable { launch {
mangaRepositoryFactory.create(it.source).getDetails(it) val details = runCatchingCancellable {
}.getOrDefault(it) mangaRepositoryFactory.create(m.source).getDetails(m)
send(details) }.getOrDefault(m)
send(details)
}
} }
} }
} }
@@ -52,19 +56,23 @@ class AlternativesUseCase @Inject constructor(
} }
} }
private suspend fun getSources(ref: MangaSource): List<MangaSource> { private suspend fun getSources(ref: MangaSource, disabled: Boolean): List<MangaSource> = if (disabled) {
val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2) sourcesRepository.getDisabledSources()
result.addAll(sourcesRepository.getEnabledSources()) } else {
result.sortByDescending { it.priority(ref) } sourcesRepository.getEnabledSources()
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) }) }.sortedByDescending { it.priority(ref) }
return result
}
private fun MangaSource.priority(ref: MangaSource): Int { private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0 var res = 0
if (this is MangaParserSource && ref is MangaParserSource) { if (this is MangaParserSource && ref is MangaParserSource) {
if (locale == ref.locale) res += 2 if (locale == ref.locale) {
if (contentType == ref.contentType) res++ res += 4
} else if (locale.toLocale() == Locale.getDefault()) {
res += 2
}
if (contentType == ref.contentType) {
res++
}
} }
return res return res
} }

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.concat
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -35,7 +36,8 @@ class AutoFixUseCase @Inject constructor(
if (seed.isHealthy()) { if (seed.isHealthy()) {
return seed to null // no fix required return seed to null // no fix required
} }
val replacement = alternativesUseCase(seed) val replacement = alternativesUseCase(seed, throughDisabledSources = false)
.concat(alternativesUseCase(seed, throughDisabledSources = true))
.filter { it.isHealthy() } .filter { it.isHealthy() }
.runningFold<Manga, Manga?>(null) { best, candidate -> .runningFold<Manga, Manga?>(null) { best, candidate ->
if (best == null || best < candidate) { if (best == null || best < candidate) {

View File

@@ -22,7 +22,9 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.core.util.ext.systemBarsInsets
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
@@ -32,6 +34,7 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(), class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
ListStateHolderListener,
OnListItemClickListener<MangaAlternativeModel> { OnListItemClickListener<MangaAlternativeModel> {
@Inject @Inject
@@ -51,6 +54,7 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null)) .addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) .addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) .addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
with(viewBinding.recyclerView) { with(viewBinding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false)) addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
@@ -58,7 +62,7 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
} }
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
viewModel.content.observe(this, listAdapter) viewModel.list.observe(this, listAdapter)
viewModel.onMigrated.observeEvent(this) { viewModel.onMigrated.observeEvent(this) {
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
router.openDetails(it) router.openDetails(it)
@@ -92,6 +96,12 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
} }
} }
override fun onRetryClick(error: Throwable) = viewModel.retry()
override fun onEmptyActionClick() = Unit
override fun onFooterButtonClick() = viewModel.continueSearch()
private fun confirmMigration(target: Manga) { private fun confirmMigration(target: Manga) {
buildAlertDialog(this, isCentered = true) { buildAlertDialog(this, isCentered = true) {
setIcon(R.drawable.ic_replace) setIcon(R.drawable.ic_replace)

View File

@@ -1,13 +1,17 @@
package org.koitharu.kotatsu.alternatives.ui package org.koitharu.kotatsu.alternatives.ui
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.onEmpty import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
@@ -18,16 +22,19 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.append
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.ButtonFooter
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -41,39 +48,62 @@ class AlternativesViewModel @Inject constructor(
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
val onMigrated = MutableEventFlow<Manga>() private var includeDisabledSources = MutableStateFlow(false)
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState)) private val results = MutableStateFlow<List<MangaAlternativeModel>>(emptyList())
private var migrationJob: Job? = null private var migrationJob: Job? = null
private var searchJob: Job? = null
private val mangaDetails = suspendLazy {
mangaRepositoryFactory.create(manga.source).getDetails(manga)
}
val onMigrated = MutableEventFlow<Manga>()
val list: StateFlow<List<ListModel>> = combine(
results,
isLoading,
includeDisabledSources,
) { list, loading, includeDisabled ->
when {
list.isEmpty() -> listOf(
when {
loading -> LoadingState
else -> EmptyState(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
)
},
)
loading -> list + LoadingFooter()
includeDisabled -> list
else -> list + ButtonFooter(R.string.search_disabled_sources)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init { init {
launchJob(Dispatchers.Default) { doSearch(throughDisabledSources = false)
val ref = runCatchingCancellable { }
mangaRepositoryFactory.create(manga.source).getDetails(manga)
}.getOrDefault(manga) fun retry() {
val refCount = ref.chaptersCount() searchJob?.cancel()
alternativesUseCase(ref) results.value = emptyList()
.map { includeDisabledSources.value = false
MangaAlternativeModel( doSearch(throughDisabledSources = false)
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel, }
referenceChapters = refCount,
) fun continueSearch() {
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item -> if (includeDisabledSources.value) {
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter() return
}.onEmpty { }
emit( val prevJob = searchJob
listOf( searchJob = launchLoadingJob(Dispatchers.Default) {
EmptyState( includeDisabledSources.value = true
icon = R.drawable.ic_empty_common, prevJob?.join()
textPrimary = R.string.nothing_found, doSearch(throughDisabledSources = true)
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
),
),
)
}.collect {
content.value = it
}
content.value = content.value.filterNot { it is LoadingFooter }
} }
} }
@@ -86,4 +116,21 @@ class AlternativesViewModel @Inject constructor(
onMigrated.call(target) onMigrated.call(target)
} }
} }
private fun doSearch(throughDisabledSources: Boolean) {
val prevJob = searchJob
searchJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
val ref = mangaDetails.getOrDefault(manga)
val refCount = ref.chaptersCount()
alternativesUseCase.invoke(ref, throughDisabledSources)
.collect {
val model = MangaAlternativeModel(
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
referenceChapters = refCount,
)
results.append(model)
}
}
}
} }

View File

@@ -4,10 +4,12 @@ import android.os.SystemClock
import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.update
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -142,3 +145,12 @@ suspend fun <T> SendChannel<T>.sendNotNull(item: T?) {
send(item) send(item)
} }
} }
fun <T> MutableStateFlow<List<T>>.append(item: T) {
update { list -> list + item }
}
fun <T> Flow<T>.concat(other: Flow<T>) = flow {
emitAll(this@concat)
emitAll(other)
}

View File

@@ -155,6 +155,7 @@ class DetailsActivity :
viewBinding.chipsTags.onChipClickListener = this viewBinding.chipsTags.onChipClickListener = this
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView) TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
viewBinding.containerBottomSheet?.let { sheet -> viewBinding.containerBottomSheet?.let { sheet ->
sheet.setOnClickListener(this)
sheet.addOnLayoutChangeListener(this) sheet.addOnLayoutChangeListener(this)
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet)) onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
BottomSheetBehavior.from(sheet).addBottomSheetCallback( BottomSheetBehavior.from(sheet).addBottomSheetCallback(

View File

@@ -341,7 +341,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.fab?.isEnabled = !isLoading val fab = viewBinding.fab ?: viewBinding.navRail?.headerView ?: return
fab.isEnabled = !isLoading
} }
private fun onResumeEnabledChanged(isEnabled: Boolean) { private fun onResumeEnabledChanged(isEnabled: Boolean) {

View File

@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@@ -25,6 +24,7 @@ import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.append
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
@@ -284,7 +284,7 @@ class SearchViewModel @Inject constructor(
private fun appendResult(item: SearchResultsListModel?) { private fun appendResult(item: SearchResultsListModel?) {
if (item != null) { if (item != null) {
results.update { list -> list + item } results.append(item)
} }
} }

View File

@@ -31,7 +31,7 @@ material = "1.13.0-alpha11"
moshi = "1.15.2" moshi = "1.15.2"
okhttp = "4.12.0" okhttp = "4.12.0"
okio = "3.10.2" okio = "3.10.2"
parsers = "77a5216ebf" parsers = "d5a4cf68c6"
preference = "1.2.1" preference = "1.2.1"
recyclerview = "1.4.0" recyclerview = "1.4.0"
room = "2.6.1" room = "2.6.1"