diff --git a/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt b/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt index 1103c8ea0..c9f05c375 100644 --- a/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt @@ -4,18 +4,13 @@ import io.legado.app.data.AppDatabase import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchKeyword +import io.legado.app.domain.gateway.BookSearchGateway +import io.legado.app.domain.model.BookSearchScope import io.legado.app.domain.usecase.BookShelfKey import io.legado.app.help.book.isNotShelf -import io.legado.app.model.webBook.SearchModel -import io.legado.app.ui.book.search.SearchScope import io.legado.app.ui.main.bookshelf.BookShelfItem -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -31,41 +26,11 @@ interface SearchRepository { suspend fun saveSearchKeyword(keyword: String) suspend fun deleteSearchKeyword(item: SearchKeyword) suspend fun clearSearchKeywords() - - fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession -} - -sealed interface SearchSessionEvent { - data object Started : SearchSessionEvent - - data class Progress( - val upsertBooks: List, - val removedBookUrls: List, - val resultCount: Int, - val processedSources: Int, - val totalSources: Int, - ) : SearchSessionEvent - - data class Finished( - val isEmpty: Boolean, - val hasMore: Boolean, - ) : SearchSessionEvent - - data class Canceled(val throwable: Throwable? = null) : SearchSessionEvent -} - -interface SearchSession { - val events: Flow - fun search(searchId: Long, keyword: String) - fun stop() - fun pause() - fun resume() - fun close() } class SearchRepositoryImpl( private val appDb: AppDatabase, -) : SearchRepository { +) : SearchRepository, BookSearchGateway { override val enabledGroups: Flow> = appDb.bookSourceDao.flowEnabledGroups() override val enabledSources: Flow> = appDb.bookSourceDao.flowEnabled() @@ -113,71 +78,34 @@ class SearchRepositoryImpl( appDb.searchKeywordDao.deleteAll() } - override fun createSearchSession(scopeProvider: () -> SearchScope): SearchSession { - return SearchSessionImpl(scopeProvider) + override suspend fun getBookSourceParts(scope: BookSearchScope): List = + withContext(Dispatchers.IO) { + val selectedSources = linkedSetOf() + when { + scope.isAll -> selectedSources.addAll(appDb.bookSourceDao.allEnabledPart) + scope.isSource -> scope.sourceUrls.forEach { sourceUrl -> + appDb.bookSourceDao.getBookSourcePart(sourceUrl)?.let { selectedSources.add(it) } + } + + else -> scope.groupNames.forEach { groupName -> + selectedSources.addAll(appDb.bookSourceDao.getEnabledPartByGroup(groupName)) + } + } + + if (selectedSources.isEmpty()) { + appDb.bookSourceDao.allEnabledPart + } else { + selectedSources.toList().sortedBy { it.customOrder } + } + } + + override suspend fun getBookSource(sourceUrl: String) = withContext(Dispatchers.IO) { + appDb.bookSourceDao.getBookSource(sourceUrl) } - private class SearchSessionImpl( - scopeProvider: () -> SearchScope, - ) : SearchSession { - - private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val _events = MutableSharedFlow(extraBufferCapacity = 8) - override val events: Flow = _events.asSharedFlow() - - private val searchModel = SearchModel(sessionScope, object : SearchModel.CallBack { - override fun getSearchScope(): SearchScope = scopeProvider() - - override suspend fun onSearchStart() { - _events.emit(SearchSessionEvent.Started) - } - - override suspend fun onSearchSuccess( - upsertBooks: List, - removedBookUrls: List, - resultCount: Int, - processedSources: Int, - totalSources: Int, - ) { - _events.emit( - SearchSessionEvent.Progress( - upsertBooks = upsertBooks, - removedBookUrls = removedBookUrls, - resultCount = resultCount, - processedSources = processedSources, - totalSources = totalSources, - ) - ) - } - - override suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) { - _events.emit(SearchSessionEvent.Finished(isEmpty, hasMore)) - } - - override fun onSearchCancel(exception: Throwable?) { - _events.tryEmit(SearchSessionEvent.Canceled(exception)) - } - }) - - override fun search(searchId: Long, keyword: String) { - searchModel.search(searchId, keyword) - } - - override fun stop() { - searchModel.cancelSearch() - } - - override fun pause() { - searchModel.pause() - } - - override fun resume() { - searchModel.resume() - } - - override fun close() { - searchModel.close() - sessionScope.cancel() + override suspend fun saveSearchBooks(books: List) = withContext(Dispatchers.IO) { + if (books.isNotEmpty()) { + appDb.searchBookDao.insert(books) } } } diff --git a/app/src/main/java/io/legado/app/domain/gateway/BookSearchGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/BookSearchGateway.kt new file mode 100644 index 000000000..790052de7 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/gateway/BookSearchGateway.kt @@ -0,0 +1,12 @@ +package io.legado.app.domain.gateway + +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.BookSourcePart +import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.model.BookSearchScope + +interface BookSearchGateway { + suspend fun getBookSourceParts(scope: BookSearchScope): List + suspend fun getBookSource(sourceUrl: String): BookSource? + suspend fun saveSearchBooks(books: List) +} diff --git a/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt b/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt new file mode 100644 index 000000000..0ef91c6c1 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt @@ -0,0 +1,40 @@ +package io.legado.app.domain.model + +import io.legado.app.utils.splitNotBlank + +data class BookSearchScope(val raw: String) { + + val items: List + get() = raw.splitNotBlank(",").toList() + + val isAll: Boolean + get() = raw.isEmpty() + + val isSource: Boolean + get() = items.isNotEmpty() && parseSourceItems().size == items.size + + val groupNames: List + get() = if (isSource) emptyList() else items + + val sourceUrls: List + get() = parseSourceItems().map { it.url } + + private fun parseSourceItems(): List { + return items.mapNotNull { item -> + val splitIndex = item.indexOf("::") + if (splitIndex <= 0 || splitIndex >= item.lastIndex) { + null + } else { + ScopeSourceItem( + name = item.substring(0, splitIndex), + url = item.substring(splitIndex + 2) + ) + } + } + } + + private data class ScopeSourceItem( + val name: String, + val url: String, + ) +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt new file mode 100644 index 000000000..ddf2c0bd3 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt @@ -0,0 +1,255 @@ +package io.legado.app.domain.usecase + +import io.legado.app.constant.AppConst +import io.legado.app.constant.AppLog +import io.legado.app.data.entities.BookSourcePart +import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.gateway.BookSearchGateway +import io.legado.app.domain.model.BookSearchScope +import io.legado.app.exception.NoStackTraceException +import io.legado.app.model.webBook.WebBook +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.coroutineContext +import kotlin.math.min + +data class BookSearchRequest( + val keyword: String, + val page: Int, + val scope: BookSearchScope, + val precision: Boolean, + val concurrency: Int, +) + +sealed interface SearchRunEvent { + data object Started : SearchRunEvent + + data class Progress( + val upsertBooks: List, + val removedBookUrls: List, + val resultCount: Int, + val processedSources: Int, + val totalSources: Int, + ) : SearchRunEvent + + data class Finished( + val isEmpty: Boolean, + val hasMore: Boolean, + ) : SearchRunEvent +} + +class BookSearchControl { + private val isResumed = MutableStateFlow(true) + + fun pause() { + isResumed.value = false + } + + fun resume() { + isResumed.value = true + } + + suspend fun awaitResumed() { + isResumed.first { it } + } +} + +class SearchBooksUseCase( + private val gateway: BookSearchGateway, +) { + + @OptIn(ExperimentalCoroutinesApi::class) + fun execute( + request: BookSearchRequest, + control: BookSearchControl, + ): Flow = flow { + val keyword = request.keyword.trim() + if (keyword.isBlank()) return@flow + + val sourceParts = gateway.getBookSourceParts(request.scope) + if (sourceParts.isEmpty()) { + throw NoStackTraceException("启用书源为空") + } + + val merger = SearchResultMerger(keyword, request.precision) + val concurrency = min(request.concurrency.coerceAtLeast(1), AppConst.MAX_THREAD) + var hasMore = false + var processedSources = 0 + + emit(SearchRunEvent.Started) + + sourceParts.asFlow() + .flatMapMerge(concurrency) { sourcePart -> + flow { + control.awaitResumed() + emit(searchSource(sourcePart, keyword, request.page, request.precision)) + }.flowOn(Dispatchers.IO) + } + .collect { result -> + currentCoroutineContext().ensureActive() + processedSources++ + when (result) { + is SourceSearchResult.Found -> { + result.books.forEach { it.releaseHtmlData() } + hasMore = hasMore || result.books.isNotEmpty() + if (result.books.isNotEmpty()) { + gateway.saveSearchBooks(result.books) + } + val change = merger.merge(result.books) + emit( + SearchRunEvent.Progress( + upsertBooks = change.upsertBooks, + removedBookUrls = change.removedBookUrls, + resultCount = merger.count, + processedSources = processedSources, + totalSources = sourceParts.size, + ) + ) + } + + is SourceSearchResult.Failed -> { + AppLog.put("书源搜索出错\n${result.throwable.localizedMessage}", result.throwable) + emit( + SearchRunEvent.Progress( + upsertBooks = emptyList(), + removedBookUrls = emptyList(), + resultCount = merger.count, + processedSources = processedSources, + totalSources = sourceParts.size, + ) + ) + } + } + } + + emit( + SearchRunEvent.Finished( + isEmpty = merger.count == 0, + hasMore = hasMore && !merger.resultLimitReached, + ) + ) + }.flowOn(Dispatchers.IO) + + private suspend fun searchSource( + sourcePart: BookSourcePart, + keyword: String, + page: Int, + precision: Boolean, + ): SourceSearchResult { + return try { + val source = gateway.getBookSource(sourcePart.bookSourceUrl) + ?: return SourceSearchResult.Found(emptyList()) + val books = withTimeout(30000L) { + WebBook.searchBookAwait( + source, + keyword, + page, + filter = { name, author -> + !precision || name.contains(keyword) || author.contains(keyword) + } + ) + } + SourceSearchResult.Found(books) + } catch (exception: Throwable) { + coroutineContext.ensureActive() + if (exception is CancellationException) throw exception + SourceSearchResult.Failed(exception) + } + } + + private sealed interface SourceSearchResult { + data class Found(val books: List) : SourceSearchResult + data class Failed(val throwable: Throwable) : SourceSearchResult + } + + private class SearchResultMerger( + private val keyword: String, + private val precision: Boolean, + ) { + private companion object { + const val MAX_RETAINED_SEARCH_RESULTS = 1000 + } + + private val equalBooks = LinkedHashMap() + private val containsBooks = LinkedHashMap() + private val otherBooks = LinkedHashMap() + var resultLimitReached = false + private set + + val count: Int + get() = equalBooks.size + containsBooks.size + otherBooks.size + + suspend fun merge(newBooks: List): SearchBookChange { + if (newBooks.isEmpty()) return SearchBookChange() + + val upsertBooks = arrayListOf() + val removedBookUrls = linkedSetOf() + newBooks.forEach { newBook -> + coroutineContext.ensureActive() + val bucket = classifyBucket(newBook) ?: return@forEach + val key = SearchBookKey(newBook.name, newBook.author) + val currentBook = bucket[key] + if (currentBook == null) { + bucket[key] = newBook + upsertBooks.add(newBook) + } else { + currentBook.addOrigin(newBook.origin) + upsertBooks.add(currentBook) + } + trimSearchBooks()?.let { removed -> + removedBookUrls.add(removed.bookUrl) + upsertBooks.removeAll { it.bookUrl == removed.bookUrl } + } + } + return SearchBookChange(upsertBooks, removedBookUrls.toList()) + } + + private fun classifyBucket(book: SearchBook): LinkedHashMap? { + return when { + book.name == keyword || book.author == keyword -> equalBooks + book.name.contains(keyword) || book.author.contains(keyword) -> containsBooks + !precision -> otherBooks + else -> null + } + } + + private fun trimSearchBooks(): SearchBook? { + if (count <= MAX_RETAINED_SEARCH_RESULTS) return null + resultLimitReached = true + return removeLast(otherBooks) + ?: removeLowestOrigin(containsBooks) + ?: removeLowestOrigin(equalBooks) + } + + private fun removeLast(bucket: LinkedHashMap): SearchBook? { + val key = bucket.keys.lastOrNull() ?: return null + return bucket.remove(key) + } + + private fun removeLowestOrigin(bucket: LinkedHashMap): SearchBook? { + val key = bucket.entries.minByOrNull { it.value.origins.size }?.key ?: return null + return bucket.remove(key) + } + } + + private data class SearchBookKey( + val name: String, + val author: String, + ) + + private data class SearchBookChange( + val upsertBooks: List = emptyList(), + val removedBookUrls: List = emptyList(), + ) +} diff --git a/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt b/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt deleted file mode 100644 index babcbaa2f..000000000 --- a/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt +++ /dev/null @@ -1,252 +0,0 @@ -package io.legado.app.model.webBook - -import io.legado.app.constant.AppConst -import io.legado.app.constant.AppLog -import io.legado.app.constant.PreferKey -import io.legado.app.data.appDb -import io.legado.app.data.entities.BookSourcePart -import io.legado.app.data.entities.SearchBook -import io.legado.app.exception.NoStackTraceException -import io.legado.app.ui.config.otherConfig.OtherConfig -import io.legado.app.ui.book.search.SearchScope -import io.legado.app.utils.getPrefBoolean -import io.legado.app.utils.mapParallelSafe -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExecutorCoroutineDispatcher -import kotlinx.coroutines.Job -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import splitties.init.appCtx -import java.util.concurrent.Executors -import kotlin.coroutines.coroutineContext -import kotlin.math.min - -class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) { - private companion object { - const val MAX_RETAINED_SEARCH_RESULTS = 1000 - } - - val threadCount = OtherConfig.threadCount - private var searchPool: ExecutorCoroutineDispatcher? = null - private var mSearchId = 0L - private var searchPage = 1 - private var searchKey: String = "" - private var bookSourceParts = emptyList() - private val equalBooks = LinkedHashMap() - private val containsBooks = LinkedHashMap() - private val otherBooks = LinkedHashMap() - private var searchJob: Job? = null - private var workingState = MutableStateFlow(true) - private var resultLimitReached = false - - private fun initSearchPool() { - searchPool?.close() - searchPool = Executors - .newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() - } - - fun search(searchId: Long, key: String) { - if (searchId != mSearchId) { - if (key.isEmpty()) { - return - } - searchKey = key - if (mSearchId != 0L) { - close() - } - clearSearchBooks() - bookSourceParts = callBack.getSearchScope().getBookSourceParts() - if (bookSourceParts.isEmpty()) { - callBack.onSearchCancel(NoStackTraceException("启用书源为空")) - return - } - mSearchId = searchId - searchPage = 1 - initSearchPool() - } else { - searchPage++ - } - startSearch() - } - - private fun startSearch() { - val precision = appCtx.getPrefBoolean(PreferKey.precisionSearch) - var hasMore = false - val totalParts = bookSourceParts.size - var processedParts = 0 - searchJob = scope.launch(searchPool!!) { - flow { - for (bs in bookSourceParts) { - bs.getBookSource()?.let { - emit(it) - } - workingState.first { it } - } - }.onStart { - callBack.onSearchStart() - }.mapParallelSafe(threadCount) { - withTimeout(30000L) { - WebBook.searchBookAwait( - it, searchKey, searchPage, - filter = { name, author -> - !precision || name.contains(searchKey) || - author.contains(searchKey) - }) - } - }.onEach { items -> - for (book in items) { - book.releaseHtmlData() - } - hasMore = hasMore || items.isNotEmpty() - if (items.isNotEmpty()) { - appDb.searchBookDao.insert(items) - } - val change = mergeItems(items, precision) - currentCoroutineContext().ensureActive() - processedParts++ - callBack.onSearchSuccess( - upsertBooks = change.upsertBooks, - removedBookUrls = change.removedBookUrls, - resultCount = searchBookCount(), - processedSources = processedParts, - totalSources = totalParts, - ) - }.onCompletion { - if (it == null) { - callBack.onSearchFinish( - isEmpty = searchBookCount() == 0, - hasMore = hasMore && !resultLimitReached, - ) - } - }.catch { - AppLog.put("书源搜索出错\n${it.localizedMessage}", it) - }.collect() - } - } - - private suspend fun mergeItems(newDataS: List, precision: Boolean): SearchBookChange { - if (newDataS.isEmpty()) { - return SearchBookChange() - } - - val upsertBooks = arrayListOf() - val removedBookUrls = linkedSetOf() - newDataS.forEach { nBook -> - coroutineContext.ensureActive() - val bucket = classifyBucket(nBook, precision) ?: return@forEach - val key = SearchBookKey(nBook.name, nBook.author) - val currentBook = bucket[key] - if (currentBook == null) { - bucket[key] = nBook - upsertBooks.add(nBook) - } else { - currentBook.addOrigin(nBook.origin) - upsertBooks.add(currentBook) - } - trimSearchBooks()?.let { removed -> - removedBookUrls.add(removed.bookUrl) - upsertBooks.removeAll { it.bookUrl == removed.bookUrl } - } - } - return SearchBookChange(upsertBooks, removedBookUrls.toList()) - } - - private fun classifyBucket( - book: SearchBook, - precision: Boolean, - ): LinkedHashMap? { - return when { - book.name == searchKey || book.author == searchKey -> equalBooks - book.name.contains(searchKey) || book.author.contains(searchKey) -> containsBooks - !precision -> otherBooks - else -> null - } - } - - private fun trimSearchBooks(): SearchBook? { - if (searchBookCount() <= MAX_RETAINED_SEARCH_RESULTS) { - return null - } - resultLimitReached = true - return removeLast(otherBooks) - ?: removeLowestOrigin(containsBooks) - ?: removeLowestOrigin(equalBooks) - } - - private fun removeLast(bucket: LinkedHashMap): SearchBook? { - val key = bucket.keys.lastOrNull() ?: return null - return bucket.remove(key) - } - - private fun removeLowestOrigin(bucket: LinkedHashMap): SearchBook? { - val key = bucket.entries.minByOrNull { it.value.origins.size }?.key ?: return null - return bucket.remove(key) - } - - private fun searchBookCount(): Int { - return equalBooks.size + containsBooks.size + otherBooks.size - } - - private fun clearSearchBooks() { - equalBooks.clear() - containsBooks.clear() - otherBooks.clear() - resultLimitReached = false - } - - fun cancelSearch() { - close() - callBack.onSearchCancel() - } - - fun close() { - searchJob?.cancel() - searchPool?.close() - searchPool = null - mSearchId = 0L - } - - fun pause() { - workingState.value = false - } - - fun resume() { - workingState.value = true - } - - interface CallBack { - fun getSearchScope(): SearchScope - suspend fun onSearchStart() - suspend fun onSearchSuccess( - upsertBooks: List, - removedBookUrls: List, - resultCount: Int, - processedSources: Int, - totalSources: Int, - ) - suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) - fun onSearchCancel(exception: Throwable? = null) - } - - private data class SearchBookKey( - val name: String, - val author: String, - ) - - private data class SearchBookChange( - val upsertBooks: List = emptyList(), - val removedBookUrls: List = emptyList(), - ) - -} diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt index 599b995a9..43b6e5ad6 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt @@ -44,6 +44,7 @@ import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults import io.legado.app.ui.widget.components.topbar.GlassTopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -178,6 +179,12 @@ fun SearchScreen( } } + DisposableEffect(viewModel) { + onDispose { + viewModel.onIntent(SearchIntent.StopSearch) + } + } + val submitSearch: (String) -> Unit = { rawQuery -> val normalized = rawQuery.trim() if (normalized.isNotBlank()) { diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt index 2f0988d80..f1d31ceb6 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -6,14 +6,21 @@ import io.legado.app.constant.PreferKey import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.data.repository.SearchRepository -import io.legado.app.data.repository.SearchSessionEvent +import io.legado.app.domain.model.BookSearchScope +import io.legado.app.domain.usecase.BookSearchControl +import io.legado.app.domain.usecase.BookSearchRequest import io.legado.app.domain.usecase.BookShelfKey import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase +import io.legado.app.domain.usecase.SearchBooksUseCase +import io.legado.app.domain.usecase.SearchRunEvent import io.legado.app.help.config.AppConfig +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.putPrefBoolean +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -30,6 +37,7 @@ import splitties.init.appCtx class SearchViewModel( private val repository: SearchRepository, private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase, + private val searchBooksUseCase: SearchBooksUseCase, ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -49,10 +57,11 @@ class SearchViewModel( private val queryFlow = MutableStateFlow("") private val bookshelfKeys = MutableStateFlow>(emptySet()) private val searchScope = SearchScope(AppConfig.searchScope) - private val searchSession = repository.createSearchSession { searchScope } + private val searchControl = BookSearchControl() private val searchResultBooks = LinkedHashMap() - private var currentSearchId = 0L + private var searchJob: Job? = null + private var currentSearchPage = 1 init { syncScopeState() @@ -61,7 +70,6 @@ class SearchViewModel( observeBookshelf() observeQueryHistory() observeQueryBookshelfHints() - observeSearchSession() } fun onIntent(intent: SearchIntent) { @@ -71,8 +79,8 @@ class SearchViewModel( SearchIntent.SubmitSearch -> submitSearch() SearchIntent.LoadMore -> loadMore() SearchIntent.StopSearch -> stopSearch() - SearchIntent.PauseEngine -> searchSession.pause() - SearchIntent.ResumeEngine -> searchSession.resume() + SearchIntent.PauseEngine -> searchControl.pause() + SearchIntent.ResumeEngine -> searchControl.resume() is SearchIntent.UseHistoryKeyword -> { updateQuery(intent.keyword, showSuggestions = false) submitSearch(intent.keyword) @@ -145,8 +153,8 @@ class SearchViewModel( } override fun onCleared() { + stopSearch(manualStop = false) super.onCleared() - searchSession.close() } private fun initialize(key: String?, scopeRaw: String?) { @@ -223,62 +231,9 @@ class SearchViewModel( } } - private fun observeSearchSession() { - viewModelScope.launch { - searchSession.events.collect { event -> - when (event) { - SearchSessionEvent.Started -> { - _uiState.update { it.copy(isSearching = true) } - } - - is SearchSessionEvent.Progress -> { - event.removedBookUrls.forEach { searchResultBooks.remove(it) } - event.upsertBooks.forEach { book -> - searchResultBooks[book.bookUrl] = book - } - _uiState.update { - it.copy( - results = buildSearchResultItems( - shelf = bookshelfKeys.value, - ), - processedSources = event.processedSources, - totalSources = event.totalSources, - ) - } - } - - is SearchSessionEvent.Finished -> { - _uiState.update { state -> - val emptyAction = if (event.isEmpty && !searchScope.isAll()) { - SearchEmptyScopeAction( - scopeDisplay = searchScope.display, - wasPrecisionSearch = state.isPrecisionSearch, - ) - } else { - null - } - state.copy( - isSearching = false, - hasMore = event.hasMore, - emptyScopeAction = emptyAction, - ) - } - } - - is SearchSessionEvent.Canceled -> { - _uiState.update { it.copy(isSearching = false) } - event.throwable?.localizedMessage - ?.takeIf { it.isNotBlank() } - ?.let { emitEffect(SearchEffect.ShowMessage(it)) } - } - } - } - } - } - private fun updateQuery(query: String, showSuggestions: Boolean) { if (showSuggestions && _uiState.value.isSearching) { - searchSession.stop() + stopSearch(manualStop = false) } queryFlow.value = query _uiState.update { @@ -297,7 +252,7 @@ class SearchViewModel( updateQuery(keyword, showSuggestions = false) - currentSearchId = System.currentTimeMillis() + currentSearchPage = 1 searchResultBooks.clear() _uiState.update { it.copy( @@ -314,7 +269,7 @@ class SearchViewModel( viewModelScope.launch { repository.saveSearchKeyword(keyword) } - searchSession.search(currentSearchId, keyword) + startSearch(keyword, currentSearchPage) } private fun loadMore() { @@ -323,18 +278,95 @@ class SearchViewModel( if (state.committedQuery.isBlank()) return if (!state.hasMore) return + currentSearchPage += 1 _uiState.update { it.copy( isManualStop = false, showSuggestions = false, ) } - searchSession.search(currentSearchId, state.committedQuery) + startSearch(state.committedQuery, currentSearchPage) } - private fun stopSearch() { - _uiState.update { it.copy(isManualStop = true) } - searchSession.stop() + private fun startSearch(keyword: String, page: Int) { + searchJob?.cancel() + searchControl.resume() + searchJob = viewModelScope.launch { + try { + searchBooksUseCase + .execute( + BookSearchRequest( + keyword = keyword, + page = page, + scope = BookSearchScope(searchScope.toString()), + precision = _uiState.value.isPrecisionSearch, + concurrency = OtherConfig.threadCount, + ), + searchControl + ) + .collect { event -> handleSearchEvent(event) } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + _uiState.update { it.copy(isSearching = false) } + exception.localizedMessage + ?.takeIf { it.isNotBlank() } + ?.let { emitEffect(SearchEffect.ShowMessage(it)) } + } + } + } + + private fun handleSearchEvent(event: SearchRunEvent) { + when (event) { + SearchRunEvent.Started -> { + _uiState.update { it.copy(isSearching = true) } + } + + is SearchRunEvent.Progress -> { + event.removedBookUrls.forEach { searchResultBooks.remove(it) } + event.upsertBooks.forEach { book -> + searchResultBooks[book.bookUrl] = book + } + _uiState.update { + it.copy( + results = buildSearchResultItems( + shelf = bookshelfKeys.value, + ), + processedSources = event.processedSources, + totalSources = event.totalSources, + ) + } + } + + is SearchRunEvent.Finished -> { + _uiState.update { state -> + val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) { + SearchEmptyScopeAction( + scopeDisplay = searchScope.display, + wasPrecisionSearch = state.isPrecisionSearch, + ) + } else { + null + } + state.copy( + isSearching = false, + hasMore = event.hasMore, + emptyScopeAction = emptyAction, + ) + } + } + } + } + + private fun stopSearch(manualStop: Boolean = true) { + searchJob?.cancel() + searchJob = null + _uiState.update { + it.copy( + isSearching = false, + isManualStop = manualStop || it.isManualStop, + ) + } } private fun toggleScopeGroup(groupName: String) { diff --git a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt b/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt index 1e0158f1b..f0d147331 100644 --- a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt +++ b/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt @@ -3,30 +3,39 @@ package io.legado.app.web.socket import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoWSD import io.legado.app.R -import io.legado.app.data.entities.SearchBook +import io.legado.app.constant.PreferKey +import io.legado.app.domain.model.BookSearchScope +import io.legado.app.domain.usecase.BookSearchControl +import io.legado.app.domain.usecase.BookSearchRequest +import io.legado.app.domain.usecase.SearchBooksUseCase +import io.legado.app.domain.usecase.SearchRunEvent import io.legado.app.help.config.AppConfig -import io.legado.app.model.webBook.SearchModel -import io.legado.app.ui.book.search.SearchScope +import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.isJson +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.koin.core.context.GlobalContext import splitties.init.appCtx import java.io.IOException class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : NanoWSD.WebSocket(handshakeRequest), - CoroutineScope by MainScope(), - SearchModel.CallBack { + CoroutineScope by MainScope() { private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure - private val searchModel = SearchModel(this, this) + private val searchBooksUseCase: SearchBooksUseCase by lazy { GlobalContext.get().get() } + private val searchControl = BookSearchControl() private val sentBookUrls = linkedSetOf() + private var searchJob: Job? = null private val SEARCH_FINISH = "Search finish" @@ -46,8 +55,8 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : reason: String, initiatedByRemote: Boolean ) { + searchJob?.cancel() cancel() - searchModel.close() } override fun onMessage(message: NanoWSD.WebSocketFrame) { @@ -61,14 +70,13 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : val searchMap = GSON.fromJsonObject>(message.textPayload).getOrNull() if (searchMap != null) { - val key = searchMap["key"] + val key = searchMap["key"]?.trim() if (key.isNullOrBlank()) { send(appCtx.getString(R.string.cannot_empty)) close(normalClosure, SEARCH_FINISH, false) return@launch } - sentBookUrls.clear() - searchModel.search(System.currentTimeMillis(), key) + startSearch(key) } } } @@ -82,27 +90,41 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) : } - override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope) + private fun startSearch(key: String) { + searchJob?.cancel() + sentBookUrls.clear() + searchControl.resume() + searchJob = launch(IO) { + try { + searchBooksUseCase + .execute( + BookSearchRequest( + keyword = key, + page = 1, + scope = BookSearchScope(AppConfig.searchScope), + precision = appCtx.getPrefBoolean(PreferKey.precisionSearch), + concurrency = OtherConfig.threadCount, + ), + searchControl + ) + .collect { event -> + when (event) { + SearchRunEvent.Started -> Unit + is SearchRunEvent.Progress -> { + val newBooks = event.upsertBooks.filter { sentBookUrls.add(it.bookUrl) } + if (newBooks.isNotEmpty()) { + send(GSON.toJson(newBooks)) + } + } - override suspend fun onSearchStart() { - - } - - override suspend fun onSearchSuccess( - upsertBooks: List, - removedBookUrls: List, - resultCount: Int, - processedSources: Int, - totalSources: Int - ) { - val newBooks = upsertBooks.filter { sentBookUrls.add(it.bookUrl) } - if (newBooks.isNotEmpty()) { - send(GSON.toJson(newBooks)) + is SearchRunEvent.Finished -> close(normalClosure, SEARCH_FINISH, false) + } + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + close(normalClosure, exception.toString(), false) + } } } - - override suspend fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) = close(normalClosure, SEARCH_FINISH, false) - - override fun onSearchCancel(exception: Throwable?) = close(normalClosure, exception?.toString() ?: SEARCH_FINISH, false) - }