搜索问题 (#990)
* Filter to searchable book sources before running search * Track first failure details when all search sources fail * Align precision matching and parallelize source loading
This commit is contained in:
@@ -11,6 +11,9 @@ import io.legado.app.model.webBook.WebBook
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -81,18 +84,35 @@ class SearchBooksUseCase(
|
||||
throw NoStackTraceException("启用书源为空")
|
||||
}
|
||||
|
||||
val searchableSources = coroutineScope {
|
||||
sourceParts.map { part ->
|
||||
async(Dispatchers.IO) {
|
||||
val source = gateway.getBookSource(part.bookSourceUrl) ?: return@async null
|
||||
if (source.bookSourceType != 0 || source.searchUrl.isNullOrBlank()) {
|
||||
return@async null
|
||||
}
|
||||
SearchableSource(part, source)
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
if (searchableSources.isEmpty()) {
|
||||
throw NoStackTraceException("可搜索书源为空")
|
||||
}
|
||||
|
||||
val merger = SearchResultMerger(keyword, request.precision)
|
||||
val concurrency = request.concurrency.coerceAtLeast(1)
|
||||
var hasMore = false
|
||||
var processedSources = 0
|
||||
var failedSources = 0
|
||||
var firstFailureMessage: String? = null
|
||||
|
||||
emit(SearchRunEvent.Started)
|
||||
|
||||
sourceParts.asFlow()
|
||||
.flatMapMerge(concurrency) { sourcePart ->
|
||||
searchableSources.asFlow()
|
||||
.flatMapMerge(concurrency) { searchableSource ->
|
||||
flow {
|
||||
control.awaitResumed()
|
||||
emit(searchSource(sourcePart, keyword, request.page, request.precision))
|
||||
emit(searchSource(searchableSource, keyword, request.page, request.precision))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
.collect { result ->
|
||||
@@ -112,12 +132,16 @@ class SearchBooksUseCase(
|
||||
removedBookUrls = change.removedBookUrls,
|
||||
resultCount = merger.count,
|
||||
processedSources = processedSources,
|
||||
totalSources = sourceParts.size,
|
||||
totalSources = searchableSources.size,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is SourceSearchResult.Failed -> {
|
||||
failedSources++
|
||||
if (firstFailureMessage.isNullOrBlank()) {
|
||||
firstFailureMessage = result.throwable.localizedMessage
|
||||
}
|
||||
AppLog.put("书源搜索出错\n${result.throwable.localizedMessage}", result.throwable)
|
||||
emit(
|
||||
SearchRunEvent.Progress(
|
||||
@@ -125,13 +149,18 @@ class SearchBooksUseCase(
|
||||
removedBookUrls = emptyList(),
|
||||
resultCount = merger.count,
|
||||
processedSources = processedSources,
|
||||
totalSources = sourceParts.size,
|
||||
totalSources = searchableSources.size,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (merger.count == 0 && failedSources == searchableSources.size) {
|
||||
val error = firstFailureMessage?.takeIf { it.isNotBlank() } ?: "全部书源搜索失败"
|
||||
throw NoStackTraceException(error)
|
||||
}
|
||||
|
||||
emit(
|
||||
SearchRunEvent.Finished(
|
||||
isEmpty = merger.count == 0,
|
||||
@@ -141,14 +170,13 @@ class SearchBooksUseCase(
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private suspend fun searchSource(
|
||||
sourcePart: BookSourcePart,
|
||||
searchableSource: SearchableSource,
|
||||
keyword: String,
|
||||
page: Int,
|
||||
precision: Boolean,
|
||||
): SourceSearchResult {
|
||||
return try {
|
||||
val source = gateway.getBookSource(sourcePart.bookSourceUrl)
|
||||
?: return SourceSearchResult.Found(emptyList())
|
||||
val source = searchableSource.source
|
||||
val supportsSearchPage = source.supportsSearchPage()
|
||||
if (page > 1 && !supportsSearchPage) {
|
||||
return SourceSearchResult.Found(emptyList())
|
||||
@@ -159,7 +187,9 @@ class SearchBooksUseCase(
|
||||
keyword,
|
||||
page,
|
||||
filter = { name, author ->
|
||||
!precision || name.contains(keyword) || author.contains(keyword)
|
||||
!precision ||
|
||||
name.contains(keyword, ignoreCase = true) ||
|
||||
author.contains(keyword, ignoreCase = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -171,6 +201,12 @@ class SearchBooksUseCase(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private data class SearchableSource(
|
||||
val part: BookSourcePart,
|
||||
val source: BookSource,
|
||||
)
|
||||
private sealed interface SourceSearchResult {
|
||||
data class Found(
|
||||
val books: List<SearchBook>,
|
||||
@@ -224,8 +260,10 @@ class SearchBooksUseCase(
|
||||
|
||||
private fun classifyBucket(book: SearchBook): LinkedHashMap<SearchBookKey, SearchBook>? {
|
||||
return when {
|
||||
book.name == keyword || book.author == keyword -> equalBooks
|
||||
book.name.contains(keyword) || book.author.contains(keyword) -> containsBooks
|
||||
book.name.equals(keyword, ignoreCase = true) ||
|
||||
book.author.equals(keyword, ignoreCase = true) -> equalBooks
|
||||
book.name.contains(keyword, ignoreCase = true) ||
|
||||
book.author.contains(keyword, ignoreCase = true) -> containsBooks
|
||||
!precision -> otherBooks
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -95,7 +95,6 @@ import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -110,6 +109,7 @@ fun SearchScreen(
|
||||
val listState = rememberLazyListState()
|
||||
var queryInput by rememberSaveable { mutableStateOf(state.query) }
|
||||
var scopeSheetTab by rememberSaveable { mutableStateOf(0) }
|
||||
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val showSuggestionPanel = state.showSuggestions
|
||||
val latestQuery by rememberUpdatedState(state.query)
|
||||
val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
|
||||
@@ -142,9 +142,14 @@ fun SearchScreen(
|
||||
snapshotFlow { queryInput }
|
||||
.distinctUntilChanged()
|
||||
.debounce(200)
|
||||
.filter { it != latestQuery }
|
||||
.collect { newQuery ->
|
||||
viewModel.onIntent(SearchIntent.UpdateQuery(newQuery))
|
||||
if (ignoreNextDebouncedQuery == newQuery) {
|
||||
ignoreNextDebouncedQuery = null
|
||||
return@collect
|
||||
}
|
||||
if (newQuery != latestQuery) {
|
||||
viewModel.onIntent(SearchIntent.UpdateQuery(newQuery))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +193,7 @@ fun SearchScreen(
|
||||
val submitSearch: (String) -> Unit = { rawQuery ->
|
||||
val normalized = rawQuery.trim()
|
||||
if (normalized.isNotBlank()) {
|
||||
ignoreNextDebouncedQuery = normalized
|
||||
queryInput = normalized
|
||||
if (normalized != state.query) {
|
||||
viewModel.onIntent(SearchIntent.UpdateQuery(normalized))
|
||||
|
||||
@@ -234,7 +234,13 @@ class SearchViewModel(
|
||||
}
|
||||
|
||||
private fun updateQuery(query: String, showSuggestions: Boolean) {
|
||||
if (showSuggestions && _uiState.value.isSearching) {
|
||||
val currentState = _uiState.value
|
||||
val isSameQuery = currentState.query == query
|
||||
val sameUiFlag = currentState.showSuggestions == showSuggestions
|
||||
if (isSameQuery && sameUiFlag) {
|
||||
return
|
||||
}
|
||||
if (showSuggestions && currentState.isSearching && !isSameQuery) {
|
||||
stopSearch(manualStop = false)
|
||||
}
|
||||
queryFlow.value = query
|
||||
|
||||
Reference in New Issue
Block a user