搜索问题 (#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:
Kudomaga authored and GitHub committed 2026-05-05 01:49:57 +08:00
1 parent f309e03b23
commit 103bdc07d1
3 files changed
+64 -14

No files matched your search

@@ -11,6 +11,9 @@ import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -81,18 +84,35 @@ class SearchBooksUseCase(
throw NoStackTraceException("启用书源为空") 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 merger = SearchResultMerger(keyword, request.precision)
val concurrency = request.concurrency.coerceAtLeast(1) val concurrency = request.concurrency.coerceAtLeast(1)
var hasMore = false var hasMore = false
var processedSources = 0 var processedSources = 0
var failedSources = 0
var firstFailureMessage: String? = null
emit(SearchRunEvent.Started) emit(SearchRunEvent.Started)
sourceParts.asFlow() searchableSources.asFlow()
.flatMapMerge(concurrency) { sourcePart -> .flatMapMerge(concurrency) { searchableSource ->
flow { flow {
control.awaitResumed() control.awaitResumed()
emit(searchSource(sourcePart, keyword, request.page, request.precision)) emit(searchSource(searchableSource, keyword, request.page, request.precision))
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
} }
.collect { result -> .collect { result ->
@@ -112,12 +132,16 @@ class SearchBooksUseCase(
removedBookUrls = change.removedBookUrls, removedBookUrls = change.removedBookUrls,
resultCount = merger.count, resultCount = merger.count,
processedSources = processedSources, processedSources = processedSources,
totalSources = sourceParts.size, totalSources = searchableSources.size,
) )
) )
} }
is SourceSearchResult.Failed -> { is SourceSearchResult.Failed -> {
failedSources++
if (firstFailureMessage.isNullOrBlank()) {
firstFailureMessage = result.throwable.localizedMessage
}
AppLog.put("书源搜索出错\n${result.throwable.localizedMessage}", result.throwable) AppLog.put("书源搜索出错\n${result.throwable.localizedMessage}", result.throwable)
emit( emit(
SearchRunEvent.Progress( SearchRunEvent.Progress(
@@ -125,13 +149,18 @@ class SearchBooksUseCase(
removedBookUrls = emptyList(), removedBookUrls = emptyList(),
resultCount = merger.count, resultCount = merger.count,
processedSources = processedSources, 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( emit(
SearchRunEvent.Finished( SearchRunEvent.Finished(
isEmpty = merger.count == 0, isEmpty = merger.count == 0,
@@ -141,14 +170,13 @@ class SearchBooksUseCase(
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
private suspend fun searchSource( private suspend fun searchSource(
sourcePart: BookSourcePart, searchableSource: SearchableSource,
keyword: String, keyword: String,
page: Int, page: Int,
precision: Boolean, precision: Boolean,
): SourceSearchResult { ): SourceSearchResult {
return try { return try {
val source = gateway.getBookSource(sourcePart.bookSourceUrl) val source = searchableSource.source
?: return SourceSearchResult.Found(emptyList())
val supportsSearchPage = source.supportsSearchPage() val supportsSearchPage = source.supportsSearchPage()
if (page > 1 && !supportsSearchPage) { if (page > 1 && !supportsSearchPage) {
return SourceSearchResult.Found(emptyList()) return SourceSearchResult.Found(emptyList())
@@ -159,7 +187,9 @@ class SearchBooksUseCase(
keyword, keyword,
page, page,
filter = { name, author -> 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 { private sealed interface SourceSearchResult {
data class Found( data class Found(
val books: List<SearchBook>, val books: List<SearchBook>,
@@ -224,8 +260,10 @@ class SearchBooksUseCase(
private fun classifyBucket(book: SearchBook): LinkedHashMap<SearchBookKey, SearchBook>? { private fun classifyBucket(book: SearchBook): LinkedHashMap<SearchBookKey, SearchBook>? {
return when { return when {
book.name == keyword || book.author == keyword -> equalBooks book.name.equals(keyword, ignoreCase = true) ||
book.name.contains(keyword) || book.author.contains(keyword) -> containsBooks book.author.equals(keyword, ignoreCase = true) -> equalBooks
book.name.contains(keyword, ignoreCase = true) ||
book.author.contains(keyword, ignoreCase = true) -> containsBooks
!precision -> otherBooks !precision -> otherBooks
else -> null else -> null
} }
@@ -95,7 +95,6 @@ import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class) @OptIn(FlowPreview::class, ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -110,6 +109,7 @@ fun SearchScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
var queryInput by rememberSaveable { mutableStateOf(state.query) } var queryInput by rememberSaveable { mutableStateOf(state.query) }
var scopeSheetTab by rememberSaveable { mutableStateOf(0) } var scopeSheetTab by rememberSaveable { mutableStateOf(0) }
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
val showSuggestionPanel = state.showSuggestions val showSuggestionPanel = state.showSuggestions
val latestQuery by rememberUpdatedState(state.query) val latestQuery by rememberUpdatedState(state.query)
val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
@@ -142,11 +142,16 @@ fun SearchScreen(
snapshotFlow { queryInput } snapshotFlow { queryInput }
.distinctUntilChanged() .distinctUntilChanged()
.debounce(200) .debounce(200)
.filter { it != latestQuery }
.collect { newQuery -> .collect { newQuery ->
if (ignoreNextDebouncedQuery == newQuery) {
ignoreNextDebouncedQuery = null
return@collect
}
if (newQuery != latestQuery) {
viewModel.onIntent(SearchIntent.UpdateQuery(newQuery)) viewModel.onIntent(SearchIntent.UpdateQuery(newQuery))
} }
} }
}
LaunchedEffect( LaunchedEffect(
shouldLoadMore, shouldLoadMore,
@@ -188,6 +193,7 @@ fun SearchScreen(
val submitSearch: (String) -> Unit = { rawQuery -> val submitSearch: (String) -> Unit = { rawQuery ->
val normalized = rawQuery.trim() val normalized = rawQuery.trim()
if (normalized.isNotBlank()) { if (normalized.isNotBlank()) {
ignoreNextDebouncedQuery = normalized
queryInput = normalized queryInput = normalized
if (normalized != state.query) { if (normalized != state.query) {
viewModel.onIntent(SearchIntent.UpdateQuery(normalized)) viewModel.onIntent(SearchIntent.UpdateQuery(normalized))
@@ -234,7 +234,13 @@ class SearchViewModel(
} }
private fun updateQuery(query: String, showSuggestions: Boolean) { 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) stopSearch(manualStop = false)
} }
queryFlow.value = query queryFlow.value = query