|
|
|
|
@ -1,22 +1,19 @@
|
|
|
|
|
package org.koitharu.kotatsu.search.ui.multi
|
|
|
|
|
|
|
|
|
|
import androidx.annotation.CheckResult
|
|
|
|
|
import androidx.collection.ArraySet
|
|
|
|
|
import androidx.collection.LongSet
|
|
|
|
|
import androidx.lifecycle.SavedStateHandle
|
|
|
|
|
import androidx.lifecycle.viewModelScope
|
|
|
|
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
|
|
|
import kotlinx.coroutines.flow.Flow
|
|
|
|
|
import kotlinx.coroutines.Job
|
|
|
|
|
import kotlinx.coroutines.cancelAndJoin
|
|
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
|
|
|
import kotlinx.coroutines.flow.SharingStarted
|
|
|
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
|
|
|
import kotlinx.coroutines.flow.channelFlow
|
|
|
|
|
import kotlinx.coroutines.flow.combine
|
|
|
|
|
import kotlinx.coroutines.flow.filterNotNull
|
|
|
|
|
import kotlinx.coroutines.flow.flatMapLatest
|
|
|
|
|
import kotlinx.coroutines.flow.onEmpty
|
|
|
|
|
import kotlinx.coroutines.flow.runningFold
|
|
|
|
|
import kotlinx.coroutines.flow.stateIn
|
|
|
|
|
import kotlinx.coroutines.flow.update
|
|
|
|
|
import kotlinx.coroutines.joinAll
|
|
|
|
|
import kotlinx.coroutines.launch
|
|
|
|
|
import kotlinx.coroutines.plus
|
|
|
|
|
@ -29,18 +26,23 @@ import org.koitharu.kotatsu.core.nav.AppRouter
|
|
|
|
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
|
|
|
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
|
|
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
|
|
|
import org.koitharu.kotatsu.core.util.ext.toLocale
|
|
|
|
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
|
|
|
|
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
|
|
|
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
|
|
|
|
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.ListModel
|
|
|
|
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
|
|
|
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
|
|
|
|
import org.koitharu.kotatsu.parsers.model.Manga
|
|
|
|
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|
|
|
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
|
|
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
|
|
|
import org.koitharu.kotatsu.search.domain.SearchKind
|
|
|
|
|
import org.koitharu.kotatsu.search.domain.SearchV2Helper
|
|
|
|
|
import java.util.Locale
|
|
|
|
|
import javax.inject.Inject
|
|
|
|
|
|
|
|
|
|
private const val MAX_PARALLELISM = 4
|
|
|
|
|
@ -58,15 +60,16 @@ class SearchViewModel @Inject constructor(
|
|
|
|
|
val query = savedStateHandle.get<String>(AppRouter.KEY_QUERY).orEmpty()
|
|
|
|
|
val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE
|
|
|
|
|
|
|
|
|
|
private val retryCounter = MutableStateFlow(0)
|
|
|
|
|
private val listData = retryCounter.flatMapLatest {
|
|
|
|
|
searchImpl().withLoading().withErrorHandling()
|
|
|
|
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
|
|
|
|
private var includeDisabledSources = MutableStateFlow(false)
|
|
|
|
|
private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList())
|
|
|
|
|
|
|
|
|
|
private var searchJob: Job? = null
|
|
|
|
|
|
|
|
|
|
val list: StateFlow<List<ListModel>> = combine(
|
|
|
|
|
listData.filterNotNull(),
|
|
|
|
|
results,
|
|
|
|
|
isLoading,
|
|
|
|
|
) { list, loading ->
|
|
|
|
|
includeDisabledSources,
|
|
|
|
|
) { list, loading, includeDisabled ->
|
|
|
|
|
when {
|
|
|
|
|
list.isEmpty() -> listOf(
|
|
|
|
|
when {
|
|
|
|
|
@ -81,13 +84,18 @@ class SearchViewModel @Inject constructor(
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
loading -> list + LoadingFooter()
|
|
|
|
|
else -> list
|
|
|
|
|
includeDisabled -> list
|
|
|
|
|
else -> list + ButtonFooter(R.string.search_disabled_sources)
|
|
|
|
|
}
|
|
|
|
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
|
|
|
|
|
|
|
|
|
init {
|
|
|
|
|
doSearch()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun getItems(ids: LongSet): Set<Manga> {
|
|
|
|
|
val snapshot = listData.value ?: return emptySet()
|
|
|
|
|
val result = HashSet<Manga>(ids.size)
|
|
|
|
|
val snapshot = results.value
|
|
|
|
|
val result = ArraySet<Manga>(ids.size)
|
|
|
|
|
snapshot.forEach { x ->
|
|
|
|
|
for (item in x.list) {
|
|
|
|
|
if (item.id in ids) {
|
|
|
|
|
@ -99,157 +107,192 @@ class SearchViewModel @Inject constructor(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun retry() {
|
|
|
|
|
retryCounter.value += 1
|
|
|
|
|
searchJob?.cancel()
|
|
|
|
|
results.value = emptyList()
|
|
|
|
|
includeDisabledSources.value = false
|
|
|
|
|
doSearch()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@CheckResult
|
|
|
|
|
private fun searchImpl(): Flow<List<SearchResultsListModel>> = channelFlow {
|
|
|
|
|
searchHistory()?.let { send(it) }
|
|
|
|
|
searchFavorites()?.let { send(it) }
|
|
|
|
|
searchLocal()?.let { send(it) }
|
|
|
|
|
val sources = sourcesRepository.getEnabledSources()
|
|
|
|
|
if (sources.isEmpty()) {
|
|
|
|
|
return@channelFlow
|
|
|
|
|
fun continueSearch() {
|
|
|
|
|
if (includeDisabledSources.value) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
|
|
|
|
sources.map { source ->
|
|
|
|
|
launch {
|
|
|
|
|
val item = runCatchingCancellable {
|
|
|
|
|
val prevJob = searchJob
|
|
|
|
|
searchJob = launchLoadingJob(Dispatchers.Default) {
|
|
|
|
|
includeDisabledSources.value = true
|
|
|
|
|
prevJob?.join()
|
|
|
|
|
val sources = sourcesRepository.getDisabledSources()
|
|
|
|
|
.sortedByDescending { it.priority() }
|
|
|
|
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
|
|
|
|
sources.map { source ->
|
|
|
|
|
launch {
|
|
|
|
|
semaphore.withPermit {
|
|
|
|
|
val searchHelper = searchHelperFactory.create(source)
|
|
|
|
|
searchHelper(query, kind)
|
|
|
|
|
appendResult(searchSource(source))
|
|
|
|
|
}
|
|
|
|
|
}.fold(
|
|
|
|
|
onSuccess = { result ->
|
|
|
|
|
if (result == null || result.manga.isEmpty()) {
|
|
|
|
|
null
|
|
|
|
|
} else {
|
|
|
|
|
val list = mangaListMapper.toListModelList(
|
|
|
|
|
manga = result.manga,
|
|
|
|
|
mode = ListMode.GRID,
|
|
|
|
|
)
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = 0,
|
|
|
|
|
source = source,
|
|
|
|
|
list = list,
|
|
|
|
|
error = null,
|
|
|
|
|
listFilter = result.listFilter,
|
|
|
|
|
sortOrder = result.sortOrder,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onFailure = { error ->
|
|
|
|
|
error.printStackTraceDebug()
|
|
|
|
|
SearchResultsListModel(0, source, null, null, emptyList(), error)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if (item != null) {
|
|
|
|
|
send(item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}.joinAll()
|
|
|
|
|
}.runningFold<SearchResultsListModel, List<SearchResultsListModel>?>(null) { list, item -> list.orEmpty() + item }
|
|
|
|
|
.filterNotNull()
|
|
|
|
|
.onEmpty { emit(emptyList()) }
|
|
|
|
|
|
|
|
|
|
private suspend fun searchHistory(): SearchResultsListModel? {
|
|
|
|
|
return runCatchingCancellable {
|
|
|
|
|
historyRepository.search(query, kind, Int.MAX_VALUE)
|
|
|
|
|
}.fold(
|
|
|
|
|
onSuccess = { result ->
|
|
|
|
|
if (result.isNotEmpty()) {
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = R.string.history,
|
|
|
|
|
source = UnknownMangaSource,
|
|
|
|
|
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
|
|
|
|
|
error = null,
|
|
|
|
|
listFilter = null,
|
|
|
|
|
sortOrder = null,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
null
|
|
|
|
|
}.joinAll()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun doSearch() {
|
|
|
|
|
val prevJob = searchJob
|
|
|
|
|
searchJob = launchLoadingJob(Dispatchers.Default) {
|
|
|
|
|
prevJob?.cancelAndJoin()
|
|
|
|
|
appendResult(searchHistory())
|
|
|
|
|
appendResult(searchFavorites())
|
|
|
|
|
appendResult(searchLocal())
|
|
|
|
|
val sources = sourcesRepository.getEnabledSources()
|
|
|
|
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
|
|
|
|
sources.map { source ->
|
|
|
|
|
launch {
|
|
|
|
|
semaphore.withPermit {
|
|
|
|
|
appendResult(searchSource(source))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onFailure = { error ->
|
|
|
|
|
}.joinAll()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// impl
|
|
|
|
|
|
|
|
|
|
private suspend fun searchSource(source: MangaSource): SearchResultsListModel? = runCatchingCancellable {
|
|
|
|
|
val searchHelper = searchHelperFactory.create(source)
|
|
|
|
|
searchHelper(query, kind)
|
|
|
|
|
}.fold(
|
|
|
|
|
onSuccess = { result ->
|
|
|
|
|
if (result == null || result.manga.isEmpty()) {
|
|
|
|
|
null
|
|
|
|
|
} else {
|
|
|
|
|
val list = mangaListMapper.toListModelList(
|
|
|
|
|
manga = result.manga,
|
|
|
|
|
mode = ListMode.GRID,
|
|
|
|
|
)
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = 0,
|
|
|
|
|
source = source,
|
|
|
|
|
list = list,
|
|
|
|
|
error = null,
|
|
|
|
|
listFilter = result.listFilter,
|
|
|
|
|
sortOrder = result.sortOrder,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onFailure = { error ->
|
|
|
|
|
error.printStackTraceDebug()
|
|
|
|
|
if (source is MangaParserSource && source.isBroken) {
|
|
|
|
|
null
|
|
|
|
|
} else {
|
|
|
|
|
SearchResultsListModel(0, source, null, null, emptyList(), error)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private suspend fun searchHistory(): SearchResultsListModel? = runCatchingCancellable {
|
|
|
|
|
historyRepository.search(query, kind, Int.MAX_VALUE)
|
|
|
|
|
}.fold(
|
|
|
|
|
onSuccess = { result ->
|
|
|
|
|
if (result.isNotEmpty()) {
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = R.string.history,
|
|
|
|
|
source = UnknownMangaSource,
|
|
|
|
|
list = emptyList(),
|
|
|
|
|
error = error,
|
|
|
|
|
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
|
|
|
|
|
error = null,
|
|
|
|
|
listFilter = null,
|
|
|
|
|
sortOrder = null,
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
null
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onFailure = { error ->
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = R.string.history,
|
|
|
|
|
source = UnknownMangaSource,
|
|
|
|
|
list = emptyList(),
|
|
|
|
|
error = error,
|
|
|
|
|
listFilter = null,
|
|
|
|
|
sortOrder = null,
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private suspend fun searchFavorites(): SearchResultsListModel? {
|
|
|
|
|
return runCatchingCancellable {
|
|
|
|
|
favouritesRepository.search(query, kind, Int.MAX_VALUE)
|
|
|
|
|
}.fold(
|
|
|
|
|
onSuccess = { result ->
|
|
|
|
|
if (result.isNotEmpty()) {
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = R.string.favourites,
|
|
|
|
|
source = UnknownMangaSource,
|
|
|
|
|
list = mangaListMapper.toListModelList(
|
|
|
|
|
manga = result,
|
|
|
|
|
mode = ListMode.GRID,
|
|
|
|
|
flags = MangaListMapper.NO_FAVORITE,
|
|
|
|
|
),
|
|
|
|
|
error = null,
|
|
|
|
|
listFilter = null,
|
|
|
|
|
sortOrder = null,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
null
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onFailure = { error ->
|
|
|
|
|
private suspend fun searchFavorites(): SearchResultsListModel? = runCatchingCancellable {
|
|
|
|
|
favouritesRepository.search(query, kind, Int.MAX_VALUE)
|
|
|
|
|
}.fold(
|
|
|
|
|
onSuccess = { result ->
|
|
|
|
|
if (result.isNotEmpty()) {
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = R.string.favourites,
|
|
|
|
|
source = UnknownMangaSource,
|
|
|
|
|
list = emptyList(),
|
|
|
|
|
error = error,
|
|
|
|
|
list = mangaListMapper.toListModelList(
|
|
|
|
|
manga = result,
|
|
|
|
|
mode = ListMode.GRID,
|
|
|
|
|
flags = MangaListMapper.NO_FAVORITE,
|
|
|
|
|
),
|
|
|
|
|
error = null,
|
|
|
|
|
listFilter = null,
|
|
|
|
|
sortOrder = null,
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
null
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onFailure = { error ->
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = R.string.favourites,
|
|
|
|
|
source = UnknownMangaSource,
|
|
|
|
|
list = emptyList(),
|
|
|
|
|
error = error,
|
|
|
|
|
listFilter = null,
|
|
|
|
|
sortOrder = null,
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private suspend fun searchLocal(): SearchResultsListModel? {
|
|
|
|
|
return runCatchingCancellable {
|
|
|
|
|
searchHelperFactory.create(LocalMangaSource).invoke(query, kind)
|
|
|
|
|
}.fold(
|
|
|
|
|
onSuccess = { result ->
|
|
|
|
|
if (!result?.manga.isNullOrEmpty()) {
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = 0,
|
|
|
|
|
source = LocalMangaSource,
|
|
|
|
|
list = mangaListMapper.toListModelList(
|
|
|
|
|
manga = result.manga,
|
|
|
|
|
mode = ListMode.GRID,
|
|
|
|
|
flags = MangaListMapper.NO_SAVED,
|
|
|
|
|
),
|
|
|
|
|
error = null,
|
|
|
|
|
listFilter = result.listFilter,
|
|
|
|
|
sortOrder = result.sortOrder,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
null
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onFailure = { error ->
|
|
|
|
|
private suspend fun searchLocal(): SearchResultsListModel? = runCatchingCancellable {
|
|
|
|
|
searchHelperFactory.create(LocalMangaSource).invoke(query, kind)
|
|
|
|
|
}.fold(
|
|
|
|
|
onSuccess = { result ->
|
|
|
|
|
if (!result?.manga.isNullOrEmpty()) {
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = 0,
|
|
|
|
|
source = LocalMangaSource,
|
|
|
|
|
list = emptyList(),
|
|
|
|
|
error = error,
|
|
|
|
|
listFilter = null,
|
|
|
|
|
sortOrder = null,
|
|
|
|
|
list = mangaListMapper.toListModelList(
|
|
|
|
|
manga = result.manga,
|
|
|
|
|
mode = ListMode.GRID,
|
|
|
|
|
flags = MangaListMapper.NO_SAVED,
|
|
|
|
|
),
|
|
|
|
|
error = null,
|
|
|
|
|
listFilter = result.listFilter,
|
|
|
|
|
sortOrder = result.sortOrder,
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
null
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onFailure = { error ->
|
|
|
|
|
SearchResultsListModel(
|
|
|
|
|
titleResId = 0,
|
|
|
|
|
source = LocalMangaSource,
|
|
|
|
|
list = emptyList(),
|
|
|
|
|
error = error,
|
|
|
|
|
listFilter = null,
|
|
|
|
|
sortOrder = null,
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
private fun appendResult(item: SearchResultsListModel?) {
|
|
|
|
|
if (item != null) {
|
|
|
|
|
results.update { list -> list + item }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun MangaSource.priority(): Int {
|
|
|
|
|
var res = 0
|
|
|
|
|
if (this is MangaParserSource) {
|
|
|
|
|
if (locale.toLocale() == Locale.getDefault()) res += 2
|
|
|
|
|
}
|
|
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|