搜索问题 (#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:
1 parent
f309e03b23
commit
103bdc07d1
3 files changed
+65
-15
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,9 +142,14 @@ fun SearchScreen(
|
|||||||
snapshotFlow { queryInput }
|
snapshotFlow { queryInput }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.debounce(200)
|
.debounce(200)
|
||||||
.filter { it != latestQuery }
|
|
||||||
.collect { newQuery ->
|
.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 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
|
||||||
|
|||||||
Reference in new issue
Block a user